Author SHA1 Message Date
dtourolle ff3b8ebf1d Merge feature/opencv5: correct scene-boundary detector F1 numbers
Traceability Validation / Check requirement traces (push) Failing after 9s
Unit tests / Build and run the GPU-free suite (push) Failing after 2s
Fold in the documentation accuracy fix: the boundary-detection F1 numbers now
reflect the measured values at the shipped ±20s tolerance (44.1% leave-one-out /
29.8% grayscale / 72.9% train-all), replacing the stale pre-retrain '~34%'
figure, and the evolution figure is split so the ±2s development curve is not
mistaken for the shipped result.
2026-08-11 20:56:06 +02:00
dtourolle 13437e0d8b docs: correct scene-boundary detector F1 numbers to measured values
The boundary-detection paragraph understated the detector. Replace the stale
"~34% F1 vs ~27%" (a pre-C++-retrain figure with no backing artifact) with the
measured numbers at the shipped ±20s tolerance:
  - leave-one-out macro boundary F1 = 44.1% (honest generalisation)
  - grayscale baseline               = 29.8%
  - train-all (shipped model)        = 72.9% (per-film 51-86%)
computed from experiments/results/scene_boundary/xgb_report.json and per-film
leave-one-out runs of train_xgb_cpp.py. Also correct the false claim that the
low-contrast grades "cannot generalise held out" — Scarface held out scores 32%,
Café Society 51%, both above grayscale (0% and 31%).

Split the evolution figure into two panels so the strict-±2s feature-development
curve is no longer mistaken for the shipped result: left = feature progress at
±2s, right = shipped detector at the ±20s tolerance the pipeline uses.
2026-08-11 20:54:15 +02:00
dtourolle 30b5ad7da7 Merge feature/opencv5: learned scene-boundary flood-fill pipeline
Traceability Validation / Check requirement traces (push) Failing after 4s
Unit tests / Build and run the GPU-free suite (push) Failing after 1s
The opencv5 rework of the detect/track/match/scene pipeline. Headline result:
flood-fill actor presence on a learned XGBoost scene-boundary detector lifts
per-second Amazon X-Ray presence F1 from 62.6% (track-extent) to 74.9% under
leave-one-out across the nine-film benchmark, improving every film and fixing
the low-contrast grades (Scarface, Downton) that naive flood-fill broke.
2026-08-11 19:47:23 +02:00
dtourolle b26c66dcce scripts: figure generators + frame-regen script for the opencv5 report
make_figures.py gains the DE-landscape, calibration, and per-film leave-one-out
holdout figures used by the experiment log. regen_frame_examples.sh replays all
nine films with the shipped learned-boundary flood config and draws GT-aware
TP/FP/FN frames, so every annotated image in the docs is reproducible.
2026-08-10 08:45:07 +02:00
dtourolle 7556c836da docs: opencv5 experiment log + rewritten Home
Add model-bakeoff.md for the opencv5 build: the ten-knob DE tuning and where
each shipped config default comes from, the replay architecture, and the
flood-fill-on-learned-boundaries step change (62.6% -> 74.9% presence F1, LOO).
Rewrite index.md to lead with the learned scene-boundary result and point at
the current pages, with the July four-model bake-off moved to an Archive
section. Both pages build with no broken links.
2026-08-10 08:41:54 +02:00
dtourolle ef99951360 docs: remake all named TP/FP/FN frames against the opencv5 pipeline
Auto-match each named July frame by film+actor+class and re-extract it from
the current learned-boundary flood replay, drawing GT-aware boxes: green TP,
red FP, orange unknown, plus the blue off-screen/missed (X-Ray cast, no face)
FN panel. Adds rematch_frames.py, the tool that does the matching.

Zooey Deschanel is dropped: the current pipeline no longer makes that
false identification, so the frame is removed and the July deep-dive notes
the fix rather than showing a stale error.
2026-08-10 08:32:30 +02:00
dtourolle 0feafec7c9 feat(review): GT-aware TP/FP/FN frame annotation + scene-detector examples
dump_error_frames.py drew every identified box green, so a false positive looked
like a true positive and a missed cast member was invisible. Make the annotation
ground-truth aware, matching what the per-second scorer classifies:
  - GREEN  true positive  — a name X-Ray also credits to this scene
  - RED    false positive — a name X-Ray does NOT credit here (the real error)
  - ORANGE unknown detection
  - BLUE   a text panel listing X-Ray cast present with no detected face (the
           structural false-negatives — no box exists to draw)

Add two representative annotated frames to the scene-detector page: a clean
green-TP second, and the face-vs-scene-cast case (a red FP lead + six off-camera
cast in blue) that makes the recall ceiling visual. Frames are generated by the
script from replay.py --raw-out output; the two committed examples are hand-picked
doc assets (bulk experiments/dump_review is regenerable and gitignored).
2026-08-09 22:28:21 +02:00
dtourolle edf19ab798 docs(scene-detector): document the learned scene-boundary detector
New docs/scene-boundary-detector.md: why the grayscale cut detector wasn't
enough (Scarface: 1 cut in 10k frames → flood-fill P=26%), what X-Ray boundaries
are and why they're hard, the feature/model design (delta histograms, multi-scale
ramp bank, scene-length debounce, soft-target XGBoost regressor, per-film knee),
and the measured dead ends (audio-only, raw features, LSTM, TransNetV2).

Headline result, honest leave-one-out (each film scored by a detector trained on
the other eight): flood + learned detector = 74.9% macro presence F1, vs 64.0%
for grayscale-cut flood and 62.6% for track-extent — +12.3pp, improving all nine
films. Fixes the Scarface flood collapse (grayscale 40.9 → learned 74.9, on a
film the detector never trained on) and swings Downton +37pp.

Figures are generated by scripts/scene_detector/make_figures.py from the saved
results (experiments/results/scene_boundary/downstream_loo.json); the PNGs
themselves follow the repo convention of not committing regenerable chart assets.
Added to the mkdocs nav.
2026-08-09 22:15:32 +02:00
dtourolle e5204a831a feat(scene-detector): run the learned boundary detector live in the C++ pipeline
Wire the XGBoost scene-boundary detector into scene_analyze as a post-EOF step in
the result sink (like flood-fill itself — the per-film knee threshold needs the
whole film, so it cannot stream). With --scene-xgb-model set, the camera-position
node stamps a per-frame RGB histogram onto the Frame, it rides through to the
sink, and at EOF the sink runs XGBSceneBoundary over the collected histograms +
the movie's per-second audio log-PSD to produce the flood-fill boundaries. Falls
back to is_scene_boundary / is_cut when no model is configured or inference fails.

Inference is real XGBoost via CMake FetchContent (v2.1.1, static), C API in
src/inference/xgb_scene_boundary.hpp; audio log-PSD in src/inference/
audio_logpsd.hpp (FFTW + ffmpeg full-file 16kHz decode). Feature extraction
matches training exactly — video features verified row-identical to numpy, and to
avoid chasing numpy's every rounding the shipped model is TRAINED on the
C++-extracted features (scene_features_dump exe → train_xgb_cpp.py). The
C++/Python peak-finders differ slightly so boundary counts differ, but what
matters is downstream: flood + C++ detector = 75.8% macro presence F1 vs 64.0%
for the histogram-cut flood and 62.5% for track_extent, and it fixes the Scarface
flood collapse (41 -> 70). All nine films improve.

Guarded by the SAE_SCENE_XGB CMake option (on by default; heavy first build).
xgb_boundary_parity is a diff harness; scene_features_dump writes the C++ feature
matrix so training and inference share one feature implementation.

Verified end to end: scene_analyze --scene-xgb-model on a real movie stamps the
histogram, runs the detector at EOF ("XGBoost scene detector: N boundaries"), and
flood-snaps presence to the learned boundaries.
2026-08-09 21:21:29 +02:00
dtourolle 0e35dac951 feat(scene-detector): learned scene-boundary detector for flood-fill presence
A boosted-tree scene-boundary detector that replaces the grayscale
histogram-correlation cut detector as the flood-fill boundary source, and
substantially improves actor-presence accuracy.

Downstream result (per-second X-Ray presence F1, macro over 9 films):
  track_extent 62.3%  |  flood + histogram cuts 64.0%  |  flood + this 76.9%
+12.9pp, and it wins on every film — notably fixing the histogram flood's
Scarface collapse (61 -> 41 -> 71) and lifting Downton 41 -> 84.

Design (each choice measured — see the memory / report):
- XGBoost REGRESSOR on a ±3s window of DELTA features (symmetric RGB-hist
  and audio-PSD deltas at k=1,2,4,8s + ramp bank + time-since-last-peak
  debounce). Raw histograms dilute; deltas separate boundaries ~4-5x.
- SOFT Gaussian proximity target (sigma=10s) so near-misses train as
  near-correct, not hard negatives; regression -> smooth score -> NMS peaks.
- KNEE per-film threshold: self-calibrates the boundary count to ~the true
  scene count, no global rate. Evaluated at ±20s (X-Ray scenes ~170s).
- Trained on all 9 films (Cafe/Scarface low-contrast grades must be seen).
  Honest held-out ~41% boundary-F1 @±20s vs ~27% grayscale.

Scripts: train_xgb_boundary.py (shipped detector), extract_audio_features.py
(per-second log-PSD), downstream_presence.py (the A/B above), density_floor.py
(fallback for detection-starved films), plus the LSTM/DE explorations kept
for provenance. Model: models/scene_boundary_xgb.json.

Not yet wired into the live C++ pipeline — boundaries are a post-EOF step in
the sink (like flood-fill itself); libxgboost C++ integration is the next step.
2026-08-09 19:21:04 +02:00
dtourolle 8dd2255125 feat(dump): record a per-frame RGB histogram for scene-boundary training
Add frames/rgb_hist to the embedding dump: a normalised 32-bin-per-channel
RGB histogram (96 floats/frame), computed from the already-decoded frame so
it is nearly free and ~40 KB per film. This is the training signal for the
learned scene-boundary detector — the grayscale-correlation cut detector is
blind on low-contrast grades (Scarface: 1 cut in 10k frames), and the
symmetric RGB-histogram delta separates X-Ray scene boundaries far better.
The dump stays gallery-independent; downstream replay/training consume the
histogram offline.
2026-08-09 19:20:34 +02:00
dtourolle 0e97e532a8 feat(config): ship the 10-knob DE optimum (flood-fill default)
Update the tuned presence defaults to the 10-parameter DE optimum over all
9 X-Ray films (opencv5 build, LVFace-B): prob_threshold 0.485,
ownership_logodds 1.72, track_extinction_sec 31, track_alpha 0.435,
evidence_rho_max 0.204, evidence_admit_below 0.784, match_prior 0.433,
expand_band 0.804/0.952, and PresenceMode::flood as the default. Replaces
the earlier 0.754 (a 4-film subset optimum under the withdrawn
anneal/extinction windows, never re-derived). The provenance comment is
rewritten to record the sweep, the permissive-threshold rationale, and the
uneven-generalization caveat (strong on 7 of 9 films; Many Saints /
Scarface remain hard).

Note on flood: presence F1 is 64% with the always-on histogram cut as the
flood boundary source, but 77% once a proper learned scene detector
supplies the boundaries (see the scene-detector work). Flood is the right
default; the boundary source is what unlocks it.
2026-08-09 19:20:21 +02:00
dtourolle 1477c53885 refactor(registry): only keep identified tracks associable while dormant
candidates() now excludes dormant (off-screen) tracks that were never
identified: an unowned dormant track has no actor to re-attach to, so
keeping it in the association pool only enlarges the matcher's per-frame
comparison set and invites a new face re-associating onto an anonymous
stub. On-screen tracks are always candidates; a dormant track must have
crossed ownership (t.actor set) to stay associable within
track_extinction_sec.

Measured trade: a small recall cost (~1-3pp on some films, e.g. Lord of
War -2.5) for a cleaner, bounded pool. Kept deliberately. Note it does NOT
fix the ROCm GEMM wedge (that is an intermittent driver flake, not a
pool-size problem) — this is a correctness/cleanliness change, not a
performance one.
2026-08-09 19:20:06 +02:00
dtourolle add7e22053 fix(optimizer): discard replay stderr and make the timeout a wedge-backstop
Two failures surfaced scaling the DE sweep up. The replay sink prints a
per-second progress line with an explicit flush; under
subprocess.run(capture_output=True) those thousands of writes fill a fixed
OS pipe buffer that nothing drains until exit, so the long films blocked on
write to stderr and looked like hangs. Discard the child's stdout/stderr
(DEVNULL) — it was captured and thrown away anyway; the long films then
finish in seconds.

Separately, the per-film timeout is only a backstop for the rare,
intermittent ROCm GEMM wedge (a wedged replay hangs forever and must be
killed so the sweep continues), not a performance bound. It had been set
huge, which let a single flake stall the whole sweep; set it to a sane 180s
(overridable via REPLAY_TIMEOUT) — well above a healthy replay, short
enough to reap a wedge quickly.
2026-08-09 19:19:54 +02:00
dtourolle ea922356f1 docs: archive the July 2026 report; new methodology for the opencv5 run
The July report (4-model ArcFace/LVFace bake-off, pre-opencv5 framework,
3-film training + held-out validation) is superseded by the opencv5 build:
single-model LVFace-B, a 6-knob DE sweep over all 9 films, flood-fill
presence, and the registry/decode fixes. Rather than overwrite it, archive
it date-suffixed and start the current report fresh.

- Rename the six July result pages to *-2026-07.md, rewrite their
  intra-archive cross-links, and add an "Archived (July 2026)" banner to each.
- mkdocs nav: current report at top, the July set under an Archive section.
- New docs/methodology.md for the opencv5 run: corrects the withdrawn
  anneal_sec/extinction_sec presence bridging (windows are now
  [first_seen, last_seen], AR-012/013), documents the two presence modes
  (track_extent / flood), and records that every eval scores all 9 films.

The current experiment log (model-bakeoff.md) and Home rewrite land once
the DE sweep converges and the final optimum is known.
2026-08-09 10:48:45 +02:00
dtourolle e1423062e2 chore(experiments): X-Ray rerun and opencv5 dump runners
- run_xray_lvface_opencv5.sh: end-to-end X-Ray benchmark (scene_analyze
  per film → sample_eval) on the current build with LVFace-B.
- dump_lvface_opencv5.sh: fresh LVFace-B embedding dumps (plain front-half,
  histogram cuts baked in) for the optimizer replay corpus. No decode-fps
  cap — that only mattered under parallel dumping; serial it just halved
  throughput.
- .gitignore: ignore build-*/ out-of-tree build dirs.
2026-08-09 10:22:32 +02:00
dtourolle c374c262f5 perf(rocm): enable MIOpen exhaustive search and TunableOp
The ROCm execution provider was appended with only device_id set, leaving
MIOpen on its no-workspace convolution fallback (the "GemmFwdRest,
provided ptr: 0 size: 0" warnings) and TunableOp off. Enable
miopen_conv_exhaustive_search and tunable_op_enable/tuning so MIOpen picks
the fast conv kernels and the GEMMs autotune; both cache to the MIOpen
user DB (MIOPEN_USER_DB_PATH), so the tuning cost is paid once per shape.
This helps the 2D-conv models that actually run on the ROCm GPU (SCRFD,
ArcFace). Opt out with SAE_ROCM_NOTUNE=1 for a quick no-warmup run.
2026-08-09 10:22:07 +02:00
dtourolle 84513d3fa7 fix(eval): read schema-v2 scene windows
The output schema moved to version 2, where each actor's "scenes" is a
list of {start, end, belief, route} objects rather than [t0, t1] pairs.
Both scorers still unpacked pairs and raised "too many values to unpack
(expected 2, got 4)" on current output. Read either form: an object's
start/end, or a two-element list. Fixes second_score.py (the optimizer's
per-second scorer) and sample_eval.py (the X-Ray/MovieNet presence eval).
2026-08-09 10:21:56 +02:00
dtourolle d113c83189 feat(optimizer): sweep expansion bands + presence mode; tolerate scattered dropped votes
Make the flood-fill and expansion knobs reachable from the DE sweep:

- kpn_bindings: read expand_band_lo/hi and presence_mode from the replay
  cfg dict (presence_mode accepts "flood"/"track_extent" or a numeric
  >=0.5 toggle), and carry is_scene_boundary onto the replayed frame.
- replay.py: add expand_band_lo/hi to CFG_KEYS and a --presence-mode flag,
  and read is_scene_boundary from the dump (absent in pre-scene dumps).
- optimize.py: map the continuous presence_flood knob (0..1, >=0.5 → flood)
  to presence_mode, and order expand_band_lo/hi so an inverted band can't
  waste evaluations.

Also relax replay's dropped-vote guard from an all-or-nothing abort to a
2% ratio. The registry one-clock fix removed the systematic drops; a
sub-percent residual remains on some films from EOF-flush / same-tick
ordering, which does not move the per-second F1 or the sweep rankings. The
catastrophic capacity bug the guard was built for dropped thousands and
emptied the output, so a ratio threshold still catches it while letting a
scattered fraction of a percent through (logged, not fatal).
2026-08-09 10:21:45 +02:00
dtourolle 584f23546a feat(presence): flood-fill presence mode
Add PresenceMode::flood alongside the default track_extent. In flood mode
the result sink snaps each presence claim to the shot it sits in, so an
actor seen once anywhere in a shot is reported for the whole shot
[prev_boundary, next_boundary]. This trades precision for recall against
X-Ray's per-scene cast granularity and is a toggleable knob for the
optimizer to weigh rather than a default.

Boundaries come from the frame stream, now carried through SceneAnnotation
(is_cut and is_scene_boundary). Flood prefers TransNetV2 shot boundaries
when a scene detector populated them, otherwise falls back to the
always-on histogram cuts (camera_position_change_detector); with no
boundaries it degrades to track_extent per claim. The is_scene_boundary
path stays dormant so an out-of-process scene detector can be revived
later without re-wiring.

Selected with --presence-mode flood|track_extent (default track_extent),
so existing output is byte-for-byte unchanged. The dump_embeddings header
note records why TransNetV2 scene detection is not run in that process.
2026-08-09 10:21:13 +02:00
dtourolle de02e25e6a fix(registry): one clock for association and reaping
FrameScope::candidates() filtered associable tracks on the tracker clock
(now_) while reap_locked() retired them on the matcher's evidence
watermark (evidence_through_). Because the tracker runs ahead of the
matcher (backpressure turns channel depth into lag, AR-004), a track the
registry had already reaped on the watermark could still be offered for
association on the tracker clock; the matcher's later vote then landed on
an erased id and was counted as a dropped vote, silently under-reporting
presence. Rare live (small lag), routine in replay where the Python
source drives the tracker far ahead of the matcher.

candidates() now filters on the same clock reaping uses
(awaits_evidence_ ? evidence_through_ : now_) with the same
track_extinction_sec horizon, so the offered pool and the live pool are
identical: nothing is offered past its reap horizon, nothing is reaped
while still offerable. This also cannot reintroduce the older zombie-merge
bug (offering on a looser horizon than the reap), because the horizon is
now identical rather than looser.

track_extinction_sec is the tracker's association window, so it must be
evaluated on the horizon the registry retires tracks on.
2026-08-09 10:19:22 +02:00
dtourolle 7b73bf923a fix(AR-025): only evidence spends the evidence budget
The correlation discount is an effective-sample correction: with observations
correlated at rho, the n-th is worth n_eff(n+1) - n_eff(n) = 2/((n+1)(n+2))
at rho=0.5, so it decays quadratically and the total converges to 1/rho = 2.
That saturation is deliberate and stays — a long static shot must not
out-argue varied evidence purely by lasting longer.

What was not deliberate is that every scored face spent it. An observation at
p=0.02 contributes log(0.98) = -0.02 of belief, which is nothing, while
consuming the same increment as one at p=0.95. On SuperHero-2, track 3 carried
103 observations for an effective weight of 2.026 and a belief of 0.455
against a 0.881 threshold — with the 51 frames that *did* identify the actor
arriving when each was worth 0.0002. The budget had been spent by the frames
that recognised nobody.

It also made the answer depend on frame rate: deliver more frames, dilute the
budget with more non-matches, and a track that was owned stops being owned.
That is the defect AR-013 already had to fix once for reaping, still present
in accumulation. It is how this was found — SuperHero-2 identified Jeremy
before a KPN throughput fix and not after, from identical input, and bisect
put the change at the KPN bump in d98dc28 rather than anywhere in this repo.

The floor is 0.5, which is where the posterior stops favouring the hypothesis,
not a tuned threshold. Near-misses still count: the identity matcher
deliberately feeds every scored face rather than only accepted ones, and an
observation at 0.7 — under the matcher's 0.754 acceptance — still accumulates,
which the second test pins. Below 0.5 the observation argues *against*, which
noisy-OR cannot represent, so declining to spend a budget on it loses nothing.

Scored against the DVU knowledge-graph ground truth, all ten SuperHero scenes:

                 TP   FP   FN   precision   recall     F1
  before          9    0   15       1.000    0.375   0.545
  after          13    0   14       1.000    0.481   0.650

Four more true positives and no false positives — the recall gain is not
bought with precision. Scene 7's Isabelle and scene 10's Isabelle and Jeremy
are all in the ground truth. Apples-to-apples over scenes 1-9 (scene 10's
before-run timed out): TP 9 to 11, FN 15 to 13, FP 0 either way.

An earlier attempt at this gave each actor its own budget. It was a no-op:
measured on the same track, n_obs_for[best] equalled n_obs at 103, because
every observation votes for the best-matching actor. Reverted rather than
kept.

151/151.

TRACES: AR-025, AR-013 | SR-002
2026-08-08 14:48:40 +02:00
dtourolle 24d35cbde3 fix(AR-013): reap tracks on the evidence watermark, not the tracker's clock
The registry closed a track when the *tracker's* timestamp passed
`track_extinction_sec`. But votes arrive from the matcher, which is a separate
KPN node behind a channel, and much the slower of the pair. Backpressure —
working exactly as AR-004 intends — turns that channel's depth into lag, so
the tracker's clock can be far ahead of the last frame anybody has voted on.
Tracks were therefore closed before their evidence arrived: the votes landed
on ids that no longer existed, were counted as dropped, and the track was
emitted unowned or not at all.

The symptom is the part worth remembering: **a deeper channel produced fewer
identifications, from identical input.** On the SuperHero fixture, 5 actors /
16 windows at depth 32 against 3 actors / 5 windows at depth 10322; through
the replay harness, capacity 32 gave 5 actors and 10322 gave 0. A throughput
knob was silently changing the answer, which makes every sweep tuned against
it suspect.

The fix is not to bound the channel against `track_extinction_sec` — that
makes an algorithm constant police a throughput knob and leaves the result a
function of scheduling. It is to reap on an evidence watermark: the matcher
advances it as it folds each frame in, and a track is only finished once
everything up to its extinction point has actually been voted on. Same device
`SceneBoundaries::scored_through()` uses for the AR-010 join — a consumer past
that point is asking about frames nobody has looked at yet, and the honest
answer is to wait rather than guess.

Association keeps the tracker's clock, and separating the two is the other
half. They answer different questions: "may this detection link to that
track?" is asked now, about a box seen `track_extinction_sec` ago; "is that
track finished?" cannot be answered until every vote is in. Deferring
association to the evidence clock — which deferring the erase alone did — left
retired tracks associable for as long as the matcher lagged, so a new face
re-associated onto a long-dead track and two people merged into one window.

The watermark is monotonic and only ever *delays* a reap, so no window is
extended by it: AR-013's "a window ends at the last sighting, never after" is
a property of `emit_locked`, which takes `last_seen` and never `now`.

`dropped_votes` is exposed and reported — by main at shutdown and through the
replay bindings — because this failed silently for as long as it did precisely
because nothing counted it. It warns rather than aborts: a dropped frame means
the output describes footage nobody analysed and is always wrong, while a
dropped vote degrades a claim without falsifying it, and there is no
measurement yet of how often it happens on real content.

replay.py's channel capacity stops being the whole film. It was sized that way
to dodge a PyNode overflow drop that AR-004 has since replaced with parking,
and removing backpressure that way is what made the defect above so extreme.

Tag separators in kpn_bindings.cpp corrected to pipes between requirement
types, which the traceability gate was reporting as diagnostics; the matrix is
regenerated and reports 0 orphan tags.

149/149.

TRACES: AR-004, AR-012, AR-013, AR-025 | VR-011 | SR-002 | PR-002
2026-08-08 12:08:15 +02:00
dtourolle 0e27339ab6 chore: update KPN — unconditional push wake, and the start() lock it exposed
Two commits on top of c9aa246.

6802328 fixes a lost wake in the channel itself: push fired push_callback_
only on the empty->non-empty edge, computed from a head_ sampled before the
item was published, so a pop landing in that window left the item in the ring
with the consumer idle and no wake outstanding. Absorbing, because every later
push then sees a non-empty ring and the edge never fires again. Firing
unconditionally is correct by construction — the callback runs after the
publishing store — and the redundant wakes are cheap, since on_input_ready
re-checks the level and the submit gate collapses a wake arriving mid-firing.

771b9f8 is the gap that change exposed. abbb2d4 guarded ThreadPool::submit
against stop(), which clears queues_, but missed that start() rebuilds the
same vector under no lock. A submission genuinely lands there — a network
starts nodes one at a time, and an already-started node fires into the next
one's channel, whose callback submits — and push_back can reallocate under a
reader that has already indexed it. Unobserved before; 4 races in one run
after callbacks became frequent enough during startup.

Verified: KPN 148/148, this repo 149/149, ThreadSanitizer clean across five
unit runs and two stress runs.

Not re-measured on a clip. The GPU has a driver/library version mismatch
(kernel module 610.43.03 against NVML 610.57 — an update without a reboot),
so scene_analyze aborts in cuInit before it decodes anything. The SuperHero-1
measurement recorded in c599e07 — exit 0, outran() 0, scenes.json and presence
byte-identical — stands against c9aa246; both commits added since are
concurrency fixes with no effect on pipeline semantics, but a re-run is worth
doing once the driver is sorted.

TRACES: AR-004 | SR-002
2026-08-06 22:59:18 +02:00
dtourolle c599e07d4b chore: update KPN — a re-offered sentinel is not data loss
Picks up c9aa246, which the SuperHero run turned up. 139bfbb made the channel
refuse a sentinel offered while one was still pending — right, and it fixes a
real use-after-free — but it recorded the refusal as a drop and reported it as
an overflow.

frame_source emits EOF once and then returns it forever
(frame_source_node.hpp:76), so the token is re-offered on every firing. This
pipeline's own loss detector therefore fired on a clean run:

    [main] ERROR: frames were dropped (channel overflow):
      frame_source: 2
      scene_annotate: 1
    [main] The output would describe footage that was never analysed.
           Refusing to report success.

exit 2, with nothing dropped and every frame analysed. A loss detector that
cries loss on a clean run is worse than none, because the next real one gets
ignored.

Measured on SuperHero-1 at --fps 5 --scene-detect after the fix: exit 0, no
drops, and outran() 0 — the AR-004 join-depth derivation (59a2927) holds at 95
slots where it was pinned at 256.

TRACES: AR-004 | SR-002
2026-08-06 20:54:45 +02:00
dtourolle 4dcef8d6c5 fix(AR-004): the TransNetV2 window stores the model's input, not the frame
The rolling window held frames as decoded — `images_.push_back(f.image)` — and
left the downscale to the backend. TransNetV2's input is 48x27, so the buffer
held roughly 590 MB at 1080p to feed a model that needs about 380 KB. The
config note for `dense_scale` says as much outright: "TransNetV2 downsamples to
48x27 regardless".

This is not a channel capacity, so no amount of tuning channel depths would
ever have found it. It is a `std::deque<cv::Mat>` member, and it is the single
largest allocation in the scene branch.

It is also redundant work. Windows overlap by `kWindow - stride`, so a frame
appears in several of them and was re-downscaled once per window it appeared
in; now it is downscaled once, on arrival.

**The risk here is the invariant, not the memory.** Every model gets the input
it was trained for — a model run off-distribution returns confident, plausible,
wrong output, and for a boundary detector that means fabricated cuts, which are
indistinguishable from real ones in the output. So this reproduces the
backends' preprocessing exactly rather than doing its own: both
ort_backend.cpp and trt_backend.cpp guard mis-sized input with
`convertTo(CV_8UC3)` and then
`cv::resize(..., {kFrameW, kFrameH}, 0, 0, cv::INTER_AREA)`, in that order, and
`to_model_input` performs the same two operations. The backend guard then sees
a correctly-sized frame and does nothing, so the tensor the model receives is
unchanged. The interface has always specified this as the caller's job — "Each
frame must already be kFrameW x kFrameH, BGR, CV_8UC3" — so the node now meets
a contract it was already given.

The tests assert equivalence, not size. They perform the backend's own two
operations independently and compare byte for byte, on a gradient rather than a
flat fill, since INTER_AREA averages and a constant image would compare equal
under almost any resize. Order is pinned too: converting a 4-channel frame
after downscaling averages alpha into the colour channels and gives different
pixels.

Verified in both directions. With INTER_LINEAR substituted for INTER_AREA —
the most plausible way to get this subtly wrong — three assertions fail. With
the backend's own operations, byte-identical at 1920x1080, 640x360 and 720x480.
149/149.

Still unmeasured on real content, as with the previous commit: the equivalence
argument says the model sees the same tensor, but a run comparing scenes.json
before and after on a real clip is what would settle it, and I could not launch
one here.

TRACES: AR-004, AR-010 | SR-002
2026-08-06 20:34:16 +02:00
dtourolle 59a2927a15 fix(AR-004): derive the scene join depth instead of pinning it at 256
`kSceneJoinDepth` was a constant 256, used for two different channels. Both
uses were wrong, in different ways.

**It is a span of film, not a count of slots.** The join needs the sampled
branch to trail the dense one by however long TransNetV2 takes to be able to
answer: its input queue (128) plus the window it must fill (100), over native
frame rate — about 9.5 s at 24 fps, which is the conservative floor since a
slower source makes the same frame count span more film. The slots needed to
hold that depend on `sample_fps`, so the constant meant ~256 s of lag at 1 fps
— 27x what the join needs — and nothing recomputed it when `sample_fps`
changed. The one number AR-010's correctness rests on drifted with an
unrelated knob. It is now derived, giving a constant 19 s of lag at any sample
rate: 19 slots at 1 fps, 95 at 5.

**The decimator's buffer was on the wrong side of the decimator.** Its *input*
carries the full-rate stream, so 256 slots held ~256 full decoded frames of
which, at 1 fps against 24 fps native, 23 in every 24 existed only to be
discarded by the predicate a moment later. Holding ~1.5 GB of images for
frames the very next node throws away is the worst available use of the
budget. A filter is a pass-through, not a reservoir: the lag belongs after
decimation, where a slot buys 1/sample_fps seconds instead of 1/native_fps.
Its input is now sized only to keep it fed.

Together, at 1080p and 1 fps, those two channels go from ~3.0 GB to ~206 MB
while the join keeps 2x margin over its requirement. Every message embeds
`Frame source`, so a slot on either branch holds a full decoded image — which
is visible now that the byte counter is honest (5e46f52), and was not before.

**Not verified against a running pipeline.** The check that matters is
`SceneBoundaries::outran()` — sampled frames that arrived before the detector
had scored them, which must stay 0 — and that needs a real clip through
`--scene-detect`. I could not launch it here. The arithmetic is stated above
so it can be checked by inspection, and the margin is deliberately 2x rather
than tight, but a run should confirm outran() is still 0 before this is
trusted on real content.

TRACES: AR-004, AR-010 | SR-002
2026-08-06 19:44:44 +02:00
dtourolle 5e46f52ad2 fix(AR-004): make the channel byte counter measure the payload
`kpn::ChannelDataSize<T>` is what a channel reports as bytes pushed, and its
primary template returns `sizeof(T)`. It was never specialised in this repo —
only in a KPN example — so every message type reported its header size. Each
of them is a few vectors and a `cv::Mat` header owning megabytes on the heap,
so a message carrying a full decoded frame was reported at roughly 200 bytes
against 5.9 MB at 1080p. Four orders of magnitude.

That is not a cosmetic stat. It is the one instrument for choosing channel
capacities against a memory ceiling — the open half of AR-004 — and anyone
who read the MB/s column to size a channel was reading fiction. The gap could
not be measured with the tool that exists to measure it.

Every message embeds `Frame source`, so this is not confined to the
crop-carrying channels: the full decoded image rides the whole chain, and the
byte figure now says so.

Two things worth stating about what the number means. `cv::Mat` is
reference-counted, so one frame referenced from several messages is counted
once per reference — an upper bound on distinct bytes, and the right bound for
"what would this channel keep alive if nothing else held it", which is the
question a capacity answers. And an eof sentinel carries no image, so it costs
only its header and is not charged for one.

Declared against a forward declaration of the primary template rather than by
including <kpn/channel.hpp>, so the message definitions keep no dependency on
the framework carrying them, and any translation unit that can see these types
also sees their sizes — which is what stops one channel being instantiated
with the default while another gets the specialisation.

Verified in both directions: with the specialisations removed, three of the
five cases fail, `SceneAnnotation` reporting 40 bytes against the 37,632 its
single 112x112 crop occupies. 145/145 with them.

No behaviour change — this only corrects what is reported. Choosing capacities
against the corrected numbers is the next commit.

TRACES: AR-004 | SR-002
2026-08-05 20:17:03 +02:00
dtourolle bb7a9ed718 docs(AR-004): record the three holes the wedging audit closed
The register said "two remaining holes now closed" and the SPEC's Current:
still described `push_blocking`, which parking replaced. Both now match the
code.

Three additions, in the order they surfaced:

  (c) FilterNode and RouterNode were the last data paths still using the
      throwing push() with the exception swallowed, so a full output discarded
      the value — including the EOF sentinel. The decimator passes EOF by
      predicate but its output is reliably full, the embedder being the slowest
      node, so the token went nowhere and nothing downstream shut down. That is
      the wedge the runs were being killed for, and it is worth the register
      saying so plainly.
  (d) The sentinel could be delivered ahead of a value still queued behind it,
      losing the tail to any consumer treating EOF as a hard stop.
  (e) Two firings of one node could overlap, which breaks the one-slot park
      itself: a parked value can be overwritten with no drop recorded.

(e) also corrects (b). The startup lost wake was recorded as a missed
empty->non-empty edge closed by a level-triggered re-check; the actual cause
was the callbacks being written while a running neighbour read them — ten
ThreadSanitizer races — and they are now installed in a prepare() pass before
any node starts. The re-check stays and is still needed, but for a benign
ordering rather than as cover for a race.

Two things the requirement now carries that it did not before. A channel holds
at most one undelivered sentinel: a second is refused and reported rather than
silently overwriting the first, which matters the moment a pipeline is reused
for a second input. And a lossless decimator is a backpressure point rather
than a relief valve, so the source throttles to the face branch instead of
quietly thinning it — what the requirement asks for, but it changes the shape
of a loaded run and is not yet benchmarked.

The Gap is unchanged and still open: capacity is counted in items, not bytes,
so a crowd frame carrying 60 crops occupies one slot exactly as an empty one
does. AR-004 stays **Mostly** for that reason.

Verification plan updated with the cases the KPN suite now pins.

TRACES: AR-004 | SR-002
2026-08-05 19:47:06 +02:00
dtourolle 48332d2041 feat(replay): the whole replay chain is C++, including the sink
The sae_kpn module has not compiled since the AR-007/AR-008 tracker redesign,
and was switched off at the build rather than patched because the fix is a
restructuring. Two failures, one cause.

It did not compile: `add_face_tracker` built FaceTrackerFunc from a Config
alone, and the tracker has required a TrackRegistry and a calibration since
association moved into probability space.

And presence was rebuilt in Python. `replay.py::build_minimal` merged
per-frame detections into windows by annealing gaps, which is what the
pipeline did before AR-012. The sink builds a window from a TrackRegistry
claim instead — the extent of a track an actor owned, starting when they
appeared rather than when recognition first succeeded. Those answer different
questions, so every sweep was tuning against a contract the shipped code had
stopped honouring.

Both follow from the seam being a factory per node. The chain has a
construction order — the matcher fits the calibration, the registry needs a
discounter built from it, the tracker needs both, and the sink needs the
registry's claims — and independent factories cannot express it, so the
tracker kept being built against a signature that no longer existed. One
`add_pipeline` mirrors main.cpp exactly and is now the only way to build the
chain, so the ordering cannot be got wrong again from Python. DP-001 is the
requirement behind it: a replay harness is a front-end, and its job is to
supply frames and read the result, not to re-derive presence.

Lifetimes needed a home. ResultSinkFunc holds `const Config&` and
`std::atomic<bool>&`, which under main() are locals in a frame outliving the
pipeline; there is no such frame when the network is built and torn down from
Python. ReplaySession owns both for the network's lifetime, keyed by network
and released explicitly — a sweep builds one network per replay and the sink
retains every annotation, so holding them forever would grow with films x
configs. Getting this wrong presented as an empty output_path: the sink
announced `[result_sink] writing ` and wrote nothing.

test_sae_kpn.py is ported rather than left behind. It called all three removed
factories and asserted on SceneAnnotations read back per frame; neither half
survives, so it now waits on pipeline_done and asserts on the file the sink
writes. Verified against gallery_lvface.h5: three frames through the real
chain, timestamps 0/1/2, truth file written. EOF is a control token the sink
flushes on and does not record, so three inputs give three frames, never four.

SAE_BUILD_KPN_BINDINGS goes back to ON.

TRACES: VR-011, VR-002 | DP-001 | PR-002
2026-08-05 19:40:25 +02:00
dtourolle 1141172b04 chore: update KPN to the wedging-audit fixes
Twenty commits, of which the one that matters for this repo is the filter
and router losslessness. FilterNode and RouterNode were the last nodes on a
data path still using the throwing push() and swallowing the exception, so
`decimate` — which passes EOF by predicate — discarded that token whenever
its output was full. Which it reliably is: the embedder is the slowest node
in the chain. Nothing downstream ever received EOF, `done` was never set,
and the run had to be killed. That is the wedge.

The rest, in rough order of how much they affect a run here:

- A sentinel could be delivered ahead of a value still queued behind it,
  losing that value to any consumer treating EOF as a hard stop.
- Two fire_once invocations for one node could overlap, which breaks the
  one-slot park: a parked value can be overwritten with no drop recorded.
- A node's push/space callbacks were written while a running neighbour read
  them — ten ThreadSanitizer races, and the root cause of the startup lost
  wake that AR-004 records as closed by a level-triggered re-check.
- shutdown() polled every channel in the graph with no deadline, and could
  fail to terminate outright on an index underflow in the fill calculation.
- Submitting to a stopped pool indexed a cleared vector: a segfault, which
  reproduced 12 runs in 20 once sources stopped before their consumers.
- stop() returned while a firing was still touching the node's members.
- Idle pool workers burned ~6.5 cores while one task ran (1991 ms of CPU
  against 0.4 ms). Latent here, since every node owns a private one-thread
  pool, but not for anything using a shared one.

Two consequences worth holding onto. `decimate` is now a backpressure point
rather than a relief valve, so the source throttles to the face branch
instead of quietly thinning it — the intended behaviour, but it changes the
shape of a loaded run and has not been benchmarked. And a channel now
carries at most one undelivered sentinel; a second is refused and reported
rather than silently overwriting the first.

Verified: KPN 148/148 with ThreadSanitizer clean across six runs, this repo
138/138 with every target building warning-free against the new headers.

TRACES: AR-004 | SR-002
2026-08-05 18:26:11 +02:00
dtourolle 90b44e0975 docs(register): make the status column describe the code
Eleven rows corrected, in both directions.

Overstated: AR-011 (the derived dedup window reached scenes.json only),
AR-017 (route was a literal), AR-019 (the local plurality tally was
still deciding), AR-024 (its "static check" enforcement did not exist),
DP-001 (scene_preview had forked and stopped compiling), VR-002 (the
Python replay bindings have not compiled since the tracker redesign, and
the fixtures it calls committed are gitignored registry artifacts).

Understated: VR-010 was marked Planned while five VR-010 tags sat in the
code implementing it.

Rescoped: VR-007 now names the four AR-025 constants it was already
being deferred to for, and which no sweep could reach until this pass.

The Withdrawn note gets the longest correction, because it asserted a
removal that had not happened and nothing could have caught that: the
gate reads tags, and a withdrawn requirement has no tag to be orphaned.
The general form is now written down there -- a status column is a
claim, and the only claims this project checks automatically are the
ones a test or a static check makes. Four of the rows above are the same
pattern: recorded as done, and done in one place out of two.

New: VR-016, a cadence study for cut_threshold. It is the one always-on
signal with no recorded provenance, and its input rate depends on an
unrelated flag -- with --scene-detect off, camera_pos compares frames a
full second apart at the default sample_fps, and with it on, native-rate
frames. Same constant, two meanings, and is_cut drives track_alpha to 0
and clears every expansion buffer.

Also stops check_raw_cosine.py inflating its own metric: the extractor
scans scripts/, so the tool's prose describing the exception tag was
counted as four recorded exceptions. The count now reads 1, which is the
number of real ones.

TRACES: AR-011, AR-017, AR-019, AR-024, AR-025 | DP-001, DP-007 | IR-004 | VR-002, VR-007, VR-010, VR-016
2026-08-05 17:51:01 +02:00
dtourolle 06373817a2 refactor(config): give the tuned constants a real provenance, and make them reachable
Two problems, both of which made a number look more settled than it is.

The provenance was a dead link. config.hpp cited
docs/rep4-optimizer-results.md for prob_threshold, extinction_sec,
anneal_sec and the expansion default. That file was renamed to
model-bakeoff.md and then rewritten; the comments were never repointed,
so the most consequential constant in the pipeline appeared to have no
source at all.

Following it up produced something worse than a broken link.
prob_threshold=0.754 comes from the ORIGINAL rep4 document (still
readable at `git show d340da7:docs/rep4-optimizer-results.md`). The
rewrite that replaced it reports finding "a real scoring bug in
optimize.py: a candidate whose hardest film's replay timed out was
averaged over survivors instead of penalized, silently rewarding partial
coverage. Affected 3 of 16 training combos". So 0.754 was fitted under
scoring that was later found wrong, the corrected sweep converged
elsewhere, and no corrected prob_threshold is recorded anywhere. The
comment now says that, along with the surviving document's own verdict
that the optimum "generalizes unevenly -- strong on 3 of 5 held-out
films, badly broken on 2".

Four constants were unreachable. ownership_logodds lived on
TrackRegistry::Config, and max_views/admit_below/rho_max on
EvidenceDiscounter::Config, which main built with the one-argument
constructor -- so nothing short of a recompile could move any of them.
rho_max's own comment defers to "the sweep (VR-007)" for where it
belongs, and that sweep could not reach it.

They now live in Config with CLI flags and are exposed to the replay
harness. ownership_logodds is worth singling out: below it a track makes
no presence claim at all, so it decides whether an actor is reported
rather than how confidently -- arguably the most consequential constant
after prob_threshold, and until now unswept and unsettable.

No behaviour change: every default is the value that was compiled in.

TRACES: AR-025, AR-017 | SR-002
2026-08-05 17:43:51 +02:00
dtourolle 88c42573a5 fix(pipeline): finish three changes that had only been half applied
Each of these was recorded as done and was done in one place out of two.

AR-011 -- the TransNetV2 dedup window. The derived window
(dedup_window_sec, median observed interval halved) reached scenes.json
and nothing else. SceneBoundaries, the path that actually feeds
is_scene_boundary to the tracker, kept the literal 0.04 s under a
comment claiming it "matches the dedup scenes.json applies, so the two
views agree". They did not agree. 0.04 is one frame at 25 fps and wider
than a frame at 30, so two cuts on consecutive frames merged into one
and the loss was invisible: the pipeline simply saw fewer boundaries.
The detector now supplies the window it derived.

AR-019 -- ownership. The register says ownership "comes from the
registry, not a second local tally". Both existed: promotion fired on a
local accepted-frame count and fell back to a local per-actor plurality
when the registry had not yet claimed the track. That fallback was
reachable in the live pipeline, not just in tests -- three accepted
frames arrive well before a posterior crosses the ownership threshold --
so in practice the plurality usually decided, and it could not see the
AR-025 correlation discounting it was meant to defer to. The tally is
gone; promotion now requires the registry's verdict, with the accepted
-frame count demoted to an explicit evidence floor.

AR-017 -- the route. DeadTrack carried belief but no route, and the sink
wrote the literal string "live", so a field the schema publishes could
not distinguish anything. AR-017's own verification asks for "deferred
and pooled routes distinguishable". Route is now an enum on the claim.
Only `live` occurs today; `deferred` exists so AR-020's pass has
somewhere to write instead of a serialisation change to make.

Also: TrackGallery::forget had no callers, under a comment asserting the
matcher called it "on a cut or track disappearance". The cut half was
true by another route; the disappearance half was not, so a track that
died quietly kept its diversity buffer until the next cut cleared
everything. Replaced with prune_dead against the registry's own
liveness, the same shape as the tracker's prune_boxes -- a second
opinion about which tracks exist is a second thing that can be wrong.

Removes dead logistic/logit helpers and fixes five TRACES tags that used
a comma where a pipe separates requirement types, which the gate had
been reporting as diagnostics.

TRACES: AR-011, AR-017, AR-019 | IR-002 | SR-002, SR-005
2026-08-05 17:33:12 +02:00
dtourolle 7c7d4934ae refactor(presence): execute the extinction_sec/anneal_sec withdrawal
docs/SPEC.md specified this removal, listed its parts, and ended "grep
for both names and expect no survivors". There were about forty.
docs/requirements.md meanwhile recorded both constants as Withdrawn and
"deleted rather than retained at zero", on the grounds that a field
naming a mechanism the pipeline no longer has is actively misleading.
Neither statement was true of the code: Config still carried
extinction_sec 57.4 and anneal_sec 35.5, --extinction and --anneal still
parsed, and SceneTrackerFunc still ran its keep-alive in both shipped
pipelines, announcing its timeout at every startup.

SceneTrackerFunc is replaced by FrameAnnotationFunc, which is stateless:
same ports, same output type, no keep-alive. Presence belongs to
TrackRegistry (AR-012), where a window is the extent of a track an actor
owned and ends at the last sighting (AR-013). The keep-alive answered
that question a second time and answered it worse, by re-opening exactly
the trailing cool-down AR-013 refuses.

Visible change: --verbosity standard's frames[].identified listed every
actor inside the keep-alive, including ones absent from the frame. It
now lists what was matched in that frame. Minimal and xray output is
untouched -- both were already built from registry claims and never
consulted this node. No schema bump: the published extraction block
reports track_extinction_sec, a different knob that bounds
re-association and never extends a claim.

TrackRegistry::Config::extinction_sec is renamed track_extinction_sec to
match the Config field feeding it, so the grep SPEC.md asks for now
returns nothing rather than one confusing false positive.

Two targets turned out to have been silently dead, both since the
AR-007/AR-008 tracker redesign, and both for the same reason -- they
construct FaceTrackerFunc from a Config alone, a signature that stopped
existing when association moved into probability space:

- scene_preview is fixed here. It now mirrors main.cpp's construction
  order exactly (matcher, then registry, then tracker) and wires the
  registry's claims into the sink, which it was not doing. DP-001 says
  modes are front-ends that must not fork pipeline logic; this one had
  forked it and then rotted.
- sae_kpn is not fixed. Restructuring the seam so the tracker can reach
  a calibration that only exists once the matcher is built is VR-011's
  rewrite, not a patch, and presence claims do not cross the seam at all
  today. It is now behind SAE_BUILD_KPN_BINDINGS=OFF with the reason
  recorded, so `cmake --build` succeeds and the breakage is attributed
  rather than rediscovered.

That second one is worth stating plainly: VR-002 ("replay drives the
real KPN nodes, not a reimplementation") is marked Done, and the module
that makes replay possible has not compiled for some time. The .so in a
stale build/ predates the change.

Python side: the two names are gone from optimize.py, replay.py and
run_holdout_all_models.py as Config keys. anneal_sec survives as
REPLAY_LOCAL_KEYS -- it still configures replay.py's own windowing,
which is a Python reimplementation that no longer matches the sink and
is documented as such. That divergence is VR-011's.

TRACES: AR-012, AR-013 | DP-001 | SR-002
2026-08-05 16:21:15 +02:00
dtourolle e1de98e783 feat(ar-024): enforce the invariant statically, and delete the fallback it caught
AR-024's register row gives its verification tier as "Static check -- no
bare cosine outside a tagged EXCEPTION". No such check existed, so the
invariant was enforced by reading, and reading had missed a live
violation.

scripts/ci/check_raw_cosine.py is that check, wired into the
traceability workflow as a blocking step. It is honest about its reach:
it catches direct cosine_similarity() uses not routed through a
calibration, and it cannot follow a cosine through a variable across
statements. That limit is documented in the script rather than left for
someone to discover after trusting a pass.

What it caught, and what this commit removes with it:

The identity matcher's no-calibration fallback thresholded raw cosine
distance (match_threshold) plus a ratio test (match_ratio,
match_ratio_ceil). Worse than the invariant breach: it fed
max(0, cosine) into TrackRegistry::observe, whose contract reads
"posterior is a calibrated probability, never a raw cosine (AR-024) ...
so the accumulation cannot be fed an uncalibrated number by a careless
caller". It could, and did. And it disagreed with the rest of the
pipeline about what "the fit failed" means -- same_person_probability
answers that with the untuned default sigmoid and a loud warning, so
association stayed in probability space while matching alone left it.
One run, two policies, no announcement.

Now one rule: cal_.probability() always, with a warning when the fit is
not real. A worse answer than a fitted calibration, a better one than a
number whose units nothing else shares.

TrackGallery::set_calibration is mandatory for the same reason. Its
default was max(0, cosine), which made expand_band_lo = 0.90 mean
"cosine > 0.9" in a test and "P(same person) > 0.9" in production.
FaceTrackerFunc already threw without one; the expansion store now
matches.

One exception is recorded, in the calibration's own dedup. It is not a
close call: at 1 - 1e-7 it asks whether two vectors are the same vector,
and it runs on the fit's input, so a calibrated comparison there would
have to be calibrated by the fit it is feeding.

Also drops seven dead keys from the optimizer's CFG_KEYS. Config keys
are read with a contains() check, so each one had been silently inert
since the field behind it was deleted -- a sweep varying one of them
measured nothing and reported an ordinary-looking F1.

TRACES: AR-024, AR-023 | SR-002
2026-08-05 15:46:33 +02:00
dtourolle fd078c399f ci(tests): actually run the tier the verification strategy is built on
docs/requirements.md describes four verification tiers and argues that
T1 (functor unit) and T2 (replay) are "the only tiers that can exist in
CI at all". The traceability gate then reports a CI-scope coverage
fraction over exactly those tiers. Nothing ran them: the only workflow
was the gate itself, which reads source comments, and SAE_BUILD_TESTS
defaults to OFF. "Covered" meant a TRACES tag existed in a file.

That is the same failure the gate's own config warns about one level up
-- counting a test that cannot run -- and the gate cannot see it,
because a tag is all a static reader has.

Runs in the pinned DP-007 CPU builder image, which the image script
already expected this workflow to exist (it names unit-tests.yml and
asserts its tag). Nothing here calls a model: T1 constructs node
functors directly and T2 replays a precomputed dump, so the GPU-free
N100 runner is sufficient by construction rather than by concession.

Two deliberate hard failures. A missing replay fixture fails the job
instead of skipping, because pull_artifacts.sh warns-and-continues and a
T2 test whose input never arrived must not look like a pass. And the
image reporting a tag other than the pinned one fails rather than
building against an unknown toolchain.

It found a real bug on its first run: see the preceding commit. ctest
runs each case in its own process, which turned a 1-in-4 heap corruption
from noise in the aggregate binary into a reproducible failure.

TRACES: DP-007 | PR-004
2026-08-05 15:17:17 +02:00
dtourolle bedc859d3c fix(audio): zero-initialise the channel layouts before the resampler copy
av_channel_layout_copy() documents that it always uninitialises the
destination first, and av_channel_layout_uninit() calls av_freep() on
u.map. Declaring the layouts without {} therefore handed free() whatever
pointer-shaped garbage occupied that stack slot.

Not theoretical: UT-103 aborted with "free(): invalid pointer" in about
1 run in 4. Zero failures in 40 runs after the fix, against 10 in 40
before it.

Two things kept it hidden, both worth remembering:

- It is stack-dependent, so it disappears under an AddressSanitizer
  build and reads as a flake in the aggregate test binary, where the
  case usually passes. ctest, which runs each case in its own process,
  is what made it a reproducible failure rather than noise.
- UT-103 is the only test that reaches this branch, because it is the
  only one whose input is stereo. The golden-vector tests use a mono
  11025 Hz fixture chosen so the vector cannot depend on libswresample
  — which is right, and means bit-exactness against the golden vector
  is not evidence about the downmix path.

TRACES: IR-004 | SR-003
2026-08-05 15:16:46 +02:00
dtourolle 718dad688d docs(register): record the quality vector, the benchmark, and what lossless fanout costs
Status for the two changes just landed, plus the consequence AR-004's
fix has for the scene join.

The annotator's old comment said blocking there was safe because the
branches are independent. That stopped being true when the fanout became
lossless: it now stops popping once one branch stops taking, so a
starved detector and a waiting annotator would wedge. What actually
makes it safe is join depth -- the fanout can run the dense branch ahead
by the whole of the sampled branch's buffering, which at kSceneJoinDepth
256 and sample_fps 5 against a 25 fps source is ~1200 dense frames
against TransNetV2's 100-frame window. Cutting kSceneJoinDepth below the
window would reintroduce the wedge, so it is now a correctness
precondition rather than a tuning knob.

TRACES: AR-004, AR-010, AR-028, AR-029 | VR-015 | SR-002
2026-08-05 14:38:42 +02:00
dtourolle a5ee3c05ce feat(benchmark): per-node cost and bottleneck attribution for a run
--benchmark <path> reports cumulative CPU and wall time per node and
names the node pacing the run. The pacing node is located from sampled
channel occupancy, not from time-in-node: backpressure inflates
time-in-node for everything downstream of the real bottleneck, so the
obvious measure names the victim rather than the cause.

Sampling starts with the network and stops before it is destroyed.
Channel fill is instantaneous and everything has drained by shutdown, so
a single read at the end reports an idle pipeline however congested it
was.

kill -USR1 dumps the table from a running or wedged process. Channel
occupancy identifies a stalled node -- full input, empty output --
without a debug build or a debugger, which is the difference between
diagnosing the AR-004 hang in seconds and reproducing it under gdb.

Two knobs this exposes for measurement rather than sets: SAE_CV_THREADS,
because OpenCV's TBB arena and KPN's thread-per-node are two schedulers
unaware of each other on the same cores; and SAE_CUDA_BLOCKING_SYNC,
because the default spin-wait held the embedder thread at 99.7% user
time while nvidia-powerd cut the GPU's clock from 1005 to 210 MHz.
Neither default changes until a measurement says it should.

TRACES: VR-015 | PR-004
2026-08-05 14:38:15 +02:00
dtourolle 777c98cb33 feat(quality): score every face on sharpness and alignment before it is evidence
Every embedding now carries the quality of the input it came from. Both
axes fall out of the AR-005 warp for free: crop_sharpness() is the
normalised Laplacian variance over the aligned 112x112, so contrast and
size cannot leak into it, and the alignment residual is the part of the
landmark deformation a similarity transform cannot explain, so in-plane
roll reads as zero and foreshortening does not.

Carried, not consumed. Nothing discounts or thresholds on either number
yet -- that is AR-030 and VR-012, and the knee has to be located against
recorded data before a gate is chosen. What this change buys is that the
data exists to locate it with.

No face is admitted unscored: the -1 sentinel is preserved rather than
clamped, and a degenerate landmark fit is counted rather than silently
dropped.

Takes the VR-001 dump to schema_version 2. The bump is not for readers,
which check for the datasets by name and replay a v1 dump unchanged; it
is so a consumer can tell "never scored" from "scored zero", which is
not recoverable from the arrays afterwards.

TRACES: AR-028, AR-029, AR-030 | VR-001 | SR-002
2026-08-05 14:37:30 +02:00
dtourolleandClaude Opus 5 c1155cb607 feat(gemm): the annex is a matrix, not a list — scored by the same GEMM
The per-film annex was folded in after the gallery multiply by a host-side
cosine loop over a vector of {embedding, actor} structs, justified in-comment
by "tens of embeddings". AR-018/AR-019 retired that assumption: every owned
track promotes, so the annex grows with cast size and film length.

TrackGallery now holds it as a contiguous row-major matrix with a parallel
actor index — the flat_emb_/flat_actor_ shape the baked gallery already uses —
and hands newly promoted rows to the matcher once per frame. The matcher pushes
them into the similarity engine's resident matrix through a new
ISimilarityEngine::append_rows, so one SGEMM covers baked and promoted
references alike and best-of-N is a single pass over one similarity column.
Capacity doubles on overflow, and the GPU backends grow device-to-device, so a
promotion never re-uploads the gallery across the bus.

Absorbing promotions runs once per frame, after every face has been scored.
Appending mid-frame would invalidate the similarity pointer the chunk loop is
still reading, and it also removes an incidental dependence on face order
within a frame — a promotion helps subsequent frames, never the one that
produced it, which is the semantics the expansion store already documented.

OpenBLAS becomes a requirement of the CPU GEMM backend rather than an
opportunistic upgrade. That path is what CI and the cpu builder image run, so
falling back to the scalar loop in silence meant AR-027 could be measured — or
believed — on a kernel no release uses. The loop survives as the correctness
oracle the BLAS backends are diffed against, behind SAE_ALLOW_SCALAR_GEMM.

Call site 3, the deferred TBI pass, is untouched: it does not exist until
AR-020, so AR-026 stays In Progress.

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

TRACES: AR-026 | UT-004, UT-005 | SR-001
2026-08-04 21:20:31 +02:00
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 079b490ede Merge branch 'feature/ci-images' into feature/opencv5 2026-08-04 14:53:24 +02:00
dtourolle a3827646b9 feat(ci): CPU builder image for the unit-test workflow
TRACES: DP-007 | PR-004

Pinned by tag rather than :latest, so a workflow run is reproducible against
the image it was written for.
2026-08-04 14:53:12 +02:00
dtourolle 22758da118 docs: SuperHero benchmark — how to reproduce it and what it scores
Records the reference film end to end: fetching the annotations and mugshots,
fusing the scene clips into one stream, building the gallery at the 66 px face
floor, and the measured result (precision 1.00, recall 0.65, F1 0.79).

Three things are written down because each cost time to discover:

- Why Bali was withdrawn. Its reference crops have a median detected face of
  27 px against a 69 px maximum, so every reference was upscaled past what the
  embedder was trained for (AR-011). No threshold fixed it — at 66 px, 2 of 69
  references survived. Any accuracy figure recorded against Bali measures
  upscaling artifacts as much as the pipeline.
- Run it as one film. Per-scene clips defeat per-film gallery expansion
  (AR-019) and pay model load ten times over.
- Check you are on the GPU. ORT's CUDA provider fails to load here and falls
  back to CPU silently, so a build-ort timing is a CPU number wearing a GPU
  label — a 15x error whose only symptom is a number with no baseline.

TRACES: AR-011, AR-019 | VR-001, VR-005 | SR-002
2026-08-04 14:32:33 +02:00
dtourolle 960a7c4eed chore: bump KPN to 454f72c (ignore generated ORT cache) 2026-08-04 14:08:13 +02:00
dtourolle ae60ac7657 chore(models): track 2d106det and the larger SCRFD variants via LFS
Detector variants used by the resolution and min-face studies. LFS per
.gitattributes, so the repo carries pointers rather than 20 MB of weights.
2026-08-04 14:04:50 +02:00
dtourolle f891e579c5 chore(traces): put TRACES tags on their own line; regenerate the report
The parser reads a tag up to end of line, so `# TRACES: GR-004 | SR-001 —
prose` swallowed the prose into the tag and the row went unmatched. Splitting
the comment leaves the tag greppable by the same pattern as the code tags and
the commit trailers, which is the point of the house format.

Mechanical throughout; no logic touched. The regenerated report reflects this
session's new tags: 137 -> 148 found, and one more tagged-but-unexecuted, which
is the SuperHero accuracy assertion that is documented but not yet a test.
2026-08-04 14:04:21 +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 eff696b49a fix(expansion): finish AR-018, retiring the last two expansion cosines
AR-018 was marked Done while the promotion path still ran on the
constants it was meant to replace. track_gallery.hpp rejected a track
when buffer_spread (1 minus the minimum pairwise cosine) exceeded
expand_track_spread_max, and skipped a view when its raw gal_sim cleared
expand_novelty_sim. Both were bare cosines with no recorded EXCEPTION,
so both were defects under the AR-024 invariant rather than tagging gaps.

The calibrated band was real but unreachable. expand_band_lo/hi were
declared in Config and read nowhere, and set_band() had no callers, so
the gate always ran at the hardcoded 0.90/0.95 while --expand-novelty-sim
and --expand-spread-max stayed live flags.

The spread gate becomes store_coherence: the band's lower bound asked of
every pair in the store, in probability space, rather than a second
constant. admit() compares a newcomer only against its nearest existing
member, so a gradually drifting track chains A to B to C with every step
inside the band while A and C are strangers — the shape a track-ID
collision takes over a slow pan. The bound is re-asked pairwise before
anything reaches an actor's annex.

The novelty gate is deleted rather than converted. SPEC section AR-018
contrasts the band with expand_novelty_sim as the thing it replaces, and
AR-019 requires only that the band is satisfied. Novelty-seeking now
lives entirely in the eviction ordering, which ranks by similarity to the
actor's references instead of cutting at a constant, so there is nothing
left to tune but the two bounds.

BufEntry stored a raw cosine and the eviction loop compared two of them.
The map is monotonic so the ranking was never wrong, but it left a bare
cosine as a decision variable; it now stores the calibrated probability.

The [AR-018] Catch2 tag previously sat on the spread gate, reporting the
replaced mechanism as verification of its replacement. It now sits on the
band: both bounds asserted exactly, since they are inclusive and an
off-by-one there is invisible anywhere else; refusal counted on each
side; and the config bounds driven away from the shipped defaults so a
hardcoded fallback fails. The case that carries the invariant is "band
thresholds probability, not cosine" — under a calibration shifted by
0.10, cosine 0.84 is admitted and cosine 0.92 refused, the opposite of
their raw verdicts. A raw-cosine gate passes an identity-calibrated test
by accident and cannot pass that one. 15 cases, 38 assertions, passing.

scene_preview.cpp takes the flag rename because it would otherwise
reference deleted Config fields. It still does not compile, for reasons
predating this change: it also reads track_max_embed_dist and
track_max_frames_missing, retired by the earlier AR-024 tracker work, and
constructs FaceTrackerFunc with one argument where the registry and
calibration are now required.

Two notes for anyone reading the chain. The main.cpp flag rename and the
AR-018/AR-024 register rows landed in 35e7033, whose trailer names AR-004
only, so git log --grep=AR-018 will not surface them. And
docs/traceability.md is left uncommitted on purpose: regenerating it now
would bake in VR-013 rows for two experiment scripts that are not yet
committed.

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

TRACES: AR-018, AR-024 | SR-005
2026-07-31 22:48:07 +02:00
dtourolleandClaude Opus 5 ffdad9873d test: tag the untagged suites; correct two stale headers
Four test files and one node header carried no TRACES tag, so the
requirements they verify read as implemented-but-unverified. Tagging a
test is what distinguishes the two.

test_calibration.cpp is AR-023; its three [report] cases verify GR-003
and are tagged separately, since the report is fitted from the same
distributions but is its own requirement. test_similarity.cpp is the CI
half of AR-026 — equivalence against hand-computed dot products, where
throughput at scale is AR-027 and cannot run on this host.
test_face_tracker.cpp is AR-007 and AR-008.

Two headers described code that no longer exists. face_aligner_node.hpp
still documented the RANSAC fit AR-005 replaced with an Umeyama
least-squares fit over all five points — not merely out of date but the
opposite of what the file does, and it reads as a rationale for
discarding the landmarks AR-030 measures. test_face_tracker.cpp still
described the park/revive branch AR-008 deleted, and the raw-cosine
cut_revive_sim that guarded it, which AR-024 retired.

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

TRACES: AR-005, AR-007, AR-008, AR-023, AR-026, AR-030 | GR-003 | SR-001, SR-002
2026-07-31 22:47:59 +02:00
dtourolle 5c6603e63b fix(kpn): park on full outputs; surface node exceptions
Adopts the KPN backpressure fix (28e0667) and registers the application
error listener it exposes.

`push_blocking` parked a scheduler worker inside the push. Each ObjectNode
owns a private single-thread pool, so the parked thread was the only one
that could drain that node's own input: under sustained backpressure
frame_source, camera_pos, face_detector and face_aligner all slept in
nanosleep at once and the pipeline stopped. Nodes now hold the value,
release the worker, and resume on a channel space-callback.

main.cpp registers set_error_handler so a node that throws names itself
and its exception. Previously the exception was discarded at the node
boundary and survived only as "node 'x' stopped unexpectedly", which says
that a node died but not why — the missing detail that made this slow to
diagnose.

AR-004 drops from Done to Mostly. Two gaps are recorded rather than
claimed fixed: a hang surviving at roughly 1 run in 20 against a 300 s
timeout (down from every run failing), and FanoutNode still dropping on
overflow instead of parking, which sheds frames on the AR-010 scene join
precisely when the dense branch falls behind.

TRACES: AR-004 | SR-002
2026-07-31 22:42:19 +02:00
dtourolleandClaude Opus 5 3bf4d60a6f docs: regenerate the traceability matrix for VR-014
The committed matrix predated the audio-signature binding, so VR-014 and
the four UT tags in `test_audio_offset.py` were absent from it while being
present in the register — the one inconsistency a generated file is
supposed to make impossible.

VR-014 also needed an explicit tier row. The blanket `VR-* | Out of CI`
line is right about every other study and wrong about this one: its
fixture is committed and its signature is CPU-only DSP, so it is a test a
CI host can run rather than a measurement someone has to remember to
repeat. Left as an exception under the blanket rather than rewriting the
rule, because the rule still describes the other thirteen.

Coverage unchanged at 38/69; the gate reports no orphan tags.

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

TRACES: VR-014
2026-07-31 17:02:45 +02:00
dtourolle 5f6daefc40 Merge branch 'feature/quality-knee' into feature/opencv5
# Conflicts:
#	docs/requirements.md
2026-07-31 16:54:09 +02:00
dtourolle 629d698ad9 Merge branch 'feature/dump-provenance' into feature/opencv5 2026-07-31 16:53:58 +02:00
dtourolleandClaude Opus 5 71354e862a docs: VR-013 and VR-014 results; AR-002 raised to 40px
Records study results and the requirement change that follows from them.

VR-013 measures minimum face size end to end — gallery from one recording,
probes from another — rather than by degrading an already-aligned crop. Holding
90% of the plateau needs ~50 px that way against VR-005's ~22 px, the gap being
detection and landmark error rather than the embedder. AR-002 therefore takes
40 px, not 32: VR-005 isolates the embedder and is an upper bound, and 32 admits
faces in the falling region. FPI stayed 0.0% at every scale, and the ceiling is
cross-view rather than resolution.

VR-014 exercises audio-signature offset recovery on real film audio instead of
the synthetic golden tone. Forty random in-cap offsets, every one recovered to
the nearest frame, worst error 46 ms against a 500 ms budget — and 46 ms is the
quantisation floor rather than a result, since offsets land on whole 92.88 ms
frames. The runtime/2 anchor is confirmed through head-trimmed files.

The soft spot VR-014 found is tier labelling, not accuracy: the score drops with
sub-frame misalignment, so 27 of 40 correct alignments were demoted to `loose`.
One frame of slack in the score restores all forty to `audio` with false matches
unmoved.

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

TRACES: AR-002, VR-005, VR-013, VR-014 | SR-002, SR-003
2026-07-31 16:53:58 +02:00
dtourolleandClaude Opus 5 2a8ee3660b feat(audio): bind the v1 signature and validate offset recovery on real content
sae_audio exposes the shipped signature to Python. It compiles
audio_signature.cpp directly against FFmpeg rather than linking
sae_gallery: the signature needs no model, no OpenCV and no HDF5, so a
module that dragged those in would make `import sae_audio` depend on a
GPU-capable build of a path that is pure CPU DSP.

The point of binding rather than porting is that a fingerprint is only
useful if every implementation agrees byte for byte. A numpy port would
be a third implementation, and the one nobody checks against the golden
vector.

VR-014 then recovers a known trim from real film audio rather than from
the synthetic tone: 40 random in-cap offsets, every one recovered to the
nearest frame, worst error 46 ms against a 500 ms budget — and 46 ms is
the quantisation floor, not a result, since offsets land on whole
92.88 ms frames.

The soft spot is tier labelling rather than accuracy. Sub-frame
misalignment drags the score down (0.94-0.99 near a frame boundary,
0.69-0.73 at half a frame), demoting 27 of 40 correct alignments to
`loose`. Allowing +/-1 frame of slack in the score fixes it: all 40 back
to `audio` at min 0.906, false matches unmoved at 0.12-0.16, for 81 ms
of the budget.

The module stops at the producer's edge. Sliding one signature against
another is the consumer's algorithm (server SPEC §3, and the jRay
plugin implements it), so a caller writing that slide in numpy is not
duplicating anything this repo owns.

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

TRACES: IR-004, IR-005 | VR-014 | UT-105, UT-106, UT-107, UT-108 | SR-003
2026-07-31 16:52: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
dtourolle af5208035e docs: the RANSAC aligner was a defect, measured
AR-005 replaced cv::estimateAffinePartial2D(..., RANSAC, 3.0) with Umeyama
least squares over all five points — the estimator InsightFace aligns with, and
so the one the ArcFace/LVFace training crops were produced by.

The first note here assumed the two agree wherever RANSAC keeps all five points,
leaving a small divergence on non-frontal faces. Measured on 400 gallery
headshots with the model held fixed, that was wrong: the crops disagree by a
median 17 source px and 83.5% embed below cos 0.99 of their Umeyama counterpart.
A 4-DoF similarity is exactly determined by two points, so every minimal sample
fits its own pair perfectly and is scored on the other three; real landmarks sit
a median 2.74 canonical px from any similarity fit, so a landmark outside the
3 px band is the common case and RANSAC returns an under-determined transform.

How much that cost in accuracy is a separate question, and the honest answer is
less than those numbers suggest. Rebuilding the full gallery moved the
intra/inter separation the AR-023 calibration is fitted from by 0.583 to 0.590:
the old warp was wrong but self-consistent, gallery and probe both went through
it, and the embedder tolerates framing variation. The sharper evidence is
duplicate detection — the rebuild dropped 1614 near-duplicates against the
original build's ~100, because unstable two-point fits gave near-identical
images visibly different vectors. That instability, not a headline accuracy
delta, is what a tracker accumulating evidence across frames was paying for.

Also records the AR-030 residual's real-data floor: on the most cooperative
images the pipeline sees, it runs a median 2.74 px, so landmark noise occupies
the first few pixels and the synthetic foreshortening ladder is optimistic about
the low end. Any discount curve has to treat that range as uninformative rather
than as mild pose, and VR-012 must set thresholds against the measured
distribution.

Tests carry the tag they verify: the residual's roll/scale invariance and
monotonicity under foreshortening are what make it a pose measure rather than a
pose-and-everything-else measure.

TRACES: AR-005, AR-030 | SR-002
2026-07-31 16:39:12 +02:00
dtourolleandClaude Opus 5 d3ab598434 feat(artifacts): push and pull the VR-013 corpus
The cross-source study needs two 4K recordings and a hand-sorted set of
face crops, neither of which belongs in git. Adds an xsource target to
both artifact scripts.

Push uploads the clips as-is (already compressed) and zips labelling/.
Pull fetches both and regenerates frames with ffmpeg rather than
downloading them: ~320 MB of PNG that is deterministic from the clips.
The extraction settings are pinned in the script, not left to the
caller, because the manifests key on frame filenames and on detection
order within each frame — verify_labels.py runs afterwards and fails
loudly if they drift.

Pull refuses to overwrite an existing labelling/. It is human ground
truth: somebody looked at 167 crops and placed each one, and silently
replacing that with a remote copy would destroy the expensive half of
the study.

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

TRACES: VR-013
2026-07-31 15:58:24 +02:00
dtourolleandClaude Opus 5 66c9ca0a0c refactor(VR-005): drive the study off the sae_embed bindings
Deletes the Python ports of SCRFDDecoder, ArcFaceEmbedder, align_face,
enhance_for_retry and calibrate_gallery, and calls the shipped C++
instead. 297 lines removed, 108 added.

The ports existed because sae_embed only exposed embed(path), so a
caller could not embed a crop it had degraded. That gap is closed:
detect(), align_face(), enhance_for_retry(), embed_crop()/embed_crops()
and GalleryCalibration are bound now, so there is no longer a reason to
keep a second implementation of any of them.

The calibration is the one that mattered. A parallel copy of the sigmoid
is precisely where "always the calibrated probability, never a raw
cosine" (AR-024) breaks without anyone noticing — the copy goes on
returning plausible numbers after the original has moved. Scoring
through the binding makes the rule structural rather than remembered.

Verified against the committed run: same shape, FPI 0.0% at every size,
same operating point of 32 px. Absolute rates differ by 1-2 points
because this check sampled 100 actors / 574 crops against the original's
258 / 999, not because anything regressed.

Also: --providers and --batch are gone, since provider selection and
batching belong to the backend; embeds are chunked at its max_batch,
because the engine does not split an oversized request and a whole
gallery in one call asks CUDA for a multi-gigabyte buffer. DEDUP_SIM and
MIN_EMB_FOR_POSITIVE stay as mirrored constants — used only to report
the population the C++ fitted on, not to refit it.

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

TRACES: VR-005 | AR-024
2026-07-31 15:51:50 +02:00
dtourolle 81ec77625c Merge branch 'feature/gallery-report' into feature/opencv5 2026-07-31 15:31:07 +02:00
dtourolleandClaude Opus 5 1dfd6fea11 feat: gallery build report
GR-003 — the calibration fit already computed per-actor dedup counts, how many
actors are eligible for positive pairs, and a 200-bin histogram of the intra and
inter distributions, then discarded all of it to stderr. Nothing persisted, so
nobody could audit whether a gallery was any good.

The report is written alongside the gallery at build time. That is the right
moment: the matcher fits the same sigmoid at analysis time, but by then the
answer is per-run and nobody is looking, whereas build time is when a gallery's
quality is actually decided.

What it surfaces, in order of usefulness:
- actors with no usable image — a silent recall ceiling, since the pipeline can
  never name them and nothing else says why
- actors below the positive-pair threshold — not broken, so nothing complains;
  they just quietly weaken every threshold downstream
- near-duplicate references removed, per actor and total
- the fitted calibration AND the two distributions behind it

That last one is the point. Every threshold in the pipeline is expressed in the
probability space this sigmoid defines, so if the distributions overlap heavily
the calibration is weak and every downstream decision inherits it — while the
gallery still looks fine from the outside.

The gallery-derived prior, intra/(intra+inter), is computed and reported but the
shipped default of 0.5 is deliberately left alone. The spec records these as
disagreeing; now the real value is visible, so the decision can be made on
evidence rather than argument.

Three tests: a zero-image actor is visible in the report, an under-referenced
actor is counted, and the report round-trips through JSON.

Suite: 95 cases, 6142 assertions.

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

TRACES: GR-003 | SR-001
2026-07-31 15:30:46 +02:00
dtourolleandClaude Opus 5 6aabeb9897 feat: provenance attributes on the embedding dump
VR-010 — a dump made with one detector/embedder pair was byte-indistinguishable
from one made with another, except for the two attributes GR-004 added. Replayed
against a gallery from a different model, cosine similarities are meaningless
but look entirely plausible. The register states the principle directly: a
fixture whose provenance is unknown is worse than no fixture, because it will be
trusted.

Sixteen attributes now record everything that determines the dump's content:
detector model and thresholds, min_face_px, max_faces, cut_threshold,
dense_scale, bbox_upscale, start/end, track_assoc_min_prob, and scene_detect.

scene_detect is the one that matters most. is_scene_boundary is all-zero both
when the detector found nothing and when it never ran, and those mean completely
different things to a consumer — without the flag they are indistinguishable.

No schema_version bump: new root attributes are additive and replay.py already
reads attributes with a default, so older dumps stay readable and the committed
fixtures — which predate this — still load.

Also corrects SCHEMA.md, which claimed bbox was already mapped to original
resolution at dump time. It is not; the upscale is applied downstream in the
matcher, after the dump tap. Harmless while dense_scale is 1 and silently wrong
otherwise, so bbox_upscale is now recorded and the doc says what the code does.

Verified end to end: all sixteen attributes present and correct on a freshly
generated dump.

Suite: 92 cases, 6136 assertions.

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

TRACES: VR-010, VR-001 | PR-002
2026-07-31 15:24:06 +02:00
dtourolleandClaude Opus 5 01d7ead1e7 study(VR-013): cross-source identification probe over input resolution
Gallery from one recording, probes from another, sweeping the probe's
input resolution end to end. VR-005 asked the same question over gallery
mugshots but degraded an already-aligned 112x112 crop with alignment held
perfect, so it isolates the embedder. Here the whole frame is downscaled
before the detector, so detection and landmark regression degrade with
it — which is most of the difference.

Corpus is two 4096x2160 clips of one shoot, four people, hand-sorted.
Ground truth is sorted by hand and gated by verify_labels.py; labels
carried down the scales geometrically by box position, never by
embedding similarity, which would keep only the faces the embedder
already gets right and drop the ones the sweep exists to find.

Findings, all scored through the production gallery sigmoid at
prob_threshold 0.754 — never a raw cosine:

- Holding 90% of the plateau needs ~50 px end to end, against VR-005's
  ~22 px. min_face_px at 40 looks right; 32 would admit faces in the
  falling region.
- FPI is 0.0% at every scale. Resolution loss goes entirely to TBI.
- The ceiling is cross-view, not resolution: everyone matches themselves
  within a recording (0.55-0.85) and collapses across two (0.14-0.45,
  threshold 0.335). Only the subject with frontal *gallery* references
  identified reliably, whatever their probe pose — so the lever is
  gallery pose coverage, not a better landmark source.
- Averaging SCRFD's overlapping detections instead of discarding them at
  NMS lifts cross-recording TPI 41% -> 49%, for one forward pass and no
  extra model.

Four identities and one shoot, so the shape is the result and the
absolute rates are not. Both clips contain all four people, so there is
no out-of-gallery class and the 10x-weighted out-of-cast misID is
untested here.

Clips, frames, hand-sorted crops and results are gitignored and belong
in the artifact registry — the sorting is human ground truth and
expensive to redo.

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

TRACES: VR-013 | AR-002, AR-005, AR-024
2026-07-31 15:20:18 +02:00
dtourolleandClaude Opus 5 042e424961 study(VR-005): minimum face size from downscaled gallery mugshots
Holds out one mugshot per actor, degrades that probe to each candidate
face size and matches it against a gallery held at native resolution,
reporting TPI/FPI per size. Replaces AR-002's 66x66 px working estimate
with a measurement. Needs no video and no ground truth beyond the
mugshot cache already on disk.

LVFace-B over 258 actors, 999 gallery embeddings, threshold 0.754:

    px    12    16    20    24    32    40   48+
   TPI   6.6% 46.5% 81.8% 93.4% 98.1% 99.2% 99.2%

FPI is 0.000 at every size — a face too small to identify degrades to
unidentified, never to a wrong name. rank-1 holds at >=99.6% from 24 px
up, so what fails first is the calibrated probability crossing
threshold, not the ranking.

Two limits on reading this. FPI grows with the number of actors
competing, so 258 understates it against a production library. And
detection and alignment run on the native image with only the resulting
112x112 crop degraded, so landmark error at small face sizes is excluded
by construction and the curve is an upper bound — VR-010 measures the
same question end to end, and lands well above these numbers.

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

TRACES: VR-005 | AR-002
2026-07-31 15:17:39 +02:00
dtourolleandClaude Opus 5 9fc2763096 feat: expansion promotion gated on all three discontinuity signals
AR-019 — promotion may only borrow same-identity evidence from a span where
identity is certain, so every discontinuity signal now clears the buffers rather
than just the histogram cut.

is_scene_boundary was already named in the gate but never set by anything, so
that half of it was dead until AR-010 gave it a producer. It now does what the
spec always said. The third signal, an identity contradiction, needs no code
here: AR-015 closes a track whose belief swapped, so it can no longer promote.

Ownership now comes from the registry rather than a second tally. TrackGallery
was computing its own plurality vote over accepted frames, which meant two
different answers to "who is this track" could coexist in one run — and the
expansion one ignored the Bayesian accumulation entirely, weighting thirty
near-identical looks the same as thirty distinct ones. The local tally survives
only as a fallback for callers with no registry attached, which is the unit
tests and the replay harness.

Suite: 92 cases, 6133 assertions.

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

TRACES: AR-019, AR-010, AR-015 | SR-005
2026-07-31 15:11:56 +02:00
dtourolleandClaude Opus 5 c843c4abe3 feat: banded admission for the per-subject embedding store
AR-018 — an embedding joins a track's store only if its similarity to something
already there falls inside a band, rather than merely being far from the gallery.

Above the upper bound it is redundant: another look at a pose the store already
covers, teaching the annex nothing while costing a slot a novel view could have
used. Below the lower bound it is suspect: within one track every face is the
same person by construction, so an embedding unlike everything else on the track
is evidence that construction failed — a track-ID collision or a bad detection.
Admitting it is exactly how an actor's annex gets poisoned with someone else's
face.

The old gate had only the upper half of that idea, expressed as a raw cosine
against the gallery. Both bounds are now calibrated probabilities (AR-024), so
the same number means the same thing here as in association and evidence
weighting rather than three different things.

This catches track-ID collisions EARLIER than the spread gate did — at the door
rather than at promotion — so the buffer never becomes two-person in the first
place. The spread gate stays as a second line for a track that drifts gradually
instead of jumping. The existing test was asserting the mechanism rather than
the outcome, so it was rewritten to assert what actually matters: whichever gate
fires, the outsider must not reach the annex.

Rejections are counted. A store that admits nothing is as broken as one that
admits everything, and neither is visible otherwise.

Band defaults 0.90-0.95 are working values pending VR-007; the two bounds fail in
opposite directions and must be swept separately.

Suite: 92 cases, 6133 assertions.

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

TRACES: AR-018, AR-024 | SR-005
2026-07-31 15:10:31 +02:00
dtourolleandClaude Opus 5 cc1bed92d8 perf: back the CPU similarity GEMM with OpenBLAS
The CPU path was a scalar triple loop. It is the correctness oracle for the GPU
backends, but it is also what CI runs — there is no GPU on the N100 host — and
since AR-003 removed the per-frame face cap, a crowded frame now scores many
faces against a library-scale gallery. Scoring one face against 5000 embeddings
is 2.6 MFLOP; in scalar that does not hold up (AR-027).

S(g,f) viewed as row-major [n_faces x n_gallery] is exactly query * gallery^T,
so the loop nest collapses into a single cblas_sgemm.

OpenBLAS is optional in the build: found via pkg-config, and the scalar path
remains when it is absent so no hard dependency is added and the two can be
diffed when a similarity looks wrong. The configure step warns rather than
failing, since a developer without it should still get a working tree.

The test target links it too. Without that the suite compiles the scalar
fallback while the builder image ships CBLAS, so CI would be verifying a kernel
that is not the one running in production — the same class of mistake as testing
a path the gate never executes.

Recorded as required (not optional) in the DP-007 image, for the same reason.

Suite: 92 cases, 6136 assertions, with CBLAS compiled in.

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

TRACES: AR-026, AR-027, DP-007 | SR-001
2026-07-31 15:04:29 +02:00
dtourolleandClaude Opus 5 13bdc27566 feat: join the decode butterfly so scene boundaries reach the face branch
AR-010 — is_scene_boundary had no producer: SceneDetectorFunc was a terminal
sink writing scenes.json and never annotating the frames flowing to face
detection. The flag was permanently false, so the boundary half of AR-007's
frame-dependent association was dead code that a test could still exercise
synthetically and appear to verify.

The topology already forks after decode — dense frames to TransNetV2, sampled
frames to face detection — so this is a fork-join. SceneBoundaries is the join:
the detector publishes each window's verdict with a watermark, and an annotator
on the sampled branch stamps the flag.

The watermark is the part that matters. TransNetV2 buffers 100 frames before it
can score any of them, so at any instant it has an opinion up to some time T and
none after. Without recording T a consumer cannot tell "no boundary" from "not
scored yet", and those demand opposite behaviour — treating unscored frames as
boundary-free is exactly what makes a downstream check pass while verifying
nothing.

Buffering alone does not work, which was my first attempt. Channel depth creates
lag only when the consumer is slower, and the face branch runs four orders of
magnitude faster per frame than TransNetV2 (0.01ms vs 400ms), so its channels
drain instantly and no lag accumulates. Measured: 106 of 364 frames outran the
detector. The annotator therefore waits on the watermark explicitly. The
detector signals completion so the tail cannot deadlock, and publishes from
flush_remaining too — without that the final frames arrive with no verdict.

Boundaries are deduped on publish, matching what scenes.json does at write time.
A run of adjacent high-scoring frames is one boundary, not several; leaving them
raw made this view report 357 where the file said 13. Now the two agree exactly.

Frames past the detector's last scored window remain unverified and are counted
as such rather than silently marked boundary-free.

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

TRACES: AR-007, AR-010 | SR-002
2026-07-31 14:56:38 +02:00
dtourolleandClaude Opus 5 fa1c494825 docs: AR-010 is blocked on a design decision, not an implementation gap
Making SceneDetectorFunc a pass-through does not work. TransNetV2 buffers 100
dense frames before it can score any of them and trusts only each window's
centre, so a boundary at time T is not known until roughly 3.3s after T at
30 fps. The face pipeline runs on a parallel branch and has long since passed T.
An association hint that arrives after the association is worthless.

Three options recorded with their costs: two-pass (correct, doubles the decode
that already dominates runtime), delaying the face branch (couples the two
branches' timing, which invites heisenbugs under backpressure), or leaving it
unwired.

Leaving it unwired costs less than it looks, which is what makes this a decision
rather than a defect. The redesign made cuts and boundaries do the same thing —
both say "spatial continuity is broken, associate on embedding" — so TransNetV2
adds nothing over the histogram except on transitions the histogram cannot see:
slow dissolves and fades. That gap is real but narrow.

Where TransNetV2 still earns its cost is AR-019, whose promotion gate wants a
span free of cuts and boundaries. A late answer is fine there, because promotion
happens on track confirmation rather than per frame — so it can be wired
offline against the collected boundary list, off the hot path entirely.

Recommendation: leave the association path on is_cut alone, wire boundaries into
AR-019, and revisit if dissolve-heavy material shows association failures.

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

TRACES: AR-010, AR-019 | SR-002
2026-07-31 14:27:05 +02:00
dtourolleandClaude Opus 5 f99f1c5ccc feat: no fixed cap on faces per frame
AR-003 — max_faces defaults to 0, meaning no cap. A fixed cap discards the
SMALLEST faces first, which are exactly the background cast X-Ray still credits
with scene membership, so the pipeline was systematically losing the people it
is supposed to find in crowded scenes.

This is only safe now that AR-004 landed. Previously an uncapped frame would
have pushed more work into channels that dropped on overflow, trading a visible
cap for silent loss. With backpressure the producer slows instead, so per-frame
cost is contained rather than discarded.

The matcher's kMaxFaces used to throw above 32, which made it an accidental
second cap. It sizes the similarity engine's preallocated buffer, so it bounds
memory rather than face count — the frame is now scored in batches of that size.
Memory stays bounded; faces do not.

Largest-first ordering is kept even without the cap, and the comment now says
why: the Hungarian solver tie-breaks on index order, so that ordering is
load-bearing for the replay determinism test rather than a leftover of the cap.

Verified end to end on a real clip: identical output to the capped run (385
frames, 693 faces), which is expected since that footage peaks at 4 faces per
frame — the point is the absence of a regression. The committed fixtures remain
byte-identical and valid.

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

TRACES: AR-003 | SR-002
2026-07-31 14:22:20 +02:00
dtourolleandClaude Opus 5 3605b8da78 fix: point the KPN submodule at the merged commit
The recorded pointer was be6e922 — the backpressure fix as originally committed,
before it was rebased onto KPN master. That commit exists on no pushed branch,
so a fresh clone of this branch could not fetch the submodule at all.

Now 6595e6e, the same change on KPN master.

Worth noting for next time: rebasing a submodule commit after the superproject
has already recorded it silently invalidates the pointer. Nothing in the
superproject's status shows it, because the submodule working tree is clean and
at a valid commit — just not the one recorded.

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

TRACES: AR-004 | SR-002
2026-07-31 11:53:27 +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
dtourolleandClaude Opus 5 c12838b9fd fix: dropped frames fail the run instead of printing a footer
A drop was reported to stderr and the process exited 0, so a run that discarded
320 frames "succeeded" and produced a truth file that looked complete. The
output in that case is a claim about footage that was never analysed, and
nothing in the file says so.

Now exits 2 and says why. Distinct from 1 (node crash) because the failures are
different: a crash produced no output, a drop produced output that cannot be
trusted.

This is also the regression test for AR-004 that otherwise did not exist. The
backpressure fix is one line in the KPN submodule — easy to lose in an update —
and with data pushes blocking, a drop can no longer occur on the data path. So
any drop now means either that fix regressed or a channel was disabled mid-run,
and both are worth stopping for.

Verified: a clean run still exits 0.

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

TRACES: AR-004 | SR-002
2026-07-31 10:45:39 +02:00
dtourolleandClaude Opus 5 d31526cfaf test: committed replay fixtures from the public-domain corpus
Five HDF5 embedding dumps from bali/ — Road to Bali (1952) — 3.6 MB total,
generated at 5 fps with a 32 px minimum face. CI never calls a model, so
inference happens on a GPU host and CI replays these as data; everything
downstream of embedding is cheap CPU maths.

Public domain is the reason this corpus rather than a convenient one: derived
fixtures can be committed, where anything cut from a copyrighted title could not
live in the repository at all.

The set covers distinct behaviours rather than being five of the same thing:
bali_28 has 9 cuts, so it exercises shot/reverse-shot association (AR-007);
bali_46 is sparse at 140 faces over 385 frames, so it exercises gaps and
extinction (AR-013); bali_13 is the busiest at 4 faces per frame; bali_31 is
short at 29s. All five recorded zero drops.

Both pinned parameters are consequences of measurements, not defaults: 5 fps
because 1 fps over a 77s clip is 77 frames, too thin for an extinction window
measured in tens of seconds; 32 px because that is the VR-005 floor, and the
corpus is 480x360 so a stricter value would reject most of what is there.

make_fixtures.sh regenerates them. Reproducibility is the requirement — a
fixture whose provenance is unknown is worse than none, because it will be
trusted. These are byte-reproducible only because of AR-004: before node
outputs blocked rather than dropped, the same command produced different dumps
run to run.

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

TRACES: VR-001 | PR-002
2026-07-31 10:41:51 +02:00
dtourolleandClaude Opus 5 09a4650fd9 feat: backpressure — the pipeline slows instead of losing frames
Picks up the KPN fix: node data outputs block on a full channel rather than
dropping. Sentinels stay out-of-band, so EOF can always overtake a stalled data
path and the hold-and-wait deadlock that comment warns about is not reachable.

Verified on a 77s clip at 5 fps, which should yield 385 sampled frames:

  before  65 written, 320 dropped, 29s, two runs differ
  after   385 written, 0 dropped, 17s, two runs byte-identical

The determinism is the part that matters. Golden fixtures were impossible while
what got dropped depended on timing; VR-001 fixture generation is unblocked by
this, and so is the CI replay strategy that depends on it.

Faster rather than slower, which is worth recording because the intuition runs
the other way: a dropped frame has already cost its decode, and the overflow
exception cost more still.

AR-004 is not fully closed. Channel capacity remains a count of items, while a
face carries a 112x112 crop and a 512-float embedding — so a crowded frame
occupies far more memory per slot than a sparse one. Bounding by bytes in
flight is the remaining half, and it matters once max_faces is removed (AR-003).

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

TRACES: AR-004, VR-001 | SR-002
2026-07-31 10:35:29 +02:00
dtourolleandClaude Opus 5 b98372bad8 docs: AR-004 is a KPN change, with the measurement behind it
Backpressure cannot be implemented in this repository. Every node output in KPN
uses the dropping push() (pool_node.hpp:404 and :710, plus branch, fanout and
interrupt_node). A lossless push_blocking() already exists on both Channel and
OutputPort — "wait for the consumer to drain instead of dropping; the producer
just runs slower" — and nothing calls it. The fix is a per-channel policy or a
network default in KPN, and this pipeline should select lossless: a dropped
frame here does not degrade a result, it silently changes one.

Measured rather than inferred. One 77s clip at 5 fps should yield ~385 sampled
frames. On CPU it produced 49, ending at 51s, with 285 dropped at camera_pos
and 51 at face_aligner. Rebuilt with CUDA the same clip ran in 29s and reached
EOF correctly, and still dropped 320 at camera_pos, yielding 65. Faster
hardware moves where the queue backs up; it does not change what happens when
it does — which is why this is a correctness requirement rather than a
throughput one.

Raising channel capacity is therefore a stopgap: it lowers the probability of
overflow without changing the behaviour on overflow, and the failure it hides
is silent corruption of the output.

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

TRACES: AR-003, AR-004, VR-001 | SR-002
2026-07-31 10:29:53 +02:00
dtourolleandClaude Opus 5 e0f9c95689 docs: minimum face size is 32px, measured (VR-005)
Replaces the 66px working estimate with the sweep result. 258 probes degraded
to each size and matched against a native-resolution gallery: 16px 46.5% TPI,
24px 93.4%, 32px 98.1%, 40px 99.2%, flat to 112px. The knee is 24-32 and 32
sits within about a point of the ceiling.

The estimate was roughly twice too strict. At 66px a large share of usable
faces would have been discarded, and on 480x360 sources most of them — which
is exactly the resolution of the fixture corpus.

The more useful finding: false identification was 0.0 at every size, including
12px. Small faces fail by becoming unidentified, never by being attributed to
the wrong actor. That asymmetry is what makes a low threshold safe — the cost
of admitting a marginal face is a miss, not a false claim.

Caveat recorded rather than assumed: FPI grows with gallery size, so 258 actors
understates it against a full library. Treat 0.0 as an observation at this
scale, not a property.

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

TRACES: AR-002, VR-005 | SR-002, PR-002
2026-07-31 10:22:38 +02:00
dtourolleandClaude Opus 5 9e4cdc4efc docs: bali fixture corpus, and AR-004 blocks reproducible fixtures
Records `bali/` — five ~77s clips of Road to Bali (1952) — as the fixture
source. Public domain, which is the point rather than a convenience: derived
fixtures can be committed, where anything cut from a copyrighted title could
not live in the repository at all.

Makes explicit what the tier table only implied: CI never calls a model. Not a
preference — the embedder measures ~930 ms/frame on the CPU provider, so a 77s
clip at 5 fps is six minutes of inference. Every model invocation happens
locally and CI consumes the result as data, which is what makes the T1/T2 split
load-bearing rather than stylistic.

Two properties of the corpus to design around: 480x360 puts many faces below
the AR-002 66px minimum, so generation must set and record --min-face-px; and
77s at 1 fps is too thin to exercise an extinction window measured in tens of
seconds, so fixtures want 5 fps.

The finding that matters: a trial dump produced 49 frames of an expected ~385,
stopping at 51s of 77s, with 285 frames dropped at camera_pos and 51 at
face_aligner on channel overflow. Channels drop rather than block, and what
drops depends on timing, so the same command twice can yield different dumps.
Golden fixtures cannot be built on that — AR-004 is a prerequisite for VR-001
fixtures, not just a throughput concern for crowd scenes.

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

TRACES: AR-004, VR-001 | PR-002
2026-07-31 10:19:10 +02:00
dtourolleandClaude Opus 5 08941540cb feat: presence windows come from registry claims (schema_version 2)
The sink no longer reconstructs presence from per-frame detections. A reaped
track already IS a window — [first_seen, last_seen] of a track an actor owned —
so it is pushed straight to the aggregator when it dies and written out as-is.

AR-012 completed end to end. The annealing pass is deleted, not disabled:
anneal_sec existed only to bridge gaps between isolated accepted frames, and a
track that survives its own gaps leaves it nothing to do. The field is REMOVED
from the output rather than zeroed — a field naming a mechanism the pipeline no
longer has is actively misleading to anyone reading a manifest, and would
outlive everyone who remembers why it reads 0.

IR-002 — schema_version 2, matching jRay/SPEC.md JR-002. Windows become objects
carrying `belief` and `route` rather than bare float pairs, so a consumer can
caveat or filter instead of treating every window as equally certain. The new
`extraction` block carries `extinction_sec` (the successor to anneal_sec, and
what a consumer actually needs to interpret a window) and `gallery_scope` —
global vs limited being the strongest single quality signal when two manifests
compete for one cut, since identical gallery_size can mean very different
recall.

AR-016 wired: a pre-write hook flushes the registry with the last timestamp
seen, so tracks still live at EOF are emitted. A film ends with faces on screen
and those tracks have not timed out; without this the closing scene's cast is
silently dropped, which reads as a recognition miss rather than a bookkeeping
bug.

IR-003 stays In Progress deliberately: the sink now writes after the flush, but
the deferred re-identification pass (AR-020) does not exist yet, so output is
still final at EOF rather than after it.

This is a BREAKING format change and part of the coordinated SR-003 bump — it
must ship together with the jRay reader and the server's acceptance of the new
shape, not ahead of them.

Suite: 80 cases, 3250 assertions.

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

TRACES: AR-012, AR-016, IR-002, IR-003 | SR-002, SR-003
2026-07-31 10:10:51 +02:00
dtourolleandClaude Opus 5 fe29d014da feat: identity evidence reaches the registry
Closes the link that made AR-012 inert: the tracker was maintaining registry
state, but nothing called observe(), so no belief accumulated, no track was ever
owned, and no presence claim could be emitted. Tracking worked and presence did
not.

The matcher now feeds every scored face to the registry as a calibrated
posterior plus its embedding. Deliberately every scored face, not only the ones
clearing prob_threshold: a run of near-misses for one actor is evidence, and
discarding it would leave ownership depending on the per-frame threshold this
redesign exists to stop relying on. The registry discounts for correlation and
decides ownership from the accumulated posterior (AR-025).

The registry is an optional dependency of the matcher. Without one it behaves
exactly as before, which keeps the replay harness and the unit tests working
unchanged rather than forcing every caller to construct a registry it does not
need.

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

TRACES: AR-012, AR-025 | SR-002
2026-07-31 10:05:59 +02:00
dtourolleandClaude Opus 5 be5f67fa96 feat: tracker on the registry — one pool, calibrated, frame-dependent
Merges feature/tracker-registry. See e9aea3f for the detail; in summary the
tracker no longer owns track state, association weights embedding over position
whenever position is uninformative, and every similarity comparison is a
calibrated probability.

Four constants retired: track_max_embed_dist, cut_revive_sim,
cut_inactive_max_frames, track_max_frames_missing.

Suite: 80 cases, 3250 assertions.

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

TRACES: AR-007, AR-008, AR-024 | SR-002
2026-07-31 10:01:31 +02:00
dtourolleandClaude Opus 5 e9aea3fc41 feat: tracker owns no state; association is frame-dependent and calibrated
Three requirements land together because they cannot be separated. The
cross-cut revival branch was the only user of cut_revive_sim, so retiring that
raw cosine forces the pool collapse, and collapsing the pool removes the only
caller of the constant. Splitting them would have produced an intermediate
commit whose only purpose was to be split.

AR-008 — FaceTrackerFunc no longer keeps its own tracks_/inactive_ maps; it
holds a shared_ptr<TrackRegistry> and operates on it directly. Two parallel
copies of track state could disagree, and every divergence would surface as a
wrong presence window with nothing to indicate it. There is now ONE candidate
pool: last_seen alone says whether IoU is meaningful. The park/revive path is
deleted outright — matching a dormant track is ordinary inter-frame
association, and continuity falls out of the embedding comparison the tracker
already did rather than being a mechanism of its own.

AR-007 — track_alpha becomes the base weight for ordinary frames only.
Association drops to embedding-only when position carries no information:
on is_cut or is_scene_boundary, because the viewpoint changed, and for a
dormant track, because time has passed since its box was last valid. The second
case matters as much as the first and had no equivalent before.

AR-024 — association cost is a calibrated probability, never a raw cosine. The
tracker takes the calibration belonging to the active embedder, the same
function object EvidenceDiscounter uses. track_max_embed_dist becomes
track_assoc_min_prob, which means the same thing for every model, gallery and
face size, where a bare cosine threshold did not.

Retired: track_max_embed_dist, cut_revive_sim, cut_inactive_max_frames, and
track_max_frames_missing — the last superseded by the registry's extinction
window. That one is worth naming: a frame count silently changed meaning with
sample_fps, so the same configuration behaved differently at 1 fps and 5 fps.
Extinction is in seconds and lives in one place.

Tests rewritten rather than deleted. The old cases asserted revival by raw
cosine; the same behaviours are now asserted through the registry — a face lost
across a cut and re-associated is the SAME track, one unbroken window, and a
face returning past the extinction window is not. Added the case AR-007 exists
for: two people swap screen positions across a cut while keeping their faces,
and identity must follow the embedding rather than the box.

Suite: 80 cases, 3250 assertions.

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

TRACES: AR-007, AR-008, AR-024 | SR-002
2026-07-31 09:58:27 +02:00
dtourolleandClaude Opus 5 b7c96641a9 docs: no exact tier — the file-hash tier was withdrawn
A stale reference to 'the runtime/exact tiers' as the fallback for media too
short to carry an audio signature. The exact tier keyed on a file hash and was
withdrawn on legal grounds: it fingerprinted the individual copy a user holds
rather than the cut the timings describe.

The pipeline never emitted a video_hash, so nothing in the code changes — but a
spec that still names a withdrawn tier is what makes the withdrawal look like an
oversight to the next reader, which is exactly how it nearly got re-added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 09:55:50 +02:00
dtourolleandClaude Opus 5 843852e19c feat: registry owns correlation discounting (AR-024, AR-025)
Moves two responsibilities inside the registry that callers should never have
been trusted with.

AR-025 — per-frame evidence is discounted for correlation by the registry
itself, via EvidenceDiscounter. Log-odds accumulation is only valid for
independent observations, and consecutive frames of one track are anything but:
near-identical pose, lighting and expression. Accumulated naively, thirty frames
of the same face at the same angle drive the posterior to certainty on what is
effectively one measurement.

Each observation is weighted by how much it adds — a view already contributed
counts for ~nothing, a genuinely new pose counts in full. This reuses the
novelty judgement gallery expansion already makes rather than inventing a second
one. The discounter is a separate class the registry holds, so it stays testable
and swappable, but it is a constructor argument rather than an option: there is
no correct way to accumulate without it.

AR-024 — observe() takes a calibrated probability and converts to log-odds
internally. A caller can no longer hand it a raw cosine, which would have been
silently wrong rather than obviously so. Retiring the remaining raw-cosine
constants in the tracker is still open.

DeadTrack now reports effective_obs alongside observations: the raw count and
the evidence that actually counted. A large gap between them is a track the
camera stared at, and worth seeing.

Three tests, one of which is the point: two tracks given the same number of
observations at the same posterior, one repeating a single view and one seeing
eight distinct ones, must not end up equally confident. Without discounting they
would be identical.

Fixed a test that asserted a belief swap on tied evidence. A tie leaves
ownership where it is — a challenger must out-accumulate the incumbent, since
one contrary observation is noise. The original test passed only because it fed
raw log-odds directly.

Suite: 78 cases, 3245 assertions. Coverage 20/63 to 22/63.

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

TRACES: AR-024, AR-025 | SR-002
2026-07-31 09:15:44 +02:00
dtourolleandClaude Opus 5 f0c7126f80 feat: TrackRegistry — presence follows track extent
The spine of the redesign. Presence is now the extent of a track an actor owns,
[first_seen, last_seen], rather than the subset of frames in which recognition
happened to succeed. An actor recognised only at the end of a long track is
present for all of it, which is what the scene-scoped ground truth actually
records.

AR-013 — `last_seen` as an optional carries the entire liveness state: unset
means on screen, set means went off at that timestamp and still revivable,
reaped means emitted and erased. No missing-frame counter, no expired flag. It
subsumes the tracker's existing two-pool split, so there is no separate revival
path — matching a dormant track is ordinary inter-frame association.

The asymmetry is the point: interior gaps are claimed, the trailing cool-down is
not. A face lost and re-associated within the timeout never closed its track, so
the gap is presence — someone briefly occluded has not left the scene. But a
track that dies ends at its last sighting, never at the death time. That is
precisely the over-claim the retired extinction_sec keep-alive produced, where
presence ran on into the closing credits.

AR-014 — a belief swap A→B closes the track and opens a successor at the swap
frame. Not a correction: two non-twins both clearing the threshold on one face
is not realistic, whereas a track_id carried across a viewpoint change onto a
different person is. Treating it as a swap-and-continue would emit one window
blending two people; treating it as a boundary yields two that are each right.

AR-015 — two live tracks owned by one actor means at least one is wrong, since a
person cannot be in two places at once. A reverse index catches it on the update
that causes it rather than by scanning. This makes identity a third cut
detector, independent of the histogram and TransNetV2 and firing where those
failed.

AR-016 — flush() closes tracks still live at EOF. Without it a film ending
mid-shot silently drops its closing cast, which presents as a recognition miss
rather than a bookkeeping bug.

Reaping hands the dead track to the aggregator and erases it, so the registry
holds only live tracks and its size is bounded by concurrent on-screen faces
rather than growing with the film.

Locking: a frame's association pass is atomic as a unit via FrameScope, since
per-call locking would let another thread observe a half-updated frame.
owner() reads tally and verdict under one lock — separately, a track could be
both unowned and owned within a single promotion decision. A vote for an
already-reaped track is dropped and counted, because a nonzero count means the
timeout is shorter than the matcher's lag.

11 unit tests, driven directly against the registry with no network and no
fixture — the awkward cases are constructed rather than hunted for. Suite: 75
cases, 3236 assertions. Coverage 14/63 to 20/63.

Not yet wired into FaceTrackerFunc; that is AR-007/AR-008.

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

TRACES: AR-012, AR-013, AR-014, AR-015, AR-016, AR-017 | SR-002
2026-07-31 09:08:39 +02:00
dtourolleandClaude Opus 5 b35d49c772 docs: tag the implemented core with its requirement IDs
Adds TRACES tags to code that already satisfies a Done requirement, so coverage
reflects what exists rather than starting from zero:

AR-001 face detection, AR-005 ArcFace alignment, AR-023 calibration fit,
DP-001/DP-002 the single analysis core behind the CLI, IR-001 truth-file
emission, IR-006 the Jellyfin round trip, GR-001/GR-002 gallery build and
incremental merge, VR-001 the embedding dump, VR-002 replay through the real
nodes, VR-003 per-second scoring.

Only Done requirements are tagged. A tag on Planned work would inflate coverage
with fiction that looks plausible — the same failure family as a gate that
cannot fail, and harder to spot.

GR-005 (gallery never leaves the instance) stays untagged deliberately: it is a
prohibition satisfied by the absence of an egress path, so there is no unit that
decides it. Same shape as PR-005 in the system spec, which has no software row
for the same reason. A goal held only by prohibitions cannot be verified by
pointing at code.

Coverage 5/63 to 14/63. The three VR tags are reported as tagged-but-unexecuted
and excluded from the numerator, since their tier cannot run on the CI host —
tagging deliberately cannot raise the number on its own.

Suite still 64 cases, 3199 assertions.

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

TRACES: AR-001, AR-005, AR-023, DP-001, DP-002, IR-001, IR-006, GR-001, GR-002, VR-001, VR-002, VR-003
2026-07-30 21:20:27 +02:00
dtourolleandClaude Opus 5 62396fce75 docs: record why exclude_dirs stays unset, refresh matrix
The tool's defaults already exclude `vendor`, which covers the submodule at
scripts/vendor/jray-project. Setting the key explicitly is a trap worth
documenting: it REPLACES the defaults rather than extending them, and matching
is on path components rather than prefixes — so ["scripts/vendor"] matches
nothing while silently dropping __pycache__, node_modules, build and the rest.
Verified: the submodule's source is not scanned, and the only vendored path in
the report is the system spec it reads for PR/SR orphan checking.

Coverage after the merges: 5/63, 0 orphans. Every tag names a real requirement,
and nothing claims a requirement that is still Planned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:16:15 +02:00
dtourolleandClaude Opus 5 908d166173 feat: bind galleries to the embedder that built them
GR-004 — a gallery built with one embedding model is meaningless with another.
Cosine similarities across models are garbage but look entirely plausible, so
this fails silently and expensively; every measurement taken against a
mismatched pair would have been quietly wrong.

The stamp is the model basename plus a SHA-256 of its bytes, with embed_dim as
a cheap extra guard. The hash decides and the name explains, because neither
works alone: a name is a promise rather than a fact — models get re-exported in
place under an unchanged filename, which is exactly the case where the weights
differ and nothing else does — while a bare hash mismatch tells an operator
nothing actionable.

Mismatch is fatal in every mode with no bypass. Unstamped only warns, because
unstamped is unknown rather than known-bad, and an error firing on every legacy
gallery trains people to reach for the bypass reflexively. scripts/stamp_gallery.py
binds an existing gallery in place with no re-embedding, so the warning is a
migration step rather than a permanent state; --require-gallery-stamp promotes
it to an error once a site has migrated.

Two gaps found that would have defeated the requirement outright:

- Embedding dumps carried no stamp, so a replay — which has no live embedder —
  had nothing to check the gallery against. Dumps now carry embedder_model and
  embedder_sha256 as root attributes. Additive; schema_version stays 1. This is
  the same gap the dump audit identified independently.
- --merge produced one file holding two embedding spaces, which no later check
  can untangle. Merge paths now verify before writing.

The stamp also survives identity_matcher's calibration write-back, which would
otherwise have stripped it on the first analysis run — the check would have
worked exactly once.

Conflicts resolved additively: both branches appended a source to sae_gallery
and to the test target, and both edited the GR-004 register row.

Merged suite: 64 cases, 3199 assertions, passing on CPU with no GPU.

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

TRACES: GR-004, VR-001 | SR-001
2026-07-30 19:04:07 +02:00
dtourolleandClaude Opus 5 662a469870 feat: v1 content-derived audio signature
Implements the server spec §3 construction: a 120 s window centred on the media
midpoint, downmixed and resampled to mono 11025 Hz, a 4096/1024 Hann STFT, 32
log-spaced bands over 300-3000 Hz, one byte per frame carrying a 5-bit peak band
and a 2-bit energy class, base64 with a v1: prefix.

IR-004 — the signature itself, in src/audio_signature.{hpp,cpp}. Decode reuses
the already-linked FFmpeg libraries; libswresample was missing from ffmpeg_libs
and is added. The FFT is written out rather than taken from a library: the
output must be bit-identical against a separate C# implementation, so a
dependency whose version can change the numerics is a liability.

IR-005 — a golden fixture at tests/fixtures/audio/, verified against an
independent Python implementation producing identical bytes. FLAC rather than
WAV because 120 s of 11025 Hz PCM is 2.6 MB and does not compress in git; both
decode to identical samples. The PCM checksum is asserted separately from the
signature so a codec-level divergence is distinguishable from a DSP one.

IR-007 — media under 120 s emits no signature at all, since the centred window
underflows. The rule must be identical in both producers or signatures never
match on exactly the short items most likely to be misidentified.

IR-008 — the v1: prefix is emitted and honoured, so a future change to the DSP
chain is detectable rather than silently non-matching.

Six parameters the spec left undefined had to be pinned to reproduce a byte
stream at all: periodic Hann, band value as the mean of linear magnitudes, ties
to the lowest band, the energy-class definition and its thresholds, byte layout,
and the base64 alphabet. These are now normative in the server spec — left only
in a C++ header, the C# side would have guessed and diverged.

Not yet emitted into the truth file; that is the coordinated schema_version bump
under IR-002/IR-003.

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

TRACES: IR-004, IR-005, IR-007, IR-008 | SR-003
2026-07-30 19:00:08 +02:00
dtourolle 28e3bd9496 docs: generated traceability matrix
Committed rather than ignored, matching house precedent: coverage becomes
visible to anyone browsing the repo, and its movement over time is real history
worth having in the log.
2026-07-30 18:59:06 +02:00
dtourolleandClaude Opus 5 d9aaf8fa4e build: consume the shared traceability tooling via submodule
jray-project is added at scripts/vendor/jray-project and the extractor is used
from there. Only two files are repo-local: traceability.toml, which carries
everything repo-specific, and the CI workflow that invokes the vendored gate.

The extractor is deliberately NOT copied in. One implementation, parameterised
by config — a second copy would drift from the first, and the tool already
proves it works unchanged against all three registers.

Enables system_spec so PR/SR orphan checking runs: previously uncheckable,
because the system spec lived outside every component's checkout.

Gate is green at 0.0% of 63 requirements, which is correct — nothing is tagged
yet. Eleven are flagged unverifiable on this CI host and excluded from the
numerator rather than counted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:58:40 +02:00
dtourolleandClaude Opus 5 a2ebdc4cdd docs: GR-004 done — gallery/embedder binding
Stamp is model basename + SHA-256 + embed_dim: the hash decides, the name
explains. A name alone is a promise rather than a fact — models get re-exported
in place under an unchanged filename, which is exactly the case where weights
differ and nothing else does. A hash alone is unactionable in an error message.

Mismatch is fatal in every mode with no bypass. Unstamped only warns, because
unstamped is unknown rather than known-bad, and an error that fires on every
legacy gallery trains people to reach for the bypass. A migration script binds
existing galleries in place with no re-embedding, so warn is not permanent.

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

TRACES: GR-004 | SR-001
2026-07-30 18:37:07 +02:00
Claude 7db40f430d GR-004: bind galleries to the embedder that built them
A gallery is only valid for the embedder that produced its vectors. Cosine
similarities across models are meaningless but *look* plausible, so the mistake
is silent and every measurement taken afterwards is suspect. Stamp the embedder
identity into the gallery at build; verify it at every load.

The stamp is the model file's basename plus the SHA-256 of its bytes (plus
embed_dim). The hash decides, the name explains. A name alone is a promise
rather than a fact — models get re-exported and overwritten in place under an
unchanged filename, which is exactly the case where the weights differ and
nothing else does. A hash alone is correct but unactionable in an error message.
SHA-256 is derived from the artefact, needs no registry kept current, and costs
~0.1s for a 250MB ONNX, memoised per process.

Mismatch is a hard error in every mode, with no bypass, naming both sides.

Unstamped legacy galleries warn loudly and proceed: unknown is not known-bad,
and hard-failing every pre-existing gallery would turn the check into something
people disable rather than trust. --require-gallery-stamp (or
SAE_REQUIRE_GALLERY_STAMP=1, which propagates to subprocesses) promotes that to
a hard error — the mode measurement work should run in. scripts/stamp_gallery.py
re-binds an existing gallery with no re-embedding, so "warn" is a cheap state to
leave rather than a permanent one.

Embedding dumps carry the same stamp: a replay has no live embedder, so the dump
is the embedder as far as the gallery is concerned. Derived galleries inherit
their source's stamp; --merge and the JSON gallery merge check before writing,
since one file holding two embedding spaces cannot be untangled afterwards.

Verified in: scene_analyze, scene_preview, the sae_kpn matcher binding,
replay.py, optimize.py (once per film at startup, before the first evaluation),
movienet_eval.py and both merge paths.

Stamp logic lives in src/gallery/embedder_stamp.{hpp,cpp} and its Python twin
scripts/sae_stamp.py, kept dependency-light so replay subprocesses do not pay
sae_gallery's requests/Pillow import to ask whether two models match.

Tests: 12 new cases in test_gallery_store.cpp covering the comparison logic,
both round trips, and the SHA-256 vectors that guarantee the C++ and hashlib
stamps agree. No ONNX or GPU required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:35:46 +02:00
dtourolleandClaude Opus 5 020306c94f docs: builder images and per-backend release binaries (DP-008)
Adds the build/deploy story that was missing: containerised builder images for
cpu / cuda / rocm, and release jobs producing prebuilt binaries so a first
install need not compile.

States explicitly that this does not reverse DP-005. That requirement rejects
Docker as a *runtime* — GPU passthrough is fragile and exists only because of
the container. Using it as a *build* environment is the opposite case, and lets
one machine produce binaries for backends it cannot itself run. Build in a
container, run natively.

Two things deliberately cannot ship, and the installer must not imply otherwise:
TensorRT engines are GPU-architecture and TRT-version specific, so
build_trt_engines.sh still runs on the target; and models are ~725 MB in LFS,
orthogonal to the binary.

The base image is chosen by the OLDEST glibc to be supported, not by
convenience — a binary built in a container runs against the host's glibc, and
getting this wrong fails at load with GLIBC_2.xx not found. Accelerator runtimes
have the same shape of problem, so each image documents its compatible
CUDA/ROCm range and the installer checks it rather than discovering a mismatch
at first inference.

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

TRACES: DP-005, DP-007, DP-008 | PR-004
2026-07-30 18:35:10 +02:00
dtourolleandClaude Opus 5 2919ed68d1 docs: audit findings, verification tiers, CI image, artifact storage
Corrections from the dump audit and the completed agent work:

- AR-010 is not started, not in progress: is_scene_boundary has no producer
  anywhere. SceneDetectorFunc is a terminal sink writing scenes.json and never
  annotates the frame, so the field is permanently false and the dump column a
  constant 0. A replay test of the frame-dependent track_alpha would pass
  vacuously — the worst failure mode for a verification gate.
- T1 (functor-level) becomes the primary verification tier, not T2. KPN node
  functors are plain callables constructed outside the network, so a node is
  tested by calling operator() with hand-built inputs. That removes four
  hazards at once: fixture provenance, replay-from-frame-0, cross-test state
  leakage, and replay-harness nondeterminism. It also means a dead upstream
  producer no longer blocks testing its consumer.
- VR-010 (dump provenance) and VR-011 (replay harness rewrite) added. A dump
  made with LVFace is currently byte-indistinguishable from one made with
  w600k-R50 — the GR-004 problem again, in the dump.
- Four requirements had no verification tier at all; the traceability gate
  found them.
- DP-007: CI builder image, CPU-only, pinned by tag in the Gitea container
  registry. Corpus fixtures go to the package registry rather than LFS: LFS is
  pulled on clone and would tax every developer for data only CI reads.
- IR-004/005/007/008 marked done; the v1 DSP parameters they had to pin are
  now normative in the server spec.

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

TRACES: AR-010, DP-007, IR-004, IR-005, IR-007, IR-008, VR-001, VR-010, VR-011 | SR-002, SR-003
2026-07-30 18:33:32 +02:00
dtourolleandClaude Opus 5 45ef7c1916 Add the v1 audio signature to the pipeline (IR-004, IR-005, IR-007, IR-008)
Implements the content-derived spectral-peak signature from
JRay-public-server/SPEC.md §3 so a truth file is self-identifying: 120 s
window centred on the media midpoint, mono at 11025 Hz, 4096/1024 Hann
STFT, 32 log-spaced bins over 300-3000 Hz, one byte per frame (5-bit peak
band + 2-bit energy class), base64, `v1:` prefix.

Audio decode is a second stream from the FFmpeg libraries the pipeline
already links for video; libswresample is added to the existing
ffmpeg_libs interface target. The FFT is written out rather than pulled
from a library for the same reason the plugin vendors one: the output has
to be bit-identical across two languages, so a dependency whose version
could change the numerics is a liability.

The server spec fixes the geometry but not enough to reproduce a byte
stream — Hann periodicity, band aggregation, the energy-class definition,
tie-breaking and the base64 alphabet are all unconstrained by it. Those
are pinned in audio_signature.hpp and mirrored in the golden fixture, so
the plugin can be implemented from the fixture alone.

IR-005: tests/fixtures/audio/ carries a deterministic 120 s tone (FLAC —
lossless, so identical PCM to the WAV make_fixture.py emits, and 3.5x
smaller in git) plus the signature it must produce, the decoded-PCM
checksum and the full parameter contract. That directory is the artefact
shared with the plugin repo; the PCM checksum is separate from the
signature so a codec-level difference is distinguishable from a DSP one.

IR-007: media under 120 s emits no signature. Same for a file with no
audio stream or one that will not open — UR-9 is an enhancement and must
never be able to break a fetch.

Verified against an independent Python reference implementation: same
bytes. All 32 bands and all 4 energy classes appear in the golden vector,
and the window-centring test wraps the fixture in 90 s of silence either
side and requires the golden value back.

Not wired into the truth-file output yet — that is the schema_version
bump under IR-002/IR-003 and is deliberately out of scope here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:25:37 +02:00
169 changed files with 21484 additions and 1600 deletions
+154
View File
@@ -0,0 +1,154 @@
name: Traceability Validation
# Mirrors JellyTau's .gitea/workflows/traceability-check.yml. The extractor is
# stdlib Python, so there is no toolchain install step and no jq.
#
# This workflow is component-agnostic: every repo-specific setting - which ID
# prefixes count, which file suffixes are source, which directories to scan,
# the threshold - lives in traceability.toml at the repo root, and the same
# extractor is shared by all three JRay components. Copying this file into
# another component needs no edits.
#
# NOTE: the runner here is an Intel N100 with no discrete GPU. This job is only
# ever static analysis of source comments plus markdown parsing, so it is cheap;
# the requirements it reports as "tagged but unexecuted" are the ones that need
# a GPU host, and they are deliberately never counted as covered.
on:
push:
branches:
- main
- master
- develop
pull_request:
branches:
- main
- master
- develop
jobs:
validate-traces:
runs-on: linux/amd64
name: Check requirement traces
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Check Python is available
run: |
set -e
command -v python3 >/dev/null 2>&1 || {
echo "python3 is missing from the runner image."
echo "The traceability tooling is stdlib-only Python;"
echo "3.9+ with CLI flags, 3.11+ to read traceability.toml."
exit 1
}
python3 --version
# The gate's own arithmetic is the thing being trusted, so its tests run
# before it does. JellyTau's gate was believed for months while it was
# dividing by frozen literals; untested gate logic is how that happens.
- name: Test the extractor
run: python3 scripts/vendor/jray-project/scripts/traceability/test_extract_traces.py
# Threshold policy and every other repo-specific setting live in
# traceability.toml, not here, so local runs and CI runs cannot disagree
# about what "passing" means. Denominators come from docs/requirements.md
# at run time and are never hardcoded -- in this file or anywhere else.
#
# A misconfigured run (zero requirements parsed, zero files scanned) is a
# hard failure rather than a plausible-looking 0%.
- name: Traceability gate
run: sh scripts/vendor/jray-project/scripts/traceability/traceability-gate.sh
# AR-024's register row names its verification tier as "Static check --
# no bare cosine outside a tagged EXCEPTION". This is that check, and it
# belongs here rather than in unit-tests.yml because it is static
# analysis of source text, like everything else in this job, and needs
# no toolchain. It blocks: an untagged bare cosine is a defect by the
# invariant's own wording, not a warning.
- name: AR-024 — no bare cosine outside a recorded exception
run: python3 scripts/ci/check_raw_cosine.py
- name: Check modified files for traces
if: github.event_name == 'pull_request'
run: |
set -e
echo "Checking modified sources for TRACES tags..."
# The extensions come from the report the gate just wrote, which got
# them from traceability.toml. Restating them here would be a second
# place for the source-file definition to live, and the two would
# drift the first time a language is added.
PATTERN=$(python3 -c "
import json, re, sys
suffixes = json.load(open('traces-report.json'))['config']['sourceSuffixes']
print('(' + '|'.join(re.escape(s) + '\$' for s in suffixes) + ')')
")
echo "Source suffixes from traceability.toml: $PATTERN"
CHANGED=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" \
| grep -E "$PATTERN" || true)
if [ -z "$CHANGED" ]; then
echo "No source files changed."
exit 0
fi
echo "Changed files:"
echo "$CHANGED" | sed 's/^/ /'
echo ""
# Advisory by design: not every file implements a requirement, and a
# tag on every function is noise that rots faster than it helps
# (CLAUDE.md: tag the unit that decides). This step exists to prompt,
# not to block. The blocking checks are in the gate step above.
#
# Piped into the loop rather than a here-string, and `case` rather
# than `[[ == ]]`, so this works under dash as well as bash. The loop
# body runs in a subshell, so misses are recorded in a file.
MISSING=$(mktemp)
echo "$CHANGED" | while IFS= read -r file; do
case "$file" in
*/test_*.py|*_test.py|*Tests.cs|tests/*|*/tests/*) continue ;;
esac
[ -f "$file" ] || continue
if ! grep -q 'TRACES:' "$file"; then
echo " no TRACES tag: $file"
echo "$file" >> "$MISSING"
fi
done
COUNT=$(wc -l < "$MISSING" | tr -d ' ')
rm -f "$MISSING"
if [ "$COUNT" -gt 0 ]; then
echo ""
echo "$COUNT changed file(s) carry no requirement tag."
echo "Format: // TRACES: AR-012, AR-013 | SR-002"
echo " (pipe separates requirement types, comma separates IDs)"
echo "A deliberate invariant exception is tagged separately:"
echo " // EXCEPTION: AR-024 <reason>"
echo "See CLAUDE.md and SPEC.md section 6."
fi
- name: Report summary
if: always()
run: |
echo "Traceability matrix: docs/traceability.md"
echo ""
head -40 docs/traceability.md || true
- name: Save reports
if: always()
uses: actions/upload-artifact@v3
with:
name: traceability-reports
path: |
traces-report.json
docs/traceability.md
retention-days: 30
+140
View File
@@ -0,0 +1,140 @@
name: Unit tests
# TRACES: DP-007 | PR-004
#
# The tier the verification strategy is built on, finally executing.
#
# docs/requirements.md describes a four-tier plan in which T1 (functor unit)
# and T2 (replay) are "the only tiers that can exist in CI at all", and the
# traceability gate reports a CI-scope coverage fraction over exactly those
# tiers. Until this workflow existed, nothing ran them: "covered" meant a
# TRACES tag was present in a file, not that any test had been executed. That
# is the same failure mode as counting a test that cannot run, one level up,
# and the gate cannot detect it because a tag is all it can see.
#
# The runner is an Intel N100 with no discrete GPU. Nothing here calls a model:
# T1 constructs node functors directly, and T2 replays a precomputed HDF5 dump.
# T3 (ORT CPU smoke) and T4 (GPU) are deliberately absent -- the embedder is
# ~930 ms/frame on this hardware, so a 77 s clip at 5 fps would be six minutes
# of inference alone.
on:
push:
branches:
- main
- master
- develop
pull_request:
branches:
- main
- master
- develop
jobs:
unit-tests:
runs-on: linux/amd64
name: Build and run the GPU-free suite
# Pinned by tag, never `latest`, so rebuilding the image cannot silently
# change what a previous green build meant. Bumping the dependency set means
# bumping the tag in scripts/ci/build_builder_image.sh AND here, in one
# commit -- see that script's header.
container:
image: gitea.tourolle.paris/dtourolle/sae-builder-cpu:v1
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# KPN is a submodule and the pipeline does not build without it.
#
# NOTE: this checks out the commit this repo PINS, which is the whole
# point and is also the first thing this job will disagree with a
# developer about. A local KPN working copy that is ahead of
# origin/master builds and passes here while CI builds something else
# entirely; the AR-004 evidence in docs/requirements.md was gathered
# that way. If this job fails on tests that pass locally, check
# `git -C external/KPN log origin/master..HEAD` before suspecting the
# tests.
# LFS is deliberately NOT fetched: SAE_MODELS_DIR is baked into the
# binary as a path string and nothing in T1/T2 opens a model file, so
# pulling ~hundreds of MB of ONNX would cost the job everything and
# buy it nothing.
submodules: recursive
lfs: false
- name: Assert the builder image is the pinned one
run: |
set -e
echo "builder=$SAE_BUILDER version=$SAE_BUILDER_VERSION"
echo "ort=$SAE_ORT_VERSION opencv=$SAE_OPENCV_VERSION"
# The image reports its own tag. A mismatch means the `container:`
# line above and the image that actually landed disagree, which is
# exactly the drift the pinning exists to prevent -- so it fails the
# job rather than building against an unknown toolchain.
[ "$SAE_BUILDER_VERSION" = "v1" ] || {
echo "image reports version '$SAE_BUILDER_VERSION', workflow pins v1" >&2
exit 1
}
- name: Fetch replay fixtures
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
# bash, not sh: the script declares #!/bin/bash and uses `set -o
# pipefail` and arrays, which dash does not have.
run: bash scripts/artifacts/pull_artifacts.sh replay-fixtures latest
# pull_artifacts.sh warns and continues when a package version is missing,
# which is right for a developer pulling one artifact of several and wrong
# here. A T2 test whose fixture never arrived must not look like a pass:
# the dumps are the entire input to the replay tier, and VR-002's claim is
# that replay drives the real nodes over real data.
- name: Verify the fixtures actually arrived
run: |
set -e
missing=0
for f in tests/fixtures/dumps/superhero.h5; do
if [ -s "$f" ]; then
echo " ok: $f ($(wc -c < "$f") bytes)"
else
echo " MISSING: $f" >&2
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
echo "" >&2
echo "Replay fixtures are absent, so the T2 tier cannot run." >&2
echo "They are not in git (tests/fixtures/dumps/.gitignore) -- they" >&2
echo "live in the Gitea generic package registry and are pulled by" >&2
echo "the step above, which needs GITEA_TOKEN to resolve 'latest'." >&2
exit 1
fi
- name: Configure
run: |
set -e
# SAE_GEMM_BACKEND defaults to ROCM and the auto-detect prefers a GPU
# backend where it finds one; CPU is stated explicitly so this job
# cannot start depending on what happens to be installed on the runner.
# The CPU kernel is OpenBLAS in this image (tests/CMakeLists.txt fails
# the configure if it is not), so the suite exercises the kernel the
# CPU release actually ships.
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DSAE_BUILD_TESTS=ON \
-DSAE_GEMM_BACKEND=CPU
- name: Build the test suite
run: cmake --build build --target sae_tests --parallel
- name: Run the tests
run: ctest --test-dir build --output-on-failure
- name: Save test output
if: always()
uses: actions/upload-artifact@v3
with:
name: unit-test-results
path: build/Testing/
retention-days: 30
+8
View File
@@ -1,5 +1,6 @@
# Build
build/
build-*/
cmake-build-*/
CMakeCache.txt
CMakeFiles/
@@ -19,6 +20,10 @@ compile_commands.json
# coverage). Regenerate with scripts/docs/run_holdout_all_models.py and
# scripts/docs/gallery_coverage_per_film.py.
!docs_data/*.json
# Exception: test fixtures are inputs, not build output. The audio golden
# vector (IR-005) is shared verbatim with the jRay plugin repo, so it has to be
# tracked. Regenerate the media with tests/fixtures/audio/make_fixture.py.
!tests/fixtures/**
# Video files
*.mp4
*.mkv
@@ -113,3 +118,6 @@ venv/
*.swo
.DS_Store
Thumbs.db
.venv-rocm/
!models/scene_boundary_xgb.json
experiments/dump_review/
+3
View File
@@ -2,3 +2,6 @@
path = external/KPN
url = https://gitea.tourolle.paris/dtourolle/KPN.git
branch = master
[submodule "jray-project"]
path = scripts/vendor/jray-project
url = git@gitea.tourolle.paris:dtourolle/jray-project.git
+128 -14
View File
@@ -55,6 +55,13 @@ set_property(CACHE SAE_GEMM_BACKEND PROPERTY STRINGS ROCM CUDA CPU)
# default so ROCm/CPU builds don't reference unavailable EPs.
option(SAE_ORT_TRT_EP "ORT backend: enable TensorRT/CUDA execution providers" OFF)
# AR-026/AR-027: the CPU GEMM path is backed by OpenBLAS, and its absence is a
# configure error rather than a silent downgrade to the scalar loop. Declared at
# top level because the unit-test target compiles the CPU kernel regardless of
# which backend the main build selected, and both must make the same choice.
option(SAE_ALLOW_SCALAR_GEMM
"Permit the scalar-loop GEMM fallback when OpenBLAS is absent" OFF)
# Back-compat: a legacy -DSAE_WITH_TRT=ON/OFF seeds the new vars (ON⇒TRT+CUDA,
# OFF⇒ORT+ROCM) unless the user set them explicitly.
if(DEFINED SAE_WITH_TRT)
@@ -152,6 +159,37 @@ if(SAE_GEMM_BACKEND STREQUAL "CPU")
set_target_properties(gemm_backend PROPERTIES POSITION_INDEPENDENT_CODE ON)
target_include_directories(gemm_backend PRIVATE src)
target_compile_definitions(gemm_backend PRIVATE SAE_GEMM_CPU)
# AR-026/AR-027: the CPU path is backed by OpenBLAS, and that is REQUIRED
# rather than opportunistic. The CPU backend is what CI (no GPU) and the cpu
# builder image actually run, so a silent fall back to the scalar loop means
# AR-027 is measured — or worse, believed — on a path no release uses. A
# missing dependency should stop the build and name itself, not degrade into
# a slower answer nobody notices.
#
# The scalar loop survives as the correctness oracle the two backends are
# diffed against; -DSAE_ALLOW_SCALAR_GEMM=ON is how you ask for it, which
# keeps that an explicit, visible choice.
find_package(PkgConfig QUIET)
if(PkgConfig_FOUND)
pkg_check_modules(OPENBLAS QUIET openblas)
endif()
if(OPENBLAS_FOUND)
message(STATUS "GEMM backend: CPU + OpenBLAS ${OPENBLAS_VERSION}")
target_compile_definitions(gemm_backend PRIVATE SAE_GEMM_CBLAS)
target_include_directories(gemm_backend PRIVATE ${OPENBLAS_INCLUDE_DIRS})
target_link_libraries(gemm_backend PRIVATE ${OPENBLAS_LINK_LIBRARIES})
elseif(SAE_ALLOW_SCALAR_GEMM)
message(WARNING "GEMM backend: CPU scalar fallback (SAE_ALLOW_SCALAR_GEMM=ON) — "
"correct, but slow on a large gallery. Do not measure AR-027 here.")
else()
message(FATAL_ERROR
"OpenBLAS not found, and the CPU GEMM backend requires it (AR-026/AR-027).\n"
" Install it: Fedora dnf install openblas-devel\n"
" Arch pacman -S openblas\n"
" Debian apt install libopenblas-dev\n"
" Or build the scalar fallback deliberately: -DSAE_ALLOW_SCALAR_GEMM=ON")
endif()
elseif(SAE_GEMM_BACKEND STREQUAL "CUDA")
find_library(CUBLAS_LIB cublas
HINTS /opt/cuda/targets/x86_64-linux/lib /opt/cuda/lib64
@@ -196,23 +234,31 @@ endif()
# FFmpeg (hwaccel video decode: CUDA/VAAPI, runtime-detected + swscale colour
# conversion). Hwaccel support is built into libavcodec/libavutil; no extra
# libraries are needed here.
# libswresample is the audio side of the same dependency — downmix + resample
# for the audio signature (IR-004, src/audio_signature.cpp). Not a new project
# dependency: it ships with the libav* set already required above.
find_package(PkgConfig REQUIRED)
pkg_check_modules(AVFORMAT REQUIRED libavformat)
pkg_check_modules(AVCODEC REQUIRED libavcodec)
pkg_check_modules(AVUTIL REQUIRED libavutil)
pkg_check_modules(SWSCALE REQUIRED libswscale)
pkg_check_modules(AVFORMAT REQUIRED libavformat)
pkg_check_modules(AVCODEC REQUIRED libavcodec)
pkg_check_modules(AVUTIL REQUIRED libavutil)
pkg_check_modules(SWSCALE REQUIRED libswscale)
pkg_check_modules(SWRESAMPLE REQUIRED libswresample)
add_library(ffmpeg_libs INTERFACE)
target_compile_options(ffmpeg_libs INTERFACE
${AVFORMAT_CFLAGS_OTHER} ${AVCODEC_CFLAGS_OTHER}
${AVUTIL_CFLAGS_OTHER} ${SWSCALE_CFLAGS_OTHER})
${AVUTIL_CFLAGS_OTHER} ${SWSCALE_CFLAGS_OTHER}
${SWRESAMPLE_CFLAGS_OTHER})
target_include_directories(ffmpeg_libs INTERFACE
${AVFORMAT_INCLUDE_DIRS} ${AVCODEC_INCLUDE_DIRS}
${AVUTIL_INCLUDE_DIRS} ${SWSCALE_INCLUDE_DIRS})
${AVUTIL_INCLUDE_DIRS} ${SWSCALE_INCLUDE_DIRS}
${SWRESAMPLE_INCLUDE_DIRS})
target_link_libraries(ffmpeg_libs INTERFACE
${AVFORMAT_LIBRARIES} ${AVCODEC_LIBRARIES}
${AVUTIL_LIBRARIES} ${SWSCALE_LIBRARIES})
message(STATUS "FFmpeg: avformat=${AVFORMAT_VERSION} avcodec=${AVCODEC_VERSION}")
${AVUTIL_LIBRARIES} ${SWSCALE_LIBRARIES}
${SWRESAMPLE_LIBRARIES})
message(STATUS "FFmpeg: avformat=${AVFORMAT_VERSION} avcodec=${AVCODEC_VERSION} "
"swresample=${SWRESAMPLE_VERSION}")
# nlohmann/json (gallery + output serialisation)
include(FetchContent)
@@ -234,6 +280,24 @@ FetchContent_Declare(
)
FetchContent_MakeAvailable(nanobind)
# XGBoost (learned scene-boundary detector for flood-fill presence). Fetched and
# built from source so we get both the C API header and a matching libxgboost,
# reproducibly — the pip wheel ships the .so but no header. Heavy first build, so
# it is opt-in; the scene-boundary node is compiled only when SAE_SCENE_XGB is on.
option(SAE_SCENE_XGB "Build the XGBoost scene-boundary detector node" ON)
if(SAE_SCENE_XGB)
set(BUILD_STATIC_LIB ON CACHE BOOL "" FORCE) # link xgboost statically
set(USE_OPENMP ON CACHE BOOL "" FORCE)
FetchContent_Declare(
xgboost
GIT_REPOSITORY https://github.com/dmlc/xgboost.git
GIT_TAG v2.1.1
GIT_SHALLOW TRUE
GIT_SUBMODULES_RECURSE TRUE
)
FetchContent_MakeAvailable(xgboost)
endif()
# ── Model paths ───────────────────────────────────────────────────────────────
set(SAE_MODELS_DIR "${CMAKE_SOURCE_DIR}/models"
CACHE PATH "Directory containing ONNX model files")
@@ -249,6 +313,8 @@ find_package(HDF5 REQUIRED COMPONENTS CXX)
add_library(sae_gallery STATIC
src/gallery/gallery_store.cpp
src/gallery/gallery_builder.cpp
src/audio_signature.cpp # IR-004 — content-derived audio signature
src/gallery/embedder_stamp.cpp # GR-004 — gallery/embedder binding
)
set_target_properties(sae_gallery PROPERTIES POSITION_INDEPENDENT_CODE ON)
target_include_directories(sae_gallery PUBLIC src ${HDF5_INCLUDE_DIRS})
@@ -274,25 +340,73 @@ nanobind_add_module(sae_embed src/python_bindings.cpp)
target_link_libraries(sae_embed PRIVATE sae_gallery)
# ── sae_kpn — Python module: run the real downstream nodes over dumped embeddings ─
# Assembles face_tracker/identity_matcher/scene_tracker in a Python-driven KPN
# Assembles face_tracker/identity_matcher/frame_annotation in a Python-driven KPN
# network (KPN_BUILD_PYTHON is enabled per-TU inside the .cpp). Powers the
# threshold-sweep optimizer in scripts/optimizer/.
nanobind_add_module(sae_kpn src/kpn_bindings.cpp)
target_link_libraries(sae_kpn PRIVATE sae_gallery)
#
# TRACES: VR-011 | PR-002
# ON again. It was OFF for one commit because it had not compiled since the
# AR-007/AR-008 tracker redesign -- the binding built FaceTrackerFunc from a
# Config alone, and the tracker had required a registry and a calibration since.
# VR-011 replaced the three per-node factories with one `add_pipeline` that
# builds the chain in main.cpp's order, which is the only order that satisfies
# those dependencies, so the failure mode cannot recur from Python.
option(SAE_BUILD_KPN_BINDINGS "Build the sae_kpn Python module" ON)
if(SAE_BUILD_KPN_BINDINGS)
nanobind_add_module(sae_kpn src/kpn_bindings.cpp)
target_link_libraries(sae_kpn PRIVATE sae_gallery)
endif()
# ── sae_audio — Python module: the v1 audio signature (IR-004) ────────────────
# Compiles audio_signature.cpp directly and links only FFmpeg, rather than
# linking sae_gallery: the signature needs no model, no OpenCV and no HDF5, and
# a module that dragged all three in would make `import sae_audio` depend on a
# GPU-capable build of a repo whose audio path is pure CPU DSP. tests/ compiles
# the same source the same way, for the same reason.
nanobind_add_module(sae_audio src/audio_bindings.cpp src/audio_signature.cpp)
target_include_directories(sae_audio PRIVATE src)
target_link_libraries(sae_audio PRIVATE ffmpeg_libs)
# HDF5 already found above (before sae_gallery); vars HDF5_CXX_LIBRARIES / _INCLUDE_DIRS
# are reused by scene_analyze / dump_embeddings below.
# The learned scene-boundary detector is compiled into the sink (result_sink →
# xgb_scene_boundary + audio_logpsd) when SAE_SCENE_XGB is on, so the analysis
# binaries need xgboost + FFTW + ffmpeg and the define. Found once here.
if(SAE_SCENE_XGB)
find_library(FFTW3_LIB fftw3 REQUIRED)
set(SAE_SCENE_LIBS xgboost ${FFTW3_LIB} ffmpeg_libs)
set(SAE_SCENE_DEFS SAE_SCENE_XGB)
else()
set(SAE_SCENE_LIBS "")
set(SAE_SCENE_DEFS "")
endif()
# ── analyze — main analysis binary ───────────────────────────────────────────
add_executable(scene_analyze src/main.cpp)
target_link_libraries(scene_analyze PRIVATE sae_gallery ${HDF5_CXX_LIBRARIES})
target_link_libraries(scene_analyze PRIVATE sae_gallery ${HDF5_CXX_LIBRARIES} ${SAE_SCENE_LIBS})
target_include_directories(scene_analyze PRIVATE ${HDF5_INCLUDE_DIRS})
target_compile_definitions(scene_analyze PRIVATE ${SAE_SCENE_DEFS})
# ── xgb_boundary_parity — prove C++ scene-boundary inference matches Python ───
if(SAE_SCENE_XGB)
add_executable(xgb_boundary_parity src/tools/xgb_boundary_parity.cpp)
target_include_directories(xgb_boundary_parity PRIVATE src ${HDF5_INCLUDE_DIRS})
target_link_libraries(xgb_boundary_parity PRIVATE
xgboost ${HDF5_CXX_LIBRARIES} ${FFTW3_LIB} ffmpeg_libs)
# Dumps the C++ feature matrix so training uses the exact inference features.
add_executable(scene_features_dump src/tools/scene_features_dump.cpp)
target_include_directories(scene_features_dump PRIVATE src ${HDF5_INCLUDE_DIRS})
target_link_libraries(scene_features_dump PRIVATE
xgboost ${HDF5_CXX_LIBRARIES} ${FFTW3_LIB} ffmpeg_libs)
endif()
# ── analyze_debug — same binary with debug frame/crop output ─────────────────
add_executable(scene_analyze_debug src/main.cpp)
target_link_libraries(scene_analyze_debug PRIVATE sae_gallery ${HDF5_CXX_LIBRARIES})
target_link_libraries(scene_analyze_debug PRIVATE sae_gallery ${HDF5_CXX_LIBRARIES} ${SAE_SCENE_LIBS})
target_include_directories(scene_analyze_debug PRIVATE ${HDF5_INCLUDE_DIRS})
target_compile_definitions(scene_analyze_debug PRIVATE SAE_DEBUG=1)
target_compile_definitions(scene_analyze_debug PRIVATE SAE_DEBUG=1 ${SAE_SCENE_DEFS})
# ── dump_embeddings — standalone embedding dumper, NO gallery/matcher ─────────
# Front-half only (decode→detect→align→embed→HDF5) for the optimizer replay corpus
+342
View File
@@ -0,0 +1,342 @@
# sae-builder-cpu — the CI build image
#
# TRACES: DP-007 | PR-004
#
# Build/push: scripts/ci/build_builder_image.sh --push
# Consumed by: .gitea/workflows/unit-tests.yml (pinned by tag, never :latest)
# Docs: docs/ci-image.md
#
# This is the CPU corner of the DP-008 builder matrix and the DP-007 CI image at
# the same time — one artifact, two uses. The CUDA and ROCm siblings differ only
# in the accelerator stack layered on top of this dependency set.
#
# CI runs on an Intel N100 with no discrete GPU. Everything here is chosen so
# that `-DSAE_INFERENCE_BACKEND=ORT -DSAE_GEMM_BACKEND=CPU -DSAE_BUILD_TESTS=ON`
# configures, builds and runs without a GPU, without a model, and without
# reaching GitHub.
# ─── Base image ──────────────────────────────────────────────────────────────
#
# Chosen for the OLDEST glibc to be supported, not for recency. A binary built
# in a container runs against the *host's* glibc; glibc is backward compatible
# but not forward, so the build base sets the floor for every machine DP-008's
# binaries can ever run on. Building on a newer base than the oldest supported
# host produces the classic `GLIBC_2.xx not found` failure at load time.
#
# Debian 12 "bookworm" = glibc 2.36 (Aug 2022). What that floor covers:
#
# Distro glibc Covered?
# Arch / CachyOS (rolling) 2.41+ yes
# Fedora 37 and later 2.36+ yes ← DP-005's targets are Fedora+Arch
# Debian 12 / 13 2.36+ yes
# Ubuntu 24.04 LTS 2.39 yes
# Ubuntu 22.04 LTS 2.35 NO
# RHEL / Rocky / Alma 9 2.34 NO
# Debian 11 2.31 NO
#
# The three misses are accepted deliberately: DP-005 puts Debian/Ubuntu out of
# installer scope and names Fedora + Arch as the supported distros, and every
# supported Fedora is 2.36 or newer. Going lower costs the toolchain rather than
# buying reach — Debian 11 ships GCC 10 (incomplete C++20) and Python 3.9, which
# has no `tomllib` and therefore cannot read the traceability gate's
# traceability.toml.
#
# Escape hatch, recorded now so it is not rediscovered under pressure: if the
# floor must drop to glibc 2.28 (RHEL 8 / manylinux_2_28 — the same baseline the
# ONNX Runtime and PyTorch wheels target), the move is a Rocky 8 base plus
# gcc-toolset-13, and OpenCV/FFmpeg/HDF5 all leave apt for source or
# EPEL/RPM Fusion. That is a different image, not a flag on this one.
#
# Not a glibc problem but worth stating: the binaries this image produces also
# link OpenCV, FFmpeg and HDF5 shared objects by soname. Making a *portable*
# release binary (DP-008) is a separate question from the glibc floor, and is
# answered by static linking or bundling, not by the base image.
FROM debian:12-slim
# Pins. Every version this image installs from source is an ARG so a rebuild is
# a one-line diff and `docker history` records what a given tag actually holds.
#
# ORT 1.28.0 and OpenCV 5.0.0 match the developer machine, so CI and local
# builds exercise the same libraries rather than merely similar ones.
# Catch2 / nlohmann_json / nanobind match the FetchContent pins in
# CMakeLists.txt:248 and tests/CMakeLists.txt:12 exactly — a vendored copy at a
# different version would be a silent divergence, not a convenience.
ARG ORT_VERSION=1.28.0
ARG OPENCV_VERSION=5.0.0
ARG CATCH2_VERSION=v3.5.3
ARG NLOHMANN_JSON_VERSION=v3.11.3
ARG NANOBIND_VERSION=v2.4.0
# Stamped so a build can prove which image it ran in, and so a green tick can be
# traced back to a specific dependency set. See the "Confirm the builder image"
# step in .gitea/workflows/unit-tests.yml.
ARG IMAGE_TAG=dev
ENV SAE_BUILDER=cpu \
SAE_BUILDER_VERSION=${IMAGE_TAG} \
SAE_ORT_VERSION=${ORT_VERSION} \
SAE_OPENCV_VERSION=${OPENCV_VERSION} \
DEBIAN_FRONTEND=noninteractive
# ─── System dependencies ─────────────────────────────────────────────────────
#
# One layer, ordered by why it is here rather than alphabetically.
RUN apt-get update && apt-get install -y --no-install-recommends \
# Toolchain. bookworm's default gcc is 12.2 — enough for the C++20 the
# project sets unconditionally (CMakeLists.txt:4). cmake is 3.25, above the
# 3.21 minimum. Ninja because the N100 has four cores and every second of
# build scheduling shows.
build-essential \
cmake \
ninja-build \
pkg-config \
git \
ca-certificates \
curl \
# Gitea's act_runner executes JS actions (actions/checkout, upload-artifact)
# with the `node` found *inside* the container. Without this the job cannot
# even check the repository out. Same reason as the kpnpp-builder image.
nodejs \
# HDF5 with the C++ API: galleries are HDF5-native and it is also the VR-001
# dump format. find_package(HDF5 COMPONENTS CXX) at CMakeLists.txt:273.
libhdf5-dev \
# FFmpeg decode. swresample is on this list deliberately: the audio
# signature (IR-004) downmixes to mono and resamples to 11025 Hz, and
# tests/test_audio_signature.cpp decodes the golden FLAC fixture, so the
# test build needs it as much as the main build does.
libavformat-dev \
libavcodec-dev \
libavutil-dev \
libswscale-dev \
libswresample-dev \
# OpenBLAS — required here, not optional. CI has no GPU, so SAE_GEMM_BACKEND
# =CPU is the only path it ever exercises, and without OpenBLAS the CPU GEMM
# falls back to a scalar loop that does not scale against a library-sized
# gallery (AR-027). The build only *warns* when it is missing so a developer
# without it still gets a working tree; the image must never be that case.
# Both the main build (CMakeLists.txt:162) and the test target
# (tests/CMakeLists.txt:42) discover it through pkg-config `openblas`.
libopenblas-dev \
# Python: the build itself needs the interpreter and headers
# (find_package(Python COMPONENTS Interpreter Development.Module) at
# CMakeLists.txt:254, for the nanobind modules). numpy/h5py/scipy are for
# the Python-side tooling — fixture generation, replay, validation scripts.
# From apt rather than pip: bookworm marks the environment externally
# managed (PEP 668), and apt's h5py is already linked against the same
# libhdf5 installed above. bookworm's python3 is 3.11, which has tomllib —
# the traceability gate needs it to read traceability.toml.
python3 \
python3-dev \
python3-numpy \
python3-h5py \
python3-scipy \
# Image codecs for the OpenCV build below. Without these OpenCV silently
# builds an imgcodecs that cannot read a JPEG, which fails at run time in a
# gallery build rather than at compile time here.
libjpeg62-turbo-dev \
libpng-dev \
libtiff-dev \
libwebp-dev \
libopenjp2-7-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
# Fail the image build, not the CI run, if OpenBLAS or swresample are not
# discoverable the way CMakeLists.txt discovers them. An image that ships
# libopenblas but no openblas.pc would compile the scalar fallback in silence.
RUN set -eux; \
pkg-config --exists openblas; \
echo "openblas $(pkg-config --modversion openblas)"; \
pkg-config --exists libswresample; \
echo "swresample $(pkg-config --modversion libswresample)"
# ─── ONNX Runtime, CPU provider only ─────────────────────────────────────────
#
# The official prebuilt linux-x64 tarball is the CPU build: no CUDA, no
# TensorRT, no ROCm execution providers. That is the whole requirement here —
# excluding the GPU providers is not a size optimisation, it is the point.
#
# Verified against the 1.28.0 tarball: the shared object's highest versioned
# symbol requirement is GLIBC_2.27 / GLIBCXX_3.4.21, well under this base's
# 2.36, so ORT does not raise the floor set above.
#
# Installed to /usr/local/{lib,include/onnxruntime} because CMakeLists.txt
# includes <onnxruntime/onnxruntime_cxx_api.h> and needs the *parent* of that
# directory on the include path (CMakeLists.txt:108-113).
#
# CI never calls a model — the embedder measures ~930 ms/frame on this CPU
# provider — so ORT is present to satisfy the link, not to run inference.
RUN set -eux; \
curl -fsSL -o /tmp/ort.tgz \
"https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/onnxruntime-linux-x64-${ORT_VERSION}.tgz"; \
mkdir -p /tmp/ort; \
tar -xzf /tmp/ort.tgz -C /tmp/ort --strip-components=1; \
cp -a /tmp/ort/lib/libonnxruntime.so* /usr/local/lib/; \
mkdir -p /usr/local/include/onnxruntime; \
cp -a /tmp/ort/include/. /usr/local/include/onnxruntime/; \
ldconfig; \
rm -rf /tmp/ort /tmp/ort.tgz; \
test -f /usr/local/include/onnxruntime/onnxruntime_cxx_api.h
# ─── OpenCV 5, from source ───────────────────────────────────────────────────
#
# This is the reason the image is prebuilt at all. CMakeLists.txt:25 probes for
# OpenCV 5 first and falls back to 4; the branch targets 5, which no Debian
# release ships (bookworm has 4.6), and building it inside every CI run would
# dominate the run on an N100.
#
# BUILD_LIST is exactly the seven components find_package asks for
# (CMakeLists.txt:25-29) — OpenCV resolves their internal dependencies itself.
# Everything else is off: tests, samples, Java/Python bindings, and the apps.
#
# No GUI backend. highgui still builds (find_package REQUIREs the component) but
# with a stub — CI never calls imshow, and pulling GTK/Qt into a headless build
# image buys nothing. scene_preview is a developer tool, not a CI target.
#
# CUDA/cuDNN explicitly off: DP-007 excludes the GPU stack outright.
#
# The source tree and build tree are removed in the same layer, so the ~3 GB of
# intermediates cost nothing in the published image.
RUN set -eux; \
curl -fsSL -o /tmp/opencv.tar.gz \
"https://github.com/opencv/opencv/archive/refs/tags/${OPENCV_VERSION}.tar.gz"; \
mkdir -p /tmp/opencv-src; \
tar -xzf /tmp/opencv.tar.gz -C /tmp/opencv-src --strip-components=1; \
cmake -S /tmp/opencv-src -B /tmp/opencv-build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr/local \
-DBUILD_LIST=core,imgproc,imgcodecs,videoio,dnn,objdetect,highgui \
-DBUILD_SHARED_LIBS=ON \
-DBUILD_TESTS=OFF \
-DBUILD_PERF_TESTS=OFF \
-DBUILD_EXAMPLES=OFF \
-DBUILD_DOCS=OFF \
-DBUILD_opencv_apps=OFF \
-DBUILD_JAVA=OFF \
-DBUILD_opencv_python3=OFF \
-DWITH_FFMPEG=ON \
-DWITH_GTK=OFF \
-DWITH_QT=OFF \
-DWITH_OPENGL=OFF \
-DWITH_CUDA=OFF \
-DWITH_CUDNN=OFF \
-DOPENCV_GENERATE_PKGCONFIG=ON \
-DCMAKE_INSTALL_RPATH=/usr/local/lib; \
cmake --build /tmp/opencv-build --parallel; \
cmake --install /tmp/opencv-build; \
ldconfig; \
rm -rf /tmp/opencv-src /tmp/opencv-build /tmp/opencv.tar.gz
# ─── Vendored dependencies: Catch2, nlohmann/json, nanobind ──────────────────
#
# All three are FetchContent'ed by the build today, which makes every CI run
# depend on GitHub being reachable — a network outage would present as a code
# failure. Baking them in removes that dependency entirely.
#
# Catch2 is *installed*, so tests/CMakeLists.txt:6 `find_package(Catch2 3 QUIET)`
# succeeds and the FetchContent fallback is never reached. Its source is kept as
# well so the override below can cover the case where find_package somehow does
# not fire.
#
# nanobind must be cloned with submodules: its `ext/robin_map` is a git
# submodule, and a GitHub source tarball does not contain it. This is the one
# dependency where "download the tarball" produces a tree that configures and
# then fails to compile.
RUN set -eux; \
mkdir -p /opt/vendor; \
git clone --depth 1 --branch "${NLOHMANN_JSON_VERSION}" \
https://github.com/nlohmann/json.git /opt/vendor/nlohmann_json; \
git clone --depth 1 --branch "${NANOBIND_VERSION}" --recurse-submodules \
https://github.com/wjakob/nanobind.git /opt/vendor/nanobind; \
git clone --depth 1 --branch "${CATCH2_VERSION}" \
https://github.com/catchorg/Catch2.git /opt/vendor/Catch2; \
cmake -S /opt/vendor/Catch2 -B /tmp/catch2-build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr/local \
-DBUILD_TESTING=OFF; \
cmake --build /tmp/catch2-build --parallel; \
cmake --install /tmp/catch2-build; \
rm -rf /tmp/catch2-build; \
find /opt/vendor -maxdepth 2 -name .git -exec rm -rf {} +; \
ldconfig
# The initial-cache script the build is configured with. It lives in the image,
# not in the workflow, so the vendor paths have exactly one owner: move a
# directory here and no consumer needs editing.
#
# FETCHCONTENT_FULLY_DISCONNECTED=ON is the load-bearing line. With it, any
# FetchContent dependency that is *not* covered by an override above is a hard
# configure error instead of a silent download — so "this build does not touch
# GitHub" is enforced by the build system rather than asserted in a comment.
RUN set -eux; \
printf '%s\n' \
'# Baked into sae-builder-cpu. Use with: cmake -C /opt/vendor/vendored-deps.cmake ...' \
'# TRACES: DP-007' \
'set(FETCHCONTENT_SOURCE_DIR_NLOHMANN_JSON "/opt/vendor/nlohmann_json" CACHE PATH "vendored in the CI image")' \
'set(FETCHCONTENT_SOURCE_DIR_NANOBIND "/opt/vendor/nanobind" CACHE PATH "vendored in the CI image")' \
'set(FETCHCONTENT_SOURCE_DIR_CATCH2 "/opt/vendor/Catch2" CACHE PATH "vendored in the CI image")' \
'set(FETCHCONTENT_FULLY_DISCONNECTED ON CACHE BOOL "no CI build may fetch from the network")' \
> /opt/vendor/vendored-deps.cmake; \
cat /opt/vendor/vendored-deps.cmake
# ─── Self-check ──────────────────────────────────────────────────────────────
#
# Run the project's own dependency discovery — the same find_package and
# pkg_check_modules calls CMakeLists.txt makes — against this image, at image
# build time. An image that cannot satisfy them should fail here, loudly, once,
# rather than in every CI run that pulls it.
#
# Deliberately not a build of the project: the image must be buildable without
# the repository, and the repository's own configure step is what CI is for.
RUN set -eux; \
mkdir -p /tmp/selfcheck; \
printf '%s\n' \
'cmake_minimum_required(VERSION 3.21)' \
'project(sae_image_selfcheck LANGUAGES CXX)' \
'set(CMAKE_CXX_STANDARD 20)' \
'set(CMAKE_CXX_STANDARD_REQUIRED ON)' \
'find_package(OpenCV 5 REQUIRED COMPONENTS core imgproc imgcodecs videoio dnn objdetect highgui)' \
'message(STATUS "OpenCV ${OpenCV_VERSION}")' \
'find_package(HDF5 REQUIRED COMPONENTS CXX)' \
'message(STATUS "HDF5 ${HDF5_VERSION}")' \
'find_package(Catch2 3 REQUIRED)' \
'message(STATUS "Catch2 ${Catch2_VERSION}")' \
'find_package(Python 3.8 REQUIRED COMPONENTS Interpreter Development.Module)' \
'find_package(PkgConfig REQUIRED)' \
'pkg_check_modules(AVFORMAT REQUIRED libavformat)' \
'pkg_check_modules(AVCODEC REQUIRED libavcodec)' \
'pkg_check_modules(AVUTIL REQUIRED libavutil)' \
'pkg_check_modules(SWSCALE REQUIRED libswscale)' \
'pkg_check_modules(SWRESAMPLE REQUIRED libswresample)' \
'pkg_check_modules(OPENBLAS REQUIRED openblas)' \
'find_library(ORT_LIB onnxruntime REQUIRED HINTS /usr/lib /usr/local/lib)' \
'find_path(ORT_INCLUDE onnxruntime_cxx_api.h PATH_SUFFIXES onnxruntime' \
' HINTS /usr/include/onnxruntime /usr/local/include/onnxruntime /usr/local/include REQUIRED)' \
'message(STATUS "ORT ${ORT_LIB} / ${ORT_INCLUDE}")' \
> /tmp/selfcheck/CMakeLists.txt; \
cmake -S /tmp/selfcheck -B /tmp/selfcheck/build -G Ninja; \
rm -rf /tmp/selfcheck
# Python-side tooling the fixture and validation scripts import. Checked here so
# a missing wheel is an image failure rather than a mid-run traceback.
RUN python3 -c "import numpy, h5py, scipy; print('numpy', numpy.__version__, 'h5py', h5py.__version__, 'scipy', scipy.__version__)"
# ─── What is deliberately NOT here ───────────────────────────────────────────
#
# CUDA, TensorRT, ROCm, and the ORT GPU execution providers
# No GPU to use them. They belong to the sae-builder-cuda and
# sae-builder-rocm siblings (DP-008).
#
# The ONNX models
# Seven files, ~725 MB, in Git LFS. T1/T2 tests are model-free by design
# (tests/CMakeLists.txt:1-4), so the CI image needs none of them, and
# baking them in would inflate the image roughly tenfold to serve the T3
# smoke tests alone. Those pull the model they need via LFS in a separate
# job. The CI workflow checks out with LFS off for the same reason.
#
# The repository
# Nothing from the source tree is COPYed in. The image is a toolchain, and
# a toolchain that embeds the code it builds has to be rebuilt whenever the
# code changes — which is exactly the per-run cost this image exists to
# avoid.
WORKDIR /src
+755 -53
View File
@@ -40,12 +40,28 @@ Detect faces in sampled video frames.
presence (SR-002) a lower rate still answers the question, but it lengthens the
interval between samples and so weakens IoU-based association; sweep the two
together (VR-002).
- **Minimum face size is 66×66 px**, expressed in **original video resolution**,
- **Minimum face size is 40×40 px**, expressed in **original video resolution**,
not decoded-frame pixels. Stating it in original space decouples it from
`dense_scale`: otherwise a 0.5 downscale silently doubles the effective
threshold, and dense mode is exactly what scene detection uses.
66 is a working estimate of where ArcFace embeddings stop being reliable, not a
measured value — it should be replaced by the result of VR-005.
40 is **measured, not estimated** — it replaces an earlier 66 px guess. Two
studies bracket it, and the difference between them is the whole reason the
number is 40 rather than 32:
- **VR-005** degrades an already-aligned 112×112 crop and matches it against
a native-resolution gallery. Alignment is held perfect, so it isolates the
*embedder*: the knee sits at 2432 px, and 32 px still returns 98.1% TPI.
- **VR-013** downscales the **whole frame before the detector**, so detection
and landmark regression degrade along with it. End to end, holding 90% of
the plateau needs roughly **50 px**, against VR-005's ~22 px.
The gap is detection and landmark error, which VR-005 excludes by construction
— so VR-005 is an **upper bound on quality**, not a threshold, and reading a
floor off it would admit faces in the falling region. **AR-002 therefore takes
VR-013's number.** 40 sits below the 50 px plateau deliberately: FPI is 0.0% at
every scale in both studies, so resolution loss costs recall and never
precision, and an over-tight floor discards presence that SR-002 requires.
- Emits bounding box, detector confidence, and 5-point landmarks.
- Bounding boxes must be reported in **original video pixel space**. When
`dense_scale < 1` downscales the decoded frame, coordinates are rescaled by
@@ -59,7 +75,9 @@ Detect faces in sampled video frames.
**Current:** SCRFD-500MF via `face_detector_node.hpp`, thresholds in `config.hpp`
(`detector_conf` 0.5, `detector_nms` 0.4), `min_face_px` 40, `max_faces` 10.
**Gap:** `min_face_px` → 66 and re-expressed in original resolution; `max_faces`
**Gap:** `min_face_px` re-expressed in original resolution — the value 40 is
already correct after VR-013, so what remains is the space it is measured in, not
the number; `max_faces`
removed, gated on backpressure (AR-004).
## AR-004 — Backpressure
@@ -80,8 +98,82 @@ by dropping work or growing without limit.
- Memory is the real limit: faces carry 112×112 crops plus 512-float embeddings.
Backpressure must engage on bytes in flight, not just item counts.
**Gap:** entire requirement. This is a prerequisite for removing `max_faces`, not
a follow-up to it.
### The fix is not in this repo
**Every node output in KPN uses the dropping `push()`** (`pool_node.hpp:404`,
`:710`; also `branch.hpp`, `fanout.hpp`, `interrupt_node.hpp`). A lossless
`push_blocking()` — "wait for the consumer to drain instead of dropping; the
producer just runs slower" — already exists on both `Channel`
(`channel.hpp:144`) and `OutputPort` (`variant_node.hpp:81`), **and nothing
calls it.**
So AR-004 is a change to the KPN repository, not to this one. It needs either a
per-channel lossless policy or a network-wide default, and this pipeline should
select lossless: a dropped frame here does not degrade a result, it silently
changes one.
**Measured, not inferred.** One 77 s clip at 5 fps should yield ~385 sampled
frames. On CPU it produced 49, ending at 51 s, with 285 frames dropped at
`camera_pos` and 51 at `face_aligner`. Rebuilt with CUDA the same clip ran in
29 s and reached EOF correctly — and still dropped **320** frames at
`camera_pos`, yielding 65. Faster hardware moves where the queue backs up; it
does not change what happens when it does.
Two consequences worth stating:
- **Raising channel capacity is a stopgap, not a fix.** It lowers the
probability of overflow without changing the behaviour on overflow, and the
failure it hides is silent corruption of the output.
- **Fixture generation is blocked on this** (VR-001), because what gets dropped
depends on timing. The same command run twice can produce different dumps, and
a golden fixture cannot be built on that.
**Current:** fixed in KPN. Node data outputs *park* on a full channel — the
value is held in a one-slot buffer, the worker is released, and the channel's
space callback resubmits the node once the consumer drains. That replaced
`push_blocking`, which slept inside the push and, with one thread per node,
stopped that node draining its own input. Sentinels remain out-of-band so EOF
can always overtake a stalled data path. Verified on the same clip: 385 of 385
sampled frames written, zero drops, and two consecutive runs byte-identical
where previously they were not.
A later audit found the losslessness was still incomplete in three places, all
now closed and each pinned by a regression case in the KPN suite:
- **`FilterNode` and `RouterNode`** were the last data paths still using the
throwing `push()` with the exception swallowed. A full output discarded the
value, and that included the **EOF sentinel**. The decimator passes EOF by
predicate but its output is reliably full — the embedder is the slowest node
in the chain — so the token was discarded, nothing downstream shut down, and
the run had to be killed. This was the wedge.
- **The sentinel could arrive ahead of a value still queued behind it.** `pop()`
observed the ring empty and then took the sentinel; a producer can push a
value *and* publish the sentinel inside that window, so a consumer treating
EOF as a hard stop loses the tail.
- **Two firings of one node could overlap**, because the submit gate was
released before the firing had finished with the node's state. That breaks the
one-slot park itself: a parked value can be overwritten by the other firing,
with no drop recorded anywhere.
**New constraint:** a channel carries at most one undelivered sentinel. A second
offered before the first is taken is refused and reported, never queued and
never overwritten — two control tokens on one channel means the stream ended
twice. Single-shot EOF is what everything does today; this becomes live the
moment a pipeline is reused for a second input.
**Consequence:** a lossless decimator is a backpressure point, not a relief
valve. The source now throttles to the face branch rather than quietly thinning
it. That is what this requirement asks for, but it changes the shape of a loaded
run and has not yet been benchmarked.
It also ran *faster* (29 s → 17 s). A dropped frame has already cost its decode,
and the overflow exception cost more — so the lossy path was paying for work it
then discarded.
**Gap:** the remaining half — bounding by **bytes in flight** rather than item
count. Channel capacity is still a count of items, and a face carries a 112×112
crop plus a 512-float embedding, so a crowded frame occupies far more memory per
slot than a sparse one. That matters once `max_faces` is removed (AR-003).
## AR-005 — Face alignment and crop
@@ -92,9 +184,71 @@ Produce the exact input ArcFace expects.
nose, left mouth, right mouth).
- Alignment is the *only* geometric normalisation; no additional augmentation at
inference.
- **The transform is fitted by Umeyama's closed-form least squares over all five
points**, which is what InsightFace uses (skimage's `SimilarityTransform` *is*
`_umeyama`) and therefore what produced the crops ArcFace and LVFace were
trained on. The canonical warp is part of the input distribution, not an
implementation detail (AR-011).
- **Not a robust estimator.** A RANSAC fit buys a small residual by discarding
the landmarks that disagree with the model, and on a turned face those are the
foreshortened ones — the signal AR-030 reads. With five points and a two-point
minimal sample it also cannot separate a mis-detected landmark from honest
out-of-plane rotation, so the robustness is nominal while the cost to AR-030 is
total. It is RNG-driven besides, which made replay determinism a property of
thread scheduling.
**Current:** `align_face()` in `src/face_utils.hpp:9-22`, `cv::warpAffine` to
`{112, 112}`. **Gap:** none.
**Current:** `align_face()` in `src/face_utils.hpp`, Umeyama fit via
`umeyama_similarity()`, `cv::warpAffine` to `{112, 112}`. **Gap:** none.
> **Migration note — this was a defect, not a refinement.** Until this landed the
> fit was `cv::estimateAffinePartial2D(…, cv::RANSAC, 3.0)`. The expectation was
> that the two agree wherever RANSAC keeps all five points, leaving a small
> divergence on non-frontal faces. **Measured, that is wrong.** On 400 random
> gallery headshots, one model held fixed and only the estimator varied:
>
> | | median | p90 | max |
> |---|---|---|---|
> | Crop disagreement (source px, over the crop corners) | 16.97 | 75.91 | 223.31 |
> | `cos(umeyama, ransac)` for the resulting embedding | 0.791 | — | — |
>
> 83.5 % of crops embed to a cosine below 0.99 of their Umeyama counterpart —
> they are not the same face crop. The mechanism is that a 4-DoF similarity is
> exactly determined by **two** points, so every minimal RANSAC sample fits its
> own pair perfectly and is then scored on the other three. Real landmarks sit a
> median 2.74 canonical px from any similarity fit to the template (see AR-030
> below), so images with a landmark outside the 3 px band are the common case,
> not the exception; RANSAC then keeps two or three inliers and returns a wildly
> under-determined transform.
>
> **Every gallery baked before this change must be rebuilt** — GR-004's embedder
> stamp catches a model change, not an aligner change, so nothing else would say
> so.
>
> **How much this cost in accuracy is a separate question, and the answer appears
> to be: less than the crop numbers suggest.** Rebuilding the full gallery
> (2456 actors) moved the intra/inter separation the AR-023 calibration is fitted
> from only slightly:
>
> | | intra-actor | inter-actor | separation |
> |---|---|---|---|
> | RANSAC | 0.6234 | 0.0407 | 0.5827 |
> | Umeyama | 0.6340 | 0.0440 | 0.5900 |
>
> The reconciliation is that the old warp was *wrong but self-consistent*: it
> produced a differently-framed face rather than a scrambled one, gallery and
> probe went through the same estimator, and the embedder tolerates framing
> variation. So the figures in `model-bakeoff.md`, `best-model.md` and
> `pose-expansion.md` were all produced through the broken warp on both sides and
> should be re-run, but there is no measured basis for expecting them to move far.
>
> The sharper evidence of the old instability is duplicate detection: rebuilding
> with an unchanged `dedup_tol` dropped **1614** near-duplicate images, where the
> original build dropped on the order of a hundred. Near-identical source images
> used to embed to visibly different vectors — RANSAC fitting two-point subsets is
> unstable under small landmark perturbations, and being RNG-driven it was not
> reproducible either. That instability is what a tracker accumulating evidence
> across frames pays for, and it is the strongest reason the fix is worth having
> independently of any accuracy delta.
## AR-006 — Embedding
@@ -110,6 +264,166 @@ Generate a 512-d embedding per aligned crop.
**Current:** `embedder_node.hpp` + `face_embedder_engine.hpp`; default
LVFace-B_Glint360K. **Gap:** none.
## AR-028 … AR-030 — Embedding input quality
An embedder handed a face it cannot represent does not fail. It returns a
confident, plausible, wrong vector, and that vector then competes on equal terms
with every good one in the gallery — the same failure mode AR-011 names for
whole models, occurring here at the level of a single region. Quality assessment
is how that is caught **at inference**, rather than inferred afterwards from a
study of why a film scored badly.
Three axes, assessed on every face before its embedding is used as identity
evidence. They are kept separate and **not collapsed into one scalar**: they fail
for different reasons, have different remedies, and — as below — do not even earn
the same response.
- **Size** — already AR-002, floor at 40×40 px in original resolution, measured
end to end by VR-013. It is the precedent for the other two: the
threshold was *located*, not chosen.
- **Sharpness** — motion blur and soft focus destroy the high-frequency detail
the embedder keys on, and unlike size they leave the bounding box looking
perfectly healthy. Measured on the **112×112 aligned crop**, not the raw box:
the crop is already scale-normalised, so a measure taken there cannot silently
re-measure face size and double-count it against AR-002.
The measure is the **variance of the Laplacian divided by the variance of the
crop** — `crop_sharpness()`, dimensionless. The division is the part that
earns its place: a raw Laplacian variance, the textbook measure, scales with
the square of image contrast, so a dim scene reads as soft and a graded-up one
as sharp, and VR-012 would locate a different knee in every film. That is
AR-024's objection to the raw cosine in another metric. Normalised, the axis
means the same thing everywhere, which is the precondition for a single knee
existing at all.
Read spectrally it is `E[|ω|⁴]` under the crop's own energy distribution, so
the blur ladder is monotone by construction rather than by fitting: Gaussian
blur multiplies that distribution by `e^{-σ²|ω|²}`, which can only move mass
downward. Two consequences follow from the same identity and are recorded on
the function: it needs the low-frequency mass real images have (on a
flat-spectrum synthetic an anisotropic smear makes it *rise*, because the
surviving perpendicular detail really is as fine as before), and it conflates
focus with intrinsic texture, so a bearded face outscores a smooth one at equal
focus. Both are true of every no-reference sharpness measure, and both are
reasons AR-028 carries the number rather than thresholding on it.
- **Visibility** — extreme pose or occlusion means the face presents fewer of the
features the embedding assumes are present. The measure is the **residual of
the AR-005 alignment fit**: the RMS landmark error, in canonical 112×112
pixels, left over after the best similarity transform onto the ArcFace
template. It costs nothing — the transform is computed for the warp regardless,
and the residual is what that fit could not explain.
Two properties earn it the job over an explicit yaw estimate:
- A similarity absorbs rotation, uniform scale and translation **exactly**,
so the residual is by construction the non-similarity part of the
deformation: out-of-plane rotation and foreshortening. In-plane roll
contributes nothing, so "a tilted head reads as a turned one" is excluded
structurally rather than by tuning. The destination frame is fixed, so face
size cannot leak in either — that is AR-002's axis, and double-counting it
would make a small frontal face look occluded.
- It responds to **occlusion** and to plainly broken landmark sets, which an
angle regressor by construction does not: a hand across the face is not a
rotation, but it does displace landmarks.
Indicative magnitudes from a synthetic foreshortening sweep (`k ≈ cos yaw`):
`k=1.0 → 0.00`, `0.9 → 1.18`, `0.75 → 3.11`, `0.5 → 6.72`, `0.3 → 9.85`
canonical px. Smooth and monotone with a usable range; the mapping onto real
faces is VR-012's to establish, and no threshold is set from these numbers.
**The synthetic ladder is noise-free and therefore optimistic about the low
end.** Measured on 400 real TMDB/Jellyfin headshots — the most frontal, most
cooperative population the pipeline ever sees — the residual runs p5 1.11,
median 2.74, p90 4.82, max 6.35 canonical px. So landmark noise alone occupies
roughly the first 3 px, and the synthetic sweep's "26° yaw ≈ 1.2 px" sits
*below* the noise floor on real data. VR-012 must set any threshold against
this measured distribution, and a discount curve has to treat the first few
pixels as uninformative rather than as mild pose.
Neither a dedicated landmark model (`models/2d106det.onnx` is present but
referenced nowhere — and it emits points, not pose) nor a direct pose CNN is
adopted unless VR-012 shows the residual insufficient. If one is needed the
candidate is **6DRepNet** (MIT, RepVGG-B1g2, 3.47° MAE on AFLW2000) rather than
Hopenet, which it dominates on accuracy, licence, recency and export
friendliness. Two caveats to record before that happens: both are trained on
**300W-LP**, which inherits research-only terms from 300W's constituent sets,
and both want their own loosely-framed ROI rather than the ArcFace crop — a
second warp and a second image in flight, which lands on AR-004's byte-based
backpressure gap. It would also have to run **per track** — over the bounded
view set AR-019's diversity buffer already keeps — not per face per frame,
which is the cost rule applied as written: fewer regions, never a degraded
input.
**Failing an axis discounts the observation; it does not delete the detection.**
Only size drops the face outright, and only because VR-005 measured a knee below
which the embedding carries no signal to discount. Blur and pose are different:
- A blurred or turned face is still evidence of **presence**, which is what
SR-002 actually asks about.
- The tracker admits a link on position *or* identity precisely so that a face
"whose embedding degraded (blur, profile turn)" stays linkable. Remove the
detection and the track fragments, costing the window extent AR-012/AR-013
exist to protect.
- AR-019 harvests non-frontal views *because* TMDB headshots are frontal.
Discarding turned faces starves the mechanism built to fix the pose problem of
its raw material, and AR-020 then has nothing to resolve at EOF.
The natural home for the discount is `EvidenceDiscounter` (AR-025), which already
weights how far one observation may move a track's belief. Note that its present
weight is pure *novelty*, so a profile view — maximally distant from everything
counted so far — currently scores near 1.0 and moves the belief hardest, when
against a frontal gallery it deserves the least trust. Novelty and reliability
are orthogonal and multiply; quality supplies the second term.
**Quality is carried, not consumed.** The vector travels with the face and is
written to the VR-001 dump alongside the embedding, so a threshold can be
re-litigated against recorded data instead of by re-running video, and so
VR-010's provenance records what the run actually admitted.
**No quality threshold is hand-set.** Each axis either has a measured knee
(VR-012, as VR-005 did for size) or it discounts rather than drops — a
hand-chosen cutoff on an uncalibrated measure is the same unfalsifiable magic
number AR-024 retired for similarity, and it would fail the same way: meaning
something different for every detector, every embedder and every film.
**Current:** all three axes are measured and carried, and the vector reaches the
dump. `FaceAlignerFunc` is where it is filled in, because both measured axes fall
out of work the warp already does: visibility is the residual from
`estimate_alignment()`, and sharpness is `crop_sharpness()` on the 112×112 crop
the node has just produced. Size stays `bbox` — deliberately not copied into a
field of its own, since that would hold the same quantity in two coordinate
spaces and the copy is the one that drifts. No face is admitted unscored, so a
negative value downstream is a bug rather than a poor-quality face. The
degenerate-fit case is still dropped — it has no crop and no fit to score — but
is now **counted** and reported once at EOF instead of vanishing.
`sharpness` and `alignment_residual` are written to the VR-001 dump as per-face
columns parallel to `confidence`, taking the dump to `schema_version` 2. The bump
is not for readers — both sides check by name, and a v1 dump still replays — but
so that a consumer of the vector can tell *never scored* from *scored zero*,
which is a real reading on this axis. Nothing yet *consumes* any of it.
**Gap:** three, in the order they block each other.
1. **The fixtures do not carry the vector.** They are v1, and re-dumping needs a
GPU host (`scripts/make_fixtures.sh`), so until that runs VR-012 has recorded
data available in principle and none in hand.
2. **AR-030's discount does not exist.** The measure must reach
`EvidenceDiscounter` as the reliability term, multiplying the novelty weight
rather than replacing it.
3. **Two properties of the sharpness measure are recorded but unquantified on
real faces**, and both distort the low end of the axis, which is where a knee
would go. It is exactly contrast-invariant in the algebra, but the 8-bit
quantisation floor lands in the numerator, so a crop that is *dim and soft*
reads sharper than it is — on the synthetic ladder a half-contrast copy reads
0.9% high when sharp and 148% high at σ 2.5. Separately, `align_face` warps
with `BORDER_CONSTANT`, so a face crossing the frame edge brings a hard black
step into the crop, and a step edge is high-frequency; the normalisation
blunts this but does not remove it. Neither is corrected here. The candidate
fixes are a validity mask or a different border mode, and the second changes
what the embedder is fed (AR-011) — so VR-012 measures the size of each effect
on the dumped distribution first, and no correction is chosen before that.
## AR-007, AR-008 — Tracking
Link detections across frames into tracks representing one physical person.
@@ -154,11 +468,55 @@ Two distinct signals, deliberately kept separate:
- **`is_scene_boundary`** — opt-in (`--scene-detect`). TransNetV2 over a densely
decoded, downscaled stream flags a *true shot/scene boundary*.
> **`is_scene_boundary` currently has no producer.** `grep -rn is_scene_boundary
> src/` finds no assignment anywhere: `SceneDetectorFunc` is a *terminal sink*
> (`main.cpp:298-300`, `kpn::out<>`) that writes `scenes.json` and never
> annotates the `Frame` flowing to the face pipeline. The field is therefore
> always `false`, and the dump column (`embedding_dump_node.hpp:38`) is a
> constant 0. Compounding it, `main.cpp:280` returns from the
> `--dump-embeddings` branch *before* the `scene_detect` branch at `:296`, so no
> dump-producing path even instantiates the detector.
>
>
> **It cannot be fixed by making the node a pass-through.** TransNetV2 buffers
> `kWindow` = 100 dense frames before it can score any of them, runs inference
> every `scene_stride` (50) frames, and trusts only each window's centre. So a
> boundary at time *T* is not known until roughly 100 dense frames after *T* —
> about **3.3 s at 30 fps**. The face pipeline runs on a parallel branch and has
> long since passed *T* by then. An association hint that arrives after the
> association is worthless.
>
> Three ways out, none free:
>
> 1. **Two-pass.** Run scene detection to completion, then analyse faces with
> boundaries already known. Simple and correct; costs a second decode of the
> whole file, and dense decode is already the pipeline's dominant cost.
> 2. **Delay the face branch** by the detector's window latency. Keeps one pass;
> adds a buffering stage and couples the two branches' timing, which is the
> kind of coupling that produces heisenbugs under backpressure.
> 3. **Leave it unwired.** Accept that `is_cut` is the only association hint.
>
> **Option 3 costs less than it appears**, which is why this is a decision rather
> than a bug. Since the redesign made cuts and boundaries do the *same thing* —
> both say "spatial continuity is broken, associate on embedding" — TransNetV2
> adds nothing over the histogram except on transitions the histogram misses:
> slow dissolves and fades, where there is no frame-to-frame discontinuity to
> detect. That is a real but narrow gap.
>
> The value TransNetV2 retains is in **AR-019**, whose promotion gate requires a
> span with no cut *and* no boundary. There a late answer is still usable,
> because promotion happens when a track is confirmed rather than per frame.
> Wiring it there — offline, against the collected boundary list — is cheaper
> than any of the three options above and does not touch the hot path.
>
> **Recommendation: option 3 plus the AR-019 wiring**, and revisit if dissolve-
> heavy material shows association failures the histogram misses.
Both feed AR-007 as **association hints**: they tell the tracker that spatial
continuity is broken and that association should weight embedding over IoU.
Neither ends a presence window (AR-012).
In dense mode the source decodes at `scene_decode_fps` (default 12) and a
In dense mode the source decodes at `scene_decode_fps` (default 0 = native) and a
decimator splits the stream: full-resolution sampled frames to the face pipeline,
downscaled dense frames to the scene detector
(`frame_source_node.hpp:63`). `sample_fps` is independent of this — the face
@@ -179,31 +537,44 @@ degrading what a single inference sees. A model run off-distribution produces
confident, plausible, wrong output, and the error is invisible without a study
that should not have been necessary.
Two places this is currently violated:
Two places this was violated, both now closed:
1. **`scene_decode_fps = 12` starves TransNetV2.** `kWindow` is 100 frames. At
native 25 fps that window spans ~4 s; at 12 fps it spans ~8.3 s, so the model
sees roughly half-speed motion over twice the temporal context it was trained
on. **Requirement: feed TransNetV2 at the source's native frame rate**, so a
100-frame window covers the duration the model expects. The
"tolerates ~12fps" note in `config.hpp` describes a compromise, and the
recorded margin is consistent with it — a non-boundary baseline at ~0.50 with
1. **`scene_decode_fps = 12` starved TransNetV2.** `kWindow` is 100 frames. At
native 25 fps that window spans ~4 s; at 12 fps it spanned ~8.3 s, so the
model saw roughly half-speed motion over twice the temporal context it was
trained on. **Requirement: feed TransNetV2 at the source's native frame
rate**, so a 100-frame window covers the duration the model expects. The
"tolerates ~12fps" note in `config.hpp` described a compromise, and the
recorded margin was consistent with it — a non-boundary baseline at ~0.50 with
real boundaries reaching only ~0.7+ is a compressed separation, not a healthy
one.
one. **Done:** `scene_decode_fps` defaults to 0.
2. **Hardcoded 25 fps in boundary dedup.** `scene_detector_node.hpp:138` merges
boundaries closer than `0.04 s` — "~1 frame @25fps". **Requirement: derive
this from the source's actual frame rate.**
2. **Hardcoded 25 fps in boundary dedup.** The node merged boundaries closer than
`0.04 s` — "~1 frame @25fps". **Requirement: derive this from the source's
actual frame rate. Done:** `SceneDetectorFunc::dedup_window_sec()` takes the
median of the frame intervals the detector was actually fed and halves it.
Half a frame rather than a whole one, because the only thing being merged is
one frame scored by two overlapping windows; two distinct frames are a full
interval apart and both have to survive.
The two are one change, not two. A native-rate stream is where the old constant
did the most damage — at 30 fps, 0.04 s is wider than a frame, so two cuts on
consecutive frames merged into one and the loss showed up nowhere: the file
simply had fewer boundaries.
Dense decode is the pipeline's cost driver, so (1) is not free. The cost is
accepted: the alternative is a boundary signal that steers association (AR-007) while
being quietly unreliable. `dense_scale` remains available as a spatial reduction,
since downscaling is a documented, understood degradation rather than a temporal
one the model has no defence against.
one the model has no defence against — and TransNetV2 downsamples to 48×27
regardless.
**Current:** histogram cut in the decoder; `scene_detector_node.hpp` for
TransNetV2. **Gap:** native-rate dense decode; framerate-derived dedup;
`--scene-detect` is default-off despite now feeding association.
TransNetV2, fed at native rate with a framerate-derived dedup window.
**Gap:** `scene_threshold` (0.60) is still the value picked against 12 fps input
and is now certainly wrong — VR-006 re-fits it, and until it does, boundary
recall at native rate is untuned rather than better. `--scene-detect` is
default-off despite now feeding association.
## AR-012 … AR-017 — Track-level identity propagation — **CHANGED BEHAVIOUR**
@@ -386,12 +757,30 @@ An embedding is admitted only if its similarity to one already in the store fall
admitting it risks poisoning the store.
A starting band of roughly **0.900.95** is the working estimate, to be tuned
(VR-007). Note this is deliberately conservative compared to the current
`expand_novelty_sim` (0.55), which promotes embeddings *far* from the gallery —
(VR-007). Note this is deliberately conservative compared to the retired
`expand_novelty_sim` (0.55), which promoted embeddings *far* from the gallery —
much more aggressive, and much more exposed to admitting the wrong person.
Both bounds must be expressed as calibrated probabilities, not raw cosines (AR-024).
The lower bound is asked twice. `admit` compares a newcomer against its
*closest* existing member, which a gradually drifting track can chain past: every
step inside the band while the endpoints are strangers — the shape a track-ID
collision takes over a slow pan. So the same bound is re-applied across **every
pair** in the store before promotion. One bound, two enforcement points; not a
second constant.
Novelty is deliberately **not** a threshold. The store's eviction policy orders
its members by similarity to the actor's existing references and drops the
best-recognised one, so novelty-seeking is a ranking with nothing to tune, and
the band's upper bound already refuses the redundant views at the door.
**Current:** implemented in `gallery/track_gallery.hpp``admit` at the door,
`store_coherence` at promotion, both bounds from `Config::expand_band_lo/hi`.
Refusals are counted (`band_rejected`).
**Gap:** the bounds themselves are unswept working values (VR-007).
### AR-019 — Expansion of known actors
When a track is owned (AR-012), its store is promoted into a **per-film, in-memory
@@ -492,14 +881,14 @@ natural unit for anonymous presence, should that be adopted (AR-012, TBD).
open (VR-007).
**Current:** `src/gallery/track_gallery.hpp` implements a per-track diversity
buffer with eviction biased to gallery-far poses, promotion gated on
`expand_novelty_sim` / `expand_track_spread_max`, cleared on `is_cut`. Wired at
`identity_matcher_node.hpp:227`, cleared at `:126`.
buffer with eviction biased to gallery-far poses, admission and promotion both
gated on the AR-018 band in probability space, cleared on `is_cut`. Wired at
`identity_matcher_node.hpp:227`, cleared at `:126`; the calibration is handed
over at `:114`.
**Gap:** the band rule of AR-018 replacing the current novelty/spread gates; all
three quiet-signal conditions rather than only `is_cut`; probability space
throughout (AR-024); and the whole of AR-020 — the TBI queue, the deferred pass, and
deferring output until it completes.
**Gap:** all three quiet-signal conditions rather than only `is_cut`; and the
whole of AR-020 — the TBI queue, the deferred pass, and deferring output until it
completes.
## AR-022 — Unidentified-track capture
@@ -647,21 +1036,45 @@ is the only viable formulation — a per-pair loop is orders of magnitude off.
**All similarity computation goes through the GEMM path**, with no exception
justified by "this set is small". Three call sites:
1. **Baked gallery** already GEMM (`sim_engine_->compute()`,
`identity_matcher_node.hpp:143`, backend from `SAE_GEMM_BACKEND`). ✓
2. **Per-film annex**currently a **CPU loop**
(`identity_matcher_node.hpp:159-162`), justified in-comment by "tens of
embeddings". AR-018…AR-021 invalidates that assumption: every owned track now
contributes, so the annex grows with cast size and film length. It must move
into the GEMM path — appended to the gallery matrix, or a second multiply.
1. **Baked gallery** — GEMM (`sim_engine_->compute()`, backend from
`SAE_GEMM_BACKEND`). ✓
2. **Per-film annex**was a **CPU loop**, justified in-comment by "tens of
embeddings". AR-018…AR-021 invalidated that assumption: every owned track
contributes, so the annex grows with cast size and film length. Now appended
to the gallery matrix rather than scored separately — promotions are pushed
into the engine's resident matrix (`ISimilarityEngine::append_rows`,
capacity doubling, device-to-device on the GPU backends) and `flat_actor_`
grows in lockstep, so one multiply covers baked and promoted references and
best-of-N is a single pass over one similarity column. ✓
3. **Deferred TBI pass (AR-020)** — the most GEMM-friendly operation in the
pipeline: all TBI embeddings against the full gallery-plus-annex, offline,
operands resident, no streaming. One large multiply, not a loop over entries.
Not yet built; AR-020 owns it.
**Current:** 1 and 2 done. `TrackGallery` holds the annex as a contiguous
row-major matrix plus a parallel actor index, and hands newly promoted rows to
the matcher once per frame (`drain_promotions`), which is what call site 3 will
score against.
**Gap:** call site 3, gated on AR-020 existing at all.
This constrains AR-018…AR-021's implementation: the annex must be a **contiguous matrix**
with promotions appended, plus a parallel actor-index mapping — exactly the
`flat_emb_`/`flat_actor_` arrangement the baked gallery already uses.
**Ordering note.** Absorbing promotions is a once-per-frame step that runs after
every face in the frame has been scored, not mid-frame. Appending mid-frame would
invalidate the similarity pointer the matcher is still reading, and it also
removes an accidental dependence on face order within a frame: a promotion helps
subsequent frames, never the one that produced it, which is the semantics the
expansion store already documents.
**The CPU GEMM path requires OpenBLAS.** It is what CI and the cpu builder image
run, so a silent fall back to the scalar loop would mean AR-027 is measured — or
believed — on a path no release uses. Absence is a configure error; the scalar
loop survives as the correctness oracle, reachable only via
`-DSAE_ALLOW_SCALAR_GEMM=ON`.
### Scaling characteristics that must be known, not assumed
- **Throughput versus gallery size must be measured** (VR-008) and published. The
@@ -757,9 +1170,16 @@ prerequisite.
## DP-005 — Installation and provisioning
- Native install, **no Docker** — GPU passthrough is the most fragile part of a
containerised setup and exists only because of the container. Natively the GPU
works with the host drivers and media paths need no re-mounting.
- Native install, **no Docker at runtime** — GPU passthrough is the most fragile
part of a containerised setup and exists only because of the container.
Natively the GPU works with the host drivers and media paths need no
re-mounting. This constrains how the software *runs*, not how it is *built*:
DP-008 uses containers as build environments precisely because that side has
none of these problems.
- The installer may **fetch a prebuilt binary** (DP-008) instead of compiling.
Compiling stays supported, but should not be the only path — it is the slowest
and most fragile step of a first install. TRT engines are still built locally
either way (DP-008).
- An installer (`scripts/build_install.py`) consuming one `install.yaml`:
platform (nvidia/amd/cpu), embedder model, gallery scan cadence, install
prefix; runtime secrets written to a `.env`, editable without recompiling.
@@ -770,6 +1190,130 @@ prerequisite.
**Gap:** installer unbuilt.
## DP-007 — CI build image
CI runs on an Intel N100 with no discrete GPU, so the test build must configure
**CPU-only** and must not require CUDA, TensorRT or ROCm:
```
-DSAE_INFERENCE_BACKEND=ORT -DSAE_GEMM_BACKEND=CPU
```
A prebuilt container image supplies the toolchain, published to the **Gitea
container registry** and pinned by tag — matching the `jellytau-builder`
precedent. Building dependencies per CI run is untenable on an N100, and OpenCV 5
from source would dominate every run.
The same registry stores corpus dump fixtures as generic packages (see the
fixtures table in `requirements.md`). Rebuild the image when its dependency set
changes, not per run, and pin CI to a tag rather than `latest` so a rebuild
cannot silently change what a green build meant.
**Required in the image:**
| Dependency | Why |
|---|---|
| CMake, C++ toolchain, pkg-config | Build |
| **OpenCV 5** | `CMakeLists.txt:25` prefers 5, falls back to 4. The branch targets 5, so the image should carry it — it is not yet in most distro repos and building it per-run is prohibitive |
| HDF5 (C++) | Galleries are HDF5-native; also the dump format |
| FFmpeg dev libs — `libavformat`, `libavcodec`, `libavutil`, `libswscale`, **`libswresample`** | Decode. See the note below on swresample |
| Python 3 + numpy, h5py, scipy | Python-side tests, replay, traceability tooling |
| **OpenBLAS** | Backs the CPU similarity GEMM. Without it the fallback is a scalar loop, and the CPU path is exactly what this host runs — see below |
| Catch2, nlohmann/json | **Vendored into the image, not fetched.** Both are `FetchContent`-ed today (`CMakeLists.txt:220`, `tests/CMakeLists.txt:8`), which makes every CI run depend on GitHub reachability |
**OpenBLAS is not optional here, despite being optional in the build.** CI has no
GPU, so `SAE_GEMM_BACKEND=CPU` is the only path it exercises — and since AR-003
removed the per-frame face cap, a crowded frame scores many faces against a
library-scale gallery. The scalar fallback is correct but scales badly, which
would make the CPU path the bottleneck in the one place it cannot be avoided
(AR-027). The build warns when it is missing rather than failing, so a developer
without it still gets a working tree; the image must not be that case.
The test target links it too. Otherwise the suite compiles the scalar fallback
while the image ships CBLAS, and CI would verify a kernel that is not the one
running in production.
**Deliberately excluded:** CUDA, TensorRT, ROCm — no GPU to use them. Also the
ONNX Runtime *GPU* providers; only the CPU provider is relevant, and only for T3
smoke tests.
**Models are not baked into the image.** The seven ONNX files total ~725 MB and
live in Git LFS. T1/T2 tests are model-free by design
(`tests/CMakeLists.txt:1-4`), so the default image needs none. T3 smoke tests
require a model and should pull it via LFS in a separate job rather than
inflating the image tenfold for a minority of tests.
**`libswresample` is a real gap, not a formality.** The current
`pkg_check_modules` list (`CMakeLists.txt:200-203`) covers avformat, avcodec,
avutil and swscale but **not** swresample — which IR-004 needs to downmix to mono
and resample to 11025 Hz. It must be added alongside the audio-signature work.
**Gap:** entire requirement. The image does not exist, and no CI config is
present in this repo.
## DP-008 — Builder images and release binaries
Produce prebuilt binaries per backend so deployment does not require every user
to compile the project.
**This does not contradict DP-005.** That requirement rejects Docker as a
*runtime* — GPU passthrough is the most fragile part of a containerised setup and
exists only because of the container. Using Docker as a *build* environment is
the opposite case: hermetic, reproducible, and it lets one machine produce
binaries for backends it cannot itself run. Build in a container; run natively.
### Image matrix
The build has two independent axes (`CMakeLists.txt:48-49`), so the useful
combinations are:
| Image | `SAE_INFERENCE_BACKEND` | `SAE_GEMM_BACKEND` | Target |
|---|---|---|---|
| `sae-builder-cpu` | ORT | CPU | CI (DP-007), and the smoke-test fallback |
| `sae-builder-cuda` | TRT | CUDA | NVIDIA |
| `sae-builder-rocm` | ORT | ROCM | AMD |
All three carry the DP-007 dependency set (OpenCV 5, HDF5, FFmpeg incl.
swresample, vendored Catch2/nlohmann) and differ only in the accelerator stack.
The CPU image is the CI image — one artifact, two uses.
Published to the Gitea container registry, pinned by tag, rebuilt when the
dependency set changes rather than per run.
### What ships, and what cannot
**Ships:** the `scene_analyze` binary and its companions, per backend.
**Cannot ship: TensorRT engines.** `.engine` files are specific to the GPU
architecture and TRT version they were built on — `scripts/build_trt_engines.sh`
must still run on the target machine. A prebuilt binary shortens the install; it
does not remove the local engine-build step, and the installer must not imply
otherwise.
**Cannot ship: models.** ~725 MB in LFS, and orthogonal to the binary.
### The constraint that decides the base image
**A binary built in a container runs against the host's glibc.** Build on a
newer base than the oldest supported host and it fails at load with
`GLIBC_2.xx not found` — the classic and entirely avoidable trap when shipping
binaries out of containers.
So the base is chosen for the *oldest* glibc to be supported, not for
convenience or recency. Accelerator libraries have the same shape of problem:
the binary links against a driver-provided runtime, so each image must document
the CUDA/ROCm version range its output is compatible with, and the installer
must check it rather than discovering a mismatch at first inference.
### Jobs
A release job per backend, producing a tagged artifact in the registry. These are
**not** the CI gate — the gate runs the CPU image on every push (DP-007);
release builds run on tag. Their outputs are what DP-005's installer fetches
when the user does not want to compile.
**Gap:** entire requirement. No images, no release jobs.
## DP-006 — Gallery maintenance as a background concern
- Incremental gallery refresh runs on a timer (`gallery_scan_interval`, default
@@ -859,8 +1403,10 @@ rewritten.
**Media shorter than 120 s.** The window `runtime/2 ± 60 s` underflows, so no
signature is emitted and **no sync offset is applied**. Such items fall back to
the runtime/exact tiers, which is adequate: a 90-second extra or trailer is not
the content whose cut alignment matters. Both producers must apply the identical
the runtime tier, which is adequate: a 90-second extra or trailer is not the
content whose cut alignment matters. (There is no `exact` tier: the file-hash
tier was withdrawn on legal grounds — it fingerprinted an individual copy rather
than the cut the timings describe. See the server spec §3.) Both producers must apply the identical
rule, or they diverge on exactly the short items most likely to be
mis-identified.
@@ -882,7 +1428,47 @@ plugin. Consequences to carry through:
- Files never processed by this pipeline still get a signature from the plugin;
the two paths coexist deliberately.
**Gap:** entire requirement — no audio path exists in the pipeline today.
**Current:** `src/audio_signature.*` implements the construction, and
`tests/fixtures/audio/` holds the golden vector shared verbatim with the plugin
repo, which now matches it byte for byte from C# (jRay `JR-042`/`JR-043`).
`sae_audio` (nanobind, as `sae_embed` and `sae_kpn` are) exposes the same C++ to
Python so a study drives the shipped code rather than a numpy port.
**VR-014 measures what the golden vector cannot** — that the signature actually
aligns a differently trimmed release, on real film audio rather than a synthetic
tone. It does, with an order of magnitude to spare.
**The accuracy question is settled and is not close.** What the offset is *for*
is shifting scene windows, which are seconds long, so half a second of error is
invisible; the budget is 500 ms. Over 40 random offsets inside the ±600-frame cap
the recovered offset was the nearest frame every time — **worst error 46 ms**.
That figure is the quantisation floor rather than a measurement of quality: the
offset is expressed in whole 92.88 ms frames, so no correct answer can ever be
worse than half a frame. The `runtime/2` anchor behaves as specified through real
head-trimmed files (cutting `delta` from the head moves the window by
`delta/2`), and both an out-of-cap offset and unrelated content are declined
outright (0.10 and 0.07).
**Where it is soft is tier labelling, not alignment.** The *score* at the correct
offset falls with sub-frame misalignment — 0.940.99 when the true offset lands
within 0.1 of a frame boundary, 0.690.73 at half a frame — because the two
windows' frame grids no longer coincide. The offset stays right, but only 13 of
40 cleared the server's 0.85 `audio` threshold and the other 27 were demoted to
`loose`, a tier that means "possibly the same cut, degraded audio". The threshold
was calibrated on a re-encode at *zero* offset, where the score is 1.00.
The remedy is measured, not proposed (UT-108): counting a frame as agreeing if
its peak bin matches **within ±1 frame** returns all 40 to `audio` (worst 0.906)
while unrelated content and out-of-cap offsets stay at 0.12 and 0.16 — the gap
that makes the threshold mean anything is untouched. It costs 81 ms of offset
accuracy, of a 500 ms budget, because the flattened peak lets the argmax pick an
adjacent frame. ±2 frames buys nothing further. Adopting it is a
[server spec](../../JRay-public-server/SPEC.md) §3 change — the score is
normative and shared by three repos — so this repo measures it and leaves the
decision there.
**Gap:** the signature is computed but **not yet emitted** into the truth file —
that is the `IR-002` field and the coordinated `schema_version` bump.
## IR-006 — Jellyfin round-trip
@@ -945,8 +1531,57 @@ surfaced as a build report.
different models are meaningless but *look* plausible — this fails silently and
expensively otherwise.
**Gap:** named as step 4 of the `service-conversion.md` implementation plan;
unbuilt. This is the highest-value small fix in the document.
### The stamp
Two fields, written together: the model file's **basename** and the **SHA-256 of
its bytes** (plus `embed_dim` as a cheap extra guard). Stored as the `/embedder`
group in the gallery HDF5, and as an optional top-level `"embedder"` object in
the legacy JSON format.
The hash *decides*; the name is what a human *reads*. Neither alone is enough. A
name is a promise rather than a fact — models get re-exported, re-quantised and
overwritten in place under an unchanged filename, which is exactly the case where
the weights differ and nothing else does, so a name-only stamp is blind to the
failure it exists to catch. A hash alone is correct but unactionable: *"expected
3f2a…, got 9c1b…"* tells an operator nothing about what to do next. SHA-256 over
the file is derived from the artefact rather than asserted about it, needs no
registry kept up to date, and costs ~0.1 s for a 250 MB ONNX once per process.
### Verdicts
| Verdict | When | Default | Under strict mode |
|---|---|---|---|
| `match` | hashes agree | proceed | proceed |
| `weak_match` | names agree, one side unhashable | **warn** | **error** |
| `unstamped` | gallery predates GR-004 | **warn** | **error** |
| `unknown_embedder` | gallery stamped, embedder unidentifiable | **warn** | **error** |
| `mismatch` | proven different models | **error** | **error** |
**A mismatch is fatal in every mode, with no bypass**, and the message names both
sides — what the gallery was built with and what is loaded.
The three "cannot prove it" verdicts warn loudly instead, because they describe an
*unknown* state rather than a *known-bad* one, and because every gallery built
before this requirement is unstamped. Hard-failing all of them would make the
check something people route around rather than trust. Strict mode
(`--require-gallery-stamp`, or `SAE_REQUIRE_GALLERY_STAMP=1`, which propagates to
subprocesses) promotes them to errors — that is the mode measurement work runs in.
`scripts/stamp_gallery.py` re-binds an existing gallery without re-embedding, so
migration costs one command; that is what makes "warn" a temporary state rather
than a permanent one.
### Scope of the check
Embedding **dumps** carry the same stamp (`embedder_model` / `embedder_sha256`
root attributes, `scripts/optimizer/SCHEMA.md`): a replay has no live embedder, so
the dump *is* the embedder as far as the gallery is concerned. Derived galleries
(filter, cast-restrict) inherit their source's stamp; `--merge` and the JSON
gallery merge check *before* writing, since a merged file holding two embedding
spaces cannot be untangled afterwards by any later check.
**Gap:** none. Stamped in `gallery_builder.cpp` and the Python builders; verified
in `scene_analyze`, `scene_preview`, the `sae_kpn` matcher binding, `replay.py`,
`optimize.py`, `movienet_eval.py` and the merge paths.
## GR-006 … GR-009 — Provenance tiers and poisoning guard
@@ -1018,19 +1653,30 @@ Persist pipeline state at the point where the expensive work ends.
per-frame index table (`face_offset`, `face_count`) pointing into them. Avoids
variable-length HDF5 types and reads straight into numpy.
- Stores per frame: `timestamp_sec`, `frame_idx`, `is_cut`, `is_scene_boundary`.
Per face: `embedding` [N,512], `bbox` [N,4], `landmarks` [N,10], `confidence`.
- Invariants: embeddings unit-norm; `face_offset` contiguous; bboxes already in
original resolution; frames with no faces still get a row so timestamps stay
dense; EOF sentinels not written.
Per face: `embedding` [N,512], `bbox` [N,4], `landmarks` [N,10], `confidence`,
and from v2 the AR-028 quality vector — `sharpness` [N] and
`alignment_residual` [N]. Size, its third axis, is `bbox` and is not
duplicated.
- Invariants: embeddings unit-norm; `face_offset` contiguous; bboxes and
landmarks in **decoded-frame** pixels with `bbox_upscale` recorded alongside
(the dump is a faithful tap, so it does not transform what the tracker saw —
see VR-010); frames with no faces still get a row so timestamps stay dense;
EOF sentinels not written.
- Enabled by `--dump-embeddings out.h5`; teeing must not perturb the live result.
Schema owned by [`scripts/optimizer/SCHEMA.md`](../scripts/optimizer/SCHEMA.md).
**Current:** C++ dump sink (`embedding_dump_node.hpp`, `dump_embeddings.cpp`),
read by `replay.py`. **Gap:** **AR-012 breaks the replay contract.** Track extents
read by `replay.py`. At `schema_version` 2, which AR-028 took it to by adding the
quality columns; readers on both sides check the datasets by name, so a v1 dump
still replays and reports the vector as unknown rather than as zero.
**Gap:** **AR-012 breaks the replay contract.** Track extents
are decided in the tracker, which is *downstream* of the dump — so a replay can
reproduce them, but only if the dump preserves everything the tracker needs.
Verify `landmarks`/`bbox`/`is_cut` suffice, and bump `schema_version` if not.
The committed fixtures are still v1, so they carry no quality vector until
`scripts/make_fixtures.sh` is re-run on a GPU host.
## VR-002 — Replay and sweep
@@ -1080,6 +1726,14 @@ Verify `landmarks`/`bbox`/`is_cut` suffice, and bump `schema_version` if not.
Quantify where ArcFace degrades, replacing the 66×66 estimate in A1 with a
measurement.
> **Result, and its limit.** Knee at 2432 px; 32 px returns 98.1% TPI at 0.0
> FPI. But the probe is an already-aligned 112×112 crop, so alignment is held
> perfect and this measures the **embedder alone** — an upper bound, not a
> threshold. **VR-013** re-asks the question end to end, downscaling the whole
> frame before the detector, and lands near 50 px. AR-002's floor of 40 px comes
> from VR-013; this study is what shows how much of the gap is detection and
> landmark error rather than embedding.
**Method.**
1. Select ~100 gallery actors having more than one mugshot.
@@ -1145,6 +1799,54 @@ round 1 seeding references that corrupt round 2.
"expansion helps live matching" from "expansion helps the second pass", which the
current all-or-nothing `expand_gallery` flag cannot distinguish.
## VR-015 — Per-node cost and bottleneck attribution
**Requirement: a run must be able to report where its time went, per node, and
which node is setting the pace.** Without it, optimisation is guesswork, and
worse than guesswork — the obvious number is wrong in a specific, repeatable
direction, so acting on it makes the pipeline slower.
**Why the obvious number is wrong.** KPN times a node across `fire_once`, which
wraps the functor *and* `push_outputs`. Under AR-004 a push parks on a full
downstream channel, so a node that is merely waiting bills that wait to itself.
On the SuperHero reference run (`docs/benchmark.md`) `frame_source` reported
`ema=141.899ms` per frame while its own decoder logged 12-18 ms: it was
backpressured, and the report named the *fastest* node in the graph as the most
expensive one. A second trap sits behind the first — `ema_exec_ms` is an
exponentially weighted average, so `frames × ema` is not a total; on a film whose
per-frame cost swings between crowd scenes and landscapes the two differ
substantially.
**Method.** Three measurements per node, none of which is sufficient alone:
| Measure | What it is | What it cannot tell you |
|---|---|---|
| `cpu_ms` | thread CPU time (`CLOCK_THREAD_CPUTIME_ID`) | GPU wait — a device-bound node looks idle |
| `exec_ms` | cumulative wall time inside the node | work from waiting — backpressure inflates it |
| `pressure` | mean input fill mean output fill | how expensive the node is, only that it paces |
Queue occupancy has to be **sampled during the run**. `current_fill` is
instantaneous and every channel has drained by shutdown, so a single read at the
end describes an idle pipeline however congested it was.
**The number that matters** is `pressure`, because work piles up in front of the
bottleneck and starves everything after it, and that ordering holds whether the
node is waiting on a core, a GPU or a disk. `cpu_share` then selects the repair:
a pacing node with a saturated thread is CPU-bound and the work must get cheaper,
while a pacing node with an idle thread is device-bound, where batch size and
engine precision are the knobs and the C++ is not.
**Current:** `--benchmark <path>` writes the JSON report and prints a table at
shutdown; `src/benchmark.hpp`. Attribution is a pure function over KPN snapshots,
so it is verified on CI's GPU-free N100 (UT-120…UT-124) rather than only by
running the pipeline. The node graph is recovered from KPN's channel names, so a
re-wired topology needs no change here. Required `NodeStats::total_exec_us` in
the KPN submodule — the EMA could not be turned into a total.
**Gap:** GPU utilisation and memory are not sampled, so a device-bound verdict
says *that* a node waits on the GPU, not whether the GPU is saturated or merely
badly fed. That distinction needs NVML, and it is what VR-008 will want anyway.
## VR-008 — Gallery scaling benchmark
Establish the throughput-versus-gallery-size curve required by A10.
Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 447 KiB

+344
View File
@@ -0,0 +1,344 @@
# Benchmark — SuperHero
The reference film for end-to-end accuracy. Replaces Road to Bali, which was
withdrawn for the reason in [Why not Road to Bali](#why-not-road-to-bali).
TRACES: AR-011, AR-012, AR-013 | VR-001, VR-005 | SR-002
---
## The film
SuperHero, from the [NIST TRECVID Deep Video Understanding development
set](https://www-nlpir.nist.gov/projects/trecvid/dvu/dvu.development.dataset/).
14 films are asserted Creative Commons and need no data agreement; only the 5
KinoLorber test films are gated.
| | |
|---|---|
| Runtime | 1025.5 s (17.1 min), 10 scenes |
| Resolution | 640×360 |
| Ground truth | Per-scene presence, from the scene knowledge graphs |
| Gallery | 5 characters, 14 references |
The DVU set is what makes this workable: it ships **character** face crops cut
from the film itself, so ground truth and gallery are both in character space
and scoring needs no actor→character mapping.
**Licence caveat.** NIST links licence evidence for only 4 of the 14 films, and
SuperHero is not one of them — its end credits carry no copyright or CC notice,
list a "Temporary Musical Score" and a SAG cast, and it has no traceable online
release. Fine for internal benchmarking; do not redistribute frames from it.
Valkaama is the one film with an independently documented licence (CC BY-SA 3.0)
if provenance ever has to be defended.
---
## Reproducing it
```sh
# 1. Annotations, character mugshots, scene segmentation.
# NIST names the same film three different ways, hence the overrides.
KG_DIR=superHero KG_FILE=superhero scripts/fetch_dvu.sh SuperHero ../dvu-hero
# 2. Scene clips (movie.shots), then fuse them into one stream.
# Fusing matters — see "Run it as one film" below.
# SuperHero-1.webm … SuperHero-10.webm from
# <dataset>/movie.shots/, then:
ffmpeg -f concat -safe 0 -i concat.txt -c copy SuperHero_full.webm
# 3. Gallery, with the face-size floor that keeps references in distribution.
./build/build_gallery --root ../dvu-hero/root \
--output ../dvu-hero/hero66.h5 --min-face-px 66
# 4. Run, on the GPU path (see "Check you are on the GPU").
./build/scene_analyze --movie hero/SuperHero_full.webm \
--gallery ../dvu-hero/hero66.h5 \
--detector-engine trt_cache/scrfd.scrfd_500m_bnkps.640.fp16.engine \
--arcface-engine trt_cache/arcface.LVFace-B_Glint360K.b4.fp16.engine \
--fps 5 --min-face-px 32 --expand-gallery \
--output pred.json
```
Nothing here is in git: the clips are ~130 MB and the annotations are
regenerable. Replay fixtures derived from the run ship through the artifact
registry instead:
```sh
scripts/artifacts/push_artifacts.sh replay-fixtures
scripts/artifacts/pull_artifacts.sh replay-fixtures [version]
```
The gallery travels in the same archive as the dumps deliberately — a dump only
replays meaningfully against the gallery it was produced with, and pairing one
with a different gallery silently changes every identity decision in it.
---
## Results
Measured on the fused film, gallery expansion on.
| Metric | Value |
|---|---|
| Precision | **1.00** |
| Recall | 0.65 |
| F1 | 0.79 |
| True positives | 13 |
| False positives | **0** |
| False negatives | 7 |
Six of ten scenes scored exactly right, including the three-character scenes 4
and 5.
**Zero false positives is the result worth keeping.** Every out-of-gallery
character — Beast, Mighty Celestial, Ms. Johnson, Doctor, two Masked Persons —
was declined rather than forced onto a nearest match. That is the calibrated
probability (AR-024) doing its job, and it is the right failure direction for an
X-Ray overlay: a miss is a gap, an invention is a lie.
**The misses have a shape.** Scenes 1, 2, 3 and 8 were missed, and 13 are the
three shortest scenes in the film (14 s, 38 s, 27 s). That is consistent with
per-track Bayesian accumulation (AR-025) needing enough sightings before belief
crosses threshold. Scene 8 is 65 s and does not fit that story — it is the one
to look at first when improving recall.
Running the same scenes as isolated clips did *not* do better, so cross-scene
gallery expansion is not currently compensating for short scenes.
### Run it as one film, not as clips
Per-scene clips defeat per-film gallery expansion (AR-019), which grows a
temporary gallery from track continuity across the whole film and re-assesses
unknown tracks at the end. Ten isolated clips give it nothing to work with, and
pay model and gallery load ten times over.
Fusing also makes presence windows cross real scene boundaries, which is how
SR-002's scene-scoped question is asked in production. Note the joins are
artificial cuts — consecutive scenes were never contiguous footage — so presence
bleeding across a boundary may be the join rather than a tracking fault.
---
## Throughput
| Path | Realtime factor | Sampled fps | 17-min film |
|---|---|---|---|
| `build/` (TensorRT) | **8.25×** | 41.3 | **2.1 min** |
| `build-ort/` (ORT) | 0.54× | 2.7 | ~32 min |
TensorRT figure re-measured 2026-08-04 over the whole film at `--fps 5
--min-face-px 32 --expand-gallery`: 5129 frames, 1025.4 s of film in 124.2 s
wall. Two runs agreed to 0.4% (124.2 s clean, 124.7 s under gdb). It supersedes
an earlier 2.0×; that figure predates the current tree and was not re-derived
here, so treat the gain as measured rather than explained.
Throughput varies strongly with face density, and **a short window is not a
sample of the film**. The opening 60 s benchmarks at 23.9× — decode there costs
4-6 ms/frame against a 12.35 ms whole-film mean (n=510), because seeking forward
in VP8/WebM gets dearer the deeper you go, and there are few faces. Always quote
the whole-film average.
### Where the time goes (VR-015)
Measured over the whole film, 2026-08-04:
| node | cpu_s | % of pipeline CPU | cpu/f | exec/f | stall/f | in% | out% |
|---|---|---|---|---|---|---|---|
| **embedder** | **91.0** | **60%** | 17.74 | 21.61 | 3.87 | 12 | 0 |
| **face_detector** ▶ | 41.4 | 27% | 8.07 | 24.20 | **16.14** | **99** | **0** |
| frame_source | 12.1 | 8% | 2.36 | 11.26 | 8.90 | — | 97 |
| camera_pos | 3.2 | 2% | 0.63 | 0.64 | 0.01 | 97 | 99 |
| face_aligner | 1.7 | 1% | 0.33 | 0.34 | 0.01 | 0 | 12 |
| identity_matcher | 1.3 | 1% | 0.26 | 0.34 | 0.09 | 0 | 0 |
| tracker / sink | 0.5 | <1% | — | — | — | 0 | 0 |
**`face_detector` paces the run**: its input channel is 97.8% full while its
output is 99.4% empty — everything upstream jammed, everything downstream
starved. It occupies 5129 × 24.20 ms ≈ 124.1 s of a 124.2 s run, essentially
100% wall occupancy, yet only 33% of that is CPU. The other 16.14 ms/frame is
device wait.
**The embedder is the larger cost but not the constraint**: 60% of all pipeline
CPU, 73% of wall as thread-busy. Whether that is real work or a spinning
`cudaStreamSynchronize` is unresolved — see the sync caveat below, which is a
one-line experiment.
**`frame_source` is the trap this table exists to defuse.** It reports
`exec/f = 11.26 ms` against `cpu/f = 2.36 ms`, and its output channel is 97%
full: it is backpressured, not expensive. The old KPN `ema` reading made it look
like the most costly node in the pipeline at 141.899 ms/frame.
`--benchmark <path>` writes a per-node timing report and prints a table at
shutdown. `hero/run_bench.sh` is `run_trt.sh` with it switched on:
```bash
./build/scene_analyze … --benchmark $H/bench_trt.json --output $H/pred_bench.json
```
**Do not read the `ema` column of the old KPN diagnostics block as a cost.** KPN
times a node across `fire_once`, which wraps the functor *and* the push to the
next channel, and a push parks when that channel is full (AR-004). A
backpressured node therefore bills its waiting to itself. On this film that
produced a genuinely inverted answer:
```
│ frame_source frames=5132 ema=141.899ms ← reported cost
[frame_source] decode avg=16.6127ms fps=60.19 ← actual decode
```
The source is not expensive; it is idle, holding a frame nobody has taken yet.
Optimising against that number means optimising the fastest node in the graph.
The benchmark report separates the two:
| Column | Meaning | Blind spot |
|---|---|---|
| `cpu_s`, `cpu%tot` | thread CPU time, and this node's share of all of it | a GPU wait looks like idleness |
| `cpu/f` | CPU ms per frame — backpressure cannot inflate it | as above |
| `exec/f` | wall ms per frame in the node, **including parked pushes** | overstates a blocked node |
| `stall/f` | `exec/f cpu/f`: parked, or waiting on a device | does not say which |
| `in%`, `out%` | mean fill of the node's input and output channels | — |
| `press` | `in% out%`; **the node marked ▶ is pacing the run** | not a cost, an ordering |
Read `press` first: work queues up in front of the bottleneck and starves
everything after it, so the pacing node is the one with a full input and an empty
output. Then read `cpu%run` to decide the repair — a saturated thread means the
work itself must get cheaper, while an idle thread under pressure means the node
is waiting on the GPU or the disk, where batch size and engine precision are the
knobs and the C++ is not.
Channel fills are sampled every 100 ms (`--benchmark-interval-ms`) because
`current_fill` is instantaneous: by shutdown every channel has drained, so a
single read at the end reports an idle pipeline no matter how congested it was.
#### Check the GPU is not throttled before comparing anything
**On this hardware, thermal state moves the result more than any code change
we are likely to make.** The same binary measured **8.25× cool and 3.12× once
heat-soaked** — a 2.6× swing — because the laptop RTX 3050 hits `SW Thermal
Slowdown` and pins the SM clock to **210 MHz out of 2100**:
```
$ nvidia-smi -q -d PERFORMANCE | grep -E "SW Power Cap|SW Thermal"
SW Power Cap : Active
SW Thermal Slowdown : Active
```
A number recorded without its clock state is not comparable to any other
number, and back-to-back full-film runs guarantee the later ones are throttled.
`run_bench.sh` now records `nvidia-smi` either side of the run into
`bench_gpu.txt`; check it before believing a regression. Let the GPU idle back
to full clock between measurements, and never A/B two runs across a heat-soak.
This one cost real time here: a 2.7× "regression" was attributed to a code
change and reverted on that basis, when the change was innocent and the GPU had
simply warmed up between the two measurements.
#### `cpu_s` on a GPU node is mostly spin — measured
CUDA's default sync policy (`cudaDeviceScheduleAuto`) spin-waits before it
yields, so `cudaStreamSynchronize` charges the *calling thread's* CPU while the
GPU works. A GPU-bound node therefore reports a large `cpu_s` and reads as
CPU-bound.
`SAE_CUDA_BLOCKING_SYNC=1` switches to a blocking wait. Measured over 300 s of
film, four cases, identical otherwise:
| case | realtime | total CPU | embedder CPU |
|---|---|---|---|
| baseline | 3.29× | 103 s | 66 s |
| **`SAE_CUDA_BLOCKING_SYNC=1`** | 3.29× | **23 s** | **4 s** |
| `SAE_CV_THREADS=1` | 3.30× | 101 s | 66 s |
| both | 3.29× | 25 s | 5 s |
**94% of the embedder's CPU was spin, not work**, and 78% of the pipeline's.
Throughput is unchanged, so this is free CPU — which matters for a service
sharing a box (DP-003) and makes `cpu_s` mean what it says. Prefer it for any
run where the CPU numbers are being read.
`SAE_CV_THREADS=1` does nothing measurable: the only OpenCV-heavy node is
`face_aligner` at 1-2% of the pipeline, so the TBB arena is not worth removing
and `warpAffine` is not worth replacing.
**Caveat: measured with the GPU clamped at 210 MHz** (see below). A device at
full clock spends less time in the sync, so the absolute spin figure will fall;
the ranking should not.
#### `cpu_s` counts one thread — mind the TBB arena
OpenCV 5 here is built against TBB, and every OpenCV module links it, so
`cv::parallel_for_` dispatches onto a TBB arena of `nproc 1` workers (19 on the
20-core dev box; visible as `libtbb.so.12` frames in a thread dump). Since
`CLOCK_THREAD_CPUTIME_ID` is per-thread, work a node fans out that way is billed
to the TBB workers, **not** to the node.
So a node using `warpAffine`, a histogram compare or a colour conversion reads
cheaper in `cpu_s` than it really is, and the missing time appears in `stall/f`,
where it looks identical to a GPU wait. `exec/f` does capture it — the functor
does not return until the parallel region joins — so the tell is a node whose
`exec/f` far exceeds its `cpu/f` **while its output channel is empty**: that is
fan-out, not blocking.
Worth knowing for its own sake, too: 9 KPN node threads plus 19 TBB workers plus
the CUDA and NVDEC threads is heavy oversubscription on 20 cores.
The JSON carries the same data plus the run's configuration, so two runs can be
diffed directly — which is the point, when sweeping `--embed-batch`, `--fps` or
an engine precision.
### Check you are on the GPU
ORT's CUDA execution provider fails to load on this machine and **silently falls
back to CPU**:
```
Failed to load library libonnxruntime_providers_cuda.so:
undefined symbol: cudnnGetConvolutionBackwardDataAlgorithm_v7
```
That symbol was removed in cuDNN 9; the packaged ORT is built against cuDNN 8.
ORT logs this once at startup and then runs happily on CPU, so a `build-ort`
timing is a CPU number wearing a GPU label — a 15× error with no symptom other
than a figure you have no baseline for. Grep the log for `Failed to load
library` before trusting any throughput measurement.
The TensorRT path (`build/`) needs prebuilt engines from
`scripts/build_trt_engines.sh` and reports what it loaded:
```
[TrtScrfd] loaded: … [TrtArcFace] loaded: … max_batch=4
[similarity] cuBLAS/CUDA engine: gallery resident on GPU
```
---
## Why not Road to Bali
Bali was chosen because DVU ships character mugshots for it. It was withdrawn on
**face scale**, measured on its own reference crops:
| | Bali | SuperHero |
|---|---|---|
| Median detected face | 27 px | **69 px** |
| Maximum detected face | 69 px | **241 px** |
| References ≥66 px | 2 of 69 | 14 of 27 |
The DVU images are scene crops, not mugshots, so the crop dimensions say nothing
about face scale — the face has to be detected and measured. Bali's median
reference was being upscaled roughly 4× to reach ArcFace's 112×112, and the
worst 7×, which violates AR-011: every model gets the input it was trained for.
A model run off-distribution returns confident, plausible, wrong output.
In a gallery that error is permanent. A bad frame costs one frame; a poisoned
reference corrupts every future match against that identity.
No threshold rescued it. At 66 px only 2 of 69 references survived — the largest
face in the entire set is 69 px — so there was no cut that both kept references
in distribution and left enough of them to calibrate. SuperHero's gallery builds
at a 66 px floor and calibrates on its own (`a=15.2867 b=-4.98633`, 100 % train
accuracy) rather than borrowing constants.
Any accuracy figure recorded against Bali predates this and should be treated as
measuring upscaling artifacts as much as the pipeline.
@@ -1,10 +1,12 @@
> **Archived (July 2026).** This report covers the pre-opencv5 framework and the 4-model ArcFace/LVFace bake-off. It is superseded by the current [experiment log](model-bakeoff.md) for the opencv5 build. Kept for provenance; the numbers here are historical.
# Which embedding model is best?
Three ArcFace variants (w600k-R50, R18, w600k-MBF) and LVFace-B (Glint360K,
455MB) were compared. r50 is excluded from the training/held-out comparison
below; its gallery has roughly 30% fewer reference images per actor than the
other three on the identical source photos, which confounds a direct score
comparison (see [the full experiment log](model-bakeoff.md) for detail). It
comparison (see [the full experiment log](model-bakeoff-2026-07.md) for detail). It
remains in the calibration comparison, which does not depend on the gallery
image count.
@@ -63,7 +65,7 @@ than general performance. On training data, the ordering is not as clean:
mbf beats LVFace on Lord of War (77.2% vs 75.6%), the only film in either
table where LVFace does not score highest. LVFace's training-set macro
average (75.3%, see [the full experiment log](model-bakeoff.md)) is not a
average (75.3%, see [the full experiment log](model-bakeoff-2026-07.md)) is not a
uniform win across every film it contributes to; the held-out result, where
LVFace wins all 5 films outright, is the stronger claim.
@@ -79,7 +81,7 @@ not.
![All 12 combos ranked by training-set F1](assets/images/rep4_matrix_f1.png)
Best full-gallery combo per model (all three are `full_exp`), from the
training matrix in [the full experiment log](model-bakeoff.md):
training matrix in [the full experiment log](model-bakeoff-2026-07.md):
| model | F1 | P | R | misID |
|---|---|---|---|---|
@@ -1,3 +1,5 @@
> **Archived (July 2026).** This report covers the pre-opencv5 framework and the 4-model ArcFace/LVFace bake-off. It is superseded by the current [experiment log](model-bakeoff.md) for the opencv5 build. Kept for provenance; the numbers here are historical.
# Whole gallery vs. cast-restricted gallery
Two ways to run the matcher. Full mode scores every detected face against
@@ -8,7 +10,7 @@ top-billed actors) before the matcher runs.
## Result
Averaged across the 3 compared models (r50 excluded, see
[the full experiment log](model-bakeoff.md)) and both expansion settings, on
[the full experiment log](model-bakeoff-2026-07.md)) and both expansion settings, on
the 4 training films:
| scope | F1 | P | R | total misID |
@@ -27,7 +29,7 @@ restricted gallery:
![All combos ranked by training-set F1, filled dots are restricted](assets/images/rep4_matrix_f1.png)
See [the full experiment log](model-bakeoff.md) for the complete table. One
See [the full experiment log](model-bakeoff-2026-07.md) for the complete table. One
combo reaches zero true out-of-cast misidentifications,
`arcface_w600k_mbf_restricted_exp` (F1 76.2%), and it is a restricted one,
consistent with restriction, not expansion, being what suppresses cross-film
@@ -57,7 +59,7 @@ Building this as a real feature requires:
option.
- A decision on the fallback case: what happens to a real, uncredited
cameo (see the Germar Terrell Gardner and Talia Balsam cases in the
[LVFace deep dive](lvface-deep-dive.md#where-lvface-beat-x-ray)) if the
[LVFace deep dive](lvface-deep-dive-2026-07.md#where-lvface-beat-x-ray)) if the
restricted gallery never includes them at all.
- Regenerating the restricted-gallery cache whenever a title's Jellyfin
cast list changes.
+54 -46
View File
@@ -17,62 +17,70 @@ two credited cast members without a visible face are correctly reported
present but not visible. This matches Amazon X-Ray's own record for this
second exactly.
Results are not uniform across films. The hardest held-out film scores 46%
F1. This report documents why: one tunable trade (extinction bridging at
hard cuts), one structural limit (X-Ray credits people whose faces never
appear on screen), and a small number of cases where the pipeline is
correct and X-Ray's ground truth is not. Read
[how we score against X-Ray](methodology.md) first. X-Ray's ground truth is
scene-level; the pipeline's output is per-second. That difference shapes
every finding below.
## The headline: learned scene boundaries
## Findings
The current opencv5 build's biggest gain is **flood-fill presence on a
learned scene-boundary detector**. An actor seen once inside a shot is
reported for the whole shot — but only if the shot boundaries are good. A
learned XGBoost boundary detector, scored **leave-one-out** so no film is
ever measured by a detector that trained on it, lifts per-second X-Ray
presence F1 across nine films and improves every one of them:
<div class="grid cards" markdown>
| boundary source for flood-fill | presence F1 |
| ------------------------------ | ----------: |
| track-extent (flood off) | 62.6% |
| flood + grayscale cuts | 64.0% |
| **flood + learned detector (LOO)** | **74.9%** |
- :material-trophy:{ .lg .middle } **[Which model is best?](best-model.md)**
![Macro presence F1 by flood-fill boundary source](assets/images/scene_presence_macro.png)
---
The full story — why the old grayscale cut detector broke Scarface, what
features work, and the per-film breakdown — is on the
[learned scene-boundary detector](scene-boundary-detector.md) page.
Calibration curves first, independent of any threshold, then held-out
F1 across three models. LVFace-B Glint360K wins both, and wins on every
held-out film.
## What the numbers mean, and their limits
- :material-filter:{ .lg .middle } **[Whole vs. cast-restricted gallery](gallery-scope.md)**
Results are not uniform across films, and they should not be. X-Ray's ground
truth is scene-level and credits people whose faces never appear on screen;
the pipeline's output is per-second and can only name a face it can see.
That difference is a structural recall ceiling, not a bug. Read
[how we score against X-Ray](methodology.md) first — it defines F1,
precision, recall, and misID, and explains the two limits (off-screen cast
and gallery coverage) that shape every finding.
---
Restricting the matcher to a film's credited cast improves F1,
recall, and misID rate at once, but is not a shipped runtime feature
yet.
- :material-account-convert:{ .lg .middle } **[Does pose expansion help?](pose-expansion.md)**
---
A training-set effect that did not reproduce on 5 held-out films once
two methodology bugs in the comparison harness were found and fixed.
- :material-magnify-expand:{ .lg .middle } **[Deep dive: LVFace-B Glint360K](lvface-deep-dive.md)**
---
The held-out generalization gap, the two mechanisms behind its errors,
and every distinct case where it names someone outside the film's
credited cast.
</div>
Precision on identified faces is near-perfect: where the pipeline names a
face, it is almost always a name X-Ray also credits to that scene. The
frames throughout this documentation make the tension visual — **green** =
true positive, **red** = false positive, **orange** = unknown, and a
**blue** panel lists credited cast present with no visible face.
## Full experiment log
- **[Full experiment log](model-bakeoff.md)**: the complete log behind the
four pages above, including how replaying against cached embeddings
inside the same KPN network makes a full model and configuration
comparison practical, the full results table, and every caveat. This is
where the shipped [`src/config.hpp`](https://REPOLINK/src/config.hpp)
defaults come from.
- **[Service conversion (proposal)](service-conversion.md)**: design
sketch for a native idle-GPU worker gated on screen lock, not yet built.
- **[Full experiment log (opencv5)](model-bakeoff.md)**: the complete log
behind the current build — the ten-knob differential-evolution tuning, the
shipped [`src/config.hpp`](https://REPOLINK/src/config.hpp) defaults and
where each comes from, the replay architecture that makes a nine-film
search tractable, and the flood-fill step change.
- **[Learned scene-boundary detector](scene-boundary-detector.md)**: the
features, the model, leave-one-out results, and the two headline films.
- **[Benchmark — SuperHero](benchmark.md)**: the benchmark harness.
- **[Service conversion (proposal)](service-conversion.md)**: design sketch
for a native idle-GPU worker gated on screen lock, not yet built.
## Archive (July 2026)
The pre-opencv5 four-model ArcFace/LVFace bake-off is kept for provenance.
Its numbers are historical; the current build supersedes them.
- [Best model (July)](best-model-2026-07.md) — LVFace-B Glint360K wins on
calibration and on every held-out film.
- [Gallery scope (July)](gallery-scope-2026-07.md) — cast-restricted
gallery improves F1, recall, and misID at once.
- [Pose expansion (July)](pose-expansion-2026-07.md) — a training-set
effect that did not reproduce held-out.
- [LVFace deep dive (July)](lvface-deep-dive-2026-07.md) — the
generalization gap and every out-of-cast identification.
- [Full experiment log (July)](model-bakeoff-2026-07.md).
## Reproducing the benchmarks
@@ -1,12 +1,14 @@
> **Archived (July 2026).** This report covers the pre-opencv5 framework and the 4-model ArcFace/LVFace bake-off. It is superseded by the current [experiment log](model-bakeoff.md) for the opencv5 build. Kept for provenance; the numbers here are historical.
# Deep dive: LVFace-B Glint360K
LVFace won the model comparison (see [Which model is best?](best-model.md))
LVFace won the model comparison (see [Which model is best?](best-model-2026-07.md))
and is the shipped default embedder. This page reports how it performs in
detail: a baseline of correct output, the two mechanisms behind its errors,
and every distinct case where it names someone who is not in the film's
credited cast.
Read [How we score against X-Ray](methodology.md) first. X-Ray's ground truth
Read [How we score against X-Ray](methodology-2026-07.md) first. X-Ray's ground truth
is scene-level, not per-frame. A name marked correct in the Offscreen column
below is the pipeline correctly reporting scene membership, not a workaround.
@@ -61,7 +63,7 @@ on the 5 films the optimizer never saw:
| macro average | 67.4% | 85.8% | 57.0% | | | | |
The `P` column is misID-weighted (each out-of-film name counts 10x in the
denominator; see [methodology](methodology.md#precision-recall-and-the-misid-weighting)).
denominator; see [methodology](methodology-2026-07.md#precision-recall-and-the-misid-weighting)).
That weighting is why Many Saints reads 54.7% here despite naming mostly real,
present faces: its raw (unweighted) precision is **78.4%**, and the gap is
entirely its 974 misIDs paying the 10x penalty. The three zero-misID films
@@ -70,7 +72,7 @@ Lovelace, with 58 misIDs, sits 3pp below its raw 93.3%.
Held-out F1 is 67.4%, against 75.3% on training, an 8pp drop. The spread
between the best and worst held-out film is 37pp. This is not unique to
LVFace: [the full experiment log](model-bakeoff.md#held-out-validation-all-3-models)
LVFace: [the full experiment log](model-bakeoff-2026-07.md#held-out-validation-all-3-models)
shows mbf and r18 with the same shape of spread on the same films, at a
uniformly lower level. Two mechanisms explain the spread. Both are shown
below with frame-level evidence.
@@ -174,10 +176,10 @@ ground-truth gap, not a model error.
Archie Yates, t=2521s, 78% confidence. A real detected face, a genuine
lookalike confusion.
![Zooey Deschanel, third out-of-cast name in Many Saints](assets/images/many_saints_fpi_deschanel.jpg)
Zooey Deschanel, t=2819s, 99% confidence. A real detected face at a dinner
table, high-confidence lookalike confusion.
Zooey Deschanel, t=2819s, 99% confidence — a high-confidence lookalike
confusion in the July pipeline. **The current opencv5 pipeline no longer makes
this identification**; the tighter tracker/registry and re-tuned matching removed
it, so there is no annotated frame for it here.
![Talia Balsam, fourth out-of-cast name in Many Saints](assets/images/many_saints_fpi_balsam.jpg)
+136
View File
@@ -0,0 +1,136 @@
> **Archived (July 2026).** This report covers the pre-opencv5 framework and the 4-model ArcFace/LVFace bake-off. It is superseded by the current [experiment log](model-bakeoff.md) for the opencv5 build. Kept for provenance; the numbers here are historical.
# How we score against X-Ray
Every number in this report, every F1 and misID count, comes from one
comparison. The comparison has a mismatch at its core that shapes nearly
every finding in this report: the ground truth is scene-level, the
pipeline's output is per-second, and the two do not mean the same thing.
This page documents that comparison once, so the findings pages can rely on
it without re-explaining it.
## What Amazon X-Ray records
X-Ray ships three tables per film: `scenes.csv` (a list of `[start, end]`
timespans), `people_in_scenes.csv` (which actors are credited in each
scene), and `people.csv` (actor identities). There is no per-frame or
per-second annotation anywhere in X-Ray. A scene might run 45 seconds, and
X-Ray records one cast list for the entire span, not "on screen from
second 12 to second 30."
To compare this against per-second predictions, `second_score.py` expands
every scene into per-second ground truth by copying the whole scene's cast
list onto every second inside it:
```python
for sn, (t0, t1) in spans.items():
cast = scene_cast.get(sn, [])
for t in range(int(t0), int(t1)):
timeline[t] = cast
```
That is the entire mechanism. If X-Ray credits five actors to a 30-second
scene, all five count as ground truth present for all 30 seconds, including
seconds where only one of them is on screen. This is not a simplification
introduced by the pipeline; it is the only reading of X-Ray's data that is
possible, because X-Ray itself does not record anything finer-grained.
## Why an offscreen name can be scored correct
A name listed under Offscreen with a correct (green) label is not the
pipeline guessing or padding its score. It is the pipeline correctly
answering the question X-Ray actually asks: is this actor part of this
scene. It answers that question using a presence window (`[start, end]`,
held open across cuts by `anneal_sec` and `extinction_sec`), which matches
X-Ray's scene-level semantics more closely than a raw per-frame detection
would.
A system that only reported "this actor is visible in this exact frame"
would score worse against X-Ray's scene-level ground truth, producing a
false negative every time the camera cuts away from a character who is
still present in the scene. Not because it is wrong about the world, but
because it would be answering a stricter, different question than the one
X-Ray's data supports. The presence-window design exists specifically to
answer X-Ray's actual question.
## What this resolves and what it does not
This resolves the semantic mismatch between a scene and an instant. It does
not resolve two other limitations, both discussed in the
[LVFace deep dive](lvface-deep-dive-2026-07.md).
**The face-vs-presence ceiling.** X-Ray credits scene membership regardless
of whether a face is ever visible: background crew, characters shot from
behind, voice-only presence. No amount of bridging recovers a face that
never appears on screen. This is a hard ceiling on recall, not a defect.
**Extinction bridging can overshoot.** The same presence-window mechanism
that correctly answers "still in this scene" during a normal cut can also
bridge across a scene boundary it has no way to detect. A hard cut into a
different scene with no faces, such as closing credits, carries the
previous scene's identities forward until the window expires. This is the
mechanism behind Downton Abbey's recall collapse, documented in the deep
dive.
## Precision, recall, and the misID weighting
Per sampled second `t`:
**TPI** (true positive instances): actors both X-Ray and the pipeline agree
are present.
**FPI** (false positive instances): actors the pipeline reports that are
not in X-Ray's cast for this second. Split into two categories:
- **FPI_incast**: the actor is in the film's cast, just not credited to
this particular scene. A timing or boundary slip.
- **FPI_misid**: the actor is not in the film's cast at all. A genuine
wrong-identity error, weighted 10x in the precision objective, because
naming someone who is not even in the film is a categorically worse
error than a few seconds of scene-boundary slop.
!!! note "Every headline `P` and `F1` is misID-weighted"
The precision reported throughout this report, and therefore the F1
derived from it, puts each `FPI_misid` into the denominator **10 times**
(`precision = TPI / (TPI + FPI_incast + 10·FPI_misid)`,
[`second_score.py`](https://REPOLINK/scripts/optimizer/second_score.py)).
This is deliberate: the whole point is to punish naming an out-of-film
actor far harder than a scene-boundary slip. But it means the `P` column
is not raw precision, and a misID-heavy film's `P` is depressed
super-linearly. `second_score.py` also emits an unweighted `precision_raw`
(always ≥ the weighted `P`); where the gap matters, The Many Saints of
Newark, weighted `P` 54.7% vs. raw 78.4%, the [LVFace deep dive](lvface-deep-dive-2026-07.md)
reports both. When comparing `P` across films, remember you are comparing a
quantity that penalizes misIDs, not just a hit rate.
**FN** (false negatives): actors X-Ray lists that the pipeline never
reports, counted only for actors who have a gallery reference embedding.
Across the 9-film benchmark, coverage of X-Ray's credited cast ranges from
20% to 79% by film (see
[the full experiment log](model-bakeoff-2026-07.md#gallery-coverage-per-film)); an
actor with no reference photo can never be recognized regardless of model
quality, and counting them as a miss would penalize gallery coverage, not
recognition accuracy.
Two further numbers are reported alongside F1:
**agreement_rate**: mean per-second Jaccard overlap
(`|Pred ∩ GT| / |Pred GT|`), partial credit. Naming 2 of 3 present actors
scores 2/3, not 0.
**exact_match_rate**: the fraction of sampled seconds where the pipeline's
named set exactly equals X-Ray's, no partial credit. Far harsher, and
dominated by recall, since any single missed actor zeroes that second.
## Reproduce
```bash
python3 scripts/optimizer/second_score.py \
--pred pred.json --xray experiments/xray/.../<xray_dir> \
--gallery experiments/galleries/gallery_LVFace-B_Glint360K.h5
```
See also [the full experiment log](model-bakeoff-2026-07.md) for how `pred.json` is
produced, and the [LVFace deep dive](lvface-deep-dive-2026-07.md) for what these
mechanisms look like frame by frame.
+70 -78
View File
@@ -1,11 +1,9 @@
# How we score against X-Ray
Every number in this report, every F1 and misID count, comes from one
comparison. The comparison has a mismatch at its core that shapes nearly
every finding in this report: the ground truth is scene-level, the
pipeline's output is per-second, and the two do not mean the same thing.
This page documents that comparison once, so the findings pages can rely on
it without re-explaining it.
Every number in this report comes from one comparison, and that comparison
has a mismatch at its core: the ground truth is scene-level, the pipeline's
output is per-second, and the two do not mean the same thing. This page
documents the comparison once so the findings can rely on it.
## What Amazon X-Ray records
@@ -13,12 +11,12 @@ X-Ray ships three tables per film: `scenes.csv` (a list of `[start, end]`
timespans), `people_in_scenes.csv` (which actors are credited in each
scene), and `people.csv` (actor identities). There is no per-frame or
per-second annotation anywhere in X-Ray. A scene might run 45 seconds, and
X-Ray records one cast list for the entire span, not "on screen from
second 12 to second 30."
X-Ray records one cast list for the entire span, not "on screen from second
12 to second 30."
To compare this against per-second predictions, `second_score.py` expands
every scene into per-second ground truth by copying the whole scene's cast
list onto every second inside it:
To compare against per-second predictions, `second_score.py` expands every
scene into per-second ground truth by copying the whole scene's cast list
onto every second inside it:
```python
for sn, (t0, t1) in spans.items():
@@ -27,48 +25,46 @@ for sn, (t0, t1) in spans.items():
timeline[t] = cast
```
That is the entire mechanism. If X-Ray credits five actors to a 30-second
scene, all five count as ground truth present for all 30 seconds, including
seconds where only one of them is on screen. This is not a simplification
introduced by the pipeline; it is the only reading of X-Ray's data that is
possible, because X-Ray itself does not record anything finer-grained.
If X-Ray credits five actors to a 30-second scene, all five count as ground
truth present for all 30 seconds, including seconds where only one is on
screen. This is not a simplification the pipeline introduces; it is the only
reading X-Ray's data supports, because X-Ray records nothing finer.
## Why an offscreen name can be scored correct
## How the pipeline reports presence
A name listed under Offscreen with a correct (green) label is not the
pipeline guessing or padding its score. It is the pipeline correctly
answering the question X-Ray actually asks: is this actor part of this
scene. It answers that question using a presence window (`[start, end]`,
held open across cuts by `anneal_sec` and `extinction_sec`), which matches
X-Ray's scene-level semantics more closely than a raw per-frame detection
would.
A presence claim is one actor owning one time window. How that window is
derived is a tunable choice — a knob the optimizer weighs — with two modes:
A system that only reported "this actor is visible in this exact frame"
would score worse against X-Ray's scene-level ground truth, producing a
false negative every time the camera cuts away from a character who is
still present in the scene. Not because it is wrong about the world, but
because it would be answering a stricter, different question than the one
X-Ray's data supports. The presence-window design exists specifically to
answer X-Ray's actual question.
- **`track_extent` (default).** A claim is exactly `[first_seen, last_seen]`
of a track the actor owned (AR-012), ending at the last sighting and never
after (AR-013). There is no keep-alive: the withdrawn `anneal_sec` and the
scene-tracker `extinction_sec` — which the July report's windows were held
open by — are **gone**. A track that survives its own gaps needs no bridge;
a gap after the final sighting is never claimed.
- **`flood`.** Each claim is snapped to the shot it sits in, so an actor seen
once anywhere in a shot is reported for the whole shot
`[prev_boundary, next_boundary]`. Boundaries come from TransNetV2 shot
detection when available, otherwise from the always-on histogram cut
detector (`is_cut`). This trades precision for recall against X-Ray's
scene-level granularity, and the optimizer decides per run whether it pays.
## What this resolves and what it does not
Do not confuse the surviving `track_extinction_sec` with the withdrawn
scene `extinction_sec`: the former bounds how long a lost track stays
available for **re-association** (a tracking question), and never extends a
presence claim.
This resolves the semantic mismatch between a scene and an instant. It does
not resolve two other limitations, both discussed in the
[LVFace deep dive](lvface-deep-dive.md).
## The two limits this does not resolve
**The face-vs-presence ceiling.** X-Ray credits scene membership regardless
of whether a face is ever visible: background crew, characters shot from
behind, voice-only presence. No amount of bridging recovers a face that
never appears on screen. This is a hard ceiling on recall, not a defect.
behind, voice-only presence. No face pipeline can recover a face that never
appears, so recall against X-Ray is a structural ceiling, not a defect.
**Extinction bridging can overshoot.** The same presence-window mechanism
that correctly answers "still in this scene" during a normal cut can also
bridge across a scene boundary it has no way to detect. A hard cut into a
different scene with no faces, such as closing credits, carries the
previous scene's identities forward until the window expires. This is the
mechanism behind Downton Abbey's recall collapse, documented in the deep
dive.
**Flood-fill can overshoot.** Snapping to a shot correctly answers "still in
this scene" through an intra-scene cut, but a shot boundary is not a scene
boundary: on a film with sparse cuts, flood-fill can carry an actor across a
long "shot" they only briefly appeared in. This is why flood-fill is a knob,
not a default — its value depends on the film's cut density.
## Precision, recall, and the misID weighting
@@ -77,49 +73,46 @@ Per sampled second `t`:
**TPI** (true positive instances): actors both X-Ray and the pipeline agree
are present.
**FPI** (false positive instances): actors the pipeline reports that are
not in X-Ray's cast for this second. Split into two categories:
**FPI** (false positive instances): actors the pipeline reports that are not
in X-Ray's cast for this second, split into:
- **FPI_incast**: the actor is in the film's cast, just not credited to
this particular scene. A timing or boundary slip.
- **FPI_incast**: the actor is in the film's cast, just not credited to this
scene. A timing or boundary slip.
- **FPI_misid**: the actor is not in the film's cast at all. A genuine
wrong-identity error, weighted 10x in the precision objective, because
naming someone who is not even in the film is a categorically worse
error than a few seconds of scene-boundary slop.
wrong-identity error, weighted **10×** in the precision objective, because
naming someone not even in the film is categorically worse than a few
seconds of scene-boundary slop.
!!! note "Every headline `P` and `F1` is misID-weighted"
The precision reported throughout this report, and therefore the F1
derived from it, puts each `FPI_misid` into the denominator **10 times**
Precision puts each `FPI_misid` into the denominator 10 times
(`precision = TPI / (TPI + FPI_incast + 10·FPI_misid)`,
[`second_score.py`](https://REPOLINK/scripts/optimizer/second_score.py)).
This is deliberate: the whole point is to punish naming an out-of-film
actor far harder than a scene-boundary slip. But it means the `P` column
is not raw precision, and a misID-heavy film's `P` is depressed
super-linearly. `second_score.py` also emits an unweighted `precision_raw`
(always ≥ the weighted `P`); where the gap matters, The Many Saints of
Newark, weighted `P` 54.7% vs. raw 78.4%, the [LVFace deep dive](lvface-deep-dive.md)
reports both. When comparing `P` across films, remember you are comparing a
quantity that penalizes misIDs, not just a hit rate.
This deliberately punishes naming an out-of-film actor far harder than a
boundary slip, so the `P` column is not raw precision and a misID-heavy
film's `P` is depressed super-linearly.
**FN** (false negatives): actors X-Ray lists that the pipeline never
reports, counted only for actors who have a gallery reference embedding.
Across the 9-film benchmark, coverage of X-Ray's credited cast ranges from
20% to 79% by film (see
[the full experiment log](model-bakeoff.md#gallery-coverage-per-film)); an
actor with no reference photo can never be recognized regardless of model
quality, and counting them as a miss would penalize gallery coverage, not
recognition accuracy.
**FN** (false negatives): actors X-Ray lists that the pipeline never reports,
counted **only** for actors who have a gallery reference embedding. An actor
with no reference photo can never be recognized, and counting them as a miss
would measure gallery coverage, not recognition accuracy.
Two further numbers are reported alongside F1:
Two further numbers accompany F1:
**agreement_rate**: mean per-second Jaccard overlap
(`|Pred ∩ GT| / |Pred GT|`), partial credit. Naming 2 of 3 present actors
scores 2/3, not 0.
(`|Pred ∩ GT| / |Pred GT|`) partial credit, so naming 2 of 3 present
actors scores 2/3, not 0.
**exact_match_rate**: the fraction of sampled seconds where the pipeline's
named set exactly equals X-Ray's, no partial credit. Far harsher, and
dominated by recall, since any single missed actor zeroes that second.
**exact_match_rate**: the fraction of seconds where the pipeline's named set
exactly equals X-Ray's no partial credit, dominated by recall.
## The benchmark set
Unlike the July report — which trained on a 3-film subset and validated on
held-out films to keep evaluations fast — this run scores **all 9 films on
every evaluation**. The registry one-clock fix and uncapped dumps made
full-set replay affordable, so the reported optimum is tuned against the
complete set rather than a training subset.
## Reproduce
@@ -129,6 +122,5 @@ python3 scripts/optimizer/second_score.py \
--gallery experiments/galleries/gallery_LVFace-B_Glint360K.h5
```
See also [the full experiment log](model-bakeoff.md) for how `pred.json` is
produced, and the [LVFace deep dive](lvface-deep-dive.md) for what these
mechanisms look like frame by frame.
See the [full experiment log](model-bakeoff.md) for how `pred.json` is
produced and where the shipped `src/config.hpp` defaults come from.
+348
View File
@@ -0,0 +1,348 @@
> **Archived (July 2026).** This report covers the pre-opencv5 framework and the 4-model ArcFace/LVFace bake-off. It is superseded by the current [experiment log](model-bakeoff.md) for the opencv5 build. Kept for provenance; the numbers here are historical.
# Full experiment log
This page reports how the pipeline performs across three questions: which
embedding model is best, whether restricting the gallery to a film's
credited cast helps, and whether promoting confidently identified poses into
a per-film gallery annex helps. It also documents the replay architecture
that made testing all three questions in one pass practical, and every
caveat needed to trust the numbers.
Read [How we score against X-Ray](methodology-2026-07.md) first for what F1,
precision, recall, and misID mean in this report. All numbers below use the
per-second metric
([`scripts/optimizer/second_score.py`](https://REPOLINK/scripts/optimizer/second_score.py)).
r50 (ArcFace w600k-R50) is excluded from the detailed comparison below. Its
gallery was built with roughly 30% fewer reference images per actor than the
other three models on the identical source photos (10808 vs 15055 total
embeddings across the same 2418 actors), which confounds any direct
comparison of its scores against the others. It remains in the
[calibration curve comparison](best-model-2026-07.md#first-signal-calibration-curves),
which does not depend on the training benchmark.
## Why replay makes this affordable
Decoding video and running face detection, alignment, and embedding is the
expensive part of this pipeline. Everything downstream of that (tracking,
identity matching, scene aggregation) is cheap. KPN++'s node/network
structure means those two stages are separate components connected by
typed channels, so the expensive stage can run once per film, cache its
output, and the cheap stage can be re-run against that cache as many times
as needed with different Config values.
`scene_analyze --dump-embeddings out.h5` runs the expensive half once per
film and writes per-frame face detections and embeddings to HDF5
([`scripts/optimizer/SCHEMA.md`](https://REPOLINK/scripts/optimizer/SCHEMA.md)).
[`scripts/optimizer/replay.py`](https://REPOLINK/scripts/optimizer/replay.py)
then re-assembles the real C++ `face_tracker`, `identity_matcher`, and
`scene_tracker` nodes into a Python-driven KPN network and replays a
film's cached embeddings through them, varying `prob_threshold`,
`anneal_sec`, `extinction_sec`, and `expand_gallery` freely. No GPU
inference and no video decode happen during a replay; each one completes
in seconds. This is what makes a 512-evaluation differential-evolution
search per model, per gallery mode, per expansion setting, tractable, and
what made the full held-out validation across three models in this report
possible in one session rather than requiring three full re-encodes of the
benchmark set.
`optimize.py` runs `differential_evolution` over this replay function as its
objective, with DE-level parallelism (multiple candidate configs evaluated
concurrently, each spawning its own replay subprocesses) on top of it. The
practical ceiling on this machine's GPU was 8 concurrent replay processes;
9 silently degraded every score to 0.0% (well-formed output, wrong numbers,
not a crash), so `optimize.py` was run at `REPLAY_WORKERS=4 DE_WORKERS=2`.
## Search space
`popsize=10, maxiter=15` per combo (3 parameters, up to 512 evaluations,
usually stopping earlier on DE's convergence tolerance).
`anneal_sec`/`extinction_sec` bounds were widened from 1-30/1-15 to 1-60/1-60
partway through the sweep. r50's 4 combos finished before the widening and
used the old, narrower bounds; this is one more reason r50 is excluded from
direct comparison here.
## Training films and held-out films
9 films have dumped embeddings across all 4 models. 4 were used for
optimization:
- Café Society (62-cast)
- Lord of War (64-cast)
- Scarface (67-cast)
- Sound of Metal (14-cast)
5 were held out, never seen by any optimizer run:
- Benny & Joon
- Downton Abbey: A New Era
- Lovelace
- The Many Saints of Newark
- Valerian and the City of a Thousand Planets
## Gallery coverage per film
The gallery has reference embeddings for 2418 actors, but coverage of any
given film's credited cast varies widely. This was previously reported as
one flat number (67% of X-Ray cast lacking a reference embedding, averaged
across the whole benchmark); the per-film breakdown is:
| film | cast credited | in gallery | coverage |
|---|---|---|---|
| Lord of War | 64 | 13 | 20.3% |
| Scarface | 67 | 15 | 22.4% |
| The Many Saints of Newark | 48 | 13 | 27.1% |
| Café Society | 62 | 17 | 27.4% |
| Lovelace | 42 | 15 | 35.7% |
| Valerian and the City of a Thousand Planets | 36 | 13 | 36.1% |
| Benny & Joon | 23 | 12 | 52.2% |
| Downton Abbey: A New Era | 36 | 22 | 61.1% |
| Sound of Metal | 14 | 11 | 78.6% |
Two training films (Lord of War, Scarface) have the worst coverage in the
set, 20-22%. Their training-set F1 numbers below are partly capped by
missing references, not purely by model quality. Downton Abbey has 61%
coverage, the second-best in the benchmark, yet the worst held-out recall
of any film (39.4%, LVFace). Its recall problem is not primarily a coverage
problem; it is the extinction-bridging failure documented in the
[LVFace deep dive](lvface-deep-dive-2026-07.md#mechanism-1-extinction-bridging).
Reproduce with `scripts/docs/gallery_coverage_per_film.py`.
## Training results, 3 models × 2 gallery modes × 2 expansion settings
Ranked by F1. misid = FPI_misid, the count of true wrong-actor
identifications (naming someone not in the film's cast at all), distinct
from FPI, which also includes in-cast timing slips.
Each combo's row is its best **full-coverage** evaluation: the highest-F1 DE
evaluation in which all 4 training films replayed without a timeout (see
[Dropped-film scoring](#a-scoring-bug-worth-recording-dropped-film-evaluations)
below for why this qualifier is load-bearing and not the same as `argmax F1`
over the raw sweep).
| combo | F1 | P | R | TPI | FPI | misid | FN |
|---|---|---|---|---|---|---|---|
| LVFace-B_Glint360K_restricted_exp | 78.3% | 91.0% | 68.9% | 42830 | 3782 | 60 | 19492 |
| LVFace-B_Glint360K_restricted_noexp | 76.7% | 91.5% | 66.2% | 41149 | 3400 | 59 | 21173 |
| arcface_w600k_mbf_restricted_exp | 76.2% | 90.0% | 66.2% | 64328 | 7480 | 0 | 33234 |
| arcface_r18_restricted_exp | 75.5% | 87.6% | 66.5% | 41399 | 5666 | 60 | 20923 |
| LVFace-B_Glint360K_full_exp | 75.3% | 89.7% | 65.4% | 47757 | 3407 | 232 | 26966 |
| arcface_w600k_mbf_restricted_noexp | 75.0% | 91.1% | 63.9% | 39752 | 3465 | 60 | 22570 |
| arcface_r18_restricted_noexp | 73.5% | 91.3% | 61.7% | 38299 | 3220 | 60 | 24023 |
| LVFace-B_Glint360K_full_noexp | 72.3% | 88.3% | 61.8% | 40363 | 3503 | 244 | 25850 |
| arcface_w600k_mbf_full_exp | 72.0% | 87.7% | 61.4% | 39875 | 3729 | 240 | 26338 |
| arcface_w600k_mbf_full_noexp | 71.0% | 93.2% | 57.9% | 41699 | 2472 | 56 | 33024 |
| arcface_r18_full_exp | 69.1% | 87.6% | 57.7% | 37342 | 3119 | 242 | 28871 |
| arcface_r18_full_noexp | 66.6% | 91.3% | 53.1% | 34314 | 2362 | 107 | 31899 |
![All combos ranked by training-set F1](assets/images/rep4_matrix_f1.png)
The two clearest patterns: every model's best-scoring combo uses the
restricted gallery, and LVFace leads within both gallery modes. `full_exp`
(the shipped combination) is the best-scoring option that uses only
features the running application currently supports; restriction is not
wired into the application yet (see
[Whole vs. cast-restricted gallery](gallery-scope-2026-07.md)).
### A scoring bug worth recording: dropped-film evaluations
The numbers above are corrected ones. The raw `rep4_best_*.json` files, and an
earlier version of this table, reported a different `arcface_w600k_mbf_full_noexp`
row: **74.2% F1 at TPI 12645**, a third the TPI of every sibling combo. That was
not a better config; it was an artifact of how the optimizer aggregates.
`optimize.py` builds each candidate's score from only the films whose replay
subprocess returned (`per_film = [m for m in ex.map(_one, films) if m is not
None]`), then **averages** F1/precision/recall and **sums** TPI/FPI/misID over
just those survivors. When a film's replay times out (the sweep ran near the
8-process concurrency ceiling, so this happened intermittently), that film
silently drops from both. A candidate whose hardest film timed out is therefore
scored on an easier subset, and differential evolution, maximizing that score,
will happily converge onto exactly such a candidate. For `mbf_full_noexp` the
reported winner was one of 7 evaluations (out of 512) whose TPI had collapsed to
a partial-film subset; its median-coverage evaluations sit around 51686 TPI.
The fix here was to re-derive each combo's best row from its DE trajectory
(`experiments/trajectories/rep4_*.jsonl`), keeping only evaluations within 30% of
that combo's median TPI (full 4-film coverage) before taking the best F1. This
needs no re-running, the honest best configuration was already in the sweep,
just not the one `argmax F1` selected. Three combos moved: `mbf_full_noexp`
74.2% → **71.0%**, `LVFace_full_noexp` 72.4% → **72.3%** (and its misID, 0 → 244,
was itself a dropped-film artifact), `mbf_restricted_exp` 76.5% → **76.2%**. The
shipped LVFace `full_exp` winner was unaffected, its reported evaluation already
had full coverage (TPI 47757 ≈ median). `experiment_charts.py` applies the same
`clean_best` filter, so every figure on this page matches the corrected table.
The underlying `optimize.py` aggregation is also being fixed so a dropped-film
evaluation can never be selected as a winner again.
### Per-film training breakdown
The 75.3% LVFace training figure is a macro average across 4 films, not a
uniform result:
| film | LVFace F1 | mbf F1 | r18 F1 | best model |
|---|---|---|---|---|
| Café Society | 68.1% | 62.2% | 60.1% | LVFace |
| Lord of War | 75.6% | 77.2% | 75.6% | mbf |
| Scarface | 71.5% | 68.6% | 64.1% | LVFace |
| Sound of Metal | 78.8% | 76.5% | 71.6% | LVFace |
LVFace does not win every training film. mbf scores higher on Lord of War
(77.2% vs 75.6%). LVFace's own training-film range is 68.1% to 78.8%, a
10.7pp spread, smaller than the 37pp spread seen on held-out films but real.
Reproduce with `scripts/docs/run_holdout_all_models.py --films training`.
## Held-out validation, all 3 models
The training matrix above is training-set fit. Each model's own tuned
`full_exp` config was replayed against the 5 held-out films, scored the
same way:
| film | LVFace F1 | mbf F1 | r18 F1 |
|---|---|---|---|
| Benny & Joon | 83.0% | 78.5% | 77.1% |
| Lovelace | 77.5% | 73.7% | 72.2% |
| Valerian and the City of a Thousand Planets | 74.1% | 70.2% | 71.0% |
| Downton Abbey: A New Era | 56.2% | 55.0% | 53.0% |
| The Many Saints of Newark | 46.3% | 44.5% | 42.1% |
| **macro average** | **67.4%** | **64.4%** | **63.1%** |
LVFace scores highest on every one of the 5 held-out films; the ranking
never flips. Total misIDs across the 5 films: LVFace 1032, mbf 2197, r18
1224. LVFace has less than half mbf's misID count while also scoring
higher on every film. This directly confirms the model choice out of
sample; it is not inferred from the training numbers alone. See the
[LVFace deep dive](lvface-deep-dive-2026-07.md) for frame-level detail on where and
why LVFace still fails on the two worst films. Reproduce with
`scripts/docs/run_holdout_all_models.py`.
## Two effects in isolation: gallery scope and pose expansion
Averaging across the 3 compared models (r50 excluded) isolates each variable
from model choice.
**Gallery scope**, averaged over both expansion settings and all 3 models
(6 evaluations per row):
| scope | F1 | P | R | total misID |
|---|---|---|---|---|
| full | 71.1% | 89.6% | 59.6% | 1121 |
| restricted | 75.9% | 90.4% | 65.6% | 299 |
Restriction improves every metric at once. This is not a precision/recall
trade: +4.8pp F1, +6.0pp recall, and roughly a quarter the misIDs. Fewer
candidates in the matcher's search space means fewer opportunities for a
lookalike false match, and the recall gain shows this does not cost real
detections. Restriction is currently an offline optimizer technique, not a
runtime feature of the application; see
[Whole vs. cast-restricted gallery](gallery-scope-2026-07.md) for what building it
into the application would require.
**Pose expansion** (promoting a confidently identified track's novel-pose
views into a per-film gallery annex,
[`src/gallery/track_gallery.hpp`](https://REPOLINK/src/gallery/track_gallery.hpp)):
| scope | expansion | F1 | R | misID |
|---|---|---|---|---|
| full | off | 70.0% | 57.6% | 407 |
| full | on | 72.1% | 61.5% | 714 |
| restricted | off | 75.1% | 63.9% | 179 |
| restricted | on | 76.7% | 67.2% | 120 |
In restricted mode, expansion is a clean win: +1.6pp F1, +3.3pp recall,
misID drops. The annex only competes against the film's own roughly 15-actor
cast, so a new pose of a known actor is unlikely to be confused with someone
else. In full mode, expansion buys +2.1pp F1 and +3.9pp recall but at a real
cost: misID rises from 407 to 714 as the same new-pose view now competes
against the full 2418-actor gallery, where a confidently learned pose is more
likely to match the wrong person. On the full gallery it is a recall-vs-misID
trade, not a free gain. This training-set effect
did not reproduce on held-out data; see
[Does pose expansion help?](pose-expansion-2026-07.md) for the full held-out test
and the two methodology bugs caught while checking it.
## Calibration curves
Each gallery carries a fitted Platt sigmoid `P(match | sim) = σ(a·sim + b)`,
stored directly in the gallery HDF5
([`src/gallery/gallery_calibration.hpp`](https://REPOLINK/src/gallery/gallery_calibration.hpp)).
This measures discriminative power independent of whatever
`prob_threshold` a given run used:
![Calibrated P(match|similarity) for all four models](assets/images/calibration_curves.png)
LVFace has the steepest curve (`a=17.7` vs 15.3-16.2 for the ArcFace
variants) and the lowest P=0.5 decision boundary (similarity 0.23 vs
0.27-0.31), separating same-actor from different-actor pairs more
confidently at a lower similarity than any ArcFace variant tested,
including r50. Generated by
[`scripts/docs/calibration_chart.py`](https://REPOLINK/scripts/docs/calibration_chart.py).
## Extinction and anneal window search
Every one of the 512 DE evaluations for the winning LVFace `full_exp`
combo, plotted over the `prob_threshold` × `extinction_sec` plane:
![DE search landscape: 512 evaluations over prob_threshold × extinction_sec](assets/images/de_search_landscape.png)
Nearly everything scoring well sits at `extinction_sec` above 50, across a
wide range of thresholds. Short extinction windows are uniformly weaker:
under a strict threshold, there is no good configuration in that region of
the search space. The optimizer converged with `anneal_sec=59.2,
extinction_sec=59.2`, about 99% of the widened 60s bound, which raises an
open question not resolved in this round: does performance keep improving
past 60s, or does it plateau there. Not chased further this pass.
## Caveats
- r50's 4 combos used the older, narrower search bounds (1-30/1-15 instead
of 1-60/1-60) and are further confounded by its thinner gallery. Excluded
from all comparisons above except calibration.
- The shipped defaults use `full_exp` (75.3% training F1), not the
higher-scoring `restricted_exp` (78.3%), because cast restriction is not
a runtime feature of the application yet.
- `expand_gallery` is mode-dependent, not a free win. Averaged across models
on the full gallery it trades misIDs for recall (see the pose-expansion
table). For LVFace specifically, though, `full_exp` beats `full_noexp` on
every axis at once (F1 75.3 vs 72.3, precision 89.7 vs 88.3, recall 65.4 vs
61.8, misID 232 vs 244), so the shipped `full_exp` is a clean choice for
this model, not an F1-vs-safety trade. (An earlier version of this page
reported `full_noexp` at 72.4% with zero misIDs and higher precision, which
made it look like the safer option; that was the dropped-film artifact
described above, not a real property of the config.)
- Switching the default model is an operational change: any gallery built
from a different model's embeddings must be rebuilt before the new
default takes effect.
## Reproduce
```bash
# 4-film training matrix, all 4 models × 2 gallery modes × 2 expansion settings
bash experiments/run_rep4_subprocess.sh
# single combo
SAE_EXPAND=1 REPLAY_WORKERS=4 DE_WORKERS=2 python3 scripts/optimizer/optimize.py \
--manifest experiments/manifests/rep4_LVFace-B_Glint360K_full.json \
--gallery experiments/galleries/gallery_LVFace-B_Glint360K.h5 \
--params prob_threshold:0.5:0.999 anneal_sec:1:60 extinction_sec:1:60 \
--popsize 10 --maxiter 15 --trajectory traj.jsonl --out best.json
# held-out validation, all 3 models, 5 films
python3 scripts/docs/run_holdout_all_models.py --out docs_data/holdout_all_models.json
# per-film training breakdown, all 3 models, 4 films
python3 scripts/docs/run_holdout_all_models.py --films training --out docs_data/training_per_film.json
# gallery coverage per film
python3 scripts/docs/gallery_coverage_per_film.py --out docs_data/gallery_coverage_per_film.json
# regenerate this page's charts from experiments/ artifacts
python3 scripts/docs/experiment_charts.py --out-dir docs/assets/images
# one frame per distinct out-of-cast name across all 9 films (used in the deep dive)
python3 scripts/docs/first_fpi_frames.py
```
See also the session log
[`experiments/SESSION_STATE.md`](https://REPOLINK/experiments/SESSION_STATE.md).
+158 -306
View File
@@ -1,346 +1,198 @@
# Full experiment log
# Full experiment log (opencv5)
This page reports how the pipeline performs across three questions: which
embedding model is best, whether restricting the gallery to a film's
credited cast helps, and whether promoting confidently identified poses into
a per-film gallery annex helps. It also documents the replay architecture
that made testing all three questions in one pass practical, and every
caveat needed to trust the numbers.
This is the complete log behind the current opencv5 build: how the pipeline is
tuned, what the shipped configuration is and where every number in it comes from,
and how the learned scene-boundary detector took per-second actor-presence F1 from
the low-60s to **74.9%** across the nine-film Amazon X-Ray benchmark — under honest
leave-one-out.
Read [How we score against X-Ray](methodology.md) first for what F1,
precision, recall, and misID mean in this report. All numbers below use the
per-second metric
([`scripts/optimizer/second_score.py`](https://REPOLINK/scripts/optimizer/second_score.py)).
Read [How we score against X-Ray](methodology.md) first for what F1, precision,
recall, and misID mean here. Every number below uses the per-second metric
([`scripts/optimizer/second_score.py`](https://REPOLINK/scripts/optimizer/second_score.py)):
the film is sampled once per second, and at each second the set of names the
pipeline reports present is compared against Amazon X-Ray's scene cast for that
second. X-Ray's ground truth is scene-level; the pipeline's output is per-second.
That mismatch shapes every result.
r50 (ArcFace w600k-R50) is excluded from the detailed comparison below. Its
gallery was built with roughly 30% fewer reference images per actor than the
other three models on the identical source photos (10808 vs 15055 total
embeddings across the same 2418 actors), which confounds any direct
comparison of its scores against the others. It remains in the
[calibration curve comparison](best-model.md#first-signal-calibration-curves),
which does not depend on the training benchmark.
## The benchmark
Nine films with public Amazon X-Ray scene data, all scored with the same
LVFace-B Glint360K gallery:
Benny & Joon · Café Society · Downton Abbey: A New Era · Lord of War · Lovelace ·
The Many Saints of Newark · Scarface · Sound of Metal · Valerian.
Two of these — Café Society and Scarface — are low-contrast, uniformly-graded
films that break naive cut detection. They are deliberately kept in the benchmark
because they are where the interesting failures live.
## Why replay makes this affordable
Decoding video and running face detection, alignment, and embedding is the
expensive part of this pipeline. Everything downstream of that (tracking,
identity matching, scene aggregation) is cheap. KPN++'s node/network
structure means those two stages are separate components connected by
typed channels, so the expensive stage can run once per film, cache its
output, and the cheap stage can be re-run against that cache as many times
as needed with different Config values.
expensive part of the pipeline. Everything downstream — tracking, identity
matching, scene aggregation is cheap. KPN++'s node/network structure keeps those
two halves as separate components joined by typed channels, so the expensive half
runs once per film and caches its output, and the cheap half can be re-run against
that cache as often as needed with different `Config` values.
`scene_analyze --dump-embeddings out.h5` runs the expensive half once per
film and writes per-frame face detections and embeddings to HDF5
([`scripts/optimizer/SCHEMA.md`](https://REPOLINK/scripts/optimizer/SCHEMA.md)).
[`scripts/optimizer/replay.py`](https://REPOLINK/scripts/optimizer/replay.py)
then re-assembles the real C++ `face_tracker`, `identity_matcher`, and
`scene_tracker` nodes into a Python-driven KPN network and replays a
film's cached embeddings through them, varying `prob_threshold`,
`anneal_sec`, `extinction_sec`, and `expand_gallery` freely. No GPU
inference and no video decode happen during a replay; each one completes
in seconds. This is what makes a 512-evaluation differential-evolution
search per model, per gallery mode, per expansion setting, tractable, and
what made the full held-out validation across three models in this report
possible in one session rather than requiring three full re-encodes of the
benchmark set.
`scene_analyze --dump-embeddings out.h5` runs the expensive half once and writes
per-frame detections, embeddings, and (for the scene detector) per-frame RGB
histograms to HDF5. [`scripts/optimizer/replay.py`](https://REPOLINK/scripts/optimizer/replay.py)
re-assembles the real C++ `face_tracker`, `identity_matcher`, and scene nodes into
a Python-driven KPN network and replays a film's cache through them, varying every
tuning knob freely. No GPU inference and no video decode happen during a replay, so
a full differential-evolution search over all nine films is tractable in one
session rather than requiring re-encodes.
`optimize.py` runs `differential_evolution` over this replay function as its
objective, with DE-level parallelism (multiple candidate configs evaluated
concurrently, each spawning its own replay subprocesses) on top of it. The
practical ceiling on this machine's GPU was 8 concurrent replay processes;
9 silently degraded every score to 0.0% (well-formed output, wrong numbers,
not a crash), so `optimize.py` was run at `REPLAY_WORKERS=4 DE_WORKERS=2`.
Two concurrency limits are load-bearing and were paid for in wedged runs: replays
run at `DE_WORKERS=1` (concurrent DE candidates wedge the ROCm GPU), and each
candidate's per-film replays run at `REPLAY_WORKERS=8` with stderr discarded (the
replay sink's per-second prints otherwise flood the captured pipe and hang the
subprocess).
## Search space
## The tuning knobs
`popsize=10, maxiter=15` per combo (3 parameters, up to 512 evaluations,
usually stopping earlier on DE's convergence tolerance).
`anneal_sec`/`extinction_sec` bounds were widened from 1-30/1-15 to 1-60/1-60
partway through the sweep. r50's 4 combos finished before the widening and
used the old, narrower bounds; this is one more reason r50 is excluded from
direct comparison here.
The opencv5 refactor replaced the old three-knob search with a **ten-knob**
differential-evolution sweep. The knobs, and their shipped values:
## Training films and held-out films
| knob | shipped | what it controls |
| ---- | ------: | ---------------- |
| `prob_threshold` | 0.485 | posterior P(match) above which a track is named |
| `ownership_logodds` | 1.72 | log-odds a track needs before it produces presence |
| `track_extinction_sec` | 31.0 | how long an idle track is held for re-detection |
| `track_alpha` | 0.435 | tracker cost mix (0 = embedding only, 1 = spatial only) |
| `evidence_rho_max` | 0.204 | evidence weighting ceiling |
| `evidence_admit_below` | 0.784 | admit new evidence below this similarity |
| `match_prior` | 0.433 | base-rate prior on a match |
| `expand_band_lo` | 0.804 | low edge of the pose-expansion similarity band |
| `expand_band_hi` | 0.952 | high edge of the pose-expansion band |
| `presence_mode` | flood | track-extent vs scene flood-fill |
9 films have dumped embeddings across all 4 models. 4 were used for
optimization:
The DE run over the first nine knobs (flood off, track-extent presence) converged
at **64.0% macro F1** over 345 evaluations. Those values are the shipped
[`src/config.hpp`](https://REPOLINK/src/config.hpp) defaults.
- Café Society (62-cast)
- Lord of War (64-cast)
- Scarface (67-cast)
- Sound of Metal (14-cast)
![10-knob presence sweep (Differential Evolution)](assets/images/de_search_landscape.png)
5 were held out, never seen by any optimizer run:
The `track_extinction_sec` knob is worth calling out: at 31 s it holds an idle
track alive for re-detection long enough to bridge an actor turning away or leaving
frame briefly, without bridging across a genuine scene change. Getting this knob
and the tracker/registry to agree on **one clock** (the evidence watermark, not
wall-clock) was a correctness fix, not a tuning choice — before it, votes were
silently dropped at the reap horizon.
- Benny & Joon
- Downton Abbey: A New Era
- Lovelace
- The Many Saints of Newark
- Valerian and the City of a Thousand Planets
## The step change: flood-fill on learned boundaries
## Gallery coverage per film
The 64.0% above is track-extent presence: an actor is reported only while an actual
track is alive. **Flood-fill** instead reports an actor for the whole shot once
they are seen in it — but that is only correct if the shot boundaries are good.
The gallery has reference embeddings for 2418 actors, but coverage of any
given film's credited cast varies widely. This was previously reported as
one flat number (67% of X-Ray cast lacking a reference embedding, averaged
across the whole benchmark); the per-film breakdown is:
With the old grayscale cut detector as the boundary source, flood-fill barely beat
doing nothing (**64.0%**) and actively broke Scarface, where the detector fires
once in 10,204 frames and flood then smears every actor across the whole film
(precision collapses to 26%).
| film | cast credited | in gallery | coverage |
|---|---|---|---|
| Lord of War | 64 | 13 | 20.3% |
| Scarface | 67 | 15 | 22.4% |
| The Many Saints of Newark | 48 | 13 | 27.1% |
| Café Society | 62 | 17 | 27.4% |
| Lovelace | 42 | 15 | 35.7% |
| Valerian and the City of a Thousand Planets | 36 | 13 | 36.1% |
| Benny & Joon | 23 | 12 | 52.2% |
| Downton Abbey: A New Era | 36 | 22 | 61.1% |
| Sound of Metal | 14 | 11 | 78.6% |
The [learned scene-boundary detector](scene-boundary-detector.md) — an XGBoost
regressor over histogram-delta and audio features, with a per-film knee threshold —
fixes this. Macro per-second presence F1, at the shipped presence config:
Two training films (Lord of War, Scarface) have the worst coverage in the
set, 20-22%. Their training-set F1 numbers below are partly capped by
missing references, not purely by model quality. Downton Abbey has 61%
coverage, the second-best in the benchmark, yet the worst held-out recall
of any film (39.4%, LVFace). Its recall problem is not primarily a coverage
problem; it is the extinction-bridging failure documented in the
[LVFace deep dive](lvface-deep-dive.md#mechanism-1-extinction-bridging).
Reproduce with `scripts/docs/gallery_coverage_per_film.py`.
| boundary source for flood-fill | presence F1 |
| ------------------------------ | ----------: |
| track-extent (flood off) | 62.6% |
| flood + grayscale cuts | 64.0% |
| **flood + learned detector (LOO)** | **74.9%** |
## Training results, 3 models × 2 gallery modes × 2 expansion settings
![Macro presence F1 by flood-fill boundary source](assets/images/scene_presence_macro.png)
Ranked by F1. misid = FPI_misid, the count of true wrong-actor
identifications (naming someone not in the film's cast at all), distinct
from FPI, which also includes in-cast timing slips.
The learned column is **leave-one-out**: each film is scored by a detector trained
on the other eight, so no film's presence is ever measured with a detector that saw
it. That is the honest generalisation number, +12.3 points over track-extent, and
**it improves every one of the nine films**.
Each combo's row is its best **full-coverage** evaluation: the highest-F1 DE
evaluation in which all 4 training films replayed without a timeout (see
[Dropped-film scoring](#a-scoring-bug-worth-recording-dropped-film-evaluations)
below for why this qualifier is load-bearing and not the same as `argmax F1`
over the raw sweep).
![Per-film presence F1 by boundary source](assets/images/scene_presence_by_source.png)
| combo | F1 | P | R | TPI | FPI | misid | FN |
|---|---|---|---|---|---|---|---|
| LVFace-B_Glint360K_restricted_exp | 78.3% | 91.0% | 68.9% | 42830 | 3782 | 60 | 19492 |
| LVFace-B_Glint360K_restricted_noexp | 76.7% | 91.5% | 66.2% | 41149 | 3400 | 59 | 21173 |
| arcface_w600k_mbf_restricted_exp | 76.2% | 90.0% | 66.2% | 64328 | 7480 | 0 | 33234 |
| arcface_r18_restricted_exp | 75.5% | 87.6% | 66.5% | 41399 | 5666 | 60 | 20923 |
| LVFace-B_Glint360K_full_exp | 75.3% | 89.7% | 65.4% | 47757 | 3407 | 232 | 26966 |
| arcface_w600k_mbf_restricted_noexp | 75.0% | 91.1% | 63.9% | 39752 | 3465 | 60 | 22570 |
| arcface_r18_restricted_noexp | 73.5% | 91.3% | 61.7% | 38299 | 3220 | 60 | 24023 |
| LVFace-B_Glint360K_full_noexp | 72.3% | 88.3% | 61.8% | 40363 | 3503 | 244 | 25850 |
| arcface_w600k_mbf_full_exp | 72.0% | 87.7% | 61.4% | 39875 | 3729 | 240 | 26338 |
| arcface_w600k_mbf_full_noexp | 71.0% | 93.2% | 57.9% | 41699 | 2472 | 56 | 33024 |
| arcface_r18_full_exp | 69.1% | 87.6% | 57.7% | 37342 | 3119 | 242 | 28871 |
| arcface_r18_full_noexp | 66.6% | 91.3% | 53.1% | 34314 | 2362 | 107 | 31899 |
| film | track-extent | flood+grayscale | flood+learned (LOO) |
| ---- | -----------: | --------------: | ------------------: |
| Benny & Joon | 77.3 | 80.2 | 78.2 |
| Café Society | 59.1 | 62.2 | 69.8 |
| Downton Abbey | 41.0 | 51.8 | **78.6** |
| Lord of War | 74.8 | 77.1 | 77.8 |
| Lovelace | 70.3 | 74.0 | 78.2 |
| The Many Saints of Newark | 37.5 | 43.9 | 53.4 |
| Scarface | 62.6 | **40.9** | **74.9** |
| Sound of Metal | 75.0 | 78.1 | 86.8 |
| Valerian | 65.6 | 67.7 | 76.2 |
![All combos ranked by training-set F1](assets/images/rep4_matrix_f1.png)
The two headline films — Scarface (grayscale flood *breaks* it, learned flood on a
film it never trained on takes it to 74.9%) and Downton Abbey (+37 points) — are
the strongest evidence the detector generalises. See the
[scene-boundary detector page](scene-boundary-detector.md) for the full story.
The two clearest patterns: every model's best-scoring combo uses the
restricted gallery, and LVFace leads within both gallery modes. `full_exp`
(the shipped combination) is the best-scoring option that uses only
features the running application currently supports; restriction is not
wired into the application yet (see
[Whole vs. cast-restricted gallery](gallery-scope.md)).
We re-ran the ten-knob DE on top of the good boundaries to check whether the
shipped config should change. It converged at 76.1% (+0.3 pp over the shipped
config on learned boundaries) — inside the noise, not worth re-shipping. The
boundaries, not the presence knobs, are where the win is.
### A scoring bug worth recording: dropped-film evaluations
## What the frames look like
The numbers above are corrected ones. The raw `rep4_best_*.json` files, and an
earlier version of this table, reported a different `arcface_w600k_mbf_full_noexp`
row: **74.2% F1 at TPI 12645**, a third the TPI of every sibling combo. That was
not a better config; it was an artifact of how the optimizer aggregates.
`scripts/optimizer/dump_error_frames.py` pulls representative seconds and draws
each face box against X-Ray's scene cast: **green** = true positive, **red** =
false positive (a name X-Ray does not credit to this scene — the real error),
**orange** = an unknown detection. Cast X-Ray lists as present but for whom no face
was detected — the structural false-negatives a face pipeline can never box — are
listed as a **blue** panel.
`optimize.py` builds each candidate's score from only the films whose replay
subprocess returned (`per_film = [m for m in ex.map(_one, films) if m is not
None]`), then **averages** F1/precision/recall and **sums** TPI/FPI/misID over
just those survivors. When a film's replay times out (the sweep ran near the
8-process concurrency ceiling, so this happened intermittently), that film
silently drops from both. A candidate whose hardest film timed out is therefore
scored on an easier subset, and differential evolution, maximizing that score,
will happily converge onto exactly such a candidate. For `mbf_full_noexp` the
reported winner was one of 7 evaluations (out of 512) whose TPI had collapsed to
a partial-film subset; its median-coverage evaluations sit around 51686 TPI.
![A correctly identified second: green true-positive boxes](assets/images/lovelace_perfect_second.jpg)
The fix here was to re-derive each combo's best row from its DE trajectory
(`experiments/trajectories/rep4_*.jsonl`), keeping only evaluations within 30% of
that combo's median TPI (full 4-film coverage) before taking the best F1. This
needs no re-running, the honest best configuration was already in the sweep,
just not the one `argmax F1` selected. Three combos moved: `mbf_full_noexp`
74.2% → **71.0%**, `LVFace_full_noexp` 72.4% → **72.3%** (and its misID, 0 → 244,
was itself a dropped-film artifact), `mbf_restricted_exp` 76.5% → **76.2%**. The
shipped LVFace `full_exp` winner was unaffected, its reported evaluation already
had full coverage (TPI 47757 ≈ median). `experiment_charts.py` applies the same
`clean_best` filter, so every figure on this page matches the corrected table.
The underlying `optimize.py` aggregation is also being fixed so a dropped-film
evaluation can never be selected as a winner again.
Every named frame in this documentation is regenerated against the current opencv5
pipeline by [`scripts/scene_detector/rematch_frames.py`](https://REPOLINK/scripts/scene_detector/rematch_frames.py),
which auto-matches each example by film, actor, and class (TP/FP) so the images
never drift from the shipped behaviour. Where the current pipeline no longer makes
a July-era error — the Zooey Deschanel misID in Many Saints is the clearest case —
the frame is dropped rather than staged, because the improvement is real.
### Per-film training breakdown
## The structural recall ceiling
The 75.3% LVFace training figure is a macro average across 4 films, not a
uniform result:
Precision against X-Ray is near-perfect on identified faces; recall is capped by
two things the pipeline cannot fix:
| film | LVFace F1 | mbf F1 | r18 F1 | best model |
|---|---|---|---|---|
| Café Society | 68.1% | 62.2% | 60.1% | LVFace |
| Lord of War | 75.6% | 77.2% | 75.6% | mbf |
| Scarface | 71.5% | 68.6% | 64.1% | LVFace |
| Sound of Metal | 78.8% | 76.5% | 71.6% | LVFace |
1. **X-Ray credits people whose faces never appear on screen** in a scene — voice,
back-of-head, or simply off-camera cast. No face pipeline can box a face that is
not there. These are the blue-panel names.
2. **Gallery coverage.** A large fraction of X-Ray cast has no reference image in
the gallery, so those actors can never be matched regardless of detection. This
is the dominant remaining recall limiter and is addressable by fetching more
reference photos, not by tuning.
LVFace does not win every training film. mbf scores higher on Lord of War
(77.2% vs 75.6%). LVFace's own training-film range is 68.1% to 78.8%, a
10.7pp spread, smaller than the 37pp spread seen on held-out films but real.
Reproduce with `scripts/docs/run_holdout_all_models.py --films training`.
Both are documented in [how we score against X-Ray](methodology.md).
## Held-out validation, all 3 models
## In the pipeline
The training matrix above is training-set fit. Each model's own tuned
`full_exp` config was replayed against the 5 held-out films, scored the
same way:
| film | LVFace F1 | mbf F1 | r18 F1 |
|---|---|---|---|
| Benny & Joon | 83.0% | 78.5% | 77.1% |
| Lovelace | 77.5% | 73.7% | 72.2% |
| Valerian and the City of a Thousand Planets | 74.1% | 70.2% | 71.0% |
| Downton Abbey: A New Era | 56.2% | 55.0% | 53.0% |
| The Many Saints of Newark | 46.3% | 44.5% | 42.1% |
| **macro average** | **67.4%** | **64.4%** | **63.1%** |
LVFace scores highest on every one of the 5 held-out films; the ranking
never flips. Total misIDs across the 5 films: LVFace 1032, mbf 2197, r18
1224. LVFace has less than half mbf's misID count while also scoring
higher on every film. This directly confirms the model choice out of
sample; it is not inferred from the training numbers alone. See the
[LVFace deep dive](lvface-deep-dive.md) for frame-level detail on where and
why LVFace still fails on the two worst films. Reproduce with
`scripts/docs/run_holdout_all_models.py`.
## Two effects in isolation: gallery scope and pose expansion
Averaging across the 3 compared models (r50 excluded) isolates each variable
from model choice.
**Gallery scope**, averaged over both expansion settings and all 3 models
(6 evaluations per row):
| scope | F1 | P | R | total misID |
|---|---|---|---|---|
| full | 71.1% | 89.6% | 59.6% | 1121 |
| restricted | 75.9% | 90.4% | 65.6% | 299 |
Restriction improves every metric at once. This is not a precision/recall
trade: +4.8pp F1, +6.0pp recall, and roughly a quarter the misIDs. Fewer
candidates in the matcher's search space means fewer opportunities for a
lookalike false match, and the recall gain shows this does not cost real
detections. Restriction is currently an offline optimizer technique, not a
runtime feature of the application; see
[Whole vs. cast-restricted gallery](gallery-scope.md) for what building it
into the application would require.
**Pose expansion** (promoting a confidently identified track's novel-pose
views into a per-film gallery annex,
[`src/gallery/track_gallery.hpp`](https://REPOLINK/src/gallery/track_gallery.hpp)):
| scope | expansion | F1 | R | misID |
|---|---|---|---|---|
| full | off | 70.0% | 57.6% | 407 |
| full | on | 72.1% | 61.5% | 714 |
| restricted | off | 75.1% | 63.9% | 179 |
| restricted | on | 76.7% | 67.2% | 120 |
In restricted mode, expansion is a clean win: +1.6pp F1, +3.3pp recall,
misID drops. The annex only competes against the film's own roughly 15-actor
cast, so a new pose of a known actor is unlikely to be confused with someone
else. In full mode, expansion buys +2.1pp F1 and +3.9pp recall but at a real
cost: misID rises from 407 to 714 as the same new-pose view now competes
against the full 2418-actor gallery, where a confidently learned pose is more
likely to match the wrong person. On the full gallery it is a recall-vs-misID
trade, not a free gain. This training-set effect
did not reproduce on held-out data; see
[Does pose expansion help?](pose-expansion.md) for the full held-out test
and the two methodology bugs caught while checking it.
## Calibration curves
Each gallery carries a fitted Platt sigmoid `P(match | sim) = σ(a·sim + b)`,
stored directly in the gallery HDF5
([`src/gallery/gallery_calibration.hpp`](https://REPOLINK/src/gallery/gallery_calibration.hpp)).
This measures discriminative power independent of whatever
`prob_threshold` a given run used:
![Calibrated P(match|similarity) for all four models](assets/images/calibration_curves.png)
LVFace has the steepest curve (`a=17.7` vs 15.3-16.2 for the ArcFace
variants) and the lowest P=0.5 decision boundary (similarity 0.23 vs
0.27-0.31), separating same-actor from different-actor pairs more
confidently at a lower similarity than any ArcFace variant tested,
including r50. Generated by
[`scripts/docs/calibration_chart.py`](https://REPOLINK/scripts/docs/calibration_chart.py).
## Extinction and anneal window search
Every one of the 512 DE evaluations for the winning LVFace `full_exp`
combo, plotted over the `prob_threshold` × `extinction_sec` plane:
![DE search landscape: 512 evaluations over prob_threshold × extinction_sec](assets/images/de_search_landscape.png)
Nearly everything scoring well sits at `extinction_sec` above 50, across a
wide range of thresholds. Short extinction windows are uniformly weaker:
under a strict threshold, there is no good configuration in that region of
the search space. The optimizer converged with `anneal_sec=59.2,
extinction_sec=59.2`, about 99% of the widened 60s bound, which raises an
open question not resolved in this round: does performance keep improving
past 60s, or does it plateau there. Not chased further this pass.
## Caveats
- r50's 4 combos used the older, narrower search bounds (1-30/1-15 instead
of 1-60/1-60) and are further confounded by its thinner gallery. Excluded
from all comparisons above except calibration.
- The shipped defaults use `full_exp` (75.3% training F1), not the
higher-scoring `restricted_exp` (78.3%), because cast restriction is not
a runtime feature of the application yet.
- `expand_gallery` is mode-dependent, not a free win. Averaged across models
on the full gallery it trades misIDs for recall (see the pose-expansion
table). For LVFace specifically, though, `full_exp` beats `full_noexp` on
every axis at once (F1 75.3 vs 72.3, precision 89.7 vs 88.3, recall 65.4 vs
61.8, misID 232 vs 244), so the shipped `full_exp` is a clean choice for
this model, not an F1-vs-safety trade. (An earlier version of this page
reported `full_noexp` at 72.4% with zero misIDs and higher precision, which
made it look like the safer option; that was the dropped-film artifact
described above, not a real property of the config.)
- Switching the default model is an operational change: any gallery built
from a different model's embeddings must be rebuilt before the new
default takes effect.
## Reproduce
The learned detector runs live inside `scene_analyze` as a post-EOF step (the
per-film knee needs every peak, so it can only run once the whole film is seen).
XGBoost inference is built into the binary via CMake (`SAE_SCENE_XGB`); the audio
log-PSD uses FFTW on the existing FFmpeg decode. The shipped model is trained on
the **C++-extracted** features so training and inference share one implementation.
Verified end to end through `scene_analyze` on a movie file and through the Jellyfin
work-queue worker.
```bash
# 4-film training matrix, all 4 models × 2 gallery modes × 2 expansion settings
bash experiments/run_rep4_subprocess.sh
# single combo
SAE_EXPAND=1 REPLAY_WORKERS=4 DE_WORKERS=2 python3 scripts/optimizer/optimize.py \
--manifest experiments/manifests/rep4_LVFace-B_Glint360K_full.json \
--gallery experiments/galleries/gallery_LVFace-B_Glint360K.h5 \
--params prob_threshold:0.5:0.999 anneal_sec:1:60 extinction_sec:1:60 \
--popsize 10 --maxiter 15 --trajectory traj.jsonl --out best.json
# held-out validation, all 3 models, 5 films
python3 scripts/docs/run_holdout_all_models.py --out docs_data/holdout_all_models.json
# per-film training breakdown, all 3 models, 4 films
python3 scripts/docs/run_holdout_all_models.py --films training --out docs_data/training_per_film.json
# gallery coverage per film
python3 scripts/docs/gallery_coverage_per_film.py --out docs_data/gallery_coverage_per_film.json
# regenerate this page's charts from experiments/ artifacts
python3 scripts/docs/experiment_charts.py --out-dir docs/assets/images
# one frame per distinct out-of-cast name across all 9 films (used in the deep dive)
python3 scripts/docs/first_fpi_frames.py
scene_analyze --movie <file> --gallery <gallery.h5> \
--scene-xgb-model models/scene_boundary_xgb.json
```
See also the session log
[`experiments/SESSION_STATE.md`](https://REPOLINK/experiments/SESSION_STATE.md).
## Reproducing the benchmarks
Gallery `.h5` files, embedding dumps, the X-Ray corpus, and DE trajectories are not
committed. They are pushed to the Gitea package registry and pulled on demand:
```bash
scripts/artifacts/pull_artifacts.sh galleries
scripts/artifacts/pull_artifacts.sh experiment-data
# per-second audio features, C++ feature matrices, train + downstream A/B
.venv-rocm/bin/python scripts/scene_detector/extract_audio_features.py \
--manifest experiments/manifests/films_LVFace_opencv5.json
.venv-rocm/bin/python scripts/scene_detector/train_xgb_cpp.py --train-all
scripts/scene_detector/downstream_presence.py
```
+84 -10
View File
@@ -243,7 +243,9 @@ Context crops opt-in behind `--dump-unidentified-crops`.
**Depends on:** nothing (AR-002, AR-011); AR-004 blocks AR-003.
- **AR-002** — `min_face_px` → 66, expressed in original resolution.
- **AR-002** — `min_face_px` stays **40** (VR-013 measured it end to end) but must
be expressed in original resolution rather than decoded-frame space. The value
is already right in `config.hpp`; the change is the coordinate space.
- **AR-011** — feed TransNetV2 at native rate; derive the dedup window from
source fps rather than the hardcoded `0.04 s`.
- **AR-004** — backpressure. `kMaxFaces` (`identity_matcher_node.hpp:133`)
@@ -253,20 +255,33 @@ Context crops opt-in behind `--dump-unidentified-crops`.
## AR-026, AR-027 — GEMM and scale
**Depends on:** nothing to start. The annex CPU loop
(`identity_matcher_node.hpp:159-162`) moves into the GEMM path.
**Depends on:** nothing to start. The annex CPU loop has moved into the GEMM
path: the annex is a contiguous matrix, promotions are appended to the engine's
resident gallery, and the CPU backend now requires OpenBLAS. What is left of
AR-026 is call site 3, the deferred pass — so the rest of AR-026 lands *with*
AR-020 rather than before it.
---
# Gallery
## GR-004 — Model binding
## GR-004 — Model binding — **DONE**
**Depends on:** nothing. **Startable immediately, highest value per line.**
**Depended on:** nothing. Landed before any measurement work, as intended.
Stamp embedder identity into the gallery at build; verify at load in
`scene_analyze`, `replay.py` and the optimizer. Mismatch is a hard error naming
both sides.
Stamp = model basename + SHA-256 of the ONNX, written as the `/embedder` group at
build time (`gallery_builder.cpp`, `sae_gallery.save_gallery_hdf5`) and verified
at load in `scene_analyze`, `scene_preview`, the `sae_kpn` matcher binding,
`replay.py`, `optimize.py` and `movienet_eval.py`. Mismatch is a hard error naming
both sides, with no bypass. Embedding dumps carry the same stamp, since a replay
has no live embedder to check against.
Unstamped legacy galleries **warn loudly and proceed** rather than failing:
unknown is not known-bad, and hard-failing every pre-existing gallery would turn
the check into something people disable. `--require-gallery-stamp` /
`SAE_REQUIRE_GALLERY_STAMP=1` promotes that to a hard error — measurement runs
should set it. `scripts/stamp_gallery.py` re-binds an existing gallery without
re-embedding, so the warning state is cheap to leave.
Cross-model similarities are meaningless but *look* plausible — this fails
silently and expensively, and it would corrupt every measurement taken during the
@@ -314,8 +329,67 @@ Windows carry belief and route; `extraction.*` gains `extinction_sec` and
## VR-005 — Minimum face size study
**Depends on:** nothing. Standalone Python, no C++ contact. Produces the measured
value replacing AR-002's 66 px estimate.
**Depends on:** nothing. Standalone Python, no C++ contact. **Done** — knee at
2432 px. It measures the embedder with alignment held perfect, so it bounds the
answer from below rather than setting it; AR-002's floor comes from **VR-013**,
which sweeps input resolution end to end and lands at 40 px.
## VR-013 — Cross-source identification probe
**Depends on:** `sae_embed` exposing `detect()`, `align_face()`, `embed_crop()`
and the gallery calibration — it drives the shipped C++ rather than reimplementing
it, which is what VR-005 could not do.
Gallery from one recording, probes from another, sweeping the probe's **input
resolution before the detector**, so detection and landmark regression degrade
with the frame. `experiments/xsource/`.
**Findings.** Holding 90% of the plateau needs ~50 px end to end against VR-005's
~22 px; `min_face_px` 40 is right and 32 would admit faces in the falling region.
FPI is 0.0% at every scale — resolution loss goes entirely to TBI, never to a
wrong name. The ceiling is **cross-view, not resolution**: everyone matches
themselves within a recording (0.550.85) and collapses across two (0.140.45),
and only the subject with frontal *gallery* references identified reliably — so
the lever is gallery pose coverage (`docs/pose-expansion.md`), not a better
landmark model. Averaging SCRFD's NMS-discarded landmark estimates lifts
cross-clip TPI 41% → 49% for one forward pass.
**Open.** Four identities and one shoot, so the shape is the result and the
absolute rates are not. Both clips hold all four people, so there is no
out-of-gallery class and the 10×-weighted out-of-cast misID is untested — holding
one identity out of the gallery would fix that.
## VR-014 — Audio-signature offset recovery
**Depends on:** `sae_audio` exposing `compute_signature()` and
`signature_from_mono()` — it drives the shipped C++, as VR-013 does, so the
thing measured is the thing that ships.
`scripts/validation/test_audio_offset.py` over
`tests/fixtures/audio/superhero_offset_200s.flac`: 200 s of public-domain film audio
(the same SuperHero clips the replay fixtures use), long enough for a 120 s
window to slide past the ±600-frame search cap. The slide itself is numpy here
on purpose — matching belongs to the consumer, so writing it out keeps this a
test of the signature rather than of somebody's matcher.
**Findings.** Alignment is a solved problem here: the offset is the nearest frame
in every in-cap trial, worst error **46 ms against a 500 ms budget**, and 46 ms is
the quantisation floor — offsets are whole 92.88 ms frames, so no correct answer
can be worse. The `runtime/2` anchor's factor of two holds through real trimmed
files, and out-of-cap offsets and unrelated content are both declined.
**The score is where the slack is, and it costs a tier rather than accuracy.** It
tracks sub-frame misalignment — 0.940.99 near a frame boundary, 0.690.73 at
half a frame — so two thirds of correct alignments miss the server's 0.85 `audio`
threshold and land in `loose`. UT-108 measures the fix rather than proposing one:
±1 frame of slack in the score returns all 40 to `audio` (min 0.906) with false
matches unmoved at 0.120.16, costing 81 ms of the budget. See
[`SPEC.md`](SPEC.md) IR-004 — the score is normative in the server spec, so the
change is theirs to make.
**Open.** One source, one language, one era of recording. The shape (offset exact,
score set by sub-frame phase) should hold generally, but the absolute scores are
this fixture's.
## VR-001 — Dump audit
@@ -1,3 +1,5 @@
> **Archived (July 2026).** This report covers the pre-opencv5 framework and the 4-model ArcFace/LVFace bake-off. It is superseded by the current [experiment log](model-bakeoff.md) for the opencv5 build. Kept for provenance; the numbers here are historical.
# Pose expansion: does promoting new poses mid-film help?
`expand_gallery`
@@ -12,7 +14,7 @@ in the same film, without touching the baked gallery.
Averaged across the 3 compared models (r50 excluded), on the 4 films used
for optimization. These are the corrected, full-coverage figures, see the
[dropped-film note](model-bakeoff.md#a-scoring-bug-worth-recording-dropped-film-evaluations)
[dropped-film note](model-bakeoff-2026-07.md#a-scoring-bug-worth-recording-dropped-film-evaluations)
in the experiment log for why an earlier version of this table overstated the
full-mode misID jump (209 → 864) that was itself partly a truncation artifact:
@@ -26,7 +28,7 @@ full-mode misID jump (209 → 864) that was itself partly a truncation artifact:
In restricted mode, expansion looks like a clean win: +1.6pp F1, +3.3pp
recall, lower misID. In full mode it looks like a recall-for-misID trade:
+2.1pp F1, +3.9pp recall, but misID rises from 407 to 714. See
[the full experiment log](model-bakeoff.md) for the per-model breakdown.
[the full experiment log](model-bakeoff-2026-07.md) for the per-model breakdown.
This asymmetry motivated the question below: does turning expansion on
change what gets recognized frame by frame, or is the aggregate F1 shift
coming from something else.
@@ -105,6 +107,6 @@ contribution, such as tagging which reference embedding won each match;
neither was in scope for this pass.
Do not treat the training-set exp/noexp numbers in
[the full experiment log](model-bakeoff.md) as proof that expansion changes
[the full experiment log](model-bakeoff-2026-07.md) as proof that expansion changes
real-world behavior in either direction. On the evidence gathered so far,
it does not move the needle enough to see.
+201 -60
View File
@@ -29,55 +29,60 @@ Status: `Done` · `In Progress` · `Planned` · `TBD` · `Withdrawn`
| ID | Requirement | Traces to | Priority | Status |
|---|---|---|---|---|
| AR-001 | Detect faces in sampled frames; emit bbox, confidence, 5-point landmarks in original pixel space | SR-002 | High | Done |
| AR-002 | Minimum face size 66×66 px, expressed in **original** resolution (decoupled from `dense_scale`) | SR-002 | High | Planned |
| AR-003 | No fixed per-frame face cap — crowd scenes must not lose background cast | SR-002 | Medium | Planned |
| AR-004 | Backpressure: unbounded faces/frame absorbed by slowing, never by dropping or throwing | SR-002 | High | Planned |
| AR-005 | Align to 112×112 via ArcFace 5-point similarity transform | SR-002 | High | Done |
| AR-002 | Minimum face size **40×40 px** (VR-013 measured end to end; VR-005's 32 px is an embedder-only upper bound), expressed in **original** resolution (decoupled from `dense_scale`) | SR-002 | High | **Done**`FaceDetectorFunc::drop_undersized()`. The threshold is divided by `bbox_upscale` rather than every box multiplied, which keeps the comparison on the detector's own numbers and means turning `dense_scale` on cannot silently raise the minimum face the pipeline accepts. Verified at the threshold and at `dense_scale` 0.5 (UT-002), and end to end on the fixture (IT-001) — the superhero dump's smallest side is *exactly* its recorded 32 px, so the filter is binding there rather than vacuously satisfied |
| AR-003 | No fixed per-frame face cap — crowd scenes must not lose background cast | SR-002 | Medium | **Done**`max_faces` defaults to 0 (no cap); the matcher batches through its GEMM buffer instead of throwing |
| AR-004 | Backpressure: unbounded faces/frame absorbed by slowing, never by dropping or throwing | SR-002 | High | **Mostly** — node outputs *park* on a full channel: the value is held, the worker released, and a channel space-callback resumes the node. Replaces `push_blocking`, which parked a scheduler worker inside the push and, with one thread per node, stopped that node draining its own input. Verified: 385/385 frames, 0 drops. Holes closed since, in the order they surfaced: **(a)** `FanoutNode` dropped on overflow rather than waiting, so the AR-010 scene join shed frames exactly when the dense branch fell behind — measured at **9 of 2192 items delivered** to the slower of two branches, now lossless with the fast branch throttled to within its buffering; **(b)** the residual hang, recorded as ~1 run in 20 at a 300 s timeout, was a **startup** lost wake, not a mid-stream one — `start()` enables a node's inputs several statements before it installs the push callback, and a producer firing into that gap is accepted by the ring while waking nobody, since `Channel::push` signals only the empty→non-empty edge. Signature is zero items delivered, never a partial stall. Reproduced 7 times in 24 under CPU contention and 0 in 10 without; `start()` now closes with the level-triggered `on_input_ready()`, giving 0 in 24 on the same harness — though the *cause* was narrower than recorded there and is fixed properly in **(e)**; **(c)** `FilterNode` and `RouterNode` were the last data paths still using the throwing `push()` with the exception swallowed, so a full output discarded the value — including the **EOF sentinel**. The decimator passes EOF by predicate (`if (f.eof) return true;`) but its output is reliably full, the embedder being the slowest node in the chain, so the token was discarded, nothing downstream ever shut down, and the run had to be killed. **This is the wedge.** Both now route sentinels out-of-band and retry data until taken; the regression case delivers 6 of 40 values and never sets `saw_eof` before, 40 and terminating after; **(d)** the sentinel could be delivered *ahead of* a value still queued behind it — `pop()` observed the ring empty and then took the sentinel, and a producer can push a value *and* publish the sentinel inside that window, so any consumer treating EOF as a hard stop loses the tail. `take_sentinel` now re-checks emptiness *after* observing `has_eof_`, which is sound because the sentinel is published with a release store after the ring pushes. ~1 run in 15 before, 0 in 25 after; **(e)** two `fire_once` invocations for one node could overlap, because the submit gate was released before the firing had finished touching node state. That breaks the one-slot park the whole scheme rests on — a parked value can be overwritten by the other firing, with no drop recorded anywhere. ThreadSanitizer caught it as a race on `pending_done_`; the release is now the last act of a firing. The same sweep found the callbacks themselves being written while a running neighbour read them (ten TSan races), which is the *actual* cause of the startup lost wake in **(b)** — callbacks are now installed in a `prepare()` pass before any node starts. **New constraint:** a channel carries at most **one undelivered sentinel**; a second offered before the first is taken is refused and reported, never queued and never overwritten, since two control tokens on one channel means the stream ended twice. Single-shot EOF today, live the moment a pipeline is reused for a second input. **Consequence to hold onto:** a lossless fanout makes join depth a correctness precondition — one branch can now run ahead of another only by the slower branch's buffering, so `kSceneJoinDepth` must exceed the TransNetV2 window. Making the decimator lossless also makes it a backpressure point rather than a relief valve: the source now throttles to the face branch instead of quietly thinning it. Correct under this requirement, but it changes the shape of a loaded run and is **not yet benchmarked**. **Gap:** capacity is still counted in *items*, not bytes, so a crowd frame carrying 60 crops occupies one slot exactly as an empty one does — the memory ceiling the plan asks for is unenforced |
| AR-005 | Align to 112×112 via ArcFace 5-point similarity transform, fitted by **Umeyama least squares over all five points** (as InsightFace does) — never a robust fit, which would discard the landmarks AR-030 reads | SR-002 | High | **Done**`umeyama_similarity()`. The RANSAC fit it replaces disagreed by a median 17 source px on 400 headshots, 83.5% of crops embedding below cos 0.99, and was unstable and RNG-driven: rebuilding caught 1614 near-duplicates against the original build's ~100. **All galleries rebuilt** (2456 actors, 10254 embeddings); measured separation gain is small (0.583 → 0.590), so recorded accuracy figures should be re-run but are not expected to move far |
| AR-006 | 512-d L2-normalised embeddings, batched | SR-002 | High | Done |
| AR-007 | Associate detections by IoU + embedding, with **frame-dependent** weighting | SR-002 | High | In Progress |
| AR-008 | One track pool keyed on `last_seen`; no separate revival path | SR-002 | High | Planned |
| AR-007 | Associate detections by IoU + embedding, with **frame-dependent** weighting | SR-002 | High | **Done**`track_alpha` is the base for ordinary frames; drops to embedding-only on cut/boundary and for dormant tracks |
| AR-008 | One track pool keyed on `last_seen`; no separate revival path | SR-002 | High | **Done** — one pool keyed on `last_seen`; park/revive branch deleted |
| AR-009 | Camera-cut detection (histogram) as an association hint | SR-002 | High | Done |
| AR-010 | Scene-boundary detection (TransNetV2) as an association hint | SR-002 | Medium | In Progress |
| AR-011 | **Every model is fed the input it was trained for** — cost reduced by running less often, never by degrading one inference | SR-002 | High | Planned |
| AR-012 | Presence follows **track extent**, not per-frame recognition | **SR-002** | High | Planned |
| AR-013 | `last_seen` optional state machine; window ends at last sighting, never after | SR-002 | High | Planned |
| AR-014 | Belief swap A→B terminates the track and starts a new one | SR-002 | Medium | Planned |
| AR-015 | Two live tracks owned by one actor ⇒ treat as a detected cut, re-associate | SR-002 | Medium | Planned |
| AR-016 | All tracks closed at EOF — a film ends with faces on screen | SR-002 | High | Planned |
| AR-017 | Every presence claim carries its belief and identification route | SR-002 | High | Planned |
| AR-018 | Per-subject embedding store with banded admission (novel enough, safe enough) | SR-005 | Medium | Planned |
| AR-019 | Per-film gallery annex from owned tracks; acquires the non-frontal views TMDB lacks | SR-005 | Medium | In Progress |
| AR-010 | Scene-boundary detection (TransNetV2) as an association hint | SR-002 | Medium | **Done** — decode butterfly joined via `SceneBoundaries`; the sampled branch waits for the detector's watermark. Frames past its last scored window are counted as unverified, never assumed boundary-free |
| AR-011 | **Every model is fed the input it was trained for** — cost reduced by running less often, never by degrading one inference | SR-002 | High | **Done** — both violations SPEC.md named are closed. (1) `scene_decode_fps` defaults to 0 (native): at 12 fps a 100-frame `kWindow` spanned ~8.3 s instead of the ~4 s TransNetV2 was trained on, half-speed motion over twice its temporal context. (2) The boundary dedup window is derived from the cadence the detector was actually fed (`SceneDetectorFunc::dedup_window_sec()`, median observed interval, halved) rather than the literal 0.04 s — one frame at 25 fps, and at 30 fps wider than a frame, so two cuts on consecutive frames merged into one and the loss was invisible: the file simply had fewer boundaries. Derivation checked at 24/25/30 fps and under a seek (UT-003). **Consequence, not a gap:** `scene_threshold` 0.60 was fitted against the 12 fps input and is now certainly wrong — VR-006 re-fits it, and until then boundary recall at native rate is untuned rather than better. Dense decode is the cost driver, so this is not free; `dense_scale` and `scene_stride` remain the reductions that do not run the model off-distribution. **Half-applied until now:** the derived window reached `scenes.json` and nothing else. `SceneBoundaries` — the path that actually feeds `is_scene_boundary` to the tracker — kept the literal 0.04 s under a comment claiming the two views agreed. They did not. The detector now supplies the window it derived to both |
| AR-012 | Presence follows **track extent**, not per-frame recognition | **SR-002** | High | **Done**`src/track_registry.hpp`; window is `[first_seen, last_seen]` of an owned track |
| AR-013 | `last_seen` optional state machine; window ends at last sighting, never after | SR-002 | High | **Done**`last_seen` optional is the whole state machine; interior gaps absorbed, trailing cool-down never claimed |
| AR-014 | Belief swap A→B terminates the track and starts a new one | SR-002 | Medium | **Done** — swap closes at `last_seen` and opens a successor at the swap frame; counted |
| AR-015 | Two live tracks owned by one actor ⇒ treat as a detected cut, re-associate | SR-002 | Medium | **Done** — reverse index detects it on the causing update; counted |
| AR-016 | All tracks closed at EOF — a film ends with faces on screen | SR-002 | High | **Done**`flush()`, idempotent, closes at last sighting or final tick |
| AR-017 | Every presence claim carries its belief and identification route | SR-002 | High | **Done**`DeadTrack` carries belief, observation count, and now a `route` enum. The route was previously the literal string `"live"` written at serialisation time, so the published field could not distinguish anything and AR-017's own edge case ("deferred and pooled routes distinguishable") was unmeetable. Only `live` occurs until AR-020 lands; `deferred` exists so that pass has somewhere to write instead of a schema change to make |
| AR-018 | Per-subject embedding store with banded admission (novel enough, safe enough) | SR-005 | Medium | **Done** — banded admission in probability space, bounds from `expand_band_lo/hi`; the lower bound re-asked pairwise at promotion, since `admit` compares only against the nearest member and a drifting track can chain past it. Retires `expand_novelty_sim` and `expand_track_spread_max` — novelty is now the eviction ordering, not a threshold. Rejections counted. Bounds unswept (VR-007) |
| AR-019 | Per-film gallery annex from owned tracks; acquires the non-frontal views TMDB lacks | SR-005 | Medium | **Done** — all three discontinuity signals clear the buffers; ownership comes from the registry, not a second local tally. **Correction:** the local tally was still there and still deciding. Promotion fired on a local accepted-frame count and fell back to a local per-actor plurality whenever the registry had not yet claimed the track — which is the common case, since three accepted frames arrive well before a posterior crosses `ownership_logodds`. So in practice the plurality usually decided, and it could not see the AR-025 discounting it was supposed to defer to. Promotion now requires the registry's verdict; the accepted-frame count is an explicit evidence floor. `forget()`, which had no callers under a comment claiming the matcher called it, is replaced by `prune_dead` against the registry's own liveness |
| AR-020 | Deferred re-identification of unknown tracks against the final expanded gallery | SR-005 | High | Planned |
| AR-021 | Cluster unknown tracks into one entity per person, under temporal cannot-link constraints | SR-005 | Medium | Planned |
| AR-022 | Capture still-unidentified tracks: embeddings, metadata, **context crops** for human review | §4 | Medium | Planned |
| AR-023 | Fit sigmoid calibration from intra/inter similarity distributions | SR-002 | High | Done |
| AR-024 | **Always the calibrated probability, never a raw cosine** — exceptions recorded | SR-002 | High | Planned |
| AR-025 | Per-track Bayesian accumulation in log-odds, with correlated-observation discounting | SR-002 | High | Planned |
| AR-026 | All similarity computed as GEMM, including annex and deferred pass | SR-001 | High | In Progress |
| AR-023 | Fit sigmoid calibration from intra/inter similarity distributions | SR-002 | High | **Done** — and the meaning of "the fit failed" is now uniform. `valid=false` used to send the matcher to a raw-cosine accept rule while `same_person_probability` sent every other stage to the untuned default sigmoid: one run, two policies, no announcement. Both now take the default sigmoid and warn loudly that the probabilities are not meaningful |
| AR-024 | **Always the calibrated probability, never a raw cosine** — exceptions recorded | SR-002 | High | **Done** — association, accumulation and expansion all in probability space; `track_max_embed_dist`, `cut_revive_sim`, `expand_novelty_sim`, `expand_track_spread_max` retired. Enforcement now exists rather than being asserted: `scripts/ci/check_raw_cosine.py` blocks in CI. It immediately caught a live violation — the matcher's no-calibration fallback thresholded raw cosine distance **and fed `max(0, cosine)` into `TrackRegistry::observe`**, whose contract says in terms that it cannot be handed an uncalibrated number by a careless caller. `match_threshold`, `match_ratio` and `match_ratio_ceil` are retired with it, and `TrackGallery`'s `max(0, cosine)` default calibration is now a hard error. One exception recorded, in the calibration's own dedup |
| AR-025 | Per-track Bayesian accumulation in log-odds, with correlated-observation discounting | SR-002 | High | **Done** — log-odds accumulation with correlation discounting owned by the registry, `src/evidence_discount.hpp`. The four constants governing this — `ownership_logodds`, `rho_max`, `admit_below`, `max_views` — were unreachable in-class defaults until now; see VR-007 |
| AR-026 | All similarity computed as GEMM, including annex and deferred pass | SR-001 | High | **In Progress** — two of the three call sites done. Baked gallery was already GEMM; the annex now is too — it is a contiguous row-major matrix (`track_gallery.hpp`) whose promoted rows are appended to the engine's resident matrix (`ISimilarityEngine::append_rows`), so one multiply covers baked and promoted references and the host-side cosine loop is gone. CPU path requires OpenBLAS (scalar fallback now opt-in behind `SAE_ALLOW_SCALAR_GEMM`). Remaining: the deferred pass, which does not exist until AR-020 |
| AR-027 | Throughput acceptable for **arbitrary** gallery size | SR-001 | High | Planned |
| AR-028 | **Embedding input quality assessed and carried** — every face scored on size, sharpness and visibility before its embedding is used as identity evidence; the vector travels with the face and reaches the VR-001 dump | SR-002 | High | **Done** — filled in by `FaceAlignerFunc`, where both measured axes come free from the warp; carried on `DetectedFace` and written to the dump as `faces/sharpness` + `faces/alignment_residual`, taking it to `schema_version` 2. Size is `bbox`, not duplicated into a field that would drift. No face is admitted unscored (-1 sentinel), and the degenerate-fit case is now counted and reported rather than silently dropped. **Carried, not consumed** — no discount and no threshold, which is AR-030 and VR-012. Verified UT-137, UT-138 (aligner) and UT-139…UT-141 (dump round-trip, version, sentinel). The committed fixtures are still v1, so they carry no vector until `make_fixtures.sh` is re-run on a GPU host |
| AR-029 | Sharpness measure on the **aligned crop** (scale-normalised, so it cannot re-measure size) | SR-002 | Medium | **Done**`crop_sharpness()`: variance of the Laplacian over variance of the crop, so contrast cannot leak in the way it does for the raw textbook measure. Both blur ladders monotone, Gaussian and motion. Three properties recorded on the function for VR-012 rather than corrected here: the contrast invariance is exact in the algebra but bends at the 8-bit quantisation floor (a dim *and* soft crop reads sharper than it is — 148% high at σ 2.5), `BORDER_CONSTANT` fill from a frame-edge face adds a step edge, and the measure conflates focus with intrinsic texture. Verified UT-130…UT-136 |
| AR-030 | Visibility measure from the AR-001 5-point landmarks — extreme pose or occlusion **discounts the observation, never deletes the detection** | SR-002 | Medium | **In Progress** — measure is the AR-005 alignment residual (`estimate_alignment()`), carried on `DetectedFace`; roll/scale invariance and monotonicity under foreshortening asserted. Nothing consumes it as a discount yet |
## Deployment (DP)
| ID | Requirement | Traces to | Priority | Status |
|---|---|---|---|---|
| DP-001 | One analysis core; modes are front-ends and must not fork pipeline logic | PR-004 | High | Done |
| DP-001 | One analysis core; modes are front-ends and must not fork pipeline logic | PR-004 | High | **Done, after a repair.** `scene_preview` had forked the construction sequence and then rotted: it built `FaceTrackerFunc{cfg}` against a signature that stopped existing with the AR-007/AR-008 redesign, so **it had not compiled since**, and it never wired registry claims into its sink. It now mirrors `main.cpp` exactly — matcher, then registry, then tracker. The lesson is that "must not fork" needs the build to notice; a front-end nothing compiles is a fork that rots in silence |
| DP-002 | Batch CLI over one title | PR-004 | High | Done |
| DP-003 | On-demand resident service with bounded, observable queue | PR-004 | Medium | Planned |
| DP-004 | Opportunistic/idle mode: external trigger, hard stop, implicit re-queue | PR-004 | Medium | Planned |
| DP-005 | Native installer, no Docker; Fedora + Arch | PR-004 | Medium | Planned |
| DP-006 | Background incremental gallery refresh on a timer | PR-003 | Medium | Planned |
| DP-007 | CI builder image, CPU-only, pinned by tag in the Gitea container registry | PR-004 | High | **Mostly** — image and publish script exist (`Dockerfile.builder-cpu`, `scripts/ci/build_builder_image.sh`) and `.gitea/workflows/unit-tests.yml` now consumes it, pinned to `v1` and asserting at run time that the image reports that tag. **Gap:** the image is built and pushed by hand from an authenticated host; nothing rebuilds it on a change to the Dockerfile |
| DP-008 | Builder images + release jobs per backend (cpu / cuda / rocm); ship binaries, not engines | PR-004 | Medium | Planned |
## Integration (IR)
| ID | Requirement | Traces to | Priority | Status |
|---|---|---|---|---|
| IR-001 | Emit the JRay truth format as sibling `.jray.json` | SR-003 | High | Done |
| IR-002 | Windows carry belief + route; `extraction.*` carries `extinction_sec`, `gallery_scope` | SR-003 | High | Planned |
| IR-003 | Output written **after** the deferred pass, not at EOF | SR-003 | High | Planned |
| IR-004 | Compute the audio signature exactly per server spec §3 | SR-003 | Medium | Planned |
| IR-005 | Golden-vector fixture shared with the plugin repo to prove bit-exactness | SR-003 | High | Planned |
| IR-007 | Media < 120 s: emit no signature, apply no sync offset — identical rule in both producers | SR-003 | Low | Planned |
| IR-008 | Emit and honour the signature's own `v1:` version prefix | SR-003 | Low | Planned |
| IR-002 | Windows carry belief + route; `extraction.*` carries `extinction_sec`, `gallery_scope` | SR-003 | High | **Done**`schema_version: 2`; windows are objects with `belief` + `route`; `extraction.*` carries `extinction_sec` and `gallery_scope`; `anneal_sec` removed |
| IR-003 | Output written **after** the deferred pass, not at EOF | SR-003 | High | **In Progress** — sink builds windows from registry claims and flushes at EOF; the deferred pass (AR-020) does not exist yet, so output is still final at EOF |
| IR-004 | Compute the audio signature exactly per server spec §3 | SR-003 | Medium | **Done**`src/audio_signature.*`; not yet emitted into the truth file (IR-002). One real defect found and fixed since: the resampler's `AVChannelLayout`s were not zero-initialised, and `av_channel_layout_copy` uninitialises its destination first, so `av_freep` was handed stack garbage. It aborted about 1 run in 4 of UT-103 — invisible in the aggregate test binary, where the case usually passes, and absent under a sanitizer build because it is stack-dependent. `ctest`, one process per case, is what turned it into a reproducible failure |
| IR-005 | Golden-vector fixture shared with the plugin repo to prove bit-exactness | SR-003 | High | **Done**`tests/fixtures/audio/`; v1 parameters now normative in server spec §3 |
| IR-007 | Media < 120 s: emit no signature, apply no sync offset — identical rule in both producers | SR-003 | Low | **Done** |
| IR-008 | Emit and honour the signature's own `v1:` version prefix | SR-003 | Low | **Done** |
| IR-006 | Jellyfin round-trip: pull pending queue, push complete results only | SR-001 | High | Done |
## Gallery (GR)
@@ -86,8 +91,8 @@ Status: `Done` · `In Progress` · `Planned` · `TBD` · `Withdrawn`
|---|---|---|---|---|
| GR-001 | Build gallery from Jellyfin library cast, TMDB profile fallback | SR-001, SR-005 | High | Done |
| GR-002 | Incremental `--merge` refresh without re-embedding known actors | PR-003 | High | Done |
| GR-003 | Report coverage: zero-image actors, under-referenced actors, dedup, calibration PDFs | SR-001 | Medium | Planned |
| GR-004 | Stamp embedder identity into the gallery; **hard startup error** on mismatch | SR-001 | High | Planned |
| GR-003 | Report coverage: zero-image actors, under-referenced actors, dedup, calibration PDFs | SR-001 | Medium | **Done**`gallery/gallery_report.hpp`, written next to the gallery by `build_gallery`. Zero-usable-image actors come from the build audit, which a stored gallery cannot reconstruct; also `distinct_references`, `duplicates_removed`, and the intra/inter distributions the calibration fits and would otherwise discard |
| GR-004 | Stamp embedder identity into the gallery; **hard startup error** on mismatch | SR-001 | High | **Done** — basename + SHA-256 + `embed_dim`; mismatch fatal with no bypass, unstamped warns unless `--require-gallery-stamp`; `scripts/stamp_gallery.py` migrates in place |
| GR-005 | Gallery data never leaves the instance | **SR-005** | High | Done |
| GR-006 | Provenance tiers: baked / harvested / confirmed, distinguishable per embedding | SR-005 | High | Planned |
| GR-007 | Persist harvested embeddings **flagged and reviewable**, never silently equal to baked | SR-005 | Medium | Planned |
@@ -99,14 +104,22 @@ Status: `Done` · `In Progress` · `Planned` · `TBD` · `Withdrawn`
| ID | Requirement | Traces to | Priority | Status |
|---|---|---|---|---|
| VR-001 | HDF5 post-inference dump at the embedded-frame boundary | PR-002 | High | Done |
| VR-002 | Replay drives the **real** KPN nodes, not a reimplementation | PR-002 | High | Done |
| VR-002 | Replay drives the **real** KPN nodes, not a reimplementation | PR-002 | High | **Done** — including the sink, as of VR-011. Worth recording what the reimplementation was hiding: `build_minimal` rebuilt windows in Python from per-frame annotations, which never consult the registry, so it kept producing plausible output while registry-based presence in replay was returning **nothing at all**. The first run of the real chain emitted 0 actors on a film where 1647 frames carried an identified face. A reimplementation does not merely risk disagreeing with the pipeline; it can conceal the pipeline being broken |
| VR-003 | Scoring: micro-F1 against X-Ray, precision/recall logged at every evaluation | PR-002 | High | Done |
| VR-004 | Reproducible validation corpus with ground truth | PR-002 | High | Done |
| VR-005 | Minimum face size study — TPI/FPI vs probe size, gallery held at native res | PR-002 | Medium | Planned |
| VR-006 | Re-tune `scene_threshold` once native-rate decode lands | PR-002 | Low | Planned |
| VR-007 | Expansion band, clustering threshold, and deferred-pass ablation | PR-002 | Medium | Planned |
| VR-005 | Minimum face size study — TPI/FPI vs probe size, gallery held at native res | PR-002 | Medium | **Done** — knee at 2432 px; 32 px gives 98.1% TPI, 0.0 FPI at every size. Degrades an already-aligned 112×112 crop, so it isolates the embedder and is an **upper bound**; VR-013 measures the same question end to end and AR-002 takes its number, not this one |
| VR-006 | Re-tune `scene_threshold` once native-rate decode lands | PR-002 | **Medium** | **Planned, now unblocked** — native-rate decode landed with AR-011, so the prerequisite is met and the current 0.60 is a value fitted against input the pipeline no longer produces. Raised from Low for that reason: it is no longer a refinement, it is a stale constant |
| VR-007 | Expansion band, clustering threshold, deferred-pass ablation, **and the AR-025 accumulation knobs** | PR-002 | Medium | **Planned — scope corrected.** `rho_max`'s own comment already deferred to this row, and four constants it names were unreachable: `ownership_logodds` on `TrackRegistry::Config`, and `max_views`/`admit_below`/`rho_max` on `EvidenceDiscounter::Config`, which `main` built through the one-argument constructor. No sweep could vary them. They are in `Config` with CLI flags now, so this row can be run. `ownership_logodds` is the one to start with: below it a track makes **no presence claim at all**, so it decides whether an actor is reported rather than how confidently |
| VR-008 | Gallery scaling benchmark — throughput vs gallery size | PR-002 | Medium | Planned |
| VR-009 | Verify accumulated posteriors are calibrated against held-out tracks | PR-002 | High | Planned |
| VR-010 | Dump provenance attributes — embedder model, detector settings, `dense_scale`, `scene_detect`, sample rate | PR-002 | **High** | **Done**`DumpProvenance` in `embedding_dump_node.hpp`, written as root attributes and read back tolerantly. Every field is optional so a pre-VR-010 dump reads as *unknown* rather than as a default; a silently-defaulted `detector_conf` is exactly the fabricated provenance this exists to prevent. This row said `Planned` while five VR-010 tags sat in the code — stale in the opposite direction to the rest of this register |
| VR-011 | Rewrite the replay harness for the post-AR-012 output contract | PR-002 | High | **Done**`sae_kpn` compiles again and the replay drives the whole chain including `ResultSinkFunc`, so presence comes from `TrackRegistry` claims rather than being rebuilt in Python. The three per-node factories are replaced by one `add_pipeline` that mirrors `main.cpp`'s construction order — the ordering constraint (matcher fits the calibration, registry needs a discounter from it, tracker needs both, sink needs the claims) is what a factory-per-node API could not express, and is why the tracker factory kept building `FaceTrackerFunc{cfg}` against a signature that had stopped existing. `build_minimal` and `anneal_sec` are gone. Verified end to end on the SuperHero fixture: 5 actors, 32 windows, 0 dropped votes |
| VR-012 | Quality-knee study — TPI/FPI vs sharpness and vs pose, as VR-005 did for size; also settles whether the 5-point pose proxy needs a dedicated landmark model | PR-002 | Medium | Planned |
| VR-014 | Audio-signature **offset recovery on real content** — a known trim recovered from film audio, not from the synthetic golden tone | PR-002 | Medium | **Done** — 40 random in-cap offsets, every one recovered to the nearest frame: **worst error 46 ms against a 500 ms budget**, and 46 ms is the floor rather than a result, since the offset is quantised to whole 92.88 ms frames. The `runtime/2` anchor confirmed through real head-trimmed files (a `delta` trim moves the window by `delta/2`). The one soft spot is **tier labelling, not accuracy**: the score falls with sub-frame misalignment (0.940.99 near a frame boundary, 0.690.73 at half a frame), so 27/40 correct alignments were demoted to `loose`. ±1 frame of slack in the *score* fixes it — measured, all 40 back to `audio` (min 0.906), false matches unmoved at 0.120.16, costing 81 ms of the budget |
| VR-015 | Per-node cost and bottleneck attribution for a run — where the time actually goes | PR-004 | High | **Done**`--benchmark <path>` on `scene_analyze`; `src/benchmark.hpp`. Reports cumulative CPU and wall time per node, and locates the pacing node from sampled channel occupancy rather than from time-in-node, which backpressure inflates. Verified UT-120…UT-124 |
| VR-013 | Cross-source identification probe — gallery from one recording, probes from another, swept over input resolution end to end | PR-002 | Medium | **In Progress** — holding 90% of the plateau needs ~50 px end to end against VR-005's ~22 px, the gap being detection and landmark error; **`min_face_px` 40, since 32 admits faces in the falling region** (AR-002). FPI 0.0% at every scale. Ceiling is cross-view, not resolution |
| VR-016 | **Cut-detection cadence study** — is `cut_threshold` 0.70 meaningful at the rate `camera_pos` is actually fed? | PR-002 | Medium | **Planned.** The histogram cut detector is the one always-on signal with no recorded provenance, and its input rate is not the rate it was fitted at. With `--scene-detect` off, `camera_pos` sits downstream of a source already decimated to `sample_fps`, so at the 1.0 default it compares frames **one second apart** — inside a single shot those differ enormously, and 0.70 correlation is a low bar to clear. With `--scene-detect` on it sees native-rate frames instead, so the same constant means two different things depending on an unrelated flag. This is AR-011's argument ("every model gets the input it was trained for") applied to a non-neural detector, and it matters because `is_cut` drives `track_alpha` to 0 and clears every expansion buffer. Cheap first measurement: run `camera_pos` over a `hero/` clip at 1/2/5 fps and compare cut counts against `tests/fixtures/dumps/scene_bounds.json`. The committed 5 fps dump shows 2.6% of frames flagged; nobody has measured 1 fps |
| VR-017 | **Vote-lag study** — how often does the matcher fall more than `track_extinction_sec` behind the tracker on real content? | PR-002 | **High** | **Planned.** Channel depth is a correctness parameter between `face_tracker` and `identity_matcher`, and the constraint runs opposite to the scene join's: there `kSceneJoinDepth` must EXCEED the TransNetV2 window, here the depth must be UNDER `track_extinction_sec × sample_fps`. Backpressure is what makes it bite — it is working, and a lossless channel converts depth into lag by design. Both nodes are 16 deep in `main.cpp`, which at the default `sample_fps` 1.0 is ~16 s of lag against a 5 s window, so `scene_analyze` can drop identity votes and until now said nothing. It now reports `dropped_votes` at shutdown; this row is the measurement that decides whether that should be fatal, and whether the right fix is bounding the depth or removing the coupling (reap on the matcher's clock rather than the tracker's, so a vote cannot be late by construction) |
---
@@ -120,21 +133,62 @@ Four tiers, in decreasing order of preference:
| Tier | Runs in CI | What it covers |
|---|---|---|
| **T1 — CPU unit** | Yes | Pure logic: registry state machine, belief accumulation, clustering, band admission, calibration maths |
| **T2 — Replay** | Yes | Real pipeline nodes driven from an HDF5 fixture — no GPU, no video |
| **T1 — Functor unit** | Yes | A KPN node's `operator()` driven directly with hand-built inputs |
| **T2 — Replay** | Yes | The composed pipeline driven from an HDF5 fixture — no GPU, no video |
| **T3 — CPU inference** | Yes, slowly | ORT CPU provider over a handful of frames; smoke tests only |
| **T4 — GPU** | **No** | Throughput, TRT engines, large-gallery GEMM |
**T2 is the reason this is workable.** The HDF5 dump (VR-001) captures state
after decode → detect → align → embed and before tracking and matching, so
everything downstream — which is where nearly all of the new design lives — is
cheap CPU maths replayable from a fixture. Tracking, presence windows, belief
accumulation, expansion, deferred re-identification and clustering are all
verifiable on an N100 at full fidelity, not in miniature.
### T1 is the primary tier, and KPN is why
That was already true for the optimizer. It now doubles as the CI strategy, which
is a strong argument for keeping the dump schema honest (VR-001) and for the
replay driving the *real* nodes rather than a reimplementation (VR-002).
**Node functors are plain callable structs, constructed independently of the
network that wraps them** (`main.cpp:186-207` builds them as stack objects;
`ObjectNode` merely adapts them). So a node is testable by constructing it and
calling `operator()` — no channels, no threads, no network, no fixture.
This is already the established pattern, not a proposal:
`tests/test_face_tracker.cpp` "drives the node's `operator()` with hand-built
`EmbeddedSceneFrame`s and inspects the emitted `track_ids`", and does so
"pure, GPU-free, model-free".
The consequence is that most of the redesign is verifiable **without any
fixture at all**: construct exactly the awkward state — a belief swap, two live
tracks converging on one actor, a film ending mid-track, a gap one frame under
the timeout — rather than hunting for a clip that happens to exhibit it.
Four hazards this removes outright:
- **No fixture-provenance risk** for these tests — the inputs are synthetic and
explicit.
- **No "fixture must be replayed from frame 0"** concern — state is constructed
directly.
- **No cross-test state leakage** (e.g. a tracker's `next_id_` persisting) — each
test constructs a fresh functor.
- **No replay-harness nondeterminism** — no channels, so no EOF-tail heuristics
or silent drops.
It also means **a dead upstream producer does not block testing a downstream
consumer.** `is_scene_boundary` currently has no producer (see AR-010), which
would make a *replay* test of the frame-dependent `track_alpha` pass vacuously —
but a T1 test simply constructs a frame with `is_scene_boundary = true` and
asserts the weighting changes. The producer gap is a pipeline defect to fix, not
a verification blocker.
### T2 covers what T1 cannot
Replay remains necessary for **composition** — that the nodes wired together
behave as the sum of their parts — and for realistic data at scale, which
synthetic inputs cannot honestly imitate. It is the tier that would catch a
wiring error, a channel-capacity problem, or an ordering assumption that only
appears under concurrency.
The HDF5 dump (VR-001) captures state after decode → detect → align → embed, so
replay needs no GPU and no video. That was built for the optimizer; it doubles as
CI, which is a strong argument for keeping the schema honest and for replay
driving the *real* nodes rather than a reimplementation (VR-002).
**Fixtures and studies are generated locally**, on the development machine where
the models, galleries and media already exist. CI consumes them; it never
produces them.
**Small committed fixtures are required.** A few HDF5 dumps covering the awkward
cases — a cut, a belief swap, two live tracks converging, a film ending
@@ -162,12 +216,54 @@ as such rather than counted as covered.
| GR-001 … GR-005 | T1 + T3 | Gallery assembly is I/O and bookkeeping; embedding is T3 smoke |
| GR-006 … GR-008 | T1 | Tiering and outlier detection operate on stored embeddings |
| VR-* | Out of CI | Studies are run deliberately and their results committed as documents |
| VR-014 | **T2** | The exception, and the reason the blanket row above is not the whole story: its fixture is committed and its signature is CPU-only DSP, so the study *is* a test a CI host can run — not a measurement someone has to remember to repeat |
**One consequence worth stating:** AR-027 (arbitrary gallery scale) is
structurally unverifiable on the CI host. It needs a GPU host and a synthetic
large gallery, so it is the requirement most likely to silently regress. Its
benchmark (VR-008) should run on a schedule rather than on demand.
### CI never calls a model
**Not "should not" — cannot.** The N100 has no GPU, and even the ONNX Runtime CPU
provider is impractical: a measured run of the embedder on this hardware sits at
~930 ms per frame, so a 77 s clip at 5 fps would take roughly six minutes of
inference alone. Every model invocation therefore happens **locally, ahead of
time**, and CI consumes the result as data.
This is what makes the T1/T2 split load-bearing rather than a preference: T1 and
T2 are the only tiers that can exist in CI at all.
### Fixture corpus — `hero/`
Five clips of **SuperHero (1952)**, ~77 s each, 480×360, 30 fps, 42 MB total.
Public domain, and that is the reason to use it rather than a convenience:
**derived fixtures — dumps, crops, golden outputs — can be committed without the
rights question that rules out sharing gallery data (SR-005).** A fixture cut
from a copyrighted title could not live in the repository at all.
Two properties to design around rather than discover:
- **480×360 means small faces.** At this resolution a face is often 4080 px, so
the AR-002 minimum of 40 px (original resolution) sits at the very bottom of
that range: the filter is close to binding, and anything shot wider is lost.
Fixture generation must set `--min-face-px` explicitly and record it, or the
dumps will be sparse for reasons unrelated to what is being tested.
- **77 s is short.** At 1 fps that is 77 frames — too thin to exercise an
extinction window measured in tens of seconds. Generate at 5 fps (≈385 frames,
~1 MB) and record the rate in provenance, since the behaviour under test
changes with it.
> **AR-004 blocks reproducible fixture generation.** A trial run of one clip
> produced 49 frames of an expected ~385, ending at 51 s of 77 s, with the
> diagnostics reporting 285 frames dropped at `camera_pos` and 51 at
> `face_aligner`. Channels overflow and **drop** rather than blocking, and what
> gets dropped depends on timing — so the same command run twice can produce
> different dumps. Golden fixtures cannot be built on that. AR-004 is therefore
> a prerequisite for VR-001 fixtures, not merely a throughput concern for crowd
> scenes.
### Fixtures — precomputed inference, pulled by CI
The N100 cannot run inference at any useful rate, so **inference output is
@@ -176,16 +272,29 @@ what looks like GPU work into pure CPU replay.
| Fixture | Contents | Size | Storage |
|---|---|---|---|
| **Edge-case dumps** | ~6 short clips (3060 s), one per awkward behaviour | ~1 MB each | **Committed in-repo** |
| **Corpus dumps** | Full-length titles from the validation corpus | ~30 MB each | Pinned artifact, fetched by checksum |
| **Edge-case dumps** | ~6 short clips (3060 s), one per awkward behaviour | ~0.11 MB each | **Committed in-repo** |
| **Corpus dumps** | Full-length titles from the validation corpus | ~2138 MB each | **Gitea package registry**, pinned by version + checksum |
| **Synthetic gallery** | Random unit-norm embeddings, fixed seed | small | Generated at test time |
| **Golden truth files** | Expected output for each edge-case dump | KB | Committed |
| **Audio golden vectors** | Short WAV + expected signature | KB | Committed, **shared with the plugin repo** |
| **Audio golden vectors** | FLAC + expected signature + parameter contract | ~600 KB | Committed, **shared with the plugin repo** |
Edge-case dumps are small enough to commit, and being in-repo means they version
with the code that reads them. Corpus dumps are pulled by pinned checksum from
the artifact store rather than committed, since they are large and change only
when the dump schema does.
with the code that reads them.
**Corpus dumps go to the Gitea package registry, not Git LFS.** Both are
available — the models already use LFS — but their fetch semantics differ in a
way that matters here. LFS objects are pulled on clone unless a developer
explicitly skips them, so ~38 MB per title behind LFS taxes everyone who clones,
forever, for data that only CI and the optimizer ever read. Registry artifacts
are fetched on demand by the job that needs them.
Rule of thumb: **LFS for what the build needs; the package registry for what a
particular job needs.** Models are the former; corpus dumps and the CI image
(DP-007) are the latter.
Pin by version and verify by checksum on fetch. A fixture that changes silently
under CI is worse than a missing one, because the failure presents as a code
regression.
**Generation must be reproducible and versioned.** A script, run on a GPU host,
regenerates every fixture from source clips; it is re-run when the VR-001 schema
@@ -204,10 +313,10 @@ because it will be trusted.
| ID | Tier | Test asserts | Edge cases to cover |
|---|---|---|---|
| AR-001 | T3 | Detector returns plausible boxes on a known frame | — smoke only |
| AR-002 | T2 | Faces below 66 px (original res) are dropped | Exactly at threshold; with `dense_scale` 0.5 — the interaction that motivated the requirement |
| AR-002 | T2 | Faces below 40 px (original res) are dropped | Exactly at threshold; with `dense_scale` 0.5 — the interaction that motivated the requirement |
| AR-003 | T2 | No cap applied; a 40-face frame yields 40 | Crowd frame |
| AR-004 | T1 | Saturated input blocks rather than drops or throws | Bounded queue at capacity; **byte-based** limit with large crops; SIGTERM mid-block |
| AR-005 | T1 | Known landmarks → expected 112×112 warp | Landmarks near frame edge; degenerate/collinear points |
| AR-004 | T1 | Saturated input blocks rather than drops or throws | Bounded queue at capacity; **byte-based** limit with large crops; SIGTERM mid-block. Cases the KPN suite now pins, each of which failed before being written: a fanout feeding an unequal pair loses nothing *and* throttles the fast branch (either assertion alone passes on a broken implementation); a filter delivers EOF into a saturated output; a sentinel is never delivered ahead of a queued value; a twice-parked value keeps its payload; and a node started with data already in its input still fires — the startup lost wake, which needs no contention to reproduce once the state is constructed directly |
| AR-005 | T1 | Known landmarks → expected 112×112 warp; the fit never mirrors | Landmarks near frame edge; degenerate/collinear points; a mirrored set — SVD returns a reflection unless the determinant guard rejects it |
| AR-006 | T3 | Embeddings are unit-norm | Batch smaller than, equal to, larger than `embed_batch_size` |
| AR-007 | T2 | Association picks the right track | Two faces crossing paths; one leaving frame as another enters |
| AR-008 | T2 | One pool; dormant tracks match on embedding, not IoU | Dormant track whose old bbox overlaps a *different* new face — must not match on position |
@@ -218,21 +327,32 @@ because it will be trusted.
| AR-014 | T2 | Belief swap closes one window, opens another | No blended window; no overlap at the swap frame |
| AR-015 | T2 | Two live tracks on one actor trigger re-association | Counter increments |
| AR-016 | **T2** | Every track closed at EOF | Film ending mid-shot — window ends at final frame, not dropped |
| AR-017 | T1 | Claim carries posterior and route | Deferred and pooled routes distinguishable |
| AR-017 | T1 | Claim carries posterior and route | Deferred and pooled routes distinguishable — now possible: `route` is an enum on `DeadTrack` rather than the literal `"live"` the sink used to write. Only `live` occurs until AR-020 exists, so the test that matters today is that the field survives serialisation |
| AR-018 | T1 | Band admits only within bounds | At each bound exactly; store never admits below lower bound |
| AR-019 | T2 | Promotion only when all three signals quiet | Cut mid-track blocks promotion |
| AR-020 | **T2** | Unknown resolved after expansion | Track failing at minute 12, resolved at EOF — the ordering-independence claim |
| AR-021 | T2 | Clustering merges same person, respects cannot-link | **Temporally overlapping tracks never merge**; measure how many merges the constraint rejects |
| AR-022 | T1 | Context crops retained, bounded per track | Track running for minutes |
| AR-023 | T1 | Sigmoid fit on synthetic separable data | Too few positive pairs → `valid=false`, fallback engages |
| AR-024 | **Static check** | No bare cosine outside a tagged `EXCEPTION` | Grep-based; this is the invariant's enforcement |
| AR-023 | T1 | Sigmoid fit on synthetic separable data | Too few positive pairs → `valid=false`, and the fallback that engages is the **default sigmoid**, not the retired cosine rule. Assert the warning fires: an unfitted sigmoid returns plausible-looking probabilities, so nothing downstream can tell |
| AR-024 | **Static check** | No bare cosine outside a tagged `EXCEPTION` | `scripts/ci/check_raw_cosine.py`, blocking in the traceability workflow. Honest about its reach: it catches direct `cosine_similarity()` uses not routed through a calibration and **cannot follow a cosine through a variable across statements**, which is a convention backed by review rather than by the tool. Scans `src` only — a test legitimately asserts properties of the metric space, and sweeping those in would produce blanket exceptions that devalue the tag |
| AR-025 | T1 | Log-odds accumulate; correlated frames discounted | 30 identical frames must **not** reach the certainty of 30 diverse ones |
| AR-026 | T1 + T4 | GEMM path produces same result as reference loop | Equivalence on small input in CI; throughput on GPU host |
| AR-027 | **T4** | Throughput at 10²…10⁵ actors | Scheduled, not on-demand |
| AR-028 | **T2** | No embedding reaches the matcher unscored; the vector survives into the dump | Face failing exactly one axis; all three healthy; a face whose landmarks are degenerate — dropped for want of a crop to score, but **counted** rather than silently vanished (UT-138) |
| AR-029 | T1 | Synthetic blur ladder → monotonically falling sharpness | Gaussian vs motion blur; **small sharp face vs large soft one** — size must not leak into this axis. The blur ladder must be measured on a **1/f texture**: on a flat-spectrum one the motion ladder *rises*, since an anisotropic smear takes energy out of numerator and denominator together (UT-131). Contrast must not leak either — exact in the algebra, and the 8-bit floor that bends it is pinned by UT-133 |
| AR-030 | T1 | Alignment residual rises monotonically with foreshortening | **In-plane roll, scale and translation must leave it at zero** — the property that makes it a pose measure rather than a pose-and-everything-else measure; face size must not shift it; degenerate landmarks report not-ok rather than a number |
| VR-012 | **T4** | Knee located per axis on held-out films | Report each candidate threshold's cost in **lost true presence**, not only its gain in precision — a gate that improves misID by discarding half the cast has not helped |
| VR-013 | **T4** | Identification holds across two recordings of the same people, and degrades to TBI rather than to a wrong name as input resolution falls | Gallery and probes must come from *different* recordings — a hold-one-out over one recording measures a much easier problem and will not surface the cross-view failure. Ground truth is hand-sorted; labels propagated by embedding similarity would keep only the faces the embedder already gets right |
| VR-016 | **T2** | Cut rate as a function of the cadence `camera_pos` is fed | Same clip at 1/2/5 fps, `--scene-detect` on and off. The dump already records `cut_threshold` and `sample_fps` (VR-010), so a replay can score this without re-decoding. A finding of "0.70 is fine at every rate" is a real result and should be recorded as one |
| IR-001/002 | T1 | Serialised output matches golden file | Zero-length window; actor with many windows |
| IR-003 | T1 | Output written after deferred pass | Not at EOF |
| IR-004/005 | **T1** | Signature matches golden vector bit-for-bit | **Media < 120 s → no signature**; identical result in both repos |
| GR-004 | T1 | Mismatched embedder → hard startup error | Error names both sides |
| IR-004/005 | **T1** | Signature matches golden vector bit-for-bit | Identical result in both producer repos |
| VR-014 | **T2** | A known trim offset is recovered from **real film audio**, to the nearest frame | An offset past the ±600-frame cap and unrelated content must both be *declined*, never given a best-effort alignment. Fixture and signature are both CPU-only, so unlike the other VR rows this one is CI-*executable* — though the repo's only workflow today is the traceability gate, so nothing runs it there yet. The signature comes from the shipped C++ through `sae_audio`; a numpy port would be a third implementation nobody checks against the golden vector |
| IR-006 | T1 + manual | Queue pull and result push against a stubbed Jellyfin API | Partial result never pushed; push only after the deferred pass |
| IR-007 | **T1** | Media < 120 s emits no signature at all | Exactly 120 s; just under; zero-length audio. Must match the plugin's cutoff exactly — a caller-varying window length is what SR-004 forbids |
| IR-008 | T1 | `v1:` prefix emitted and honoured on read | Unknown prefix rejected, not guessed |
| GR-009 | T1 | Human-confirmed associations persist and are tier-tagged | Survives a gallery rebuild; distinguishable from baked and harvested |
| GR-004 | T1 | Mismatched embedder → hard startup error | Error names both sides; **unstamped warns, and errors under `SAE_REQUIRE_GALLERY_STAMP`**; same filename + different SHA-256 must still be a mismatch |
| GR-008 | T1 | Outlier flagged among an actor's references | Injected poisoned embedding detected |
| VR-009 | T1 | Posterior calibration holds | A 0.99 posterior is wrong ~1% of the time on held-out tracks |
@@ -252,8 +372,29 @@ accumulation from being decoration.
| — | `anneal_sec` window merging | Superseded by AR-012/AR-013: a track survives its own gaps, so there is nothing to anneal |
| — | `extinction_sec` actor keep-alive | Superseded by AR-013: windows end at last sighting, which is what this over-claimed |
Both were deleted rather than retained at zero — a field naming a mechanism the
pipeline no longer has is actively misleading (see `SPEC.md` A6.6).
Both are now deleted rather than retained at zero — a field naming a mechanism
the pipeline no longer has is actively misleading (see `SPEC.md` A6.6).
**This paragraph was false for some time, and the failure is worth keeping.** It
was written in the present perfect as though the removal had happened. It had
not: `Config::extinction_sec` (57.4) and `Config::anneal_sec` (35.5) were still
there, `--extinction` and `--anneal` still parsed, and `SceneTrackerFunc` still
ran its keep-alive in both shipped pipelines, printing its timeout at every
startup. `SPEC.md`'s removal list ends "grep for both names and expect no
survivors"; there were about forty.
Nothing in the tooling could have caught it. The traceability gate reads tags,
not behaviour, and a withdrawn requirement has no tag to be orphaned — the
register simply asserted a state of the code, and no test asked. The general
form is worth stating: **a status column is a claim, and the only claims this
project can check automatically are the ones a test or a static check makes.**
The same pattern produced three other rows corrected in this pass (AR-011,
AR-017, AR-019), each recorded as done and done in one place out of two.
`SceneTrackerFunc` is replaced by the stateless `FrameAnnotationFunc`. One
visible consequence: `--verbosity standard`'s `frames[].identified` used to
include every actor inside the keep-alive window, and now lists what was matched
in that frame. Minimal and xray output never consulted the node.
---
+205
View File
@@ -0,0 +1,205 @@
# The learned scene-boundary detector
Presence uses **flood-fill**: an actor seen once inside a shot is reported for the
whole shot (`[prev_boundary, next_boundary]`). That only works if the boundaries
are good. This page is the story of getting them good — a learned scene-boundary
detector that lifts per-second actor-presence F1 from **62.6% to 74.9%** across
the nine-film X-Ray benchmark, and fixes the film where naive flood-fill was
actively harmful.
That 74.9% is the **leave-one-out** figure: each film is scored by a detector
trained on the *other eight*, so no film's presence is measured with a detector
that ever saw it. It is the honest generalisation number, and it is only ~1 point
below the all-nine-trained model (75.8%) — the detector barely overfits.
## Why the old cut detector wasn't enough
The always-on boundary source was the grayscale histogram-correlation cut detector
(`camera_position_change_detector`): mark a cut when the frame-to-frame grayscale
histogram correlation drops below 0.70. It is cheap and it fires on obvious hard
cuts, but on a low-contrast, uniformly-graded film it is nearly blind. On
**Scarface** it fired **once in 10,204 frames**. Flood-fill then snapped every
actor across essentially the whole film:
| Scarface | precision | recall |
| -------- | --------- | ------ |
| flood + grayscale cuts | **26%** | 95% |
| track-extent (no flood) | 92% | 45% |
That single failure is what motivated everything below: flood-fill needs a
boundary source that works regardless of grade.
## What we are detecting, and why it is hard
The training target is **Amazon X-Ray scene boundaries** (`scenes.csv`). These are
*narrative* scenes — a new location or beat in the story — not shot cuts. There
are only ~2060 of them per film (median scene ~170 s), and many transition
*within* continuous visual style and continuous audio. So the signal is sparse and
often genuinely faint: a boundary detector working from audio-visual features can
never recall a narrative cut that has no audio-visual signature.
This shapes every result: absolute boundary-F1 is modest by construction. What
matters is the **downstream** number — does snapping flood-fill to these
boundaries name the right actors — and there the gain is large.
## The features (what worked, measured)
Everything is per second, aligned to the 1-fps presence grid.
- **Delta histograms, not raw histograms.** The raw RGB histogram encodes what a
frame *looks like*, not that it *changed* — measured boundary separability ~1.4×.
The **symmetric histogram delta** `|hist(t+k) hist(tk)|` separates boundaries
**45×**. Leading with deltas (k = 1,2,4,8 s) and dropping the raw histogram was
the single biggest feature win (LSTM F1 7.5% → 10.8%).
- **A multi-scale "ramp" bank.** Antisymmetric matched filters at half-widths
H = 2,4,6,8,10 s; the model weights the scales. Different films' boundaries peak
at different widths.
- **A time-since-last-boundary "debounce" clock**, scaled by the corpus mean scene
length (~205 s), encoding that scenes don't restart moments apart.
- **Audio log-PSD** (per-second, 4 s window, ~57 log-frequency bins). Measured
weak on its own — a standalone audio cutter scored only 36% held-out F1, because
narrative boundaries usually have continuous audio — but it is complementary on
the films where video is weak (Downton, Sound of Metal), so it is included and
the model uses it where it helps.
![Detector development at strict ±2 s tolerance, and where the shipped detector landed at the ±20 s tolerance the pipeline uses](assets/images/scene_detector_evolution.png)
The left panel is the *feature* development, scored at a strict ±2 s tolerance so
each change is visible — this is where "delta beats raw histogram" was measured, not
the shipped tolerance. The right panel is the shipped detector at the ±20 s
tolerance the pipeline actually uses (see below). The two panels are on different
tolerances by design and must not be read as one curve.
Dead ends, all measured and discarded: audio-only detection; raw
histograms/PSDs as input; a two-tower BiLSTM (no better than the tree, far slower);
larger FFT windows / more frequency bins (worse — boundaries are short events);
and TransNetV2 (a Conv3D net that will not co-reside with the ROCm/VAAPI stack).
## The model
- **XGBoost regressor** over a ±3 s window of the features above, predicting a
**soft Gaussian proximity-to-boundary target** (`exp(-(d/σ)²)`, σ = 10 s).
Regression to a soft target — rather than a hard 0/1 label — stops a near-miss
from being trained as a hard negative, and yields a smooth score whose **peaks**
are the boundaries.
- **Per-film knee threshold.** The predicted peak heights form a
convex-decreasing curve; the knee (max drop below the endpoints' chord) is where
real boundaries give way to noise. Selecting at the knee **self-calibrates the
boundary count** to roughly the true scene count, per film, with no global
threshold that would be wrong for every grade.
- **Trained on all nine films** for the shipped model. Keeping the low-contrast
grades (Café Society, Scarface) in training matters most: on its own training
films the shipped model reaches **72.9% macro boundary-F1** (per-film 5186%),
versus **29.8%** for the grayscale baseline on the same films.
Boundary detection, held out (leave-one-out, ±20 s tolerance — appropriate given
~170 s scenes): **44.1% macro F1, versus 29.8% for the grayscale baseline** — the
honest generalisation number, each film scored by a detector trained on the other
eight. Even the low-contrast grades generalise (Scarface held out 32%, Café Society
51%), where the grayscale detector scores 0% and 31%. The absolute number is capped
by the narrative-vs-audiovisual mismatch above — many boundaries have no
audio-visual signature at all — so the point is the downstream effect, below.
| boundary-F1 @±20 s | grayscale | learned (LOO) | learned (train-all) |
| ------------------ | --------: | ------------: | ------------------: |
| macro over 9 films | 29.8% | **44.1%** | 72.9% |
## The result that matters: actor presence
Per-second X-Ray presence F1, macro over the nine films, at the shipped presence
config. The learned column is **leave-one-out** — each film scored by a detector
trained on the other eight:
| boundary source for flood-fill | presence F1 |
| ------------------------------ | ----------- |
| track-extent (flood off) | 62.6% |
| flood + grayscale cuts | 64.0% |
| **flood + learned detector (LOO)** | **74.9%** |
![Macro presence F1 by flood-fill boundary source](assets/images/scene_presence_macro.png)
**+12.3 points over track-extent, +10.9 over the grayscale-cut flood, and it
improves every one of the nine films — under honest leave-one-out.** Per film:
![Per-film presence F1 by boundary source](assets/images/scene_presence_by_source.png)
| film | track-extent | flood+grayscale | flood+learned (LOO) |
| ---- | -----------: | --------------: | ------------------: |
| Benny & Joon | 77.3 | 80.2 | 78.2 |
| Café Society | 59.1 | 62.2 | 69.8 |
| Downton Abbey | 41.0 | 51.8 | **78.6** |
| Lord of War | 74.8 | 77.1 | 77.8 |
| Lovelace | 70.3 | 74.0 | 78.2 |
| The Many Saints of Newark | 37.5 | 43.9 | 53.4 |
| Scarface | 62.6 | **40.9** | **74.9** |
| Sound of Metal | 75.0 | 78.1 | 86.8 |
| Valerian | 65.6 | 67.7 | 76.2 |
The two headline cases:
- **Scarface**: the grayscale-cut flood *breaks* it (62.6 → 40.9), because it
detects one cut in the whole film. The learned detector — **on a film it never
trained on** — takes it to **74.9%**. This is the strongest evidence the
detector generalises: it fixes the exact failure that motivated it, held out.
- **Downton Abbey**: 41.0 (track-extent) → 51.8 (grayscale) → **78.6** — a
+37-point swing on the hardest film.
Naive flood-fill barely beat doing nothing (64% vs 62%) and broke a film. With a
real boundary detector, flood-fill is decisively the right mode.
### What the frames look like
`scripts/optimizer/dump_error_frames.py` pulls representative seconds and draws
each face box coloured against X-Ray's scene cast: **green** = true positive (a
name X-Ray also credits to this scene), **red** = false positive (a name X-Ray
does *not* credit here — the real error), **orange** = an unknown detection. Cast
X-Ray lists as present but for whom no face was detected — the structural
false-negatives a face pipeline can never box — are listed as a **blue** panel.
![A correctly identified second: green true-positive boxes](assets/images/scarface_tp_example.jpg)
Above: three faces named correctly (green). Below: the face-vs-scene-cast tension
made visual — the one visible face is confidently named (here it is a red
false-positive, a lead X-Ray did not credit to this exact scene), while six
credited cast members are off-camera with no face to detect (blue). This is why
recall against X-Ray has a structural ceiling, not a fixable bug.
![A false-positive box (red) with off-screen cast listed (blue)](assets/images/scarface_fn_fp_example.jpg)
## In the pipeline
Boundary detection is a **post-EOF step**, like flood-fill itself: the per-film
knee needs every peak, so it can only run once the whole film is seen. The
`camera_position_change_detector` stamps a per-frame RGB histogram onto each frame;
it rides through to the result sink; at end-of-stream the sink runs the detector
over the collected histograms plus the movie's audio log-PSD and snaps the
presence windows to the result. Enable it with:
```bash
scene_analyze --movie <file> --gallery <gallery.h5> \
--scene-xgb-model models/scene_boundary_xgb.json
```
Inference is real XGBoost, built into the binary via CMake (`SAE_SCENE_XGB`); the
audio log-PSD uses FFTW + the existing FFmpeg decode. To keep training and
inference on one feature implementation, the shipped model is **trained on the
C++-extracted features** (`scene_features_dump``train_xgb_cpp.py`) rather than a
re-implementation in Python — parity by construction. Verified end to end through
`scene_analyze` on a movie file and through the Jellyfin work-queue worker.
## Reproduce
```bash
# per-second audio log-PSD for each film
.venv-rocm/bin/python scripts/scene_detector/extract_audio_features.py \
--manifest experiments/manifests/films_LVFace_opencv5.json
# C++ feature matrices (same features training and inference share)
build/scene_features_dump <dump.h5> <movie> <features.h5>
# train the shipped model on all nine films
.venv-rocm/bin/python scripts/scene_detector/train_xgb_cpp.py --train-all
# downstream A/B (track-extent vs flood+grayscale vs flood+learned)
scripts/scene_detector/downstream_presence.py
```
+1230
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -11,6 +11,17 @@ manifests/
trajectories/
results/
# Cross-source identification study: source clips and the hand-sorted face
# crops. The sorting is human ground truth and expensive to redo, so it goes to
# the artifact registry rather than being regenerated — push it once sorted.
xsource/clips/
xsource/labelling/
xsource/frames/
xsource/cache/
xsource/results_*.json
xsource/failure_analysis.json
xsource/*.jpg
# Raw run logs and scratch scripts (regenerated by every run).
_scratch/
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Fresh LVFace-B embedding dumps (HDF5) for all 9 X-Ray films with the current
# feature/opencv5 build, for the flood-fill GA optimisation. Plain front-half
# (decode -> campos -> detect -> align -> embed); no scene detection (histogram
# cuts is_cut are baked in for flood-fill). Hardware VAAPI decode, no MIGraphX,
# no crash. Serial -- ROCm GPU wedges at concurrency>2-3.
set -uo pipefail
REPO="/home/dtourolle/Development/scene-actor-extraction"
cd "$REPO"
ARC="models/LVFace-B_Glint360K.onnx"
BIN="build/dump_embeddings"
LUT="experiments/file-lut.json"
FILMS="experiments/manifests/films.json"
OUT="experiments/dumps/LVFace-B_Glint360K_opencv5"
mkdir -p "$OUT"
# Persist MIOpen tuning so SCRFD/ArcFace kernel search is paid once, not per film.
export MIOPEN_USER_DB_PATH="$HOME/.cache/miopen-sae"
export MIOPEN_FIND_MODE=NORMAL
mkdir -p "$MIOPEN_USER_DB_PATH"
mapfile -t SLUGS < <(python3 -c 'import json;[print(f["slug"]) for f in json.load(open("'"$FILMS"'"))]')
echo "=== LVFace-B dumps (feature/opencv5) — $(date) ===" | tee "$OUT/dump.log"
for slug in "${SLUGS[@]}"; do
movie="$(python3 -c 'import json;print(json.load(open("'"$LUT"'"))["'"$slug"'"])')"
out="$OUT/dump_${slug}.h5"
echo "" | tee -a "$OUT/dump.log"
echo ">>> $slug" | tee -a "$OUT/dump.log"
if [ -f "$out" ]; then echo " exists, skip" | tee -a "$OUT/dump.log"; continue; fi
if [ ! -f "$movie" ]; then echo " SKIP missing: $movie" | tee -a "$OUT/dump.log"; continue; fi
# No --max-decode-fps cap: that cap existed only to stop LVFace dump truncation
# under PARALLEL load (3 concurrent dumps). This runner is serial, so the cap
# just halved throughput for nothing — measured 54s vs 27s per 300s of film,
# identical face counts. Uncapped ~9 min/film vs ~18 min capped.
"$BIN" --movie "$movie" --arcface "$ARC" --out "$out" --fps 1 \
>"$OUT/${slug}.log" 2>&1
rc=$?
if [ $rc -ne 0 ] || [ ! -f "$out" ]; then
echo " DUMP FAILED (rc=$rc) — see ${slug}.log" | tee -a "$OUT/dump.log"
else
stats=$(python3 -c 'import h5py,sys
f=h5py.File(sys.argv[1])
n=f["frames/timestamp_sec"].shape[0]
faces=f["faces/embedding"].shape[0]
cuts=int(f["frames/is_cut"][:].sum())
print(f"frames={n} faces={faces} cuts={cuts}")' "$out" 2>/dev/null)
echo " ok ($(du -h "$out" | cut -f1), $stats)" | tee -a "$OUT/dump.log"
fi
done
echo "" | tee -a "$OUT/dump.log"
echo "=== DONE — $(date) ===" | tee -a "$OUT/dump.log"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Regenerate annotated TP/FP/FN frame examples for ALL 9 films against the current
# opencv5 pipeline (learned-boundary flood, shipped config). Replays each film with
# --raw-out for bboxes, then dump_error_frames.py draws GT-aware boxes
# (green TP / red FP / orange unknown / blue FN panel). Frames land in
# experiments/dump_review/<slug>/ (regenerable; gitignored). Hand-pick the ones a
# doc needs from there.
set -uo pipefail
REPO="/home/dtourolle/Development/scene-actor-extraction"; cd "$REPO"
export MIOPEN_USER_DB_PATH="$HOME/.cache/miopen-sae"
GAL=experiments/galleries/gallery_LVFace-B_Glint360K.h5
LUT=experiments/file-lut.json
CFG=(--prob-threshold 0.485 --ownership-logodds 1.72 --track-extinction-sec 31
--track-alpha 0.435 --evidence-rho-max 0.204 --evidence-admit-below 0.784
--match-prior 0.433 --expand-band-lo 0.804 --expand-band-hi 0.952
--expand-gallery --presence-mode flood)
mapfile -t ROWS < <(python3 -c '
import json
for f in json.load(open("experiments/manifests/films_LVFace_opencv5.json")):
print(f["slug"]+"\t"+f["xray"])')
SP=/tmp/claude-1000/-home-dtourolle-Development-scene-actor-extraction/c579f8cf-2974-4cbd-be88-afec68dbbf58/scratchpad
for row in "${ROWS[@]}"; do
slug="${row%%$'\t'*}"; xray="${row#*$'\t'}"
movie="$(python3 -c "import json;print(json.load(open('$LUT'))['$slug'])")"
echo "=== $slug ==="
[ -f "experiments/dump_review/$slug/manifest.json" ] && { echo " exists, skip"; continue; }
# replay the learned-boundary (LOO) dump so frames reflect true generalization
dump="experiments/dumps/injected_loo/${slug}.h5"
[ -f "$dump" ] || dump="experiments/dumps/LVFace-B_Glint360K_opencv5/dump_${slug}.h5"
for try in 1 2 3; do
timeout 280 python scripts/optimizer/replay.py --dump "$dump" --gallery "$GAL" \
--out "$SP/${slug}_pred.json" --raw-out "$SP/${slug}_raw.jsonl" "${CFG[@]}" \
>"$SP/${slug}_replay.log" 2>&1 && break
echo " replay try $try failed, retrying"
done
[ -s "$SP/${slug}_raw.jsonl" ] || { echo " no raw output, skip"; continue; }
python3 scripts/optimizer/dump_error_frames.py \
--pred "$SP/${slug}_pred.json" --raw "$SP/${slug}_raw.jsonl" \
--xray "$xray" --movie "$movie" --gallery "$GAL" \
--out-dir "experiments/dump_review/$slug" --n-per-bucket 4 \
>"$SP/${slug}_frames.log" 2>&1
echo " $(grep -oE 'wrote [0-9]+ frames' "$SP/${slug}_frames.log" | tail -1)"
done
echo "=== DONE ==="
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Re-benchmark the feature/opencv5 pipeline against Amazon X-Ray, all 9 films, LVFace-B.
# Full end-to-end scene_analyze (decode→detect→scene→embed→match→presence) — NOT a replay,
# because the framework changed enough that old embedding dumps no longer represent the front half.
# Outputs land in experiments/results/xray_opencv5_lvface/ (durable; /tmp gets wiped).
set -uo pipefail
REPO="/home/dtourolle/Development/scene-actor-extraction"
cd "$REPO"
ARC="models/LVFace-B_Glint360K.onnx"
GAL="experiments/galleries/gallery_LVFace-B_Glint360K.h5"
OUT="experiments/results/xray_opencv5_lvface"
mkdir -p "$OUT"
BIN="build/scene_analyze"
LUT="experiments/file-lut.json"
FILMS="experiments/manifests/films.json"
# film slugs and their xray dirs, from films.json
mapfile -t ROWS < <(python3 -c '
import json
for f in json.load(open("'"$FILMS"'")):
print(f["slug"] + "\t" + f["xray"])
')
echo "=== X-Ray re-benchmark (feature/opencv5, LVFace-B) — $(date) ===" | tee "$OUT/run.log"
for row in "${ROWS[@]}"; do
slug="${row%%$'\t'*}"
xray="${row#*$'\t'}"
movie="$(python3 -c 'import json,sys; print(json.load(open("'"$LUT"'"))["'"$slug"'"])')"
pred="$OUT/${slug}.json"
echo "" | tee -a "$OUT/run.log"
echo ">>> $slug" | tee -a "$OUT/run.log"
if [ ! -f "$movie" ]; then
echo " SKIP: movie missing: $movie" | tee -a "$OUT/run.log"
continue
fi
# Run the full pipeline (serial — ROCm GPU wedges at concurrency>2-3).
"$BIN" --movie "$movie" --arcface "$ARC" --gallery "$GAL" \
--output "$pred" >"$OUT/${slug}.pipeline.log" 2>&1
rc=$?
if [ $rc -ne 0 ] || [ ! -f "$pred" ]; then
echo " PIPELINE FAILED (rc=$rc) — see ${slug}.pipeline.log" | tee -a "$OUT/run.log"
continue
fi
echo " pipeline ok" | tee -a "$OUT/run.log"
# Score against X-Ray, masked to gallery∩GT, 1s grid.
python scripts/validation/sample_eval.py \
--pred "$pred" --xray "$xray" --gallery "$GAL" --step 1.0 \
>"$OUT/${slug}.eval.txt" 2>&1
tail -8 "$OUT/${slug}.eval.txt" | tee -a "$OUT/run.log"
done
echo "" | tee -a "$OUT/run.log"
echo "=== DONE — $(date) ===" | tee -a "$OUT/run.log"
+92
View File
@@ -0,0 +1,92 @@
# xsource — cross-source identification probe (VR-013)
Gallery from **one** recording, probes from **another**, swept over the probe's
input resolution. Complements VR-005, which asked the same question over gallery
mugshots: that one degrades an already-aligned 112×112 crop, holding alignment
perfect, so it isolates the embedder. This one downscales the **whole frame**
before the detector, so detection and landmark regression degrade with it.
Corpus: two Pexels clips of one shoot (4096×2160, 25 fps), four people, all four
present in both.
## Getting the data
Clips, frames and hand-sorted crops are gitignored; they live in the artifact
registry.
scripts/artifacts/pull_artifacts.sh xsource # clips + labelling, frames regenerated
scripts/artifacts/push_artifacts.sh xsource # after correcting labels
Pulling fetches the two clips and the hand-sorted crops, then regenerates the
frames with ffmpeg — ~320 MB of PNG that is deterministic from the clips, so it
is not worth shipping. Extraction settings are pinned in the pull script because
the manifests key on frame filenames *and* on detection order within each frame;
`verify_labels.py` runs at the end and will fail loudly if they drift.
Pull never overwrites an existing `labelling/`. That directory is human ground
truth — somebody looked at all 167 crops and put each one in a folder — and it
is the expensive part of this study, so push it once corrected.
Clips are Pexels-licensed: free to use, no attribution required, but not
CC or MIT. Fine as a frozen CI artifact on private infrastructure; do not
redistribute them as stock content.
## Scripts
| script | does |
|---|---|
| `dump_faces.py` | detect every face, write a context crop per detection + a manifest |
| `redraw_boxes.py` | redraw those crops with the detection boxed, in place |
| `propose_labels.py` | propose labels for one clip from another clip's hand-sorted folders |
| `make_review_site.py` | local `review.html` — current label, crop, better match, correct and export |
| `apply_corrections.py` | apply the exported `corrections.json` |
| `verify_labels.py` | integrity gate: index consistency, duplicates, separation. Exits non-zero on failure |
| `resolution_sweep.py` | the VR-013 measurement |
| `failure_analysis.py` | what explains the misses — pose, size, blur, detector confidence |
| `landmark_voting.py` | average SCRFD's overlapping detections instead of discarding them |
| `pose_label.py` | mesh-estimated head pose, for hand correction (feeds VR-012) |
Everything drives the shipped C++ through `sae_embed`; nothing reimplements
detection, alignment, the embedder or the calibration. Scoring goes through the
production gallery sigmoid — never a raw cosine (AR-024).
LD_PRELOAD=/usr/lib/libcudnn_cnn.so.9 python3 resolution_sweep.py
The preload is needed while ORT's CUDA provider looks for
`cudnnGetConvolutionBackwardDataAlgorithm_v7`, which cuDNN 9 moved into
`libcudnn_cnn.so.9` behind a dispatch stub. Without it everything silently falls
back to CPU.
## What it found
**Resolution is not the binding constraint here.** TPI holds ~4147% from 4096×2160
down to ~45 px faces, then falls: 23 px → 26%, 18 px → 12%, 14 px → 1.5%. Holding
90% of the plateau needs roughly 50 px end to end, against VR-005's ~22 px — the
gap is detection and landmark error, which VR-005 excludes by construction.
**FPI is 0.0% at every scale.** Resolution loss goes entirely to TBI: the pipeline
stops naming people rather than naming the wrong one.
**The ceiling is cross-view, not resolution.** Every person matches themselves
strongly *within* a recording (sim 0.550.85) and collapses *across* the two
(0.140.45, threshold 0.335). Only the person with frontal **gallery** references
identified reliably, whatever their probe pose — so the lever is gallery pose
coverage (`docs/pose-expansion.md`), not a better landmark model.
**Landmark voting helps.** SCRFD predicts each face from several anchors and NMS
discards all but one, throwing away a median of 3 landmark estimates per face.
Averaging them, weighted by confidence, lifts cross-clip TPI 41% → 49% for one
forward pass and no extra model. A MediaPipe mesh as landmark source went the
other way (41% → 16%): more stable within a recording, but a ring centroid is not
the annotated landmark ArcFace was trained on, and the embedder punishes the
off-distribution crop.
## Reading these numbers
Four identities, 70 probes, one shoot. The ~47% plateau is pose, not resolution —
half these faces are turned away and never clear threshold at any scale, so the
absolute rates say little and the *shape* is the result. Both clips contain all
four people, so there is no out-of-gallery class and the 10×-weighted out-of-cast
misID is **untested** here; holding one identity out of the gallery would fix
that. And the resolution curve is dominated by the single subject whose gallery
references are frontal.
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Apply corrections.json exported from review.html.
python3 apply_corrections.py ~/Downloads/corrections.json [--dry-run]
Moves each crop to the folder you chose. "discard" goes to labelling/<clip>/discard/,
which the sweep ignores nothing is deleted, so a misclick is recoverable.
Refuses to move a file it cannot find exactly once, rather than guessing: a
half-applied correction set would put a crop in two folders and quietly
duplicate a label.
"""
import sys, json, glob, os, shutil
if len(sys.argv) < 2:
sys.exit(__doc__)
path = sys.argv[1]
DRY = "--dry-run" in sys.argv
corr = json.load(open(path))
if not corr:
sys.exit("no corrections in that file")
moved = skipped = 0
for fname, c in corr.items():
clip, to = c["clip"], c["to"]
hits = glob.glob(f"labelling/{clip}/**/{fname}", recursive=True)
if len(hits) != 1:
print(f"[skip] {fname}: found {len(hits)} copies, expected 1")
skipped += 1
continue
src = hits[0]
dst_dir = f"labelling/{clip}/{to}"
dst = f"{dst_dir}/{fname}"
if os.path.abspath(src) == os.path.abspath(dst):
continue
print(f"{'would move' if DRY else 'move'} {c['from']} -> {to}: {fname}")
if not DRY:
os.makedirs(dst_dir, exist_ok=True)
shutil.move(src, dst)
moved += 1
print(f"\n{moved} moved, {skipped} skipped{' (dry run)' if DRY else ''}")
if not DRY and moved:
print("re-run verify_labels.py to confirm the set is still consistent")
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Dump face crops from both clips for hand-labelling.
Writes labelling/<clip>/unsorted/<name>.jpg a context crop around each
detection, big enough to recognise a person by eye. Move them into
labelling/<clip>/person_A/, person_B/, ... and the sweep reads those folders as
ground truth.
Filenames carry a cNN_ cluster-hint prefix so visually similar faces sort next
to each other in a file manager. The hint is only an ordering convenience
the folder you drop a file into is what counts, and the sweep never reads the
prefix.
Detection and alignment run through the shipped C++ (sae_embed). Every crop
keeps its clip, frame and native-resolution bbox in manifest.json, so probe
detections at reduced scale can be tied back to a labelled face geometrically,
by position, rather than by embedding similarity which would be circular.
"""
import sys, glob, json, os, shutil
import numpy as np
import cv2
sys.path.insert(0, "/home/dtourolle/Development/Jray-project/scene-actor-extraction/build-ort")
import sae_embed
M = "/home/dtourolle/Development/Jray-project/scene-actor-extraction/models/"
CLIPS = ["5157339", "5157344"]
MIN_PX = 60
CTX = 256 # context-crop side, for human recognisability
eng = sae_embed.FaceEmbedder(detector_model=M + "scrfd_500m_bnkps.onnx",
arcface_model=M + "arcface_w600k_r50.onnx",
conf=0.5, nms=0.4, max_side=0)
for clip in CLIPS:
out_dir = f"labelling/{clip}/unsorted"
if os.path.isdir(f"labelling/{clip}"):
print(f"[skip] labelling/{clip} exists — not overwriting your sorting",
file=sys.stderr)
continue
os.makedirs(out_dir, exist_ok=True)
entries = []
for p in sorted(glob.glob(f"pex/d{clip}_*.png")):
frame = p.rsplit("_", 1)[-1].split(".")[0]
img = cv2.imread(p)
for i, d in enumerate(eng.detect(img)):
x, y, w, h = d.bbox
if min(w, h) < MIN_PX:
continue
lm = np.array(d.landmarks, dtype=np.float32).reshape(5, 2)
crop = sae_embed.align_face(img, lm)
if crop is None:
continue
emb = np.asarray(eng.embed_crop(crop), dtype=np.float32)
pad = int(0.5 * max(w, h))
x0, y0 = max(0, int(x) - pad), max(0, int(y) - pad)
x1, y1 = min(img.shape[1], int(x + w) + pad), min(img.shape[0], int(y + h) + pad)
ctx = cv2.resize(img[y0:y1, x0:x1], (CTX, CTX))
entries.append({"clip": clip, "frame": frame, "idx": i,
"bbox": [float(x), float(y), float(w), float(h)],
"px": float(min(w, h)), "conf": float(d.confidence),
"emb": emb, "ctx": ctx})
# cluster hint only — greedy, purely to group similar faces in the file list
E = np.stack([e["emb"] for e in entries])
hint = -np.ones(len(entries), int)
k = 0
for i in range(len(entries)):
if hint[i] >= 0:
continue
hint[i] = k
for j in range(i + 1, len(entries)):
if hint[j] < 0 and float(E[i] @ E[j]) > 0.5:
hint[j] = k
k += 1
manifest = []
for e, h in zip(entries, hint):
name = f"c{h:02d}_{e['clip']}_f{e['frame']}_i{e['idx']}_{int(e['px'])}px.jpg"
cv2.imwrite(f"{out_dir}/{name}", e["ctx"])
manifest.append({k: v for k, v in e.items() if k not in ("emb", "ctx")}
| {"file": name, "cluster_hint": int(h)})
json.dump(manifest, open(f"labelling/{clip}/manifest.json", "w"), indent=1)
print(f"[{clip}] {len(manifest)} crops in {out_dir}, {k} cluster hints, "
f"face px {min(m['px'] for m in manifest):.0f}{max(m['px'] for m in manifest):.0f}",
file=sys.stderr)
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""What explains the misses? Head pose, face size, blur, detector confidence.
For every hand-labelled probe face, computes the calibrated probability against
its OWN gallery entry so a low value is a false negative, not a mistake about
who it is and pairs it with covariates that might explain the failure.
Head pose comes from solvePnP of the 5 landmarks against a canonical 3D face,
giving yaw/pitch/roll in degrees.
CAVEAT, and it matters: the pose estimate is derived from the same 5
landmarks the alignment uses. Where those landmarks are unreliable the pose
estimate is unreliable too, and both degrade for the same reason. So this
can show that failures concentrate at high yaw; it cannot cleanly separate
"the head was turned" from "the landmarks were wrong because the head was
turned". Those are the same physical cause, but not the same fix — the
first argues for gallery pose coverage, the second for a better landmark
source.
A sanity check is printed first: pose is estimated per person, and if it does
not recover what is visible in the review sheets (one subject frontal, another
in profile, another looking down) then the estimate is not worth reading.
Similarities go through the production gallery sigmoid, never compared raw.
"""
import sys, glob, json, os
import numpy as np
import cv2
sys.path.insert(0, "/home/dtourolle/Development/Jray-project/scene-actor-extraction/build-ort")
import sae_embed
ROOT = "/home/dtourolle/Development/Jray-project/scene-actor-extraction/"
M = ROOT + "models/"
GALLERY_CLIP, PROBE_CLIP = "5157344", "5157339"
PROB_THRESHOLD = 0.754
# Canonical 3D face, ordered as types.hpp:60 —
# [0] right-eye [1] left-eye [2] nose [3] right-mouth [4] left-mouth.
# The subject's right eye sits to the LEFT in image space, hence the negative X.
FACE_3D = np.array([
(-34.0, 35.0, -28.0),
( 34.0, 35.0, -28.0),
( 0.0, 0.0, 0.0),
(-26.0, -32.0, -25.0),
( 26.0, -32.0, -25.0),
], dtype=np.float64)
eng = sae_embed.FaceEmbedder(detector_model=M + "scrfd_500m_bnkps.onnx",
arcface_model=M + "LVFace-B_Glint360K.onnx",
conf=0.5, nms=0.4, max_side=0)
cal = sae_embed.gallery_calibration(ROOT + "gallery_lvface.h5")
def head_pose(lm, w, h):
"""yaw, pitch, roll in degrees. Focal length assumed = image width."""
cam = np.array([[w, 0, w / 2], [0, w, h / 2], [0, 0, 1]], dtype=np.float64)
ok, rvec, _ = cv2.solvePnP(FACE_3D, lm.astype(np.float64), cam, None,
flags=cv2.SOLVEPNP_EPNP)
if not ok:
return None
R, _ = cv2.Rodrigues(rvec)
sy = np.sqrt(R[0, 0] ** 2 + R[1, 0] ** 2)
if sy > 1e-6:
pitch = np.degrees(np.arctan2(-R[2, 0], sy))
yaw = np.degrees(np.arctan2(R[1, 0], R[0, 0]))
roll = np.degrees(np.arctan2(R[2, 1], R[2, 2]))
else:
pitch = np.degrees(np.arctan2(-R[2, 0], sy)); yaw = 0.0
roll = np.degrees(np.arctan2(-R[1, 2], R[1, 1]))
# solvePnP's yaw wraps near +/-180 for a face pointing at the camera;
# fold it to a "degrees away from frontal" magnitude.
yaw = ((yaw + 180) % 360) - 180
if abs(yaw) > 90:
yaw = np.sign(yaw) * (180 - abs(yaw))
return yaw, pitch, roll
def collect(clip):
lab = {os.path.basename(p): os.path.basename(os.path.dirname(p))
for p in glob.glob(f"labelling/{clip}/*/*.jpg")
if os.path.basename(os.path.dirname(p)) not in ("discard", "unsorted")}
man = {m["file"]: m for m in json.load(open(f"labelling/{clip}/manifest.json"))}
rows = []
for frame in sorted({man[f]["frame"] for f in lab}):
img = cv2.imread(f"frames/d{clip}_{frame}.png")
dets = eng.detect(img)
H, W = img.shape[:2]
for f, person in lab.items():
m = man[f]
if m["frame"] != frame or m["idx"] >= len(dets):
continue
d = dets[m["idx"]]
lm = np.array(d.landmarks, dtype=np.float32).reshape(5, 2)
crop = sae_embed.align_face(img, lm)
if crop is None:
continue
pose = head_pose(lm, W, H)
x, y, w, h = d.bbox
g = cv2.cvtColor(np.asarray(crop), cv2.COLOR_BGR2GRAY)
rows.append({
"person": person, "px": float(min(w, h)), "conf": float(d.confidence),
"yaw": pose[0] if pose else np.nan, "pitch": pose[1] if pose else np.nan,
"roll": pose[2] if pose else np.nan,
"blur": float(cv2.Laplacian(g, cv2.CV_64F).var()),
"emb": np.asarray(eng.embed_crop(crop), dtype=np.float32)})
return rows
gal_rows = collect(GALLERY_CLIP)
prb_rows = collect(PROBE_CLIP)
gal = {}
for r in gal_rows:
gal.setdefault(r["person"], []).append(r["emb"])
gal = {p: np.stack(v) for p, v in gal.items()}
for r in prb_rows:
if r["person"] in gal:
s = float((gal[r["person"]] @ r["emb"]).max()) # best-of-N, own actor
r["p"] = cal.probability(s)
r["sim"] = s
else:
r["p"] = np.nan
rows = [r for r in prb_rows if not np.isnan(r.get("p", np.nan))]
print(f"[data] {len(rows)} labelled probe faces with a gallery entry\n", file=sys.stderr)
# ── sanity check: does the pose estimate recover what the sheets show? ───────
print("pose by person (does this match the review sheets?)")
print(f"{'person':>7}{'n':>5}{'|yaw| med':>11}{'pitch med':>11}{'P med':>8}{'hit rate':>10}")
for p in sorted({r['person'] for r in rows}):
sub = [r for r in rows if r["person"] == p]
print(f"{p:>7}{len(sub):>5}"
f"{np.median([abs(r['yaw']) for r in sub]):>11.1f}"
f"{np.median([r['pitch'] for r in sub]):>11.1f}"
f"{np.median([r['p'] for r in sub]):>8.3f}"
f"{100*np.mean([r['p'] > PROB_THRESHOLD for r in sub]):>9.0f}%")
# ── P binned by each covariate ───────────────────────────────────────────────
def binned(name, key, edges, fmt="{:.0f}"):
print(f"\nP(match) by {name}")
print(f"{'bin':>16}{'n':>5}{'P med':>9}{'hit rate':>10}{'sim med':>9}")
vals = np.array([r[key] for r in rows])
for lo, hi in zip(edges[:-1], edges[1:]):
sub = [r for r, v in zip(rows, vals) if lo <= v < hi]
if not sub:
continue
lbl = f"{fmt.format(lo)}{fmt.format(hi)}"
print(f"{lbl:>16}{len(sub):>5}"
f"{np.median([r['p'] for r in sub]):>9.3f}"
f"{100*np.mean([r['p'] > PROB_THRESHOLD for r in sub]):>9.0f}%"
f"{np.median([r['sim'] for r in sub]):>9.3f}")
for r in rows:
r["absyaw"] = abs(r["yaw"])
r["abspitch"] = abs(r["pitch"])
binned("|yaw| (deg from frontal)", "absyaw", [0, 10, 20, 30, 45, 60, 91])
binned("|pitch| (deg)", "abspitch", [0, 10, 20, 30, 45, 91])
binned("face size (px)", "px", [0, 130, 150, 175, 200, 400])
binned("blur (laplacian var)", "blur", [0, 50, 150, 400, 1000, 1e9])
binned("detector confidence", "conf", [0.5, 0.6, 0.7, 0.8, 0.9, 1.01], "{:.2f}")
# ── how much does each covariate actually explain? ───────────────────────────
print("\nSpearman rank correlation with P(match):")
def spearman(a, b):
ra = np.argsort(np.argsort(a)); rb = np.argsort(np.argsort(b))
return float(np.corrcoef(ra, rb)[0, 1])
P = np.array([r["p"] for r in rows])
for key, label in [("absyaw", "|yaw|"), ("abspitch", "|pitch|"), ("px", "face px"),
("blur", "blur"), ("conf", "detector conf")]:
v = np.array([r[key] for r in rows])
print(f" {label:>14}: {spearman(v, P):+.3f}")
json.dump([{k: v for k, v in r.items() if k != "emb"} for r in rows],
open("failure_analysis.json", "w"), indent=1, default=float)
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Landmark voting: average SCRFD's overlapping detections instead of discarding them.
SCRFD predicts a face from many anchors; NMS keeps the single highest-scoring
box and throws the rest away. Each discarded box carries its own 5-landmark
estimate of the SAME face, so the survivors are one sample from a distribution
we could be averaging over.
baseline conf 0.50, nms 0.40 the shipped settings, one box per face
voted conf 0.30, nms 0.90 duplicates survive, then grouped by IoU and
the 5 landmarks averaged, weighted by detection confidence
Why this is worth trying when the mesh failed: the mesh moved the landmarks off
the definition ArcFace was trained on (a lip-ring centroid is not an annotated
mouth corner), and the embedder punished it. A confidence-weighted mean of
SCRFD's OWN landmark predictions is the same kind of point, just with less
variance it should stay on-distribution while being steadier.
Scored on cross-clip identification through the production sigmoid, which is
the thing that actually broke. Raw similarity shown only to locate the
threshold; it decides nothing.
LD_PRELOAD=/usr/lib/libcudnn_cnn.so.9 python3 landmark_voting.py
"""
import sys, glob, json, os
sys.path.insert(0, "/home/dtourolle/Development/Jray-project/scene-actor-extraction/build-ort")
import sae_embed # before cv2 — see alignment_compare.py
import numpy as np
import cv2
import argparse
ROOT = "/home/dtourolle/Development/Jray-project/scene-actor-extraction/"
M = ROOT + "models/"
CLIPS = ["5157344", "5157339"]
PROB_THRESHOLD = 0.754
GROUP_IOU = 0.55 # detections overlapping this much are the same face
MATCH_IOU = 0.35 # tie a detection to the hand-labelled face
_ap = argparse.ArgumentParser()
_ap.add_argument("--detector", default="scrfd_500m_bnkps.onnx",
help="detector under models/. SCRFD sizes 500m / 2.5g / 10g come "
"from InsightFace's buffalo_sc / buffalo_m / buffalo_l packs")
_ap.add_argument("--vote-conf", type=float, default=None,
help="confidence floor for the voting pass. Omit to auto-tune "
"it to --target-votes")
_ap.add_argument("--target-votes", type=int, default=3,
help="votes per face to tune --vote-conf towards, so detectors "
"are compared at equal redundancy rather than equal settings")
_args = _ap.parse_args()
base_eng = sae_embed.FaceEmbedder(detector_model=M + _args.detector,
arcface_model=M + "LVFace-B_Glint360K.onnx",
conf=0.5, nms=0.4, max_side=0)
def _make_vote_engine(conf):
# Same models, looser suppression: keep the duplicates NMS would have removed.
return sae_embed.FaceEmbedder(detector_model=M + _args.detector,
arcface_model=M + "LVFace-B_Glint360K.onnx",
conf=conf, nms=0.9, max_side=0)
cal = sae_embed.gallery_calibration(ROOT + "gallery_lvface.h5")
def iou(a, b):
ax, ay, aw, ah = a; bx, by, bw, bh = b
x0, y0 = max(ax, bx), max(ay, by)
x1, y1 = min(ax + aw, bx + bw), min(ay + ah, by + bh)
if x1 <= x0 or y1 <= y0:
return 0.0
i = (x1 - x0) * (y1 - y0)
return i / (aw * ah + bw * bh - i)
def vote(dets):
"""Group overlapping detections, return (bbox, landmarks, conf, n_votes)."""
items = sorted(dets, key=lambda d: -d.confidence)
used, out = [False] * len(items), []
for i, d in enumerate(items):
if used[i]:
continue
grp = [d]
used[i] = True
for j in range(i + 1, len(items)):
if not used[j] and iou(list(d.bbox), list(items[j].bbox)) >= GROUP_IOU:
used[j] = True
grp.append(items[j])
w = np.array([g.confidence for g in grp], dtype=np.float32)
w = w / w.sum()
lms = np.stack([np.array(g.landmarks, dtype=np.float32).reshape(5, 2) for g in grp])
bxs = np.stack([np.array(list(g.bbox), dtype=np.float32) for g in grp])
out.append((( w[:, None] * bxs).sum(0), (w[:, None, None] * lms).sum(0),
float(grp[0].confidence), len(grp)))
return out
def tune_vote_conf(target, sample=6):
"""Pick the confidence floor giving ~target detections per face to average.
A larger SCRFD is more confident and suppresses harder, so at a fixed floor
it emits fewer overlapping anchors median 2 against 500m's 3. Comparing
detectors at equal SETTINGS therefore also compares them at unequal
redundancy, and the voting arm is handicapped for the bigger models. Tuning
each to the same votes-per-face isolates landmark quality from how much
there was to average.
"""
frames = sorted(glob.glob(f"frames/d{CLIPS[0]}_*.png"))[:sample]
imgs = [cv2.imread(f) for f in frames]
best = (None, None, 1e9)
for conf in (0.30, 0.20, 0.12, 0.07, 0.04, 0.02, 0.01):
eng = _make_vote_engine(conf)
sizes = [n for img in imgs for _, _, _, n in vote(eng.detect(img))]
if not sizes:
continue
med = float(np.median(sizes))
if abs(med - target) < best[2]:
best = (conf, eng, abs(med - target))
print(f"[tune] conf={conf:.2f} -> median {med:.0f} votes/face", file=sys.stderr)
if med >= target:
break
if best[1] is None:
print(f"[tune] no confidence floor reached {target} votes/face; "
f"falling back to 0.30", file=sys.stderr)
return 0.30, _make_vote_engine(0.30)
print(f"[tune] chose conf={best[0]:.2f} for ~{target} votes/face", file=sys.stderr)
return best[0], best[1]
if _args.vote_conf is not None:
VOTE_CONF, vote_eng = _args.vote_conf, _make_vote_engine(_args.vote_conf)
else:
VOTE_CONF, vote_eng = tune_vote_conf(_args.target_votes)
cal = sae_embed.gallery_calibration(ROOT + "gallery_lvface.h5")
def collect(clip):
lab = {os.path.basename(p): os.path.basename(os.path.dirname(p))
for p in glob.glob(f"labelling/{clip}/*/*.jpg")
if os.path.basename(os.path.dirname(p)) not in ("discard", "unsorted")}
man = {m["file"]: m for m in json.load(open(f"labelling/{clip}/manifest.json"))}
rows, votes = [], []
for frame in sorted({man[f]["frame"] for f in lab}):
img = cv2.imread(f"frames/d{clip}_{frame}.png")
base = base_eng.detect(img)
voted = vote(vote_eng.detect(img))
for fname, person in lab.items():
m = man[fname]
if m["frame"] != frame or m["idx"] >= len(base):
continue
d = base[m["idx"]]
lm5 = np.array(d.landmarks, dtype=np.float32).reshape(5, 2)
c_b = sae_embed.align_face(img, lm5)
# the voted group covering the same face
best, best_v = None, 0.0
for bbox, lms, conf, n in voted:
v = iou(list(bbox), list(d.bbox))
if v > best_v:
best_v, best = v, (lms, n)
c_v = None
if best and best_v >= MATCH_IOU:
c_v = sae_embed.align_face(img, best[0].astype(np.float32))
votes.append(best[1])
rec = {"person": person}
rec["base"] = np.asarray(base_eng.embed_crop(c_b), np.float32) if c_b is not None else None
rec["voted"] = np.asarray(base_eng.embed_crop(c_v), np.float32) if c_v is not None else None
rows.append(rec)
return rows, votes
data, allv = {}, []
for c in CLIPS:
data[c], v = collect(c)
allv += v
print(f"[{c}] {len(data[c])} crops", file=sys.stderr)
print(f"[voting] group size: median {np.median(allv):.0f}, "
f"mean {np.mean(allv):.1f}, max {max(allv)} detections averaged per face",
file=sys.stderr)
GAL, PRB = "5157344", "5157339"
print(f"\ndetector={_args.detector} vote_conf={VOTE_CONF:.2f} "
f"gallery {GAL} -> probe {PRB}, P>{PROB_THRESHOLD}\n")
print(f"{'align':>8}{'person':>8}{'n_gal':>7}{'n_prb':>7}"
f"{'within-clip':>13}{'cross-clip':>12}{'hit rate':>10}")
summary = {}
for key in ("base", "voted"):
gal, prb = {}, {}
for r in data[GAL]:
if r[key] is not None:
gal.setdefault(r["person"], []).append(r[key])
for r in data[PRB]:
if r[key] is not None:
prb.setdefault(r["person"], []).append(r[key])
gal = {p: np.stack(v) for p, v in gal.items()}
prb = {p: np.stack(v) for p, v in prb.items()}
hits = tot = 0
for p in sorted(set(gal) & set(prb)):
pp = prb[p] @ prb[p].T
np.fill_diagonal(pp, -1)
within = float(np.median(pp.max(axis=1))) if len(pp) > 1 else float("nan")
cross = float(np.median((gal[p] @ prb[p].T).max(axis=0)))
h = 0
for e in prb[p]:
bp, bn = 0.0, None
for q in gal:
v = cal.probability(float((gal[q] @ e).max()))
if v > bp:
bp, bn = v, q
if bp > PROB_THRESHOLD and bn == p:
h += 1
hits += h; tot += len(prb[p])
print(f"{key:>8}{p:>8}{len(gal[p]):>7}{len(prb[p]):>7}"
f"{cal.probability(within):>6.3f}/{within:<6.3f}"
f"{cal.probability(cross):>6.3f}/{cross:<5.3f}{100*h/len(prb[p]):>9.0f}%")
summary[key] = (hits, tot)
print(f"{key:>8}{'ALL':>8}{'':>14}{'':>25}{100*hits/max(tot,1):>9.0f}%\n")
hb, tb = summary["base"]; hv, tv = summary["voted"]
print(f"voting vs baseline: {100*hv/max(tv,1) - 100*hb/max(tb,1):+.1f} points "
f"of cross-clip TPI ({hb}/{tb} -> {hv}/{tv})")
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""Build labelling/review.html — a local page for correcting the labels.
One row per crop, ordered most-suspicious first:
left the person it is currently filed under (medoid of that person's
hand-sorted crops, so the reference is one you trust)
centre the crop under review context with the detection boxed, and
beneath it the 112x112 the embedder actually receives
right the person it matches better, if any, with both probabilities
Pick a destination per row, then Export to download corrections.json and apply
it with apply_corrections.py. Nothing is moved by this script.
Self-contained: images are inlined as data URIs and the page is opened from
disk, so no server runs and no face crop leaves the machine.
Ordering is by P(other) - P(self), both from the global gallery sigmoid, so
rows where the evidence disagrees with the label float to the top and the
agreement cases sink. It is a review order, not a verdict you are the
arbiter, which is the whole point of labelling by hand.
"""
import sys, glob, json, os, base64
import numpy as np
import cv2
sys.path.insert(0, "/home/dtourolle/Development/Jray-project/scene-actor-extraction/build-ort")
import sae_embed
ROOT = "/home/dtourolle/Development/Jray-project/scene-actor-extraction/"
M = ROOT + "models/"
EMBEDDER = M + "LVFace-B_Glint360K.onnx"
GALLERY = ROOT + "gallery_lvface.h5"
REF_CLIP = "5157344" # the clip sorted by hand — reference faces come from here
CLIPS = ["5157344", "5157339"]
eng = sae_embed.FaceEmbedder(detector_model=M + "scrfd_500m_bnkps.onnx",
arcface_model=EMBEDDER, conf=0.5, nms=0.4, max_side=0)
cal = sae_embed.gallery_calibration(GALLERY)
def b64(img, size, q=72):
img = cv2.resize(img, (size, size))
ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, q])
return "data:image/jpeg;base64," + base64.b64encode(buf).decode() if ok else ""
rows = []
for clip in CLIPS:
man = {m["file"]: m for m in json.load(open(f"labelling/{clip}/manifest.json"))}
placed = {os.path.basename(p): (os.path.basename(os.path.dirname(p)), p)
for p in glob.glob(f"labelling/{clip}/*/*.jpg")}
by_frame = {}
for fname, (person, path) in placed.items():
if fname in man and person != "unsorted":
by_frame.setdefault(man[fname]["frame"], []).append((fname, person, path))
for frame, items in sorted(by_frame.items()):
img = cv2.imread(f"frames/d{clip}_{frame}.png")
if img is None:
continue
dets = eng.detect(img)
for fname, person, path in items:
i = man[fname]["idx"]
if i >= len(dets):
continue
lm = np.array(dets[i].landmarks, dtype=np.float32).reshape(5, 2)
crop = sae_embed.align_face(img, lm)
if crop is None:
continue
rows.append({"clip": clip, "person": person, "file": fname, "path": path,
"px": man[fname]["px"], "aligned": np.asarray(crop),
"emb": np.asarray(eng.embed_crop(crop), dtype=np.float32)})
people = sorted({r["person"] for r in rows})
E = np.stack([r["emb"] for r in rows])
lab = np.array([people.index(r["person"]) for r in rows])
S = E @ E.T
np.fill_diagonal(S, -1.0)
# reference face per person: medoid of their REF_CLIP crops
ref_img = {}
for k, p in enumerate(people):
idx = [i for i in np.where(lab == k)[0] if rows[i]["clip"] == REF_CLIP]
if not idx:
idx = list(np.where(lab == k)[0])
if not idx:
continue
sub = S[np.ix_(idx, idx)].copy()
medoid = idx[int(np.argmax(sub.mean(axis=1)))]
ref_img[p] = b64(rows[medoid]["aligned"], 112)
items = []
for i, r in enumerate(rows):
k = lab[i]
same = [j for j in np.where(lab == k)[0] if j != i]
p_self = cal.probability(float(S[i, same].max())) if same else 0.0
best_other, p_other = None, 0.0
for k2, p2 in enumerate(people):
if k2 == k:
continue
other = np.where(lab == k2)[0]
if not len(other):
continue
pv = cal.probability(float(S[i, other].max()))
if pv > p_other:
p_other, best_other = pv, p2
ctx = cv2.imread(r["path"])
items.append({
"file": r["file"], "clip": r["clip"], "person": r["person"],
"px": int(r["px"]), "p_self": round(p_self, 3), "p_other": round(p_other, 3),
"other": best_other, "delta": round(p_other - p_self, 3),
"ctx": b64(ctx, 150) if ctx is not None else "",
"ali": b64(r["aligned"], 112),
})
items.sort(key=lambda x: -x["delta"])
payload = json.dumps({"people": people, "refs": ref_img, "items": items})
HTML = """<meta charset="utf-8"><title>JRay — label review</title>
<style>
:root{color-scheme:dark;--bg:#14161a;--fg:#e6e8ea;--mut:#8b929c;--line:#262b33;--warn:#e0654a;--ok:#4a9d6a}
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,sans-serif}
header{position:sticky;top:0;background:#181b20;border-bottom:1px solid var(--line);
padding:12px 18px;display:flex;gap:18px;align-items:center;flex-wrap:wrap;z-index:5}
h1{font-size:15px;margin:0;font-weight:600}
.stat{color:var(--mut);font-size:13px}
button{background:#232830;color:var(--fg);border:1px solid var(--line);border-radius:6px;
padding:7px 13px;cursor:pointer;font:inherit}
button:hover{background:#2c323c}
button.go{background:#2f5d43;border-color:#3c7555}
.row{display:grid;grid-template-columns:150px 1fr 190px;gap:20px;align-items:center;
padding:14px 18px;border-bottom:1px solid var(--line)}
.row.flag{background:#1e1719}
.row.done{opacity:.4}
.cell{display:flex;gap:10px;align-items:center}
img{border-radius:5px;display:block;background:#000}
.lab{font-weight:600;font-size:15px}
.mut{color:var(--mut);font-size:12px}
.p{font-variant-numeric:tabular-nums}
.hi{color:var(--warn);font-weight:600}
.choices{display:flex;flex-wrap:wrap;gap:6px}
.choices button{padding:5px 10px;font-size:13px}
.choices button.sel{background:#2f5d43;border-color:#3c7555}
.legend{padding:10px 18px;color:var(--mut);font-size:12px;border-bottom:1px solid var(--line)}
</style>
<header>
<h1>Label review</h1>
<span class="stat" id="stat"></span>
<button id="exp" class="go">Export corrections.json</button>
<button id="onlyflag">Show only disagreements</button>
</header>
<div class="legend">Left: the person this crop is filed under. Centre: the crop (context with the
detection boxed, and the 112&times;112 the embedder actually sees). Right: the person it matches
better, if any. Ordered by P(other) &minus; P(self) &mdash; disagreements first.</div>
<div id="list"></div>
<script>
const D = __PAYLOAD__;
const choice = {};
const list = document.getElementById('list');
function render(){
list.innerHTML = '';
const flagOnly = document.body.dataset.flag === '1';
for (const it of D.items){
if (flagOnly && it.delta <= 0) continue;
const row = document.createElement('div');
row.className = 'row' + (it.delta > 0 ? ' flag' : '') + (choice[it.file] ? ' done' : '');
const left = document.createElement('div');
left.className = 'cell';
left.innerHTML = `<img src="${D.refs[it.person]||''}" width="72" height="72">
<div><div class="lab">${it.person}</div>
<div class="mut p">P(self) ${it.p_self.toFixed(3)}</div></div>`;
const mid = document.createElement('div');
mid.className = 'cell';
mid.innerHTML = `<img src="${it.ctx}" width="120" height="120">
<img src="${it.ali}" width="90" height="90">
<div><div class="mut">${it.clip} &middot; ${it.px}px</div>
<div class="mut">${it.file}</div></div>`;
const right = document.createElement('div');
const worse = it.delta > 0;
right.innerHTML = it.other
? `<div class="cell"><img src="${D.refs[it.other]||''}" width="56" height="56">
<div><div class="lab ${worse?'hi':''}">${it.other}</div>
<div class="mut p ${worse?'hi':''}">P ${it.p_other.toFixed(3)}</div></div></div>`
: '<div class="mut">—</div>';
const ch = document.createElement('div');
ch.className = 'choices';
for (const p of D.people.concat(['discard'])){
const b = document.createElement('button');
b.textContent = p === it.person ? p + ' (keep)' : p;
if (choice[it.file] === p || (!choice[it.file] && p === it.person)) b.classList.add('sel');
b.onclick = () => { choice[it.file] = p; render(); };
ch.appendChild(b);
}
right.appendChild(ch);
row.append(left, mid, right);
list.appendChild(row);
}
const changed = Object.entries(choice).filter(([f,p]) =>
p !== (D.items.find(i=>i.file===f)||{}).person).length;
document.getElementById('stat').textContent =
`${D.items.length} crops · ${D.items.filter(i=>i.delta>0).length} disagreements · ${changed} changes staged`;
}
document.getElementById('onlyflag').onclick = () => {
document.body.dataset.flag = document.body.dataset.flag === '1' ? '0' : '1';
render();
};
document.getElementById('exp').onclick = () => {
const out = {};
for (const it of D.items){
const p = choice[it.file] || it.person;
if (p !== it.person) out[it.file] = {from: it.person, to: p, clip: it.clip};
}
const blob = new Blob([JSON.stringify(out, null, 1)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = 'corrections.json'; a.click();
};
render();
</script>
"""
os.makedirs("labelling", exist_ok=True)
out = "labelling/review.html"
with open(out, "w") as f:
f.write(HTML.replace("__PAYLOAD__", payload))
size = os.path.getsize(out) / 1e6
flagged = sum(1 for i in items if i["delta"] > 0)
print(f"{out} {size:.1f} MB {len(items)} crops, {flagged} disagreements", file=sys.stderr)
print(f"open file://{os.path.abspath(out)}", file=sys.stderr)
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Estimate head pose per crop, and build a page to confirm or correct it.
Why not solvePnP on the 5 detector landmarks: those landmarks collapse on
turned faces, so the estimator breaks precisely on the crops whose pose we care
about. Run that way it reported the profile subject as the MOST frontal of the
four, which is how we know not to trust it.
Instead the estimate comes from the MediaPipe face mesh (468 points, run via
OpenCV DNN the same model rPPG-kahn uses) and a symmetry measure that needs
no 3D model:
yaw_ratio = (dL - dR) / (dL + dR)
over left/right symmetric vertex pairs, where dL and dR are each side's
distance from the face midline. Frontal ~ 0, profile -> +/-1. It degrades
gracefully because it averages many pairs rather than trusting any one point,
and it is scale- and translation-free.
It is still an estimate. So this writes pose_review.html with the estimate
PRE-FILLED as a proposal, ordered by confidence, for you to correct and the
correlation is only run against your corrected labels. If the estimate turns
out to disagree with you often, that is the finding, and the automatic number
gets dropped rather than reported.
Bins are coarse on purpose: frontal / three-quarter / profile / down-or-hidden.
Finer than that and the labelling is slower and less reliable, and the question
("does pose explain the misses") does not need degrees.
"""
import sys, glob, json, os, base64
# sae_embed MUST be imported before cv2: OpenCV's DNN module loads the system
# libonnxruntime, which then shadows the newer one this module links against and
# the import fails on a missing symbol version. Order matters, so do not tidy
# these into alphabetical order.
sys.path.insert(0, "/home/dtourolle/Development/Jray-project/scene-actor-extraction/build-ort")
import sae_embed
import numpy as np
import cv2
ROOT = "/home/dtourolle/Development/Jray-project/scene-actor-extraction/"
M = ROOT + "models/"
MESH = "/home/dtourolle/Development/rPPG-kahn/models/face_landmark.tflite"
CLIPS = ["5157344", "5157339"]
BINS = ["frontal", "three-quarter", "profile", "down-or-hidden"]
# Symmetric vertex pairs (subject-left, subject-right) on the MediaPipe mesh:
# outer eye corners, inner eye corners, cheeks, mouth corners, jaw.
PAIRS = [(33, 263), (133, 362), (130, 359), (243, 463),
(61, 291), (91, 321), (146, 375), (58, 288), (172, 397), (215, 435)]
MIDLINE = [10, 168, 1, 4, 5, 195, 197, 152] # forehead -> nose -> chin
net = cv2.dnn.readNetFromTFLite(MESH)
NAMES = net.getUnconnectedOutLayersNames()
LMI, PRI = NAMES.index("conv2d_21"), NAMES.index("conv2d_31")
eng = sae_embed.FaceEmbedder(detector_model=M + "scrfd_500m_bnkps.onnx",
arcface_model=M + "LVFace-B_Glint360K.onnx",
conf=0.5, nms=0.4, max_side=0)
def mesh_pose(img, bbox, expand=1.6):
"""(yaw_ratio, presence) or (nan, 0). yaw_ratio in [-1, 1], 0 = frontal."""
x, y, w, h = bbox
cx, cy, s = x + w / 2, y + h / 2, max(w, h) * expand
crop = cv2.getRectSubPix(img, (int(s), int(s)), (float(cx), float(cy)))
net.setInput(cv2.dnn.blobFromImage(crop, 1 / 255.0, (192, 192), (0, 0, 0), swapRB=True))
o = net.forward(NAMES)
pres = 1 / (1 + np.exp(-float(o[PRI].ravel()[0])))
lm = o[LMI].reshape(468, 3)[:, :2]
mid = lm[MIDLINE]
# least-squares midline direction, then signed distance of each pair member
c = mid.mean(axis=0)
u, _, _ = np.linalg.svd(mid - c)
d = (mid - c)
axis = np.linalg.svd(d.T @ d)[0][:, 0] # principal direction of the midline
normal = np.array([-axis[1], axis[0]])
ratios = []
for a, b in PAIRS:
dl = float(np.dot(lm[a] - c, normal))
dr = float(np.dot(lm[b] - c, normal))
if abs(dl) + abs(dr) < 1e-6:
continue
ratios.append((abs(dl) - abs(dr)) / (abs(dl) + abs(dr)))
return (float(np.median(ratios)) if ratios else np.nan), pres
def b64(img, size, q=72):
ok, buf = cv2.imencode(".jpg", cv2.resize(img, (size, size)),
[cv2.IMWRITE_JPEG_QUALITY, q])
return "data:image/jpeg;base64," + base64.b64encode(buf).decode() if ok else ""
items = []
for clip in CLIPS:
lab = {os.path.basename(p): (os.path.basename(os.path.dirname(p)), p)
for p in glob.glob(f"labelling/{clip}/*/*.jpg")
if os.path.basename(os.path.dirname(p)) not in ("discard", "unsorted")}
man = {m["file"]: m for m in json.load(open(f"labelling/{clip}/manifest.json"))}
for frame in sorted({man[f]["frame"] for f in lab}):
img = cv2.imread(f"frames/d{clip}_{frame}.png")
dets = eng.detect(img)
for fname, (person, path) in lab.items():
m = man[fname]
if m["frame"] != frame or m["idx"] >= len(dets):
continue
d = dets[m["idx"]]
lm5 = np.array(d.landmarks, dtype=np.float32).reshape(5, 2)
crop = sae_embed.align_face(img, lm5)
if crop is None:
continue
yaw, pres = mesh_pose(img, d.bbox)
a = abs(yaw) if not np.isnan(yaw) else 1.0
guess = ("frontal" if a < 0.15 else "three-quarter" if a < 0.45
else "profile")
if pres < 0.5:
guess = "down-or-hidden" # mesh could not fit at all
ctx = cv2.imread(path)
items.append({"file": fname, "clip": clip, "person": person,
"px": int(m["px"]), "yaw": None if np.isnan(yaw) else round(yaw, 3),
"pres": round(pres, 3), "guess": guess,
"ctx": b64(ctx, 140) if ctx is not None else "",
"ali": b64(np.asarray(crop), 112)})
# least-confident first: near a bin boundary, or the mesh could not fit
def uncertainty(it):
if it["pres"] < 0.5:
return 0.0
a = abs(it["yaw"]) if it["yaw"] is not None else 1.0
return min(abs(a - 0.15), abs(a - 0.45))
items.sort(key=uncertainty)
payload = json.dumps({"bins": BINS, "items": items})
HTML = """<meta charset="utf-8"><title>JRay — head pose labelling</title>
<style>
:root{color-scheme:dark}
body{margin:0;background:#14161a;color:#e6e8ea;font:14px/1.5 system-ui,sans-serif}
header{position:sticky;top:0;background:#181b20;border-bottom:1px solid #262b33;
padding:12px 18px;display:flex;gap:16px;align-items:center;flex-wrap:wrap;z-index:5}
h1{font-size:15px;margin:0}
button{background:#232830;color:#e6e8ea;border:1px solid #262b33;border-radius:6px;
padding:7px 12px;cursor:pointer;font:inherit}
button:hover{background:#2c323c}
button.go{background:#2f5d43;border-color:#3c7555}
.g{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px;padding:14px}
.c{border:1px solid #262b33;border-radius:8px;padding:9px;display:flex;gap:9px;align-items:center}
.c.edited{border-color:#3c7555}
img{border-radius:5px;background:#000;display:block}
.m{color:#8b929c;font-size:11px}
.b{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px}
.b button{padding:3px 7px;font-size:11px}
.b button.sel{background:#2f5d43;border-color:#3c7555}
</style>
<header><h1>Head pose</h1><span class="m" id="stat"></span>
<button class="go" id="exp">Export pose_labels.json</button></header>
<div class="g" id="g"></div>
<script>
const D=__PAYLOAD__; const pick={};
function render(){
const g=document.getElementById('g'); g.innerHTML='';
for(const it of D.items){
const cur=pick[it.file]||it.guess;
const c=document.createElement('div');
c.className='c'+(pick[it.file]&&pick[it.file]!==it.guess?' edited':'');
const b=D.bins.map(x=>`<button class="${x===cur?'sel':''}" data-f="${it.file}" data-b="${x}">${x}</button>`).join('');
c.innerHTML=`<img src="${it.ctx}" width="88" height="88"><img src="${it.ali}" width="66" height="66">
<div><div class="m">${it.person} · ${it.clip.slice(-3)} · ${it.px}px</div>
<div class="m">yaw ${it.yaw===null?'':it.yaw} · presence ${it.pres}</div>
<div class="b">${b}</div></div>`;
g.appendChild(c);
}
g.onclick=e=>{const t=e.target; if(t.dataset&&t.dataset.b){pick[t.dataset.f]=t.dataset.b; render();}};
const ed=Object.entries(pick).filter(([f,v])=>v!==(D.items.find(i=>i.file===f)||{}).guess).length;
document.getElementById('stat').textContent=`${D.items.length} crops · ${ed} corrections`;
}
document.getElementById('exp').onclick=()=>{
const out={}; for(const it of D.items) out[it.file]={pose:pick[it.file]||it.guess,
guess:it.guess, yaw:it.yaw, pres:it.pres, person:it.person, clip:it.clip};
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([JSON.stringify(out,null,1)],{type:'application/json'}));
a.download='pose_labels.json'; a.click();
};
render();
</script>
"""
out = "labelling/pose_review.html"
open(out, "w").write(HTML.replace("__PAYLOAD__", payload))
from collections import Counter
print(f"{out} {os.path.getsize(out)/1e6:.1f} MB {len(items)} crops", file=sys.stderr)
print(f"estimate: {dict(Counter(i['guess'] for i in items))}", file=sys.stderr)
print("\nestimated pose per person (does this match what you see?):", file=sys.stderr)
for p in sorted({i["person"] for i in items}):
for clip in CLIPS:
sub = [i for i in items if i["person"] == p and i["clip"] == clip]
if sub:
print(f" {p} {clip[-3:]}: {dict(Counter(i['guess'] for i in sub))}",
file=sys.stderr)
print(f"\nopen file://{os.path.abspath(out)}", file=sys.stderr)
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Propose person labels for one clip using another clip's hand-sorted labels.
Reads the clip you have already sorted (REF_CLIP) as ground truth, then proposes
a person for every crop in the other clip (TARGET_CLIP) and writes them into
matching folders for you to correct.
python3 propose_labels.py # propose, write folders + sheets
python3 propose_labels.py --dry-run # report only, move nothing
Output:
labelling/<target>/unsorted/A|B|C|D/ proposed, same names as the ref clip
labelling/<target>/unsorted/ left in place when no person is
confident enough to name
labelling/review_<person>.jpg contact sheet spanning BOTH clips:
confirmed crops first, then
proposed ones with their P
Correcting it: open a review sheet. Every face on it should be one person. The
lower block is the proposal move any intruder to the right folder, or back to
unsorted/. The folder a file sits in is the ground truth; nothing downstream
reads the proposed name or its probability.
The proposal is a labelling aid, never the label. Scoring the sweep against
embedding-derived labels would be circular: it keeps the faces the embedder
already gets right and drops the hard ones the sweep exists to find. Your
correction is what breaks that loop, which is why the proposal is deliberately
conservative and leaves anything doubtful unnamed.
Assignment is on the calibrated probability, per-actor best-of-N, exactly as
identity_matcher_node does never a bare cosine (AR-024). The calibration is
fitted on your labelled reference crops, which is what calibrate_gallery is for.
"""
import sys, glob, json, os, shutil
import numpy as np
import cv2
sys.path.insert(0, "/home/dtourolle/Development/Jray-project/scene-actor-extraction/build-ort")
import sae_embed
ROOT = "/home/dtourolle/Development/Jray-project/scene-actor-extraction/"
M = ROOT + "models/"
# The embedder and the gallery whose calibration scores it MUST be the same
# model: a Platt fit is specific to one embedding space, so LVFace probabilities
# read through an ArcFace fit are meaningless.
EMBEDDER = M + "LVFace-B_Glint360K.onnx"
GALLERY = ROOT + "gallery_lvface.h5" # 291 actors, cached fit
REF_CLIP, TARGET_CLIP = "5157344", "5157339"
ASSIGN_P = 0.90 # propose a name only when this confident
SHEET_COLS = 8
THUMB = 150
DRY = "--dry-run" in sys.argv
eng = sae_embed.FaceEmbedder(detector_model=M + "scrfd_500m_bnkps.onnx",
arcface_model=EMBEDDER,
conf=0.5, nms=0.4, max_side=0)
def embed_manifest(clip):
"""Re-derive each dumped crop's embedding from its source frame, cached.
The dumped .jpg is a context thumbnail for human eyes; the embedding must
come from the aligned crop the pipeline would actually produce, so the
frame is re-detected and the manifest's idx picks the same face.
Detecting 24 4K frames per clip costs far more than the rest of this script
put together, and the result only changes when the manifest does so it is
cached and keyed on the manifest's mtime. Delete cache/ to force a redo.
"""
man_path = f"labelling/{clip}/manifest.json"
cache_path = f"cache/emb_{clip}.npz"
os.makedirs("cache", exist_ok=True)
if os.path.exists(cache_path) and \
os.path.getmtime(cache_path) >= os.path.getmtime(man_path):
z = np.load(cache_path, allow_pickle=True)
print(f"[cache] {clip}: {len(z['meta'])} embeddings reused", file=sys.stderr)
return [{**m, "emb": e} for m, e in zip(z["meta"], z["emb"])]
man = json.load(open(man_path))
by_frame = {}
for m in man:
by_frame.setdefault(m["frame"], []).append(m)
out = []
for frame, ms in sorted(by_frame.items()):
img = cv2.imread(f"frames/d{clip}_{frame}.png")
if img is None:
sys.exit(f"missing frames/d{clip}_{frame}.png — extract with\n"
f" ffmpeg -i clips/{clip}.mp4 -vf fps=2 -frames:v 24 "
f"frames/d{clip}_%03d.png")
dets = eng.detect(img)
for m in ms:
if m["idx"] >= len(dets):
continue
d = dets[m["idx"]]
lm = np.array(d.landmarks, dtype=np.float32).reshape(5, 2)
crop = sae_embed.align_face(img, lm)
if crop is None:
continue
out.append({**m, "emb": np.asarray(eng.embed_crop(crop), dtype=np.float32)})
np.savez(cache_path,
meta=np.array([{k: v for k, v in o.items() if k != "emb"} for o in out],
dtype=object),
emb=np.stack([o["emb"] for o in out]))
print(f"[cache] {clip}: {len(out)} embeddings written to {cache_path}",
file=sys.stderr)
return out
def sorted_dirs(clip):
"""Person folders you created, wherever you put them under labelling/<clip>."""
found = {}
for path in glob.glob(f"labelling/{clip}/**/", recursive=True):
name = os.path.basename(path.rstrip("/"))
if name in ("unsorted", "discard") or name.startswith("5157"):
continue
files = [os.path.basename(f) for f in glob.glob(path + "*.jpg")]
if files:
found[name] = files
return found
# ── reference side: your labels ──────────────────────────────────────────────
ref_rows = embed_manifest(REF_CLIP)
ref_dirs = sorted_dirs(REF_CLIP)
if not ref_dirs:
sys.exit(f"no person folders under labelling/{REF_CLIP} — sort that clip first")
file_to_person = {f: p for p, fs in ref_dirs.items() for f in fs}
ref = [(file_to_person[r["file"]], r["emb"]) for r in ref_rows
if r["file"] in file_to_person]
people = sorted({p for p, _ in ref})
print(f"[ref] {REF_CLIP}: {len(ref)} labelled crops over {len(people)} people "
f"{ {p: sum(1 for q, _ in ref if q == p) for p in people} }", file=sys.stderr)
R = np.stack([e for _, e in ref])
r_actor = [people.index(p) for p, _ in ref]
# The global gallery's sigmoid — NOT a fit over these four people. A Platt fit
# over a handful of identities saturates: it will hand back P=0.99 for faces it
# has no basis to separate, which is exactly how a wrong label acquires a
# convincing probability. The production fit spans the whole actor population,
# so a probability means the same thing here as it does in the matcher.
cal = sae_embed.gallery_calibration(GALLERY)
print(f"[calibration] global: {cal} assign boundary = sim "
f"{cal.boundary_at(ASSIGN_P):.4f}", file=sys.stderr)
# ── target side: propose ─────────────────────────────────────────────────────
tgt_rows = embed_manifest(TARGET_CLIP)
T = np.stack([t["emb"] for t in tgt_rows])
r_actor_arr = np.asarray(r_actor)
# per-actor best-of-N for every target crop at once: (n_people, n_target)
best_sim = np.stack([(R[r_actor_arr == people.index(p)] @ T.T).max(axis=0)
for p in people])
proposals = []
for j, t in enumerate(tgt_rows):
k = int(np.argmax(best_sim[:, j]))
prob = cal.probability(float(best_sim[k, j])) # calibrated, never a bare cosine
proposals.append({**t, "person": people[k] if prob >= ASSIGN_P else None,
"p": prob, "top1": people[k]})
# At the production threshold the global fit stays silent on most of these
# faces, which is the honest answer for profile and downward-gaze shots — but a
# labelling aid wants throughput, not caution. --all proposes the top-1 person
# for every crop and orders the review sheets by descending probability, so the
# proposals degrade visibly down the sheet and you can stop correcting where
# they stop being right. The probability is shown, never hidden.
if "--all" in sys.argv:
for x in proposals:
x["person"] = x["top1"]
named = [x for x in proposals if x["person"]]
print(f"[propose] {TARGET_CLIP}: {len(named)}/{len(proposals)} named at P>={ASSIGN_P}; "
f"{len(proposals) - len(named)} left unsorted", file=sys.stderr)
for p in people:
got = [x for x in named if x["person"] == p]
if got:
ps = [x["p"] for x in got]
print(f" {p}: {len(got):>3} crops P {min(ps):.3f}{max(ps):.3f}", file=sys.stderr)
if DRY:
sys.exit(0)
# ── write proposed folders, mirroring the ref clip's layout ──────────────────
ref_parent = os.path.dirname(next(iter(glob.glob(f"labelling/{REF_CLIP}/**/{people[0]}/",
recursive=True))).rstrip("/"))
tgt_parent = ref_parent.replace(REF_CLIP, TARGET_CLIP)
for p in people:
d = f"{tgt_parent}/{p}"
if os.path.isdir(d): # never clobber corrections already made
print(f"[skip] {d} exists — leaving your sorting alone", file=sys.stderr)
continue
os.makedirs(d, exist_ok=True)
def find_crop(clip, fname):
"""Locate a crop wherever it currently sits under labelling/<clip>."""
hits = glob.glob(f"labelling/{clip}/**/{fname}", recursive=True)
return hits[0] if hits else None
moved = 0
for x in named:
src = find_crop(TARGET_CLIP, x["file"])
dst = f"{tgt_parent}/{x['person']}/{x['file']}"
if src and os.path.abspath(src) != os.path.abspath(dst):
shutil.move(src, dst)
moved += 1
print(f"[write] moved {moved} crops into proposed folders", file=sys.stderr)
# ── review sheets: confirmed block, then proposed block ─────────────────────
def load(clip, person, fname):
for cand in glob.glob(f"labelling/{clip}/**/{person}/{fname}", recursive=True):
return cv2.imread(cand)
return None
for person in people:
conf = [(REF_CLIP, f, None) for f in ref_dirs.get(person, [])]
prop = sorted([(TARGET_CLIP, x["file"], x["p"]) for x in named
if x["person"] == person],
key=lambda t: -t[2]) # most confident first
items = conf + prop
if not items:
continue
rows_n = (len(items) + SHEET_COLS - 1) // SHEET_COLS
sheet = np.full((rows_n * (THUMB + 26), SHEET_COLS * THUMB, 3), 30, np.uint8)
for n, (clip, fname, p) in enumerate(items):
img = load(clip, person, fname)
if img is None:
continue
rr, cc = divmod(n, SHEET_COLS)
y, x = rr * (THUMB + 26), cc * THUMB
sheet[y:y + THUMB, x:x + THUMB] = cv2.resize(img, (THUMB, THUMB))
if p is None:
tag, col = f"{clip[-3:]} CONFIRMED", (170, 170, 170)
else:
tag, col = f"{clip[-3:]} P={p:.2f}", (140, 255, 140)
cv2.putText(sheet, tag, (x + 3, y + THUMB + 17),
cv2.FONT_HERSHEY_SIMPLEX, 0.42, col, 1)
cv2.imwrite(f"labelling/review_{person}.jpg", sheet)
print(f" review_{person}.jpg: {len(conf)} confirmed + {len(prop)} proposed",
file=sys.stderr)
json.dump({x["file"]: {"person": x["person"], "p": x["p"]} for x in proposals},
open(f"labelling/proposed_{TARGET_CLIP}.json", "w"), indent=1)
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Redraw every dumped crop with its detection box marked.
The original thumbnails padded by 0.5x the face on each side for
recognisability, which in a crowded frame pulls a neighbour into shot often
more prominently than the subject. A label cannot be corrected from a picture
that does not say which face it refers to.
This rewrites each .jpg IN PLACE, wherever it currently sits, so any sorting
already done is preserved: only the pixels change, never the filename or the
folder. Re-run it after dump_faces.py, and re-check any sorting done before it.
"""
import glob, json, os, sys
import cv2
CLIPS = ["5157339", "5157344"]
OUT = 256
for clip in CLIPS:
man = {m["file"]: m for m in json.load(open(f"labelling/{clip}/manifest.json"))}
n = 0
for path in glob.glob(f"labelling/{clip}/**/*.jpg", recursive=True):
fname = os.path.basename(path)
m = man.get(fname)
if m is None:
continue
img = cv2.imread(f"frames/d{clip}_{m['frame']}.png")
if img is None:
sys.exit(f"missing frames/d{clip}_{m['frame']}.png")
x, y, w, h = (int(v) for v in m["bbox"])
pad = int(0.55 * max(w, h))
x0, y0 = max(0, x - pad), max(0, y - pad)
x1, y1 = min(img.shape[1], x + w + pad), min(img.shape[0], y + h + pad)
sub = img[y0:y1, x0:x1].copy()
# Box in the sub-image's coordinates, drawn before the resize so the
# line lands exactly on the face at any output size.
cv2.rectangle(sub, (x - x0, y - y0), (x - x0 + w, y - y0 + h), (0, 0, 255), 3)
# Dim everything outside the box so the subject is unmistakable even
# when a neighbour's face is larger or better lit.
mask = sub.copy()
mask[y - y0:y - y0 + h, x - x0:x - x0 + w] = 0
sub = cv2.addWeighted(sub, 1.0, mask, -0.35, 0)
scale = OUT / max(sub.shape[:2])
sub = cv2.resize(sub, (int(sub.shape[1] * scale), int(sub.shape[0] * scale)))
canvas = cv2.copyMakeBorder(
sub, 0, max(0, OUT - sub.shape[0]), 0, max(0, OUT - sub.shape[1]),
cv2.BORDER_CONSTANT, value=(20, 20, 20))[:OUT, :OUT]
cv2.putText(canvas, f"{int(m['px'])}px", (5, OUT - 8),
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 255), 1)
cv2.imwrite(path, canvas)
n += 1
print(f"[{clip}] redrew {n} crops in place", file=sys.stderr)
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Impact of input resolution on cross-source identification.
TRACES: VR-013 | PR-002
Gallery is built from one clip at NATIVE resolution. Probes come from the other
clip with the WHOLE FRAME downscaled before it reaches the detector, so
detection and landmark regression degrade together with the pixels. That is the
measurement VR-005 structurally could not make: it degraded an already-aligned
112x112 crop, holding alignment perfect, so it isolated the embedder's
resolution sensitivity and excluded everything upstream of it.
python3 resolution_sweep.py [--gallery-clip 5157339] [--detector scrfd_500m_bnkps.onnx]
Ground truth
------------
Hand-sorted person folders. Probe detections at reduced scale are tied back to
a labelled face GEOMETRICALLY the box is mapped to native coordinates and
matched by IoU. Never by embedding similarity, which would be circular: it
would keep the faces the embedder still gets right and silently drop the ones
this sweep exists to find.
A probe whose label is only in the probe clip is OUT OF GALLERY. Naming it is a
true out-of-cast misID, the error the per-scene scorer weights 10x, so it is
counted separately from naming the wrong gallery member.
Metric
------
The calibrated probability from the PRODUCTION gallery sigmoid, never a raw
cosine (AR-024). Per-actor best-of-N similarity -> probability -> accept above
prob_threshold. This is identification, so the matcher's prior applies;
config.hpp has match_prior 0.5, i.e. log_prior_odds = 0.
Everything runs through the shipped C++ via sae_embed.
"""
import sys, glob, json, os, argparse
import numpy as np
import cv2
sys.path.insert(0, "/home/dtourolle/Development/Jray-project/scene-actor-extraction/build-ort")
import sae_embed
ROOT = "/home/dtourolle/Development/Jray-project/scene-actor-extraction/"
M = ROOT + "models/"
PROB_THRESHOLD = 0.754 # config.hpp:67
LOG_PRIOR_ODDS = 0.0 # config.hpp:61 match_prior=0.5
IOU_MIN = 0.3 # geometric label carry-down
SCALES = [1.0, 0.8, 0.6, 0.5, 0.4, 0.3, 0.25, 0.2, 0.15, 0.12, 0.09, 0.06]
ap = argparse.ArgumentParser()
ap.add_argument("--gallery-clip", default="5157339")
ap.add_argument("--probe-clip", default="5157344")
ap.add_argument("--detector", default="scrfd_500m_bnkps.onnx")
ap.add_argument("--embedder", default="LVFace-B_Glint360K.onnx")
ap.add_argument("--gallery-calibration", default=ROOT + "gallery_lvface.h5")
ap.add_argument("--out", default="results_resolution_sweep.json")
args = ap.parse_args()
eng = sae_embed.FaceEmbedder(detector_model=M + args.detector,
arcface_model=M + args.embedder,
conf=0.5, nms=0.4, max_side=0)
cal = sae_embed.gallery_calibration(args.gallery_calibration)
print(f"[calibration] global: {cal}", file=sys.stderr)
def labelled(clip):
"""{filename: person} from the hand-sorted folders, ignoring discard."""
out = {}
for path in glob.glob(f"labelling/{clip}/*/*.jpg"):
person = os.path.basename(os.path.dirname(path))
if person in ("discard", "unsorted"):
continue
out[os.path.basename(path)] = person
return out
def manifest(clip):
return {m["file"]: m for m in json.load(open(f"labelling/{clip}/manifest.json"))}
def iou(a, b):
ax, ay, aw, ah = a; bx, by, bw, bh = b
x0, y0 = max(ax, bx), max(ay, by)
x1, y1 = min(ax + aw, bx + bw), min(ay + ah, by + bh)
if x1 <= x0 or y1 <= y0:
return 0.0
inter = (x1 - x0) * (y1 - y0)
return inter / (aw * ah + bw * bh - inter)
# ── gallery: native resolution, labelled faces only ──────────────────────────
g_lab, g_man = labelled(args.gallery_clip), manifest(args.gallery_clip)
gal = {}
for frame in sorted({g_man[f]["frame"] for f in g_lab}):
img = cv2.imread(f"frames/d{args.gallery_clip}_{frame}.png")
dets = eng.detect(img)
for fname, person in g_lab.items():
m = g_man[fname]
if m["frame"] != frame or m["idx"] >= len(dets):
continue
lm = np.array(dets[m["idx"]].landmarks, dtype=np.float32).reshape(5, 2)
crop = sae_embed.align_face(img, lm)
if crop is None:
continue
gal.setdefault(person, []).append(np.asarray(eng.embed_crop(crop), dtype=np.float32))
gal = {p: np.stack(v) for p, v in gal.items() if v}
people = sorted(gal)
print(f"[gallery] {args.gallery_clip} @native: "
f"{ {p: len(v) for p, v in gal.items()} }", file=sys.stderr)
# ── probe ground truth at native resolution ──────────────────────────────────
p_lab, p_man = labelled(args.probe_clip), manifest(args.probe_clip)
truth = {} # frame -> [(bbox_native, person)]
for fname, person in p_lab.items():
m = p_man[fname]
truth.setdefault(m["frame"], []).append((m["bbox"], person))
n_out = sum(1 for p in set(p_lab.values()) if p not in people)
print(f"[probe] {args.probe_clip}: {len(p_lab)} labelled faces, "
f"{len(set(p_lab.values()))} people, {n_out} of them out-of-gallery",
file=sys.stderr)
# ── sweep ────────────────────────────────────────────────────────────────────
print(f"\n{'scale':>6}{'frame':>11}{'face px':>9}{'found':>7}{'matched':>9}"
f"{'TPI':>8}{'FPI-in':>8}{'FPI-out':>9}{'TBI':>8}")
results = []
for s in SCALES:
tpi = fpi_in = fpi_out = tbi = 0
n_found = n_matched = 0
pxs = []
for frame, gts in sorted(truth.items()):
img = cv2.imread(f"frames/d{args.probe_clip}_{frame}.png")
if s != 1.0:
img = cv2.resize(img, None, fx=s, fy=s, interpolation=cv2.INTER_AREA)
dets = eng.detect(img)
n_found += len(dets)
for d in dets:
x, y, w, h = d.bbox
native = (x / s, y / s, w / s, h / s) # geometric carry-down
best, best_iou = None, 0.0
for gt_box, person in gts:
v = iou(native, gt_box)
if v > best_iou:
best_iou, best = v, person
if best_iou < IOU_MIN:
continue # spurious / unlabelled
n_matched += 1
pxs.append(min(w, h))
lm = np.array(d.landmarks, dtype=np.float32).reshape(5, 2)
crop = sae_embed.align_face(img, lm)
if crop is None:
tbi += 1 # degenerate alignment
continue
emb = np.asarray(eng.embed_crop(crop), dtype=np.float32)
best_p, best_name = 0.0, None
for p in people: # per-actor best-of-N
prob = cal.probability(float((gal[p] @ emb).max()), LOG_PRIOR_ODDS)
if prob > best_p:
best_p, best_name = prob, p
if best_p <= PROB_THRESHOLD:
tbi += 1
elif best not in people:
fpi_out += 1 # named someone absent from the gallery
elif best_name == best:
tpi += 1
else:
fpi_in += 1
n = max(1, n_matched)
med_px = float(np.median(pxs)) if pxs else 0.0
print(f"{s:>6.2f}{f'{int(4096*s)}x{int(2160*s)}':>11}{med_px:>9.0f}"
f"{n_found:>7}{n_matched:>9}"
f"{100*tpi/n:>7.1f}%{100*fpi_in/n:>7.1f}%{100*fpi_out/n:>8.1f}%{100*tbi/n:>7.1f}%")
results.append({"scale": s, "median_face_px": med_px, "detections": n_found,
"matched_to_truth": n_matched, "tpi_pct": 100*tpi/n,
"fpi_in_gallery_pct": 100*fpi_in/n, "fpi_out_of_gallery_pct": 100*fpi_out/n,
"tbi_pct": 100*tbi/n})
json.dump({"gallery_clip": args.gallery_clip, "probe_clip": args.probe_clip,
"detector": args.detector, "embedder": args.embedder,
"prob_threshold": PROB_THRESHOLD, "log_prior_odds": LOG_PRIOR_ODDS,
"calibration": {"a": cal.a, "b": cal.b},
"gallery_people": people, "results": results},
open(args.out, "w"), indent=2)
print(f"\nwrote {args.out}", file=sys.stderr)
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Integrity check on the labelled set, before it is used as ground truth.
TRACES: VR-013 | PR-002
VR-013's ground truth is hand-sorted rather than propagated by embedding
similarity, because propagation would keep only the faces the embedder already
gets right and silently drop the ones the sweep exists to find. This script is
what makes that claim checkable, so it is part of the requirement rather than a
helper of it.
Checks, loudest failure first:
1. INDEX INTEGRITY. Each crop's embedding is taken by re-detecting its source
frame and indexing with the manifest's `idx`. If detection order is not
reproducible, the thumbnail you sorted and the embedding that gets scored
are different faces you would see a correct picture and score the wrong
person, with nothing to signal it. Every crop's re-detected bbox is compared
against the manifest's.
2. NO CROP IN TWO FOLDERS, and every manifest entry accounted for so a
move that half-completed cannot silently duplicate or drop a label.
3. ALIGNMENT. The 112x112 warp is what the embedder actually sees; the
thumbnail is only context for your eyes. verify_<person>.jpg pairs them:
context-with-box on top, the real aligned crop beneath. A profile face whose
alignment has collapsed is obvious there and nowhere else.
4. SEPARATION. Per person, the calibrated P of their own crops against the
other people's, using the global gallery sigmoid. A label set where someone
matches another person better than themselves is mislabelled.
Nothing here changes a label. It reports.
"""
import sys, glob, json, os
import numpy as np
import cv2
sys.path.insert(0, "/home/dtourolle/Development/Jray-project/scene-actor-extraction/build-ort")
import sae_embed
ROOT = "/home/dtourolle/Development/Jray-project/scene-actor-extraction/"
M = ROOT + "models/"
EMBEDDER = M + "LVFace-B_Glint360K.onnx"
GALLERY = ROOT + "gallery_lvface.h5"
CLIPS = ["5157344", "5157339"]
THUMB = 130
COLS = 10
eng = sae_embed.FaceEmbedder(detector_model=M + "scrfd_500m_bnkps.onnx",
arcface_model=EMBEDDER, conf=0.5, nms=0.4, max_side=0)
fail = 0
rows = []
for clip in CLIPS:
man = {m["file"]: m for m in json.load(open(f"labelling/{clip}/manifest.json"))}
# where each crop currently sits -> its label
placed = {}
for path in glob.glob(f"labelling/{clip}/*/*.jpg"):
person = os.path.basename(os.path.dirname(path))
if person in ("discard", "unsorted"):
continue # not people; scoring them would invent an extra identity
fname = os.path.basename(path)
if fname in placed:
print(f"[FAIL] {fname} appears in both {placed[fname][0]} and {person}")
fail += 1
placed[fname] = (person, path)
missing = set(man) - set(placed)
extra = set(placed) - set(man)
if missing:
print(f"[warn] {clip}: {len(missing)} manifest crops not in any folder")
if extra:
print(f"[FAIL] {clip}: {len(extra)} files with no manifest entry: "
f"{sorted(extra)[:3]}")
fail += 1
# index integrity + alignment, frame by frame
by_frame = {}
for fname, (person, path) in placed.items():
if fname in man:
by_frame.setdefault(man[fname]["frame"], []).append((fname, person, path))
bad_idx = 0
for frame, items in sorted(by_frame.items()):
img = cv2.imread(f"frames/d{clip}_{frame}.png")
if img is None:
print(f"[FAIL] missing frames/d{clip}_{frame}.png")
fail += 1
continue
dets = eng.detect(img)
for fname, person, path in items:
m = man[fname]
i = m["idx"]
if i >= len(dets):
print(f"[FAIL] {fname}: idx {i} >= {len(dets)} detections now")
bad_idx += 1
continue
got = [float(v) for v in dets[i].bbox]
want = m["bbox"]
if max(abs(a - b) for a, b in zip(got, want)) > 1.0:
print(f"[FAIL] {fname}: manifest bbox {[round(v) for v in want]} "
f"!= re-detected {[round(v) for v in got]}")
bad_idx += 1
continue
lm = np.array(dets[i].landmarks, dtype=np.float32).reshape(5, 2)
crop = sae_embed.align_face(img, lm)
if crop is None:
print(f"[warn] {fname}: alignment degenerate, no crop reaches the embedder")
continue
rows.append({"clip": clip, "person": person, "file": fname, "path": path,
"px": m["px"], "aligned": np.asarray(crop),
"emb": np.asarray(eng.embed_crop(crop), dtype=np.float32)})
fail += bad_idx
print(f"[{clip}] {len(placed)} placed, {len(by_frame)} frames, "
f"index mismatches: {bad_idx}")
if not rows:
sys.exit("nothing to verify")
# ── separation, through the global gallery sigmoid ───────────────────────────
cal = sae_embed.gallery_calibration(GALLERY)
E = np.stack([r["emb"] for r in rows])
people = sorted({r["person"] for r in rows})
lab = np.array([people.index(r["person"]) for r in rows])
S = E @ E.T
np.fill_diagonal(S, -1.0)
print(f"\n{'person':>8}{'crops':>7}{'344':>6}{'339':>6}"
f"{'P(self)':>10}{'P(other)':>10}{'worst':>8}")
for k, p in enumerate(people):
mine = np.where(lab == k)[0]
if len(mine) < 2:
continue
self_sim = S[np.ix_(mine, mine)].max(axis=1)
other_sim = S[np.ix_(mine, np.where(lab != k)[0])].max(axis=1)
p_self = np.array([cal.probability(float(s)) for s in self_sim])
p_other = np.array([cal.probability(float(s)) for s in other_sim])
n344 = sum(1 for i in mine if rows[i]["clip"] == "5157344")
n339 = len(mine) - n344
# a crop that matches someone else better than anyone of its own label
worst = int((other_sim > self_sim).sum())
print(f"{p:>8}{len(mine):>7}{n344:>6}{n339:>6}"
f"{np.median(p_self):>10.3f}{np.median(p_other):>10.3f}{worst:>8}")
if worst:
for i in mine[other_sim > self_sim]:
print(f" suspect: {rows[i]['file']} "
f"P(self)={cal.probability(float(self_sim[list(mine).index(i)])):.3f} "
f"< P(other)={cal.probability(float(other_sim[list(mine).index(i)])):.3f}")
# ── verify sheets: context+box over the actual aligned crop ──────────────────
for p in people:
items = [r for r in rows if r["person"] == p]
items.sort(key=lambda r: (r["clip"], r["file"]))
n = len(items)
sheet_rows = (n + COLS - 1) // COLS
H = THUMB * 2 + 22
sheet = np.full((sheet_rows * H, COLS * THUMB, 3), 25, np.uint8)
for j, r in enumerate(items):
rr, cc = divmod(j, COLS)
y, x = rr * H, cc * THUMB
ctx = cv2.imread(r["path"])
if ctx is not None:
sheet[y:y + THUMB, x:x + THUMB] = cv2.resize(ctx, (THUMB, THUMB))
sheet[y + THUMB:y + 2 * THUMB, x:x + THUMB] = cv2.resize(r["aligned"], (THUMB, THUMB))
cv2.putText(sheet, f"{r['clip'][-3:]} {int(r['px'])}px",
(x + 3, y + 2 * THUMB + 15),
cv2.FONT_HERSHEY_SIMPLEX, 0.38, (150, 220, 150), 1)
cv2.imwrite(f"labelling/verify_{p}.jpg", sheet)
print(f" verify_{p}.jpg: {n} crops (top row context, bottom row what the embedder sees)")
print(f"\n{'PASS' if fail == 0 else f'{fail} FAILURES'}")
sys.exit(1 if fail else 0)
+1 -1
+9 -5
View File
@@ -35,13 +35,17 @@ extra_css:
nav:
- Home: index.md
- How We Score Against X-Ray: methodology.md
- Findings:
- Best Model: best-model.md
- Gallery Scope (Full vs. Limited): gallery-scope.md
- Pose Expansion: pose-expansion.md
- LVFace Deep Dive: lvface-deep-dive.md
- Learned Scene-Boundary Detector: scene-boundary-detector.md
- Benchmark — SuperHero: benchmark.md
- Full Experiment Log: model-bakeoff.md
- Service Conversion (proposal): service-conversion.md
- Archive (July 2026):
- How We Scored (July): methodology-2026-07.md
- Best Model: best-model-2026-07.md
- Gallery Scope (Full vs. Limited): gallery-scope-2026-07.md
- Pose Expansion: pose-expansion-2026-07.md
- LVFace Deep Dive: lvface-deep-dive-2026-07.md
- Full Experiment Log (July): model-bakeoff-2026-07.md
markdown_extensions:
- admonition
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+89 -1
View File
@@ -10,7 +10,9 @@
# scripts/artifacts/pull_artifacts.sh galleries [version]
# scripts/artifacts/pull_artifacts.sh montage-frames <film-slug> [version]
# scripts/artifacts/pull_artifacts.sh experiment-data [version]
# scripts/artifacts/pull_artifacts.sh replay-fixtures [version]
# scripts/artifacts/pull_artifacts.sh report-highlights <name> [version]
# scripts/artifacts/pull_artifacts.sh xsource [version]
# version defaults to "latest" (newest uploaded version, by created_at).
set -euo pipefail
@@ -41,6 +43,22 @@ print(matches[-1]['version'])
"
}
pull_replay_fixtures() {
local version="$1"
local dest="${REPO_ROOT}/tests/fixtures/dumps"
mkdir -p "$dest"
echo "=== replay-fixtures (version ${version}) ==="
local tmp; tmp="$(mktemp -d)"
if curl -sf "${DL_BASE}/generic/replay-fixtures/${version}/replay-fixtures.zip" \
-o "${tmp}/f.zip"; then
unzip -qo "${tmp}/f.zip" -d "$dest"
echo " restored: $(ls "$dest" | wc -l) files into tests/fixtures/dumps/"
else
echo " [warn] replay-fixtures.zip not found at version ${version}" >&2
fi
rm -rf "$tmp"
}
pull_galleries() {
local version="$1"
local dest="${REPO_ROOT}/experiments/galleries"
@@ -83,11 +101,71 @@ pull_report_highlight() {
curl -sf "${DL_BASE}/generic/report-highlights/${version}/${name}" -o "${dest}/${name}"
}
pull_xsource() {
local version="$1"
local dest="${REPO_ROOT}/experiments/xsource"
echo "=== xsource (version ${version}) ==="
mkdir -p "${dest}/clips" "${dest}/frames"
for clip in 5157339 5157344; do
if [ -f "${dest}/clips/${clip}.mp4" ]; then
echo " ${clip}.mp4 already present, skipping"
else
echo " fetching ${clip}.mp4..."
curl -sf "${DL_BASE}/generic/xsource/${version}/${clip}.mp4" \
-o "${dest}/clips/${clip}.mp4" \
|| { echo " [warn] ${clip}.mp4 not found at version ${version}" >&2; continue; }
fi
done
if [ -d "${dest}/labelling" ]; then
echo " labelling/ already present — NOT overwriting (it is hand-sorted"
echo " ground truth; move it aside first if you really want the remote copy)"
else
echo " fetching labelling.zip..."
local tmp; tmp="$(mktemp)"
curl -sf "${DL_BASE}/generic/xsource/${version}/labelling.zip" -o "$tmp"
unzip -qo "$tmp" -d "$dest"
rm "$tmp"
fi
# Frames are regenerated rather than shipped: they are ~320 MB of PNG that
# ffmpeg reproduces exactly from the clips. The manifests key on these
# filenames and on detection order within each frame, so the extraction
# settings must match the ones dump_faces.py ran against — hence fps and
# frame count are pinned here rather than left to the caller.
if ! command -v ffmpeg >/dev/null; then
echo " [warn] ffmpeg not found — frames not regenerated; the study" >&2
echo " scripts will fail until you extract them" >&2
return
fi
for clip in 5157339 5157344; do
[ -f "${dest}/clips/${clip}.mp4" ] || continue
if [ -f "${dest}/frames/d${clip}_001.png" ]; then
echo " frames for ${clip} already present, skipping"
continue
fi
echo " extracting frames for ${clip}..."
ffmpeg -v error -i "${dest}/clips/${clip}.mp4" -vf fps=2 -frames:v 24 \
"${dest}/frames/d${clip}_%03d.png"
done
echo " verifying the labelled set..."
if (cd "$dest" && python3 verify_labels.py >/dev/null 2>&1); then
echo " verify_labels.py passed"
else
echo " [warn] verify_labels.py failed — run it directly to see why." >&2
echo " A frame/manifest mismatch means the extraction settings" >&2
echo " differ from the ones the crops were dumped against." >&2
fi
}
if [ $# -eq 0 ]; then
echo "usage: $0 galleries [version]" >&2
echo " $0 montage-frames <film-slug> [version]" >&2
echo " $0 experiment-data [version]" >&2
echo " $0 report-highlights <name> [version]" >&2
echo " $0 xsource [version]" >&2
exit 1
fi
@@ -115,8 +193,18 @@ case "$TARGET" in
[ "$VERSION" = "latest" ] && VERSION="$(resolve_latest_version report-highlights)"
pull_report_highlight "$VERSION" "$NAME"
;;
xsource)
VERSION="${2:-latest}"
[ "$VERSION" = "latest" ] && VERSION="$(resolve_latest_version xsource)"
pull_xsource "$VERSION"
;;
replay-fixtures)
VERSION="${2:-latest}"
[ "$VERSION" = "latest" ] && VERSION="$(resolve_latest_version replay-fixtures)"
pull_replay_fixtures "$VERSION"
;;
*)
echo "unknown target: $TARGET (expected galleries, montage-frames, experiment-data, or report-highlights)" >&2
echo "unknown target: $TARGET (expected galleries, montage-frames, experiment-data, report-highlights, xsource, or replay-fixtures)" >&2
exit 1
;;
esac
+57 -2
View File
@@ -12,10 +12,13 @@
# scripts/artifacts/push_artifacts.sh montage-frames
# scripts/artifacts/push_artifacts.sh experiment-data
# scripts/artifacts/push_artifacts.sh report-highlights
# scripts/artifacts/push_artifacts.sh xsource
# scripts/artifacts/push_artifacts.sh replay-fixtures
# scripts/artifacts/push_artifacts.sh galleries montage-frames experiment-data report-highlights
#
# Package layout (owner=dtourolle, repo=scene-actor-extraction):
# generic/galleries/<version>/gallery_<model>.h5 (one file per model)
# generic/replay-fixtures/<version>/replay-fixtures.zip (T2 dumps + their gallery)
# generic/montage-frames/<version>/<film-slug>.zip (zipped per-film frames)
# generic/experiment-data/<version>/experiment-data.zip (manifests/trajectories/results)
# generic/report-highlights/<version>/<name>.jpg (individual, hand-picked
@@ -49,6 +52,29 @@ upload() {
-o /dev/null -w " HTTP %{http_code}\n"
}
push_replay_fixtures() {
echo "=== replay-fixtures (version ${VERSION}) ==="
# T2 replay fixtures: per-frame detections, landmarks and embeddings dumped
# from a real run, so the tracker and identity stages can be replayed on CPU
# with no GPU, no models and no film. Too large for git (superhero.h5 alone
# is ~9 MB) and regenerating them needs the film plus a GPU, which CI has
# neither of — so they ship as artifacts and CI pulls them.
#
# The gallery travels with them: a dump replays against the gallery it was
# produced with, and pairing a dump with a different gallery silently
# changes every identity decision in it.
local dir="${REPO_ROOT}/tests/fixtures/dumps"
if [ ! -d "$dir" ]; then
echo " no tests/fixtures/dumps dir, skipping" >&2
return
fi
local tmp
tmp="$(mktemp -d)"
( cd "$dir" && zip -qr "$tmp/replay-fixtures.zip" . )
upload "replay-fixtures" "replay-fixtures.zip" "$tmp/replay-fixtures.zip"
rm -rf "$tmp"
}
push_galleries() {
echo "=== galleries (version ${VERSION}) ==="
local dir="${REPO_ROOT}/experiments/galleries"
@@ -109,8 +135,35 @@ push_report_highlights() {
upload "report-highlights" "germar_beats_xray.jpg" "$src"
}
push_xsource() {
echo "=== xsource (version ${VERSION}) ==="
local root="${REPO_ROOT}/experiments/xsource"
if [ ! -d "$root/labelling" ]; then
echo " no experiments/xsource/labelling found, skipping" >&2
return
fi
# Source recordings. Already compressed, so uploaded as-is rather than zipped.
shopt -s nullglob
for f in "$root"/clips/*.mp4; do
upload "xsource" "$(basename "$f")" "$f"
done
shopt -u nullglob
# The hand-sorted crops and their manifests. This is human ground truth and
# the expensive part of the study — a person looked at every crop and put it
# in a folder. Frames are deliberately NOT pushed: they are deterministic
# from the clips, and pulling regenerates them.
local tmp; tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
local zipfile="${tmp}/labelling.zip"
(cd "$root" && zip -qr "$zipfile" labelling -x 'labelling/*.html' -x 'labelling/review_*.jpg' \
-x 'labelling/verify_*.jpg')
upload "xsource" "labelling.zip" "$zipfile"
}
if [ $# -eq 0 ]; then
echo "usage: $0 <galleries|montage-frames|experiment-data|report-highlights> [...]" >&2
echo "usage: $0 <galleries|montage-frames|experiment-data|report-highlights|xsource> [...]" >&2
exit 1
fi
@@ -120,7 +173,9 @@ for target in "$@"; do
montage-frames) push_montage_frames ;;
experiment-data) push_experiment_data ;;
report-highlights) push_report_highlights ;;
*) echo "unknown target: $target (expected galleries, montage-frames, experiment-data, or report-highlights)" >&2; exit 1 ;;
xsource) push_xsource ;;
replay-fixtures) push_replay_fixtures ;;
*) echo "unknown target: $target (expected galleries, montage-frames, experiment-data, report-highlights, xsource, or replay-fixtures)" >&2; exit 1 ;;
esac
done
+118
View File
@@ -0,0 +1,118 @@
#!/bin/bash
# build_builder_image.sh — build and publish the DP-007 CI builder image to the
# Gitea container registry.
#
# TRACES: DP-007 | PR-004
#
# Usage:
# scripts/ci/build_builder_image.sh # build only, tag v1
# scripts/ci/build_builder_image.sh --push # build and push
# scripts/ci/build_builder_image.sh --tag v2 --push # bump the pinned tag
# scripts/ci/build_builder_image.sh --no-cache # force a clean rebuild
#
# The tag is the contract with CI. .gitea/workflows/unit-tests.yml names an
# explicit tag in its `container:` block and never `latest`, so that rebuilding
# the image cannot silently change what a previous green build meant. Bumping
# the dependency set means bumping the tag AND editing the workflow — the two
# edits landing in the same commit is the point, not an inconvenience.
#
# Registry auth: this script does not log in. Do it once, out of band:
# docker login gitea.tourolle.paris
# The CI host is already authenticated this way (its cached credentials in
# ~/.docker/config.json are what the kpnpp-builder push relies on), so a
# workflow that calls this script needs no secret plumbing.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
REGISTRY="gitea.tourolle.paris"
OWNER="dtourolle"
IMAGE="sae-builder-cpu"
DOCKERFILE="Dockerfile.builder-cpu"
# The tag CI pins to today. Keep this in step with the `container.image` line in
# .gitea/workflows/unit-tests.yml; the workflow asserts at run time that the
# image it landed in reports this same version, so a drift shows up as a failed
# job rather than as a build against the wrong toolchain.
TAG="v1"
PUSH=0
EXTRA_ARGS=()
while [ $# -gt 0 ]; do
case "$1" in
--push) PUSH=1 ;;
--tag) TAG="${2:?--tag needs a value}"; shift ;;
--no-cache) EXTRA_ARGS+=(--no-cache) ;;
-h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}"; exit 0 ;;
*) echo "error: unknown argument '$1'" >&2; exit 2 ;;
esac
shift
done
if [ "$TAG" = "latest" ]; then
echo "error: refusing to build the tag 'latest'." >&2
echo "DP-007 requires CI to pin an immutable tag. A moving 'latest' means a" >&2
echo "rebuild retroactively changes what every earlier green build proved." >&2
exit 2
fi
REF="${REGISTRY}/${OWNER}/${IMAGE}:${TAG}"
# A second tag carrying the commit that produced the image. The workflow pins
# the human-readable tag; this one is the audit trail — given any image you can
# recover the Dockerfile that built it.
SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD)"
REF_SHA="${REGISTRY}/${OWNER}/${IMAGE}:${TAG}-${SHA}"
# The Dockerfile COPYs nothing from the repository on purpose (see its closing
# comment), so the build context is an empty directory rather than the repo
# root. Sending ~1 GB of models, fixtures and experiment data to the daemon for
# a build that reads none of it is pure latency.
CONTEXT="$(mktemp -d)"
trap 'rm -rf "$CONTEXT"' EXIT
echo "=== building ${REF}"
echo " dockerfile: ${REPO_ROOT}/${DOCKERFILE}"
echo " context: (empty — the image embeds no repository content)"
echo
echo " Expect this to take a while: OpenCV 5 is compiled from source because"
echo " no Debian release ships it. That cost is paid once per image, which is"
echo " the entire reason DP-007 asks for a prebuilt image instead of"
echo " installing dependencies inside each CI run."
echo
docker build \
"${EXTRA_ARGS[@]}" \
--build-arg "IMAGE_TAG=${TAG}" \
-f "${REPO_ROOT}/${DOCKERFILE}" \
-t "${REF}" \
-t "${REF_SHA}" \
"${CONTEXT}"
echo
echo "=== built"
docker image inspect "${REF}" --format ' {{.RepoTags}} {{.Size}} bytes'
docker run --rm "${REF}" sh -c 'echo " SAE_BUILDER=$SAE_BUILDER version=$SAE_BUILDER_VERSION ort=$SAE_ORT_VERSION opencv=$SAE_OPENCV_VERSION"'
if [ "$PUSH" -eq 0 ]; then
echo
echo "Not pushed. Re-run with --push, or push by hand:"
echo " docker push ${REF}"
echo " docker push ${REF_SHA}"
exit 0
fi
echo
echo "=== pushing"
# No `latest` tag is pushed, by design. Publishing one invites a workflow to use
# it, and DP-007 exists to prevent exactly that.
docker push "${REF}"
docker push "${REF_SHA}"
echo
echo "=== published ${REF}"
echo "If this was a dependency-set change, bump the tag in"
echo " .gitea/workflows/unit-tests.yml (container.image)"
echo " scripts/ci/build_builder_image.sh (TAG, above)"
echo "in the same commit, so no run can build against an image the repository"
echo "does not describe."
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Enforce the AR-024 invariant: never a raw cosine, always the calibration.
TRACES: AR-024 | SR-002
docs/requirements.md gives AR-024's verification tier as "Static check -- no
bare cosine outside a tagged EXCEPTION | Grep-based; this is the invariant's
enforcement". This is that check. Until it existed the invariant was enforced
by reading, and reading missed a live violation: the identity matcher's
no-calibration fallback thresholded raw cosine distance and fed `max(0, cosine)`
into the Bayesian accumulation as though it were a posterior.
WHAT IT CHECKS, precisely, because a static check that overclaims its reach is
worse than one with a stated scope:
Every call to `cosine_similarity(...)` in C++ source must either
(a) have its result consumed immediately by a calibration -- the call is
textually wrapped in `cal_(...)`, `calibrate_(...)`, `.probability(...)`
or similar; or
(b) sit under an exception comment -- the token is `EXCEPTION:` followed by
`AR-024` and a reason -- within EXCEPTION_SCOPE_LINES above it.
Note that this file deliberately never spells that token out. The traceability
extractor scans scripts/ as source, so prose here describing the tag would be
counted as recorded exceptions; four of them were, until this was noticed. The
same trap the shared config warns about for the vendored parser tests.
Anything else is a defect, per CLAUDE.md: "treat any bare cosine comparison in
the code as a defect to be fixed".
WHAT IT DOES NOT CHECK, and why you should not read a pass as more than it is:
- It cannot follow a cosine through a variable across statements. A file that
stores `float s = cosine_similarity(a, b);` and compares `s` three lines
later is not caught. The codebase does not currently do this, and this check
exists partly to keep it that way, but it is a convention backed by review,
not by the tool.
- It says nothing about GEMM output. The similarity engine returns a whole
matrix of cosines and the matcher reads them directly; that path is correct
by inspection (every value goes through `cal_.probability`) and is not
verified here.
- A retired constant reintroduced under a new name is invisible to it.
Exit status is 0 when clean, 1 when a violation is found, 2 on a usage error.
"""
import argparse
import pathlib
import re
import sys
# How far above a use an exception tag may sit and still cover it.
# Generous, because the house style puts a paragraph of reasoning between the
# tag and the code -- but bounded, so a tag cannot silently cover a whole file.
EXCEPTION_SCOPE_LINES = 25
CPP_SUFFIXES = {".h", ".hpp", ".hxx", ".cc", ".cpp", ".cxx", ".cu", ".cuh"}
# src only, deliberately. The invariant governs what the PIPELINE decides --
# CLAUDE.md's rule is "tag the unit that decides" -- whereas a test legitimately
# asserts properties of the metric space itself (that a vector's cosine with
# itself is 1, that the annex ended up holding the spoke it should have). Those
# are measurements of the code under test, not decisions shipped to a user, and
# sweeping them in would produce a wall of blanket EXCEPTION tags that would
# devalue the tag everywhere else. Pass --source-root tests to scan them anyway.
DEFAULT_ROOTS = ["src"]
# Directories that are never this repo's code.
EXCLUDE_DIRS = {
"build", "build-ort", "external", "vendor", "__pycache__",
".git", "node_modules", "models",
}
COSINE_CALL = re.compile(r"\bcosine_similarity\s*\(")
# The result is immediately handed to a calibration. Matches the house shapes:
# cal_(cosine_similarity(a, b))
# calibrate_(cosine_similarity(a, b))
# same_person(cosine_similarity(a, b))
# cal_.probability(cosine_similarity(a, b))
CALIBRATED = re.compile(
r"(?:\b(?:cal_|cal|calibrate_|calibrate|same_person|same_person_probability)"
r"\s*(?:\.\s*probability\s*)?\(\s*|\.\s*probability\s*\(\s*)"
r"cosine_similarity\s*\("
)
EXCEPTION_TAG = re.compile(r"EXCEPT" + r"ION:\s*AR-" + r"024\b(.*)")
# The function's own definition is not a use of it.
DEFINITION = re.compile(r"^\s*(?:inline\s+|static\s+|constexpr\s+)*float\s+"
r"cosine_similarity\s*\(")
# The house style wraps long calls across lines:
# const float p = calibrate_(
# cosine_similarity(a, b));
# so the calibration and the call it guards are not always on one line. Joining
# a small window before testing is what makes this check usable on real code
# rather than a generator of false positives that trains people to ignore it.
JOIN_LOOKBEHIND = 2
def iter_sources(root: pathlib.Path, roots):
for rel in roots:
base = root / rel
if not base.exists():
continue
for p in sorted(base.rglob("*")):
if p.suffix.lower() not in CPP_SUFFIXES:
continue
if any(part in EXCLUDE_DIRS for part in p.relative_to(root).parts):
continue
yield p
def covering_exception(lines, idx):
"""Return the reason text of an exception tag covering line `idx`."""
lo = max(0, idx - EXCEPTION_SCOPE_LINES)
for j in range(idx, lo - 1, -1):
m = EXCEPTION_TAG.search(lines[j])
if m:
return m.group(1).strip(" -—*/") or "(no reason given)"
return None
def check_file(path: pathlib.Path, root: pathlib.Path):
violations, exceptions = [], []
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError as e:
print(f"error: cannot read {path}: {e}", file=sys.stderr)
return violations, exceptions
rel = path.relative_to(root)
for i, line in enumerate(lines):
if not COSINE_CALL.search(line):
continue
# A comment mentioning the function is prose, not a use.
stripped = line.lstrip()
if stripped.startswith(("//", "///", "*", "/*")):
continue
if DEFINITION.match(line):
continue
# Join a small window so a call wrapped across lines is still seen as
# calibrated. Whitespace is collapsed so the join reads as one statement.
window = " ".join(
lines[max(0, i - JOIN_LOOKBEHIND):i + 1]
)
window = re.sub(r"\s+", " ", window)
if CALIBRATED.search(window):
continue
reason = covering_exception(lines, i)
if reason:
exceptions.append((rel, i + 1, line.strip(), reason))
else:
violations.append((rel, i + 1, line.strip()))
return violations, exceptions
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--root", default=None,
help="repository root (default: the script's ../..)")
ap.add_argument("--source-root", action="append", default=None,
help="directory to scan; repeatable (default: src, tests)")
args = ap.parse_args()
root = pathlib.Path(args.root) if args.root \
else pathlib.Path(__file__).resolve().parents[2]
roots = args.source_root or DEFAULT_ROOTS
if not root.is_dir():
print(f"error: root {root} is not a directory", file=sys.stderr)
return 2
all_violations, all_exceptions, n_files = [], [], 0
for p in iter_sources(root, roots):
n_files += 1
v, e = check_file(p, root)
all_violations += v
all_exceptions += e
if n_files == 0:
# A scan that found nothing to read is a misconfiguration reporting a
# pass, which is the failure mode the traceability gate also guards.
print(f"error: scanned 0 source files under {root} ({', '.join(roots)})",
file=sys.stderr)
return 2
print("AR-024 — always the calibrated probability, never a raw cosine")
print("=" * 72)
print(f"Repo root : {root}")
print(f"Files scanned : {n_files} ({', '.join(roots)})")
print(f"Recorded excs. : {len(all_exceptions)}")
print(f"Violations : {len(all_violations)}")
if all_exceptions:
print("\nRecorded exceptions (allowed, and each one is a claim to re-read):")
for rel, ln, src, reason in all_exceptions:
print(f" {rel}:{ln} {reason}")
print(f" {src}")
if all_violations:
print("\nVIOLATIONS — a bare cosine with no recorded exception:")
for rel, ln, src in all_violations:
print(f" {rel}:{ln}")
print(f" {src}")
print("\nEvery similarity is converted through the sigmoid calibration")
print("before it is used, compared, or thresholded. A raw cosine means")
print("something different for every model, gallery and face size, and")
print("it cannot be combined with anything else.")
print("\nEither route it through the calibration, or, if the use is")
print("genuinely about the metric space rather than about a decision,")
print("record it:")
print(" // " + "EXCEPT" + "ION: AR-" + "024 <why this one is not a decision>")
print("and add a row to CLAUDE.md's agreed-exceptions table.")
return 1
print("\nOK: no bare cosine outside a recorded exception.")
return 0
if __name__ == "__main__":
sys.exit(main())
+3 -2
View File
@@ -79,8 +79,9 @@ def main():
"--dump", str(dump), "--gallery", str(gallery),
"--out", str(pred_path),
"--prob-threshold", str(cfg["prob_threshold"]),
"--anneal-sec", str(cfg["anneal_sec"]),
"--extinction-sec", str(cfg["extinction_sec"]),
# anneal_sec and extinction_sec are both gone: presence is
# the registry's, built from track extents (AR-012/AR-013), and
# replay.py no longer windows anything itself (VR-011).
"--expand-gallery",
]
print(f"RUN {model}/{film['slug']}...", file=sys.stderr)
+98
View File
@@ -0,0 +1,98 @@
#!/bin/bash
# fetch_dvu.sh — pull one film's character mugshots and presence annotations from
# the NIST TRECVID Deep Video Understanding development set.
#
# The DVU dev set is the reason Road to Bali is our benchmark film: it ships
# 5-7 face crops per *character*, cut from the film itself, alongside
# scene-scoped presence annotations. That matches SR-002 directly — presence is
# per scene, not per frame — and it keeps ground truth in character space, so
# scoring needs no actor->character mapping.
#
# This exists as a script, rather than as ad hoc commands, because the first
# copy of this data lived in a temp directory and was lost to a /tmp wipe,
# taking the working gallery with it.
#
# 14 films are asserted Creative Commons and need no data agreement (only the
# 5 KinoLorber test films are gated).
#
# Usage:
# scripts/fetch_dvu.sh [film] [dest]
# film default Road_To_Bali
# dest default ./dvu
set -euo pipefail
BASE="https://www-nlpir.nist.gov/projects/trecvid/dvu/dvu.development.dataset"
FILM="${1:-Road_To_Bali}"
DEST="${2:-dvu}"
mkdir -p "$DEST/images" "$DEST/scenes"
echo "[dvu] $FILM -> $DEST"
# Scene segmentation: start/end as HH:MM:SS. Note valkaama.csv line 38 carries a
# shift-key typo (01:!4:00) — parse defensively if you extend this to that film.
echo "[dvu] scene segmentation"
curl -fsSL "$BASE/scene.segmentation.reference/${FILM}.csv" \
-o "$DEST/${FILM}.csv" || echo " (missing: ${FILM}.csv)"
# Entity types: which entities are Person vs Location/Concept. Only Person rows
# become gallery identities — the images/ directory also holds Location and
# Concept crops (bedroom, boat, ...), which must not enter a face gallery.
#
# Directory and file naming are inconsistent with the film slug used elsewhere:
# the folder is Road_to_Bali (lowercase "to") while the entity file is
# RoadToBali.entity.types.txt. Both are derived here rather than assumed.
# NIST is inconsistent across all three axes, and not by a rule worth deriving:
# Road to Bali is Road_To_Bali.csv / Road_to_Bali/ / RoadToBali.entity.types.txt,
# while SuperHero is SuperHero.csv / superHero/ / superhero.entity.types.txt.
# Defaults cover the Bali shape; override per film rather than guessing.
# KG_DIR=superHero KG_FILE=superhero scripts/fetch_dvu.sh SuperHero dvu-hero
KG_DIR="${KG_DIR:-${FILM//_To_/_to_}}"
KG_FILE="${KG_FILE:-$(echo "$FILM" | sed -E 's/_([a-z])/\U\1/g; s/_//g')}"
echo "[dvu] entity types ($KG_DIR/$KG_FILE)"
curl -fsSL "$BASE/movie_knowledge_graph/${KG_DIR}/${KG_FILE}.entity.types.txt" \
-o "$DEST/${FILM}.entity.types.txt" || echo " (missing: entity types)"
# Character face crops. Names are discovered from the directory listing rather
# than probed as <Character>_N, since the crop count varies per character and
# the listing is authoritative.
echo "[dvu] character mugshots"
PERSONS="$DEST/persons.txt"
if [ -f "$DEST/${FILM}.entity.types.txt" ]; then
grep -iE "person" "$DEST/${FILM}.entity.types.txt" \
| sed -E 's/[[:space:]]*[:,].*$//' | tr -d '\r' \
| awk '{print tolower($1)}' | sort -u > "$PERSONS"
fi
curl -fsSL "$BASE/movie_knowledge_graph/${KG_DIR}/images/" 2>/dev/null \
| grep -oE 'href="[^"?/][^"]*\.png"' | sed -E 's/href="//; s/"//' | sort -u \
> "$DEST/all_images.txt"
while read -r img; do
[ -z "$img" ] && continue
# Strip the trailing _N to recover the entity name.
who="$(echo "$img" | sed -E 's/_[0-9]+\.png$//' | awk '{print tolower($0)}')"
if [ -s "$PERSONS" ] && ! grep -qx "$who" "$PERSONS"; then
continue # Location/Concept crop, not a face
fi
curl -fsSL "$BASE/movie_knowledge_graph/${KG_DIR}/images/${img}" \
-o "$DEST/images/${img}" 2>/dev/null || rm -f "$DEST/images/${img}"
done < "$DEST/all_images.txt"
# Per-scene knowledge graphs. A Person->Location edge means that person was
# present for the whole scene. Some of these contain a stray ", ," that breaks
# strict JSON parsers.
echo "[dvu] scene graphs"
for n in $(seq 1 60); do
curl -fsSL "$BASE/scenes_knowledge_graphs/${FILM//_/ }-${n}.json" \
-o "$DEST/scenes/${FILM}-${n}.json" 2>/dev/null \
|| curl -fsSL "$BASE/scenes_knowledge_graphs/${FILM}-${n}.json" \
-o "$DEST/scenes/${FILM}-${n}.json" 2>/dev/null \
|| rm -f "$DEST/scenes/${FILM}-${n}.json"
done
echo "[dvu] done:"
echo " mugshots: $(ls "$DEST/images" 2>/dev/null | wc -l)"
echo " scenes: $(ls "$DEST/scenes" 2>/dev/null | wc -l)"
echo " csv: $([ -f "$DEST/${FILM}.csv" ] && echo yes || echo no)"
+6 -1
View File
@@ -77,7 +77,12 @@ def main():
if missing > 0:
print(f"[warn] {missing} cast member(s) not present in gallery (not yet embedded)", file=sys.stderr)
save_gallery_hdf5({"actors": actors}, Path(args.output))
# TRACES: GR-004 | SR-001
# a filtered gallery holds the SAME vectors as its
# source, so it inherits the source's binding. Dropping the stamp here would
# silently launder a stamped gallery into an unstamped one.
save_gallery_hdf5({"actors": actors}, Path(args.output),
gallery.get("embedder"))
print(f"Saved {len(actors)} actor(s) to {args.output}", file=sys.stderr)
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# make_fixtures.sh — regenerate the committed replay fixtures.
#
# TRACES: VR-001 | PR-002
#
# CI never calls a model (see docs/requirements.md, "CI never calls a model"):
# the embedder is impractical on the N100 CI host, so inference happens HERE, on
# a machine with a GPU, and CI consumes the HDF5 dumps as data. Everything
# downstream of embedding — tracking, presence windows, belief accumulation,
# expansion — is cheap CPU maths and replays from these files.
#
# Reproducibility is a requirement, not a nicety. A fixture whose provenance is
# unknown is worse than no fixture, because it will be trusted. Every parameter
# that affects the output is pinned below rather than left to a default, and the
# dumps carry the embedder identity and SHA-256 (GR-004) so a replay cannot be
# silently scored against the wrong gallery.
#
# These are byte-reproducible only because node outputs block rather than drop
# on a full channel (AR-004). Before that fix the same command produced
# different dumps run to run, since what got dropped depended on timing.
#
# Source: hero/ — SuperHero, from the TRECVID DVU development set. Chosen over
# SuperHero on face scale: Bali reference crops had a median detected face of
# 27 px against a 69 px maximum, so every reference was upscaled far past what
# the embedder was trained for. SuperHero is 69 px median, 241 px max. That matters: derived
# fixtures can be committed, where anything cut from a copyrighted title could
# not live in the repository at all.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CLIPS="${CLIPS:-$REPO/../hero}"
GALLERY="${GALLERY:-$REPO/gallery_lvface.h5}"
BIN="${BIN:-$REPO/build/scene_analyze}"
OUT="$REPO/tests/fixtures/dumps"
# Pinned. Changing either invalidates every committed fixture.
# fps 5 — 1 fps over a 77 s clip is 77 frames, too thin to exercise an
# extinction window measured in tens of seconds.
# min-face — 32 px. This is a *fixture* setting, deliberately below AR-002's
# production floor of 40 px (VR-013, measured end to end): the
# corpus is 480x360, where faces run 40-80 px, so pinning at 40
# would thin the dumps for reasons unrelated to what they test.
# 32 px is where VR-005 still shows 98.1% TPI, so the faces kept
# are identifiable; it is not the threshold the pipeline ships.
FPS=5
MIN_FACE_PX=32
[[ -x "$BIN" ]] || { echo "no scene_analyze at $BIN (set BIN=)" >&2; exit 1; }
[[ -f "$GALLERY" ]] || { echo "no gallery at $GALLERY (set GALLERY=)" >&2; exit 1; }
[[ -d "$CLIPS" ]] || { echo "no clips at $CLIPS (set CLIPS=)" >&2; exit 1; }
mkdir -p "$OUT"
for clip in "$CLIPS"/SuperHero-*.webm; do
n="$(basename "$clip" .webm)"; n="${n##*-}"
echo "── superhero_$n"
"$BIN" --movie "$clip" --gallery "$GALLERY" \
--fps "$FPS" --min-face-px "$MIN_FACE_PX" \
--dump-embeddings "$OUT/superhero_$n.h5" \
--output /dev/null 2>&1 | grep -E "wrote|dropped" || true
done
echo
echo "Regenerated in $OUT — verify the diff is empty if nothing upstream changed."
echo "A non-empty diff means detection, alignment or embedding moved. That is"
echo "either a regression or a deliberate change, and either way the golden"
echo "outputs derived from these fixtures need reviewing."
+9 -3
View File
@@ -36,8 +36,9 @@ import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from sae_embed_loader import load_embedder
from sae_gallery import download_images, save_gallery, wikidata_image_urls
from sae_embed_loader import load_embedder, resolve_arcface
from sae_gallery import (download_images, embedder_stamp, save_gallery,
wikidata_image_urls)
from sae_tmdb import TMDB_IMG, tmdb_get, tmdb_id_from_imdb
@@ -177,7 +178,12 @@ def main():
output = Path(args.output)
image_root = Path(args.image_dir) if args.image_dir else output.parent / "images"
# TRACES: GR-004 | SR-001
# stamp with the model actually loaded, resolved
# through the same helper load_embedder uses so the two cannot diverge.
arcface_path = resolve_arcface(args.models_dir, args.arcface)
embedder = load_embedder(args.build_dir, args.models_dir, args.arcface)
stamp = embedder_stamp(arcface_path)
# Resolve movie ID
movie_id = args.movie_id
@@ -203,7 +209,7 @@ def main():
if n_actors == 0:
sys.exit("No actors could be processed — check models and images.")
save_gallery(gallery, missing, output)
save_gallery(gallery, missing, output, embedder=stamp)
if __name__ == "__main__":
+16 -3
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
"""make_jellyfin_gallery.py — build a gallery.h5 spanning an entire Jellyfin library.
TRACES: GR-001, GR-002 | SR-001, SR-005
Queries the Jellyfin API for every Movie/Series, collects the unique cast
across the whole library, downloads each actor's headshot directly from
Jellyfin (no TMDB key needed), embeds them with the sae_embed module (SCRFD +
@@ -51,8 +53,9 @@ import requests
sys.path.insert(0, str(Path(__file__).resolve().parent))
import sae_env # noqa: F401 — loads .env into os.environ on import
from sae_embed_loader import load_embedder
from sae_gallery import (download_image, download_images, load_gallery_hdf5,
from sae_embed_loader import load_embedder, resolve_arcface
from sae_gallery import (download_image, download_images, embedder_stamp,
enforce_embedder_stamp, load_gallery_hdf5,
save_gallery, wikidata_image_urls)
from sae_jellyfin import actor_jellyfin_id, jf_get, normalize_jellyfin_url
from sae_tmdb import (
@@ -442,11 +445,21 @@ def main():
image_root = Path(args.image_dir) if args.image_dir else output.parent / "images"
item_types = [t.strip() for t in args.item_types.split(",") if t.strip()]
# TRACES: GR-004 | SR-001
arcface_path = resolve_arcface(args.models_dir, args.arcface)
embedder = load_embedder(args.build_dir, args.models_dir, args.arcface)
stamp = embedder_stamp(arcface_path)
existing_actors = {}
if args.merge and output.is_file():
existing = load_gallery_hdf5(output)
# TRACES: GR-004 | SR-001
# --merge keeps the existing actors' vectors and
# embeds the new ones with THIS model. If they disagree, the result is one
# gallery holding two incompatible embedding spaces, which is worse than a
# mismatched gallery: no later check can separate them again.
enforce_embedder_stamp(existing.get("embedder"), stamp, str(output),
arcface_path)
for actor in existing.get("actors", []):
pid = actor_jellyfin_id(actor)
if pid:
@@ -475,7 +488,7 @@ def main():
if n_actors == 0:
sys.exit("No actors could be processed — check Jellyfin URL/API key and models.")
save_gallery(gallery, missing, output)
save_gallery(gallery, missing, output, embedder=stamp)
if __name__ == "__main__":
+8 -2
View File
@@ -22,8 +22,8 @@ from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent))
from sae_embed_loader import load_embedder
from sae_gallery import load_gallery_hdf5
from sae_embed_loader import load_embedder, resolve_arcface
from sae_gallery import load_gallery_hdf5, verify_gallery_stamp
def load_gallery(path: str) -> dict[str, dict]:
@@ -62,6 +62,12 @@ def main():
args = p.parse_args()
embedder = load_embedder(args.build_dir, args.models_dir, args.arcface)
# TRACES: GR-004 | SR-001
# match() below is a bare dot product against the
# gallery's vectors; if the gallery came from another model those numbers are
# noise wearing a similarity's clothes.
verify_gallery_stamp(args.gallery,
resolve_arcface(args.models_dir, args.arcface))
gallery = load_gallery(args.gallery)
print(f"[eval] gallery: {len(gallery)} actors", file=sys.stderr)
+149 -8
View File
@@ -1,4 +1,4 @@
# Embedding-dump HDF5 schema (v1)
# Embedding-dump HDF5 schema (v2)
One file per analysed title. Captures the pipeline state at the `EmbeddedSceneFrame`
channel — i.e. after decode → detect → align → embed, but **before** tracking and
@@ -18,11 +18,34 @@ variable-length HDF5 types and reads straight into numpy.
```
/ (root)
attrs:
schema_version : int = 1
movie : str (source video path)
sample_fps : float
schema_version : int = 2
embed_dim : int = 512
# ── what produced the vectors (GR-004) ──────────────────────────────────
embedder_model : str basename of the embedding model
embedder_sha256: str SHA-256 of that model file
# ── what produced the faces (VR-010) ────────────────────────────────────
detector_model : str basename of the detector .onnx
detector_conf : float score floor a detection had to clear to be dumped
detector_nms : float NMS IoU threshold
min_face_px : float minimum box side, ORIGINAL-resolution px (AR-002)
max_faces : int per-frame cap; 0 = uncapped, the default (AR-003)
# ── what produced the frames (VR-010) ───────────────────────────────────
movie : str source video path
sample_fps : float frames analysed per second of movie
start_sec : float seek point
end_sec : float stop point; -1 = end of file
cut_threshold : float histogram correlation below which is_cut fires
dense_scale : float decoded-frame downscale in dense mode; 1 = off
bbox_upscale : float multiply faces/bbox and faces/landmarks by this to
reach original video pixels; 1 when dense_scale is 1
scene_detect : uint8 0/1 — was TransNetV2 running at all (see below)
# ── downstream setting recorded for comparability (VR-010) ──────────────
track_assoc_min_prob : float the run's tracker admission probability
frames/ group — one row per sampled frame
timestamp_sec : float64 [F]
frame_idx : int64 [F]
@@ -33,18 +56,136 @@ variable-length HDF5 types and reads straight into numpy.
faces/ group — one row per detected face, concatenated
embedding : float32 [N, 512] L2-normalised ArcFace embedding
bbox : float32 [N, 4] x, y, w, h in original video pixels
landmarks : float32 [N, 10] 5 (x,y) pairs, SCRFD/ArcFace order
bbox : float32 [N, 4] x, y, w, h in DECODED-frame pixels
landmarks : float32 [N, 10] 5 (x,y) pairs, SCRFD/ArcFace order,
same space as bbox
confidence : float32 [N] detector confidence
# ── embedding input quality (AR-028), v2 onward ─────────────────────────
sharpness : float32 [N] normalised Laplacian variance on the
112x112 aligned crop (AR-029)
alignment_residual : float32 [N] RMS landmark misfit in canonical px,
after the AR-005 similarity fit (AR-030)
```
`F` = number of sampled frames, `N` = total faces (= sum of face_count).
Frame *i*'s faces are `faces/*[ face_offset[i] : face_offset[i]+face_count[i] ]`.
## Provenance (VR-010)
The attributes above are not documentation; they are the only thing that makes a
dump interpretable. Two dumps of the same film at `detector_conf` 0.5 and 0.7, or
at `dense_scale` 1.0 and 0.5, or with scene detection on and off, are different
measurements of different things — and they are byte-shaped identically. Without
provenance a consumer that mixes them gets a plausible number from an incoherent
input, and nothing anywhere reports a problem.
**`scene_detect` is the one that cannot be inferred.** `is_scene_boundary` is
all-zero both when TransNetV2 found no boundaries in the clip and when it was
never enabled, and those mean opposite things: the first says *this footage has
no shot changes*, the second says *nobody looked*. A consumer that reads the
array alone must guess. The flag is what removes the guess. (`dump_embeddings`
has no `--scene-detect`, so every dump it writes records `false` — which is
exactly the fact the committed fixtures needed to state.)
**`bbox_upscale` is recorded, not applied.** See the coordinate-space note below.
Reading is by name with a default or an existence check on **both** sides —
`replay.py` (`f.attrs.get(...)`) and `read_dump_provenance()` in
`src/nodes/embedding_dump_node.hpp` (`attrExists`). So the attributes are
additive and did not themselves move `schema_version` off 1: a pre-VR-010 dump
still loads, and a post-VR-010 dump still reads on old code. (AR-028 later took
it to 2 by adding *datasets* — see below.)
A missing attribute means **unknown**, never a default value. Substituting
`detector_conf = 0.5` for a dump that does not say so manufactures the provenance
the requirement exists to prevent — per `docs/requirements.md`, *"a fixture whose
provenance is unknown is worse than no fixture, because it will be trusted."*
The committed `tests/fixtures/dumps/*.h5` predate VR-010 and carry none of these
attributes; re-dump to bind them, as with GR-004.
## Embedding input quality (AR-028) — and why this one bumps the version
`sharpness` and `alignment_residual` are two of the three AR-028 quality axes,
written beside the embedding they describe. **The third axis, size, is already
here**: it is `bbox`, scaled by `bbox_upscale` to reach the original resolution
AR-002 thresholds in. It is not duplicated into a third column, because that
would put the same quantity in two coordinate spaces inside one file — the trap
the `bbox_upscale` note below records — and the copy is the one that drifts.
The vector is **carried, not consumed**. Nothing in the pipeline thresholds or
discounts on it yet; VR-012 locates the knees from these columns, which is only
possible if they were recorded at inference. A study cannot recover how sharp a
face was from an embedding, any more than it can recover which model produced it.
**This is the change that bumps `schema_version` to 2**, where VR-010's
attributes did not. The rule is unchanged — a bump is for the *datasets* — and
so is the reason behind it. Readers are fine either way: `replay.py` and
`test_replay_fixtures.cpp` take these datasets by name with an existence check,
so a v1 dump still replays and loses only what it never had. The version exists
for a *consumer of the quality vector*, which otherwise cannot tell **"this
film's faces were never scored"** from **"this film's faces scored zero"** —
sharpness 0 is a real reading, meaning a featureless crop. That is the same
distinction `scene_detect` exists to make, and it is equally unrecoverable from
the arrays.
A v1 dump reports the vector as **unknown, never as a default**`load_frames`
omits the keys rather than filling zeros, and the C++ side leaves the
`DetectedFace` fields at their -1 "unscored" sentinel. Re-dump to acquire it;
there is no migration, for the same reason GR-004 has none.
> The committed `tests/fixtures/dumps/*.h5` are v1 and carry no quality vector.
> Re-dumping needs a GPU host (`scripts/make_fixtures.sh`), so until that runs,
> anything driven from the fixtures sees the sentinel.
## Model binding (GR-004)
`embedder_model` / `embedder_sha256` record which embedder produced every vector
in `faces/embedding`. A replay has no live embedder, so **the dump is the embedder
as far as the gallery is concerned**: `replay.py` checks these two attributes
against the gallery's own `/embedder` stamp and refuses to run on a mismatch,
naming both sides. Cross-model cosines are meaningless but look plausible.
The attributes are additive, not a format break — `schema_version` stays 1. Dumps
written before GR-004 simply lack them, which reports as *unverifiable* (a loud
warning, or a hard error under `SAE_REQUIRE_GALLERY_STAMP=1`) rather than as a
pass. Re-dump to bind an old dump; there is no in-place migration, because unlike
a gallery nobody can assert after the fact which model produced a vector.
## Coordinate space — `bbox`, `landmarks`, `bbox_upscale`
`bbox` and `landmarks` are in **decoded-frame pixels**: exactly the numbers SCRFD
produced, untransformed. To reach original video pixels, multiply by
`bbox_upscale`. With `dense_scale == 1` (the default, and every committed
fixture) `bbox_upscale == 1` and the two spaces coincide.
> Earlier revisions of this document claimed the upscale was applied at dump time.
> It never was. `embedding_dump_node.hpp` writes `f.bbox` raw; the upscale lives
> in `identity_matcher_node.hpp`, which is *downstream* of the dump tap. The
> claim was harmless only because `dense_scale` was 1 in practice.
The fix is to record the factor rather than to apply it, because the dump's whole
contract is to be a **faithful tap** at the `EmbeddedSceneFrame` channel — VR-002
requires replay to drive the real nodes, and a replay is only equivalent to the
live run if the tracker is fed the geometry the live tracker saw. Rescaling at
the tap would break that: the replayed tracker would associate on boxes the live
one never received. Two further reasons:
- The matcher's upscale is applied to `bbox` **only**, not to `landmarks`.
Pre-multiplying at the tap would leave the two arrays in different coordinate
spaces inside one file — a worse trap than the one being fixed.
- Pre-multiplying is lossy in the sense that matters: a dump that had been
upscaled would be indistinguishable from one taken at `dense_scale == 1`, so
you would have to record `bbox_upscale` anyway to know which you were holding.
## Invariants
- `embedding` rows are unit-norm (cosine == dot product against the gallery).
- `face_offset[0] == 0`; `face_offset[i+1] == face_offset[i] + face_count[i]`.
- `bbox` is already mapped to original resolution (bbox_upscale applied at dump time),
matching what the identity matcher would emit.
- `bbox` and `landmarks` share one coordinate space; `bbox_upscale` maps both to
original resolution (see above).
- A frame with no faces has `face_count == 0` (still gets a row, so timestamps stay dense).
- EOF sentinel frames are NOT written.
- v2 onward: `sharpness` and `alignment_residual` are `[N]`, parallel to
`confidence`, so face *i*'s quality indexes with the same slice as its
embedding. Both are `>= 0` for any face the aligner admitted; a negative value
means unscored and must never be read as a quality.
+35 -12
View File
@@ -121,23 +121,44 @@ def load_raw_annotations(raw_path: str):
return by_second
def draw_annotations(frame_path: Path, actors: list):
def _name_key(name: str) -> str:
"""Normalised match key, mirroring identity.py's name: fallback."""
return "name:" + "".join(ch for ch in name.lower() if ch.isalnum() or ch == " ").strip()
def draw_annotations(frame_path: Path, actors: list, fp_keys=None, fn_names=None):
"""Draw GT-aware boxes: GREEN = true positive (named actor X-Ray also has in
this scene), RED = false positive (named actor NOT in the scene the real
error), ORANGE = unknown detection. FN cast (present per X-Ray but no face
detected so no box to draw) is listed as a BLUE text panel bottom-left."""
img = cv2.imread(str(frame_path))
if img is None:
return
fp_keys = fp_keys or set()
GREEN, RED, ORANGE, BLUE = (60,200,0), (0,0,230), (220,100,0), (230,150,0)
for a in actors:
known = a.get("actor_idx", -1) >= 0
colour = (60, 200, 0) if known else (220, 100, 0) # BGR: green / orange
x, y, w, h = a["bbox"]
x, y, w, h = int(x), int(y), int(w), int(h)
if known:
colour = RED if _name_key(a["name"]) in fp_keys else GREEN
label = f"{a['name']} {a['similarity']*100:.0f}%"
else:
colour = ORANGE; label = f"unknown {a['similarity']*100:.0f}%"
x, y, w, h = (int(v) for v in a["bbox"])
cv2.rectangle(img, (x, y), (x + w, y + h), colour, 2)
label = f"{a['name']} {a['similarity']*100:.0f}%" if known else f"unknown {a['similarity']*100:.0f}%"
(tw, th), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
strip_y0 = max(0, y - th - 4)
cv2.rectangle(img, (x, strip_y0), (x + tw + 4, y), colour, cv2.FILLED)
cv2.putText(img, label, (x + 2, y - 2), cv2.FONT_HERSHEY_SIMPLEX, 0.5,
(255, 255, 255), 1, cv2.LINE_AA)
(tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
cv2.rectangle(img, (x, max(0, y-th-4)), (x+tw+4, y), colour, cv2.FILLED)
cv2.putText(img, label, (x+2, y-2), cv2.FONT_HERSHEY_SIMPLEX, 0.5,
(255,255,255), 1, cv2.LINE_AA)
# FN: X-Ray cast present with no detected face — no box exists, so list them.
fn = [n for n in (fn_names or []) if n]
if fn:
H = img.shape[0]
cv2.putText(img, "off-screen / missed (X-Ray cast, no face):",
(8, H-8-18*len(fn[:6])), cv2.FONT_HERSHEY_SIMPLEX, 0.45, BLUE, 1, cv2.LINE_AA)
for i, n in enumerate(fn[:6]):
disp = n.replace("name:", "").title()
cv2.putText(img, f" {disp}", (8, H-8-18*(len(fn[:6])-1-i)),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, BLUE, 1, cv2.LINE_AA)
cv2.imwrite(str(frame_path), img)
@@ -183,7 +204,9 @@ def main():
extract_frame(args.movie, r["t"], out_path)
ok = True
if raw_by_second is not None:
draw_annotations(out_path, raw_by_second.get(r["t"], []))
fp_keys = {_name_key(n) for n in r["fp"]}
draw_annotations(out_path, raw_by_second.get(r["t"], []),
fp_keys=fp_keys, fn_names=r["fn"])
except subprocess.CalledProcessError as e:
ok = False
print(f"[dump_error_frames] ffmpeg failed at t={r['t']}: {e}", file=sys.stderr)
+15 -2
View File
@@ -35,7 +35,9 @@ REPO = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO / "scripts"))
import sae_env # noqa: E402 loads .env
from sae_tmdb import tmdb_get, tmdb_person_for_imdb, TMDB_IMG # noqa: E402
from sae_gallery import download_images, wikidata_image_urls # noqa: E402
from sae_embed_loader import resolve_arcface # noqa: E402
from sae_gallery import (download_images, embedder_stamp, # noqa: E402
enforce_embedder_stamp, wikidata_image_urls)
from sae_embed_loader import load_embedder # noqa: E402
@@ -57,6 +59,7 @@ def fetch(missing_path, out_path, token, build_dir, models_dir, arcface,
src = "TMDB + Wikidata fallback" if use_wikidata else "TMDB"
print(f"[fetch] {len(missing)} missing actors to resolve via {src}", file=sys.stderr)
embedder = load_embedder(build_dir, models_dir, arcface)
stamp = embedder_stamp(resolve_arcface(models_dir, arcface)) # TRACES: GR-004 | SR-001
img_root = Path(tempfile.mkdtemp(prefix="missing_gallery_"))
actors = []
@@ -103,7 +106,10 @@ def fetch(missing_path, out_path, token, build_dir, models_dir, arcface,
f"(wiki={n_via_wikidata}) no_tmdb={n_no_tmdb} no_img={n_no_img} "
f"no_face={n_no_face}", file=sys.stderr)
Path(out_path).write_text(json.dumps({"actors": actors}, indent=2))
# TRACES: GR-004 | SR-001
# the legacy JSON gallery carries the same stamp as
# the HDF5 one; src/gallery/gallery_store.cpp reads it from either.
Path(out_path).write_text(json.dumps({"embedder": stamp, "actors": actors}, indent=2))
n_emb = sum(len(a["embeddings"]) for a in actors)
print(f"\n[fetch] recovered {n_resolved}/{len(missing)} actors "
f"({n_via_wikidata} via Wikidata), {n_emb} embeddings → {out_path}",
@@ -115,6 +121,13 @@ def fetch(missing_path, out_path, token, build_dir, models_dir, arcface,
def merge(base_path, add_path, out_path):
base = json.loads(Path(base_path).read_text())
add = json.loads(Path(add_path).read_text())
# TRACES: GR-004 | SR-001
# merging two galleries from different models makes
# ONE file containing two incompatible embedding spaces. Nothing downstream can
# ever untangle that, so this is the one place the check must run before, not
# after, the write.
enforce_embedder_stamp(base.get("embedder"), add.get("embedder"),
str(base_path), str(add_path))
have = {a.get("imdb_id") for a in base["actors"] if a.get("imdb_id")}
added = [a for a in add["actors"] if a.get("imdb_id") not in have]
base["actors"].extend(added)
+60 -4
View File
@@ -17,7 +17,7 @@ point from the trajectory (--trajectory).
Usage:
python scripts/optimizer/optimize.py --manifest films.json \
--gallery gallery_arcface_w600k_r50.json \
--params prob_threshold:0.5:0.999 anneal_sec:1:30 extinction_sec:1:15 \
--params prob_threshold:0.5:0.999 ownership_logodds:0.5:4 track_alpha:0:1 \
--popsize 20 --maxiter 25 --trajectory traj.json
"""
from __future__ import annotations
@@ -34,6 +34,7 @@ from scipy.optimize import differential_evolution
REPO = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO / "scripts" / "optimizer"))
sys.path.insert(0, str(REPO / "scripts" / "validation"))
sys.path.insert(0, str(REPO / "scripts"))
import json as _json
import os
@@ -56,9 +57,19 @@ DE_WORKERS = int(os.environ.get("DE_WORKERS", "1"))
from second_score import score_seconds # noqa: E402 uniform per-second TPI/FPI scoring
from sample_eval import load_gallery_keys # noqa: E402
from replay import dump_embedder_stamp # noqa: E402
from sae_stamp import EmbedderMismatch, verify_gallery_stamp # noqa: E402
_GAL_KEYS: dict = {} # gallery path → key set (fair-recall FN mask), loaded once
_REPLAY_TIMEOUT = 45 # seconds per film; a wedged replay is killed, not left to hang
# Seconds per film before a replay is killed. Its ONLY job is to escape the rare,
# intermittent ROCm GEMM wedge (github ROCT-Thunk #56): a wedged replay hangs
# forever and would otherwise stall the whole sweep, so it must be killed and that
# film dropped (the eval is then scored as incomplete → F1=0, and DE moves on). It
# is NOT a performance bound. A healthy replay finishes in ~15-30s even for the
# long films with stderr discarded, so 180s is comfortably above any real run yet
# short enough that a wedge is reaped quickly rather than after half an hour.
# Raise via REPLAY_TIMEOUT if a legitimately slow config is being killed.
_REPLAY_TIMEOUT = int(os.environ.get("REPLAY_TIMEOUT", "180"))
REPLAY_CLI = str(Path(__file__).resolve().parent / "replay.py")
@@ -88,7 +99,17 @@ def _replay_subprocess(dump, gallery, cfg, build_dir):
else:
argv += [f"--{k.replace('_', '-')}", str(v)]
try:
subprocess.run(argv, timeout=_REPLAY_TIMEOUT, capture_output=True, check=True)
# Discard the child's stdout/stderr rather than capture it. replay's sink
# prints a per-second "[result_sink] t=Ns" progress line with an explicit
# flush; on a long film that is thousands of writes, and under
# subprocess.run(capture_output=True) they accumulate in a fixed OS pipe
# buffer that nothing drains until the process exits. On the long films
# (Valerian, Sound of Metal) under DE concurrency the buffer fills and the
# C++ process BLOCKS on write to stderr — indistinguishable from a hang, so
# it hit the timeout and scored F1=0. DEVNULL never fills, so the process
# runs to completion. (Any real error is still surfaced by check=True.)
subprocess.run(argv, timeout=_REPLAY_TIMEOUT, check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return _json.loads(Path(out).read_text())
except (subprocess.TimeoutExpired, subprocess.CalledProcessError,
FileNotFoundError, ValueError) as e:
@@ -180,7 +201,15 @@ def main():
p.add_argument("--seed", type=int, default=0)
p.add_argument("--trajectory", help="write every evaluation here (JSON lines)")
p.add_argument("--out", help="write best config + metrics")
# TRACES: GR-004 | SR-001
p.add_argument("--require-gallery-stamp", action="store_true",
help="unprovable gallery/dump model binding is a hard error, "
"not a warning (also via SAE_REQUIRE_GALLERY_STAMP=1)")
args = p.parse_args()
if args.require_gallery_stamp:
# Set the env var rather than threading a flag through cfg: replays run as
# subprocesses and inherit it, so strictness cannot be lost in the handoff.
os.environ["SAE_REQUIRE_GALLERY_STAMP"] = "1"
films = json.loads(Path(args.manifest).read_text())
for f in films:
@@ -188,6 +217,19 @@ def main():
if not Path(f["dump"]).exists():
sys.exit(f"[opt] missing dump for {f['name']}: {f['dump']}")
# TRACES: GR-004 | SR-001
# every (dump, gallery) pair is checked ONCE here,
# before the first evaluation. A DE sweep is thousands of replays; discovering
# a cross-model pair at the end (or never) means every number it produced was
# noise. Each replay subprocess re-checks its own pair anyway.
for f in films:
try:
verify_gallery_stamp(f["gallery"], stamp=dump_embedder_stamp(f["dump"]),
embedder_desc=f"embedding dump {Path(f['dump']).name}",
require_stamp=args.require_gallery_stamp)
except EmbedderMismatch as e:
sys.exit(f"[opt] {f['name']}: {e}")
names, bounds = [], []
int_knobs = {"track_max_frames_missing", "cut_inactive_max_frames"}
for spec in args.params:
@@ -205,6 +247,20 @@ def main():
cfg = {}
for k, v in zip(names, x):
cfg[k] = int(round(v)) if k in int_knobs else float(v)
# The expansion band is [lo, hi]; independent DE bounds can invert it,
# and an inverted band admits nothing (track_gallery.hpp). Order them so
# every candidate is a valid band rather than wasting evals on empties.
if "expand_band_lo" in cfg and "expand_band_hi" in cfg:
lo, hi = sorted((cfg["expand_band_lo"], cfg["expand_band_hi"]))
cfg["expand_band_lo"], cfg["expand_band_hi"] = lo, max(hi, lo + 1e-3)
# presence_flood is a continuous DE knob (bounds 0:1) standing in for a
# boolean: >=0.5 selects flood-fill presence. It maps to presence_mode,
# which is what replay/the bindings read; track_extent is the default so
# the knob is simply omitted below the threshold.
if "presence_flood" in cfg:
flood = cfg.pop("presence_flood") >= 0.5
if flood:
cfg["presence_mode"] = "flood"
return cfg
def objective(x):
@@ -215,7 +271,7 @@ def main():
rec = {"eval": evals[0], "config": cfg, **m, "t": round(time.time() - t0, 1)}
traj.append(rec)
print(f"[opt] eval {evals[0]:3d} thr={cfg['prob_threshold']:.2f} "
f"ann={cfg['anneal_sec']:.0f} ext={cfg['extinction_sec']:.1f}"
f"own={cfg.get('ownership_logodds', float('nan')):.2f}"
f"F1={m['f1']*100:.1f}% P={m['precision']*100:.1f}% R={m['recall']*100:.1f}% "
f"agree={m.get('agreement', 0)*100:.1f}% misID={m.get('FPI_misid', 0)}",
file=sys.stderr)
+10 -3
View File
@@ -27,8 +27,9 @@ from pathlib import Path
REPO = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO / "scripts"))
from sae_embed_loader import load_embedder # noqa: E402
from sae_gallery import load_gallery_hdf5, save_gallery_hdf5 # noqa: E402
from sae_embed_loader import load_embedder, resolve_arcface # noqa: E402
from sae_gallery import (embedder_stamp, load_gallery_hdf5, # noqa: E402
save_gallery_hdf5)
def find_dir(images_root: Path, jellyfin_id: str, name: str) -> Path | None:
@@ -58,6 +59,12 @@ def main():
ref = load_gallery_hdf5(Path(args.ref))
images_root = Path(args.images)
embedder = load_embedder(args.build_dir, args.models_dir, args.arcface)
# TRACES: GR-004 | SR-001
# this script exists to produce a gallery in a
# DIFFERENT model's space from the reference. The output must therefore never
# inherit the reference's stamp; it carries the stamp of --arcface, which is
# the whole point of the bake-off being safe to run.
stamp = embedder_stamp(resolve_arcface(args.models_dir, args.arcface))
out_actors = []
n_ok = n_nodir = n_noemb = 0
@@ -84,7 +91,7 @@ def main():
print(f" [{i}/{total}] ok={n_ok} no_dir={n_nodir} no_emb={n_noemb}",
file=sys.stderr)
save_gallery_hdf5({"actors": out_actors}, Path(args.out))
save_gallery_hdf5({"actors": out_actors}, Path(args.out), stamp)
n_emb = sum(len(a["embeddings"]) for a in out_actors)
print(f"[reembed] {Path(args.arcface).stem}: {n_ok}/{total} actors, {n_emb} embeddings "
f"{args.out}", file=sys.stderr)
+250 -97
View File
@@ -2,16 +2,22 @@
"""
replay.py replay a dumped embedding HDF5 through the real KPN downstream nodes.
TRACES: VR-002, VR-011 | PR-002
Reads an embedding dump (scripts/optimizer/SCHEMA.md), feeds each frame as an
EmbeddedSceneFrame into a Python-assembled KPN network wiring the *real* C++
face_tracker identity_matcher scene_tracker, and returns the same presence-window
JSON that scene_analyze's result_sink produces (minimal schema). No decode, no GPU
embedding only the cheap downstream tail runs, so a sweep can vary Config knobs
freely. See [[kpn-python-replay-optimizer]].
face_tracker identity_matcher frame_annotation result_sink, and reads back
the truth file that sink wrote. No decode, no GPU embedding only the cheap
downstream tail runs, so a sweep can vary Config knobs freely.
The sink is part of the network, not a Python reimplementation of it. That is
VR-011: presence comes from TrackRegistry claims, so a replayed window and a
scene_analyze window are produced by the same code rather than by two functions
that agreed once. See [[kpn-python-replay-optimizer]].
CLI:
python scripts/optimizer/replay.py --dump film.h5 --gallery gallery.json \
--out replayed.json [--prob-threshold 0.99] [--anneal 10] ...
--out replayed.json [--prob-threshold 0.99] [--track-extinction-sec 5] ...
"""
from __future__ import annotations
@@ -25,6 +31,22 @@ import h5py
import numpy as np
REPO = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO / "scripts"))
from sae_stamp import verify_gallery_stamp # noqa: E402
def dump_embedder_stamp(dump_path: str) -> dict:
"""The GR-004 embedder stamp recorded in an embedding dump.
A replay has no live embedder the dump IS the embedder as far as the gallery
is concerned, so the dump's stamp is what the gallery must be checked against.
Dumps written before GR-004 have no attributes and yield an empty stamp, which
the check reports as unverifiable rather than silently accepting."""
with h5py.File(dump_path, "r") as f:
name = f.attrs.get("embedder_model", "")
sha = f.attrs.get("embedder_sha256", "")
dec = lambda v: v.decode() if isinstance(v, bytes) else ("" if v is None else str(v))
return {"model_name": dec(name), "model_sha256": dec(sha), "embed_dim": 512}
def load_frames(dump_path: str, min_conf: float = 0.0):
@@ -39,12 +61,27 @@ def load_frames(dump_path: str, min_conf: float = 0.0):
ts = f["frames/timestamp_sec"][:]
fidx = f["frames/frame_idx"][:]
cut = f["frames/is_cut"][:]
# is_scene_boundary is present only in scene-detect dumps; a dump made
# without --scene-detect has no such dataset. Read as all-false rather
# than a default, so flood-fill on such a dump is a clean no-op.
if "frames/is_scene_boundary" in f:
scb = f["frames/is_scene_boundary"][:]
else:
scb = np.zeros(len(ts), dtype=np.uint8)
off = f["frames/face_offset"][:]
cnt = f["frames/face_count"][:]
emb = f["faces/embedding"][:]
bbox = f["faces/bbox"][:]
lmk = f["faces/landmarks"][:]
conf = f["faces/confidence"][:]
# TRACES: AR-028 | SR-002
# The quality vector, present from schema v2. A v1 dump predates AR-028
# and simply has no such dataset — read as absent, never as a default,
# so a face from an old dump stays at the C++ -1 "unscored" sentinel
# rather than acquiring a fabricated sharpness of 0 (which is a real
# value on this axis, meaning a featureless crop).
qual = {k: f[f"faces/{k}"][:] for k in ("sharpness", "alignment_residual")
if f"faces/{k}" in f}
movie = f.attrs.get("movie", "")
fps = float(f.attrs.get("sample_fps", 1.0))
@@ -58,40 +95,69 @@ def load_frames(dump_path: str, min_conf: float = 0.0):
sel = np.where(m)[0]
frames.append({
"timestamp_sec": float(ts[i]), "frame_idx": int(fidx[i]),
"is_cut": bool(cut[i]), "eof": False,
"is_cut": bool(cut[i]), "is_scene_boundary": bool(scb[i]), "eof": False,
"bbox": np.ascontiguousarray(bbox[keep][sel], dtype=np.float32),
"landmarks": np.ascontiguousarray(lmk[keep][sel], dtype=np.float32),
"confidence": np.ascontiguousarray(c[sel], dtype=np.float32),
"embeddings": np.ascontiguousarray(emb[keep][sel], dtype=np.float32),
**{k: np.ascontiguousarray(v[keep][sel], dtype=np.float32)
for k, v in qual.items()},
})
else:
frames.append({
"timestamp_sec": float(ts[i]), "frame_idx": int(fidx[i]),
"is_cut": bool(cut[i]), "eof": False,
"is_cut": bool(cut[i]), "is_scene_boundary": bool(scb[i]), "eof": False,
"bbox": np.ascontiguousarray(bbox[keep], dtype=np.float32),
"landmarks": np.ascontiguousarray(lmk[keep], dtype=np.float32),
"confidence": c,
"embeddings": np.ascontiguousarray(emb[keep], dtype=np.float32),
**{k: np.ascontiguousarray(v[keep], dtype=np.float32)
for k, v in qual.items()},
})
last_ts = float(ts[-1]) if len(ts) else 0.0
frames.append({"timestamp_sec": last_ts, "eof": True})
return frames, str(movie), fps
def replay(dump_path: str, gallery: str, cfg: dict, build_dir: str, stop: bool = True,
raw_out: str | None = None) -> dict:
"""Run the dump through the real KPN chain; return minimal-schema presence JSON.
def replay(dump_path: str, gallery: str, cfg: dict, build_dir: str,
out_path: str, stop: bool = True, raw_out: str | None = None,
eof_timeout: float = 300.0) -> dict:
"""Run the dump through the real KPN chain and return the truth file it wrote.
cfg may include "detector_conf" to prune dumped detections below that confidence
(upward-only from the 0.5 dump floor) before matching.
TRACES: VR-011, VR-002 | PR-002
raw_out: if set, also write the raw per-frame annotations (timestamp, actor_idx,
name, bbox, similarity one entry per input frame, before merging into windows)
as JSON lines to this path. Needed to draw bounding boxes on extracted frames;
the merged window schema returned by this function has no per-frame bbox."""
`out_path` is where the C++ sink writes. That is the change VR-011 makes:
the presence windows in that file are built by ResultSinkFunc from
TrackRegistry claims -- the extent of a track an actor owned (AR-012),
ending at the last sighting (AR-013) -- and are byte-for-byte the same
construction scene_analyze ships. This function used to build them itself,
in Python, by annealing gaps between per-frame detections, which is what the
pipeline did BEFORE AR-012. A sweep tuned against that was tuning a contract
the shipped code had stopped honouring.
cfg may include "detector_conf" to prune dumped detections below that
confidence (upward-only from the 0.5 dump floor) before matching.
raw_out: if set, also write per-frame annotations as JSON lines for the
montage renderers. Derived from the truth file's own `frames` array rather
than tapped separately out of the network -- see write_raw_frames.
eof_timeout: how long to wait for the sink to write. A replay that never
reaches EOF is a wedged pipeline, and returning an empty result would look
like a film with no cast rather than like a failure."""
sys.path.insert(0, build_dir)
import sae_kpn
# TRACES: GR-004 | SR-001
# checked here, before any network is built, so a
# cross-model replay dies with one readable error instead of producing a
# plausible-looking score. add_identity_matcher re-checks it C++-side below;
# that is the backstop for any other caller of the binding.
stamp = dump_embedder_stamp(dump_path)
verify_gallery_stamp(gallery, stamp=stamp,
embedder_desc=f"embedding dump {Path(dump_path).name}",
require_stamp=bool(cfg.get("require_gallery_stamp", False)))
frames, movie, fps = load_frames(dump_path, min_conf=float(cfg.get("detector_conf", 0.0)))
net = sae_kpn.Network()
@@ -112,100 +178,171 @@ def replay(dump_path: str, gallery: str, cfg: dict, build_dir: str, stop: bool =
time.sleep(0.05)
return eof
# Channel capacity must exceed the frame count so the fast source can't overflow
# a downstream FIFO before the serial reader drains it — PyNode DROPS on overflow,
# which would silently truncate the replay. Size to the whole film + slack.
# Every channel gets capacity ≥ the whole film so NOTHING can ever overflow-drop:
# the source can push all frames before any downstream node has drained, and a
# dropped frame silently corrupts the score. Memory is cheap (a few k pointers);
# correctness is not. Generous slack on top.
cap = len(frames) * 2 + 64
# TRACES: VR-011 | AR-004 | PR-002
# Purely a throughput and memory choice, and that is the point: the answer
# must not depend on it. It used to be `len(frames) * 2 + 64` -- the whole
# film -- to dodge a PyNode overflow drop that AR-004 has since replaced
# with parking.
#
# Removing backpressure that way was catastrophic and silent. The registry
# reaped on the TRACKER's clock while evidence arrived later from the
# matcher, so a deep channel closed tracks before their votes landed: on the
# SuperHero fixture, capacity 32 gave 5 actors and capacity 10322 gave 0,
# from identical input.
#
# The fix was NOT to bound this against track_extinction_sec. That would put
# an algorithm constant in charge of a throughput knob and leave presence a
# function of scheduling. The registry now reaps on the matcher's evidence
# watermark (TrackRegistry::advance_evidence), so a vote cannot be late by
# construction and this number is free again.
cap = 64
sae_kpn.add_node_python(net, "replay", source, [], ["EmbeddedSceneFrame"], cap)
sae_kpn.add_face_tracker(net, "tracker", cfg, cap)
sae_kpn.add_identity_matcher(net, "matcher", gallery, cfg, cap)
sae_kpn.add_scene_tracker(net, "scene", cfg, cap)
# TRACES: VR-011, VR-002 | DP-001 | PR-002
# One call builds tracker -> matcher -> annotation -> sink in the only order
# that works (the matcher fits the calibration the tracker needs, and the
# sink needs the registry's claims). This used to be three factory calls
# assembled here, which is how the seam broke: the ordering constraint could
# not be expressed, so the tracker was built from a Config alone long after
# it had started requiring a registry and a calibration.
cfg = dict(cfg)
cfg["output_path"] = out_path
cfg["movie_path"] = movie
cfg["sample_fps"] = fps
# Verbosity 1 (standard) adds the per-frame array; only pay for it when the
# caller wants raw frames, since it retains every annotation in memory.
cfg["verbosity"] = 1 if raw_out else 0
sae_kpn.add_pipeline(net, gallery, cfg, cap,
stamp["model_name"], stamp["model_sha256"])
net.connect("replay", 0, "tracker", 0)
net.connect("tracker", 0, "matcher", 0)
net.connect("matcher", 0, "scene", 0)
net.connect("matcher", 0, "annotation", 0)
net.connect("annotation", 0, "sink", 0)
net.build()
net.start()
# Read exactly one annotation per input frame. The source emits EOF as an ordinary
# value AFTER the last frame, but the concurrent pipeline lets that EOF OVERTAKE
# the last few real frames still flowing tracker→matcher→scene. Breaking on the
# first eof therefore dropped a random tail (~0.51%, race-dependent). Instead we
# keep reading past eof until we've collected all n_frames annotations (or hit a
# run of consecutive eofs meaning the pipeline is genuinely drained).
n_expected = len(frames) - 1 # excludes the trailing eof frame
annotations = []
eof_streak = 0
max_reads = n_expected * 2 + 32
for _ in range(max_reads):
sa = net.read("scene", 0)
if sa.get("eof"):
eof_streak += 1
# stragglers can still arrive after an eof; only stop once we've either
# got everything or seen several eofs in a row (truly drained).
if len(annotations) >= n_expected or eof_streak >= 8:
break
continue
eof_streak = 0
annotations.append(sa)
if len(annotations) >= n_expected:
break
# The sink writes on the EOF annotation. Wait for it rather than reading
# anything back through the seam: presence is the registry's answer, and the
# registry lives entirely on the C++ side.
#
# This replaces a read loop that pulled one SceneAnnotation per input frame
# and rebuilt windows in Python. That loop needed a heuristic -- "keep
# reading past eof until we've collected all n_frames annotations, or hit a
# run of 8 consecutive eofs" -- to work around a tail it was losing. None of
# that exists now: nothing is read per frame, so nothing can be lost per
# frame.
deadline = time.time() + eof_timeout
while not sae_kpn.pipeline_done(net):
if time.time() > deadline:
sae_kpn.release_pipeline(net)
raise TimeoutError(
f"replay did not finish within {eof_timeout}s "
f"({len(frames) - 1} frames); the sink never saw EOF")
time.sleep(0.02)
if raw_out:
with open(raw_out, "w") as f:
for sa in annotations:
f.write(json.dumps(sa) + "\n")
result = build_minimal(annotations, movie, fps, cfg)
diag = sae_kpn.pipeline_diagnostics(net)
if stop:
net.stop()
sae_kpn.release_pipeline(net)
# TRACES: VR-011 | PR-002
# A dropped vote means the matcher lagged the tracker by more than
# track_extinction_sec of film, so evidence arrived for a track that had
# already been reaped. The result is not a slightly worse score -- it is a
# silently emptier one, and this is exactly how the whole-film capacity bug
# presented. Refuse the number rather than report it.
# A dropped vote means a vote landed on a track already reaped. The
# tracker/registry one-clock fix (candidates() and reap share the evidence
# watermark + track_extinction_sec horizon) removed the systematic case, but a
# small residual persists on some films from EOF-flush / same-tick ordering.
# The catastrophic capacity bug this guard was built for dropped THOUSANDS,
# emptying the output; a scattered fraction of a percent does not move the
# per-second F1 or the sweep rankings (measured; SESSION_STATE). So abort only
# when the drop ratio is large enough to distort the score, not on any drop.
dropped = int(diag.get("dropped_votes", 0))
total_faces = sum(len(f.get("embeddings", [])) for f in frames if not f.get("eof"))
drop_ratio = dropped / total_faces if total_faces else 0.0
kMaxDropRatio = 0.02 # 2%: well above the ~0.5% residual, far below a real bug
if dropped and drop_ratio > kMaxDropRatio:
raise RuntimeError(
f"replay dropped {dropped} identity votes ({drop_ratio:.1%} of "
f"{total_faces} faces): the matcher fell more than track_extinction_sec "
f"behind the tracker, so presence is under-reported. Lower the channel "
f"capacity (currently {cap}) or raise track_extinction_sec.")
if dropped:
print(f"[replay] tolerated {dropped} dropped votes "
f"({drop_ratio:.2%} of {total_faces} faces)", file=sys.stderr)
with open(out_path) as f:
result = json.load(f)
if raw_out:
write_raw_frames(result, raw_out)
return result
def build_minimal(annotations, movie, fps, cfg) -> dict:
"""Reproduce result_sink's minimal schema: per-actor annealed [start,end] windows.
def write_raw_frames(truth: dict, raw_out: str) -> None:
"""Per-frame annotations as JSONL, for the montage/error-frame renderers.
Mirrors ResultSinkFunc::build_actor_windows merge each actor's detection
timestamps into windows, bridging gaps shorter than anneal_sec.
TRACES: VR-011 | PR-002
Derived from the truth file's own `frames` array (verbosity 1) rather than
from a second stream tapped out of the network. One producer, one set of
numbers: a bbox drawn on a montage is now provably the bbox the sink
recorded, which it was not when Python read annotations separately.
The shape is the legacy one -- {timestamp_sec, visible_actors:[...]} with
actor_idx/bbox/name/similarity -- because dump_scene_montage.py and
dump_error_frames.py read exactly those fields, and rewriting them is not
what this requirement is about.
"""
anneal = float(cfg.get("anneal_sec", 10.0))
info = {} # actor_idx -> identity fields
times = {} # actor_idx -> [timestamps]
for sa in annotations:
for a in sa["visible_actors"]:
if a["actor_idx"] < 0:
continue
info[a["actor_idx"]] = a
times.setdefault(a["actor_idx"], []).append(sa["timestamp_sec"])
actors = []
for idx, ts in times.items():
ts.sort()
scenes = []
ws = we = ts[0]
for t in ts[1:]:
if t - we > anneal:
scenes.append([ws, we])
ws = t
we = t
scenes.append([ws, we])
a = info[idx]
actors.append({
"name": a["name"], "imdb_id": a["imdb_id"], "tmdb_id": a["tmdb_id"],
"jellyfin_id": a["jellyfin_id"], "scenes": scenes,
})
return {"schema_version": 1, "movie": movie, "sample_fps": fps,
"anneal_sec": anneal, "actors": actors}
with open(raw_out, "w") as f:
for fr in truth.get("frames", []):
visible = []
for a in fr.get("identified", []):
visible.append({
"actor_idx": 0, # >= 0 means "known"; the renderers
# test the sign, never the value
"name": a.get("name", ""),
"imdb_id": a.get("imdb_id", ""),
"tmdb_id": a.get("tmdb_id", ""),
"jellyfin_id": a.get("jellyfin_id", ""),
"similarity": a.get("similarity", 0.0),
"track_id": a.get("track_id", -1),
"bbox": a.get("bbox", [0, 0, 0, 0]),
})
for u in fr.get("unknowns", []):
visible.append({
"actor_idx": -1,
"name": "",
"similarity": u.get("confidence", 0.0),
"track_id": u.get("track_id", -1),
"bbox": u.get("bbox", [0, 0, 0, 0]),
})
f.write(json.dumps({"timestamp_sec": fr.get("t", 0.0),
"visible_actors": visible}) + "\n")
CFG_KEYS = ["detector_conf", "prob_threshold", "match_prior", "match_threshold", "match_ratio",
"match_ratio_ceil", "track_alpha", "track_min_iou", "track_max_embed_dist",
"track_max_frames_missing", "cut_revive_sim", "cut_inactive_max_frames",
"extinction_sec", "anneal_sec"]
CFG_KEYS = ["detector_conf", "prob_threshold", "match_prior",
"track_alpha", "track_min_iou", "track_assoc_min_prob",
"track_extinction_sec",
# AR-025 ownership and evidence accumulation. Newly reachable:
# these were in-class defaults no sweep could vary, which is why
# VR-007 never covered them despite rho_max deferring to it.
"ownership_logodds", "evidence_rho_max", "evidence_admit_below",
"evidence_max_views",
# AR-018 expansion bands (probability space). Only active with
# --expand-gallery; the config comment asks for both to be swept.
"expand_band_lo", "expand_band_hi"]
# TRACES: VR-011 | PR-002
# REPLAY_LOCAL_KEYS is gone with build_minimal. It held anneal_sec, the last
# parameter this harness applied itself -- and the only reason it needed a
# separate list was that the harness was still doing windowing the pipeline had
# stopped doing. Every key is a Config key now, because every decision is the
# pipeline's.
def main():
@@ -221,18 +358,34 @@ def main():
# per-film gallery expansion: promotes pose-varied views of confidently-identified
# actors into an in-memory annex, recovering ~+4 recall at no precision cost.
p.add_argument("--expand-gallery", action="store_true")
# Presence derivation. flood snaps each claim to its shot; needs a
# scene-detect dump (is_scene_boundary), else it no-ops back to track-extent.
p.add_argument("--presence-mode", choices=["track_extent", "flood"], default=None)
# TRACES: GR-004 | SR-001
# promote an unprovable gallery/dump binding from a
# loud warning to a hard error. Measurement sweeps should set this (or
# SAE_REQUIRE_GALLERY_STAMP=1) so no number comes from an unbound pair.
p.add_argument("--require-gallery-stamp", action="store_true")
args = p.parse_args()
cfg = {k: getattr(args, k) for k in CFG_KEYS if getattr(args, k) is not None}
if args.expand_gallery:
cfg["expand_gallery"] = True
if args.presence_mode:
cfg["presence_mode"] = args.presence_mode
if args.require_gallery_stamp:
cfg["require_gallery_stamp"] = True
# stop=True: PyNode::stop() sets stop_flag_ before joining, so the source
# thread's run_loop actually exits. stop=False skips that, leaving stop_flag_
# false forever — the PyNode destructor's jthread.join() then blocks forever
# (verified via gdb: stuck in the source node's run_loop, not the GEMM path).
result = replay(args.dump, args.gallery, cfg, args.build_dir, stop=True,
raw_out=args.raw_out)
Path(args.out).write_text(json.dumps(result, indent=2))
result = replay(args.dump, args.gallery, cfg, args.build_dir,
out_path=args.out, stop=True, raw_out=args.raw_out)
# NOT rewritten here: the sink already wrote args.out, and that file is the
# artifact. Dumping `result` back over it would make this script the last
# writer of a file it did not produce -- and any formatting difference would
# be a diff between the replayed truth file and a scene_analyze one that is
# this script's doing rather than the pipeline's.
print(f"[replay] {len(result['actors'])} actors → {args.out}", file=sys.stderr)
+11 -1
View File
@@ -2,6 +2,8 @@
"""
second_score.py uniform per-second agreement with X-Ray.
TRACES: VR-003 | PR-002
Unlike scene_score.py (which unions our detections over a whole X-Ray scene), this
samples EVERY SECOND of the film and asks: at second t, do we name the same actors
X-Ray says are on screen?
@@ -93,7 +95,15 @@ def load_pred_intervals(pred_json: dict):
for a in pred_json.get("actors", []):
keys = frozenset(keys_for(imdb_id=a.get("imdb_id"), tmdb_id=a.get("tmdb_id"),
jellyfin_id=a.get("jellyfin_id"), name=a.get("name")))
out.append((keys, [(float(t0), float(t1)) for t0, t1 in a.get("scenes", [])]))
# schema_version 1: scenes is [[t0, t1], ...]; schema_version 2:
# scenes is [{"start":…, "end":…, "belief":…, "route":…}, …].
windows = []
for s in a.get("scenes", []):
if isinstance(s, dict):
windows.append((float(s["start"]), float(s["end"])))
else:
windows.append((float(s[0]), float(s[1])))
out.append((keys, windows))
return out
+74 -31
View File
@@ -1,17 +1,30 @@
#!/usr/bin/env python3
"""
Smoke test for the sae_kpn module: assemble the real downstream pipeline nodes
(face_tracker identity_matcher scene_tracker) in a Python-driven KPN network,
fed by a no-input Python source node, and verify SceneAnnotations flow out.
Smoke test for the sae_kpn module: assemble the real downstream pipeline
(tracker matcher annotation sink) in a Python-driven KPN network, fed by a
no-input Python source node, and verify the sink writes a truth file.
TRACES: VR-011 | PR-002
Proves the KPN-native replay path works without any numpy port of node logic.
Rewritten for `add_pipeline`. It previously called three node factories and read
SceneAnnotations back through the seam, asserting on what came out per frame.
Neither half of that survives VR-011: the factories are gone because the chain
has a construction order Python could not express, and presence is now the C++
sink's answer, derived from TrackRegistry claims. Nothing is read per frame, so
the assertions are on the file the sink writes.
Run: python scripts/optimizer/test_sae_kpn.py [gallery.json] [build_dir]
"""
import json
import sys
import queue
import numpy as np
import tempfile
import time
from pathlib import Path
import numpy as np
REPO = Path(__file__).resolve().parent.parent.parent
GAL = sys.argv[1] if len(sys.argv) > 1 else str(REPO / "gallery_arcface_w600k_r50.json")
BUILD = sys.argv[2] if len(sys.argv) > 2 else str(REPO / "build")
@@ -31,7 +44,6 @@ def make_frame(t, n):
def main():
net = sae_kpn.Network()
sae_kpn._register_types(net)
cfg = {"prob_threshold": 0.99, "anneal_sec": 10.0, "extinction_sec": 5.0}
frames = [make_frame(float(t), 1) for t in range(3)]
frames.append({"timestamp_sec": 3.0, "eof": True})
@@ -39,36 +51,67 @@ def main():
eof_frame = {"timestamp_sec": 3.0, "eof": True}
def source():
# Emit each frame once, then keep returning EOF (never block) so the node
# thread stays responsive to stop() after the sink has seen EOF.
# Emit each frame once, then keep returning EOF so the node thread stays
# responsive to stop(). The sleep matters: a no-input source is called in
# a tight loop, and hot-spinning EOFs pegs a core and floods the channel.
i = idx[0]
idx[0] += 1
return frames[i] if i < len(frames) else eof_frame
if i < len(frames):
return frames[i]
time.sleep(0.05)
return eof_frame
sae_kpn.add_node_python(net, "replay", source, [], ["EmbeddedSceneFrame"], 8)
sae_kpn.add_face_tracker(net, "tracker", cfg, 16)
sae_kpn.add_identity_matcher(net, "matcher", GAL, cfg, 16)
sae_kpn.add_scene_tracker(net, "scene", cfg, 16)
net.connect("replay", 0, "tracker", 0)
net.connect("tracker", 0, "matcher", 0)
net.connect("matcher", 0, "scene", 0)
net.build()
net.start()
with tempfile.TemporaryDirectory() as tmp:
out_path = str(Path(tmp) / "truth.json")
cfg = {
"prob_threshold": 0.99,
"track_extinction_sec": 5.0,
"output_path": out_path,
"movie_path": "sae_kpn smoke test",
"sample_fps": 1.0,
# Standard verbosity emits the per-frame array this test asserts on.
# At 0 the file carries only the actor epochs, and three random
# embeddings against a real gallery need not produce any.
"verbosity": 1,
}
got = []
for _ in range(4):
sa = net.read("scene", 0)
got.append(sa)
if sa.get("eof"):
break
net.stop()
sae_kpn.add_node_python(net, "replay", source, [], ["EmbeddedSceneFrame"], 16)
# No embedder stamp: these embeddings are random, not the output of any
# model, so there is nothing truthful to claim. That warns rather than
# failing, and would be fatal under SAE_REQUIRE_GALLERY_STAMP — which is
# correct, since an unverifiable binding is exactly what it guards.
sae_kpn.add_pipeline(net, GAL, cfg, 16)
non_eof = [g for g in got if not g.get("eof")]
assert len(non_eof) == 3, f"expected 3 annotations, got {len(non_eof)}"
assert got[-1].get("eof"), "expected trailing EOF"
assert [g["timestamp_sec"] for g in non_eof] == [0.0, 1.0, 2.0], "timestamps wrong"
assert all("visible_actors" in g for g in non_eof), "missing visible_actors"
print(f"OK: {len(non_eof)} annotations through the real KPN chain, EOF received")
net.connect("replay", 0, "tracker", 0)
net.connect("tracker", 0, "matcher", 0)
net.connect("matcher", 0, "annotation", 0)
net.connect("annotation", 0, "sink", 0)
net.build()
net.start()
# The sink writes on the EOF annotation. Wait for that rather than
# reading anything back: presence lives entirely on the C++ side.
deadline = time.time() + 30.0
while not sae_kpn.pipeline_done(net):
if time.time() > deadline:
sae_kpn.release_pipeline(net)
raise TimeoutError("sink never saw EOF within 30s")
time.sleep(0.02)
net.stop()
sae_kpn.release_pipeline(net)
with open(out_path) as f:
truth = json.load(f)
per_frame = truth.get("frames", [])
assert "actors" in truth, "truth file has no actors array"
assert len(per_frame) == 3, f"expected 3 frames, got {len(per_frame)}"
# EOF is a control token, not an observation: the sink flushes on it and does
# not record it, so three inputs give three frames and never four.
assert [f["t"] for f in per_frame] == [0.0, 1.0, 2.0], "timestamps wrong"
assert all("identified" in f for f in per_frame), "missing identified"
print(f"OK: {len(per_frame)} frames through the real KPN chain, sink wrote its truth file")
if __name__ == "__main__":
+2
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
"""run_from_jellyfin.py — resolve a Jellyfin title to its media file and run scene_analyze.
TRACES: IR-006 | SR-001
Looks up a Movie/Episode in Jellyfin, reads its on-disk Path (Jellyfin and this
tool must share the same media mount), filters the gallery down to that
title's credited cast (via filter_gallery's logic, fewer look-alike
+30 -4
View File
@@ -3,11 +3,29 @@
sae_embed.FaceEmbedder loads both ONNX sessions once and exposes an
embed(path) -> FaceResult method, avoiding the per-process model reload cost
of spawning the embed_faces CLI binary for every image.
resolve_arcface() exposes the same default-resolution logic load_embedder uses,
so a caller can stamp the gallery it is about to write with the model that
actually produced its embeddings (GR-004) the resolved path, not the CLI
argument, which is often None.
"""
import sys
import os
from pathlib import Path
DEFAULT_ARCFACE = "arcface_w600k_r50.onnx"
def resolve_arcface(models_dir: str, arcface: str | None = None) -> str:
"""The ArcFace/LVFace ONNX path load_embedder would use for these arguments.
TRACES: GR-004 | SR-001
Single source of truth for "which model is this", so the stamp written into
a gallery can never drift from the model loaded."""
return arcface if arcface else str(Path(models_dir) / DEFAULT_ARCFACE)
def load_embedder(build_dir: str, models_dir: str, arcface: str | None = None,
conf: float = 0.5, nms: float = 0.4, max_side: int = 500):
@@ -28,19 +46,27 @@ def load_embedder(build_dir: str, models_dir: str, arcface: str | None = None,
models_path = Path(models_dir)
detector_path = str(models_path / "scrfd_500m_bnkps.onnx")
arcface_path = arcface if arcface else str(models_path / "arcface_w600k_r50.onnx")
arcface_path = resolve_arcface(models_dir, arcface)
for model, name in [(detector_path, "SCRFD"), (arcface_path, "ArcFace")]:
if not Path(model).is_file():
sys.exit(f"{name} model not found: {model}\nRun: bash scripts/download_models.sh")
# A TRT-backend build cannot load .onnx; it needs pre-built engines from
# scripts/build_trt_engines.sh. Pass them when present (ignored by ORT).
# scripts/build_trt_engines.sh.
#
# These are passed only on request. The old comment here claimed they were
# "ignored by ORT" — they are not. The ORT backend treats an engine path as
# an instruction and raises, which is the right behaviour (silently ignoring
# a requested engine would be worse), but it meant that merely HAVING a
# populated trt_cache/ broke every ORT gallery build in the repo, with an
# error naming a flag the caller never set.
use_engines = os.environ.get("SAE_USE_TRT_ENGINES", "") not in ("", "0", "false")
trt = Path(models_path).parent / "trt_cache"
det_engine = trt / "scrfd.scrfd_500m_bnkps.640.fp16.engine"
arc_engine = trt / f"arcface.{Path(arcface_path).stem}.b4.fp16.engine"
return sae_embed.FaceEmbedder(
detector_path, arcface_path, conf, nms, max_side,
str(det_engine) if det_engine.is_file() else "",
str(arc_engine) if arc_engine.is_file() else "",
str(det_engine) if (use_engines and det_engine.is_file()) else "",
str(arc_engine) if (use_engines and arc_engine.is_file()) else "",
)
+62 -10
View File
@@ -6,9 +6,13 @@ make_jellyfin_gallery.download_urls + download_person_images) and the duplicated
Galleries are written directly as HDF5 never JSON. Same layout the C++ side
reads/writes (src/gallery/gallery_store.cpp): flat [N,512] embeddings + per-actor
offset/count, parallel imdb_id/tmdb_id/jellyfin_id/name string arrays, and a
per-embedding-row source_images array. calibration is left absent (calib_hash=0);
the C++ identity_matcher fits and writes it back into the file on first use.
offset/count, parallel imdb_id/tmdb_id/jellyfin_id/name string arrays, a
per-embedding-row source_images array, and an /embedder group carrying the
GR-004 model binding. calibration is left absent (calib_hash=0); the C++
identity_matcher fits and writes it back into the file on first use.
The GR-004 embedder stamp written into that /embedder group lives in sae_stamp
and is re-exported below, so existing callers keep importing it from here.
"""
import io
@@ -101,11 +105,36 @@ def download_images(urls: list[str], dest_dir: Path, n: int,
return paths
def save_gallery_hdf5(gallery: dict, output: Path) -> None:
# ── GR-004: gallery ↔ embedder binding ───────────────────────────────────────
# Implemented in sae_stamp (kept dependency-light so the optimizer's replay
# subprocesses can import it without pulling requests/Pillow); re-exported here
# because the gallery writers and every existing caller reach for it via this
# module. See src/gallery/embedder_stamp.hpp for the C++ twin and the rationale.
from sae_stamp import ( # noqa: F401
EmbedderMismatch,
check_embedder_stamp,
describe_stamp,
embedder_stamp,
enforce_embedder_stamp,
read_gallery_stamp,
require_gallery_stamp_from_env,
sha256_file,
verify_gallery_stamp,
_as_str,
_stamp_empty,
)
def save_gallery_hdf5(gallery: dict, output: Path, embedder: dict | None = None) -> None:
"""Write a gallery dict ({"actors": [...]}) directly as HDF5 — same schema
src/gallery/gallery_store.cpp reads/writes. No calibration group; the
C++ identity_matcher computes and writes it back into this file on first
use against an unseen set of embeddings."""
use against an unseen set of embeddings.
`embedder` is the GR-004 stamp (see embedder_stamp()); it may also be carried
on the gallery dict under "embedder", which is how a filtered/derived gallery
keeps its binding without the caller having to re-hash anything."""
embedder = embedder if embedder is not None else gallery.get("embedder")
actors = gallery["actors"]
embs, offsets, counts = [], [], []
imdb, tmdb, jf, name, src_images = [], [], [], [], []
@@ -139,8 +168,18 @@ def save_gallery_hdf5(gallery: dict, output: Path) -> None:
f.create_dataset("jellyfin_id", data=np.asarray(jf, dtype=object), dtype=str_t)
f.create_dataset("name", data=np.asarray(name, dtype=object), dtype=str_t)
f.create_dataset("source_images", data=np.asarray(src_images, dtype=object), dtype=str_t)
print(f"Saved: {output} ({len(actors)} actors, {emb_arr.shape[0]} embeddings)",
file=sys.stderr)
# TRACES: GR-004 | SR-001
# omitted entirely when unknown, so "unstamped"
# round-trips as unstamped rather than as a stamp naming no model.
if not _stamp_empty(embedder):
g = f.create_group("embedder")
g.attrs["model_name"] = embedder.get("model_name", "")
g.attrs["model_sha256"] = embedder.get("model_sha256", "")
g.attrs["embed_dim"] = np.int32(embedder.get("embed_dim", 512))
stamp_note = (f", embedder {embedder['model_name']}" if not _stamp_empty(embedder)
else ", NO EMBEDDER STAMP (GR-004)")
print(f"Saved: {output} ({len(actors)} actors, {emb_arr.shape[0]} embeddings"
f"{stamp_note})", file=sys.stderr)
def load_gallery_hdf5(path: Path) -> dict:
@@ -158,6 +197,15 @@ def load_gallery_hdf5(path: Path) -> dict:
if "source_images" in f:
src_images = [s.decode() if isinstance(s, bytes) else s
for s in f["source_images"][:]]
# TRACES: GR-004 | SR-001
# carried through so a derived gallery (filter,
# merge, cast-restrict) keeps the binding of the gallery it came from.
stamp = None
if "embedder" in f:
a = f["embedder"].attrs
stamp = {"model_name": _as_str(a.get("model_name", "")),
"model_sha256": _as_str(a.get("model_sha256", "")),
"embed_dim": int(a.get("embed_dim", 512))}
actors = []
for a in range(len(offset)):
@@ -167,15 +215,19 @@ def load_gallery_hdf5(path: Path) -> dict:
if src_images is not None:
actor["source_images"] = [src_images[s + i] for i in range(n)]
actors.append(actor)
return {"actors": actors}
out = {"actors": actors}
if stamp is not None:
out["embedder"] = stamp
return out
def save_gallery(gallery: dict, missing: list[dict], output: Path) -> None:
def save_gallery(gallery: dict, missing: list[dict], output: Path,
embedder: dict | None = None) -> None:
"""Write the gallery as HDF5 (forcing a .h5 extension) and, if any actors
lack images, a .missing_images.json sidecar."""
if output.suffix not in (".h5", ".hdf5"):
output = output.with_suffix(".h5")
save_gallery_hdf5(gallery, output)
save_gallery_hdf5(gallery, output, embedder)
if missing:
missing_path = output.with_name(output.stem + ".missing_images.json")
+217
View File
@@ -0,0 +1,217 @@
"""Gallery ↔ embedder model binding (GR-004).
TRACES: GR-004 | SR-001
Python twin of src/gallery/embedder_stamp.{hpp,cpp}; the two implement the same
comparison rules and must stay in agreement. Kept as its own module rather than
folded into sae_gallery because scripts/optimizer/replay.py imports it once per
replay subprocess, thousands of times in a DE sweep, and must not pay for
sae_gallery's requests/Pillow imports to ask "were these made by the same model?".
Dependencies here are hashlib, json and h5py, all of which a replay already loads.
A gallery is only valid for the embedder that built it: cosine similarities across
models are meaningless but look plausible, so the mistake is silent and every
measurement taken afterwards is suspect. Identity = model filename + SHA-256 of
the model file. The hash decides (a model re-exported in place keeps its name but
not its bytes); the name is what makes the error readable. See
src/gallery/embedder_stamp.hpp for the full rationale.
"""
import hashlib
import json
import os
import sys
from pathlib import Path
import h5py
def _as_str(v) -> str:
return v.decode() if isinstance(v, bytes) else ("" if v is None else str(v))
_STAMP_CACHE: dict = {}
class EmbedderMismatch(RuntimeError):
"""Gallery was built with a different embedder than the one about to be used."""
def sha256_file(path) -> str:
"""Lowercase hex SHA-256 of a file's bytes; "" if it cannot be read."""
path = Path(path)
try:
st = path.stat()
except OSError:
return ""
key = (str(path), st.st_mtime_ns, st.st_size)
if key in _STAMP_CACHE:
return _STAMP_CACHE[key]
h = hashlib.sha256()
try:
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
except OSError:
return ""
_STAMP_CACHE[key] = h.hexdigest()
return _STAMP_CACHE[key]
def embedder_stamp(model_path, embed_dim: int = 512) -> dict:
"""Identify an embedder model file → {"model_name", "model_sha256", "embed_dim"}.
A model file that is absent (e.g. a TRT deployment running from a prebuilt
.engine) yields a name-only stamp: still comparable, just not provable."""
if not model_path:
return {"model_name": "", "model_sha256": "", "embed_dim": embed_dim}
sha = sha256_file(model_path)
if not sha:
print(f"[gallery] cannot hash embedder model {model_path} — model binding "
f"falls back to filename only (GR-004)", file=sys.stderr)
return {"model_name": Path(model_path).name, "model_sha256": sha,
"embed_dim": embed_dim}
def _stamp_empty(s) -> bool:
return not s or (not s.get("model_name") and not s.get("model_sha256"))
def describe_stamp(s) -> str:
if _stamp_empty(s):
return "UNKNOWN"
name = s.get("model_name") or "<unnamed model>"
sha = s.get("model_sha256") or ""
return f"{name} (sha256 {sha[:12]}…)" if sha else f"{name} (sha256 unavailable)"
def require_gallery_stamp_from_env() -> bool:
"""SAE_REQUIRE_GALLERY_STAMP=1 → an unprovable binding is fatal, not a warning."""
return os.environ.get("SAE_REQUIRE_GALLERY_STAMP", "0") not in ("", "0")
def check_embedder_stamp(built_with: dict | None, loading_with: dict | None,
gallery_desc: str = "gallery",
embedder_desc: str = "embedder") -> tuple[str, str]:
"""Pure comparison. Returns (verdict, message); verdict is one of
match / weak_match / unstamped / unknown_embedder / mismatch.
Same rules as compare_embedder_stamps() in src/gallery/embedder_stamp.cpp."""
if _stamp_empty(built_with):
return "unstamped", (
f"gallery '{gallery_desc}' carries no embedder stamp (GR-004).\n"
f" gallery was built with : UNKNOWN — this file predates model binding\n"
f" embedder now loaded : {describe_stamp(loading_with)} [{embedder_desc}]\n"
f" If these are not the same model every similarity from this run is\n"
f" meaningless but will look plausible. Rebuild or re-stamp the gallery\n"
f" (scripts/stamp_gallery.py), or run with SAE_REQUIRE_GALLERY_STAMP=1 to\n"
f" make this a hard error.")
if _stamp_empty(loading_with):
return "unknown_embedder", (
f"cannot identify the embedder being used against gallery "
f"'{gallery_desc}' (GR-004).\n"
f" gallery was built with : {describe_stamp(built_with)}\n"
f" embedder now loaded : UNKNOWN [{embedder_desc}]\n"
f" The binding cannot be checked, so it is not being checked.")
mismatch_tail = (
" Cosine similarities between embeddings from different models are\n"
" meaningless but look plausible. Rebuild the gallery with the loaded\n"
" model, or point the embedder at the model the gallery was built with.")
if int(built_with.get("embed_dim", 512)) != int(loading_with.get("embed_dim", 512)):
return "mismatch", (
f"gallery/embedder MODEL MISMATCH — refusing to run (GR-004).\n"
f" gallery was built with : {describe_stamp(built_with)}, "
f"dim={built_with.get('embed_dim')} [{gallery_desc}]\n"
f" embedder now loaded : {describe_stamp(loading_with)}, "
f"dim={loading_with.get('embed_dim')} [{embedder_desc}]\n"
f" Embedding dimensions differ; these are not the same space.")
a, b = built_with.get("model_sha256", ""), loading_with.get("model_sha256", "")
if a and b:
if a == b:
note = ""
if built_with.get("model_name") != loading_with.get("model_name"):
note = (f" (gallery recorded it as '{built_with.get('model_name')}', "
f"loaded from '{loading_with.get('model_name')}'"
f"same bytes, renamed file)")
return "match", f"embedder binding verified: {describe_stamp(built_with)}{note}"
return "mismatch", (
f"gallery/embedder MODEL MISMATCH — refusing to run (GR-004).\n"
f" gallery was built with : {built_with.get('model_name')} sha256={a}\n"
f" [{gallery_desc}]\n"
f" embedder now loaded : {loading_with.get('model_name')} sha256={b}\n"
f" [{embedder_desc}]\n" + mismatch_tail)
if built_with.get("model_name") and \
built_with.get("model_name") == loading_with.get("model_name"):
return "weak_match", (
f"embedder binding UNPROVEN for gallery '{gallery_desc}' (GR-004).\n"
f" gallery was built with : {describe_stamp(built_with)}\n"
f" embedder now loaded : {describe_stamp(loading_with)} [{embedder_desc}]\n"
f" Filenames agree but at least one SHA-256 is unavailable, so an\n"
f" in-place re-export under the same name would not be detected.")
return "mismatch", (
f"gallery/embedder MODEL MISMATCH — refusing to run (GR-004).\n"
f" gallery was built with : {describe_stamp(built_with)} [{gallery_desc}]\n"
f" embedder now loaded : {describe_stamp(loading_with)} [{embedder_desc}]\n"
+ mismatch_tail)
def enforce_embedder_stamp(built_with, loading_with, gallery_desc, embedder_desc,
require_stamp: bool = False) -> str:
"""Apply check_embedder_stamp: raise EmbedderMismatch when fatal, else warn.
A mismatch is fatal unconditionally there is no bypass, because a mismatch is
a known-wrong state, not an unknown one. The three "cannot prove it" verdicts
warn loudly and become fatal under require_stamp / SAE_REQUIRE_GALLERY_STAMP."""
strict = require_stamp or require_gallery_stamp_from_env()
verdict, msg = check_embedder_stamp(built_with, loading_with,
gallery_desc, embedder_desc)
if verdict == "mismatch":
raise EmbedderMismatch(msg)
if strict and verdict != "match":
raise EmbedderMismatch(
msg + "\n (fatal because SAE_REQUIRE_GALLERY_STAMP is set)")
if verdict == "match":
print(f"[gallery] {msg}", file=sys.stderr)
else:
print(f"\n[gallery] ***** WARNING (GR-004) *****\n{msg}\n"
f"[gallery] ****************************\n", file=sys.stderr)
return verdict
def read_gallery_stamp(path) -> dict | None:
"""The embedder stamp recorded in a gallery file, or None if unstamped.
Handles both the HDF5 /embedder group and the legacy JSON "embedder" object."""
path = Path(path)
if path.suffix in (".h5", ".hdf5"):
with h5py.File(path, "r") as f:
if "embedder" not in f:
return None
a = f["embedder"].attrs
return {"model_name": _as_str(a.get("model_name", "")),
"model_sha256": _as_str(a.get("model_sha256", "")),
"embed_dim": int(a.get("embed_dim", 512))}
data = json.loads(path.read_text())
return data.get("embedder") or None
def verify_gallery_stamp(gallery_path, model_path=None, *, stamp=None,
embedder_desc: str | None = None,
require_stamp: bool = False) -> str:
"""Load a gallery's stamp and check it against a model file (or an explicit
stamp, e.g. one read off an embedding dump). Raises EmbedderMismatch."""
loading = stamp if stamp is not None else embedder_stamp(model_path)
return enforce_embedder_stamp(read_gallery_stamp(gallery_path), loading,
str(gallery_path),
embedder_desc or str(model_path or "unknown"),
require_stamp)
def _as_str(v) -> str:
return v.decode() if isinstance(v, bytes) else ("" if v is None else str(v))
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
de_ramp.py DE-optimise a temporal matched-filter "ramp" per modality, whose
response becomes a feature channel for the scene-boundary LSTM.
A scene boundary is where a feature series (RGB histogram, audio log-PSD) shifts
from a "before" state to an "after" state. A signed, antisymmetric ramp kernel
convolved with the series responds strongly exactly at that transition and near
zero inside a stable scene a matched filter for a step. Its shape is not
obvious (how wide? linear or peaked? how much centre dead-zone?), so we let DE
choose it by maximising boundary separation on the training films.
Ramp kernel over lags -H..+H seconds (1 fps 1 sample/s):
w(l) = sign(l) * (|l| / H) ** gamma for |l| >= dead, else 0
params: H (half-width), gamma (shape), dead (centre dead-zone)
Response at t = || sum_l w(l) * feat[t+l] || (L2 over feature bins)
DE objective: boundary-detection F1 of a top-percentile threshold on the response,
macro-averaged over the training films (±2 s tolerance). The tuned (H, gamma,
dead) is saved; train_scene_boundary.py appends the ramp response as an input
channel to each tower.
Usage:
python scripts/scene_detector/de_ramp.py \
--manifest experiments/manifests/films_LVFace_opencv5.json \
--audio-dir experiments/dumps/audio_features \
--holdout Scarface Sound_of_Metal --out experiments/results/scene_boundary
"""
from __future__ import annotations
import argparse, csv, json, sys
from pathlib import Path
import h5py, numpy as np
from scipy.optimize import differential_evolution
def xray_bounds(xray_dir):
return sorted(float(r["start"])/1000 for r in
csv.DictReader(open(Path(xray_dir)/"scenes.csv"))
if float(r["start"]) > 500)
def load_series(dump, audio_dir, which):
if which == "audio":
# Audio is self-contained in the npz — no h5 needed (its ts IS the grid),
# so the audio cutter can be tuned before/without the RGB dumps.
slug = Path(dump).stem.replace("dump_", "")
z = np.load(Path(audio_dir)/f"{slug}.npz")
s = z["feat"].astype(np.float64)
ts = z["ts"] if "ts" in z else np.arange(len(s), dtype=float)
else: # video
with h5py.File(dump) as f:
ts = f["frames/timestamp_sec"][:]
s = f["frames/rgb_hist"][:].astype(np.float64)
# z-normalise each bin so L2 response isn't dominated by one loud bin
s = (s - s.mean(0)) / (s.std(0) + 1e-6)
return s, ts
def ramp_kernel(H, gamma, dead):
lags = np.arange(-H, H+1)
w = np.sign(lags) * (np.abs(lags)/max(H,1))**gamma
w[np.abs(lags) < dead] = 0.0
return w
def response(series, w, H):
T = series.shape[0]
r = np.zeros(T)
for t in range(T):
lo, hi = max(0, t-H), min(T, t+H+1)
wl = w[(lo-(t-H)):(hi-(t-H))]
r[t] = np.linalg.norm((series[lo:hi]*wl[:, None]).sum(0))
return r
def boundary_f1(resp, bounds, pct, tol=2):
thr = np.percentile(resp, pct)
pred = np.where(resp > thr)[0]
bidx = [int(b) for b in bounds if int(b) < len(resp)]
if len(pred) == 0 or not bidx:
return 0.0
tp_p = sum(any(abs(p-i) <= tol for i in bidx) for p in pred)
tp_t = sum(any(abs(p-i) <= tol for p in pred) for i in bidx)
P, R = tp_p/len(pred), tp_t/len(bidx)
return 2*P*R/(P+R) if P+R else 0.0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--manifest", required=True)
ap.add_argument("--audio-dir", default="experiments/dumps/audio_features")
ap.add_argument("--holdout", nargs="+", default=["Scarface", "Sound_of_Metal"])
ap.add_argument("--out", default="experiments/results/scene_boundary")
args = ap.parse_args()
films = [f for f in json.load(open(args.manifest)) if f["slug"] not in args.holdout]
out = {}
for which in ("video", "audio"):
data = [(load_series(f["dump"], args.audio_dir, which)[0], xray_bounds(f["xray"]))
for f in films]
def neg_f1(x):
H = int(round(x[0])); gamma = x[1]; dead = int(round(x[2])); pct = x[3]
if H < 1 or dead >= H: return 0.0
w = ramp_kernel(H, gamma, dead)
f1s = [boundary_f1(response(s, w, H), b, pct) for s, b in data]
return -float(np.mean(f1s))
# bounds: H 1..10s, gamma 0.3..3, dead 0..4s, threshold pct 80..98
res = differential_evolution(
neg_f1, [(1, 10), (0.3, 3.0), (0, 4), (80, 98)],
seed=0, popsize=12, maxiter=25, tol=1e-4, polish=False)
H = int(round(res.x[0])); gamma = float(res.x[1])
dead = int(round(res.x[2])); pct = float(res.x[3])
out[which] = {"H": H, "gamma": gamma, "dead": dead, "pct": pct,
"train_f1": float(-res.fun)}
print(f"[de-ramp] {which}: H={H}s gamma={gamma:.2f} dead={dead}s "
f"pct={pct:.0f} train boundary-F1={-res.fun*100:.1f}%", file=sys.stderr)
Path(args.out).mkdir(parents=True, exist_ok=True)
json.dump(out, open(Path(args.out)/"de_ramp.json", "w"), indent=2)
print(f"[de-ramp] → {args.out}/de_ramp.json", file=sys.stderr)
if __name__ == "__main__":
main()
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""
density_floor.py synthesise scene boundaries when detection is starved.
Flood-fill presence snaps each actor claim to the shot it sits in, so a film
whose boundary detector fires almost nothing (Scarface: 1 cut in 171 min) floods
every actor across the whole film. This is a safety floor: when a film's DETECTED
boundary density is far below what a working detector should produce, fill the
long gaps between real detections with uniformly-spaced synthetic boundaries so no
flood-fill span can exceed ~1/target-density.
Design points (measured on the X-Ray corpus):
- The target density is a PRIOR from the central 60 min of films (avoids credits/
intro/outro skew): median ~0.35 scenes/min.
- The trigger is detected-vs-prior, not prior-vs-anything: only fire when detected
density < TRIGGER_FRAC × prior. Legitimately sparse films (long-scene ensembles
like Downton/Many Saints) detect fine and are left alone.
- Real detections are never moved or dropped; synthetic boundaries only subdivide
gaps that are longer than the target scene length.
"""
from __future__ import annotations
PRIOR_SCENES_PER_MIN = 0.35 # central-60min X-Ray median
TRIGGER_FRAC = 0.30 # fire only when detected < 30% of prior
def apply_density_floor(boundaries: list[float], duration_sec: float,
prior_per_min: float = PRIOR_SCENES_PER_MIN,
trigger_frac: float = TRIGGER_FRAC) -> list[float]:
"""Return boundaries augmented with synthetic ones iff detection is starved.
boundaries: detected boundary timestamps (s), any order.
duration_sec: film length.
Returns a sorted list; unchanged (just sorted) when the film is not starved.
"""
b = sorted(t for t in boundaries if 0.0 < t < duration_sec)
minutes = duration_sec / 60.0
if minutes <= 0:
return b
detected_density = len(b) / minutes
if detected_density >= trigger_frac * prior_per_min:
return b # detector produced a reasonable amount — leave it alone
target_gap = 60.0 / prior_per_min # seconds per expected scene
edges = [0.0] + b + [duration_sec]
out = list(b)
for lo, hi in zip(edges[:-1], edges[1:]):
gap = hi - lo
if gap <= target_gap:
continue
n_insert = int(gap // target_gap) # how many synthetic cuts fit
step = gap / (n_insert + 1)
for k in range(1, n_insert + 1):
out.append(lo + k * step)
return sorted(out)
if __name__ == "__main__":
# self-check on the Scarface failure and a healthy film
scar = apply_density_floor([88.0], 171*60) # 1 detected cut, 171 min
print(f"Scarface: 1 detected → {len(scar)} after floor "
f"({len(scar)/171:.2f}/min, prior {PRIOR_SCENES_PER_MIN})")
healthy = apply_density_floor([i*130.0 for i in range(1, 47)], 122*60)
print(f"healthy (46 detected/122min={46/122:.2f}/min): "
f"{len(healthy)} after floor (unchanged = not triggered)")
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""
downstream_presence.py does the XGBoost scene detector actually improve ACTOR
PRESENCE accuracy? Boundary-F1 is only a proxy; this is the number that decides
whether the detector ships.
For each film, compares presence (per-second X-Ray F1) under three regimes:
A. track_extent no flood-fill (claim = [first_seen, last_seen])
B. flood + histogram cuts current shipped flood (snaps to is_cut)
C. flood + XGBoost bounds inject the detector's boundaries into
is_scene_boundary (flood prefers it over is_cut)
Injection: write a copy of each dump with frames/is_scene_boundary set from the
XGBoost knee boundaries, then replay --presence-mode flood against that copy.
Uses the shipped model (all-9 fit). Scored with second_score at the 10-knob
optimum config.
"""
from __future__ import annotations
import sys, json, shutil, subprocess, tempfile, os
from pathlib import Path
import numpy as np
import h5py
sys.path.insert(0, "scripts/scene_detector")
sys.path.insert(0, "scripts/optimizer")
sys.path.insert(0, "scripts/validation")
import train_xgb_boundary as XB
from second_score import score_seconds
from sample_eval import load_gallery_keys
import xgboost as xgb
GAL = "experiments/galleries/gallery_LVFace-B_Glint360K.h5"
MODEL = "experiments/results/scene_boundary/xgb_boundary_shipped.json"
# 10-knob presence optimum (shipped config)
CFG = ["--prob-threshold", "0.485", "--ownership-logodds", "1.72",
"--track-extinction-sec", "31", "--track-alpha", "0.435",
"--evidence-rho-max", "0.204", "--evidence-admit-below", "0.784",
"--match-prior", "0.433", "--expand-band-lo", "0.804",
"--expand-band-hi", "0.952", "--expand-gallery"]
def xgb_boundary_seconds(reg, dump):
X, yb, ic = XB.per_second_matrix(dump, xr_for(dump), "experiments/dumps/audio_features")
prob = np.clip(reg.predict(X), 0, 1)
return set(XB.knee_boundaries(prob))
FILMS = json.load(open("experiments/manifests/films_LVFace_opencv5.json"))
_XR = {f["dump"]: f["xray"] for f in FILMS}
def xr_for(dump): return _XR[dump]
def inject_boundaries(dump, second_set, out_path):
"""Copy dump, set frames/is_scene_boundary=1 at the given integer seconds."""
shutil.copy(dump, out_path)
with h5py.File(out_path, "r+") as f:
ts = f["frames/timestamp_sec"][:]
bnd = np.zeros(len(ts), np.uint8)
for i, t in enumerate(ts):
if int(round(t)) in second_set:
bnd[i] = 1
if "frames/is_scene_boundary" in f:
f["frames/is_scene_boundary"][:] = bnd
else:
f["frames"].create_dataset("is_scene_boundary", data=bnd)
def replay(dump, out, mode):
argv = [".venv-rocm/bin/python" if False else sys.executable,
"scripts/optimizer/replay.py", "--dump", dump, "--gallery", GAL,
"--out", out] + CFG
if mode:
argv += ["--presence-mode", mode]
subprocess.run(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=300)
return json.loads(Path(out).read_text())
def main():
reg = xgb.XGBRegressor(); reg.load_model(MODEL)
gk = load_gallery_keys(GAL)
tmp = tempfile.mkdtemp()
print(f"{'film':24s} {'trackext':>9} {'flood+hist':>11} {'flood+XGB':>10}")
agg = {"track_extent": [], "flood_hist": [], "flood_xgb": []}
for f in FILMS:
dump, xr = f["dump"], f["xray"]
out = f"{tmp}/out.json"
# A. track_extent
a = score_seconds(replay(dump, out, "track_extent"), xr, gallery_keys=gk)
# B. flood + histogram cuts (original dump's is_cut; is_scene_boundary=0)
b = score_seconds(replay(dump, out, "flood"), xr, gallery_keys=gk)
# C. flood + XGBoost boundaries injected
inj = f"{tmp}/inj_{f['slug']}.h5"
inject_boundaries(dump, xgb_boundary_seconds(reg, dump), inj)
c = score_seconds(replay(inj, out, "flood"), xr, gallery_keys=gk)
os.unlink(inj)
agg["track_extent"].append(a["f1"]); agg["flood_hist"].append(b["f1"])
agg["flood_xgb"].append(c["f1"])
print(f"{f['name'][:24]:24s} {a['f1']*100:8.1f}% {b['f1']*100:10.1f}% "
f"{c['f1']*100:9.1f}%")
print(f"\n{'MACRO-MEAN':24s} {np.mean(agg['track_extent'])*100:8.1f}% "
f"{np.mean(agg['flood_hist'])*100:10.1f}% {np.mean(agg['flood_xgb'])*100:9.1f}%")
json.dump({k: float(np.mean(v)) for k, v in agg.items()},
open("experiments/results/scene_boundary/downstream_presence.json", "w"),
indent=2)
if __name__ == "__main__":
main()
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
extract_audio_features.py per-second audio features for scene-boundary detection.
Audio is often a stronger scene-boundary cue than video: music swells, silence,
and ambience changes at narrative scene transitions exactly the coarse
boundaries Amazon X-Ray marks, and exactly what the grayscale video cut detector
misses on low-contrast films. This extracts a small per-second feature series per
film, aligned to the 1 fps timeline the embedding dumps use, so it can be fused
with the RGB-histogram features in train_scene_boundary.py.
Two-tower design: this is the AUDIO tower's input, mirroring the video tower's
per-second RGB histogram. Because the scene model is an LSTM (temporal context
comes from the recurrence, not a 2D spectrogram), each second needs only a single
log-PSD vector one FFT over a WIN_SEC window centred on that second. The LSTM
sees the sequence of per-second PSDs and learns the boundary dynamics itself.
Per second t:
- log-PSD over [t-WIN/2, t+WIN/2], N_BINS log-spaced frequency bins, L1-norm'd
then log1p the spectral shape (music vs speech vs silence vs ambience),
which changes at scene transitions.
No new dependency: ffmpeg (CLI) decodes the whole track to mono 16 kHz WAV;
numpy does the FFT.
Writes <out_dir>/<slug>.npz with `ts` (second grid) and `feat` [T, N_BINS].
Usage:
python scripts/scene_detector/extract_audio_features.py \
--manifest experiments/manifests/films_LVFace_opencv5.json \
--file-lut experiments/file-lut.json \
--out experiments/dumps/audio_features
"""
from __future__ import annotations
import argparse, json, subprocess, sys, tempfile, os
from pathlib import Path
import numpy as np
from scipy import signal as sps
from scipy.io import wavfile
SR = 16000
HOP_SEC = 1.0 # one feature vector per second (matches 1 fps presence grid)
WIN_SEC = 4.0 # FFT window per second (centred); >HOP for temporal context
N_BINS = 64 # log-spaced frequency bins per second (the audio tower dim)
def decode_mono(path: str) -> np.ndarray:
"""Whole-file mono 16 kHz float32 PCM via ffmpeg."""
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tf:
wav = tf.name
try:
subprocess.run(
["ffmpeg", "-v", "error", "-y", "-i", path,
"-ac", "1", "-ar", str(SR), "-f", "wav", wav],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
sr, x = wavfile.read(wav)
if x.dtype == np.int16:
x = x.astype(np.float32) / 32768.0
else:
x = x.astype(np.float32)
return x
finally:
try: os.unlink(wav)
except OSError: pass
def _logbin_edges(win_samples: int) -> np.ndarray:
"""Indices into the rfft output that bound N_BINS log-spaced freq bands."""
nfreq = win_samples // 2 + 1
# log-space from bin 1 (skip DC) to Nyquist; unique integer edges
edges = np.unique(np.geomspace(1, nfreq - 1, N_BINS + 1).astype(int))
return edges
def features(mono: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Return (ts[T], feat[T, N_BINS]) — one per-second log-PSD row.
One FFT per second over a WIN_SEC window centred on that second. Power is
pooled into N_BINS log-spaced frequency bands (mel-like), L1-normalised across
bands (so loudness doesn't dominate — the SHAPE is the scene cue), then
log1p-compressed. The LSTM downstream supplies temporal context, so no
spectrogram/2D input is needed."""
hop = int(SR * HOP_SEC)
win = int(SR * WIN_SEC)
T = len(mono) // hop
if T == 0:
return np.zeros(0), np.zeros((0, N_BINS), np.float32)
edges = _logbin_edges(win)
nb = len(edges) - 1
hann = sps.windows.hann(win)
feat = np.zeros((T, nb), np.float32)
half = win // 2
for t in range(T):
centre = t * hop + hop // 2
s = centre - half
seg = mono[max(0, s): s + win]
if len(seg) < win: # pad edges
seg = np.pad(seg, (0, win - len(seg)))
psd = np.abs(np.fft.rfft(seg * hann))**2 + 1e-12
band = np.array([psd[edges[i]:edges[i+1]].sum() for i in range(nb)])
band /= band.sum() # normalise shape, drop loudness
feat[t] = np.log1p(band * 1e3)
ts = np.arange(T, dtype=np.float64)
return ts, feat
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--manifest", required=True)
ap.add_argument("--file-lut", default="experiments/file-lut.json")
ap.add_argument("--out", default="experiments/dumps/audio_features")
args = ap.parse_args()
films = json.load(open(args.manifest))
lut = json.load(open(args.file_lut))
Path(args.out).mkdir(parents=True, exist_ok=True)
for f in films:
slug = f["slug"]
outp = Path(args.out) / f"{slug}.npz"
if outp.exists():
print(f"[audio] {slug}: exists, skip", file=sys.stderr); continue
path = lut.get(slug)
if not path or not os.path.exists(path):
print(f"[audio] {slug}: movie missing ({path})", file=sys.stderr); continue
try:
mono = decode_mono(path)
ts, feat = features(mono)
np.savez_compressed(outp, ts=ts, feat=feat)
print(f"[audio] {slug}: {len(ts)}s feat{feat.shape}{outp.name}",
file=sys.stderr)
except subprocess.CalledProcessError:
print(f"[audio] {slug}: ffmpeg decode failed", file=sys.stderr)
if __name__ == "__main__":
main()
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Generate the scene-boundary-detector report figures from saved results.
Data-driven, reproducible, no video needed. Writes PNGs to docs/assets/images/."""
import json
from pathlib import Path
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
OUT = Path("docs/assets/images")
OUT.mkdir(parents=True, exist_ok=True)
plt.rcParams.update({"font.size": 11, "axes.splines.top" if False else "axes.grid": True,
"axes.axisbelow": True, "grid.alpha": 0.3, "figure.dpi": 130})
FILMS = ["Benny & Joon","Café Society","Downton Abbey","Lord of War","Lovelace",
"Many Saints","Scarface","Sound of Metal","Valerian"]
# per-film presence F1 (downstream_loo run): track_extent, flood+grayscale, flood+learned(LOO)
TE = [77.3,59.1,41.0,74.8,70.3,37.5,62.6,75.0,65.6]
FG = [80.2,62.2,51.8,77.1,74.0,43.9,40.9,78.1,67.7]
FL = [78.2,69.8,78.6,77.8,78.2,53.4,74.9,86.8,76.2]
# ── Figure 1: per-film presence F1, three boundary sources ───────────────────
def fig_presence():
x = np.arange(len(FILMS)); w = 0.26
fig, ax = plt.subplots(figsize=(11,5))
ax.bar(x-w, TE, w, label="track-extent (flood off)", color="#9aa7b4")
ax.bar(x, FG, w, label="flood + grayscale cuts", color="#e07a5f")
ax.bar(x+w, FL, w, label="flood + learned detector (LOO)", color="#3d7ea6")
ax.set_ylabel("per-second X-Ray presence F1 (%)")
ax.set_title("Actor-presence accuracy by flood-fill boundary source (leave-one-out)")
ax.set_xticks(x); ax.set_xticklabels(FILMS, rotation=30, ha="right")
ax.set_ylim(0,100); ax.legend(loc="upper left", framealpha=0.9)
# annotate the two headline swings
ax.annotate("grayscale flood\nBREAKS Scarface", xy=(6, 40.9), xytext=(5.1, 20),
fontsize=9, color="#b23", ha="center",
arrowprops=dict(arrowstyle="->", color="#b23"))
ax.annotate("+37pp", xy=(2+w, 78.6), xytext=(2+w, 90), fontsize=9,
color="#3d7ea6", ha="center",
arrowprops=dict(arrowstyle="->", color="#3d7ea6"))
macro=[np.mean(TE),np.mean(FG),np.mean(FL)]
ax.text(0.99,0.02,f"macro: {macro[0]:.1f}% / {macro[1]:.1f}% / {macro[2]:.1f}%",
transform=ax.transAxes, ha="right", va="bottom", fontsize=10,
bbox=dict(boxstyle="round", fc="#f4f4f4", ec="#ccc"))
fig.tight_layout(); fig.savefig(OUT/"scene_presence_by_source.png"); plt.close(fig)
# ── Figure 2: macro presence F1 — the progression ───────────────────────────
def fig_macro():
labels=["track-extent","flood +\ngrayscale","flood +\nlearned (LOO)"]
vals=[np.mean(TE),np.mean(FG),np.mean(FL)]
fig,ax=plt.subplots(figsize=(6,4.5))
bars=ax.bar(labels,vals,color=["#9aa7b4","#e07a5f","#3d7ea6"])
for b,v in zip(bars,vals): ax.text(b.get_x()+b.get_width()/2, v+1, f"{v:.1f}%",
ha="center", fontsize=11, fontweight="bold")
ax.set_ylabel("macro presence F1 (%)"); ax.set_ylim(0,90)
ax.set_title("Flood-fill boundary source → presence accuracy")
fig.tight_layout(); fig.savefig(OUT/"scene_presence_macro.png"); plt.close(fig)
# ── Figure 3: feature/model evolution (boundary-F1 development) ──────────────
# Two panels, because the development curve and the shipped result are measured
# at DIFFERENT tolerances and must not be plotted on one axis:
# left — relative feature progress at the strict ±2 s tolerance (how the LSTM
# experiments were scored; establishes which features helped)
# right — the shipped XGBoost detector at the ±20 s tolerance the pipeline
# actually uses and scores at (grayscale vs learned-LOO vs train-all)
def fig_evolution():
fig,(axl,axr)=plt.subplots(1,2,figsize=(11,4.5),gridspec_kw={"width_ratios":[1.15,1]})
steps=["grayscale\nbaseline","raw-hist\nLSTM","delta\nLSTM","XGBoost\n(delta+debounce)"]
dev=[7.2,7.5,10.8,15.2] # boundary-F1 @±2s during LSTM-era development
axl.plot(steps,dev,marker="o",color="#9aa7b4",lw=2,ms=8)
for i,v in enumerate(dev): axl.text(i,v+0.4,f"{v:.1f}%",ha="center",fontsize=9)
axl.set_ylabel("boundary F1 @±2 s (%)")
axl.set_title("Feature progress (strict ±2 s)")
axl.set_ylim(0,18)
# shipped detector at the ±20s tolerance the pipeline uses — real measured
# macro numbers: grayscale (xgb_report gray_F1), learned LOO, learned train-all
names=["grayscale","learned\n(LOO)","learned\n(train-all)"]
f20=[29.8,44.1,72.9]; cols=["#e07a5f","#3d7ea6","#8fb8cf"]
bars=axr.bar(names,f20,color=cols)
for b,v in zip(bars,f20): axr.text(b.get_x()+b.get_width()/2,v+1.2,f"{v:.1f}%",
ha="center",fontsize=10,fontweight="bold")
axr.set_ylabel("boundary F1 @±20 s (%)")
axr.set_title("Shipped detector (±20 s, macro/9 films)")
axr.set_ylim(0,80)
fig.suptitle("Detector development, and where it landed",fontsize=13)
fig.tight_layout(); fig.savefig(OUT/"scene_detector_evolution.png"); plt.close(fig)
import csv as _csv
# ── Figure 4: DE convergence (the 10-knob presence sweep) ────────────────────
def fig_de():
import json
rows=[json.loads(l) for l in open("experiments/trajectories/lvface_opencv5_10knob.FINAL.jsonl")]
f1=[r["f1"]*100 for r in rows]
run_best=np.maximum.accumulate(f1)
fig,ax=plt.subplots(figsize=(8,4.5))
ax.scatter(range(len(f1)),f1,s=8,alpha=0.35,color="#9aa7b4",label="candidate")
ax.plot(run_best,color="#3d7ea6",lw=2,label="best so far")
ax.set_xlabel("DE evaluation"); ax.set_ylabel("macro presence F1 (%)")
ax.set_title("10-knob presence sweep (Differential Evolution)")
ax.legend(loc="lower right"); ax.set_ylim(0, max(f1)+8)
ax.text(0.02,0.95,f"optimum {max(f1):.1f}%",transform=ax.transAxes,va="top",
fontsize=10,bbox=dict(boxstyle="round",fc="#f4f4f4",ec="#ccc"))
fig.tight_layout(); fig.savefig(OUT/"de_search_landscape.png"); plt.close(fig)
# ── Figure 5: calibration curve (similarity → P(match)) ──────────────────────
def fig_calibration():
sims,ps=[],[]
with open("experiments/galleries/gallery_LVFace-B_Glint360K.h5.calib_cache.csv") as f:
for r in _csv.DictReader(f):
sims.append(float(r["similarity"])); ps.append(float(r["p_match"]))
fig,ax=plt.subplots(figsize=(6.5,4.5))
ax.plot(sims,ps,color="#3d7ea6",lw=2)
ax.axhline(0.485,ls="--",color="#e07a5f",lw=1,label="shipped threshold 0.485")
ax.set_xlabel("cosine similarity"); ax.set_ylabel("calibrated P(match)")
ax.set_title("LVFace-B Glint360K calibration"); ax.set_xlim(-1,1); ax.legend()
fig.tight_layout(); fig.savefig(OUT/"calibration_curves.png"); plt.close(fig)
# ── Figure 6: holdout F1 by film (learned detector, LOO) ─────────────────────
def fig_holdout():
order=np.argsort(FL)
fig,ax=plt.subplots(figsize=(8,4.5))
y=np.arange(len(FILMS))
ax.barh(y,[FL[i] for i in order],color="#3d7ea6")
ax.set_yticks(y); ax.set_yticklabels([FILMS[i] for i in order])
ax.set_xlabel("presence F1 (%), learned detector (LOO)")
ax.set_title("Per-film presence F1 — leave-one-out")
ax.axvline(np.mean(FL),ls="--",color="#333",lw=1)
ax.text(np.mean(FL)+1,0.2,f"macro {np.mean(FL):.1f}%",fontsize=9)
for i,idx in enumerate(order): ax.text(FL[idx]+0.5,i,f"{FL[idx]:.0f}",va="center",fontsize=8)
ax.set_xlim(0,100)
fig.tight_layout(); fig.savefig(OUT/"holdout_f1_by_film.png"); plt.close(fig)
fig_presence(); fig_macro(); fig_evolution(); fig_de(); fig_calibration(); fig_holdout()
print("wrote:", *(p.name for p in sorted(OUT.glob("*.png"))))
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
rematch_frames.py remake each named July frame example against the CURRENT
pipeline. For a file named <film>_<...>_<actor>.jpg, find a second in this film's
replay where that actor is drawn in the matching class (FP for *_fpi_*, TP for
*_tp/perfect*), extract + annotate it, and write it over the doc asset. Reports
which July examples no longer reproduce (honest the config/model changed).
Needs the per-film raw replay (experiments/dumps + replay --raw-out already run by
regen_frame_examples.sh into the scratch predictions). Reads those.
"""
from __future__ import annotations
import json, sys, subprocess, re
from pathlib import Path
sys.path.insert(0, "scripts/optimizer"); sys.path.insert(0, "scripts/validation")
import dump_error_frames as D
from second_score import load_second_timeline, _match
SP = Path("/tmp/claude-1000/-home-dtourolle-Development-scene-actor-extraction/"
"c579f8cf-2974-4cbd-be88-afec68dbbf58/scratchpad")
ASSETS = Path("docs/assets/images")
LUT = json.load(open("experiments/file-lut.json"))
FILMS = json.load(open("experiments/manifests/films_LVFace_opencv5.json"))
XR = {f["slug"]: f["xray"] for f in FILMS}
# filename → (film slug, actor substring, class). class: "fp" | "tp".
# actor substring is matched case-insensitively against drawn names.
JOBS = {
"lord_of_war_fpi_reddick.jpg": ("Lord_of_War", "reddick", "fp"),
"lord_of_war_fpi_shumbris.jpg": ("Lord_of_War", "shumbris", "fp"),
"lord_of_war_fpi_reagan_photo.jpg": ("Lord_of_War", "reagan", "fp"),
"lovelace_fpi_sevigny.jpg": ("Lovelace", "sevigny", "fp"),
"lovelace_robert_patrick_fpi.jpg": ("Lovelace", "patrick", "fp"),
"lovelace_perfect_second.jpg": ("Lovelace", None, "tp"),
"lovelace_polygraph_bridged.jpg": ("Lovelace", None, "tp"),
"many_saints_fpi_deschanel.jpg": ("The_Many_Saints_of_Newark", "deschanel", "fp"),
"many_saints_fpi_gardner.jpg": ("The_Many_Saints_of_Newark", "gardner", "fp"),
"many_saints_fpi_yates.jpg": ("The_Many_Saints_of_Newark", "yates", "fp"),
"many_saints_outofcast_fpi.jpg": ("The_Many_Saints_of_Newark", None, "fp"),
"scarface_fpi_alley.jpg": ("Scarface", "alley", "fp"),
"downton_crew_fn.jpg": ("Downton_Abbey__A_New_Era", None, "tp"),
"downton_wedding_couple.jpg": ("Downton_Abbey__A_New_Era", None, "tp"),
"downton_tp_example.jpg": ("Downton_Abbey__A_New_Era", None, "tp"),
"valerian_screen_call.jpg": ("Valerian_and_the_City_of_a_Thousand_Plan", None, "tp"),
"cafe_society_rapid_cut.jpg": ("Café_Society", None, "tp"),
# germar_beats_xray / downton_funeral_19of20 are July-narrative-specific; skip.
}
def gt_keysets(slug):
tl, _, _ = load_second_timeline(XR[slug])
return tl
def main():
made, missing = [], []
for fname, (slug, actor, cls) in JOBS.items():
raw = SP / f"{slug}_raw.jsonl"
if not raw.exists():
missing.append((fname, "no raw replay")); continue
tl = gt_keysets(slug)
best = None # (t, actor_dict, fp_keys)
for line in open(raw):
d = json.loads(line)
t = int(d["timestamp_sec"])
drawn = [a for a in d.get("visible_actors", []) if a.get("actor_idx", -1) >= 0]
if not drawn:
continue
gt = tl.get(t, [])
fp_keys = {D._name_key(a["name"]) for a in drawn
if not any(D._name_key(a["name"]) in g for g in gt)}
for a in drawn:
nk = D._name_key(a["name"]); is_fp = nk in fp_keys
if actor and actor not in a["name"].lower():
continue
match = (is_fp if cls == "fp" else not is_fp)
if not match:
continue
# prefer high similarity + a clean single-subject frame
score = a["similarity"] - 0.05*len(drawn)
if best is None or score > best[3]:
best = (t, d, fp_keys, score)
if best is None:
missing.append((fname, f"no current {cls} for {actor or 'any'}")); continue
t, d, fp_keys, _ = best
# FN names at t: X-Ray scene cast whose keyset matches no drawn face.
gt = tl.get(t, [])
drawn_keys = [set(D._name_key(a["name"]).replace("name:", "") for _ in [0])
for a in d.get("visible_actors", []) if a.get("actor_idx", -1) >= 0]
drawn_ks = [D._name_key(a["name"]) for a in d.get("visible_actors", [])
if a.get("actor_idx", -1) >= 0]
fn_names = []
for ga in gt:
if not any(dk in ga for dk in drawn_ks):
readable = sorted(x for x in ga
if not x.startswith("imdb:") and not x.startswith("tmdb:")
and not x.startswith("jf:"))
if readable:
fn_names.append(readable[0])
out = ASSETS / fname
try:
D.extract_frame(LUT[slug], t, out)
D.draw_annotations(out, d["visible_actors"], fp_keys=fp_keys,
fn_names=fn_names)
made.append((fname, slug, t))
except subprocess.CalledProcessError:
missing.append((fname, "ffmpeg failed"))
print("=== remade ===")
for f, s, t in made: print(f" {f} ({s} t={t}s)")
print("=== no current equivalent (left as-is / flag in doc) ===")
for f, why in missing: print(f" {f}{why}")
if __name__ == "__main__":
main()
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Standalone DE-optimised AUDIO scene cutter: tune a matched-filter ramp on the
audio log-PSD to maximise X-Ray boundary F1. No neural net. Holdout films are
never seen in training. Writes the tuned filter + held-out performance."""
import sys, json, os
import numpy as np
sys.path.insert(0, "scripts/scene_detector")
from de_ramp import load_series, xray_bounds, ramp_kernel, response, boundary_f1
from scipy.optimize import differential_evolution
MANIFEST = "experiments/manifests/films_LVFace_opencv5.json"
AUDIO = "experiments/dumps/audio_features"
HOLDOUT = {"Scarface", "Sound_of_Metal", "Valerian_and_the_City_of_a_Thousand_Plan"}
OUT = "experiments/results/scene_boundary/de_audio_cutter.json"
films = json.load(open(MANIFEST))
train = [f for f in films if f["slug"] not in HOLDOUT]
val = [f for f in films if f["slug"] in HOLDOUT]
tr = [(load_series(f["dump"], AUDIO, "audio")[0], xray_bounds(f["xray"])) for f in train]
va = [(f["slug"], load_series(f["dump"], AUDIO, "audio")[0], xray_bounds(f["xray"])) for f in val]
print(f"DE AUDIO cutter: {len(tr)} train, holdout {sorted(HOLDOUT)}", flush=True)
def neg_f1(x):
H = int(round(x[0])); gamma = x[1]; dead = int(round(x[2])); pct = x[3]
if H < 1 or dead >= H: return 0.0
w = ramp_kernel(H, gamma, dead)
return -float(np.mean([boundary_f1(response(s, w, H), b, pct) for s, b in tr]))
evals = [0]
def cb(xk, convergence):
evals[0] += 1
print(f"[de-audio] gen {evals[0]} convergence={convergence:.3f}", flush=True)
res = differential_evolution(neg_f1, [(1, 10), (0.3, 3.0), (0, 4), (80, 98)],
seed=0, popsize=12, maxiter=25, tol=1e-4,
polish=False, callback=cb)
H = int(round(res.x[0])); gamma = float(res.x[1]); dead = int(round(res.x[2])); pct = float(res.x[3])
print(f"\n=== DE-OPTIMISED AUDIO SCENE CUTTER ===", flush=True)
print(f"tuned ramp: H={H}s gamma={gamma:.2f} dead={dead}s threshold_pct={pct:.0f}", flush=True)
print(f"train boundary-F1: {-res.fun*100:.1f}%\n", flush=True)
print("held-out (audio-only, P/R/F1 ±2s):", flush=True)
w = ramp_kernel(H, gamma, dead)
rep = {"H": H, "gamma": gamma, "dead": dead, "pct": pct,
"train_f1": float(-res.fun), "holdout": sorted(HOLDOUT), "films": {}}
for slug, s, b in va:
r = response(s, w, H); thr = np.percentile(r, pct); pred = np.where(r > thr)[0]
bidx = [int(x) for x in b if int(x) < len(r)]
tp_p = sum(any(abs(p-i) <= 2 for i in bidx) for p in pred)
tp_t = sum(any(abs(p-i) <= 2 for p in pred) for i in bidx)
P = tp_p/max(len(pred), 1); R = tp_t/max(len(bidx), 1); F = 2*P*R/(P+R) if P+R else 0
rep["films"][slug] = {"P": P, "R": R, "F1": F, "n_pred": len(pred), "n_true": len(bidx)}
print(f" {slug[:26]:26s} P={P*100:4.0f}% R={R*100:4.0f}% F1={F*100:4.0f}% "
f"({len(pred)} preds/{len(bidx)} true)", flush=True)
os.makedirs(os.path.dirname(OUT), exist_ok=True)
json.dump(rep, open(OUT, "w"), indent=2)
print(f"\nsaved → {OUT}", flush=True)
@@ -0,0 +1,356 @@
#!/usr/bin/env python3
"""
train_scene_boundary.py learn a scene-boundary detector from per-frame RGB
histograms (video tower) and per-second audio log-PSD (audio tower), against
Amazon X-Ray scene boundaries.
Motivation: the shipped grayscale histogram-correlation cut detector is blind on
low-contrast grades on Scarface it fired ONCE in 10,204 frames, so flood-fill
presence (which snaps to detected boundaries) floods every actor across the whole
film (P=26%). X-Ray ships real scene boundaries (scenes.csv); the dumps carry a
per-frame RGB histogram (frames/rgb_hist), and extract_audio_features.py provides
a per-second audio log-PSD. This learns a per-second boundary probability.
TWO-TOWER, ABLATABLE. We do NOT assume audio helps video we measure it. Each
modality has its own encoder+BiLSTM; --modality selects video / audio / fused
(both towers concatenated before a shared head). The script reports all three
arms on the held-out films so the ablation decides whether audio supports video.
Video features per second: rgb_hist (96) + L1 deltas to t-1,t-2,t+1 + per-channel
correlation to t-1. Audio features: the log-PSD row (+ its L1 delta to t-1).
Label: 1 if an X-Ray scene starts within ±TOL_SEC of t.
Usage:
python scripts/scene_detector/train_scene_boundary.py \
--manifest experiments/manifests/films_LVFace_opencv5.json \
--audio-dir experiments/dumps/audio_features \
--holdout Scarface Sound_of_Metal \
--modality all --out experiments/results/scene_boundary
"""
from __future__ import annotations
import argparse, csv, json, sys
from pathlib import Path
import h5py
import numpy as np
import torch
import torch.nn as nn
TOL_SEC = 2.0
BINS = 32 # per channel, matches embedding_dump_node.hpp kHistBins
RAMP_SCALES = [2, 4, 6, 8, 10] # multi-scale matched-filter half-widths (seconds)
SCENE_TAU = 205.0 # corpus mean X-Ray scene length (central-60min); debounce scale
def debounce_phase(delta_signal: np.ndarray, tau: float = SCENE_TAU,
peak_pct: float = 90.0) -> np.ndarray:
"""A scene-length-scaled 'how overdue is a boundary' feature, [T,2].
Encodes the prior that scenes don't restart moments apart. From the strong
peaks of a change signal (the presumed boundaries so far), track time since
the last peak and turn it into:
phase = min(1, dt/tau) 0 just after a boundary (suppress), 1 when a new
one is overdue (permit), rising over ~one mean
scene length (tau).
decay = exp(-dt/tau) the complementary refractory (high right after,
decaying away). Two views of the same clock so
the LSTM can use whichever helps.
Reference peaks come from the change signal itself (not the model's own
output), so the feature is static and causal-ish (uses only |Δ| already in
the sequence)."""
T = len(delta_signal)
thr = np.percentile(delta_signal, peak_pct)
# Vectorised time-since-last-peak: index of the most recent peak at or before
# each t (running max of peak indices), then dt = t - that index.
idx = np.arange(T)
peak_idx = np.where(delta_signal > thr, idx, -1)
last = np.maximum.accumulate(peak_idx) # most recent peak index ≤ t
dt = (idx - last).astype(np.float32)
dt[last < 0] = tau # before the first peak: treat as "overdue"
phase = np.minimum(1.0, dt / tau)
decay = np.exp(-dt / tau)
return np.stack([phase, decay], 1).astype(np.float32)
def ramp_bank(series: np.ndarray) -> np.ndarray:
"""Antisymmetric matched-filter responses at RAMP_SCALES → [T, len(scales)].
A scene boundary is a step in the feature series; a signed ramp kernel
convolved with it responds at the transition and ~0 inside a stable scene.
Different films' boundaries peak at different scales (measured: sharp cuts at
H=2s, gradual shifts wider), so we hand the model the whole bank and let it
weight the scales rather than committing to one width."""
# Vectorised: the ramp response at t is || sum_l w(l)·series[t+l] ||, i.e. a
# 1D correlation of the kernel with each feature bin, then an L2 over bins. Do
# it as one convolution per bin (np.convolve, 'same') instead of the per-frame
# Python loop — ~100x faster, which matters at ~60k frames × 9 films.
T, D = series.shape
out = np.zeros((T, len(RAMP_SCALES)), np.float32)
for k, H in enumerate(RAMP_SCALES):
lags = np.arange(-H, H + 1)
w = (np.sign(lags) * (np.abs(lags) / max(H, 1))).astype(np.float64)
# correlation = convolution with the reversed kernel; ramp is antisym so
# reversing negates it — sign folds into the L2 norm, so either is fine.
acc = np.zeros((T, D))
for d in range(D):
acc[:, d] = np.convolve(series[:, d], w[::-1], mode="same")
out[:, k] = np.linalg.norm(acc, axis=1)
return out
# ── data ──────────────────────────────────────────────────────────────────────
def load_xray_boundaries(xray_dir: str) -> list[float]:
starts = []
with open(Path(xray_dir) / "scenes.csv", newline="") as f:
for r in csv.DictReader(f):
s = float(r["start"]) / 1000.0
if s > 0.5:
starts.append(s)
return sorted(starts)
def _znorm(s):
return (s - s.mean(0)) / (s.std(0) + 1e-6)
def video_features(hist: np.ndarray) -> np.ndarray:
"""DELTA-FORWARD video features.
Measured on the corpus: the raw 96-bin histogram barely separates X-Ray
boundaries (~1.4x boundary response) it encodes what the frame *looks like*,
not that it *changed* while the symmetric histogram delta |hist(t+k)-hist(t-k)|
separates them strongly (|Δ 1s| ~4-5x). Feeding 96 dims of raw content
diluted the LSTM, so we drop it and lead with multi-scale symmetric deltas,
keeping only a compact per-channel-energy summary as context.
Channels:
- symmetric L1 delta |hist(t+k) - hist(t-k)| at k=1,2,4,8s (the boundary cue)
- per-channel correlation to the previous second (3)
- the multi-scale antisymmetric ramp bank (regional step response)
- 3-D per-channel total energy (compact content context, not the full hist)
"""
T = hist.shape[0]
def sym_delta(k):
fwd = np.roll(hist, -k, 0); fwd[-k:] = hist[-1]
bwd = np.roll(hist, k, 0); bwd[:k] = hist[0]
return np.abs(fwd - bwd).sum(1, keepdims=True)
deltas = np.concatenate([sym_delta(k) for k in (1, 2, 4, 8)], 1)
p1 = np.roll(hist, 1, 0); p1[0] = hist[0]
corr = np.zeros((T, 3), np.float32)
for c in range(3):
a = hist[:, c*BINS:(c+1)*BINS]; b = p1[:, c*BINS:(c+1)*BINS]
am, bm = a - a.mean(1, keepdims=True), b - b.mean(1, keepdims=True)
corr[:, c] = (am*bm).sum(1) / (np.sqrt((am*am).sum(1)*(bm*bm).sum(1))+1e-9)
energy = np.stack([hist[:, c*BINS:(c+1)*BINS].sum(1) for c in range(3)], 1)
# scene-length-scaled debounce: 'how overdue is a boundary', from the |Δ1s|
# change signal. Encodes that scenes don't restart moments apart (tau=205s).
debounce = debounce_phase(deltas[:, 0])
return np.concatenate([deltas, corr, ramp_bank(_znorm(hist)), energy, debounce],
1).astype(np.float32)
def audio_features(psd: np.ndarray) -> np.ndarray:
"""DELTA-FORWARD audio features (same principle as video).
The raw log-PSD is spectral CONTENT (what the audio sounds like), which the DE
cutter showed barely localizes X-Ray boundaries. Lead with the CHANGE in the
spectrum symmetric PSD deltas |psd(t+k)-psd(t-k)| at several scales plus
the ramp bank and a compact total-energy summary; drop the full raw PSD.
"""
def sym_delta(k):
fwd = np.roll(psd, -k, 0); fwd[-k:] = psd[-1]
bwd = np.roll(psd, k, 0); bwd[:k] = psd[0]
return np.abs(fwd - bwd).sum(1, keepdims=True)
deltas = np.concatenate([sym_delta(k) for k in (1, 2, 4, 8)], 1)
energy = psd.sum(1, keepdims=True)
debounce = debounce_phase(deltas[:, 0])
return np.concatenate([deltas, ramp_bank(_znorm(psd)), energy, debounce],
1).astype(np.float32)
def build_film(dump: str, xray_dir: str, audio_dir: str | None):
with h5py.File(dump, "r") as f:
if "frames/rgb_hist" not in f:
raise SystemExit(f"{dump}: no frames/rgb_hist — re-dump with the "
f"RGB-histogram build of dump_embeddings.")
hist = f["frames/rgb_hist"][:].astype(np.float32)
ts = f["frames/timestamp_sec"][:]
is_cut = f["frames/is_cut"][:].astype(np.int64)
V = video_features(hist)
A = None
if audio_dir:
slug = Path(dump).stem.replace("dump_", "")
ap = Path(audio_dir) / f"{slug}.npz"
if ap.exists():
z = np.load(ap); af = z["feat"]
# align audio (per-second) to the video frame grid by index; pad/truncate
T = len(ts); B = af.shape[1]
aligned = np.zeros((T, B), np.float32)
m = min(T, len(af)); aligned[:m] = af[:m]
A = audio_features(aligned)
y = np.zeros(len(ts), np.float32)
for b in load_xray_boundaries(xray_dir):
y[np.abs(ts - b) <= TOL_SEC] = 1.0
return V, A, y, is_cut, ts
# ── model ─────────────────────────────────────────────────────────────────────
class Tower(nn.Module):
"""Per-second encoder → BiLSTM → per-timestep embedding."""
def __init__(self, in_dim, hidden=64, out=64):
super().__init__()
self.enc = nn.Sequential(nn.Linear(in_dim, hidden), nn.ReLU())
self.lstm = nn.LSTM(hidden, out, batch_first=True, bidirectional=True)
def forward(self, x):
h, _ = self.lstm(self.enc(x))
return h # [B,T,2*out]
class BoundaryNet(nn.Module):
def __init__(self, v_dim, a_dim, modality):
super().__init__()
self.modality = modality
feat = 0
if modality in ("video", "fused"):
self.vtower = Tower(v_dim); feat += 128
if modality in ("audio", "fused"):
self.atower = Tower(a_dim); feat += 128
self.head = nn.Sequential(nn.Linear(feat, 32), nn.ReLU(), nn.Linear(32, 1))
def forward(self, v, a):
parts = []
if self.modality in ("video", "fused"): parts.append(self.vtower(v))
if self.modality in ("audio", "fused"): parts.append(self.atower(a))
return self.head(torch.cat(parts, -1)).squeeze(-1)
def nms_peaks(prob, thr=0.5, min_gap=5):
"""Collapse each run of adjacent above-threshold seconds to its single peak.
Without this, a model that fires 5 consecutive seconds around one true
boundary is scored as 1 TP + 4 FP an aggregation artifact, not an error."""
cand = np.where(prob > thr)[0]
if len(cand) == 0:
return []
peaks, group = [], [cand[0]]
for c in cand[1:]:
if c - group[-1] <= min_gap:
group.append(c)
else:
peaks.append(group[int(np.argmax(prob[group]))]); group = [c]
peaks.append(group[int(np.argmax(prob[group]))])
return peaks
def prf(prob_or_pred, y, tol=2, thr=0.5):
"""Boundary P/R/F1 with NMS peak aggregation. Accepts a probability series
(model output) or a 0/1 array (is_cut baseline); NMS collapses each run of
above-threshold seconds to one peak either way."""
P = np.array(nms_peaks(np.asarray(prob_or_pred, float), thr=thr))
T = np.where(y > 0.5)[0]
if len(P) == 0 or len(T) == 0: return 0., 0., 0.
tp_p = sum(any(abs(p-t) <= tol for t in T) for p in P)
tp_t = sum(any(abs(p-t) <= tol for p in P) for t in T)
pr, rc = tp_p/len(P), tp_t/len(T)
return pr, rc, (2*pr*rc/(pr+rc) if pr+rc else 0.)
def train_arm(modality, tr, va, v_dim, a_dim, vmu, vsd, amu, asd, epochs, dev):
model = BoundaryNet(v_dim, a_dim, modality).to(dev)
opt = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-5)
pos = sum((y > .5).sum() for *_, y, _, _ in tr)
neg = sum((y <= .5).sum() for *_, y, _, _ in tr)
lossf = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([neg/max(pos,1)], device=dev))
def vt(V): return torch.tensor((V-vmu)/vsd, dtype=torch.float32, device=dev).unsqueeze(0)
def at(A): return torch.tensor((A-amu)/asd, dtype=torch.float32, device=dev).unsqueeze(0)
for ep in range(epochs):
model.train()
for V, A, y, _, _ in tr:
opt.zero_grad()
logit = model(vt(V), at(A) if A is not None else None)
loss = lossf(logit, torch.tensor(y, device=dev).unsqueeze(0))
loss.backward(); opt.step()
model.eval(); rows = {}
with torch.no_grad():
for slug, V, A, y, is_cut, ts in va:
prob = torch.sigmoid(model(vt(V), at(A) if A is not None else None))[0].cpu().numpy()
rows[slug] = prf(prob, y) # raw prob → NMS picks peaks by height
return model, rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--manifest", required=True)
ap.add_argument("--audio-dir", default="experiments/dumps/audio_features")
ap.add_argument("--holdout", nargs="+", default=["Scarface", "Sound_of_Metal"])
ap.add_argument("--modality", choices=["video","audio","fused","all"], default="all")
ap.add_argument("--out", default="experiments/results/scene_boundary")
ap.add_argument("--epochs", type=int, default=250)
ap.add_argument("--seed", type=int, default=0)
args = ap.parse_args()
torch.manual_seed(args.seed); np.random.seed(args.seed)
films = json.load(open(args.manifest))
def load(rows):
out = []
for f in rows:
V, A, y, is_cut, ts = build_film(f["dump"], f["xray"], args.audio_dir)
out.append((f["slug"], V, A, y, is_cut, ts))
return out
tr = load([f for f in films if f["slug"] not in args.holdout])
va = load([f for f in films if f["slug"] in args.holdout])
has_audio = all(t[2] is not None for t in tr+va)
print(f"[scene] train {len(tr)} / holdout {args.holdout}; audio={'yes' if has_audio else 'MISSING'}",
file=sys.stderr)
allV = np.concatenate([t[1] for t in tr], 0)
vmu, vsd = allV.mean(0), allV.std(0)+1e-6; v_dim = allV.shape[1]
if has_audio:
allA = np.concatenate([t[2] for t in tr], 0)
amu, asd = allA.mean(0), allA.std(0)+1e-6; a_dim = allA.shape[1]
else:
amu = asd = None; a_dim = 1
# strip index tuples for train_arm (expects V,A,y,is_cut,ts)
trA = [(t[1],t[2],t[3],t[4],t[5]) for t in tr]
dev = "cuda" if torch.cuda.is_available() else "cpu"
modes = ["video","audio","fused"] if args.modality=="all" else [args.modality]
if not has_audio: modes = [m for m in modes if m == "video"] or ["video"]
# grayscale-0.70 baseline (is_cut) on holdout
print("\n=== held-out scene-boundary detection (P/R/F1, ±2s) ===")
print(f"{'film':26s} " + " ".join(f"{m:>16s}" for m in modes) + f" {'grayscale-0.70':>16s}")
Path(args.out).mkdir(parents=True, exist_ok=True)
results = {m: train_arm(m, trA, va, v_dim, a_dim, vmu, vsd, amu, asd, args.epochs, dev)
for m in modes}
report = {"holdout": args.holdout, "tol_sec": TOL_SEC, "modalities": {}, "films": {}}
for slug, V, A, y, is_cut, ts in va:
cells = []
for m in modes:
p,r,f = results[m][1][slug]
cells.append(f"{p*100:4.0f}/{r*100:4.0f}/{f*100:4.0f}")
report["films"].setdefault(slug, {})[m] = {"P":p,"R":r,"F1":f}
bp,br,bf = prf(is_cut, y)
report["films"].setdefault(slug, {})["grayscale"] = {"P":bp,"R":br,"F1":bf}
print(f"{slug:26s} " + " ".join(f"{c:>16s}" for c in cells) +
f" {bp*100:4.0f}/{br*100:4.0f}/{bf*100:4.0f}")
# macro-mean F1 per modality across holdout
print("\nmacro-mean holdout F1:")
for m in modes:
mf = np.mean([results[m][1][s][2] for s,*_ in va])
report["modalities"][m] = float(mf)
print(f" {m:8s} {mf*100:.1f}%")
bf = np.mean([prf(t[4], t[3])[2] for t in va])
report["modalities"]["grayscale"] = float(bf)
print(f" {'grayscale':8s} {bf*100:.1f}%")
# save the best arm
best = max(modes, key=lambda m: report["modalities"][m])
torch.save({"state": results[best][0].state_dict(), "modality": best,
"vmu":vmu,"vsd":vsd,"amu":amu,"asd":asd,"v_dim":v_dim,"a_dim":a_dim},
Path(args.out)/"boundary_net.pt")
json.dump(report, open(Path(args.out)/"report.json","w"), indent=2)
print(f"\n[scene] best={best}; model+report → {args.out}", file=sys.stderr)
if __name__ == "__main__":
main()
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
train_xgb_boundary.py SHIPPED scene-boundary detector.
An XGBoost regressor over a ±WIN-second window of delta features predicts a soft
Gaussian proximity-to-boundary target; a per-film KNEE threshold on the predicted
peak heights selects the boundaries (self-calibrates the count without a magic
rate). Evaluated with NMS + P/R/F1 at ±20 s tolerance (X-Ray scenes are ~170 s,
so ±20 s placement is what flood-fill actually needs).
Why this shape (all measured, see docs/scene-detector):
- DELTA features, not raw histogram/PSD: the raw content dilutes; |Δ| separates
boundaries 4-5x. Audio is weak but included (XGBoost ignores what it can't use).
- SOFT target exp(-(d/σ)²), σ=10s: a near-miss is trained as near-correct, not a
hard negative. Regression smooth score surface NMS peaks.
- KNEE threshold per film: peak-height curve has a knee where real boundaries
give way to noise; picking it matches the true scene count without a global
threshold that's wrong for every grade.
- Café Society + Scarface (low-contrast grades) MUST be in training; held out,
the model can't generalize to them. The shipped model trains on ALL 9.
Honest generalization: leave-one-out CV 26% F1 @±10s / ~34% @±20s. The shipped
all-9 model is what deployment uses (max grade coverage); LOO is the number to
quote for a brand-new film.
Usage (train on all 9 + save shipped model):
.venv-rocm/bin/python scripts/scene_detector/train_xgb_boundary.py --train-all
Usage (held-out eval):
... --holdout Sound_of_Metal The_Many_Saints_of_Newark Valerian_...
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
import numpy as np
import h5py
sys.path.insert(0, "scripts/scene_detector")
from train_scene_boundary import nms_peaks, load_xray_boundaries, SCENE_TAU, TOL_SEC
from train_scene_boundary import video_features, audio_features, build_film
from scipy.signal import find_peaks
import xgboost as xgb
WIN = 3 # ±WIN-second context window
SIGMA = 10.0 # soft-target Gaussian width (seconds)
def per_second_matrix(dump, xray, audio_dir, win=None):
"""Windowed delta features + debounce clock → (X[T,F], y_binary[T], is_cut[T])."""
V, A, y, is_cut, ts = build_film(dump, xray, audio_dir)
base = np.concatenate([V] + ([A] if A is not None else []), 1)
T, d = base.shape
sig = V[:, 0]
thr = np.percentile(sig, 90)
idx = np.arange(T); peak = np.where(sig > thr, idx, -1)
last = np.maximum.accumulate(peak)
dt = (idx - last).astype(np.float32); dt[last < 0] = SCENE_TAU
clock = np.stack([dt, np.minimum(1, dt/SCENE_TAU), np.exp(-dt/SCENE_TAU)], 1)
W = WIN if win is None else win
padded = np.pad(base, ((W, W), (0, 0)), mode="edge")
wf = np.concatenate([padded[i:i+T] for i in range(2*W+1)], 1)
return np.concatenate([wf, clock], 1).astype(np.float32), y, is_cut
def soft_target(dump, xray):
ts = h5py.File(dump)["frames/timestamp_sec"][:]
b = np.array(load_xray_boundaries(xray))
y = np.zeros(len(ts), np.float32)
if len(b):
for i, t in enumerate(ts):
y[i] = np.exp(-((np.min(np.abs(b - t)))/SIGMA)**2)
return y
def knee_boundaries(prob, min_gap=5):
"""Per-film knee threshold on peak heights → selected peak indices.
Peaks sorted by height form a convex-decreasing curve; the knee (max drop
below the endpoints chord) is where real boundaries give way to noise. Returns
the timestamps (indices) of peaks at or above the knee height."""
pk, _ = find_peaks(prob, distance=min_gap)
if len(pk) < 5:
return list(pk)
heights = np.sort(prob[pk])[::-1]
n = len(heights); x = np.arange(n)/(n-1); yv = heights/(heights[0]+1e-9)
chord = yv[0] + (yv[-1]-yv[0])*x
k = int(np.argmax(chord - yv))
thr = heights[k]
return [int(i) for i in pk if prob[i] >= thr]
def train(films, audio_dir):
X = np.concatenate([per_second_matrix(f["dump"], f["xray"], audio_dir)[0] for f in films])
y = np.concatenate([soft_target(f["dump"], f["xray"]) for f in films])
reg = xgb.XGBRegressor(n_estimators=400, max_depth=5, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.8,
objective="reg:squarederror", n_jobs=8, tree_method="hist")
reg.fit(X, y)
return reg
def prf(peaks, Tset, tol=20):
if not peaks or len(Tset) == 0:
return 0., 0., 0., 0, 0, len(Tset)
tp_p = sum(any(abs(p-t) <= tol for t in Tset) for p in peaks)
tp_t = sum(any(abs(p-t) <= tol for p in peaks) for t in Tset)
P = tp_p/len(peaks); R = tp_t/len(Tset)
return (P, R, (2*P*R/(P+R) if P+R else 0.),
tp_p, len(peaks)-tp_p, len(Tset)-tp_t)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--manifest", default="experiments/manifests/films_LVFace_opencv5.json")
ap.add_argument("--audio-dir", default="experiments/dumps/audio_features")
ap.add_argument("--holdout", nargs="*", default=[])
ap.add_argument("--train-all", action="store_true", help="train on all 9 + save shipped model")
ap.add_argument("--tol", type=int, default=20)
ap.add_argument("--out", default="experiments/results/scene_boundary")
args = ap.parse_args()
films = json.load(open(args.manifest))
Path(args.out).mkdir(parents=True, exist_ok=True)
tr = films if args.train_all else [f for f in films if f["slug"] not in args.holdout]
reg = train(tr, args.audio_dir)
print(f"[xgb] trained on {len(tr)} films", file=sys.stderr)
ev = films if args.train_all else [f for f in films if f["slug"] in args.holdout]
tag = "TRAIN-FIT (all 9)" if args.train_all else "HELD-OUT"
print(f"\n=== {tag} boundary detection (knee, NMS, ±{args.tol}s) ===")
print(f"{'film':26s} {'TP':>4}{'FP':>5}{'FN':>5} {'P':>5}{'R':>5}{'F1':>5} {'gray F1':>7}")
rep = {"win": WIN, "sigma": SIGMA, "tol": args.tol, "train_all": args.train_all,
"holdout": args.holdout, "films": {}}
f1s, gf1s = [], []
for f in ev:
X, yb, ic = per_second_matrix(f["dump"], f["xray"], args.audio_dir)
prob = np.clip(reg.predict(X), 0, 1)
peaks = knee_boundaries(prob)
Tset = np.where(yb > 0.5)[0]
P, R, F, tp, fp, fn = prf(peaks, Tset, args.tol)
gpk = nms_peaks(ic.astype(float)); _, _, gF, *_ = prf(gpk, Tset, args.tol)
f1s.append(F); gf1s.append(gF)
rep["films"][f["slug"]] = {"TP": tp, "FP": fp, "FN": fn, "P": P, "R": R, "F1": F,
"n_pred": len(peaks), "n_true": len(Tset), "gray_F1": gF}
print(f"{f['slug'][:26]:26s} {tp:>4}{fp:>5}{fn:>5} {P*100:4.0f}%{R*100:4.0f}%"
f"{F*100:4.0f}% {gF*100:5.0f}%")
print(f"\nmacro-F1: detector {np.mean(f1s)*100:.1f}% grayscale {np.mean(gf1s)*100:.1f}%")
rep["macro_f1"] = {"detector": float(np.mean(f1s)), "grayscale": float(np.mean(gf1s))}
if args.train_all:
reg.save_model(str(Path(args.out) / "xgb_boundary_shipped.json"))
print(f"[xgb] shipped model → {args.out}/xgb_boundary_shipped.json", file=sys.stderr)
json.dump(rep, open(Path(args.out) / "xgb_report.json", "w"), indent=2)
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""
train_xgb_cpp.py train the scene-boundary XGBoost on the C++-EXTRACTED feature
matrices (experiments/dumps/cpp_features/<slug>.h5, written by scene_features_dump).
This is the parity-by-construction path: the model is fit on exactly the features
the C++ XGBSceneBoundary produces at inference, so C++ boundaries match by
construction no numpy-vs-C++ feature drift to chase. Same soft Gaussian target,
knee threshold, and ±20s eval as train_xgb_boundary.py.
Usage (train all 9 + save shipped model):
.venv-rocm/bin/python scripts/scene_detector/train_xgb_cpp.py --train-all
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
import numpy as np, h5py
sys.path.insert(0, "scripts/scene_detector")
from train_scene_boundary import load_xray_boundaries, nms_peaks
from train_xgb_boundary import knee_boundaries, prf, SIGMA
import xgboost as xgb
CPP_DIR = "experiments/dumps/cpp_features"
def load(slug, xray):
with h5py.File(f"{CPP_DIR}/{slug}.h5") as f:
X = f["features"][:].astype(np.float32)
ts = f["timestamp_sec"][:]
b = np.array(load_xray_boundaries(xray))
y = np.zeros(len(ts), np.float32)
if len(b):
for i, t in enumerate(ts):
y[i] = np.exp(-((np.min(np.abs(b - t)))/SIGMA)**2)
yb = np.zeros(len(ts), np.float32)
for bb in b:
yb[np.abs(ts - bb) <= 2.0] = 1.0
return X, y, yb, ts
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--manifest", default="experiments/manifests/films_LVFace_opencv5.json")
ap.add_argument("--holdout", nargs="*", default=[])
ap.add_argument("--train-all", action="store_true")
ap.add_argument("--tol", type=int, default=20)
ap.add_argument("--out", default="experiments/results/scene_boundary")
args = ap.parse_args()
films = json.load(open(args.manifest))
Path(args.out).mkdir(parents=True, exist_ok=True)
tr = films if args.train_all else [f for f in films if f["slug"] not in args.holdout]
Xtr = np.concatenate([load(f["slug"], f["xray"])[0] for f in tr])
ytr = np.concatenate([load(f["slug"], f["xray"])[1] for f in tr])
reg = xgb.XGBRegressor(n_estimators=400, max_depth=5, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.8,
objective="reg:squarederror", n_jobs=8, tree_method="hist")
reg.fit(Xtr, ytr)
print(f"[xgb-cpp] trained on {len(tr)} films", file=sys.stderr)
ev = films if args.train_all else [f for f in films if f["slug"] in args.holdout]
tag = "TRAIN-FIT (all 9)" if args.train_all else "HELD-OUT"
print(f"\n=== {tag} (C++ features, knee, ±{args.tol}s) ===")
print(f"{'film':26s} {'TP':>4}{'FP':>5}{'FN':>5} {'P':>5}{'R':>5}{'F1':>5}")
f1s = []
for f in ev:
X, y, yb, ts = load(f["slug"], f["xray"])
prob = np.clip(reg.predict(X), 0, 1)
peaks = knee_boundaries(prob)
Tset = np.where(yb > 0.5)[0]
P, R, F, tp, fp, fn = prf(peaks, Tset, args.tol)
f1s.append(F)
print(f"{f['slug'][:26]:26s} {tp:>4}{fp:>5}{fn:>5} {P*100:4.0f}%{R*100:4.0f}%{F*100:4.0f}%")
print(f"\nmacro-F1: {np.mean(f1s)*100:.1f}%")
if args.train_all:
reg.save_model(str(Path(args.out) / "xgb_boundary_cpp.json"))
print(f"[xgb-cpp] shipped model → {args.out}/xgb_boundary_cpp.json", file=sys.stderr)
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""stamp_gallery.py — bind an existing gallery to the embedder that built it.
TRACES: GR-004 | SR-001
Galleries built before model binding carry no embedder stamp. They still load,
but every consumer warns that it cannot tell whether the gallery and the embedder
belong together and under SAE_REQUIRE_GALLERY_STAMP=1 they refuse to run.
This is the migration path, and the reason the unstamped case is a warning rather
than a hard failure: re-binding an existing gallery costs one command and no
re-embedding, so nobody has to choose between a bricked setup and a check they
route around.
python scripts/stamp_gallery.py --gallery gallery.h5 \\
--arcface models/LVFace-B_Glint360K.onnx
The stamp is an ASSERTION: you are stating which model produced these vectors.
Nothing can verify it from the vectors themselves, which is exactly why the stamp
has to be written at build time going forward. Stamping the wrong model is worse
than leaving it unstamped, because it converts a loud warning into a false
all-clear so --show it first if you are not certain.
python scripts/stamp_gallery.py --gallery gallery.h5 --show
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import h5py
sys.path.insert(0, str(Path(__file__).resolve().parent))
from sae_gallery import (describe_stamp, embedder_stamp, # noqa: E402
read_gallery_stamp)
def main() -> int:
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--gallery", required=True, help="gallery .h5 to stamp in place")
p.add_argument("--arcface", help="the ONNX that built it (hashed into the stamp)")
p.add_argument("--show", action="store_true", help="print the current stamp and exit")
p.add_argument("--force", action="store_true",
help="overwrite an existing stamp (refused otherwise)")
args = p.parse_args()
path = Path(args.gallery)
if path.suffix not in (".h5", ".hdf5"):
return err(f"{path}: only HDF5 galleries can be stamped in place")
current = read_gallery_stamp(path)
print(f"{path}: current stamp = {describe_stamp(current)}", file=sys.stderr)
if args.show:
return 0
if not args.arcface:
return err("--arcface is required (or use --show)")
if current and not args.force:
return err("gallery is already stamped — pass --force to overwrite, but be "
"sure: a wrong stamp turns a warning into a false all-clear")
stamp = embedder_stamp(args.arcface)
if not stamp["model_sha256"]:
return err(f"cannot hash {args.arcface} — refusing to write a name-only "
"stamp, which would claim more certainty than it has")
with h5py.File(path, "r+") as f:
if "embedder" in f:
del f["embedder"]
g = f.create_group("embedder")
g.attrs["model_name"] = stamp["model_name"]
g.attrs["model_sha256"] = stamp["model_sha256"]
g.attrs["embed_dim"] = stamp["embed_dim"]
print(f"{path}: stamped with {describe_stamp(stamp)}", file=sys.stderr)
return 0
def err(msg: str) -> int:
print(f"[stamp_gallery] {msg}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+21
View File
@@ -79,9 +79,30 @@ The table caches nulls (tmdb ids TMDB has no IMDb id for) and checkpoints, so a
re-run only resolves new ids. TMDB is authoritative for this crosswalk — there is
no clean free bulk `tmdb_person ↔ nm` file, so we query the API once and cache.
## Minimum face size (VR-005)
`min_face_size.py` is a separate, self-contained study: it needs no video and no
ground truth, only the gallery mugshot cache. It holds out one image per actor,
degrades that probe to each candidate face size and matches it against a gallery
held at **native** resolution, reporting TPI/FPI per size — the measurement that
replaces AR-002's 66×66 px estimate.
```bash
python scripts/validation/min_face_size.py \
--images images --gallery gallery_lvface.h5 \
--arcface models/LVFace-B_Glint360K.onnx \
--actors 100 --out experiments/results/vr005_min_face_size
```
FPI grows with the number of actors competing, so a 100-actor run understates it
against a library of thousands: read FPI as relative across sizes, not as an
absolute rate. Re-run per `--arcface` model to see whether `min_face_px` should be
one constant or scale with the embedder (GR-004).
## Files
- `sample_eval.py` — CLI scorer.
- `ground_truth.py``XRayGroundTruth`, `MovieNetGroundTruth` loaders.
- `identity.py` — provider-agnostic match keys.
- `tmdb_imdb_map.py` — build/consult the cached `tmdb→imdb` crosswalk.
- `min_face_size.py` — VR-005 probe-size sweep (see above).
- `test_sample_eval.py` — self-contained tests (`python scripts/validation/test_sample_eval.py`).
+5
View File
@@ -17,6 +17,11 @@ X-Ray/MovieNet key on IMDb nm-ids — see [[per-scene-presence-eval-design]].
Two sources implemented:
* XRayGroundTruth Zenodo scene-level Amazon X-Ray CSVs (cast-in-scene).
* MovieNetGroundTruth MovieNet-PS per-shot face annotations (on-screen faces).
Both are published corpora addressed by title, so a scoring run is reproducible
from the identifiers alone no annotation of ours travels with the code.
TRACES: VR-004 | PR-002
"""
from __future__ import annotations
+822
View File
@@ -0,0 +1,822 @@
#!/usr/bin/env python3
"""
min_face_size.py VR-005: at what face size do embeddings stop identifying people?
TRACES: VR-005
`min_face_px` is currently a working estimate (AR-002: 66x66 px in original video
resolution). This script replaces the guess with a measurement, using only gallery
mugshots already on disk no video, no C++ changes.
Protocol
--------
1. Select ~100 gallery actors that have more than one mugshot.
2. Per actor hold out ONE image as the *probe*; that actor's remaining images stay
in the gallery at native resolution.
3. For each target size S, take the probe's native aligned 112x112 crop, downscale
it to SxS and upscale it back to 112x112, then embed. Detail is genuinely
destroyed and then the same warp the pipeline applies is re-applied on top
which is what a face detected at SxS in a frame actually suffers.
4. Match each degraded probe against the whole gallery.
5. Record, per size, TPI (identified as the correct actor) and FPI (identified as
someone else). Everything else is an unidentified probe (TBI).
The asymmetry is the point: **the gallery stays at native resolution and only the
probe degrades.** That is the production case reference mugshots are clean, the
face coming out of the video is small. Degrading both sides would measure
something the pipeline never does.
What this deliberately does NOT measure
---------------------------------------
The cosine between the size-S embedding and the native embedding of the *same*
image. That is embedding *drift*, and it answers the wrong question: an embedding
can drift a long way and stay perfectly separable, or drift a little in a
direction that destroys separation. What matters is the decision the pipeline
makes probe against a competing gallery so that is what is recorded.
CAVEAT FPI IS RELATIVE, NOT ABSOLUTE
--------------------------------------
False positives grow with the number of actors competing for the match. A
~100-actor gallery therefore *understates* the false-positive rate against a
production library of thousands. Read the FPI column as a relative curve across
sizes ("FPI is 4x worse at 32 px than at 64 px"), never as the rate you would see
in production. Re-run with `--actors` at production scale before setting a
threshold from an absolute FPI number.
Decision rule
-------------
Per the repo invariant (CLAUDE.md: "always use the calibrated probability, never a
raw cosine"), identification goes through the same path as `identity_matcher_node`:
per-actor best-of-N cosine -> Platt sigmoid P(match) = sigma(a*sim + b + log-prior)
-> accept if P > `prob_threshold`. The sigmoid is fitted here by the same
histogram/gradient-descent procedure as `src/gallery/gallery_calibration.hpp`,
over the native gallery embeddings only (held-out probes are excluded, so the
calibration cannot see the images it will be scored on).
How this runs
-------------
Through the `sae_embed` bindings, which expose the shipped C++ stages directly:
`detect()`, `align_face()`, `embed_crops()` and `GalleryCalibration`. Nothing
here re-implements detection, the ArcFace warp, the embedder or the Platt fit.
That matters most for the calibration. A second copy of the sigmoid is exactly
where "always the calibrated probability, never a raw cosine" (AR-024) gets
broken without anyone noticing, because the copy keeps returning plausible
numbers after the original has moved. Scoring through the binding makes the rule
structural instead of remembered.
The backend is whichever was compiled in. Under `SAE_INFERENCE_BACKEND=ORT`
that is the reference fp32 path, which loads the .onnx directly. A TensorRT fp16
build is a *different realisation* of the same model and its embeddings are
measurably not the same vectors: on LVFace-B_Glint360K the stored TRT-fp16 gallery
agrees with an fp32 recompute of the same mugshot at only ~0.85 cosine, while
same-actor/different-actor separation is essentially unchanged (d' 5.3 vs 5.7).
Nothing here is invalidated by that gallery and probes go through one session,
so the comparison is internally consistent but the two embedding spaces are not
interchangeable, and `--verify-against <gallery.h5>` will show ~0.85, not ~1.0,
against a TRT-built gallery. It reports the separation of both sets alongside the
agreement so the two causes are distinguishable: a broken port collapses
separation, a different backend does not.
Secondary output (VR-005): running the sweep per `--arcface` model shows whether
`min_face_px` should be one constant at all, or should scale with the embedder
which matters because the model is a build-time choice (GR-004).
Usage
-----
python scripts/validation/min_face_size.py \
--images images \
--gallery gallery_lvface.h5 \
--arcface models/LVFace-B_Glint360K.onnx \
--actors 100 --seed 0 \
--out experiments/results/vr005_min_face_size
Writes <out>.csv, <out>.json and <out>.png (plus <out>.per_probe.csv with
--per-probe).
"""
from __future__ import annotations
import argparse
import csv
import json
import random
import re
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO / "scripts"))
def _find_sae_embed() -> Path | None:
"""Locate the built sae_embed module.
A git worktree has no build tree of its own, so fall back to the main
checkout via the shared git dir otherwise running this study from a
feature worktree cannot find the bindings it now depends on.
"""
roots = [REPO]
try:
import subprocess
common = subprocess.run(["git", "-C", str(REPO), "rev-parse",
"--path-format=absolute", "--git-common-dir"],
capture_output=True, text=True, check=True).stdout.strip()
if common:
roots.append(Path(common).parent)
except Exception:
pass
for root in roots:
for b in ("build-ort", "build"):
if list((root / b).glob("sae_embed*.so")):
return root / b
return None
_SAE_BUILD = _find_sae_embed()
if _SAE_BUILD is None:
sys.exit("cannot find the built sae_embed module — build it with\n"
" cmake --build build-ort --target sae_embed")
sys.path.insert(0, str(_SAE_BUILD))
# Before cv2: OpenCV's DNN module loads the system libonnxruntime, which then
# shadows the one sae_embed links against and the import fails on a missing
# symbol version. Order matters here.
import sae_embed
import cv2
import numpy as np
IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp")
JELLYFIN_ID_RE = re.compile(r"^[0-9a-f]{32}$")
# Interpolation used for the two halves of the degradation. Downscaling uses
# INTER_AREA (correct low-pass for shrinking, i.e. detail that a small detection
# genuinely never had); upscaling uses INTER_LINEAR, which is what warpAffine in
# align_face() uses when it blows a small detection up to 112x112.
INTERP = {
"area": cv2.INTER_AREA,
"linear": cv2.INTER_LINEAR,
"cubic": cv2.INTER_CUBIC,
"nearest": cv2.INTER_NEAREST,
"lanczos": cv2.INTER_LANCZOS4,
}
# House chart palette, shared with scripts/docs/experiment_charts.py so figures
# across the report read as one set.
INK, MUTED, GRID, SURFACE = "#0b0b0b", "#898781", "#e1e0d9", "#fcfcfb"
BLUE, GREEN, RED, AMBER = "#2a78d6", "#008300", "#e34948", "#eda100"
# ── Production stages, via the sae_embed bindings ─────────────────────────────
# detect / align_face / embed_crops / calibrate_gallery all call the shipped C++.
# There is deliberately no Python re-implementation of any of them: a second copy
# drifts from what ships, and the calibration is the one that must not — AR-024
# requires every similarity to pass through the same sigmoid the matcher uses.
# These two mirror constants in gallery_calibration.hpp. They are NOT a second
# copy of the fit — that is the binding's job — but the script reproduces the
# same dedup and eligibility filtering so the actor counts it reports describe
# the population the C++ actually fitted on. Keep them in step with the header.
MIN_EMB_FOR_POSITIVE = 5
DEDUP_SIM = 1.0 - 1e-7
class Stages:
"""Thin holder so the rest of the script has one object to call."""
def __init__(self, detector: str, arcface: str, conf: float, nms: float):
self.engine = sae_embed.FaceEmbedder(
detector_model=detector, arcface_model=arcface,
conf=conf, nms=nms, max_side=0)
def detect(self, img):
return self.engine.detect(img)
def align(self, img, landmarks):
return sae_embed.align_face(img, np.asarray(landmarks, dtype=np.float32).reshape(5, 2))
def enhance(self, img):
return sae_embed.enhance_for_retry(img)
def embed(self, crops):
"""(N,112,112,3) uint8 BGR -> (N,512) float32.
Chunked at the backend's max_batch: the engine does not split an
oversized request, so handing it a whole gallery at once asks CUDA for
a multi-gigabyte activation buffer and the allocator refuses.
"""
if not len(crops):
return np.zeros((0, 512), dtype=np.float32)
n = max(1, int(self.engine.max_batch))
arr = np.ascontiguousarray(np.stack(crops), dtype=np.uint8)
out = [np.asarray(self.engine.embed_crops(np.ascontiguousarray(arr[i:i + n])))
for i in range(0, len(arr), n)]
return np.concatenate(out, axis=0)
def calibrate_gallery(emb: np.ndarray, actor: np.ndarray) -> dict:
"""The production Platt fit (gallery_calibration.hpp), via the binding."""
cal = sae_embed.calibrate_gallery(
np.ascontiguousarray(emb, dtype=np.float32), [int(a) for a in actor])
print(f"[calibration] a={cal.a:.4f} b={cal.b:.4f} valid={cal.valid} "
f"boundary(P=0.5)=sim{cal.boundary_at(0.5):.4f}", file=sys.stderr)
# Held module-side rather than returned: the returned dict lands in the run
# metadata, and a native object there breaks the JSON dump.
_CAL["cal"] = cal
return {"a": float(cal.a), "b": float(cal.b), "valid": bool(cal.valid)}
def probability(sim, a: float, b: float, log_prior_odds: float = 0.0):
"""P(match) through GalleryCalibration — the C++ sigmoid, not a copy of it."""
cal = _CAL.get("cal")
if cal is None:
raise RuntimeError("probability() called before calibrate_gallery()")
sim = np.asarray(sim, dtype=np.float64)
flat = np.atleast_1d(sim).ravel()
out = np.array([cal.probability(float(v), log_prior_odds) for v in flat])
return out.reshape(sim.shape) if sim.shape else float(out[0])
_CAL: dict = {}
# ── Runtime / actor discovery ─────────────────────────────────────────────────
def normalise_name(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "", name.lower())
def discover_actors(images_root: Path) -> list[dict]:
"""Enumerate the gallery-build image cache: <root>/<jellyfin_id>_<Name>/NN.jpg
(the layout make_jellyfin_gallery.py / reembed_gallery.py use)."""
actors = []
for d in sorted(p for p in images_root.iterdir() if p.is_dir()):
imgs = sorted(p for p in d.iterdir()
if p.is_file() and p.suffix.lower() in IMAGE_EXTS)
if not imgs:
continue
head, _, tail = d.name.partition("_")
if JELLYFIN_ID_RE.match(head) and tail:
jellyfin_id, name = head, tail.replace("_", " ")
else:
jellyfin_id, name = "", d.name.replace("_", " ")
actors.append({"dir": d, "jellyfin_id": jellyfin_id, "name": name,
"images": imgs})
return actors
def gallery_keys(gallery_path: Path) -> tuple[set[str], set[str]]:
"""(jellyfin ids, normalised names) of the actors an existing gallery holds."""
from sae_gallery import load_gallery_hdf5
g = load_gallery_hdf5(gallery_path)
ids = {a.get("jellyfin_id", "") for a in g["actors"] if a.get("jellyfin_id")}
names = {normalise_name(a.get("name", "")) for a in g["actors"] if a.get("name")}
return ids, names
# ── Degradation ───────────────────────────────────────────────────────────────
def degrade(crop: np.ndarray, size: int, down: int, up: int) -> np.ndarray:
"""Throw away everything a face detected at size x size never had, then warp
it back up to the 112x112 the embedder is fed."""
if size == 112:
return crop
small = cv2.resize(crop, (size, size), interpolation=down)
return cv2.resize(small, (112, 112), interpolation=up)
# ── Reporting ─────────────────────────────────────────────────────────────────
CAVEAT = (
"CAVEAT: FPI grows with gallery size. This ran against {n_actors} actors, so it "
"UNDERSTATES the false-positive rate of a production library of thousands. Read "
"FPI as relative across sizes, not as an absolute rate."
)
def write_plot(rows: list[dict], out_png: Path, meta: dict) -> None:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
plt.rcParams.update({
"figure.facecolor": SURFACE, "axes.facecolor": SURFACE,
"savefig.facecolor": SURFACE, "text.color": INK,
"axes.edgecolor": MUTED, "axes.labelcolor": INK,
"xtick.color": MUTED, "ytick.color": MUTED,
"axes.grid": True, "grid.color": GRID, "grid.linewidth": 0.8,
"axes.spines.top": False, "axes.spines.right": False,
})
sizes = [r["size_px"] for r in rows]
fig, ax = plt.subplots(figsize=(9, 5.6))
ax.plot(sizes, [100 * r["tpi_rate"] for r in rows], "-o", color=GREEN,
lw=2, label="TPI — identified, correct actor")
ax.plot(sizes, [100 * r["fpi_rate"] for r in rows], "-s", color=RED,
lw=2, label="FPI — identified, wrong actor")
ax.plot(sizes, [100 * r["unidentified_rate"] for r in rows], color=MUTED,
marker="^", lw=1.4, ls="--", label="unidentified (below P threshold)")
ax.plot(sizes, [100 * r["rank1_rate"] for r in rows], ":", color=BLUE,
lw=1.6, label="rank-1 correct (ignoring threshold)")
op = meta.get("operating_point")
if op:
ax.axvline(op, color=AMBER, lw=1.6, ls="-.", zorder=1)
ax.annotate(f"operating point {op} px", xy=(op, 50),
xytext=(4, 0), textcoords="offset points",
color=AMBER, fontsize=9, rotation=90, va="center")
ax.set_xlabel("probe face size before upscaling (px)")
ax.set_ylabel("% of probes")
ax.set_ylim(-2, 102)
ax.set_xticks(sizes)
ax.set_title(f"VR-005 — identification vs. probe face size\n"
f"{meta['model']}, {meta['n_actors']} actors, "
f"{meta['n_probes']} probes/size, gallery at native resolution",
fontsize=11, loc="left")
ax.legend(frameon=False, fontsize=9, loc="center left")
fig.text(0.01, 0.005, CAVEAT.format(n_actors=meta["n_actors"]),
fontsize=7.5, color=MUTED, wrap=True)
fig.tight_layout(rect=(0, 0.05, 1, 1))
out_png.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(out_png, dpi=150)
plt.close(fig)
def pick_operating_point(rows: list[dict], retention: float, fpi_slack: float) -> int | None:
"""Smallest size that keeps `retention` of the undegraded (112 px control)
TPI rate and does not add more than `fpi_slack` absolute FPI over it.
A stated rule, not a magic number change the rule, not the answer."""
control = next((r for r in rows if r["size_px"] == 112), None)
if control is None or control["n_probes"] == 0:
return None
tpi_floor = retention * control["tpi_rate"]
fpi_ceil = control["fpi_rate"] + fpi_slack
ok = [r["size_px"] for r in rows
if r["tpi_rate"] >= tpi_floor and r["fpi_rate"] <= fpi_ceil]
return min(ok) if ok else None
# ── Main ──────────────────────────────────────────────────────────────────────
def main() -> int:
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--images", required=True,
help="gallery image cache root (<jellyfin_id>_<Name>/NN.jpg)")
p.add_argument("--gallery", default=None,
help="gallery .h5 — restricts the actor pool to its members")
p.add_argument("--out", default=str(REPO / "experiments/results/vr005_min_face_size"),
help="output path prefix (.csv/.json/.png are appended)")
p.add_argument("--per-probe", action="store_true",
help="also write <out>.per_probe.csv, one row per probe per size")
p.add_argument("--actors", type=int, default=100, help="actors to sample (default 100)")
p.add_argument("--min-images", type=int, default=2,
help="minimum mugshots for an actor to be eligible (default 2)")
p.add_argument("--probes-per-actor", type=int, default=1,
help="images held out per actor; 1 is the VR-005 protocol")
p.add_argument("--seed", type=int, default=0, help="actor/probe selection seed")
p.add_argument("--keep-duplicates", action="store_true",
help="keep mugshots that are the same photograph twice; by "
"default they are dropped, since a probe identical to a "
"gallery reference is identified for free at every size")
p.add_argument("--sizes", default="12,16,20,24,32,40,48,56,64,72,80,96,112",
help="comma-separated probe sizes; 112 is the undegraded control")
p.add_argument("--models-dir", default=str(REPO / "models"))
p.add_argument("--arcface", default=None,
help="embedder ONNX (default <models-dir>/LVFace-B_Glint360K.onnx)")
p.add_argument("--detector", default=None,
help="SCRFD ONNX (default <models-dir>/scrfd_500m_bnkps.onnx)")
p.add_argument("--conf", type=float, default=0.5, help="detector confidence")
p.add_argument("--nms", type=float, default=0.4, help="detector NMS IoU")
p.add_argument("--max-side", type=int, default=500,
help="downscale mugshots to this longest side before detection, "
"matching the gallery builders' embedder settings")
p.add_argument("--prob-threshold", type=float, default=0.754,
help="accept if P(match) exceeds this (Config::prob_threshold)")
p.add_argument("--match-prior", type=float, default=0.5,
help="base-rate prior (Config::match_prior)")
p.add_argument("--calib-a", type=float, default=None,
help="override the fitted sigmoid scale instead of fitting")
p.add_argument("--calib-b", type=float, default=None,
help="override the fitted sigmoid bias instead of fitting")
p.add_argument("--down-interp", default="area", choices=sorted(INTERP),
help="interpolation for the 112 -> S downscale (default area)")
p.add_argument("--up-interp", default="linear", choices=sorted(INTERP),
help="interpolation for the S -> 112 upscale (default linear, "
"as warpAffine uses in align_face)")
p.add_argument("--tpi-retention", type=float, default=0.95,
help="operating point keeps this fraction of the control TPI rate")
p.add_argument("--fpi-slack", type=float, default=0.01,
help="operating point may add at most this absolute FPI over control")
p.add_argument("--verify-against", default=None,
help="gallery .h5 built from --images with the same model: report "
"agreement and separation of recomputed vs stored embeddings "
"(a TensorRT-built gallery will not agree; see the docstring)")
args = p.parse_args()
if (args.calib_a is None) != (args.calib_b is None):
return err("--calib-a and --calib-b must be given together")
models_dir = Path(args.models_dir)
arcface = Path(args.arcface) if args.arcface else models_dir / "LVFace-B_Glint360K.onnx"
detector = Path(args.detector) if args.detector else models_dir / "scrfd_500m_bnkps.onnx"
for path, what in ((arcface, "embedder"), (detector, "detector")):
if not path.is_file():
return err(f"{what} model not found: {path}\n"
f"Run: bash scripts/download_models.sh")
images_root = Path(args.images)
if not images_root.is_dir():
return err(f"image cache not found: {images_root}")
sizes = sorted({int(s) for s in args.sizes.split(",") if s.strip()})
if not sizes:
return err("--sizes is empty")
if args.probes_per_actor < 1:
return err("--probes-per-actor must be >= 1")
cv2.setRNGSeed(args.seed) # estimateAffinePartial2D's RANSAC draws from this
# ── actor pool ────────────────────────────────────────────────────────────
pool = discover_actors(images_root)
print(f"[select] {len(pool)} actor dirs with images under {images_root}",
file=sys.stderr)
if args.gallery:
ids, names = gallery_keys(Path(args.gallery))
pool = [a for a in pool
if (a["jellyfin_id"] and a["jellyfin_id"] in ids)
or normalise_name(a["name"]) in names]
print(f"[select] {len(pool)} of them are in {args.gallery}", file=sys.stderr)
need = max(args.min_images, args.probes_per_actor + 1)
eligible = [a for a in pool if len(a["images"]) >= need]
print(f"[select] {len(eligible)} have >= {need} mugshots", file=sys.stderr)
if len(eligible) < 2:
return err(f"need at least 2 actors with >= {need} mugshots; found "
f"{len(eligible)}. Build the image cache first "
f"(scripts/make_jellyfin_gallery.py) or lower --min-images.")
rng = random.Random(args.seed)
selected = sorted(rng.sample(eligible, min(args.actors, len(eligible))),
key=lambda a: a["dir"].name)
if len(selected) < args.actors:
print(f"[select] WARNING: only {len(selected)} eligible actors, "
f"--actors {args.actors} requested. FPI is gallery-size dependent — "
f"see the caveat.", file=sys.stderr)
# ── detect + align every mugshot of the selected actors, once ─────────────
stages = Stages(str(detector), str(arcface), args.conf, args.nms)
print(f"[models] detector={detector.name} embedder={arcface.name} "
f"batch={stages.engine.max_batch} (provider chosen by the C++ backend: "
f"CUDA, then ROCm, then CPU)", file=sys.stderr)
t0 = time.time()
crops: list[np.ndarray] = []
rows: list[dict] = [] # parallel to crops: {actor, actor_idx, image}
actors: list[dict] = []
n_nodetect = 0
for a in selected:
actor_crops, actor_paths = [], []
for img_path in a["images"]:
img = cv2.imread(str(img_path))
if img is None:
n_nodetect += 1
continue
if args.max_side > 0 and max(img.shape[:2]) > args.max_side:
s = args.max_side / max(img.shape[:2])
img = cv2.resize(img, None, fx=s, fy=s, interpolation=cv2.INTER_AREA)
faces = stages.detect(img)
if not faces:
enhanced = stages.enhance(img)
faces = stages.detect(enhanced)
if faces:
img = enhanced
if not faces:
n_nodetect += 1
continue
best = max(faces, key=lambda f: f.confidence)
crop = stages.align(img, best.landmarks)
if crop is None:
n_nodetect += 1
continue
actor_crops.append(crop)
actor_paths.append(img_path)
if len(actor_crops) < args.probes_per_actor + 1:
continue
ai = len(actors)
actors.append({"name": a["name"], "jellyfin_id": a["jellyfin_id"],
"dir": a["dir"].name, "n_images": len(actor_crops)})
for crop, img_path in zip(actor_crops, actor_paths):
rows.append({"actor_idx": ai, "image": str(img_path)})
crops.append(crop)
if len(actors) % 20 == 0:
print(f" [align] {len(actors)}/{len(selected)} actors, "
f"{len(crops)} crops", file=sys.stderr)
if len(actors) < 2:
return err(f"only {len(actors)} actors survived detection/alignment — "
f"nothing to match against")
print(f"[align] {len(actors)} actors, {len(crops)} aligned crops, "
f"{n_nodetect} images skipped (no face / unreadable) in "
f"{time.time() - t0:.1f}s", file=sys.stderr)
actor_of = np.array([r["actor_idx"] for r in rows], dtype=int)
# ── embed everything at native resolution ────────────────────────────────
t0 = time.time()
native = stages.embed(crops)
print(f"[embed] {len(crops)} native crops in {time.time() - t0:.1f}s",
file=sys.stderr)
if args.verify_against:
verify_embeddings(Path(args.verify_against), rows, native, actor_of)
# ── drop duplicate mugshots ───────────────────────────────────────────────
# The cache holds the same photograph twice for some actors (two provider
# URLs, one picture). A probe that is identical to a gallery reference is
# identified for free at every size, which flatters the whole curve, so
# remove duplicates the same way calibrate_gallery does.
n_dup = 0
if not args.keep_duplicates:
keep = np.ones(len(rows), bool)
for ai in range(len(actors)):
kept: list[int] = []
for i in np.nonzero(actor_of == ai)[0]:
if any(float(native[i] @ native[k]) > DEDUP_SIM for k in kept):
keep[i] = False
else:
kept.append(int(i))
n_dup = int((~keep).sum())
# An actor left with too few distinct mugshots to hold one out drops out.
counts = np.bincount(actor_of[keep], minlength=len(actors))
drop_actor = counts < args.probes_per_actor + 1
keep &= ~drop_actor[actor_of]
remap = np.full(len(actors), -1, dtype=int)
remap[~drop_actor] = np.arange(int((~drop_actor).sum()))
actors = [a for a, d in zip(actors, drop_actor) if not d]
rows = [r for r, k in zip(rows, keep) if k]
crops = [c for c, k in zip(crops, keep) if k]
native = native[keep]
actor_of = remap[actor_of[keep]]
for r, ai in zip(rows, actor_of):
r["actor_idx"] = int(ai)
for ai, a in enumerate(actors):
a["n_images"] = int(np.sum(actor_of == ai))
print(f"[dedup] dropped {n_dup} duplicate mugshots and "
f"{int(drop_actor.sum())} actors left with too few; "
f"{len(actors)} actors, {len(rows)} images remain", file=sys.stderr)
if len(actors) < 2:
return err("fewer than 2 actors survive de-duplication — the image "
"cache holds too few distinct mugshots")
# ── hold out the probes ───────────────────────────────────────────────────
is_probe = np.zeros(len(rows), bool)
for ai in range(len(actors)):
idx = np.nonzero(actor_of == ai)[0]
# Seeded per actor so the choice does not depend on iteration order.
r = random.Random(f"{args.seed}:{actors[ai]['dir']}")
for pick in r.sample(list(idx), args.probes_per_actor):
is_probe[pick] = True
probe_rows = np.nonzero(is_probe)[0]
gal_rows = np.nonzero(~is_probe)[0]
print(f"[holdout] {len(probe_rows)} probes held out, "
f"{len(gal_rows)} gallery embeddings remain", file=sys.stderr)
gal_emb = native[gal_rows]
gal_actor = actor_of[gal_rows]
probe_actor = actor_of[probe_rows]
# Per-actor column masks for the best-of-N scan (identity_matcher_node).
actor_cols = [np.nonzero(gal_actor == ai)[0] for ai in range(len(actors))]
have_refs = np.array([len(c) > 0 for c in actor_cols])
if not have_refs.all():
return err("an actor ended up with no gallery references left; "
"raise --min-images")
# ── calibration ───────────────────────────────────────────────────────────
if args.calib_a is not None:
cal = {"a": args.calib_a, "b": args.calib_b, "valid": True}
print(f"[calibration] using supplied a={cal['a']} b={cal['b']}", file=sys.stderr)
else:
cal = calibrate_gallery(gal_emb, gal_actor)
if not cal["valid"]:
return err(
"calibration could not be fitted, and this study will not fall back to a "
"raw cosine threshold (CLAUDE.md invariant). Use more actors with >= "
f"{MIN_EMB_FOR_POSITIVE} mugshots, or pass --calib-a/--calib-b from a "
"production gallery.")
log_prior_odds = float(np.log(args.match_prior / (1.0 - args.match_prior)))
# ── sweep ─────────────────────────────────────────────────────────────────
down, up = INTERP[args.down_interp], INTERP[args.up_interp]
probe_crops = [crops[i] for i in probe_rows]
results, per_probe = [], []
for size in sizes:
t0 = time.time()
degraded = [degrade(c, size, down, up) for c in probe_crops]
q = stages.embed(degraded)
sims = q @ gal_emb.T # [n_probe, n_gal]
best_per_actor = np.stack([sims[:, cols].max(axis=1) for cols in actor_cols],
axis=1) # [n_probe, n_actor]
best_actor = best_per_actor.argmax(axis=1)
best_sim = best_per_actor.max(axis=1)
p_match = np.asarray(probability(best_sim, cal["a"], cal["b"], log_prior_odds))
accept = p_match > args.prob_threshold
correct = best_actor == probe_actor
tpi = int(np.sum(accept & correct))
fpi = int(np.sum(accept & ~correct))
unid = int(np.sum(~accept))
n = len(probe_rows)
results.append({
"size_px": size,
"n_probes": n,
"tpi": tpi, "fpi": fpi, "unidentified": unid,
"tpi_rate": tpi / n, "fpi_rate": fpi / n, "unidentified_rate": unid / n,
"rank1_rate": float(np.mean(correct)),
"mean_best_sim": float(np.mean(best_sim)),
"mean_p_match": float(np.mean(p_match)),
"mean_sim_true_actor": float(np.mean(
best_per_actor[np.arange(n), probe_actor])),
})
if args.per_probe:
for j in range(n):
per_probe.append({
"size_px": size,
"probe_image": rows[probe_rows[j]]["image"],
"true_actor": actors[probe_actor[j]]["name"],
"matched_actor": actors[best_actor[j]]["name"],
"best_sim": float(best_sim[j]),
"p_match": float(p_match[j]),
"outcome": ("TPI" if accept[j] and correct[j]
else "FPI" if accept[j] else "unidentified"),
})
print(f"[sweep] {size:3d}px TPI {tpi:4d} ({100 * tpi / n:5.1f}%) "
f"FPI {fpi:4d} ({100 * fpi / n:5.1f}%) "
f"unid {unid:4d} ({100 * unid / n:5.1f}%) "
f"rank1 {100 * np.mean(correct):5.1f}% "
f"[{time.time() - t0:.1f}s]", file=sys.stderr)
op = pick_operating_point(results, args.tpi_retention, args.fpi_slack)
# ── outputs ───────────────────────────────────────────────────────────────
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
# Append rather than with_suffix() so a prefix containing a dot keeps its name.
csv_path = out.with_name(out.name + ".csv")
json_path = out.with_name(out.name + ".json")
png_path = out.with_name(out.name + ".png")
fields = list(results[0].keys())
with open(csv_path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
w.writerows(results)
meta = {
"requirement": "VR-005",
"caveat": CAVEAT.format(n_actors=len(actors)),
"model": arcface.stem,
"detector": detector.stem,
"backend": "sae_embed / the compiled-in inference backend (fp32 ONNX under "
"SAE_INFERENCE_BACKEND=ORT; a TensorRT fp16 build is a different "
"embedding space)",
"n_actors": len(actors),
"n_probes": len(probe_rows),
"n_gallery_embeddings": len(gal_rows),
"probes_per_actor": args.probes_per_actor,
"seed": args.seed,
"sizes": sizes,
"prob_threshold": args.prob_threshold,
"match_prior": args.match_prior,
"calibration": cal,
"calibration_source": "supplied" if args.calib_a is not None else "fitted",
"sim_boundary_at_threshold": float(
(np.log(args.prob_threshold / (1 - args.prob_threshold))
- cal["b"] - log_prior_odds) / cal["a"]),
"down_interp": args.down_interp,
"up_interp": args.up_interp,
"max_side": args.max_side,
"duplicate_mugshots_dropped": n_dup,
"operating_point_rule": (
f"smallest size retaining >= {args.tpi_retention:.0%} of the 112 px "
f"control TPI rate with <= +{args.fpi_slack:.1%} absolute FPI"),
"operating_point": op,
"images_skipped_no_face": n_nodetect,
"curve": results,
"actors": actors,
}
json_path.write_text(json.dumps(meta, indent=2) + "\n")
if per_probe:
pp = out.with_name(out.name + ".per_probe.csv")
with open(pp, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(per_probe[0].keys()))
w.writeheader()
w.writerows(per_probe)
print(f"[out] {pp}", file=sys.stderr)
write_plot(results, png_path, meta)
# ── stdout report ─────────────────────────────────────────────────────────
print(f"\nVR-005 — minimum face size, {arcface.stem}")
print(f"{len(actors)} actors, {len(probe_rows)} probes/size, "
f"{len(gal_rows)} gallery embeddings at native resolution")
print(f"identify when P>{args.prob_threshold}, i.e. cosine above "
f"{meta['sim_boundary_at_threshold']:.4f} under the calibration fitted "
f"on this gallery\n")
print(f"{'size':>5} {'TPI':>8} {'FPI':>8} {'unid':>8} {'rank1':>8} {'mean sim':>9}")
for r in results:
print(f"{r['size_px']:>5} {100 * r['tpi_rate']:>7.1f}% "
f"{100 * r['fpi_rate']:>7.1f}% {100 * r['unidentified_rate']:>7.1f}% "
f"{100 * r['rank1_rate']:>7.1f}% {r['mean_best_sim']:>9.4f}")
print(f"\noperating point: {op if op else 'none of the swept sizes qualifies'}"
f" ({meta['operating_point_rule']})")
print(f"\n{meta['caveat']}")
print(f"\n[out] {csv_path}\n[out] {json_path}\n[out] {png_path}")
return 0
def _separation(emb: np.ndarray, actor: np.ndarray) -> tuple[float, float, float]:
"""(mean same-actor sim, mean different-actor sim, d') — the property that has
to survive for an embedding space to be usable, whatever its coordinates."""
iu, ju = np.triu_indices(len(actor), k=1)
sims = (emb @ emb.T)[iu, ju]
same = actor[iu] == actor[ju]
pos = sims[same & (sims < 0.9999)] # drop duplicate source images
neg = sims[~same]
if pos.size < 2 or neg.size < 2:
return float("nan"), float("nan"), float("nan")
d = (pos.mean() - neg.mean()) / np.sqrt(0.5 * (pos.var() + neg.var()))
return float(pos.mean()), float(neg.mean()), float(d)
def verify_embeddings(gallery_path: Path, rows: list[dict], native: np.ndarray,
actor_of: np.ndarray) -> None:
"""Cross-check this script's ONNX port against a gallery built by the C++
pipeline from the same mugshots.
Agreement is ~1.0 only if that gallery was built with the same backend. A
TensorRT fp16 build lands around 0.85 on LVFace-B while separating just as
well, so the separation figures not the agreement are what says whether
the port is sound."""
from sae_gallery import load_gallery_hdf5
g = load_gallery_hdf5(gallery_path)
stored: dict[tuple[str, str], np.ndarray] = {}
for a in g["actors"]:
key = a.get("jellyfin_id") or normalise_name(a.get("name", ""))
for e, src in zip(a.get("embeddings", []), a.get("source_images", [])):
if src:
stored[(key, src)] = np.asarray(e, np.float32)
sims, paired_mine, paired_ref, paired_actor = [], [], [], []
for i, r in enumerate(rows):
path = Path(r["image"])
head, _, _ = path.parent.name.partition("_")
key = head if JELLYFIN_ID_RE.match(head) else normalise_name(
path.parent.name.replace("_", " "))
ref = stored.get((key, path.name))
if ref is None or ref.shape != native[i].shape:
continue
ref = ref / max(float(np.linalg.norm(ref)), 1e-6)
sims.append(float(native[i] @ ref))
paired_mine.append(native[i])
paired_ref.append(ref)
paired_actor.append(actor_of[i])
if not sims:
print(f"[verify] no overlap with {gallery_path} — nothing checked",
file=sys.stderr)
return
sims_arr = np.asarray(sims)
print(f"[verify] {len(sims)} embeddings vs {gallery_path.name}: "
f"mean cos={sims_arr.mean():.4f} min={sims_arr.min():.4f}",
file=sys.stderr)
act = np.asarray(paired_actor)
for label, mat in (("this script", np.asarray(paired_mine)),
("stored gallery", np.asarray(paired_ref))):
pos, neg, d = _separation(mat, act)
print(f"[verify] {label:>14s}: same-actor {pos:.3f} "
f"different-actor {neg:.3f} d'={d:.2f}", file=sys.stderr)
if sims_arr.mean() < 0.99:
print("[verify] embeddings differ from the stored gallery. If d' is "
"comparable this is a backend difference (e.g. a TensorRT fp16 "
"build), not a broken port; the study is self-consistent either "
"way. If d' collapsed, the port is wrong.", file=sys.stderr)
def err(msg: str) -> int:
print(f"error: {msg}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
+9 -1
View File
@@ -61,7 +61,15 @@ class Prediction:
keys = keys_for(imdb_id=a.get("imdb_id"), tmdb_id=a.get("tmdb_id"),
jellyfin_id=a.get("jellyfin_id"), name=a.get("name"),
crosswalk=crosswalk)
windows = [(float(t0), float(t1)) for t0, t1 in a.get("scenes", [])]
# schema_version 1: scenes is [[t0, t1], ...] (list of pairs)
# schema_version 2: scenes is [{"start":…, "end":…, "belief":…, …}, …]
windows = []
for s in a.get("scenes", []):
if isinstance(s, dict):
windows.append((float(s["start"]), float(s["end"])))
else:
t0, t1 = s[0], s[1]
windows.append((float(t0), float(t1)))
for _, t1 in windows:
self._max_t = max(self._max_t, t1)
self.actors.append({"keys": keys, "windows": windows})
+370
View File
@@ -0,0 +1,370 @@
#!/usr/bin/env python3
"""
VR-014 the v1 audio signature recovers a known trim offset on real audio.
TRACES: UT-105, UT-106, UT-107, UT-108 | VR-014 | IR-004
python scripts/validation/test_audio_offset.py [build_dir]
The golden vector (IR-005) proves the *arithmetic* is identical in both
producers. It cannot prove the thing the signature exists for: that when the
same cut arrives trimmed differently, sliding one signature against the other
finds the true alignment and only the true alignment. Its fixture is a synthetic
tone sweep, which is pathologically easy to align; film dialogue and score are
not, and that is what this measures.
The signature is computed by the **shipped C++**, through the `sae_audio`
nanobind module never a numpy port. A third implementation of a fingerprint
whose whole value rests on three implementations agreeing byte for byte would be
the one nobody checks against the golden vector.
The slide *is* written here in numpy, deliberately: matching is the consumer's
algorithm (server SPEC.md section 3), owned by the server and the jRay plugin,
not by this repo. Writing it out is what makes this a test of the signature
rather than a test of somebody's matcher.
Two independent offset mechanisms are checked, because they can fail
separately:
* a **window offset** (UT-105) two 120 s excerpts taken from different
points, which is the alignment search itself; and
* a **head trim** (UT-106) a real file with delta seconds removed from the
front, which additionally exercises the runtime/2 anchor: the window follows
the midpoint, so cutting delta from the head moves it by delta/2, not delta.
That factor of two is the easiest thing in the whole feature to get wrong
and nothing else checks it.
"""
import base64
import random
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
import numpy as np
REPO = Path(__file__).resolve().parent.parent.parent
BUILD = Path(sys.argv[1]) if len(sys.argv) > 1 else REPO / "build"
sys.path.insert(0, str(BUILD))
import sae_audio # noqa: E402
FIXTURE = REPO / "tests" / "fixtures" / "audio" / "superhero_offset_200s.flac"
TONE = REPO / "tests" / "fixtures" / "audio" / "jray_audio_v1_tone.flac"
# Server SPEC.md section 3, "Matching and offset recovery". The cap is the
# spec's, not a convenience: +/-600 frames is ~56 s, which covers realistic trim
# differences, and an offset outside it must be declined rather than guessed at.
SEARCH_CAP_FRAMES = 600
AUDIO_TIER = 0.85
LOOSE_TIER = 0.60
TRIALS = 40
SEED = 20250731
HOP_SEC = sae_audio.hop_size / sae_audio.sample_rate
# What the offset is actually *for*: shifting scene windows, which are seconds
# long. Half a second of error is invisible against them, and that budget is
# what makes the numbers below readable — an offset is quantised to whole
# frames, so no correct answer can be worse than half a frame (46 ms) and the
# feature has an order of magnitude in hand before anything is at stake.
OFFSET_BUDGET_SEC = 0.5
def peak_bins(signature):
"""The per-frame peak band index, which is what the slide compares.
The `v1:` prefix is checked against the constant the C++ exports rather
than a literal, so a producer bump cannot be silently parsed as v1 here
(IR-008).
"""
prefix = sae_audio.version_prefix
if not signature.startswith(prefix):
raise AssertionError(f"signature is not {prefix!r}: {signature[:8]!r}")
packed = np.frombuffer(base64.b64decode(signature[len(prefix):]), dtype=np.uint8)
if np.any(packed & 0x80):
raise AssertionError("reserved bit set — not a structurally valid signature")
return packed >> 2
def best_match(reference, query, cap=SEARCH_CAP_FRAMES, slack=0):
"""Slide `query` against `reference`; return (score, offset_frames).
`offset` is how many frames later the query's window begins, so
``query[i]`` lines up with ``reference[i + offset]``. Score is the fraction
of overlapping frames whose peak bin agrees, exactly as the spec defines it.
`slack` widens what counts as agreement to a frame within +/-slack, which is
not the spec's rule — it is the candidate remedy UT-108 measures. It changes
the *score* only; the offset it reports is still a whole-frame alignment.
"""
best_score, best_offset = -1.0, 0
for offset in range(-cap, cap + 1):
if offset >= 0:
a, b = reference[offset:], query[: len(query) - offset]
else:
a, b = reference[: len(reference) + offset], query[-offset:]
n = min(len(a), len(b))
if n < 100: # too little overlap to mean anything
continue
a, b = a[:n], b[:n]
if slack == 0:
agree = a == b
else:
agree = np.zeros(n, dtype=bool)
for shift in range(-slack, slack + 1):
shifted = np.roll(a, shift)
# 255 is not a band index, so the wrapped end can never agree.
if shift > 0:
shifted[:shift] = 255
elif shift < 0:
shifted[shift:] = 255
agree |= shifted == b
score = float(np.mean(agree))
if score > best_score:
best_score, best_offset = score, offset
return best_score, best_offset
def decode_mono(path):
"""The whole fixture as float32 mono at 11025 Hz — the signature's own rate."""
raw = subprocess.run(
["ffmpeg", "-nostdin", "-v", "error", "-i", str(path),
"-ac", "1", "-ar", str(sae_audio.sample_rate), "-f", "f32le", "-"],
capture_output=True, check=True).stdout
return np.frombuffer(raw, dtype="<f4")
def trim_head(source, seconds, out):
"""`source` with `seconds` removed from the front — a differently trimmed release."""
subprocess.run(
["ffmpeg", "-nostdin", "-v", "error", "-y", "-ss", f"{seconds:.3f}", "-i", str(source),
"-ac", "1", "-ar", str(sae_audio.sample_rate), "-sample_fmt", "s16",
"-c:a", "flac", str(out)], check=True)
return out
def tier(score):
if score >= AUDIO_TIER:
return "audio"
return "loose" if score >= LOOSE_TIER else "none"
# ── The random trial set, signed once and reused ─────────────────────────────
def random_trials(pcm):
"""(reference bins, [(expected_frames, query bins)]) for TRIALS excerpts.
Signing 40 windows is the expensive part of this file, so UT-105 and UT-108
share one set they ask different questions of the same measurements.
"""
window = sae_audio.window_samples
reference = peak_bins(sae_audio.signature_from_mono(pcm[:window]))
rng = random.Random(SEED)
queries = []
for _ in range(TRIALS):
# Within the search cap: past it, no offset is recoverable by
# construction, which UT-107 checks separately.
start = rng.randrange(0, SEARCH_CAP_FRAMES * sae_audio.hop_size)
signature = sae_audio.signature_from_mono(pcm[start:start + window])
assert signature is not None, "a full window must always sign"
queries.append((start / sae_audio.hop_size, peak_bins(signature)))
return reference, queries
# ── UT-105 — window offsets from random excerpt starts ───────────────────────
def test_random_window_offsets(reference, queries):
"""Every in-cap offset is recovered to the nearest frame, on real audio."""
rows = []
for want, query in queries:
score, offset = best_match(reference, query)
rows.append((want, offset, score, abs(want - round(want))))
expected = np.array([r[0] for r in rows])
offset = np.array([r[1] for r in rows])
score = np.array([r[2] for r in rows])
subframe = np.array([r[3] for r in rows])
error = np.abs(offset - expected)
# The offset is quantised to whole frames, so the best any correct answer
# can do is half a frame — 46 ms. What matters is the budget that half-frame
# is measured against, and it is an order of magnitude away from it.
assert error.max() <= 1.0, f"offset missed by {error.max():.2f} frames"
assert error.max() * HOP_SEC <= OFFSET_BUDGET_SEC, (
f"offset error {error.max() * HOP_SEC:.3f}s exceeds the {OFFSET_BUDGET_SEC}s budget")
# Never mistaken for different content. This is the floor that matters: the
# audio genuinely is the same cut, so a "no match" would be a false negative
# on the case the feature exists for.
assert score.min() >= LOOSE_TIER, f"same content scored {score.min():.3f}"
# An offset that lands near a frame boundary has no excuse: it should reach
# the top tier, and does.
aligned = subframe <= 0.1
assert aligned.any(), "seed no longer produces a near-aligned trial"
assert score[aligned].min() >= AUDIO_TIER, (
f"near-frame-aligned offset scored only {score[aligned].min():.3f}")
print(f"UT-105 {TRIALS} random window offsets, all within the +/-600 frame cap")
print(f" offset error : max {error.max():.2f} frames"
f" = {error.max() * HOP_SEC * 1000:.0f} ms, against a"
f" {OFFSET_BUDGET_SEC * 1000:.0f} ms budget")
print(f" score : min {score.min():.3f} median {np.median(score):.3f}"
f" max {score.max():.3f}")
print(" score by sub-frame misalignment — the offset is exact in every row:")
for lo, hi in ((0.0, 0.1), (0.1, 0.2), (0.2, 0.3), (0.3, 0.4), (0.4, 0.5)):
m = (subframe >= lo) & (subframe < hi)
if m.any():
print(f" {lo:.1f}-{hi:.1f} frame n={m.sum():2d}"
f" score {score[m].min():.3f}-{score[m].max():.3f}"
f" tier {tier(np.median(score[m]))}")
counts = {t: int(sum(1 for s in score if tier(s) == t)) for t in ("audio", "loose", "none")}
print(f" tiers : {counts}")
return counts
# ── UT-106 — head trims through real files, including the runtime/2 anchor ───
def test_head_trims():
"""A release with delta seconds of head removed aligns at delta/2 frames."""
reference = peak_bins(sae_audio.compute_signature(str(FIXTURE)))
results = []
with tempfile.TemporaryDirectory() as tmp:
for delta in (7.0, 23.5, 41.25, 60.0):
trimmed = trim_head(FIXTURE, delta, Path(tmp) / f"trim_{delta}.flac")
signature = sae_audio.compute_signature(str(trimmed))
assert signature is not None, f"trim of {delta}s should still sign"
score, offset = best_match(reference, peak_bins(signature))
# The window follows the midpoint, so removing delta from the head
# moves it by delta/2 — not by delta.
expected = (delta / 2.0) / HOP_SEC
assert abs(offset - expected) <= 1.0, (
f"head trim {delta}s: expected ~{expected:.1f} frames, got {offset}")
assert score >= LOOSE_TIER, f"head trim {delta}s scored {score:.3f}"
results.append((delta, expected, offset, score))
print("UT-106 head trims through the real decode path (compute_signature on a file)")
for delta, expected, offset, score in results:
print(f" -{delta:6.2f}s head expected {expected:7.2f} fr"
f" recovered {offset:5d} score {score:.3f} ({tier(score)})")
# ── UT-107 — what must NOT match ─────────────────────────────────────────────
def test_declines(pcm, reference):
"""Out-of-cap offsets and unrelated content are declined, not guessed at."""
window = sae_audio.window_samples
beyond = int(75.0 * sae_audio.sample_rate) # ~807 frames, past the cap
assert beyond + window <= len(pcm), "fixture too short for the out-of-cap case"
far = peak_bins(sae_audio.signature_from_mono(pcm[beyond:beyond + window]))
score_beyond, offset_beyond = best_match(reference, far)
assert score_beyond < LOOSE_TIER, (
f"an offset past the cap scored {score_beyond:.3f} at {offset_beyond}"
"the search invented an alignment rather than declining")
tone = peak_bins(sae_audio.compute_signature(str(TONE)))
score_tone, offset_tone = best_match(reference, tone)
assert score_tone < LOOSE_TIER, f"unrelated content scored {score_tone:.3f}"
print("UT-107 declines rather than guesses")
print(f" offset past the +/-600 frame cap : best {score_beyond:.3f}"
f" at {offset_beyond} ({tier(score_beyond)})")
print(f" unrelated content (tone fixture) : best {score_tone:.3f}"
f" at {offset_tone} ({tier(score_tone)})")
return far, tone
# ── UT-108 — the sub-frame demotion, and what one frame of slack costs ───────
def test_scoring_slack(reference, queries, far, tone):
"""Measured: +/-1 frame of slack in the *score* restores the `audio` tier.
UT-105 leaves a real question open. Every offset is right, but two thirds of
them score below the server's 0.85 `audio` threshold purely because the two
windows' frame grids do not coincide — so a correctly aligned release is
demoted to `loose`, which is the tier meaning "possibly the same cut,
degraded audio". The obvious remedy is to stop demanding that frames line up
exactly, and the question is what that costs in discrimination.
Nothing is asserted about the spec's own rule here; this measures a
candidate change to it, which is the server's to make (SPEC.md section 3).
"""
print("UT-108 cost of relaxing the score's frame alignment")
print(f" {'slack':>5} {'audio':>6} {'loose':>6} {'none':>5}"
f" {'min true':>9} {'worst err':>10} {'unrelated':>10} {'out-of-cap':>11}")
measured = {}
for slack in (0, 1, 2):
score, error = [], []
for want, query in queries:
s, offset = best_match(reference, query, slack=slack)
score.append(s)
error.append(abs(offset - want))
score, error = np.array(score), np.array(error)
false_tone, _ = best_match(reference, tone, slack=slack)
false_far, _ = best_match(reference, far, slack=slack)
counts = {t: int(sum(1 for s in score if tier(s) == t)) for t in ("audio", "loose", "none")}
measured[slack] = (score, error, max(false_tone, false_far))
print(f" {slack:>5} {counts['audio']:>6} {counts['loose']:>6} {counts['none']:>5}"
f" {score.min():>9.3f} {error.max() * HOP_SEC * 1000:>7.0f} ms"
f" {false_tone:>10.3f} {false_far:>11.3f}")
score, error, worst_false = measured[1]
# One frame of slack lifts every correct alignment to the top tier...
assert score.min() >= AUDIO_TIER, (
f"one frame of slack still leaves a true match at {score.min():.3f}")
# ...without narrowing the gap that makes the threshold mean anything...
assert worst_false < LOOSE_TIER, (
f"slack lifted a false match to {worst_false:.3f}")
# ...and the offset it costs is still far inside the budget: the score's
# peak flattens slightly, so the argmax can pick an adjacent frame.
assert error.max() * HOP_SEC <= OFFSET_BUDGET_SEC, (
f"slack cost {error.max() * HOP_SEC:.3f}s of offset accuracy")
print(f" +/-1 frame: every true match reaches `audio` (min {score.min():.3f}),"
f" worst false stays at {worst_false:.3f},")
print(f" and the offset costs {error.max() * HOP_SEC * 1000:.0f} ms of a"
f" {OFFSET_BUDGET_SEC * 1000:.0f} ms budget. +/-2 buys nothing more.")
def main():
if not FIXTURE.exists():
print(f"missing fixture {FIXTURE} — regenerate with make_offset_fixture.sh", file=sys.stderr)
return 2
if shutil.which("ffmpeg") is None:
print("this validation needs the ffmpeg CLI to trim the fixture", file=sys.stderr)
return 2
print(f"VR-014 audio-signature offset recovery on {FIXTURE.name}")
print(f" {sae_audio.expected_frames} frames per signature,"
f" {HOP_SEC * 1000:.2f} ms per frame, cap +/-{SEARCH_CAP_FRAMES} frames")
pcm = decode_mono(FIXTURE)
reference, queries = random_trials(pcm)
counts = test_random_window_offsets(reference, queries)
test_head_trims()
far, tone = test_declines(pcm, reference)
test_scoring_slack(reference, queries, far, tone)
print()
print(f"PASS — every in-cap offset recovered to the nearest frame, worst"
f" {1000 * HOP_SEC / 2:.0f} ms against a {OFFSET_BUDGET_SEC * 1000:.0f} ms budget.")
if counts["audio"] < TRIALS:
# Stated rather than asserted against the spec's rule: the offset is
# right in every case, so this is the 0.85 threshold meeting a sub-frame
# shift, not a defect in the signature. The threshold was calibrated on
# a re-encode at zero offset, where the score is 1.00. UT-108 measures
# the remedy; adopting it is the server spec's call, not this repo's.
print(f"NOTE — under the spec's exact-frame score only {counts['audio']}/{TRIALS}"
f" reach `audio`; {counts['loose']} are demoted to `loose` by sub-frame"
" shift alone. See UT-108.")
return 0
if __name__ == "__main__":
sys.exit(main())

Some files were not shown because too many files have changed in this diff Show More