Add LLM OCR engine via OpenCode Go + mimo-v2-omni

Adds optional LLM-based OCR as an alternative to Tesseract for reading
machine IDs from photos.

Backend (server.py):
- New run_ocr_llm() function calls OpenCode Go API (mimo-v2-omni model)
- Auto-falls back to Tesseract if API key missing or call fails
- Endpoints /api/analyze and /api/bulk-process accept ?ocr_engine=llm
  query param (default: tesseract) and ?ocr_model for model override
- Configurable via env vars: OPENCODE_GO_API_KEY, LLM_OCR_MODEL
- Requires User-Agent: Hermes-Agent/1.0 header for OpenCode Go API

Frontend (static/index.html):
- Toggle checkbox 'Use LLM OCR' in the UI
- OCR engine badge shown in results (llm vs tesseract + model name)
- getOcrParams() helper appends ?ocr_engine=llm to API calls

Infrastructure:
- .gitignore for uploads/ directory

Closes: #2
This commit is contained in:
2026-05-25 17:12:37 -04:00
parent 0052c59f81
commit 3a67070063
22 changed files with 154 additions and 25 deletions
+44 -18
View File
@@ -187,6 +187,20 @@
margin-top: 6px;
}
/* OCR engine toggle */
.ocr-toggle {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
font-size: 12px; color: var(--text2);
}
.ocr-toggle label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
.ocr-toggle input[type="checkbox"] { accent-color: var(--accent); }
.engine-badge {
font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600;
background: var(--card2); color: var(--text2); display: inline-block; margin-left: 4px;
}
.engine-badge.llm { background: var(--accent); color: #fff; }
.engine-badge.tesseract { background: var(--card2); color: var(--text2); }
/* Bulk results */
#mapContainer { height: 250px; border-radius: var(--radius-sm); margin-bottom: 10px; display: none; }
.bulk-card {
@@ -263,6 +277,14 @@
<div id="serverResults"></div>
</div>
<!-- OCR engine toggle -->
<div class="ocr-toggle" style="margin-bottom:4px;">
<label>
<input type="checkbox" id="ocrLlmToggle" onchange="onOcrToggle()">
🧠 Use LLM OCR (<code id="ocrModelLabel">mimo-v2-omni</code>)
</label>
</div>
<!-- Bulk process button -->
<button class="btn btn-primary" id="bulkBtn" style="display:none;margin-top:12px;" onclick="startBulkProcess()">
🔍 Bulk Process GPS Photos
@@ -440,7 +462,7 @@ async function uploadSelected() {
fd.append('file', file, file.name || 'photo.jpg');
try {
const resp = await fetch('/api/analyze', { method: 'POST', body: fd });
const resp = await fetch('/api/analyze' + getOcrParams(), { method: 'POST', body: fd });
const data = await resp.json();
let html = '';
@@ -465,21 +487,20 @@ async function uploadSelected() {
// OCR
const ocr = data.ocr;
if (ocr.available) {
html += '<div style="margin-top:10px;font-weight:600;">🔤 OCR:</div>';
if (ocr.raw_text) {
html += '<div class="ocr-text">' + esc(ocr.raw_text) + '</div>';
}
if (ocr.match_5dash6) {
const mid = ocr.match_5dash6.replace(/[^0-9]/g,'').slice(-5);
html += '<div style="margin-top:4px;color:var(--green);">✅ Matched: <strong>' + esc(ocr.match_5dash6) + '</strong> → machine ID: ' + mid + '</div>';
// Auto-lookup
lookupAsset(mid);
} else if (ocr.match_5plus) {
html += '<div style="margin-top:4px;color:var(--amber);">⚠️ Digits: <strong>' + esc(ocr.match_5plus) + '</strong> (no 5-6 pattern)</div>';
} else {
html += '<div style="margin-top:4px;color:var(--text3);">No machine ID found in image</div>';
}
const engineCls = ocr.engine === 'llm' ? 'llm' : 'tesseract';
html += '<div style="margin-top:10px;font-weight:600;">🔤 OCR <span class="engine-badge ' + engineCls + '">' + esc(ocr.engine || 'tesseract') + (ocr.llm_model ? ' ' + ocr.llm_model : '') + '</span></div>';
if (ocr.raw_text) {
html += '<div class="ocr-text">' + esc(ocr.raw_text) + '</div>';
}
if (ocr.match_5dash6) {
const mid = ocr.match_5dash6.replace(/[^0-9]/g,'').slice(-5);
html += '<div style="margin-top:4px;color:var(--green);">✅ Matched: <strong>' + esc(ocr.match_5dash6) + '</strong> → machine ID: ' + mid + '</div>';
// Auto-lookup
lookupAsset(mid);
} else if (ocr.match_5plus) {
html += '<div style="margin-top:4px;color:var(--amber);">⚠️ Digits: <strong>' + esc(ocr.match_5plus) + '</strong> (no 5-6 pattern)</div>';
} else {
html += '<div style="margin-top:4px;color:var(--text3);">No machine ID found in image</div>';
}
div.innerHTML = html;
@@ -540,7 +561,7 @@ async function startBulkProcess() {
gpsPhotos.forEach(p => fd.append('files', p.file, p.file.name || 'photo.jpg'));
try {
const resp = await fetch('/api/bulk-process', { method: 'POST', body: fd });
const resp = await fetch('/api/bulk-process' + getOcrParams(), { method: 'POST', body: fd });
const data = await resp.json();
// Merge thumbnails back into results
@@ -753,8 +774,13 @@ function esc(s) {
return d.innerHTML;
}
function getOcrParams() {
const useLlm = document.getElementById('ocrLlmToggle').checked;
if (!useLlm) return '';
return '?ocr_engine=llm';
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}