diff --git a/__pycache__/server.cpython-311.pyc b/__pycache__/server.cpython-311.pyc index f761353..339b421 100644 Binary files a/__pycache__/server.cpython-311.pyc and b/__pycache__/server.cpython-311.pyc differ diff --git a/server.py b/server.py index 6486dd1..ad09ef6 100644 --- a/server.py +++ b/server.py @@ -119,6 +119,31 @@ 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 + + +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. @@ -134,6 +159,8 @@ 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() body = json.dumps({ @@ -183,6 +210,109 @@ def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> dict: } +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() @@ -286,24 +416,37 @@ async def bulk_process( if not files: return {"results": [], "summary": {"total": 0, "has_gps": 0, "matched": 0, "needs_gps": 0}} - results = [] - summary = {"total": len(files), "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(file.filename or "photo.jpg").suffix.lower() + 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" - fname = f"{uuid.uuid4().hex}{ext}" - (UPLOADS / fname).write_bytes(contents) + saved_name = f"{uuid.uuid4().hex}{ext}" + (UPLOADS / saved_name).write_bytes(contents) exif_result = extract_exif(contents) - if ocr_engine == "llm": - ocr_result = run_ocr_llm(contents, ocr_model or None) + 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" @@ -329,8 +472,8 @@ async def bulk_process( summary["needs_gps"] += 1 results.append({ - "filename": file.filename, - "saved_as": fname, + "filename": orig_fname, + "saved_as": saved_name, "file_size_kb": round(file_size / 1024, 1), "exif": exif_result, "ocr": ocr_result, diff --git a/static/index.html b/static/index.html index 56a6b71..62bf5ad 100644 --- a/static/index.html +++ b/static/index.html @@ -487,8 +487,8 @@ async function uploadSelected() { // OCR const ocr = data.ocr; - const engineCls = ocr.engine === 'llm' ? 'llm' : 'tesseract'; - html += '