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
+3
View File
@@ -0,0 +1,3 @@
uploads/
__pycache__/
*.py[cod]
Binary file not shown.
+107 -7
View File
@@ -1,8 +1,8 @@
"""EXIF + OCR test backend — validate that GPS survives upload pipeline."""
import io, json, re, uuid, sqlite3
import io, json, os, re, uuid, sqlite3, urllib.request
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.staticfiles import StaticFiles
from PIL import Image as PILImage
@@ -20,6 +20,11 @@ try:
except ImportError:
HAS_TESSERACT = False
# === LLM OCR via OpenCode Go ===
OPENCODE_GO_KEY = os.environ.get("OPENCODE_GO_API_KEY", "")
OPENCODE_GO_BASE = os.environ.get("OPENCODE_GO_BASE_URL", "https://opencode.ai/zen/go/v1")
LLM_OCR_MODEL = os.environ.get("LLM_OCR_MODEL", "mimo-v2-omni")
UPLOADS = Path(__file__).parent / "uploads"
UPLOADS.mkdir(exist_ok=True)
CANTEEN_DB = Path(__file__).parent.parent / "canteen-asset-tracker" / "assets.db"
@@ -114,6 +119,70 @@ def run_ocr(image_bytes: bytes) -> dict:
tmp_path.unlink(missing_ok=True)
def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> dict:
"""Run OCR via an LLM vision model on OpenCode Go.
Falls back to Tesseract if the API key is missing or the call fails.
Returns the same shape as run_ocr() with an additional ''engine'' field.
"""
if not OPENCODE_GO_KEY:
result = run_ocr(image_bytes)
result["engine"] = "tesseract"
result["llm_fallback_reason"] = "no_api_key"
return result
import base64
model = model or LLM_OCR_MODEL
b64 = base64.b64encode(image_bytes).decode()
body = json.dumps({
"model": model,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "Read ALL text and numbers visible in this photo. "
"Return the exact text shown, nothing else."},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
]
}],
"max_tokens": 200,
}).encode()
req = urllib.request.Request(
f"{OPENCODE_GO_BASE}/chat/completions",
data=body,
headers={
"Authorization": f"Bearer {OPENCODE_GO_KEY}",
"Content-Type": "application/json",
"User-Agent": "Hermes-Agent/1.0",
},
)
try:
resp = urllib.request.urlopen(req, timeout=60)
result = json.loads(resp.read())
text = result["choices"][0]["message"]["content"].strip()
except Exception as exc:
# Fallback to Tesseract
result = run_ocr(image_bytes)
result["engine"] = "tesseract"
result["llm_fallback_reason"] = str(exc)[:200]
return result
match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
match_5plus = re.search(r"(\d{5,})", text)
return {
"available": True,
"engine": "llm",
"llm_model": model,
"raw_text": text[:500],
"match_5dash6": match_5dash.group(0) if match_5dash else None,
"match_5plus": match_5plus.group(0) if match_5plus else None,
}
def lookup_machine_id(machine_id: str) -> dict | None:
"""Look up an asset by machine_id. Returns asset dict or None."""
conn = _get_canteen_db()
@@ -150,8 +219,17 @@ def lookup_machine_id(machine_id: str) -> dict | None:
@app.post("/api/analyze")
async def analyze_photo(file: UploadFile = File(...)):
"""Upload a photo, get back EXIF + OCR results."""
async def analyze_photo(
file: UploadFile = File(...),
ocr_engine: str = Query(default="tesseract"),
ocr_model: str = Query(default=""),
):
"""Upload a photo, get back EXIF + OCR results.
Query params:
- ocr_engine: ''tesseract'' (default) or ''llm''
- ocr_model: model name override (e.g. ''mimo-v2-omni'', ''glm-5.1'')
"""
contents = await file.read()
file_size = len(contents)
@@ -162,7 +240,14 @@ async def analyze_photo(file: UploadFile = File(...)):
(UPLOADS / fname).write_bytes(contents)
exif_result = extract_exif(contents)
ocr_result = run_ocr(contents) if HAS_TESSERACT else {"available": False, "text": ""}
if ocr_engine == "llm":
ocr_result = run_ocr_llm(contents, ocr_model or None)
elif HAS_TESSERACT:
ocr_result = run_ocr(contents)
ocr_result["engine"] = "tesseract"
else:
ocr_result = {"available": False, "text": "", "engine": "none"}
machine_id = None
asset = None
@@ -183,9 +268,17 @@ async def analyze_photo(file: UploadFile = File(...)):
@app.post("/api/bulk-process")
async def bulk_process(files: list[UploadFile] = File(...)):
async def bulk_process(
files: list[UploadFile] = File(...),
ocr_engine: str = Query(default="tesseract"),
ocr_model: str = Query(default=""),
):
"""Process multiple photos: OCR each, extract EXIF GPS, look up matching assets.
Query params:
- ocr_engine: ''tesseract'' (default) or ''llm''
- ocr_model: model name override
Returns a list of results, each with:
- filename, exif (gps), ocr match, matched asset (if found)
- needs_gps: true if asset exists AND has no coordinates AND photo has GPS
@@ -208,7 +301,14 @@ async def bulk_process(files: list[UploadFile] = File(...)):
(UPLOADS / fname).write_bytes(contents)
exif_result = extract_exif(contents)
ocr_result = run_ocr(contents) if HAS_TESSERACT else {"available": False, "text": ""}
if ocr_engine == "llm":
ocr_result = run_ocr_llm(contents, ocr_model or None)
elif HAS_TESSERACT:
ocr_result = run_ocr(contents)
ocr_result["engine"] = "tesseract"
else:
ocr_result = {"available": False, "text": "", "engine": "none"}
has_gps = exif_result.get("gps") is not None
if has_gps:
+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';
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB