Commit Graph
4 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 f33403fff8 feat(scene): feed TransNetV2 at native rate, derive the dedup window from it
Closes both violations SPEC.md named under "Every model gets the input it
was trained for". They are one bug, not two.

The dense stream defaulted to 12 fps, so a 100-frame TransNetV2 window
spanned ~8.3 s against the ~4 s it was trained on: half-speed motion over
twice its temporal context. Boundary timestamps stayed correct throughout,
which is exactly why the degradation was invisible and why the compressed
separation it produced (~0.50 baseline against ~0.7+ peaks) was read as a
property of the ONNX export rather than of the input.

Dedup then merged boundaries closer than a literal 0.04 s — one frame at
25 fps, and wider than a frame at 30, so two cuts on consecutive frames
became one. Nothing in scenes.json showed it; the file simply had fewer
boundaries. Native rate is where that constant did the most damage, which
is why fixing the decode rate without fixing the dedup would have made
things worse.

dedup_window_sec() now takes the median interval the detector was actually
fed and halves it. Half a frame rather than a whole one: the only thing
being merged is one frame scored by two overlapping windows, and two
distinct frames are a full interval apart.

Cost is real — dense decode is the pipeline's cost driver. It is accepted;
dense_scale and scene_stride remain the reductions that do not run the
model off-distribution. scene_threshold 0.60 was fitted against the 12 fps
input and is now stale, so VR-006 goes from Low to Medium: it is no longer
a refinement, it is a constant that no longer describes the input.

AR-002 rides along because it was already implemented, just untagged and
unverified — the register said Planned while the code was correct. The size
filter becomes FaceDetectorFunc::drop_undersized(), tested at the threshold
and at dense_scale 0.5, and checked end to end against the superhero dump,
whose smallest face is exactly its recorded 32 px minimum, so the fixture
check cannot pass vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: AR-002, AR-011 | SR-002 | UT-002, UT-003, IT-001
2026-08-04 21:17:57 +02:00
dtourolle d98dc2855a refactor(bench): SuperHero replaces Road to Bali as the reference film
Bali was chosen because the TRECVID DVU set ships character mugshots, but
its reference crops are unusable at scale: median detected face 27 px
against a 69 px maximum, so every reference was upscaled 4x or more past
what the embedder was trained for (AR-011). A 66 px floor left 2 of 69
references; no threshold exists that both keeps the faces in distribution
and leaves enough of them to calibrate.

SuperHero is 69 px median and 241 px max. Its gallery builds at a 66 px
floor with 14 references over 5 characters, and calibrates on its own
(a=15.2867 b=-4.98633, 100% train accuracy) instead of borrowing constants.

Measured on the fused 17-minute film, one stream rather than per-scene
clips so presence windows cross real scene boundaries as SR-002 intends:
precision 1.00, recall 0.65, F1 0.79 — 13 true positives, 0 false
positives, 7 misses. Every out-of-gallery character was declined rather
than forced onto a nearest match. The misses are the short scenes (14 s,
38 s, 27 s), consistent with per-track accumulation needing sightings.

- build_gallery gains --min-face-px, filtering the *detected face* rather
  than the crop. The DVU images are scene crops, not mugshots, so crop
  dimensions say nothing about face scale. A poisoned reference is
  permanent in a way a bad frame is not: it corrupts every future match
  against that identity.
- scripts/fetch_dvu.sh fetches mugshots, scene graphs and segmentation for
  any DVU film. NIST names the same film three different ways, so KG_DIR
  and KG_FILE are overridable rather than derived. This exists as a script
  because the first copy of this data was assembled ad hoc in /tmp and was
  lost with it, taking the working gallery along.
- Replay fixtures move to the artifact registry: push/pull_artifacts.sh
  gain a replay-fixtures target, and tests/fixtures/dumps/.gitignore keeps
  them out of git. superhero.h5 is ~9 MB and regenerating it needs the
  film, the models and a GPU — none of which CI has. The gallery ships
  with the dumps, since a dump only replays against the gallery it was
  produced with.
- AR-012 and AR-013 coverage is ported onto the new fixture rather than
  dropped with the Bali cases: 12369 assertions, up from 7991, since the
  film is an order of magnitude larger than the clips.

