Files
manga-recap-pipeline/worker_crop.py
T
kami bec9411af3 Put every S3 URI in one place, and add a lint gate
Five workers built output URIs with inline f-strings, so the bucket-per-artifact
layout was spread across worker_tts, worker_identity, worker_crop, worker_layers
and worker_render. Moving a class between buckets meant a grep. They are now
templates in transport.py, formatted at each call site.

Three of those workers also each reimplemented the same parse to recover
manga_id and chapter_id from an input uri, because the orchestrator does not
send them. That is transport.ids_from_uri now, and it raises on a uri too short
to carry the ids rather than returning a wrong pair.

ruff.toml makes `ruff check .` exit 0, so CI can gate on it and a new finding
means a new defect. Fixed: an implicit Optional in 8 signatures, an unparenthesized
implicit concatenation in the ASS filter list, 5 subprocess.run calls now saying
check=False out loud, an unused import, a duplicate exception handler and a
non-executable shebang. Every rule left off carries its reason in ruff.toml.

The ASYNC rules are off because ffmpeg on the event loop is real and already
recorded at caveats/audit-open.md#blocking-event-loop. It needs a refactor per
handler, not a lint fix.

Checked: transport, collage, bubble_detect, test_vision_parse, worker_crop,
worker_scene, worker_script, worker_identity, worker_tts, session_manager,
worker_vision and worker_render self-checks all pass. worker_layers still fails
on a missing legacy/qwen_layered_workflow.json, which predates this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:02:06 +04:00

364 lines
16 KiB
Python

