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:
Binary file not shown.
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user