feat: persistence, rerun OCR, Google Gemini, sticker mode, manual entry

- 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
This commit is contained in:
2026-05-25 17:42:10 -04:00
parent 70d8374ca6
commit a8b1e694b0
4 changed files with 802 additions and 182 deletions
Binary file not shown.
BIN
View File
Binary file not shown.
+519 -113
View File
@@ -1,8 +1,9 @@
"""EXIF + OCR test backend — validate that GPS survives upload pipeline.""" """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 pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from PIL import Image as PILImage 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") 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") 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 = Path(__file__).parent / "uploads"
UPLOADS.mkdir(exist_ok=True) UPLOADS.mkdir(exist_ok=True)
PHOTOS_DB = Path(__file__).parent / "photos.db"
CANTEEN_DB = Path(__file__).parent.parent / "canteen-asset-tracker" / "assets.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") 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(): def _get_canteen_db():
"""Get a read/write connection to the canteen assets database.""" """Get a read/write connection to the canteen assets database."""
if not CANTEEN_DB.exists(): if not CANTEEN_DB.exists():
@@ -41,6 +177,44 @@ def _get_canteen_db():
return conn 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): def _dms_to_decimal(dms, ref):
"""Convert EXIF DMS tuple to decimal degrees.""" """Convert EXIF DMS tuple to decimal degrees."""
try: try:
@@ -96,6 +270,9 @@ def extract_exif(image_bytes: bytes) -> dict:
return result return result
# ---------------------------------------------------------------------------
# Tesseract OCR
# ---------------------------------------------------------------------------
def run_ocr(image_bytes: bytes) -> dict: def run_ocr(image_bytes: bytes) -> dict:
"""Run Tesseract OCR on the image.""" """Run Tesseract OCR on the image."""
if not HAS_TESSERACT: if not HAS_TESSERACT:
@@ -119,12 +296,9 @@ def run_ocr(image_bytes: bytes) -> dict:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
# Maximum images per batch API call (prevents context limit issues) # ---------------------------------------------------------------------------
BATCH_SIZE_LIMIT = 20 # LLM OCR helpers
# Downscale images to this max dimension before sending to LLM (saves image tokens) # ---------------------------------------------------------------------------
LLM_IMAGE_MAX_DIM = 1600
def _resize_for_llm(image_bytes: bytes, max_dim: int = LLM_IMAGE_MAX_DIM) -> bytes: 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.""" """Downscale image to max_dim on longest edge to save vision API costs."""
img = PILImage.open(io.BytesIO(image_bytes)) 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) img_resized = img.resize(new_size, PILImage.LANCZOS)
buf = io.BytesIO() buf = io.BytesIO()
ext = img.format or "JPEG" ext = img.format or "JPEG"
# Convert PNG, WebP, etc. to JPEG for smaller size
if ext.upper() in ("PNG", "WEBP", "TIFF", "BMP"): if ext.upper() in ("PNG", "WEBP", "TIFF", "BMP"):
img_resized = img_resized.convert("RGB") img_resized = img_resized.convert("RGB")
ext = "JPEG" ext = "JPEG"
@@ -144,11 +317,14 @@ def _resize_for_llm(image_bytes: bytes, max_dim: int = LLM_IMAGE_MAX_DIM) -> byt
return buf.getvalue() 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. """Run OCR via an LLM vision model on OpenCode Go.
Falls back to Tesseract if the API key is missing or the call fails. 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: if not OPENCODE_GO_KEY:
result = run_ocr(image_bytes) result = run_ocr(image_bytes)
@@ -159,17 +335,17 @@ def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> dict:
import base64 import base64
model = model or LLM_OCR_MODEL model = model or LLM_OCR_MODEL
# Downscale to save costs
image_bytes = _resize_for_llm(image_bytes) image_bytes = _resize_for_llm(image_bytes)
b64 = base64.b64encode(image_bytes).decode() b64 = base64.b64encode(image_bytes).decode()
prompt = STICKER_OCR_PROMPT if sticker_mode else DEFAULT_OCR_PROMPT
body = json.dumps({ body = json.dumps({
"model": model, "model": model,
"messages": [{ "messages": [{
"role": "user", "role": "user",
"content": [ "content": [
{"type": "text", "text": "Read ALL text and numbers visible in this photo. " {"type": "text", "text": prompt},
"Return the exact text shown, nothing else."},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}} {"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()) result = json.loads(resp.read())
text = result["choices"][0]["message"]["content"].strip() text = result["choices"][0]["message"]["content"].strip()
except Exception as exc: except Exception as exc:
# Fallback to Tesseract
result = run_ocr(image_bytes) result = run_ocr(image_bytes)
result["engine"] = "tesseract" result["engine"] = "tesseract"
result["llm_fallback_reason"] = str(exc)[:200] 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_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
match_5plus = re.search(r"(\d{5,})", text) match_5plus = re.search(r"(\d{5,})", text)
return { out = {
"available": True, "available": True,
"engine": "llm", "engine": "llm",
"llm_model": model, "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_5dash6": match_5dash.group(0) if match_5dash else None,
"match_5plus": match_5plus.group(0) if match_5plus 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]: def run_ocr_llm_batch(
"""Batch OCR multiple images in a single LLM API call. images: list[tuple[str, bytes]],
model: str | None = None,
Sends all images in one request with a structured JSON prompt. max_per_batch: int = BATCH_SIZE_LIMIT,
Returns list of OCR results in the same order as the input images. sticker_mode: bool = False,
Falls back to individual calls if the batch call fails. ) -> list[dict]:
"""Batch OCR multiple images in a single LLM API call."""
images: list of (filename, image_bytes)
"""
import base64 import base64
if not OPENCODE_GO_KEY: 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 model = model or LLM_OCR_MODEL
# Split into sub-batches if over the limit
all_results: list[dict] = [] all_results: list[dict] = []
for batch_start in range(0, len(images), max_per_batch): for batch_start in range(0, len(images), max_per_batch):
batch = images[batch_start:batch_start + 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) all_results.extend(batch_results)
return all_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.""" """Inner helper: sends one batch of images in a single API call."""
import base64 import base64
# Resize all images first
resized = [(_resize_for_llm(b), n) for n, b in batch] 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] = [{ content: list[dict] = [{"type": "text", "text": batch_prompt}]
"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.'
)
}]
for idx, (img_bytes, _) in enumerate(resized): for idx, (img_bytes, _) in enumerate(resized):
b64 = base64.b64encode(img_bytes).decode() 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()) result = json.loads(resp.read())
raw = result["choices"][0]["message"]["content"].strip() raw = result["choices"][0]["message"]["content"].strip()
except Exception: except Exception:
# Fallback: individual calls for this batch return [run_ocr_llm(b, model, sticker_mode=sticker_mode) for n, b in batch]
return [run_ocr_llm(b, model) for n, b in batch]
# Strip markdown code fences if present
raw_clean = raw raw_clean = raw
if raw_clean.startswith("```"): if raw_clean.startswith("```"):
raw_clean = raw_clean.split("\n", 1)[-1] 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) parsed = json.loads(raw_clean)
results: list[dict] = [] results: list[dict] = []
for item in parsed: for item in parsed:
raw_text = str(item.get("text", "") or "") if sticker_mode:
digit_str = str(item.get("digits") or "") raw_text = str(item.get("machine_id", "") or "")
combined = raw_text + " " + digit_str else:
match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", combined) raw_text = str(item.get("text", "") or "")
match_5plus = re.search(r"(\d{5,})", combined) digit_str = str(item.get("digits") or "")
results.append({ 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, "available": True,
"engine": "llm_batch", "engine": "llm_batch",
"llm_model": model, "llm_model": model,
"raw_text": raw_text[:500], "raw_text": raw_text[:500],
"match_5dash6": match_5dash.group(0) if match_5dash else None, "match_5dash6": match_5dash.group(0) if match_5dash else None,
"match_5plus": match_5plus.group(0) if match_5plus 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 return results
except (json.JSONDecodeError, KeyError, TypeError): except (json.JSONDecodeError, KeyError, TypeError):
# Fallback to individual calls return [run_ocr_llm(b, model, sticker_mode=sticker_mode) for n, b in batch]
return [run_ocr_llm(b, model) 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.""" # Google Gemini OCR
conn = _get_canteen_db() # ---------------------------------------------------------------------------
if not conn: def run_ocr_google(image_bytes: bytes, model: str | None = None, sticker_mode: bool = False) -> dict:
return None """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: try:
row = conn.execute( resp = urllib.request.urlopen(req, timeout=60)
"SELECT id, machine_id, name, category, status, address, building_name, " result = json.loads(resp.read())
"floor, room, latitude, longitude, make, model, description, photo_path " text = result["candidates"][0]["content"]["parts"][0]["text"].strip()
"FROM assets WHERE machine_id = ?", except Exception as exc:
(machine_id.strip(),), result = run_ocr(image_bytes)
).fetchone() result["engine"] = "tesseract"
if row: result["llm_fallback_reason"] = str(exc)[:200]
return { return result
"id": row["id"],
"machine_id": row["machine_id"], match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
"name": row["name"], match_5plus = re.search(r"(\d{5,})", text)
"category": row["category"],
"status": row["status"], out: dict = {
"address": row["address"], "available": True,
"building_name": row["building_name"], "engine": "google",
"floor": row["floor"], "llm_model": model,
"room": row["room"], "raw_text": text[:500],
"latitude": row["latitude"], "match_5dash6": match_5dash.group(0) if match_5dash else None,
"longitude": row["longitude"], "match_5plus": match_5plus.group(0) if match_5plus else None,
"make": row["make"], }
"model": row["model"], if sticker_mode:
"description": row["description"], cm = re.search(r"\b(green|orange|yellow)\b", text, re.I)
"photo_path": row["photo_path"], out["sticker_color"] = cm.group(1).lower() if cm else "unknown"
} return out
finally:
conn.close()
return None
@app.post("/api/analyze") # ---------------------------------------------------------------------------
async def analyze_photo( # Process one photo (shared between analyze + bulk)
file: UploadFile = File(...), # ---------------------------------------------------------------------------
ocr_engine: str = Query(default="tesseract"), def _process_one(orig_filename: str, contents: bytes, ocr_engine: str,
ocr_model: str = Query(default=""), ocr_model: str | None, sticker_mode: bool) -> dict:
): """Run EXIF + OCR on a single photo. Returns result dict."""
"""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) file_size = len(contents)
fhash = _file_hash(contents)
ext = Path(file.filename or "photo.jpg").suffix.lower() # Check dup
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}: dup_row = None
ext = ".jpg" db_conn = _get_photos_db()
fname = f"{uuid.uuid4().hex}{ext}" if db_conn:
(UPLOADS / fname).write_bytes(contents) 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) exif_result = extract_exif(contents)
if ocr_engine == "llm": 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: elif HAS_TESSERACT:
ocr_result = run_ocr(contents) ocr_result = run_ocr(contents)
ocr_result["engine"] = "tesseract" ocr_result["engine"] = "tesseract"
@@ -385,11 +600,20 @@ async def analyze_photo(
machine_id = re.sub(r"\D", "", ocr_result["match_5dash6"])[-5:] machine_id = re.sub(r"\D", "", ocr_result["match_5dash6"])[-5:]
asset = lookup_machine_id(machine_id) 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 { return {
"filename": file.filename, "filename": orig_filename,
"saved_as": fname, "saved_as": saved_name,
"photo_id": photo_id or (dup_row["id"] if dup_row else None),
"file_size": file_size, "file_size": file_size,
"file_size_kb": round(file_size / 1024, 1), "file_size_kb": round(file_size / 1024, 1),
"duplicate": is_dup,
"exif": exif_result, "exif": exif_result,
"ocr": ocr_result, "ocr": ocr_result,
"machine_id": machine_id, "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") @app.post("/api/bulk-process")
async def bulk_process( async def bulk_process(
files: list[UploadFile] = File(...), files: list[UploadFile] = File(...),
ocr_engine: str = Query(default="tesseract"), ocr_engine: str = Query(default="tesseract"),
ocr_model: str = Query(default=""), ocr_model: str = Query(default=""),
sticker_mode: bool = Query(default=False),
): ):
"""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: Query params:
- ocr_engine: ''tesseract'' (default) or ''llm'' - ocr_engine: 'tesseract' (default) or 'llm'
- ocr_model: model name override - ocr_model: model name override
- sticker_mode: if true, uses sticker-specific prompt
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)
@@ -423,30 +674,55 @@ async def bulk_process(
all_files.append((file.filename or "photo.jpg", contents)) all_files.append((file.filename or "photo.jpg", contents))
ocr_use_llm = ocr_engine == "llm" ocr_use_llm = ocr_engine == "llm"
ocr_use_google = ocr_engine == "google"
# Batch OCR if using LLM mode # Batch OCR if using LLM mode
if ocr_use_llm: 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: else:
llm_ocr_results = None llm_ocr_results = None
results = [] 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): for i, (orig_fname, contents) in enumerate(all_files):
file_size = len(contents) file_size = len(contents)
fhash = _file_hash(contents)
# Save # Check dup
ext = Path(orig_fname or "photo.jpg").suffix.lower() dup_row = None
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}: db_conn = _get_photos_db()
ext = ".jpg" if db_conn:
saved_name = f"{uuid.uuid4().hex}{ext}" try:
(UPLOADS / saved_name).write_bytes(contents) 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) exif_result = extract_exif(contents)
if ocr_use_llm: 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"} 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: elif HAS_TESSERACT:
ocr_result = run_ocr(contents) ocr_result = run_ocr(contents)
ocr_result["engine"] = "tesseract" ocr_result["engine"] = "tesseract"
@@ -466,15 +742,26 @@ async def bulk_process(
asset = lookup_machine_id(machine_id) asset = lookup_machine_id(machine_id)
if asset: if asset:
summary["matched"] += 1 summary["matched"] += 1
# Check if asset needs GPS
if (asset["latitude"] is None or asset["longitude"] is None) and has_gps: if (asset["latitude"] is None or asset["longitude"] is None) and has_gps:
needs_gps = True needs_gps = True
summary["needs_gps"] += 1 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({ results.append({
"filename": orig_fname, "filename": orig_fname,
"saved_as": saved_name, "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), "file_size_kb": round(file_size / 1024, 1),
"duplicate": is_dup,
"exif": exif_result, "exif": exif_result,
"ocr": ocr_result, "ocr": ocr_result,
"machine_id": machine_id, "machine_id": machine_id,
@@ -485,6 +772,126 @@ async def bulk_process(
return {"results": results, "summary": summary} 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") @app.get("/api/lookup")
async def lookup_asset(machine_id: str = ""): async def lookup_asset(machine_id: str = ""):
"""Look up an asset by machine_id in the canteen assets database.""" """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") raise HTTPException(500, "Database not available")
try: try:
# Only update if currently NULL
row = conn.execute( row = conn.execute(
"SELECT latitude, longitude FROM assets WHERE id = ?", (int(asset_id),) "SELECT latitude, longitude FROM assets WHERE id = ?", (int(asset_id),)
).fetchone() ).fetchone()
+283 -69
View File
@@ -26,19 +26,17 @@
h1 { font-size: 20px; margin-bottom: 4px; } h1 { font-size: 20px; margin-bottom: 4px; }
.subtitle { font-size: 12px; color: var(--text2); margin-bottom: 16px; } .subtitle { font-size: 12px; color: var(--text2); margin-bottom: 16px; }
/* Upload area */
.upload-area { .upload-area {
border: 2px dashed var(--border); border-radius: var(--radius); border: 2px dashed var(--border); border-radius: var(--radius);
padding: 28px 20px; text-align: center; cursor: pointer; padding: 28px 20px; text-align: center; cursor: pointer;
transition: border-color 0.2s; background: var(--card); 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:active { border-color: var(--accent); background: var(--card2); }
.upload-area .icon { font-size: 36px; display: block; margin-bottom: 6px; } .upload-area .icon { font-size: 36px; display: block; margin-bottom: 6px; }
.upload-area .label { font-size: 15px; font-weight: 600; } .upload-area .label { font-size: 15px; font-weight: 600; }
.upload-area .hint { font-size: 11px; color: var(--text2); margin-top: 4px; } .upload-area .hint { font-size: 11px; color: var(--text2); margin-top: 4px; }
/* Summary bar */
#summary { #summary {
display: none; background: var(--card); border-radius: var(--radius); display: none; background: var(--card); border-radius: var(--radius);
padding: 12px 14px; margin-bottom: 12px; padding: 12px 14px; margin-bottom: 12px;
@@ -53,7 +51,6 @@
.summary-nogps { color: var(--amber); } .summary-nogps { color: var(--amber); }
.summary-total { color: var(--text); } .summary-total { color: var(--text); }
/* Photo grid */
#gallery { #gallery {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
margin-bottom: 12px; margin-bottom: 12px;
@@ -84,7 +81,6 @@
} }
.photo-card.selected .check { display: flex; } .photo-card.selected .check { display: flex; }
/* Detail card */
#detail { #detail {
display: none; background: var(--card); border-radius: var(--radius); display: none; background: var(--card); border-radius: var(--radius);
padding: 14px; margin-bottom: 12px; padding: 14px; margin-bottom: 12px;
@@ -102,6 +98,8 @@
.badge-warn { background: var(--amber); color: #000; } .badge-warn { background: var(--amber); color: #000; }
.badge-fail { background: var(--red); color: #fff; } .badge-fail { background: var(--red); color: #fff; }
.badge-info { background: var(--accent); color: #fff; } .badge-info { background: var(--accent); color: #fff; }
.badge-dup { background: var(--red); color: #fff; }
.badge-sticker { background: #8b5cf6; color: #fff; }
.exif-row { .exif-row {
display: flex; justify-content: space-between; padding: 6px 0; display: flex; justify-content: space-between; padding: 6px 0;
@@ -118,7 +116,6 @@
border-radius: var(--radius-sm); margin: 8px 0; border-radius: var(--radius-sm); margin: 8px 0;
} }
/* Buttons */
.btn { .btn {
display: block; width: 100%; padding: 14px; font-size: 15px; display: block; width: 100%; padding: 14px; font-size: 15px;
font-weight: 600; border: none; border-radius: var(--radius-sm); font-weight: 600; border: none; border-radius: var(--radius-sm);
@@ -132,6 +129,7 @@
.btn:disabled { opacity: 0.4; pointer-events: none; } .btn:disabled { opacity: 0.4; pointer-events: none; }
.btn-row { display: flex; gap: 8px; margin-top: 8px; } .btn-row { display: flex; gap: 8px; margin-top: 8px; }
.btn-sm { padding: 8px 14px; font-size: 12px; width: auto; } .btn-sm { padding: 8px 14px; font-size: 12px; width: auto; }
.btn-xs { padding: 4px 10px; font-size: 11px; width: auto; }
.spinner { .spinner {
display: inline-block; width: 16px; height: 16px; display: inline-block; width: 16px; height: 16px;
@@ -145,7 +143,6 @@
#fileInput { display: none; } #fileInput { display: none; }
/* Filter bar */
#filterBar { #filterBar {
display: none; align-items: center; gap: 6px; margin-bottom: 10px; 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; } .filter-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Asset match card */
.asset-card { .asset-card {
background: var(--card2); border-radius: var(--radius-sm); background: var(--card2); border-radius: var(--radius-sm);
padding: 12px; margin-top: 10px; border-left: 3px solid var(--green); padding: 12px; margin-top: 10px; border-left: 3px solid var(--green);
} }
.asset-card .asset-name { .asset-card .asset-name { font-size: 15px; font-weight: 700; margin-bottom: 4px; }
font-size: 15px; font-weight: 700; margin-bottom: 4px;
}
.asset-card .asset-meta { .asset-card .asset-meta {
font-size: 12px; color: var(--text2); font-size: 12px; color: var(--text2);
display: flex; flex-wrap: wrap; gap: 8px; display: flex; flex-wrap: wrap; gap: 8px;
@@ -174,7 +168,6 @@
white-space: nowrap; white-space: nowrap;
} }
/* Server section */
.section { .section {
background: var(--card); border-radius: var(--radius); background: var(--card); border-radius: var(--radius);
padding: 14px; margin-bottom: 12px; padding: 14px; margin-bottom: 12px;
@@ -187,10 +180,9 @@
margin-top: 6px; margin-top: 6px;
} }
/* OCR engine toggle */
.ocr-toggle { .ocr-toggle {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px; display: flex; align-items: center; gap: 10px; margin-bottom: 8px;
font-size: 12px; color: var(--text2); font-size: 12px; color: var(--text2); flex-wrap: wrap;
} }
.ocr-toggle label { display: flex; align-items: center; gap: 4px; cursor: pointer; } .ocr-toggle label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
.ocr-toggle input[type="checkbox"] { accent-color: var(--accent); } .ocr-toggle input[type="checkbox"] { accent-color: var(--accent); }
@@ -201,7 +193,6 @@
.engine-badge.llm { background: var(--accent); color: #fff; } .engine-badge.llm { background: var(--accent); color: #fff; }
.engine-badge.tesseract { background: var(--card2); color: var(--text2); } .engine-badge.tesseract { background: var(--card2); color: var(--text2); }
/* 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 {
background: var(--card); border-radius: var(--radius-sm); padding: 10px; 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-match { background: var(--border); color: var(--text2); }
.match-badge.no-gps { background: var(--amber); color: #000; } .match-badge.no-gps { background: var(--amber); color: #000; }
.match-badge.has-gps { background: var(--card2); color: var(--text2); } .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;
}
</style> </style>
</head> </head>
<body> <body>
@@ -243,6 +279,23 @@
</div> </div>
<input type="file" id="fileInput" accept="image/*" multiple> <input type="file" id="fileInput" accept="image/*" multiple>
<!-- Options -->
<div class="ocr-toggle" style="margin-bottom:4px;">
<label style="gap:6px;">
<span style="font-size:11px;">OCR Engine:</span>
<select id="ocrEngine" onchange="onOcrToggle()" style="background:var(--card2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:3px 8px;font-size:11px;">
<option value="tesseract">🖥️ Tesseract</option>
<option value="llm" selected>🧠 LLM (mimo-v2-omni)</option>
<option value="google">🌐 Google Gemini</option>
</select>
</label>
<label>
<input type="checkbox" id="stickerToggle" onchange="onOcrToggle()">
🏷️ Sticker Mode
</label>
<span style="font-size:10px;color:var(--text3);" id="modelLabel">mimo-v2-omni</span>
</div>
<!-- Summary stats --> <!-- Summary stats -->
<div id="summary"> <div id="summary">
<div class="summary-grid"> <div class="summary-grid">
@@ -262,7 +315,7 @@
<!-- Photo grid --> <!-- Photo grid -->
<div id="gallery"></div> <div id="gallery"></div>
<!-- Detail card (tap a photo to see) --> <!-- Detail card -->
<div id="detail"> <div id="detail">
<img id="detailPreview" alt=""> <img id="detailPreview" alt="">
<div id="detailExif"></div> <div id="detailExif"></div>
@@ -271,20 +324,12 @@
</div> </div>
</div> </div>
<!-- Server results for single upload --> <!-- Server results -->
<div class="section" id="serverSection" style="display:none;"> <div class="section" id="serverSection" style="display:none;">
<div class="detail-section">🖥️ Server Results <span class="badge badge-info">PIL + OCR</span></div> <div class="detail-section">🖥️ Server Results <span class="badge badge-info">PIL + OCR</span></div>
<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
@@ -303,10 +348,101 @@
<button class="btn btn-outline btn-sm" id="clearBtn" style="display:none;" onclick="resetAll()">🔄 Clear All & Re-select</button> <button class="btn btn-outline btn-sm" id="clearBtn" style="display:none;" onclick="resetAll()">🔄 Clear All & Re-select</button>
</div> </div>
<!-- Previously processed -->
<div class="section" id="prevSection" style="display:none;">
<div class="detail-section">📂 Previously Processed <span class="badge badge-info" id="prevCount">0</span></div>
<div id="prevResults"></div>
</div>
<script> <script>
let allPhotos = []; // {file, exif, hasGps, lat, lng, thumb} let allPhotos = []; // {file, exif, hasGps, lat, lng, thumb}
let selectedIdx = -1; let selectedIdx = -1;
let currentFilter = 'all'; let currentFilter = 'all';
let bulkMap = null;
let bulkData = [];
// On load: fetch previously processed photos
document.addEventListener('DOMContentLoaded', loadPreviousPhotos);
function loadPreviousPhotos() {
fetch('/api/photos?limit=30')
.then(r => r.json())
.then(data => {
if (!data.photos || !data.photos.length) return;
const section = document.getElementById('prevSection');
const div = document.getElementById('prevResults');
section.style.display = 'block';
document.getElementById('prevCount').textContent = data.photos.length;
div.innerHTML = data.photos.map(p => renderPrevCard(p)).join('');
})
.catch(() => {});
}
function renderPrevCard(p) {
const hasMatch = p.ocr_match_5dash6 ? 'matched' : 'no-match';
const matchText = p.ocr_match_5dash6 || (p.ocr_match_5plus ? 'digits found' : 'no match');
const engine = p.ocr_engine || 'tesseract';
const engCls = engine === 'llm' || engine === 'llm_batch' ? 'llm' : 'tesseract';
const time = p.created_at ? new Date(p.created_at + 'Z').toLocaleString() : '';
const colorHtml = p.sticker_color
? `<span class="sticker-color" style="background:${p.sticker_color === 'green' ? 'var(--green)' : p.sticker_color === 'orange' ? 'var(--amber)' : p.sticker_color === 'yellow' ? '#eab308' : 'var(--text3)'}"></span>`
: '';
return `<div class="prev-photo-card">
<div class="prev-header">
<span class="prev-filename">${esc(p.orig_filename)}</span>
<span class="prev-time">${time}</span>
</div>
<div class="prev-details">
Machine: <strong>${p.machine_id ? esc(p.machine_id) : '—'}</strong>
<span class="match-badge ${hasMatch}">${esc(matchText)}</span>
<span class="engine-badge ${engCls}">${esc(engine)}${p.ocr_model ? ' ' + esc(p.ocr_model) : ''}</span>
${colorHtml}
</div>
<div class="prev-actions">
<select id="reEng${p.id}">
<option value="tesseract">Tesseract</option>
<option value="llm" ${engine.startsWith('llm') ? 'selected' : ''}>LLM</option>
<option value="google" ${engine === 'google' ? 'selected' : ''}>🌐 Google</option>
</select>
<select id="reModel${p.id}">
<option value="">default</option>
<option value="mimo-v2-omni">mimo-v2-omni</option>
<option value="mimo-v2-pro">mimo-v2-pro</option>
<option value="kimi-k2.5">kimi-k2.5</option>
<option value="glm-5.1">glm-5.1</option>
<option value="gemini-2.5-flash">gemini-2.5-flash</option>
</select>
<label><input type="checkbox" id="reSticker${p.id}"> 🏷️</label>
<button class="btn btn-primary btn-xs" onclick="reprocessPhoto(${p.id})">🔄 Re-run</button>
</div>
</div>`;
}
async function reprocessPhoto(photoId) {
const eng = document.getElementById('reEng' + photoId).value;
const model = document.getElementById('reModel' + photoId).value;
const sticker = document.getElementById('reSticker' + photoId).checked;
let params = '?ocr_engine=' + eng;
if (model) params += '&ocr_model=' + model;
if (sticker) params += '&sticker_mode=true';
const card = document.querySelector(`#reEng${photoId}`).closest('.prev-photo-card');
const actions = card.querySelector('.prev-actions');
actions.innerHTML = '<span class="spinner" style="width:14px;height:14px;"></span> Reprocessing...';
try {
const resp = await fetch(`/api/photos/${photoId}/reprocess${params}`, { method: 'POST' });
const data = await resp.json();
// Refresh the card
const updated = { ...data, orig_filename: card.querySelector('.prev-filename')?.textContent || 'Photo' };
// Just reload the full list
loadPreviousPhotos();
} catch (e) {
actions.innerHTML = '<span style="color:var(--red);font-size:11px;">❌ Failed</span>';
}
}
// --- File handling ---
document.getElementById('fileInput').addEventListener('change', async function(e) { document.getElementById('fileInput').addEventListener('change', async function(e) {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
@@ -316,7 +452,6 @@ document.getElementById('fileInput').addEventListener('change', async function(e
document.getElementById('filterBar').style.display = 'flex'; document.getElementById('filterBar').style.display = 'flex';
document.getElementById('clearBtn').style.display = 'block'; document.getElementById('clearBtn').style.display = 'block';
// Show scanning state
document.getElementById('gallery').innerHTML = files.map((_, i) => document.getElementById('gallery').innerHTML = files.map((_, i) =>
'<div class="photo-card" id="card' + i + '">' + '<div class="photo-card" id="card' + i + '">' +
'<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text3);font-size:24px;">' + '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text3);font-size:24px;">' +
@@ -326,7 +461,6 @@ document.getElementById('fileInput').addEventListener('change', async function(e
'</div>' '</div>'
).join(''); ).join('');
// Scan in small batches — iOS Safari can't handle many concurrent file reads
const results = []; const results = [];
const batchSize = 2; const batchSize = 2;
for (let i = 0; i < files.length; i += batchSize) { for (let i = 0; i < files.length; i += batchSize) {
@@ -342,12 +476,7 @@ document.getElementById('fileInput').addEventListener('change', async function(e
async function scanPhoto(file) { async function scanPhoto(file) {
const result = { file, exif: null, hasGps: false, lat: null, lng: null, thumb: null }; const result = { file, exif: null, hasGps: false, lat: null, lng: null, thumb: null };
// Use object URL instead of readAsDataURL — avoids loading the entire file
// into memory as a base64 string (the main cause of iOS hangs with many photos)
result.thumb = URL.createObjectURL(file); result.thumb = URL.createObjectURL(file);
// Read EXIF
try { try {
const exif = await exifr.parse(file); const exif = await exifr.parse(file);
if (exif && Object.keys(exif).length > 0) { if (exif && Object.keys(exif).length > 0) {
@@ -361,8 +490,7 @@ async function scanPhoto(file) {
result.lng = exif.longitude; result.lng = exif.longitude;
} }
} }
} catch (_) { /* EXIF is best-effort */ } } catch (_) {}
return result; return result;
} }
@@ -373,7 +501,6 @@ function updateSummary() {
document.getElementById('sumGps').textContent = gps; document.getElementById('sumGps').textContent = gps;
document.getElementById('sumNoGps').textContent = nogps; document.getElementById('sumNoGps').textContent = nogps;
document.getElementById('summary').style.display = 'block'; document.getElementById('summary').style.display = 'block';
// Show bulk button if we have GPS photos
document.getElementById('bulkBtn').style.display = gps > 0 ? 'block' : 'none'; document.getElementById('bulkBtn').style.display = gps > 0 ? 'block' : 'none';
} }
@@ -412,7 +539,7 @@ function showDetail(idx) {
const p = allPhotos[idx]; const p = allPhotos[idx];
if (!p) return; if (!p) return;
renderGallery(); // update selection highlights renderGallery();
const detail = document.getElementById('detail'); const detail = document.getElementById('detail');
detail.style.display = 'block'; detail.style.display = 'block';
@@ -466,6 +593,9 @@ async function uploadSelected() {
const data = await resp.json(); const data = await resp.json();
let html = ''; let html = '';
if (data.duplicate) {
html += '<div style="margin-bottom:8px;"><span class="match-badge dup">♻️ Already processed — skipped</span></div>';
}
html += '<div class="exif-row"><span class="exif-key">Saved as</span><span class="exif-val">' + esc(data.saved_as) + '</span></div>'; html += '<div class="exif-row"><span class="exif-key">Saved as</span><span class="exif-val">' + esc(data.saved_as) + '</span></div>';
html += '<div class="exif-row"><span class="exif-key">File size</span><span class="exif-val">' + data.file_size_kb + ' KB</span></div>'; html += '<div class="exif-row"><span class="exif-key">File size</span><span class="exif-val">' + data.file_size_kb + ' KB</span></div>';
@@ -478,7 +608,6 @@ async function uploadSelected() {
html = '<div class="gps-coords" style="color:var(--red);margin:8px 0;">❌ Server found no EXIF — stripped during upload</div>' + html; html = '<div class="gps-coords" style="color:var(--red);margin:8px 0;">❌ Server found no EXIF — stripped during upload</div>' + html;
} }
// EXIF tags
if (exif.has_exif) { if (exif.has_exif) {
for (const [k, v] of Object.entries(exif.tags).slice(0, 8)) { for (const [k, v] of Object.entries(exif.tags).slice(0, 8)) {
html += '<div class="exif-row"><span class="exif-key">' + esc(k) + '</span><span class="exif-val">' + esc(v) + '</span></div>'; html += '<div class="exif-row"><span class="exif-key">' + esc(k) + '</span><span class="exif-val">' + esc(v) + '</span></div>';
@@ -488,14 +617,17 @@ async function uploadSelected() {
// OCR // OCR
const ocr = data.ocr; const ocr = data.ocr;
const engineCls = ocr.engine === 'llm' || ocr.engine === 'llm_batch' ? 'llm' : 'tesseract'; const engineCls = ocr.engine === 'llm' || ocr.engine === 'llm_batch' ? 'llm' : 'tesseract';
html += '<div style="margin-top:10px;font-weight:600;">🔤 OCR <span class="engine-badge ' + engineCls + '">' + esc(ocr.engine || 'tesseract') + (ocr.llm_model ? ' ' + esc(ocr.llm_model) : '') + '</span></div>'; let ocrBadge = '<span class="engine-badge ' + engineCls + '">' + esc(ocr.engine || 'tesseract');
if (ocr.llm_model) ocrBadge += ' ' + esc(ocr.llm_model);
if (ocr.sticker_color) ocrBadge += ' 🏷️' + esc(ocr.sticker_color);
ocrBadge += '</span>';
html += '<div style="margin-top:10px;font-weight:600;">🔤 OCR ' + ocrBadge + '</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
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>';
@@ -503,12 +635,96 @@ async function uploadSelected() {
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>';
} }
// Re-run controls
html += '<div class="reprocess-row">' +
'<select id="reRunEng">' +
'<option value="tesseract">Tesseract</option>' +
'<option value="llm" ' + (ocr.engine === 'llm' || ocr.engine === 'llm_batch' ? 'selected' : '') + '>LLM</option>' +
'<option value="google"' + (ocr.engine === 'google' ? 'selected' : '') + '>🌐 Google</option>' +
'</select>' +
'<select id="reRunModel">' +
'<option value="">default</option>' +
'<option value="mimo-v2-omni">mimo-v2-omni</option>' +
'<option value="kimi-k2.5">kimi-k2.5</option>' +
'<option value="glm-5.1">glm-5.1</option>' +
'<option value="gemini-2.5-flash">gemini-2.5-flash</option>' +
'</select>' +
'<label style="font-size:10px;"><input type="checkbox" id="reRunSticker"> 🏷️</label>' +
'<button class="btn btn-primary btn-xs" onclick="reprocessCurrent()">🔄 Re-run</button>' +
'</div>';
// Manual entry when GPS exists but OCR failed
if ((!ocr.match_5dash6 && !ocr.match_5plus) && data.exif && data.exif.gps) {
html += '<div style="margin-top:8px;padding-top:8px;border-top:1px solid var(--border);">' +
'<div style="font-size:11px;color:var(--text2);margin-bottom:4px;">✏️ GPS found but no machine ID in OCR. Enter it manually:</div>' +
'<div style="display:flex;gap:6px;">' +
'<input type="text" id="manualMid" placeholder="e.g. 12345-678901" style="flex:1;background:var(--card2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px;font-size:13px;">' +
'<button class="btn btn-primary btn-xs" style="width:auto;white-space:nowrap;" onclick="manualLookup()">🔍 Lookup</button>' +
'</div>' +
'<div id="manualResult" style="margin-top:4px;"></div>' +
'</div>';
}
div.innerHTML = html; div.innerHTML = html;
} catch (e) { } catch (e) {
div.innerHTML = '<div class="empty">❌ Upload failed: ' + esc(e.message) + '</div>'; div.innerHTML = '<div class="empty">❌ Upload failed: ' + esc(e.message) + '</div>';
} }
} }
function manualLookup() {
const input = document.getElementById('manualMid');
const resultDiv = document.getElementById('manualResult');
const val = input.value.trim();
if (!val) { resultDiv.innerHTML = '<span style="color:var(--amber);font-size:11px;">Enter a machine ID</span>'; return; }
resultDiv.innerHTML = '<span class="spinner" style="width:12px;height:12px;"></span>';
fetch('/api/lookup?machine_id=' + encodeURIComponent(val))
.then(r => r.json())
.then(data => {
if (data.found) {
const a = data.asset;
resultDiv.innerHTML = '<div class="asset-card" style="margin-top:0;">' +
'<div class="asset-name">' + esc(a.name) + '</div>' +
'<div class="asset-meta"><span>🆔 ' + esc(a.machine_id) + '</span><span>📦 ' + esc(a.category) + '</span>' +
(a.status === 'active' ? '🟢 Active' : '⚪ ' + esc(a.status)) +
'</div></div>';
} else {
resultDiv.innerHTML = '<span style="color:var(--amber);font-size:11px;">⚠️ No asset found for <strong>' + esc(val) + '</strong></span>';
}
})
.catch(e => {
resultDiv.innerHTML = '<span style="color:var(--red);font-size:11px;">❌ Error: ' + esc(e.message) + '</span>';
});
}
async function reprocessCurrent() {
const eng = document.getElementById('reRunEng').value;
const model = document.getElementById('reRunModel').value;
const sticker = document.getElementById('reRunSticker').checked;
// Need the photo_id from the last upload. Get it from server results.
// We'll just re-upload the same file with new params
if (selectedIdx < 0 || !allPhotos[selectedIdx]) return;
const file = allPhotos[selectedIdx].file;
const div = document.getElementById('serverResults');
div.innerHTML += '<div style="text-align:center;padding:8px;"><span class="spinner"></span> Reprocessing...</div>';
const fd = new FormData();
fd.append('file', file, file.name || 'photo.jpg');
let params = '?ocr_engine=' + eng;
if (model) params += '&ocr_model=' + model;
if (sticker) params += '&sticker_mode=true';
try {
const resp = await fetch('/api/analyze' + params, { method: 'POST', body: fd });
const data = await resp.json();
// Just reload the whole result
uploadSelected();
} catch (e) {
div.innerHTML += '<div class="empty">❌ Re-run failed: ' + esc(e.message) + '</div>';
}
}
async function lookupAsset(machineId) { async function lookupAsset(machineId) {
const div = document.getElementById('serverResults'); const div = document.getElementById('serverResults');
try { try {
@@ -545,9 +761,6 @@ async function lookupAsset(machineId) {
} }
} }
let bulkMap = null;
let bulkData = [];
async function startBulkProcess() { async function startBulkProcess() {
const gpsPhotos = allPhotos.filter(p => p.hasGps); const gpsPhotos = allPhotos.filter(p => p.hasGps);
if (!gpsPhotos.length) return; if (!gpsPhotos.length) return;
@@ -556,7 +769,6 @@ async function startBulkProcess() {
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Processing ' + gpsPhotos.length + ' photos...'; btn.innerHTML = '<span class="spinner"></span> Processing ' + gpsPhotos.length + ' photos...';
// Build FormData with all GPS photos
const fd = new FormData(); const fd = new FormData();
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'));
@@ -564,7 +776,6 @@ async function startBulkProcess() {
const resp = await fetch('/api/bulk-process' + getOcrParams(), { 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
bulkData = data.results.map((r, i) => ({ bulkData = data.results.map((r, i) => ({
...r, ...r,
thumb: gpsPhotos[i]?.thumb || null, thumb: gpsPhotos[i]?.thumb || null,
@@ -586,13 +797,12 @@ function renderBulkResults(summary) {
const section = document.getElementById('bulkSection'); const section = document.getElementById('bulkSection');
section.style.display = 'block'; section.style.display = 'block';
// Summary badge
document.getElementById('bulkSummary').textContent = document.getElementById('bulkSummary').textContent =
summary.matched + ' matched, ' + summary.needs_gps + ' need GPS'; summary.matched + ' matched, ' + summary.needs_gps + ' need GPS' +
(summary.duplicates ? ', ' + summary.duplicates + ' skipped' : '');
document.getElementById('bulkSummary').className = document.getElementById('bulkSummary').className =
'match-badge ' + (summary.matched > 0 ? 'matched' : 'no-match'); 'match-badge ' + (summary.matched > 0 ? 'matched' : 'no-match');
// Init map
const mapDiv = document.getElementById('mapContainer'); const mapDiv = document.getElementById('mapContainer');
mapDiv.style.display = 'block'; mapDiv.style.display = 'block';
if (bulkMap) bulkMap.remove(); if (bulkMap) bulkMap.remove();
@@ -610,7 +820,6 @@ function renderBulkResults(summary) {
const gpsLat = hasGps ? item.exif.gps.lat : null; const gpsLat = hasGps ? item.exif.gps.lat : null;
const gpsLng = hasGps ? item.exif.gps.lng : null; const gpsLng = hasGps ? item.exif.gps.lng : null;
// Map marker for every GPS photo
if (gpsLat && gpsLng) { if (gpsLat && gpsLng) {
const marker = L.marker([gpsLat, gpsLng]) const marker = L.marker([gpsLat, gpsLng])
.addTo(bulkMap) .addTo(bulkMap)
@@ -622,21 +831,17 @@ function renderBulkResults(summary) {
markers.push([gpsLat, gpsLng]); markers.push([gpsLat, gpsLng]);
} }
// Asset marker if it already has GPS in DB
if (asset && asset.latitude && asset.longitude) { if (asset && asset.latitude && asset.longitude) {
L.marker([asset.latitude, asset.longitude], { L.marker([asset.latitude, asset.longitude], {
icon: L.divIcon({ icon: L.divIcon({
className: 'cat-pin-icon', className: 'cat-pin-icon',
html: '<div style="width:22px;height:22px;border-radius:50%;background:#3b82f6;' + html: '<div style="width:22px;height:22px;border-radius:50%;background:#3b82f6;display:flex;align-items:center;justify-content:center;font-size:11px;box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">🏠</div>',
'display:flex;align-items:center;justify-content:center;font-size:11px;' +
'box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">🏠</div>',
iconSize: [24, 24], iconAnchor: [12, 12] iconSize: [24, 24], iconAnchor: [12, 12]
}) })
}).addTo(bulkMap) }).addTo(bulkMap)
.bindPopup('<b>' + esc(asset.name) + '</b><br>Existing location'); .bindPopup('<b>' + esc(asset.name) + '</b><br>Existing location');
} }
// Card
const isGpsReady = item.needs_gps; const isGpsReady = item.needs_gps;
html += '<div class="bulk-card' + (isGpsReady ? ' gps-ready' : '') + '">'; html += '<div class="bulk-card' + (isGpsReady ? ' gps-ready' : '') + '">';
@@ -646,7 +851,10 @@ function renderBulkResults(summary) {
html += '<div class="bulk-info">'; html += '<div class="bulk-info">';
if (asset) { if (item.duplicate) {
html += '<div class="bulk-name">' + esc(item.filename || 'Photo') + '</div>';
html += '<div class="bulk-meta"><span class="match-badge dup">♻️ Already processed</span></div>';
} else if (asset) {
html += '<div class="bulk-name">' + esc(asset.name) + '</div>'; html += '<div class="bulk-name">' + esc(asset.name) + '</div>';
html += '<div class="bulk-meta">'; html += '<div class="bulk-meta">';
html += '<span class="match-badge matched">🆔 ' + esc(asset.machine_id) + '</span> '; html += '<span class="match-badge matched">🆔 ' + esc(asset.machine_id) + '</span> ';
@@ -668,9 +876,8 @@ function renderBulkResults(summary) {
html += '<div class="bulk-meta"><span class="match-badge no-match">No OCR match</span></div>'; html += '<div class="bulk-meta"><span class="match-badge no-match">No OCR match</span></div>';
} }
html += '</div>'; // bulk-info html += '</div>';
// Push button
html += '<div class="bulk-action">'; html += '<div class="bulk-action">';
if (isGpsReady) { if (isGpsReady) {
html += '<button class="btn-push" id="pushBtn' + i + '" onclick="pushGpsToAsset(' + i + ')">📤 Push GPS</button>'; html += '<button class="btn-push" id="pushBtn' + i + '" onclick="pushGpsToAsset(' + i + ')">📤 Push GPS</button>';
@@ -679,20 +886,17 @@ function renderBulkResults(summary) {
} }
html += '</div>'; html += '</div>';
html += '</div>'; // bulk-card html += '</div>';
}); });
document.getElementById('bulkResults').innerHTML = html; document.getElementById('bulkResults').innerHTML = html;
// Fit map to markers
if (markers.length) { if (markers.length) {
const bounds = L.latLngBounds(markers); const bounds = L.latLngBounds(markers);
bulkMap.fitBounds(bounds, { padding: [30, 30], maxZoom: 15 }); bulkMap.fitBounds(bounds, { padding: [30, 30], maxZoom: 15 });
} }
// Invalidate map size after display
setTimeout(() => { if (bulkMap) bulkMap.invalidateSize(); }, 200); setTimeout(() => { if (bulkMap) bulkMap.invalidateSize(); }, 200);
section.scrollIntoView({ behavior: 'smooth' }); section.scrollIntoView({ behavior: 'smooth' });
} }
@@ -719,14 +923,11 @@ async function pushGpsToAsset(idx) {
if (data.updated) { if (data.updated) {
btn.className = 'btn-push done'; btn.className = 'btn-push done';
btn.textContent = '✓ Pushed!'; btn.textContent = '✓ Pushed!';
// Add marker to map for pushed location
if (bulkMap) { if (bulkMap) {
L.marker([gps.lat, gps.lng], { L.marker([gps.lat, gps.lng], {
icon: L.divIcon({ icon: L.divIcon({
className: 'cat-pin-icon', className: 'cat-pin-icon',
html: '<div style="width:22px;height:22px;border-radius:50%;background:#22c55e;' + html: '<div style="width:22px;height:22px;border-radius:50%;background:#22c55e;display:flex;align-items:center;justify-content:center;font-size:11px;box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">✓</div>',
'display:flex;align-items:center;justify-content:center;font-size:11px;' +
'box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">✓</div>',
iconSize: [24, 24], iconAnchor: [12, 12] iconSize: [24, 24], iconAnchor: [12, 12]
}) })
}).addTo(bulkMap) }).addTo(bulkMap)
@@ -744,7 +945,6 @@ async function pushGpsToAsset(idx) {
} }
function resetAll() { function resetAll() {
// Revoke all blob URLs to prevent memory leaks
allPhotos.forEach(p => { allPhotos.forEach(p => {
if (p.thumb && p.thumb.startsWith('blob:')) { if (p.thumb && p.thumb.startsWith('blob:')) {
URL.revokeObjectURL(p.thumb); URL.revokeObjectURL(p.thumb);
@@ -774,10 +974,24 @@ function esc(s) {
return d.innerHTML; return d.innerHTML;
} }
function onOcrToggle() {
const engine = document.getElementById('ocrEngine').value;
const sticker = document.getElementById('stickerToggle').checked;
const label = document.getElementById('modelLabel');
if (engine === 'llm' && sticker) label.textContent = '🏷️ sticker mode';
else if (engine === 'llm') label.textContent = 'mimo-v2-omni';
else if (engine === 'google' && sticker) label.textContent = '🏷️ gemini sticker';
else if (engine === 'google') label.textContent = 'gemini-2.5-flash';
else label.textContent = 'tesseract';
}
function getOcrParams() { function getOcrParams() {
const useLlm = document.getElementById('ocrLlmToggle').checked; const engine = document.getElementById('ocrEngine').value;
if (!useLlm) return ''; const sticker = document.getElementById('stickerToggle').checked;
return '?ocr_engine=llm'; let params = [];
if (engine !== 'tesseract') params.push('ocr_engine=' + engine);
if (sticker) params.push('sticker_mode=true');
return params.length ? '?' + params.join('&') : '';
} }
function formatSize(bytes) { function formatSize(bytes) {