Files
manga-recap-pipeline/attic/worker_ocr.py
T
kami ff6a512630 Reconstruct repo from Claude Code + codex transcripts
Working tree (including .git) was lost to an rm. Rebuilt by replaying Write/Edit/
Read/attachment events from 25 Claude sessions and 22 successful codex apply_patch
blocks into one timestamp-ordered timeline.

Verified against ground truth recorded in the transcripts: wc -l on 10 files and
ls -l on 5 files at 2026-07-18T13:13:44Z both match exactly; 18 files are
byte-identical to their newest ~/.claude/file-history blob.

See HANDOFF.md for sources, gaps, and how to rebuild .venv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 02:42:41 +04:00

100 lines
3.5 KiB
Python

# worker_ocr.py — stage 3 text extraction. FastAPI :8001. cpu (easyocr), no session.
# panel in -> text blocks with bboxes + confidence out. orchestrator persists to sqlite.
# easyocr (not tesseract): it reads stylized manga lettering far better -- recovers whole
# lines tesseract garbles or drops. runs on GPU (~0.4s/page warm) by default; the OCR stage
# runs before any LLM session opens so it doesn't contend with the resident model. set
# OCR_GPU=0 to force CPU (~3s/page). GPU needs MIOPEN_FIND_MODE=FAST in the env or the first
# ROCm run spends ~60s in MIOpen's exhaustive kernel search -- the launcher sets it.
import os, uuid
from fastapi import FastAPI
from pydantic import BaseModel
import transport
app = FastAPI()
SHM = "/dev/shm"
MIN_CONF = 0.3 # easyocr line confidence floor
OCR_GPU = os.environ.get("OCR_GPU", "1") == "1"
_reader = None # easyocr.Reader, lazy-loaded on first request
def _get_reader():
global _reader
if _reader is None:
import easyocr
_reader = easyocr.Reader(["en"], gpu=OCR_GPU, verbose=False)
return _reader
def _detections_to_texts(detections):
"""easyocr readtext output [(box_pts, text, conf)] -> our text blocks with xywh bboxes.
box_pts is 4 corner [x,y] points. drops low-confidence and art-noise (<2 letters).
casing is left as-is (mixed) -- the vision stage re-cases from the image anyway."""
texts = []
for i, (box, txt, conf) in enumerate(detections):
txt = txt.strip()
if conf < MIN_CONF or sum(c.isalpha() for c in txt) < 2:
continue
xs = [p[0] for p in box]; ys = [p[1] for p in box]
x, y = int(min(xs)), int(min(ys))
texts.append({
"id": f"t{i+1:03d}",
"content": txt,
"bbox": [x, y, int(max(xs)) - x, int(max(ys)) - y],
"confidence": round(float(conf), 3),
})
return texts
def ocr_image(path: str):
return _detections_to_texts(_get_reader().readtext(path, detail=1, paragraph=False))
class OCRInput(BaseModel):
panel_uri: str
job_id: str = ""
panel_id: str = ""
@app.post("/ocr")
async def ocr(data: OCRInput):
local = transport.get(data.panel_uri, f"{SHM}/ocr_{uuid.uuid4().hex[:8]}.png")
texts = ocr_image(local)
os.remove(local)
return {"panel_id": data.panel_id, "texts": texts}
@app.post("/unload")
async def unload():
"""free the resident easyocr reader (~1-2GB) once the OCR stage is done, before gemma4 loads.
ocr isn't session-managed, so the orchestrator calls this at stage end."""
global _reader
was = _reader is not None
_reader = None
import gc; gc.collect()
try:
import torch; torch.cuda.empty_cache()
except Exception:
pass
return {"ok": True, "unloaded": was}
@app.get("/health")
async def health():
return {"status": "ok"}
if __name__ == "__main__":
# self-check: detection->text-block conversion (pure, no model needed).
dets = [
([[10, 10], [110, 10], [110, 40], [10, 40]], "HELLO", 0.9), # kept
([[10, 200], [70, 200], [70, 230], [10, 230]], "WORLD", 0.8), # kept
([[5, 5], [13, 5], [13, 13], [5, 13]], "=", 0.9), # art-noise: <2 letters
([[0, 0], [50, 0], [50, 20], [0, 20]], "REAL", 0.1), # below MIN_CONF
]
texts = _detections_to_texts(dets)
assert [t["content"] for t in texts] == ["HELLO", "WORLD"], texts
assert texts[0]["bbox"] == [10, 10, 100, 30], texts[0]["bbox"]
assert texts[1]["confidence"] == 0.8
print("worker_ocr self-check ok")