Suite: 15679 assertions, 101 test cases.

TRACES: AR-011, AR-012, AR-013 | VR-001, VR-005 | SR-002
2026-08-04 13:49:11 +02:00
dtourolleandClaude Opus 5 b4318f8d9e fix: belief accumulates across frames (lazy-OR), not once
A track recognised on 318 of 385 frames was owned on none, so the truth file
named nobody while the matcher was accepting almost continuously.

The correlation discount was an annihilator rather than an attenuator. Weight
was 1 - P(same view), so once a track had one stored view every later frame of
that same face scored ~0.01 and the belief stopped moving. One observation just
over the accept threshold is logit(0.78) ~ 1.27, under the ownership bar — hence
recognised always, owned never.

Two changes, in the order they were found.

Correlated evidence is now attenuated by effective sample size,
n_eff = n / (1 + (n-1)·rho), each frame contributing the marginal gain. That has
the right shape at both ends: uncorrelated evidence accumulates linearly, and a
held pose converges on 1/rho rather than growing without bound. A constant floor
was tried first and rejected — it grows linearly forever, so a long shot could
out-argue genuinely varied evidence purely by lasting longer.

Combination is now weighted lazy-OR: P = 1 - (1-P_old)·(1-p)^w, stored as
log(1-P) so the update is additive and precision stays where it matters as P
approaches 1. Each frame is new evidence that this track is that actor, and the
belief is the probability that at least one sighting was right. It converges
faster than summing log-odds at the same effective count — 2.98 vs 2.53 after
two observations at p=0.78 — which is what a real clip needs.

Note that summing log-odds was already a correct sequential Bayesian update:
the matcher fits with prior 0.5, so logit(p) IS the per-frame log-likelihood
ratio and the running sum carries the prior forward. It was not wrong, it was
slow. What blocked ownership was the discount, not the combination rule.

Also fixes a real correctness bug: the observation count lived on the
discounter, which is shared by every track, so tracks pooled into one effective
sample and each was discounted by how many others happened to be on screen. It
is now a per-track parameter.

The registry's frame scope holds its lock for its lifetime and the mutex is not
recursive, so calling observe() inside a scope self-deadlocks. The pipeline
never does — separate nodes — but the test did, and hung rather than failing.
Documented at the call site.

Verified end to end: the same clip that produced zero actors now identifies
Bing Crosby and Dorothy Lamour with belief 0.97.

Suite: 96 cases, 6142 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: AR-025 | SR-002
2026-07-31 16:51:08 +02:00
dtourolleandClaude Opus 5 61e487fbee test: replay the real tracker and registry from committed fixtures
Tier T2 — composition rather than units. The registry tests construct awkward
states directly; these feed the pieces real 480x360 footage with the cuts, gaps
and crowded frames that synthetic input does not produce.

Six cases:
- fixture integrity: exact frame and face counts, contiguous face_offset, and
  the embedder identity each dump carries (GR-004). The counts are asserted
  exactly rather than approximately, which was impossible before AR-004 — what
  a lossy run dropped depended on timing.
- determinism: replaying a fixture twice gives identical track ids and windows.
  This is the property the whole fixture strategy rests on; without it every
  golden output derived from a fixture is unreliable and the CI replay tier is
  worthless.
- every face is assigned a track, and flush leaves nothing open — a track still
  live at EOF is a window that never reaches the output.
- windows are well-formed and inside the clip. A window ends at the last
  sighting, so it can never extend past the footage that produced it.
- a longer extinction window yields fewer, longer tracks. On the sparse fixture
  (140 faces over 385 frames) that is the difference the constant actually
  makes: absorbing a gap versus splitting a window.
- the cut-heavy fixture still contains cuts. This guards the corpus, not the
  code: a regeneration that produced cut-free fixtures would leave the
  association tests passing while silently testing nothing.

Driving the functors directly rather than through a KPN network is deliberate —
no threads, no channels, no scheduling, so the same input gives the same output.

Suite: 86 cases, 6106 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: AR-004, AR-012, AR-013, VR-001, VR-002 | SR-002
2026-07-31 11:23:17 +02:00