From 54bd126cd793ccd67ca3e00548ebfb91822e6db1 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 12 Aug 2026 22:21:52 +0400 Subject: [PATCH] Order bbox corners, and find the ground truth is for another manga `_bbox_to_pixels` sorts each coordinate pair after clamping. Run B returned `p007 person_1` as `[226, 417, 130, 551]`, x1 > x2, which cropped to nothing and lost that detection silently. The third GPU cycle came back with 0 degenerate boxes over 119 detections. The plan's first item cannot run as written. All 145 rows of `identity_labels` belong to chapter 8ca8249b, a different manga. Chapter 7c944dd4 has none, so `/review/identity` reports `accuracy: null`. Scoring 8ca8249b gives 7/138 on an identity run that predates every fix. The cycle also settled the coverage question. All 68 assignments landed on face-bearing detections and none on a gated one, so recall among face-bearing detections is 96%, up from 82%. Coverage is the `has_face` gate and nothing else. What is now open is precision: the lead holds 36 of 68 assignments. Co-Authored-By: Claude Opus 5 --- HANDOFF.md | 182 +++++++++++-------------------------- JOURNAL.md | 58 ++++++++++++ NEXT.md | 64 +++++++------ caveats/CLAUDE.md | 1 - caveats/audit-open.md | 47 +++++----- decisions/CLAUDE.md | 1 + decisions/identity-bbox.md | 22 +++++ worker_vision.py | 16 +++- 8 files changed, 211 insertions(+), 180 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 2a9fd5c..36be549 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,160 +1,88 @@ -# HANDOFF, 2026-08-12 (fifth session) +# HANDOFF, 2026-08-12 (sixth session) Live state is in `NEXT.md`. This file is only what this session did. The previous handoff is in `JOURNAL.md`. ## Asked -"go ahead" on the GPU cycle the fourth session left staged. Then "what do we do now". Then go ahead on -the fixes its evidence asked for, and run it again. +The two files, then "go" on the third GPU cycle. ## Result -Two full GPU cycles ran. All four of the fourth session's identity changes are now proven on real panels. -Four more fixes were written on top, all orchestrator-side, all deployed. Coverage fell twice and that is -the open question. +One worker fix, one measurement that killed the plan's first item, one GPU cycle. The cycle proved the new +fix and the fifth session's merge fix. Coverage rose for the first time, 50% to 57%. It is now explained +entirely by the `has_face` gate. -| cycle | detections | assignments | coverage | registry | named | -| --- | --- | --- | --- | --- | --- | -| baseline (13:11, pre-change) | 110 | 77 | 70% | 8 | 1 | -| run A (16:39-16:45) | 110 | 67 | 61% | 16 | 2 | -| run B (17:38-17:44) | 117 | 59 | 50% | 20 | 2 | +| metric | 17:38 run | 18:07 run | +| --- | --- | --- | +| detections | 117 | 119 | +| assignments | 59 | 68 | +| coverage | 50% | 57% | +| degenerate boxes | 1 | 0 | +| assigned among face-bearing | 59/72 = 82% | 68/71 = 96% | +| assigned among gated | -- | 0 | +| chains deeper than one hop | 1 | 0 | +| `merged_from` stamps | 9 | 22 | -Vision is non-deterministic, so detection counts move between runs. Each cycle is ~6 minutes: -vision ~3m50s, identity ~1m25s, reconcile ~50s. +## The corner-ordering fix -## First, a correction the session started with +`_bbox_to_pixels` sorts each coordinate pair after clamping. Three lines +(`decisions/identity-bbox.md#bbox-corners-ordered`). The self-check feeds it run B's real +`[226, 417, 130, 551]` and asserts `[117, 688, 203, 909]`. Unsorted the call returns `[203, 688, 117, 909]`, +so the assert fails without the fix. -The vision/identity/reconcile timestamps in `/job/status` are UTC. The git log is local, UTC+4. The run -that looked like a completed rerun was the pre-change baseline: it finished 13:17 local, and -`_mark_has_face` was not committed until 19:07. The fourth session's handoff was right that nothing had -run. +The run returned 0 degenerate boxes over 119 detections. The caveat is deleted and the decision is indexed. -## Run A: the four staged changes, verified +## The measurement that killed item 1 -| change | verdict | -| --- | --- | -| `has_face` stamp | on all 110 detections, 67 true / 43 gated (39%) | -| extras gate | panel 7's two wrong bindings gone | -| NONE mints | 16 characters where 8 existed | -| non-destructive merge | 7 rows carry `merged_into`, 9 assignments stamped `merged_from:` | +The plan said to score precision with `eval_identity.py` against the 145 rows of `identity_labels`. Every +one of those rows belongs to chapter `8ca8249b`, a different manga with cast "Rico" and "Ikekin", spread +over 81 panels. Chapter `7c944dd4` has zero labels, so +`/review/identity?job_id=778297bc...` returns `labeled: 0, correct: 0, accuracy: null`. -Panel 7, the worked example: +Scoring `8ca8249b` anyway gives 7/138, with 113 rows labelled as a real person and left unassigned. That +chapter's identity run is stale: 44 assignments over 246 panels, predating every fix. The number measures +old code on the wrong chapter. -``` -person_1 Seonho 1.00 -> -- none -- has_face=False -person_2 character_f7a4fd 0.00 -> character_519d2b 0.00 has_face=True -person_4 character_d72710 0.94 -> -- none -- WRONG binding removed -person_5 Seonho 1.00 -> -- none -- WRONG binding removed -``` +So the eval path is proven end to end and the ground truth is absent. A precision number needs a hand pass +over `7c944dd4` through `POST /review/identity/label`, keyed by bbox rather than `local_id`. -`person_1` going unassigned is correct, and this was measured, not assumed. Ran `face_detect.detect_faces` -on `p006.png` directly: one face on the whole panel, conf 0.599, inside `person_2`. Swept the threshold to -0.04 and nothing else appears above 0.056. The crop shows the lead drawn from behind at his desk, back of -the head and headphones. Detector right, gate right, lead unidentifiable in that panel. +## What the cycle settled -`person_1`'s box now frames the lead. The old "empty window mullion" note was written against pre-fix -boxes and is retired. +**Coverage is the gate and nothing else.** All 68 assignments landed on face-bearing detections and none on +a gated one. Recall among face-bearing detections is 96%, up from 82%. The resolver is not losing cast, so +the suspicion in `caveats/audit-open.md#coverage-trend` is closed. -## Run A's new defect: a roster hint named the wrong man +**The merge-chain fix holds under load.** 9 merges this run against 7 last run, and 22 `merged_from` +stamps. No chain is deeper than one hop. -Verified against the art, three real people: +## What the cycle opened -- `character_92129ac7` "Lim Seonho", 22 assignments. The p010 caption reads `LIM SEONHO (29)`, yellow - plaid shirt, headphones, matching the roster's description. Correct. -- `character_556aef60`, 25 assignments, unnamed. The woman with short black hair and pearls, the roster's - second character, whom the roster itself calls "Unknown". Correct. -- `character_dbadfff7` "Seonho", 15 assignments. A different man, glasses, dark clothing, wearing the - lead's roster name. +**The lead may be absorbing.** 36 of 68 assignments, 53%, against 25 of 59 before. `audit_registry.py` +flags it. A protagonist in half the panels looks identical to an over-merge without labels. -p020 assigned `Seonho` to `person_1` and `Lim Seonho` to `person_3`, two people in one panel. - -## What was changed, and where the cause was - -**Roster hints no longer seed detection.** Deleted the two lines at `service.py:882` that appended -`_roster_char_hints` to `known_characters`. `build_detect_prompt` drops any hint without a name. Passing a -nameless hint would have contributed nothing, so removing the seeding was the only real option. -Names now reach detection from registry rows only, which carry embeddings and were named from an in-panel -caption or address. The roster still feeds `roster_cast` in `run_stage_dialogue`, where names match -against speech rather than faces. - -**`merge_characters` keeps `merged_into` one hop deep.** Two halves, one per direction, and the second was -missed on the first attempt: - -- resolve the keeper to its chain root before merging (a bounded 64-step walk, the cap only so a cycle - cannot hang reconcile) -- repoint the loser's own dependents to the keeper when the loser is retired - -The keeper walk alone does not work. Run B still produced `477c1894 -> a92d9df4 -> 4fb94c15`. At merge -time that pair was fine. The chain formed later, when a row that was already somebody's keeper was itself -retired. - -**`reset_registry` deletes `name_claims`** for the manga's panels and reports the count. Confirmed live: -run B's reset reported `name_claims: 5`, the orphans that had pointed at `character_afa7623b` since two -resets earlier. - -**`audit_registry.py` is in the repo.** It had only ever been `docker cp`'d, so every rebuild dropped it. -`Dockerfile` has `COPY . .`, so it is baked now and that trap is gone. - -## Run B: what the fixes did - -The registry split is fixed. "Lim Seonho" came back as ONE row holding 25 assignments. That answers the -open risk from before the run: dropping the roster hint did not split the lead across the panels before -his caption. "Seonho" fell from 15 assignments to 1. - -Two things run B surfaced: - -- **A second naming mechanism, untouched by the fix.** Even with no roster hint, p011 and p026 emit - `name: "Seonho"` on `person_2` from in-panel text. Detection reads a name off the panel and attaches it - to the wrong body. That is what keeps the glasses man named, now at 1 assignment instead of 15. -- **One degenerate bbox.** `p007 person_1` is `[226, 417, 130, 551]`, x1 > x2. One in 117. It crops to - nothing, so that detection can never enroll or match. `_bbox_to_pixels` clamps each coordinate but never - orders the corners. +**Panel 7 got worse.** 5 of its 6 detections carry `has_face = False`, and `person_6`, the one that does, +went unassigned. The previous run bound its colleague. That is the cost of requiring a face, `NEXT.md` +item 4. ## Not done -- **The degenerate-box guard.** It is a worker change (`worker_vision.py:_bbox_to_pixels`), so it needs a - vision worker restart and a third GPU cycle to prove. Not started, nothing half-edited. -- **The coverage trend.** 70% -> 61% -> 50%. Part is the `has_face` gate, which is stable at ~39% of - detections across both runs. Framed against face-bearing detections only, run B assigned 59 of 72, or - 82%. Nothing establishes whether the rest is correct abstention or lost cast. Settle that number before - trusting the registry. -- **Nothing downstream re-ran.** The job is parked at `dialogue waiting` with every stage below it - cleared. No clip or chapter has been rebuilt against the new cast. +- Nothing downstream re-ran. The job is still parked at `dialogue waiting` with every stage below cleared. +- Nothing is committed. All edits are in the working tree. ## Checks ```bash -/usr/bin/ssh kami@192.168.1.104 "cd /home/kami/docker-apps/manga-infra/orchestrator && python3 -m pytest -q --ignore=test_api.py" # 118 passed, was 116 -./check_stale.sh # exit 0, all 9 workers current +.venv/bin/python worker_vision.py # self-check ok, including the swapped-corner assert +./check_stale.sh # exit 0 before the run and after it ``` -Both new merge tests were confirmed to fail with their fix disabled, then the fix was restored and the -suite re-run. The deployed container was verified by parsing its source, not grepping it. The first -attempt gave a false negative, matching `_roster_char_hints` inside the comment that explains its removal. +## Traps confirmed -## Next command - -The third cycle, after adding the corner-ordering guard to `_bbox_to_pixels` and restarting the vision -worker. Watch coverage against 50% and the degenerate box count against 1. - -```bash -cd /home/kami/Programs/n8n-worker && ./check_stale.sh # must exit 0 -J=778297bc-e7ce-439d-91b5-8a027060d17f; M=ef105a86-4b7e-4ac4-b45c-b7d83b8f5b5e -/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}'" -/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\":\"vision\"}'" -# restart the identity worker here, see the trap below -for S in vision identity reconcile; do /usr/bin/ssh kami@192.168.1.104 "curl -s --max-time 5400 -X POST http://127.0.0.1:9090/stage/run -H 'Content-Type: application/json' -d '{\"job_id\":\"$J\",\"stage\":\"$S\"}'"; done -/usr/bin/ssh kami@192.168.1.104 "docker exec manga-orchestrator python3 /app/audit_registry.py 7c944dd4-e972-42c7-ba60-9f6939548e80 6" -``` - -## Traps confirmed or found - -- **`kill $(pgrep -f "worker_identity:app")` kills the shell running it.** The pattern matches the calling - command line. It killed this session's own script mid-way, so the `send-keys` relaunch never fired and - the identity worker sat dead. Restart it with the two `tmux send-keys` lines only, then poll `/health`. -- **Verify deployed code by parsing it, not by grepping it.** A comment explaining a removal contains the - name of the thing removed. -- **A test that cannot fail proves nothing.** Both merge tests were run with their fix disabled first. -- Panel-7 line numbers move between runs, because vision is non-deterministic and `local_id` is assigned - top-to-bottom per run. In run B the lead is `person_5`, not `person_1`. Compare by bbox, not by id. +- Restart a worker with `tmux send-keys -t manga-workers: C-c`, then re-send the launch line from + `start_workers.sh` with the `MIOPEN_ENV` prefix. `pgrep`-based kills match the calling shell. +- `POST /characters/reset` returns `restart_identity_worker: true`. Honour it, the worker caches the + registry. +- `identity_assignments` has no `method` column. The `merged_from:` stamps live in + `identity_assignment_sources`. +- `chapters` has no `title` column. diff --git a/JOURNAL.md b/JOURNAL.md index f9b99e2..857869e 100644 --- a/JOURNAL.md +++ b/JOURNAL.md @@ -586,3 +586,61 @@ first. Deployed by rebuild and verified by parsing the container's source. Coverage is now 70 -> 61 -> 50 across three runs and is the open question, recorded as `caveats/audit-open.md#coverage-trend`. `identity_labels` already holds 145 rows of ground truth, so the next move is to score with `eval_identity.py` rather than to keep reading the coverage number. + +## 2026-08-12, sixth session — the ground truth is for the wrong manga + +No GPU work. Two things, both cheap. + +**`_bbox_to_pixels` orders its corners.** Each coordinate pair is sorted after clamping. The self-check +feeds it run B's real `[226, 417, 130, 551]` and asserts `[117, 688, 203, 909]`. Unsorted the call returns +`[203, 688, 117, 909]`, so the assert fails without the fix. `worker_vision.py` self-check passes. +The vision worker is now STALE and needs a restart before the next cycle. + +**Item 1 of the plan cannot run as written.** All 145 rows of `identity_labels` belong to chapter +`8ca8249b`, a different manga with cast "Rico" and "Ikekin", spread over 81 panels. Chapter `7c944dd4` +has none, so `/review/identity?job_id=778297bc...` returns `labeled: 0, correct: 0, accuracy: null`. + +Scoring `8ca8249b` anyway gives 7/138, with 113 rows labelled as a real person and left unassigned. That +chapter's identity run is stale: 44 assignments over 246 panels, predating every fix. The number measures +old code on the wrong chapter. + +So the eval path is proven end to end and the ground truth is absent. A precision number for the current +registry needs a hand-labelling pass over `7c944dd4` through `POST /review/identity/label`, keyed by bbox +rather than by `local_id`, because vision reassigns `local_id` every run. + +## 2026-08-12 18:07-18:13 UTC, third GPU cycle + +Restarted vision (window 3) and identity (`/characters/reset` returned `restart_identity_worker: true`), +both by `send-keys C-c` then re-sending the launch line, never by `pgrep`. `check_stale.sh` exit 0 before +the run. Reset dropped 20 characters, 59 assignments, 69 sources, 168 S3 objects. + +vision 116/116 in 3m59s, identity 116/116 in 1m24s, reconcile 18/18 in 50s. + +| metric | 17:38 run | 18:07 run | +| --- | --- | --- | +| detections | 117 | 119 | +| assignments | 59 | 68 | +| coverage | 50% | 57% | +| degenerate boxes | 1 | **0** | +| `has_face` true / gated | 72 / 45 | 71 / 48 | +| assigned among face-bearing | 59/72 = 82% | **68/71 = 96%** | +| assigned among gated | -- | 0 | +| registry rows / merged | 20 / 7 | 18 / 9 | +| `merged_from` stamps | 9 | 22 | +| chains deeper than one hop | 1 | **0** | + +The corner-ordering fix works: 0 degenerate boxes. The merge-chain fix holds under a heavier merge load, +9 merges and no chain. + +**Coverage is now the gate and nothing else.** Every assignment landed on a face-bearing detection and none +on a gated one. Recall among face-bearing detections is 96%. So the 43% with no assignment is the 40% the +gate drops plus 3 detections, and the resolver is not losing cast. That closes the part of +`#coverage-trend` that suspected the resolver. + +**The open question moved to precision.** The lead holds 36 of 68 assignments, 53%, against 25 of 59 +before, and `audit_registry.py` flags it as absorbing. The merge count rose from 7 to 9 over the same +interval. Whether 36 is a protagonist in half the panels or an over-merge cannot be told apart without +labels, which is item 1. + +Panel 7 got worse, not better: 5 of its 6 detections carry `has_face = False`, and `person_6`, the one that +does, went unassigned. The previous run bound its colleague. That is the cost of requiring a face, item 4. diff --git a/NEXT.md b/NEXT.md index 7dca918..ccfb237 100644 --- a/NEXT.md +++ b/NEXT.md @@ -1,6 +1,6 @@ # NEXT -Updated 2026-08-12 (fifth session). What the fourth session did is in `HANDOFF.md`, the run is in +Updated 2026-08-12 (sixth session). What the fifth session did is in `HANDOFF.md`, the runs are in `JOURNAL.md`. ## State @@ -9,38 +9,50 @@ The chapter runs end to end. The A/V sync defect is fixed and `chapter.mp4` is r against audio 364.122s at `25/1`. Job `778297bc-e7ce-439d-91b5-8a027060d17f`, chapter `7c944dd4-e972-42c7-ba60-9f6939548e80`, 116 panels. -Two GPU cycles ran on 2026-08-12, the last 17:38-17:44 UTC on a reset registry. The job is `running` and -parked at `dialogue waiting`. Everything from `dialogue` down is cleared and stale. +Three GPU cycles ran on 2026-08-12, the last 18:07-18:13 UTC on a reset registry. The job is `running` and +parked at `dialogue waiting`. Everything from `dialogue` down is cleared and stale. All 9 workers are +current. -Seven changes are now proven on real panels: the fourth session's four, plus this session's roster-hint -removal, merge-chain flattening and `name_claims` reset. The registry after the last run: +Eight changes are now proven on real panels. The fourth session contributed four. The fifth added the +roster-hint removal, merge-chain flattening and `name_claims` reset. This session added bbox corner +ordering. The registry after the 18:07 run holds 18 rows, 9 live and 9 carrying `merged_into`: -- `character_713ea2ce` "LIM SEONHO", 25 assignments, the lead, one row, named from the p010 caption. -- `character_4fb94c15`, 18 assignments, the woman, correct and unnamed. -- `character_023ba5a3` "Seonho", 1 assignment, still the wrong man, down from 15. +- `LIM SEONHO`, 36 assignments, the lead, one row, named from the p010 caption. +- `character_b112d4`, 23 assignments, the woman, correct and unnamed. +- `Seonho`, still the wrong man, still named off in-panel text. -Coverage is 50%, from 61% and 70% before it. That trend is the open question and is filed as -`caveats/audit-open.md#coverage-trend`. +Coverage is 57%, from 50%, 61% and 70% before it, and it is now the `has_face` gate and nothing else. All 68 +assignments landed on face-bearing detections and none on a gated one. Recall among face-bearing detections +is 68 of 71, or 96%, up from 82%. Degenerate boxes went from 1 in 117 to 0 in 119, and the merge +chain is flat with 22 assignments stamped `merged_from`. + +The open question moved. The lead holds 36 of 68 assignments, 53%, which `audit_registry.py` flags as +absorbing. Nothing separates a protagonist in half the panels from an over-merge +(`caveats/audit-open.md#coverage-trend`). ## Next -1. **Settle coverage before trusting the registry.** 70% -> 61% -> 50% over three runs. `has_face` gates a - steady 39%, and against face-bearing detections alone the last run assigned 59 of 72 (82%). Nothing - separates correct abstention from lost cast. `identity_labels` already holds 145 rows of human ground - truth, and `eval_identity.py` already scores against it. Measure precision and recall instead of - reading the coverage number (`caveats/audit-open.md#coverage-trend`). -2. **Order the corners in `_bbox_to_pixels`.** `p007 person_1` came back `[226, 417, 130, 551]`, x1 > x2, - 1 in 117. It crops to nothing, so that detection is silently lost - (`caveats/audit-open.md#degenerate-bbox`). Two `min`/`max` pairs. It is a worker change, so it needs a - vision restart and a GPU cycle to prove. +1. **Decide whether the lead absorbing 53% of assignments is real.** It needs labels this chapter lacks. + The resolver is cleared: recall among face-bearing detections is 96%. What is unmeasured is precision. + The lead went from 25 of 59 to 36 of 68 as the merge count rose from 7 to 9. + + The previous plan pointed at `identity_labels` and its 145 rows. Measured this session: every one of + those rows belongs to chapter `8ca8249b`, a different manga. Chapter `7c944dd4` has zero labels, so + `/review/identity` reports `labeled: 0, accuracy: null`. Scoring `8ca8249b` gives 7/138 on an identity + run that predates all eight fixes. The eval plumbing works and the ground truth is missing. + Labelling `7c944dd4` by hand is the only path to a precision number + (`caveats/audit-open.md#coverage-trend`). +2. ~~**Order the corners in `_bbox_to_pixels`.**~~ **Done 2026-08-12, run and verified on a GPU.** 0 + degenerate boxes over 119 detections (`decisions/identity-bbox.md#bbox-corners-ordered`). 3. **Detection still names from in-panel text and can hit the wrong body.** p011 and p026 emit `name: "Seonho"` on `person_2` with the roster hint gone. That is the residue of the naming defect and the reason the glasses man is named at all (`decisions/identity-bbox.md#roster-does-not-name`, "not covered"). -4. **Decide what a back-turned character costs.** `has_face` gates 39% of detections. Panel 7's lead is at - his desk from behind. `face_detect` finds one face on the whole panel at conf 0.599, and nothing else - above 0.056 even at a 0.04 threshold. The gate is right and the detector is right. The lead is still - unidentifiable there. Options are a body or head detector alongside the face one, or letting the +4. **Decide what a back-turned character costs.** This is now the whole of the coverage number. `has_face` + gates 40% of detections. On the 18:07 run panel 7 lost 5 of its 6 detections to the gate, and its one + face-bearing detection went unassigned. Panel 7's lead is at his desk from behind. `face_detect` + finds one face on the whole panel at conf 0.599, and nothing else above 0.056 even at a 0.04 + threshold. The gate is right and the detector is right. The lead is still unidentifiable there. Options are a body or head detector alongside the face one, or letting the tracklet carry identity across a back-turned panel. Do not lower `FACE_CONF`, the sweep shows nothing to find. @@ -68,9 +80,9 @@ Coverage is 50%, from 61% and 70% before it. That trend is the open question and `_present_characters` stays ungated on purpose, reasoned out in the decision. The remaining gap is that vision still emits extras into the blob, which is deliberate so the audit can see what was gated. - d. ~~Merge and split.~~ **Safety net done and now exercised.** The rerun's `reconcile` merged 7 rows - and stamped 9 assignments `merged_from:`, so the non-destructive path is proven on real data. It also - produced the chain in item 3, which is the first thing to fix in it. + d. ~~Merge and split.~~ **Safety net done and now exercised.** The 18:07 run merged 9 rows and stamped + 22 assignments `merged_from:`, with no chain deeper than one hop. The non-destructive path and the + flattening fix are both proven on real data. A merge no longer deletes the losing row: it sets `merged_into`, and stamps every repointed assignment with diff --git a/caveats/CLAUDE.md b/caveats/CLAUDE.md index e8b9ce2..be09a22 100644 --- a/caveats/CLAUDE.md +++ b/caveats/CLAUDE.md @@ -42,5 +42,4 @@ a complaint, so give it one or drop it. | [Identity cannot say "a person with no name"](speaker-attribution.md#no-anonymous-identity) | 2026-08-12 panel 7 | | [Vision does not separate a background extra from cast](speaker-attribution.md#extras-as-cast) | 2026-08-12 panel 7 | | [Cast reference profiles are enrolled from wrong crops](speaker-attribution.md#poisoned-reference-set) | 2026-08-12 panel 7 | -| [Detection can order a bbox backwards](audit-open.md#degenerate-bbox) | next vision run | | [Identity coverage has fallen on every run since the gate landed](audit-open.md#coverage-trend) | before the next downstream run | diff --git a/caveats/audit-open.md b/caveats/audit-open.md index 9eea843..99c28c8 100644 --- a/caveats/audit-open.md +++ b/caveats/audit-open.md @@ -182,28 +182,33 @@ Every clip in that run therefore has no parallax. The stage is a sibling of Costs: a silent quality regression that no status field reveals. Revisit when: parallax matters for a deliverable, or before quoting this run as a full-pipeline pass. -## Detection can order a bbox backwards {#degenerate-bbox} - -`p007 person_1` came back as `[226, 417, 130, 551]` on the 2026-08-12 17:38 run: x1 greater than x2. One -detection in 117. `_bbox_to_pixels` clamps every coordinate into the panel but never orders the corners, -so the box survives as a zero-or-negative-width region. It crops to nothing, so that detection can never -enroll, embed or match, and it is silently lost rather than reported. - -The guard is two `min`/`max` pairs in `_bbox_to_pixels`. It was not written this session because it is a -worker change and needs a vision worker restart plus a GPU cycle to prove. - -Revisit trigger: the next vision run. Count degenerate boxes against 1 in 117. - ## Identity coverage has fallen on every run since the gate landed {#coverage-trend} -70% -> 61% -> 50% across the 13:11 baseline, the 16:39 run and the 17:38 run. The `has_face` gate explains -part of it and is stable, gating 39% of detections on both post-change runs. Against face-bearing -detections only, the 17:38 run assigned 59 of 72, or 82%. +70% -> 61% -> 50% -> 57% across the four 2026-08-12 runs. The gate is stable at 39 to 40% of detections. -Nothing yet separates correct abstention from lost cast, and both fixes that could have caused the second -drop landed together. A back-turned lead is a correct abstention. A real character the resolver refused is -not, and the two are indistinguishable in the coverage number alone. +**The 18:07 run resolves most of this.** Coverage is now the gate and nothing else. All 68 assignments went +to face-bearing detections and none to a gated one. Recall among face-bearing detections is 68 of 71, or +96%, up from 82%. The resolver is not losing cast. The 43% that carries no assignment is the 40% the gate +drops plus 3 detections. -Revisit trigger: before trusting the registry for a downstream run. `identity_labels` already exists for -exactly this and holds 145 rows of human ground truth, so `eval_identity.py` can score precision against -recall instead of counting assignments. +So the coverage number no longer measures the resolver. What it measures is the cost of requiring a face, +which is item 4 in `NEXT.md`. Panel 7 is the case: 5 of its 6 detections carry `has_face = False`, and the +one that does is unassigned. + +The open question moved. The lead now holds 36 of 68 assignments, or 53%, against 25 of 59 before, and +`audit_registry.py` flags that as absorbing. Whether 36 is the protagonist appearing in half the panels or +an over-merge cannot be told apart without labels. + +Revisit trigger: before trusting the registry for a downstream run. + +**The 145 ground-truth labels do not cover this chapter.** Measured 2026-08-12. All 145 rows in +`identity_labels` key to chapter `8ca8249b`, a different manga with cast "Rico" and "Ikekin", over 81 +distinct panels. Chapter `7c944dd4` has zero labels, so `/review/identity?job_id=778297bc...` returns +`labeled: 0, accuracy: null`. Scoring 8ca instead gives 7/138, because its identity run is stale: 44 +assignments over 246 panels, predating all seven fixes. That number measures the old code on the wrong +chapter and settles nothing. + +The plumbing works, the labels are missing. Precision and recall on the current registry need a hand +pass over `7c944dd4` first, via `POST /review/identity/label` per detection. Label against the bbox, not +the `local_id`. Vision is non-deterministic and reassigns `local_id` top-to-bottom every run. A label +taken before a vision rerun then points at whoever now occupies that slot. diff --git a/decisions/CLAUDE.md b/decisions/CLAUDE.md index 89afdde..98943df 100644 --- a/decisions/CLAUDE.md +++ b/decisions/CLAUDE.md @@ -47,3 +47,4 @@ still live belongs in `caveats/`. | [The extras gate runs at enrollment and at narration, not at the speaker prompt](identity-bbox.md#extras-gate-consumers) | closed | | [A roster name is a guess, so it never reaches detection](identity-bbox.md#roster-does-not-name) | closed | | [`merged_into` is exactly one hop deep](identity-bbox.md#merge-chains-flatten) | closed | +| [`_bbox_to_pixels` orders the corners, because the model sometimes swaps them](identity-bbox.md#bbox-corners-ordered) | closed | diff --git a/decisions/identity-bbox.md b/decisions/identity-bbox.md index 1503671..d5fc2f0 100644 --- a/decisions/identity-bbox.md +++ b/decisions/identity-bbox.md @@ -221,3 +221,25 @@ Two rules, one per direction, and both are needed: The keeper walk alone is not enough, which the 17:38 run proved by producing `477c1894 -> a92d9df4 -> 4fb94c15` with the walk deployed. At merge time that pair was fine. The chain formed later, when a row that was already somebody's keeper was itself retired. + +## `_bbox_to_pixels` orders the corners, because the model sometimes swaps them {#bbox-corners-ordered} + +**Closed, 2026-08-12.** + +The 17:38 run returned `p007 person_1` as `[226, 417, 130, 551]`, x1 greater than x2. One detection in 117. +Clamping each coordinate into the panel kept the swap, so the box stayed a negative-width region. It +cropped to nothing, so that detection could not enroll, embed or match, and nothing reported the loss. + +`_bbox_to_pixels` now sorts each pair after clamping: + +```python +xs = sorted((clamped_x1, clamped_x2)) +ys = sorted((clamped_y1, clamped_y2)) +c["bbox"] = [xs[0], ys[0], xs[1], ys[1]] +``` + +Sorting is enough. A zero-area box still crops to nothing, and no consumer needs a minimum size that it +does not already enforce. The self-check feeds the real swapped box in and asserts `[117, 688, 203, 909]`, +which fails without the sort. + +Proven on the 18:07 run: 0 degenerate boxes over 119 detections. diff --git a/worker_vision.py b/worker_vision.py index dcc40ce..3bbfb4e 100644 --- a/worker_vision.py +++ b/worker_vision.py @@ -323,10 +323,13 @@ def _bbox_to_pixels(chars: list, w: int, h: int) -> list: b = c.get("bbox") if not (isinstance(b, list) and len(b) == 4 and all(isinstance(v, (int, float)) for v in b)): continue - c["bbox"] = [min(w, max(0, round(b[0] * w / BBOX_GRID))), - min(h, max(0, round(b[1] * h / BBOX_GRID))), - min(w, max(0, round(b[2] * w / BBOX_GRID))), - min(h, max(0, round(b[3] * h / BBOX_GRID)))] + xs = sorted((min(w, max(0, round(b[0] * w / BBOX_GRID))), + min(w, max(0, round(b[2] * w / BBOX_GRID))))) + ys = sorted((min(h, max(0, round(b[1] * h / BBOX_GRID))), + min(h, max(0, round(b[3] * h / BBOX_GRID))))) + # ponytail: the model swapped corners on 1 of 117 boxes (p007 person_1, x1 > x2). + # Ordering here is enough because a zero-area box still crops to nothing downstream. + c["bbox"] = [xs[0], ys[0], xs[1], ys[1]] return chars @@ -1216,8 +1219,11 @@ if __name__ == "__main__": # the top sixth of the panel, inside a speech balloon, which is what identity embedded. _ch = [{"local_id": "person_5", "bbox": [222, 405, 654, 1000]}, {"local_id": "edge", "bbox": [0, 0, 1000, 1000]}, - {"local_id": "junk", "bbox": "nope"}] + {"local_id": "junk", "bbox": "nope"}, + # p007 person_1 of run B: x1 > x2, so the crop came out empty and the detection was lost + {"local_id": "swapped", "bbox": [226, 417, 130, 551]}] _bbox_to_pixels(_ch, 900, 1650) + assert _ch[3]["bbox"] == [117, 688, 203, 909], _ch[3]["bbox"] # corners ordered, non-empty crop assert _ch[0]["bbox"] == [200, 668, 589, 1650], _ch[0]["bbox"] assert _ch[1]["bbox"] == [0, 0, 900, 1650], _ch[1]["bbox"] # a clamped box spans the whole panel assert _ch[2]["bbox"] == "nope", _ch[2]["bbox"] # unparseable is left alone, not crashed