From 3a67070063da04b5f421bd1b054f1f9dee078209 Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 25 May 2026 17:12:37 -0400 Subject: [PATCH] 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 --- .gitignore | 3 + __pycache__/server.cpython-311.pyc | Bin 16183 -> 20427 bytes server.py | 114 +++++++++++++++++-- static/index.html | 62 +++++++--- uploads/0ed9143a7e684ffab74661520089a0da.jpg | Bin 2533147 -> 0 bytes uploads/46f86a948f7b427d8b7f2d6681d73179.jpg | Bin 1072 -> 0 bytes uploads/5b8b9a6e72bf4db6b1693d684214628a.jpg | Bin 2315661 -> 0 bytes uploads/720c7ae9fec14a13a8729abde83bee30.jpg | Bin 982 -> 0 bytes uploads/734ce1ca47014285bf7d4f4c46c2ce82.jpg | Bin 982 -> 0 bytes uploads/73d57c99c1cb4df4a7ccb9b49587bbb0.jpg | Bin 1425368 -> 0 bytes uploads/7a2af4ab1e0746cb9a770ac40d28d243.jpg | Bin 1796582 -> 0 bytes uploads/7bd95d92ab3a464bb0f83d6e8ec61ee5.jpg | Bin 2620691 -> 0 bytes uploads/866a657a83824fe19314566af0bd634a.jpg | Bin 2897168 -> 0 bytes uploads/8d25107e64874e48a5ef6d8356feba9a.jpg | Bin 2620691 -> 0 bytes uploads/a099b3c03dfc47529a10d2470930e3ed.jpg | Bin 1072 -> 0 bytes uploads/b083576095b6499b84eb896c2ee85735.jpg | Bin 1876538 -> 0 bytes uploads/b5e428e13b8840a7bf0e3c8fd219a1af.jpg | Bin 1862982 -> 0 bytes uploads/d54b437bb36744928701fabdb8c168ed.jpg | Bin 1796582 -> 0 bytes uploads/dc0b1a7a779c4d2a8d2240a4d0a7f6d8.jpg | Bin 1072 -> 0 bytes uploads/e43c484d08b144bfb34abc6826689f2f.jpg | Bin 1796582 -> 0 bytes uploads/e897232562934f1ba3404503cbc2d818.jpg | Bin 1072 -> 0 bytes uploads/f10213b35c874c4fa61befae5730cef9.jpg | Bin 2620691 -> 0 bytes 22 files changed, 154 insertions(+), 25 deletions(-) create mode 100644 .gitignore delete mode 100644 uploads/0ed9143a7e684ffab74661520089a0da.jpg delete mode 100644 uploads/46f86a948f7b427d8b7f2d6681d73179.jpg delete mode 100644 uploads/5b8b9a6e72bf4db6b1693d684214628a.jpg delete mode 100644 uploads/720c7ae9fec14a13a8729abde83bee30.jpg delete mode 100644 uploads/734ce1ca47014285bf7d4f4c46c2ce82.jpg delete mode 100644 uploads/73d57c99c1cb4df4a7ccb9b49587bbb0.jpg delete mode 100644 uploads/7a2af4ab1e0746cb9a770ac40d28d243.jpg delete mode 100644 uploads/7bd95d92ab3a464bb0f83d6e8ec61ee5.jpg delete mode 100644 uploads/866a657a83824fe19314566af0bd634a.jpg delete mode 100644 uploads/8d25107e64874e48a5ef6d8356feba9a.jpg delete mode 100644 uploads/a099b3c03dfc47529a10d2470930e3ed.jpg delete mode 100644 uploads/b083576095b6499b84eb896c2ee85735.jpg delete mode 100644 uploads/b5e428e13b8840a7bf0e3c8fd219a1af.jpg delete mode 100644 uploads/d54b437bb36744928701fabdb8c168ed.jpg delete mode 100644 uploads/dc0b1a7a779c4d2a8d2240a4d0a7f6d8.jpg delete mode 100644 uploads/e43c484d08b144bfb34abc6826689f2f.jpg delete mode 100644 uploads/e897232562934f1ba3404503cbc2d818.jpg delete mode 100644 uploads/f10213b35c874c4fa61befae5730cef9.jpg diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b657f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +uploads/ +__pycache__/ +*.py[cod] diff --git a/__pycache__/server.cpython-311.pyc b/__pycache__/server.cpython-311.pyc index 06164009e4a00ef68afac1b58778ff923d211288..f7613535ce843c3cc5cc0286b67e7926e8d77e7a 100644 GIT binary patch delta 8407 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 D7q2d< delta 4383 zcmbVPYj7LY6~6nBRJ41hnnY82XOkeP)`O#^Zw4G@>)AXE`{E!)# zw(H%`_ndp~*>mrn@7${opB7&KddCYMPZ5FZ>0g%(4XA{Cg2LRTj<1!fKq@JnbLT|xZ>YX6LI$8o)PkmYgE!CFy z%d~7l4mMI>$GoB(6f0lbK^=IIyd zvI*Nci1X!KtboDRIX`=A3&(tppE|8g^VH0b2cPwy@!1YOJ21AchC-h-fOCN{jh`8# z&c)HWV#$~$SQ?QwPsmFm_s_khy=BRW)(Nu2b|u)Z!ier@gL!#rJ5{DS&WW%R@Z>Jq zKBbRhj0D< zTde+A*uV*}?KvqiU`bm+V%tz9Xu22vHx^#Znz~yXF<3ZagtU=pKRXI$D`$S=drKBf z{&?kTVF&-9vUSa9MaL0(@4Ae7xbuj9)Q`dqt1{}S!%{EyU!0z(_zF@p;XuyWcw^J8XpJ^u<$)BjG`+ZHX?(ebu_FGvM6YDtW5ck zbFnIHI1x%l7)wLXftl{=+l85rn;Rq{!-e+4ifM}{sTSkm_PVh#uwWU4afJI2?ngL@ zFoEy@!h;A8A;7mK>>CK*MEDlMBLJ$6VYZlxHljs_4UHW~$k9kUj~&xP)bC6mX7Q=xb`G8j#0p(tgipg%s-(y>LTH|73lOpDA{Lg;bA zgRx${lb=^NNrEBqw>B>mGa{G$hdfeRqSA4JiB!%=e8|71Liz!DO3cVczJS6=l9u_j zzm|Wn*~?A87yp-LYz9sye=)C7i(ab}GPO}VRnCYRI~6htJdU(|ve>E^jzTW8wZ`%} z2ZRij%(bbL+S7LG;1^fcj1^Jg0db)wWfY@%&JN;I3O&NR$PliUb*1IBXtd5rU@g*o z0WYi$XmQHw`c?N7y$VcHmOO!0%On#T5AbEXE9Mlvvz8hF^2H+nED4o#yQY-=^Ea+h&Xr33@U8uoaWrG0M#3Q#{{@3;E z*Dn(pR9F^N2jm5O$V51tH6=`0%tg}{)^*KL%S@SRVQNZX%5dWB8SJ&| z!~Oly5z`h+p3oSJV@CoZi4a3bAs|WFF$ClRyB)y*FeQDs&q^z{0z}oxN*+VCBa%!Q zTEfuTS)K`~H5M(f9P5~5d4^eK>%Bl(#Rbqkik&+l9sdk}Z8scE+eqh#zBVeA%RlmfyiNEkupzw+1bTI$uYlsSvu zvBVbWArUDe;tm14cERJO zj*KVm%rkMxp@l_1hlyV5Ogm|@#l#XS7ckLPz(m)QrE{movnLRc;5lxyr%=Wd1p5gBa@pcI z`ysZTM))bh{6?{ly^iuEfa&4J9 zUm>hU{@Bnr96J_Dv1CNkb@nz`VcpO}Z~(~Hj1r!`vB$33T%Qt^*WGkbIh1wb&F{Sl z%7_2PY{k(h?l^t$ql&gm6>ZsywqLHx29IPnABFqjq8Z=%okArh)u^j0oQihkwoj`P z&%`!#2=;fNkDcS+-`IKsZphXesN%wkFFsg+m0xxd?Egae-#0!`XT8B=y&*NM z4|WcubW>s4-NTx0%sjT~CE1?)>w z{Mo{Rp@$%J_6sohCz&bRMkPVzBR!2m6@Rj)vrdI_sr#UFoJ?Hr@{=j(cBDd z@nbZ~m>2COpkD4{t0>y~?lCmolxZ|#Y|8D{)<%urILB2vmDCM}j4@@Lu|=R<;#@={ z{Ejet{>sj#G3%EDuB1i0YmQBD$Q_PpUt(85hfAj)gi>-v5(HtE?9P(iv!p5e|K78t zI$L=A|46oG|GS%?-qi}5r}wsvvYoiR{M2o4^ReAGjLoj>da-+2I=_Bu{S{I#*ey6c zw9vL%pGrhrc1ftYBBII7@cZ8OQ1$)Q*`oT7goaB(!|a-M)8*5V3pKCRTp>3J-4>jt slN&BI1Qrxl3tcUTAy-7S6ORl0xjom*@Ke2E|1$ZRvc)OA=MVt@1HD7wqW}N^ diff --git a/server.py b/server.py index 148acc3..6486dd1 100644 --- a/server.py +++ b/server.py @@ -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: diff --git a/static/index.html b/static/index.html index f3b925c..56a6b71 100644 --- a/static/index.html +++ b/static/index.html @@ -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 @@
+ +
+ +
+