Files
scene-actor-extraction/scripts/scene_detector/train_scene_boundary.py
T
dtourolle 0e35dac951 feat(scene-detector): learned scene-boundary detector for flood-fill presence
A boosted-tree scene-boundary detector that replaces the grayscale
histogram-correlation cut detector as the flood-fill boundary source, and
substantially improves actor-presence accuracy.

Downstream result (per-second X-Ray presence F1, macro over 9 films):
  track_extent 62.3%  |  flood + histogram cuts 64.0%  |  flood + this 76.9%
+12.9pp, and it wins on every film — notably fixing the histogram flood's
Scarface collapse (61 -> 41 -> 71) and lifting Downton 41 -> 84.

Design (each choice measured — see the memory / report):
- XGBoost REGRESSOR on a ±3s window of DELTA features (symmetric RGB-hist
  and audio-PSD deltas at k=1,2,4,8s + ramp bank + time-since-last-peak
  debounce). Raw histograms dilute; deltas separate boundaries ~4-5x.
- SOFT Gaussian proximity target (sigma=10s) so near-misses train as
  near-correct, not hard negatives; regression -> smooth score -> NMS peaks.
- KNEE per-film threshold: self-calibrates the boundary count to ~the true
  scene count, no global rate. Evaluated at ±20s (X-Ray scenes ~170s).
- Trained on all 9 films (Cafe/Scarface low-contrast grades must be seen).
  Honest held-out ~41% boundary-F1 @±20s vs ~27% grayscale.

Scripts: train_xgb_boundary.py (shipped detector), extract_audio_features.py
(per-second log-PSD), downstream_presence.py (the A/B above), density_floor.py
(fallback for detection-starved films), plus the LSTM/DE explorations kept
for provenance. Model: models/scene_boundary_xgb.json.

Not yet wired into the live C++ pipeline — boundaries are a post-EOF step in
the sink (like flood-fill itself); libxgboost C++ integration is the next step.
2026-08-09 19:21:04 +02:00

357 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()