Files
exif-test/server.py
T
shawn 70d8374ca6 feat: batch LLM OCR — multiple images in one API call + downscaling
- Add run_ocr_llm_batch() — sends N images in a single vision API call
  with structured JSON prompt, up to 20 images per batch
- Add _resize_for_llm() — downscales images to 1600px max dimension
  before sending to LLM, reducing per-image token cost
- Update bulk_process() to pre-read all files and batch-OCR in one call
- Graceful fallback: if batch JSON parsing fails, retries individually
- Frontend shows llm_batch engine badge

Without batch: N photos = N API calls (each with full prompt overhead)
With batch: N photos = ceil(N/20) API calls + image downscaling savings
2026-05-25 17:26:10 -04:00

561 lines
19 KiB
Python

"""EXIF + OCR test backend — validate that GPS survives upload pipeline."""
import io, json, os, re, uuid, sqlite3, urllib.request
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.staticfiles import StaticFiles
from PIL import Image as PILImage
# Optional HEIC/HEIF support for iPhone photos
try:
from pillow_heif import register_heif_opener
register_heif_opener()
HAS_HEIF = True
except ImportError:
HAS_HEIF = False
try:
import pytesseract
HAS_TESSERACT = True
except ImportError:
HAS_TESSERACT = False
# === LLM OCR via OpenCode Go ===
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")
UPLOADS = Path(__file__).parent / "uploads"
UPLOADS.mkdir(exist_ok=True)
CANTEEN_DB = Path(__file__).parent.parent / "canteen-asset-tracker" / "assets.db"
app = FastAPI(title="EXIF Test")
def _get_canteen_db():
"""Get a read/write connection to the canteen assets database."""
if not CANTEEN_DB.exists():
return None
conn = sqlite3.connect(str(CANTEEN_DB))
conn.row_factory = sqlite3.Row
return conn
def _dms_to_decimal(dms, ref):
"""Convert EXIF DMS tuple to decimal degrees."""
try:
deg, minutes, sec = float(dms[0]), float(dms[1]), float(dms[2])
decimal = deg + minutes / 60.0 + sec / 3600.0
if ref in ("S", "W"):
decimal = -decimal
return round(decimal, 7)
except Exception:
return None
def extract_exif(image_bytes: bytes) -> dict:
"""Pull all useful EXIF fields + GPS from raw image bytes."""
result = {"has_exif": False, "tags": {}, "gps": None}
try:
img = PILImage.open(io.BytesIO(image_bytes))
exif = img.getexif()
if not exif:
return result
for tag_id, value in exif.items():
tag_name = PILImage.ExifTags.TAGS.get(tag_id, f"0x{tag_id:04x}")
if tag_name in ("MakerNote", "UserComment", "PrintImageMatching"):
continue
result["tags"][tag_name] = str(value)[:300]
if result["tags"]:
result["has_exif"] = True
gps_ifd = exif.get_ifd(0x8825)
if gps_ifd:
lat_ref = gps_ifd.get(1, "N")
lat_dms = gps_ifd.get(2)
lng_ref = gps_ifd.get(3, "E")
lng_dms = gps_ifd.get(4)
if lat_dms and lng_dms:
lat = _dms_to_decimal(lat_dms, lat_ref)
lng = _dms_to_decimal(lng_dms, lng_ref)
if lat is not None and lng is not None:
result["gps"] = {
"lat": lat,
"lng": lng,
"lat_ref": str(lat_ref),
"lng_ref": str(lng_ref),
"raw_lat_dms": [float(v) for v in lat_dms],
"raw_lng_dms": [float(v) for v in lng_dms],
}
except Exception as e:
result["error"] = str(e)
return result
def run_ocr(image_bytes: bytes) -> dict:
"""Run Tesseract OCR on the image."""
if not HAS_TESSERACT:
return {"available": False, "text": "", "error": "pytesseract not installed"}
tmp_path = UPLOADS / f"ocr_{uuid.uuid4().hex}.jpg"
tmp_path.write_bytes(image_bytes)
try:
img = PILImage.open(tmp_path)
img_gray = img.convert("L")
text = pytesseract.image_to_string(img_gray, config="--psm 6")
match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
match_5plus = re.search(r"(\d{5,})", text)
return {
"available": True,
"raw_text": text.strip()[:500],
"match_5dash6": match_5dash.group(0) if match_5dash else None,
"match_5plus": match_5plus.group(0) if match_5plus else None,
}
finally:
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
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))
w, h = img.size
if max(w, h) <= max_dim:
return image_bytes # already small enough
ratio = max_dim / max(w, h)
new_size = (int(w * ratio), int(h * ratio))
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"
img_resized.save(buf, format=ext, quality=85)
return buf.getvalue()
def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> 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.
"""
if not OPENCODE_GO_KEY:
result = run_ocr(image_bytes)
result["engine"] = "tesseract"
result["llm_fallback_reason"] = "no_api_key"
return result
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()
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": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
]
}],
"max_tokens": 200,
}).encode()
req = urllib.request.Request(
f"{OPENCODE_GO_BASE}/chat/completions",
data=body,
headers={
"Authorization": f"Bearer {OPENCODE_GO_KEY}",
"Content-Type": "application/json",
"User-Agent": "Hermes-Agent/1.0",
},
)
try:
resp = urllib.request.urlopen(req, timeout=60)
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]
return result
match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
match_5plus = re.search(r"(\d{5,})", text)
return {
"available": True,
"engine": "llm",
"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,
}
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)
"""
import base64
if not OPENCODE_GO_KEY:
return [run_ocr(b) for _, b in images]
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)
all_results.extend(batch_results)
return all_results
def _run_ocr_llm_batch_inner(batch: list[tuple[str, bytes]], model: str) -> 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]
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.'
)
}]
for idx, (img_bytes, _) in enumerate(resized):
b64 = base64.b64encode(img_bytes).decode()
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"}
})
body = json.dumps({
"model": model,
"messages": [{"role": "user", "content": content}],
"max_tokens": min(300 * len(batch), 8000),
"temperature": 0.1,
}).encode()
req = urllib.request.Request(
f"{OPENCODE_GO_BASE}/chat/completions",
data=body,
headers={
"Authorization": f"Bearer {OPENCODE_GO_KEY}",
"Content-Type": "application/json",
"User-Agent": "Hermes-Agent/1.0",
},
)
try:
resp = urllib.request.urlopen(req, timeout=120)
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]
# Strip markdown code fences if present
raw_clean = raw
if raw_clean.startswith("```"):
raw_clean = raw_clean.split("\n", 1)[-1]
raw_clean = raw_clean.rsplit("```", 1)[0].strip()
try:
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({
"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,
})
return results
except (json.JSONDecodeError, KeyError, TypeError):
# Fallback to individual calls
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."""
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
@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()
file_size = len(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)
exif_result = extract_exif(contents)
if ocr_engine == "llm":
ocr_result = run_ocr_llm(contents, ocr_model or None)
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)
return {
"filename": file.filename,
"saved_as": fname,
"file_size": file_size,
"file_size_kb": round(file_size / 1024, 1),
"exif": exif_result,
"ocr": ocr_result,
"machine_id": machine_id,
"asset": asset,
}
@app.post("/api/bulk-process")
async def bulk_process(
files: list[UploadFile] = File(...),
ocr_engine: str = Query(default="tesseract"),
ocr_model: str = Query(default=""),
):
"""Process multiple photos: OCR each, extract EXIF GPS, look up matching assets.
Query params:
- ocr_engine: ''tesseract'' (default) or ''llm''
- ocr_model: model name override
Returns a list of results, each with:
- filename, exif (gps), ocr match, matched asset (if found)
- needs_gps: true if asset exists AND has no coordinates AND photo has GPS
"""
if not files:
return {"results": [], "summary": {"total": 0, "has_gps": 0, "matched": 0, "needs_gps": 0}}
# Read all files first
all_files: list[tuple[str, bytes]] = []
for file in files:
contents = await file.read()
all_files.append((file.filename or "photo.jpg", contents))
ocr_use_llm = ocr_engine == "llm"
# Batch OCR if using LLM mode
if ocr_use_llm:
llm_ocr_results = run_ocr_llm_batch(all_files, ocr_model or None)
else:
llm_ocr_results = None
results = []
summary = {"total": len(all_files), "has_gps": 0, "matched": 0, "needs_gps": 0}
for i, (orig_fname, contents) in enumerate(all_files):
file_size = len(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)
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 HAS_TESSERACT:
ocr_result = run_ocr(contents)
ocr_result["engine"] = "tesseract"
else:
ocr_result = {"available": False, "text": "", "engine": "none"}
has_gps = exif_result.get("gps") is not None
if has_gps:
summary["has_gps"] += 1
machine_id = None
asset = None
needs_gps = False
if ocr_result.get("match_5dash6"):
machine_id = re.sub(r"\D", "", ocr_result["match_5dash6"])[-5:]
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
results.append({
"filename": orig_fname,
"saved_as": saved_name,
"file_size_kb": round(file_size / 1024, 1),
"exif": exif_result,
"ocr": ocr_result,
"machine_id": machine_id,
"asset": asset,
"needs_gps": needs_gps,
})
return {"results": results, "summary": summary}
@app.get("/api/lookup")
async def lookup_asset(machine_id: str = ""):
"""Look up an asset by machine_id in the canteen assets database."""
if not machine_id or not machine_id.strip():
return {"found": False, "reason": "No machine_id provided"}
asset = lookup_machine_id(machine_id.strip())
if asset:
return {"found": True, "asset": asset}
return {"found": False, "reason": f"No asset with machine_id '{machine_id}'"}
@app.post("/api/push-gps")
async def push_gps(request: dict):
"""Update an asset's GPS coordinates from photo EXIF data.
Body: {"asset_id": 123, "latitude": 40.7417, "longitude": -73.9292}
Only updates assets that currently have NULL lat/lng.
"""
asset_id = request.get("asset_id")
latitude = request.get("latitude")
longitude = request.get("longitude")
if not asset_id or latitude is None or longitude is None:
raise HTTPException(400, "asset_id, latitude, and longitude are required")
conn = _get_canteen_db()
if not conn:
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()
if not row:
conn.close()
raise HTTPException(404, f"Asset {asset_id} not found")
if row["latitude"] is not None and row["longitude"] is not None:
conn.close()
return {"updated": False, "reason": "Asset already has GPS coordinates", "asset_id": asset_id}
conn.execute(
"UPDATE assets SET latitude = ?, longitude = ?, updated_at = datetime('now') WHERE id = ?",
(float(latitude), float(longitude), int(asset_id)),
)
conn.commit()
conn.close()
return {"updated": True, "asset_id": asset_id, "latitude": float(latitude), "longitude": float(longitude)}
except Exception as e:
try:
conn.close()
except Exception:
pass
raise HTTPException(500, str(e))
@app.get("/api/uploads")
async def list_uploads():
"""List previously uploaded files."""
files = sorted(UPLOADS.glob("*"), key=lambda p: p.stat().st_mtime, reverse=True)
return [
{"name": f.name, "size_kb": round(f.stat().st_size / 1024, 1)}
for f in files[:20]
]
# Mount static LAST
app.mount("/", StaticFiles(directory="static", html=True), name="static")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8903)