diff --git a/HANDOFF.md b/HANDOFF.md index b8b7fad..56c2ab9 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,121 +1,106 @@ -# HANDOFF, 2026-08-12/13 (seventh session) +# HANDOFF, 2026-08-13 (eighth session) Live state is in `NEXT.md`. This file is only what this session did. The previous handoff is in `JOURNAL.md`. ## Asked -"go" on item 1 of the previous plan, then a fourth GPU cycle. Then "commit and let's figure out how to -deal with the 2b1b". Then a long architecture message, "mind turning the architecture in docs?", plus a -question about panel merging before cropping. Then "well, I'd like the merge to be wired actually. and -then 3 of your fixes." +Push the repo to Gitea and open a PR to `master`. Then, from the PR review: fix the s3 URI constants, +run ruff and fix what it finds, push, run a ponytail audit, fix, push, merge. Then audit. ## Result -Item 1 written, run on a GPU, committed. Naming diagnosed from a real dialogue run and three fixes written -against it. The crop merge wired. `ARCHITECTURE.md` written. Nothing has run on a GPU since 20:25 UTC. +No GPU work. Nothing ran on a pipeline stage. PR #1 is open at +`https://gitea.kvmx.ru/kami/manga-recap-pipeline/pulls/1`, `master` <- `restore-runtime`. -## Committed and proven on a GPU +`master` held only the reconstruction commit `ff6a512`. All 27 commits of real work sat unpushed on +`restore-runtime`. -`a386e9d` here, `2daaa84` in the orchestrator. **The resolver gallery is the live cast, not cosine's top-k** -(`decisions/identity-bbox.md#cast-is-the-gallery`). `tracklets.cast_gallery` builds it from -`get_known_characters`, gender-compatible, named first, `GALLERY_CAP = 10`, re-read per tracklet. The -`if s.get("candidates")` guard is gone, so a crop with an empty cosine top-k now reaches the resolver. -`worker_vision.REF_BUDGET = 12` spreads reference images, `max(1, min(3, 12 // n))` apiece. +## Landed -Fourth cycle, 19:44-19:52 UTC: vision 116/116 3m55s, identity 116/116 2m44s, reconcile 20/20 44s. Ran with -the span cap and `GENERIC` tokenizer, which had never touched a GPU either. +| commit | what | +| --- | --- | +| `12d4ed8` | the `ARCHITECTURE.md` rewrite, which was finished but uncommitted | +| `be63b22` | deleted a 0-byte file named `II, d[i+4:i+12]))\n"; done`, added by `8113bdf` | +| `bec9411` | s3 URI templates into `transport.py`, plus `ruff.toml` and the lint fixes | -| metric | 18:07 run | 19:44 run | -| --- | --- | --- | -| detections | 119 | 119 | -| assignments | 68 | 60 | -| coverage | 57% | 50% | -| tracklets / crops | 12 / 64 | 33 / 72 | -| lead's assignments | 36 | 16 | -| characters after reconcile | 18 | 14 | -| minted / cleared | -- | 10 / 12 | +### S3 URIs -Coverage fell because gemma clears 12 crops instead of naming them wrongly. Checked by eye and confirmed by -the user. The lead's 16 are 14 him plus 2 art-in-art. `character_2b1b12a1` holds 13, all correct. -`character_f0d4e901` holds 9, of which 2 are `2b1b12a1`. +Eight templates now live in `transport.py`: `PANEL_URI`, `PAGE_PANEL_URI`, `AUDIO_URI`, +`AUDIO_FLAT_URI`, `LAYER_URI`, `CLIP_URI`, `CHAPTER_URI`, `CHAR_PNG_URI`, `CHAR_NPY_URI`. Five workers +formatted their own before. -## Measured, then fixed +`transport.ids_from_uri` replaces three separate copies of the same parse in `worker_tts`, +`worker_layers` and `worker_render`. It raises on a uri too short to carry the ids instead of returning +a wrong pair. `worker_render._mc_from_uri` is gone, its 6 call sites repointed. -Dialogue ran 116/116 in 5m57s at 20:19 UTC to test the naming path. Six claims, three defects: +### Lint -``` -p040 character_2b1b12a1 "Choi Haeseon" caption 1.00 -> NOT promoted -p010 character_b1dd5659 "Lim Seonho" caption 1.00 -> conflict flag -p047 character_b1dd5659 "Seonho" address 0.90 -> conflict flag -p011 character_f0d4e901 "Seonho" address 1.00 -> PROMOTED (wrong body) -p026 character_f0d4e901 "Seonho" address 1.00 -> PROMOTED (wrong body) -p110 character_028d4a49 "Haeseon" address 1.00 -> already named -``` +`ruff check .` exits 0. Ruff's defaults found 115. Fixed: implicit `Optional` in 8 signatures, an +unparenthesized implicit concatenation inside the ASS filter list, 5 `subprocess.run` calls now saying +`check=False` out loud, 1 unused import, 1 duplicate exception handler, 1 non-executable shebang, +4 `dict()` calls and 2 `startswith` chains. -## Written, tested, NOT run on a GPU +12 rules are off in `ruff.toml`, each with its reason. The ASYNC ones matter: ffmpeg and ffprobe run +synchronously inside `async def` endpoints, so a busy worker cannot answer `/health`. That is already +`caveats/audit-open.md#blocking-event-loop`, tracked as [#199], and it needs a per-handler refactor +rather than a lint fix. -Orchestrator, committed `2927927`, `db.add_name_claim` + `decisions/identity-naming.md`: +`CLAUDE.md` said "There is no lint or build step". It now names `ruff check .` and the rule that every +output URI is a template in `transport.py`. -- `alias_groups`: one name's word set inside another's is the same name, longer wins -- `STRONG_EVIDENCE` gains `caption` at confidence >= 0.9 -- a name held by another live character refuses to promote, files `name-already-taken` +## Audit findings -Here, committed `97cb483`: +Applied: -- `merge_faceless_captions` wired into both crop endpoints. It existed and was never called. -- `ARCHITECTURE.md`, the target shape from the user's design, each section carrying what exists today. +- `delete:` `attic/worker_ocr.py` and `attic/worker_parse.py`, 224 lines, imported by nothing and named + in no doc. `attic/char-recognition.md` and `attic/plan-workpc.md` kept, they are design history. +- `delete:` `worker_vision._panel_size`, 8 lines, one reference and it is the definition. -## Not started +Found and NOT applied, in order of size: -- **The head/body split is not fixed.** The wired merge only folds a fragment with text and no face. A body - fragment has no dialogue, so `_merge_plan` leaves it solo. No evidence gathered on how often it happens. -- The three cheap items from `ARCHITECTURE.md`: a `plane` field per detection, same-panel co-presence as a - weak cannot-link, `offscreen` as a fourth `speaker_ref` kind. -- `caveats/audit-open.md#gallery-cap-drops-the-unnamed`: the cap fired at `p097` (16 -> 10) and `p109` - (11 -> 10) and dropped exactly the freshly minted anonymous rows. +- `shrink:` `call_gemma4`, `_extract_json` and `_strip_thought` each exist twice, in `worker_vision.py` + and `worker_script.py`, and **have already diverged**. `worker_vision.call_gemma4` is 21 lines and + takes a content list, `worker_script`'s is 8 and takes a prompt string plus a system prompt. The two + `_extract_json` bodies carry the same comment about `raw_decode` but different error text. This is + the live risk: `caveats/audit-open.md#repair-fabricates` says the JSON repair pass can fabricate + dialogue, and a fix would land in one copy. A shared `gemma.py` costs one new file and removes about + 25 duplicated lines. Not done because it touches the two largest workers and nothing has run on a GPU + since. +- `stdlib:` `worker_render._wrap2`, 16 lines of greedy word-wrap with a 2-line cap, is close to + `textwrap.wrap(text, width, max_lines=2, placeholder="…")`. Not identical: `_wrap2` does + `rstrip(".,")` before the ellipsis, and textwrap reserves width for the placeholder, so the break + points differ. It renders burned-in subtitles, so the diff is user-visible. Verify against the render + self-check before swapping. +- `shrink:` `_letterbox` and `_load` are duplicated between `bubble_detect.py` and `face_detect.py`, + two ONNX detectors with the same preprocessing. About 16 lines. +- `delete:` `worker_layers.py:14` points `LAYERED_WORKFLOW` at `legacy/qwen_layered_workflow.json`. + `legacy/` was **never tracked in git** and is absent from disk, so the module's self-check has never + been able to pass here. This is the other half of `caveats/audit-open.md#layers-writes-nothing`. ## Checks ```bash -.venv/bin/python worker_vision.py # ok, including the reference-budget asserts -.venv/bin/python worker_crop.py # ok -./check_stale.sh # exit 0 before the cycle -/usr/bin/ssh kami@192.168.1.104 "cd /home/kami/docker-apps/manga-infra/orchestrator && python3 tracklets.py && python3 test_name_binding.py && python3 -m pytest -q --ignore=test_api.py" # 121 passed +ruff check . # All checks passed +.venv/bin/python worker_render.py # ok, ffmpeg ran, about 4 minutes ``` -Each new assert was confirmed to fail with its fix disabled. +Self-checks pass: `transport`, `collage`, `bubble_detect`, `test_vision_parse`, `worker_crop`, +`worker_scene`, `worker_script`, `worker_identity`, `worker_tts`, `session_manager`, `worker_vision`, +`worker_render`, `face_detect`. + +`worker_layers` fails, and did before this branch, on the missing `legacy/` file above. ## Next command -Re-crop and run the fifth cycle. **Clear the panels prefix first** or the merge will not take effect: -`crop_webtoon` skips an upload when the key exists. +The fifth GPU cycle is still the next pipeline work. It is blocked only on the GPU being free. The +exact sequence is in `JOURNAL.md` under the seventh session, and `NEXT.md` item 1 holds the +expectations. **Clear the panels prefix first** or the wired caption merge +will not take effect. -```bash -cd /home/kami/Programs/n8n-worker && ./check_stale.sh # restart crop, it is stale after the wiring -/usr/bin/ssh kami@192.168.1.104 "cd /home/kami/docker-apps/manga-infra/orchestrator && docker compose up -d --build orchestrator" -J=778297bc-e7ce-439d-91b5-8a027060d17f; M=ef105a86-4b7e-4ac4-b45c-b7d83b8f5b5e; C=7c944dd4-e972-42c7-ba60-9f6939548e80 -/usr/bin/ssh kami@192.168.1.104 "mc rm --recursive --force homesrv/panels/$M/$C/panels/" -/usr/bin/ssh kami@192.168.1.104 "curl -s -X POST http://127.0.0.1:9090/characters/reset -H 'Content-Type: application/json' -d '{\"manga_id\":\"$M\",\"confirm\":true}'" -# honour restart_identity_worker: true -/usr/bin/ssh kami@192.168.1.104 "curl -s -X POST http://127.0.0.1:9090/stage/clear -H 'Content-Type: application/json' -d '{\"job_id\":\"$J\",\"stage\":\"crop\"}'" -for S in crop vision identity reconcile dialogue; do /usr/bin/ssh kami@192.168.1.104 "curl -s --max-time 7200 -X POST http://127.0.0.1:9090/stage/run -H 'Content-Type: application/json' -d '{\"job_id\":\"$J\",\"stage\":\"$S\"}'"; done -``` +Before that, or instead of it while the GPU is busy, `ARCHITECTURE.md` step 0 is the measurement spine: +`eval/chapter-truth.json` plus purity and fragmentation printed by `audit_registry.py`. -Expect: fewer than 116 panels, `2b1b12a1` named `Choi Haeseon`, no `conflicting-name-claims` on the lead, -and a `name-already-taken` flag where the female `Seonho` used to be. - -## Traps confirmed or found - -- **`crop_webtoon` skips an upload when the key exists.** Right for a resume, silently wrong after any - slicing change. Clear `s3://panels///panels/` before re-cropping. -- Restart a worker with `tmux send-keys -t manga-workers: C-c`, then re-send the launch line from - `start_workers.sh` with its `MIOPEN_ENV` prefix. A `pgrep` kill matches the calling shell. -- `POST /characters/reset` returns `restart_identity_worker: true`. Honour it. -- `docker compose up --build orchestrator` drops any `docker cp`'d script, `audit_registry.py` included. -- A heredoc piped into `docker exec` over `/usr/bin/ssh` produces no output. `scp` the file, `docker cp` - it, then run it. -- `panel_order` and the panel filename differ by one: `panel_order` 10 is `p009.png`. -- The contact-sheet script that found the identity errors is `sheet.py` in the session scratchpad, not - committed. It reads `assigns.json` dumped from `identity_assignments` and pulls crops from - `manga//characters/_crops/`. +One ordering trap, unresolved: the truth file cannot be keyed on `panel_id` or `character_id`. The +fifth cycle re-crops and calls `/characters/reset`, which destroys both. Key it on page-space geometry, +or write it after the fifth cycle rather than before. diff --git a/attic/worker_ocr.py b/attic/worker_ocr.py deleted file mode 100644 index d7371dd..0000000 --- a/attic/worker_ocr.py +++ /dev/null @@ -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") diff --git a/attic/worker_parse.py b/attic/worker_parse.py deleted file mode 100644 index 08b177e..0000000 --- a/attic/worker_parse.py +++ /dev/null @@ -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") diff --git a/caveats/CLAUDE.md b/caveats/CLAUDE.md index d7fbb8e..e5d3270 100644 --- a/caveats/CLAUDE.md +++ b/caveats/CLAUDE.md @@ -28,6 +28,7 @@ a complaint, so give it one or drop it. | [`layers` runs after `tts`, so pipelined solo beats lose parallax](audit-open.md#layers-after-tts) | AUDIT.md | | [`completed` means something different in each stage](audit-open.md#inconsistent-stage-policy) | AUDIT.md | | [Identity worker caches characters the orchestrator has deleted](audit-open.md#stale-known-cache) | AUDIT.md | +| [The gemma helpers exist twice and have diverged](audit-open.md#gemma-helpers-duplicated) | repo audit | | [A character seen once gets no assignment at all](audit-open.md#pending-in-worker-memory) | AUDIT.md | | [MinIO credentials are hardcoded in committed source](audit-open.md#hardcoded-credentials) | AUDIT.md | | [Assemble marks a job completed with no clips](audit-open.md#empty-assemble) | AUDIT.md | diff --git a/caveats/audit-open.md b/caveats/audit-open.md index 3fcc889..7aa0a8e 100644 --- a/caveats/audit-open.md +++ b/caveats/audit-open.md @@ -255,3 +255,19 @@ query crop. The cap is a VRAM and context budget, not a modelling choice. here. The fix is to order the gallery by how many assignments each character already holds in this chapter, so the tail is the rows nobody has matched rather than the rows nobody has named yet. That needs one count query per tracklet. + +## The gemma helpers exist twice and have diverged {#gemma-helpers-duplicated} + +**Open. Found by the repo audit of 2026-08-13, not yet by a failure.** + +`call_gemma4`, `_extract_json` and `_strip_thought` are defined in both `worker_vision.py` and +`worker_script.py`. They are no longer the same code. `worker_vision.call_gemma4` is 21 lines and takes a +content list; `worker_script`'s is 8 and takes a prompt string plus a system prompt. Both `_extract_json` +bodies carry the same comment about `raw_decode` stopping at the first object, and raise different errors. + +Who pays: whoever fixes the JSON path. `#repair-fabricates` above says the repair pass can fabricate +dialogue. A fix written against one copy leaves the other worker on the old behaviour, and no self-check +compares them. + +**Revisit when** either JSON path is touched for any reason. The fix is a `gemma.py` holding the three +helpers, imported by both workers, which removes about 25 duplicated lines and costs one file. diff --git a/worker_vision.py b/worker_vision.py index 3bb6bc1..bf0b88d 100644 --- a/worker_vision.py +++ b/worker_vision.py @@ -367,16 +367,6 @@ def _mark_has_face(img, chars: list) -> list: return chars -def _panel_size(path: str) -> tuple: - """(width, height) of a panel image, or (0, 0) when it cannot be read.""" - import cv2 - img = cv2.imread(path) - if img is None: - return (0, 0) - h, w = img.shape[:2] - return (w, h) - - @app.post("/vision") async def vision(data: VisionInput): local = transport.get(data.panel_uri, f"{SHM}/vision_{uuid.uuid4().hex[:8]}.png")