docs: rep4 bake-off write-up, MkDocs site, artifact-registry-backed experiments

docs/rep4-optimizer-results.md is the main deliverable: the model bake-off +
threshold re-tune experiment log, including the ROCm teardown deadlock root
cause and fix, DE concurrency tuning, the 16-combo results table, held-out
validation against 5 films never seen by the optimizer (macro F1 67.4% vs.
75.3% training — a real generalization gap), the frozen-bbox "ghost track"
failure mode found via annotated frame evidence, calibration curves per model,
and an isolated-effects breakdown of gallery scope vs. pose expansion.

MkDocs site (mkdocs.yml, docs/index.md) renders docs/*.md; scripts/docs/
pulls referenced images from the artifact registry and generates the
calibration chart at build time (see the tooling commit) rather than
committing images to the repo.

experiments/ now keeps only scripts + README + SESSION_STATE.md in git — every
data artifact (galleries, dumps, X-Ray corpus, montage frames, trajectories,
manifests, results) moved to the Gitea package registry. film-lut.template.json
is the committed placeholder for the gitignored file-lut.json (real local
movie paths, never shared — some source filenames carry scene-release tags).

Adds models/transnetv2.onnx (via Git LFS, matching the other ONNX models) for
the new scene-detection path.
This commit is contained in:
2026-07-19 19:12:22 +02:00
parent 76df2f66aa
commit d340da755a
15 changed files with 1223 additions and 5 deletions
+117
View File
@@ -0,0 +1,117 @@
# Threshold optimization against Amazon X-Ray — experiment log
Record of the July 2026 work that tuned the pipeline's recognition/tracking defaults
against ground-truth per-scene actor presence, and the tooling built to do it.
## TL;DR — what changed
| knob | old default | new default | why |
| ---- | ----------- | ----------- | --- |
| `prob_threshold` | 0.99 | **0.76** | 0.99 was far too strict — halved recall for a fraction of a precision point. DE optimum, tightly converged. |
| `extinction_sec` | 5.0 | **1.5** | Long extinction smears presence into later scenes → FPs. DE converged tightly low. |
| `anneal_sec` | 10.0 | 10.0 (unchanged) | DE found it **insensitive** (F1 flat ±0.3pp across 326s) — kept the round default. |
| `detector_conf` | 0.5 | 0.5 (unchanged) | Sweep showed raising it only trades recall for precision at a net F1 loss — near-threshold detections are real faces, not phantoms. |
Net effect on the 9-film benchmark (strict per-scene, augmented gallery):
recall **58% → ~72%**, F1 **70% → ~76%**, precision ~85%, at no meaningful precision cost.
## Ground truth
Public scene-level **Amazon X-Ray** dataset (Zenodo DOI 10.5281/zenodo.17659734,
CC-BY-4.0): per movie, `people.csv` (name_id/person/character), `scenes.csv`
(scene/start/end ms), `people_in_scenes.csv`. Films matched to the library by an
**authoritative Jellyfin ID join** (query `/Items?IncludeItemTypes=Movie&Fields=
ProviderIds,Path`, join Imdb/Tmdb against X-Ray metadata) — NOT fuzzy title matching,
which collides badly (TV episodes vs same-named films). 9 genuine films with source
video on disk: Benny & Joon, Café Society, Downton Abbey: A New Era, Lord of War,
Lovelace, The Many Saints of Newark, Scarface, Sound of Metal, Valerian.
## The scoring metric (evolved through review)
Comparison unit is the **X-Ray scene**, not sampled timepoints. For each scene
`[start,end]`: predicted set = **union** of actors detected anywhere in the span;
GT set = actors X-Ray lists for that scene. Per scene TP/FP/FN, then:
- **Precision: STRICT.** Any predicted actor not in the scene's X-Ray set is an FP,
*including out-of-cast confusions* (no gallery∩cast masking). An earlier
timepoint-sampled, cast-masked metric HID ~570 such FPs across 9 films and let the
optimizer drive `prob_threshold` to the 0.50 floor — a metric artifact. Counting
them is essential.
- **Recall: FAIR.** FN counts only X-Ray cast members **who are in the gallery**. 67%
of X-Ray cast (261/392) have no gallery reference embedding and can never be
recognised — counting them as misses penalises coverage, not the threshold. Both
`recall` (fair) and `recall_strict` (all) are reported.
- **Aggregation:** per-scene F1 → **duration-weighted average within a movie** (long
scenes count more) → **equal-weight mean across movies** (macro; each film counts
the same regardless of length). This is the DE objective.
Implemented in `scripts/optimizer/scene_score.py`.
## The gallery coverage gap
Diagnosing low recall: only **131 of 392** X-Ray cast were in the gallery (33%). Every
in-gallery actor HAD embeddings (gallery well-formed) — the gap was pure coverage.
`scripts/optimizer/fetch_missing_actors.py` recovers missing actors:
`nm-id → TMDB /find external_ids → /person/{id}/images → download → embed (sae_embed)`,
with a `--wikidata` fallback (P345→P18 Commons photo).
- **TMDB recovered 143/261** (55%). 0 face-detection failures; the rest had no TMDB
person (60) or no profile photo (58). Coverage 33% → **70%**.
- **Wikidata fallback: 0/118** of the TMDB failures — only 4 even had a Commons photo,
none yielded a detectable face. → **TheTVDB not worth pursuing**: these remaining
actors are obscure enough that no image source covers them, AND (see below) most are
off-camera anyway.
**Coverage vs detectability.** Adding references lifted recall (58→68% at fixed config)
but modestly. Per-film drill-down (Lord of War: 12 actors recovered, only 1 had a
detectable on-camera face) showed most missing cast are a **detectability gap** — X-Ray
credits them as cast-in-scene (incl. off-camera/background), but their face never
appears clearly for the pipeline to detect. This is a fundamental ceiling of a
face-recognition pipeline vs X-Ray's presence semantics, not a fixable gap.
## Optimizer
`scripts/optimizer/optimize.py` — scipy `differential_evolution` over the knob space,
each candidate = full replay of all films through the **real** C++ nodes (see the
KPN replay architecture below) scored by the metric above. Global objective (one
config for all films, not per-film).
**Convergence stability (augmented gallery, 233 evals):**
| knob | top-20 range | verdict |
| ---- | ------------ | ------- |
| `prob_threshold` | 0.690.83 (σ 0.05) | TIGHT — trust 0.76 |
| `extinction_sec` | 1.02.2 (σ 0.33) | TIGHT — trust 1.5 |
| `anneal_sec` | 3.126.3 (σ 6.4) | LOOSE — insensitive, not hard-coded |
F1 varied only 0.3pp across the top-20 → objective is flat near the optimum, so only
the tightly-converged knobs were adopted as defaults.
## Replay architecture (how the sweep is cheap)
The optimizer never re-decodes video. `scene_analyze --dump-embeddings out.h5` runs the
expensive half once (decode→detect→align→embed) and dumps per-frame face embeddings
+ metadata to HDF5 (`scripts/optimizer/SCHEMA.md`). `scripts/optimizer/replay.py` then
replays that dump through the **real** C++ `face_tracker → identity_matcher →
scene_tracker` assembled in a Python KPN network (`sae_kpn` nanobind module), varying
Config knobs freely — no GPU embedding, no decode. Verified BYTE-EXACT against
`scene_analyze`'s own output. The dumps are gallery-independent, so testing the
augmented gallery needed no re-dump. `detector_conf` is replayable UPWARD only (the
dump floor is 0.5).
## Reproduce
```bash
# 1. dump (once per film, needs video)
scene_analyze --movie <f> --gallery gallery.json --dump-embeddings dump.h5 --fps 1
# 2. build films manifest by Jellyfin ID join (see scripts/optimizer notes)
# 3. optimize
python scripts/optimizer/optimize.py --manifest films.json --gallery gallery.json \
--params prob_threshold:0.5:0.999 anneal_sec:1:30 extinction_sec:1:15 \
--popsize 8 --maxiter 20 --trajectory traj.jsonl --out opt.json
# 4. score a fixed config / validate on a held-out set
python scripts/optimizer/score_config.py --manifest heldout.json --gallery gallery.json \
--config '{"prob_threshold":0.76,"extinction_sec":1.5,"anneal_sec":10}'
```
See also memory: kpn-python-replay-optimizer, gallery-coverage-gap, xray-validation-*.