Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13437e0d8b | ||
|
|
b26c66dcce | ||
|
|
7556c836da | ||
|
|
ef99951360 | ||
|
|
0feafec7c9 | ||
|
|
edf19ab798 | ||
|
|
e5204a831a | ||
|
|
0e35dac951 | ||
|
|
8dd2255125 | ||
|
|
0e97e532a8 | ||
|
|
1477c53885 | ||
|
|
add7e22053 | ||
|
|
ea922356f1 |
@@ -118,3 +118,6 @@ venv/
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.venv-rocm/
|
||||
!models/scene_boundary_xgb.json
|
||||
experiments/dump_review/
|
||||
|
||||
@@ -280,6 +280,24 @@ FetchContent_Declare(
|
||||
)
|
||||
FetchContent_MakeAvailable(nanobind)
|
||||
|
||||
# XGBoost (learned scene-boundary detector for flood-fill presence). Fetched and
|
||||
# built from source so we get both the C API header and a matching libxgboost,
|
||||
# reproducibly — the pip wheel ships the .so but no header. Heavy first build, so
|
||||
# it is opt-in; the scene-boundary node is compiled only when SAE_SCENE_XGB is on.
|
||||
option(SAE_SCENE_XGB "Build the XGBoost scene-boundary detector node" ON)
|
||||
if(SAE_SCENE_XGB)
|
||||
set(BUILD_STATIC_LIB ON CACHE BOOL "" FORCE) # link xgboost statically
|
||||
set(USE_OPENMP ON CACHE BOOL "" FORCE)
|
||||
FetchContent_Declare(
|
||||
xgboost
|
||||
GIT_REPOSITORY https://github.com/dmlc/xgboost.git
|
||||
GIT_TAG v2.1.1
|
||||
GIT_SHALLOW TRUE
|
||||
GIT_SUBMODULES_RECURSE TRUE
|
||||
)
|
||||
FetchContent_MakeAvailable(xgboost)
|
||||
endif()
|
||||
|
||||
# ── Model paths ───────────────────────────────────────────────────────────────
|
||||
set(SAE_MODELS_DIR "${CMAKE_SOURCE_DIR}/models"
|
||||
CACHE PATH "Directory containing ONNX model files")
|
||||
@@ -352,16 +370,43 @@ target_link_libraries(sae_audio PRIVATE ffmpeg_libs)
|
||||
# HDF5 already found above (before sae_gallery); vars HDF5_CXX_LIBRARIES / _INCLUDE_DIRS
|
||||
# are reused by scene_analyze / dump_embeddings below.
|
||||
|
||||
# The learned scene-boundary detector is compiled into the sink (result_sink →
|
||||
# xgb_scene_boundary + audio_logpsd) when SAE_SCENE_XGB is on, so the analysis
|
||||
# binaries need xgboost + FFTW + ffmpeg and the define. Found once here.
|
||||
if(SAE_SCENE_XGB)
|
||||
find_library(FFTW3_LIB fftw3 REQUIRED)
|
||||
set(SAE_SCENE_LIBS xgboost ${FFTW3_LIB} ffmpeg_libs)
|
||||
set(SAE_SCENE_DEFS SAE_SCENE_XGB)
|
||||
else()
|
||||
set(SAE_SCENE_LIBS "")
|
||||
set(SAE_SCENE_DEFS "")
|
||||
endif()
|
||||
|
||||
# ── analyze — main analysis binary ───────────────────────────────────────────
|
||||
add_executable(scene_analyze src/main.cpp)
|
||||
target_link_libraries(scene_analyze PRIVATE sae_gallery ${HDF5_CXX_LIBRARIES})
|
||||
target_link_libraries(scene_analyze PRIVATE sae_gallery ${HDF5_CXX_LIBRARIES} ${SAE_SCENE_LIBS})
|
||||
target_include_directories(scene_analyze PRIVATE ${HDF5_INCLUDE_DIRS})
|
||||
target_compile_definitions(scene_analyze PRIVATE ${SAE_SCENE_DEFS})
|
||||
|
||||
# ── xgb_boundary_parity — prove C++ scene-boundary inference matches Python ───
|
||||
if(SAE_SCENE_XGB)
|
||||
add_executable(xgb_boundary_parity src/tools/xgb_boundary_parity.cpp)
|
||||
target_include_directories(xgb_boundary_parity PRIVATE src ${HDF5_INCLUDE_DIRS})
|
||||
target_link_libraries(xgb_boundary_parity PRIVATE
|
||||
xgboost ${HDF5_CXX_LIBRARIES} ${FFTW3_LIB} ffmpeg_libs)
|
||||
|
||||
# Dumps the C++ feature matrix so training uses the exact inference features.
|
||||
add_executable(scene_features_dump src/tools/scene_features_dump.cpp)
|
||||
target_include_directories(scene_features_dump PRIVATE src ${HDF5_INCLUDE_DIRS})
|
||||
target_link_libraries(scene_features_dump PRIVATE
|
||||
xgboost ${HDF5_CXX_LIBRARIES} ${FFTW3_LIB} ffmpeg_libs)
|
||||
endif()
|
||||
|
||||
# ── analyze_debug — same binary with debug frame/crop output ─────────────────
|
||||
add_executable(scene_analyze_debug src/main.cpp)
|
||||
target_link_libraries(scene_analyze_debug PRIVATE sae_gallery ${HDF5_CXX_LIBRARIES})
|
||||
target_link_libraries(scene_analyze_debug PRIVATE sae_gallery ${HDF5_CXX_LIBRARIES} ${SAE_SCENE_LIBS})
|
||||
target_include_directories(scene_analyze_debug PRIVATE ${HDF5_INCLUDE_DIRS})
|
||||
target_compile_definitions(scene_analyze_debug PRIVATE SAE_DEBUG=1)
|
||||
target_compile_definitions(scene_analyze_debug PRIVATE SAE_DEBUG=1 ${SAE_SCENE_DEFS})
|
||||
|
||||
# ── dump_embeddings — standalone embedding dumper, NO gallery/matcher ─────────
|
||||
# Front-half only (decode→detect→align→embed→HDF5) for the optimizer replay corpus
|
||||
|
||||
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 447 KiB |
@@ -1,10 +1,12 @@
|
||||
> **Archived (July 2026).** This report covers the pre-opencv5 framework and the 4-model ArcFace/LVFace bake-off. It is superseded by the current [experiment log](model-bakeoff.md) for the opencv5 build. Kept for provenance; the numbers here are historical.
|
||||
|
||||
# Which embedding model is best?
|
||||
|
||||
Three ArcFace variants (w600k-R50, R18, w600k-MBF) and LVFace-B (Glint360K,
|
||||
455MB) were compared. r50 is excluded from the training/held-out comparison
|
||||
below; its gallery has roughly 30% fewer reference images per actor than the
|
||||
other three on the identical source photos, which confounds a direct score
|
||||
comparison (see [the full experiment log](model-bakeoff.md) for detail). It
|
||||
comparison (see [the full experiment log](model-bakeoff-2026-07.md) for detail). It
|
||||
remains in the calibration comparison, which does not depend on the gallery
|
||||
image count.
|
||||
|
||||
@@ -63,7 +65,7 @@ than general performance. On training data, the ordering is not as clean:
|
||||
|
||||
mbf beats LVFace on Lord of War (77.2% vs 75.6%), the only film in either
|
||||
table where LVFace does not score highest. LVFace's training-set macro
|
||||
average (75.3%, see [the full experiment log](model-bakeoff.md)) is not a
|
||||
average (75.3%, see [the full experiment log](model-bakeoff-2026-07.md)) is not a
|
||||
uniform win across every film it contributes to; the held-out result, where
|
||||
LVFace wins all 5 films outright, is the stronger claim.
|
||||
|
||||
@@ -79,7 +81,7 @@ not.
|
||||

|
||||
|
||||
Best full-gallery combo per model (all three are `full_exp`), from the
|
||||
training matrix in [the full experiment log](model-bakeoff.md):
|
||||
training matrix in [the full experiment log](model-bakeoff-2026-07.md):
|
||||
|
||||
| model | F1 | P | R | misID |
|
||||
|---|---|---|---|---|
|
||||
@@ -1,3 +1,5 @@
|
||||
> **Archived (July 2026).** This report covers the pre-opencv5 framework and the 4-model ArcFace/LVFace bake-off. It is superseded by the current [experiment log](model-bakeoff.md) for the opencv5 build. Kept for provenance; the numbers here are historical.
|
||||
|
||||
# Whole gallery vs. cast-restricted gallery
|
||||
|
||||
Two ways to run the matcher. Full mode scores every detected face against
|
||||
@@ -8,7 +10,7 @@ top-billed actors) before the matcher runs.
|
||||
## Result
|
||||
|
||||
Averaged across the 3 compared models (r50 excluded, see
|
||||
[the full experiment log](model-bakeoff.md)) and both expansion settings, on
|
||||
[the full experiment log](model-bakeoff-2026-07.md)) and both expansion settings, on
|
||||
the 4 training films:
|
||||
|
||||
| scope | F1 | P | R | total misID |
|
||||
@@ -27,7 +29,7 @@ restricted gallery:
|
||||
|
||||

