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
@@ -0,0 +1,3 @@
|
|||||||
|
uploads/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""EXIF + OCR test backend — validate that GPS survives upload pipeline."""
|
"""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 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 fastapi.staticfiles import StaticFiles
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
@@ -20,6 +20,11 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_TESSERACT = False
|
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 = Path(__file__).parent / "uploads"
|
||||||
UPLOADS.mkdir(exist_ok=True)
|
UPLOADS.mkdir(exist_ok=True)
|
||||||
CANTEEN_DB = Path(__file__).parent.parent / "canteen-asset-tracker" / "assets.db"
|
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)
|
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:
|
def lookup_machine_id(machine_id: str) -> dict | None:
|
||||||
"""Look up an asset by machine_id. Returns asset dict or None."""
|
"""Look up an asset by machine_id. Returns asset dict or None."""
|
||||||
conn = _get_canteen_db()
|
conn = _get_canteen_db()
|
||||||
@@ -150,8 +219,17 @@ def lookup_machine_id(machine_id: str) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/analyze")
|
@app.post("/api/analyze")
|
||||||
async def analyze_photo(file: UploadFile = File(...)):
|
async def analyze_photo(
|
||||||
"""Upload a photo, get back EXIF + OCR results."""
|
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()
|
contents = await file.read()
|
||||||
file_size = len(contents)
|
file_size = len(contents)
|
||||||
|
|
||||||
@@ -162,7 +240,14 @@ async def analyze_photo(file: UploadFile = File(...)):
|
|||||||
(UPLOADS / fname).write_bytes(contents)
|
(UPLOADS / fname).write_bytes(contents)
|
||||||
|
|
||||||
exif_result = extract_exif(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
|
machine_id = None
|
||||||
asset = None
|
asset = None
|
||||||
@@ -183,9 +268,17 @@ async def analyze_photo(file: UploadFile = File(...)):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/bulk-process")
|
@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.
|
"""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:
|
Returns a list of results, each with:
|
||||||
- filename, exif (gps), ocr match, matched asset (if found)
|
- filename, exif (gps), ocr match, matched asset (if found)
|
||||||
- needs_gps: true if asset exists AND has no coordinates AND photo has GPS
|
- 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)
|
(UPLOADS / fname).write_bytes(contents)
|
||||||
|
|
||||||
exif_result = extract_exif(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
|
has_gps = exif_result.get("gps") is not None
|
||||||
if has_gps:
|
if has_gps:
|
||||||
|
|||||||
@@ -187,6 +187,20 @@
|
|||||||
margin-top: 6px;
|
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 */
|
/* Bulk results */
|
||||||
#mapContainer { height: 250px; border-radius: var(--radius-sm); margin-bottom: 10px; display: none; }
|
#mapContainer { height: 250px; border-radius: var(--radius-sm); margin-bottom: 10px; display: none; }
|
||||||
.bulk-card {
|
.bulk-card {
|
||||||
@@ -263,6 +277,14 @@
|
|||||||
<div id="serverResults"></div>
|
<div id="serverResults"></div>
|
||||||
</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 -->
|
<!-- Bulk process button -->
|
||||||
<button class="btn btn-primary" id="bulkBtn" style="display:none;margin-top:12px;" onclick="startBulkProcess()">
|
<button class="btn btn-primary" id="bulkBtn" style="display:none;margin-top:12px;" onclick="startBulkProcess()">
|
||||||
🔍 Bulk Process GPS Photos
|
🔍 Bulk Process GPS Photos
|
||||||
@@ -440,7 +462,7 @@ async function uploadSelected() {
|
|||||||
fd.append('file', file, file.name || 'photo.jpg');
|
fd.append('file', file, file.name || 'photo.jpg');
|
||||||
|
|
||||||
try {
|
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();
|
const data = await resp.json();
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
@@ -465,21 +487,20 @@ async function uploadSelected() {
|
|||||||
|
|
||||||
// OCR
|
// OCR
|
||||||
const ocr = data.ocr;
|
const ocr = data.ocr;
|
||||||
if (ocr.available) {
|
const engineCls = ocr.engine === 'llm' ? 'llm' : 'tesseract';
|
||||||
html += '<div style="margin-top:10px;font-weight:600;">🔤 OCR:</div>';
|
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) {
|
if (ocr.raw_text) {
|
||||||
html += '<div class="ocr-text">' + esc(ocr.raw_text) + '</div>';
|
html += '<div class="ocr-text">' + esc(ocr.raw_text) + '</div>';
|
||||||
}
|
}
|
||||||
if (ocr.match_5dash6) {
|
if (ocr.match_5dash6) {
|
||||||
const mid = ocr.match_5dash6.replace(/[^0-9]/g,'').slice(-5);
|
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>';
|
html += '<div style="margin-top:4px;color:var(--green);">✅ Matched: <strong>' + esc(ocr.match_5dash6) + '</strong> → machine ID: ' + mid + '</div>';
|
||||||
// Auto-lookup
|
// Auto-lookup
|
||||||
lookupAsset(mid);
|
lookupAsset(mid);
|
||||||
} else if (ocr.match_5plus) {
|
} 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>';
|
html += '<div style="margin-top:4px;color:var(--amber);">⚠️ Digits: <strong>' + esc(ocr.match_5plus) + '</strong> (no 5-6 pattern)</div>';
|
||||||
} else {
|
} else {
|
||||||
html += '<div style="margin-top:4px;color:var(--text3);">No machine ID found in image</div>';
|
html += '<div style="margin-top:4px;color:var(--text3);">No machine ID found in image</div>';
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div.innerHTML = html;
|
div.innerHTML = html;
|
||||||
@@ -540,7 +561,7 @@ async function startBulkProcess() {
|
|||||||
gpsPhotos.forEach(p => fd.append('files', p.file, p.file.name || 'photo.jpg'));
|
gpsPhotos.forEach(p => fd.append('files', p.file, p.file.name || 'photo.jpg'));
|
||||||
|
|
||||||
try {
|
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();
|
const data = await resp.json();
|
||||||
|
|
||||||
// Merge thumbnails back into results
|
// Merge thumbnails back into results
|
||||||
@@ -753,8 +774,13 @@ function esc(s) {
|
|||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOcrParams() {
|
||||||
|
const useLlm = document.getElementById('ocrLlmToggle').checked;
|
||||||
|
if (!useLlm) return '';
|
||||||
|
return '?ocr_engine=llm';
|
||||||
|
}
|
||||||
|
|
||||||
function formatSize(bytes) {
|
function formatSize(bytes) {
|
||||||
if (bytes < 1024) return bytes + ' B';
|
|
||||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.5 MiB |