Files
scene-actor-extraction/scripts/scene_detector/make_figures.py
T
dtourolle edf19ab798 docs(scene-detector): document the learned scene-boundary detector
New docs/scene-boundary-detector.md: why the grayscale cut detector wasn't
enough (Scarface: 1 cut in 10k frames → flood-fill P=26%), what X-Ray boundaries
are and why they're hard, the feature/model design (delta histograms, multi-scale
ramp bank, scene-length debounce, soft-target XGBoost regressor, per-film knee),
and the measured dead ends (audio-only, raw features, LSTM, TransNetV2).

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

Figures are generated by scripts/scene_detector/make_figures.py from the saved
results (experiments/results/scene_boundary/downstream_loo.json); the PNGs
themselves follow the repo convention of not committing regenerable chart assets.
Added to the mkdocs nav.
2026-08-09 22:15:32 +02:00

73 lines
4.0 KiB
Python

#!/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) ──────────────
def fig_evolution():
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
fig,ax=plt.subplots(figsize=(6.5,4.5))
ax.plot(steps,f1,marker="o",color="#3d7ea6",lw=2,ms=8)
for i,v in enumerate(f1): ax.text(i,v+0.4,f"{v:.1f}%",ha="center",fontsize=10)
ax.set_ylabel("held-out boundary F1 @±2s (%)")
ax.set_title("Detector development: features + model")
ax.set_ylim(0,18)
fig.tight_layout(); fig.savefig(OUT/"scene_detector_evolution.png"); plt.close(fig)
fig_presence(); fig_macro(); fig_evolution()
print("wrote:", *(p.name for p in sorted(OUT.glob("scene_*.png"))))