Files
manga-recap-pipeline/audit_registry.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

94 lines
4.5 KiB
Python

"""Registry audit for one chapter, after vision + identity + reconcile and before anything downstream.
Runs inside manga-orchestrator (reads /data/manga.db). `audit_speakers.py` answers the attribution
questions and needs the dialogue stage; this one answers the questions that decide whether dialogue is
worth running at all:
1. did the bbox fix land — are stored boxes pixels, or still gemma's 0-1000 grid,
2. how many characters did the rebaseline mint, and did one of them absorb the chapter again,
3. what happened on panel 7, the worked example.
Usage: docker exec manga-orchestrator python3 /app/audit_registry.py [chapter_id] [panel_index]
"""
import collections
import json
import sqlite3
import sys
CHAPTER = sys.argv[1] if len(sys.argv) > 1 else "7c944dd4-e972-42c7-ba60-9f6939548e80"
WORKED_EXAMPLE = int(sys.argv[2]) if len(sys.argv) > 2 else 7
c = sqlite3.connect("/data/manga.db")
c.row_factory = sqlite3.Row
manga_id = c.execute("SELECT manga_id FROM chapters WHERE chapter_id=?", (CHAPTER,)).fetchone()[0]
reg = {r["character_id"]: dict(r) for r in c.execute(
"SELECT character_id, name, aliases, gender, ref_image_uris, embedding_uri "
"FROM characters WHERE manga_id=?", (manga_id,))}
panels = c.execute(
'SELECT panel_id, panel_index, page_index, bbox FROM panels WHERE chapter_id=? ORDER BY panel_order',
(CHAPTER,)).fetchall()
# 1. coordinate space. A 0-1000 grid box on a panel wider or taller than 1000px cannot exceed 1000,
# and clamps AT 1000. Real pixel boxes track the panel and scatter past it. The tell is the ratio of
# the largest coordinate to the panel dimension, plus how many boxes sit exactly on 1000.
detections = 0
past_1000 = at_1000 = 0
max_ratio = 0.0
assigned_total = 0
per_char = collections.Counter()
worked = None
for p in panels:
row = c.execute("SELECT result_json FROM vision_results WHERE panel_id=?", (p["panel_id"],)).fetchone()
if not row:
continue
v = json.loads(row["result_json"])
# the vision blob carries no panel size. panels.bbox is the panel's box on its page and is
# [x, y, w, h], not corners — panel 3 of this chapter is [0, 615, 900, 106].
pb = json.loads(p["bbox"] or "null")
pw, ph = (pb[2], pb[3]) if pb and len(pb) == 4 else (None, None)
assigns = {a["local_id"]: (a["character_id"], a["confidence"]) for a in c.execute(
"SELECT local_id, character_id, confidence FROM identity_assignments WHERE panel_id=?",
(p["panel_id"],))}
assigned_total += len(assigns)
for cid, _ in assigns.values():
per_char[reg.get(cid, {}).get("name") or cid[:16]] += 1
people = [ch for ch in (v.get("characters") or []) if ch.get("bbox")]
detections += len(people)
for ch in people:
x1, y1, x2, y2 = ch["bbox"]
past_1000 += 1 if max(x2, y2) > 1000 else 0
at_1000 += 1 if 1000 in (x2, y2) else 0
if pw and ph:
max_ratio = max(max_ratio, x2 / pw, y2 / ph)
if p["panel_index"] == WORKED_EXAMPLE:
worked = (p, v, people, assigns, pw, ph)
named = [r for r in reg.values() if (r["name"] or "").strip()]
print(f"registry: {len(reg)} characters, {len(named)} named -> {sorted((r['name'] or '') for r in named)}")
print(f"detections: {detections} assignments: {assigned_total} "
f"= {100*assigned_total/max(detections,1):.0f}% coverage")
if per_char:
top, n = per_char.most_common(1)[0]
print(f"assignment spread: {dict(per_char.most_common(8))}")
print(f" top character holds {n}/{assigned_total} = {100*n/max(assigned_total,1):.0f}% "
f"({'ABSORBING, same signature as before' if n > 0.5 * assigned_total else 'ok'})")
print(f"bbox space: {past_1000}/{detections} boxes exceed 1000, {at_1000} sit exactly on 1000, "
f"largest coord/panel-dimension = {max_ratio:.2f}")
print(f" verdict: {'PIXELS' if past_1000 or max_ratio > 0.02 and at_1000 == 0 else 'STILL 0-1000 GRID'}")
missing_refs = [k for k, r in reg.items() if not r["ref_image_uris"] or not r["embedding_uri"]]
print(f"characters missing a ref crop or embedding: {len(missing_refs)}")
if worked:
p, v, people, assigns, pw, ph = worked
print(f"\npanel_index {WORKED_EXAMPLE} ({p['panel_id']}), {pw}x{ph}:")
for ch in people:
cid, conf = assigns.get(ch["local_id"], (None, None))
name = reg.get(cid, {}).get("name") or (cid[:16] if cid else "-- none --")
print(f" {ch['local_id']:10} {ch['bbox']!s:28} {name:22} "
f"{'' if conf is None else f'{conf:.2f}'}")
else:
print(f"\npanel_index {WORKED_EXAMPLE} not found in this chapter")