29 Commits
Author SHA1 Message Date
dtourolle 02fed7d328 chore: bump KPN to 454f72c (ignore generated ORT cache) 2026-08-04 14:08:13 +02:00
dtourolle 2e167c671f 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 51205d0ba2 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 546700f47e 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 05a3142d27 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 e327ab19e7 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 35e703350f 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 de2eb5fa5c 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 7b093eee92 Merge branch 'feature/quality-knee' into feature/opencv5
# Conflicts:
#	docs/requirements.md
2026-07-31 16:54:09 +02:00
dtourolle 890f1946bf Merge branch 'feature/dump-provenance' into feature/opencv5 2026-07-31 16:53:58 +02:00
dtourolleandClaude Opus 5 afca0524c9 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 d57e489e91 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 0dbbe5f6a3 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 83f38a617e 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 05f30c51fc 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
dtourolle 402429dc2f Merge branch 'feature/gallery-report' into feature/opencv5 2026-07-31 15:31:07 +02:00
dtourolleandClaude Opus 5 ddb748eecb 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 a667caa313 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 d81fc59824 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 50649c1f87 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 080581c050 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 6da8ac2bdb 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 bbab5aed23 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 a2c699a844 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 dfb8f5801e 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 036f44fbdd 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 cd62d6452d 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 c36885de73 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 b5c7d4f6d9 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
108 changed files with 1362 additions and 9843 deletions
-9
View File
@@ -65,15 +65,6 @@ jobs:
- name: Traceability gate - name: Traceability gate
run: sh scripts/vendor/jray-project/scripts/traceability/traceability-gate.sh 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 - name: Check modified files for traces
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
run: | run: |
-140
View File
@@ -1,140 +0,0 @@
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
-4
View File
@@ -1,6 +1,5 @@
# Build # Build
build/ build/
build-*/
cmake-build-*/ cmake-build-*/
CMakeCache.txt CMakeCache.txt
CMakeFiles/ CMakeFiles/
@@ -118,6 +117,3 @@ venv/
*.swo *.swo
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.venv-rocm/
!models/scene_boundary_xgb.json
experiments/dump_review/
+12 -88
View File
@@ -55,13 +55,6 @@ set_property(CACHE SAE_GEMM_BACKEND PROPERTY STRINGS ROCM CUDA CPU)
# default so ROCm/CPU builds don't reference unavailable EPs. # default so ROCm/CPU builds don't reference unavailable EPs.
option(SAE_ORT_TRT_EP "ORT backend: enable TensorRT/CUDA execution providers" OFF) 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, # 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. # OFF⇒ORT+ROCM) unless the user set them explicitly.
if(DEFINED SAE_WITH_TRT) if(DEFINED SAE_WITH_TRT)
@@ -160,16 +153,10 @@ if(SAE_GEMM_BACKEND STREQUAL "CPU")
target_include_directories(gemm_backend PRIVATE src) target_include_directories(gemm_backend PRIVATE src)
target_compile_definitions(gemm_backend PRIVATE SAE_GEMM_CPU) target_compile_definitions(gemm_backend PRIVATE SAE_GEMM_CPU)
# AR-026/AR-027: the CPU path is backed by OpenBLAS, and that is REQUIRED # AR-026/AR-027: back the CPU path with OpenBLAS when present. Optional, so
# rather than opportunistic. The CPU backend is what CI (no GPU) and the cpu # the build gains no hard dependency — but without it the fallback is a
# builder image actually run, so a silent fall back to the scalar loop means # scalar loop, which does not hold up against a library-scale gallery, and
# AR-027 is measured — or worse, believed — on a path no release uses. A # the CPU path is exactly what CI (no GPU) and the cpu builder image use.
# 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) find_package(PkgConfig QUIET)
if(PkgConfig_FOUND) if(PkgConfig_FOUND)
pkg_check_modules(OPENBLAS QUIET openblas) pkg_check_modules(OPENBLAS QUIET openblas)
@@ -179,16 +166,9 @@ if(SAE_GEMM_BACKEND STREQUAL "CPU")
target_compile_definitions(gemm_backend PRIVATE SAE_GEMM_CBLAS) target_compile_definitions(gemm_backend PRIVATE SAE_GEMM_CBLAS)
target_include_directories(gemm_backend PRIVATE ${OPENBLAS_INCLUDE_DIRS}) target_include_directories(gemm_backend PRIVATE ${OPENBLAS_INCLUDE_DIRS})
target_link_libraries(gemm_backend PRIVATE ${OPENBLAS_LINK_LIBRARIES}) 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() else()
message(FATAL_ERROR message(WARNING "GEMM backend: CPU scalar fallback — OpenBLAS not found. "
"OpenBLAS not found, and the CPU GEMM backend requires it (AR-026/AR-027).\n" "Correct, but slow on a large gallery (AR-027).")
" 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() endif()
elseif(SAE_GEMM_BACKEND STREQUAL "CUDA") elseif(SAE_GEMM_BACKEND STREQUAL "CUDA")
find_library(CUBLAS_LIB cublas find_library(CUBLAS_LIB cublas
@@ -280,24 +260,6 @@ FetchContent_Declare(
) )
FetchContent_MakeAvailable(nanobind) 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 ─────────────────────────────────────────────────────────────── # ── Model paths ───────────────────────────────────────────────────────────────
set(SAE_MODELS_DIR "${CMAKE_SOURCE_DIR}/models" set(SAE_MODELS_DIR "${CMAKE_SOURCE_DIR}/models"
CACHE PATH "Directory containing ONNX model files") CACHE PATH "Directory containing ONNX model files")
@@ -340,22 +302,11 @@ nanobind_add_module(sae_embed src/python_bindings.cpp)
target_link_libraries(sae_embed PRIVATE sae_gallery) target_link_libraries(sae_embed PRIVATE sae_gallery)
# ── sae_kpn — Python module: run the real downstream nodes over dumped embeddings ─ # ── sae_kpn — Python module: run the real downstream nodes over dumped embeddings ─
# Assembles face_tracker/identity_matcher/frame_annotation in a Python-driven KPN # Assembles face_tracker/identity_matcher/scene_tracker in a Python-driven KPN
# network (KPN_BUILD_PYTHON is enabled per-TU inside the .cpp). Powers the # network (KPN_BUILD_PYTHON is enabled per-TU inside the .cpp). Powers the
# threshold-sweep optimizer in scripts/optimizer/. # threshold-sweep optimizer in scripts/optimizer/.
# nanobind_add_module(sae_kpn src/kpn_bindings.cpp)
# TRACES: VR-011 | PR-002 target_link_libraries(sae_kpn PRIVATE sae_gallery)
# 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) ──────────────── # ── sae_audio — Python module: the v1 audio signature (IR-004) ────────────────
# Compiles audio_signature.cpp directly and links only FFmpeg, rather than # Compiles audio_signature.cpp directly and links only FFmpeg, rather than
@@ -370,43 +321,16 @@ target_link_libraries(sae_audio PRIVATE ffmpeg_libs)
# HDF5 already found above (before sae_gallery); vars HDF5_CXX_LIBRARIES / _INCLUDE_DIRS # HDF5 already found above (before sae_gallery); vars HDF5_CXX_LIBRARIES / _INCLUDE_DIRS
# are reused by scene_analyze / dump_embeddings below. # 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 ─────────────────────────────────────────── # ── analyze — main analysis binary ───────────────────────────────────────────
add_executable(scene_analyze src/main.cpp) add_executable(scene_analyze src/main.cpp)
target_link_libraries(scene_analyze PRIVATE sae_gallery ${HDF5_CXX_LIBRARIES} ${SAE_SCENE_LIBS}) target_link_libraries(scene_analyze PRIVATE sae_gallery ${HDF5_CXX_LIBRARIES})
target_include_directories(scene_analyze PRIVATE ${HDF5_INCLUDE_DIRS}) 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 ───────────────── # ── analyze_debug — same binary with debug frame/crop output ─────────────────
add_executable(scene_analyze_debug src/main.cpp) add_executable(scene_analyze_debug src/main.cpp)
target_link_libraries(scene_analyze_debug PRIVATE sae_gallery ${HDF5_CXX_LIBRARIES} ${SAE_SCENE_LIBS}) target_link_libraries(scene_analyze_debug PRIVATE sae_gallery ${HDF5_CXX_LIBRARIES})
target_include_directories(scene_analyze_debug PRIVATE ${HDF5_INCLUDE_DIRS}) target_include_directories(scene_analyze_debug PRIVATE ${HDF5_INCLUDE_DIRS})
target_compile_definitions(scene_analyze_debug PRIVATE SAE_DEBUG=1 ${SAE_SCENE_DEFS}) target_compile_definitions(scene_analyze_debug PRIVATE SAE_DEBUG=1)
# ── dump_embeddings — standalone embedding dumper, NO gallery/matcher ───────── # ── dump_embeddings — standalone embedding dumper, NO gallery/matcher ─────────
# Front-half only (decode→detect→align→embed→HDF5) for the optimizer replay corpus # Front-half only (decode→detect→align→embed→HDF5) for the optimizer replay corpus
-342
View File
@@ -1,342 +0,0 @@
# 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
+40 -212
View File
@@ -128,43 +128,10 @@ Two consequences worth stating:
depends on timing. The same command run twice can produce different dumps, and depends on timing. The same command run twice can produce different dumps, and
a golden fixture cannot be built on that. a golden fixture cannot be built on that.
**Current:** fixed in KPN. Node data outputs *park* on a full channel — the **Current:** fixed in KPN — node data outputs use `push_blocking`, sentinels
value is held in a one-slot buffer, the worker is released, and the channel's remain out-of-band so EOF can always overtake a stalled data path. Verified on
space callback resubmits the node once the consumer drains. That replaced the same clip: 385 of 385 sampled frames written, zero drops, and two
`push_blocking`, which slept inside the push and, with one thread per node, consecutive runs byte-identical where previously they were not.
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, 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 and the overflow exception cost more — so the lossy path was paying for work it
@@ -286,26 +253,6 @@ the same response.
perfectly healthy. Measured on the **112×112 aligned crop**, not the raw box: 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 the crop is already scale-normalised, so a measure taken there cannot silently
re-measure face size and double-count it against AR-002. 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 - **Visibility** — extreme pose or occlusion means the face presents fewer of the
features the embedding assumes are present. The measure is the **residual of 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 the AR-005 alignment fit**: the RMS landmark error, in canonical 112×112
@@ -386,43 +333,18 @@ 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 number AR-024 retired for similarity, and it would fail the same way: meaning
something different for every detector, every embedder and every film. something different for every detector, every embedder and every film.
**Current:** all three axes are measured and carried, and the vector reaches the **Current:** visibility is measured and carried`estimate_alignment()` in
dump. `FaceAlignerFunc` is where it is filled in, because both measured axes fall `src/face_utils.hpp` returns the residual alongside the transform, and
out of work the warp already does: visibility is the residual from `FaceAlignerFunc` writes it to `DetectedFace::alignment_residual`. Size is
`estimate_alignment()`, and sharpness is `crop_sharpness()` on the 112×112 crop `min_face_px` (40, decoded-frame space — AR-002 still open). Sharpness is
the node has just produced. Size stays `bbox` — deliberately not copied into a unmeasured. Nothing yet *consumes* any of it: no discount is applied, and
field of its own, since that would hold the same quantity in two coordinate `align_face()` still drops the degenerate-fit case without counting it.
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 **Gap:** AR-029 entirely. For AR-030, the measure exists but the discount does
columns parallel to `confidence`, taking the dump to `schema_version` 2. The bump not — it must reach `EvidenceDiscounter` as the reliability term. For AR-028, the
is not for readers — both sides check by name, and a v1 dump still replays — but residual does not yet reach the VR-001 dump, which is what VR-012 needs to run
so that a consumer of the vector can tell *never scored* from *scored zero*, from fixtures; that is the next step, since it unblocks the study that sets
which is a real reading on this axis. Nothing yet *consumes* any of it. every remaining behaviour.
**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 ## AR-007, AR-008 — Tracking
@@ -516,7 +438,7 @@ Both feed AR-007 as **association hints**: they tell the tracker that spatial
continuity is broken and that association should weight embedding over IoU. continuity is broken and that association should weight embedding over IoU.
Neither ends a presence window (AR-012). Neither ends a presence window (AR-012).
In dense mode the source decodes at `scene_decode_fps` (default 0 = native) and a In dense mode the source decodes at `scene_decode_fps` (default 12) and a
decimator splits the stream: full-resolution sampled frames to the face pipeline, decimator splits the stream: full-resolution sampled frames to the face pipeline,
downscaled dense frames to the scene detector downscaled dense frames to the scene detector
(`frame_source_node.hpp:63`). `sample_fps` is independent of this — the face (`frame_source_node.hpp:63`). `sample_fps` is independent of this — the face
@@ -537,44 +459,31 @@ degrading what a single inference sees. A model run off-distribution produces
confident, plausible, wrong output, and the error is invisible without a study confident, plausible, wrong output, and the error is invisible without a study
that should not have been necessary. that should not have been necessary.
Two places this was violated, both now closed: Two places this is currently violated:
1. **`scene_decode_fps = 12` starved TransNetV2.** `kWindow` is 100 frames. At 1. **`scene_decode_fps = 12` starves TransNetV2.** `kWindow` is 100 frames. At
native 25 fps that window spans ~4 s; at 12 fps it spanned ~8.3 s, so the native 25 fps that window spans ~4 s; at 12 fps it spans ~8.3 s, so the model
model saw roughly half-speed motion over twice the temporal context it was sees roughly half-speed motion over twice the temporal context it was trained
trained on. **Requirement: feed TransNetV2 at the source's native frame on. **Requirement: feed TransNetV2 at the source's native frame rate**, so a
rate**, so a 100-frame window covers the duration the model expects. The 100-frame window covers the duration the model expects. The
"tolerates ~12fps" note in `config.hpp` described a compromise, and the "tolerates ~12fps" note in `config.hpp` describes a compromise, and the
recorded margin was consistent with it — a non-boundary baseline at ~0.50 with recorded margin is consistent with it — a non-boundary baseline at ~0.50 with
real boundaries reaching only ~0.7+ is a compressed separation, not a healthy real boundaries reaching only ~0.7+ is a compressed separation, not a healthy
one. **Done:** `scene_decode_fps` defaults to 0. one.
2. **Hardcoded 25 fps in boundary dedup.** The node merged boundaries closer than 2. **Hardcoded 25 fps in boundary dedup.** `scene_detector_node.hpp:138` merges
`0.04 s` — "~1 frame @25fps". **Requirement: derive this from the source's boundaries closer than `0.04 s` — "~1 frame @25fps". **Requirement: derive
actual frame rate. Done:** `SceneDetectorFunc::dedup_window_sec()` takes the this from the source's actual frame rate.**
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 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 accepted: the alternative is a boundary signal that steers association (AR-007) while
being quietly unreliable. `dense_scale` remains available as a spatial reduction, being quietly unreliable. `dense_scale` remains available as a spatial reduction,
since downscaling is a documented, understood degradation rather than a temporal since downscaling is a documented, understood degradation rather than a temporal
one the model has no defence against — and TransNetV2 downsamples to 48×27 one the model has no defence against.
regardless.
**Current:** histogram cut in the decoder; `scene_detector_node.hpp` for **Current:** histogram cut in the decoder; `scene_detector_node.hpp` for
TransNetV2, fed at native rate with a framerate-derived dedup window. TransNetV2. **Gap:** native-rate dense decode; framerate-derived dedup;
**Gap:** `scene_threshold` (0.60) is still the value picked against 12 fps input `--scene-detect` is default-off despite now feeding association.
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** ## AR-012 … AR-017 — Track-level identity propagation — **CHANGED BEHAVIOUR**
@@ -1036,45 +945,21 @@ 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 **All similarity computation goes through the GEMM path**, with no exception
justified by "this set is small". Three call sites: justified by "this set is small". Three call sites:
1. **Baked gallery** — GEMM (`sim_engine_->compute()`, backend from 1. **Baked gallery** already GEMM (`sim_engine_->compute()`,
`SAE_GEMM_BACKEND`). ✓ `identity_matcher_node.hpp:143`, backend from `SAE_GEMM_BACKEND`). ✓
2. **Per-film annex**was a **CPU loop**, justified in-comment by "tens of 2. **Per-film annex**currently a **CPU loop**
embeddings". AR-018…AR-021 invalidated that assumption: every owned track (`identity_matcher_node.hpp:159-162`), justified in-comment by "tens of
contributes, so the annex grows with cast size and film length. Now appended embeddings". AR-018…AR-021 invalidates that assumption: every owned track now
to the gallery matrix rather than scored separately — promotions are pushed contributes, so the annex grows with cast size and film length. It must move
into the engine's resident matrix (`ISimilarityEngine::append_rows`, into the GEMM path — appended to the gallery matrix, or a second multiply.
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 3. **Deferred TBI pass (AR-020)** — the most GEMM-friendly operation in the
pipeline: all TBI embeddings against the full gallery-plus-annex, offline, pipeline: all TBI embeddings against the full gallery-plus-annex, offline,
operands resident, no streaming. One large multiply, not a loop over entries. 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** 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 with promotions appended, plus a parallel actor-index mapping — exactly the
`flat_emb_`/`flat_actor_` arrangement the baked gallery already uses. `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 ### Scaling characteristics that must be known, not assumed
- **Throughput versus gallery size must be measured** (VR-008) and published. The - **Throughput versus gallery size must be measured** (VR-008) and published. The
@@ -1653,10 +1538,7 @@ Persist pipeline state at the point where the expensive work ends.
per-frame index table (`face_offset`, `face_count`) pointing into them. Avoids per-frame index table (`face_offset`, `face_count`) pointing into them. Avoids
variable-length HDF5 types and reads straight into numpy. variable-length HDF5 types and reads straight into numpy.
- Stores per frame: `timestamp_sec`, `frame_idx`, `is_cut`, `is_scene_boundary`. - Stores per frame: `timestamp_sec`, `frame_idx`, `is_cut`, `is_scene_boundary`.
Per face: `embedding` [N,512], `bbox` [N,4], `landmarks` [N,10], `confidence`, 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 - Invariants: embeddings unit-norm; `face_offset` contiguous; bboxes and
landmarks in **decoded-frame** pixels with `bbox_upscale` recorded alongside 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 — (the dump is a faithful tap, so it does not transform what the tracker saw —
@@ -1667,16 +1549,10 @@ Persist pipeline state at the point where the expensive work ends.
Schema owned by [`scripts/optimizer/SCHEMA.md`](../scripts/optimizer/SCHEMA.md). Schema owned by [`scripts/optimizer/SCHEMA.md`](../scripts/optimizer/SCHEMA.md).
**Current:** C++ dump sink (`embedding_dump_node.hpp`, `dump_embeddings.cpp`), **Current:** C++ dump sink (`embedding_dump_node.hpp`, `dump_embeddings.cpp`),
read by `replay.py`. At `schema_version` 2, which AR-028 took it to by adding the read by `replay.py`. **Gap:** **AR-012 breaks the replay contract.** Track extents
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 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. reproduce them, but only if the dump preserves everything the tracker needs.
Verify `landmarks`/`bbox`/`is_cut` suffice, and bump `schema_version` if not. 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 ## VR-002 — Replay and sweep
@@ -1799,54 +1675,6 @@ round 1 seeding references that corrupt round 2.
"expansion helps live matching" from "expansion helps the second pass", which the "expansion helps live matching" from "expansion helps the second pass", which the
current all-or-nothing `expand_gallery` flag cannot distinguish. 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 ## VR-008 — Gallery scaling benchmark
Establish the throughput-versus-gallery-size curve required by A10. Establish the throughput-versus-gallery-size curve required by A10.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

After

Width:  |  Height:  |  Size: 165 KiB

-344
View File
@@ -1,344 +0,0 @@
# 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,12 +1,10 @@
> **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? # Which embedding model is best?
Three ArcFace variants (w600k-R50, R18, w600k-MBF) and LVFace-B (Glint360K, Three ArcFace variants (w600k-R50, R18, w600k-MBF) and LVFace-B (Glint360K,
455MB) were compared. r50 is excluded from the training/held-out comparison 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 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 other three on the identical source photos, which confounds a direct score
comparison (see [the full experiment log](model-bakeoff-2026-07.md) for detail). It comparison (see [the full experiment log](model-bakeoff.md) for detail). It
remains in the calibration comparison, which does not depend on the gallery remains in the calibration comparison, which does not depend on the gallery
image count. image count.
@@ -65,7 +63,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 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 table where LVFace does not score highest. LVFace's training-set macro
average (75.3%, see [the full experiment log](model-bakeoff-2026-07.md)) is not a average (75.3%, see [the full experiment log](model-bakeoff.md)) is not a
uniform win across every film it contributes to; the held-out result, where uniform win across every film it contributes to; the held-out result, where
LVFace wins all 5 films outright, is the stronger claim. LVFace wins all 5 films outright, is the stronger claim.
@@ -81,7 +79,7 @@ not.
![All 12 combos ranked by training-set F1](assets/images/rep4_matrix_f1.png) ![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 Best full-gallery combo per model (all three are `full_exp`), from the
training matrix in [the full experiment log](model-bakeoff-2026-07.md): training matrix in [the full experiment log](model-bakeoff.md):
| model | F1 | P | R | misID | | model | F1 | P | R | misID |
|---|---|---|---|---| |---|---|---|---|---|
@@ -1,5 +1,3 @@
> **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 # Whole gallery vs. cast-restricted gallery
Two ways to run the matcher. Full mode scores every detected face against Two ways to run the matcher. Full mode scores every detected face against
@@ -10,7 +8,7 @@ top-billed actors) before the matcher runs.
## Result ## Result
Averaged across the 3 compared models (r50 excluded, see Averaged across the 3 compared models (r50 excluded, see
[the full experiment log](model-bakeoff-2026-07.md)) and both expansion settings, on [the full experiment log](model-bakeoff.md)) and both expansion settings, on
the 4 training films: the 4 training films:
| scope | F1 | P | R | total misID | | scope | F1 | P | R | total misID |
@@ -29,7 +27,7 @@ restricted gallery:
![All combos ranked by training-set F1, filled dots are restricted](assets/images/rep4_matrix_f1.png) ![All combos ranked by training-set F1, filled dots are restricted](assets/images/rep4_matrix_f1.png)
See [the full experiment log](model-bakeoff-2026-07.md) for the complete table. One See [the full experiment log](model-bakeoff.md) for the complete table. One
combo reaches zero true out-of-cast misidentifications, combo reaches zero true out-of-cast misidentifications,
`arcface_w600k_mbf_restricted_exp` (F1 76.2%), and it is a restricted one, `arcface_w600k_mbf_restricted_exp` (F1 76.2%), and it is a restricted one,
consistent with restriction, not expansion, being what suppresses cross-film consistent with restriction, not expansion, being what suppresses cross-film
@@ -59,7 +57,7 @@ Building this as a real feature requires:
option. option.
- A decision on the fallback case: what happens to a real, uncredited - A decision on the fallback case: what happens to a real, uncredited
cameo (see the Germar Terrell Gardner and Talia Balsam cases in the cameo (see the Germar Terrell Gardner and Talia Balsam cases in the
[LVFace deep dive](lvface-deep-dive-2026-07.md#where-lvface-beat-x-ray)) if the [LVFace deep dive](lvface-deep-dive.md#where-lvface-beat-x-ray)) if the
restricted gallery never includes them at all. restricted gallery never includes them at all.
- Regenerating the restricted-gallery cache whenever a title's Jellyfin - Regenerating the restricted-gallery cache whenever a title's Jellyfin
cast list changes. cast list changes.
+46 -54
View File
@@ -17,70 +17,62 @@ 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 present but not visible. This matches Amazon X-Ray's own record for this
second exactly. second exactly.
## The headline: learned scene boundaries 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 current opencv5 build's biggest gain is **flood-fill presence on a ## Findings
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:
| boundary source for flood-fill | presence F1 | <div class="grid cards" markdown>
| ------------------------------ | ----------: |
| 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) - :material-trophy:{ .lg .middle } **[Which model is best?](best-model.md)**
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.
## What the numbers mean, and their limits 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.
Results are not uniform across films, and they should not be. X-Ray's ground - :material-filter:{ .lg .middle } **[Whole vs. cast-restricted gallery](gallery-scope.md)**
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.
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** = Restricting the matcher to a film's credited cast improves F1,
true positive, **red** = false positive, **orange** = unknown, and a recall, and misID rate at once, but is not a shipped runtime feature
**blue** panel lists credited cast present with no visible face. 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>
## Full experiment log ## Full experiment log
- **[Full experiment log (opencv5)](model-bakeoff.md)**: the complete log - **[Full experiment log](model-bakeoff.md)**: the complete log behind the
behind the current build — the ten-knob differential-evolution tuning, the four pages above, including how replaying against cached embeddings
shipped [`src/config.hpp`](https://REPOLINK/src/config.hpp) defaults and inside the same KPN network makes a full model and configuration
where each comes from, the replay architecture that makes a nine-film comparison practical, the full results table, and every caveat. This is
search tractable, and the flood-fill step change. where the shipped [`src/config.hpp`](https://REPOLINK/src/config.hpp)
- **[Learned scene-boundary detector](scene-boundary-detector.md)**: the defaults come from.
features, the model, leave-one-out results, and the two headline films. - **[Service conversion (proposal)](service-conversion.md)**: design
- **[Benchmark — SuperHero](benchmark.md)**: the benchmark harness. sketch for a native idle-GPU worker gated on screen lock, not yet built.
- **[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 ## Reproducing the benchmarks
@@ -1,14 +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.
# Deep dive: LVFace-B Glint360K # Deep dive: LVFace-B Glint360K
LVFace won the model comparison (see [Which model is best?](best-model-2026-07.md)) LVFace won the model comparison (see [Which model is best?](best-model.md))
and is the shipped default embedder. This page reports how it performs in 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, 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 and every distinct case where it names someone who is not in the film's
credited cast. credited cast.
Read [How we score against X-Ray](methodology-2026-07.md) first. X-Ray's ground truth Read [How we score against X-Ray](methodology.md) first. X-Ray's ground truth
is scene-level, not per-frame. A name marked correct in the Offscreen column 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. below is the pipeline correctly reporting scene membership, not a workaround.
@@ -63,7 +61,7 @@ on the 5 films the optimizer never saw:
| macro average | 67.4% | 85.8% | 57.0% | | | | | | macro average | 67.4% | 85.8% | 57.0% | | | | |
The `P` column is misID-weighted (each out-of-film name counts 10x in the The `P` column is misID-weighted (each out-of-film name counts 10x in the
denominator; see [methodology](methodology-2026-07.md#precision-recall-and-the-misid-weighting)). denominator; see [methodology](methodology.md#precision-recall-and-the-misid-weighting)).
That weighting is why Many Saints reads 54.7% here despite naming mostly real, 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 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 entirely its 974 misIDs paying the 10x penalty. The three zero-misID films
@@ -72,7 +70,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 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 between the best and worst held-out film is 37pp. This is not unique to
LVFace: [the full experiment log](model-bakeoff-2026-07.md#held-out-validation-all-3-models) LVFace: [the full experiment log](model-bakeoff.md#held-out-validation-all-3-models)
shows mbf and r18 with the same shape of spread on the same films, at a 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 uniformly lower level. Two mechanisms explain the spread. Both are shown
below with frame-level evidence. below with frame-level evidence.
@@ -176,10 +174,10 @@ ground-truth gap, not a model error.
Archie Yates, t=2521s, 78% confidence. A real detected face, a genuine Archie Yates, t=2521s, 78% confidence. A real detected face, a genuine
lookalike confusion. lookalike confusion.
Zooey Deschanel, t=2819s, 99% confidence — a high-confidence lookalike ![Zooey Deschanel, third out-of-cast name in Many Saints](assets/images/many_saints_fpi_deschanel.jpg)
confusion in the July pipeline. **The current opencv5 pipeline no longer makes
this identification**; the tighter tracker/registry and re-tuned matching removed Zooey Deschanel, t=2819s, 99% confidence. A real detected face at a dinner
it, so there is no annotated frame for it here. table, high-confidence lookalike confusion.
![Talia Balsam, fourth out-of-cast name in Many Saints](assets/images/many_saints_fpi_balsam.jpg) ![Talia Balsam, fourth out-of-cast name in Many Saints](assets/images/many_saints_fpi_balsam.jpg)
-136
View File
@@ -1,136 +0,0 @@
> **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.
+78 -70
View File
@@ -1,9 +1,11 @@
# How we score against X-Ray # How we score against X-Ray
Every number in this report comes from one comparison, and that comparison Every number in this report, every F1 and misID count, comes from one
has a mismatch at its core: the ground truth is scene-level, the pipeline's comparison. The comparison has a mismatch at its core that shapes nearly
output is per-second, and the two do not mean the same thing. This page every finding in this report: the ground truth is scene-level, the
documents the comparison once so the findings can rely on it. 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 ## What Amazon X-Ray records
@@ -11,12 +13,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 timespans), `people_in_scenes.csv` (which actors are credited in each
scene), and `people.csv` (actor identities). There is no per-frame or 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 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 X-Ray records one cast list for the entire span, not "on screen from
12 to second 30." second 12 to second 30."
To compare against per-second predictions, `second_score.py` expands every To compare this against per-second predictions, `second_score.py` expands
scene into per-second ground truth by copying the whole scene's cast list every scene into per-second ground truth by copying the whole scene's cast
onto every second inside it: list onto every second inside it:
```python ```python
for sn, (t0, t1) in spans.items(): for sn, (t0, t1) in spans.items():
@@ -25,46 +27,48 @@ for sn, (t0, t1) in spans.items():
timeline[t] = cast timeline[t] = cast
``` ```
If X-Ray credits five actors to a 30-second scene, all five count as ground That is the entire mechanism. If X-Ray credits five actors to a 30-second
truth present for all 30 seconds, including seconds where only one is on scene, all five count as ground truth present for all 30 seconds, including
screen. This is not a simplification the pipeline introduces; it is the only seconds where only one of them is on screen. This is not a simplification
reading X-Ray's data supports, because X-Ray records nothing finer. 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.
## How the pipeline reports presence ## Why an offscreen name can be scored correct
A presence claim is one actor owning one time window. How that window is A name listed under Offscreen with a correct (green) label is not the
derived is a tunable choice — a knob the optimizer weighs — with two modes: 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.
- **`track_extent` (default).** A claim is exactly `[first_seen, last_seen]` A system that only reported "this actor is visible in this exact frame"
of a track the actor owned (AR-012), ending at the last sighting and never would score worse against X-Ray's scene-level ground truth, producing a
after (AR-013). There is no keep-alive: the withdrawn `anneal_sec` and the false negative every time the camera cuts away from a character who is
scene-tracker `extinction_sec` — which the July report's windows were held still present in the scene. Not because it is wrong about the world, but
open by — are **gone**. A track that survives its own gaps needs no bridge; because it would be answering a stricter, different question than the one
a gap after the final sighting is never claimed. X-Ray's data supports. The presence-window design exists specifically to
- **`flood`.** Each claim is snapped to the shot it sits in, so an actor seen answer X-Ray's actual question.
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.
Do not confuse the surviving `track_extinction_sec` with the withdrawn ## What this resolves and what it does not
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.
## The two limits this does not resolve 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 face-vs-presence ceiling.** X-Ray credits scene membership regardless **The face-vs-presence ceiling.** X-Ray credits scene membership regardless
of whether a face is ever visible: background crew, characters shot from of whether a face is ever visible: background crew, characters shot from
behind, voice-only presence. No face pipeline can recover a face that never behind, voice-only presence. No amount of bridging recovers a face that
appears, so recall against X-Ray is a structural ceiling, not a defect. never appears on screen. This is a hard ceiling on recall, not a defect.
**Flood-fill can overshoot.** Snapping to a shot correctly answers "still in **Extinction bridging can overshoot.** The same presence-window mechanism
this scene" through an intra-scene cut, but a shot boundary is not a scene that correctly answers "still in this scene" during a normal cut can also
boundary: on a film with sparse cuts, flood-fill can carry an actor across a bridge across a scene boundary it has no way to detect. A hard cut into a
long "shot" they only briefly appeared in. This is why flood-fill is a knob, different scene with no faces, such as closing credits, carries the
not a default — its value depends on the film's cut density. 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 ## Precision, recall, and the misID weighting
@@ -73,46 +77,49 @@ Per sampled second `t`:
**TPI** (true positive instances): actors both X-Ray and the pipeline agree **TPI** (true positive instances): actors both X-Ray and the pipeline agree
are present. are present.
**FPI** (false positive instances): actors the pipeline reports that are not **FPI** (false positive instances): actors the pipeline reports that are
in X-Ray's cast for this second, split into: 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 - **FPI_incast**: the actor is in the film's cast, just not credited to
scene. A timing or boundary slip. this particular scene. A timing or boundary slip.
- **FPI_misid**: the actor is not in the film's cast at all. A genuine - **FPI_misid**: the actor is not in the film's cast at all. A genuine
wrong-identity error, weighted **10×** in the precision objective, because wrong-identity error, weighted 10x in the precision objective, because
naming someone not even in the film is categorically worse than a few naming someone who is not even in the film is a categorically worse
seconds of scene-boundary slop. error than a few seconds of scene-boundary slop.
!!! note "Every headline `P` and `F1` is misID-weighted" !!! note "Every headline `P` and `F1` is misID-weighted"
Precision puts each `FPI_misid` into the denominator 10 times 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)`, (`precision = TPI / (TPI + FPI_incast + 10·FPI_misid)`,
[`second_score.py`](https://REPOLINK/scripts/optimizer/second_score.py)). [`second_score.py`](https://REPOLINK/scripts/optimizer/second_score.py)).
This deliberately punishes naming an out-of-film actor far harder than a This is deliberate: the whole point is to punish naming an out-of-film
boundary slip, so the `P` column is not raw precision and a misID-heavy actor far harder than a scene-boundary slip. But it means the `P` column
film's `P` is depressed super-linearly. 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.
**FN** (false negatives): actors X-Ray lists that the pipeline never reports, **FN** (false negatives): actors X-Ray lists that the pipeline never
counted **only** for actors who have a gallery reference embedding. An actor reports, counted only for actors who have a gallery reference embedding.
with no reference photo can never be recognized, and counting them as a miss Across the 9-film benchmark, coverage of X-Ray's credited cast ranges from
would measure gallery coverage, not recognition accuracy. 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.
Two further numbers accompany F1: Two further numbers are reported alongside F1:
**agreement_rate**: mean per-second Jaccard overlap **agreement_rate**: mean per-second Jaccard overlap
(`|Pred ∩ GT| / |Pred GT|`) partial credit, so naming 2 of 3 present (`|Pred ∩ GT| / |Pred GT|`), partial credit. Naming 2 of 3 present actors
actors scores 2/3, not 0. scores 2/3, not 0.
**exact_match_rate**: the fraction of seconds where the pipeline's named set **exact_match_rate**: the fraction of sampled seconds where the pipeline's
exactly equals X-Ray's no partial credit, dominated by recall. named set exactly equals X-Ray's, no partial credit. Far harsher, and
dominated by recall, since any single missed actor zeroes that second.
## 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 ## Reproduce
@@ -122,5 +129,6 @@ python3 scripts/optimizer/second_score.py \
--gallery experiments/galleries/gallery_LVFace-B_Glint360K.h5 --gallery experiments/galleries/gallery_LVFace-B_Glint360K.h5
``` ```
See the [full experiment log](model-bakeoff.md) for how `pred.json` is See also [the full experiment log](model-bakeoff.md) for how `pred.json` is
produced and where the shipped `src/config.hpp` defaults come from. produced, and the [LVFace deep dive](lvface-deep-dive.md) for what these
mechanisms look like frame by frame.
-348
View File
@@ -1,348 +0,0 @@
> **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).
+306 -158
View File
@@ -1,198 +1,346 @@
# Full experiment log (opencv5) # Full experiment log
This is the complete log behind the current opencv5 build: how the pipeline is This page reports how the pipeline performs across three questions: which
tuned, what the shipped configuration is and where every number in it comes from, embedding model is best, whether restricting the gallery to a film's
and how the learned scene-boundary detector took per-second actor-presence F1 from credited cast helps, and whether promoting confidently identified poses into
the low-60s to **74.9%** across the nine-film Amazon X-Ray benchmark — under honest a per-film gallery annex helps. It also documents the replay architecture
leave-one-out. 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.md) first for what F1, precision, Read [How we score against X-Ray](methodology.md) first for what F1,
recall, and misID mean here. Every number below uses the per-second metric precision, recall, and misID mean in this report. All numbers below use the
([`scripts/optimizer/second_score.py`](https://REPOLINK/scripts/optimizer/second_score.py)): per-second metric
the film is sampled once per second, and at each second the set of names the ([`scripts/optimizer/second_score.py`](https://REPOLINK/scripts/optimizer/second_score.py)).
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.
## The benchmark 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
Nine films with public Amazon X-Ray scene data, all scored with the same other three models on the identical source photos (10808 vs 15055 total
LVFace-B Glint360K gallery: embeddings across the same 2418 actors), which confounds any direct
comparison of its scores against the others. It remains in the
Benny & Joon · Café Society · Downton Abbey: A New Era · Lord of War · Lovelace · [calibration curve comparison](best-model.md#first-signal-calibration-curves),
The Many Saints of Newark · Scarface · Sound of Metal · Valerian. which does not depend on the training benchmark.
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 ## Why replay makes this affordable
Decoding video and running face detection, alignment, and embedding is the Decoding video and running face detection, alignment, and embedding is the
expensive part of the pipeline. Everything downstream — tracking, identity expensive part of this pipeline. Everything downstream of that (tracking,
matching, scene aggregation is cheap. KPN++'s node/network structure keeps those identity matching, scene aggregation) is cheap. KPN++'s node/network
two halves as separate components joined by typed channels, so the expensive half structure means those two stages are separate components connected by
runs once per film and caches its output, and the cheap half can be re-run against typed channels, so the expensive stage can run once per film, cache its
that cache as often as needed with different `Config` values. 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 and writes `scene_analyze --dump-embeddings out.h5` runs the expensive half once per
per-frame detections, embeddings, and (for the scene detector) per-frame RGB film and writes per-frame face detections and embeddings to HDF5
histograms to HDF5. [`scripts/optimizer/replay.py`](https://REPOLINK/scripts/optimizer/replay.py) ([`scripts/optimizer/SCHEMA.md`](https://REPOLINK/scripts/optimizer/SCHEMA.md)).
re-assembles the real C++ `face_tracker`, `identity_matcher`, and scene nodes into [`scripts/optimizer/replay.py`](https://REPOLINK/scripts/optimizer/replay.py)
a Python-driven KPN network and replays a film's cache through them, varying every then re-assembles the real C++ `face_tracker`, `identity_matcher`, and
tuning knob freely. No GPU inference and no video decode happen during a replay, so `scene_tracker` nodes into a Python-driven KPN network and replays a
a full differential-evolution search over all nine films is tractable in one film's cached embeddings through them, varying `prob_threshold`,
session rather than requiring re-encodes. `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.
Two concurrency limits are load-bearing and were paid for in wedged runs: replays `optimize.py` runs `differential_evolution` over this replay function as its
run at `DE_WORKERS=1` (concurrent DE candidates wedge the ROCm GPU), and each objective, with DE-level parallelism (multiple candidate configs evaluated
candidate's per-film replays run at `REPLAY_WORKERS=8` with stderr discarded (the concurrently, each spawning its own replay subprocesses) on top of it. The
replay sink's per-second prints otherwise flood the captured pipe and hang the practical ceiling on this machine's GPU was 8 concurrent replay processes;
subprocess). 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`.
## The tuning knobs ## Search space
The opencv5 refactor replaced the old three-knob search with a **ten-knob** `popsize=10, maxiter=15` per combo (3 parameters, up to 512 evaluations,
differential-evolution sweep. The knobs, and their shipped values: 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.
| knob | shipped | what it controls | ## Training films and held-out films
| ---- | ------: | ---------------- |
| `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 |
The DE run over the first nine knobs (flood off, track-extent presence) converged 9 films have dumped embeddings across all 4 models. 4 were used for
at **64.0% macro F1** over 345 evaluations. Those values are the shipped optimization:
[`src/config.hpp`](https://REPOLINK/src/config.hpp) defaults.
![10-knob presence sweep (Differential Evolution)](assets/images/de_search_landscape.png) - Café Society (62-cast)
- Lord of War (64-cast)
- Scarface (67-cast)
- Sound of Metal (14-cast)
The `track_extinction_sec` knob is worth calling out: at 31 s it holds an idle 5 were held out, never seen by any optimizer run:
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.
## The step change: flood-fill on learned boundaries - Benny & Joon
- Downton Abbey: A New Era
- Lovelace
- The Many Saints of Newark
- Valerian and the City of a Thousand Planets
The 64.0% above is track-extent presence: an actor is reported only while an actual ## Gallery coverage per film
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.
With the old grayscale cut detector as the boundary source, flood-fill barely beat The gallery has reference embeddings for 2418 actors, but coverage of any
doing nothing (**64.0%**) and actively broke Scarface, where the detector fires given film's credited cast varies widely. This was previously reported as
once in 10,204 frames and flood then smears every actor across the whole film one flat number (67% of X-Ray cast lacking a reference embedding, averaged
(precision collapses to 26%). across the whole benchmark); the per-film breakdown is:
The [learned scene-boundary detector](scene-boundary-detector.md) — an XGBoost | film | cast credited | in gallery | coverage |
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: | 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% |
| boundary source for flood-fill | presence F1 | 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
| track-extent (flood off) | 62.6% | missing references, not purely by model quality. Downton Abbey has 61%
| flood + grayscale cuts | 64.0% | coverage, the second-best in the benchmark, yet the worst held-out recall
| **flood + learned detector (LOO)** | **74.9%** | 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`.
![Macro presence F1 by flood-fill boundary source](assets/images/scene_presence_macro.png) ## Training results, 3 models × 2 gallery modes × 2 expansion settings
The learned column is **leave-one-out**: each film is scored by a detector trained Ranked by F1. misid = FPI_misid, the count of true wrong-actor
on the other eight, so no film's presence is ever measured with a detector that saw identifications (naming someone not in the film's cast at all), distinct
it. That is the honest generalisation number, +12.3 points over track-extent, and from FPI, which also includes in-cast timing slips.
**it improves every one of the nine films**.
![Per-film presence F1 by boundary source](assets/images/scene_presence_by_source.png) 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).
| film | track-extent | flood+grayscale | flood+learned (LOO) | | combo | F1 | P | R | TPI | FPI | misid | FN |
| ---- | -----------: | --------------: | ------------------: | |---|---|---|---|---|---|---|---|
| Benny & Joon | 77.3 | 80.2 | 78.2 | | LVFace-B_Glint360K_restricted_exp | 78.3% | 91.0% | 68.9% | 42830 | 3782 | 60 | 19492 |
| Café Society | 59.1 | 62.2 | 69.8 | | LVFace-B_Glint360K_restricted_noexp | 76.7% | 91.5% | 66.2% | 41149 | 3400 | 59 | 21173 |
| Downton Abbey | 41.0 | 51.8 | **78.6** | | arcface_w600k_mbf_restricted_exp | 76.2% | 90.0% | 66.2% | 64328 | 7480 | 0 | 33234 |
| Lord of War | 74.8 | 77.1 | 77.8 | | arcface_r18_restricted_exp | 75.5% | 87.6% | 66.5% | 41399 | 5666 | 60 | 20923 |
| Lovelace | 70.3 | 74.0 | 78.2 | | LVFace-B_Glint360K_full_exp | 75.3% | 89.7% | 65.4% | 47757 | 3407 | 232 | 26966 |
| The Many Saints of Newark | 37.5 | 43.9 | 53.4 | | arcface_w600k_mbf_restricted_noexp | 75.0% | 91.1% | 63.9% | 39752 | 3465 | 60 | 22570 |
| Scarface | 62.6 | **40.9** | **74.9** | | arcface_r18_restricted_noexp | 73.5% | 91.3% | 61.7% | 38299 | 3220 | 60 | 24023 |
| Sound of Metal | 75.0 | 78.1 | 86.8 | | LVFace-B_Glint360K_full_noexp | 72.3% | 88.3% | 61.8% | 40363 | 3503 | 244 | 25850 |
| Valerian | 65.6 | 67.7 | 76.2 | | 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 |
The two headline films — Scarface (grayscale flood *breaks* it, learned flood on a ![All combos ranked by training-set F1](assets/images/rep4_matrix_f1.png)
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.
We re-ran the ten-knob DE on top of the good boundaries to check whether the The two clearest patterns: every model's best-scoring combo uses the
shipped config should change. It converged at 76.1% (+0.3 pp over the shipped restricted gallery, and LVFace leads within both gallery modes. `full_exp`
config on learned boundaries) — inside the noise, not worth re-shipping. The (the shipped combination) is the best-scoring option that uses only
boundaries, not the presence knobs, are where the win is. features the running application currently supports; restriction is not
wired into the application yet (see
[Whole vs. cast-restricted gallery](gallery-scope.md)).
## What the frames look like ### A scoring bug worth recording: dropped-film evaluations
`scripts/optimizer/dump_error_frames.py` pulls representative seconds and draws The numbers above are corrected ones. The raw `rep4_best_*.json` files, and an
each face box against X-Ray's scene cast: **green** = true positive, **red** = earlier version of this table, reported a different `arcface_w600k_mbf_full_noexp`
false positive (a name X-Ray does not credit to this scene — the real error), row: **74.2% F1 at TPI 12645**, a third the TPI of every sibling combo. That was
**orange** = an unknown detection. Cast X-Ray lists as present but for whom no face not a better config; it was an artifact of how the optimizer aggregates.
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/lovelace_perfect_second.jpg) `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.
Every named frame in this documentation is regenerated against the current opencv5 The fix here was to re-derive each combo's best row from its DE trajectory
pipeline by [`scripts/scene_detector/rematch_frames.py`](https://REPOLINK/scripts/scene_detector/rematch_frames.py), (`experiments/trajectories/rep4_*.jsonl`), keeping only evaluations within 30% of
which auto-matches each example by film, actor, and class (TP/FP) so the images that combo's median TPI (full 4-film coverage) before taking the best F1. This
never drift from the shipped behaviour. Where the current pipeline no longer makes needs no re-running, the honest best configuration was already in the sweep,
a July-era error — the Zooey Deschanel misID in Many Saints is the clearest case — just not the one `argmax F1` selected. Three combos moved: `mbf_full_noexp`
the frame is dropped rather than staged, because the improvement is real. 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.
## The structural recall ceiling ### Per-film training breakdown
Precision against X-Ray is near-perfect on identified faces; recall is capped by The 75.3% LVFace training figure is a macro average across 4 films, not a
two things the pipeline cannot fix: uniform result:
1. **X-Ray credits people whose faces never appear on screen** in a scene — voice, | film | LVFace F1 | mbf F1 | r18 F1 | best model |
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. | Café Society | 68.1% | 62.2% | 60.1% | LVFace |
2. **Gallery coverage.** A large fraction of X-Ray cast has no reference image in | Lord of War | 75.6% | 77.2% | 75.6% | mbf |
the gallery, so those actors can never be matched regardless of detection. This | Scarface | 71.5% | 68.6% | 64.1% | LVFace |
is the dominant remaining recall limiter and is addressable by fetching more | Sound of Metal | 78.8% | 76.5% | 71.6% | LVFace |
reference photos, not by tuning.
Both are documented in [how we score against X-Ray](methodology.md). 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`.
## In the pipeline ## Held-out validation, all 3 models
The learned detector runs live inside `scene_analyze` as a post-EOF step (the The training matrix above is training-set fit. Each model's own tuned
per-film knee needs every peak, so it can only run once the whole film is seen). `full_exp` config was replayed against the 5 held-out films, scored the
XGBoost inference is built into the binary via CMake (`SAE_SCENE_XGB`); the audio same way:
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. | film | LVFace F1 | mbf F1 | r18 F1 |
Verified end to end through `scene_analyze` on a movie file and through the Jellyfin |---|---|---|---|
work-queue worker. | 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
```bash ```bash
scene_analyze --movie <file> --gallery <gallery.h5> \ # 4-film training matrix, all 4 models × 2 gallery modes × 2 expansion settings
--scene-xgb-model models/scene_boundary_xgb.json 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
``` ```
## Reproducing the benchmarks See also the session log
[`experiments/SESSION_STATE.md`](https://REPOLINK/experiments/SESSION_STATE.md).
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
```
+2 -5
View File
@@ -255,11 +255,8 @@ Context crops opt-in behind `--dump-unidentified-crops`.
## AR-026, AR-027 — GEMM and scale ## AR-026, AR-027 — GEMM and scale
**Depends on:** nothing to start. The annex CPU loop has moved into the GEMM **Depends on:** nothing to start. The annex CPU loop
path: the annex is a contiguous matrix, promotions are appended to the engine's (`identity_matcher_node.hpp:159-162`) moves into the GEMM path.
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.
--- ---
@@ -1,5 +1,3 @@
> **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? # Pose expansion: does promoting new poses mid-film help?
`expand_gallery` `expand_gallery`
@@ -14,7 +12,7 @@ in the same film, without touching the baked gallery.
Averaged across the 3 compared models (r50 excluded), on the 4 films used Averaged across the 3 compared models (r50 excluded), on the 4 films used
for optimization. These are the corrected, full-coverage figures, see the for optimization. These are the corrected, full-coverage figures, see the
[dropped-film note](model-bakeoff-2026-07.md#a-scoring-bug-worth-recording-dropped-film-evaluations) [dropped-film note](model-bakeoff.md#a-scoring-bug-worth-recording-dropped-film-evaluations)
in the experiment log for why an earlier version of this table overstated the 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: full-mode misID jump (209 → 864) that was itself partly a truncation artifact:
@@ -28,7 +26,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 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: 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 +2.1pp F1, +3.9pp recall, but misID rises from 407 to 714. See
[the full experiment log](model-bakeoff-2026-07.md) for the per-model breakdown. [the full experiment log](model-bakeoff.md) for the per-model breakdown.
This asymmetry motivated the question below: does turning expansion on This asymmetry motivated the question below: does turning expansion on
change what gets recognized frame by frame, or is the aggregate F1 shift change what gets recognized frame by frame, or is the aggregate F1 shift
coming from something else. coming from something else.
@@ -107,6 +105,6 @@ contribution, such as tagging which reference embedding won each match;
neither was in scope for this pass. neither was in scope for this pass.
Do not treat the training-set exp/noexp numbers in Do not treat the training-set exp/noexp numbers in
[the full experiment log](model-bakeoff-2026-07.md) as proof that expansion changes [the full experiment log](model-bakeoff.md) as proof that expansion changes
real-world behavior in either direction. On the evidence gathered so far, real-world behavior in either direction. On the evidence gathered so far,
it does not move the needle enough to see. it does not move the needle enough to see.
+27 -52
View File
@@ -29,47 +29,47 @@ Status: `Done` · `In Progress` · `Planned` · `TBD` · `Withdrawn`
| ID | Requirement | Traces to | Priority | Status | | 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-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 **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-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 | Planned |
| 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-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-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. **Gap:** a rare hang survives, ~1 run in 20 at a 300 s timeout (was: every run). `FanoutNode` still drops on overflow (`fanout.hpp:129`) rather than parking, so the AR-010 scene join sheds frames exactly when the dense branch falls behind |
| 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-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-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 | **Done**`track_alpha` is the base for ordinary frames; drops to embedding-only on cut/boundary and for dormant tracks | | 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-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-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 | **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-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-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 | **Done**`src/track_registry.hpp`; window is `[first_seen, last_seen]` of an owned track | | 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-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-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-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-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-017 | Every presence claim carries its belief and identification route | SR-002 | High | **Done**`DeadTrack` carries belief and observation count |
| 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-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-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 |
| AR-020 | Deferred re-identification of unknown tracks against the final expanded gallery | SR-005 | High | Planned | | 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-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-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** — 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-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 | **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-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 |
| 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-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` |
| 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-026 | All similarity computed as GEMM, including annex and deferred pass | SR-001 | High | In Progress |
| AR-027 | Throughput acceptable for **arbitrary** gallery size | SR-001 | High | Planned | | 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-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 | Planned |
| 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-029 | Sharpness measure on the **aligned crop** (scale-normalised, so it cannot re-measure size) | SR-002 | Medium | Planned |
| 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 | | 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) ## Deployment (DP)
| ID | Requirement | Traces to | Priority | Status | | 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, 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-001 | One analysis core; modes are front-ends and must not fork pipeline logic | PR-004 | High | Done |
| DP-002 | Batch CLI over one title | PR-004 | High | Done | | 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-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-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-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-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-007 | CI builder image, CPU-only, pinned by tag in the Gitea container registry | PR-004 | High | Planned |
| DP-008 | Builder images + release jobs per backend (cpu / cuda / rocm); ship binaries, not engines | PR-004 | Medium | Planned | | DP-008 | Builder images + release jobs per backend (cpu / cuda / rocm); ship binaries, not engines | PR-004 | Medium | Planned |
## Integration (IR) ## Integration (IR)
@@ -79,7 +79,7 @@ Status: `Done` · `In Progress` · `Planned` · `TBD` · `Withdrawn`
| IR-001 | Emit the JRay truth format as sibling `.jray.json` | SR-003 | High | Done | | 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 | **Done**`schema_version: 2`; windows are objects with `belief` + `route`; `extraction.*` carries `extinction_sec` and `gallery_scope`; `anneal_sec` removed | | 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-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-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) |
| 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-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-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-008 | Emit and honour the signature's own `v1:` version prefix | SR-003 | Low | **Done** |
@@ -104,22 +104,19 @@ Status: `Done` · `In Progress` · `Planned` · `TBD` · `Withdrawn`
| ID | Requirement | Traces to | Priority | Status | | ID | Requirement | Traces to | Priority | Status |
|---|---|---|---|---| |---|---|---|---|---|
| VR-001 | HDF5 post-inference dump at the embedded-frame boundary | PR-002 | High | Done | | 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**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-002 | Replay drives the **real** KPN nodes, not a reimplementation | PR-002 | High | **Done**replay driven from committed fixtures in `tests/test_replay_fixtures.cpp`; determinism asserted |
| VR-003 | Scoring: micro-F1 against X-Ray, precision/recall logged at every evaluation | PR-002 | High | Done | | 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-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 | **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-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-006 | Re-tune `scene_threshold` once native-rate decode lands | PR-002 | Low | Planned |
| 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-007 | Expansion band, clustering threshold, and deferred-pass ablation | PR-002 | Medium | Planned |
| VR-008 | Gallery scaling benchmark — throughput vs gallery size | PR-002 | Medium | Planned | | 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-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-010 | Dump provenance attributes — embedder model, detector settings, `dense_scale`, `scene_detect`, sample rate | PR-002 | **High** | Planned |
| 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-011 | Rewrite the replay harness for the post-AR-012 output contract | PR-002 | High | Planned |
| 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-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-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-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) |
--- ---
@@ -315,7 +312,7 @@ because it will be trusted.
| AR-001 | T3 | Detector returns plausible boxes on a known frame | — smoke only | | AR-001 | T3 | Detector returns plausible boxes on a known frame | — smoke only |
| 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-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-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. 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-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; 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-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-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-007 | T2 | Association picks the right track | Two faces crossing paths; one leaving frame as another enters |
@@ -327,23 +324,22 @@ 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-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-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-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 — 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-017 | T1 | Claim carries posterior and route | Deferred and pooled routes distinguishable |
| AR-018 | T1 | Band admits only within bounds | At each bound exactly; store never admits below lower bound | | 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-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-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-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-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`, 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-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` | `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-024 | **Static check** | No bare cosine outside a tagged `EXCEPTION` | Grep-based; this is the invariant's enforcement |
| AR-025 | T1 | Log-odds accumulate; correlated frames discounted | 30 identical frames must **not** reach the certainty of 30 diverse ones | | 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-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-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-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 — scored, not silently vanished |
| 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-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 |
| 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 | | 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-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-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-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-003 | T1 | Output written after deferred pass | Not at EOF |
| IR-004/005 | **T1** | Signature matches golden vector bit-for-bit | Identical result in both producer repos | | IR-004/005 | **T1** | Signature matches golden vector bit-for-bit | Identical result in both producer repos |
@@ -372,29 +368,8 @@ 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 | | — | `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 | | — | `extinction_sec` actor keep-alive | Superseded by AR-013: windows end at last sighting, which is what this over-claimed |
Both are now deleted rather than retained at zero — a field naming a mechanism Both were deleted rather than retained at zero — a field naming a mechanism the
the pipeline no longer has is actively misleading (see `SPEC.md` A6.6). 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
@@ -1,205 +0,0 @@
# 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
```
+181 -582
View File
File diff suppressed because it is too large Load Diff
-54
View File
@@ -1,54 +0,0 @@
#!/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
@@ -1,44 +0,0 @@
#!/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
@@ -1,60 +0,0 @@
#!/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"
+1 -1
+5 -9
View File
@@ -35,17 +35,13 @@ extra_css:
nav: nav:
- Home: index.md - Home: index.md
- How We Score Against X-Ray: methodology.md - How We Score Against X-Ray: methodology.md
- Learned Scene-Boundary Detector: scene-boundary-detector.md - Findings:
- Benchmark — SuperHero: benchmark.md - 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
- Full Experiment Log: model-bakeoff.md - Full Experiment Log: model-bakeoff.md
- Service Conversion (proposal): service-conversion.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: markdown_extensions:
- admonition - admonition
File diff suppressed because one or more lines are too long
-118
View File
@@ -1,118 +0,0 @@
#!/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
@@ -1,225 +0,0 @@
#!/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())
+2 -3
View File
@@ -79,9 +79,8 @@ def main():
"--dump", str(dump), "--gallery", str(gallery), "--dump", str(dump), "--gallery", str(gallery),
"--out", str(pred_path), "--out", str(pred_path),
"--prob-threshold", str(cfg["prob_threshold"]), "--prob-threshold", str(cfg["prob_threshold"]),
# anneal_sec and extinction_sec are both gone: presence is "--anneal-sec", str(cfg["anneal_sec"]),
# the registry's, built from track extents (AR-012/AR-013), and "--extinction-sec", str(cfg["extinction_sec"]),
# replay.py no longer windows anything itself (VR-011).
"--expand-gallery", "--expand-gallery",
] ]
print(f"RUN {model}/{film['slug']}...", file=sys.stderr) print(f"RUN {model}/{film['slug']}...", file=sys.stderr)
+4 -49
View File
@@ -1,4 +1,4 @@
# Embedding-dump HDF5 schema (v2) # Embedding-dump HDF5 schema (v1)
One file per analysed title. Captures the pipeline state at the `EmbeddedSceneFrame` One file per analysed title. Captures the pipeline state at the `EmbeddedSceneFrame`
channel — i.e. after decode → detect → align → embed, but **before** tracking and channel — i.e. after decode → detect → align → embed, but **before** tracking and
@@ -18,7 +18,7 @@ variable-length HDF5 types and reads straight into numpy.
``` ```
/ (root) / (root)
attrs: attrs:
schema_version : int = 2 schema_version : int = 1
embed_dim : int = 512 embed_dim : int = 512
# ── what produced the vectors (GR-004) ────────────────────────────────── # ── what produced the vectors (GR-004) ──────────────────────────────────
@@ -60,12 +60,6 @@ variable-length HDF5 types and reads straight into numpy.
landmarks : float32 [N, 10] 5 (x,y) pairs, SCRFD/ArcFace order, landmarks : float32 [N, 10] 5 (x,y) pairs, SCRFD/ArcFace order,
same space as bbox same space as bbox
confidence : float32 [N] detector confidence 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). `F` = number of sampled frames, `N` = total faces (= sum of face_count).
@@ -93,9 +87,8 @@ exactly the fact the committed fixtures needed to state.)
Reading is by name with a default or an existence check on **both** sides — Reading is by name with a default or an existence check on **both** sides —
`replay.py` (`f.attrs.get(...)`) and `read_dump_provenance()` in `replay.py` (`f.attrs.get(...)`) and `read_dump_provenance()` in
`src/nodes/embedding_dump_node.hpp` (`attrExists`). So the attributes are `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 additive and `schema_version` stays 1: a pre-VR-010 dump still loads, and a
still loads, and a post-VR-010 dump still reads on old code. (AR-028 later took post-VR-010 dump still reads on old code.
it to 2 by adding *datasets* — see below.)
A missing attribute means **unknown**, never a default value. Substituting 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 `detector_conf = 0.5` for a dump that does not say so manufactures the provenance
@@ -104,40 +97,6 @@ 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 The committed `tests/fixtures/dumps/*.h5` predate VR-010 and carry none of these
attributes; re-dump to bind them, as with GR-004. 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) ## Model binding (GR-004)
`embedder_model` / `embedder_sha256` record which embedder produced every vector `embedder_model` / `embedder_sha256` record which embedder produced every vector
@@ -185,7 +144,3 @@ one never received. Two further reasons:
original resolution (see above). original resolution (see above).
- A frame with no faces has `face_count == 0` (still gets a row, so timestamps stay dense). - A frame with no faces has `face_count == 0` (still gets a row, so timestamps stay dense).
- EOF sentinel frames are NOT written. - 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.
+12 -35
View File
@@ -121,44 +121,23 @@ def load_raw_annotations(raw_path: str):
return by_second return by_second
def _name_key(name: str) -> str: def draw_annotations(frame_path: Path, actors: list):
"""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)) img = cv2.imread(str(frame_path))
if img is None: if img is None:
return 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: for a in actors:
known = a.get("actor_idx", -1) >= 0 known = a.get("actor_idx", -1) >= 0
if known: colour = (60, 200, 0) if known else (220, 100, 0) # BGR: green / orange
colour = RED if _name_key(a["name"]) in fp_keys else GREEN x, y, w, h = a["bbox"]
label = f"{a['name']} {a['similarity']*100:.0f}%" x, y, w, h = int(x), int(y), int(w), int(h)
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) cv2.rectangle(img, (x, y), (x + w, y + h), colour, 2)
(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) label = f"{a['name']} {a['similarity']*100:.0f}%" if known else f"unknown {a['similarity']*100:.0f}%"
cv2.putText(img, label, (x+2, y-2), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (tw, th), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
(255,255,255), 1, cv2.LINE_AA) strip_y0 = max(0, y - th - 4)
# FN: X-Ray cast present with no detected face — no box exists, so list them. cv2.rectangle(img, (x, strip_y0), (x + tw + 4, y), colour, cv2.FILLED)
fn = [n for n in (fn_names or []) if n] cv2.putText(img, label, (x + 2, y - 2), cv2.FONT_HERSHEY_SIMPLEX, 0.5,
if fn: (255, 255, 255), 1, cv2.LINE_AA)
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) cv2.imwrite(str(frame_path), img)
@@ -204,9 +183,7 @@ def main():
extract_frame(args.movie, r["t"], out_path) extract_frame(args.movie, r["t"], out_path)
ok = True ok = True
if raw_by_second is not None: if raw_by_second is not None:
fp_keys = {_name_key(n) for n in r["fp"]} draw_annotations(out_path, raw_by_second.get(r["t"], []))
draw_annotations(out_path, raw_by_second.get(r["t"], []),
fp_keys=fp_keys, fn_names=r["fn"])
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
ok = False ok = False
print(f"[dump_error_frames] ffmpeg failed at t={r['t']}: {e}", file=sys.stderr) print(f"[dump_error_frames] ffmpeg failed at t={r['t']}: {e}", file=sys.stderr)
+4 -36
View File
@@ -17,7 +17,7 @@ point from the trajectory (--trajectory).
Usage: Usage:
python scripts/optimizer/optimize.py --manifest films.json \ python scripts/optimizer/optimize.py --manifest films.json \
--gallery gallery_arcface_w600k_r50.json \ --gallery gallery_arcface_w600k_r50.json \
--params prob_threshold:0.5:0.999 ownership_logodds:0.5:4 track_alpha:0:1 \ --params prob_threshold:0.5:0.999 anneal_sec:1:30 extinction_sec:1:15 \
--popsize 20 --maxiter 25 --trajectory traj.json --popsize 20 --maxiter 25 --trajectory traj.json
""" """
from __future__ import annotations from __future__ import annotations
@@ -61,15 +61,7 @@ from replay import dump_embedder_stamp # noqa: E402
from sae_stamp import EmbedderMismatch, verify_gallery_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 _GAL_KEYS: dict = {} # gallery path → key set (fair-recall FN mask), loaded once
# Seconds per film before a replay is killed. Its ONLY job is to escape the rare, _REPLAY_TIMEOUT = 45 # seconds per film; a wedged replay is killed, not left to hang
# 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") REPLAY_CLI = str(Path(__file__).resolve().parent / "replay.py")
@@ -99,17 +91,7 @@ def _replay_subprocess(dump, gallery, cfg, build_dir):
else: else:
argv += [f"--{k.replace('_', '-')}", str(v)] argv += [f"--{k.replace('_', '-')}", str(v)]
try: try:
# Discard the child's stdout/stderr rather than capture it. replay's sink subprocess.run(argv, timeout=_REPLAY_TIMEOUT, capture_output=True, check=True)
# 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()) return _json.loads(Path(out).read_text())
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, except (subprocess.TimeoutExpired, subprocess.CalledProcessError,
FileNotFoundError, ValueError) as e: FileNotFoundError, ValueError) as e:
@@ -247,20 +229,6 @@ def main():
cfg = {} cfg = {}
for k, v in zip(names, x): for k, v in zip(names, x):
cfg[k] = int(round(v)) if k in int_knobs else float(v) 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 return cfg
def objective(x): def objective(x):
@@ -271,7 +239,7 @@ def main():
rec = {"eval": evals[0], "config": cfg, **m, "t": round(time.time() - t0, 1)} rec = {"eval": evals[0], "config": cfg, **m, "t": round(time.time() - t0, 1)}
traj.append(rec) traj.append(rec)
print(f"[opt] eval {evals[0]:3d} thr={cfg['prob_threshold']:.2f} " print(f"[opt] eval {evals[0]:3d} thr={cfg['prob_threshold']:.2f} "
f"own={cfg.get('ownership_logodds', float('nan')):.2f}" f"ann={cfg['anneal_sec']:.0f} ext={cfg['extinction_sec']:.1f}"
f"F1={m['f1']*100:.1f}% P={m['precision']*100:.1f}% R={m['recall']*100:.1f}% " 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)}", f"agree={m.get('agreement', 0)*100:.1f}% misID={m.get('FPI_misid', 0)}",
file=sys.stderr) file=sys.stderr)
+100 -217
View File
@@ -2,22 +2,18 @@
""" """
replay.py replay a dumped embedding HDF5 through the real KPN downstream nodes. replay.py replay a dumped embedding HDF5 through the real KPN downstream nodes.
TRACES: VR-002, VR-011 | PR-002 TRACES: VR-002 | PR-002
Reads an embedding dump (scripts/optimizer/SCHEMA.md), feeds each frame as an Reads an embedding dump (scripts/optimizer/SCHEMA.md), feeds each frame as an
EmbeddedSceneFrame into a Python-assembled KPN network wiring the *real* C++ EmbeddedSceneFrame into a Python-assembled KPN network wiring the *real* C++
face_tracker identity_matcher frame_annotation result_sink, and reads back face_tracker identity_matcher scene_tracker, and returns the same presence-window
the truth file that sink wrote. No decode, no GPU embedding only the cheap JSON that scene_analyze's result_sink produces (minimal schema). No decode, no GPU
downstream tail runs, so a sweep can vary Config knobs freely. embedding only the cheap downstream tail runs, so a sweep can vary Config knobs
freely. See [[kpn-python-replay-optimizer]].
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: CLI:
python scripts/optimizer/replay.py --dump film.h5 --gallery gallery.json \ python scripts/optimizer/replay.py --dump film.h5 --gallery gallery.json \
--out replayed.json [--prob-threshold 0.99] [--track-extinction-sec 5] ... --out replayed.json [--prob-threshold 0.99] [--anneal 10] ...
""" """
from __future__ import annotations from __future__ import annotations
@@ -61,27 +57,12 @@ def load_frames(dump_path: str, min_conf: float = 0.0):
ts = f["frames/timestamp_sec"][:] ts = f["frames/timestamp_sec"][:]
fidx = f["frames/frame_idx"][:] fidx = f["frames/frame_idx"][:]
cut = f["frames/is_cut"][:] 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"][:] off = f["frames/face_offset"][:]
cnt = f["frames/face_count"][:] cnt = f["frames/face_count"][:]
emb = f["faces/embedding"][:] emb = f["faces/embedding"][:]
bbox = f["faces/bbox"][:] bbox = f["faces/bbox"][:]
lmk = f["faces/landmarks"][:] lmk = f["faces/landmarks"][:]
conf = f["faces/confidence"][:] 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", "") movie = f.attrs.get("movie", "")
fps = float(f.attrs.get("sample_fps", 1.0)) fps = float(f.attrs.get("sample_fps", 1.0))
@@ -95,56 +76,37 @@ def load_frames(dump_path: str, min_conf: float = 0.0):
sel = np.where(m)[0] sel = np.where(m)[0]
frames.append({ frames.append({
"timestamp_sec": float(ts[i]), "frame_idx": int(fidx[i]), "timestamp_sec": float(ts[i]), "frame_idx": int(fidx[i]),
"is_cut": bool(cut[i]), "is_scene_boundary": bool(scb[i]), "eof": False, "is_cut": bool(cut[i]), "eof": False,
"bbox": np.ascontiguousarray(bbox[keep][sel], dtype=np.float32), "bbox": np.ascontiguousarray(bbox[keep][sel], dtype=np.float32),
"landmarks": np.ascontiguousarray(lmk[keep][sel], dtype=np.float32), "landmarks": np.ascontiguousarray(lmk[keep][sel], dtype=np.float32),
"confidence": np.ascontiguousarray(c[sel], dtype=np.float32), "confidence": np.ascontiguousarray(c[sel], dtype=np.float32),
"embeddings": np.ascontiguousarray(emb[keep][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: else:
frames.append({ frames.append({
"timestamp_sec": float(ts[i]), "frame_idx": int(fidx[i]), "timestamp_sec": float(ts[i]), "frame_idx": int(fidx[i]),
"is_cut": bool(cut[i]), "is_scene_boundary": bool(scb[i]), "eof": False, "is_cut": bool(cut[i]), "eof": False,
"bbox": np.ascontiguousarray(bbox[keep], dtype=np.float32), "bbox": np.ascontiguousarray(bbox[keep], dtype=np.float32),
"landmarks": np.ascontiguousarray(lmk[keep], dtype=np.float32), "landmarks": np.ascontiguousarray(lmk[keep], dtype=np.float32),
"confidence": c, "confidence": c,
"embeddings": np.ascontiguousarray(emb[keep], dtype=np.float32), "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 last_ts = float(ts[-1]) if len(ts) else 0.0
frames.append({"timestamp_sec": last_ts, "eof": True}) frames.append({"timestamp_sec": last_ts, "eof": True})
return frames, str(movie), fps return frames, str(movie), fps
def replay(dump_path: str, gallery: str, cfg: dict, build_dir: str, def replay(dump_path: str, gallery: str, cfg: dict, build_dir: str, stop: bool = True,
out_path: str, stop: bool = True, raw_out: str | None = None, raw_out: str | None = None) -> dict:
eof_timeout: float = 300.0) -> dict: """Run the dump through the real KPN chain; return minimal-schema presence JSON.
"""Run the dump through the real KPN chain and return the truth file it wrote.
TRACES: VR-011, VR-002 | PR-002 cfg may include "detector_conf" to prune dumped detections below that confidence
(upward-only from the 0.5 dump floor) before matching.
`out_path` is where the C++ sink writes. That is the change VR-011 makes: raw_out: if set, also write the raw per-frame annotations (timestamp, actor_idx,
the presence windows in that file are built by ResultSinkFunc from name, bbox, similarity one entry per input frame, before merging into windows)
TrackRegistry claims -- the extent of a track an actor owned (AR-012), as JSON lines to this path. Needed to draw bounding boxes on extracted frames;
ending at the last sighting (AR-013) -- and are byte-for-byte the same the merged window schema returned by this function has no per-frame bbox."""
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) sys.path.insert(0, build_dir)
import sae_kpn import sae_kpn
@@ -178,171 +140,101 @@ def replay(dump_path: str, gallery: str, cfg: dict, build_dir: str,
time.sleep(0.05) time.sleep(0.05)
return eof return eof
# TRACES: VR-011 | AR-004 | PR-002 # Channel capacity must exceed the frame count so the fast source can't overflow
# Purely a throughput and memory choice, and that is the point: the answer # a downstream FIFO before the serial reader drains it — PyNode DROPS on overflow,
# must not depend on it. It used to be `len(frames) * 2 + 64` -- the whole # which would silently truncate the replay. Size to the whole film + slack.
# film -- to dodge a PyNode overflow drop that AR-004 has since replaced # Every channel gets capacity ≥ the whole film so NOTHING can ever overflow-drop:
# with parking. # 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);
# Removing backpressure that way was catastrophic and silent. The registry # correctness is not. Generous slack on top.
# reaped on the TRACKER's clock while evidence arrived later from the cap = len(frames) * 2 + 64
# 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_node_python(net, "replay", source, [], ["EmbeddedSceneFrame"], cap)
sae_kpn.add_face_tracker(net, "tracker", cfg, cap)
# TRACES: VR-011, VR-002 | DP-001 | PR-002 sae_kpn.add_identity_matcher(net, "matcher", gallery, cfg, cap,
# One call builds tracker -> matcher -> annotation -> sink in the only order stamp["model_name"], stamp["model_sha256"])
# that works (the matcher fits the calibration the tracker needs, and the sae_kpn.add_scene_tracker(net, "scene", cfg, cap)
# 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("replay", 0, "tracker", 0)
net.connect("tracker", 0, "matcher", 0) net.connect("tracker", 0, "matcher", 0)
net.connect("matcher", 0, "annotation", 0) net.connect("matcher", 0, "scene", 0)
net.connect("annotation", 0, "sink", 0)
net.build() net.build()
net.start() net.start()
# The sink writes on the EOF annotation. Wait for it rather than reading # Read exactly one annotation per input frame. The source emits EOF as an ordinary
# anything back through the seam: presence is the registry's answer, and the # value AFTER the last frame, but the concurrent pipeline lets that EOF OVERTAKE
# registry lives entirely on the C++ side. # 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
# This replaces a read loop that pulled one SceneAnnotation per input frame # keep reading past eof until we've collected all n_frames annotations (or hit a
# and rebuilt windows in Python. That loop needed a heuristic -- "keep # run of consecutive eofs meaning the pipeline is genuinely drained).
# reading past eof until we've collected all n_frames annotations, or hit a n_expected = len(frames) - 1 # excludes the trailing eof frame
# run of 8 consecutive eofs" -- to work around a tail it was losing. None of annotations = []
# that exists now: nothing is read per frame, so nothing can be lost per eof_streak = 0
# frame. max_reads = n_expected * 2 + 32
deadline = time.time() + eof_timeout for _ in range(max_reads):
while not sae_kpn.pipeline_done(net): sa = net.read("scene", 0)
if time.time() > deadline: if sa.get("eof"):
sae_kpn.release_pipeline(net) eof_streak += 1
raise TimeoutError( # stragglers can still arrive after an eof; only stop once we've either
f"replay did not finish within {eof_timeout}s " # got everything or seen several eofs in a row (truly drained).
f"({len(frames) - 1} frames); the sink never saw EOF") if len(annotations) >= n_expected or eof_streak >= 8:
time.sleep(0.02) break
continue
diag = sae_kpn.pipeline_diagnostics(net) eof_streak = 0
if stop: annotations.append(sa)
net.stop() if len(annotations) >= n_expected:
sae_kpn.release_pipeline(net) break
# 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: if raw_out:
write_raw_frames(result, 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)
if stop:
net.stop()
return result return result
def write_raw_frames(truth: dict, raw_out: str) -> None: def build_minimal(annotations, movie, fps, cfg) -> dict:
"""Per-frame annotations as JSONL, for the montage/error-frame renderers. """Reproduce result_sink's minimal schema: per-actor annealed [start,end] windows.
TRACES: VR-011 | PR-002 Mirrors ResultSinkFunc::build_actor_windows merge each actor's detection
timestamps into windows, bridging gaps shorter than anneal_sec.
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.
""" """
with open(raw_out, "w") as f: anneal = float(cfg.get("anneal_sec", 10.0))
for fr in truth.get("frames", []): info = {} # actor_idx -> identity fields
visible = [] times = {} # actor_idx -> [timestamps]
for a in fr.get("identified", []): for sa in annotations:
visible.append({ for a in sa["visible_actors"]:
"actor_idx": 0, # >= 0 means "known"; the renderers if a["actor_idx"] < 0:
# test the sign, never the value continue
"name": a.get("name", ""), info[a["actor_idx"]] = a
"imdb_id": a.get("imdb_id", ""), times.setdefault(a["actor_idx"], []).append(sa["timestamp_sec"])
"tmdb_id": a.get("tmdb_id", ""),
"jellyfin_id": a.get("jellyfin_id", ""), actors = []
"similarity": a.get("similarity", 0.0), for idx, ts in times.items():
"track_id": a.get("track_id", -1), ts.sort()
"bbox": a.get("bbox", [0, 0, 0, 0]), scenes = []
}) ws = we = ts[0]
for u in fr.get("unknowns", []): for t in ts[1:]:
visible.append({ if t - we > anneal:
"actor_idx": -1, scenes.append([ws, we])
"name": "", ws = t
"similarity": u.get("confidence", 0.0), we = t
"track_id": u.get("track_id", -1), scenes.append([ws, we])
"bbox": u.get("bbox", [0, 0, 0, 0]), a = info[idx]
}) actors.append({
f.write(json.dumps({"timestamp_sec": fr.get("t", 0.0), "name": a["name"], "imdb_id": a["imdb_id"], "tmdb_id": a["tmdb_id"],
"visible_actors": visible}) + "\n") "jellyfin_id": a["jellyfin_id"], "scenes": scenes,
})
return {"schema_version": 1, "movie": movie, "sample_fps": fps,
"anneal_sec": anneal, "actors": actors}
CFG_KEYS = ["detector_conf", "prob_threshold", "match_prior", CFG_KEYS = ["detector_conf", "prob_threshold", "match_prior", "match_threshold", "match_ratio",
"track_alpha", "track_min_iou", "track_assoc_min_prob", "match_ratio_ceil", "track_alpha", "track_min_iou", "track_max_embed_dist",
"track_extinction_sec", "track_max_frames_missing", "cut_revive_sim", "cut_inactive_max_frames",
# AR-025 ownership and evidence accumulation. Newly reachable: "extinction_sec", "anneal_sec"]
# 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(): def main():
@@ -358,9 +250,6 @@ def main():
# per-film gallery expansion: promotes pose-varied views of confidently-identified # per-film gallery expansion: promotes pose-varied views of confidently-identified
# actors into an in-memory annex, recovering ~+4 recall at no precision cost. # actors into an in-memory annex, recovering ~+4 recall at no precision cost.
p.add_argument("--expand-gallery", action="store_true") 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 # TRACES: GR-004 | SR-001
# promote an unprovable gallery/dump binding from a # promote an unprovable gallery/dump binding from a
# loud warning to a hard error. Measurement sweeps should set this (or # loud warning to a hard error. Measurement sweeps should set this (or
@@ -371,21 +260,15 @@ def main():
cfg = {k: getattr(args, k) for k in CFG_KEYS if getattr(args, k) is not None} cfg = {k: getattr(args, k) for k in CFG_KEYS if getattr(args, k) is not None}
if args.expand_gallery: if args.expand_gallery:
cfg["expand_gallery"] = True cfg["expand_gallery"] = True
if args.presence_mode:
cfg["presence_mode"] = args.presence_mode
if args.require_gallery_stamp: if args.require_gallery_stamp:
cfg["require_gallery_stamp"] = True cfg["require_gallery_stamp"] = True
# stop=True: PyNode::stop() sets stop_flag_ before joining, so the source # 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_ # thread's run_loop actually exits. stop=False skips that, leaving stop_flag_
# false forever — the PyNode destructor's jthread.join() then blocks forever # 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). # (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, result = replay(args.dump, args.gallery, cfg, args.build_dir, stop=True,
out_path=args.out, stop=True, raw_out=args.raw_out) raw_out=args.raw_out)
# NOT rewritten here: the sink already wrote args.out, and that file is the Path(args.out).write_text(json.dumps(result, indent=2))
# 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) print(f"[replay] {len(result['actors'])} actors → {args.out}", file=sys.stderr)
+1 -9
View File
@@ -95,15 +95,7 @@ def load_pred_intervals(pred_json: dict):
for a in pred_json.get("actors", []): for a in pred_json.get("actors", []):
keys = frozenset(keys_for(imdb_id=a.get("imdb_id"), tmdb_id=a.get("tmdb_id"), 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"))) jellyfin_id=a.get("jellyfin_id"), name=a.get("name")))
# schema_version 1: scenes is [[t0, t1], ...]; schema_version 2: out.append((keys, [(float(t0), float(t1)) for t0, t1 in a.get("scenes", [])]))
# 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 return out
+31 -74
View File
@@ -1,29 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Smoke test for the sae_kpn module: assemble the real downstream pipeline Smoke test for the sae_kpn module: assemble the real downstream pipeline nodes
(tracker matcher annotation sink) in a Python-driven KPN network, fed by a (face_tracker identity_matcher scene_tracker) in a Python-driven KPN network,
no-input Python source node, and verify the sink writes a truth file. fed by a no-input Python source node, and verify SceneAnnotations flow out.
TRACES: VR-011 | PR-002
Proves the KPN-native replay path works without any numpy port of node logic. 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] Run: python scripts/optimizer/test_sae_kpn.py [gallery.json] [build_dir]
""" """
import json
import sys import sys
import tempfile import queue
import time
from pathlib import Path
import numpy as np import numpy as np
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent.parent REPO = Path(__file__).resolve().parent.parent.parent
GAL = sys.argv[1] if len(sys.argv) > 1 else str(REPO / "gallery_arcface_w600k_r50.json") GAL = sys.argv[1] if len(sys.argv) > 1 else str(REPO / "gallery_arcface_w600k_r50.json")
@@ -44,6 +31,7 @@ def make_frame(t, n):
def main(): def main():
net = sae_kpn.Network() net = sae_kpn.Network()
sae_kpn._register_types(net) 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 = [make_frame(float(t), 1) for t in range(3)]
frames.append({"timestamp_sec": 3.0, "eof": True}) frames.append({"timestamp_sec": 3.0, "eof": True})
@@ -51,67 +39,36 @@ def main():
eof_frame = {"timestamp_sec": 3.0, "eof": True} eof_frame = {"timestamp_sec": 3.0, "eof": True}
def source(): def source():
# Emit each frame once, then keep returning EOF so the node thread stays # Emit each frame once, then keep returning EOF (never block) so the node
# responsive to stop(). The sleep matters: a no-input source is called in # thread stays responsive to stop() after the sink has seen EOF.
# a tight loop, and hot-spinning EOFs pegs a core and floods the channel.
i = idx[0] i = idx[0]
idx[0] += 1 idx[0] += 1
if i < len(frames): return frames[i] if i < len(frames) else eof_frame
return frames[i]
time.sleep(0.05)
return eof_frame
with tempfile.TemporaryDirectory() as tmp: sae_kpn.add_node_python(net, "replay", source, [], ["EmbeddedSceneFrame"], 8)
out_path = str(Path(tmp) / "truth.json") sae_kpn.add_face_tracker(net, "tracker", cfg, 16)
cfg = { sae_kpn.add_identity_matcher(net, "matcher", GAL, cfg, 16)
"prob_threshold": 0.99, sae_kpn.add_scene_tracker(net, "scene", cfg, 16)
"track_extinction_sec": 5.0, net.connect("replay", 0, "tracker", 0)
"output_path": out_path, net.connect("tracker", 0, "matcher", 0)
"movie_path": "sae_kpn smoke test", net.connect("matcher", 0, "scene", 0)
"sample_fps": 1.0, net.build()
# Standard verbosity emits the per-frame array this test asserts on. net.start()
# At 0 the file carries only the actor epochs, and three random
# embeddings against a real gallery need not produce any.
"verbosity": 1,
}
sae_kpn.add_node_python(net, "replay", source, [], ["EmbeddedSceneFrame"], 16) got = []
# No embedder stamp: these embeddings are random, not the output of any for _ in range(4):
# model, so there is nothing truthful to claim. That warns rather than sa = net.read("scene", 0)
# failing, and would be fatal under SAE_REQUIRE_GALLERY_STAMP — which is got.append(sa)
# correct, since an unverifiable binding is exactly what it guards. if sa.get("eof"):
sae_kpn.add_pipeline(net, GAL, cfg, 16) break
net.stop()
net.connect("replay", 0, "tracker", 0) non_eof = [g for g in got if not g.get("eof")]
net.connect("tracker", 0, "matcher", 0) assert len(non_eof) == 3, f"expected 3 annotations, got {len(non_eof)}"
net.connect("matcher", 0, "annotation", 0) assert got[-1].get("eof"), "expected trailing EOF"
net.connect("annotation", 0, "sink", 0) assert [g["timestamp_sec"] for g in non_eof] == [0.0, 1.0, 2.0], "timestamps wrong"
net.build() assert all("visible_actors" in g for g in non_eof), "missing visible_actors"
net.start() print(f"OK: {len(non_eof)} annotations through the real KPN chain, EOF received")
# 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__": if __name__ == "__main__":
-124
View File
@@ -1,124 +0,0 @@
#!/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
@@ -1,65 +0,0 @@
#!/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)")
@@ -1,108 +0,0 @@
#!/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()
@@ -1,136 +0,0 @@
#!/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
@@ -1,137 +0,0 @@
#!/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
@@ -1,116 +0,0 @@
#!/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()
@@ -1,56 +0,0 @@
#!/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)
@@ -1,356 +0,0 @@
#!/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()
@@ -1,155 +0,0 @@
#!/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
@@ -1,81 +0,0 @@
#!/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()
+1 -9
View File
@@ -61,15 +61,7 @@ class Prediction:
keys = keys_for(imdb_id=a.get("imdb_id"), tmdb_id=a.get("tmdb_id"), 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"), jellyfin_id=a.get("jellyfin_id"), name=a.get("name"),
crosswalk=crosswalk) crosswalk=crosswalk)
# schema_version 1: scenes is [[t0, t1], ...] (list of pairs) windows = [(float(t0), float(t1)) for t0, t1 in a.get("scenes", [])]
# 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: for _, t1 in windows:
self._max_t = max(self._max_t, t1) self._max_t = max(self._max_t, t1)
self.actors.append({"keys": keys, "windows": windows}) self.actors.append({"keys": keys, "windows": windows})
+2 -17
View File
@@ -155,24 +155,9 @@ struct DecodeCtx {
bool open_resampler(DecodeCtx& c, const AVFrame* f) { bool open_resampler(DecodeCtx& c, const AVFrame* f) {
#if LIBAVUTIL_VERSION_INT >= AV_VERSION_INT(57, 24, 100) #if LIBAVUTIL_VERSION_INT >= AV_VERSION_INT(57, 24, 100)
// Both MUST be zero-initialised. av_channel_layout_copy documents that it AVChannelLayout out_layout;
// "will always uninitialize the destination before copy", and
// av_channel_layout_uninit() calls av_freep() on u.map — so a declaration
// without {} hands free() whatever pointer-shaped garbage the stack frame
// happened to hold. That is a real crash ("free(): invalid pointer"), not a
// theoretical one: it reproduced in roughly 1 run in 4 of UT-103, the only
// test that exercises this branch, because it is the only one whose input
// is stereo and so the only one that reaches the downmix path at all.
//
// It hid for two reasons worth remembering. It is stack-dependent, so it
// vanishes under a sanitizer build and looks like a flake in the aggregate
// test binary; and the golden-vector tests (UT-101) pass a mono 11025 Hz
// fixture, which is chosen precisely so the vector does not depend on the
// resampler — so bit-exactness against the golden vector proves nothing
// about this function.
AVChannelLayout out_layout{};
av_channel_layout_default(&out_layout, 1); // mono av_channel_layout_default(&out_layout, 1); // mono
AVChannelLayout in_layout{}; AVChannelLayout in_layout;
if (av_channel_layout_copy(&in_layout, &f->ch_layout) < 0) return false; if (av_channel_layout_copy(&in_layout, &f->ch_layout) < 0) return false;
if (in_layout.nb_channels <= 0) { if (in_layout.nb_channels <= 0) {
av_channel_layout_uninit(&in_layout); av_channel_layout_uninit(&in_layout);
+14 -79
View File
@@ -42,20 +42,17 @@ constexpr int kDim = 512;
// Used for CI and as the correctness oracle for the GPU backends. // Used for CI and as the correctness oracle for the GPU backends.
// //
// TRACES: AR-026, AR-027 | SR-001 // TRACES: AR-026, AR-027 | SR-001
// Backed by CBLAS (OpenBLAS), which CMake now REQUIRES for this backend. The // Backed by CBLAS (OpenBLAS) when available, falling back to a scalar loop when
// scalar loop below is portable but scales badly: scoring one face against a // not. The fallback is portable but scales badly: scoring one face against a
// 5000-embedding gallery is 2.6 MFLOP, and a crowded frame multiplies that by // 5000-embedding gallery is 2.6 MFLOP, and a crowded frame multiplies that by
// the face count. Since AR-003 removed the per-frame face cap and CI has no GPU, // the face count. Since AR-003 removed the per-frame face cap and CI has no GPU,
// the CPU path is the one that has to hold up under a library-scale gallery // the CPU path is now the one that has to hold up under a library-scale gallery
// (AR-027) rather than merely be correct — so falling back to it silently would // (AR-027) rather than merely be correct.
// mean measuring AR-027 on a path no release runs.
// //
// The fallback is kept as the correctness oracle the two BLAS backends are // The fallback is kept rather than made mandatory so the build has no hard new
// diffed against when a similarity looks wrong, and is reachable only via // dependency, and so the two can be diffed when a similarity looks wrong. The gallery is L2-normalised (as are the queries), so each
// -DSAE_ALLOW_SCALAR_GEMM=ON. The gallery is L2-normalised (as are the queries), // similarity is a plain dot product. S is stored column-major to match the GPU
// so each similarity is a plain dot product. S is stored column-major to match // backends: the gallery similarities for face fi start at result + fi*n_gallery.
// the GPU backends: the gallery similarities for face fi start at
// result + fi*n_gallery().
class SimilarityEngine final : public ISimilarityEngine { class SimilarityEngine final : public ISimilarityEngine {
public: public:
SimilarityEngine(const float* gallery_row_major, int n_gallery, int max_faces) SimilarityEngine(const float* gallery_row_major, int n_gallery, int max_faces)
@@ -75,19 +72,6 @@ public:
} }
int max_faces() const override { return max_faces_; } int max_faces() const override { return max_faces_; }
int n_gallery() const override { return n_gallery_; }
/// TRACES: AR-026 | SR-001
/// Promotions join the resident matrix, so the annex is scored by the same
/// SGEMM as the baked references. std::vector already grows geometrically,
/// so this is amortised O(1) per row.
void append_rows(const float* rows_row_major, int n_rows) override {
if (n_rows <= 0) return;
gallery_.insert(gallery_.end(), rows_row_major,
rows_row_major + static_cast<size_t>(n_rows) * kDim);
n_gallery_ += n_rows;
host_sims_.resize(static_cast<size_t>(max_faces_) * n_gallery_);
}
const float* compute(const float* query_row_major, int n_faces) override { const float* compute(const float* query_row_major, int n_faces) override {
if (n_faces <= 0) return host_sims_.data(); if (n_faces <= 0) return host_sims_.data();
@@ -159,7 +143,6 @@ inline void gpu_free(void* p) { cudaFree(p)
inline void gpu_memcpy_h2d(void* dst, const void* src, size_t n, stream_t s) { check_gpu(cudaMemcpyAsync(dst, src, n, cudaMemcpyHostToDevice, s), "H2D"); } inline void gpu_memcpy_h2d(void* dst, const void* src, size_t n, stream_t s) { check_gpu(cudaMemcpyAsync(dst, src, n, cudaMemcpyHostToDevice, s), "H2D"); }
inline void gpu_memcpy_d2h(void* dst, const void* src, size_t n, stream_t s) { check_gpu(cudaMemcpyAsync(dst, src, n, cudaMemcpyDeviceToHost, s), "D2H"); } inline void gpu_memcpy_d2h(void* dst, const void* src, size_t n, stream_t s) { check_gpu(cudaMemcpyAsync(dst, src, n, cudaMemcpyDeviceToHost, s), "D2H"); }
inline void gpu_memcpy_h2d_sync(void* dst, const void* src, size_t n) { check_gpu(cudaMemcpy(dst, src, n, cudaMemcpyHostToDevice), "H2D_sync"); } inline void gpu_memcpy_h2d_sync(void* dst, const void* src, size_t n) { check_gpu(cudaMemcpy(dst, src, n, cudaMemcpyHostToDevice), "H2D_sync"); }
inline void gpu_memcpy_d2d_sync(void* dst, const void* src, size_t n) { check_gpu(cudaMemcpy(dst, src, n, cudaMemcpyDeviceToDevice), "D2D_sync"); }
inline void stream_create(stream_t* s) { check_gpu(cudaStreamCreate(s), "cudaStreamCreate"); } inline void stream_create(stream_t* s) { check_gpu(cudaStreamCreate(s), "cudaStreamCreate"); }
inline void stream_destroy(stream_t s) { cudaStreamDestroy(s); } inline void stream_destroy(stream_t s) { cudaStreamDestroy(s); }
inline void stream_sync(stream_t s) { check_gpu(cudaStreamSynchronize(s), "cudaStreamSync"); } inline void stream_sync(stream_t s) { check_gpu(cudaStreamSynchronize(s), "cudaStreamSync"); }
@@ -194,7 +177,6 @@ inline void gpu_free(void* p) { (void)hipFr
inline void gpu_memcpy_h2d(void* dst, const void* src, size_t n, stream_t s) { check_gpu(hipMemcpyAsync(dst, src, n, hipMemcpyHostToDevice, s), "H2D"); } inline void gpu_memcpy_h2d(void* dst, const void* src, size_t n, stream_t s) { check_gpu(hipMemcpyAsync(dst, src, n, hipMemcpyHostToDevice, s), "H2D"); }
inline void gpu_memcpy_d2h(void* dst, const void* src, size_t n, stream_t s) { check_gpu(hipMemcpyAsync(dst, src, n, hipMemcpyDeviceToHost, s), "D2H"); } inline void gpu_memcpy_d2h(void* dst, const void* src, size_t n, stream_t s) { check_gpu(hipMemcpyAsync(dst, src, n, hipMemcpyDeviceToHost, s), "D2H"); }
inline void gpu_memcpy_h2d_sync(void* dst, const void* src, size_t n) { check_gpu(hipMemcpy(dst, src, n, hipMemcpyHostToDevice), "H2D_sync"); } inline void gpu_memcpy_h2d_sync(void* dst, const void* src, size_t n) { check_gpu(hipMemcpy(dst, src, n, hipMemcpyHostToDevice), "H2D_sync"); }
inline void gpu_memcpy_d2d_sync(void* dst, const void* src, size_t n) { check_gpu(hipMemcpy(dst, src, n, hipMemcpyDeviceToDevice), "D2D_sync"); }
inline void stream_create(stream_t* s) { check_gpu(hipStreamCreate(s), "hipStreamCreate"); } inline void stream_create(stream_t* s) { check_gpu(hipStreamCreate(s), "hipStreamCreate"); }
inline void stream_destroy(stream_t s) { (void)hipStreamDestroy(s); } inline void stream_destroy(stream_t s) { (void)hipStreamDestroy(s); }
inline void stream_sync(stream_t s) { check_gpu(hipStreamSynchronize(s), "hipStreamSync"); } inline void stream_sync(stream_t s) { check_gpu(hipStreamSynchronize(s), "hipStreamSync"); }
@@ -219,15 +201,14 @@ public:
SimilarityEngine(const float* gallery_row_major, int n_gallery, int max_faces) SimilarityEngine(const float* gallery_row_major, int n_gallery, int max_faces)
: n_gallery_(n_gallery), max_faces_(max_faces) : n_gallery_(n_gallery), max_faces_(max_faces)
{ {
const size_t gallery_floats = static_cast<size_t>(n_gallery_) * kDim;
gpu_malloc(reinterpret_cast<void**>(&d_gallery_), gallery_floats * sizeof(float));
gpu_memcpy_h2d_sync(d_gallery_, gallery_row_major, gallery_floats * sizeof(float));
gpu_malloc(reinterpret_cast<void**>(&d_query_), gpu_malloc(reinterpret_cast<void**>(&d_query_),
static_cast<size_t>(max_faces_) * kDim * sizeof(float)); static_cast<size_t>(max_faces_) * kDim * sizeof(float));
gpu_malloc(reinterpret_cast<void**>(&d_sims_),
// Allocates d_gallery_/d_sims_ at the initial row count; append_rows() static_cast<size_t>(max_faces_) * n_gallery_ * sizeof(float));
// grows them geometrically from here.
reserve_rows(std::max(n_gallery_, 1));
const size_t gallery_floats = static_cast<size_t>(n_gallery_) * kDim;
if (gallery_floats)
gpu_memcpy_h2d_sync(d_gallery_, gallery_row_major, gallery_floats * sizeof(float));
stream_create(&stream_); stream_create(&stream_);
blas_create(&handle_); blas_create(&handle_);
@@ -251,24 +232,6 @@ public:
SimilarityEngine& operator=(const SimilarityEngine&) = delete; SimilarityEngine& operator=(const SimilarityEngine&) = delete;
int max_faces() const override { return max_faces_; } int max_faces() const override { return max_faces_; }
int n_gallery() const override { return n_gallery_; }
/// TRACES: AR-026 | SR-001
/// Promotions join the GPU-resident matrix, so the annex is scored by the
/// same SGEMM as the baked references rather than by a host-side loop.
/// Capacity doubles on overflow, so the gallery is re-uploaded O(log n)
/// times over a film rather than once per promotion.
void append_rows(const float* rows_row_major, int n_rows) override {
if (n_rows <= 0) return;
const int want = n_gallery_ + n_rows;
if (want > capacity_) reserve_rows(std::max(want, capacity_ * 2));
gpu_memcpy_h2d_sync(d_gallery_ + static_cast<size_t>(n_gallery_) * kDim,
rows_row_major,
static_cast<size_t>(n_rows) * kDim * sizeof(float));
n_gallery_ = want;
host_sims_.resize(static_cast<size_t>(max_faces_) * n_gallery_);
}
const float* compute(const float* query_row_major, int n_faces) override { const float* compute(const float* query_row_major, int n_faces) override {
if (n_faces <= 0) return host_sims_.data(); if (n_faces <= 0) return host_sims_.data();
@@ -288,35 +251,7 @@ public:
} }
private: private:
// Grow the resident gallery (and the similarity output sized against it) to
// `rows` capacity, preserving the n_gallery_ rows already there. The copy is
// device-to-device, so a promotion never re-uploads the baked gallery across
// the bus.
void reserve_rows(int rows) {
if (rows <= capacity_) return;
float* d_new_gallery = nullptr;
gpu_malloc(reinterpret_cast<void**>(&d_new_gallery),
static_cast<size_t>(rows) * kDim * sizeof(float));
if (d_gallery_ && n_gallery_ > 0)
gpu_memcpy_d2d_sync(d_new_gallery, d_gallery_,
static_cast<size_t>(n_gallery_) * kDim * sizeof(float));
if (d_gallery_) gpu_free(d_gallery_);
d_gallery_ = d_new_gallery;
// S is (capacity × n_faces); its contents are rewritten by every
// compute(), so this one is a plain reallocation with nothing to keep.
float* d_new_sims = nullptr;
gpu_malloc(reinterpret_cast<void**>(&d_new_sims),
static_cast<size_t>(max_faces_) * rows * sizeof(float));
if (d_sims_) gpu_free(d_sims_);
d_sims_ = d_new_sims;
capacity_ = rows;
}
int n_gallery_{0}; int n_gallery_{0};
int capacity_{0};
int max_faces_{0}; int max_faces_{0};
float* d_gallery_{nullptr}; float* d_gallery_{nullptr};
float* d_query_{nullptr}; float* d_query_{nullptr};
+1 -14
View File
@@ -124,21 +124,8 @@ inline OrtProvider apply_ort_provider(Ort::SessionOptions& opts,
try { try {
OrtROCMProviderOptions rocm{}; OrtROCMProviderOptions rocm{};
rocm.device_id = 0; rocm.device_id = 0;
// Without these MIOpen runs convolutions on the no-workspace GEMM
// fallback (the "GemmFwdRest, provided ptr: 0 size: 0" warnings), which
// is the slow path — most visible on the conv-heavy TransNetV2 scene
// detector. Exhaustive search lets MIOpen pick the fast conv kernel,
// and TunableOp autotunes the GEMMs; both cache to the MIOpen user DB
// (MIOPEN_USER_DB_PATH), so the tuning cost is paid once per shape.
// Opt-out via SAE_ROCM_NOTUNE=1 for a quick no-warmup run.
const bool tune = std::getenv("SAE_ROCM_NOTUNE") == nullptr;
rocm.miopen_conv_exhaustive_search = tune ? 1 : 0;
rocm.tunable_op_enable = tune;
rocm.tunable_op_tuning_enable = tune;
opts.AppendExecutionProvider_ROCM(rocm); opts.AppendExecutionProvider_ROCM(rocm);
std::cerr << "[" << label << "] ROCm provider" std::cerr << "[" << label << "] ROCm provider\n";
<< (tune ? " (MIOpen exhaustive + TunableOp)" : " (untuned)")
<< "\n";
return OrtProvider::ROCm; return OrtProvider::ROCm;
} catch (const Ort::Exception& e) { } catch (const Ort::Exception& e) {
std::cerr << "[" << label << "] ROCm unavailable (" std::cerr << "[" << label << "] ROCm unavailable ("
-38
View File
@@ -19,7 +19,6 @@
#include <NvInfer.h> #include <NvInfer.h>
#include <cuda_runtime_api.h> #include <cuda_runtime_api.h>
#include <cstdlib>
#include <opencv2/dnn.hpp> #include <opencv2/dnn.hpp>
#include <opencv2/imgproc.hpp> #include <opencv2/imgproc.hpp>
@@ -46,40 +45,6 @@ inline void check_cuda(cudaError_t e, const char* what) {
throw CudaError(std::string(what) + ": " + cudaGetErrorString(e)); throw CudaError(std::string(what) + ": " + cudaGetErrorString(e));
} }
/// TRACES: VR-015 | PR-004
/// Select how a thread waits for the GPU. Must run before the CUDA context is
/// created, so every engine constructor calls it and the first one wins.
///
/// The default (`cudaDeviceScheduleAuto`) spin-waits: `cudaStreamSynchronize`
/// burns the calling thread's CPU for the whole of the device's work. Measured
/// here, the embedder thread sat at 99.7% *user* time with 0.5 s of system time
/// across 183 s — i.e. no blocking syscalls at all — while the GPU ran flat out.
///
/// On this laptop that is not merely wasted CPU. `nvidia-powerd` arbitrates one
/// power budget across CPU and GPU, and the GPU's ceiling was observed dropping
/// from 20 W idle to 15 W under our load, with the SM clock *falling* from
/// 1005 MHz to 210 MHz once work started. Spinning may therefore be buying
/// watts away from the device the pipeline is actually waiting on.
///
/// SAE_CUDA_BLOCKING_SYNC=1 switches to a blocking wait so the A/B needs no
/// rebuild. Default is unchanged until the measurement says otherwise.
inline void configure_cuda_sync_once() {
static const bool done = [] {
const char* env = std::getenv("SAE_CUDA_BLOCKING_SYNC");
if (env && env[0] == '1') {
cudaError_t e = cudaSetDeviceFlags(cudaDeviceScheduleBlockingSync);
std::cerr << "[cuda] sync policy: BlockingSync"
<< (e == cudaSuccess ? "" : " (FAILED — context already created)")
<< "\n";
} else {
std::cerr << "[cuda] sync policy: default (spin) — "
"set SAE_CUDA_BLOCKING_SYNC=1 to compare\n";
}
return true;
}();
(void)done;
}
class TrtLogger : public nvinfer1::ILogger { class TrtLogger : public nvinfer1::ILogger {
public: public:
void log(Severity sev, const char* msg) noexcept override { void log(Severity sev, const char* msg) noexcept override {
@@ -144,7 +109,6 @@ public:
(output_is_fp16_ ? 2 : 4); (output_is_fp16_ ? 2 : 4);
check_cuda(cudaMalloc(&d_input_, in_bytes), "cudaMalloc input"); check_cuda(cudaMalloc(&d_input_, in_bytes), "cudaMalloc input");
check_cuda(cudaMalloc(&d_output_, out_bytes), "cudaMalloc output"); check_cuda(cudaMalloc(&d_output_, out_bytes), "cudaMalloc output");
configure_cuda_sync_once();
check_cuda(cudaStreamCreate(&stream_), "cudaStreamCreate"); check_cuda(cudaStreamCreate(&stream_), "cudaStreamCreate");
context_->setTensorAddress(input_name_.c_str(), d_input_); context_->setTensorAddress(input_name_.c_str(), d_input_);
@@ -329,7 +293,6 @@ public:
out_elem_counts_[oi] = count; out_elem_counts_[oi] = count;
} }
configure_cuda_sync_once();
check_cuda(cudaStreamCreate(&stream_), "cudaStreamCreate"); check_cuda(cudaStreamCreate(&stream_), "cudaStreamCreate");
std::cerr << "[TrtScrfd] loaded: " << engine_path std::cerr << "[TrtScrfd] loaded: " << engine_path
@@ -499,7 +462,6 @@ public:
const std::size_t out_count = static_cast<std::size_t>(kWindow); const std::size_t out_count = static_cast<std::size_t>(kWindow);
check_cuda(cudaMalloc(&d_input_, in_count * 4), "cudaMalloc input"); check_cuda(cudaMalloc(&d_input_, in_count * 4), "cudaMalloc input");
check_cuda(cudaMalloc(&d_output_, out_count * 4), "cudaMalloc output"); check_cuda(cudaMalloc(&d_output_, out_count * 4), "cudaMalloc output");
configure_cuda_sync_once();
check_cuda(cudaStreamCreate(&stream_), "cudaStreamCreate"); check_cuda(cudaStreamCreate(&stream_), "cudaStreamCreate");
context_->setTensorAddress(input_name_.c_str(), d_input_); context_->setTensorAddress(input_name_.c_str(), d_input_);
context_->setTensorAddress(output_name_.c_str(), d_output_); context_->setTensorAddress(output_name_.c_str(), d_output_);
-590
View File
@@ -1,590 +0,0 @@
#pragma once
/// TRACES: VR-015 | PR-004
///
/// Pipeline throughput benchmark — how much of a run is spent in each node.
///
/// The KPN network already counts most of what an optimiser needs, and
/// `print_diagnostics()` throws nearly all of it away: it prints frames and
/// `ema` per node, and passes `elapsed_s = 0`, which zeroes throughput. Two
/// things had to change before "time per node" could be answered honestly.
///
/// **`ema` is not a total.** It is an exponentially weighted average, so
/// `frames * ema` tracks the end of the run rather than the whole of it. On a
/// film that is a real difference — a detector costs one thing in a crowd scene
/// and another over a landscape. `NodeStats::total_exec_us` (added alongside
/// this) is the true sum.
///
/// **Wall time inside a node is not all work.** `PoolObjectNode::fire_once`
/// times the functor *and* `push_outputs`, and `push_outputs` parks on a full
/// downstream channel (AR-004). A node that is merely backpressured therefore
/// bills the time it spent waiting to whoever is ahead of it: SuperHero's
/// `frame_source` reported 141.9 ms/frame against a decoder logging 12-18 ms.
/// Optimising against that number means optimising the fastest node in the
/// graph.
///
/// So each node is reported three ways, and the three together are what
/// identify a cost:
///
/// - `exec_ms` — cumulative wall time in the node, work *and* parked pushes
/// - `cpu_ms` — thread CPU time (CLOCK_THREAD_CPUTIME_ID). Backpressure
/// cannot inflate it, because a parked node holds no thread.
/// - `pressure` — mean fill of its input channels minus that of its outputs
///
/// **`cpu_ms` cannot tell real work from a spinning GPU wait.** CUDA's default
/// sync policy (`cudaDeviceScheduleAuto`) spin-waits before yielding, so
/// `cudaStreamSynchronize` burns the calling thread's CPU while the GPU works.
/// A node that is purely GPU-bound can therefore report a high `cpu_ms` and read
/// as CPU-bound. `cudaSetDeviceFlags(cudaDeviceScheduleBlockingSync)` settles it
/// in one line: if a node's `cpu_ms` collapses under blocking sync, that CPU was
/// spin, not work.
///
/// **`cpu_ms` counts one thread only.** `CLOCK_THREAD_CPUTIME_ID` is per-thread,
/// and OpenCV here is built against TBB, so any node whose functor goes through
/// `cv::parallel_for_` (histogram compare, `warpAffine`, colour conversion) has
/// that work executed on TBB's arena — 19 workers on a 20-core box — and billed
/// to those threads rather than to the node. Such a node reads *cheaper* than it
/// is, and the difference shows up in `stall/f` instead, indistinguishable from a
/// GPU wait. `exec_ms` does capture it, since the functor does not return until
/// the parallel region joins: a node where `exec/f` greatly exceeds `cpu/f`
/// while its output channel is empty is fanning out, not waiting.
///
/// Work piles up *in front of* a bottleneck and starves everything *after* it,
/// so `pressure` is maximal at the node setting the pace. `cpu_ms` then says
/// which repair applies: high pressure with a saturated thread is CPU-bound and
/// the work must get cheaper, while high pressure with an idle thread is
/// waiting on a device, where batch size and engine precision are the knobs.
///
/// Occupancy has to be sampled during the run. `current_fill` is instantaneous
/// and every channel has drained by the time the network stops, so a single
/// read at the end reports an idle pipeline however congested it was.
///
/// Nothing here is specific to this pipeline's topology: the node graph is
/// recovered from KPN's channel names, so it keeps working when the graph
/// changes.
#include <kpn/diagnostics.hpp>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <functional>
#include <iomanip>
#include <map>
#include <ostream>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
namespace sae::bench {
// ── Edge naming ──────────────────────────────────────────────────────────────
/// TRACES: VR-015 | PR-004
/// KPN names a channel "<src>:<idx> → <dst>:<idx>" (static_network.hpp).
/// Recovering the two node names from it is what keeps attribution
/// topology-agnostic: the graph is read back out of the channel names rather
/// than hard-coded here, so a new node or a re-wired branch needs no change.
/// Leaves both outputs untouched if the name does not carry an arrow.
inline void split_edge_name(const std::string& name,
std::string& producer, std::string& consumer) {
static const std::string kArrow = " \xe2\x86\x92 "; // " → "
const auto arrow = name.find(kArrow);
if (arrow == std::string::npos) return;
auto strip_port = [](std::string s) {
const auto colon = s.rfind(':');
return colon == std::string::npos ? s : s.substr(0, colon);
};
producer = strip_port(name.substr(0, arrow));
consumer = strip_port(name.substr(arrow + kArrow.size()));
}
// ── Channel occupancy, time-averaged ─────────────────────────────────────────
/// TRACES: VR-015 | PR-004
/// One channel's fill level integrated over the run. `peak_fill` is already
/// cumulative in `ChannelStats`, but a peak cannot distinguish "full once" from
/// "full throughout", and those are opposite diagnoses. A mean can.
struct ChannelOccupancy {
std::string name; // "src:0 → dst:0", as KPN names it
std::string producer; // node name left of the arrow
std::string consumer; // node name right of the arrow
std::size_t capacity{0};
std::uint64_t samples{0};
double fill_sum{0.0};
std::uint64_t samples_full{0};
std::uint64_t samples_empty{0};
// Final-snapshot totals (monotonic counters, so the last read is the total).
std::size_t peak_fill{0};
std::uint64_t pushes{0};
std::uint64_t pops{0};
std::uint64_t drops{0};
std::uint64_t overflows{0};
std::uint64_t bytes_pushed{0};
double mean_fill() const { return samples ? fill_sum / static_cast<double>(samples) : 0.0; }
double mean_fill_pct() const { return capacity ? 100.0 * mean_fill() / static_cast<double>(capacity) : 0.0; }
double peak_pct() const { return capacity ? 100.0 * static_cast<double>(peak_fill) / static_cast<double>(capacity) : 0.0; }
double full_pct() const { return samples ? 100.0 * static_cast<double>(samples_full) / static_cast<double>(samples) : 0.0; }
double empty_pct() const { return samples ? 100.0 * static_cast<double>(samples_empty) / static_cast<double>(samples) : 0.0; }
double bandwidth_mbs(double wall_s) const {
return wall_s > 0.0 ? static_cast<double>(bytes_pushed) / wall_s / 1e6 : 0.0;
}
};
// ── Per-node attributed cost ─────────────────────────────────────────────────
/// TRACES: VR-015 | PR-004
struct NodeCost {
std::string name;
std::uint64_t frames{0};
// Cumulative wall time in the node — the answer to "where did the run go",
// but only for a node that is not backpressured; it includes parked pushes.
double exec_ms{0.0};
double exec_ms_per_frame{0.0}; // true mean, not the EMA
double exec_share{0.0}; // exec_ms / wall_ms, 0..1
double ema_exec_ms{0.0}; // KPN's EMA, kept for continuity with the old report
double max_exec_ms{0.0};
// Thread CPU time: excludes sleeping, parking and waiting on a device, so it
// is the one number backpressure cannot inflate.
double cpu_ms{0.0};
double cpu_ms_per_frame{0.0};
double cpu_share{0.0}; // cpu_ms / wall_ms — thread saturation, 0..1
double cpu_pct_of_pipeline{0.0}; // this node's share of all nodes' CPU time
// Per frame, time inside the node not spent on its own CPU: parked on a
// full output channel, or waiting on the GPU. `pressure` separates those —
// a backpressured node has a full output, a device-bound one does not.
double stall_ms_per_frame{0.0};
// Queue occupancy either side of the node, in percent of capacity.
double in_fill_pct{0.0};
double out_fill_pct{0.0};
double pressure{0.0}; // in out; maximal at the pacing node
bool has_input{false};
bool has_output{false};
double queue_wait_ms{0.0};
bool is_bottleneck{false};
/// TRACES: VR-015 | AR-004 | PR-004
/// Live scheduling state, so a wedged run says *why* it is wedged rather
/// than only that it is. With `queued=0, wake=1` a wake was recorded and
/// never consumed; with `queued=0, wake=0` and a full input, no wake was
/// ever generated. Those are different bugs in different files.
bool queued{false};
bool wake_pending{false};
};
/// TRACES: VR-015 | PR-004
/// Attribute cost to nodes from a KPN node snapshot plus sampled channel
/// occupancy. Pure — no clocks, no threads, no network — so the ranking is
/// unit-testable on CI hardware that can never run the pipeline itself.
///
/// A node with several inputs takes the **minimum** input fill: it can only run
/// once every input has data, so the emptiest one gates it, and a full sibling
/// channel means that channel's producer is blocked rather than this node being
/// slow. A node with several outputs takes the **maximum** output fill, since
/// parking on any one branch stops the node.
///
/// Terminals are the infinite-reservoir limit of the same rule: a source has
/// unlimited work available (input treated as 100% full) and a sink unlimited
/// drain (output treated as empty), so both stay rankable against the interior
/// nodes instead of dropping out of the comparison.
inline std::vector<NodeCost> attribute_cost(
const std::vector<kpn::NodeSnapshot>& nodes,
const std::vector<ChannelOccupancy>& channels,
double wall_sec)
{
const double wall_ms = wall_sec * 1000.0;
double cpu_total = 0.0;
for (const auto& n : nodes) cpu_total += n.total_cpu_ms;
std::vector<NodeCost> out;
out.reserve(nodes.size());
for (const auto& n : nodes) {
NodeCost c;
c.name = n.name;
c.frames = n.frames_processed;
c.exec_ms = n.total_exec_ms;
c.ema_exec_ms = n.ema_exec_ms;
c.max_exec_ms = n.max_exec_ms;
c.cpu_ms = n.total_cpu_ms;
c.queue_wait_ms = n.queue_wait_ms;
c.queued = n.queued;
c.wake_pending = n.wake_pending;
c.exec_ms_per_frame = c.frames ? c.exec_ms / static_cast<double>(c.frames) : 0.0;
c.cpu_ms_per_frame = c.frames ? c.cpu_ms / static_cast<double>(c.frames) : 0.0;
c.exec_share = wall_ms > 0.0 ? c.exec_ms / wall_ms : 0.0;
c.cpu_share = wall_ms > 0.0 ? c.cpu_ms / wall_ms : 0.0;
c.cpu_pct_of_pipeline = cpu_total > 0.0 ? 100.0 * c.cpu_ms / cpu_total : 0.0;
c.stall_ms_per_frame = c.exec_ms_per_frame - c.cpu_ms_per_frame;
if (c.stall_ms_per_frame < 0.0) c.stall_ms_per_frame = 0.0;
double in_min = 0.0; bool have_in = false;
double out_max = 0.0; bool have_out = false;
for (const auto& ch : channels) {
if (ch.consumer == n.name) {
const double f = ch.mean_fill_pct();
if (!have_in || f < in_min) in_min = f;
have_in = true;
}
if (ch.producer == n.name) {
const double f = ch.mean_fill_pct();
if (!have_out || f > out_max) out_max = f;
have_out = true;
}
}
c.has_input = have_in;
c.has_output = have_out;
c.in_fill_pct = have_in ? in_min : 100.0; // source: always has work
c.out_fill_pct = have_out ? out_max : 0.0; // sink: never blocks
c.pressure = c.in_fill_pct - c.out_fill_pct;
out.push_back(std::move(c));
}
// Rank, but only among nodes that actually ran: a node with zero frames has
// no cost to attribute and its neighbouring channels never moved.
auto best = out.end();
for (auto it = out.begin(); it != out.end(); ++it) {
if (it->frames == 0) continue;
if (best == out.end() || it->pressure > best->pressure) best = it;
}
if (best != out.end()) best->is_bottleneck = true;
return out;
}
/// TRACES: VR-015 | PR-004
/// One line of plain English about the winning node, since the point of the
/// report is to say what to change next. A saturated thread means the node's
/// own work is the limit; an idle thread under pressure means it is waiting on
/// a device, and those are different repairs.
inline std::string verdict(const std::vector<NodeCost>& costs) {
for (const auto& c : costs) {
if (!c.is_bottleneck) continue;
std::ostringstream os;
os << std::fixed << c.name << " sets the pace: ";
// A source's 100% input is the infinite-reservoir convention, not a
// measured queue — saying "work is backed up in front of it" would be
// asserting something no counter observed.
if (!c.has_input)
os << "nothing downstream is waiting on it (output "
<< std::setprecision(1) << c.out_fill_pct
<< "% full), so the pipeline is running as fast as this node can feed it. ";
else if (!c.has_output)
os << std::setprecision(1) << c.in_fill_pct
<< "% full input and nothing to block on, so it is the drain. ";
else
os << std::setprecision(1) << c.in_fill_pct << "% full input, "
<< c.out_fill_pct << "% full output. ";
os << std::setprecision(2) << c.cpu_ms_per_frame << " ms/frame on CPU. ";
if (c.cpu_share >= 0.85)
os << "CPU-bound — its thread is busy " << std::setprecision(0)
<< (100.0 * c.cpu_share) << "% of the run, so the work itself has to get"
" cheaper or be split across more threads.";
else if (c.cpu_share <= 0.35 && c.stall_ms_per_frame > c.cpu_ms_per_frame)
os << "Device-bound — its thread is busy only " << std::setprecision(0)
<< (100.0 * c.cpu_share) << "% of the run and it spends "
<< std::setprecision(2) << c.stall_ms_per_frame
<< " ms/frame off-CPU, so it is waiting on the GPU or the disk: batch size,"
" engine precision and the decode path are the knobs, not the C++.";
else
os << "Mixed — thread busy " << std::setprecision(0) << (100.0 * c.cpu_share)
<< "% of the run, " << std::setprecision(2) << c.stall_ms_per_frame
<< " ms/frame off-CPU.";
return os.str();
}
return "no node processed a frame — nothing to attribute";
}
// ── Recorder ─────────────────────────────────────────────────────────────────
/// TRACES: VR-015 | PR-004
/// Samples the live network on a timer and emits the report at the end.
///
/// The sampler only reads relaxed atomics, so it does not perturb what it
/// measures — which matters, since this exists to be trusted as a timing
/// measurement.
class BenchmarkRecorder {
public:
using Sampler = std::function<kpn::NetworkSnapshot()>;
explicit BenchmarkRecorder(int sample_interval_ms = 100)
: interval_(std::chrono::milliseconds(sample_interval_ms)) {}
~BenchmarkRecorder() { stop(); }
void start(Sampler sampler) {
sampler_ = std::move(sampler);
running_.store(true, std::memory_order_release);
thread_ = std::thread([this] {
while (running_.load(std::memory_order_acquire)) {
accumulate(sampler_());
std::this_thread::sleep_for(interval_);
}
});
}
/// Stops sampling and latches the final counter values. Call while the
/// network object is still alive: the monotonic counters stay valid after
/// `net.stop()`, but they die with the object.
void stop() {
if (!running_.exchange(false, std::memory_order_acq_rel)) return;
if (thread_.joinable()) thread_.join();
if (sampler_) {
final_ = sampler_();
// Occupancy is deliberately NOT accumulated from this last read:
// the pipeline has drained by now, and folding an idle sample into
// the mean biases every channel toward "never congested".
for (const auto& ch : final_.channels) {
auto& occ = occupancy_[ch.name];
if (occ.name.empty()) { // a channel that never moved
occ.name = ch.name;
occ.capacity = ch.capacity;
split_edge_name(ch.name, occ.producer, occ.consumer);
}
occ.peak_fill = ch.peak_fill;
occ.pushes = ch.pushes;
occ.pops = ch.pops;
occ.drops = ch.drops;
occ.overflows = ch.overflows;
occ.bytes_pushed = ch.bytes_pushed;
}
}
stopped_ = true;
}
bool has_data() const { return stopped_ && !final_.nodes.empty(); }
double wall_sec() const { return final_.elapsed_s; }
std::vector<ChannelOccupancy> channels() const {
std::vector<ChannelOccupancy> v;
v.reserve(occupancy_.size());
for (const auto& [_, occ] : occupancy_) v.push_back(occ);
return v;
}
std::vector<NodeCost> costs() const {
return attribute_cost(final_.nodes, channels(), final_.elapsed_s);
}
/// TRACES: VR-015 | PR-004
/// Machine-readable report, for sweeping configurations and diffing runs.
/// `film_sec` is the last timestamp the pipeline reached, so
/// `realtime_factor` answers what the optimiser is really asking: seconds
/// of film per second of wall clock. It is 0 for a topology with no result
/// sink (the dump-only path), and the field is then omitted rather than
/// reported as zero throughput.
nlohmann::json to_json(const nlohmann::json& run_config, double film_sec) const {
using nlohmann::json;
const double wall = final_.elapsed_s;
const auto chans = channels();
const auto cost = attribute_cost(final_.nodes, chans, wall);
json j;
j["schema_version"] = 1;
j["config"] = run_config;
json summary;
summary["wall_sec"] = wall;
summary["sample_count"] = sample_count_;
summary["sample_interval_ms"] = interval_.count();
if (film_sec > 0.0) {
summary["film_sec"] = film_sec;
summary["realtime_factor"] = wall > 0.0 ? film_sec / wall : 0.0;
}
for (const auto& c : cost)
if (c.is_bottleneck) { summary["bottleneck"] = c.name; break; }
summary["verdict"] = verdict(cost);
j["summary"] = summary;
json jnodes = json::array();
for (const auto& c : cost) {
jnodes.push_back({
{"name", c.name},
{"frames", c.frames},
{"fps", wall > 0.0 ? c.frames / wall : 0.0},
{"exec_ms", c.exec_ms},
{"exec_ms_per_frame", c.exec_ms_per_frame},
{"exec_share", c.exec_share},
{"ema_exec_ms", c.ema_exec_ms},
{"max_exec_ms", c.max_exec_ms},
{"cpu_ms", c.cpu_ms},
{"cpu_ms_per_frame", c.cpu_ms_per_frame},
{"cpu_share", c.cpu_share},
{"cpu_pct_of_pipeline", c.cpu_pct_of_pipeline},
{"stall_ms_per_frame", c.stall_ms_per_frame},
{"queue_wait_ms", c.queue_wait_ms},
{"in_fill_pct", c.in_fill_pct},
{"out_fill_pct", c.out_fill_pct},
{"pressure", c.pressure},
{"is_bottleneck", c.is_bottleneck},
{"queued", c.queued},
{"wake_pending", c.wake_pending},
});
}
j["nodes"] = std::move(jnodes);
json jch = json::array();
for (const auto& ch : chans) {
jch.push_back({
{"name", ch.name},
{"producer", ch.producer},
{"consumer", ch.consumer},
{"capacity", ch.capacity},
{"mean_fill", ch.mean_fill()},
{"mean_fill_pct", ch.mean_fill_pct()},
{"peak_fill", ch.peak_fill},
{"peak_pct", ch.peak_pct()},
{"full_pct", ch.full_pct()},
{"empty_pct", ch.empty_pct()},
{"pushes", ch.pushes},
{"pops", ch.pops},
{"drops", ch.drops},
{"overflows", ch.overflows},
{"mb_per_sec", ch.bandwidth_mbs(wall)},
});
}
j["channels"] = std::move(jch);
return j;
}
/// TRACES: VR-015 | PR-004
/// Human-readable form of the same data, so a run is legible without
/// opening the JSON.
void print(std::ostream& os, double film_sec) const {
print_impl(os, final_, film_sec);
}
/// TRACES: VR-015 | AR-004 | PR-004
/// Dump the report from a LIVE snapshot, mid-run, without stopping anything.
///
/// A report that only exists at shutdown is no use against the failure this
/// pipeline actually has: a wedged run never reaches shutdown, so the one
/// moment the numbers matter most is the one moment they were unavailable.
/// Channel occupancy names the stalled node directly — it is the one whose
/// input is full and whose output is empty — which is otherwise a debug-build
/// and a gdb session away.
///
/// Safe to call from the wait loop while the pipeline is running or hung: it
/// takes the same lock-free snapshot the sampler does.
void dump_live(std::ostream& os, double film_sec) const {
if (!sampler_) { os << "[benchmark] no sampler — run with --benchmark\n"; return; }
print_impl(os, sampler_(), film_sec);
}
private:
void print_impl(std::ostream& os, const kpn::NetworkSnapshot& snap,
double film_sec) const {
const double wall = snap.elapsed_s;
const auto chans = channels();
const auto cost = attribute_cost(snap.nodes, chans, wall);
os << "\n┌─ Pipeline benchmark (VR-015) ──────────────────────────────────────────────\n";
os << "│ wall " << std::fixed << std::setprecision(1) << wall << "s";
if (film_sec > 0.0)
os << " film " << film_sec << "s realtime x" << std::setprecision(2)
<< (wall > 0.0 ? film_sec / wall : 0.0);
os << " samples " << sample_count_ << "\n\n";
os << "│ node frames cpu_s cpu%run cpu%tot cpu/f"
" exec/f stall/f in% out% press q/w\n";
for (const auto& c : cost) {
os << "" << (c.is_bottleneck ? "" : " ") << std::left << std::setw(16)
<< c.name << std::right
<< std::setw(7) << c.frames
<< std::setw(10) << std::setprecision(1) << (c.cpu_ms / 1000.0)
<< std::setw(9) << std::setprecision(0) << (100.0 * c.cpu_share)
<< std::setw(9) << std::setprecision(0) << c.cpu_pct_of_pipeline
<< std::setw(8) << std::setprecision(2) << c.cpu_ms_per_frame
<< std::setw(8) << std::setprecision(2) << c.exec_ms_per_frame
<< std::setw(9) << std::setprecision(2) << c.stall_ms_per_frame
<< std::setw(7) << std::setprecision(0) << c.in_fill_pct
<< std::setw(7) << std::setprecision(0) << c.out_fill_pct
<< std::setw(8) << std::setprecision(1) << c.pressure
<< " " << int(c.queued) << "/" << int(c.wake_pending)
<< "\n";
}
/// TRACES: VR-015 | AR-004 | PR-004
// Fires only when the scheduling state is actually wrong, so a healthy
// run stays quiet and a wedged one names the fault — instead of leaving
// it to be reconstructed under a debugger that suppresses the bug.
for (const auto& c : cost) {
if (c.queued || !c.has_input) continue;
if (c.wake_pending)
os << "│ !! " << c.name << " idle with a wake outstanding"
" (queued=0 wake=1): the wake was recorded and never"
" consumed — submit/release handshake.\n";
else if (c.in_fill_pct > 50.0)
os << "│ !! " << c.name << " idle with a "
<< std::setprecision(0) << c.in_fill_pct
<< "% full input and no wake pending: the wake was never"
" generated — channel edge detection.\n";
}
os << "\n│ channel cap mean% peak% full%"
" empty% MB/s\n";
for (const auto& ch : chans) {
os << "" << std::left << std::setw(36) << ch.name << std::right
<< std::setw(5) << ch.capacity
<< std::setw(7) << std::setprecision(1) << ch.mean_fill_pct()
<< std::setw(7) << ch.peak_pct()
<< std::setw(7) << ch.full_pct()
<< std::setw(7) << ch.empty_pct()
<< std::setw(9) << std::setprecision(1) << ch.bandwidth_mbs(wall)
<< "\n";
}
os << "\n" << verdict(cost) << "\n";
os << "└────────────────────────────────────────────────────────────────────────────\n";
os << " cpu_s / cpu%tot is where the run's compute actually went. exec/f is wall\n"
" time in the node INCLUDING time parked on a full output channel, so it\n"
" overstates a backpressured node — compare it against cpu/f, which cannot\n"
" be inflated that way. press = input fill output fill, and locates the\n"
" node that work is queueing up in front of.\n"
" cpu_s counts THIS node's thread only: work OpenCV fans out via TBB is\n"
" billed to the TBB arena, so a node using cv::parallel_for_ reads cheaper\n"
" than it is and the difference surfaces in stall/f.\n";
}
private:
void accumulate(const kpn::NetworkSnapshot& snap) {
++sample_count_;
for (const auto& ch : snap.channels) {
auto& occ = occupancy_[ch.name];
if (occ.name.empty()) {
occ.name = ch.name;
occ.capacity = ch.capacity;
split_edge_name(ch.name, occ.producer, occ.consumer);
}
occ.fill_sum += static_cast<double>(ch.current_fill);
++occ.samples;
if (ch.capacity && ch.current_fill >= ch.capacity) ++occ.samples_full;
if (ch.current_fill == 0) ++occ.samples_empty;
}
}
std::chrono::milliseconds interval_;
Sampler sampler_;
std::thread thread_;
std::atomic<bool> running_{false};
bool stopped_{false};
std::uint64_t sample_count_{0};
std::map<std::string, ChannelOccupancy> occupancy_;
kpn::NetworkSnapshot final_{};
};
} // namespace sae::bench
+38 -150
View File
@@ -11,20 +11,6 @@ enum class Verbosity {
standard, // per-frame detail: bbox, similarity, unknowns logged standard, // per-frame detail: bbox, similarity, unknowns logged
xray, // Jellyfin-Xray format: {"second": ["Actor", ...], ...} xray, // Jellyfin-Xray format: {"second": ["Actor", ...], ...}
}; };
// How a track's accepted frames become a reported presence window.
enum class PresenceMode {
// A claim IS its track's [first_seen, last_seen] (AR-012/AR-013). The
// default and the only mode whose semantics the register validated.
track_extent,
// Flood-fill: snap each claim to the shot it sits in, so an actor seen once
// anywhere in a scene is reported for the whole scene [prev_boundary,
// next_boundary]. Trades precision for recall against X-Ray's per-scene cast
// granularity. Snaps to TransNetV2 shot boundaries (is_scene_boundary) when a
// scene detector populated them, else to the always-on histogram cuts
// (is_cut). With no boundaries at all it degrades to track_extent per claim.
flood,
};
// debug verbosity = compile with -DSAE_DEBUG → scene_analyze_debug binary // debug verbosity = compile with -DSAE_DEBUG → scene_analyze_debug binary
struct Config { struct Config {
@@ -46,14 +32,6 @@ struct Config {
// scripts/optimizer/SCHEMA.md) for offline threshold-sweep replay via sae_kpn. // scripts/optimizer/SCHEMA.md) for offline threshold-sweep replay via sae_kpn.
std::string dump_embeddings_path; std::string dump_embeddings_path;
/// TRACES: VR-015 | PR-004
// When set, write a per-node timing and bottleneck report here (src/
// benchmark.hpp) and print it at shutdown. Costs one background thread
// reading relaxed atomics on a timer, so it is safe to leave on, but a
// measurement run should still be isolated (nothing else on the GPU).
std::string benchmark_path;
int benchmark_interval_ms{100}; // channel-occupancy sampling period
// ── Sampling ───────────────────────────────────────────────────────────── // ── Sampling ─────────────────────────────────────────────────────────────
float sample_fps{1.0f}; // frames to analyse per second of movie float sample_fps{1.0f}; // frames to analyse per second of movie
float max_decode_fps{0.f}; // wall-clock cap on source decode rate (0 = uncapped) float max_decode_fps{0.f}; // wall-clock cap on source decode rate (0 = uncapped)
@@ -85,51 +63,16 @@ struct Config {
std::string arcface_model; std::string arcface_model;
std::string arcface_engine; // optional path to a pre-built TRT engine; bypasses ORT std::string arcface_engine; // optional path to a pre-built TRT engine; bypasses ORT
int embed_batch_size{4}; // max faces per ORT Run() call — bounds per-call latency int embed_batch_size{4}; // max faces per ORT Run() call — bounds per-call latency
float match_prior{0.433f}; // base-rate prior; 10-knob DE optimum (was 0.5) float match_prior{0.5f}; // base-rate prior; 0.5 = use calibrated sigmoid directly
// Tuned by Differential Evolution against Amazon X-Ray per-second presence // prob_threshold tuned by Differential Evolution against Amazon X-Ray per-scene
// over ALL 9 films (opencv5 build, LVFace-B_Glint360K, full gallery, // presence over 4 films, per-second metric (see docs/rep4-optimizer-results.md).
// expansion on), a 10-parameter sweep — see docs/model-bakeoff.md. The // Best model+mode: LVFace-B_Glint360K, full gallery, expansion on. Supersedes the
// per-second misID-weighted macro-F1 optimum is 64.0% (P 79.0%, R 61.1%). // earlier 9-film scene-union-metric tuning (0.76) — that metric is now known to
// // have hidden out-of-cast false positives (see docs/optimizer-experiments.md).
// This is a permissive operating point: the sweep discovered that with float prob_threshold{0.754f}; // posterior P(match | sim, prior) threshold
// flood-fill presence recovering recall, a LOW threshold pays off. It float match_threshold{0.45f}; // cosine distance hard ceiling fallback (no calibration)
// supersedes the earlier 0.754, which came from a 4-film subset under the float match_ratio{0.80f}; // ratio test fallback: accept if best/second < ratio
// now-withdrawn anneal/extinction windows and was never re-derived after a float match_ratio_ceil{0.65f}; // ratio test only fires below this absolute distance
// scoring-bug fix. The full-9-film sweep at 0.485 beats it.
//
// Caveat, still true: the optimum generalises unevenly. It is strong on 7 of
// 9 films (F1 6280%) and weak on two — The Many Saints of Newark (an
// ensemble of look-alikes; nearly all the run's misIDs land here) and
// Scarface (sparse cuts, so flood-fill over-extends: R 95% / P 26%). Both
// were the low outliers in every prior run too. Shipped because it wins on
// average and on the misID-weighted objective; not a settled, film-agnostic
// constant.
float prob_threshold{0.485f}; // posterior P(match | sim, prior) threshold
// TRACES: AR-024 | SR-002
// match_threshold (0.45), match_ratio (0.80) and match_ratio_ceil (0.65) are
// RETIRED, joining track_max_embed_dist, cut_revive_sim, expand_novelty_sim
// and expand_track_spread_max. All were raw cosine distances, and they were
// the accept rule whenever the calibration fit failed — so the one situation
// in which the pipeline knew its probabilities were untrustworthy was the
// one in which it stopped using them. An unfitted sigmoid is now the
// fallback everywhere, which is at least the same wrong number in every
// stage. See identity_matcher_node.hpp.
// ── Presence derivation ──────────────────────────────────────────────────
// How accepted frames become a reported window. flood requires scene_detect.
// Default flood: the 10-knob DE optimum uses it — snapping presence to the
// shot recovers enough recall against X-Ray's scene-level cast to win the
// misID-weighted F1, at a precision cost that is a net gain on 7 of 9 films.
// Falls back to track_extent per claim when no boundaries exist. See
// docs/model-bakeoff.md and PresenceMode above.
PresenceMode presence_mode{PresenceMode::flood};
// Path to the learned XGBoost scene-boundary model. When set (build has
// SAE_SCENE_XGB), the camera-position node stamps a per-frame RGB histogram
// and the sink runs the detector post-EOF to supply flood-fill boundaries —
// the measured best flood boundary source (presence F1 ~76% vs ~64% for the
// always-on histogram cut). Empty → flood falls back to is_cut.
std::string scene_xgb_model;
// ── Cut detection ──────────────────────────────────────────────────────── // ── Cut detection ────────────────────────────────────────────────────────
float cut_threshold{0.70f}; // grayscale histogram correlation below this → hard cut float cut_threshold{0.70f}; // grayscale histogram correlation below this → hard cut
@@ -148,28 +91,17 @@ struct Config {
// at ~0.50; real boundaries spike to ~0.7+) // at ~0.50; real boundaries spike to ~0.7+)
int scene_stride{50}; // frames advanced between windows (≤ kWindow) int scene_stride{50}; // frames advanced between windows (≤ kWindow)
// Dense-decode knobs (only active with scene_detect). Dense decode of every // Dense-decode throughput knobs (only active with scene_detect). Dense decode
// native-rate frame is the pipeline's cost driver, which is what made the // of every native-rate frame is the pipeline's cost driver; these trade a
// temporal shortcut below tempting. // little boundary precision for a large speedup.
/// TRACES: AR-011 | SR-002 // scene_decode_fps: rate the source decodes at in dense mode. Lower =
// scene_decode_fps: rate the source decodes at in dense mode. // fewer frames decoded. TransNetV2 tolerates ~12fps; boundary timestamps
// **0 = native, and native is the only correct setting.** kWindow is 100 // stay correct (keyed off each frame's real timestamp). 0 = native fps.
// frames: at native 25 fps that window spans ~4 s, which is what
// TransNetV2 was trained on; at the 12 fps this used to default to it
// spans ~8.3 s, so the model saw half-speed motion over twice its
// temporal context. Boundary *timestamps* stay right either way — 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 export rather than of the input. Lowering this
// buys decode time by running the model off-distribution; reach for
// dense_scale or scene_stride instead, which do not.
// dense_scale: downscale factor applied to decoded frames in dense mode // dense_scale: downscale factor applied to decoded frames in dense mode
// (0<f≤1; e.g. 0.5 = half size). Cheaper sws_scale + smaller frames // (0<f≤1; e.g. 0.5 = half size). Cheaper sws_scale + smaller frames
// through the fanout. A spatial reduction, and TransNetV2 downsamples to // through the fanout. NOTE: also shrinks what the face detector sees
// 48×27 regardless, so unlike the above it is a documented, understood // keep ≥0.5 on 1080p sources so SCRFD still resolves small faces. 1 = off.
// degradation. NOTE: also shrinks what the face detector sees — keep float scene_decode_fps{12.0f}; // dense decode rate (0 = native)
// ≥0.5 on 1080p sources so SCRFD still resolves small faces. 1 = off.
float scene_decode_fps{0.f}; // dense decode rate (0 = native)
float dense_scale{1.0f}; // dense-mode frame downscale (1 = off) float dense_scale{1.0f}; // dense-mode frame downscale (1 = off)
// ── Face tracking (frame-to-frame) ─────────────────────────────────────── // ── Face tracking (frame-to-frame) ───────────────────────────────────────
@@ -178,7 +110,7 @@ struct Config {
// frame-dependent (AR-007): on is_cut / is_scene_boundary, and for any track // frame-dependent (AR-007): on is_cut / is_scene_boundary, and for any track
// that is no longer on screen, it drops to 0 (embedding only), because // that is no longer on screen, it drops to 0 (embedding only), because
// position carries no information across a viewpoint change or a gap. // position carries no information across a viewpoint change or a gap.
float track_alpha{0.435f}; // base cost weight: 0=embedding only, 1=spatial only (10-knob DE optimum) float track_alpha{0.4f}; // base cost weight: 0=embedding only, 1=spatial only
float track_min_iou{0.1f}; // IoU below which spatial link alone is rejected float track_min_iou{0.1f}; // IoU below which spatial link alone is rejected
// Minimum P(same person) for an association to be admissible on appearance // Minimum P(same person) for an association to be admissible on appearance
// alone. This replaces track_max_embed_dist (a raw cosine distance, AR-024). // alone. This replaces track_max_embed_dist (a raw cosine distance, AR-024).
@@ -191,60 +123,19 @@ struct Config {
// Replaces track_max_frames_missing: a frame count silently changed meaning // Replaces track_max_frames_missing: a frame count silently changed meaning
// with sample_fps, and the same number had to be guessed twice (once for an // with sample_fps, and the same number had to be guessed twice (once for an
// ordinary miss, once for a cut). Seconds mean one thing at any sample rate. // ordinary miss, once for a cut). Seconds mean one thing at any sample rate.
double track_extinction_sec{31.0}; // 10-knob DE optimum (was 5.0) double track_extinction_sec{5.0};
// ── Ownership and evidence accumulation (AR-025) ──────────────────────────
// TRACES: AR-025, AR-017 | SR-002
// These four decided how presence is claimed and were unreachable: they
// lived as in-class initialisers on TrackRegistry::Config and
// EvidenceDiscounter::Config, and main constructed the discounter with the
// one-argument constructor, so nothing short of a recompile could move
// them. rho_max's own comment defers to "the sweep (VR-007)" for where it
// belongs — a sweep that could not reach it.
//
// ownership_logodds is arguably the most consequential constant in the
// pipeline after prob_threshold: below it a track produces no presence
// claim at all, so it decides whether an actor is reported rather than how
// confidently. 1.72 is a posterior of ~0.85 — the 10-knob DE optimum (was
// an unswept 2.0 ≈ 0.88); slightly more permissive, consistent with the
// low-threshold operating point the sweep converged on.
float ownership_logodds{1.72f};
// How much a single observation may move a track's belief. n_eff =
// n / (1 + (n-1)·rho), so rho_max caps what a repeated view can ever be
// worth: 0.5 caps it at two independent observations however long the shot
// runs. It is deliberately below 1 — a held pose still yields a fresh
// detection, alignment and noise realisation, so a little independent
// evidence survives. Setting it to 1 freezes belief after the first frame,
// which is the bug this replaced.
float evidence_rho_max{0.204f}; // 10-knob DE optimum (was 0.5): weights a
// held pose closer to a single observation
// P(same view) below this and the observation counts as a genuinely new
// look, so it joins the per-track view set.
float evidence_admit_below{0.784f}; // 10-knob DE optimum (was 0.6)
// Distinct views remembered per track, which bounds the novelty comparison.
int evidence_max_views{8};
// ── Scene tracking ──────────────────────────────────────────────────────── // ── Scene tracking ────────────────────────────────────────────────────────
// TRACES: AR-012, AR-013 | SR-002 // extinction_sec re-tuned by DE against X-Ray per-second presence, 4-film rep4
// extinction_sec (57.4) and anneal_sec (35.5) are GONE, along with // matrix (docs/rep4-optimizer-results.md). Reverses the earlier "short is better"
// SceneTrackerFunc, which is what read the first of them. docs/SPEC.md // finding: with a stricter prob_threshold, a long extinction window bridges real
// specified this removal and ended it "grep for both names and expect no // presence gaps (occlusion, turned face) instead of just smearing FPs — every
// survivors"; there were about forty, and the register meanwhile recorded // model's best config pushed to ~90%+ of the search ceiling (tried up to 60s).
// both as Withdrawn and "deleted rather than retained at zero" on the // The ceiling kept getting hit, so treat 60 as "good enough", not a proven optimum.
// grounds that a field naming a mechanism the pipeline no longer has is double extinction_sec{57.4}; // keep actor active this many seconds after last detection
// actively misleading. // anneal_sec: previously found INSENSITIVE at a 130s range; the wider rep4 sweep
// // (160s) also pushed this to the ceiling alongside extinction_sec (see above).
// Both existed to bridge gaps between isolated accepted frames. A track double anneal_sec{35.5}; // merge actor windows separated by less than this into one epoch
// that survives its own gaps leaves them nothing to do: AR-012 makes a
// window the extent of a track an actor owns, and AR-013 ends it at the
// last sighting. The keep-alive answered the same question again and
// answered it worse, by re-opening exactly the trailing cool-down AR-013
// refuses.
//
// track_extinction_sec above is NOT the same knob under a new name. It
// bounds how long a lost track stays available for re-association, which is
// a tracking question; it never extends a presence claim.
// ── Per-film gallery expansion ──────────────────────────────────────────── // ── Per-film gallery expansion ────────────────────────────────────────────
// Within one uncut track every face is the same physical person — a free // Within one uncut track every face is the same physical person — a free
@@ -253,12 +144,9 @@ struct Config {
// new reference views; they are promoted into a per-film, in-memory annex so // new reference views; they are promoted into a per-film, in-memory annex so
// later frames/tracks of that actor at similar poses recognise. See // later frames/tracks of that actor at similar poses recognise. See
// gallery/track_gallery.hpp. // gallery/track_gallery.hpp.
// Default ON: the rep4 matrix (docs/model-bakeoff.md, "Two effects in // Default ON: rep4 matrix (docs/rep4-optimizer-results.md) found expansion helps
// isolation") found expansion helps recall on the full (unrestricted) // recall on the full (unrestricted) gallery for the winning model/mode — the
// gallery for the winning model/mode — the opposite of the earlier // opposite of the earlier assumption that it only helps restricted galleries.
// assumption that it only helps restricted galleries. The same section is
// explicit that on the full gallery it buys +2.1pp F1 and +3.9pp recall
// "at a real cost" in misIDs, where in restricted mode it is a clean win.
bool expand_gallery{true}; // master switch bool expand_gallery{true}; // master switch
int expand_buffer_size{20}; // per-track diversity buffer capacity int expand_buffer_size{20}; // per-track diversity buffer capacity
// TRACES: AR-018, AR-024 | SR-005 // TRACES: AR-018, AR-024 | SR-005
@@ -269,10 +157,10 @@ struct Config {
// at promotion time — see track_gallery.hpp. This is the only threshold the // at promotion time — see track_gallery.hpp. This is the only threshold the
// expansion path has: it replaces the raw-cosine expand_novelty_sim (0.55) // expansion path has: it replaces the raw-cosine expand_novelty_sim (0.55)
// and expand_track_spread_max (0.60), which are retired (AR-024). // and expand_track_spread_max (0.60), which are retired (AR-024).
// 10-knob DE optimum (was 0.90/0.95). The sweep widened the band — a lower lo // Working values pending VR-007; sweep both bounds, they fail in opposite
// admits more pose-varied views into the annex — which the optimum preferred. // directions.
float expand_band_lo{0.804f}; float expand_band_lo{0.90f};
float expand_band_hi{0.952f}; float expand_band_hi{0.95f};
int expand_min_anchor_frames{3}; // require ≥N accepted frames naming the actor before int expand_min_anchor_frames{3}; // require ≥N accepted frames naming the actor before
// the track is confirmed and its buffer promoted // the track is confirmed and its buffer promoted
std::string expand_debug_dir; // if set, dump promoted mugshots + embeddings here std::string expand_debug_dir; // if set, dump promoted mugshots + embeddings here
-6
View File
@@ -8,12 +8,6 @@
// gallery file needed. Purpose-built for the optimizer's replay corpus and the // gallery file needed. Purpose-built for the optimizer's replay corpus and the
// embedding-model bake-off (dump each --arcface model over the film set). // embedding-model bake-off (dump each --arcface model over the film set).
// //
// The camera-position (histogram) cut detector runs, so frames/is_cut is recorded
// in the dump. Flood-fill presence snaps to those cuts. TransNetV2 scene detection
// is NOT run here: on the ROCm build it needs MIGraphX, whose statically-linked
// LLVM collides with the VAAPI decoder's system libLLVM and crashes at compile, so
// the two cannot share a process. is_scene_boundary therefore stays 0 here.
//
// Usage: // Usage:
// dump_embeddings --movie <path> --out <dump.h5> [--arcface <model.onnx>] // dump_embeddings --movie <path> --out <dump.h5> [--arcface <model.onnx>]
// [--detector <model.onnx>] [--fps 1] [--start S] [--end S] // [--detector <model.onnx>] [--fps 1] [--start S] [--end S]
+1 -91
View File
@@ -1,5 +1,5 @@
#pragma once #pragma once
/// TRACES: AR-005, AR-029, AR-030 | SR-002 /// TRACES: AR-005, AR-030 | SR-002
#include "types.hpp" #include "types.hpp"
#include <opencv2/core.hpp> #include <opencv2/core.hpp>
@@ -143,96 +143,6 @@ inline cv::Mat align_face(const cv::Mat& img,
return crop; return crop;
} }
// ── crop_sharpness ────────────────────────────────────────────────────────────
/// TRACES: AR-029 | SR-002
//
// Normalised variance of the Laplacian over the aligned 112×112 crop: the AR-029
// sharpness axis. Returns -1 for an empty crop (unscored), matching the
// DetectedFace sentinel.
//
// sharpness = Var(∇²I) / Var(I)
//
// Two normalisations, each removing a quantity that would otherwise be read as
// blur:
//
// - **Divided by the image variance, so contrast cannot leak in.** Scaling
// intensity by α scales the Laplacian by α too, so both variances scale by α²
// and the ratio is unchanged. A raw Var(∇²I) — the textbook measure — instead
// falls with exposure, so a dim scene reads as soft and a graded-up one as
// sharp. VR-012 has to locate one knee across films whose grading differs by
// more than their focus does; an uncalibrated measure would put the knee in a
// different place per film, which is the AR-024 failure in another metric.
// - **Measured on the aligned crop, so size cannot leak in.** The destination
// frame is fixed at 112×112 (AR-002 owns size, and double-counting it here
// would make every small face read as blurred). What the ratio reports is the
// detail actually present in the embedder's input — so a small sharp face can
// and does outscore a large soft one. That is the claim; it is *not* a claim
// of invariance to source resolution, because a 40 px face warped up to 112
// genuinely carries less detail, and hiding that would defeat the point.
//
// Frequency-domain reading of why the blur ladder is monotone: with
// Var(∇²I) = ∫|ω|⁴|F(ω)|² and Var(I) = ∫|F(ω)|², the ratio is E[|ω|⁴] under the
// image's own spectral measure. Gaussian blur multiplies that measure by
// e^{-σ²|ω|²}, concentrating it at low |ω|, so the expectation falls strictly
// with σ. It is a property of the construction, not a fitted behaviour.
//
// **Three known hazards, for VR-012 to check rather than for a threshold to
// absorb.** All are recorded here because they are properties of the measure,
// visible in the dumped distribution, and neither should be papered over by a
// correction chosen before that distribution has been looked at.
//
// 1. **Border fill.** `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 it — the fill
// inflates Var(I) as well as Var(∇²I) — but does not remove it, so
// heavily-cropped faces may read sharper than they are. The fix is either a
// validity mask or a different border mode, and the second changes what the
// embedder is fed (AR-011).
//
// 2. **The contrast invariance is exact in the algebra and approximate in
// 8 bits.** Scaling I by α cancels exactly; what does not cancel is the
// quantisation floor of a stored crop, which is broadband and so lands in
// the numerator. It matters only where there is little signal left to
// compete with it: on the AR-029 test texture a half-contrast copy reads
// 0.9% high when sharp, 24% high at sigma 1.2 and 148% high at sigma 2.5.
// A crop that is both **dim and soft therefore reads sharper than it is** —
// the low corner of the axis, and the corner VR-012 must put a knee in.
//
// 3. **It reports where the energy sits, not how much there is.** A crop whose
// energy is *already* concentrated at high frequency — dense film grain,
// a face against foliage — loses numerator and denominator together under
// blur, so the ratio moves less than the damage does. Measured on a
// flat-spectrum synthetic, an anisotropic (motion) smear even makes it rise,
// because the surviving perpendicular detail really is as fine as before.
// Natural crops have the low-frequency mass that keeps the denominator
// steady, and on those both ladders fall (see the AR-029 tests, which use a
// 1/f texture for exactly this reason). The same property means the axis
// conflates focus with intrinsic texture — a bearded face outscores a smooth
// one at equal focus — which is true of every no-reference sharpness measure
// and is why AR-028 carries the number instead of thresholding on it.
inline float crop_sharpness(const cv::Mat& crop) {
if (crop.empty()) return -1.f;
cv::Mat gray;
if (crop.channels() == 3) cv::cvtColor(crop, gray, cv::COLOR_BGR2GRAY);
else gray = crop;
cv::Mat lap;
cv::Laplacian(gray, lap, CV_32F, 3);
cv::Scalar mean_i, sd_i, mean_l, sd_l;
cv::meanStdDev(gray, mean_i, sd_i);
cv::meanStdDev(lap, mean_l, sd_l);
const double var_i = sd_i[0] * sd_i[0];
// A flat crop has no detail to be sharp or soft about, and the ratio is 0/0.
// Zero is the honest answer and keeps the axis finite; -1 would claim the
// face was never scored, which is a different fact.
if (var_i < 1e-6) return 0.f;
return static_cast<float>((sd_l[0] * sd_l[0]) / var_i);
}
// ── enhance_for_retry ──────────────────────────────────────────────────────── // ── enhance_for_retry ────────────────────────────────────────────────────────
// Used when initial face detection finds nothing. Pads the image by 50% // Used when initial face detection finds nothing. Pads the image by 50%
// (border-replicated, so the detector doesn't see a hard edge) and applies // (border-replicated, so the detector doesn't see a hard edge) and applies
-13
View File
@@ -162,19 +162,6 @@ inline GalleryCalibration calibrate_gallery(
for (const auto& e : by_actor[ai]) { for (const auto& e : by_actor[ai]) {
bool dup = false; bool dup = false;
for (const auto& k : kept) { for (const auto& k : kept) {
// EXCEPTION: AR-024 this asks whether two vectors are THE SAME
// VECTOR, not whether two faces are the same person.
//
// Two independent reasons, either sufficient. First, at
// 1 - 1e-7 the threshold is a floating-point identity test: it
// catches one source image embedded twice, and no genuine pair
// of distinct photographs lands there. Nothing about it is a
// decision, so there is nothing for a probability to mean.
//
// Second, and structurally: this IS the calibration fit. The
// dedup runs on its input, before (a, b) exist. A calibrated
// comparison here would have to be calibrated by the fit it is
// feeding, which is not a thing that can be arranged.
if (cosine_similarity(e, k) > kDedupSimThreshold) { dup = true; break; } if (cosine_similarity(e, k) > kDedupSimThreshold) { dup = true; break; }
} }
if (!dup) kept.push_back(e); if (!dup) kept.push_back(e);
+54 -117
View File
@@ -9,7 +9,6 @@
#include <iostream> #include <iostream>
#include <limits> #include <limits>
#include <map> #include <map>
#include <stdexcept>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -47,19 +46,18 @@
// cosines this replaces, expand_novelty_sim and expand_track_spread_max, are // cosines this replaces, expand_novelty_sim and expand_track_spread_max, are
// retired under AR-024. // retired under AR-024.
// //
// TRACES: AR-026 | SR-001 // The annex is CPU-side and in-memory: it is small (tens of embeddings) so the
// The annex is in-memory and discarded when the process exits, but it is NOT // matcher scans it with a scalar loop, and it is discarded when the process
// small: every owned track contributes, so it grows with cast size and film // exits. Promoted embeddings only help SUBSEQUENT frames and later tracks of A —
// length. It is therefore held as a contiguous row-major matrix with a parallel // the pipeline stays streaming, no emitted output is buffered or relabelled.
// actor index — the same flat_emb_/flat_actor_ shape the baked gallery uses —
// and the matcher hands promoted rows to the similarity engine rather than
// scanning them with a host-side loop. The deferred pass (AR-020) needs the same
// contiguous operand to score the TBI queue against in one multiply.
//
// Promoted embeddings only help SUBSEQUENT frames and later tracks of A — the
// pipeline stays streaming, no emitted output is buffered or relabelled.
struct TrackGallery { struct TrackGallery {
// One promoted reference view held in the per-actor annex.
struct AnnexEntry {
Embedding emb;
int actor_idx{-1};
};
explicit TrackGallery(const Config& cfg) explicit TrackGallery(const Config& cfg)
: enabled_(cfg.expand_gallery) : enabled_(cfg.expand_gallery)
, buffer_size_(std::max(1, cfg.expand_buffer_size)) , buffer_size_(std::max(1, cfg.expand_buffer_size))
@@ -82,39 +80,10 @@ struct TrackGallery {
bool enabled() const { return enabled_; } bool enabled() const { return enabled_; }
/// TRACES: AR-026 | SR-001 // Current annex contents (empty when disabled). The matcher scans these
/// The annex as a contiguous row-major matrix (annex_size() × 512) plus the // alongside the baked gallery so a promoted view can win best-of-N for its
/// parallel actor index. Only ever grows, never reordered, so a row index is // actor. Returned by const-ref; only grows, never reordered.
/// stable for the life of the film — which is what lets the similarity const std::vector<AnnexEntry>& annex() const { return annex_; }
/// engine hold the same rows and the actor mapping stay a plain vector.
int annex_size() const { return static_cast<int>(annex_actor_.size()); }
const float* annex_data() const { return annex_emb_.data(); }
const std::vector<int>& annex_actors() const { return annex_actor_; }
/// One annex row (512 floats). The deferred pass (AR-020) scores the whole
/// matrix at once via annex_data(); this is for inspecting a single view.
const float* annex_row(int i) const {
return annex_emb_.data() + static_cast<size_t>(i) * kEmbDim;
}
/// TRACES: AR-026 | SR-001
/// Hand the caller every row promoted since the previous call, appending to
/// its buffers, and return how many. The matcher pushes these into the
/// similarity engine so the next frame's single GEMM covers the annex —
/// draining rather than re-reading the whole matrix keeps that O(promoted),
/// not O(annex), per frame.
int drain_promotions(std::vector<float>& emb_out, std::vector<int>& actor_out) {
const int pending = annex_size() - drained_;
if (pending <= 0) return 0;
emb_out.insert(emb_out.end(),
annex_emb_.begin() + static_cast<size_t>(drained_) * kEmbDim,
annex_emb_.end());
actor_out.insert(actor_out.end(),
annex_actor_.begin() + drained_, annex_actor_.end());
drained_ = annex_size();
return pending;
}
// Offer one observed face to its track's diversity buffer. // Offer one observed face to its track's diversity buffer.
// track_id : face_tracker track (1 = untracked, ignored) // track_id : face_tracker track (1 = untracked, ignored)
@@ -133,52 +102,25 @@ struct TrackGallery {
TrackState& ts = tracks_[track_id]; TrackState& ts = tracks_[track_id];
/// TRACES: AR-019 | SR-005 // Vote toward ownership: only accepted frames name an actor, and a track
// accepted_frames is an EVIDENCE FLOOR, not an identity decision: it // that flip-flops between actors is ambiguous, so we tally per actor and
// asks "has this track been recognised often enough to be worth // pick the plurality winner at confirmation time.
// promoting", never "who is it". Who it is comes from the registry. if (accepted && best_actor >= 0) {
// ts.actor_votes[best_actor]++;
// There used to be a per-actor tally here too, and promote() fell back ts.accepted_frames++;
// to its plurality winner. That made two answers to "who is this track" }
// able to coexist, and the local one ignored the Bayesian accumulation
// entirely -- weighting thirty near-identical looks the same as thirty
// distinct ones, which is exactly what AR-025's discounting exists to
// stop. Since promotion only fired on the local count, the fallback was
// reachable in the live pipeline and not merely in tests: three
// accepted frames arrive well before a posterior crosses ownership.
if (accepted && best_actor >= 0) ts.accepted_frames++;
insert_into_buffer(ts, emb, best_gal_sim, crop); insert_into_buffer(ts, emb, best_gal_sim, crop);
// Confirm and promote once BOTH hold: the registry owns this track, and // Confirm and promote as soon as the anchor threshold is met, once.
// enough frames have been accepted to be worth the slots. Ownership is if (!ts.promoted && ts.accepted_frames >= min_anchor_frames_)
// the necessary one -- without it there is no actor to promote into.
if (!ts.promoted && ts.registry_owner >= 0 &&
ts.accepted_frames >= min_anchor_frames_)
promote(track_id, ts); promote(track_id, ts);
} }
/// TRACES: AR-019 | SR-005 // Drop a track's buffer when the face_tracker expires it or on a scene cut,
/// Drop the buffers of tracks the registry no longer has. // so stale/cross-cut embeddings can never be promoted later. Called by the
/// // matcher when it observes a cut or track disappearance.
/// `alive` is the registry's own liveness test, so this annotates the track void forget(int track_id) { tracks_.erase(track_id); }
/// pool rather than duplicating it — the same shape as FaceTrackerFunc's
/// prune_boxes, and for the same reason: a second opinion about which
/// tracks exist is a second thing that can be wrong.
///
/// This replaces a `forget(int)` that had NO callers, under a comment
/// asserting "called by the matcher when it observes a cut or track
/// disappearance". The cut half was true by another route (clear_tracks);
/// the disappearance half was not, so a track that died quietly kept its
/// buffer until the next cut cleared everything.
template <typename AlivePredicate>
void prune_dead(const AlivePredicate& alive) {
if (!enabled_) return;
for (auto it = tracks_.begin(); it != tracks_.end(); ) {
if (alive(it->first)) ++it;
else it = tracks_.erase(it);
}
}
/// TRACES: AR-019 | SR-005 /// TRACES: AR-019 | SR-005
/// The registry's verdict on who this track is. Authoritative: it comes from /// The registry's verdict on who this track is. Authoritative: it comes from
@@ -191,20 +133,10 @@ struct TrackGallery {
} }
/// TRACES: AR-024 | SR-005 /// TRACES: AR-024 | SR-005
/// Supply the calibration belonging to the active embedder. /// Supply the calibration belonging to the active embedder. Without it the
/// /// band falls back to treating cosine as probability, which is wrong but
/// Required, not optional. The default used to be `max(0, cosine)` — a raw /// bounded — and the default is loud in the header rather than silent.
/// cosine worn as a probability, which made `expand_band_lo = 0.90` mean void set_calibration(std::function<float(float)> c) { calibrate_ = std::move(c); }
/// "cosine above 0.9" in a test and "P(same person) above 0.9" in
/// production. Those are wildly different gates, and nothing announced the
/// switch. `FaceTrackerFunc` already refuses to construct without a
/// calibration for the same reason; this now matches it.
void set_calibration(std::function<float(float)> c) {
if (!c) throw std::invalid_argument(
"track_gallery: a calibration is required — the admission band is "
"expressed in probability space (AR-024)");
calibrate_ = std::move(c);
}
/// Embeddings the band refused. A store that admits nothing is as wrong as /// Embeddings the band refused. A store that admits nothing is as wrong as
/// one that admits everything, and neither is visible without this. /// one that admits everything, and neither is visible without this.
@@ -225,6 +157,7 @@ private:
struct TrackState { struct TrackState {
std::vector<BufEntry> buf; std::vector<BufEntry> buf;
std::map<int, int> actor_votes; // actor_idx → accepted-frame count
int accepted_frames{0}; int accepted_frames{0};
bool promoted{false}; bool promoted{false};
int registry_owner{-1}; ///< AR-019: authoritative int registry_owner{-1}; ///< AR-019: authoritative
@@ -297,8 +230,8 @@ private:
void promote(int track_id, TrackState& ts) { void promote(int track_id, TrackState& ts) {
ts.promoted = true; // idempotent: never promote a track twice ts.promoted = true; // idempotent: never promote a track twice
const int actor = ts.registry_owner; int actor = owning_actor(ts);
if (actor < 0) return; // unreachable: observe() gates on this if (actor < 0) return;
// ── Safety gate: the band's lower bound, across the whole store ────── // ── Safety gate: the band's lower bound, across the whole store ──────
float worst = store_coherence(ts.buf); float worst = store_coherence(ts.buf);
@@ -312,10 +245,7 @@ private:
int added = 0; int added = 0;
for (const auto& be : ts.buf) { for (const auto& be : ts.buf) {
// Row-major append: the matrix stays contiguous so the matcher can annex_.push_back({be.emb, actor});
// hand whole blocks of new rows to the GEMM path (AR-026).
annex_emb_.insert(annex_emb_.end(), be.emb.begin(), be.emb.end());
annex_actor_.push_back(actor);
if (!debug_dir_.empty() && !be.crop.empty()) if (!debug_dir_.empty() && !be.crop.empty())
dump_mugshot(track_id, actor, added, be); dump_mugshot(track_id, actor, added, be);
++added; ++added;
@@ -325,7 +255,22 @@ private:
<< " confirmed actor " << actor << " confirmed actor " << actor
<< " (" << ts.accepted_frames << " accepted frames, worst " << " (" << ts.accepted_frames << " accepted frames, worst "
<< "pairwise P=" << worst << ") — promoted " << added << "pairwise P=" << worst << ") — promoted " << added
<< " views; annex now " << annex_size() << "\n"; << " views; annex now " << annex_.size() << "\n";
}
/// Prefer the registry's verdict; fall back to the local tally only when no
/// registry is attached (unit tests, replay harness).
static int owning_actor(const TrackState& ts) {
if (ts.registry_owner >= 0) return ts.registry_owner;
return plurality_actor(ts);
}
static int plurality_actor(const TrackState& ts) {
int best = -1, best_votes = 0;
for (const auto& [ai, v] : ts.actor_votes) {
if (v > best_votes) { best_votes = v; best = ai; }
}
return best;
} }
/// TRACES: AR-018, AR-024 | SR-005 /// TRACES: AR-018, AR-024 | SR-005
@@ -363,9 +308,8 @@ private:
} }
/// cosine → P(same person). The one probability space the pipeline reasons /// cosine → P(same person). The one probability space the pipeline reasons
/// in; see gallery_calibration.hpp's same_person_probability. Never default /// in; see gallery_calibration.hpp's same_person_probability.
/// constructed to an identity-ish stand-in — see set_calibration. std::function<float(float)> calibrate_{[](float c) { return std::max(0.f, c); }};
std::function<float(float)> calibrate_;
std::size_t rejected_{0}; ///< admissions refused by the band std::size_t rejected_{0}; ///< admissions refused by the band
bool enabled_; bool enabled_;
@@ -376,12 +320,5 @@ private:
std::string debug_dir_; std::string debug_dir_;
std::map<int, TrackState> tracks_; std::map<int, TrackState> tracks_;
std::vector<AnnexEntry> annex_;
/// TRACES: AR-026 | SR-001
/// Contiguous annex matrix and its parallel actor index. `drained_` marks
/// how much of it the similarity engine already holds.
static constexpr int kEmbDim = 512;
std::vector<float> annex_emb_; ///< annex_size() × 512, row-major
std::vector<int> annex_actor_; ///< actor index per annex row
int drained_{0};
}; };
-143
View File
@@ -1,143 +0,0 @@
#pragma once
// Per-second audio log-PSD, C++ parity with scripts/scene_detector/
// extract_audio_features.py — the audio tower input for the XGBoost scene
// detector. Decodes the whole track to mono 16 kHz, then one FFT per second over
// a 4 s Hann-windowed window, power pooled into geomspace log-frequency bands,
// L1-normalised (shape not loudness) and log1p-compressed.
//
// Must match the Python exactly (SR=16000, WIN_SEC=4, N_BINS=64→geomspace unique
// edges, log1p(band*1e3)); the shipped model was trained on those features.
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
#include <libswresample/swresample.h>
}
#include <fftw3.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <vector>
class AudioLogPSD {
public:
static constexpr int kSR = 16000;
static constexpr double kHop = 1.0; // 1 feature row / second
static constexpr double kWin = 4.0; // FFT window seconds
static constexpr int kNBins = 64; // geomspace target (dedups to ~57)
// Returns [T][B] per-second log-PSD (T ≈ film seconds, B ≈ 57), aligned to the
// 1 fps grid. Empty on decode failure (caller then feeds a zero block).
static std::vector<std::vector<float>> extract(const std::string& path) {
std::vector<float> mono = decode_mono_16k(path);
if (mono.empty()) return {};
return features(mono);
}
// Public for the parity harness.
static std::vector<std::vector<float>> features(const std::vector<float>& mono) {
const int win = int(kSR * kWin), hop = int(kSR * kHop);
const int T = int(mono.size()) / hop;
if (T <= 0) return {};
const int nfreq = win/2 + 1;
std::vector<int> edges = geomspace_edges(nfreq);
const int nb = int(edges.size()) - 1;
// Hann window (matches scipy.signal.windows.hann, sym=True default → but
// numpy code uses sps.windows.hann(win) which is symmetric).
std::vector<double> hann(win);
for (int i = 0; i < win; ++i)
hann[i] = 0.5 - 0.5*std::cos(2.0*M_PI*i/(win-1));
std::vector<double> in(win);
auto* out = fftw_alloc_complex(nfreq);
fftw_plan plan = fftw_plan_dft_r2c_1d(win, in.data(), out, FFTW_ESTIMATE);
std::vector<std::vector<float>> feat(T, std::vector<float>(nb, 0.f));
const int half = win/2;
for (int t = 0; t < T; ++t) {
int centre = t*hop + hop/2;
int s = centre - half;
for (int i = 0; i < win; ++i) {
int idx = s + i;
double v = (idx >= 0 && idx < int(mono.size())) ? mono[idx] : 0.0;
in[i] = v * hann[i];
}
fftw_execute(plan);
// power spectrum + 1e-12
std::vector<double> psd(nfreq);
for (int i = 0; i < nfreq; ++i)
psd[i] = out[i][0]*out[i][0] + out[i][1]*out[i][1] + 1e-12;
std::vector<double> band(nb, 0.0);
double tot = 0.0;
for (int b = 0; b < nb; ++b) {
for (int i = edges[b]; i < edges[b+1]; ++i) band[b] += psd[i];
tot += band[b];
}
for (int b = 0; b < nb; ++b)
feat[t][b] = float(std::log1p(band[b]/tot * 1e3));
}
fftw_destroy_plan(plan); fftw_free(out);
return feat;
}
private:
// np.unique(np.geomspace(1, nfreq-1, N_BINS+1).astype(int))
static std::vector<int> geomspace_edges(int nfreq) {
const int n = kNBins + 1;
double a = std::log(1.0), b = std::log(double(nfreq-1));
std::vector<int> raw(n);
for (int i = 0; i < n; ++i)
raw[i] = int(std::exp(a + (b-a)*i/(n-1))); // .astype(int) truncates
std::vector<int> uniq;
for (int v : raw) if (uniq.empty() || v != uniq.back()) uniq.push_back(v);
return uniq;
}
static std::vector<float> decode_mono_16k(const std::string& path) {
AVFormatContext* fmt = nullptr;
if (avformat_open_input(&fmt, path.c_str(), nullptr, nullptr) < 0) return {};
std::vector<float> out;
SwrContext* swr = nullptr; AVCodecContext* dec = nullptr;
AVPacket* pkt = av_packet_alloc(); AVFrame* fr = av_frame_alloc();
try {
if (avformat_find_stream_info(fmt, nullptr) < 0) throw 0;
int ai = av_find_best_stream(fmt, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0);
if (ai < 0) throw 0;
AVStream* st = fmt->streams[ai];
const AVCodec* codec = avcodec_find_decoder(st->codecpar->codec_id);
dec = avcodec_alloc_context3(codec);
avcodec_parameters_to_context(dec, st->codecpar);
if (avcodec_open2(dec, codec, nullptr) < 0) throw 0;
AVChannelLayout out_ch = AV_CHANNEL_LAYOUT_MONO;
swr_alloc_set_opts2(&swr, &out_ch, AV_SAMPLE_FMT_FLT, kSR,
&dec->ch_layout, dec->sample_fmt,
dec->sample_rate ? dec->sample_rate : kSR, 0, nullptr);
if (!swr || swr_init(swr) < 0) throw 0;
while (av_read_frame(fmt, pkt) >= 0) {
if (pkt->stream_index == ai && avcodec_send_packet(dec, pkt) >= 0) {
while (avcodec_receive_frame(dec, fr) >= 0) {
int max_out = swr_get_out_samples(swr, fr->nb_samples);
size_t base = out.size(); out.resize(base + max_out);
uint8_t* dst = reinterpret_cast<uint8_t*>(out.data() + base);
int got = swr_convert(swr, &dst, max_out,
(const uint8_t**)fr->extended_data, fr->nb_samples);
out.resize(base + std::max(0, got));
}
}
av_packet_unref(pkt);
}
} catch (...) { out.clear(); }
if (swr) swr_free(&swr);
if (dec) avcodec_free_context(&dec);
av_frame_free(&fr); av_packet_free(&pkt);
avformat_close_input(&fmt);
return out;
}
};
+2 -23
View File
@@ -15,15 +15,6 @@
// by CMake (SAE_GEMM_BACKEND); backends/gemm_backend.cpp provides // by CMake (SAE_GEMM_BACKEND); backends/gemm_backend.cpp provides
// make_similarity_engine(). The core matcher node sees only this interface and // make_similarity_engine(). The core matcher node sees only this interface and
// holds no CUDA/HIP/BLAS headers. // holds no CUDA/HIP/BLAS headers.
//
// TRACES: AR-026 | SR-001
// The resident matrix GROWS. Per-film expansion (AR-018/AR-019) promotes new
// reference views mid-film, and those have to be scored by the same multiply as
// the baked references rather than by a side loop — "this set is small" is not
// an exception, because the annex grows with cast size and film length. Rows are
// therefore appended to the resident matrix and the next compute() covers baked
// and promoted references alike, in one GEMM. The deferred pass (AR-020) then
// inherits a single contiguous operand to score the TBI queue against.
struct ISimilarityEngine { struct ISimilarityEngine {
virtual ~ISimilarityEngine() = default; virtual ~ISimilarityEngine() = default;
@@ -31,23 +22,11 @@ struct ISimilarityEngine {
// Largest n_faces accepted by compute() per call (bounds GPU buffer sizes). // Largest n_faces accepted by compute() per call (bounds GPU buffer sizes).
virtual int max_faces() const = 0; virtual int max_faces() const = 0;
// Rows currently resident: the baked gallery plus every appended promotion.
// This is compute()'s column stride, and it changes as rows are appended —
// read it per call rather than caching it across frames.
virtual int n_gallery() const = 0;
/// TRACES: AR-026 | SR-001
/// Append n_rows unit-norm embeddings (row-major, 512 floats each) to the
/// resident matrix. Amortised O(1) per row: capacity grows geometrically, so
/// a promotion does not re-upload the gallery. Invalidates any pointer
/// previously returned by compute().
virtual void append_rows(const float* rows_row_major, int n_rows) = 0;
// Compute similarities for n_faces query embeddings. // Compute similarities for n_faces query embeddings.
// query_row_major: n_faces × 512, row fi at query + fi*512. // query_row_major: n_faces × 512, row fi at query + fi*512.
// Returns a pointer to host memory holding S column-major: the gallery // Returns a pointer to host memory holding S column-major: the gallery
// similarities for face fi start at result + fi*n_gallery(). The pointer is // similarities for face fi start at result + fi*n_gallery. The pointer is
// owned by the engine and valid until the next compute() or append_rows(). // owned by the engine and valid until the next compute() call.
virtual const float* compute(const float* query_row_major, int n_faces) = 0; virtual const float* compute(const float* query_row_major, int n_faces) = 0;
}; };
-319
View File
@@ -1,319 +0,0 @@
#pragma once
// XGBoost scene-boundary detector — C++ inference of the shipped model
// (models/scene_boundary_xgb.json), for flood-fill presence in the live pipeline.
//
// This is a POST-EOF step (like flood-fill itself): the per-film knee threshold
// needs every peak, so boundaries can only be finalized after the whole film is
// seen. The result sink collects a per-frame RGB histogram; at EOF it calls
// boundaries() with the full (timestamp, hist) series and gets back the boundary
// timestamps to flood-snap against.
//
// The feature pipeline MUST match scripts/scene_detector/train_scene_boundary.py
// exactly (206 features): a ±WIN=3s window of per-second base features + a
// 3-value debounce clock. Base per second (29):
// video(17): sym-delta |hist(t+k)-hist(t-k)| L1 at k=1,2,4,8; per-channel corr
// to t-1 (3); ramp bank at H=2,4,6,8,10 on z-normed hist (5);
// per-channel energy (3); debounce phase/decay from |delta k=1| (2)
// audio(12): same but on the log-PSD, no corr, 1 energy [ZERO when no audio]
// then window flatten t-3..t+3 (×7) and append clock (dt, phase, decay).
//
// Audio is not available live (the pipeline has no per-second PSD stream), so the
// audio block is fed zeros — the model was trained with audio present but it is
// weak (measured) and XGBoost tolerates a constant block; the video signal
// carries the detector. (If live audio is added later, fill the block.)
#include <xgboost/c_api.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <numeric>
#include <stdexcept>
#include <string>
#include <vector>
class XGBSceneBoundary {
public:
// Must match kHistBins in embedding_dump_node.hpp / the training dump.
static constexpr int kHistBins = 32; // per channel → 96-float hist
static constexpr int kWin = 3; // ±WIN-second window
static constexpr double kSigmaTau = 205.0; // SCENE_TAU (unused at infer; kept for parity docs)
static constexpr int kRampScales[5] = {2, 4, 6, 8, 10};
explicit XGBSceneBoundary(const std::string& model_path) {
if (XGBoosterCreate(nullptr, 0, &booster_) != 0)
throw std::runtime_error("XGBoosterCreate failed");
if (XGBoosterLoadModel(booster_, model_path.c_str()) != 0)
throw std::runtime_error("XGBoosterLoadModel failed: " +
std::string(XGBGetLastError()));
}
~XGBSceneBoundary() { if (booster_) XGBoosterFree(booster_); }
XGBSceneBoundary(const XGBSceneBoundary&) = delete;
XGBSceneBoundary& operator=(const XGBSceneBoundary&) = delete;
// hist: T rows × 96 (normalised RGB histogram per second).
// audio: T rows × B log-PSD (from AudioLogPSD; aligned to the same seconds),
// or empty → the audio block is filled with its zero-input values
// (deltas/ramp/energy 0, but debounce phase=1/decay=exp(-1), matching
// the Python audio_features on a zero series).
// Returns boundary timestamps (knee-selected).
std::vector<double> boundaries(const std::vector<std::vector<float>>& hist,
const std::vector<double>& ts,
const std::vector<std::vector<float>>& audio = {}) {
const int T = static_cast<int>(hist.size());
if (T < 2 * kWin + 2) return {};
auto base = build_base(hist, audio); // [T][29]
std::vector<float> X = window_and_clock(base, hist);
std::vector<float> prob = predict(X, T, 206);
return knee_boundaries(prob, ts);
}
static std::vector<std::vector<float>> debug_base(const std::vector<std::vector<float>>& hist,
const std::vector<std::vector<float>>& audio = {}) {
return build_base(hist, audio);
}
// Predict boundaries from a precomputed [rows×cols] feature matrix (for the
// clean parity check: same bytes both sides).
std::vector<double> boundaries_from_features(const std::vector<float>& X, int rows,
int cols, const std::vector<double>& ts) {
auto prob = predict(X, rows, cols);
return knee_boundaries(prob, ts);
}
std::vector<float> debug_predict(const std::vector<float>& X, int r, int c) {
return predict(X, r, c);
}
static std::vector<int> debug_find_peaks(const std::vector<float>& p, int d) {
return find_peaks(p, d);
}
// The flat [T*206] feature matrix — exposed so TRAINING uses the exact same
// C++ features as inference (parity by construction; no numpy re-match). The
// Python trainer reshapes to [T,206], attaches the soft target, and fits.
static std::vector<float> feature_matrix(const std::vector<std::vector<float>>& hist,
const std::vector<std::vector<float>>& audio) {
auto base = build_base(hist, audio);
return window_and_clock(base, hist);
}
static constexpr int kNFeatures = 206;
private:
BoosterHandle booster_{nullptr};
// ── feature builders (exact parity with the Python) ──────────────────────
static float l1(const std::vector<float>& a, const std::vector<float>& b) {
float s = 0; for (size_t i = 0; i < a.size(); ++i) s += std::fabs(a[i] - b[i]);
return s;
}
// z-normalise each of the 96 columns across time (matches _znorm).
static std::vector<std::vector<float>> znorm(const std::vector<std::vector<float>>& h) {
const int T = h.size(), D = h[0].size();
std::vector<float> mu(D, 0), sd(D, 0);
for (auto& r : h) for (int d = 0; d < D; ++d) mu[d] += r[d];
for (int d = 0; d < D; ++d) mu[d] /= T;
for (auto& r : h) for (int d = 0; d < D; ++d) sd[d] += (r[d]-mu[d])*(r[d]-mu[d]);
for (int d = 0; d < D; ++d) sd[d] = std::sqrt(sd[d]/T) + 1e-6f;
std::vector<std::vector<float>> z(T, std::vector<float>(D));
for (int t = 0; t < T; ++t) for (int d = 0; d < D; ++d) z[t][d] = (h[t][d]-mu[d])/sd[d];
return z;
}
// ramp bank: L2 of the antisymmetric ramp-weighted sum over ±H, per scale.
// Matches ramp_bank() (np.convolve 'same' with reversed kernel; sign folds
// into the L2 norm so the direct antisymmetric sum is equivalent).
static std::vector<std::array<float,5>> ramp_bank(const std::vector<std::vector<float>>& z) {
const int T = z.size(), D = z[0].size();
std::vector<std::array<float,5>> out(T);
for (int k = 0; k < 5; ++k) {
const int H = kRampScales[k];
for (int t = 0; t < T; ++t) {
std::vector<double> acc(D, 0.0);
for (int l = -H; l <= H; ++l) {
int idx = t + l;
if (idx < 0 || idx >= T) continue;
double w = (l == 0) ? 0.0 : (l > 0 ? 1.0 : -1.0) * (double(std::abs(l))/H);
for (int d = 0; d < D; ++d) acc[d] += w * z[idx][d];
}
double n = 0; for (double v : acc) n += v*v;
out[t][k] = static_cast<float>(std::sqrt(n));
}
}
return out;
}
// Generic symmetric-delta + ramp + energy + debounce feature block for one
// modality's z-normable series `raw` (hist or PSD). Fills `out` columns
// [off .. off+width). corr=true adds the 3 per-channel corr features (video
// only); n_energy is 3 (video, per-channel) or 1 (audio, total).
static void modality_block(const std::vector<std::vector<float>>& raw,
bool corr, int n_energy,
std::vector<std::vector<float>>& out, int off) {
const int T = raw.size();
auto z = znorm(raw);
auto rb = ramp_bank(z);
auto sym = [&](int t, int k)->float{
int f = std::min(T-1, t+k), b = std::max(0, t-k);
return l1(raw[f], raw[b]);
};
const int B = kHistBins; // only used for corr (video)
for (int t = 0; t < T; ++t) {
int o = off;
for (int k : {1,2,4,8}) out[t][o++] = sym(t,k);
if (corr) {
int tp = std::max(0, t-1);
for (int c = 0; c < 3; ++c) {
double ma=0, mb=0;
for (int i=0;i<B;++i){ ma+=raw[t][c*B+i]; mb+=raw[tp][c*B+i]; }
ma/=B; mb/=B; double num=0, da=0, db=0;
for (int i=0;i<B;++i){ double x=raw[t][c*B+i]-ma, y=raw[tp][c*B+i]-mb;
num+=x*y; da+=x*x; db+=y*y; }
out[t][o++] = float(num/(std::sqrt(da*db)+1e-9));
}
}
for (int k=0;k<5;++k) out[t][o++] = rb[t][k];
if (n_energy == 3) {
for (int c=0;c<3;++c){ float e=0; for(int i=0;i<B;++i) e+=raw[t][c*B+i]; out[t][o++]=e; }
} else {
float e=0; for (float v : raw[t]) e+=v; out[t][o++]=e;
}
o += 2; // debounce filled below
}
// debounce from this block's delta-k1 (its first column = off)
std::vector<float> d1(T); for (int t=0;t<T;++t) d1[t]=out[t][off];
auto clk = debounce_phase(d1);
// debounce sits at the end of the block: off + 4(deltas) + (corr?3:0) + 5(ramp) + n_energy
int deb = off + 4 + (corr?3:0) + 5 + n_energy;
for (int t=0;t<T;++t){ out[t][deb]=clk[t].first; out[t][deb+1]=clk[t].second; }
}
// per-second base = video(17) + audio(12). Audio empty → its block is the
// zero-series result (deltas/ramp/energy 0, debounce phase=1/decay=exp(-1)).
static std::vector<std::vector<float>> build_base(const std::vector<std::vector<float>>& hist,
const std::vector<std::vector<float>>& audio) {
const int T = hist.size();
std::vector<std::vector<float>> base(T, std::vector<float>(29, 0.0f));
modality_block(hist, /*corr=*/true, /*n_energy=*/3, base, /*off=*/0); // video → 0..16
if (!audio.empty() && int(audio.size()) == T) {
modality_block(audio, /*corr=*/false, /*n_energy=*/1, base, /*off=*/17); // audio → 17..28
} else {
// zero-series audio: deltas/ramp/energy already 0; only debounce differs.
auto clk = debounce_phase(std::vector<float>(T, 0.0f));
for (int t=0;t<T;++t){ base[t][27]=clk[t].first; base[t][28]=clk[t].second; }
}
return base;
}
// matches debounce_phase(): 90th-pct peaks, dt=time since last, phase/decay.
static std::vector<std::pair<float,float>> debounce_phase(const std::vector<float>& sig) {
const int T = sig.size();
std::vector<float> s(sig); std::sort(s.begin(), s.end());
float thr = s[std::min(T-1, int(0.90*T))];
std::vector<std::pair<float,float>> out(T);
int last = -1000000000;
for (int t=0;t<T;++t){
if (sig[t] > thr) last = t;
double dt = (last < -100000000) ? kSigmaTau : double(t - last);
out[t] = { float(std::min(1.0, dt/kSigmaTau)), float(std::exp(-dt/kSigmaTau)) };
}
return out;
}
// window flatten (t-3..t+3, edge-pad) + append the 3-value film clock.
static std::vector<float> window_and_clock(const std::vector<std::vector<float>>& base,
const std::vector<std::vector<float>>& hist) {
const int T = base.size(), d = base[0].size(); // d=29
// film-level clock: time-since-last-peak on the |delta k1| video signal
// (base col 0), same as per_second_matrix's `clock`.
std::vector<float> sig(T); for (int t=0;t<T;++t) sig[t]=base[t][0];
std::vector<float> ss(sig); std::sort(ss.begin(), ss.end());
float thr = ss[std::min(T-1, int(0.90*T))];
std::vector<float> X; X.reserve(size_t(T)*206);
int last=-1000000000;
for (int t=0;t<T;++t){
for (int off=-kWin; off<=kWin; ++off){
int idx = std::min(T-1, std::max(0, t+off));
for (int j=0;j<d;++j) X.push_back(base[idx][j]);
}
if (sig[t] > thr) last=t;
double dt=(last<-100000000)?kSigmaTau:double(t-last);
X.push_back(float(dt));
X.push_back(float(std::min(1.0, dt/kSigmaTau)));
X.push_back(float(std::exp(-dt/kSigmaTau)));
}
return X;
}
std::vector<float> predict(const std::vector<float>& X, int rows, int cols) {
DMatrixHandle dm;
if (XGDMatrixCreateFromMat(X.data(), rows, cols, std::nanf(""), &dm) != 0)
throw std::runtime_error("XGDMatrixCreateFromMat failed");
bst_ulong out_len = 0; const float* out = nullptr;
if (XGBoosterPredict(booster_, dm, 0, 0, 0, &out_len, &out) != 0)
throw std::runtime_error("XGBoosterPredict failed");
std::vector<float> p(out, out + out_len);
XGDMatrixFree(dm);
for (auto& v : p) v = std::clamp(v, 0.f, 1.f);
return p;
}
// Exact replica of scipy.signal.find_peaks(x, distance=d):
// 1. local maxima (plateau-aware: rising then falling, midpoint of a flat top)
// 2. keep peaks by DESCENDING height; drop any within `d` of an already-kept
// taller peak. This is height-priority, NOT the greedy left-to-right merge
// — the two give different peak sets and hence a different knee.
static std::vector<int> find_peaks(const std::vector<float>& x, int d) {
const int n = x.size();
std::vector<int> mid;
int i = 1;
while (i < n-1) {
if (x[i-1] < x[i]) {
int ahead = i+1;
while (ahead < n-1 && x[ahead] == x[i]) ahead++;
if (x[ahead] < x[i]) mid.push_back((i + ahead - 1) / 2);
i = ahead;
} else i++;
}
// height-priority distance filter (scipy's _select_by_peak_distance)
std::vector<int> order(mid.size());
for (size_t k = 0; k < mid.size(); ++k) order[k] = k;
std::sort(order.begin(), order.end(),
[&](int a, int b){ return x[mid[a]] < x[mid[b]]; }); // ascending
std::vector<char> keep(mid.size(), 1);
for (int j = int(order.size())-1; j >= 0; --j) { // tallest first
int k = order[j];
if (!keep[k]) continue;
for (int l = k-1; l >= 0 && mid[k]-mid[l] < d; --l) keep[l] = 0;
for (int r = k+1; r < int(mid.size()) && mid[r]-mid[k] < d; ++r) keep[r] = 0;
}
std::vector<int> out;
for (size_t k = 0; k < mid.size(); ++k) if (keep[k]) out.push_back(mid[k]);
return out;
}
// knee threshold on peak heights → boundary timestamps (matches knee_boundaries).
static std::vector<double> knee_boundaries(const std::vector<float>& prob,
const std::vector<double>& ts,
int min_gap = 5) {
std::vector<int> pk = find_peaks(prob, min_gap);
if (pk.size() < 5) {
std::vector<double> r; for (int i : pk) r.push_back(ts[i]); return r;
}
std::vector<float> h; for (int i : pk) h.push_back(prob[i]);
std::sort(h.begin(), h.end(), std::greater<float>());
int n = h.size(); float h0 = h.front() + 1e-9f;
int kbest = 0; double dmax = -1;
for (int i = 0; i < n; ++i) {
double x = double(i)/(n-1);
double yv = h[i]/h0;
double chord = (h[0]/h0) + ((h[n-1]/h0)-(h[0]/h0))*x;
if (chord - yv > dmax) { dmax = chord - yv; kbest = i; }
}
float knee = h[kbest];
std::vector<double> out;
for (int i : pk) if (prob[i] >= knee) out.push_back(ts[i]);
return out;
}
};
+62 -273
View File
@@ -1,38 +1,11 @@
// sae_kpn — run the real downstream pipeline inside a Python-assembled KPN // sae_kpn — run the real downstream pipeline nodes (face_tracker, identity_matcher,
// network, fed by a Python HDF5 replay source. Lets a parameter sweep re-run the // scene_tracker) inside a Python-assembled KPN network, fed by a Python HDF5 replay
// exact C++ tracking/matching/presence logic over dumped embeddings — no video // source. Lets a parameter sweep re-run the exact C++ matching/tracking logic over
// decode, no GPU — with different Config knobs each run. // dumped embeddings — no video decode, no GPU — with different Config knobs each run.
//
/// TRACES: VR-011, VR-002 | PR-002
//
// **The whole chain is C++, including the sink.** That is the VR-011 change and
// it is the point of the requirement: replay must drive the real nodes, not a
// reimplementation. Two things were wrong before.
//
// 1. It did not compile. `add_face_tracker` built `FaceTrackerFunc` from a
// Config alone, and the tracker has required a TrackRegistry and a
// calibration since AR-007/AR-008 moved association into probability
// space. Any .so in a stale build/ predates that.
//
// 2. 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 now builds a window from a
// TrackRegistry claim: 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 had the same root cause, which is why this is one binding and not three.
// 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 a factory-per-node API cannot express
// it. `add_pipeline` mirrors main.cpp exactly and is the only way to build the
// chain, so the ordering cannot be got wrong again from Python.
// //
// Boundary types (cross the Python seam): // Boundary types (cross the Python seam):
// EmbeddedSceneFrame IN (built by the Python replay source from HDF5 arrays) // EmbeddedSceneFrame IN (built by the Python replay source from HDF5 arrays)
// SceneAnnotation OUT (optional tee for per-frame debug rendering only — // SceneAnnotation OUT (read by the Python sink → presence JSON)
// the presence output is written by the C++ sink)
// Intermediate types (TrackedSceneFrame, MatchedSceneFrame) flow C++→C++ only, but // Intermediate types (TrackedSceneFrame, MatchedSceneFrame) flow C++→C++ only, but
// still need channel factories + converters registered so PyNetwork can wire them. // still need channel factories + converters registered so PyNetwork can wire them.
@@ -46,10 +19,7 @@
#include "gallery/gallery_store.hpp" #include "gallery/gallery_store.hpp"
#include "nodes/face_tracker_node.hpp" #include "nodes/face_tracker_node.hpp"
#include "nodes/identity_matcher_node.hpp" #include "nodes/identity_matcher_node.hpp"
#include "nodes/frame_annotation_node.hpp" #include "nodes/scene_tracker_node.hpp"
#include "nodes/result_sink_node.hpp"
#include "track_registry.hpp"
#include "evidence_discount.hpp"
#include <nanobind/nanobind.h> #include <nanobind/nanobind.h>
#include <nanobind/ndarray.h> #include <nanobind/ndarray.h>
@@ -57,61 +27,16 @@
#include <nanobind/stl/vector.h> #include <nanobind/stl/vector.h>
#include <nanobind/stl/map.h> #include <nanobind/stl/map.h>
#include <atomic>
#include <map>
#include <memory> #include <memory>
#include <optional>
#include <variant> #include <variant>
namespace nb = nanobind; namespace nb = nanobind;
using namespace nb::literals; using namespace nb::literals;
// ── ReplaySession ─────────────────────────────────────────────────────────────
/// TRACES: VR-011 | PR-002
/// State the network's nodes reference but do not own.
///
/// ResultSinkFunc holds `std::atomic<bool>&`, exactly as it does under main(),
/// where it is a stack local in a function that outlives the pipeline. There is
/// no such frame here -- the network is built and torn down from Python -- so
/// the flag lives in a session held for the network's lifetime and released
/// explicitly. The registry is here for the same reason: the sink's claim
/// callback captures it.
struct ReplaySession {
/// Owns the Config, and must. ResultSinkFunc holds `const Config&` -- under
/// main() that is a stack local in a frame which outlives the pipeline, so
/// the reference is fine there. There is no such frame here: the network is
/// built inside a binding call and torn down from Python, so a Config local
/// to add_pipeline dies the moment it returns and the sink is left reading
/// freed memory. It presented as an empty output_path -- the sink announced
/// `[result_sink] writing ` and wrote nothing.
Config cfg;
std::atomic<bool> done{false};
std::shared_ptr<TrackRegistry> registry;
};
// Function-local static so ordering against other translation units cannot bite.
inline std::map<void*, std::shared_ptr<ReplaySession>>& sessions() {
static std::map<void*, std::shared_ptr<ReplaySession>> s;
return s;
}
// The variant spanning every type that flows on a channel in the replay chain. // The variant spanning every type that flows on a channel in the replay chain.
using SaeVariant = std::variant<EmbeddedSceneFrame, TrackedSceneFrame, using SaeVariant = std::variant<EmbeddedSceneFrame, TrackedSceneFrame,
MatchedSceneFrame, SceneAnnotation>; MatchedSceneFrame, SceneAnnotation>;
// ── Node wrapper aliases ──────────────────────────────────────────────────────
// Named once so add_pipeline and the runtime setters cannot disagree about a
// node's port names: a mismatch there is a dynamic_cast that returns null, i.e.
// a runtime setter that silently does nothing.
using MatcherWrap = kpn::ObjectVariantNodeWrapper<
IdentityMatcherFunc, SaeVariant, kpn::in<"tracked">, kpn::out<"matched">>;
using TrackerWrap = kpn::ObjectVariantNodeWrapper<
FaceTrackerFunc, SaeVariant, kpn::in<"embedded">, kpn::out<"tracked">>;
using AnnotWrap = kpn::ObjectVariantNodeWrapper<
FrameAnnotationFunc, SaeVariant, kpn::in<"matched">, kpn::out<"annotation">>;
using SinkWrap = kpn::ObjectVariantNodeWrapper<
ResultSinkFunc, SaeVariant, kpn::in<"annotation">, kpn::out<>>;
// ── Converters ───────────────────────────────────────────────────────────────── // ── Converters ─────────────────────────────────────────────────────────────────
// Only EmbeddedSceneFrame (in) and SceneAnnotation (out) actually cross the seam; // Only EmbeddedSceneFrame (in) and SceneAnnotation (out) actually cross the seam;
// the two intermediates get identity-ish stubs (never converted in practice) so the // the two intermediates get identity-ish stubs (never converted in practice) so the
@@ -136,8 +61,6 @@ template<> struct PythonConverter<EmbeddedSceneFrame> {
ef.source.frame_idx = d.contains("frame_idx") ? nb::cast<int64_t>(d["frame_idx"]) : -1; ef.source.frame_idx = d.contains("frame_idx") ? nb::cast<int64_t>(d["frame_idx"]) : -1;
ef.source.eof = d.contains("eof") ? nb::cast<bool>(d["eof"]) : false; ef.source.eof = d.contains("eof") ? nb::cast<bool>(d["eof"]) : false;
ef.source.is_cut = d.contains("is_cut") ? nb::cast<bool>(d["is_cut"]) : false; ef.source.is_cut = d.contains("is_cut") ? nb::cast<bool>(d["is_cut"]) : false;
ef.source.is_scene_boundary = d.contains("is_scene_boundary")
? nb::cast<bool>(d["is_scene_boundary"]) : false;
if (ef.source.eof) return ef; if (ef.source.eof) return ef;
// faces: (N,4) bbox, (N,10) landmarks, (N,) confidence, (N,512) embeddings // faces: (N,4) bbox, (N,10) landmarks, (N,) confidence, (N,512) embeddings
@@ -146,22 +69,6 @@ template<> struct PythonConverter<EmbeddedSceneFrame> {
auto conf = nb::cast<nb::ndarray<float, nb::shape<-1>, nb::c_contig>>(d["confidence"]); auto conf = nb::cast<nb::ndarray<float, nb::shape<-1>, nb::c_contig>>(d["confidence"]);
auto emb = nb::cast<nb::ndarray<float, nb::shape<-1, 512>, nb::c_contig>>(d["embeddings"]); auto emb = nb::cast<nb::ndarray<float, nb::shape<-1, 512>, nb::c_contig>>(d["embeddings"]);
// AR-028 quality vector. Optional because a v1 dump predates it — absent
// leaves the DetectedFace sentinels at -1, which reads as *unscored*, not
// as a bad face. There is no live aligner on this path to recompute it:
// the replay starts at the embedded-frame channel, so what the dump does
// not carry is genuinely gone.
//
// Held in named locals, like the four above, because the ndarray owns the
// reference that keeps the buffer alive — reading .data() off a temporary
// would leave the pointer dangling at the end of the statement.
using FloatCol = nb::ndarray<float, nb::shape<-1>, nb::c_contig>;
std::optional<FloatCol> sharp_col, resid_col;
if (d.contains("sharpness")) sharp_col = nb::cast<FloatCol>(d["sharpness"]);
if (d.contains("alignment_residual")) resid_col = nb::cast<FloatCol>(d["alignment_residual"]);
const float* sp = sharp_col ? sharp_col->data() : nullptr;
const float* rp = resid_col ? resid_col->data() : nullptr;
const size_t n = bbox.shape(0); const size_t n = bbox.shape(0);
ef.faces.reserve(n); ef.faces.reserve(n);
ef.embeddings.reserve(n); ef.embeddings.reserve(n);
@@ -175,8 +82,6 @@ template<> struct PythonConverter<EmbeddedSceneFrame> {
for (int k = 0; k < 5; ++k) for (int k = 0; k < 5; ++k)
f.landmarks[k] = cv::Point2f(lp[i*10 + k*2], lp[i*10 + k*2 + 1]); f.landmarks[k] = cv::Point2f(lp[i*10 + k*2], lp[i*10 + k*2 + 1]);
f.confidence = cp[i]; f.confidence = cp[i];
if (sp) f.sharpness = sp[i];
if (rp) f.alignment_residual = rp[i];
ef.faces.push_back(f); ef.faces.push_back(f);
Embedding e; Embedding e;
@@ -244,67 +149,31 @@ static Config config_from_dict(nb::dict d) {
// identity matcher // identity matcher
getf("match_prior", cfg.match_prior); getf("match_prior", cfg.match_prior);
getf("prob_threshold", cfg.prob_threshold); getf("prob_threshold", cfg.prob_threshold);
getf("match_threshold", cfg.match_threshold);
getf("match_ratio", cfg.match_ratio);
getf("match_ratio_ceil", cfg.match_ratio_ceil);
// face tracker // face tracker
getf("track_alpha", cfg.track_alpha); getf("track_alpha", cfg.track_alpha);
getf("track_min_iou", cfg.track_min_iou); getf("track_min_iou", cfg.track_min_iou);
getf("track_assoc_min_prob", cfg.track_assoc_min_prob); getf("track_max_embed_dist", cfg.track_max_embed_dist);
getd("track_extinction_sec", cfg.track_extinction_sec); geti("track_max_frames_missing", cfg.track_max_frames_missing);
// AR-025: swept knobs, previously unreachable from any config. getf("cut_revive_sim", cfg.cut_revive_sim);
getf("ownership_logodds", cfg.ownership_logodds); geti("cut_inactive_max_frames", cfg.cut_inactive_max_frames);
getf("evidence_rho_max", cfg.evidence_rho_max); // scene tracker
getf("evidence_admit_below", cfg.evidence_admit_below); getd("extinction_sec", cfg.extinction_sec);
geti("evidence_max_views", cfg.evidence_max_views); getd("anneal_sec", cfg.anneal_sec);
// gallery expansion (usually off for sweeps; expose so it can be toggled) // gallery expansion (usually off for sweeps; expose so it can be toggled)
if (d.contains("expand_gallery")) cfg.expand_gallery = nb::cast<bool>(d["expand_gallery"]); if (d.contains("expand_gallery")) cfg.expand_gallery = nb::cast<bool>(d["expand_gallery"]);
// AR-018: banded admission bounds for the per-film annex, in probability
// space. Reachable from a sweep — the config comment asks for both to be
// swept, and they are ignored unless expand_gallery is on. See track_gallery.hpp.
getf("expand_band_lo", cfg.expand_band_lo);
getf("expand_band_hi", cfg.expand_band_hi);
// Presence derivation. Accepts a string ("flood"/"track_extent") or a
// number (DE only produces floats: >=0.5 → flood) so the sweep can toggle
// it as a sixth knob. flood snaps to boundaries in the replayed frames
// (is_scene_boundary if present, else is_cut).
if (d.contains("presence_mode")) {
const auto& pm = d["presence_mode"];
bool flood = false;
if (nb::isinstance<nb::str>(pm)) flood = (nb::cast<std::string>(pm) == "flood");
else flood = (nb::cast<double>(pm) >= 0.5);
cfg.presence_mode = flood ? PresenceMode::flood : PresenceMode::track_extent;
}
/// TRACES: GR-004 | SR-001 /// TRACES: GR-004 | SR-001
if (d.contains("require_gallery_stamp")) if (d.contains("require_gallery_stamp"))
cfg.require_gallery_stamp = nb::cast<bool>(d["require_gallery_stamp"]); cfg.require_gallery_stamp = nb::cast<bool>(d["require_gallery_stamp"]);
/// TRACES: VR-011 | IR-001 | PR-002 | SR-003
// The sink is a real node in this network now, so it needs the two things
// that decide what it writes and where. Both used to be irrelevant here
// because the replay never had a sink -- Python rebuilt presence instead,
// which is the reimplementation VR-002 forbids and VR-011 removes.
if (d.contains("output_path"))
cfg.output_path = nb::cast<std::string>(d["output_path"]);
if (d.contains("verbosity")) {
const int v = nb::cast<int>(d["verbosity"]);
cfg.verbosity = v == 2 ? Verbosity::xray
: v == 1 ? Verbosity::standard
: Verbosity::minimal;
}
// Reported verbatim in the truth file's extraction block, so a replayed
// manifest says which gallery scope produced it (IR-002).
if (d.contains("gallery_scope"))
cfg.gallery_scope = nb::cast<std::string>(d["gallery_scope"]);
if (d.contains("sample_fps"))
cfg.sample_fps = nb::cast<float>(d["sample_fps"]);
if (d.contains("movie_path"))
cfg.movie_path = nb::cast<std::string>(d["movie_path"]);
return cfg; return cfg;
} }
using Net = kpn::python::PyNetwork<SaeVariant>; using Net = kpn::python::PyNetwork<SaeVariant>;
NB_MODULE(sae_kpn, m) { NB_MODULE(sae_kpn, m) {
m.doc() = "Real KPN downstream nodes (tracker/matcher/frame_annotation) for Python replay sweeps"; m.doc() = "Real KPN downstream nodes (tracker/matcher/scene_tracker) for Python replay sweeps";
kpn::python::register_py_network<SaeVariant>(m, "Network"); kpn::python::register_py_network<SaeVariant>(m, "Network");
@@ -337,51 +206,38 @@ NB_MODULE(sae_kpn, m) {
std::move(outs), cap); std::move(outs), cap);
}, "net"_a, "name"_a, "callable"_a, "inputs"_a, "outputs"_a, "capacity"_a = 5); }, "net"_a, "name"_a, "callable"_a, "inputs"_a, "outputs"_a, "capacity"_a = 5);
// ── The pipeline ──────────────────────────────────────────────────────────── // ── Real node factories ─────────────────────────────────────────────────────
/// TRACES: VR-011, VR-002 | DP-001 | PR-002, PR-004 m.def("add_face_tracker", [](Net& net, std::string name, nb::dict cfg_dict, std::size_t cap) {
///
/// One call builds the whole downstream chain, in the one order that works:
///
/// matcher (fits the calibration)
/// -> registry (needs a discounter built from it)
/// -> tracker (needs both)
/// -> frame_annotation
/// -> result_sink (needs the registry's claims)
///
/// This replaces add_face_tracker / add_identity_matcher / add_frame_annotation.
/// They were separate because the network is assembled node by node from
/// Python -- and that is exactly how the seam broke: the tracker's dependency
/// on a calibration that only exists once the matcher is built cannot be
/// expressed as three independent factories, so the tracker factory kept
/// constructing FaceTrackerFunc{cfg} against a signature that no longer
/// existed. A binding that cannot represent the order will eventually be
/// called in the wrong one.
///
/// DP-001 -- "modes are front-ends and must not fork pipeline logic" -- is
/// the requirement this serves. The replay harness is a front-end. Its job is
/// to supply frames and read the result, not to re-derive presence.
m.def("add_pipeline", [](Net& net, std::string gallery_path, nb::dict cfg_dict,
std::size_t cap, std::string embedder_model,
std::string embedder_sha256) {
Config cfg = config_from_dict(cfg_dict); Config cfg = config_from_dict(cfg_dict);
cfg.gallery_path = gallery_path; // so a refreshed calibration persists back auto node = std::make_shared<kpn::ObjectVariantNodeWrapper<
FaceTrackerFunc, SaeVariant, kpn::in<"embedded">, kpn::out<"tracked">>>(cap, cfg);
net.add(std::move(name), std::move(node));
}, "net"_a, "name"_a, "config"_a, "capacity"_a = 16);
/// TRACES: GR-004 | SR-001
// embedder_model / embedder_sha256 identify whatever produced the embeddings
// that will be fed in. In a replay those come from the dump's own stamp (see
// scripts/optimizer/SCHEMA.md), because there is no live embedder in the
// network — the dump *is* the embedder as far as this gallery is concerned.
// Passing neither leaves the binding unverifiable, which warns loudly and is
// fatal under SAE_REQUIRE_GALLERY_STAMP.
m.def("add_identity_matcher", [](Net& net, std::string name, std::string gallery_path,
nb::dict cfg_dict, std::size_t cap,
std::string embedder_model,
std::string embedder_sha256) {
Config cfg = config_from_dict(cfg_dict);
cfg.gallery_path = gallery_path; // needed to persist refreshed calibration back
// Cache loaded galleries by path so a threshold sweep (many networks, same // Cache loaded galleries by path so a threshold sweep (many networks, same
// gallery) pays the parse once. The matcher holds a const ref; the cache // gallery) pays the ~24s JSON parse only once. The matcher holds a const
// keeps the gallery alive for the process lifetime. // ref; the cache keeps the gallery alive for the process lifetime.
static std::map<std::string, std::shared_ptr<ActorGallery>> cache; static std::map<std::string, std::shared_ptr<ActorGallery>> cache;
auto it = cache.find(gallery_path); auto it = cache.find(gallery_path);
if (it == cache.end()) if (it == cache.end())
it = cache.emplace(gallery_path, it = cache.emplace(gallery_path,
std::make_shared<ActorGallery>(load_gallery(gallery_path))).first; std::make_shared<ActorGallery>(load_gallery(gallery_path))).first;
/// TRACES: GR-004 | SR-001 // Checked on every construction, not only on the cache miss: the same
// embedder_model / embedder_sha256 identify whatever produced the // process may replay several dumps against one cached gallery.
// embeddings that will be fed in. In a replay those come from the dump's
// own stamp: there is no live embedder here, so the dump *is* the
// embedder as far as this gallery is concerned. Checked on every
// construction, not only on a cache miss -- one process may replay
// several dumps against one cached gallery.
EmbedderStamp feeding; EmbedderStamp feeding;
feeding.model_name = std::move(embedder_model); feeding.model_name = std::move(embedder_model);
feeding.model_sha256 = std::move(embedder_sha256); feeding.model_sha256 = std::move(embedder_sha256);
@@ -391,104 +247,37 @@ NB_MODULE(sae_kpn, m) {
: feeding.model_name, : feeding.model_name,
cfg.require_gallery_stamp); cfg.require_gallery_stamp);
// 1. Matcher first: its constructor fits (or loads) the calibration. auto node = std::make_shared<kpn::ObjectVariantNodeWrapper<
auto matcher = std::make_shared<MatcherWrap>(cap, *it->second, cfg); IdentityMatcherFunc, SaeVariant, kpn::in<"tracked">, kpn::out<"matched">>>(
cap, *it->second, cfg);
// 2. The calibration every other stage must decide in (AR-024). net.add(std::move(name), std::move(node));
auto same_person = same_person_probability(matcher->functor().calibration()); }, "net"_a, "name"_a, "gallery"_a, "config"_a, "capacity"_a = 16,
// 3. Registry + discounter, from Config (AR-025).
TrackRegistry::Config reg_cfg;
reg_cfg.track_extinction_sec = cfg.track_extinction_sec;
reg_cfg.ownership_logodds = cfg.ownership_logodds;
EvidenceDiscounter::Config disc_cfg;
disc_cfg.max_views = cfg.evidence_max_views;
disc_cfg.admit_below = cfg.evidence_admit_below;
disc_cfg.rho_max = cfg.evidence_rho_max;
auto registry = std::make_shared<TrackRegistry>(
reg_cfg, EvidenceDiscounter(same_person, disc_cfg));
matcher->functor().set_registry(registry);
// 4. Tracker, which needs both.
auto tracker = std::make_shared<TrackerWrap>(cap, cfg, registry, same_person);
// 5. Projection, stateless.
auto annot = std::make_shared<AnnotWrap>(cap);
// 6. The real sink. `done` outlives the network via the session below;
// ResultSinkFunc holds it by reference, as it does in main.cpp.
auto session = std::make_shared<ReplaySession>();
session->cfg = cfg; // the sink holds this by reference
session->registry = registry;
auto sink = std::make_shared<SinkWrap>(cap, session->cfg, session->done);
/// TRACES: AR-012, AR-016 | IR-003 | SR-002
// The claim path, identical to main.cpp's. Without the flush hook every
// track still live at EOF is silently dropped -- which in a replay is
// most of the closing scene, and reads as a recognition miss rather than
// as a missing wire.
ResultSinkFunc& sink_fn = sink->functor();
registry->on_track_dead([&sink_fn](const DeadTrack& d) { sink_fn.add_claim(d); });
sink_fn.set_pre_write_hook([registry](double last_ts) { registry->flush(last_ts); });
net.add("tracker", tracker);
net.add("matcher", matcher);
net.add("annotation", annot);
net.add("sink", sink);
// Keyed by network so release_pipeline can free it. Not a leak-by-design:
// a sweep builds one network per replay, and the sink accumulates every
// annotation, so holding these forever would grow with films x configs.
sessions()[&net] = session;
}, "net"_a, "gallery"_a, "config"_a, "capacity"_a = 16,
"embedder_model"_a = "", "embedder_sha256"_a = ""); "embedder_model"_a = "", "embedder_sha256"_a = "");
/// Drop the session for a network. Idempotent. Call after net.stop(); not m.def("add_scene_tracker", [](Net& net, std::string name, nb::dict cfg_dict, std::size_t cap) {
/// calling it holds one registry and one sink's accumulated frames per Config cfg = config_from_dict(cfg_dict);
/// replay, which a long sweep will notice. auto node = std::make_shared<kpn::ObjectVariantNodeWrapper<
m.def("release_pipeline", [](Net& net) { sessions().erase(&net); }, "net"_a); SceneTrackerFunc, SaeVariant, kpn::in<"matched">, kpn::out<"annotation">>>(cap, cfg);
net.add(std::move(name), std::move(node));
/// TRACES: VR-011 | AR-025 | PR-002 }, "net"_a, "name"_a, "config"_a, "capacity"_a = 16);
/// The registry's own count of how often it was wrong, exposed so a replay
/// can fail on it instead of returning a plausible-looking empty answer.
///
/// `dropped_votes` is the one that matters here and it earned its keep
/// immediately. A vote lands on a track the registry has already reaped when
/// the matcher lags the tracker by more than track_extinction_sec of film.
/// In scene_analyze that cannot happen -- channels are 16-64 deep, so
/// backpressure pins the two nodes within a few frames of each other. This
/// harness sized every channel to the whole film to avoid a PyNode overflow
/// drop, which removed the backpressure entirely: the tracker ran the film
/// to the end while the matcher was still in its first minute, every vote
/// arrived after its track was gone, no track was ever owned, and the run
/// produced zero presence windows while cheerfully reporting 1647 frames
/// with an identified face.
m.def("pipeline_diagnostics", [](Net& net) {
nb::dict d;
auto it = sessions().find(&net);
if (it == sessions().end() || !it->second->registry) return d;
const auto& r = *it->second->registry;
d["dropped_votes"] = r.dropped_votes();
d["belief_swaps"] = r.belief_swaps();
d["actor_conflicts"] = r.actor_conflicts();
d["live_tracks"] = static_cast<int>(r.live());
return d;
}, "net"_a);
/// True once the sink has written its output. The sink flushes on the EOF
/// annotation, so a caller that reads the file before this is racing it.
m.def("pipeline_done", [](Net& net) {
auto it = sessions().find(&net);
return it != sessions().end()
&& it->second->done.load(std::memory_order_acquire);
}, "net"_a);
// ── Runtime setters (persistent-pipeline reuse across a threshold sweep) ───── // ── Runtime setters (persistent-pipeline reuse across a threshold sweep) ─────
// Build the network once, then change thresholds between replays — no rebuild, // Build the network once, then change thresholds between replays — no rebuild,
// no teardown (which is where the ROCm deadlock lives), no gallery reload. // no teardown (which is where the ROCm deadlock lives), no gallery reload.
using MatcherWrap = kpn::ObjectVariantNodeWrapper<
IdentityMatcherFunc, SaeVariant, kpn::in<"tracked">, kpn::out<"matched">>;
using SceneWrap = kpn::ObjectVariantNodeWrapper<
SceneTrackerFunc, SaeVariant, kpn::in<"matched">, kpn::out<"annotation">>;
m.def("set_prob_threshold", [](Net& net, std::string name, float t) { m.def("set_prob_threshold", [](Net& net, std::string name, float t) {
auto* w = dynamic_cast<MatcherWrap*>(net.node_ptr(name)); auto* w = dynamic_cast<MatcherWrap*>(net.node_ptr(name));
if (!w) throw std::runtime_error("set_prob_threshold: '" + name + "' is not an identity_matcher"); if (!w) throw std::runtime_error("set_prob_threshold: '" + name + "' is not an identity_matcher");
w->functor().set_prob_threshold(t); w->functor().set_prob_threshold(t);
}, "net"_a, "name"_a, "value"_a); }, "net"_a, "name"_a, "value"_a);
m.def("set_extinction_sec", [](Net& net, std::string name, double s) {
auto* w = dynamic_cast<SceneWrap*>(net.node_ptr(name));
if (!w) throw std::runtime_error("set_extinction_sec: '" + name + "' is not a scene_tracker");
w->functor().set_extinction_sec(s);
}, "net"_a, "name"_a, "value"_a);
} }
+28 -244
View File
@@ -8,11 +8,11 @@
// //
// [frame_source] ──Frame──► [face_detector] ──SceneFrame──► [face_aligner] // [frame_source] ──Frame──► [face_detector] ──SceneFrame──► [face_aligner]
// ──AlignedSceneFrame──► [embedder] ──EmbeddedSceneFrame──► // ──AlignedSceneFrame──► [embedder] ──EmbeddedSceneFrame──►
// [identity_matcher] ──MatchedSceneFrame──► [frame_annotation] // [identity_matcher] ──MatchedSceneFrame──► [scene_tracker]
// ──SceneAnnotation──► [result_sink] // ──SceneAnnotation──► [result_sink]
// //
// Debug build (SAE_DEBUG=1): // Debug build (SAE_DEBUG=1):
// [identity_matcher] output fans out to both [frame_annotation] AND [debug_renderer]. // [identity_matcher] output fans out to both [scene_tracker] AND [debug_renderer].
// FanoutNode<MatchedSceneFrame, 2> is auto-inserted by make_network(). // FanoutNode<MatchedSceneFrame, 2> is auto-inserted by make_network().
// //
// Usage: // Usage:
@@ -22,7 +22,8 @@
// --output <path> output JSON (default: annotations.json) // --output <path> output JSON (default: annotations.json)
// --fps <N> sample rate in frames/sec (default: 1.0) // --fps <N> sample rate in frames/sec (default: 1.0)
// --verbosity <0|1|2> 0=minimal, 1=standard, 2=jellyfin-xray (default: 0) // --verbosity <0|1|2> 0=minimal, 1=standard, 2=jellyfin-xray (default: 0)
// --prob-threshold <f> posterior P(match) to accept (default: 0.754) // --match-threshold <f> cosine dist threshold (default: 0.45)
// --extinction <f> actor extinction window in seconds (default: 5.0)
// --detector <path> override SCRFD detector model path // --detector <path> override SCRFD detector model path
// --arcface <path> override ArcFace model path // --arcface <path> override ArcFace model path
// --scene-detect enable TransNetV2 shot-boundary detection (dense decode; // --scene-detect enable TransNetV2 shot-boundary detection (dense decode;
@@ -31,34 +32,21 @@
// --scene-detector-engine <path> pre-built TransNetV2 TRT engine (TRT backend) // --scene-detector-engine <path> pre-built TransNetV2 TRT engine (TRT backend)
// --scene-threshold <f> boundary sigmoid prob above this → cut (default: 0.60) // --scene-threshold <f> boundary sigmoid prob above this → cut (default: 0.60)
// --scene-stride <N> frames between TransNetV2 windows (default: 50, ≤100) // --scene-stride <N> frames between TransNetV2 windows (default: 50, ≤100)
// --scene-decode-fps <f> dense decode rate in scene-detect mode (default: 0 = // --scene-decode-fps <f> dense decode rate in scene-detect mode (default: 12;
// native, the only rate TransNetV2 is calibrated for; // 0 = native fps). Lower = faster, coarser boundaries.
// AR-011). Lowering it runs the model off-distribution.
// --dense-scale <f> downscale decoded frames in scene-detect mode (0<f≤1, // --dense-scale <f> downscale decoded frames in scene-detect mode (0<f≤1,
// default 1=off). Speeds decode; keep ≥0.5 on 1080p. // default 1=off). Speeds decode; keep ≥0.5 on 1080p.
// --max-faces <N> max faces kept per frame (default: 0 = uncapped) // --max-faces <N> max faces kept per frame (default: 10)
// --ownership-logodds <f> belief needed to own a track (default: 2.0 ≈ P 0.88).
// Below it a track makes no presence claim at all.
// --evidence-rho-max <f> ceiling on correlation between two observations of
// one track (default: 0.5 = a repeated view is worth
// at most two independent ones). AR-025.
// --evidence-admit-below <p> P(same view) under this counts as a new look
// --evidence-max-views <N> distinct views remembered per track
// --expand-gallery enable per-film gallery expansion from track continuity // --expand-gallery enable per-film gallery expansion from track continuity
// --expand-buffer <N> per-track diversity buffer size (default: 20) // --expand-buffer <N> per-track diversity buffer size (default: 20)
// --expand-band-lo <p> store admission floor, P(same person) (default: 0.90) // --expand-band-lo <p> store admission floor, P(same person) (default: 0.90)
// --expand-band-hi <p> store admission ceiling, P(same person) (default: 0.95) // --expand-band-hi <p> store admission ceiling, P(same person) (default: 0.95)
// --expand-min-anchor <N> accepted frames before a track confirms (default: 3) // --expand-min-anchor <N> accepted frames before a track confirms (default: 3)
// --expand-debug-dir <p> dump promoted mugshots + embeddings here (SAE_DEBUG) // --expand-debug-dir <p> dump promoted mugshots + embeddings here (SAE_DEBUG)
// --benchmark <path> write a per-node timing + bottleneck report (JSON) and
// print it at shutdown. Says where the run's time went
// and which node is pacing it. See src/benchmark.hpp.
// --benchmark-interval-ms <N> channel-occupancy sampling period (default: 100)
// (SAE_DEBUG only) // (SAE_DEBUG only)
// --debug-dir <path> debug frames output dir (default: debug_frames) // --debug-dir <path> debug frames output dir (default: debug_frames)
// --crop-context <f> bbox expansion factor for context crops (default: 1.5) // --crop-context <f> bbox expansion factor for context crops (default: 1.5)
#include "benchmark.hpp"
#include "config.hpp" #include "config.hpp"
#include "types.hpp" #include "types.hpp"
#include "gallery/embedder_stamp.hpp" #include "gallery/embedder_stamp.hpp"
@@ -70,8 +58,7 @@
#include "nodes/embedder_node.hpp" #include "nodes/embedder_node.hpp"
#include "nodes/face_tracker_node.hpp" #include "nodes/face_tracker_node.hpp"
#include "nodes/identity_matcher_node.hpp" #include "nodes/identity_matcher_node.hpp"
#include "nodes/frame_annotation_node.hpp" #include "nodes/scene_tracker_node.hpp"
#include "inference/scene_detector.hpp" // ISceneDetector::kWindow, for the join-depth derivation
#include "nodes/scene_detector_node.hpp" #include "nodes/scene_detector_node.hpp"
#include "scene_boundaries.hpp" #include "scene_boundaries.hpp"
#include "nodes/scene_boundary_annotator_node.hpp" #include "nodes/scene_boundary_annotator_node.hpp"
@@ -83,16 +70,9 @@
#include <kpn/kpn.hpp> #include <kpn/kpn.hpp>
#include <opencv2/core/utility.hpp> // cv::setNumThreads (SAE_CV_THREADS)
#include <algorithm>
#include <atomic> #include <atomic>
#include <chrono> #include <chrono>
#include <cmath>
#include <csignal>
#include <cstdlib>
#include <cstring> #include <cstring>
#include <fstream>
#include <iostream> #include <iostream>
#include <map> #include <map>
#include <mutex> #include <mutex>
@@ -104,85 +84,17 @@
// ── CLI parsing ─────────────────────────────────────────────────────────────── // ── CLI parsing ───────────────────────────────────────────────────────────────
/// TRACES: AR-010, AR-004 | SR-002 /// TRACES: AR-010, AR-004 | SR-002
/// Depth of the dense branch's own input queue. Part of how far behind the /// How deeply the sampled branch is buffered behind the dense one. TransNetV2
/// fanout head TransNetV2 can be, and therefore an input to the join depth. /// needs kWindow (100) dense frames before it can score any of them, so the face
static constexpr std::size_t kSceneInputDepth = 128; /// branch must lag by at least that much or it asks about frames nobody has
/// looked at yet. Backpressure turns depth into lag: the fanout blocks on the
/// TRACES: AR-010, AR-004 | SR-002 /// slower branch rather than dropping, so the detector simply runs ahead.
/// How far the sampled branch must trail the dense one, in seconds of film. static constexpr std::size_t kSceneJoinDepth = 256;
///
/// TransNetV2 needs kWindow (100) dense frames before it can score any of
/// them, and its input queue can hold kSceneInputDepth more, so in the worst
/// case it has scored only up to (kSceneInputDepth + kWindow) frames behind
/// whatever the fanout has just delivered. The face branch must be at least
/// that far behind, or `scene_annotate` asks about frames nobody has looked at
/// yet. Backpressure turns depth into lag: the fanout blocks on the slower
/// branch rather than dropping, so the dense branch simply runs ahead.
///
/// Divided by a *lower bound* on native frame rate, because a slower source
/// makes the same frame count span more film — 24 fps is the floor for the
/// material this runs on, so it is the conservative choice.
static constexpr double kMinNativeFps = 24.0;
static constexpr double kSceneJoinLagSec =
(kSceneInputDepth + ISceneDetector::kWindow) / kMinNativeFps; // ~9.5 s
/// Margin over that minimum, for jitter in TransNetV2's inference time.
static constexpr double kSceneJoinSafety = 2.0;
/// TRACES: AR-004 | SR-002
/// Slots the sampled branch needs to hold `kSceneJoinLagSec` of film.
///
/// This used to be a constant 256, which is the whole bug: the requirement is a
/// span of *film*, and the slots needed to hold it depend on `sample_fps`.
/// Pinned at 256 it was ~256 s of lag at 1 fps — 27x what the join needs — and
/// nothing recomputed it if `sample_fps` changed, so the one number the join's
/// correctness rests on drifted silently with an unrelated knob.
///
/// It is also the largest single memory item in the pipeline. Every message
/// embeds `Frame source`, so a slot on this branch holds a full decoded image:
/// 256 of them is ~1.5 GB at 1080p, against ~110 MB for the derived depth at
/// 1 fps. See AR-004 — capacity is counted in items, and only the byte figure
/// (now correct, see types.hpp) shows what a slot really costs.
static std::size_t scene_join_depth(float sample_fps) {
const double slots = kSceneJoinSafety * kSceneJoinLagSec * sample_fps;
// Floor of 16: below that the queue stops absorbing ordinary jitter and
// starts throttling the fanout, which would slow the dense branch it
// exists to let run ahead.
return std::max<std::size_t>(16, static_cast<std::size_t>(std::ceil(slots)));
}
/// TRACES: AR-004 | SR-002
/// The decimator's input, on the *full-rate* stream.
///
/// This was also kSceneJoinDepth, which put a 256-slot buffer of full-rate
/// frames in front of the decimator — and at 1 fps against 24 fps native, 23 of
/// every 24 of those frames exist only to be discarded a moment later. Holding
/// ~1.5 GB of decoded images for frames the very next node throws away is the
/// worst available use of the memory budget.
///
/// A filter is a pass-through, not a reservoir: the lag belongs *after*
/// decimation, where a slot buys `1/sample_fps` seconds of film instead of
/// `1/native_fps`. Sized only to keep the decimator fed.
static constexpr std::size_t kDecimatorInputDepth = 16;
/// Set when the scene branch is built, so shutdown can report whether the join /// Set when the scene branch is built, so shutdown can report whether the join
/// actually worked. /// actually worked.
static std::shared_ptr<SceneBoundaries> scene_stats; static std::shared_ptr<SceneBoundaries> scene_stats;
/// TRACES: VR-015 | AR-004 | PR-004
/// Set by SIGUSR1, serviced by the wait loop. `kill -USR1 <pid>` on a running
/// or WEDGED run prints the benchmark table immediately — channel occupancy
/// names the 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.
///
/// The handler only stores a flag; all printing happens on the main thread,
/// since nothing in the report is async-signal-safe.
static std::atomic<bool> g_dump_request{false};
extern "C" void sae_on_dump_signal(int) {
g_dump_request.store(true, std::memory_order_relaxed);
}
static Config parse_args(int argc, char** argv) { static Config parse_args(int argc, char** argv) {
Config cfg; Config cfg;
cfg.detector_model = kDefaultDetectorModel; cfg.detector_model = kDefaultDetectorModel;
@@ -201,15 +113,11 @@ static Config parse_args(int argc, char** argv) {
else if (arg("--gallery")) cfg.gallery_path = next(); else if (arg("--gallery")) cfg.gallery_path = next();
else if (arg("--output")) cfg.output_path = next(); else if (arg("--output")) cfg.output_path = next();
else if (arg("--dump-embeddings")) cfg.dump_embeddings_path = next(); else if (arg("--dump-embeddings")) cfg.dump_embeddings_path = next();
else if (arg("--benchmark")) cfg.benchmark_path = next();
else if (arg("--benchmark-interval-ms")) cfg.benchmark_interval_ms = std::stoi(next());
else if (arg("--fps")) cfg.sample_fps = std::stof(next()); else if (arg("--fps")) cfg.sample_fps = std::stof(next());
else if (arg("--max-decode-fps")) cfg.max_decode_fps = std::stof(next()); else if (arg("--max-decode-fps")) cfg.max_decode_fps = std::stof(next());
else if (arg("--start")) cfg.start_sec = std::stod(next()); else if (arg("--start")) cfg.start_sec = std::stod(next());
else if (arg("--end")) cfg.end_sec = std::stod(next()); else if (arg("--end")) cfg.end_sec = std::stod(next());
else if (arg("--cut-threshold")) cfg.cut_threshold = std::stof(next()); else if (arg("--cut-threshold")) cfg.cut_threshold = std::stof(next());
else if (arg("--presence-mode")) { std::string m = next(); cfg.presence_mode = (m == "flood") ? PresenceMode::flood : PresenceMode::track_extent; }
else if (arg("--scene-xgb-model")) cfg.scene_xgb_model = next();
else if (arg("--scene-detect")) cfg.scene_detect = true; else if (arg("--scene-detect")) cfg.scene_detect = true;
else if (arg("--scene-detector")) cfg.scene_model = next(); else if (arg("--scene-detector")) cfg.scene_model = next();
else if (arg("--scene-detector-engine")) cfg.scene_engine = next(); else if (arg("--scene-detector-engine")) cfg.scene_engine = next();
@@ -220,6 +128,8 @@ static Config parse_args(int argc, char** argv) {
else if (arg("--verbosity")) { int v = std::stoi(next()); cfg.verbosity = v == 2 ? Verbosity::xray : v == 1 ? Verbosity::standard : Verbosity::minimal; } else if (arg("--verbosity")) { int v = std::stoi(next()); cfg.verbosity = v == 2 ? Verbosity::xray : v == 1 ? Verbosity::standard : Verbosity::minimal; }
else if (arg("--prior")) cfg.match_prior = std::stof(next()); else if (arg("--prior")) cfg.match_prior = std::stof(next());
else if (arg("--prob-threshold")) cfg.prob_threshold = std::stof(next()); else if (arg("--prob-threshold")) cfg.prob_threshold = std::stof(next());
else if (arg("--match-threshold")) cfg.match_threshold = std::stof(next());
else if (arg("--extinction")) cfg.extinction_sec = std::stod(next());
else if (arg("--detector")) cfg.detector_model = next(); else if (arg("--detector")) cfg.detector_model = next();
else if (arg("--detector-engine")) cfg.detector_engine = next(); else if (arg("--detector-engine")) cfg.detector_engine = next();
else if (arg("--arcface")) cfg.arcface_model = next(); else if (arg("--arcface")) cfg.arcface_model = next();
@@ -228,14 +138,13 @@ static Config parse_args(int argc, char** argv) {
else if (arg("--conf")) cfg.detector_conf = std::stof(next()); else if (arg("--conf")) cfg.detector_conf = std::stof(next());
else if (arg("--max-faces")) cfg.max_faces = std::stoi(next()); else if (arg("--max-faces")) cfg.max_faces = std::stoi(next());
else if (arg("--min-face-px")) cfg.min_face_px = std::stof(next()); else if (arg("--min-face-px")) cfg.min_face_px = std::stof(next());
else if (arg("--ratio")) cfg.match_ratio = std::stof(next());
else if (arg("--ratio-ceil")) cfg.match_ratio_ceil = std::stof(next());
else if (arg("--track-alpha")) cfg.track_alpha = std::stof(next()); else if (arg("--track-alpha")) cfg.track_alpha = std::stof(next());
else if (arg("--track-min-iou")) cfg.track_min_iou = std::stof(next()); else if (arg("--track-min-iou")) cfg.track_min_iou = std::stof(next());
else if (arg("--track-min-prob")) cfg.track_assoc_min_prob = std::stof(next()); else if (arg("--track-min-prob")) cfg.track_assoc_min_prob = std::stof(next());
else if (arg("--track-extinction")) cfg.track_extinction_sec = std::stod(next()); else if (arg("--track-extinction")) cfg.track_extinction_sec = std::stod(next());
else if (arg("--ownership-logodds")) cfg.ownership_logodds = std::stof(next()); else if (arg("--anneal")) cfg.anneal_sec = std::stod(next());
else if (arg("--evidence-rho-max")) cfg.evidence_rho_max = std::stof(next());
else if (arg("--evidence-admit-below")) cfg.evidence_admit_below = std::stof(next());
else if (arg("--evidence-max-views")) cfg.evidence_max_views = std::stoi(next());
else if (arg("--expand-gallery")) cfg.expand_gallery = true; else if (arg("--expand-gallery")) cfg.expand_gallery = true;
else if (arg("--expand-buffer")) cfg.expand_buffer_size = std::stoi(next()); else if (arg("--expand-buffer")) cfg.expand_buffer_size = std::stoi(next());
else if (arg("--expand-band-lo")) cfg.expand_band_lo = std::stof(next()); else if (arg("--expand-band-lo")) cfg.expand_band_lo = std::stof(next());
@@ -264,21 +173,6 @@ static Config parse_args(int argc, char** argv) {
// ── Main ────────────────────────────────────────────────────────────────────── // ── Main ──────────────────────────────────────────────────────────────────────
int main(int argc, char** argv) { int main(int argc, char** argv) {
/// TRACES: VR-015 | PR-004
// OpenCV here is built against TBB, so cv::parallel_for_ opens an arena of
// nproc-1 workers (19 on a 20-core box) *on top of* KPN's one thread per
// node. Two schedulers, neither aware of the other, on the same cores.
//
// SAE_CV_THREADS=1 hands concurrency entirely to KPN, which is where this
// pipeline's parallelism is supposed to come from. Worth measuring rather
// than assuming: TBB fan-out inside warpAffine is free speed when the
// pipeline is otherwise idle, so this can cut either way. Unset = default.
if (const char* t = std::getenv("SAE_CV_THREADS")) {
const int n = std::atoi(t);
cv::setNumThreads(n);
std::cerr << "[opencv] cv::setNumThreads(" << n << ")\n";
}
Config cfg; Config cfg;
try { try {
cfg = parse_args(argc, argv); cfg = parse_args(argc, argv);
@@ -322,24 +216,15 @@ int main(int argc, char** argv) {
// and its final answer is only known when a track dies. // and its final answer is only known when a track dies.
auto same_person = same_person_probability(matcher_fn.calibration()); auto same_person = same_person_probability(matcher_fn.calibration());
TrackRegistry::Config reg_cfg; TrackRegistry::Config reg_cfg;
reg_cfg.track_extinction_sec = cfg.track_extinction_sec; reg_cfg.extinction_sec = cfg.track_extinction_sec;
reg_cfg.ownership_logodds = cfg.ownership_logodds;
/// TRACES: AR-025 | SR-002
// The discounter's parameters come from Config now. They used to be
// in-class defaults reached through the one-argument constructor, so the
// VR-007 sweep that rho_max's own comment defers to could not vary it.
EvidenceDiscounter::Config disc_cfg;
disc_cfg.max_views = cfg.evidence_max_views;
disc_cfg.admit_below = cfg.evidence_admit_below;
disc_cfg.rho_max = cfg.evidence_rho_max;
auto registry = std::make_shared<TrackRegistry>( auto registry = std::make_shared<TrackRegistry>(
reg_cfg, EvidenceDiscounter(same_person, disc_cfg)); reg_cfg, EvidenceDiscounter(same_person));
matcher_fn.set_registry(registry); matcher_fn.set_registry(registry);
FaceTrackerFunc ftracker_fn{cfg, registry, same_person}; FaceTrackerFunc ftracker_fn{cfg, registry, same_person};
FrameAnnotationFunc tracker_fn {}; SceneTrackerFunc tracker_fn {cfg};
ResultSinkFunc sink_fn {cfg, done}; ResultSinkFunc sink_fn {cfg, done};
/// TRACES: AR-012, AR-016 | IR-002, IR-003 | SR-002 /// TRACES: AR-012, AR-016 | IR-002, IR-003 | SR-002
@@ -350,15 +235,7 @@ int main(int argc, char** argv) {
// AR-016: a film ends with faces on screen and those tracks have not timed // AR-016: a film ends with faces on screen and those tracks have not timed
// out. Without this flush the closing scene's cast is silently never // out. Without this flush the closing scene's cast is silently never
// emitted — a loss that reads as a recognition miss, not a bookkeeping bug. // emitted — a loss that reads as a recognition miss, not a bookkeeping bug.
/// TRACES: VR-015 | PR-004 sink_fn.set_pre_write_hook([registry](double last_ts) { registry->flush(last_ts); });
// Last timestamp the pipeline reached, latched on the way out. It is what
// turns wall-clock seconds into the number that matters — seconds of film
// per second of run — and the sink is the only node that knows it.
std::atomic<double> film_sec{0.0};
sink_fn.set_pre_write_hook([registry, &film_sec](double last_ts) {
film_sec.store(last_ts, std::memory_order_release);
registry->flush(last_ts);
});
#ifdef SAE_DEBUG #ifdef SAE_DEBUG
DebugRendererFunc debug_fn {cfg}; DebugRendererFunc debug_fn {cfg};
#endif #endif
@@ -375,7 +252,7 @@ int main(int argc, char** argv) {
kpn::ObjectNode<EmbedderFunc, kpn::in<"aligned">, kpn::out<"embedded">, "embedder", 0> embedder (embedder_fn, 32); kpn::ObjectNode<EmbedderFunc, kpn::in<"aligned">, kpn::out<"embedded">, "embedder", 0> embedder (embedder_fn, 32);
kpn::ObjectNode<FaceTrackerFunc, kpn::in<"embedded">, kpn::out<"tracked">, "face_tracker", 0> ftracker (ftracker_fn, 16); kpn::ObjectNode<FaceTrackerFunc, kpn::in<"embedded">, kpn::out<"tracked">, "face_tracker", 0> ftracker (ftracker_fn, 16);
kpn::ObjectNode<IdentityMatcherFunc, kpn::in<"tracked">, kpn::out<"matched">, "identity_matcher", 0> matcher (matcher_fn, 16); kpn::ObjectNode<IdentityMatcherFunc, kpn::in<"tracked">, kpn::out<"matched">, "identity_matcher", 0> matcher (matcher_fn, 16);
kpn::ObjectNode<FrameAnnotationFunc, kpn::in<"matched">, kpn::out<"annotation">, "frame_annotation", 0> tracker (tracker_fn, 16); kpn::ObjectNode<SceneTrackerFunc, kpn::in<"matched">, kpn::out<"annotation">, "scene_tracker", 0> tracker (tracker_fn, 16);
kpn::ObjectNode<ResultSinkFunc, kpn::in<"annotation">,kpn::out<>, "result_sink", 0> sink (sink_fn, 16); kpn::ObjectNode<ResultSinkFunc, kpn::in<"annotation">,kpn::out<>, "result_sink", 0> sink (sink_fn, 16);
// ── Pipeline observability + run loop (topology-agnostic) ────────────────── // ── Pipeline observability + run loop (topology-agnostic) ──────────────────
@@ -424,23 +301,8 @@ int main(int argc, char** argv) {
return false; return false;
}); });
/// TRACES: VR-015 | PR-004
// Sampling must start with the network and stop before it is destroyed:
// channel fill is instantaneous, and by the time a run ends everything
// has drained, so a single read at shutdown reports an idle pipeline no
// matter how congested it was.
sae::bench::BenchmarkRecorder bench{cfg.benchmark_interval_ms};
const bool benchmarking = !cfg.benchmark_path.empty();
std::cerr << "[main] starting pipeline…\n"; std::cerr << "[main] starting pipeline…\n";
net.start(); net.start();
if (benchmarking) {
bench.start([&net] { return net.network_snapshot(); });
std::signal(SIGUSR1, sae_on_dump_signal);
std::cerr << "[benchmark] sampling every " << cfg.benchmark_interval_ms
<< "ms — `kill -USR1 " << getpid()
<< "` to dump the table now (works while hung)\n";
}
// Wait until BOTH terminal branches finish: result_sink (face pipeline) // Wait until BOTH terminal branches finish: result_sink (face pipeline)
// and, when enabled, scene_detector (the dense TransNetV2 branch, which // and, when enabled, scene_detector (the dense TransNetV2 branch, which
@@ -448,51 +310,12 @@ int main(int argc, char** argv) {
// pre-set true when scene detection is disabled. // pre-set true when scene detection is disabled.
while ((!done.load(std::memory_order_acquire) || while ((!done.load(std::memory_order_acquire) ||
!scene_done.load(std::memory_order_acquire)) && !scene_done.load(std::memory_order_acquire)) &&
!node_crashed.load(std::memory_order_acquire)) { !node_crashed.load(std::memory_order_acquire))
std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::this_thread::sleep_for(std::chrono::milliseconds(100));
/// TRACES: VR-015 | AR-004 | PR-004
if (g_dump_request.exchange(false, std::memory_order_relaxed))
bench.dump_live(std::cerr, film_sec.load(std::memory_order_acquire));
}
// Latch the counters before stop(): they stay readable afterwards, but
// only while the network object is alive, and this keeps the numbers
// describing the run rather than the teardown.
if (benchmarking) bench.stop();
net.stop(); net.stop();
net.print_diagnostics(); net.print_diagnostics();
/// TRACES: VR-015 | PR-004
if (benchmarking && bench.has_data()) {
const double film = film_sec.load(std::memory_order_acquire);
bench.print(std::cerr, film);
nlohmann::json run_cfg{
{"movie", cfg.movie_path},
{"gallery", cfg.gallery_path},
{"gallery_actors", gallery.actors.size()},
{"sample_fps", cfg.sample_fps},
{"min_face_px", cfg.min_face_px},
{"max_faces", cfg.max_faces},
{"embed_batch", cfg.embed_batch_size},
{"expand_gallery", cfg.expand_gallery},
{"scene_detect", cfg.scene_detect},
{"detector_engine", cfg.detector_engine},
{"arcface_engine", cfg.arcface_engine},
{"detector_model", cfg.detector_model},
{"arcface_model", cfg.arcface_model},
};
std::ofstream bf(cfg.benchmark_path);
if (bf) {
bf << bench.to_json(run_cfg, film).dump(2) << "\n";
std::cerr << "[benchmark] wrote " << cfg.benchmark_path << "\n";
} else {
std::cerr << "[benchmark] ERROR: could not write "
<< cfg.benchmark_path << "\n";
}
}
/// TRACES: AR-004 | SR-002 /// TRACES: AR-004 | SR-002
// A dropped frame does not degrade a result, it silently changes one — // A dropped frame does not degrade a result, it silently changes one —
// the output is a claim about footage that was never analysed, and // the output is a claim about footage that was never analysed, and
@@ -518,45 +341,6 @@ int main(int argc, char** argv) {
std::cerr << "\n"; std::cerr << "\n";
} }
/// TRACES: AR-025, AR-012 | SR-002
// How often the registry was asked about a track it had already reaped.
//
// A vote is dropped when the matcher lags the tracker by more than
// track_extinction_sec of FILM time. The two are adjacent nodes with a
// 16-deep channel between them, and the matcher is much the slower of
// the pair (a GEMM over the whole gallery against a Hungarian solve over
// a handful of boxes), so that channel runs full and the lag is close to
// its depth. In frames:
//
// lag_sec ~= channel_depth / sample_fps
//
// At the default sample_fps of 1.0 that is ~16 s against a 5 s window,
// so votes CAN be dropped here, and each one is identity evidence that
// never reached the track it belonged to -- presence under-reported, in
// a way that reads as a recognition miss.
//
// Reported rather than fatal, deliberately, and the distinction from the
// dropped-frame case below is real: a dropped frame means the output
// describes footage nobody analysed, which is always wrong. A dropped
// vote means one observation of a track went missing, which degrades a
// claim without falsifying it. There is also no measurement yet of how
// often it happens on real content -- so this prints the number that
// would justify a harder line rather than presuming it. See VR-017.
if (registry) {
const int dv = registry->dropped_votes();
if (dv > 0) {
std::cerr << "[registry] WARNING: " << dv << " identity vote(s) "
"arrived for already-reaped tracks. The matcher is "
"lagging the tracker by more than track_extinction_sec ("
<< cfg.track_extinction_sec << "s) of film; presence is "
"under-reported. Raise --track-extinction or reduce the "
"face_tracker/identity_matcher channel depth.\n";
}
std::cerr << "[registry] belief_swaps=" << registry->belief_swaps()
<< " actor_conflicts=" << registry->actor_conflicts()
<< " dropped_votes=" << dv << "\n";
}
bool dropped = false; bool dropped = false;
{ {
std::lock_guard<std::mutex> lk(event_mtx); std::lock_guard<std::mutex> lk(event_mtx);
@@ -627,7 +411,7 @@ int main(int argc, char** argv) {
auto boundaries = std::make_shared<SceneBoundaries>(); auto boundaries = std::make_shared<SceneBoundaries>();
scene_fn.set_boundaries(boundaries); scene_fn.set_boundaries(boundaries);
kpn::ObjectNode<SceneDetectorFunc, kpn::in<"dense">, kpn::out<>, "scene_detector", 0> kpn::ObjectNode<SceneDetectorFunc, kpn::in<"dense">, kpn::out<>, "scene_detector", 0>
scene_node(scene_fn, kSceneInputDepth); scene_node(scene_fn, 128);
// Decimator: keep frames on the sample_fps cadence, drop the rest. // Decimator: keep frames on the sample_fps cadence, drop the rest.
// eof always passes so downstream shuts down cleanly. Stateful — one // eof always passes so downstream shuts down cleanly. Stateful — one
@@ -642,7 +426,7 @@ int main(int argc, char** argv) {
return true; return true;
} }
return false; return false;
}, kDecimatorInputDepth); }, kSceneJoinDepth);
/// TRACES: AR-010 | SR-002 /// TRACES: AR-010 | SR-002
// Stamp is_scene_boundary from the detector's published verdict. tol is // Stamp is_scene_boundary from the detector's published verdict. tol is
@@ -657,7 +441,7 @@ int main(int argc, char** argv) {
// otherwise look exactly like "no boundary here". // otherwise look exactly like "no boundary here".
SceneBoundaryAnnotatorFunc annotate_fn{boundaries, 0.5 / cfg.sample_fps}; SceneBoundaryAnnotatorFunc annotate_fn{boundaries, 0.5 / cfg.sample_fps};
kpn::ObjectNode<SceneBoundaryAnnotatorFunc, kpn::in<"frame">, kpn::out<"frame">, kpn::ObjectNode<SceneBoundaryAnnotatorFunc, kpn::in<"frame">, kpn::out<"frame">,
"scene_annotate", 0> annotate(annotate_fn, scene_join_depth(cfg.sample_fps)); "scene_annotate", 0> annotate(annotate_fn, kSceneJoinDepth);
// Reported at shutdown: without this the join is unverifiable, and an // Reported at shutdown: without this the join is unverifiable, and an
// annotator that never fired looks identical to footage with no // annotator that never fired looks identical to footage with no
@@ -33,29 +33,9 @@ struct CameraPositionChangeDetectorFunc {
explicit CameraPositionChangeDetectorFunc(const Config& cfg) explicit CameraPositionChangeDetectorFunc(const Config& cfg)
: cut_threshold_(cfg.cut_threshold) : cut_threshold_(cfg.cut_threshold)
, want_rgb_hist_(!cfg.scene_xgb_model.empty())
{ {
std::cerr << "[camera_position_change_detector] cut_threshold=" std::cerr << "[camera_position_change_detector] cut_threshold="
<< cut_threshold_ << cut_threshold_ << "\n";
<< (want_rgb_hist_ ? " (+rgb_hist for scene detector)" : "")
<< "\n";
}
// 32-bin-per-channel normalised RGB histogram (96 floats), the exact layout
// the XGBoost scene detector was trained on (see embedding_dump_node). Only
// computed when a scene model is configured, so it costs nothing otherwise.
static std::vector<float> rgb_histogram(const cv::Mat& img) {
constexpr int kBins = 32;
std::vector<float> out(kBins * 3, 0.f);
if (img.empty() || img.channels() != 3) return out;
float range[] = {0.f, 256.f}; const float* ranges = range; int bins = kBins;
for (int c = 0; c < 3; ++c) { // OpenCV BGR → store B,G,R blocks
cv::Mat h;
cv::calcHist(&img, 1, &c, cv::Mat(), h, 1, &bins, &ranges);
cv::normalize(h, h, 1.0, 0.0, cv::NORM_L1);
for (int b = 0; b < kBins; ++b) out[c*kBins + b] = h.at<float>(b);
}
return out;
} }
Frame operator()(Frame f) { Frame operator()(Frame f) {
@@ -84,13 +64,11 @@ struct CameraPositionChangeDetectorFunc {
prev_hist_ = hist; prev_hist_ = hist;
prev_hist_valid_ = true; prev_hist_valid_ = true;
if (want_rgb_hist_) f.rgb_hist = rgb_histogram(f.image);
return f; return f;
} }
private: private:
float cut_threshold_; float cut_threshold_;
bool want_rgb_hist_{false};
cv::Mat prev_hist_; cv::Mat prev_hist_;
bool prev_hist_valid_{false}; bool prev_hist_valid_{false};
}; };
+3 -62
View File
@@ -1,11 +1,10 @@
#pragma once #pragma once
/// TRACES: AR-028 | VR-001, VR-010 | PR-002 /// TRACES: VR-001, VR-010 | PR-002
#include "types.hpp" #include "types.hpp"
#include "config.hpp" #include "config.hpp"
#include "gallery/embedder_stamp.hpp" #include "gallery/embedder_stamp.hpp"
#include <H5Cpp.h> #include <H5Cpp.h>
#include <opencv2/imgproc.hpp> // cv::calcHist for the per-frame RGB histogram
#include <atomic> #include <atomic>
#include <cstdint> #include <cstdint>
@@ -168,12 +167,6 @@ struct EmbeddingDumpFunc {
fidx_.push_back(ef.source.frame_idx); fidx_.push_back(ef.source.frame_idx);
is_cut_.push_back(ef.source.is_cut ? 1 : 0); is_cut_.push_back(ef.source.is_cut ? 1 : 0);
is_bnd_.push_back(ef.source.is_scene_boundary ? 1 : 0); is_bnd_.push_back(ef.source.is_scene_boundary ? 1 : 0);
// Per-frame normalised RGB histogram (kHistBins per channel), for offline
// training of a learned scene-boundary detector against X-Ray scene
// boundaries — the grayscale-correlation cut detector is blind on
// low-contrast grades (Scarface: 1 cut in 10k frames). Cheap and the frame
// is already decoded here; empty frame → zeros.
append_rgb_hist(ef.source.image);
face_off_.push_back(static_cast<int64_t>(conf_.size())); face_off_.push_back(static_cast<int64_t>(conf_.size()));
face_cnt_.push_back(n); face_cnt_.push_back(n);
@@ -185,16 +178,6 @@ struct EmbeddingDumpFunc {
lmk_.push_back(f.landmarks[k].y); lmk_.push_back(f.landmarks[k].y);
} }
conf_.push_back(f.confidence); conf_.push_back(f.confidence);
/// TRACES: AR-028 | SR-002
// The quality vector, carried rather than consumed: written beside
// the embedding it describes so VR-012 can locate its knees against
// recorded data instead of by re-running video. Size is the third
// axis and is already here as bbox + the bbox_upscale attribute.
// Both are -1 only if a face reached the dump unscored, which the
// aligner does not allow — the sentinel is preserved rather than
// clamped so that a future path which did would be visible.
sharp_.push_back(f.sharpness);
resid_.push_back(f.alignment_residual);
const auto& e = ef.embeddings[i]; const auto& e = ef.embeddings[i];
emb_.insert(emb_.end(), e.begin(), e.end()); emb_.insert(emb_.end(), e.begin(), e.end());
} }
@@ -211,20 +194,11 @@ struct EmbeddingDumpFunc {
} }
private: private:
// Root attributes are additive: schema_version stayed 1 across VR-010, because // Root attributes are additive: schema_version stays 1 across VR-010, because
// every reader takes attributes by name with a default (replay.py) or an // every reader takes attributes by name with a default (replay.py) or an
// existence check (read_dump_provenance), so an old dump loses nothing and a // existence check (read_dump_provenance), so an old dump loses nothing and a
// new dump breaks nothing. A bump is for a change to the *datasets*. // new dump breaks nothing. A bump is for a change to the *datasets*.
// static constexpr int kSchemaVersion = 1;
// v2 is that change: AR-028 adds faces/sharpness and faces/alignment_residual.
// The bump is not about readers — those check for the datasets by name, and a
// v1 dump still replays. It is so a *consumer of the quality vector* can tell
// "this film's faces were never scored" from "this film's faces scored zero",
// which is the same distinction scene_detect exists to make and is likewise
// not recoverable from the arrays. A v1 dump reports the vector as unknown;
// re-dump to acquire it, since nobody can assert after the fact how sharp a
// face was.
static constexpr int kSchemaVersion = 2;
static constexpr int kEmbedDim = 512; static constexpr int kEmbedDim = 512;
static std::string basename_of(const std::string& path) { static std::string basename_of(const std::string& path) {
@@ -294,48 +268,17 @@ private:
write_vec(frames, "is_scene_boundary", is_bnd_, H5::PredType::NATIVE_UINT8); write_vec(frames, "is_scene_boundary", is_bnd_, H5::PredType::NATIVE_UINT8);
write_vec(frames, "face_offset", face_off_, H5::PredType::NATIVE_INT64); write_vec(frames, "face_offset", face_off_, H5::PredType::NATIVE_INT64);
write_vec(frames, "face_count", face_cnt_, H5::PredType::NATIVE_INT32); write_vec(frames, "face_count", face_cnt_, H5::PredType::NATIVE_INT32);
// Per-frame normalised RGB histogram, kHistBins per channel laid out
// [R(kHistBins) G(kHistBins) B(kHistBins)] per row. Feeds the learned
// scene-boundary detector (see scripts/scene_detector/).
write_vec(frames, "rgb_hist", rgb_hist_, H5::PredType::NATIVE_FLOAT,
kHistBins * 3);
H5::Group faces = file.createGroup("faces"); H5::Group faces = file.createGroup("faces");
write_vec(faces, "embedding", emb_, H5::PredType::NATIVE_FLOAT, kEmbedDim); write_vec(faces, "embedding", emb_, H5::PredType::NATIVE_FLOAT, kEmbedDim);
write_vec(faces, "bbox", bbox_, H5::PredType::NATIVE_FLOAT, 4); write_vec(faces, "bbox", bbox_, H5::PredType::NATIVE_FLOAT, 4);
write_vec(faces, "landmarks", lmk_, H5::PredType::NATIVE_FLOAT, 10); write_vec(faces, "landmarks", lmk_, H5::PredType::NATIVE_FLOAT, 10);
write_vec(faces, "confidence", conf_, H5::PredType::NATIVE_FLOAT); write_vec(faces, "confidence", conf_, H5::PredType::NATIVE_FLOAT);
/// TRACES: AR-028 | SR-002
write_vec(faces, "sharpness", sharp_, H5::PredType::NATIVE_FLOAT);
write_vec(faces, "alignment_residual", resid_, H5::PredType::NATIVE_FLOAT);
std::cerr << "[embedding_dump] wrote " << ts_.size() << " frames, " std::cerr << "[embedding_dump] wrote " << ts_.size() << " frames, "
<< conf_.size() << " faces → " << path_ << "\n"; << conf_.size() << " faces → " << path_ << "\n";
} }
// Per-channel bin count for the RGB histogram. 32 → a 96-float row per frame,
// ~40 KB per 10k-frame film: negligible next to the embeddings.
static constexpr int kHistBins = 32;
// Append the frame's normalised per-channel RGB histogram (R,G,B blocks). An
// empty frame (EOF sentinels never reach here) yields a zero row so the array
// stays parallel to ts_.
void append_rgb_hist(const cv::Mat& img) {
const size_t base = rgb_hist_.size();
rgb_hist_.resize(base + kHistBins * 3, 0.f);
if (img.empty() || img.channels() != 3) return;
float range[] = {0.f, 256.f};
const float* ranges[] = {range};
int bins = kHistBins;
for (int c = 0; c < 3; ++c) { // OpenCV is BGR; store as B,G,R blocks
cv::Mat h;
cv::calcHist(&img, 1, &c, cv::Mat(), h, 1, &bins, ranges);
cv::normalize(h, h, 1.0, 0.0, cv::NORM_L1);
for (int b = 0; b < kHistBins; ++b)
rgb_hist_[base + c * kHistBins + b] = h.at<float>(b);
}
}
std::string path_, movie_; std::string path_, movie_;
EmbedderStamp stamp_; EmbedderStamp stamp_;
DumpProvenance prov_; DumpProvenance prov_;
@@ -349,6 +292,4 @@ private:
std::vector<int64_t> face_off_; std::vector<int64_t> face_off_;
std::vector<int32_t> face_cnt_; std::vector<int32_t> face_cnt_;
std::vector<float> emb_, bbox_, lmk_, conf_; std::vector<float> emb_, bbox_, lmk_, conf_;
std::vector<float> sharp_, resid_; // AR-028 quality vector, parallel to conf_
std::vector<float> rgb_hist_; // kHistBins*3 per frame, parallel to ts_
}; };
+6 -59
View File
@@ -1,55 +1,25 @@
#pragma once #pragma once
#include "face_utils.hpp" #include "face_utils.hpp"
#include <cstdint>
#include <iostream> #include <iostream>
// ── FaceAlignerFunc ─────────────────────────────────────────────────────────── // ── FaceAlignerFunc ───────────────────────────────────────────────────────────
/// TRACES: AR-005, AR-028, AR-029, AR-030 | SR-002 /// TRACES: AR-005, AR-030 | SR-002
/// ///
// KPN node: applies a 5-point similarity transform to each detected face, // KPN node: applies a 5-point similarity transform to each detected face,
// producing a 112×112 BGR crop suitable for ArcFace inference. // producing a 112×112 BGR crop suitable for ArcFace inference.
// //
// Alignment is an Umeyama least-squares fit over all five landmarks (AR-005), // Alignment is an Umeyama least-squares fit over all five landmarks (AR-005),
// not a robust one: a RANSAC fit discards the very landmarks AR-030 reads. // not a robust one: a RANSAC fit discards the very landmarks AR-030 reads.
// // Degenerate detections (where the fit fails) are dropped from the output
// This is also where the AR-028 quality vector is filled in, because this is // vectors. The fit's residual is the AR-030 visibility measure and comes free,
// where the inputs to it already exist: // since the warp needs the transform anyway.
//
// - **Visibility** (AR-030) is the fit's residual, and is genuinely free — the
// transform is computed for the warp regardless, and the residual is what
// that fit could not explain.
// - **Sharpness** (AR-029) is measured on the crop this node just produced,
// which is the only place it *can* be measured: the aligned canvas is what
// makes the number scale-normalised, and downstream of the embedder the crop
// is only forwarded for debug rendering. It is not free — 33 us per face
// single-threaded (cvtColor, one Laplacian, two meanStdDev over 112x112) —
// but it is two orders below the embedder inference it qualifies, and it
// runs per face rather than per frame, so a landscape shot costs nothing.
//
// Size, the third axis, is `bbox` and needs no work here.
//
// No face is admitted unscored: every face in the output carries both numbers,
// so a negative value downstream is a bug rather than a poor-quality face.
// Nothing is dropped or discounted on quality — that is AR-030's discount and
// VR-012's knee, both still open.
//
// Degenerate detections (where the fit fails) cannot be scored, since there is
// no crop and no residual to score, and are therefore dropped — but they are
// **counted**, not silently discarded. A nonzero tally means the detector is
// emitting landmark sets the aligner cannot use, which is a fact about the
// detector; losing it leaves a hole in the dump that looks like footage with
// no faces in it.
struct FaceAlignerFunc { struct FaceAlignerFunc {
static constexpr std::string_view label() { return "face_aligner"; } static constexpr std::string_view label() { return "face_aligner"; }
AlignedSceneFrame operator()(SceneFrame sf) { AlignedSceneFrame operator()(SceneFrame sf) {
if (sf.source.eof) { if (sf.source.eof || sf.faces.empty())
report();
return {std::move(sf.source), {}, {}};
}
if (sf.faces.empty())
return {std::move(sf.source), {}, {}}; return {std::move(sf.source), {}, {}};
std::vector<DetectedFace> good_faces; std::vector<DetectedFace> good_faces;
@@ -63,38 +33,15 @@ struct FaceAlignerFunc {
float residual = -1.f; float residual = -1.f;
cv::Mat crop = align_face(sf.source.image, face.landmarks, &residual); cv::Mat crop = align_face(sf.source.image, face.landmarks, &residual);
if (crop.empty()) { if (crop.empty()) {
++degenerate_; std::cerr << "[face_aligner] degenerate detection skipped\n";
continue; continue;
} }
face.alignment_residual = residual; face.alignment_residual = residual;
face.sharpness = crop_sharpness(crop);
good_faces.push_back(face); good_faces.push_back(face);
crops.push_back(std::move(crop)); crops.push_back(std::move(crop));
++scored_;
} }
return {std::move(sf.source), std::move(good_faces), std::move(crops)}; return {std::move(sf.source), std::move(good_faces), std::move(crops)};
} }
/// Faces that carry a full quality vector, and faces the fit could not use.
uint64_t scored() const { return scored_; }
uint64_t degenerate() const { return degenerate_; }
private:
// Reported once at EOF rather than per occurrence: a run with a systematic
// landmark problem would otherwise emit one line per face for the length of
// a film, which is how the count came to be ignored.
void report() {
if (reported_) return;
reported_ = true;
if (degenerate_)
std::cerr << "[face_aligner] " << degenerate_ << " of "
<< (degenerate_ + scored_)
<< " detections had a degenerate landmark fit and were dropped"
" (no crop, so no embedding and no quality vector)\n";
}
uint64_t scored_{0};
uint64_t degenerate_{0};
bool reported_{false};
}; };
+11 -28
View File
@@ -6,7 +6,6 @@
#include <algorithm> #include <algorithm>
#include <memory> #include <memory>
#include <string> #include <string>
#include <vector>
// ── FaceDetectorFunc ────────────────────────────────────────────────────────── // ── FaceDetectorFunc ──────────────────────────────────────────────────────────
// KPN node: runs SCRFD-500MF to detect ALL faces in a frame. // KPN node: runs SCRFD-500MF to detect ALL faces in a frame.
@@ -23,38 +22,22 @@ struct FaceDetectorFunc {
, min_face_px_(cfg.min_face_px) , min_face_px_(cfg.min_face_px)
{} {}
/// TRACES: AR-002 | SR-002
// Drop faces below the minimum size — too small for reliable ArcFace
// alignment, and below the resolution where identification still holds
// (VR-013 measured the knee end to end).
//
// The minimum is expressed in ORIGINAL video resolution, which is what makes
// it a property of the footage rather than of a throughput knob. When
// dense_scale downscaled the frame the detector's boxes are in downscaled
// space, and `bbox_upscale` is what maps them back; dividing the threshold by
// it rather than multiplying every box keeps the comparison on the detector's
// own numbers and the physical cutoff constant across scales.
//
// Strictly less-than: a face exactly at the minimum is admissible, which is
// what a "minimum of 40x40" means.
static void drop_undersized(std::vector<DetectedFace>& faces,
float min_face_px,
float bbox_upscale) {
const float min_px = (bbox_upscale > 0.f) ? min_face_px / bbox_upscale
: min_face_px;
faces.erase(
std::remove_if(faces.begin(), faces.end(), [&](const DetectedFace& d) {
return d.bbox.width < min_px || d.bbox.height < min_px;
}),
faces.end());
}
SceneFrame operator()(Frame f) { SceneFrame operator()(Frame f) {
if (f.eof) return {std::move(f), {}}; if (f.eof) return {std::move(f), {}};
auto faces = detector_->detect(f.image); auto faces = detector_->detect(f.image);
drop_undersized(faces, min_face_px_, f.bbox_upscale); // Drop faces below minimum pixel size (too small for reliable ArcFace
// alignment). Note: when dense_scale downscaled the frame, both the
// detection coords and min_face_px are in downscaled space — so scale
// the threshold down to match, keeping the physical size cutoff constant.
const float min_px = (f.bbox_upscale != 1.f)
? min_face_px_ / f.bbox_upscale : min_face_px_;
faces.erase(
std::remove_if(faces.begin(), faces.end(), [&](const DetectedFace& d) {
return d.bbox.width < min_px || d.bbox.height < min_px;
}),
faces.end());
// Sort largest-first so max_faces_ keeps the most informative detections // Sort largest-first so max_faces_ keeps the most informative detections
std::sort(faces.begin(), faces.end(), std::sort(faces.begin(), faces.end(),
-49
View File
@@ -1,49 +0,0 @@
#pragma once
/// TRACES: AR-012, AR-013 | SR-002
///
/// FrameAnnotationFunc — project a matched frame into a per-frame annotation.
///
/// Stateless, and that is the entire point of it.
///
/// It replaces `SceneTrackerFunc`, which kept an extinction timer per actor and
/// reported an actor as visible for `extinction_sec` (57.4 s) after their last
/// detection. docs/SPEC.md specified that node's deletion -- "anneal_sec and
/// extinction_sec are deleted, not re-tuned ... SceneTrackerFunc goes with
/// them", with a removal list ending "grep for both names and expect no
/// survivors" -- and docs/requirements.md recorded both constants as Withdrawn,
/// deleted "rather than retained at zero", on the grounds that a field naming a
/// mechanism the pipeline no longer has is actively misleading. None of that
/// removal had happened. The node was still wired into both shipped pipelines
/// and still printed its timeout at every startup.
///
/// **Presence is not this node's business.** AR-012 moved it to TrackRegistry,
/// where a window is `[first_seen, last_seen]` of a track an actor owns, and
/// AR-013 ends that window at the last sighting rather than after it. A
/// keep-alive here answered the same question a second time and answered it
/// worse: it re-opened the trailing cool-down the registry exists to refuse.
///
/// What a consumer sees change: `--verbosity standard`'s `frames[].identified`
/// used to list every actor still inside the keep-alive, including ones absent
/// from the frame. It now lists what was actually matched in that frame. The
/// minimal and xray outputs are unaffected -- they were already built from
/// registry claims and never consulted this node.
#include "types.hpp"
#include <string_view>
#include <utility>
struct FrameAnnotationFunc {
static constexpr std::string_view label() { return "frame_annotation"; }
SceneAnnotation operator()(MatchedSceneFrame mf) {
if (mf.source.eof) return {0.0, {}, /*eof=*/true};
SceneAnnotation sa;
sa.timestamp_sec = mf.source.timestamp_sec;
sa.visible_actors = std::move(mf.actors);
sa.is_cut = mf.source.is_cut;
sa.is_scene_boundary = mf.source.is_scene_boundary;
sa.rgb_hist = std::move(mf.source.rgb_hist);
return sa;
}
};
+78 -148
View File
@@ -19,36 +19,19 @@
// KPN node: compares each embedding against every reference embedding in the // KPN node: compares each embedding against every reference embedding in the
// actor gallery using cosine similarity. // actor gallery using cosine similarity.
// //
// Matching strategy — one mode, always. // Matching strategy — two modes selected at construction time:
// //
// Gallery calibration fits a sigmoid P(match) = σ(a·similarity + b) from // Calibrated (preferred): gallery calibration fits a sigmoid
// intra/inter-class pairs. A face is accepted if P(match | best_actor) > // P(match) = σ(a·similarity + b) from intra/inter-class pairs.
// prob_threshold. Per-actor best similarity is the closest reference // A face is accepted if P(match | best_actor) > prob_threshold.
// embedding (best-of-N).
// //
/// TRACES: AR-024 | SR-002 // Fallback (no calibration): dual-criterion accept —
// **There is no raw-cosine fallback.** There used to be: when the fit was // (a) best cosine distance < match_threshold, OR
// invalid this node switched to a cosine-distance ceiling plus a ratio test // (b) ratio test: best_dist/second_best_dist < match_ratio
// (`match_threshold`, `match_ratio`, `match_ratio_ceil`). Three things were // AND best_dist < match_ratio_ceil.
// wrong with it, and the third is the one that mattered.
// //
// 1. It violated AR-024 outright, untagged — a bare cosine threshold means // In both modes, per-actor best similarity is determined by scanning
// something different for every model, gallery and face size. // reference embeddings and taking the closest (best-of-N).
// 2. It disagreed with the rest of the pipeline about what "calibration
// failed" means. `same_person_probability` answers that question by
// falling back to the untuned default sigmoid and saying so loudly, so
// tracking and evidence weighting stayed in probability space while
// matching alone left it. One run, two policies.
// 3. Its accepted faces were still fed to `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. `max(0, cosine)` went straight
// into the log-odds accumulation as though it were a probability.
//
// An invalid fit now behaves exactly as everywhere else: the default sigmoid,
// with a warning that says the probabilities are not meaningful. That is a
// worse answer than a fitted calibration and a better one than a number whose
// units nothing else in the pipeline shares.
// //
// Gallery scan: the full reference set (tens of thousands of 512-dim // Gallery scan: the full reference set (tens of thousands of 512-dim
// embeddings) is uploaded to the GPU once at construction time and stays // embeddings) is uploaded to the GPU once at construction time and stays
@@ -56,13 +39,6 @@
// uploaded and a single SGEMM computes the full similarity matrix in well under // uploaded and a single SGEMM computes the full similarity matrix in well under
// a millisecond. The GPU math backend (cuBLAS or rocBLAS) lives behind // a millisecond. The GPU math backend (cuBLAS or rocBLAS) lives behind
// ISimilarityEngine (backends/gemm_backend.cpp) and is selected at compile time. // ISimilarityEngine (backends/gemm_backend.cpp) and is selected at compile time.
//
// TRACES: AR-026 | SR-001
// That resident matrix grows during a film: per-film expansion (AR-019) promotes
// pose-varied views, and they are APPENDED to it rather than scored separately,
// so one multiply covers baked and promoted references alike and best-of-N is a
// single pass over one similarity column. There is no second similarity path in
// this node to fall out of step with the first.
struct IdentityMatcherFunc { struct IdentityMatcherFunc {
static constexpr std::string_view label() { return "identity_matcher"; } static constexpr std::string_view label() { return "identity_matcher"; }
@@ -74,6 +50,9 @@ struct IdentityMatcherFunc {
: gallery_(gallery) : gallery_(gallery)
, prob_threshold_(cfg.prob_threshold) , prob_threshold_(cfg.prob_threshold)
, log_prior_odds_(std::log(cfg.match_prior / (1.f - cfg.match_prior))) , log_prior_odds_(std::log(cfg.match_prior / (1.f - cfg.match_prior)))
, threshold_(cfg.match_threshold)
, ratio_(cfg.match_ratio)
, ratio_ceil_(cfg.match_ratio_ceil)
, track_gallery_(cfg) , track_gallery_(cfg)
{ {
std::cerr << "[identity_matcher] flattening gallery embeddings...\n"; std::cerr << "[identity_matcher] flattening gallery embeddings...\n";
@@ -106,21 +85,16 @@ struct IdentityMatcherFunc {
<< cfg.gallery_path << "\n"; << cfg.gallery_path << "\n";
} }
/// TRACES: AR-024 | SR-002 if (cal_.valid) {
// Same sentence either way, because it is the same decision rule; only std::cerr << "[identity_matcher] calibrated Bayesian matching"
// the provenance of (a, b) differs. An unfitted sigmoid still returns << " prior=" << cfg.match_prior
// plausible-looking probabilities, so the warning has to be the thing << " P_threshold=" << prob_threshold_
// that distinguishes them — nothing downstream can. << " effective_sim_boundary="
std::cerr << "[identity_matcher] calibrated Bayesian matching" << cal_.boundary_at(prob_threshold_, log_prior_odds_) << "\n";
<< " prior=" << cfg.match_prior } else {
<< " P_threshold=" << prob_threshold_ std::cerr << "[identity_matcher] threshold matching (calibration skipped)"
<< " effective_sim_boundary=" << " threshold=" << threshold_
<< cal_.boundary_at(prob_threshold_, log_prior_odds_) << "\n"; << " ratio=" << ratio_ << " ratio_ceil=" << ratio_ceil_ << "\n";
if (!cal_.valid) {
std::cerr << "[identity_matcher] WARNING: the calibration is NOT fitted "
"(a=" << cal_.a << ", b=" << cal_.b << ") — matching runs "
"on the untuned default sigmoid, so prob_threshold is not "
"comparable to a tuned run's.\n";
} }
std::cerr << "[identity_matcher] gallery: " std::cerr << "[identity_matcher] gallery: "
<< gallery_.actors.size() << " actors, " << gallery_.actors.size() << " actors, "
@@ -152,12 +126,7 @@ struct IdentityMatcherFunc {
/// Where per-frame identity evidence reaches the registry. Optional: with no /// Where per-frame identity evidence reaches the registry. Optional: with no
/// registry attached the matcher behaves exactly as before, which keeps the /// registry attached the matcher behaves exactly as before, which keeps the
/// replay harness and the unit tests working unchanged. /// replay harness and the unit tests working unchanged.
void set_registry(std::shared_ptr<TrackRegistry> r) { void set_registry(std::shared_ptr<TrackRegistry> r) { registry_ = std::move(r); }
registry_ = std::move(r);
// This node is the evidence source, so the registry must not close a
// track until this node's watermark has passed it (AR-013).
if (registry_) registry_->expect_evidence();
}
// Runtime setter — lets a persistent pipeline be reused across a threshold sweep // Runtime setter — lets a persistent pipeline be reused across a threshold sweep
// without rebuilding the (expensive, gallery-resident) matcher. The gallery, // without rebuilding the (expensive, gallery-resident) matcher. The gallery,
@@ -170,22 +139,6 @@ struct IdentityMatcherFunc {
return {std::move(tf.source), {}}; return {std::move(tf.source), {}};
} }
/// TRACES: AR-012, AR-013 | SR-002
// Publish the evidence watermark BEFORE voting on this frame: every
// observation strictly before it has now been folded in, so the registry
// may reap against it. Unconditional -- a frame with no faces still
// advances the watermark, or a long faceless stretch would stall reaping
// and hold every dormant track open to the end of the film.
//
// This is what makes presence independent of node speed. The registry
// used to reap on the TRACKER's clock, and backpressure (working as
// AR-004 intends) means the tracker can be a whole channel's depth ahead
// of this node -- so tracks were closed before their votes arrived, the
// votes were dropped, and the run silently under-reported. Measured on
// the SuperHero fixture before this change: channel depth 32 gave 5
// actors, depth 10322 gave 0, from identical input.
if (registry_) registry_->advance_evidence(tf.source.timestamp_sec);
// A hard cut changes the camera viewpoint. The face_tracker may revive a // A hard cut changes the camera viewpoint. The face_tracker may revive a
// track_id across the cut (identity continuity), but promotion must never // track_id across the cut (identity continuity), but promotion must never
// mix embeddings from two viewpoints under one buffer, so we still drop // mix embeddings from two viewpoints under one buffer, so we still drop
@@ -230,51 +183,62 @@ struct IdentityMatcherFunc {
tf.embeddings[base + k].data(), 512 * sizeof(float)); tf.embeddings[base + k].data(), 512 * sizeof(float));
} }
/// TRACES: AR-026 | SR-001 // S (N_gallery × chunk) col-major: face k's gallery sims at sims + k*n_gallery.
// One GEMM now covers baked references AND the per-film annex: promoted
// rows were appended to the engine's resident matrix, so they are just
// more gallery rows with an entry in flat_actor_. The annex used to be
// folded in afterwards by a host-side cosine loop, justified by "tens of
// embeddings" — an assumption AR-018/AR-019 retired, since every owned
// track promotes and the annex grows with cast size and film length.
//
// n_gallery() is read per frame, not cached: it grows as promotions land.
const int n_gal = sim_engine_->n_gallery();
const float* host_sims = sim_engine_->compute(host_query.data(), chunk); const float* host_sims = sim_engine_->compute(host_query.data(), chunk);
for (int ci = 0; ci < chunk; ++ci) { for (int ci = 0; ci < chunk; ++ci) {
const int fi = base + ci; const int fi = base + ci;
const float* sims = host_sims + static_cast<size_t>(ci) * n_gal; const float* sims = host_sims + static_cast<size_t>(ci) * n_gallery_;
std::vector<float> best_sim(gallery_.actors.size(), std::vector<float> best_sim(gallery_.actors.size(),
-std::numeric_limits<float>::max()); -std::numeric_limits<float>::max());
for (int ei = 0; ei < n_gal; ++ei) { for (int ei = 0; ei < n_gallery_; ++ei) {
float sim = sims[ei]; float sim = sims[ei];
int ai = flat_actor_[ei]; int ai = flat_actor_[ei];
if (sim > best_sim[ai]) best_sim[ai] = sim; if (sim > best_sim[ai]) best_sim[ai] = sim;
} }
// Only the best matters now. The runner-up was tracked solely for // Fold in the per-film annex (CPU-side, tens of embeddings). Promoted
// the retired ratio test, which asked whether the best cosine stood // pose-varied views compete for best-of-N exactly like baked refs, so
// out from the second — a question the calibrated posterior does // a face at a pose the gallery lacked can now win its true actor.
// not need, since it already says how likely the best match is to for (const auto& ae : track_gallery_.annex()) {
// be right rather than how much it beat its neighbour by. float sim = cosine_similarity(tf.embeddings[fi], ae.emb);
int best_actor = -1; if (sim > best_sim[ae.actor_idx]) best_sim[ae.actor_idx] = sim;
float best_s = -std::numeric_limits<float>::max();
for (int ai = 0; ai < static_cast<int>(best_sim.size()); ++ai) {
if (best_sim[ai] > best_s) {
best_s = best_sim[ai];
best_actor = ai;
}
} }
/// TRACES: AR-024 | SR-002 int best_actor = -1;
// One rule, whatever the fit's provenance. The cosine reaches a int second_actor = -1;
// comparison only through cal_.probability(). float best_s = -std::numeric_limits<float>::max();
const float best_p = best_actor >= 0 float second_s = -std::numeric_limits<float>::max();
? cal_.probability(best_s, log_prior_odds_) for (int ai = 0; ai < static_cast<int>(best_sim.size()); ++ai) {
: 0.f; if (best_sim[ai] > best_s) {
const bool accept = best_actor >= 0 && best_p > prob_threshold_; second_s = best_s;
second_actor = best_actor;
best_s = best_sim[ai];
best_actor = ai;
} else if (best_sim[ai] > second_s) {
second_s = best_sim[ai];
second_actor = ai;
}
}
(void)second_actor;
bool accept = false;
if (best_actor >= 0) {
if (cal_.valid) {
accept = cal_.probability(best_s, log_prior_odds_) > prob_threshold_;
} else {
float best_d = 1.f - best_s;
float second_d = (second_s > -std::numeric_limits<float>::max())
? 1.f - second_s
: std::numeric_limits<float>::max();
bool absolute = best_d < threshold_;
bool ratio = (best_d < ratio_ceil_) &&
(second_d == std::numeric_limits<float>::max() ||
best_d / second_d < ratio_);
accept = absolute || ratio;
}
}
IdentifiedActor ia; IdentifiedActor ia;
// Map bbox back to original video resolution when dense_scale // Map bbox back to original video resolution when dense_scale
@@ -295,7 +259,9 @@ struct IdentityMatcherFunc {
ia.imdb_id = gallery_.actors[best_actor].imdb_id; ia.imdb_id = gallery_.actors[best_actor].imdb_id;
ia.tmdb_id = gallery_.actors[best_actor].tmdb_id; ia.tmdb_id = gallery_.actors[best_actor].tmdb_id;
ia.jellyfin_id = gallery_.actors[best_actor].jellyfin_id; ia.jellyfin_id = gallery_.actors[best_actor].jellyfin_id;
ia.similarity = best_p; ia.similarity = cal_.valid
? cal_.probability(best_s, log_prior_odds_)
: best_s;
} }
// Feed this face into per-film gallery expansion. best_actor/best_s // Feed this face into per-film gallery expansion. best_actor/best_s
@@ -309,9 +275,12 @@ struct IdentityMatcherFunc {
// would make ownership depend on a per-frame threshold the redesign // would make ownership depend on a per-frame threshold the redesign
// exists to stop relying on. The registry discounts for correlation // exists to stop relying on. The registry discounts for correlation
// and decides ownership from the accumulated posterior (AR-025). // and decides ownership from the accumulated posterior (AR-025).
if (registry_ && best_actor >= 0 && tf.track_ids[fi] >= 0) if (registry_ && best_actor >= 0 && tf.track_ids[fi] >= 0) {
registry_->observe(tf.track_ids[fi], best_actor, best_p, const float p = cal_.valid
tf.embeddings[fi]); ? cal_.probability(best_s, log_prior_odds_)
: std::max(0.f, best_s);
registry_->observe(tf.track_ids[fi], best_actor, p, tf.embeddings[fi]);
}
// TRACES: AR-019 | SR-005 // TRACES: AR-019 | SR-005
// Ownership is the registry's, computed once. TrackGallery used to // Ownership is the registry's, computed once. TrackGallery used to
@@ -330,60 +299,21 @@ struct IdentityMatcherFunc {
} }
} // chunk loop } // chunk loop
absorb_promotions();
/// TRACES: AR-019 | SR-005
// Drop buffers for tracks the registry has reaped. Without this a track
// that simply went off screen kept its diversity buffer until the next
// cut, so the store grew with the film rather than with what is on
// screen — and a buffer that outlives its track is evidence about a
// person nobody is looking at any more.
if (registry_)
track_gallery_.prune_dead(
[this](int id) { return registry_->is_live(id); });
return {std::move(tf.source), std::move(actors)}; return {std::move(tf.source), std::move(actors)};
} }
private: private:
/// TRACES: AR-026 | SR-001
/// Move rows promoted during this frame into the resident gallery matrix,
/// extending the actor mapping in lockstep so row i keeps naming the actor
/// at flat_actor_[i]. 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 is also the semantics the expansion store
/// documents — a promotion helps SUBSEQUENT frames, never the one that
/// produced it, so identification cannot depend on face order within a frame.
void absorb_promotions() {
if (!track_gallery_.enabled()) return;
pending_emb_.clear();
pending_actor_.clear();
const int n = track_gallery_.drain_promotions(pending_emb_, pending_actor_);
if (n == 0) return;
sim_engine_->append_rows(pending_emb_.data(), n);
flat_actor_.insert(flat_actor_.end(),
pending_actor_.begin(), pending_actor_.end());
n_gallery_ = sim_engine_->n_gallery();
}
ActorGallery gallery_; ActorGallery gallery_;
GalleryCalibration cal_; GalleryCalibration cal_;
float prob_threshold_; float prob_threshold_;
float log_prior_odds_; float log_prior_odds_;
/// flat_emb_ is the BAKED reference set only — it is the calibration fit's float threshold_;
/// input (AR-023) and is not touched again after construction. flat_actor_, float ratio_;
/// by contrast, is the actor mapping parallel to the *engine's* rows, so it float ratio_ceil_;
/// grows with every promotion absorbed (AR-026) and is the longer of the two.
std::vector<Embedding> flat_emb_; std::vector<Embedding> flat_emb_;
std::vector<int> flat_actor_; std::vector<int> flat_actor_;
int n_gallery_{0}; int n_gallery_{0};
// Reused across frames so absorbing a promotion allocates nothing.
std::vector<float> pending_emb_;
std::vector<int> pending_actor_;
std::unique_ptr<ISimilarityEngine> sim_engine_; std::unique_ptr<ISimilarityEngine> sim_engine_;
TrackGallery track_gallery_; TrackGallery track_gallery_;
std::shared_ptr<TrackRegistry> registry_; std::shared_ptr<TrackRegistry> registry_;
+5 -110
View File
@@ -3,10 +3,6 @@
#include "types.hpp" #include "types.hpp"
#include "config.hpp" #include "config.hpp"
#include "track_registry.hpp" #include "track_registry.hpp"
#ifdef SAE_SCENE_XGB
#include "inference/xgb_scene_boundary.hpp"
#include "inference/audio_logpsd.hpp"
#endif
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <algorithm> #include <algorithm>
@@ -27,8 +23,7 @@ using json = nlohmann::json;
// //
// Verbosity::minimal — merges per-frame presence into contiguous time windows. // Verbosity::minimal — merges per-frame presence into contiguous time windows.
// Output: { // Output: {
// "schema_version": 2, "movie": "...", // "schema_version": 1, "movie": "...", "sample_fps": ..., "anneal_sec": ...,
// "extraction": { "sample_fps": ..., "extinction_sec": ..., "gallery_scope": ... },
// "actors": [{ "name", "imdb_id", "tmdb_id", "jellyfin_id", "scenes": [[t0,t1], ...] }] // "actors": [{ "name", "imdb_id", "tmdb_id", "jellyfin_id", "scenes": [[t0,t1], ...] }]
// } // }
// An optional top-level "jellyfin_item_id" (the analysed title's Jellyfin item // An optional top-level "jellyfin_item_id" (the analysed title's Jellyfin item
@@ -128,9 +123,8 @@ private:
/// schema_version 2, per jRay/SPEC.md JR-002. anneal_sec is REMOVED /// schema_version 2, per jRay/SPEC.md JR-002. anneal_sec is REMOVED
/// rather than zeroed: a field naming a mechanism the pipeline no /// rather than zeroed: a field naming a mechanism the pipeline no
/// longer has is actively misleading, and would outlive everyone who /// longer has is actively misleading, and would outlive everyone who
/// remembers why it reads 0. The extraction block reports /// remembers why it reads 0. extinction_sec succeeds it as the
/// track_extinction_sec, which bounds re-association -- not the /// parameter that actually shapes window extent.
/// withdrawn actor keep-alive that shared its name.
root["schema_version"] = kSchemaVersion; root["schema_version"] = kSchemaVersion;
root["movie"] = cfg_.movie_path; root["movie"] = cfg_.movie_path;
root["extraction"] = { root["extraction"] = {
@@ -156,7 +150,6 @@ private:
double start{0.0}; double start{0.0};
double end{0.0}; double end{0.0};
float belief{0.f}; ///< the posterior that justified the claim (AR-017) float belief{0.f}; ///< the posterior that justified the claim (AR-017)
Route route{Route::live}; ///< how it was identified (AR-017)
}; };
struct ActorWindow { struct ActorWindow {
std::string name, imdb_id, tmdb_id, jellyfin_id; std::string name, imdb_id, tmdb_id, jellyfin_id;
@@ -185,21 +178,7 @@ private:
aw.jellyfin_id = it->second.jellyfin_id; aw.jellyfin_id = it->second.jellyfin_id;
} }
} }
aw.scenes.push_back({c.first_seen, c.last_seen, c.belief, c.route}); aw.scenes.push_back({c.first_seen, c.last_seen, c.belief});
}
// Flood-fill: snap each claim to the shot it sits in, so an actor seen
// once in a scene is reported across the whole scene. Bounded by real
// TransNetV2 boundaries — a window never crosses one — and a no-op when
// scene detection found no boundaries (nothing to snap to).
if (cfg_.presence_mode == PresenceMode::flood) {
const std::vector<double> bounds = scene_boundaries();
if (!bounds.empty())
for (auto& [idx, aw] : by_actor)
for (auto& w : aw.scenes) {
w.start = boundary_at_or_before(bounds, w.start);
w.end = boundary_after(bounds, w.end);
}
} }
std::vector<ActorWindow> result; std::vector<ActorWindow> result;
@@ -211,90 +190,6 @@ private:
return result; return result;
} }
// Sorted, de-duplicated boundary timestamps seen this run, framed by the
// film's own extent so the first and last shots are closed intervals. Derived
// from frames_ rather than a separate accumulator: the frames are already
// retained and this runs once.
//
// Prefers TransNetV2 shot boundaries (is_scene_boundary) when a scene detector
// populated them; otherwise falls back to the always-on histogram cuts
// (is_cut, camera_position_change_detector). On this ROCm box the scene
// detector cannot run in-process (see the dumper note), so is_cut is what
// flood-fill actually snaps to — coarser than true shot boundaries (cuts also
// fire on in-shot angle changes) but present with no extra pass.
std::vector<double> scene_boundaries() const {
std::vector<double> b;
b.push_back(0.0);
// Preferred: the learned XGBoost scene detector, run once here post-EOF
// (the knee threshold needs the whole film, so this is inherently a final
// step — like flood-fill itself). Measured best flood boundary source.
std::vector<double> learned = xgb_boundaries();
if (!learned.empty()) {
for (double t : learned) b.push_back(t);
} else {
// Fallback: TransNetV2 shot boundaries if present, else histogram cuts.
bool have_scene = false;
for (const auto& sa : frames_)
if (sa.is_scene_boundary) { have_scene = true; break; }
for (const auto& sa : frames_) {
const bool boundary = have_scene ? sa.is_scene_boundary : sa.is_cut;
if (boundary) b.push_back(sa.timestamp_sec);
}
}
b.push_back(last_ts_ + 1.0); // a right edge past the final sample
std::sort(b.begin(), b.end());
b.erase(std::unique(b.begin(), b.end()), b.end());
return b;
}
// Run the learned scene-boundary detector over the collected per-frame RGB
// histograms + per-second audio log-PSD (decoded once from the movie). Returns
// {} when no model is configured, the build lacks XGBoost, or no rgb_hist was
// stamped (camera-position node only does so when a model is set).
std::vector<double> xgb_boundaries() const {
#ifdef SAE_SCENE_XGB
if (cfg_.scene_xgb_model.empty()) return {};
std::vector<std::vector<float>> hist;
std::vector<double> ts;
hist.reserve(frames_.size()); ts.reserve(frames_.size());
for (const auto& sa : frames_) {
if (sa.rgb_hist.empty()) return {}; // hist not stamped → bail to fallback
hist.push_back(sa.rgb_hist);
ts.push_back(sa.timestamp_sec);
}
if (hist.size() < 16) return {};
try {
auto audio = AudioLogPSD::extract(cfg_.movie_path); // [T'][B], aligned per second
if ((int)audio.size() != (int)hist.size())
audio.resize(hist.size(),
std::vector<float>(audio.empty() ? 57 : audio[0].size(), 0.f));
XGBSceneBoundary det(cfg_.scene_xgb_model);
auto b = det.boundaries(hist, ts, audio);
std::cerr << "[result_sink] XGBoost scene detector: " << b.size()
<< " boundaries\n";
return b;
} catch (const std::exception& e) {
std::cerr << "[result_sink] scene detector failed (" << e.what()
<< "), falling back to histogram cuts\n";
return {};
}
#else
return {};
#endif
}
// The boundary opening the shot that contains t (largest boundary ≤ t).
static double boundary_at_or_before(const std::vector<double>& b, double t) {
auto it = std::upper_bound(b.begin(), b.end(), t);
return (it == b.begin()) ? b.front() : *(it - 1);
}
// The boundary closing the shot that contains t (smallest boundary > t).
static double boundary_after(const std::vector<double>& b, double t) {
auto it = std::upper_bound(b.begin(), b.end(), t);
return (it == b.end()) ? b.back() : *it;
}
json build_epochs() { json build_epochs() {
json actors = json::array(); json actors = json::array();
for (const auto& aw : build_actor_windows()) { for (const auto& aw : build_actor_windows()) {
@@ -307,7 +202,7 @@ private:
windows.push_back({{"start", w.start}, windows.push_back({{"start", w.start},
{"end", w.end}, {"end", w.end},
{"belief", w.belief}, {"belief", w.belief},
{"route", route_name(w.route)}}); {"route", "live"}});
json ja; json ja;
ja["name"] = aw.name; ja["name"] = aw.name;
ja["imdb_id"] = aw.imdb_id; ja["imdb_id"] = aw.imdb_id;
+3 -11
View File
@@ -47,17 +47,9 @@ struct SceneBoundaryAnnotatorFunc {
// consumer is slower, and this branch is orders of magnitude faster per // consumer is slower, and this branch is orders of magnitude faster per
// frame than TransNetV2. Blocking here is what makes the join real. // frame than TransNetV2. Blocking here is what makes the join real.
// //
// What makes that safe is **join depth**, not branch independence. Now // Safe under backpressure because the branches are independent: this
// that the fanout is lossless (AR-004) it stops popping once this branch // node stalling does not stop the detector consuming dense frames, and
// stops taking, so stalling here does eventually starve the detector — // the fanout keeps feeding it.
// the two would wedge if this node could ask about a frame the detector
// has not been given the frames to score. It cannot, by a wide margin:
// the fanout can run the dense branch ahead by the whole of this
// branch's buffering, which is kSceneJoinDepth (256) *sampled* frames,
// and at sample_fps 5 against a ~25 fps source that is on the order of
// 1200 dense frames against TransNetV2's 100-frame window.
//
// Cutting kSceneJoinDepth below the window would reintroduce the wedge.
if (!bounds_->wait_until_scored(f.timestamp_sec)) { if (!bounds_->wait_until_scored(f.timestamp_sec)) {
// The detector finished without covering this frame — the tail after // The detector finished without covering this frame — the tail after
// its last full window. Unknown, not negative; counted so it cannot // its last full window. Unknown, not negative; counted so it cannot
+6 -109
View File
@@ -6,8 +6,6 @@
#include <memory> #include <memory>
#include "inference/scene_detector.hpp" #include "inference/scene_detector.hpp"
#include <opencv2/imgproc.hpp> // cv::resize, for to_model_input
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <algorithm> #include <algorithm>
#include <atomic> #include <atomic>
@@ -69,16 +67,7 @@ struct SceneDetectorFunc {
return; return;
} }
/// TRACES: AR-011 | SR-002 images_.push_back(f.image);
// Learn the cadence of the stream from the stream itself, rather than
// assuming one. See dedup_window_sec().
if (prev_ts_ >= 0.0 && intervals_.size() < kCadenceSamples) {
const double dt = f.timestamp_sec - prev_ts_;
if (dt > 0.0) intervals_.push_back(dt);
}
prev_ts_ = f.timestamp_sec;
images_.push_back(to_model_input(f.image));
times_.push_back(f.timestamp_sec); times_.push_back(f.timestamp_sec);
// Once we have a full window, score it and slide forward by `stride`. // Once we have a full window, score it and slide forward by `stride`.
@@ -92,76 +81,6 @@ struct SceneDetectorFunc {
} }
} }
/// TRACES: AR-004, AR-010 | SR-002
/// Reduce a decoded frame to exactly what TransNetV2 consumes, once.
///
/// The window used to hold the frames as decoded — full resolution — and
/// leave the downscale to the backend. But the model's input is 48x27
/// (`ISceneDetector::kFrameW/H`; the config note for `dense_scale` says so
/// outright: "TransNetV2 downsamples to 48x27 regardless"), so the buffer
/// held ~590 MB at 1080p to feed something that needs ~380 KB. That is not
/// a channel capacity, so no amount of tuning channel depths would ever
/// have found it.
///
/// 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;
/// now it is downscaled once, when it arrives.
///
/// **This must reproduce the backends' preprocessing exactly**, because the
/// project invariant is that every model gets the input it was trained for
/// — a model run off-distribution returns confident, plausible, wrong
/// output, and here that means fabricated shot boundaries. Both
/// ort_backend.cpp and trt_backend.cpp guard mis-sized input with, in this
/// order, `convertTo(CV_8UC3)` then
/// `cv::resize(..., {kFrameW, kFrameH}, 0, 0, cv::INTER_AREA)`. The same
/// two operations are done here, so the tensor the model receives is
/// unchanged; the backend guard then sees a correctly-sized frame and does
/// nothing. The interface has always specified this shape as the caller's
/// job ("Each frame must already be kFrameW x kFrameH, BGR, CV_8UC3"), so
/// this makes the node meet a contract it was already given.
static cv::Mat to_model_input(const cv::Mat& src) {
cv::Mat typed;
if (src.type() != CV_8UC3) src.convertTo(typed, CV_8UC3);
else typed = src;
if (typed.cols == ISceneDetector::kFrameW &&
typed.rows == ISceneDetector::kFrameH)
return typed;
cv::Mat small;
cv::resize(typed, small, {ISceneDetector::kFrameW, ISceneDetector::kFrameH},
0, 0, cv::INTER_AREA);
return small;
}
/// TRACES: AR-011 | SR-002
// How close two boundaries have to be before they are the same boundary,
// derived from the cadence the detector was actually fed.
//
// What this replaces is a literal 0.04 s — one frame at 25 fps, and silently
// wrong at any other rate. On a 30 fps source it spans more than a frame, so
// two cuts on consecutive frames merge into one and a real boundary is lost;
// the output does not show this, it simply contains fewer cuts. Assuming a
// frame rate is the same class of mistake as feeding a model the wrong rate,
// which is why this belongs to AR-011 and not to a tidy-up.
//
// Half a frame, not a whole one, because the only thing being deduplicated is
// one frame scored by two overlapping windows — a gap of zero. Two distinct
// frames are a full interval apart and must both survive. Half an interval
// separates those two cases without putting the decision on the knife-edge
// where floating-point error settles it.
//
// Median, not mean: a seek, or a gap where the decoder dropped a frame,
// contributes one long interval that would drag a mean and cannot move a
// median.
static double dedup_window_sec(std::vector<double> intervals) {
if (intervals.empty()) return 0.0; // <2 frames: nothing to deduplicate
const std::size_t mid = intervals.size() / 2;
std::nth_element(intervals.begin(), intervals.begin() + mid,
intervals.end());
return intervals[mid] * 0.5;
}
private: private:
// Run TransNetV2 on the leading kWindow frames of the buffer and record any // Run TransNetV2 on the leading kWindow frames of the buffer and record any
// boundaries found within the trusted centre region. // boundaries found within the trusted centre region.
@@ -193,15 +112,8 @@ private:
// final verdict. The face branch consults this for frames it has not // final verdict. The face branch consults this for frames it has not
// reached yet, and the watermark is what lets it tell "no boundary // reached yet, and the watermark is what lets it tell "no boundary
// here" from "not scored yet". // here" from "not scored yet".
/// TRACES: AR-011 | SR-002 if (shared_ && hi > lo)
// Hand the join the same dedup window scenes.json uses, derived from the shared_->publish(fresh, times_[hi - 1]);
// observed cadence rather than assumed. Set on every window because the
// median refines as intervals accumulate; it converges within the first
// window and costs a double assignment thereafter.
if (shared_) {
shared_->set_merge_window(dedup_window_sec(intervals_));
if (hi > lo) shared_->publish(fresh, times_[hi - 1]);
}
} }
// At EOF the tail (< kWindow frames) never formed a full window. Pad it out // At EOF the tail (< kWindow frames) never formed a full window. Pad it out
@@ -234,10 +146,7 @@ private:
// the last full window — reach the join with no verdict and are treated // the last full window — reach the join with no verdict and are treated
// as boundary-free without evidence, which is precisely the ambiguity // as boundary-free without evidence, which is precisely the ambiguity
// the watermark exists to prevent. // the watermark exists to prevent.
if (shared_ && n > 0) { if (shared_ && n > 0) shared_->publish(fresh, times_[n - 1]);
shared_->set_merge_window(dedup_window_sec(intervals_));
shared_->publish(fresh, times_[n - 1]);
}
} }
void write_output() { void write_output() {
@@ -245,8 +154,6 @@ private:
written_ = true; written_ = true;
// Merge boundaries closer than one frame apart (dedup across window seams). // Merge boundaries closer than one frame apart (dedup across window seams).
const double dedup_sec = dedup_window_sec(intervals_);
std::sort(boundaries_.begin(), boundaries_.end(), std::sort(boundaries_.begin(), boundaries_.end(),
[](const Boundary& a, const Boundary& b) { [](const Boundary& a, const Boundary& b) {
return a.t < b.t; return a.t < b.t;
@@ -260,7 +167,7 @@ private:
nlohmann::json cuts = nlohmann::json::array(); nlohmann::json cuts = nlohmann::json::array();
double last_t = -1e9; double last_t = -1e9;
for (const auto& b : boundaries_) { for (const auto& b : boundaries_) {
if (b.t - last_t < dedup_sec) continue; if (b.t - last_t < 0.04) continue; // ~1 frame @25fps dedup
cuts.push_back({{"t", b.t}, {"probability", b.prob}}); cuts.push_back({{"t", b.t}, {"probability", b.prob}});
last_t = b.t; last_t = b.t;
} }
@@ -273,12 +180,8 @@ private:
return; return;
} }
f << root.dump(2) << "\n"; f << root.dump(2) << "\n";
// Report the derived cadence: VR-006 re-tunes scene_threshold against it,
// and a rate that is not the source's is the first thing to suspect.
std::cerr << "\n[scene_detector] wrote " << root["cuts"].size() std::cerr << "\n[scene_detector] wrote " << root["cuts"].size()
<< " boundaries → " << output_path_ << " boundaries → " << output_path_ << "\n";
<< " (dedup=" << dedup_sec << "s from "
<< (dedup_sec > 0.0 ? 0.5 / dedup_sec : 0.0) << " fps)\n";
} }
static int kLast_() { return ISceneDetector::kWindow - 1; } static int kLast_() { return ISceneDetector::kWindow - 1; }
@@ -292,10 +195,6 @@ private:
struct Boundary { double t; float prob; }; struct Boundary { double t; float prob; };
// Enough to establish a rate; bounded so a feature-length film does not
// accumulate one double per frame for a number that stops moving early.
static constexpr std::size_t kCadenceSamples = 512;
std::unique_ptr<ISceneDetector> detector_; std::unique_ptr<ISceneDetector> detector_;
float threshold_; float threshold_;
int stride_; int stride_;
@@ -308,8 +207,6 @@ private:
std::deque<double> times_; std::deque<double> times_;
int64_t window_base_{0}; // frame index of images_.front() int64_t window_base_{0}; // frame index of images_.front()
std::vector<Boundary> boundaries_; std::vector<Boundary> boundaries_;
double prev_ts_{-1.0}; // AR-011: cadence, learned not assumed
std::vector<double> intervals_;
bool written_{false}; bool written_{false};
std::shared_ptr<SceneBoundaries> shared_; ///< AR-010 join point std::shared_ptr<SceneBoundaries> shared_; ///< AR-010 join point
}; };
+102
View File
@@ -0,0 +1,102 @@
#pragma once
#include "types.hpp"
#include "config.hpp"
#include <map>
#include <iostream>
// ── SceneTrackerFunc ──────────────────────────────────────────────────────────
// KPN node: maintains an extinction-timer state machine per identified actor.
//
// On each MatchedSceneFrame:
// 1. Update last_seen for every matched known actor.
// 2. Expire actors whose last_seen is older than extinction_sec.
// 3. Emit SceneAnnotation with all currently active (non-expired) actors,
// including their most recently seen bbox and best similarity score.
//
// Unknown faces (actor_idx == -1) are passed through per-frame but are NOT
// tracked across frames — each frame reports its own unknowns independently.
struct SceneTrackerFunc {
static constexpr std::string_view label() { return "scene_tracker"; }
explicit SceneTrackerFunc(const Config& cfg)
: extinction_sec_(cfg.extinction_sec)
{
std::cerr << "[scene_tracker] extinction_sec=" << extinction_sec_ << "\n";
}
// Runtime setter for pipeline reuse across a sweep. Also clears the active-actor
// state so a re-run starts clean (no carry-over from the previous config's film).
void set_extinction_sec(double s) { extinction_sec_ = s; active_.clear(); }
SceneAnnotation operator()(MatchedSceneFrame mf) {
if (mf.source.eof) return {0.0, {}, /*eof=*/true};
double now = mf.source.timestamp_sec;
// Update known actors
for (const auto& ia : mf.actors) {
if (ia.actor_idx < 0) continue; // skip unknowns
auto& slot = active_[ia.actor_idx];
slot.last_seen = now;
slot.last_bbox = ia.bbox;
slot.last_crop = ia.crop;
slot.name = ia.name;
slot.imdb_id = ia.imdb_id;
slot.tmdb_id = ia.tmdb_id;
slot.jellyfin_id = ia.jellyfin_id;
// Keep the best (highest) similarity seen in this window
if (ia.similarity > slot.best_similarity)
slot.best_similarity = ia.similarity;
}
// Expire stale actors
for (auto it = active_.begin(); it != active_.end(); ) {
if ((now - it->second.last_seen) > extinction_sec_)
it = active_.erase(it);
else
++it;
}
// Build annotation: active known actors
std::vector<IdentifiedActor> visible;
visible.reserve(active_.size() + mf.actors.size());
for (const auto& [actor_idx, slot] : active_) {
IdentifiedActor ia;
ia.actor_idx = actor_idx;
ia.name = slot.name;
ia.imdb_id = slot.imdb_id;
ia.tmdb_id = slot.tmdb_id;
ia.jellyfin_id = slot.jellyfin_id;
ia.similarity = slot.best_similarity;
ia.bbox = slot.last_bbox;
ia.crop = slot.last_crop;
visible.push_back(ia);
}
// Append per-frame unknowns (actor_idx == -1) directly
for (const auto& ia : mf.actors) {
if (ia.actor_idx < 0) visible.push_back(ia);
}
return {now, std::move(visible)};
}
private:
struct Slot {
double last_seen{0.0};
float best_similarity{0.f};
cv::Rect2f last_bbox;
cv::Mat last_crop;
std::string name;
std::string imdb_id;
std::string tmdb_id;
std::string jellyfin_id;
};
double extinction_sec_;
std::map<int, Slot> active_; // actor_idx → state
};
+4 -25
View File
@@ -27,35 +27,15 @@
class SceneBoundaries { class SceneBoundaries {
public: public:
/// TRACES: AR-011 | SR-002 /// Peaks closer than this are one boundary. Matches the dedup scenes.json
/// Peaks closer than this are one boundary. /// applies, so the two views agree.
/// static constexpr double kMergeSec = 0.04;
/// Supplied by the detector, derived from the cadence it was actually fed
/// (SceneDetectorFunc::dedup_window_sec), NOT assumed. It used to be a hard
/// 0.04 here, and AR-011 is recorded as having replaced that literal --
/// which it did, but only for scenes.json. This path, the one that feeds
/// is_scene_boundary into the tracker, kept the constant while the comment
/// above it claimed "matches the dedup scenes.json applies, so the two
/// views agree". They did not agree. 0.04 s 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.
///
/// Zero until the detector sets it, which makes the pre-cadence state a
/// no-op dedup rather than a wrong one -- adjacent peaks stay separate
/// until there is evidence about how far apart frames are, and is_boundary
/// absorbs duplicates in its tolerance anyway.
void set_merge_window(double sec) {
std::lock_guard<std::mutex> g(mu_);
merge_sec_ = sec;
}
/// Called by the scene detector as each window is scored. `through` is the /// Called by the scene detector as each window is scored. `through` is the
/// timestamp up to which its verdict is now final. /// timestamp up to which its verdict is now final.
void publish(const std::vector<double>& ts, double through) { void publish(const std::vector<double>& ts, double through) {
{ {
std::lock_guard<std::mutex> g(mu_); std::lock_guard<std::mutex> g(mu_);
const double merge = merge_sec_;
// Dedup on insert, matching what scenes.json does at write time. A run // Dedup on insert, matching what scenes.json does at write time. A run
// of adjacent high-scoring frames is one boundary, not several, and // of adjacent high-scoring frames is one boundary, not several, and
// leaving them raw made this view report 357 where the file said 13 — // leaving them raw made this view report 357 where the file said 13 —
@@ -65,7 +45,7 @@ public:
bounds_.insert(bounds_.end(), ts.begin(), ts.end()); bounds_.insert(bounds_.end(), ts.begin(), ts.end());
std::sort(bounds_.begin(), bounds_.end()); std::sort(bounds_.begin(), bounds_.end());
bounds_.erase(std::unique(bounds_.begin(), bounds_.end(), bounds_.erase(std::unique(bounds_.begin(), bounds_.end(),
[merge](double a, double b) { return b - a < merge; }), [](double a, double b) { return b - a < kMergeSec; }),
bounds_.end()); bounds_.end());
scored_through_ = std::max(scored_through_, through); scored_through_ = std::max(scored_through_, through);
} }
@@ -140,7 +120,6 @@ private:
mutable std::mutex mu_; mutable std::mutex mu_;
mutable std::condition_variable cv_; mutable std::condition_variable cv_;
bool finished_{false}; bool finished_{false};
double merge_sec_{0.0}; ///< set by the detector; see set_merge_window
std::vector<double> bounds_; std::vector<double> bounds_;
double scored_through_{-1.0}; double scored_through_{-1.0};
mutable std::size_t outran_{0}; mutable std::size_t outran_{0};
+13 -36
View File
@@ -8,7 +8,7 @@
// //
// camera_pos (histogram cut detector) stamps Frame::cut_score / is_cut, which // camera_pos (histogram cut detector) stamps Frame::cut_score / is_cut, which
// ride through to the preview HUD's cut-score meter. // ride through to the preview HUD's cut-score meter.
// ├──► [frame_annotation] ──► [result_sink] (background thread) // ├──► [scene_tracker] ──► [result_sink] (background thread)
// └──► [preview_node] (main thread) // └──► [preview_node] (main thread)
// //
// The main thread drives preview_node via preview.step(). When the movie ends // The main thread drives preview_node via preview.step(). When the movie ends
@@ -30,9 +30,7 @@
#include "nodes/embedder_node.hpp" #include "nodes/embedder_node.hpp"
#include "nodes/face_tracker_node.hpp" #include "nodes/face_tracker_node.hpp"
#include "nodes/identity_matcher_node.hpp" #include "nodes/identity_matcher_node.hpp"
#include "track_registry.hpp" #include "nodes/scene_tracker_node.hpp"
#include "evidence_discount.hpp"
#include "nodes/frame_annotation_node.hpp"
#include "nodes/result_sink_node.hpp" #include "nodes/result_sink_node.hpp"
#include "nodes/preview_node.hpp" #include "nodes/preview_node.hpp"
@@ -73,6 +71,8 @@ static Config parse_args(int argc, char** argv) {
else if (arg("--verbosity")) { int v = std::stoi(next()); cfg.verbosity = v == 2 ? Verbosity::xray : v == 1 ? Verbosity::standard : Verbosity::minimal; } else if (arg("--verbosity")) { int v = std::stoi(next()); cfg.verbosity = v == 2 ? Verbosity::xray : v == 1 ? Verbosity::standard : Verbosity::minimal; }
else if (arg("--prior")) cfg.match_prior = std::stof(next()); else if (arg("--prior")) cfg.match_prior = std::stof(next());
else if (arg("--prob-threshold")) cfg.prob_threshold = std::stof(next()); else if (arg("--prob-threshold")) cfg.prob_threshold = std::stof(next());
else if (arg("--match-threshold")) cfg.match_threshold = std::stof(next());
else if (arg("--extinction")) cfg.extinction_sec = std::stod(next());
else if (arg("--detector")) cfg.detector_model = next(); else if (arg("--detector")) cfg.detector_model = next();
else if (arg("--detector-engine")) cfg.detector_engine = next(); else if (arg("--detector-engine")) cfg.detector_engine = next();
else if (arg("--arcface")) cfg.arcface_model = next(); else if (arg("--arcface")) cfg.arcface_model = next();
@@ -81,10 +81,13 @@ static Config parse_args(int argc, char** argv) {
else if (arg("--conf")) cfg.detector_conf = std::stof(next()); else if (arg("--conf")) cfg.detector_conf = std::stof(next());
else if (arg("--max-faces")) cfg.max_faces = std::stoi(next()); else if (arg("--max-faces")) cfg.max_faces = std::stoi(next());
else if (arg("--min-face-px")) cfg.min_face_px = std::stof(next()); else if (arg("--min-face-px")) cfg.min_face_px = std::stof(next());
else if (arg("--ratio")) cfg.match_ratio = std::stof(next());
else if (arg("--ratio-ceil")) cfg.match_ratio_ceil = std::stof(next());
else if (arg("--track-alpha")) cfg.track_alpha = std::stof(next()); else if (arg("--track-alpha")) cfg.track_alpha = std::stof(next());
else if (arg("--track-min-iou")) cfg.track_min_iou = std::stof(next()); else if (arg("--track-min-iou")) cfg.track_min_iou = std::stof(next());
else if (arg("--track-min-prob")) cfg.track_assoc_min_prob = std::stof(next()); else if (arg("--track-max-embed")) cfg.track_max_embed_dist = std::stof(next());
else if (arg("--track-extinction")) cfg.track_extinction_sec = std::stod(next()); else if (arg("--track-max-missing")) cfg.track_max_frames_missing = std::stoi(next());
else if (arg("--anneal")) cfg.anneal_sec = std::stod(next());
else if (arg("--trt-cache")) cfg.trt.cache_dir = next(); else if (arg("--trt-cache")) cfg.trt.cache_dir = next();
else if (arg("--trt-fp16")) cfg.trt.fp16 = true; else if (arg("--trt-fp16")) cfg.trt.fp16 = true;
else if (arg("--no-trt-fp16")) cfg.trt.fp16 = false; else if (arg("--no-trt-fp16")) cfg.trt.fp16 = false;
@@ -144,37 +147,11 @@ int main(int argc, char** argv) {
FaceDetectorFunc detector_fn{cfg}; FaceDetectorFunc detector_fn{cfg};
FaceAlignerFunc aligner_fn; FaceAlignerFunc aligner_fn;
EmbedderFunc embedder_fn{cfg}; EmbedderFunc embedder_fn{cfg};
/// TRACES: AR-007, AR-012, AR-024 | DP-001 | SR-002 | PR-004 FaceTrackerFunc ftracker_fn{cfg};
// Construction order matters and is the same as main.cpp's, deliberately:
// the matcher fits (or loads) the calibration, the registry needs a
// discounter built from it, and the tracker needs both. DP-001 says modes
// are front-ends that must not fork pipeline logic -- this file had forked
// it and then rotted, constructing FaceTrackerFunc{cfg} against a signature
// that stopped existing with the AR-007/AR-008 redesign, so scene_preview
// has not compiled since. Keeping the order identical is what stops that
// recurring.
IdentityMatcherFunc matcher_fn {gallery, cfg}; IdentityMatcherFunc matcher_fn {gallery, cfg};
auto same_person = same_person_probability(matcher_fn.calibration()); SceneTrackerFunc tracker_fn {cfg};
TrackRegistry::Config reg_cfg;
reg_cfg.track_extinction_sec = cfg.track_extinction_sec;
reg_cfg.ownership_logodds = cfg.ownership_logodds;
EvidenceDiscounter::Config disc_cfg;
disc_cfg.max_views = cfg.evidence_max_views;
disc_cfg.admit_below = cfg.evidence_admit_below;
disc_cfg.rho_max = cfg.evidence_rho_max;
auto registry = std::make_shared<TrackRegistry>(
reg_cfg, EvidenceDiscounter(same_person, disc_cfg));
matcher_fn.set_registry(registry);
FaceTrackerFunc ftracker_fn{cfg, registry, same_person};
FrameAnnotationFunc tracker_fn {};
ResultSinkFunc sink_fn {cfg, done}; ResultSinkFunc sink_fn {cfg, done};
// AR-012/AR-016: windows come from registry claims, and tracks still live
// at EOF must be flushed or the closing scene's cast is never emitted.
registry->on_track_dead([&sink_fn](const DeadTrack& d) { sink_fn.add_claim(d); });
sink_fn.set_pre_write_hook([registry](double last_ts) { registry->flush(last_ts); });
// ── KPN ObjectNodes ─────────────────────────────────────────────────────── // ── KPN ObjectNodes ───────────────────────────────────────────────────────
kpn::ObjectNode<FrameSourceFunc, kpn::in<>, kpn::out<"raw">, "frame_source", 0> source (source_fn, 32); kpn::ObjectNode<FrameSourceFunc, kpn::in<>, kpn::out<"raw">, "frame_source", 0> source (source_fn, 32);
kpn::ObjectNode<CameraPositionChangeDetectorFunc, kpn::in<"raw">, kpn::out<"frame">, "camera_pos", 0> campos (campos_fn, 32); kpn::ObjectNode<CameraPositionChangeDetectorFunc, kpn::in<"raw">, kpn::out<"frame">, "camera_pos", 0> campos (campos_fn, 32);
@@ -183,13 +160,13 @@ int main(int argc, char** argv) {
kpn::ObjectNode<EmbedderFunc, kpn::in<"aligned">, kpn::out<"embedded">, "embedder", 0> embedder (embedder_fn, 32); kpn::ObjectNode<EmbedderFunc, kpn::in<"aligned">, kpn::out<"embedded">, "embedder", 0> embedder (embedder_fn, 32);
kpn::ObjectNode<FaceTrackerFunc, kpn::in<"embedded">, kpn::out<"tracked">, "face_tracker", 0> ftracker (ftracker_fn, 16); kpn::ObjectNode<FaceTrackerFunc, kpn::in<"embedded">, kpn::out<"tracked">, "face_tracker", 0> ftracker (ftracker_fn, 16);
kpn::ObjectNode<IdentityMatcherFunc, kpn::in<"tracked">, kpn::out<"matched">, "identity_matcher", 0> matcher (matcher_fn, 16); kpn::ObjectNode<IdentityMatcherFunc, kpn::in<"tracked">, kpn::out<"matched">, "identity_matcher", 0> matcher (matcher_fn, 16);
kpn::ObjectNode<FrameAnnotationFunc, kpn::in<"matched">, kpn::out<"annotation">, "frame_annotation", 0> tracker (tracker_fn, 16); kpn::ObjectNode<SceneTrackerFunc, kpn::in<"matched">, kpn::out<"annotation">, "scene_tracker", 0> tracker (tracker_fn, 16);
kpn::ObjectNode<ResultSinkFunc, kpn::in<"annotation">,kpn::out<>, "result_sink", 0> sink (sink_fn, 16); kpn::ObjectNode<ResultSinkFunc, kpn::in<"annotation">,kpn::out<>, "result_sink", 0> sink (sink_fn, 16);
// MainThreadNode — no thread spawned; driven by preview.step() below // MainThreadNode — no thread spawned; driven by preview.step() below
PreviewNode preview{cfg, 16}; PreviewNode preview{cfg, 16};
// matcher → FanoutNode<MatchedSceneFrame,2> → [frame_annotation, preview] (auto-inserted) // matcher → FanoutNode<MatchedSceneFrame,2> → [scene_tracker, preview] (auto-inserted)
auto net = kpn::make_network( auto net = kpn::make_network(
kpn::edge(source.output<"raw">(), campos.input<"raw">()), kpn::edge(source.output<"raw">(), campos.input<"raw">()),
kpn::edge(campos.output<"frame">(), detector.input<"frame">()), kpn::edge(campos.output<"frame">(), detector.input<"frame">()),
-58
View File
@@ -1,58 +0,0 @@
// scene_features_dump — write the C++ scene-boundary feature matrix to HDF5, so
// the XGBoost model is TRAINED on exactly the features the C++ detector produces
// at inference (parity by construction — no numpy re-implementation to keep in
// sync). Reads frames/rgb_hist + frames/timestamp_sec from a dump and, given the
// movie, the per-second audio log-PSD; writes features [T,206] + timestamps.
//
// scene_features_dump <dump.h5> <movie> <out_features.h5>
//
// The py3.12 venv trainer (train_xgb_cpp.py) reads <out_features.h5>, attaches
// the soft Gaussian boundary target, fits XGBoost, and saves the model that
// XGBSceneBoundary loads. Same C++ features both sides → exact parity.
#include "inference/xgb_scene_boundary.hpp"
#include "inference/audio_logpsd.hpp"
#include <H5Cpp.h>
#include <iostream>
#include <vector>
int main(int argc, char** argv) {
if (argc < 4) {
std::cerr << "usage: scene_features_dump <dump.h5> <movie> <out.h5>\n";
return 1;
}
H5::H5File in(argv[1], H5F_ACC_RDONLY);
H5::DataSet hd = in.openDataSet("frames/rgb_hist");
hsize_t hdims[2]; hd.getSpace().getSimpleExtentDims(hdims);
std::vector<float> flat(hdims[0]*hdims[1]);
hd.read(flat.data(), H5::PredType::NATIVE_FLOAT);
const int T = hdims[0], C = hdims[1];
std::vector<std::vector<float>> hist(T, std::vector<float>(C));
for (int t = 0; t < T; ++t)
for (int c = 0; c < C; ++c) hist[t][c] = flat[t*C+c];
H5::DataSet td = in.openDataSet("frames/timestamp_sec");
hsize_t tdim[1]; td.getSpace().getSimpleExtentDims(tdim);
std::vector<double> ts(tdim[0]);
td.read(ts.data(), H5::PredType::NATIVE_DOUBLE);
auto audio = AudioLogPSD::extract(argv[2]);
if ((int)audio.size() != T) {
std::cerr << "[features] audio rows " << audio.size() << " != hist rows "
<< T << " — aligning (pad/truncate)\n";
audio.resize(T, std::vector<float>(audio.empty()?57:audio[0].size(), 0.f));
}
std::vector<float> X = XGBSceneBoundary::feature_matrix(hist, audio);
const int F = XGBSceneBoundary::kNFeatures;
H5::H5File out(argv[3], H5F_ACC_TRUNC);
hsize_t xd[2] = {(hsize_t)T, (hsize_t)F};
out.createDataSet("features", H5::PredType::NATIVE_FLOAT, H5::DataSpace(2, xd))
.write(X.data(), H5::PredType::NATIVE_FLOAT);
hsize_t td2[1] = {(hsize_t)T};
out.createDataSet("timestamp_sec", H5::PredType::NATIVE_DOUBLE, H5::DataSpace(1, td2))
.write(ts.data(), H5::PredType::NATIVE_DOUBLE);
std::cerr << "[features] wrote [" << T << "," << F << "] → " << argv[3] << "\n";
return 0;
}
-82
View File
@@ -1,82 +0,0 @@
// Parity harness: run the C++ XGBSceneBoundary on a dump's frames/rgb_hist and
// print the boundary timestamps, so they can be diffed against the Python
// knee_boundaries (scripts/scene_detector). Feature parity is the whole risk of
// the C++ port; this proves it before wiring into the pipeline.
//
// xgb_boundary_parity <dump.h5> <model.json>
//
// Prints: "<n> boundaries: t0 t1 t2 ..."
#include "inference/xgb_scene_boundary.hpp"
#include "inference/audio_logpsd.hpp"
#include <H5Cpp.h>
#include <iostream>
#include <fstream>
#include <string>
#include <vector>
int main(int argc, char** argv) {
if (argc < 3) { std::cerr << "usage: xgb_boundary_parity <dump.h5> <model.json>\n"; return 1; }
H5::H5File f(argv[1], H5F_ACC_RDONLY);
auto read2d = [&](const char* name, std::vector<std::vector<float>>& out, int cols) {
H5::DataSet ds = f.openDataSet(name);
H5::DataSpace sp = ds.getSpace();
hsize_t dims[2]; sp.getSimpleExtentDims(dims);
std::vector<float> flat(dims[0]*dims[1]);
ds.read(flat.data(), H5::PredType::NATIVE_FLOAT);
out.assign(dims[0], std::vector<float>(cols));
for (hsize_t i = 0; i < dims[0]; ++i)
for (int j = 0; j < cols; ++j) out[i][j] = flat[i*dims[1]+j];
};
std::vector<std::vector<float>> hist;
read2d("frames/rgb_hist", hist, XGBSceneBoundary::kHistBins*3);
H5::DataSet tsd = f.openDataSet("frames/timestamp_sec");
hsize_t td[1]; tsd.getSpace().getSimpleExtentDims(td);
std::vector<double> ts(td[0]);
tsd.read(ts.data(), H5::PredType::NATIVE_DOUBLE);
// parity debug: print video features for row 100 (compare to Python)
if (argc > 3 && std::string(argv[3]) == "--row100") {
auto base = XGBSceneBoundary::debug_base(hist, {});
std::cout << "row100:";
for (int j = 0; j < 17; ++j) std::cout << " " << base[100][j];
std::cout << "\n";
return 0;
}
// --feat <cpp_features.h5>: predict directly on the dumped C++ feature matrix
// (same bytes Python reads) — a clean parity check with no live-decode variance.
if (argc > 4 && std::string(argv[3]) == "--feat") {
H5::H5File ff(argv[4], H5F_ACC_RDONLY);
H5::DataSet fd = ff.openDataSet("features");
hsize_t fdm[2]; fd.getSpace().getSimpleExtentDims(fdm);
std::vector<float> X(fdm[0]*fdm[1]);
fd.read(X.data(), H5::PredType::NATIVE_FLOAT);
XGBSceneBoundary det(argv[2]);
auto pdbg = det.debug_predict(X, int(fdm[0]), int(fdm[1]));
auto pk = XGBSceneBoundary::debug_find_peaks(pdbg, 5);
std::cerr << "[parity] C++ raw peaks=" << pk.size() << "\n";
auto b = det.boundaries_from_features(X, int(fdm[0]), int(fdm[1]), ts);
std::cout << b.size() << " boundaries:";
for (double t : b) std::cout << " " << int(t);
std::cout << "\n";
return 0;
}
// Optional movie path (argv[4]): decode audio → per-second log-PSD.
std::vector<std::vector<float>> audio;
if (argc > 4) {
audio = AudioLogPSD::extract(argv[4]);
std::cerr << "[parity] audio rows=" << audio.size()
<< " (hist rows=" << hist.size() << ")\n";
}
XGBSceneBoundary det(argv[2]);
auto b = det.boundaries(hist, ts, audio);
std::cout << b.size() << " boundaries:";
for (double t : b) std::cout << " " << int(t);
std::cout << "\n";
return 0;
}
+20 -208
View File
@@ -44,39 +44,12 @@
// A finished presence claim, emitted exactly once when a track is reaped or // A finished presence claim, emitted exactly once when a track is reaped or
// flushed. Immutable by construction: it carries everything needed to justify // flushed. Immutable by construction: it carries everything needed to justify
// itself (AR-017), with no back-reference into registry state. // itself (AR-017), with no back-reference into registry state.
/// TRACES: AR-017 | IR-002 | SR-002, SR-003
/// How an actor came to be attached to a track.
///
/// AR-017 requires every presence claim to carry its identification route, and
/// IR-002 publishes it per window. Until now the sink wrote the string "live"
/// unconditionally, so the field existed but could not distinguish anything --
/// and AR-017's own verification asks for "deferred and pooled routes
/// distinguishable".
///
/// Only `live` occurs today. `deferred` is what AR-020's pass will set when it
/// resolves a track that failed during streaming and was identified against the
/// final expanded gallery; the value exists now so that pass has somewhere to
/// write rather than a serialisation change to make.
enum class Route {
live, ///< identified while streaming, from accumulated per-frame evidence
deferred, ///< resolved after EOF against the expanded gallery (AR-020)
};
inline const char* route_name(Route r) {
switch (r) {
case Route::deferred: return "deferred";
case Route::live: break;
}
return "live";
}
struct DeadTrack { struct DeadTrack {
int track_id{-1}; int track_id{-1};
double first_seen{0.0}; double first_seen{0.0};
double last_seen{0.0}; ///< always the last sighting, never the death time double last_seen{0.0}; ///< always the last sighting, never the death time
int actor_idx{-1}; ///< -1 when the track was never owned int actor_idx{-1}; ///< -1 when the track was never owned
float belief{0.0f}; ///< accumulated posterior for actor_idx float belief{0.0f}; ///< accumulated posterior for actor_idx
Route route{Route::live}; ///< how the actor was attached (AR-017)
int observations{0}; ///< evidence updates that landed on this track int observations{0}; ///< evidence updates that landed on this track
float effective_obs{0.f}; ///< sum of discounted weights — the evidence that counted float effective_obs{0.f}; ///< sum of discounted weights — the evidence that counted
}; };
@@ -97,12 +70,7 @@ struct Track {
Embedding mean{}; ///< running directional mean Embedding mean{}; ///< running directional mean
std::vector<Embedding> views; ///< distinct looks, for AR-025 discounting std::vector<Embedding> views; ///< distinct looks, for AR-025 discounting
float discounted_weight{0.f}; ///< sum of applied weights float discounted_weight{0.f}; ///< sum of applied weights
int n_obs{0}; ///< every scored face on this track int n_obs{0};
/// Observations that were actually evidence, and so spent the correlation
/// budget. Indexing the effective-sample correction by this rather than by
/// n_obs is what stops non-matches exhausting it — see
/// Config::evidence_floor_p.
int n_evidence{0};
bool on_screen() const { return !last_seen.has_value(); } bool on_screen() const { return !last_seen.has_value(); }
}; };
@@ -113,50 +81,8 @@ public:
using DeadTrackFn = std::function<void(const DeadTrack&)>; using DeadTrackFn = std::function<void(const DeadTrack&)>;
struct Config { struct Config {
/// How long a lost track stays available for re-association. double extinction_sec{5.0}; ///< how long a lost track stays revivable
///
/// Named to match Config::track_extinction_sec, which feeds it, and
/// deliberately NOT `extinction_sec`: that name belonged to the
/// withdrawn actor keep-alive, and SPEC.md's removal list ends "grep
/// for both names and expect no survivors". A survivor here would be
/// the one false positive in that grep, on a field that means
/// something else entirely -- this one bounds re-association and never
/// extends a presence claim.
double track_extinction_sec{5.0};
float ownership_logodds{2.0f}; ///< belief needed to own a track (~0.88 posterior) float ownership_logodds{2.0f}; ///< belief needed to own a track (~0.88 posterior)
/// TRACES: AR-025 | SR-002
/// Posterior below which an observation is not evidence *for* an actor,
/// and so does not spend that actor's correlation budget.
///
/// The budget is an effective-sample correction: with observations
/// correlated at rho, the weight of the n-th is
/// `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 is the
/// intended behaviour — a long static shot must not out-argue varied
/// evidence purely by lasting longer.
///
/// What was not intended is *who spends it*. Every scored face was
/// folded in, so an observation at p=0.02 — which contributes
/// log(0.98) = -0.02 of belief, nothing — consumed the same increment
/// as one at p=0.95. On SuperHero-2 track 3 that exhausted the budget
/// on the frames that recognised nobody: 103 observations, effective
/// weight 2.026, belief 0.455 against a 0.881 threshold, with the 51
/// frames that did identify the actor arriving when each was worth
/// 0.0002. The identification was lost.
///
/// It also made the answer depend on frame rate, which is the defect
/// AR-013 already had to fix once: deliver more frames, dilute the
/// budget with more non-matches, and a track that was owned stops
/// being owned. Measured — the same clip identified the actor before a
/// KPN throughput fix and not after, from identical input.
///
/// 0.5 is the point where the posterior stops favouring the hypothesis
/// at all, not a tuned threshold. Near-misses still count, which is the
/// design: an observation at 0.6 is evidence and is folded in. Below
/// 0.5 the observation argues *against*, which noisy-OR cannot
/// represent, so nothing is lost by declining to spend a budget on it.
float evidence_floor_p{0.5f};
}; };
/// The discounter is a constructor argument rather than an option: there is /// The discounter is a constructor argument rather than an option: there is
@@ -176,65 +102,13 @@ public:
FrameScope(TrackRegistry& reg, double now) FrameScope(TrackRegistry& reg, double now)
: reg_(reg), lock_(reg.mu_) { reg_.tick_locked(now); } : reg_(reg), lock_(reg.mu_) { reg_.tick_locked(now); }
/// All ASSOCIABLE tracks — **one pool**. `last_seen` tells the caller /// All live tracks — **one pool**. `last_seen` tells the caller whether
/// whether IoU is meaningful; a dormant track is matched on embedding /// IoU is meaningful; a dormant track is matched on embedding alone.
/// alone. There is no separate revival path (AR-008). /// There is no separate revival path (AR-008).
///
/// TRACES: AR-008, AR-013 | SR-002
/// Association and reaping share ONE clock — the evidence watermark when a
/// matcher is attached, the tracker clock otherwise (they coincide when
/// there is only one). `candidates()` and `reap_locked()` apply the SAME
/// `track_extinction_sec` horizon against that clock, so the offered pool
/// and the live pool are the same set:
///
/// offered ⟺ (clock - last_seen) ≤ track_extinction_sec
/// reaped/erased ⟺ (clock - last_seen) > track_extinction_sec
///
/// This closes two symmetric failures. (1) Offering on the tracker's clock
/// (ahead of the watermark) let a face associate onto a track the registry
/// had ALREADY reaped on the watermark; the vote then landed on a dead id
/// and was dropped (record_vote → dropped_votes_). Rare live (small lag),
/// but replay runs the tracker far ahead of the matcher and lost ~0.3% of
/// votes. (2) Historically, offering on a LOOSER horizon than the reap left
/// retired tracks in the pool while the matcher lagged, so a new face
/// re-associated onto a long-dead track and two people merged into one
/// window (measured: 5 actors/16 windows at depth 32 vs 3/5 at depth 10322).
/// A single clock and a single threshold make both impossible: nothing is
/// offered past its reap horizon, nothing is reaped while still offerable.
std::vector<Track*> candidates() { std::vector<Track*> candidates() {
std::vector<Track*> out; std::vector<Track*> out;
out.reserve(reg_.tracks_.size()); out.reserve(reg_.tracks_.size());
// Filter association on the SAME clock reaping uses (the evidence for (auto& [id, t] : reg_.tracks_) out.push_back(&t);
// watermark when a matcher is attached, else the tracker clock). The
// two used to differ deliberately — the tracker offered on now_ while
// the registry reaped on evidence_through_ — but that let the tracker
// associate a face onto a track the registry had already reaped on the
// watermark, whose vote then landed on a dead id and was dropped
// (record_vote → dropped_votes_). In the live pipeline the lag is tiny
// so it rarely bit; in replay the Python source runs the tracker far
// ahead of the matcher and ~0.3% of votes were lost. One clock for both
// "may this associate?" and "is this reaped?" closes the race: a track
// past the horizon is neither offered nor reaped-out-from-under a vote.
const double clock =
reg_.awaits_evidence_ ? reg_.evidence_through_ : reg_.now_;
for (auto& [id, t] : reg_.tracks_) {
// On-screen tracks are always candidates (actively tracked this
// frame). A dormant (off-screen) track is only worth keeping alive
// for re-association if it was actually IDENTIFIED: an unowned
// dormant track has no actor to re-attach to, so holding it in the
// pool only bloats the matcher's per-frame comparison set (every
// candidate is a GEMM row) and invites a new face re-associating
// onto an anonymous stub. Gating dormant tracks on t.actor keeps
// the pool bounded regardless of how large track_extinction_sec is
// — which is what makes a long re-association window affordable.
if (t.last_seen) { // dormant
if (!t.actor.has_value())
continue; // never identified: not worth re-associating
if ((clock - *t.last_seen) > reg_.cfg_.track_extinction_sec)
continue; // past the re-association horizon
}
out.push_back(&t);
}
return out; return out;
} }
@@ -254,50 +128,6 @@ public:
/// happens to appear, and a film ending mid-track never closes. /// happens to appear, and a film ending mid-track never closes.
void tick(double now) { std::lock_guard g(mu_); tick_locked(now); } void tick(double now) { std::lock_guard g(mu_); tick_locked(now); }
/// TRACES: AR-012, AR-013, AR-025 | SR-002
/// The evidence watermark: every observation up to `t` has been folded in.
///
/// Reaping is driven by THIS, not by the tracker's clock, and the difference
/// is what stops a correct answer from depending on how fast two nodes run.
///
/// The tracker and the matcher are separate KPN nodes with a channel between
/// them, and the matcher is much the slower of the pair. Backpressure —
/// working exactly as AR-004 intends — turns that channel's depth into lag,
/// so the tracker's timestamp can be far ahead of the last frame anybody has
/// actually voted on. Reaping on the tracker's clock therefore closed tracks
/// 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. Deeper channel, fewer identifications, from identical input.
///
/// The fix is not to bound the channel against `track_extinction_sec`. That
/// makes an algorithm constant police a throughput knob, and leaves the
/// answer a function of scheduling. It is to reap on the watermark, which is
/// the 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 response is to wait rather than to guess.
///
/// Monotonic, and only ever *delays* a reap, so no window can be 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`.
void advance_evidence(double t) {
std::lock_guard g(mu_);
if (t > evidence_through_) evidence_through_ = t;
reap_locked();
}
/// TRACES: AR-013, AR-025 | SR-002
/// Declare that some stage will publish an evidence watermark, so reaping
/// must wait for it.
///
/// Explicit rather than inferred from "has anyone voted yet". Inferring it
/// re-opens the bug exactly at startup: before the matcher's first frame no
/// vote has been seen, so the registry would fall back to the tracker's
/// clock during precisely the window in which the tracker is furthest
/// ahead. `IdentityMatcherFunc::set_registry` calls this, so any pipeline
/// with a matcher waits, and a test that drives the tracker alone keeps the
/// simple behaviour instead of hanging on a watermark nobody will publish.
void expect_evidence() { std::lock_guard g(mu_); awaits_evidence_ = true; }
// ── Evidence ───────────────────────────────────────────────────────────── // ── Evidence ─────────────────────────────────────────────────────────────
/// Fold one observation into a track's belief (AR-025). /// Fold one observation into a track's belief (AR-025).
/// ///
@@ -322,13 +152,7 @@ public:
if (it == tracks_.end()) { ++dropped_votes_; return; } if (it == tracks_.end()) { ++dropped_votes_; return; }
Track& t = it->second; Track& t = it->second;
++t.n_obs; // every scored face is seen, whether or not it is evidence const float w = discounter_.weight(t.views, t.n_obs, e);
// Not evidence *for* this actor: contributes ~nothing to the belief and
// must not spend the correlation budget. See Config::evidence_floor_p.
if (posterior < cfg_.evidence_floor_p) return;
const float w = discounter_.weight(t.views, t.n_evidence, e);
// Weighted lazy-OR: P_new = 1 (1 P_old)·(1 p)^w, which in log // Weighted lazy-OR: P_new = 1 (1 P_old)·(1 p)^w, which in log
// space is a plain sum. w is the discounted evidence (AR-025), so a // space is a plain sum. w is the discounted evidence (AR-025), so a
@@ -337,7 +161,7 @@ public:
const float p = std::min(1.f - 1e-6f, std::max(1e-6f, posterior)); const float p = std::min(1.f - 1e-6f, std::max(1e-6f, posterior));
t.belief[actor_idx] += w * std::log(1.f - p); t.belief[actor_idx] += w * std::log(1.f - p);
t.discounted_weight += w; t.discounted_weight += w;
++t.n_evidence; ++t.n_obs;
const int best = argmax_belief(t); const int best = argmax_belief(t);
const float best_p = 1.f - std::exp(t.belief[best]); const float best_p = 1.f - std::exp(t.belief[best]);
@@ -382,15 +206,6 @@ public:
// ── Diagnostics ────────────────────────────────────────────────────────── // ── Diagnostics ──────────────────────────────────────────────────────────
// These measure how often tracking is silently wrong, which nothing in the // These measure how often tracking is silently wrong, which nothing in the
// pipeline currently reveals. // pipeline currently reveals.
/// Whether the registry still holds this track. The authority on which
/// tracks exist, so annotating structures elsewhere (spatial boxes in the
/// tracker, diversity buffers in the expansion store) can prune against it
/// rather than keeping a second opinion.
bool is_live(int track_id) const {
std::lock_guard g(mu_);
return tracks_.count(track_id) != 0;
}
int dropped_votes() const { std::lock_guard g(mu_); return dropped_votes_; } int dropped_votes() const { std::lock_guard g(mu_); return dropped_votes_; }
int belief_swaps() const { std::lock_guard g(mu_); return belief_swaps_; } int belief_swaps() const { std::lock_guard g(mu_); return belief_swaps_; }
int actor_conflicts() const { std::lock_guard g(mu_); return actor_conflicts_; } int actor_conflicts() const { std::lock_guard g(mu_); return actor_conflicts_; }
@@ -399,20 +214,9 @@ public:
private: private:
// ── Locked internals ───────────────────────────────────────────────────── // ── Locked internals ─────────────────────────────────────────────────────
void tick_locked(double now) { void tick_locked(double now) {
// The tracker's clock still bounds association (a dormant track is only
// a candidate while it is alive), but it no longer decides death.
now_ = now;
reap_locked();
}
/// Reap against the evidence watermark when a producer of one is attached
/// (see expect_evidence); otherwise against the tracker's clock, which is
/// the same thing when there is only one clock.
void reap_locked() {
const double clock = awaits_evidence_ ? evidence_through_ : now_;
for (auto it = tracks_.begin(); it != tracks_.end(); ) { for (auto it = tracks_.begin(); it != tracks_.end(); ) {
const auto& ls = it->second.last_seen; const auto& ls = it->second.last_seen;
if (ls && (clock - *ls) > cfg_.track_extinction_sec) { if (ls && (now - *ls) > cfg_.extinction_sec) {
emit_locked(it->second, *ls); emit_locked(it->second, *ls);
it = tracks_.erase(it); it = tracks_.erase(it);
} else { } else {
@@ -527,6 +331,17 @@ private:
for (int i = 0; i < 512; ++i) t.mean[i] = static_cast<float>(t.mean[i] / norm); for (int i = 0; i < 512; ++i) t.mean[i] = static_cast<float>(t.mean[i] / norm);
} }
static float logistic(float z) {
return z >= 0 ? 1.f / (1.f + std::exp(-z))
: std::exp(z) / (1.f + std::exp(z));
}
static float logit(float p) {
const float eps = 1e-6f;
p = std::min(1.f - eps, std::max(eps, p));
return std::log(p / (1.f - p));
}
Config cfg_; Config cfg_;
EvidenceDiscounter discounter_; EvidenceDiscounter discounter_;
mutable std::mutex mu_; mutable std::mutex mu_;
@@ -534,9 +349,6 @@ private:
std::map<int, int> owner_index_; ///< actor_idx → live track_id (AR-015) std::map<int, int> owner_index_; ///< actor_idx → live track_id (AR-015)
DeadTrackFn on_dead_; DeadTrackFn on_dead_;
int next_id_{0}; int next_id_{0};
double now_{0.0}; ///< tracker's clock (association)
double evidence_through_{0.0}; ///< matcher's watermark (reaping)
bool awaits_evidence_{false};
int dropped_votes_{0}; int dropped_votes_{0};
int belief_swaps_{0}; int belief_swaps_{0};
int actor_conflicts_{0}; int actor_conflicts_{0};
-147
View File
@@ -31,10 +31,6 @@ struct Frame {
float cut_score{0.f}; // histogram cut score = 1 - hist_corr (0=identical, ~1=cut); HUD/debug float cut_score{0.f}; // histogram cut score = 1 - hist_corr (0=identical, ~1=cut); HUD/debug
float bbox_upscale{1.f}; // multiply detector bboxes/landmarks by this to map back to float bbox_upscale{1.f}; // multiply detector bboxes/landmarks by this to map back to
// original video resolution (>1 when dense_scale downscaled the frame) // original video resolution (>1 when dense_scale downscaled the frame)
// Normalised 32-bin-per-channel RGB histogram (96 floats), stamped by the
// camera-position node and carried to the sink for the learned scene-boundary
// detector (post-EOF, flood-fill boundaries). Empty when scene detection off.
std::vector<float> rgb_hist;
}; };
// ── CutEvent ────────────────────────────────────────────────────────────────── // ── CutEvent ──────────────────────────────────────────────────────────────────
@@ -63,36 +59,11 @@ inline constexpr float kArcFaceRef[5][2] = {
// Landmark order matches ArcFace convention (same as SCRFD output order): // Landmark order matches ArcFace convention (same as SCRFD output order):
// [0] right-eye-centre [1] left-eye-centre [2] nose // [0] right-eye-centre [1] left-eye-centre [2] nose
// [3] right-mouth [4] left-mouth // [3] right-mouth [4] left-mouth
/// TRACES: AR-028 | SR-002
struct DetectedFace { struct DetectedFace {
cv::Rect2f bbox; cv::Rect2f bbox;
std::array<cv::Point2f, 5> landmarks; std::array<cv::Point2f, 5> landmarks;
float confidence{0.f}; float confidence{0.f};
// ── AR-028 quality vector ────────────────────────────────────────────────
// Three axes, kept separate and never collapsed into one scalar: they fail
// for different reasons, have different remedies, and do not earn the same
// response. Carried, not consumed — the vector travels with the face into
// the VR-001 dump so a threshold can be re-litigated against recorded data
// rather than by re-running video.
//
// **Size is the third axis and is deliberately not a field here.** It is
// `bbox`, which every consumer already has, scaled by the frame's
// `bbox_upscale` to reach the original resolution AR-002 thresholds in.
// Copying it into a second field would put the same quantity in two
// coordinate spaces inside one struct — the trap SCHEMA.md records for
// `bbox_upscale` — and the copy would be the one that drifts.
//
// Both fields below are -1 until the aligner runs, so *unscored* is
// distinguishable from *scored badly*. Nothing downstream may read a
// negative value as a quality.
// AR-029 sharpness: normalised Laplacian variance over the aligned crop,
// dimensionless. Falls with motion blur and soft focus; invariant to
// contrast, and taken on the fixed 112×112 canvas so it cannot re-measure
// face size. See crop_sharpness() for the construction and its one hazard.
float sharpness{-1.f};
// AR-030 visibility: RMS landmark misfit, in canonical 112×112 pixels, left // AR-030 visibility: RMS landmark misfit, in canonical 112×112 pixels, left
// over after the best similarity fit to the ArcFace template. Rises with // over after the best similarity fit to the ArcFace template. Rises with
// out-of-plane pose and with occlusion; blind to in-plane roll and to face // out-of-plane pose and with occlusion; blind to in-plane roll and to face
@@ -159,16 +130,6 @@ struct SceneAnnotation {
double timestamp_sec{0.0}; double timestamp_sec{0.0};
std::vector<IdentifiedActor> visible_actors; std::vector<IdentifiedActor> visible_actors;
bool eof{false}; bool eof{false};
// Carried through from Frame so the sink can collect boundaries for flood-fill
// presence (PresenceMode::flood). is_cut is the always-on histogram cut
// (camera_position_change_detector) — the boundary flood-fill uses by default.
// is_scene_boundary is the opt-in TransNetV2 shot boundary (0 unless scene
// detection ran); kept for a future out-of-process scene detector.
bool is_cut{false};
bool is_scene_boundary{false};
// Per-frame RGB histogram, carried to the sink for the learned scene-boundary
// detector run post-EOF (flood-fill). Empty unless scene detection is enabled.
std::vector<float> rgb_hist;
}; };
// ── Actor gallery ───────────────────────────────────────────────────────────── // ── Actor gallery ─────────────────────────────────────────────────────────────
@@ -198,111 +159,3 @@ struct ActorGallery {
bool calib_valid{false}; bool calib_valid{false};
uint64_t calib_hash{0}; uint64_t calib_hash{0};
}; };
// ── Channel byte accounting ───────────────────────────────────────────────────
/// TRACES: AR-004 | SR-002
///
/// KPN measures a channel's occupancy in *items* and its bandwidth in bytes,
/// and gets the byte figure from `kpn::ChannelDataSize<T>`. That primary
/// template returns `sizeof(T)` — right for a POD, badly wrong for every type
/// below, each of which is a handful of vectors and a `cv::Mat` header owning
/// megabytes on the heap.
///
/// Unspecialised, the diagnostics reported roughly 200 bytes for a message
/// carrying a full decoded frame — off by four orders of magnitude at 1080p.
/// That is not merely a cosmetic stat: it is the one instrument for choosing
/// channel capacities against a memory ceiling, which is the open half of
/// AR-004, and it was reading fiction.
///
/// **What the number means.** `cv::Mat` is reference-counted, so one decoded
/// frame referenced from several messages is counted once per reference. The
/// sum is therefore an upper bound on distinct bytes, and the right bound for
/// the question being asked: how much would this channel keep alive if nothing
/// else held it.
///
/// Declared against a forward declaration rather than including
/// `<kpn/channel.hpp>` here, so the message definitions keep no dependency on
/// the framework that carries them — and so any translation unit that can see
/// these types also sees their sizes, which is what stops one channel being
/// instantiated with the default and another with the specialisation.
namespace kpn { template<typename T> struct ChannelDataSize; }
namespace sae::bytes {
inline std::size_t of(const cv::Mat& m) {
return m.empty() ? 0u : m.total() * m.elemSize();
}
inline std::size_t of(const std::vector<cv::Mat>& v) {
std::size_t n = 0;
for (const auto& m : v) n += of(m);
return n;
}
inline std::size_t of(const Frame& f) { return sizeof(Frame) + of(f.image); }
inline std::size_t of(const std::vector<IdentifiedActor>& v) {
std::size_t n = v.size() * sizeof(IdentifiedActor);
for (const auto& a : v) {
n += of(a.crop);
// The id strings are short but there is one set per actor per frame,
// and a crowd frame carries dozens.
n += a.name.capacity() + a.imdb_id.capacity()
+ a.tmdb_id.capacity() + a.jellyfin_id.capacity();
}
return n;
}
} // namespace sae::bytes
template<> struct kpn::ChannelDataSize<Frame> {
static std::size_t bytes(const Frame& f) { return sae::bytes::of(f); }
};
template<> struct kpn::ChannelDataSize<SceneFrame> {
static std::size_t bytes(const SceneFrame& v) {
return sizeof(SceneFrame) + sae::bytes::of(v.source)
+ v.faces.size() * sizeof(DetectedFace);
}
};
template<> struct kpn::ChannelDataSize<AlignedSceneFrame> {
static std::size_t bytes(const AlignedSceneFrame& v) {
return sizeof(AlignedSceneFrame) + sae::bytes::of(v.source)
+ v.faces.size() * sizeof(DetectedFace)
+ sae::bytes::of(v.crops);
}
};
template<> struct kpn::ChannelDataSize<EmbeddedSceneFrame> {
static std::size_t bytes(const EmbeddedSceneFrame& v) {
return sizeof(EmbeddedSceneFrame) + sae::bytes::of(v.source)
+ v.faces.size() * sizeof(DetectedFace)
+ sae::bytes::of(v.crops)
+ v.embeddings.size() * sizeof(Embedding);
}
};
template<> struct kpn::ChannelDataSize<TrackedSceneFrame> {
static std::size_t bytes(const TrackedSceneFrame& v) {
return sizeof(TrackedSceneFrame) + sae::bytes::of(v.source)
+ v.faces.size() * sizeof(DetectedFace)
+ sae::bytes::of(v.crops)
+ v.track_ids.size() * sizeof(int)
+ v.embeddings.size() * sizeof(Embedding);
}
};
template<> struct kpn::ChannelDataSize<MatchedSceneFrame> {
static std::size_t bytes(const MatchedSceneFrame& v) {
return sizeof(MatchedSceneFrame) + sae::bytes::of(v.source)
+ sae::bytes::of(v.actors);
}
};
template<> struct kpn::ChannelDataSize<SceneAnnotation> {
static std::size_t bytes(const SceneAnnotation& v) {
return sizeof(SceneAnnotation) + sae::bytes::of(v.visible_actors);
}
};
// CutEvent owns nothing on the heap, so the default sizeof(T) is already right.
-17
View File
@@ -22,13 +22,8 @@ add_executable(sae_tests
test_track_gallery.cpp test_track_gallery.cpp
test_face_tracker.cpp test_face_tracker.cpp
test_track_registry.cpp test_track_registry.cpp
test_face_detector_node.cpp
test_scene_detector_node.cpp
test_replay_fixtures.cpp test_replay_fixtures.cpp
test_embedding_dump.cpp
test_audio_signature.cpp test_audio_signature.cpp
test_benchmark.cpp
test_channel_bytes.cpp
${CMAKE_SOURCE_DIR}/src/backends/gemm_backend.cpp ${CMAKE_SOURCE_DIR}/src/backends/gemm_backend.cpp
${CMAKE_SOURCE_DIR}/src/gallery/gallery_store.cpp ${CMAKE_SOURCE_DIR}/src/gallery/gallery_store.cpp
${CMAKE_SOURCE_DIR}/src/audio_signature.cpp ${CMAKE_SOURCE_DIR}/src/audio_signature.cpp
@@ -49,14 +44,6 @@ endif()
if(OPENBLAS_T_FOUND) if(OPENBLAS_T_FOUND)
target_include_directories(sae_tests PRIVATE ${OPENBLAS_T_INCLUDE_DIRS}) target_include_directories(sae_tests PRIVATE ${OPENBLAS_T_INCLUDE_DIRS})
target_link_libraries(sae_tests PRIVATE ${OPENBLAS_T_LINK_LIBRARIES}) target_link_libraries(sae_tests PRIVATE ${OPENBLAS_T_LINK_LIBRARIES})
elseif(NOT SAE_ALLOW_SCALAR_GEMM)
# Same rule as the CPU backend itself: testing the scalar loop while the
# shipped CPU path is OpenBLAS means the suite is not evidence about the
# kernel that runs.
message(FATAL_ERROR
"OpenBLAS not found, and the unit tests compile the CPU GEMM kernel "
"(AR-026). Install openblas-devel, or pass -DSAE_ALLOW_SCALAR_GEMM=ON "
"to test the scalar fallback deliberately.")
endif() endif()
target_compile_definitions(sae_tests PRIVATE target_compile_definitions(sae_tests PRIVATE
@@ -72,10 +59,6 @@ target_compile_definitions(sae_tests PRIVATE
target_link_libraries(sae_tests PRIVATE target_link_libraries(sae_tests PRIVATE
Catch2::Catch2WithMain Catch2::Catch2WithMain
nlohmann_json::nlohmann_json nlohmann_json::nlohmann_json
# VR-015: test_benchmark.cpp includes src/benchmark.hpp, which reads KPN's
# diagnostics structs. Header-only no KPN network is constructed here, so
# the cost attribution stays testable on CI's GPU-free N100.
kpn
ffmpeg_libs ffmpeg_libs
${OpenCV_LIBS} ${OpenCV_LIBS}
${HDF5_CXX_LIBRARIES}) ${HDF5_CXX_LIBRARIES})
-221
View File
@@ -1,221 +0,0 @@
// Cost attribution for the pipeline benchmark.
//
// TRACES: VR-015 | UT-120, UT-121, UT-122, UT-123, UT-124 | PR-004
//
// `attribute_cost` is pure — no clock, no thread, no network — precisely so the
// ranking can be tested on CI hardware that can never run the pipeline. These
// are T1 tests: they build the snapshots the KPN network would have produced
// and assert which node gets blamed.
//
// The case that matters is UT-121. On SuperHero the real run reported
// `frame_source ema=141.899ms` against a decoder logging 12-18 ms, because
// `fire_once` bills time parked pushing into a full downstream channel to the
// node doing the pushing. Any metric that ranks nodes by wall time inside the
// node picks the source — the fastest node in the graph — as the thing to
// optimise. That is the mistake this file exists to prevent regressing.
#include "benchmark.hpp"
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <stdexcept>
#include <string_view>
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
using namespace sae::bench;
namespace {
/// A KPN node snapshot with only the fields attribution reads.
kpn::NodeSnapshot node(std::string name, std::uint64_t frames,
double ema_ms, double cpu_ms, double exec_ms) {
kpn::NodeSnapshot s{};
s.name = std::move(name);
s.frames_processed = frames;
s.ema_exec_ms = ema_ms;
s.total_cpu_ms = cpu_ms;
s.total_exec_ms = exec_ms;
return s;
}
/// A channel whose mean fill is `fill_pct` of `capacity`.
ChannelOccupancy chan(const std::string& producer, const std::string& consumer,
std::size_t capacity, double fill_pct) {
ChannelOccupancy c;
c.name = producer + ":0 \xe2\x86\x92 " + consumer + ":0";
c.producer = producer;
c.consumer = consumer;
c.capacity = capacity;
c.samples = 1000;
c.fill_sum = static_cast<double>(c.samples) * static_cast<double>(capacity) * fill_pct / 100.0;
return c;
}
// Returned by value: a reference into `v` bound to a name built from a string
// literal trips -Wdangling-reference, and the struct is small enough not to care.
NodeCost by_name(const std::vector<NodeCost>& v, std::string_view name) {
for (const auto& c : v) if (c.name == name) return c;
throw std::runtime_error("no such node: " + std::string(name));
}
NodeCost bottleneck(const std::vector<NodeCost>& v) {
for (const auto& c : v) if (c.is_bottleneck) return c;
throw std::runtime_error("no bottleneck flagged");
}
} // namespace
// UT-120 — the node work queues up in front of is the one blamed.
TEST_CASE("attribute_cost blames the node with a full input and an empty output",
"[benchmark][VR-015]") {
// source → mid → sink. mid is slow: its input backs up, its output drains.
const auto nodes = std::vector<kpn::NodeSnapshot>{
node("source", 1000, 10.0, 2'000.0, 10'000.0),
node("mid", 1000, 50.0, 45'000.0, 50'000.0),
node("sink", 1000, 0.5, 400.0, 500.0),
};
const auto channels = std::vector<ChannelOccupancy>{
chan("source", "mid", 32, 95.0), // full: work piling up in front of mid
chan("mid", "sink", 16, 2.0), // empty: mid starves everything after
};
const auto costs = attribute_cost(nodes, channels, /*wall_sec=*/50.0);
CHECK(bottleneck(costs).name == "mid");
CHECK(by_name(costs, "mid").pressure > by_name(costs, "source").pressure);
CHECK(by_name(costs, "mid").pressure > by_name(costs, "sink").pressure);
}
// UT-121 — the SuperHero regression: a backpressured source reports a huge
// wall time per frame and must NOT be mistaken for the bottleneck.
TEST_CASE("a backpressured source is not blamed for the time it spent parked",
"[benchmark][VR-015]") {
// Numbers taken from the real SuperHero TRT run: the source reports
// 141.9 ms/frame inside fire_once while its decoder logs ~15 ms, because
// the remaining ~127 ms is spent parked on a full output channel.
const auto nodes = std::vector<kpn::NodeSnapshot>{
node("frame_source", 5132, 141.899, 77'000.0, 728'000.0),
node("face_detector", 5129, 6.830, 480'000.0, 35'000.0),
node("result_sink", 5129, 0.080, 410.0, 410.0),
};
const auto channels = std::vector<ChannelOccupancy>{
chan("frame_source", "face_detector", 32, 99.0), // source blocked on this
chan("face_detector", "result_sink", 16, 1.0),
};
const auto costs = attribute_cost(nodes, channels, /*wall_sec=*/500.0);
const auto& src = by_name(costs, "frame_source");
const auto& det = by_name(costs, "face_detector");
// The trap: by wall time inside the node, the source looks 20x costlier.
REQUIRE(src.exec_ms_per_frame > det.exec_ms_per_frame * 10.0);
// The fix: it is not blamed, because its own output channel is the thing
// that is full — it is waiting, not working.
CHECK_FALSE(src.is_bottleneck);
CHECK(bottleneck(costs).name == "face_detector");
// And CPU time, which parking cannot inflate, agrees: the detector burns
// 480 s of thread time against the source's 77 s.
CHECK(det.cpu_ms > src.cpu_ms);
CHECK(det.cpu_pct_of_pipeline > src.cpu_pct_of_pipeline);
}
// UT-122 — terminals stay rankable via the infinite-reservoir convention.
TEST_CASE("a source with an empty output is blamed; a sink with a full input is too",
"[benchmark][VR-015]") {
SECTION("starved pipeline: the source cannot keep up") {
const auto nodes = std::vector<kpn::NodeSnapshot>{
node("source", 100, 90.0, 9'000.0, 9'000.0),
node("mid", 100, 1.0, 100.0, 100.0),
};
// Nothing ever accumulates: the source is the constraint.
const auto costs = attribute_cost(nodes, {chan("source", "mid", 32, 1.0)}, 10.0);
CHECK(bottleneck(costs).name == "source");
// Source has no input channel, so it is treated as always having work.
CHECK_THAT(by_name(costs, "source").in_fill_pct, WithinAbs(100.0, 1e-9));
}
SECTION("congested pipeline: the sink cannot drain") {
const auto nodes = std::vector<kpn::NodeSnapshot>{
node("mid", 100, 1.0, 100.0, 100.0),
node("sink", 100, 90.0, 9'000.0, 9'000.0),
};
const auto costs = attribute_cost(nodes, {chan("mid", "sink", 16, 98.0)}, 10.0);
CHECK(bottleneck(costs).name == "sink");
// Sink has no output channel, so it is treated as never blocking.
CHECK_THAT(by_name(costs, "sink").out_fill_pct, WithinAbs(0.0, 1e-9));
}
}
// UT-123 — the per-node time figures are the ones an optimiser would act on.
TEST_CASE("cost shares are computed against wall clock and pipeline total",
"[benchmark][VR-015]") {
const auto nodes = std::vector<kpn::NodeSnapshot>{
node("a", 100, 1.0, 30'000.0, 40'000.0), // 30 s CPU
node("b", 100, 1.0, 10'000.0, 12'000.0), // 10 s CPU
};
const auto costs = attribute_cost(nodes, {chan("a", "b", 8, 50.0)}, /*wall_sec=*/50.0);
const auto& a = by_name(costs, "a");
CHECK_THAT(a.cpu_ms_per_frame, WithinRel(300.0, 1e-9)); // 30 s / 100 frames
CHECK_THAT(a.exec_ms_per_frame, WithinRel(400.0, 1e-9));
CHECK_THAT(a.cpu_share, WithinRel(0.6, 1e-9)); // 30 s of a 50 s run
CHECK_THAT(a.exec_share, WithinRel(0.8, 1e-9));
CHECK_THAT(a.cpu_pct_of_pipeline, WithinRel(75.0, 1e-9)); // 30 of 40 s total
// Time inside the node that was not spent on its own CPU: parked, or on GPU.
CHECK_THAT(a.stall_ms_per_frame, WithinRel(100.0, 1e-9));
// A node that never ran cannot be the bottleneck, and contributes no cost.
const auto idle = std::vector<kpn::NodeSnapshot>{
node("ran", 10, 1.0, 100.0, 100.0),
node("idle", 0, 0.0, 0.0, 0.0),
};
const auto idle_costs = attribute_cost(idle, {}, 10.0);
CHECK(bottleneck(idle_costs).name == "ran");
CHECK_FALSE(by_name(idle_costs, "idle").is_bottleneck);
}
// UT-124 — the node graph is recovered from KPN's channel names, which is what
// keeps attribution working when the topology changes.
TEST_CASE("channel names split back into producer and consumer",
"[benchmark][VR-015]") {
std::string p, c;
split_edge_name("frame_source:0 \xe2\x86\x92 camera_pos:0", p, c);
CHECK(p == "frame_source");
CHECK(c == "camera_pos");
// Multi-port nodes: the port index is stripped, the node name is not.
split_edge_name("detector:2 \xe2\x86\x92 aligner:1", p, c);
CHECK(p == "detector");
CHECK(c == "aligner");
// A name with no arrow leaves both untouched rather than inventing an edge.
std::string q = "unset", r = "unset";
split_edge_name("not an edge", q, r);
CHECK(q == "unset");
CHECK(r == "unset");
}
// A node with several inputs is gated by its emptiest one, and blocked by its
// fullest output — the multi-branch case the scene-detect topology creates.
TEST_CASE("multi-port nodes take min input fill and max output fill",
"[benchmark][VR-015]") {
const auto nodes = std::vector<kpn::NodeSnapshot>{
node("join", 100, 1.0, 1'000.0, 1'000.0),
};
const auto channels = std::vector<ChannelOccupancy>{
chan("up_a", "join", 32, 99.0), // full, but...
chan("up_b", "join", 32, 4.0), // ...this one gates the node
chan("join", "down_a", 16, 10.0),
chan("join", "down_b", 16, 80.0), // parking on this stops the node
};
const auto costs = attribute_cost(nodes, channels, 10.0);
const auto& j = by_name(costs, "join");
CHECK_THAT(j.in_fill_pct, WithinAbs( 4.0, 1e-9));
CHECK_THAT(j.out_fill_pct, WithinAbs(80.0, 1e-9));
CHECK(j.pressure < 0.0); // starved, not congested
}
-109
View File
@@ -1,109 +0,0 @@
// Channel byte accounting for the pipeline message types.
//
// TRACES: AR-004 | SR-002
//
// kpn::ChannelDataSize<T> is what a channel reports as bytes pushed, and its
// primary template returns sizeof(T). Every message type here is a handful of
// vectors and a cv::Mat header owning megabytes on the heap, so unspecialised
// the diagnostics reported ~200 bytes for a message carrying a full decoded
// frame — off by four orders of magnitude at 1080p.
//
// That is the instrument for choosing channel capacities against a memory
// ceiling, which is the open half of AR-004. These cases assert it measures the
// payload rather than the header, because a stat that is quietly wrong is worse
// than no stat: it was read as evidence.
#include <catch2/catch_test_macros.hpp>
#include <kpn/channel.hpp>
#include "types.hpp"
namespace {
Frame frame_with_image(int w, int h) {
Frame f;
f.image = cv::Mat(h, w, CV_8UC3, cv::Scalar(0, 0, 0));
f.timestamp_sec = 1.0;
return f;
}
} // namespace
TEST_CASE("frame bytes count the decoded image, not the header", "[channel_bytes]") {
const Frame f = frame_with_image(1920, 1080);
const std::size_t got = kpn::ChannelDataSize<Frame>::bytes(f);
// 1920 * 1080 * 3 = 6,220,800 payload bytes.
REQUIRE(got >= 1920u * 1080u * 3u);
// The header is a rounding error next to it; this is the assertion that
// fails on the unspecialised default.
CHECK(got > 100u * sizeof(Frame));
}
TEST_CASE("an empty frame costs only its header", "[channel_bytes]") {
// The eof sentinel carries no image, and must not be charged for one.
Frame eof;
eof.eof = true;
CHECK(kpn::ChannelDataSize<Frame>::bytes(eof) == sizeof(Frame));
}
TEST_CASE("crops and embeddings are counted on top of the frame", "[channel_bytes]") {
// The case AR-003 created: a crowd frame occupies one slot exactly as an
// empty one does, and only the byte figure distinguishes them.
EmbeddedSceneFrame v;
v.source = frame_with_image(640, 360);
const std::size_t bare = kpn::ChannelDataSize<EmbeddedSceneFrame>::bytes(v);
constexpr int kFaces = 60;
for (int i = 0; i < kFaces; ++i) {
v.faces.push_back({});
v.crops.emplace_back(112, 112, CV_8UC3, cv::Scalar(0, 0, 0));
v.embeddings.emplace_back();
}
const std::size_t crowded = kpn::ChannelDataSize<EmbeddedSceneFrame>::bytes(v);
// 60 crops at 112*112*3 = 2,257,920 bytes, plus 60 * 2 KiB of embeddings.
CHECK(crowded - bare >= kFaces * (112u * 112u * 3u + sizeof(Embedding)));
// And the crowd frame really is the multiple of the empty one that the
// item-count capacity cannot see: 640x360x3 is ~691 KB, the crops ~2.26 MB.
CHECK(crowded > 3 * bare);
}
TEST_CASE("every message type on a channel measures its payload", "[channel_bytes]") {
// A specialisation missing for any one of these silently reverts that
// channel to sizeof(T), which is exactly how this went unnoticed.
const Frame f = frame_with_image(320, 240);
const std::size_t img = 320u * 240u * 3u;
SceneFrame sf; sf.source = f;
AlignedSceneFrame af; af.source = f;
EmbeddedSceneFrame ef; ef.source = f;
TrackedSceneFrame tf; tf.source = f;
MatchedSceneFrame mf; mf.source = f;
CHECK(kpn::ChannelDataSize<SceneFrame>::bytes(sf) >= img);
CHECK(kpn::ChannelDataSize<AlignedSceneFrame>::bytes(af) >= img);
CHECK(kpn::ChannelDataSize<EmbeddedSceneFrame>::bytes(ef) >= img);
CHECK(kpn::ChannelDataSize<TrackedSceneFrame>::bytes(tf) >= img);
CHECK(kpn::ChannelDataSize<MatchedSceneFrame>::bytes(mf) >= img);
// SceneAnnotation carries no source frame — only the actors it identified,
// each with its own crop.
SceneAnnotation sa;
sa.visible_actors.push_back({});
sa.visible_actors.back().crop = cv::Mat(112, 112, CV_8UC3, cv::Scalar(0, 0, 0));
CHECK(kpn::ChannelDataSize<SceneAnnotation>::bytes(sa) >= 112u * 112u * 3u);
}
TEST_CASE("a shared image is charged to each message holding it", "[channel_bytes]") {
// cv::Mat is reference-counted, so a frame referenced from several messages
// is counted once per reference. The sum is 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.
const Frame f = frame_with_image(320, 240);
SceneFrame a; a.source = f;
SceneFrame b; b.source = f; // shares the same pixel buffer
CHECK(kpn::ChannelDataSize<SceneFrame>::bytes(a)
== kpn::ChannelDataSize<SceneFrame>::bytes(b));
}
-169
View File
@@ -1,169 +0,0 @@
// TRACES: AR-028 | VR-001 | UT-139, UT-140, UT-141 | SR-002
//
// The other half of AR-028: the quality vector has to *survive into the dump*.
// Measuring it at inference and then leaving it in a struct that dies at the
// EmbeddedSceneFrame channel would satisfy the letter of "assessed" and none of
// the point — VR-012 sets its knees from recorded data, and what the dump does
// not carry cannot be re-litigated without re-running video on a GPU.
//
// Tier T2, but cheap: EmbeddingDumpFunc is a sink, so it can be driven directly
// with hand-built frames. No model, no video, no gallery — the embedder stamp
// tolerates an unset model path (GR-004 records it as unverifiable).
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "config.hpp"
#include "nodes/embedding_dump_node.hpp"
#include "types.hpp"
#include <H5Cpp.h>
#include <atomic>
#include <cstdio>
#include <filesystem>
#include <string>
#include <vector>
using Catch::Matchers::WithinAbs;
namespace {
namespace fs = std::filesystem;
// Removes the file on scope exit so a failing assertion cannot leave the next
// run reading a stale dump.
struct TempDump {
fs::path path;
explicit TempDump(const char* stem)
: path(fs::temp_directory_path() / (std::string("sae_") + stem + ".h5")) {
std::remove(path.c_str());
}
~TempDump() { std::error_code ec; fs::remove(path, ec); }
};
EmbeddedSceneFrame frame_with(double ts, const std::vector<std::pair<float, float>>& quality) {
EmbeddedSceneFrame ef;
ef.source.timestamp_sec = ts;
ef.source.frame_idx = static_cast<int64_t>(ts * 5.0);
for (const auto& [sharpness, residual] : quality) {
DetectedFace f;
f.bbox = cv::Rect2f(10.f, 20.f, 60.f, 60.f);
f.confidence = 0.8f;
f.sharpness = sharpness;
f.alignment_residual = residual;
ef.faces.push_back(f);
Embedding e{};
e[0] = 1.f;
ef.embeddings.push_back(e);
}
return ef;
}
EmbeddedSceneFrame eof_frame() {
EmbeddedSceneFrame ef;
ef.source.eof = true;
return ef;
}
std::vector<float> read_face_col(const H5::H5File& f, const char* name) {
H5::DataSet ds = f.openDataSet(std::string("faces/") + name);
hsize_t n = 0;
ds.getSpace().getSimpleExtentDims(&n, nullptr);
std::vector<float> out(n);
if (n) ds.read(out.data(), H5::PredType::NATIVE_FLOAT);
return out;
}
int read_schema_version(const H5::H5File& f) {
int v = 0;
f.openAttribute("schema_version").read(H5::PredType::NATIVE_INT, &v);
return v;
}
} // namespace
TEST_CASE("the quality vector survives into the dump", "[dump][AR-028][UT-139]") {
TempDump tmp("quality_roundtrip");
Config cfg;
cfg.dump_embeddings_path = tmp.path.string();
cfg.movie_path = "synthetic";
cfg.sample_fps = 5.f;
std::atomic<bool> done{false};
{
EmbeddingDumpFunc dump(cfg, done);
dump(frame_with(0.0, {{3.25f, 0.75f}, {0.5f, 4.5f}}));
dump(frame_with(0.2, {})); // a frame with no faces
dump(frame_with(0.4, {{12.0f, 0.0f}}));
dump(eof_frame());
}
REQUIRE(done.load());
REQUIRE(fs::exists(tmp.path));
H5::H5File f(tmp.path.string(), H5F_ACC_RDONLY);
const std::vector<float> sharp = read_face_col(f, "sharpness");
const std::vector<float> resid = read_face_col(f, "alignment_residual");
const std::vector<float> conf = read_face_col(f, "confidence");
// Parallel to every other per-face array, so a consumer can index the
// quality of face i with the same slice it uses for the embedding.
REQUIRE(sharp.size() == conf.size());
REQUIRE(resid.size() == conf.size());
REQUIRE(sharp.size() == 3);
CHECK_THAT(sharp[0], WithinAbs(3.25f, 1e-6f));
CHECK_THAT(sharp[1], WithinAbs(0.50f, 1e-6f));
CHECK_THAT(sharp[2], WithinAbs(12.0f, 1e-6f));
CHECK_THAT(resid[0], WithinAbs(0.75f, 1e-6f));
CHECK_THAT(resid[1], WithinAbs(4.50f, 1e-6f));
CHECK_THAT(resid[2], WithinAbs(0.00f, 1e-6f));
}
TEST_CASE("a dump carrying the quality vector announces itself as v2", "[dump][AR-028][UT-140]") {
// The bump is not for readers — they check for the datasets by name, and a
// v1 dump still replays. It is so a consumer of the vector can tell "these
// faces were never scored" from "these faces scored zero", which is not
// recoverable from the arrays. Same reason scene_detect is an attribute.
TempDump tmp("quality_version");
Config cfg;
cfg.dump_embeddings_path = tmp.path.string();
cfg.movie_path = "synthetic";
std::atomic<bool> done{false};
{
EmbeddingDumpFunc dump(cfg, done);
dump(frame_with(0.0, {{1.f, 1.f}}));
dump(eof_frame());
}
H5::H5File f(tmp.path.string(), H5F_ACC_RDONLY);
CHECK(read_schema_version(f) == 2);
}
TEST_CASE("an unscored face keeps its sentinel through the dump", "[dump][AR-028][UT-141]") {
// The aligner admits no unscored face, so this state should be unreachable.
// The dump still must not clamp it: -1 is how a future path that skipped
// scoring would be caught, and rewriting it to 0 would hide that path behind
// a legitimate-looking "featureless crop" reading.
TempDump tmp("quality_sentinel");
Config cfg;
cfg.dump_embeddings_path = tmp.path.string();
cfg.movie_path = "synthetic";
std::atomic<bool> done{false};
{
EmbeddingDumpFunc dump(cfg, done);
dump(frame_with(0.0, {{-1.f, -1.f}}));
dump(eof_frame());
}
H5::H5File f(tmp.path.string(), H5F_ACC_RDONLY);
CHECK(read_face_col(f, "sharpness")[0] < 0.f);
CHECK(read_face_col(f, "alignment_residual")[0] < 0.f);
}

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