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
This commit is contained in:
Binary file not shown.
@@ -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,
|
||||
|
||||
+2
-2
@@ -487,8 +487,8 @@ async function uploadSelected() {
|
||||
|
||||
// OCR
|
||||
const ocr = data.ocr;
|
||||
const engineCls = ocr.engine === 'llm' ? 'llm' : 'tesseract';
|
||||
html += '<div style="margin-top:10px;font-weight:600;">🔤 OCR <span class="engine-badge ' + engineCls + '">' + esc(ocr.engine || 'tesseract') + (ocr.llm_model ? ' ' + ocr.llm_model : '') + '</span></div>';
|
||||
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>';
|
||||
if (ocr.raw_text) {
|
||||
html += '<div class="ocr-text">' + esc(ocr.raw_text) + '</div>';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user