Cut the dead attic workers, and file what the audit left standing

attic/worker_ocr.py and attic/worker_parse.py are 224 lines imported by nothing
and named in no doc. The OCR stage was removed when narration moved to the
director beat. The two design notes in attic/ stay, they are history.

worker_vision._panel_size had one reference and it was the definition.

The audit's larger finding is filed rather than fixed: call_gemma4,
_extract_json and _strip_thought exist in both worker_vision and worker_script
and have already diverged. That matters because the JSON repair pass can
fabricate dialogue, so a fix would land in one copy and not the other. It is
caveats/audit-open.md#gemma-helpers-duplicated with its revisit trigger.

HANDOFF.md carries the rest: _wrap2 against textwrap, the duplicated ONNX
preprocessing, and worker_layers pointing at a legacy/ directory that was never
tracked in git.

Checked: ruff clean, worker_vision and worker_render self-checks pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 23:06:34 +04:00
parent bec9411af3
commit 0d281016e7
6 changed files with 88 additions and 320 deletions
-99
View File
@@ -1,99 +0,0 @@
# 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")
-125
View File
@@ -1,125 +0,0 @@
# worker_parse.py — manga parse (paged manga only). FastAPI :8009. GPU, session-guarded ("magi").
# Magi v2 chapter-wide pass: panel detection + reading order + OCR in one shot. Replaces the
# crop+ocr stages for paged manga; downstream ocr stage no-ops because rows are pre-populated.
# webtoons do NOT come here — they use worker_crop /crop/webtoon. See manga-two-repo-split memory.
import os, uuid
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import cv2
import numpy as np
import transport
app = FastAPI()
SHM = "/dev/shm"
MAGI_MODEL = "ragavsachdeva/magiv2"
_model = None
def _load_magi():
global _model
if _model is None:
import torch
from transformers import AutoModel
_model = AutoModel.from_pretrained(MAGI_MODEL, trust_remote_code=True).cuda().eval()
_model._torch = torch
return _model
class ParseInput(BaseModel):
page_uris: list # all pages of the chapter, in order
manga_id: str
chapter_id: str
session_id: str = "" # magi GPU lease (opened by orchestrator)
job_id: str = ""
def _center_in(box, panel) -> bool:
x1, y1, x2, y2 = box
px1, py1, px2, py2 = panel
cx, cy = (x1 + x2) / 2, (y1 + y2) / 2
return px1 <= cx <= px2 and py1 <= cy <= py2
def assemble_panels(pages, results, manga_id, chapter_id, put):
"""Flatten Magi's per-page output into chapter-order panels with their OCR.
`pages`: RGB np arrays. `results`: per-page dicts (Magi keys). `put(np_crop, uri)` uploads.
Text is assigned to the panel whose box contains the text-box center; SFX (non-essential)
is dropped so narration isn't polluted. bbox converted [x1,y1,x2,y2] -> [x,y,w,h]."""
out, gidx = [], 0
for img, res in zip(pages, results):
panels = res.get("panels", [])
texts = res.get("texts", [])
ocr = res.get("ocr", [])
essential = res.get("is_essential_text", [True] * len(texts))
for p in panels:
x1, y1, x2, y2 = (int(v) for v in p)
uri = f"s3://manga/{manga_id}/{chapter_id}/panels/p{gidx:03d}.png"
if not transport.exists(uri): # deterministic per gidx -> resumable
put(img[y1:y2, x1:x2], uri)
ocr_texts = []
for ti, tb in enumerate(texts):
if ti < len(ocr) and essential[ti] and _center_in(tb, p):
tx1, ty1, tx2, ty2 = (int(v) for v in tb)
ocr_texts.append({"text_id": f"t{ti}", "content": ocr[ti],
"bbox": [tx1, ty1, tx2 - tx1, ty2 - ty1], "confidence": 1.0})
out.append({"panel_index": gidx, "uri": uri,
"bbox": [x1, y1, x2 - x1, y2 - y1], "ocr": ocr_texts})
gidx += 1
return out
def _put_crop(np_rgb, uri):
tmp = f"{SHM}/parse_{uuid.uuid4().hex[:8]}.png"
cv2.imwrite(tmp, cv2.cvtColor(np_rgb, cv2.COLOR_RGB2BGR))
transport.put(tmp, uri)
os.remove(tmp)
@app.post("/parse")
async def parse(data: ParseInput):
tag = uuid.uuid4().hex[:8]
pages = []
for i, u in enumerate(data.page_uris):
local = transport.get(u, f"{SHM}/pp_{tag}_{i:03d}.png")
img = cv2.imread(local)
if img is None:
raise HTTPException(400, f"page not readable: {u}")
pages.append(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
os.remove(local)
model = _load_magi()
# ponytail: empty character bank in v1 — identity stays with the downstream siglip stage;
# feed a real bank (known-char ref crops + names) here to get Magi speaker association.
bank = {"images": [], "names": []}
with model._torch.no_grad():
results = model.do_chapter_wide_prediction(pages, bank, use_tqdm=False, do_ocr=True)
panels = assemble_panels(pages, results, data.manga_id, data.chapter_id, _put_crop)
return {"panels": panels}
@app.get("/health")
async def health():
return {"status": "ok"}
if __name__ == "__main__":
# self-check: model-free. Fake a 2-page Magi result and assert panel flattening,
# chapter-order indexing, text->panel containment, SFX drop, and bbox conversion.
transport.exists = lambda *a, **k: False # no minio in self-check
stored = {}
pages = [np.zeros((100, 100, 3), np.uint8), np.zeros((100, 100, 3), np.uint8)]
results = [
{"panels": [[0, 0, 50, 100], [50, 0, 100, 100]], # page 0: two panels
"texts": [[10, 10, 20, 20], [60, 10, 70, 20]], # one text in each
"ocr": ["HELLO", "BOOM"], "is_essential_text": [True, False]}, # BOOM = SFX, dropped
{"panels": [[0, 0, 100, 100]], # page 1: one panel
"texts": [[5, 5, 15, 15]], "ocr": ["WORLD"], "is_essential_text": [True]},
]
panels = assemble_panels(pages, results, "m", "c", lambda img, uri: stored.__setitem__(uri, img.shape))
assert [p["panel_index"] for p in panels] == [0, 1, 2], "chapter-order index"
assert panels[0]["ocr"][0]["content"] == "HELLO"
assert panels[1]["ocr"] == [], "SFX text dropped from panel 1"
assert panels[2]["ocr"][0]["content"] == "WORLD"
assert panels[0]["bbox"] == [0, 0, 50, 100], "xyxy->xywh"
assert panels[0]["ocr"][0]["bbox"] == [10, 10, 10, 10]
assert stored, "crops uploaded via put"
print("worker_parse self-check ok")