|
||||
|
||||
See [the full experiment log](model-bakeoff.md) for the complete table. One
|
||||
See [the full experiment log](model-bakeoff-2026-07.md) for the complete table. One
|
||||
combo reaches zero true out-of-cast misidentifications,
|
||||
`arcface_w600k_mbf_restricted_exp` (F1 76.2%), and it is a restricted one,
|
||||
consistent with restriction, not expansion, being what suppresses cross-film
|
||||
@@ -57,7 +59,7 @@ Building this as a real feature requires:
|
||||
option.
|
||||
- A decision on the fallback case: what happens to a real, uncredited
|
||||
cameo (see the Germar Terrell Gardner and Talia Balsam cases in the
|
||||
[LVFace deep dive](lvface-deep-dive.md#where-lvface-beat-x-ray)) if the
|
||||
[LVFace deep dive](lvface-deep-dive-2026-07.md#where-lvface-beat-x-ray)) if the
|
||||
restricted gallery never includes them at all.
|
||||
- Regenerating the restricted-gallery cache whenever a title's Jellyfin
|
||||
cast list changes.
|
||||
@@ -17,62 +17,70 @@ two credited cast members without a visible face are correctly reported
|
||||
present but not visible. This matches Amazon X-Ray's own record for this
|
||||
second exactly.
|
||||
|
||||
Results are not uniform across films. The hardest held-out film scores 46%
|
||||
F1. This report documents why: one tunable trade (extinction bridging at
|
||||
hard cuts), one structural limit (X-Ray credits people whose faces never
|
||||
appear on screen), and a small number of cases where the pipeline is
|
||||
correct and X-Ray's ground truth is not. Read
|
||||
[how we score against X-Ray](methodology.md) first. X-Ray's ground truth is
|
||||
scene-level; the pipeline's output is per-second. That difference shapes
|
||||
every finding below.
|
||||
## The headline: learned scene boundaries
|
||||
|
||||
## Findings
|
||||
The current opencv5 build's biggest gain is **flood-fill presence on a
|
||||
learned scene-boundary detector**. An actor seen once inside a shot is
|
||||
reported for the whole shot — but only if the shot boundaries are good. A
|
||||
learned XGBoost boundary detector, scored **leave-one-out** so no film is
|
||||
ever measured by a detector that trained on it, lifts per-second X-Ray
|
||||
presence F1 across nine films and improves every one of them:
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
| boundary source for flood-fill | presence F1 |
|
||||
| ------------------------------ | ----------: |
|
||||
| track-extent (flood off) | 62.6% |
|
||||
| flood + grayscale cuts | 64.0% |
|
||||
| **flood + learned detector (LOO)** | **74.9%** |
|
||||
|
||||
- :material-trophy:{ .lg .middle } **[Which model is best?](best-model.md)**
|
||||

|
||||
|
||||
---
|
||||
The full story — why the old grayscale cut detector broke Scarface, what
|
||||
features work, and the per-film breakdown — is on the
|
||||
[learned scene-boundary detector](scene-boundary-detector.md) page.
|
||||
|
||||
Calibration curves first, independent of any threshold, then held-out
|
||||
F1 across three models. LVFace-B Glint360K wins both, and wins on every
|
||||
held-out film.
|
||||
## What the numbers mean, and their limits
|
||||
|
||||
- :material-filter:{ .lg .middle } **[Whole vs. cast-restricted gallery](gallery-scope.md)**
|
||||
Results are not uniform across films, and they should not be. X-Ray's ground
|
||||
truth is scene-level and credits people whose faces never appear on screen;
|
||||
the pipeline's output is per-second and can only name a face it can see.
|
||||
That difference is a structural recall ceiling, not a bug. Read
|
||||
[how we score against X-Ray](methodology.md) first — it defines F1,
|
||||
precision, recall, and misID, and explains the two limits (off-screen cast
|
||||
and gallery coverage) that shape every finding.
|
||||
|
||||
---
|
||||
|
||||
Restricting the matcher to a film's credited cast improves F1,
|
||||
recall, and misID rate at once, but is not a shipped runtime feature
|
||||
yet.
|
||||
|
||||
- :material-account-convert:{ .lg .middle } **[Does pose expansion help?](pose-expansion.md)**
|
||||
|
||||
---
|
||||
|
||||
A training-set effect that did not reproduce on 5 held-out films once
|
||||
two methodology bugs in the comparison harness were found and fixed.
|
||||
|
||||
- :material-magnify-expand:{ .lg .middle } **[Deep dive: LVFace-B Glint360K](lvface-deep-dive.md)**
|
||||
|
||||
---
|
||||
|
||||
The held-out generalization gap, the two mechanisms behind its errors,
|
||||
and every distinct case where it names someone outside the film's
|
||||
credited cast.
|
||||
|
||||
</div>
|
||||
Precision on identified faces is near-perfect: where the pipeline names a
|
||||
face, it is almost always a name X-Ray also credits to that scene. The
|
||||
frames throughout this documentation make the tension visual — **green** =
|
||||
true positive, **red** = false positive, **orange** = unknown, and a
|
||||
**blue** panel lists credited cast present with no visible face.
|
||||
|
||||
## Full experiment log
|
||||
|
||||
- **[Full experiment log](model-bakeoff.md)**: the complete log behind the
|
||||
four pages above, including how replaying against cached embeddings
|
||||
inside the same KPN network makes a full model and configuration
|
||||
comparison practical, the full results table, and every caveat. This is
|
||||
where the shipped [`src/config.hpp`](https://REPOLINK/src/config.hpp)
|
||||
defaults come from.
|
||||
- **[Service conversion (proposal)](service-conversion.md)**: design
|
||||
sketch for a native idle-GPU worker gated on screen lock, not yet built.
|
||||
- **[Full experiment log (opencv5)](model-bakeoff.md)**: the complete log
|
||||
behind the current build — the ten-knob differential-evolution tuning, the
|
||||
shipped [`src/config.hpp`](https://REPOLINK/src/config.hpp) defaults and
|
||||
where each comes from, the replay architecture that makes a nine-film
|
||||
search tractable, and the flood-fill step change.
|
||||
- **[Learned scene-boundary detector](scene-boundary-detector.md)**: the
|
||||
features, the model, leave-one-out results, and the two headline films.
|
||||
- **[Benchmark — SuperHero](benchmark.md)**: the benchmark harness.
|
||||
- **[Service conversion (proposal)](service-conversion.md)**: design sketch
|
||||
for a native idle-GPU worker gated on screen lock, not yet built.
|
||||
|
||||
## Archive (July 2026)
|
||||
|
||||
The pre-opencv5 four-model ArcFace/LVFace bake-off is kept for provenance.
|
||||
Its numbers are historical; the current build supersedes them.
|
||||
|
||||
- [Best model (July)](best-model-2026-07.md) — LVFace-B Glint360K wins on
|
||||
calibration and on every held-out film.
|
||||
- [Gallery scope (July)](gallery-scope-2026-07.md) — cast-restricted
|
||||
gallery improves F1, recall, and misID at once.
|
||||
- [Pose expansion (July)](pose-expansion-2026-07.md) — a training-set
|
||||
effect that did not reproduce held-out.
|
||||
- [LVFace deep dive (July)](lvface-deep-dive-2026-07.md) — the
|
||||
generalization gap and every out-of-cast identification.
|
||||
- [Full experiment log (July)](model-bakeoff-2026-07.md).
|
||||
|
||||
## Reproducing the benchmarks
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
> **Archived (July 2026).** This report covers the pre-opencv5 framework and the 4-model ArcFace/LVFace bake-off. It is superseded by the current [experiment log](model-bakeoff.md) for the opencv5 build. Kept for provenance; the numbers here are historical.
|
||||
|
||||
# Deep dive: LVFace-B Glint360K
|
||||
|
||||
LVFace won the model comparison (see [Which model is best?](best-model.md))
|
||||
LVFace won the model comparison (see [Which model is best?](best-model-2026-07.md))
|
||||
and is the shipped default embedder. This page reports how it performs in
|
||||
detail: a baseline of correct output, the two mechanisms behind its errors,
|
||||
and every distinct case where it names someone who is not in the film's
|
||||
credited cast.
|
||||
|
||||
Read [How we score against X-Ray](methodology.md) first. X-Ray's ground truth
|
||||
Read [How we score against X-Ray](methodology-2026-07.md) first. X-Ray's ground truth
|
||||
is scene-level, not per-frame. A name marked correct in the Offscreen column
|
||||
below is the pipeline correctly reporting scene membership, not a workaround.
|
||||
|
||||
@@ -61,7 +63,7 @@ on the 5 films the optimizer never saw:
|
||||
| macro average | 67.4% | 85.8% | 57.0% | | | | |
|
||||
|
||||
The `P` column is misID-weighted (each out-of-film name counts 10x in the
|
||||
denominator; see [methodology](methodology.md#precision-recall-and-the-misid-weighting)).
|
||||
denominator; see [methodology](methodology-2026-07.md#precision-recall-and-the-misid-weighting)).
|
||||
That weighting is why Many Saints reads 54.7% here despite naming mostly real,
|
||||
present faces: its raw (unweighted) precision is **78.4%**, and the gap is
|
||||
entirely its 974 misIDs paying the 10x penalty. The three zero-misID films
|
||||
@@ -70,7 +72,7 @@ Lovelace, with 58 misIDs, sits 3pp below its raw 93.3%.
|
||||
|
||||
Held-out F1 is 67.4%, against 75.3% on training, an 8pp drop. The spread
|
||||
between the best and worst held-out film is 37pp. This is not unique to
|
||||
LVFace: [the full experiment log](model-bakeoff.md#held-out-validation-all-3-models)
|
||||
LVFace: [the full experiment log](model-bakeoff-2026-07.md#held-out-validation-all-3-models)
|
||||
shows mbf and r18 with the same shape of spread on the same films, at a
|
||||
uniformly lower level. Two mechanisms explain the spread. Both are shown
|
||||
below with frame-level evidence.
|
||||
@@ -174,10 +176,10 @@ ground-truth gap, not a model error.
|
||||
Archie Yates, t=2521s, 78% confidence. A real detected face, a genuine
|
||||
lookalike confusion.
|
||||
|
||||

|
||||
|
||||
Zooey Deschanel, t=2819s, 99% confidence. A real detected face at a dinner
|
||||
table, high-confidence lookalike confusion.
|
||||
Zooey Deschanel, t=2819s, 99% confidence — a high-confidence lookalike
|
||||
confusion in the July pipeline. **The current opencv5 pipeline no longer makes
|
||||
this identification**; the tighter tracker/registry and re-tuned matching removed
|
||||
it, so there is no annotated frame for it here.
|
||||
|
||||

|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
> **Archived (July 2026).** This report covers the pre-opencv5 framework and the 4-model ArcFace/LVFace bake-off. It is superseded by the current [experiment log](model-bakeoff.md) for the opencv5 build. Kept for provenance; the numbers here are historical.
|
||||
|
||||
# How we score against X-Ray
|
||||
|
||||
Every number in this report, every F1 and misID count, comes from one
|
||||
comparison. The comparison has a mismatch at its core that shapes nearly
|
||||
every finding in this report: the ground truth is scene-level, the
|
||||
pipeline's output is per-second, and the two do not mean the same thing.
|
||||
This page documents that comparison once, so the findings pages can rely on
|
||||
it without re-explaining it.
|
||||
|
||||
## What Amazon X-Ray records
|
||||
|
||||
X-Ray ships three tables per film: `scenes.csv` (a list of `[start, end]`
|
||||
timespans), `people_in_scenes.csv` (which actors are credited in each
|
||||
scene), and `people.csv` (actor identities). There is no per-frame or
|
||||
per-second annotation anywhere in X-Ray. A scene might run 45 seconds, and
|
||||
X-Ray records one cast list for the entire span, not "on screen from
|
||||
second 12 to second 30."
|
||||
|
||||
To compare this against per-second predictions, `second_score.py` expands
|
||||
every scene into per-second ground truth by copying the whole scene's cast
|
||||
list onto every second inside it:
|
||||
|
||||
```python
|
||||
for sn, (t0, t1) in spans.items():
|
||||
cast = scene_cast.get(sn, [])
|
||||
for t in range(int(t0), int(t1)):
|
||||
timeline[t] = cast
|
||||
```
|
||||
|
||||
That is the entire mechanism. If X-Ray credits five actors to a 30-second
|
||||
scene, all five count as ground truth present for all 30 seconds, including
|
||||
seconds where only one of them is on screen. This is not a simplification
|
||||
introduced by the pipeline; it is the only reading of X-Ray's data that is
|
||||
possible, because X-Ray itself does not record anything finer-grained.
|
||||
|
||||
## Why an offscreen name can be scored correct
|
||||
|
||||
A name listed under Offscreen with a correct (green) label is not the
|
||||
pipeline guessing or padding its score. It is the pipeline correctly
|
||||
answering the question X-Ray actually asks: is this actor part of this
|
||||
scene. It answers that question using a presence window (`[start, end]`,
|
||||
held open across cuts by `anneal_sec` and `extinction_sec`), which matches
|
||||
X-Ray's scene-level semantics more closely than a raw per-frame detection
|
||||
would.
|
||||
|
||||
A system that only reported "this actor is visible in this exact frame"
|
||||
would score worse against X-Ray's scene-level ground truth, producing a
|
||||
false negative every time the camera cuts away from a character who is
|
||||
still present in the scene. Not because it is wrong about the world, but
|
||||
because it would be answering a stricter, different question than the one
|
||||
X-Ray's data supports. The presence-window design exists specifically to
|
||||
answer X-Ray's actual question.
|
||||
|
||||
## What this resolves and what it does not
|
||||
|
||||
This resolves the semantic mismatch between a scene and an instant. It does
|
||||
not resolve two other limitations, both discussed in the
|
||||
[LVFace deep dive](lvface-deep-dive-2026-07.md).
|
||||
|
||||
**The face-vs-presence ceiling.** X-Ray credits scene membership regardless
|
||||
of whether a face is ever visible: background crew, characters shot from
|
||||
behind, voice-only presence. No amount of bridging recovers a face that
|
||||
never appears on screen. This is a hard ceiling on recall, not a defect.
|
||||
|
||||
**Extinction bridging can overshoot.** The same presence-window mechanism
|
||||
that correctly answers "still in this scene" during a normal cut can also
|
||||
bridge across a scene boundary it has no way to detect. A hard cut into a
|
||||
different scene with no faces, such as closing credits, carries the
|
||||
previous scene's identities forward until the window expires. This is the
|
||||
mechanism behind Downton Abbey's recall collapse, documented in the deep
|
||||
dive.
|
||||
|
||||
## Precision, recall, and the misID weighting
|
||||
|
||||
Per sampled second `t`:
|
||||
|
||||
**TPI** (true positive instances): actors both X-Ray and the pipeline agree
|
||||
are present.
|
||||
|
||||
**FPI** (false positive instances): actors the pipeline reports that are
|
||||
not in X-Ray's cast for this second. Split into two categories:
|
||||
|
||||
- **FPI_incast**: the actor is in the film's cast, just not credited to
|
||||
this particular scene. A timing or boundary slip.
|
||||
- **FPI_misid**: the actor is not in the film's cast at all. A genuine
|
||||
wrong-identity error, weighted 10x in the precision objective, because
|
||||
naming someone who is not even in the film is a categorically worse
|
||||
error than a few seconds of scene-boundary slop.
|
||||
|
||||
!!! note "Every headline `P` and `F1` is misID-weighted"
|
||||
|
||||
The precision reported throughout this report, and therefore the F1
|
||||
derived from it, puts each `FPI_misid` into the denominator **10 times**
|
||||
(`precision = TPI / (TPI + FPI_incast + 10·FPI_misid)`,
|
||||
[`second_score.py`](https://REPOLINK/scripts/optimizer/second_score.py)).
|
||||
This is deliberate: the whole point is to punish naming an out-of-film
|
||||
actor far harder than a scene-boundary slip. But it means the `P` column
|
||||
is not raw precision, and a misID-heavy film's `P` is depressed
|
||||
super-linearly. `second_score.py` also emits an unweighted `precision_raw`
|
||||
(always ≥ the weighted `P`); where the gap matters, The Many Saints of
|
||||
Newark, weighted `P` 54.7% vs. raw 78.4%, the [LVFace deep dive](lvface-deep-dive-2026-07.md)
|
||||
reports both. When comparing `P` across films, remember you are comparing a
|
||||
quantity that penalizes misIDs, not just a hit rate.
|
||||
|
||||
**FN** (false negatives): actors X-Ray lists that the pipeline never
|
||||
reports, counted only for actors who have a gallery reference embedding.
|
||||
Across the 9-film benchmark, coverage of X-Ray's credited cast ranges from
|
||||
20% to 79% by film (see
|
||||
[the full experiment log](model-bakeoff-2026-07.md#gallery-coverage-per-film)); an
|
||||
actor with no reference photo can never be recognized regardless of model
|
||||
quality, and counting them as a miss would penalize gallery coverage, not
|
||||
recognition accuracy.
|
||||
|
||||
Two further numbers are reported alongside F1:
|
||||
|
||||
**agreement_rate**: mean per-second Jaccard overlap
|
||||
(`|Pred ∩ GT| / |Pred ∪ GT|`), partial credit. Naming 2 of 3 present actors
|
||||
scores 2/3, not 0.
|
||||
|
||||
**exact_match_rate**: the fraction of sampled seconds where the pipeline's
|
||||
named set exactly equals X-Ray's, no partial credit. Far harsher, and
|
||||
dominated by recall, since any single missed actor zeroes that second.
|
||||
|
||||
## Reproduce
|
||||
|
||||
```bash
|
||||
python3 scripts/optimizer/second_score.py \
|
||||
--pred pred.json --xray experiments/xray/.../<xray_dir> \
|
||||
--gallery experiments/galleries/gallery_LVFace-B_Glint360K.h5
|
||||
```
|
||||
|
||||
See also [the full experiment log](model-bakeoff-2026-07.md) for how `pred.json` is
|
||||
produced, and the [LVFace deep dive](lvface-deep-dive-2026-07.md) for what these
|
||||
mechanisms look like frame by frame.
|
||||
@@ -1,11 +1,9 @@
|
||||
# How we score against X-Ray
|
||||
|
||||
Every number in this report, every F1 and misID count, comes from one
|
||||
comparison. The comparison has a mismatch at its core that shapes nearly
|
||||
every finding in this report: the ground truth is scene-level, the
|
||||
pipeline's output is per-second, and the two do not mean the same thing.
|
||||
This page documents that comparison once, so the findings pages can rely on
|
||||
it without re-explaining it.
|
||||
Every number in this report comes from one comparison, and that comparison
|
||||
has a mismatch at its core: the ground truth is scene-level, the pipeline's
|
||||
output is per-second, and the two do not mean the same thing. This page
|
||||
documents the comparison once so the findings can rely on it.
|
||||
|
||||
## What Amazon X-Ray records
|
||||
|
||||
@@ -13,12 +11,12 @@ X-Ray ships three tables per film: `scenes.csv` (a list of `[start, end]`
|
||||
timespans), `people_in_scenes.csv` (which actors are credited in each
|
||||
scene), and `people.csv` (actor identities). There is no per-frame or
|
||||
per-second annotation anywhere in X-Ray. A scene might run 45 seconds, and
|
||||
X-Ray records one cast list for the entire span, not "on screen from
|
||||
second 12 to second 30."
|
||||
X-Ray records one cast list for the entire span, not "on screen from second
|
||||
12 to second 30."
|
||||
|
||||
To compare this against per-second predictions, `second_score.py` expands
|
||||
every scene into per-second ground truth by copying the whole scene's cast
|
||||
list onto every second inside it:
|
||||
To compare against per-second predictions, `second_score.py` expands every
|
||||
scene into per-second ground truth by copying the whole scene's cast list
|
||||
onto every second inside it:
|
||||
|
||||
```python
|
||||
for sn, (t0, t1) in spans.items():
|
||||
@@ -27,48 +25,46 @@ for sn, (t0, t1) in spans.items():
|
||||
timeline[t] = cast
|
||||
```
|
||||
|
||||
That is the entire mechanism. If X-Ray credits five actors to a 30-second
|
||||
scene, all five count as ground truth present for all 30 seconds, including
|
||||
seconds where only one of them is on screen. This is not a simplification
|
||||
introduced by the pipeline; it is the only reading of X-Ray's data that is
|
||||
possible, because X-Ray itself does not record anything finer-grained.
|
||||
If X-Ray credits five actors to a 30-second scene, all five count as ground
|
||||
truth present for all 30 seconds, including seconds where only one is on
|
||||
screen. This is not a simplification the pipeline introduces; it is the only
|
||||
reading X-Ray's data supports, because X-Ray records nothing finer.
|
||||
|
||||
## Why an offscreen name can be scored correct
|
||||
## How the pipeline reports presence
|
||||
|
||||
A name listed under Offscreen with a correct (green) label is not the
|
||||
pipeline guessing or padding its score. It is the pipeline correctly
|
||||
answering the question X-Ray actually asks: is this actor part of this
|
||||
scene. It answers that question using a presence window (`[start, end]`,
|
||||
held open across cuts by `anneal_sec` and `extinction_sec`), which matches
|
||||
X-Ray's scene-level semantics more closely than a raw per-frame detection
|
||||
would.
|
||||
A presence claim is one actor owning one time window. How that window is
|
||||
derived is a tunable choice — a knob the optimizer weighs — with two modes:
|
||||
|
||||
A system that only reported "this actor is visible in this exact frame"
|
||||
would score worse against X-Ray's scene-level ground truth, producing a
|
||||
false negative every time the camera cuts away from a character who is
|
||||
still present in the scene. Not because it is wrong about the world, but
|
||||
because it would be answering a stricter, different question than the one
|
||||
X-Ray's data supports. The presence-window design exists specifically to
|
||||
answer X-Ray's actual question.
|
||||
- **`track_extent` (default).** A claim is exactly `[first_seen, last_seen]`
|
||||
of a track the actor owned (AR-012), ending at the last sighting and never
|
||||
after (AR-013). There is no keep-alive: the withdrawn `anneal_sec` and the
|
||||
scene-tracker `extinction_sec` — which the July report's windows were held
|
||||
open by — are **gone**. A track that survives its own gaps needs no bridge;
|
||||
a gap after the final sighting is never claimed.
|
||||
- **`flood`.** Each claim is snapped to the shot it sits in, so an actor seen
|
||||
once anywhere in a shot is reported for the whole shot
|
||||
`[prev_boundary, next_boundary]`. Boundaries come from TransNetV2 shot
|
||||
detection when available, otherwise from the always-on histogram cut
|
||||
detector (`is_cut`). This trades precision for recall against X-Ray's
|
||||
scene-level granularity, and the optimizer decides per run whether it pays.
|
||||
|
||||
## What this resolves and what it does not
|
||||
Do not confuse the surviving `track_extinction_sec` with the withdrawn
|
||||
scene `extinction_sec`: the former bounds how long a lost track stays
|
||||
available for **re-association** (a tracking question), and never extends a
|
||||
presence claim.
|
||||
|
||||
This resolves the semantic mismatch between a scene and an instant. It does
|
||||
not resolve two other limitations, both discussed in the
|
||||
[LVFace deep dive](lvface-deep-dive.md).
|
||||
## The two limits this does not resolve
|
||||
|
||||
**The face-vs-presence ceiling.** X-Ray credits scene membership regardless
|
||||
of whether a face is ever visible: background crew, characters shot from
|
||||
behind, voice-only presence. No amount of bridging recovers a face that
|
||||
never appears on screen. This is a hard ceiling on recall, not a defect.
|
||||
behind, voice-only presence. No face pipeline can recover a face that never
|
||||
appears, so recall against X-Ray is a structural ceiling, not a defect.
|
||||
|
||||
**Extinction bridging can overshoot.** The same presence-window mechanism
|
||||
that correctly answers "still in this scene" during a normal cut can also
|
||||
bridge across a scene boundary it has no way to detect. A hard cut into a
|
||||
different scene with no faces, such as closing credits, carries the
|
||||
previous scene's identities forward until the window expires. This is the
|
||||
mechanism behind Downton Abbey's recall collapse, documented in the deep
|
||||
dive.
|
||||
**Flood-fill can overshoot.** Snapping to a shot correctly answers "still in
|
||||
this scene" through an intra-scene cut, but a shot boundary is not a scene
|
||||
boundary: on a film with sparse cuts, flood-fill can carry an actor across a
|
||||
long "shot" they only briefly appeared in. This is why flood-fill is a knob,
|
||||
not a default — its value depends on the film's cut density.
|
||||
|
||||
## Precision, recall, and the misID weighting
|
||||
|
||||
@@ -77,49 +73,46 @@ Per sampled second `t`:
|
||||
**TPI** (true positive instances): actors both X-Ray and the pipeline agree
|
||||
are present.
|
||||
|
||||
**FPI** (false positive instances): actors the pipeline reports that are
|
||||
not in X-Ray's cast for this second. Split into two categories:
|
||||
**FPI** (false positive instances): actors the pipeline reports that are not
|
||||
in X-Ray's cast for this second, split into:
|
||||
|
||||
- **FPI_incast**: the actor is in the film's cast, just not credited to
|
||||
this particular scene. A timing or boundary slip.
|
||||
- **FPI_incast**: the actor is in the film's cast, just not credited to this
|
||||
scene. A timing or boundary slip.
|
||||
- **FPI_misid**: the actor is not in the film's cast at all. A genuine
|
||||
wrong-identity error, weighted 10x in the precision objective, because
|
||||
naming someone who is not even in the film is a categorically worse
|
||||
error than a few seconds of scene-boundary slop.
|
||||
wrong-identity error, weighted **10×** in the precision objective, because
|
||||
naming someone not even in the film is categorically worse than a few
|
||||
seconds of scene-boundary slop.
|
||||
|
||||
!!! note "Every headline `P` and `F1` is misID-weighted"
|
||||
|
||||
The precision reported throughout this report, and therefore the F1
|
||||
derived from it, puts each `FPI_misid` into the denominator **10 times**
|
||||
Precision puts each `FPI_misid` into the denominator 10 times
|
||||
(`precision = TPI / (TPI + FPI_incast + 10·FPI_misid)`,
|
||||
[`second_score.py`](https://REPOLINK/scripts/optimizer/second_score.py)).
|
||||
This is deliberate: the whole point is to punish naming an out-of-film
|
||||
actor far harder than a scene-boundary slip. But it means the `P` column
|
||||
is not raw precision, and a misID-heavy film's `P` is depressed
|
||||
super-linearly. `second_score.py` also emits an unweighted `precision_raw`
|
||||
(always ≥ the weighted `P`); where the gap matters, The Many Saints of
|
||||
Newark, weighted `P` 54.7% vs. raw 78.4%, the [LVFace deep dive](lvface-deep-dive.md)
|
||||
reports both. When comparing `P` across films, remember you are comparing a
|
||||
quantity that penalizes misIDs, not just a hit rate.
|
||||
This deliberately punishes naming an out-of-film actor far harder than a
|
||||
boundary slip, so the `P` column is not raw precision and a misID-heavy
|
||||
film's `P` is depressed super-linearly.
|
||||
|
||||
**FN** (false negatives): actors X-Ray lists that the pipeline never
|
||||
reports, counted only for actors who have a gallery reference embedding.
|
||||
Across the 9-film benchmark, coverage of X-Ray's credited cast ranges from
|
||||
20% to 79% by film (see
|
||||
[the full experiment log](model-bakeoff.md#gallery-coverage-per-film)); an
|
||||
actor with no reference photo can never be recognized regardless of model
|
||||
quality, and counting them as a miss would penalize gallery coverage, not
|
||||
recognition accuracy.
|
||||
**FN** (false negatives): actors X-Ray lists that the pipeline never reports,
|
||||
counted **only** for actors who have a gallery reference embedding. An actor
|
||||
with no reference photo can never be recognized, and counting them as a miss
|
||||
would measure gallery coverage, not recognition accuracy.
|
||||
|
||||
Two further numbers are reported alongside F1:
|
||||
Two further numbers accompany F1:
|
||||
|
||||
**agreement_rate**: mean per-second Jaccard overlap
|
||||
(`|Pred ∩ GT| / |Pred ∪ GT|`), partial credit. Naming 2 of 3 present actors
|
||||
scores 2/3, not 0.
|
||||
(`|Pred ∩ GT| / |Pred ∪ GT|`) — partial credit, so naming 2 of 3 present
|
||||
actors scores 2/3, not 0.
|
||||
|
||||
**exact_match_rate**: the fraction of sampled seconds where the pipeline's
|
||||
named set exactly equals X-Ray's, no partial credit. Far harsher, and
|
||||
dominated by recall, since any single missed actor zeroes that second.
|
||||
**exact_match_rate**: the fraction of seconds where the pipeline's named set
|
||||
exactly equals X-Ray's — no partial credit, dominated by recall.
|
||||
|
||||
## The benchmark set
|
||||
|
||||
Unlike the July report — which trained on a 3-film subset and validated on
|
||||
held-out films to keep evaluations fast — this run scores **all 9 films on
|
||||
every evaluation**. The registry one-clock fix and uncapped dumps made
|
||||
full-set replay affordable, so the reported optimum is tuned against the
|
||||
complete set rather than a training subset.
|
||||
|
||||
## Reproduce
|
||||
|
||||
@@ -129,6 +122,5 @@ python3 scripts/optimizer/second_score.py \
|
||||
--gallery experiments/galleries/gallery_LVFace-B_Glint360K.h5
|
||||
```
|
||||
|
||||
See also [the full experiment log](model-bakeoff.md) for how `pred.json` is
|
||||
produced, and the [LVFace deep dive](lvface-deep-dive.md) for what these
|
||||
mechanisms look like frame by frame.
|
||||
See the [full experiment log](model-bakeoff.md) for how `pred.json` is
|
||||
produced and where the shipped `src/config.hpp` defaults come from.
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
> **Archived (July 2026).** This report covers the pre-opencv5 framework and the 4-model ArcFace/LVFace bake-off. It is superseded by the current [experiment log](model-bakeoff.md) for the opencv5 build. Kept for provenance; the numbers here are historical.
|
||||
|
||||
# Full experiment log
|
||||
|
||||
This page reports how the pipeline performs across three questions: which
|
||||
embedding model is best, whether restricting the gallery to a film's
|
||||
credited cast helps, and whether promoting confidently identified poses into
|
||||
a per-film gallery annex helps. It also documents the replay architecture
|
||||
that made testing all three questions in one pass practical, and every
|
||||
caveat needed to trust the numbers.
|
||||
|
||||
Read [How we score against X-Ray](methodology-2026-07.md) first for what F1,
|
||||
precision, recall, and misID mean in this report. All numbers below use the
|
||||
per-second metric
|
||||
([`scripts/optimizer/second_score.py`](https://REPOLINK/scripts/optimizer/second_score.py)).
|
||||
|
||||
r50 (ArcFace w600k-R50) is excluded from the detailed comparison below. Its
|
||||
gallery was built with roughly 30% fewer reference images per actor than the
|
||||
other three models on the identical source photos (10808 vs 15055 total
|
||||
embeddings across the same 2418 actors), which confounds any direct
|
||||
comparison of its scores against the others. It remains in the
|
||||
[calibration curve comparison](best-model-2026-07.md#first-signal-calibration-curves),
|
||||
which does not depend on the training benchmark.
|
||||
|
||||
## Why replay makes this affordable
|
||||
|
||||
Decoding video and running face detection, alignment, and embedding is the
|
||||
expensive part of this pipeline. Everything downstream of that (tracking,
|
||||
identity matching, scene aggregation) is cheap. KPN++'s node/network
|
||||
structure means those two stages are separate components connected by
|
||||
typed channels, so the expensive stage can run once per film, cache its
|
||||
output, and the cheap stage can be re-run against that cache as many times
|
||||
as needed with different Config values.
|
||||
|
||||
`scene_analyze --dump-embeddings out.h5` runs the expensive half once per
|
||||
film and writes per-frame face detections and embeddings to HDF5
|
||||
([`scripts/optimizer/SCHEMA.md`](https://REPOLINK/scripts/optimizer/SCHEMA.md)).
|
||||
[`scripts/optimizer/replay.py`](https://REPOLINK/scripts/optimizer/replay.py)
|
||||
then re-assembles the real C++ `face_tracker`, `identity_matcher`, and
|
||||
`scene_tracker` nodes into a Python-driven KPN network and replays a
|
||||
film's cached embeddings through them, varying `prob_threshold`,
|
||||
`anneal_sec`, `extinction_sec`, and `expand_gallery` freely. No GPU
|
||||
inference and no video decode happen during a replay; each one completes
|
||||
in seconds. This is what makes a 512-evaluation differential-evolution
|
||||
search per model, per gallery mode, per expansion setting, tractable, and
|
||||
what made the full held-out validation across three models in this report
|
||||
possible in one session rather than requiring three full re-encodes of the
|
||||
benchmark set.
|
||||
|
||||
`optimize.py` runs `differential_evolution` over this replay function as its
|
||||
objective, with DE-level parallelism (multiple candidate configs evaluated
|
||||
concurrently, each spawning its own replay subprocesses) on top of it. The
|
||||
practical ceiling on this machine's GPU was 8 concurrent replay processes;
|
||||
9 silently degraded every score to 0.0% (well-formed output, wrong numbers,
|
||||
not a crash), so `optimize.py` was run at `REPLAY_WORKERS=4 DE_WORKERS=2`.
|
||||
|
||||
## Search space
|
||||
|
||||
`popsize=10, maxiter=15` per combo (3 parameters, up to 512 evaluations,
|
||||
usually stopping earlier on DE's convergence tolerance).
|
||||
`anneal_sec`/`extinction_sec` bounds were widened from 1-30/1-15 to 1-60/1-60
|
||||
partway through the sweep. r50's 4 combos finished before the widening and
|
||||
used the old, narrower bounds; this is one more reason r50 is excluded from
|
||||
direct comparison here.
|
||||
|
||||
## Training films and held-out films
|
||||
|
||||
9 films have dumped embeddings across all 4 models. 4 were used for
|
||||
optimization:
|
||||
|
||||
- Café Society (62-cast)
|
||||
- Lord of War (64-cast)
|
||||
- Scarface (67-cast)
|
||||
- Sound of Metal (14-cast)
|
||||
|
||||
5 were held out, never seen by any optimizer run:
|
||||
|
||||
- Benny & Joon
|
||||
- Downton Abbey: A New Era
|
||||
- Lovelace
|
||||
- The Many Saints of Newark
|
||||
- Valerian and the City of a Thousand Planets
|
||||
|
||||
## Gallery coverage per film
|
||||
|
||||
The gallery has reference embeddings for 2418 actors, but coverage of any
|
||||
given film's credited cast varies widely. This was previously reported as
|
||||
one flat number (67% of X-Ray cast lacking a reference embedding, averaged
|
||||
across the whole benchmark); the per-film breakdown is:
|
||||
|
||||
| film | cast credited | in gallery | coverage |
|
||||
|---|---|---|---|
|
||||
| Lord of War | 64 | 13 | 20.3% |
|
||||
| Scarface | 67 | 15 | 22.4% |
|
||||
| The Many Saints of Newark | 48 | 13 | 27.1% |
|
||||
| Café Society | 62 | 17 | 27.4% |
|
||||
| Lovelace | 42 | 15 | 35.7% |
|
||||
| Valerian and the City of a Thousand Planets | 36 | 13 | 36.1% |
|
||||
| Benny & Joon | 23 | 12 | 52.2% |
|
||||
| Downton Abbey: A New Era | 36 | 22 | 61.1% |
|
||||
| Sound of Metal | 14 | 11 | 78.6% |
|
||||
|
||||
Two training films (Lord of War, Scarface) have the worst coverage in the
|
||||
set, 20-22%. Their training-set F1 numbers below are partly capped by
|
||||
missing references, not purely by model quality. Downton Abbey has 61%
|
||||
coverage, the second-best in the benchmark, yet the worst held-out recall
|
||||
of any film (39.4%, LVFace). Its recall problem is not primarily a coverage
|
||||
problem; it is the extinction-bridging failure documented in the
|
||||
[LVFace deep dive](lvface-deep-dive-2026-07.md#mechanism-1-extinction-bridging).
|
||||
Reproduce with `scripts/docs/gallery_coverage_per_film.py`.
|
||||
|
||||
## Training results, 3 models × 2 gallery modes × 2 expansion settings
|
||||
|
||||
Ranked by F1. misid = FPI_misid, the count of true wrong-actor
|
||||
identifications (naming someone not in the film's cast at all), distinct
|
||||
from FPI, which also includes in-cast timing slips.
|
||||
|
||||
Each combo's row is its best **full-coverage** evaluation: the highest-F1 DE
|
||||
evaluation in which all 4 training films replayed without a timeout (see
|
||||
[Dropped-film scoring](#a-scoring-bug-worth-recording-dropped-film-evaluations)
|
||||
below for why this qualifier is load-bearing and not the same as `argmax F1`
|
||||
over the raw sweep).
|
||||
|
||||
| combo | F1 | P | R | TPI | FPI | misid | FN |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| LVFace-B_Glint360K_restricted_exp | 78.3% | 91.0% | 68.9% | 42830 | 3782 | 60 | 19492 |
|
||||
| LVFace-B_Glint360K_restricted_noexp | 76.7% | 91.5% | 66.2% | 41149 | 3400 | 59 | 21173 |
|
||||
| arcface_w600k_mbf_restricted_exp | 76.2% | 90.0% | 66.2% | 64328 | 7480 | 0 | 33234 |
|
||||
| arcface_r18_restricted_exp | 75.5% | 87.6% | 66.5% | 41399 | 5666 | 60 | 20923 |
|
||||
| LVFace-B_Glint360K_full_exp | 75.3% | 89.7% | 65.4% | 47757 | 3407 | 232 | 26966 |
|
||||
| arcface_w600k_mbf_restricted_noexp | 75.0% | 91.1% | 63.9% | 39752 | 3465 | 60 | 22570 |
|
||||
| arcface_r18_restricted_noexp | 73.5% | 91.3% | 61.7% | 38299 | 3220 | 60 | 24023 |
|
||||
| LVFace-B_Glint360K_full_noexp | 72.3% | 88.3% | 61.8% | 40363 | 3503 | 244 | 25850 |
|
||||
| arcface_w600k_mbf_full_exp | 72.0% | 87.7% | 61.4% | 39875 | 3729 | 240 | 26338 |
|
||||
| arcface_w600k_mbf_full_noexp | 71.0% | 93.2% | 57.9% | 41699 | 2472 | 56 | 33024 |
|
||||
| arcface_r18_full_exp | 69.1% | 87.6% | 57.7% | 37342 | 3119 | 242 | 28871 |
|
||||
| arcface_r18_full_noexp | 66.6% | 91.3% | 53.1% | 34314 | 2362 | 107 | 31899 |
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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).
|
||||
@@ -1,346 +1,198 @@
|
||||
# Full experiment log
|
||||
# Full experiment log (opencv5)
|
||||
|
||||
This page reports how the pipeline performs across three questions: which
|
||||
embedding model is best, whether restricting the gallery to a film's
|
||||
credited cast helps, and whether promoting confidently identified poses into
|
||||
a per-film gallery annex helps. It also documents the replay architecture
|
||||
that made testing all three questions in one pass practical, and every
|
||||
caveat needed to trust the numbers.
|
||||
This is the complete log behind the current opencv5 build: how the pipeline is
|
||||
tuned, what the shipped configuration is and where every number in it comes from,
|
||||
and how the learned scene-boundary detector took per-second actor-presence F1 from
|
||||
the low-60s to **74.9%** across the nine-film Amazon X-Ray benchmark — under honest
|
||||
leave-one-out.
|
||||
|
||||
Read [How we score against X-Ray](methodology.md) first for what F1,
|
||||
precision, recall, and misID mean in this report. All numbers below use the
|
||||
per-second metric
|
||||
([`scripts/optimizer/second_score.py`](https://REPOLINK/scripts/optimizer/second_score.py)).
|
||||
Read [How we score against X-Ray](methodology.md) first for what F1, precision,
|
||||
recall, and misID mean here. Every number below uses the per-second metric
|
||||
([`scripts/optimizer/second_score.py`](https://REPOLINK/scripts/optimizer/second_score.py)):
|
||||
the film is sampled once per second, and at each second the set of names the
|
||||
pipeline reports present is compared against Amazon X-Ray's scene cast for that
|
||||
second. X-Ray's ground truth is scene-level; the pipeline's output is per-second.
|
||||
That mismatch shapes every result.
|
||||
|
||||
r50 (ArcFace w600k-R50) is excluded from the detailed comparison below. Its
|
||||
gallery was built with roughly 30% fewer reference images per actor than the
|
||||
other three models on the identical source photos (10808 vs 15055 total
|
||||
embeddings across the same 2418 actors), which confounds any direct
|
||||
comparison of its scores against the others. It remains in the
|
||||
[calibration curve comparison](best-model.md#first-signal-calibration-curves),
|
||||
which does not depend on the training benchmark.
|
||||
## The benchmark
|
||||
|
||||
Nine films with public Amazon X-Ray scene data, all scored with the same
|
||||
LVFace-B Glint360K gallery:
|
||||
|
||||
Benny & Joon · Café Society · Downton Abbey: A New Era · Lord of War · Lovelace ·
|
||||
The Many Saints of Newark · Scarface · Sound of Metal · Valerian.
|
||||
|
||||
Two of these — Café Society and Scarface — are low-contrast, uniformly-graded
|
||||
films that break naive cut detection. They are deliberately kept in the benchmark
|
||||
because they are where the interesting failures live.
|
||||
|
||||
## Why replay makes this affordable
|
||||
|
||||
Decoding video and running face detection, alignment, and embedding is the
|
||||
expensive part of this pipeline. Everything downstream of that (tracking,
|
||||
identity matching, scene aggregation) is cheap. KPN++'s node/network
|
||||
structure means those two stages are separate components connected by
|
||||
typed channels, so the expensive stage can run once per film, cache its
|
||||
output, and the cheap stage can be re-run against that cache as many times
|
||||
as needed with different Config values.
|
||||
expensive part of the pipeline. Everything downstream — tracking, identity
|
||||
matching, scene aggregation — is cheap. KPN++'s node/network structure keeps those
|
||||
two halves as separate components joined by typed channels, so the expensive half
|
||||
runs once per film and caches its output, and the cheap half can be re-run against
|
||||
that cache as often as needed with different `Config` values.
|
||||
|
||||
`scene_analyze --dump-embeddings out.h5` runs the expensive half once per
|
||||
film and writes per-frame face detections and embeddings to HDF5
|
||||
([`scripts/optimizer/SCHEMA.md`](https://REPOLINK/scripts/optimizer/SCHEMA.md)).
|
||||
[`scripts/optimizer/replay.py`](https://REPOLINK/scripts/optimizer/replay.py)
|
||||
then re-assembles the real C++ `face_tracker`, `identity_matcher`, and
|
||||
`scene_tracker` nodes into a Python-driven KPN network and replays a
|
||||
film's cached embeddings through them, varying `prob_threshold`,
|
||||
`anneal_sec`, `extinction_sec`, and `expand_gallery` freely. No GPU
|
||||
inference and no video decode happen during a replay; each one completes
|
||||
in seconds. This is what makes a 512-evaluation differential-evolution
|
||||
search per model, per gallery mode, per expansion setting, tractable, and
|
||||
what made the full held-out validation across three models in this report
|
||||
possible in one session rather than requiring three full re-encodes of the
|
||||
benchmark set.
|
||||
`scene_analyze --dump-embeddings out.h5` runs the expensive half once and writes
|
||||
per-frame detections, embeddings, and (for the scene detector) per-frame RGB
|
||||
histograms to HDF5. [`scripts/optimizer/replay.py`](https://REPOLINK/scripts/optimizer/replay.py)
|
||||
re-assembles the real C++ `face_tracker`, `identity_matcher`, and scene nodes into
|
||||
a Python-driven KPN network and replays a film's cache through them, varying every
|
||||
tuning knob freely. No GPU inference and no video decode happen during a replay, so
|
||||
a full differential-evolution search over all nine films is tractable in one
|
||||
session rather than requiring re-encodes.
|
||||
|
||||
`optimize.py` runs `differential_evolution` over this replay function as its
|
||||
objective, with DE-level parallelism (multiple candidate configs evaluated
|
||||
concurrently, each spawning its own replay subprocesses) on top of it. The
|
||||
practical ceiling on this machine's GPU was 8 concurrent replay processes;
|
||||
9 silently degraded every score to 0.0% (well-formed output, wrong numbers,
|
||||
not a crash), so `optimize.py` was run at `REPLAY_WORKERS=4 DE_WORKERS=2`.
|
||||
Two concurrency limits are load-bearing and were paid for in wedged runs: replays
|
||||
run at `DE_WORKERS=1` (concurrent DE candidates wedge the ROCm GPU), and each
|
||||
candidate's per-film replays run at `REPLAY_WORKERS=8` with stderr discarded (the
|
||||
replay sink's per-second prints otherwise flood the captured pipe and hang the
|
||||
subprocess).
|
||||
|
||||
## Search space
|
||||
## The tuning knobs
|
||||
|
||||
`popsize=10, maxiter=15` per combo (3 parameters, up to 512 evaluations,
|
||||
usually stopping earlier on DE's convergence tolerance).
|
||||
`anneal_sec`/`extinction_sec` bounds were widened from 1-30/1-15 to 1-60/1-60
|
||||
partway through the sweep. r50's 4 combos finished before the widening and
|
||||
used the old, narrower bounds; this is one more reason r50 is excluded from
|
||||
direct comparison here.
|
||||
The opencv5 refactor replaced the old three-knob search with a **ten-knob**
|
||||
differential-evolution sweep. The knobs, and their shipped values:
|
||||
|
||||
## Training films and held-out films
|
||||
| knob | shipped | what it controls |
|
||||
| ---- | ------: | ---------------- |
|
||||
| `prob_threshold` | 0.485 | posterior P(match) above which a track is named |
|
||||
| `ownership_logodds` | 1.72 | log-odds a track needs before it produces presence |
|
||||
| `track_extinction_sec` | 31.0 | how long an idle track is held for re-detection |
|
||||
| `track_alpha` | 0.435 | tracker cost mix (0 = embedding only, 1 = spatial only) |
|
||||
| `evidence_rho_max` | 0.204 | evidence weighting ceiling |
|
||||
| `evidence_admit_below` | 0.784 | admit new evidence below this similarity |
|
||||
| `match_prior` | 0.433 | base-rate prior on a match |
|
||||
| `expand_band_lo` | 0.804 | low edge of the pose-expansion similarity band |
|
||||
| `expand_band_hi` | 0.952 | high edge of the pose-expansion band |
|
||||
| `presence_mode` | flood | track-extent vs scene flood-fill |
|
||||
|
||||
9 films have dumped embeddings across all 4 models. 4 were used for
|
||||
optimization:
|
||||
The DE run over the first nine knobs (flood off, track-extent presence) converged
|
||||
at **64.0% macro F1** over 345 evaluations. Those values are the shipped
|
||||
[`src/config.hpp`](https://REPOLINK/src/config.hpp) defaults.
|
||||
|
||||
- Café Society (62-cast)
|
||||
- Lord of War (64-cast)
|
||||
- Scarface (67-cast)
|
||||
- Sound of Metal (14-cast)
|
||||

|
||||
|
||||
5 were held out, never seen by any optimizer run:
|
||||
The `track_extinction_sec` knob is worth calling out: at 31 s it holds an idle
|
||||
track alive for re-detection long enough to bridge an actor turning away or leaving
|
||||
frame briefly, without bridging across a genuine scene change. Getting this knob
|
||||
and the tracker/registry to agree on **one clock** (the evidence watermark, not
|
||||
wall-clock) was a correctness fix, not a tuning choice — before it, votes were
|
||||
silently dropped at the reap horizon.
|
||||
|
||||
- Benny & Joon
|
||||
- Downton Abbey: A New Era
|
||||
- Lovelace
|
||||
- The Many Saints of Newark
|
||||
- Valerian and the City of a Thousand Planets
|
||||
## The step change: flood-fill on learned boundaries
|
||||
|
||||
## Gallery coverage per film
|
||||
The 64.0% above is track-extent presence: an actor is reported only while an actual
|
||||
track is alive. **Flood-fill** instead reports an actor for the whole shot once
|
||||
they are seen in it — but that is only correct if the shot boundaries are good.
|
||||
|
||||
The gallery has reference embeddings for 2418 actors, but coverage of any
|
||||
given film's credited cast varies widely. This was previously reported as
|
||||
one flat number (67% of X-Ray cast lacking a reference embedding, averaged
|
||||
across the whole benchmark); the per-film breakdown is:
|
||||
With the old grayscale cut detector as the boundary source, flood-fill barely beat
|
||||
doing nothing (**64.0%**) and actively broke Scarface, where the detector fires
|
||||
once in 10,204 frames and flood then smears every actor across the whole film
|
||||
(precision collapses to 26%).
|
||||
|
||||
| film | cast credited | in gallery | coverage |
|
||||
|---|---|---|---|
|
||||
| Lord of War | 64 | 13 | 20.3% |
|
||||
| Scarface | 67 | 15 | 22.4% |
|
||||
| The Many Saints of Newark | 48 | 13 | 27.1% |
|
||||
| Café Society | 62 | 17 | 27.4% |
|
||||
| Lovelace | 42 | 15 | 35.7% |
|
||||
| Valerian and the City of a Thousand Planets | 36 | 13 | 36.1% |
|
||||
| Benny & Joon | 23 | 12 | 52.2% |
|
||||
| Downton Abbey: A New Era | 36 | 22 | 61.1% |
|
||||
| Sound of Metal | 14 | 11 | 78.6% |
|
||||
The [learned scene-boundary detector](scene-boundary-detector.md) — an XGBoost
|
||||
regressor over histogram-delta and audio features, with a per-film knee threshold —
|
||||
fixes this. Macro per-second presence F1, at the shipped presence config:
|
||||
|
||||
Two training films (Lord of War, Scarface) have the worst coverage in the
|
||||
set, 20-22%. Their training-set F1 numbers below are partly capped by
|
||||
missing references, not purely by model quality. Downton Abbey has 61%
|
||||
coverage, the second-best in the benchmark, yet the worst held-out recall
|
||||
of any film (39.4%, LVFace). Its recall problem is not primarily a coverage
|
||||
problem; it is the extinction-bridging failure documented in the
|
||||
[LVFace deep dive](lvface-deep-dive.md#mechanism-1-extinction-bridging).
|
||||
Reproduce with `scripts/docs/gallery_coverage_per_film.py`.
|
||||
| boundary source for flood-fill | presence F1 |
|
||||
| ------------------------------ | ----------: |
|
||||
| track-extent (flood off) | 62.6% |
|
||||
| flood + grayscale cuts | 64.0% |
|
||||
| **flood + learned detector (LOO)** | **74.9%** |
|
||||
|
||||
## Training results, 3 models × 2 gallery modes × 2 expansion settings
|
||||

|
||||
|
||||
Ranked by F1. misid = FPI_misid, the count of true wrong-actor
|
||||
identifications (naming someone not in the film's cast at all), distinct
|
||||
from FPI, which also includes in-cast timing slips.
|
||||
The learned column is **leave-one-out**: each film is scored by a detector trained
|
||||
on the other eight, so no film's presence is ever measured with a detector that saw
|
||||
it. That is the honest generalisation number, +12.3 points over track-extent, and
|
||||
**it improves every one of the nine films**.
|
||||
|
||||
Each combo's row is its best **full-coverage** evaluation: the highest-F1 DE
|
||||
evaluation in which all 4 training films replayed without a timeout (see
|
||||
[Dropped-film scoring](#a-scoring-bug-worth-recording-dropped-film-evaluations)
|
||||
below for why this qualifier is load-bearing and not the same as `argmax F1`
|
||||
over the raw sweep).
|
||||

|
||||
|
||||
| combo | F1 | P | R | TPI | FPI | misid | FN |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| LVFace-B_Glint360K_restricted_exp | 78.3% | 91.0% | 68.9% | 42830 | 3782 | 60 | 19492 |
|
||||
| LVFace-B_Glint360K_restricted_noexp | 76.7% | 91.5% | 66.2% | 41149 | 3400 | 59 | 21173 |
|
||||
| arcface_w600k_mbf_restricted_exp | 76.2% | 90.0% | 66.2% | 64328 | 7480 | 0 | 33234 |
|
||||
| arcface_r18_restricted_exp | 75.5% | 87.6% | 66.5% | 41399 | 5666 | 60 | 20923 |
|
||||
| LVFace-B_Glint360K_full_exp | 75.3% | 89.7% | 65.4% | 47757 | 3407 | 232 | 26966 |
|
||||
| arcface_w600k_mbf_restricted_noexp | 75.0% | 91.1% | 63.9% | 39752 | 3465 | 60 | 22570 |
|
||||
| arcface_r18_restricted_noexp | 73.5% | 91.3% | 61.7% | 38299 | 3220 | 60 | 24023 |
|
||||
| LVFace-B_Glint360K_full_noexp | 72.3% | 88.3% | 61.8% | 40363 | 3503 | 244 | 25850 |
|
||||
| arcface_w600k_mbf_full_exp | 72.0% | 87.7% | 61.4% | 39875 | 3729 | 240 | 26338 |
|
||||
| arcface_w600k_mbf_full_noexp | 71.0% | 93.2% | 57.9% | 41699 | 2472 | 56 | 33024 |
|
||||
| arcface_r18_full_exp | 69.1% | 87.6% | 57.7% | 37342 | 3119 | 242 | 28871 |
|
||||
| arcface_r18_full_noexp | 66.6% | 91.3% | 53.1% | 34314 | 2362 | 107 | 31899 |
|
||||
| film | track-extent | flood+grayscale | flood+learned (LOO) |
|
||||
| ---- | -----------: | --------------: | ------------------: |
|
||||
| Benny & Joon | 77.3 | 80.2 | 78.2 |
|
||||
| Café Society | 59.1 | 62.2 | 69.8 |
|
||||
| Downton Abbey | 41.0 | 51.8 | **78.6** |
|
||||
| Lord of War | 74.8 | 77.1 | 77.8 |
|
||||
| Lovelace | 70.3 | 74.0 | 78.2 |
|
||||
| The Many Saints of Newark | 37.5 | 43.9 | 53.4 |
|
||||
| Scarface | 62.6 | **40.9** | **74.9** |
|
||||
| Sound of Metal | 75.0 | 78.1 | 86.8 |
|
||||
| Valerian | 65.6 | 67.7 | 76.2 |
|
||||
|
||||

|
||||
The two headline films — Scarface (grayscale flood *breaks* it, learned flood on a
|
||||
film it never trained on takes it to 74.9%) and Downton Abbey (+37 points) — are
|
||||
the strongest evidence the detector generalises. See the
|
||||
[scene-boundary detector page](scene-boundary-detector.md) for the full story.
|
||||
|
||||
The two clearest patterns: every model's best-scoring combo uses the
|
||||
restricted gallery, and LVFace leads within both gallery modes. `full_exp`
|
||||
(the shipped combination) is the best-scoring option that uses only
|
||||
features the running application currently supports; restriction is not
|
||||
wired into the application yet (see
|
||||
[Whole vs. cast-restricted gallery](gallery-scope.md)).
|
||||
We re-ran the ten-knob DE on top of the good boundaries to check whether the
|
||||
shipped config should change. It converged at 76.1% (+0.3 pp over the shipped
|
||||
config on learned boundaries) — inside the noise, not worth re-shipping. The
|
||||
boundaries, not the presence knobs, are where the win is.
|
||||
|
||||
### A scoring bug worth recording: dropped-film evaluations
|
||||
## What the frames look like
|
||||
|
||||
The numbers above are corrected ones. The raw `rep4_best_*.json` files, and an
|
||||
earlier version of this table, reported a different `arcface_w600k_mbf_full_noexp`
|
||||
row: **74.2% F1 at TPI 12645**, a third the TPI of every sibling combo. That was
|
||||
not a better config; it was an artifact of how the optimizer aggregates.
|
||||
`scripts/optimizer/dump_error_frames.py` pulls representative seconds and draws
|
||||
each face box against X-Ray's scene cast: **green** = true positive, **red** =
|
||||
false positive (a name X-Ray does not credit to this scene — the real error),
|
||||
**orange** = an unknown detection. Cast X-Ray lists as present but for whom no face
|
||||
was detected — the structural false-negatives a face pipeline can never box — are
|
||||
listed as a **blue** panel.
|
||||
|
||||
`optimize.py` builds each candidate's score from only the films whose replay
|
||||
subprocess returned (`per_film = [m for m in ex.map(_one, films) if m is not
|
||||
None]`), then **averages** F1/precision/recall and **sums** TPI/FPI/misID over
|
||||
just those survivors. When a film's replay times out (the sweep ran near the
|
||||
8-process concurrency ceiling, so this happened intermittently), that film
|
||||
silently drops from both. A candidate whose hardest film timed out is therefore
|
||||
scored on an easier subset, and differential evolution, maximizing that score,
|
||||
will happily converge onto exactly such a candidate. For `mbf_full_noexp` the
|
||||
reported winner was one of 7 evaluations (out of 512) whose TPI had collapsed to
|
||||
a partial-film subset; its median-coverage evaluations sit around 51686 TPI.
|
||||

|
||||
|
||||
The fix here was to re-derive each combo's best row from its DE trajectory
|
||||
(`experiments/trajectories/rep4_*.jsonl`), keeping only evaluations within 30% of
|
||||
that combo's median TPI (full 4-film coverage) before taking the best F1. This
|
||||
needs no re-running, the honest best configuration was already in the sweep,
|
||||
just not the one `argmax F1` selected. Three combos moved: `mbf_full_noexp`
|
||||
74.2% → **71.0%**, `LVFace_full_noexp` 72.4% → **72.3%** (and its misID, 0 → 244,
|
||||
was itself a dropped-film artifact), `mbf_restricted_exp` 76.5% → **76.2%**. The
|
||||
shipped LVFace `full_exp` winner was unaffected, its reported evaluation already
|
||||
had full coverage (TPI 47757 ≈ median). `experiment_charts.py` applies the same
|
||||
`clean_best` filter, so every figure on this page matches the corrected table.
|
||||
The underlying `optimize.py` aggregation is also being fixed so a dropped-film
|
||||
evaluation can never be selected as a winner again.
|
||||
Every named frame in this documentation is regenerated against the current opencv5
|
||||
pipeline by [`scripts/scene_detector/rematch_frames.py`](https://REPOLINK/scripts/scene_detector/rematch_frames.py),
|
||||
which auto-matches each example by film, actor, and class (TP/FP) so the images
|
||||
never drift from the shipped behaviour. Where the current pipeline no longer makes
|
||||
a July-era error — the Zooey Deschanel misID in Many Saints is the clearest case —
|
||||
the frame is dropped rather than staged, because the improvement is real.
|
||||
|
||||
### Per-film training breakdown
|
||||
## The structural recall ceiling
|
||||
|
||||
The 75.3% LVFace training figure is a macro average across 4 films, not a
|
||||
uniform result:
|
||||
Precision against X-Ray is near-perfect on identified faces; recall is capped by
|
||||
two things the pipeline cannot fix:
|
||||
|
||||
| film | LVFace F1 | mbf F1 | r18 F1 | best model |
|
||||
|---|---|---|---|---|
|
||||
| Café Society | 68.1% | 62.2% | 60.1% | LVFace |
|
||||
| Lord of War | 75.6% | 77.2% | 75.6% | mbf |
|
||||
| Scarface | 71.5% | 68.6% | 64.1% | LVFace |
|
||||
| Sound of Metal | 78.8% | 76.5% | 71.6% | LVFace |
|
||||
1. **X-Ray credits people whose faces never appear on screen** in a scene — voice,
|
||||
back-of-head, or simply off-camera cast. No face pipeline can box a face that is
|
||||
not there. These are the blue-panel names.
|
||||
2. **Gallery coverage.** A large fraction of X-Ray cast has no reference image in
|
||||
the gallery, so those actors can never be matched regardless of detection. This
|
||||
is the dominant remaining recall limiter and is addressable by fetching more
|
||||
reference photos, not by tuning.
|
||||
|
||||
LVFace does not win every training film. mbf scores higher on Lord of War
|
||||
(77.2% vs 75.6%). LVFace's own training-film range is 68.1% to 78.8%, a
|
||||
10.7pp spread, smaller than the 37pp spread seen on held-out films but real.
|
||||
Reproduce with `scripts/docs/run_holdout_all_models.py --films training`.
|
||||
Both are documented in [how we score against X-Ray](methodology.md).
|
||||
|
||||
## Held-out validation, all 3 models
|
||||
## In the pipeline
|
||||
|
||||
The training matrix above is training-set fit. Each model's own tuned
|
||||
`full_exp` config was replayed against the 5 held-out films, scored the
|
||||
same way:
|
||||
|
||||
| film | LVFace F1 | mbf F1 | r18 F1 |
|
||||
|---|---|---|---|
|
||||
| Benny & Joon | 83.0% | 78.5% | 77.1% |
|
||||
| Lovelace | 77.5% | 73.7% | 72.2% |
|
||||
| Valerian and the City of a Thousand Planets | 74.1% | 70.2% | 71.0% |
|
||||
| Downton Abbey: A New Era | 56.2% | 55.0% | 53.0% |
|
||||
| The Many Saints of Newark | 46.3% | 44.5% | 42.1% |
|
||||
| **macro average** | **67.4%** | **64.4%** | **63.1%** |
|
||||
|
||||
LVFace scores highest on every one of the 5 held-out films; the ranking
|
||||
never flips. Total misIDs across the 5 films: LVFace 1032, mbf 2197, r18
|
||||
1224. LVFace has less than half mbf's misID count while also scoring
|
||||
higher on every film. This directly confirms the model choice out of
|
||||
sample; it is not inferred from the training numbers alone. See the
|
||||
[LVFace deep dive](lvface-deep-dive.md) for frame-level detail on where and
|
||||
why LVFace still fails on the two worst films. Reproduce with
|
||||
`scripts/docs/run_holdout_all_models.py`.
|
||||
|
||||
## Two effects in isolation: gallery scope and pose expansion
|
||||
|
||||
Averaging across the 3 compared models (r50 excluded) isolates each variable
|
||||
from model choice.
|
||||
|
||||
**Gallery scope**, averaged over both expansion settings and all 3 models
|
||||
(6 evaluations per row):
|
||||
|
||||
| scope | F1 | P | R | total misID |
|
||||
|---|---|---|---|---|
|
||||
| full | 71.1% | 89.6% | 59.6% | 1121 |
|
||||
| restricted | 75.9% | 90.4% | 65.6% | 299 |
|
||||
|
||||
Restriction improves every metric at once. This is not a precision/recall
|
||||
trade: +4.8pp F1, +6.0pp recall, and roughly a quarter the misIDs. Fewer
|
||||
candidates in the matcher's search space means fewer opportunities for a
|
||||
lookalike false match, and the recall gain shows this does not cost real
|
||||
detections. Restriction is currently an offline optimizer technique, not a
|
||||
runtime feature of the application; see
|
||||
[Whole vs. cast-restricted gallery](gallery-scope.md) for what building it
|
||||
into the application would require.
|
||||
|
||||
**Pose expansion** (promoting a confidently identified track's novel-pose
|
||||
views into a per-film gallery annex,
|
||||
[`src/gallery/track_gallery.hpp`](https://REPOLINK/src/gallery/track_gallery.hpp)):
|
||||
|
||||
| scope | expansion | F1 | R | misID |
|
||||
|---|---|---|---|---|
|
||||
| full | off | 70.0% | 57.6% | 407 |
|
||||
| full | on | 72.1% | 61.5% | 714 |
|
||||
| restricted | off | 75.1% | 63.9% | 179 |
|
||||
| restricted | on | 76.7% | 67.2% | 120 |
|
||||
|
||||
In restricted mode, expansion is a clean win: +1.6pp F1, +3.3pp recall,
|
||||
misID drops. The annex only competes against the film's own roughly 15-actor
|
||||
cast, so a new pose of a known actor is unlikely to be confused with someone
|
||||
else. In full mode, expansion buys +2.1pp F1 and +3.9pp recall but at a real
|
||||
cost: misID rises from 407 to 714 as the same new-pose view now competes
|
||||
against the full 2418-actor gallery, where a confidently learned pose is more
|
||||
likely to match the wrong person. On the full gallery it is a recall-vs-misID
|
||||
trade, not a free gain. This training-set effect
|
||||
did not reproduce on held-out data; see
|
||||
[Does pose expansion help?](pose-expansion.md) for the full held-out test
|
||||
and the two methodology bugs caught while checking it.
|
||||
|
||||
## Calibration curves
|
||||
|
||||
Each gallery carries a fitted Platt sigmoid `P(match | sim) = σ(a·sim + b)`,
|
||||
stored directly in the gallery HDF5
|
||||
([`src/gallery/gallery_calibration.hpp`](https://REPOLINK/src/gallery/gallery_calibration.hpp)).
|
||||
This measures discriminative power independent of whatever
|
||||
`prob_threshold` a given run used:
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
Nearly everything scoring well sits at `extinction_sec` above 50, across a
|
||||
wide range of thresholds. Short extinction windows are uniformly weaker:
|
||||
under a strict threshold, there is no good configuration in that region of
|
||||
the search space. The optimizer converged with `anneal_sec=59.2,
|
||||
extinction_sec=59.2`, about 99% of the widened 60s bound, which raises an
|
||||
open question not resolved in this round: does performance keep improving
|
||||
past 60s, or does it plateau there. Not chased further this pass.
|
||||
|
||||
## Caveats
|
||||
|
||||
- r50's 4 combos used the older, narrower search bounds (1-30/1-15 instead
|
||||
of 1-60/1-60) and are further confounded by its thinner gallery. Excluded
|
||||
from all comparisons above except calibration.
|
||||
- The shipped defaults use `full_exp` (75.3% training F1), not the
|
||||
higher-scoring `restricted_exp` (78.3%), because cast restriction is not
|
||||
a runtime feature of the application yet.
|
||||
- `expand_gallery` is mode-dependent, not a free win. Averaged across models
|
||||
on the full gallery it trades misIDs for recall (see the pose-expansion
|
||||
table). For LVFace specifically, though, `full_exp` beats `full_noexp` on
|
||||
every axis at once (F1 75.3 vs 72.3, precision 89.7 vs 88.3, recall 65.4 vs
|
||||
61.8, misID 232 vs 244), so the shipped `full_exp` is a clean choice for
|
||||
this model, not an F1-vs-safety trade. (An earlier version of this page
|
||||
reported `full_noexp` at 72.4% with zero misIDs and higher precision, which
|
||||
made it look like the safer option; that was the dropped-film artifact
|
||||
described above, not a real property of the config.)
|
||||
- Switching the default model is an operational change: any gallery built
|
||||
from a different model's embeddings must be rebuilt before the new
|
||||
default takes effect.
|
||||
|
||||
## Reproduce
|
||||
The learned detector runs live inside `scene_analyze` as a post-EOF step (the
|
||||
per-film knee needs every peak, so it can only run once the whole film is seen).
|
||||
XGBoost inference is built into the binary via CMake (`SAE_SCENE_XGB`); the audio
|
||||
log-PSD uses FFTW on the existing FFmpeg decode. The shipped model is trained on
|
||||
the **C++-extracted** features so training and inference share one implementation.
|
||||
Verified end to end through `scene_analyze` on a movie file and through the Jellyfin
|
||||
work-queue worker.
|
||||
|
||||
```bash
|
||||
# 4-film training matrix, all 4 models × 2 gallery modes × 2 expansion settings
|
||||
bash experiments/run_rep4_subprocess.sh
|
||||
|
||||
# single combo
|
||||
SAE_EXPAND=1 REPLAY_WORKERS=4 DE_WORKERS=2 python3 scripts/optimizer/optimize.py \
|
||||
--manifest experiments/manifests/rep4_LVFace-B_Glint360K_full.json \
|
||||
--gallery experiments/galleries/gallery_LVFace-B_Glint360K.h5 \
|
||||
--params prob_threshold:0.5:0.999 anneal_sec:1:60 extinction_sec:1:60 \
|
||||
--popsize 10 --maxiter 15 --trajectory traj.jsonl --out best.json
|
||||
|
||||
# held-out validation, all 3 models, 5 films
|
||||
python3 scripts/docs/run_holdout_all_models.py --out docs_data/holdout_all_models.json
|
||||
|
||||
# per-film training breakdown, all 3 models, 4 films
|
||||
python3 scripts/docs/run_holdout_all_models.py --films training --out docs_data/training_per_film.json
|
||||
|
||||
# gallery coverage per film
|
||||
python3 scripts/docs/gallery_coverage_per_film.py --out docs_data/gallery_coverage_per_film.json
|
||||
|
||||
# regenerate this page's charts from experiments/ artifacts
|
||||
python3 scripts/docs/experiment_charts.py --out-dir docs/assets/images
|
||||
|
||||
# one frame per distinct out-of-cast name across all 9 films (used in the deep dive)
|
||||
python3 scripts/docs/first_fpi_frames.py
|
||||
scene_analyze --movie <file> --gallery <gallery.h5> \
|
||||
--scene-xgb-model models/scene_boundary_xgb.json
|
||||
```
|
||||
|
||||
See also the session log
|
||||
[`experiments/SESSION_STATE.md`](https://REPOLINK/experiments/SESSION_STATE.md).
|
||||
## Reproducing the benchmarks
|
||||
|
||||
Gallery `.h5` files, embedding dumps, the X-Ray corpus, and DE trajectories are not
|
||||
committed. They are pushed to the Gitea package registry and pulled on demand:
|
||||
|
||||
```bash
|
||||
scripts/artifacts/pull_artifacts.sh galleries
|
||||
scripts/artifacts/pull_artifacts.sh experiment-data
|
||||
|
||||
# per-second audio features, C++ feature matrices, train + downstream A/B
|
||||
.venv-rocm/bin/python scripts/scene_detector/extract_audio_features.py \
|
||||
--manifest experiments/manifests/films_LVFace_opencv5.json
|
||||
.venv-rocm/bin/python scripts/scene_detector/train_xgb_cpp.py --train-all
|
||||
scripts/scene_detector/downstream_presence.py
|
||||
```
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
> **Archived (July 2026).** This report covers the pre-opencv5 framework and the 4-model ArcFace/LVFace bake-off. It is superseded by the current [experiment log](model-bakeoff.md) for the opencv5 build. Kept for provenance; the numbers here are historical.
|
||||
|
||||
# Pose expansion: does promoting new poses mid-film help?
|
||||
|
||||
`expand_gallery`
|
||||
@@ -12,7 +14,7 @@ in the same film, without touching the baked gallery.
|
||||
|
||||
Averaged across the 3 compared models (r50 excluded), on the 4 films used
|
||||
for optimization. These are the corrected, full-coverage figures, see the
|
||||
[dropped-film note](model-bakeoff.md#a-scoring-bug-worth-recording-dropped-film-evaluations)
|
||||
[dropped-film note](model-bakeoff-2026-07.md#a-scoring-bug-worth-recording-dropped-film-evaluations)
|
||||
in the experiment log for why an earlier version of this table overstated the
|
||||
full-mode misID jump (209 → 864) that was itself partly a truncation artifact:
|
||||
|
||||
@@ -26,7 +28,7 @@ full-mode misID jump (209 → 864) that was itself partly a truncation artifact:
|
||||
In restricted mode, expansion looks like a clean win: +1.6pp F1, +3.3pp
|
||||
recall, lower misID. In full mode it looks like a recall-for-misID trade:
|
||||
+2.1pp F1, +3.9pp recall, but misID rises from 407 to 714. See
|
||||
[the full experiment log](model-bakeoff.md) for the per-model breakdown.
|
||||
[the full experiment log](model-bakeoff-2026-07.md) for the per-model breakdown.
|
||||
This asymmetry motivated the question below: does turning expansion on
|
||||
change what gets recognized frame by frame, or is the aggregate F1 shift
|
||||
coming from something else.
|
||||
@@ -105,6 +107,6 @@ contribution, such as tagging which reference embedding won each match;
|
||||
neither was in scope for this pass.
|
||||
|
||||
Do not treat the training-set exp/noexp numbers in
|
||||
[the full experiment log](model-bakeoff.md) as proof that expansion changes
|
||||
[the full experiment log](model-bakeoff-2026-07.md) as proof that expansion changes
|
||||
real-world behavior in either direction. On the evidence gathered so far,
|
||||
it does not move the needle enough to see.
|
||||
@@ -0,0 +1,205 @@
|
||||
# The learned scene-boundary detector
|
||||
|
||||
Presence uses **flood-fill**: an actor seen once inside a shot is reported for the
|
||||
whole shot (`[prev_boundary, next_boundary]`). That only works if the boundaries
|
||||
are good. This page is the story of getting them good — a learned scene-boundary
|
||||
detector that lifts per-second actor-presence F1 from **62.6% to 74.9%** across
|
||||
the nine-film X-Ray benchmark, and fixes the film where naive flood-fill was
|
||||
actively harmful.
|
||||
|
||||
That 74.9% is the **leave-one-out** figure: each film is scored by a detector
|
||||
trained on the *other eight*, so no film's presence is measured with a detector
|
||||
that ever saw it. It is the honest generalisation number, and it is only ~1 point
|
||||
below the all-nine-trained model (75.8%) — the detector barely overfits.
|
||||
|
||||
## Why the old cut detector wasn't enough
|
||||
|
||||
The always-on boundary source was the grayscale histogram-correlation cut detector
|
||||
(`camera_position_change_detector`): mark a cut when the frame-to-frame grayscale
|
||||
histogram correlation drops below 0.70. It is cheap and it fires on obvious hard
|
||||
cuts, but on a low-contrast, uniformly-graded film it is nearly blind. On
|
||||
**Scarface** it fired **once in 10,204 frames**. Flood-fill then snapped every
|
||||
actor across essentially the whole film:
|
||||
|
||||
| Scarface | precision | recall |
|
||||
| -------- | --------- | ------ |
|
||||
| flood + grayscale cuts | **26%** | 95% |
|
||||
| track-extent (no flood) | 92% | 45% |
|
||||
|
||||
That single failure is what motivated everything below: flood-fill needs a
|
||||
boundary source that works regardless of grade.
|
||||
|
||||
## What we are detecting, and why it is hard
|
||||
|
||||
The training target is **Amazon X-Ray scene boundaries** (`scenes.csv`). These are
|
||||
*narrative* scenes — a new location or beat in the story — not shot cuts. There
|
||||
are only ~20–60 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(t−k)|` separates boundaries
|
||||
**4–5×**. 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 3–6% 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.
|
||||
|
||||

|
||||
|
||||
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 51–86%),
|
||||
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%** |
|
||||
|
||||

|
||||
|
||||
**+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:
|
||||
|
||||

|
||||
|
||||
| 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate annotated TP/FP/FN frame examples for ALL 9 films against the current
|
||||
# opencv5 pipeline (learned-boundary flood, shipped config). Replays each film with
|
||||
# --raw-out for bboxes, then dump_error_frames.py draws GT-aware boxes
|
||||
# (green TP / red FP / orange unknown / blue FN panel). Frames land in
|
||||
# experiments/dump_review/<slug>/ (regenerable; gitignored). Hand-pick the ones a
|
||||
# doc needs from there.
|
||||
set -uo pipefail
|
||||
REPO="/home/dtourolle/Development/scene-actor-extraction"; cd "$REPO"
|
||||
export MIOPEN_USER_DB_PATH="$HOME/.cache/miopen-sae"
|
||||
GAL=experiments/galleries/gallery_LVFace-B_Glint360K.h5
|
||||
LUT=experiments/file-lut.json
|
||||
CFG=(--prob-threshold 0.485 --ownership-logodds 1.72 --track-extinction-sec 31
|
||||
--track-alpha 0.435 --evidence-rho-max 0.204 --evidence-admit-below 0.784
|
||||
--match-prior 0.433 --expand-band-lo 0.804 --expand-band-hi 0.952
|
||||
--expand-gallery --presence-mode flood)
|
||||
mapfile -t ROWS < <(python3 -c '
|
||||
import json
|
||||
for f in json.load(open("experiments/manifests/films_LVFace_opencv5.json")):
|
||||
print(f["slug"]+"\t"+f["xray"])')
|
||||
SP=/tmp/claude-1000/-home-dtourolle-Development-scene-actor-extraction/c579f8cf-2974-4cbd-be88-afec68dbbf58/scratchpad
|
||||
for row in "${ROWS[@]}"; do
|
||||
slug="${row%%$'\t'*}"; xray="${row#*$'\t'}"
|
||||
movie="$(python3 -c "import json;print(json.load(open('$LUT'))['$slug'])")"
|
||||
echo "=== $slug ==="
|
||||
[ -f "experiments/dump_review/$slug/manifest.json" ] && { echo " exists, skip"; continue; }
|
||||
# replay the learned-boundary (LOO) dump so frames reflect true generalization
|
||||
dump="experiments/dumps/injected_loo/${slug}.h5"
|
||||
[ -f "$dump" ] || dump="experiments/dumps/LVFace-B_Glint360K_opencv5/dump_${slug}.h5"
|
||||
for try in 1 2 3; do
|
||||
timeout 280 python scripts/optimizer/replay.py --dump "$dump" --gallery "$GAL" \
|
||||
--out "$SP/${slug}_pred.json" --raw-out "$SP/${slug}_raw.jsonl" "${CFG[@]}" \
|
||||
>"$SP/${slug}_replay.log" 2>&1 && break
|
||||
echo " replay try $try failed, retrying"
|
||||
done
|
||||
[ -s "$SP/${slug}_raw.jsonl" ] || { echo " no raw output, skip"; continue; }
|
||||
python3 scripts/optimizer/dump_error_frames.py \
|
||||
--pred "$SP/${slug}_pred.json" --raw "$SP/${slug}_raw.jsonl" \
|
||||
--xray "$xray" --movie "$movie" --gallery "$GAL" \
|
||||
--out-dir "experiments/dump_review/$slug" --n-per-bucket 4 \
|
||||
>"$SP/${slug}_frames.log" 2>&1
|
||||
echo " $(grep -oE 'wrote [0-9]+ frames' "$SP/${slug}_frames.log" | tail -1)"
|
||||
done
|
||||
echo "=== DONE ==="
|
||||
@@ -35,14 +35,17 @@ extra_css:
|
||||
nav:
|
||||
- Home: index.md
|
||||
- How We Score Against X-Ray: methodology.md
|
||||
- Learned Scene-Boundary Detector: scene-boundary-detector.md
|
||||
- Benchmark — SuperHero: benchmark.md
|
||||
- Findings:
|
||||
- Best Model: best-model.md
|
||||
- Gallery Scope (Full vs. Limited): gallery-scope.md
|
||||
- Pose Expansion: pose-expansion.md
|
||||
- LVFace Deep Dive: lvface-deep-dive.md
|
||||
- Full Experiment Log: model-bakeoff.md
|
||||
- Service Conversion (proposal): service-conversion.md
|
||||
- Archive (July 2026):
|
||||
- How We Scored (July): methodology-2026-07.md
|
||||
- Best Model: best-model-2026-07.md
|
||||
- Gallery Scope (Full vs. Limited): gallery-scope-2026-07.md
|
||||
- Pose Expansion: pose-expansion-2026-07.md
|
||||
- LVFace Deep Dive: lvface-deep-dive-2026-07.md
|
||||
- Full Experiment Log (July): model-bakeoff-2026-07.md
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
|
||||
@@ -121,23 +121,44 @@ def load_raw_annotations(raw_path: str):
|
||||
return by_second
|
||||
|
||||
|
||||
def draw_annotations(frame_path: Path, actors: list):
|
||||
def _name_key(name: str) -> str:
|
||||
"""Normalised match key, mirroring identity.py's name: fallback."""
|
||||
return "name:" + "".join(ch for ch in name.lower() if ch.isalnum() or ch == " ").strip()
|
||||
|
||||
|
||||
def draw_annotations(frame_path: Path, actors: list, fp_keys=None, fn_names=None):
|
||||
"""Draw GT-aware boxes: GREEN = true positive (named actor X-Ray also has in
|
||||
this scene), RED = false positive (named actor NOT in the scene → the real
|
||||
error), ORANGE = unknown detection. FN cast (present per X-Ray but no face
|
||||
detected — so no box to draw) is listed as a BLUE text panel bottom-left."""
|
||||
img = cv2.imread(str(frame_path))
|
||||
if img is None:
|
||||
return
|
||||
fp_keys = fp_keys or set()
|
||||
GREEN, RED, ORANGE, BLUE = (60,200,0), (0,0,230), (220,100,0), (230,150,0)
|
||||
for a in actors:
|
||||
known = a.get("actor_idx", -1) >= 0
|
||||
colour = (60, 200, 0) if known else (220, 100, 0) # BGR: green / orange
|
||||
x, y, w, h = a["bbox"]
|
||||
x, y, w, h = int(x), int(y), int(w), int(h)
|
||||
if known:
|
||||
colour = RED if _name_key(a["name"]) in fp_keys else GREEN
|
||||
label = f"{a['name']} {a['similarity']*100:.0f}%"
|
||||
else:
|
||||
colour = ORANGE; label = f"unknown {a['similarity']*100:.0f}%"
|
||||
x, y, w, h = (int(v) for v in a["bbox"])
|
||||
cv2.rectangle(img, (x, y), (x + w, y + h), colour, 2)
|
||||
|
||||
label = f"{a['name']} {a['similarity']*100:.0f}%" if known else f"unknown {a['similarity']*100:.0f}%"
|
||||
(tw, th), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
|
||||
strip_y0 = max(0, y - th - 4)
|
||||
cv2.rectangle(img, (x, strip_y0), (x + tw + 4, y), colour, cv2.FILLED)
|
||||
cv2.putText(img, label, (x + 2, y - 2), cv2.FONT_HERSHEY_SIMPLEX, 0.5,
|
||||
(255, 255, 255), 1, cv2.LINE_AA)
|
||||
(tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
|
||||
cv2.rectangle(img, (x, max(0, y-th-4)), (x+tw+4, y), colour, cv2.FILLED)
|
||||
cv2.putText(img, label, (x+2, y-2), cv2.FONT_HERSHEY_SIMPLEX, 0.5,
|
||||
(255,255,255), 1, cv2.LINE_AA)
|
||||
# FN: X-Ray cast present with no detected face — no box exists, so list them.
|
||||
fn = [n for n in (fn_names or []) if n]
|
||||
if fn:
|
||||
H = img.shape[0]
|
||||
cv2.putText(img, "off-screen / missed (X-Ray cast, no face):",
|
||||
(8, H-8-18*len(fn[:6])), cv2.FONT_HERSHEY_SIMPLEX, 0.45, BLUE, 1, cv2.LINE_AA)
|
||||
for i, n in enumerate(fn[:6]):
|
||||
disp = n.replace("name:", "").title()
|
||||
cv2.putText(img, f" {disp}", (8, H-8-18*(len(fn[:6])-1-i)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, BLUE, 1, cv2.LINE_AA)
|
||||
cv2.imwrite(str(frame_path), img)
|
||||
|
||||
|
||||
@@ -183,7 +204,9 @@ def main():
|
||||
extract_frame(args.movie, r["t"], out_path)
|
||||
ok = True
|
||||
if raw_by_second is not None:
|
||||
draw_annotations(out_path, raw_by_second.get(r["t"], []))
|
||||
fp_keys = {_name_key(n) for n in r["fp"]}
|
||||
draw_annotations(out_path, raw_by_second.get(r["t"], []),
|
||||
fp_keys=fp_keys, fn_names=r["fn"])
|
||||
except subprocess.CalledProcessError as e:
|
||||
ok = False
|
||||
print(f"[dump_error_frames] ffmpeg failed at t={r['t']}: {e}", file=sys.stderr)
|
||||
|
||||
@@ -61,7 +61,15 @@ from replay import dump_embedder_stamp # noqa: E402
|
||||
from sae_stamp import EmbedderMismatch, verify_gallery_stamp # noqa: E402
|
||||
|
||||
_GAL_KEYS: dict = {} # gallery path → key set (fair-recall FN mask), loaded once
|
||||
_REPLAY_TIMEOUT = 45 # seconds per film; a wedged replay is killed, not left to hang
|
||||
# Seconds per film before a replay is killed. Its ONLY job is to escape the rare,
|
||||
# intermittent ROCm GEMM wedge (github ROCT-Thunk #56): a wedged replay hangs
|
||||
# forever and would otherwise stall the whole sweep, so it must be killed and that
|
||||
# film dropped (the eval is then scored as incomplete → F1=0, and DE moves on). It
|
||||
# is NOT a performance bound. A healthy replay finishes in ~15-30s even for the
|
||||
# long films with stderr discarded, so 180s is comfortably above any real run yet
|
||||
# short enough that a wedge is reaped quickly rather than after half an hour.
|
||||
# Raise via REPLAY_TIMEOUT if a legitimately slow config is being killed.
|
||||
_REPLAY_TIMEOUT = int(os.environ.get("REPLAY_TIMEOUT", "180"))
|
||||
|
||||
REPLAY_CLI = str(Path(__file__).resolve().parent / "replay.py")
|
||||
|
||||
@@ -91,7 +99,17 @@ def _replay_subprocess(dump, gallery, cfg, build_dir):
|
||||
else:
|
||||
argv += [f"--{k.replace('_', '-')}", str(v)]
|
||||
try:
|
||||
subprocess.run(argv, timeout=_REPLAY_TIMEOUT, capture_output=True, check=True)
|
||||
# Discard the child's stdout/stderr rather than capture it. replay's sink
|
||||
# prints a per-second "[result_sink] t=Ns" progress line with an explicit
|
||||
# flush; on a long film that is thousands of writes, and under
|
||||
# subprocess.run(capture_output=True) they accumulate in a fixed OS pipe
|
||||
# buffer that nothing drains until the process exits. On the long films
|
||||
# (Valerian, Sound of Metal) under DE concurrency the buffer fills and the
|
||||
# C++ process BLOCKS on write to stderr — indistinguishable from a hang, so
|
||||
# it hit the timeout and scored F1=0. DEVNULL never fills, so the process
|
||||
# runs to completion. (Any real error is still surfaced by check=True.)
|
||||
subprocess.run(argv, timeout=_REPLAY_TIMEOUT, check=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return _json.loads(Path(out).read_text())
|
||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError,
|
||||
FileNotFoundError, ValueError) as e:
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
de_ramp.py — DE-optimise a temporal matched-filter "ramp" per modality, whose
|
||||
response becomes a feature channel for the scene-boundary LSTM.
|
||||
|
||||
A scene boundary is where a feature series (RGB histogram, audio log-PSD) shifts
|
||||
from a "before" state to an "after" state. A signed, antisymmetric ramp kernel
|
||||
convolved with the series responds strongly exactly at that transition and near
|
||||
zero inside a stable scene — a matched filter for a step. Its shape is not
|
||||
obvious (how wide? linear or peaked? how much centre dead-zone?), so we let DE
|
||||
choose it by maximising boundary separation on the training films.
|
||||
|
||||
Ramp kernel over lags -H..+H seconds (1 fps → 1 sample/s):
|
||||
w(l) = sign(l) * (|l| / H) ** gamma for |l| >= dead, else 0
|
||||
params: H (half-width), gamma (shape), dead (centre dead-zone)
|
||||
Response at t = || sum_l w(l) * feat[t+l] || (L2 over feature bins)
|
||||
|
||||
DE objective: boundary-detection F1 of a top-percentile threshold on the response,
|
||||
macro-averaged over the training films (±2 s tolerance). The tuned (H, gamma,
|
||||
dead) is saved; train_scene_boundary.py appends the ramp response as an input
|
||||
channel to each tower.
|
||||
|
||||
Usage:
|
||||
python scripts/scene_detector/de_ramp.py \
|
||||
--manifest experiments/manifests/films_LVFace_opencv5.json \
|
||||
--audio-dir experiments/dumps/audio_features \
|
||||
--holdout Scarface Sound_of_Metal --out experiments/results/scene_boundary
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse, csv, json, sys
|
||||
from pathlib import Path
|
||||
import h5py, numpy as np
|
||||
from scipy.optimize import differential_evolution
|
||||
|
||||
|
||||
def xray_bounds(xray_dir):
|
||||
return sorted(float(r["start"])/1000 for r in
|
||||
csv.DictReader(open(Path(xray_dir)/"scenes.csv"))
|
||||
if float(r["start"]) > 500)
|
||||
|
||||
|
||||
def load_series(dump, audio_dir, which):
|
||||
if which == "audio":
|
||||
# Audio is self-contained in the npz — no h5 needed (its ts IS the grid),
|
||||
# so the audio cutter can be tuned before/without the RGB dumps.
|
||||
slug = Path(dump).stem.replace("dump_", "")
|
||||
z = np.load(Path(audio_dir)/f"{slug}.npz")
|
||||
s = z["feat"].astype(np.float64)
|
||||
ts = z["ts"] if "ts" in z else np.arange(len(s), dtype=float)
|
||||
else: # video
|
||||
with h5py.File(dump) as f:
|
||||
ts = f["frames/timestamp_sec"][:]
|
||||
s = f["frames/rgb_hist"][:].astype(np.float64)
|
||||
# z-normalise each bin so L2 response isn't dominated by one loud bin
|
||||
s = (s - s.mean(0)) / (s.std(0) + 1e-6)
|
||||
return s, ts
|
||||
|
||||
|
||||
def ramp_kernel(H, gamma, dead):
|
||||
lags = np.arange(-H, H+1)
|
||||
w = np.sign(lags) * (np.abs(lags)/max(H,1))**gamma
|
||||
w[np.abs(lags) < dead] = 0.0
|
||||
return w
|
||||
|
||||
|
||||
def response(series, w, H):
|
||||
T = series.shape[0]
|
||||
r = np.zeros(T)
|
||||
for t in range(T):
|
||||
lo, hi = max(0, t-H), min(T, t+H+1)
|
||||
wl = w[(lo-(t-H)):(hi-(t-H))]
|
||||
r[t] = np.linalg.norm((series[lo:hi]*wl[:, None]).sum(0))
|
||||
return r
|
||||
|
||||
|
||||
def boundary_f1(resp, bounds, pct, tol=2):
|
||||
thr = np.percentile(resp, pct)
|
||||
pred = np.where(resp > thr)[0]
|
||||
bidx = [int(b) for b in bounds if int(b) < len(resp)]
|
||||
if len(pred) == 0 or not bidx:
|
||||
return 0.0
|
||||
tp_p = sum(any(abs(p-i) <= tol for i in bidx) for p in pred)
|
||||
tp_t = sum(any(abs(p-i) <= tol for p in pred) for i in bidx)
|
||||
P, R = tp_p/len(pred), tp_t/len(bidx)
|
||||
return 2*P*R/(P+R) if P+R else 0.0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--manifest", required=True)
|
||||
ap.add_argument("--audio-dir", default="experiments/dumps/audio_features")
|
||||
ap.add_argument("--holdout", nargs="+", default=["Scarface", "Sound_of_Metal"])
|
||||
ap.add_argument("--out", default="experiments/results/scene_boundary")
|
||||
args = ap.parse_args()
|
||||
films = [f for f in json.load(open(args.manifest)) if f["slug"] not in args.holdout]
|
||||
|
||||
out = {}
|
||||
for which in ("video", "audio"):
|
||||
data = [(load_series(f["dump"], args.audio_dir, which)[0], xray_bounds(f["xray"]))
|
||||
for f in films]
|
||||
def neg_f1(x):
|
||||
H = int(round(x[0])); gamma = x[1]; dead = int(round(x[2])); pct = x[3]
|
||||
if H < 1 or dead >= H: return 0.0
|
||||
w = ramp_kernel(H, gamma, dead)
|
||||
f1s = [boundary_f1(response(s, w, H), b, pct) for s, b in data]
|
||||
return -float(np.mean(f1s))
|
||||
# bounds: H 1..10s, gamma 0.3..3, dead 0..4s, threshold pct 80..98
|
||||
res = differential_evolution(
|
||||
neg_f1, [(1, 10), (0.3, 3.0), (0, 4), (80, 98)],
|
||||
seed=0, popsize=12, maxiter=25, tol=1e-4, polish=False)
|
||||
H = int(round(res.x[0])); gamma = float(res.x[1])
|
||||
dead = int(round(res.x[2])); pct = float(res.x[3])
|
||||
out[which] = {"H": H, "gamma": gamma, "dead": dead, "pct": pct,
|
||||
"train_f1": float(-res.fun)}
|
||||
print(f"[de-ramp] {which}: H={H}s gamma={gamma:.2f} dead={dead}s "
|
||||
f"pct={pct:.0f} train boundary-F1={-res.fun*100:.1f}%", file=sys.stderr)
|
||||
|
||||
Path(args.out).mkdir(parents=True, exist_ok=True)
|
||||
json.dump(out, open(Path(args.out)/"de_ramp.json", "w"), indent=2)
|
||||
print(f"[de-ramp] → {args.out}/de_ramp.json", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
density_floor.py — synthesise scene boundaries when detection is starved.
|
||||
|
||||
Flood-fill presence snaps each actor claim to the shot it sits in, so a film
|
||||
whose boundary detector fires almost nothing (Scarface: 1 cut in 171 min) floods
|
||||
every actor across the whole film. This is a safety floor: when a film's DETECTED
|
||||
boundary density is far below what a working detector should produce, fill the
|
||||
long gaps between real detections with uniformly-spaced synthetic boundaries so no
|
||||
flood-fill span can exceed ~1/target-density.
|
||||
|
||||
Design points (measured on the X-Ray corpus):
|
||||
- The target density is a PRIOR from the central 60 min of films (avoids credits/
|
||||
intro/outro skew): median ~0.35 scenes/min.
|
||||
- The trigger is detected-vs-prior, not prior-vs-anything: only fire when detected
|
||||
density < TRIGGER_FRAC × prior. Legitimately sparse films (long-scene ensembles
|
||||
like Downton/Many Saints) detect fine and are left alone.
|
||||
- Real detections are never moved or dropped; synthetic boundaries only subdivide
|
||||
gaps that are longer than the target scene length.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
PRIOR_SCENES_PER_MIN = 0.35 # central-60min X-Ray median
|
||||
TRIGGER_FRAC = 0.30 # fire only when detected < 30% of prior
|
||||
|
||||
|
||||
def apply_density_floor(boundaries: list[float], duration_sec: float,
|
||||
prior_per_min: float = PRIOR_SCENES_PER_MIN,
|
||||
trigger_frac: float = TRIGGER_FRAC) -> list[float]:
|
||||
"""Return boundaries augmented with synthetic ones iff detection is starved.
|
||||
|
||||
boundaries: detected boundary timestamps (s), any order.
|
||||
duration_sec: film length.
|
||||
Returns a sorted list; unchanged (just sorted) when the film is not starved.
|
||||
"""
|
||||
b = sorted(t for t in boundaries if 0.0 < t < duration_sec)
|
||||
minutes = duration_sec / 60.0
|
||||
if minutes <= 0:
|
||||
return b
|
||||
detected_density = len(b) / minutes
|
||||
if detected_density >= trigger_frac * prior_per_min:
|
||||
return b # detector produced a reasonable amount — leave it alone
|
||||
|
||||
target_gap = 60.0 / prior_per_min # seconds per expected scene
|
||||
edges = [0.0] + b + [duration_sec]
|
||||
out = list(b)
|
||||
for lo, hi in zip(edges[:-1], edges[1:]):
|
||||
gap = hi - lo
|
||||
if gap <= target_gap:
|
||||
continue
|
||||
n_insert = int(gap // target_gap) # how many synthetic cuts fit
|
||||
step = gap / (n_insert + 1)
|
||||
for k in range(1, n_insert + 1):
|
||||
out.append(lo + k * step)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# self-check on the Scarface failure and a healthy film
|
||||
scar = apply_density_floor([88.0], 171*60) # 1 detected cut, 171 min
|
||||
print(f"Scarface: 1 detected → {len(scar)} after floor "
|
||||
f"({len(scar)/171:.2f}/min, prior {PRIOR_SCENES_PER_MIN})")
|
||||
healthy = apply_density_floor([i*130.0 for i in range(1, 47)], 122*60)
|
||||
print(f"healthy (46 detected/122min={46/122:.2f}/min): "
|
||||
f"{len(healthy)} after floor (unchanged = not triggered)")
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
downstream_presence.py — does the XGBoost scene detector actually improve ACTOR
|
||||
PRESENCE accuracy? Boundary-F1 is only a proxy; this is the number that decides
|
||||
whether the detector ships.
|
||||
|
||||
For each film, compares presence (per-second X-Ray F1) under three regimes:
|
||||
A. track_extent — no flood-fill (claim = [first_seen, last_seen])
|
||||
B. flood + histogram cuts — current shipped flood (snaps to is_cut)
|
||||
C. flood + XGBoost bounds — inject the detector's boundaries into
|
||||
is_scene_boundary (flood prefers it over is_cut)
|
||||
|
||||
Injection: write a copy of each dump with frames/is_scene_boundary set from the
|
||||
XGBoost knee boundaries, then replay --presence-mode flood against that copy.
|
||||
Uses the shipped model (all-9 fit). Scored with second_score at the 10-knob
|
||||
optimum config.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, json, shutil, subprocess, tempfile, os
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import h5py
|
||||
|
||||
sys.path.insert(0, "scripts/scene_detector")
|
||||
sys.path.insert(0, "scripts/optimizer")
|
||||
sys.path.insert(0, "scripts/validation")
|
||||
import train_xgb_boundary as XB
|
||||
from second_score import score_seconds
|
||||
from sample_eval import load_gallery_keys
|
||||
import xgboost as xgb
|
||||
|
||||
GAL = "experiments/galleries/gallery_LVFace-B_Glint360K.h5"
|
||||
MODEL = "experiments/results/scene_boundary/xgb_boundary_shipped.json"
|
||||
# 10-knob presence optimum (shipped config)
|
||||
CFG = ["--prob-threshold", "0.485", "--ownership-logodds", "1.72",
|
||||
"--track-extinction-sec", "31", "--track-alpha", "0.435",
|
||||
"--evidence-rho-max", "0.204", "--evidence-admit-below", "0.784",
|
||||
"--match-prior", "0.433", "--expand-band-lo", "0.804",
|
||||
"--expand-band-hi", "0.952", "--expand-gallery"]
|
||||
|
||||
|
||||
def xgb_boundary_seconds(reg, dump):
|
||||
X, yb, ic = XB.per_second_matrix(dump, xr_for(dump), "experiments/dumps/audio_features")
|
||||
prob = np.clip(reg.predict(X), 0, 1)
|
||||
return set(XB.knee_boundaries(prob))
|
||||
|
||||
|
||||
FILMS = json.load(open("experiments/manifests/films_LVFace_opencv5.json"))
|
||||
_XR = {f["dump"]: f["xray"] for f in FILMS}
|
||||
def xr_for(dump): return _XR[dump]
|
||||
|
||||
|
||||
def inject_boundaries(dump, second_set, out_path):
|
||||
"""Copy dump, set frames/is_scene_boundary=1 at the given integer seconds."""
|
||||
shutil.copy(dump, out_path)
|
||||
with h5py.File(out_path, "r+") as f:
|
||||
ts = f["frames/timestamp_sec"][:]
|
||||
bnd = np.zeros(len(ts), np.uint8)
|
||||
for i, t in enumerate(ts):
|
||||
if int(round(t)) in second_set:
|
||||
bnd[i] = 1
|
||||
if "frames/is_scene_boundary" in f:
|
||||
f["frames/is_scene_boundary"][:] = bnd
|
||||
else:
|
||||
f["frames"].create_dataset("is_scene_boundary", data=bnd)
|
||||
|
||||
|
||||
def replay(dump, out, mode):
|
||||
argv = [".venv-rocm/bin/python" if False else sys.executable,
|
||||
"scripts/optimizer/replay.py", "--dump", dump, "--gallery", GAL,
|
||||
"--out", out] + CFG
|
||||
if mode:
|
||||
argv += ["--presence-mode", mode]
|
||||
subprocess.run(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=300)
|
||||
return json.loads(Path(out).read_text())
|
||||
|
||||
|
||||
def main():
|
||||
reg = xgb.XGBRegressor(); reg.load_model(MODEL)
|
||||
gk = load_gallery_keys(GAL)
|
||||
tmp = tempfile.mkdtemp()
|
||||
print(f"{'film':24s} {'trackext':>9} {'flood+hist':>11} {'flood+XGB':>10}")
|
||||
agg = {"track_extent": [], "flood_hist": [], "flood_xgb": []}
|
||||
for f in FILMS:
|
||||
dump, xr = f["dump"], f["xray"]
|
||||
out = f"{tmp}/out.json"
|
||||
# A. track_extent
|
||||
a = score_seconds(replay(dump, out, "track_extent"), xr, gallery_keys=gk)
|
||||
# B. flood + histogram cuts (original dump's is_cut; is_scene_boundary=0)
|
||||
b = score_seconds(replay(dump, out, "flood"), xr, gallery_keys=gk)
|
||||
# C. flood + XGBoost boundaries injected
|
||||
inj = f"{tmp}/inj_{f['slug']}.h5"
|
||||
inject_boundaries(dump, xgb_boundary_seconds(reg, dump), inj)
|
||||
c = score_seconds(replay(inj, out, "flood"), xr, gallery_keys=gk)
|
||||
os.unlink(inj)
|
||||
agg["track_extent"].append(a["f1"]); agg["flood_hist"].append(b["f1"])
|
||||
agg["flood_xgb"].append(c["f1"])
|
||||
print(f"{f['name'][:24]:24s} {a['f1']*100:8.1f}% {b['f1']*100:10.1f}% "
|
||||
f"{c['f1']*100:9.1f}%")
|
||||
print(f"\n{'MACRO-MEAN':24s} {np.mean(agg['track_extent'])*100:8.1f}% "
|
||||
f"{np.mean(agg['flood_hist'])*100:10.1f}% {np.mean(agg['flood_xgb'])*100:9.1f}%")
|
||||
json.dump({k: float(np.mean(v)) for k, v in agg.items()},
|
||||
open("experiments/results/scene_boundary/downstream_presence.json", "w"),
|
||||
indent=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
extract_audio_features.py — per-second audio features for scene-boundary detection.
|
||||
|
||||
Audio is often a stronger scene-boundary cue than video: music swells, silence,
|
||||
and ambience changes at narrative scene transitions — exactly the coarse
|
||||
boundaries Amazon X-Ray marks, and exactly what the grayscale video cut detector
|
||||
misses on low-contrast films. This extracts a small per-second feature series per
|
||||
film, aligned to the 1 fps timeline the embedding dumps use, so it can be fused
|
||||
with the RGB-histogram features in train_scene_boundary.py.
|
||||
|
||||
Two-tower design: this is the AUDIO tower's input, mirroring the video tower's
|
||||
per-second RGB histogram. Because the scene model is an LSTM (temporal context
|
||||
comes from the recurrence, not a 2D spectrogram), each second needs only a single
|
||||
log-PSD vector — one FFT over a WIN_SEC window centred on that second. The LSTM
|
||||
sees the sequence of per-second PSDs and learns the boundary dynamics itself.
|
||||
|
||||
Per second t:
|
||||
- log-PSD over [t-WIN/2, t+WIN/2], N_BINS log-spaced frequency bins, L1-norm'd
|
||||
then log1p — the spectral shape (music vs speech vs silence vs ambience),
|
||||
which changes at scene transitions.
|
||||
|
||||
No new dependency: ffmpeg (CLI) decodes the whole track to mono 16 kHz WAV;
|
||||
numpy does the FFT.
|
||||
|
||||
Writes <out_dir>/<slug>.npz with `ts` (second grid) and `feat` [T, N_BINS].
|
||||
|
||||
Usage:
|
||||
python scripts/scene_detector/extract_audio_features.py \
|
||||
--manifest experiments/manifests/films_LVFace_opencv5.json \
|
||||
--file-lut experiments/file-lut.json \
|
||||
--out experiments/dumps/audio_features
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse, json, subprocess, sys, tempfile, os
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from scipy import signal as sps
|
||||
from scipy.io import wavfile
|
||||
|
||||
SR = 16000
|
||||
HOP_SEC = 1.0 # one feature vector per second (matches 1 fps presence grid)
|
||||
WIN_SEC = 4.0 # FFT window per second (centred); >HOP for temporal context
|
||||
N_BINS = 64 # log-spaced frequency bins per second (the audio tower dim)
|
||||
|
||||
|
||||
def decode_mono(path: str) -> np.ndarray:
|
||||
"""Whole-file mono 16 kHz float32 PCM via ffmpeg."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tf:
|
||||
wav = tf.name
|
||||
try:
|
||||
subprocess.run(
|
||||
["ffmpeg", "-v", "error", "-y", "-i", path,
|
||||
"-ac", "1", "-ar", str(SR), "-f", "wav", wav],
|
||||
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
sr, x = wavfile.read(wav)
|
||||
if x.dtype == np.int16:
|
||||
x = x.astype(np.float32) / 32768.0
|
||||
else:
|
||||
x = x.astype(np.float32)
|
||||
return x
|
||||
finally:
|
||||
try: os.unlink(wav)
|
||||
except OSError: pass
|
||||
|
||||
|
||||
def _logbin_edges(win_samples: int) -> np.ndarray:
|
||||
"""Indices into the rfft output that bound N_BINS log-spaced freq bands."""
|
||||
nfreq = win_samples // 2 + 1
|
||||
# log-space from bin 1 (skip DC) to Nyquist; unique integer edges
|
||||
edges = np.unique(np.geomspace(1, nfreq - 1, N_BINS + 1).astype(int))
|
||||
return edges
|
||||
|
||||
|
||||
def features(mono: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Return (ts[T], feat[T, N_BINS]) — one per-second log-PSD row.
|
||||
|
||||
One FFT per second over a WIN_SEC window centred on that second. Power is
|
||||
pooled into N_BINS log-spaced frequency bands (mel-like), L1-normalised across
|
||||
bands (so loudness doesn't dominate — the SHAPE is the scene cue), then
|
||||
log1p-compressed. The LSTM downstream supplies temporal context, so no
|
||||
spectrogram/2D input is needed."""
|
||||
hop = int(SR * HOP_SEC)
|
||||
win = int(SR * WIN_SEC)
|
||||
T = len(mono) // hop
|
||||
if T == 0:
|
||||
return np.zeros(0), np.zeros((0, N_BINS), np.float32)
|
||||
edges = _logbin_edges(win)
|
||||
nb = len(edges) - 1
|
||||
hann = sps.windows.hann(win)
|
||||
feat = np.zeros((T, nb), np.float32)
|
||||
half = win // 2
|
||||
for t in range(T):
|
||||
centre = t * hop + hop // 2
|
||||
s = centre - half
|
||||
seg = mono[max(0, s): s + win]
|
||||
if len(seg) < win: # pad edges
|
||||
seg = np.pad(seg, (0, win - len(seg)))
|
||||
psd = np.abs(np.fft.rfft(seg * hann))**2 + 1e-12
|
||||
band = np.array([psd[edges[i]:edges[i+1]].sum() for i in range(nb)])
|
||||
band /= band.sum() # normalise shape, drop loudness
|
||||
feat[t] = np.log1p(band * 1e3)
|
||||
ts = np.arange(T, dtype=np.float64)
|
||||
return ts, feat
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--manifest", required=True)
|
||||
ap.add_argument("--file-lut", default="experiments/file-lut.json")
|
||||
ap.add_argument("--out", default="experiments/dumps/audio_features")
|
||||
args = ap.parse_args()
|
||||
films = json.load(open(args.manifest))
|
||||
lut = json.load(open(args.file_lut))
|
||||
Path(args.out).mkdir(parents=True, exist_ok=True)
|
||||
for f in films:
|
||||
slug = f["slug"]
|
||||
outp = Path(args.out) / f"{slug}.npz"
|
||||
if outp.exists():
|
||||
print(f"[audio] {slug}: exists, skip", file=sys.stderr); continue
|
||||
path = lut.get(slug)
|
||||
if not path or not os.path.exists(path):
|
||||
print(f"[audio] {slug}: movie missing ({path})", file=sys.stderr); continue
|
||||
try:
|
||||
mono = decode_mono(path)
|
||||
ts, feat = features(mono)
|
||||
np.savez_compressed(outp, ts=ts, feat=feat)
|
||||
print(f"[audio] {slug}: {len(ts)}s feat{feat.shape} → {outp.name}",
|
||||
file=sys.stderr)
|
||||
except subprocess.CalledProcessError:
|
||||
print(f"[audio] {slug}: ffmpeg decode failed", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the scene-boundary-detector report figures from saved results.
|
||||
Data-driven, reproducible, no video needed. Writes PNGs to docs/assets/images/."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
OUT = Path("docs/assets/images")
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
plt.rcParams.update({"font.size": 11, "axes.splines.top" if False else "axes.grid": True,
|
||||
"axes.axisbelow": True, "grid.alpha": 0.3, "figure.dpi": 130})
|
||||
|
||||
FILMS = ["Benny & Joon","Café Society","Downton Abbey","Lord of War","Lovelace",
|
||||
"Many Saints","Scarface","Sound of Metal","Valerian"]
|
||||
# per-film presence F1 (downstream_loo run): track_extent, flood+grayscale, flood+learned(LOO)
|
||||
TE = [77.3,59.1,41.0,74.8,70.3,37.5,62.6,75.0,65.6]
|
||||
FG = [80.2,62.2,51.8,77.1,74.0,43.9,40.9,78.1,67.7]
|
||||
FL = [78.2,69.8,78.6,77.8,78.2,53.4,74.9,86.8,76.2]
|
||||
|
||||
# ── Figure 1: per-film presence F1, three boundary sources ───────────────────
|
||||
def fig_presence():
|
||||
x = np.arange(len(FILMS)); w = 0.26
|
||||
fig, ax = plt.subplots(figsize=(11,5))
|
||||
ax.bar(x-w, TE, w, label="track-extent (flood off)", color="#9aa7b4")
|
||||
ax.bar(x, FG, w, label="flood + grayscale cuts", color="#e07a5f")
|
||||
ax.bar(x+w, FL, w, label="flood + learned detector (LOO)", color="#3d7ea6")
|
||||
ax.set_ylabel("per-second X-Ray presence F1 (%)")
|
||||
ax.set_title("Actor-presence accuracy by flood-fill boundary source (leave-one-out)")
|
||||
ax.set_xticks(x); ax.set_xticklabels(FILMS, rotation=30, ha="right")
|
||||
ax.set_ylim(0,100); ax.legend(loc="upper left", framealpha=0.9)
|
||||
# annotate the two headline swings
|
||||
ax.annotate("grayscale flood\nBREAKS Scarface", xy=(6, 40.9), xytext=(5.1, 20),
|
||||
fontsize=9, color="#b23", ha="center",
|
||||
arrowprops=dict(arrowstyle="->", color="#b23"))
|
||||
ax.annotate("+37pp", xy=(2+w, 78.6), xytext=(2+w, 90), fontsize=9,
|
||||
color="#3d7ea6", ha="center",
|
||||
arrowprops=dict(arrowstyle="->", color="#3d7ea6"))
|
||||
macro=[np.mean(TE),np.mean(FG),np.mean(FL)]
|
||||
ax.text(0.99,0.02,f"macro: {macro[0]:.1f}% / {macro[1]:.1f}% / {macro[2]:.1f}%",
|
||||
transform=ax.transAxes, ha="right", va="bottom", fontsize=10,
|
||||
bbox=dict(boxstyle="round", fc="#f4f4f4", ec="#ccc"))
|
||||
fig.tight_layout(); fig.savefig(OUT/"scene_presence_by_source.png"); plt.close(fig)
|
||||
|
||||
# ── Figure 2: macro presence F1 — the progression ───────────────────────────
|
||||
def fig_macro():
|
||||
labels=["track-extent","flood +\ngrayscale","flood +\nlearned (LOO)"]
|
||||
vals=[np.mean(TE),np.mean(FG),np.mean(FL)]
|
||||
fig,ax=plt.subplots(figsize=(6,4.5))
|
||||
bars=ax.bar(labels,vals,color=["#9aa7b4","#e07a5f","#3d7ea6"])
|
||||
for b,v in zip(bars,vals): ax.text(b.get_x()+b.get_width()/2, v+1, f"{v:.1f}%",
|
||||
ha="center", fontsize=11, fontweight="bold")
|
||||
ax.set_ylabel("macro presence F1 (%)"); ax.set_ylim(0,90)
|
||||
ax.set_title("Flood-fill boundary source → presence accuracy")
|
||||
fig.tight_layout(); fig.savefig(OUT/"scene_presence_macro.png"); plt.close(fig)
|
||||
|
||||
# ── Figure 3: feature/model evolution (boundary-F1 development) ──────────────
|
||||
# Two panels, because the development curve and the shipped result are measured
|
||||
# at DIFFERENT tolerances and must not be plotted on one axis:
|
||||
# left — relative feature progress at the strict ±2 s tolerance (how the LSTM
|
||||
# experiments were scored; establishes which features helped)
|
||||
# right — the shipped XGBoost detector at the ±20 s tolerance the pipeline
|
||||
# actually uses and scores at (grayscale vs learned-LOO vs train-all)
|
||||
def fig_evolution():
|
||||
fig,(axl,axr)=plt.subplots(1,2,figsize=(11,4.5),gridspec_kw={"width_ratios":[1.15,1]})
|
||||
|
||||
steps=["grayscale\nbaseline","raw-hist\nLSTM","delta\nLSTM","XGBoost\n(delta+debounce)"]
|
||||
dev=[7.2,7.5,10.8,15.2] # boundary-F1 @±2s during LSTM-era development
|
||||
axl.plot(steps,dev,marker="o",color="#9aa7b4",lw=2,ms=8)
|
||||
for i,v in enumerate(dev): axl.text(i,v+0.4,f"{v:.1f}%",ha="center",fontsize=9)
|
||||
axl.set_ylabel("boundary F1 @±2 s (%)")
|
||||
axl.set_title("Feature progress (strict ±2 s)")
|
||||
axl.set_ylim(0,18)
|
||||
|
||||
# shipped detector at the ±20s tolerance the pipeline uses — real measured
|
||||
# macro numbers: grayscale (xgb_report gray_F1), learned LOO, learned train-all
|
||||
names=["grayscale","learned\n(LOO)","learned\n(train-all)"]
|
||||
f20=[29.8,44.1,72.9]; cols=["#e07a5f","#3d7ea6","#8fb8cf"]
|
||||
bars=axr.bar(names,f20,color=cols)
|
||||
for b,v in zip(bars,f20): axr.text(b.get_x()+b.get_width()/2,v+1.2,f"{v:.1f}%",
|
||||
ha="center",fontsize=10,fontweight="bold")
|
||||
axr.set_ylabel("boundary F1 @±20 s (%)")
|
||||
axr.set_title("Shipped detector (±20 s, macro/9 films)")
|
||||
axr.set_ylim(0,80)
|
||||
fig.suptitle("Detector development, and where it landed",fontsize=13)
|
||||
fig.tight_layout(); fig.savefig(OUT/"scene_detector_evolution.png"); plt.close(fig)
|
||||
|
||||
import csv as _csv
|
||||
|
||||
# ── Figure 4: DE convergence (the 10-knob presence sweep) ────────────────────
|
||||
def fig_de():
|
||||
import json
|
||||
rows=[json.loads(l) for l in open("experiments/trajectories/lvface_opencv5_10knob.FINAL.jsonl")]
|
||||
f1=[r["f1"]*100 for r in rows]
|
||||
run_best=np.maximum.accumulate(f1)
|
||||
fig,ax=plt.subplots(figsize=(8,4.5))
|
||||
ax.scatter(range(len(f1)),f1,s=8,alpha=0.35,color="#9aa7b4",label="candidate")
|
||||
ax.plot(run_best,color="#3d7ea6",lw=2,label="best so far")
|
||||
ax.set_xlabel("DE evaluation"); ax.set_ylabel("macro presence F1 (%)")
|
||||
ax.set_title("10-knob presence sweep (Differential Evolution)")
|
||||
ax.legend(loc="lower right"); ax.set_ylim(0, max(f1)+8)
|
||||
ax.text(0.02,0.95,f"optimum {max(f1):.1f}%",transform=ax.transAxes,va="top",
|
||||
fontsize=10,bbox=dict(boxstyle="round",fc="#f4f4f4",ec="#ccc"))
|
||||
fig.tight_layout(); fig.savefig(OUT/"de_search_landscape.png"); plt.close(fig)
|
||||
|
||||
# ── Figure 5: calibration curve (similarity → P(match)) ──────────────────────
|
||||
def fig_calibration():
|
||||
sims,ps=[],[]
|
||||
with open("experiments/galleries/gallery_LVFace-B_Glint360K.h5.calib_cache.csv") as f:
|
||||
for r in _csv.DictReader(f):
|
||||
sims.append(float(r["similarity"])); ps.append(float(r["p_match"]))
|
||||
fig,ax=plt.subplots(figsize=(6.5,4.5))
|
||||
ax.plot(sims,ps,color="#3d7ea6",lw=2)
|
||||
ax.axhline(0.485,ls="--",color="#e07a5f",lw=1,label="shipped threshold 0.485")
|
||||
ax.set_xlabel("cosine similarity"); ax.set_ylabel("calibrated P(match)")
|
||||
ax.set_title("LVFace-B Glint360K calibration"); ax.set_xlim(-1,1); ax.legend()
|
||||
fig.tight_layout(); fig.savefig(OUT/"calibration_curves.png"); plt.close(fig)
|
||||
|
||||
# ── Figure 6: holdout F1 by film (learned detector, LOO) ─────────────────────
|
||||
def fig_holdout():
|
||||
order=np.argsort(FL)
|
||||
fig,ax=plt.subplots(figsize=(8,4.5))
|
||||
y=np.arange(len(FILMS))
|
||||
ax.barh(y,[FL[i] for i in order],color="#3d7ea6")
|
||||
ax.set_yticks(y); ax.set_yticklabels([FILMS[i] for i in order])
|
||||
ax.set_xlabel("presence F1 (%), learned detector (LOO)")
|
||||
ax.set_title("Per-film presence F1 — leave-one-out")
|
||||
ax.axvline(np.mean(FL),ls="--",color="#333",lw=1)
|
||||
ax.text(np.mean(FL)+1,0.2,f"macro {np.mean(FL):.1f}%",fontsize=9)
|
||||
for i,idx in enumerate(order): ax.text(FL[idx]+0.5,i,f"{FL[idx]:.0f}",va="center",fontsize=8)
|
||||
ax.set_xlim(0,100)
|
||||
fig.tight_layout(); fig.savefig(OUT/"holdout_f1_by_film.png"); plt.close(fig)
|
||||
|
||||
fig_presence(); fig_macro(); fig_evolution(); fig_de(); fig_calibration(); fig_holdout()
|
||||
print("wrote:", *(p.name for p in sorted(OUT.glob("*.png"))))
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
rematch_frames.py — remake each named July frame example against the CURRENT
|
||||
pipeline. For a file named <film>_<...>_<actor>.jpg, find a second in this film's
|
||||
replay where that actor is drawn in the matching class (FP for *_fpi_*, TP for
|
||||
*_tp/perfect*), extract + annotate it, and write it over the doc asset. Reports
|
||||
which July examples no longer reproduce (honest — the config/model changed).
|
||||
|
||||
Needs the per-film raw replay (experiments/dumps + replay --raw-out already run by
|
||||
regen_frame_examples.sh into the scratch predictions). Reads those.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json, sys, subprocess, re
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, "scripts/optimizer"); sys.path.insert(0, "scripts/validation")
|
||||
import dump_error_frames as D
|
||||
from second_score import load_second_timeline, _match
|
||||
|
||||
SP = Path("/tmp/claude-1000/-home-dtourolle-Development-scene-actor-extraction/"
|
||||
"c579f8cf-2974-4cbd-be88-afec68dbbf58/scratchpad")
|
||||
ASSETS = Path("docs/assets/images")
|
||||
LUT = json.load(open("experiments/file-lut.json"))
|
||||
FILMS = json.load(open("experiments/manifests/films_LVFace_opencv5.json"))
|
||||
XR = {f["slug"]: f["xray"] for f in FILMS}
|
||||
|
||||
# filename → (film slug, actor substring, class). class: "fp" | "tp".
|
||||
# actor substring is matched case-insensitively against drawn names.
|
||||
JOBS = {
|
||||
"lord_of_war_fpi_reddick.jpg": ("Lord_of_War", "reddick", "fp"),
|
||||
"lord_of_war_fpi_shumbris.jpg": ("Lord_of_War", "shumbris", "fp"),
|
||||
"lord_of_war_fpi_reagan_photo.jpg": ("Lord_of_War", "reagan", "fp"),
|
||||
"lovelace_fpi_sevigny.jpg": ("Lovelace", "sevigny", "fp"),
|
||||
"lovelace_robert_patrick_fpi.jpg": ("Lovelace", "patrick", "fp"),
|
||||
"lovelace_perfect_second.jpg": ("Lovelace", None, "tp"),
|
||||
"lovelace_polygraph_bridged.jpg": ("Lovelace", None, "tp"),
|
||||
"many_saints_fpi_deschanel.jpg": ("The_Many_Saints_of_Newark", "deschanel", "fp"),
|
||||
"many_saints_fpi_gardner.jpg": ("The_Many_Saints_of_Newark", "gardner", "fp"),
|
||||
"many_saints_fpi_yates.jpg": ("The_Many_Saints_of_Newark", "yates", "fp"),
|
||||
"many_saints_outofcast_fpi.jpg": ("The_Many_Saints_of_Newark", None, "fp"),
|
||||
"scarface_fpi_alley.jpg": ("Scarface", "alley", "fp"),
|
||||
"downton_crew_fn.jpg": ("Downton_Abbey__A_New_Era", None, "tp"),
|
||||
"downton_wedding_couple.jpg": ("Downton_Abbey__A_New_Era", None, "tp"),
|
||||
"downton_tp_example.jpg": ("Downton_Abbey__A_New_Era", None, "tp"),
|
||||
"valerian_screen_call.jpg": ("Valerian_and_the_City_of_a_Thousand_Plan", None, "tp"),
|
||||
"cafe_society_rapid_cut.jpg": ("Café_Society", None, "tp"),
|
||||
# germar_beats_xray / downton_funeral_19of20 are July-narrative-specific; skip.
|
||||
}
|
||||
|
||||
|
||||
def gt_keysets(slug):
|
||||
tl, _, _ = load_second_timeline(XR[slug])
|
||||
return tl
|
||||
|
||||
|
||||
def main():
|
||||
made, missing = [], []
|
||||
for fname, (slug, actor, cls) in JOBS.items():
|
||||
raw = SP / f"{slug}_raw.jsonl"
|
||||
if not raw.exists():
|
||||
missing.append((fname, "no raw replay")); continue
|
||||
tl = gt_keysets(slug)
|
||||
best = None # (t, actor_dict, fp_keys)
|
||||
for line in open(raw):
|
||||
d = json.loads(line)
|
||||
t = int(d["timestamp_sec"])
|
||||
drawn = [a for a in d.get("visible_actors", []) if a.get("actor_idx", -1) >= 0]
|
||||
if not drawn:
|
||||
continue
|
||||
gt = tl.get(t, [])
|
||||
fp_keys = {D._name_key(a["name"]) for a in drawn
|
||||
if not any(D._name_key(a["name"]) in g for g in gt)}
|
||||
for a in drawn:
|
||||
nk = D._name_key(a["name"]); is_fp = nk in fp_keys
|
||||
if actor and actor not in a["name"].lower():
|
||||
continue
|
||||
match = (is_fp if cls == "fp" else not is_fp)
|
||||
if not match:
|
||||
continue
|
||||
# prefer high similarity + a clean single-subject frame
|
||||
score = a["similarity"] - 0.05*len(drawn)
|
||||
if best is None or score > best[3]:
|
||||
best = (t, d, fp_keys, score)
|
||||
if best is None:
|
||||
missing.append((fname, f"no current {cls} for {actor or 'any'}")); continue
|
||||
t, d, fp_keys, _ = best
|
||||
# FN names at t: X-Ray scene cast whose keyset matches no drawn face.
|
||||
gt = tl.get(t, [])
|
||||
drawn_keys = [set(D._name_key(a["name"]).replace("name:", "") for _ in [0])
|
||||
for a in d.get("visible_actors", []) if a.get("actor_idx", -1) >= 0]
|
||||
drawn_ks = [D._name_key(a["name"]) for a in d.get("visible_actors", [])
|
||||
if a.get("actor_idx", -1) >= 0]
|
||||
fn_names = []
|
||||
for ga in gt:
|
||||
if not any(dk in ga for dk in drawn_ks):
|
||||
readable = sorted(x for x in ga
|
||||
if not x.startswith("imdb:") and not x.startswith("tmdb:")
|
||||
and not x.startswith("jf:"))
|
||||
if readable:
|
||||
fn_names.append(readable[0])
|
||||
out = ASSETS / fname
|
||||
try:
|
||||
D.extract_frame(LUT[slug], t, out)
|
||||
D.draw_annotations(out, d["visible_actors"], fp_keys=fp_keys,
|
||||
fn_names=fn_names)
|
||||
made.append((fname, slug, t))
|
||||
except subprocess.CalledProcessError:
|
||||
missing.append((fname, "ffmpeg failed"))
|
||||
|
||||
print("=== remade ===")
|
||||
for f, s, t in made: print(f" {f} ({s} t={t}s)")
|
||||
print("=== no current equivalent (left as-is / flag in doc) ===")
|
||||
for f, why in missing: print(f" {f} — {why}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone DE-optimised AUDIO scene cutter: tune a matched-filter ramp on the
|
||||
audio log-PSD to maximise X-Ray boundary F1. No neural net. Holdout films are
|
||||
never seen in training. Writes the tuned filter + held-out performance."""
|
||||
import sys, json, os
|
||||
import numpy as np
|
||||
sys.path.insert(0, "scripts/scene_detector")
|
||||
from de_ramp import load_series, xray_bounds, ramp_kernel, response, boundary_f1
|
||||
from scipy.optimize import differential_evolution
|
||||
|
||||
MANIFEST = "experiments/manifests/films_LVFace_opencv5.json"
|
||||
AUDIO = "experiments/dumps/audio_features"
|
||||
HOLDOUT = {"Scarface", "Sound_of_Metal", "Valerian_and_the_City_of_a_Thousand_Plan"}
|
||||
OUT = "experiments/results/scene_boundary/de_audio_cutter.json"
|
||||
|
||||
films = json.load(open(MANIFEST))
|
||||
train = [f for f in films if f["slug"] not in HOLDOUT]
|
||||
val = [f for f in films if f["slug"] in HOLDOUT]
|
||||
tr = [(load_series(f["dump"], AUDIO, "audio")[0], xray_bounds(f["xray"])) for f in train]
|
||||
va = [(f["slug"], load_series(f["dump"], AUDIO, "audio")[0], xray_bounds(f["xray"])) for f in val]
|
||||
print(f"DE AUDIO cutter: {len(tr)} train, holdout {sorted(HOLDOUT)}", flush=True)
|
||||
|
||||
def neg_f1(x):
|
||||
H = int(round(x[0])); gamma = x[1]; dead = int(round(x[2])); pct = x[3]
|
||||
if H < 1 or dead >= H: return 0.0
|
||||
w = ramp_kernel(H, gamma, dead)
|
||||
return -float(np.mean([boundary_f1(response(s, w, H), b, pct) for s, b in tr]))
|
||||
|
||||
evals = [0]
|
||||
def cb(xk, convergence):
|
||||
evals[0] += 1
|
||||
print(f"[de-audio] gen {evals[0]} convergence={convergence:.3f}", flush=True)
|
||||
|
||||
res = differential_evolution(neg_f1, [(1, 10), (0.3, 3.0), (0, 4), (80, 98)],
|
||||
seed=0, popsize=12, maxiter=25, tol=1e-4,
|
||||
polish=False, callback=cb)
|
||||
H = int(round(res.x[0])); gamma = float(res.x[1]); dead = int(round(res.x[2])); pct = float(res.x[3])
|
||||
print(f"\n=== DE-OPTIMISED AUDIO SCENE CUTTER ===", flush=True)
|
||||
print(f"tuned ramp: H={H}s gamma={gamma:.2f} dead={dead}s threshold_pct={pct:.0f}", flush=True)
|
||||
print(f"train boundary-F1: {-res.fun*100:.1f}%\n", flush=True)
|
||||
print("held-out (audio-only, P/R/F1 ±2s):", flush=True)
|
||||
w = ramp_kernel(H, gamma, dead)
|
||||
rep = {"H": H, "gamma": gamma, "dead": dead, "pct": pct,
|
||||
"train_f1": float(-res.fun), "holdout": sorted(HOLDOUT), "films": {}}
|
||||
for slug, s, b in va:
|
||||
r = response(s, w, H); thr = np.percentile(r, pct); pred = np.where(r > thr)[0]
|
||||
bidx = [int(x) for x in b if int(x) < len(r)]
|
||||
tp_p = sum(any(abs(p-i) <= 2 for i in bidx) for p in pred)
|
||||
tp_t = sum(any(abs(p-i) <= 2 for p in pred) for i in bidx)
|
||||
P = tp_p/max(len(pred), 1); R = tp_t/max(len(bidx), 1); F = 2*P*R/(P+R) if P+R else 0
|
||||
rep["films"][slug] = {"P": P, "R": R, "F1": F, "n_pred": len(pred), "n_true": len(bidx)}
|
||||
print(f" {slug[:26]:26s} P={P*100:4.0f}% R={R*100:4.0f}% F1={F*100:4.0f}% "
|
||||
f"({len(pred)} preds/{len(bidx)} true)", flush=True)
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
json.dump(rep, open(OUT, "w"), indent=2)
|
||||
print(f"\nsaved → {OUT}", flush=True)
|
||||
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
train_scene_boundary.py — learn a scene-boundary detector from per-frame RGB
|
||||
histograms (video tower) and per-second audio log-PSD (audio tower), against
|
||||
Amazon X-Ray scene boundaries.
|
||||
|
||||
Motivation: the shipped grayscale histogram-correlation cut detector is blind on
|
||||
low-contrast grades — on Scarface it fired ONCE in 10,204 frames, so flood-fill
|
||||
presence (which snaps to detected boundaries) floods every actor across the whole
|
||||
film (P=26%). X-Ray ships real scene boundaries (scenes.csv); the dumps carry a
|
||||
per-frame RGB histogram (frames/rgb_hist), and extract_audio_features.py provides
|
||||
a per-second audio log-PSD. This learns a per-second boundary probability.
|
||||
|
||||
TWO-TOWER, ABLATABLE. We do NOT assume audio helps video — we measure it. Each
|
||||
modality has its own encoder+BiLSTM; --modality selects video / audio / fused
|
||||
(both towers concatenated before a shared head). The script reports all three
|
||||
arms on the held-out films so the ablation decides whether audio supports video.
|
||||
|
||||
Video features per second: rgb_hist (96) + L1 deltas to t-1,t-2,t+1 + per-channel
|
||||
correlation to t-1. Audio features: the log-PSD row (+ its L1 delta to t-1).
|
||||
Label: 1 if an X-Ray scene starts within ±TOL_SEC of t.
|
||||
|
||||
Usage:
|
||||
python scripts/scene_detector/train_scene_boundary.py \
|
||||
--manifest experiments/manifests/films_LVFace_opencv5.json \
|
||||
--audio-dir experiments/dumps/audio_features \
|
||||
--holdout Scarface Sound_of_Metal \
|
||||
--modality all --out experiments/results/scene_boundary
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse, csv, json, sys
|
||||
from pathlib import Path
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
TOL_SEC = 2.0
|
||||
BINS = 32 # per channel, matches embedding_dump_node.hpp kHistBins
|
||||
RAMP_SCALES = [2, 4, 6, 8, 10] # multi-scale matched-filter half-widths (seconds)
|
||||
SCENE_TAU = 205.0 # corpus mean X-Ray scene length (central-60min); debounce scale
|
||||
|
||||
|
||||
def debounce_phase(delta_signal: np.ndarray, tau: float = SCENE_TAU,
|
||||
peak_pct: float = 90.0) -> np.ndarray:
|
||||
"""A scene-length-scaled 'how overdue is a boundary' feature, [T,2].
|
||||
|
||||
Encodes the prior that scenes don't restart moments apart. From the strong
|
||||
peaks of a change signal (the presumed boundaries so far), track time since
|
||||
the last peak and turn it into:
|
||||
phase = min(1, dt/tau) — 0 just after a boundary (suppress), 1 when a new
|
||||
one is overdue (permit), rising over ~one mean
|
||||
scene length (tau).
|
||||
decay = exp(-dt/tau) — the complementary refractory (high right after,
|
||||
decaying away). Two views of the same clock so
|
||||
the LSTM can use whichever helps.
|
||||
Reference peaks come from the change signal itself (not the model's own
|
||||
output), so the feature is static and causal-ish (uses only |Δ| already in
|
||||
the sequence)."""
|
||||
T = len(delta_signal)
|
||||
thr = np.percentile(delta_signal, peak_pct)
|
||||
# Vectorised time-since-last-peak: index of the most recent peak at or before
|
||||
# each t (running max of peak indices), then dt = t - that index.
|
||||
idx = np.arange(T)
|
||||
peak_idx = np.where(delta_signal > thr, idx, -1)
|
||||
last = np.maximum.accumulate(peak_idx) # most recent peak index ≤ t
|
||||
dt = (idx - last).astype(np.float32)
|
||||
dt[last < 0] = tau # before the first peak: treat as "overdue"
|
||||
phase = np.minimum(1.0, dt / tau)
|
||||
decay = np.exp(-dt / tau)
|
||||
return np.stack([phase, decay], 1).astype(np.float32)
|
||||
|
||||
|
||||
def ramp_bank(series: np.ndarray) -> np.ndarray:
|
||||
"""Antisymmetric matched-filter responses at RAMP_SCALES → [T, len(scales)].
|
||||
|
||||
A scene boundary is a step in the feature series; a signed ramp kernel
|
||||
convolved with it responds at the transition and ~0 inside a stable scene.
|
||||
Different films' boundaries peak at different scales (measured: sharp cuts at
|
||||
H=2s, gradual shifts wider), so we hand the model the whole bank and let it
|
||||
weight the scales rather than committing to one width."""
|
||||
# Vectorised: the ramp response at t is || sum_l w(l)·series[t+l] ||, i.e. a
|
||||
# 1D correlation of the kernel with each feature bin, then an L2 over bins. Do
|
||||
# it as one convolution per bin (np.convolve, 'same') instead of the per-frame
|
||||
# Python loop — ~100x faster, which matters at ~60k frames × 9 films.
|
||||
T, D = series.shape
|
||||
out = np.zeros((T, len(RAMP_SCALES)), np.float32)
|
||||
for k, H in enumerate(RAMP_SCALES):
|
||||
lags = np.arange(-H, H + 1)
|
||||
w = (np.sign(lags) * (np.abs(lags) / max(H, 1))).astype(np.float64)
|
||||
# correlation = convolution with the reversed kernel; ramp is antisym so
|
||||
# reversing negates it — sign folds into the L2 norm, so either is fine.
|
||||
acc = np.zeros((T, D))
|
||||
for d in range(D):
|
||||
acc[:, d] = np.convolve(series[:, d], w[::-1], mode="same")
|
||||
out[:, k] = np.linalg.norm(acc, axis=1)
|
||||
return out
|
||||
|
||||
|
||||
# ── data ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_xray_boundaries(xray_dir: str) -> list[float]:
|
||||
starts = []
|
||||
with open(Path(xray_dir) / "scenes.csv", newline="") as f:
|
||||
for r in csv.DictReader(f):
|
||||
s = float(r["start"]) / 1000.0
|
||||
if s > 0.5:
|
||||
starts.append(s)
|
||||
return sorted(starts)
|
||||
|
||||
|
||||
def _znorm(s):
|
||||
return (s - s.mean(0)) / (s.std(0) + 1e-6)
|
||||
|
||||
|
||||
def video_features(hist: np.ndarray) -> np.ndarray:
|
||||
"""DELTA-FORWARD video features.
|
||||
|
||||
Measured on the corpus: the raw 96-bin histogram barely separates X-Ray
|
||||
boundaries (~1.4x boundary response) — it encodes what the frame *looks like*,
|
||||
not that it *changed* — while the symmetric histogram delta |hist(t+k)-hist(t-k)|
|
||||
separates them strongly (|Δ 1s| ~4-5x). Feeding 96 dims of raw content
|
||||
diluted the LSTM, so we drop it and lead with multi-scale symmetric deltas,
|
||||
keeping only a compact per-channel-energy summary as context.
|
||||
|
||||
Channels:
|
||||
- symmetric L1 delta |hist(t+k) - hist(t-k)| at k=1,2,4,8s (the boundary cue)
|
||||
- per-channel correlation to the previous second (3)
|
||||
- the multi-scale antisymmetric ramp bank (regional step response)
|
||||
- 3-D per-channel total energy (compact content context, not the full hist)
|
||||
"""
|
||||
T = hist.shape[0]
|
||||
def sym_delta(k):
|
||||
fwd = np.roll(hist, -k, 0); fwd[-k:] = hist[-1]
|
||||
bwd = np.roll(hist, k, 0); bwd[:k] = hist[0]
|
||||
return np.abs(fwd - bwd).sum(1, keepdims=True)
|
||||
deltas = np.concatenate([sym_delta(k) for k in (1, 2, 4, 8)], 1)
|
||||
p1 = np.roll(hist, 1, 0); p1[0] = hist[0]
|
||||
corr = np.zeros((T, 3), np.float32)
|
||||
for c in range(3):
|
||||
a = hist[:, c*BINS:(c+1)*BINS]; b = p1[:, c*BINS:(c+1)*BINS]
|
||||
am, bm = a - a.mean(1, keepdims=True), b - b.mean(1, keepdims=True)
|
||||
corr[:, c] = (am*bm).sum(1) / (np.sqrt((am*am).sum(1)*(bm*bm).sum(1))+1e-9)
|
||||
energy = np.stack([hist[:, c*BINS:(c+1)*BINS].sum(1) for c in range(3)], 1)
|
||||
# scene-length-scaled debounce: 'how overdue is a boundary', from the |Δ1s|
|
||||
# change signal. Encodes that scenes don't restart moments apart (tau=205s).
|
||||
debounce = debounce_phase(deltas[:, 0])
|
||||
return np.concatenate([deltas, corr, ramp_bank(_znorm(hist)), energy, debounce],
|
||||
1).astype(np.float32)
|
||||
|
||||
|
||||
def audio_features(psd: np.ndarray) -> np.ndarray:
|
||||
"""DELTA-FORWARD audio features (same principle as video).
|
||||
|
||||
The raw log-PSD is spectral CONTENT (what the audio sounds like), which the DE
|
||||
cutter showed barely localizes X-Ray boundaries. Lead with the CHANGE in the
|
||||
spectrum — symmetric PSD deltas |psd(t+k)-psd(t-k)| at several scales — plus
|
||||
the ramp bank and a compact total-energy summary; drop the full raw PSD.
|
||||
"""
|
||||
def sym_delta(k):
|
||||
fwd = np.roll(psd, -k, 0); fwd[-k:] = psd[-1]
|
||||
bwd = np.roll(psd, k, 0); bwd[:k] = psd[0]
|
||||
return np.abs(fwd - bwd).sum(1, keepdims=True)
|
||||
deltas = np.concatenate([sym_delta(k) for k in (1, 2, 4, 8)], 1)
|
||||
energy = psd.sum(1, keepdims=True)
|
||||
debounce = debounce_phase(deltas[:, 0])
|
||||
return np.concatenate([deltas, ramp_bank(_znorm(psd)), energy, debounce],
|
||||
1).astype(np.float32)
|
||||
|
||||
|
||||
def build_film(dump: str, xray_dir: str, audio_dir: str | None):
|
||||
with h5py.File(dump, "r") as f:
|
||||
if "frames/rgb_hist" not in f:
|
||||
raise SystemExit(f"{dump}: no frames/rgb_hist — re-dump with the "
|
||||
f"RGB-histogram build of dump_embeddings.")
|
||||
hist = f["frames/rgb_hist"][:].astype(np.float32)
|
||||
ts = f["frames/timestamp_sec"][:]
|
||||
is_cut = f["frames/is_cut"][:].astype(np.int64)
|
||||
V = video_features(hist)
|
||||
A = None
|
||||
if audio_dir:
|
||||
slug = Path(dump).stem.replace("dump_", "")
|
||||
ap = Path(audio_dir) / f"{slug}.npz"
|
||||
if ap.exists():
|
||||
z = np.load(ap); af = z["feat"]
|
||||
# align audio (per-second) to the video frame grid by index; pad/truncate
|
||||
T = len(ts); B = af.shape[1]
|
||||
aligned = np.zeros((T, B), np.float32)
|
||||
m = min(T, len(af)); aligned[:m] = af[:m]
|
||||
A = audio_features(aligned)
|
||||
y = np.zeros(len(ts), np.float32)
|
||||
for b in load_xray_boundaries(xray_dir):
|
||||
y[np.abs(ts - b) <= TOL_SEC] = 1.0
|
||||
return V, A, y, is_cut, ts
|
||||
|
||||
|
||||
# ── model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Tower(nn.Module):
|
||||
"""Per-second encoder → BiLSTM → per-timestep embedding."""
|
||||
def __init__(self, in_dim, hidden=64, out=64):
|
||||
super().__init__()
|
||||
self.enc = nn.Sequential(nn.Linear(in_dim, hidden), nn.ReLU())
|
||||
self.lstm = nn.LSTM(hidden, out, batch_first=True, bidirectional=True)
|
||||
def forward(self, x):
|
||||
h, _ = self.lstm(self.enc(x))
|
||||
return h # [B,T,2*out]
|
||||
|
||||
|
||||
class BoundaryNet(nn.Module):
|
||||
def __init__(self, v_dim, a_dim, modality):
|
||||
super().__init__()
|
||||
self.modality = modality
|
||||
feat = 0
|
||||
if modality in ("video", "fused"):
|
||||
self.vtower = Tower(v_dim); feat += 128
|
||||
if modality in ("audio", "fused"):
|
||||
self.atower = Tower(a_dim); feat += 128
|
||||
self.head = nn.Sequential(nn.Linear(feat, 32), nn.ReLU(), nn.Linear(32, 1))
|
||||
def forward(self, v, a):
|
||||
parts = []
|
||||
if self.modality in ("video", "fused"): parts.append(self.vtower(v))
|
||||
if self.modality in ("audio", "fused"): parts.append(self.atower(a))
|
||||
return self.head(torch.cat(parts, -1)).squeeze(-1)
|
||||
|
||||
|
||||
def nms_peaks(prob, thr=0.5, min_gap=5):
|
||||
"""Collapse each run of adjacent above-threshold seconds to its single peak.
|
||||
Without this, a model that fires 5 consecutive seconds around one true
|
||||
boundary is scored as 1 TP + 4 FP — an aggregation artifact, not an error."""
|
||||
cand = np.where(prob > thr)[0]
|
||||
if len(cand) == 0:
|
||||
return []
|
||||
peaks, group = [], [cand[0]]
|
||||
for c in cand[1:]:
|
||||
if c - group[-1] <= min_gap:
|
||||
group.append(c)
|
||||
else:
|
||||
peaks.append(group[int(np.argmax(prob[group]))]); group = [c]
|
||||
peaks.append(group[int(np.argmax(prob[group]))])
|
||||
return peaks
|
||||
|
||||
|
||||
def prf(prob_or_pred, y, tol=2, thr=0.5):
|
||||
"""Boundary P/R/F1 with NMS peak aggregation. Accepts a probability series
|
||||
(model output) or a 0/1 array (is_cut baseline); NMS collapses each run of
|
||||
above-threshold seconds to one peak either way."""
|
||||
P = np.array(nms_peaks(np.asarray(prob_or_pred, float), thr=thr))
|
||||
T = np.where(y > 0.5)[0]
|
||||
if len(P) == 0 or len(T) == 0: return 0., 0., 0.
|
||||
tp_p = sum(any(abs(p-t) <= tol for t in T) for p in P)
|
||||
tp_t = sum(any(abs(p-t) <= tol for p in P) for t in T)
|
||||
pr, rc = tp_p/len(P), tp_t/len(T)
|
||||
return pr, rc, (2*pr*rc/(pr+rc) if pr+rc else 0.)
|
||||
|
||||
|
||||
def train_arm(modality, tr, va, v_dim, a_dim, vmu, vsd, amu, asd, epochs, dev):
|
||||
model = BoundaryNet(v_dim, a_dim, modality).to(dev)
|
||||
opt = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-5)
|
||||
pos = sum((y > .5).sum() for *_, y, _, _ in tr)
|
||||
neg = sum((y <= .5).sum() for *_, y, _, _ in tr)
|
||||
lossf = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([neg/max(pos,1)], device=dev))
|
||||
def vt(V): return torch.tensor((V-vmu)/vsd, dtype=torch.float32, device=dev).unsqueeze(0)
|
||||
def at(A): return torch.tensor((A-amu)/asd, dtype=torch.float32, device=dev).unsqueeze(0)
|
||||
for ep in range(epochs):
|
||||
model.train()
|
||||
for V, A, y, _, _ in tr:
|
||||
opt.zero_grad()
|
||||
logit = model(vt(V), at(A) if A is not None else None)
|
||||
loss = lossf(logit, torch.tensor(y, device=dev).unsqueeze(0))
|
||||
loss.backward(); opt.step()
|
||||
model.eval(); rows = {}
|
||||
with torch.no_grad():
|
||||
for slug, V, A, y, is_cut, ts in va:
|
||||
prob = torch.sigmoid(model(vt(V), at(A) if A is not None else None))[0].cpu().numpy()
|
||||
rows[slug] = prf(prob, y) # raw prob → NMS picks peaks by height
|
||||
return model, rows
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--manifest", required=True)
|
||||
ap.add_argument("--audio-dir", default="experiments/dumps/audio_features")
|
||||
ap.add_argument("--holdout", nargs="+", default=["Scarface", "Sound_of_Metal"])
|
||||
ap.add_argument("--modality", choices=["video","audio","fused","all"], default="all")
|
||||
ap.add_argument("--out", default="experiments/results/scene_boundary")
|
||||
ap.add_argument("--epochs", type=int, default=250)
|
||||
ap.add_argument("--seed", type=int, default=0)
|
||||
args = ap.parse_args()
|
||||
torch.manual_seed(args.seed); np.random.seed(args.seed)
|
||||
|
||||
films = json.load(open(args.manifest))
|
||||
def load(rows):
|
||||
out = []
|
||||
for f in rows:
|
||||
V, A, y, is_cut, ts = build_film(f["dump"], f["xray"], args.audio_dir)
|
||||
out.append((f["slug"], V, A, y, is_cut, ts))
|
||||
return out
|
||||
tr = load([f for f in films if f["slug"] not in args.holdout])
|
||||
va = load([f for f in films if f["slug"] in args.holdout])
|
||||
has_audio = all(t[2] is not None for t in tr+va)
|
||||
print(f"[scene] train {len(tr)} / holdout {args.holdout}; audio={'yes' if has_audio else 'MISSING'}",
|
||||
file=sys.stderr)
|
||||
|
||||
allV = np.concatenate([t[1] for t in tr], 0)
|
||||
vmu, vsd = allV.mean(0), allV.std(0)+1e-6; v_dim = allV.shape[1]
|
||||
if has_audio:
|
||||
allA = np.concatenate([t[2] for t in tr], 0)
|
||||
amu, asd = allA.mean(0), allA.std(0)+1e-6; a_dim = allA.shape[1]
|
||||
else:
|
||||
amu = asd = None; a_dim = 1
|
||||
|
||||
# strip index tuples for train_arm (expects V,A,y,is_cut,ts)
|
||||
trA = [(t[1],t[2],t[3],t[4],t[5]) for t in tr]
|
||||
dev = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
modes = ["video","audio","fused"] if args.modality=="all" else [args.modality]
|
||||
if not has_audio: modes = [m for m in modes if m == "video"] or ["video"]
|
||||
|
||||
# grayscale-0.70 baseline (is_cut) on holdout
|
||||
print("\n=== held-out scene-boundary detection (P/R/F1, ±2s) ===")
|
||||
print(f"{'film':26s} " + " ".join(f"{m:>16s}" for m in modes) + f" {'grayscale-0.70':>16s}")
|
||||
Path(args.out).mkdir(parents=True, exist_ok=True)
|
||||
results = {m: train_arm(m, trA, va, v_dim, a_dim, vmu, vsd, amu, asd, args.epochs, dev)
|
||||
for m in modes}
|
||||
report = {"holdout": args.holdout, "tol_sec": TOL_SEC, "modalities": {}, "films": {}}
|
||||
for slug, V, A, y, is_cut, ts in va:
|
||||
cells = []
|
||||
for m in modes:
|
||||
p,r,f = results[m][1][slug]
|
||||
cells.append(f"{p*100:4.0f}/{r*100:4.0f}/{f*100:4.0f}")
|
||||
report["films"].setdefault(slug, {})[m] = {"P":p,"R":r,"F1":f}
|
||||
bp,br,bf = prf(is_cut, y)
|
||||
report["films"].setdefault(slug, {})["grayscale"] = {"P":bp,"R":br,"F1":bf}
|
||||
print(f"{slug:26s} " + " ".join(f"{c:>16s}" for c in cells) +
|
||||
f" {bp*100:4.0f}/{br*100:4.0f}/{bf*100:4.0f}")
|
||||
# macro-mean F1 per modality across holdout
|
||||
print("\nmacro-mean holdout F1:")
|
||||
for m in modes:
|
||||
mf = np.mean([results[m][1][s][2] for s,*_ in va])
|
||||
report["modalities"][m] = float(mf)
|
||||
print(f" {m:8s} {mf*100:.1f}%")
|
||||
bf = np.mean([prf(t[4], t[3])[2] for t in va])
|
||||
report["modalities"]["grayscale"] = float(bf)
|
||||
print(f" {'grayscale':8s} {bf*100:.1f}%")
|
||||
# save the best arm
|
||||
best = max(modes, key=lambda m: report["modalities"][m])
|
||||
torch.save({"state": results[best][0].state_dict(), "modality": best,
|
||||
"vmu":vmu,"vsd":vsd,"amu":amu,"asd":asd,"v_dim":v_dim,"a_dim":a_dim},
|
||||
Path(args.out)/"boundary_net.pt")
|
||||
json.dump(report, open(Path(args.out)/"report.json","w"), indent=2)
|
||||
print(f"\n[scene] best={best}; model+report → {args.out}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
train_xgb_boundary.py — SHIPPED scene-boundary detector.
|
||||
|
||||
An XGBoost regressor over a ±WIN-second window of delta features predicts a soft
|
||||
Gaussian proximity-to-boundary target; a per-film KNEE threshold on the predicted
|
||||
peak heights selects the boundaries (self-calibrates the count without a magic
|
||||
rate). Evaluated with NMS + P/R/F1 at ±20 s tolerance (X-Ray scenes are ~170 s,
|
||||
so ±20 s placement is what flood-fill actually needs).
|
||||
|
||||
Why this shape (all measured, see docs/scene-detector):
|
||||
- DELTA features, not raw histogram/PSD: the raw content dilutes; |Δ| separates
|
||||
boundaries 4-5x. Audio is weak but included (XGBoost ignores what it can't use).
|
||||
- SOFT target exp(-(d/σ)²), σ=10s: a near-miss is trained as near-correct, not a
|
||||
hard negative. Regression → smooth score surface → NMS peaks.
|
||||
- KNEE threshold per film: peak-height curve has a knee where real boundaries
|
||||
give way to noise; picking it matches the true scene count without a global
|
||||
threshold that's wrong for every grade.
|
||||
- Café Society + Scarface (low-contrast grades) MUST be in training; held out,
|
||||
the model can't generalize to them. The shipped model trains on ALL 9.
|
||||
|
||||
Honest generalization: leave-one-out CV ≈ 26% F1 @±10s / ~34% @±20s. The shipped
|
||||
all-9 model is what deployment uses (max grade coverage); LOO is the number to
|
||||
quote for a brand-new film.
|
||||
|
||||
Usage (train on all 9 + save shipped model):
|
||||
.venv-rocm/bin/python scripts/scene_detector/train_xgb_boundary.py --train-all
|
||||
Usage (held-out eval):
|
||||
... --holdout Sound_of_Metal The_Many_Saints_of_Newark Valerian_...
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse, json, sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import h5py
|
||||
|
||||
sys.path.insert(0, "scripts/scene_detector")
|
||||
from train_scene_boundary import nms_peaks, load_xray_boundaries, SCENE_TAU, TOL_SEC
|
||||
from train_scene_boundary import video_features, audio_features, build_film
|
||||
from scipy.signal import find_peaks
|
||||
import xgboost as xgb
|
||||
|
||||
WIN = 3 # ±WIN-second context window
|
||||
SIGMA = 10.0 # soft-target Gaussian width (seconds)
|
||||
|
||||
|
||||
def per_second_matrix(dump, xray, audio_dir, win=None):
|
||||
"""Windowed delta features + debounce clock → (X[T,F], y_binary[T], is_cut[T])."""
|
||||
V, A, y, is_cut, ts = build_film(dump, xray, audio_dir)
|
||||
base = np.concatenate([V] + ([A] if A is not None else []), 1)
|
||||
T, d = base.shape
|
||||
sig = V[:, 0]
|
||||
thr = np.percentile(sig, 90)
|
||||
idx = np.arange(T); peak = np.where(sig > thr, idx, -1)
|
||||
last = np.maximum.accumulate(peak)
|
||||
dt = (idx - last).astype(np.float32); dt[last < 0] = SCENE_TAU
|
||||
clock = np.stack([dt, np.minimum(1, dt/SCENE_TAU), np.exp(-dt/SCENE_TAU)], 1)
|
||||
W = WIN if win is None else win
|
||||
padded = np.pad(base, ((W, W), (0, 0)), mode="edge")
|
||||
wf = np.concatenate([padded[i:i+T] for i in range(2*W+1)], 1)
|
||||
return np.concatenate([wf, clock], 1).astype(np.float32), y, is_cut
|
||||
|
||||
|
||||
def soft_target(dump, xray):
|
||||
ts = h5py.File(dump)["frames/timestamp_sec"][:]
|
||||
b = np.array(load_xray_boundaries(xray))
|
||||
y = np.zeros(len(ts), np.float32)
|
||||
if len(b):
|
||||
for i, t in enumerate(ts):
|
||||
y[i] = np.exp(-((np.min(np.abs(b - t)))/SIGMA)**2)
|
||||
return y
|
||||
|
||||
|
||||
def knee_boundaries(prob, min_gap=5):
|
||||
"""Per-film knee threshold on peak heights → selected peak indices.
|
||||
|
||||
Peaks sorted by height form a convex-decreasing curve; the knee (max drop
|
||||
below the endpoints chord) is where real boundaries give way to noise. Returns
|
||||
the timestamps (indices) of peaks at or above the knee height."""
|
||||
pk, _ = find_peaks(prob, distance=min_gap)
|
||||
if len(pk) < 5:
|
||||
return list(pk)
|
||||
heights = np.sort(prob[pk])[::-1]
|
||||
n = len(heights); x = np.arange(n)/(n-1); yv = heights/(heights[0]+1e-9)
|
||||
chord = yv[0] + (yv[-1]-yv[0])*x
|
||||
k = int(np.argmax(chord - yv))
|
||||
thr = heights[k]
|
||||
return [int(i) for i in pk if prob[i] >= thr]
|
||||
|
||||
|
||||
def train(films, audio_dir):
|
||||
X = np.concatenate([per_second_matrix(f["dump"], f["xray"], audio_dir)[0] for f in films])
|
||||
y = np.concatenate([soft_target(f["dump"], f["xray"]) for f in films])
|
||||
reg = xgb.XGBRegressor(n_estimators=400, max_depth=5, learning_rate=0.05,
|
||||
subsample=0.8, colsample_bytree=0.8,
|
||||
objective="reg:squarederror", n_jobs=8, tree_method="hist")
|
||||
reg.fit(X, y)
|
||||
return reg
|
||||
|
||||
|
||||
def prf(peaks, Tset, tol=20):
|
||||
if not peaks or len(Tset) == 0:
|
||||
return 0., 0., 0., 0, 0, len(Tset)
|
||||
tp_p = sum(any(abs(p-t) <= tol for t in Tset) for p in peaks)
|
||||
tp_t = sum(any(abs(p-t) <= tol for p in peaks) for t in Tset)
|
||||
P = tp_p/len(peaks); R = tp_t/len(Tset)
|
||||
return (P, R, (2*P*R/(P+R) if P+R else 0.),
|
||||
tp_p, len(peaks)-tp_p, len(Tset)-tp_t)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--manifest", default="experiments/manifests/films_LVFace_opencv5.json")
|
||||
ap.add_argument("--audio-dir", default="experiments/dumps/audio_features")
|
||||
ap.add_argument("--holdout", nargs="*", default=[])
|
||||
ap.add_argument("--train-all", action="store_true", help="train on all 9 + save shipped model")
|
||||
ap.add_argument("--tol", type=int, default=20)
|
||||
ap.add_argument("--out", default="experiments/results/scene_boundary")
|
||||
args = ap.parse_args()
|
||||
films = json.load(open(args.manifest))
|
||||
Path(args.out).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tr = films if args.train_all else [f for f in films if f["slug"] not in args.holdout]
|
||||
reg = train(tr, args.audio_dir)
|
||||
print(f"[xgb] trained on {len(tr)} films", file=sys.stderr)
|
||||
|
||||
ev = films if args.train_all else [f for f in films if f["slug"] in args.holdout]
|
||||
tag = "TRAIN-FIT (all 9)" if args.train_all else "HELD-OUT"
|
||||
print(f"\n=== {tag} boundary detection (knee, NMS, ±{args.tol}s) ===")
|
||||
print(f"{'film':26s} {'TP':>4}{'FP':>5}{'FN':>5} {'P':>5}{'R':>5}{'F1':>5} {'gray F1':>7}")
|
||||
rep = {"win": WIN, "sigma": SIGMA, "tol": args.tol, "train_all": args.train_all,
|
||||
"holdout": args.holdout, "films": {}}
|
||||
f1s, gf1s = [], []
|
||||
for f in ev:
|
||||
X, yb, ic = per_second_matrix(f["dump"], f["xray"], args.audio_dir)
|
||||
prob = np.clip(reg.predict(X), 0, 1)
|
||||
peaks = knee_boundaries(prob)
|
||||
Tset = np.where(yb > 0.5)[0]
|
||||
P, R, F, tp, fp, fn = prf(peaks, Tset, args.tol)
|
||||
gpk = nms_peaks(ic.astype(float)); _, _, gF, *_ = prf(gpk, Tset, args.tol)
|
||||
f1s.append(F); gf1s.append(gF)
|
||||
rep["films"][f["slug"]] = {"TP": tp, "FP": fp, "FN": fn, "P": P, "R": R, "F1": F,
|
||||
"n_pred": len(peaks), "n_true": len(Tset), "gray_F1": gF}
|
||||
print(f"{f['slug'][:26]:26s} {tp:>4}{fp:>5}{fn:>5} {P*100:4.0f}%{R*100:4.0f}%"
|
||||
f"{F*100:4.0f}% {gF*100:5.0f}%")
|
||||
print(f"\nmacro-F1: detector {np.mean(f1s)*100:.1f}% grayscale {np.mean(gf1s)*100:.1f}%")
|
||||
rep["macro_f1"] = {"detector": float(np.mean(f1s)), "grayscale": float(np.mean(gf1s))}
|
||||
if args.train_all:
|
||||
reg.save_model(str(Path(args.out) / "xgb_boundary_shipped.json"))
|
||||
print(f"[xgb] shipped model → {args.out}/xgb_boundary_shipped.json", file=sys.stderr)
|
||||
json.dump(rep, open(Path(args.out) / "xgb_report.json", "w"), indent=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
train_xgb_cpp.py — train the scene-boundary XGBoost on the C++-EXTRACTED feature
|
||||
matrices (experiments/dumps/cpp_features/<slug>.h5, written by scene_features_dump).
|
||||
|
||||
This is the parity-by-construction path: the model is fit on exactly the features
|
||||
the C++ XGBSceneBoundary produces at inference, so C++ boundaries match by
|
||||
construction — no numpy-vs-C++ feature drift to chase. Same soft Gaussian target,
|
||||
knee threshold, and ±20s eval as train_xgb_boundary.py.
|
||||
|
||||
Usage (train all 9 + save shipped model):
|
||||
.venv-rocm/bin/python scripts/scene_detector/train_xgb_cpp.py --train-all
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse, json, sys
|
||||
from pathlib import Path
|
||||
import numpy as np, h5py
|
||||
sys.path.insert(0, "scripts/scene_detector")
|
||||
from train_scene_boundary import load_xray_boundaries, nms_peaks
|
||||
from train_xgb_boundary import knee_boundaries, prf, SIGMA
|
||||
import xgboost as xgb
|
||||
|
||||
CPP_DIR = "experiments/dumps/cpp_features"
|
||||
|
||||
|
||||
def load(slug, xray):
|
||||
with h5py.File(f"{CPP_DIR}/{slug}.h5") as f:
|
||||
X = f["features"][:].astype(np.float32)
|
||||
ts = f["timestamp_sec"][:]
|
||||
b = np.array(load_xray_boundaries(xray))
|
||||
y = np.zeros(len(ts), np.float32)
|
||||
if len(b):
|
||||
for i, t in enumerate(ts):
|
||||
y[i] = np.exp(-((np.min(np.abs(b - t)))/SIGMA)**2)
|
||||
yb = np.zeros(len(ts), np.float32)
|
||||
for bb in b:
|
||||
yb[np.abs(ts - bb) <= 2.0] = 1.0
|
||||
return X, y, yb, ts
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--manifest", default="experiments/manifests/films_LVFace_opencv5.json")
|
||||
ap.add_argument("--holdout", nargs="*", default=[])
|
||||
ap.add_argument("--train-all", action="store_true")
|
||||
ap.add_argument("--tol", type=int, default=20)
|
||||
ap.add_argument("--out", default="experiments/results/scene_boundary")
|
||||
args = ap.parse_args()
|
||||
films = json.load(open(args.manifest))
|
||||
Path(args.out).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tr = films if args.train_all else [f for f in films if f["slug"] not in args.holdout]
|
||||
Xtr = np.concatenate([load(f["slug"], f["xray"])[0] for f in tr])
|
||||
ytr = np.concatenate([load(f["slug"], f["xray"])[1] for f in tr])
|
||||
reg = xgb.XGBRegressor(n_estimators=400, max_depth=5, learning_rate=0.05,
|
||||
subsample=0.8, colsample_bytree=0.8,
|
||||
objective="reg:squarederror", n_jobs=8, tree_method="hist")
|
||||
reg.fit(Xtr, ytr)
|
||||
print(f"[xgb-cpp] trained on {len(tr)} films", file=sys.stderr)
|
||||
|
||||
ev = films if args.train_all else [f for f in films if f["slug"] in args.holdout]
|
||||
tag = "TRAIN-FIT (all 9)" if args.train_all else "HELD-OUT"
|
||||
print(f"\n=== {tag} (C++ features, knee, ±{args.tol}s) ===")
|
||||
print(f"{'film':26s} {'TP':>4}{'FP':>5}{'FN':>5} {'P':>5}{'R':>5}{'F1':>5}")
|
||||
f1s = []
|
||||
for f in ev:
|
||||
X, y, yb, ts = load(f["slug"], f["xray"])
|
||||
prob = np.clip(reg.predict(X), 0, 1)
|
||||
peaks = knee_boundaries(prob)
|
||||
Tset = np.where(yb > 0.5)[0]
|
||||
P, R, F, tp, fp, fn = prf(peaks, Tset, args.tol)
|
||||
f1s.append(F)
|
||||
print(f"{f['slug'][:26]:26s} {tp:>4}{fp:>5}{fn:>5} {P*100:4.0f}%{R*100:4.0f}%{F*100:4.0f}%")
|
||||
print(f"\nmacro-F1: {np.mean(f1s)*100:.1f}%")
|
||||
if args.train_all:
|
||||
reg.save_model(str(Path(args.out) / "xgb_boundary_cpp.json"))
|
||||
print(f"[xgb-cpp] shipped model → {args.out}/xgb_boundary_cpp.json", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -85,40 +85,26 @@ struct Config {
|
||||
std::string arcface_model;
|
||||
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
|
||||
float match_prior{0.5f}; // base-rate prior; 0.5 = use calibrated sigmoid directly
|
||||
float match_prior{0.433f}; // base-rate prior; 10-knob DE optimum (was 0.5)
|
||||
// Tuned by Differential Evolution against Amazon X-Ray per-second presence
|
||||
// over the 4-film rep4 matrix. Best model+mode: LVFace-B_Glint360K, full
|
||||
// gallery, expansion on. Supersedes an earlier 9-film scene-union tuning
|
||||
// (0.76); that metric hid out-of-cast false positives.
|
||||
// over ALL 9 films (opencv5 build, LVFace-B_Glint360K, full gallery,
|
||||
// expansion on), a 10-parameter sweep — see docs/model-bakeoff.md. The
|
||||
// per-second misID-weighted macro-F1 optimum is 64.0% (P 79.0%, R 61.1%).
|
||||
//
|
||||
// **Read the provenance before trusting the value.** Two things about it:
|
||||
// This is a permissive operating point: the sweep discovered that with
|
||||
// flood-fill presence recovering recall, a LOW threshold pays off. It
|
||||
// supersedes the earlier 0.754, which came from a 4-film subset under the
|
||||
// now-withdrawn anneal/extinction windows and was never re-derived after a
|
||||
// scoring-bug fix. The full-9-film sweep at 0.485 beats it.
|
||||
//
|
||||
// 1. The document it came from no longer exists under that name. It was
|
||||
// docs/rep4-optimizer-results.md, renamed to docs/model-bakeoff.md and
|
||||
// then rewritten (0bd2747). This comment pointed at the dead path for
|
||||
// long enough that the number looked unsourced. The original is still
|
||||
// readable at `git show d340da7:docs/rep4-optimizer-results.md`, where
|
||||
// the shipped triple appears as
|
||||
// `prob_threshold=0.754, anneal_sec=35.5`.
|
||||
//
|
||||
// 2. **0.754 predates a scoring bug fix and was never re-derived.** That
|
||||
// same rewrite reports finding "a real scoring bug in optimize.py: a
|
||||
// candidate whose hardest film's replay timed out was averaged over
|
||||
// survivors instead of penalized, silently rewarding partial coverage.
|
||||
// Affected 3 of 16 training combos". The corrected sweep converged
|
||||
// somewhere else — the surviving document records anneal_sec=59.2,
|
||||
// extinction_sec=59.2 against the 35.5/57.4 shipped alongside this
|
||||
// threshold — and no corrected prob_threshold is recorded anywhere.
|
||||
// (The other two constants are now withdrawn outright, which is why
|
||||
// only this one still matters.)
|
||||
//
|
||||
// The doc is also candid that the optimum "generalizes unevenly — strong on
|
||||
// 3 of 5 held-out films, badly broken on 2 (one with a 974-count misID
|
||||
// blowup)", and that it is shipped anyway because it still beats the old
|
||||
// defaults on average. That is a defensible call and not a settled,
|
||||
// film-agnostic optimum; it should be visible here rather than only in a
|
||||
// document this comment used to point at incorrectly.
|
||||
float prob_threshold{0.754f}; // posterior P(match | sim, prior) threshold
|
||||
// Caveat, still true: the optimum generalises unevenly. It is strong on 7 of
|
||||
// 9 films (F1 62–80%) 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
|
||||
@@ -131,7 +117,19 @@ struct Config {
|
||||
|
||||
// ── Presence derivation ──────────────────────────────────────────────────
|
||||
// How accepted frames become a reported window. flood requires scene_detect.
|
||||
PresenceMode presence_mode{PresenceMode::track_extent};
|
||||
// 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 ────────────────────────────────────────────────────────
|
||||
float cut_threshold{0.70f}; // grayscale histogram correlation below this → hard cut
|
||||
@@ -180,7 +178,7 @@ struct Config {
|
||||
// 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
|
||||
// position carries no information across a viewpoint change or a gap.
|
||||
float track_alpha{0.4f}; // base cost weight: 0=embedding only, 1=spatial only
|
||||
float track_alpha{0.435f}; // base cost weight: 0=embedding only, 1=spatial only (10-knob DE optimum)
|
||||
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
|
||||
// alone. This replaces track_max_embed_dist (a raw cosine distance, AR-024).
|
||||
@@ -193,7 +191,7 @@ struct Config {
|
||||
// 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
|
||||
// ordinary miss, once for a cut). Seconds mean one thing at any sample rate.
|
||||
double track_extinction_sec{5.0};
|
||||
double track_extinction_sec{31.0}; // 10-knob DE optimum (was 5.0)
|
||||
|
||||
// ── Ownership and evidence accumulation (AR-025) ──────────────────────────
|
||||
// TRACES: AR-025, AR-017 | SR-002
|
||||
@@ -207,8 +205,10 @@ struct Config {
|
||||
// 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. 2.0 is a posterior of ~0.88. Unswept.
|
||||
float ownership_logodds{2.0f};
|
||||
// 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
|
||||
@@ -217,10 +217,11 @@ struct Config {
|
||||
// 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.5f};
|
||||
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.6f};
|
||||
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};
|
||||
|
||||
@@ -268,10 +269,10 @@ struct Config {
|
||||
// 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)
|
||||
// and expand_track_spread_max (0.60), which are retired (AR-024).
|
||||
// Working values pending VR-007; sweep both bounds, they fail in opposite
|
||||
// directions.
|
||||
float expand_band_lo{0.90f};
|
||||
float expand_band_hi{0.95f};
|
||||
// 10-knob DE optimum (was 0.90/0.95). The sweep widened the band — a lower lo
|
||||
// admits more pose-varied views into the annex — which the optimum preferred.
|
||||
float expand_band_lo{0.804f};
|
||||
float expand_band_hi{0.952f};
|
||||
int expand_min_anchor_frames{3}; // require ≥N accepted frames naming the actor before
|
||||
// the track is confirmed and its buffer promoted
|
||||
std::string expand_debug_dir; // if set, dump promoted mugshots + embeddings here
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
#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;
|
||||
}
|
||||
};
|
||||
@@ -209,6 +209,7 @@ static Config parse_args(int argc, char** argv) {
|
||||
else if (arg("--end")) cfg.end_sec = std::stod(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-detector")) cfg.scene_model = next();
|
||||
else if (arg("--scene-detector-engine")) cfg.scene_engine = next();
|
||||
|
||||
@@ -33,9 +33,29 @@ struct CameraPositionChangeDetectorFunc {
|
||||
|
||||
explicit CameraPositionChangeDetectorFunc(const Config& cfg)
|
||||
: cut_threshold_(cfg.cut_threshold)
|
||||
, want_rgb_hist_(!cfg.scene_xgb_model.empty())
|
||||
{
|
||||
std::cerr << "[camera_position_change_detector] cut_threshold="
|
||||
<< cut_threshold_ << "\n";
|
||||
<< cut_threshold_
|
||||
<< (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) {
|
||||
@@ -64,11 +84,13 @@ struct CameraPositionChangeDetectorFunc {
|
||||
prev_hist_ = hist;
|
||||
prev_hist_valid_ = true;
|
||||
|
||||
if (want_rgb_hist_) f.rgb_hist = rgb_histogram(f.image);
|
||||
return f;
|
||||
}
|
||||
|
||||
private:
|
||||
float cut_threshold_;
|
||||
bool want_rgb_hist_{false};
|
||||
cv::Mat prev_hist_;
|
||||
bool prev_hist_valid_{false};
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "gallery/embedder_stamp.hpp"
|
||||
|
||||
#include <H5Cpp.h>
|
||||
#include <opencv2/imgproc.hpp> // cv::calcHist for the per-frame RGB histogram
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
@@ -167,6 +168,12 @@ struct EmbeddingDumpFunc {
|
||||
fidx_.push_back(ef.source.frame_idx);
|
||||
is_cut_.push_back(ef.source.is_cut ? 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_cnt_.push_back(n);
|
||||
|
||||
@@ -287,6 +294,11 @@ private:
|
||||
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_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");
|
||||
write_vec(faces, "embedding", emb_, H5::PredType::NATIVE_FLOAT, kEmbedDim);
|
||||
@@ -301,6 +313,29 @@ private:
|
||||
<< 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_;
|
||||
EmbedderStamp stamp_;
|
||||
DumpProvenance prov_;
|
||||
@@ -315,4 +350,5 @@ private:
|
||||
std::vector<int32_t> face_cnt_;
|
||||
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_
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ struct FrameAnnotationFunc {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
#include "types.hpp"
|
||||
#include "config.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 <algorithm>
|
||||
@@ -219,15 +223,24 @@ private:
|
||||
// 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 {
|
||||
bool have_scene = false;
|
||||
for (const auto& sa : frames_)
|
||||
if (sa.is_scene_boundary) { have_scene = true; break; }
|
||||
|
||||
std::vector<double> b;
|
||||
b.push_back(0.0);
|
||||
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);
|
||||
|
||||
// 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());
|
||||
@@ -235,6 +248,42 @@ private:
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -218,9 +218,21 @@ public:
|
||||
const double clock =
|
||||
reg_.awaits_evidence_ ? reg_.evidence_through_ : reg_.now_;
|
||||
for (auto& [id, t] : reg_.tracks_) {
|
||||
if (t.last_seen &&
|
||||
(clock - *t.last_seen) > reg_.cfg_.track_extinction_sec)
|
||||
continue; // retired from association; still awaiting evidence
|
||||
// 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;
|
||||
|
||||
@@ -31,6 +31,10 @@ struct Frame {
|
||||
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
|
||||
// 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 ──────────────────────────────────────────────────────────────────
|
||||
@@ -162,6 +166,9 @@ struct SceneAnnotation {
|
||||
// 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||