From a8b1e694b0293c0b2879a0061c75712da430155a Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 25 May 2026 17:42:10 -0400 Subject: [PATCH] feat: persistence, rerun OCR, Google Gemini, sticker mode, manual entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQLite DB (photos.db) — persists all processed photo records - Dedup by SHA256 hash — same file upload returns duplicate: true - /api/photos — list previously processed photos - /api/photos/{id} — get single record - /api/photos/{id}/file — serve saved image - /api/photos/{id}/reprocess — re-run OCR with different engine/model - Google Gemini OCR engine (gemini-2.5-flash, free tier) alongside OpenCode Go LLM and Tesseract - Sticker mode — specialized LLM/Google prompt for green/orange/yellow equipment stickers with 2D barcode + machine ID - Manual machine ID entry — when GPS exists but OCR fails, show text input for manual lookup - Frontend: Previous Photos section, Re-run OCR per photo, duplicate badges, engine dropdown, sticker toggle --- __pycache__/server.cpython-311.pyc | Bin 27909 -> 45689 bytes photos.db | Bin 0 -> 16384 bytes server.py | 632 +++++++++++++++++++++++------ static/index.html | 352 ++++++++++++---- 4 files changed, 802 insertions(+), 182 deletions(-) create mode 100644 photos.db diff --git a/__pycache__/server.cpython-311.pyc b/__pycache__/server.cpython-311.pyc index 339b421923edb888e8654ba54e6c0beb6b95b0a1..e20301d523d62efd295b679e134180c53f60c5d2 100644 GIT binary patch literal 45689 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 delta 10495 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 USf*>O=H95*w>GHXsOJIy7t=@NAOHXW diff --git a/photos.db b/photos.db new file mode 100644 index 0000000000000000000000000000000000000000..5de138927ae93d9baf727305544be2ccf72c43ad GIT binary patch literal 16384 zcmeI%!EVzq7zglVod%kywgXbtn{R8?34st?AwV`&WZ6~{iJmMkaT`MtcS*Wc+_~&! zco1HQBX+V*bGIVS)!$MQx&CZ_{@FQ1{c`S!ij#>fGFFk-*1l!i)_X!M%d*wAt*&Kx ztiEhp_q44SH_zL9f2`KCUsl`R`>h@*5P$##AOHafKmY;|fB*#kN8qfr(-{oxam6lD zUS6i^+l7|=a?bN;wYvRr(u6-lwF=vUaWZB%e zj1ijONN<)EIxA_aJ{_B)$X9Z&+wV@I^ ze^a*?>w*9TAOHafKmY;|fB*y_009Wp0{H(Q2LS>QfB*y_009U<00Izz00bbg`2v3d D(gNkT literal 0 HcmV?d00001 diff --git a/server.py b/server.py index ad09ef6..837064f 100644 --- a/server.py +++ b/server.py @@ -1,8 +1,9 @@ """EXIF + OCR test backend — validate that GPS survives upload pipeline.""" -import io, json, os, re, uuid, sqlite3, urllib.request +import hashlib, io, json, os, re, uuid, sqlite3, urllib.request from pathlib import Path from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from PIL import Image as PILImage @@ -25,13 +26,148 @@ 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") +# === Google Gemini OCR (free tier) === +GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "") +GOOGLE_API_BASE = os.environ.get("GOOGLE_API_BASE_URL", "https://generativelanguage.googleapis.com/v1beta") +GOOGLE_OCR_MODEL = os.environ.get("GOOGLE_OCR_MODEL", "gemini-2.5-flash") + UPLOADS = Path(__file__).parent / "uploads" UPLOADS.mkdir(exist_ok=True) +PHOTOS_DB = Path(__file__).parent / "photos.db" CANTEEN_DB = Path(__file__).parent.parent / "canteen-asset-tracker" / "assets.db" +# Max images per batch API call +BATCH_SIZE_LIMIT = 20 +# Downscale images to this max dimension before LLM OCR +LLM_IMAGE_MAX_DIM = 1600 + +# --------------------------------------------------------------------------- +# Sticker-mode prompts +# --------------------------------------------------------------------------- +DEFAULT_OCR_PROMPT = ( + "Read ALL text and numbers visible in this photo. " + "Return the exact text shown, nothing else." +) + +STICKER_OCR_PROMPT = ( + "This photo shows a colored equipment sticker (green, orange, or yellow background) " + "with a 2D barcode and a machine ID number printed below the barcode. " + "Read ONLY the machine ID number that appears below the barcode. " + "It is typically a 5-digit number followed by a dash and 6 more digits " + "(e.g., 12345-678901). Return ONLY the machine ID number, nothing else." +) + +DEFAULT_BATCH_PROMPT = ( + "I have {n} photos. For EACH photo, read ALL visible text and numbers.\n" + 'Respond with a JSON array of objects, one per photo in order:\n' + '[{{"i":0,"text":"all text and digits found","digits":"e.g. 12345-678901 or null if none"}}, ...]\n' + 'Return ONLY the JSON array, no markdown, no explanation.' +) + +STICKER_BATCH_PROMPT = ( + "I have {n} photos of colored equipment stickers (green, orange, or yellow). " + "Each sticker has a 2D barcode and a machine ID printed below it.\n" + 'Respond with a JSON array of objects, one per photo in order:\n' + '[{{"i":0,"sticker_color":"green/orange/yellow/unknown","machine_id":"12345-678901 or null if not found"}}, ...]\n' + 'Return ONLY the JSON array, no markdown, no explanation.' +) + +# --------------------------------------------------------------------------- +# App + DB init +# --------------------------------------------------------------------------- app = FastAPI(title="EXIF Test") +def _init_photos_db(): + """Create photos table for persistence + dedup.""" + conn = sqlite3.connect(str(PHOTOS_DB)) + conn.execute(""" + CREATE TABLE IF NOT EXISTS photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + orig_filename TEXT NOT NULL, + file_hash TEXT NOT NULL UNIQUE, + saved_as TEXT NOT NULL, + file_size INTEGER, + exif_json TEXT, + gps_lat REAL, + gps_lng REAL, + ocr_engine TEXT, + ocr_model TEXT, + ocr_raw_text TEXT, + ocr_match_5dash6 TEXT, + ocr_match_5plus TEXT, + machine_id TEXT, + sticker_color TEXT, + has_barcode INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ) + """) + conn.commit() + conn.close() + + +_init_photos_db() + + +# --------------------------------------------------------------------------- +# DB helpers +# --------------------------------------------------------------------------- +def _get_photos_db() -> sqlite3.Connection: + conn = sqlite3.connect(str(PHOTOS_DB)) + conn.row_factory = sqlite3.Row + return conn + + +def _file_hash(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _save_photo_to_db( + orig_filename: str, file_hash: str, saved_as: str, file_size: int, + exif_result: dict, ocr_result: dict, machine_id: str | None, +) -> int: + """Insert a photo record. Returns the row id.""" + conn = _get_photos_db() + try: + cur = conn.execute( + """INSERT OR IGNORE INTO photos + (orig_filename, file_hash, saved_as, file_size, exif_json, + gps_lat, gps_lng, ocr_engine, ocr_model, ocr_raw_text, + ocr_match_5dash6, ocr_match_5plus, machine_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + orig_filename, + file_hash, + saved_as, + file_size, + json.dumps(exif_result) if exif_result else None, + exif_result.get("gps", {}).get("lat") if exif_result and exif_result.get("gps") else None, + exif_result.get("gps", {}).get("lng") if exif_result and exif_result.get("gps") else None, + ocr_result.get("engine") if ocr_result else None, + ocr_result.get("llm_model") if ocr_result else None, + ocr_result.get("raw_text") if ocr_result else None, + ocr_result.get("match_5dash6") if ocr_result else None, + ocr_result.get("match_5plus") if ocr_result else None, + machine_id, + ), + ) + conn.commit() + return cur.lastrowid or 0 + except sqlite3.IntegrityError: + return 0 + finally: + conn.close() + + +def _row_to_dict(row: sqlite3.Row) -> dict: + d = dict(row) + d["exif_data"] = json.loads(d.pop("exif_json", "{}") or "{}") + return d + + +# --------------------------------------------------------------------------- +# Canteen DB helpers +# --------------------------------------------------------------------------- def _get_canteen_db(): """Get a read/write connection to the canteen assets database.""" if not CANTEEN_DB.exists(): @@ -41,6 +177,44 @@ def _get_canteen_db(): return conn +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() + if not conn: + return None + try: + row = conn.execute( + "SELECT id, machine_id, name, category, status, address, building_name, " + "floor, room, latitude, longitude, make, model, description, photo_path " + "FROM assets WHERE machine_id = ?", + (machine_id.strip(),), + ).fetchone() + if row: + return { + "id": row["id"], + "machine_id": row["machine_id"], + "name": row["name"], + "category": row["category"], + "status": row["status"], + "address": row["address"], + "building_name": row["building_name"], + "floor": row["floor"], + "room": row["room"], + "latitude": row["latitude"], + "longitude": row["longitude"], + "make": row["make"], + "model": row["model"], + "description": row["description"], + "photo_path": row["photo_path"], + } + finally: + conn.close() + return None + + +# --------------------------------------------------------------------------- +# EXIF extraction +# --------------------------------------------------------------------------- def _dms_to_decimal(dms, ref): """Convert EXIF DMS tuple to decimal degrees.""" try: @@ -96,6 +270,9 @@ def extract_exif(image_bytes: bytes) -> dict: return result +# --------------------------------------------------------------------------- +# Tesseract OCR +# --------------------------------------------------------------------------- def run_ocr(image_bytes: bytes) -> dict: """Run Tesseract OCR on the image.""" if not HAS_TESSERACT: @@ -119,12 +296,9 @@ def run_ocr(image_bytes: bytes) -> dict: tmp_path.unlink(missing_ok=True) -# Maximum images per batch API call (prevents context limit issues) -BATCH_SIZE_LIMIT = 20 -# Downscale images to this max dimension before sending to LLM (saves image tokens) -LLM_IMAGE_MAX_DIM = 1600 - - +# --------------------------------------------------------------------------- +# LLM OCR helpers +# --------------------------------------------------------------------------- def _resize_for_llm(image_bytes: bytes, max_dim: int = LLM_IMAGE_MAX_DIM) -> bytes: """Downscale image to max_dim on longest edge to save vision API costs.""" img = PILImage.open(io.BytesIO(image_bytes)) @@ -136,7 +310,6 @@ def _resize_for_llm(image_bytes: bytes, max_dim: int = LLM_IMAGE_MAX_DIM) -> byt img_resized = img.resize(new_size, PILImage.LANCZOS) buf = io.BytesIO() ext = img.format or "JPEG" - # Convert PNG, WebP, etc. to JPEG for smaller size if ext.upper() in ("PNG", "WEBP", "TIFF", "BMP"): img_resized = img_resized.convert("RGB") ext = "JPEG" @@ -144,11 +317,14 @@ def _resize_for_llm(image_bytes: bytes, max_dim: int = LLM_IMAGE_MAX_DIM) -> byt return buf.getvalue() -def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> dict: +def run_ocr_llm( + image_bytes: bytes, model: str | None = None, + sticker_mode: bool = False, +) -> 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. + Returns the same shape as run_ocr() with an additional 'engine' field. """ if not OPENCODE_GO_KEY: result = run_ocr(image_bytes) @@ -159,17 +335,17 @@ def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> dict: import base64 model = model or LLM_OCR_MODEL - # Downscale to save costs image_bytes = _resize_for_llm(image_bytes) b64 = base64.b64encode(image_bytes).decode() + prompt = STICKER_OCR_PROMPT if sticker_mode else DEFAULT_OCR_PROMPT + 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": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}} ] }], @@ -191,7 +367,6 @@ def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> dict: 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] @@ -200,7 +375,7 @@ def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> dict: match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text) match_5plus = re.search(r"(\d{5,})", text) - return { + out = { "available": True, "engine": "llm", "llm_model": model, @@ -208,17 +383,20 @@ def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> dict: "match_5dash6": match_5dash.group(0) if match_5dash else None, "match_5plus": match_5plus.group(0) if match_5plus else None, } + if sticker_mode: + # Try to detect sticker color from the response + color_match = re.search(r"\b(green|orange|yellow)\b", text, re.I) + out["sticker_color"] = color_match.group(1).lower() if color_match else "unknown" + return out -def run_ocr_llm_batch(images: list[tuple[str, bytes]], model: str | None = None, max_per_batch: int = BATCH_SIZE_LIMIT) -> list[dict]: - """Batch OCR multiple images in a single LLM API call. - - Sends all images in one request with a structured JSON prompt. - Returns list of OCR results in the same order as the input images. - Falls back to individual calls if the batch call fails. - - images: list of (filename, image_bytes) - """ +def run_ocr_llm_batch( + images: list[tuple[str, bytes]], + model: str | None = None, + max_per_batch: int = BATCH_SIZE_LIMIT, + sticker_mode: bool = False, +) -> list[dict]: + """Batch OCR multiple images in a single LLM API call.""" import base64 if not OPENCODE_GO_KEY: @@ -226,31 +404,25 @@ def run_ocr_llm_batch(images: list[tuple[str, bytes]], model: str | None = None, model = model or LLM_OCR_MODEL - # Split into sub-batches if over the limit all_results: list[dict] = [] for batch_start in range(0, len(images), max_per_batch): batch = images[batch_start:batch_start + max_per_batch] - batch_results = _run_ocr_llm_batch_inner(batch, model) + batch_results = _run_ocr_llm_batch_inner(batch, model, sticker_mode) all_results.extend(batch_results) return all_results -def _run_ocr_llm_batch_inner(batch: list[tuple[str, bytes]], model: str) -> list[dict]: +def _run_ocr_llm_batch_inner( + batch: list[tuple[str, bytes]], model: str, + sticker_mode: bool = False, +) -> list[dict]: """Inner helper: sends one batch of images in a single API call.""" import base64 - # Resize all images first resized = [(_resize_for_llm(b), n) for n, b in batch] + batch_prompt = (STICKER_BATCH_PROMPT if sticker_mode else DEFAULT_BATCH_PROMPT).format(n=len(batch)) - content: list[dict] = [{ - "type": "text", - "text": ( - f"I have {len(batch)} photos. For EACH photo, read ALL visible text and numbers.\n" - "Respond with a JSON array of objects, one per photo in order:\n" - '[{"i":0,"text":"all text and digits found","digits":"e.g. 12345-678901 or null if none"}, ...]\n' - 'Return ONLY the JSON array, no markdown, no explanation.' - ) - }] + content: list[dict] = [{"type": "text", "text": batch_prompt}] for idx, (img_bytes, _) in enumerate(resized): b64 = base64.b64encode(img_bytes).decode() @@ -281,10 +453,8 @@ def _run_ocr_llm_batch_inner(batch: list[tuple[str, bytes]], model: str) -> list result = json.loads(resp.read()) raw = result["choices"][0]["message"]["content"].strip() except Exception: - # Fallback: individual calls for this batch - return [run_ocr_llm(b, model) for n, b in batch] + return [run_ocr_llm(b, model, sticker_mode=sticker_mode) for n, b in batch] - # Strip markdown code fences if present raw_clean = raw if raw_clean.startswith("```"): raw_clean = raw_clean.split("\n", 1)[-1] @@ -294,85 +464,130 @@ def _run_ocr_llm_batch_inner(batch: list[tuple[str, bytes]], model: str) -> list parsed = json.loads(raw_clean) results: list[dict] = [] for item in parsed: - raw_text = str(item.get("text", "") or "") - digit_str = str(item.get("digits") or "") - combined = raw_text + " " + digit_str - match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", combined) - match_5plus = re.search(r"(\d{5,})", combined) - results.append({ + if sticker_mode: + raw_text = str(item.get("machine_id", "") or "") + else: + raw_text = str(item.get("text", "") or "") + digit_str = str(item.get("digits") or "") + raw_text = raw_text + " " + digit_str + match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", raw_text) + match_5plus = re.search(r"(\d{5,})", raw_text) + entry: dict = { "available": True, "engine": "llm_batch", "llm_model": model, "raw_text": raw_text[:500], "match_5dash6": match_5dash.group(0) if match_5dash else None, "match_5plus": match_5plus.group(0) if match_5plus else None, - }) + } + if sticker_mode: + entry["sticker_color"] = str(item.get("sticker_color", "unknown")) + results.append(entry) return results except (json.JSONDecodeError, KeyError, TypeError): - # Fallback to individual calls - return [run_ocr_llm(b, model) for n, b in batch] + return [run_ocr_llm(b, model, sticker_mode=sticker_mode) for n, b in batch] -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() - if not conn: - return None +# --------------------------------------------------------------------------- +# Google Gemini OCR +# --------------------------------------------------------------------------- +def run_ocr_google(image_bytes: bytes, model: str | None = None, sticker_mode: bool = False) -> dict: + """Run OCR via Google Gemini vision API (free tier).""" + import base64 + + if not GOOGLE_API_KEY: + result = run_ocr(image_bytes) + result["engine"] = "tesseract" + result["llm_fallback_reason"] = "no_google_api_key" + return result + + model = model or GOOGLE_OCR_MODEL + image_bytes = _resize_for_llm(image_bytes) + b64 = base64.b64encode(image_bytes).decode() + prompt = STICKER_OCR_PROMPT if sticker_mode else DEFAULT_OCR_PROMPT + + body = json.dumps({ + "contents": [{ + "parts": [ + {"text": prompt}, + {"inline_data": {"mime_type": "image/jpeg", "data": b64}} + ] + }], + "generationConfig": {"maxOutputTokens": 200, "temperature": 0.1}, + }).encode() + + req = urllib.request.Request( + f"{GOOGLE_API_BASE}/models/{model}:generateContent?key={GOOGLE_API_KEY}", + data=body, + headers={"Content-Type": "application/json"}, + ) + try: - row = conn.execute( - "SELECT id, machine_id, name, category, status, address, building_name, " - "floor, room, latitude, longitude, make, model, description, photo_path " - "FROM assets WHERE machine_id = ?", - (machine_id.strip(),), - ).fetchone() - if row: - return { - "id": row["id"], - "machine_id": row["machine_id"], - "name": row["name"], - "category": row["category"], - "status": row["status"], - "address": row["address"], - "building_name": row["building_name"], - "floor": row["floor"], - "room": row["room"], - "latitude": row["latitude"], - "longitude": row["longitude"], - "make": row["make"], - "model": row["model"], - "description": row["description"], - "photo_path": row["photo_path"], - } - finally: - conn.close() - return None + resp = urllib.request.urlopen(req, timeout=60) + result = json.loads(resp.read()) + text = result["candidates"][0]["content"]["parts"][0]["text"].strip() + except Exception as exc: + 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) + + out: dict = { + "available": True, + "engine": "google", + "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, + } + if sticker_mode: + cm = re.search(r"\b(green|orange|yellow)\b", text, re.I) + out["sticker_color"] = cm.group(1).lower() if cm else "unknown" + return out -@app.post("/api/analyze") -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() +# --------------------------------------------------------------------------- +# Process one photo (shared between analyze + bulk) +# --------------------------------------------------------------------------- +def _process_one(orig_filename: str, contents: bytes, ocr_engine: str, + ocr_model: str | None, sticker_mode: bool) -> dict: + """Run EXIF + OCR on a single photo. Returns result dict.""" file_size = len(contents) + fhash = _file_hash(contents) - ext = Path(file.filename or "photo.jpg").suffix.lower() - if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}: - ext = ".jpg" - fname = f"{uuid.uuid4().hex}{ext}" - (UPLOADS / fname).write_bytes(contents) + # Check dup + dup_row = None + db_conn = _get_photos_db() + if db_conn: + try: + dup_row = db_conn.execute( + "SELECT id, saved_as FROM photos WHERE file_hash = ?", (fhash,) + ).fetchone() + finally: + db_conn.close() + + if dup_row: + saved_name = dup_row["saved_as"] + is_dup = True + photo_id = dup_row["id"] + else: + ext = Path(orig_filename or "photo.jpg").suffix.lower() + if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}: + ext = ".jpg" + saved_name = f"{uuid.uuid4().hex}{ext}" + (UPLOADS / saved_name).write_bytes(contents) + is_dup = False + photo_id = None exif_result = extract_exif(contents) if ocr_engine == "llm": - ocr_result = run_ocr_llm(contents, ocr_model or None) + ocr_result = run_ocr_llm(contents, ocr_model, sticker_mode=sticker_mode) + elif ocr_engine == "google": + ocr_result = run_ocr_google(contents, ocr_model, sticker_mode=sticker_mode) elif HAS_TESSERACT: ocr_result = run_ocr(contents) ocr_result["engine"] = "tesseract" @@ -385,11 +600,20 @@ async def analyze_photo( machine_id = re.sub(r"\D", "", ocr_result["match_5dash6"])[-5:] asset = lookup_machine_id(machine_id) + # Save to DB if not a duplicate + if not is_dup and not dup_row: + photo_id = _save_photo_to_db( + orig_filename, fhash, saved_name, file_size, + exif_result, ocr_result, machine_id, + ) + return { - "filename": file.filename, - "saved_as": fname, + "filename": orig_filename, + "saved_as": saved_name, + "photo_id": photo_id or (dup_row["id"] if dup_row else None), "file_size": file_size, "file_size_kb": round(file_size / 1024, 1), + "duplicate": is_dup, "exif": exif_result, "ocr": ocr_result, "machine_id": machine_id, @@ -397,17 +621,44 @@ async def analyze_photo( } +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- +@app.post("/api/analyze") +async def analyze_photo( + file: UploadFile = File(...), + ocr_engine: str = Query(default="tesseract"), + ocr_model: str = Query(default=""), + sticker_mode: bool = Query(default=False), +): + """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') + - sticker_mode: if true, uses sticker-specific prompt + """ + contents = await file.read() + result = _process_one( + file.filename or "photo.jpg", contents, + ocr_engine, ocr_model or None, sticker_mode, + ) + return result + + @app.post("/api/bulk-process") async def bulk_process( files: list[UploadFile] = File(...), ocr_engine: str = Query(default="tesseract"), ocr_model: str = Query(default=""), + sticker_mode: bool = Query(default=False), ): """Process multiple photos: OCR each, extract EXIF GPS, look up matching assets. Query params: - - ocr_engine: ''tesseract'' (default) or ''llm'' + - ocr_engine: 'tesseract' (default) or 'llm' - ocr_model: model name override + - sticker_mode: if true, uses sticker-specific prompt Returns a list of results, each with: - filename, exif (gps), ocr match, matched asset (if found) @@ -423,30 +674,55 @@ async def bulk_process( all_files.append((file.filename or "photo.jpg", contents)) ocr_use_llm = ocr_engine == "llm" + ocr_use_google = ocr_engine == "google" # Batch OCR if using LLM mode if ocr_use_llm: - llm_ocr_results = run_ocr_llm_batch(all_files, ocr_model or None) + llm_ocr_results = run_ocr_llm_batch( + [(f, b) for f, b in all_files], + ocr_model or None, + sticker_mode=sticker_mode, + ) else: llm_ocr_results = None results = [] - summary = {"total": len(all_files), "has_gps": 0, "matched": 0, "needs_gps": 0} + summary = {"total": len(all_files), "has_gps": 0, "matched": 0, "needs_gps": 0, "duplicates": 0} for i, (orig_fname, contents) in enumerate(all_files): file_size = len(contents) + fhash = _file_hash(contents) - # Save - ext = Path(orig_fname or "photo.jpg").suffix.lower() - if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}: - ext = ".jpg" - saved_name = f"{uuid.uuid4().hex}{ext}" - (UPLOADS / saved_name).write_bytes(contents) + # Check dup + dup_row = None + db_conn = _get_photos_db() + if db_conn: + try: + dup_row = db_conn.execute( + "SELECT id, saved_as FROM photos WHERE file_hash = ?", (fhash,) + ).fetchone() + finally: + db_conn.close() + + if dup_row: + saved_name = dup_row["saved_as"] + is_dup = True + photo_id = dup_row["id"] + else: + ext = Path(orig_fname or "photo.jpg").suffix.lower() + if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}: + ext = ".jpg" + saved_name = f"{uuid.uuid4().hex}{ext}" + (UPLOADS / saved_name).write_bytes(contents) + is_dup = False + photo_id = None exif_result = extract_exif(contents) if ocr_use_llm: ocr_result = llm_ocr_results[i] if i < len(llm_ocr_results) else {"available": False, "engine": "none", "error": "missing batch result"} + elif ocr_use_google: + ocr_result = run_ocr_google(contents, ocr_model or None, sticker_mode=sticker_mode) elif HAS_TESSERACT: ocr_result = run_ocr(contents) ocr_result["engine"] = "tesseract" @@ -466,15 +742,26 @@ async def bulk_process( asset = lookup_machine_id(machine_id) if asset: summary["matched"] += 1 - # Check if asset needs GPS if (asset["latitude"] is None or asset["longitude"] is None) and has_gps: needs_gps = True summary["needs_gps"] += 1 + # Save to DB if not dup + if not is_dup and not dup_row: + photo_id = _save_photo_to_db( + orig_fname, fhash, saved_name, file_size, + exif_result, ocr_result, machine_id, + ) + + if is_dup: + summary["duplicates"] += 1 + results.append({ "filename": orig_fname, "saved_as": saved_name, + "photo_id": photo_id or (dup_row["id"] if dup_row else None), "file_size_kb": round(file_size / 1024, 1), + "duplicate": is_dup, "exif": exif_result, "ocr": ocr_result, "machine_id": machine_id, @@ -485,6 +772,126 @@ async def bulk_process( return {"results": results, "summary": summary} +@app.get("/api/photos") +async def list_photos(limit: int = Query(default=50, le=200)): + """List previously processed photos from DB (newest first).""" + conn = _get_photos_db() + try: + rows = conn.execute( + "SELECT * FROM photos ORDER BY created_at DESC LIMIT ?", (limit,) + ).fetchall() + return {"photos": [_row_to_dict(r) for r in rows]} + finally: + conn.close() + + +@app.get("/api/photos/{photo_id}") +async def get_photo(photo_id: int): + """Get a single photo record from DB.""" + conn = _get_photos_db() + try: + row = conn.execute("SELECT * FROM photos WHERE id = ?", (photo_id,)).fetchone() + if not row: + raise HTTPException(404, "Photo not found") + return _row_to_dict(row) + finally: + conn.close() + + +@app.get("/api/photos/{photo_id}/file") +async def get_photo_file(photo_id: int): + """Serve the saved image file for a photo record.""" + conn = _get_photos_db() + try: + row = conn.execute("SELECT saved_as FROM photos WHERE id = ?", (photo_id,)).fetchone() + if not row: + raise HTTPException(404, "Photo not found") + finally: + conn.close() + + filepath = UPLOADS / row["saved_as"] + if not filepath.exists(): + raise HTTPException(404, "File not found on disk") + return FileResponse(str(filepath)) + + +@app.post("/api/photos/{photo_id}/reprocess") +async def reprocess_photo( + photo_id: int, + ocr_engine: str = Query(default="tesseract"), + ocr_model: str = Query(default=""), + sticker_mode: bool = Query(default=False), +): + """Re-run OCR on a previously saved photo with different engine/model. + + Query params: + - ocr_engine: 'tesseract' (default) or 'llm' + - ocr_model: model name override (e.g. 'mimo-v2-omni', 'glm-5.1') + - sticker_mode: if true, uses sticker-specific prompt + """ + conn = _get_photos_db() + try: + row = conn.execute( + "SELECT saved_as, orig_filename FROM photos WHERE id = ?", (photo_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Photo not found") + finally: + conn.close() + + filepath = UPLOADS / row["saved_as"] + if not filepath.exists(): + raise HTTPException(404, "Uploaded file not found on disk") + + contents = filepath.read_bytes() + + if ocr_engine == "llm": + ocr_result = run_ocr_llm(contents, ocr_model or None, sticker_mode=sticker_mode) + elif ocr_engine == "google": + ocr_result = run_ocr_google(contents, ocr_model or None, sticker_mode=sticker_mode) + 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 + if ocr_result.get("match_5dash6"): + machine_id = re.sub(r"\D", "", ocr_result["match_5dash6"])[-5:] + asset = lookup_machine_id(machine_id) + + # Update DB record with new OCR results + db2 = _get_photos_db() + try: + db2.execute( + """UPDATE photos SET ocr_engine=?, ocr_model=?, ocr_raw_text=?, + ocr_match_5dash6=?, ocr_match_5plus=?, machine_id=? + WHERE id=?""", + ( + ocr_result.get("engine"), + ocr_result.get("llm_model"), + ocr_result.get("raw_text"), + ocr_result.get("match_5dash6"), + ocr_result.get("match_5plus"), + machine_id, + photo_id, + ), + ) + db2.commit() + finally: + db2.close() + + return { + "photo_id": photo_id, + "ocr_engine": ocr_result.get("engine"), + "llm_model": ocr_result.get("llm_model"), + "ocr": ocr_result, + "machine_id": machine_id, + "asset": asset, + } + + @app.get("/api/lookup") async def lookup_asset(machine_id: str = ""): """Look up an asset by machine_id in the canteen assets database.""" @@ -516,7 +923,6 @@ async def push_gps(request: dict): raise HTTPException(500, "Database not available") try: - # Only update if currently NULL row = conn.execute( "SELECT latitude, longitude FROM assets WHERE id = ?", (int(asset_id),) ).fetchone() diff --git a/static/index.html b/static/index.html index 62bf5ad..c0ee528 100644 --- a/static/index.html +++ b/static/index.html @@ -26,19 +26,17 @@ h1 { font-size: 20px; margin-bottom: 4px; } .subtitle { font-size: 12px; color: var(--text2); margin-bottom: 16px; } - /* Upload area */ .upload-area { border: 2px dashed var(--border); border-radius: var(--radius); padding: 28px 20px; text-align: center; cursor: pointer; transition: border-color 0.2s; background: var(--card); - margin-bottom: 16px; + margin-bottom: 12px; } .upload-area:active { border-color: var(--accent); background: var(--card2); } .upload-area .icon { font-size: 36px; display: block; margin-bottom: 6px; } .upload-area .label { font-size: 15px; font-weight: 600; } .upload-area .hint { font-size: 11px; color: var(--text2); margin-top: 4px; } - /* Summary bar */ #summary { display: none; background: var(--card); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 12px; @@ -53,7 +51,6 @@ .summary-nogps { color: var(--amber); } .summary-total { color: var(--text); } - /* Photo grid */ #gallery { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; @@ -84,7 +81,6 @@ } .photo-card.selected .check { display: flex; } - /* Detail card */ #detail { display: none; background: var(--card); border-radius: var(--radius); padding: 14px; margin-bottom: 12px; @@ -102,6 +98,8 @@ .badge-warn { background: var(--amber); color: #000; } .badge-fail { background: var(--red); color: #fff; } .badge-info { background: var(--accent); color: #fff; } + .badge-dup { background: var(--red); color: #fff; } + .badge-sticker { background: #8b5cf6; color: #fff; } .exif-row { display: flex; justify-content: space-between; padding: 6px 0; @@ -118,7 +116,6 @@ border-radius: var(--radius-sm); margin: 8px 0; } - /* Buttons */ .btn { display: block; width: 100%; padding: 14px; font-size: 15px; font-weight: 600; border: none; border-radius: var(--radius-sm); @@ -132,6 +129,7 @@ .btn:disabled { opacity: 0.4; pointer-events: none; } .btn-row { display: flex; gap: 8px; margin-top: 8px; } .btn-sm { padding: 8px 14px; font-size: 12px; width: auto; } + .btn-xs { padding: 4px 10px; font-size: 11px; width: auto; } .spinner { display: inline-block; width: 16px; height: 16px; @@ -145,7 +143,6 @@ #fileInput { display: none; } - /* Filter bar */ #filterBar { display: none; align-items: center; gap: 6px; margin-bottom: 10px; } @@ -157,14 +154,11 @@ } .filter-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; } - /* Asset match card */ .asset-card { background: var(--card2); border-radius: var(--radius-sm); padding: 12px; margin-top: 10px; border-left: 3px solid var(--green); } - .asset-card .asset-name { - font-size: 15px; font-weight: 700; margin-bottom: 4px; - } + .asset-card .asset-name { font-size: 15px; font-weight: 700; margin-bottom: 4px; } .asset-card .asset-meta { font-size: 12px; color: var(--text2); display: flex; flex-wrap: wrap; gap: 8px; @@ -174,7 +168,6 @@ white-space: nowrap; } - /* Server section */ .section { background: var(--card); border-radius: var(--radius); padding: 14px; margin-bottom: 12px; @@ -187,10 +180,9 @@ margin-top: 6px; } - /* OCR engine toggle */ .ocr-toggle { - display: flex; align-items: center; gap: 8px; margin-bottom: 8px; - font-size: 12px; color: var(--text2); + display: flex; align-items: center; gap: 10px; margin-bottom: 8px; + font-size: 12px; color: var(--text2); flex-wrap: wrap; } .ocr-toggle label { display: flex; align-items: center; gap: 4px; cursor: pointer; } .ocr-toggle input[type="checkbox"] { accent-color: var(--accent); } @@ -201,7 +193,6 @@ .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 { background: var(--card); border-radius: var(--radius-sm); padding: 10px; @@ -229,6 +220,51 @@ .match-badge.no-match { background: var(--border); color: var(--text2); } .match-badge.no-gps { background: var(--amber); color: #000; } .match-badge.has-gps { background: var(--card2); color: var(--text2); } + .match-badge.dup { background: var(--red); color: #fff; } + .match-badge.sticker { background: #8b5cf6; color: #fff; } + + /* Reprocess card */ + .reprocess-row { + display: flex; align-items: center; gap: 8px; margin-top: 8px; + padding-top: 8px; border-top: 1px solid var(--border); + } + .reprocess-row select { + background: var(--card2); color: var(--text); border: 1px solid var(--border); + border-radius: 8px; padding: 4px 8px; font-size: 11px; flex: 1; + } + .sticker-chip.active { background: #8b5cf6; border-color: #8b5cf6; color: #fff; } + + /* Previous photos */ + .prev-photo-card { + background: var(--card); border-radius: var(--radius-sm); padding: 10px; + margin-bottom: 8px; border-left: 3px solid var(--border); + font-size: 12px; + } + .prev-photo-card .prev-header { + display: flex; justify-content: space-between; align-items: center; gap: 6px; + margin-bottom: 4px; + } + .prev-photo-card .prev-filename { + font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .prev-photo-card .prev-time { font-size: 10px; color: var(--text3); white-space: nowrap; } + .prev-photo-card .prev-details { font-size: 11px; color: var(--text2); } + .prev-photo-card .prev-actions { + margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; + align-items: center; + } + .prev-photo-card .prev-actions select, + .prev-photo-card .prev-actions input { + background: var(--card2); color: var(--text); border: 1px solid var(--border); + border-radius: 8px; padding: 3px 6px; font-size: 11px; + } + .prev-photo-card .prev-actions label { + display: flex; align-items: center; gap: 3px; font-size: 10px; color: var(--text2); + } + .prev-photo-card .prev-actions label input { accent-color: #8b5cf6; } + .prev-photo-card .sticker-color { + display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-left: 4px; + } @@ -243,6 +279,23 @@ + +
+ + + mimo-v2-omni +
+
@@ -262,7 +315,7 @@ - +
@@ -271,20 +324,12 @@
- + - -
- -
-
+ + +