Author SHA1 Message Date
dtourolle 13437e0d8b docs: correct scene-boundary detector F1 numbers to measured values
The boundary-detection paragraph understated the detector. Replace the stale
"~34% F1 vs ~27%" (a pre-C++-retrain figure with no backing artifact) with the
measured numbers at the shipped ±20s tolerance:
  - leave-one-out macro boundary F1 = 44.1% (honest generalisation)
  - grayscale baseline               = 29.8%
  - train-all (shipped model)        = 72.9% (per-film 51-86%)
computed from experiments/results/scene_boundary/xgb_report.json and per-film
leave-one-out runs of train_xgb_cpp.py. Also correct the false claim that the
low-contrast grades "cannot generalise held out" — Scarface held out scores 32%,
Café Society 51%, both above grayscale (0% and 31%).

Split the evolution figure into two panels so the strict-±2s feature-development
curve is no longer mistaken for the shipped result: left = feature progress at
±2s, right = shipped detector at the ±20s tolerance the pipeline uses.
2026-08-11 20:54:15 +02:00
dtourolle b26c66dcce scripts: figure generators + frame-regen script for the opencv5 report
make_figures.py gains the DE-landscape, calibration, and per-film leave-one-out
holdout figures used by the experiment log. regen_frame_examples.sh replays all
nine films with the shipped learned-boundary flood config and draws GT-aware
TP/FP/FN frames, so every annotated image in the docs is reproducible.
2026-08-10 08:45:07 +02:00
4 changed files with 139 additions and 16 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+21 -7
View File
@@ -62,7 +62,13 @@ Everything is per second, aligned to the 1-fps presence grid.
the films where video is weak (Downton, Sound of Metal), so it is included and the films where video is weak (Downton, Sound of Metal), so it is included and
the model uses it where it helps. the model uses it where it helps.
![Detector development: features and model](assets/images/scene_detector_evolution.png) ![Detector development at strict ±2 s tolerance, and where the shipped detector landed at the ±20 s tolerance the pipeline uses](assets/images/scene_detector_evolution.png)
The left panel is the *feature* development, scored at a strict ±2 s tolerance so
each change is visible — this is where "delta beats raw histogram" was measured, not
the shipped tolerance. The right panel is the shipped detector at the ±20 s
tolerance the pipeline actually uses (see below). The two panels are on different
tolerances by design and must not be read as one curve.
Dead ends, all measured and discarded: audio-only detection; raw 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); histograms/PSDs as input; a two-tower BiLSTM (no better than the tree, far slower);
@@ -81,14 +87,22 @@ and TransNetV2 (a Conv3D net that will not co-reside with the ROCm/VAAPI stack).
real boundaries give way to noise. Selecting at the knee **self-calibrates the 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 boundary count** to roughly the true scene count, per film, with no global
threshold that would be wrong for every grade. threshold that would be wrong for every grade.
- **Trained on all nine films** for the shipped model. Café Society and Scarface - **Trained on all nine films** for the shipped model. Keeping the low-contrast
(the low-contrast grades) *must* be in training — held out, the model cannot grades (Café Society, Scarface) in training matters most: on its own training
generalise to them; in training they reach 7086% boundary-F1. films the shipped model reaches **72.9% macro boundary-F1** (per-film 5186%),
versus **29.8%** for the grayscale baseline on the same films.
Boundary detection, held out (leave-one-out, ±20 s tolerance — appropriate given Boundary detection, held out (leave-one-out, ±20 s tolerance — appropriate given
~170 s scenes): **~34% F1, versus ~27% for the grayscale baseline.** The absolute ~170 s scenes): **44.1% macro F1, versus 29.8% for the grayscale baseline** — the
number is capped by the narrative-vs-audiovisual mismatch above; the point is the honest generalisation number, each film scored by a detector trained on the other
downstream effect. 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 ## The result that matters: actor presence
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Regenerate annotated TP/FP/FN frame examples for ALL 9 films against the current
# opencv5 pipeline (learned-boundary flood, shipped config). Replays each film with
# --raw-out for bboxes, then dump_error_frames.py draws GT-aware boxes
# (green TP / red FP / orange unknown / blue FN panel). Frames land in
# experiments/dump_review/<slug>/ (regenerable; gitignored). Hand-pick the ones a
# doc needs from there.
set -uo pipefail
REPO="/home/dtourolle/Development/scene-actor-extraction"; cd "$REPO"
export MIOPEN_USER_DB_PATH="$HOME/.cache/miopen-sae"
GAL=experiments/galleries/gallery_LVFace-B_Glint360K.h5
LUT=experiments/file-lut.json
CFG=(--prob-threshold 0.485 --ownership-logodds 1.72 --track-extinction-sec 31
--track-alpha 0.435 --evidence-rho-max 0.204 --evidence-admit-below 0.784
--match-prior 0.433 --expand-band-lo 0.804 --expand-band-hi 0.952
--expand-gallery --presence-mode flood)
mapfile -t ROWS < <(python3 -c '
import json
for f in json.load(open("experiments/manifests/films_LVFace_opencv5.json")):
print(f["slug"]+"\t"+f["xray"])')
SP=/tmp/claude-1000/-home-dtourolle-Development-scene-actor-extraction/c579f8cf-2974-4cbd-be88-afec68dbbf58/scratchpad
for row in "${ROWS[@]}"; do
slug="${row%%$'\t'*}"; xray="${row#*$'\t'}"
movie="$(python3 -c "import json;print(json.load(open('$LUT'))['$slug'])")"
echo "=== $slug ==="
[ -f "experiments/dump_review/$slug/manifest.json" ] && { echo " exists, skip"; continue; }
# replay the learned-boundary (LOO) dump so frames reflect true generalization
dump="experiments/dumps/injected_loo/${slug}.h5"
[ -f "$dump" ] || dump="experiments/dumps/LVFace-B_Glint360K_opencv5/dump_${slug}.h5"
for try in 1 2 3; do
timeout 280 python scripts/optimizer/replay.py --dump "$dump" --gallery "$GAL" \
--out "$SP/${slug}_pred.json" --raw-out "$SP/${slug}_raw.jsonl" "${CFG[@]}" \
>"$SP/${slug}_replay.log" 2>&1 && break
echo " replay try $try failed, retrying"
done
[ -s "$SP/${slug}_raw.jsonl" ] || { echo " no raw output, skip"; continue; }
python3 scripts/optimizer/dump_error_frames.py \
--pred "$SP/${slug}_pred.json" --raw "$SP/${slug}_raw.jsonl" \
--xray "$xray" --movie "$movie" --gallery "$GAL" \
--out-dir "experiments/dump_review/$slug" --n-per-bucket 4 \
>"$SP/${slug}_frames.log" 2>&1
echo " $(grep -oE 'wrote [0-9]+ frames' "$SP/${slug}_frames.log" | tail -1)"
done
echo "=== DONE ==="
+74 -9
View File
@@ -57,16 +57,81 @@ def fig_macro():
fig.tight_layout(); fig.savefig(OUT/"scene_presence_macro.png"); plt.close(fig) fig.tight_layout(); fig.savefig(OUT/"scene_presence_macro.png"); plt.close(fig)
# ── Figure 3: feature/model evolution (boundary-F1 development) ────────────── # ── 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(): 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)"] steps=["grayscale\nbaseline","raw-hist\nLSTM","delta\nLSTM","XGBoost\n(delta+debounce)"]
f1=[7.2,7.5,10.8,15.2] # boundary-F1 @±2s during development dev=[7.2,7.5,10.8,15.2] # boundary-F1 @±2s during LSTM-era development
fig,ax=plt.subplots(figsize=(6.5,4.5)) axl.plot(steps,dev,marker="o",color="#9aa7b4",lw=2,ms=8)
ax.plot(steps,f1,marker="o",color="#3d7ea6",lw=2,ms=8) for i,v in enumerate(dev): axl.text(i,v+0.4,f"{v:.1f}%",ha="center",fontsize=9)
for i,v in enumerate(f1): ax.text(i,v+0.4,f"{v:.1f}%",ha="center",fontsize=10) axl.set_ylabel("boundary F1 @±2 s (%)")
ax.set_ylabel("held-out boundary F1 @±2s (%)") axl.set_title("Feature progress (strict ±2 s)")
ax.set_title("Detector development: features + model") axl.set_ylim(0,18)
ax.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) fig.tight_layout(); fig.savefig(OUT/"scene_detector_evolution.png"); plt.close(fig)
fig_presence(); fig_macro(); fig_evolution() import csv as _csv
print("wrote:", *(p.name for p in sorted(OUT.glob("scene_*.png"))))
# ── 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"))))