diff --git a/__pycache__/server.cpython-311.pyc b/__pycache__/server.cpython-311.pyc index 339b421..e20301d 100644 Binary files a/__pycache__/server.cpython-311.pyc and b/__pycache__/server.cpython-311.pyc differ diff --git a/photos.db b/photos.db new file mode 100644 index 0000000..5de1389 Binary files /dev/null and b/photos.db differ 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 @@
- + - -
- -
-
+ + +