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.
138 lines
7.7 KiB
Python
138 lines
7.7 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) ──────────────
|
|
# 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"))))
|