# worker_crop.py — stage 2 panel detector. FastAPI :8000. cpu/opencv, no GPU, no session.
# one page in -> N panel crops (reading order) uploaded to minio, uris + bboxes returned.
# routes by aspect: framed pages -> kumiko; tall webtoon strips -> whitespace slicer.
import sys, os, uuid, logging
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import cv2
import numpy as np
import transport
try: # CPU-EP ONNX detectors; absent -> merge pass is a no-op
import face_detect, bubble_detect
except Exception:
face_detect = bubble_detect = None
log = logging.getLogger("crop")
sys.path.insert(0, os.path.expanduser("~/Programs/kumiko"))
app = FastAPI()
transport.install_logging(app, "crop")
SHM = "/dev/shm"
WEBTOON_RATIO = 2.0 # ponytail: manga ~1.4, webtoons 2.5-10+; tune if a tall page misroutes
class CropInput(BaseModel):
page_uri: str
manga_id: str
chapter_id: str
page_index: int = 0
job_id: str = ""
rtl: bool = True # manga reads right-to-left; set false for western comics/webtoons
class WebtoonInput(BaseModel):
# webtoon scrapers deliver one episode as arbitrary fixed-height tiles cut mid-panel.
# restitch the whole chapter into one strip, THEN slice — per-tile slicing severs panels.
page_uris: list # all tiles of the chapter, in order
manga_id: str
chapter_id: str
job_id: str = ""
def slice_webtoon(img, bg_thresh=235, min_gap=20, min_seg=64, max_seg=2500, blank_frac=0.995):
"""Cut a vertical strip on blank row-bands. Returns (crop, bbox[x,y,w,h]) top-to-bottom.
Defaults tuned on real webtoon strips: 235/20 finds true gutters over full-color art;
max_seg=2500 (~one phone screen) caps full-bleed art that has no internal gutter.
186: a row is 'blank' when >=blank_frac of its pixels are bright — NOT every pixel (min>=thresh),
because one dark speck/border pixel/stray letter in a true gutter used to defeat the whole cut."""
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if img.ndim == 3 else img
row_blank = (gray >= bg_thresh).mean(axis=1) >= blank_frac
h, w = gray.shape[:2]
cuts, i = [0], 0
while i < h:
if row_blank[i]:
j = i
while j < h and row_blank[j]:
j += 1
if j - i >= min_gap:
cuts.append((i + j) // 2)
i = j
else:
i += 1
cuts.append(h)
out = []
for a, b in zip(cuts, cuts[1:]):
if b - a < min_seg:
continue
n = -(-(b - a) // max_seg)
step = (b - a) // n
for s in range(n):
y0 = a + s * step
y1 = b if s == n - 1 else a + (s + 1) * step
out.append((img[y0:y1], [0, y0, w, y1 - y0]))
return out or [(img, [0, 0, w, h])]
def _has_face(img):
try:
return bool(face_detect.detect_faces(img))
except Exception:
return True # detector broke -> assume a face so we do NOT merge (conservative)
def _has_text(img):
try:
return bool(bubble_detect.detect_text_regions(img))
except Exception:
return False
def _merge_plan(faces, texts):
"""Pure grouping decision (testable without a real face). faces/texts: per-fragment booleans,
top-to-bottom. A 'stranded caption' = has text but no face; its line has no face to attribute to.
Attach it to an adjacent face fragment — preferring the face ABOVE (the speaker usually appears,
then their caption follows), else the face below. Scenery (no text) and face fragments stay solo.
Returns a list of index-groups covering 0..n-1 in order; each group with >1 index gets vstacked."""
n = len(faces)
groups, i = [], 0
while i < n:
if faces[i]: # a face swallows the run of stranded captions below it
g = [i]
while i + 1 < n and texts[i + 1] and not faces[i + 1]:
i += 1
g.append(i)
groups.append(g)
elif texts[i] and not faces[i] and i + 1 < n and faces[i + 1]: # no face above -> ride the one below
groups.append([i, i + 1])
i += 1
else:
groups.append([i])
i += 1
return groups
def merge_faceless_captions(crops):
"""Post-slice pass (webtoon only): fold a stranded caption fragment into the vertically nearest
face-bearing fragment so the windowed vision/dialogue call sees the caption next to a real face,
instead of on a faceless panel where attribution has nothing to anchor to. This ONLY combines
images — it never assigns a speaker, so it can't misbind a monologue to the nearer face; gemma
still decides from the window's flow. Bonus: fewer panels -> fits gemma's window-count limit.
ponytail: runs face+text ONNX on every fragment (CPU); fine for batch, cache the models if a
huge chapter makes crop the bottleneck."""
if not (face_detect and bubble_detect) or len(crops) < 2:
return crops
faces = [_has_face(c) for c, _ in crops]
texts = [_has_text(c) for c, _ in crops]
plan = _merge_plan(faces, texts)
if len(plan) == len(crops):
return crops # nothing stranded -> untouched
out = []
for g in plan:
if len(g) == 1:
out.append(crops[g[0]])
continue
merged = np.vstack([crops[k][0] for k in g])
x, y, w, _ = crops[g[0]][1]
out.append((merged, [x, y, w, merged.shape[0]]))
return out
def context_fragment_links(crops):
"""Return non-destructive caption↔face links keyed by source-fragment index.
Both fragments remain independent panels with their original bboxes; the dialogue stage may use
the linked image as context, while the orchestrator remains free to accept/reject the linkage.
"""
links = {i: [] for i in range(len(crops))}
if not (face_detect and bubble_detect) or len(crops) < 2:
return links
faces = [_has_face(c) for c, _ in crops]
texts = [_has_text(c) for c, _ in crops]
for group in _merge_plan(faces, texts):
face_idxs = [i for i in group if faces[i]]
if not face_idxs:
continue
anchor = face_idxs[0]
for i in group:
if i == anchor or not (texts[i] and not faces[i]):
continue
links[i].append({"fragment_index": anchor, "bbox": crops[anchor][1],
"link_reason": "adjacent_face_context"})
links[anchor].append({"fragment_index": i, "bbox": crops[i][1],
"link_reason": "adjacent_text_context"})
return links
def reading_order(panels, rtl=True):
"""186: deterministic manga/comic reading order, replacing reliance on kumiko's neighbour-based
comparator (which emitted this RTL title left-to-right on irregular/overlapping layouts).
Group panels into horizontal row-bands by vertical overlap, order bands top-to-bottom, then within
a band order by x — right-to-left for rtl manga, left-to-right otherwise.
panels: [(crop, [x,y,w,h])]. ponytail: greedy vertical banding; a deeply interleaved splash
collage can still misband — upgrade to a full topological pass if a fixture proves it needed."""
if not panels:
return panels
bands = [] # each: {"y0","y1","items"}
for it in sorted(panels, key=lambda p: p[1][1]): # seed bands top-down
x, y, w, h = it[1]
for band in bands:
ov = max(0, min(y + h, band["y1"]) - max(y, band["y0"]))
if ov >= 0.5 * min(h, band["y1"] - band["y0"]): # substantial vertical overlap = same row
band["items"].append(it)
band["y0"], band["y1"] = min(band["y0"], y), max(band["y1"], y + h)
break
else:
bands.append({"y0": y, "y1": y + h, "items": [it]})
bands.sort(key=lambda b: b["y0"])
out = []
for band in bands:
band["items"].sort(key=lambda p: p[1][0], reverse=rtl) # x; rtl -> rightmost first
out.extend(band["items"])
return out
def kumiko_panels(path, rtl=True):
"""Framed-page panels via kumiko contour detection, then a DETERMINISTIC reading-order sort
(186 — do not trust kumiko's implicit comparator for RTL/irregular layouts)."""
from kumikolib import Kumiko
k = Kumiko({"rtl": rtl})
k.parse_image(path)
page = k.page_list[-1]
full = page.img
panels = [(full[p.y:p.b, p.x:p.r], [p.x, p.y, p.r - p.x, p.b - p.y]) for p in page.panels]
return reading_order(panels, rtl)
def flag_overlaps(panels, page_index=0):
"""Warn on panel pairs whose bboxes overlap by >50% of the smaller panel's area —
a sign of ambiguous splitting/ordering that silently corrupts narration sequence.
Returns the list of ambiguous (i, j) index pairs (also for the self-check)."""
def _ov(a, b):
ax, ay, aw, ah = a; bx, by, bw, bh = b
ix = max(0, min(ax + aw, bx + bw) - max(ax, bx))
iy = max(0, min(ay + ah, by + bh) - max(ay, by))
inter = ix * iy
return inter / max(1, min(aw * ah, bw * bh))
ambiguous = []
for i in range(len(panels)):
for j in range(i + 1, len(panels)):
if _ov(panels[i][1], panels[j][1]) > 0.5:
ambiguous.append((i, j))
if ambiguous:
log.warning("page=%d ambiguous panel overlap, order may be wrong: pairs=%s",
page_index, ambiguous)
return ambiguous
def restitch(paths):
"""Stack chapter tiles into one vertical strip. Tiles share width (scraper output)."""
imgs = [cv2.imread(p) for p in paths]
if any(i is None for i in imgs):
raise HTTPException(400, "a webtoon tile was not readable")
w = min(i.shape[1] for i in imgs)
imgs = [i if i.shape[1] == w else cv2.resize(i, (w, int(i.shape[0] * w / i.shape[1]))) for i in imgs]
return np.vstack(imgs)
@app.post("/crop/webtoon")
async def crop_webtoon(data: WebtoonInput):
tag = uuid.uuid4().hex[:8]
locals_ = [transport.get(u, f"{SHM}/wt_{tag}_{i:04d}.png") for i, u in enumerate(data.page_uris)]
strip = restitch(locals_)
crops = merge_faceless_captions(slice_webtoon(strip))
context_links = context_fragment_links(crops)
panels = []
for idx, (crop_img, bbox) in enumerate(crops):
uri = transport.PANEL_URI.format(manga_id=data.manga_id, chapter_id=data.chapter_id, idx=idx)
# slicing is deterministic, so on a resume the same idx -> same key; skip re-upload.
# TRAP: that holds only while the PLAN is unchanged. Edit slice_webtoon or the merge pass and a
# re-crop silently keeps the previous run's images under the same keys, because every one of them
# already exists. Clear the s3://panels/<manga>/<chapter>/panels/ prefix before re-cropping.
if not transport.exists(uri):
out = f"{SHM}/wt_{tag}_p{idx:03d}.png"
cv2.imwrite(out, crop_img)
transport.put(out, uri)
os.remove(out)
panels.append({"panel_index": idx, "uri": uri, "bbox": bbox,
"context_fragments": context_links[idx]})
for p in locals_:
os.remove(p)
return {"page_index": 0, "panels": panels}
@app.post("/crop")
async def crop(data: CropInput):
local = transport.get(data.page_uri, f"{SHM}/crop_{uuid.uuid4().hex[:8]}.png")
img = cv2.imread(local)
if img is None:
raise HTTPException(400, f"page not readable: {data.page_uri}")
h, w = img.shape[:2]
webtoon = h / w >= WEBTOON_RATIO
crops = merge_faceless_captions(slice_webtoon(img)) if webtoon else kumiko_panels(local, data.rtl)
context_links = context_fragment_links(crops) if webtoon else {i: [] for i in range(len(crops))}
ambiguous = flag_overlaps(crops, data.page_index)
panels = []
for idx, (crop_img, bbox) in enumerate(crops):
out = f"{SHM}/pg{data.page_index:03d}_p{idx:02d}.png"
cv2.imwrite(out, crop_img)
uri = transport.PAGE_PANEL_URI.format(manga_id=data.manga_id, chapter_id=data.chapter_id,
page_index=data.page_index, idx=idx)
transport.put(out, uri)
os.remove(out)
panels.append({"panel_index": idx, "uri": uri, "bbox": bbox,
"context_fragments": context_links[idx]})
os.remove(local)
# 186: surface anomalies so review can see a suspect page instead of it silently completing.
warnings = [f"ambiguous overlap {p}" for p in ambiguous]
if not panels:
warnings.append("no panels detected")
return {"page_index": data.page_index, "panels": panels, "warnings": warnings}
@app.get("/health")
async def health():
return {"status": "ok"}
if __name__ == "__main__":
# self-check: synthetic 2-band strip -> 2 panels top-to-bottom with sane bboxes.
strip = np.full((300, 100, 3), 255, np.uint8)
strip[50:100] = 0
strip[200:250] = 0
segs = slice_webtoon(strip)
assert len(segs) == 2, len(segs)
assert segs[0][1][1] < segs[1][1][1], "reading order top-to-bottom"
assert all(len(b) == 4 for _, b in segs)
# max_seg cap: a 6000px gutterless block must split into ceil(6000/2500)=3 beats
solid = np.zeros((6000, 100, 3), np.uint8)
capped = slice_webtoon(solid)
assert len(capped) == 3, len(capped)
# a true gutter with one dark speck must still cut (186: fraction, not min-pixel).
speckled = np.full((300, 100, 3), 255, np.uint8)
speckled[50:100] = 0; speckled[200:250] = 0
speckled[75, 40] = 0 # stray dark pixel inside the top band — used to defeat min()>=thresh
assert len(slice_webtoon(speckled)) == 2, "gutter detection must tolerate a speck"
# faceless-caption merge plan (pure grouping). F=face T=text per fragment, top-to-bottom.
assert _merge_plan([True, False], [False, True]) == [[0, 1]] # caption below a face -> absorbed up
assert _merge_plan([False, True], [True, False]) == [[0, 1]] # caption above a face -> absorbed down
assert _merge_plan([False], [True]) == [[0]] # lone caption, no face -> left alone
assert _merge_plan([False], [False]) == [[0]] # scenery (no text) -> left alone
assert _merge_plan([True, False, False], [False, True, True]) == [[0, 1, 2]] # face swallows caption run
assert _merge_plan([True, True], [False, False]) == [[0], [1]] # two faces -> untouched
# vstack path: a real merge stacks images and reports the combined height
a = (np.zeros((30, 10, 3), np.uint8), [0, 0, 10, 30])
b = (np.zeros((20, 10, 3), np.uint8), [0, 40, 10, 20])
import types
_fd, _bd = face_detect, bubble_detect
face_detect = types.SimpleNamespace(detect_faces=lambda im: [1] if im.shape[0] == 30 else [])
bubble_detect = types.SimpleNamespace(detect_text_regions=lambda im: [1] if im.shape[0] == 20 else [])
merged = merge_faceless_captions([a, b])
assert len(merged) == 1 and merged[0][0].shape[0] == 50, merged[0][0].shape
linked = context_fragment_links([a, b])
assert linked[0][0]["fragment_index"] == 1 and linked[1][0]["fragment_index"] == 0
assert linked[0][0]["bbox"] == b[1] and linked[1][0]["bbox"] == a[1]
face_detect, bubble_detect = _fd, _bd
# overlap flag: disjoint panels are clean; a >50% overlapping pair is flagged ambiguous.
clean = [(None, [0, 0, 10, 10]), (None, [20, 0, 10, 10])]
assert flag_overlaps(clean) == []
overlap = [(None, [0, 0, 10, 10]), (None, [2, 2, 10, 10])] # ~64% of the smaller area
assert flag_overlaps(overlap) == [(0, 1)]
# 186 reading order — bbox = [x,y,w,h]. ids track source panels so we can assert final order.
def ro_ids(boxes, rtl):
tagged = [(i, b) for i, b in enumerate(boxes)]
return [i for i, _ in reading_order(tagged, rtl)]
# two panels on one row: RTL reads the right one (x=60) first, LTR the left (x=0) first.
two = [[0, 0, 50, 100], [60, 0, 50, 100]]
assert ro_ids(two, rtl=True) == [1, 0], "RTL: rightmost first"
assert ro_ids(two, rtl=False) == [0, 1], "LTR: leftmost first"
# 2x2 grid: top row R->L then bottom row R->L. sources: 0=TL 1=TR 2=BL 3=BR.
grid = [[0, 0, 50, 50], [60, 0, 50, 50], [0, 60, 50, 50], [60, 60, 50, 50]]
assert ro_ids(grid, rtl=True) == [1, 0, 3, 2], ro_ids(grid, rtl=True)
# staggered rows (slightly offset y) still band by vertical overlap, not exact y.
stag = [[0, 0, 50, 100], [60, 5, 50, 100], [0, 200, 100, 80]]
assert ro_ids(stag, rtl=True) == [1, 0, 2], ro_ids(stag, rtl=True)
# splash (single full panel) -> unchanged.
assert ro_ids([[0, 0, 200, 300]], rtl=True) == [0]
print("worker_crop self-check ok")