From 70d8374ca6d2d738dbed6b81d5c115a47af98b7d Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 25 May 2026 17:26:10 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20batch=20LLM=20OCR=20=E2=80=94=20multipl?= =?UTF-8?q?e=20images=20in=20one=20API=20call=20+=20downscaling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- __pycache__/server.cpython-311.pyc | Bin 20427 -> 27909 bytes server.py | 163 +++++++++++++++++++++++++++-- static/index.html | 4 +- 3 files changed, 155 insertions(+), 12 deletions(-) diff --git a/__pycache__/server.cpython-311.pyc b/__pycache__/server.cpython-311.pyc index f7613535ce843c3cc5cc0286b67e7926e8d77e7a..339b421923edb888e8654ba54e6c0beb6b95b0a1 100644 GIT binary patch delta 10509 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 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 += '
🔤 OCR ' + esc(ocr.engine || 'tesseract') + (ocr.llm_model ? ' ' + ocr.llm_model : '') + '
'; + const engineCls = ocr.engine === 'llm' || ocr.engine === 'llm_batch' ? 'llm' : 'tesseract'; + html += '
🔤 OCR ' + esc(ocr.engine || 'tesseract') + (ocr.llm_model ? ' ' + esc(ocr.llm_model) : '') + '
'; if (ocr.raw_text) { html += '
' + esc(ocr.raw_text) + '
'; }