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:
@@ -1,8 +1,9 @@
|
||||
"""EXIF + OCR test backend — validate that GPS survives upload pipeline."""
|
||||
import io, json, os, re, uuid, sqlite3, urllib.request
|
||||
import hashlib, io, json, os, re, uuid, sqlite3, urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from PIL import Image as PILImage
|
||||
|
||||
@@ -25,13 +26,148 @@ OPENCODE_GO_KEY = os.environ.get("OPENCODE_GO_API_KEY", "")
|
||||
OPENCODE_GO_BASE = os.environ.get("OPENCODE_GO_BASE_URL", "https://opencode.ai/zen/go/v1")
|
||||
LLM_OCR_MODEL = os.environ.get("LLM_OCR_MODEL", "mimo-v2-omni")
|
||||
|
||||
# === Google Gemini OCR (free tier) ===
|
||||
GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "")
|
||||
GOOGLE_API_BASE = os.environ.get("GOOGLE_API_BASE_URL", "https://generativelanguage.googleapis.com/v1beta")
|
||||
GOOGLE_OCR_MODEL = os.environ.get("GOOGLE_OCR_MODEL", "gemini-2.5-flash")
|
||||
|
||||
UPLOADS = Path(__file__).parent / "uploads"
|
||||
UPLOADS.mkdir(exist_ok=True)
|
||||
PHOTOS_DB = Path(__file__).parent / "photos.db"
|
||||
CANTEEN_DB = Path(__file__).parent.parent / "canteen-asset-tracker" / "assets.db"
|
||||
|
||||
# Max images per batch API call
|
||||
BATCH_SIZE_LIMIT = 20
|
||||
# Downscale images to this max dimension before LLM OCR
|
||||
LLM_IMAGE_MAX_DIM = 1600
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sticker-mode prompts
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_OCR_PROMPT = (
|
||||
"Read ALL text and numbers visible in this photo. "
|
||||
"Return the exact text shown, nothing else."
|
||||
)
|
||||
|
||||
STICKER_OCR_PROMPT = (
|
||||
"This photo shows a colored equipment sticker (green, orange, or yellow background) "
|
||||
"with a 2D barcode and a machine ID number printed below the barcode. "
|
||||
"Read ONLY the machine ID number that appears below the barcode. "
|
||||
"It is typically a 5-digit number followed by a dash and 6 more digits "
|
||||
"(e.g., 12345-678901). Return ONLY the machine ID number, nothing else."
|
||||
)
|
||||
|
||||
DEFAULT_BATCH_PROMPT = (
|
||||
"I have {n} photos. For EACH photo, read ALL visible text and numbers.\n"
|
||||
'Respond with a JSON array of objects, one per photo in order:\n'
|
||||
'[{{"i":0,"text":"all text and digits found","digits":"e.g. 12345-678901 or null if none"}}, ...]\n'
|
||||
'Return ONLY the JSON array, no markdown, no explanation.'
|
||||
)
|
||||
|
||||
STICKER_BATCH_PROMPT = (
|
||||
"I have {n} photos of colored equipment stickers (green, orange, or yellow). "
|
||||
"Each sticker has a 2D barcode and a machine ID printed below it.\n"
|
||||
'Respond with a JSON array of objects, one per photo in order:\n'
|
||||
'[{{"i":0,"sticker_color":"green/orange/yellow/unknown","machine_id":"12345-678901 or null if not found"}}, ...]\n'
|
||||
'Return ONLY the JSON array, no markdown, no explanation.'
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App + DB init
|
||||
# ---------------------------------------------------------------------------
|
||||
app = FastAPI(title="EXIF Test")
|
||||
|
||||
|
||||
def _init_photos_db():
|
||||
"""Create photos table for persistence + dedup."""
|
||||
conn = sqlite3.connect(str(PHOTOS_DB))
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
orig_filename TEXT NOT NULL,
|
||||
file_hash TEXT NOT NULL UNIQUE,
|
||||
saved_as TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
exif_json TEXT,
|
||||
gps_lat REAL,
|
||||
gps_lng REAL,
|
||||
ocr_engine TEXT,
|
||||
ocr_model TEXT,
|
||||
ocr_raw_text TEXT,
|
||||
ocr_match_5dash6 TEXT,
|
||||
ocr_match_5plus TEXT,
|
||||
machine_id TEXT,
|
||||
sticker_color TEXT,
|
||||
has_barcode INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
_init_photos_db()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _get_photos_db() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(PHOTOS_DB))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _file_hash(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _save_photo_to_db(
|
||||
orig_filename: str, file_hash: str, saved_as: str, file_size: int,
|
||||
exif_result: dict, ocr_result: dict, machine_id: str | None,
|
||||
) -> int:
|
||||
"""Insert a photo record. Returns the row id."""
|
||||
conn = _get_photos_db()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT OR IGNORE INTO photos
|
||||
(orig_filename, file_hash, saved_as, file_size, exif_json,
|
||||
gps_lat, gps_lng, ocr_engine, ocr_model, ocr_raw_text,
|
||||
ocr_match_5dash6, ocr_match_5plus, machine_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
orig_filename,
|
||||
file_hash,
|
||||
saved_as,
|
||||
file_size,
|
||||
json.dumps(exif_result) if exif_result else None,
|
||||
exif_result.get("gps", {}).get("lat") if exif_result and exif_result.get("gps") else None,
|
||||
exif_result.get("gps", {}).get("lng") if exif_result and exif_result.get("gps") else None,
|
||||
ocr_result.get("engine") if ocr_result else None,
|
||||
ocr_result.get("llm_model") if ocr_result else None,
|
||||
ocr_result.get("raw_text") if ocr_result else None,
|
||||
ocr_result.get("match_5dash6") if ocr_result else None,
|
||||
ocr_result.get("match_5plus") if ocr_result else None,
|
||||
machine_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid or 0
|
||||
except sqlite3.IntegrityError:
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _row_to_dict(row: sqlite3.Row) -> dict:
|
||||
d = dict(row)
|
||||
d["exif_data"] = json.loads(d.pop("exif_json", "{}") or "{}")
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canteen DB helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _get_canteen_db():
|
||||
"""Get a read/write connection to the canteen assets database."""
|
||||
if not CANTEEN_DB.exists():
|
||||
@@ -41,6 +177,44 @@ def _get_canteen_db():
|
||||
return conn
|
||||
|
||||
|
||||
def lookup_machine_id(machine_id: str) -> dict | None:
|
||||
"""Look up an asset by machine_id. Returns asset dict or None."""
|
||||
conn = _get_canteen_db()
|
||||
if not conn:
|
||||
return None
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id, machine_id, name, category, status, address, building_name, "
|
||||
"floor, room, latitude, longitude, make, model, description, photo_path "
|
||||
"FROM assets WHERE machine_id = ?",
|
||||
(machine_id.strip(),),
|
||||
).fetchone()
|
||||
if row:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"machine_id": row["machine_id"],
|
||||
"name": row["name"],
|
||||
"category": row["category"],
|
||||
"status": row["status"],
|
||||
"address": row["address"],
|
||||
"building_name": row["building_name"],
|
||||
"floor": row["floor"],
|
||||
"room": row["room"],
|
||||
"latitude": row["latitude"],
|
||||
"longitude": row["longitude"],
|
||||
"make": row["make"],
|
||||
"model": row["model"],
|
||||
"description": row["description"],
|
||||
"photo_path": row["photo_path"],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EXIF extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
def _dms_to_decimal(dms, ref):
|
||||
"""Convert EXIF DMS tuple to decimal degrees."""
|
||||
try:
|
||||
@@ -96,6 +270,9 @@ def extract_exif(image_bytes: bytes) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tesseract OCR
|
||||
# ---------------------------------------------------------------------------
|
||||
def run_ocr(image_bytes: bytes) -> dict:
|
||||
"""Run Tesseract OCR on the image."""
|
||||
if not HAS_TESSERACT:
|
||||
@@ -119,12 +296,9 @@ def run_ocr(image_bytes: bytes) -> dict:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# Maximum images per batch API call (prevents context limit issues)
|
||||
BATCH_SIZE_LIMIT = 20
|
||||
# Downscale images to this max dimension before sending to LLM (saves image tokens)
|
||||
LLM_IMAGE_MAX_DIM = 1600
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM OCR helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _resize_for_llm(image_bytes: bytes, max_dim: int = LLM_IMAGE_MAX_DIM) -> bytes:
|
||||
"""Downscale image to max_dim on longest edge to save vision API costs."""
|
||||
img = PILImage.open(io.BytesIO(image_bytes))
|
||||
@@ -136,7 +310,6 @@ def _resize_for_llm(image_bytes: bytes, max_dim: int = LLM_IMAGE_MAX_DIM) -> byt
|
||||
img_resized = img.resize(new_size, PILImage.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
ext = img.format or "JPEG"
|
||||
# Convert PNG, WebP, etc. to JPEG for smaller size
|
||||
if ext.upper() in ("PNG", "WEBP", "TIFF", "BMP"):
|
||||
img_resized = img_resized.convert("RGB")
|
||||
ext = "JPEG"
|
||||
@@ -144,11 +317,14 @@ def _resize_for_llm(image_bytes: bytes, max_dim: int = LLM_IMAGE_MAX_DIM) -> byt
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> dict:
|
||||
def run_ocr_llm(
|
||||
image_bytes: bytes, model: str | None = None,
|
||||
sticker_mode: bool = False,
|
||||
) -> dict:
|
||||
"""Run OCR via an LLM vision model on OpenCode Go.
|
||||
|
||||
Falls back to Tesseract if the API key is missing or the call fails.
|
||||
Returns the same shape as run_ocr() with an additional ''engine'' field.
|
||||
Returns the same shape as run_ocr() with an additional 'engine' field.
|
||||
"""
|
||||
if not OPENCODE_GO_KEY:
|
||||
result = run_ocr(image_bytes)
|
||||
@@ -159,17 +335,17 @@ def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> dict:
|
||||
import base64
|
||||
|
||||
model = model or LLM_OCR_MODEL
|
||||
# Downscale to save costs
|
||||
image_bytes = _resize_for_llm(image_bytes)
|
||||
b64 = base64.b64encode(image_bytes).decode()
|
||||
|
||||
prompt = STICKER_OCR_PROMPT if sticker_mode else DEFAULT_OCR_PROMPT
|
||||
|
||||
body = json.dumps({
|
||||
"model": model,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Read ALL text and numbers visible in this photo. "
|
||||
"Return the exact text shown, nothing else."},
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
|
||||
]
|
||||
}],
|
||||
@@ -191,7 +367,6 @@ def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> dict:
|
||||
result = json.loads(resp.read())
|
||||
text = result["choices"][0]["message"]["content"].strip()
|
||||
except Exception as exc:
|
||||
# Fallback to Tesseract
|
||||
result = run_ocr(image_bytes)
|
||||
result["engine"] = "tesseract"
|
||||
result["llm_fallback_reason"] = str(exc)[:200]
|
||||
@@ -200,7 +375,7 @@ def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> dict:
|
||||
match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
|
||||
match_5plus = re.search(r"(\d{5,})", text)
|
||||
|
||||
return {
|
||||
out = {
|
||||
"available": True,
|
||||
"engine": "llm",
|
||||
"llm_model": model,
|
||||
@@ -208,17 +383,20 @@ def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> dict:
|
||||
"match_5dash6": match_5dash.group(0) if match_5dash else None,
|
||||
"match_5plus": match_5plus.group(0) if match_5plus else None,
|
||||
}
|
||||
if sticker_mode:
|
||||
# Try to detect sticker color from the response
|
||||
color_match = re.search(r"\b(green|orange|yellow)\b", text, re.I)
|
||||
out["sticker_color"] = color_match.group(1).lower() if color_match else "unknown"
|
||||
return out
|
||||
|
||||
|
||||
def run_ocr_llm_batch(images: list[tuple[str, bytes]], model: str | None = None, max_per_batch: int = BATCH_SIZE_LIMIT) -> list[dict]:
|
||||
"""Batch OCR multiple images in a single LLM API call.
|
||||
|
||||
Sends all images in one request with a structured JSON prompt.
|
||||
Returns list of OCR results in the same order as the input images.
|
||||
Falls back to individual calls if the batch call fails.
|
||||
|
||||
images: list of (filename, image_bytes)
|
||||
"""
|
||||
def run_ocr_llm_batch(
|
||||
images: list[tuple[str, bytes]],
|
||||
model: str | None = None,
|
||||
max_per_batch: int = BATCH_SIZE_LIMIT,
|
||||
sticker_mode: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Batch OCR multiple images in a single LLM API call."""
|
||||
import base64
|
||||
|
||||
if not OPENCODE_GO_KEY:
|
||||
@@ -226,31 +404,25 @@ def run_ocr_llm_batch(images: list[tuple[str, bytes]], model: str | None = None,
|
||||
|
||||
model = model or LLM_OCR_MODEL
|
||||
|
||||
# Split into sub-batches if over the limit
|
||||
all_results: list[dict] = []
|
||||
for batch_start in range(0, len(images), max_per_batch):
|
||||
batch = images[batch_start:batch_start + max_per_batch]
|
||||
batch_results = _run_ocr_llm_batch_inner(batch, model)
|
||||
batch_results = _run_ocr_llm_batch_inner(batch, model, sticker_mode)
|
||||
all_results.extend(batch_results)
|
||||
return all_results
|
||||
|
||||
|
||||
def _run_ocr_llm_batch_inner(batch: list[tuple[str, bytes]], model: str) -> list[dict]:
|
||||
def _run_ocr_llm_batch_inner(
|
||||
batch: list[tuple[str, bytes]], model: str,
|
||||
sticker_mode: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Inner helper: sends one batch of images in a single API call."""
|
||||
import base64
|
||||
|
||||
# Resize all images first
|
||||
resized = [(_resize_for_llm(b), n) for n, b in batch]
|
||||
batch_prompt = (STICKER_BATCH_PROMPT if sticker_mode else DEFAULT_BATCH_PROMPT).format(n=len(batch))
|
||||
|
||||
content: list[dict] = [{
|
||||
"type": "text",
|
||||
"text": (
|
||||
f"I have {len(batch)} photos. For EACH photo, read ALL visible text and numbers.\n"
|
||||
"Respond with a JSON array of objects, one per photo in order:\n"
|
||||
'[{"i":0,"text":"all text and digits found","digits":"e.g. 12345-678901 or null if none"}, ...]\n'
|
||||
'Return ONLY the JSON array, no markdown, no explanation.'
|
||||
)
|
||||
}]
|
||||
content: list[dict] = [{"type": "text", "text": batch_prompt}]
|
||||
|
||||
for idx, (img_bytes, _) in enumerate(resized):
|
||||
b64 = base64.b64encode(img_bytes).decode()
|
||||
@@ -281,10 +453,8 @@ def _run_ocr_llm_batch_inner(batch: list[tuple[str, bytes]], model: str) -> list
|
||||
result = json.loads(resp.read())
|
||||
raw = result["choices"][0]["message"]["content"].strip()
|
||||
except Exception:
|
||||
# Fallback: individual calls for this batch
|
||||
return [run_ocr_llm(b, model) for n, b in batch]
|
||||
return [run_ocr_llm(b, model, sticker_mode=sticker_mode) for n, b in batch]
|
||||
|
||||
# Strip markdown code fences if present
|
||||
raw_clean = raw
|
||||
if raw_clean.startswith("```"):
|
||||
raw_clean = raw_clean.split("\n", 1)[-1]
|
||||
@@ -294,85 +464,130 @@ def _run_ocr_llm_batch_inner(batch: list[tuple[str, bytes]], model: str) -> list
|
||||
parsed = json.loads(raw_clean)
|
||||
results: list[dict] = []
|
||||
for item in parsed:
|
||||
raw_text = str(item.get("text", "") or "")
|
||||
digit_str = str(item.get("digits") or "")
|
||||
combined = raw_text + " " + digit_str
|
||||
match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", combined)
|
||||
match_5plus = re.search(r"(\d{5,})", combined)
|
||||
results.append({
|
||||
if sticker_mode:
|
||||
raw_text = str(item.get("machine_id", "") or "")
|
||||
else:
|
||||
raw_text = str(item.get("text", "") or "")
|
||||
digit_str = str(item.get("digits") or "")
|
||||
raw_text = raw_text + " " + digit_str
|
||||
match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", raw_text)
|
||||
match_5plus = re.search(r"(\d{5,})", raw_text)
|
||||
entry: dict = {
|
||||
"available": True,
|
||||
"engine": "llm_batch",
|
||||
"llm_model": model,
|
||||
"raw_text": raw_text[:500],
|
||||
"match_5dash6": match_5dash.group(0) if match_5dash else None,
|
||||
"match_5plus": match_5plus.group(0) if match_5plus else None,
|
||||
})
|
||||
}
|
||||
if sticker_mode:
|
||||
entry["sticker_color"] = str(item.get("sticker_color", "unknown"))
|
||||
results.append(entry)
|
||||
return results
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
# Fallback to individual calls
|
||||
return [run_ocr_llm(b, model) for n, b in batch]
|
||||
return [run_ocr_llm(b, model, sticker_mode=sticker_mode) for n, b in batch]
|
||||
|
||||
|
||||
def lookup_machine_id(machine_id: str) -> dict | None:
|
||||
"""Look up an asset by machine_id. Returns asset dict or None."""
|
||||
conn = _get_canteen_db()
|
||||
if not conn:
|
||||
return None
|
||||
# ---------------------------------------------------------------------------
|
||||
# Google Gemini OCR
|
||||
# ---------------------------------------------------------------------------
|
||||
def run_ocr_google(image_bytes: bytes, model: str | None = None, sticker_mode: bool = False) -> dict:
|
||||
"""Run OCR via Google Gemini vision API (free tier)."""
|
||||
import base64
|
||||
|
||||
if not GOOGLE_API_KEY:
|
||||
result = run_ocr(image_bytes)
|
||||
result["engine"] = "tesseract"
|
||||
result["llm_fallback_reason"] = "no_google_api_key"
|
||||
return result
|
||||
|
||||
model = model or GOOGLE_OCR_MODEL
|
||||
image_bytes = _resize_for_llm(image_bytes)
|
||||
b64 = base64.b64encode(image_bytes).decode()
|
||||
prompt = STICKER_OCR_PROMPT if sticker_mode else DEFAULT_OCR_PROMPT
|
||||
|
||||
body = json.dumps({
|
||||
"contents": [{
|
||||
"parts": [
|
||||
{"text": prompt},
|
||||
{"inline_data": {"mime_type": "image/jpeg", "data": b64}}
|
||||
]
|
||||
}],
|
||||
"generationConfig": {"maxOutputTokens": 200, "temperature": 0.1},
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{GOOGLE_API_BASE}/models/{model}:generateContent?key={GOOGLE_API_KEY}",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id, machine_id, name, category, status, address, building_name, "
|
||||
"floor, room, latitude, longitude, make, model, description, photo_path "
|
||||
"FROM assets WHERE machine_id = ?",
|
||||
(machine_id.strip(),),
|
||||
).fetchone()
|
||||
if row:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"machine_id": row["machine_id"],
|
||||
"name": row["name"],
|
||||
"category": row["category"],
|
||||
"status": row["status"],
|
||||
"address": row["address"],
|
||||
"building_name": row["building_name"],
|
||||
"floor": row["floor"],
|
||||
"room": row["room"],
|
||||
"latitude": row["latitude"],
|
||||
"longitude": row["longitude"],
|
||||
"make": row["make"],
|
||||
"model": row["model"],
|
||||
"description": row["description"],
|
||||
"photo_path": row["photo_path"],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
return None
|
||||
resp = urllib.request.urlopen(req, timeout=60)
|
||||
result = json.loads(resp.read())
|
||||
text = result["candidates"][0]["content"]["parts"][0]["text"].strip()
|
||||
except Exception as exc:
|
||||
result = run_ocr(image_bytes)
|
||||
result["engine"] = "tesseract"
|
||||
result["llm_fallback_reason"] = str(exc)[:200]
|
||||
return result
|
||||
|
||||
match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
|
||||
match_5plus = re.search(r"(\d{5,})", text)
|
||||
|
||||
out: dict = {
|
||||
"available": True,
|
||||
"engine": "google",
|
||||
"llm_model": model,
|
||||
"raw_text": text[:500],
|
||||
"match_5dash6": match_5dash.group(0) if match_5dash else None,
|
||||
"match_5plus": match_5plus.group(0) if match_5plus else None,
|
||||
}
|
||||
if sticker_mode:
|
||||
cm = re.search(r"\b(green|orange|yellow)\b", text, re.I)
|
||||
out["sticker_color"] = cm.group(1).lower() if cm else "unknown"
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/api/analyze")
|
||||
async def analyze_photo(
|
||||
file: UploadFile = File(...),
|
||||
ocr_engine: str = Query(default="tesseract"),
|
||||
ocr_model: str = Query(default=""),
|
||||
):
|
||||
"""Upload a photo, get back EXIF + OCR results.
|
||||
|
||||
Query params:
|
||||
- ocr_engine: ''tesseract'' (default) or ''llm''
|
||||
- ocr_model: model name override (e.g. ''mimo-v2-omni'', ''glm-5.1'')
|
||||
"""
|
||||
contents = await file.read()
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process one photo (shared between analyze + bulk)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _process_one(orig_filename: str, contents: bytes, ocr_engine: str,
|
||||
ocr_model: str | None, sticker_mode: bool) -> dict:
|
||||
"""Run EXIF + OCR on a single photo. Returns result dict."""
|
||||
file_size = len(contents)
|
||||
fhash = _file_hash(contents)
|
||||
|
||||
ext = Path(file.filename or "photo.jpg").suffix.lower()
|
||||
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}:
|
||||
ext = ".jpg"
|
||||
fname = f"{uuid.uuid4().hex}{ext}"
|
||||
(UPLOADS / fname).write_bytes(contents)
|
||||
# Check dup
|
||||
dup_row = None
|
||||
db_conn = _get_photos_db()
|
||||
if db_conn:
|
||||
try:
|
||||
dup_row = db_conn.execute(
|
||||
"SELECT id, saved_as FROM photos WHERE file_hash = ?", (fhash,)
|
||||
).fetchone()
|
||||
finally:
|
||||
db_conn.close()
|
||||
|
||||
if dup_row:
|
||||
saved_name = dup_row["saved_as"]
|
||||
is_dup = True
|
||||
photo_id = dup_row["id"]
|
||||
else:
|
||||
ext = Path(orig_filename or "photo.jpg").suffix.lower()
|
||||
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}:
|
||||
ext = ".jpg"
|
||||
saved_name = f"{uuid.uuid4().hex}{ext}"
|
||||
(UPLOADS / saved_name).write_bytes(contents)
|
||||
is_dup = False
|
||||
photo_id = None
|
||||
|
||||
exif_result = extract_exif(contents)
|
||||
|
||||
if ocr_engine == "llm":
|
||||
ocr_result = run_ocr_llm(contents, ocr_model or None)
|
||||
ocr_result = run_ocr_llm(contents, ocr_model, sticker_mode=sticker_mode)
|
||||
elif ocr_engine == "google":
|
||||
ocr_result = run_ocr_google(contents, ocr_model, sticker_mode=sticker_mode)
|
||||
elif HAS_TESSERACT:
|
||||
ocr_result = run_ocr(contents)
|
||||
ocr_result["engine"] = "tesseract"
|
||||
@@ -385,11 +600,20 @@ async def analyze_photo(
|
||||
machine_id = re.sub(r"\D", "", ocr_result["match_5dash6"])[-5:]
|
||||
asset = lookup_machine_id(machine_id)
|
||||
|
||||
# Save to DB if not a duplicate
|
||||
if not is_dup and not dup_row:
|
||||
photo_id = _save_photo_to_db(
|
||||
orig_filename, fhash, saved_name, file_size,
|
||||
exif_result, ocr_result, machine_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"filename": file.filename,
|
||||
"saved_as": fname,
|
||||
"filename": orig_filename,
|
||||
"saved_as": saved_name,
|
||||
"photo_id": photo_id or (dup_row["id"] if dup_row else None),
|
||||
"file_size": file_size,
|
||||
"file_size_kb": round(file_size / 1024, 1),
|
||||
"duplicate": is_dup,
|
||||
"exif": exif_result,
|
||||
"ocr": ocr_result,
|
||||
"machine_id": machine_id,
|
||||
@@ -397,17 +621,44 @@ async def analyze_photo(
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.post("/api/analyze")
|
||||
async def analyze_photo(
|
||||
file: UploadFile = File(...),
|
||||
ocr_engine: str = Query(default="tesseract"),
|
||||
ocr_model: str = Query(default=""),
|
||||
sticker_mode: bool = Query(default=False),
|
||||
):
|
||||
"""Upload a photo, get back EXIF + OCR results.
|
||||
|
||||
Query params:
|
||||
- ocr_engine: 'tesseract' (default) or 'llm'
|
||||
- ocr_model: model name override (e.g. 'mimo-v2-omni', 'glm-5.1')
|
||||
- sticker_mode: if true, uses sticker-specific prompt
|
||||
"""
|
||||
contents = await file.read()
|
||||
result = _process_one(
|
||||
file.filename or "photo.jpg", contents,
|
||||
ocr_engine, ocr_model or None, sticker_mode,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/bulk-process")
|
||||
async def bulk_process(
|
||||
files: list[UploadFile] = File(...),
|
||||
ocr_engine: str = Query(default="tesseract"),
|
||||
ocr_model: str = Query(default=""),
|
||||
sticker_mode: bool = Query(default=False),
|
||||
):
|
||||
"""Process multiple photos: OCR each, extract EXIF GPS, look up matching assets.
|
||||
|
||||
Query params:
|
||||
- ocr_engine: ''tesseract'' (default) or ''llm''
|
||||
- ocr_engine: 'tesseract' (default) or 'llm'
|
||||
- ocr_model: model name override
|
||||
- sticker_mode: if true, uses sticker-specific prompt
|
||||
|
||||
Returns a list of results, each with:
|
||||
- filename, exif (gps), ocr match, matched asset (if found)
|
||||
@@ -423,30 +674,55 @@ async def bulk_process(
|
||||
all_files.append((file.filename or "photo.jpg", contents))
|
||||
|
||||
ocr_use_llm = ocr_engine == "llm"
|
||||
ocr_use_google = ocr_engine == "google"
|
||||
|
||||
# Batch OCR if using LLM mode
|
||||
if ocr_use_llm:
|
||||
llm_ocr_results = run_ocr_llm_batch(all_files, ocr_model or None)
|
||||
llm_ocr_results = run_ocr_llm_batch(
|
||||
[(f, b) for f, b in all_files],
|
||||
ocr_model or None,
|
||||
sticker_mode=sticker_mode,
|
||||
)
|
||||
else:
|
||||
llm_ocr_results = None
|
||||
|
||||
results = []
|
||||
summary = {"total": len(all_files), "has_gps": 0, "matched": 0, "needs_gps": 0}
|
||||
summary = {"total": len(all_files), "has_gps": 0, "matched": 0, "needs_gps": 0, "duplicates": 0}
|
||||
|
||||
for i, (orig_fname, contents) in enumerate(all_files):
|
||||
file_size = len(contents)
|
||||
fhash = _file_hash(contents)
|
||||
|
||||
# Save
|
||||
ext = Path(orig_fname or "photo.jpg").suffix.lower()
|
||||
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}:
|
||||
ext = ".jpg"
|
||||
saved_name = f"{uuid.uuid4().hex}{ext}"
|
||||
(UPLOADS / saved_name).write_bytes(contents)
|
||||
# Check dup
|
||||
dup_row = None
|
||||
db_conn = _get_photos_db()
|
||||
if db_conn:
|
||||
try:
|
||||
dup_row = db_conn.execute(
|
||||
"SELECT id, saved_as FROM photos WHERE file_hash = ?", (fhash,)
|
||||
).fetchone()
|
||||
finally:
|
||||
db_conn.close()
|
||||
|
||||
if dup_row:
|
||||
saved_name = dup_row["saved_as"]
|
||||
is_dup = True
|
||||
photo_id = dup_row["id"]
|
||||
else:
|
||||
ext = Path(orig_fname or "photo.jpg").suffix.lower()
|
||||
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}:
|
||||
ext = ".jpg"
|
||||
saved_name = f"{uuid.uuid4().hex}{ext}"
|
||||
(UPLOADS / saved_name).write_bytes(contents)
|
||||
is_dup = False
|
||||
photo_id = None
|
||||
|
||||
exif_result = extract_exif(contents)
|
||||
|
||||
if ocr_use_llm:
|
||||
ocr_result = llm_ocr_results[i] if i < len(llm_ocr_results) else {"available": False, "engine": "none", "error": "missing batch result"}
|
||||
elif ocr_use_google:
|
||||
ocr_result = run_ocr_google(contents, ocr_model or None, sticker_mode=sticker_mode)
|
||||
elif HAS_TESSERACT:
|
||||
ocr_result = run_ocr(contents)
|
||||
ocr_result["engine"] = "tesseract"
|
||||
@@ -466,15 +742,26 @@ async def bulk_process(
|
||||
asset = lookup_machine_id(machine_id)
|
||||
if asset:
|
||||
summary["matched"] += 1
|
||||
# Check if asset needs GPS
|
||||
if (asset["latitude"] is None or asset["longitude"] is None) and has_gps:
|
||||
needs_gps = True
|
||||
summary["needs_gps"] += 1
|
||||
|
||||
# Save to DB if not dup
|
||||
if not is_dup and not dup_row:
|
||||
photo_id = _save_photo_to_db(
|
||||
orig_fname, fhash, saved_name, file_size,
|
||||
exif_result, ocr_result, machine_id,
|
||||
)
|
||||
|
||||
if is_dup:
|
||||
summary["duplicates"] += 1
|
||||
|
||||
results.append({
|
||||
"filename": orig_fname,
|
||||
"saved_as": saved_name,
|
||||
"photo_id": photo_id or (dup_row["id"] if dup_row else None),
|
||||
"file_size_kb": round(file_size / 1024, 1),
|
||||
"duplicate": is_dup,
|
||||
"exif": exif_result,
|
||||
"ocr": ocr_result,
|
||||
"machine_id": machine_id,
|
||||
@@ -485,6 +772,126 @@ async def bulk_process(
|
||||
return {"results": results, "summary": summary}
|
||||
|
||||
|
||||
@app.get("/api/photos")
|
||||
async def list_photos(limit: int = Query(default=50, le=200)):
|
||||
"""List previously processed photos from DB (newest first)."""
|
||||
conn = _get_photos_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM photos ORDER BY created_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return {"photos": [_row_to_dict(r) for r in rows]}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.get("/api/photos/{photo_id}")
|
||||
async def get_photo(photo_id: int):
|
||||
"""Get a single photo record from DB."""
|
||||
conn = _get_photos_db()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM photos WHERE id = ?", (photo_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Photo not found")
|
||||
return _row_to_dict(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.get("/api/photos/{photo_id}/file")
|
||||
async def get_photo_file(photo_id: int):
|
||||
"""Serve the saved image file for a photo record."""
|
||||
conn = _get_photos_db()
|
||||
try:
|
||||
row = conn.execute("SELECT saved_as FROM photos WHERE id = ?", (photo_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Photo not found")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
filepath = UPLOADS / row["saved_as"]
|
||||
if not filepath.exists():
|
||||
raise HTTPException(404, "File not found on disk")
|
||||
return FileResponse(str(filepath))
|
||||
|
||||
|
||||
@app.post("/api/photos/{photo_id}/reprocess")
|
||||
async def reprocess_photo(
|
||||
photo_id: int,
|
||||
ocr_engine: str = Query(default="tesseract"),
|
||||
ocr_model: str = Query(default=""),
|
||||
sticker_mode: bool = Query(default=False),
|
||||
):
|
||||
"""Re-run OCR on a previously saved photo with different engine/model.
|
||||
|
||||
Query params:
|
||||
- ocr_engine: 'tesseract' (default) or 'llm'
|
||||
- ocr_model: model name override (e.g. 'mimo-v2-omni', 'glm-5.1')
|
||||
- sticker_mode: if true, uses sticker-specific prompt
|
||||
"""
|
||||
conn = _get_photos_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT saved_as, orig_filename FROM photos WHERE id = ?", (photo_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Photo not found")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
filepath = UPLOADS / row["saved_as"]
|
||||
if not filepath.exists():
|
||||
raise HTTPException(404, "Uploaded file not found on disk")
|
||||
|
||||
contents = filepath.read_bytes()
|
||||
|
||||
if ocr_engine == "llm":
|
||||
ocr_result = run_ocr_llm(contents, ocr_model or None, sticker_mode=sticker_mode)
|
||||
elif ocr_engine == "google":
|
||||
ocr_result = run_ocr_google(contents, ocr_model or None, sticker_mode=sticker_mode)
|
||||
elif HAS_TESSERACT:
|
||||
ocr_result = run_ocr(contents)
|
||||
ocr_result["engine"] = "tesseract"
|
||||
else:
|
||||
ocr_result = {"available": False, "text": "", "engine": "none"}
|
||||
|
||||
machine_id = None
|
||||
asset = None
|
||||
if ocr_result.get("match_5dash6"):
|
||||
machine_id = re.sub(r"\D", "", ocr_result["match_5dash6"])[-5:]
|
||||
asset = lookup_machine_id(machine_id)
|
||||
|
||||
# Update DB record with new OCR results
|
||||
db2 = _get_photos_db()
|
||||
try:
|
||||
db2.execute(
|
||||
"""UPDATE photos SET ocr_engine=?, ocr_model=?, ocr_raw_text=?,
|
||||
ocr_match_5dash6=?, ocr_match_5plus=?, machine_id=?
|
||||
WHERE id=?""",
|
||||
(
|
||||
ocr_result.get("engine"),
|
||||
ocr_result.get("llm_model"),
|
||||
ocr_result.get("raw_text"),
|
||||
ocr_result.get("match_5dash6"),
|
||||
ocr_result.get("match_5plus"),
|
||||
machine_id,
|
||||
photo_id,
|
||||
),
|
||||
)
|
||||
db2.commit()
|
||||
finally:
|
||||
db2.close()
|
||||
|
||||
return {
|
||||
"photo_id": photo_id,
|
||||
"ocr_engine": ocr_result.get("engine"),
|
||||
"llm_model": ocr_result.get("llm_model"),
|
||||
"ocr": ocr_result,
|
||||
"machine_id": machine_id,
|
||||
"asset": asset,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/lookup")
|
||||
async def lookup_asset(machine_id: str = ""):
|
||||
"""Look up an asset by machine_id in the canteen assets database."""
|
||||
@@ -516,7 +923,6 @@ async def push_gps(request: dict):
|
||||
raise HTTPException(500, "Database not available")
|
||||
|
||||
try:
|
||||
# Only update if currently NULL
|
||||
row = conn.execute(
|
||||
"SELECT latitude, longitude FROM assets WHERE id = ?", (int(asset_id),)
|
||||
).fetchone()
|
||||
|
||||
Reference in New Issue
Block a user