#!/usr/bin/env python3 """ train_xgb_boundary.py — SHIPPED scene-boundary detector. An XGBoost regressor over a ±WIN-second window of delta features predicts a soft Gaussian proximity-to-boundary target; a per-film KNEE threshold on the predicted peak heights selects the boundaries (self-calibrates the count without a magic rate). Evaluated with NMS + P/R/F1 at ±20 s tolerance (X-Ray scenes are ~170 s, so ±20 s placement is what flood-fill actually needs). Why this shape (all measured, see docs/scene-detector): - DELTA features, not raw histogram/PSD: the raw content dilutes; |Δ| separates boundaries 4-5x. Audio is weak but included (XGBoost ignores what it can't use). - SOFT target exp(-(d/σ)²), σ=10s: a near-miss is trained as near-correct, not a hard negative. Regression → smooth score surface → NMS peaks. - KNEE threshold per film: peak-height curve has a knee where real boundaries give way to noise; picking it matches the true scene count without a global threshold that's wrong for every grade. - Café Society + Scarface (low-contrast grades) MUST be in training; held out, the model can't generalize to them. The shipped model trains on ALL 9. Honest generalization: leave-one-out CV ≈ 26% F1 @±10s / ~34% @±20s. The shipped all-9 model is what deployment uses (max grade coverage); LOO is the number to quote for a brand-new film. Usage (train on all 9 + save shipped model): .venv-rocm/bin/python scripts/scene_detector/train_xgb_boundary.py --train-all Usage (held-out eval): ... --holdout Sound_of_Metal The_Many_Saints_of_Newark Valerian_... """ from __future__ import annotations import argparse, json, sys from pathlib import Path import numpy as np import h5py sys.path.insert(0, "scripts/scene_detector") from train_scene_boundary import nms_peaks, load_xray_boundaries, SCENE_TAU, TOL_SEC from train_scene_boundary import video_features, audio_features, build_film from scipy.signal import find_peaks import xgboost as xgb WIN = 3 # ±WIN-second context window SIGMA = 10.0 # soft-target Gaussian width (seconds) def per_second_matrix(dump, xray, audio_dir, win=None): """Windowed delta features + debounce clock → (X[T,F], y_binary[T], is_cut[T]).""" V, A, y, is_cut, ts = build_film(dump, xray, audio_dir) base = np.concatenate([V] + ([A] if A is not None else []), 1) T, d = base.shape sig = V[:, 0] thr = np.percentile(sig, 90) idx = np.arange(T); peak = np.where(sig > thr, idx, -1) last = np.maximum.accumulate(peak) dt = (idx - last).astype(np.float32); dt[last < 0] = SCENE_TAU clock = np.stack([dt, np.minimum(1, dt/SCENE_TAU), np.exp(-dt/SCENE_TAU)], 1) W = WIN if win is None else win padded = np.pad(base, ((W, W), (0, 0)), mode="edge") wf = np.concatenate([padded[i:i+T] for i in range(2*W+1)], 1) return np.concatenate([wf, clock], 1).astype(np.float32), y, is_cut def soft_target(dump, xray): ts = h5py.File(dump)["frames/timestamp_sec"][:] b = np.array(load_xray_boundaries(xray)) y = np.zeros(len(ts), np.float32) if len(b): for i, t in enumerate(ts): y[i] = np.exp(-((np.min(np.abs(b - t)))/SIGMA)**2) return y def knee_boundaries(prob, min_gap=5): """Per-film knee threshold on peak heights → selected peak indices. Peaks sorted by height form a convex-decreasing curve; the knee (max drop below the endpoints chord) is where real boundaries give way to noise. Returns the timestamps (indices) of peaks at or above the knee height.""" pk, _ = find_peaks(prob, distance=min_gap) if len(pk) < 5: return list(pk) heights = np.sort(prob[pk])[::-1] n = len(heights); x = np.arange(n)/(n-1); yv = heights/(heights[0]+1e-9) chord = yv[0] + (yv[-1]-yv[0])*x k = int(np.argmax(chord - yv)) thr = heights[k] return [int(i) for i in pk if prob[i] >= thr] def train(films, audio_dir): X = np.concatenate([per_second_matrix(f["dump"], f["xray"], audio_dir)[0] for f in films]) y = np.concatenate([soft_target(f["dump"], f["xray"]) for f in films]) reg = xgb.XGBRegressor(n_estimators=400, max_depth=5, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, objective="reg:squarederror", n_jobs=8, tree_method="hist") reg.fit(X, y) return reg def prf(peaks, Tset, tol=20): if not peaks or len(Tset) == 0: return 0., 0., 0., 0, 0, len(Tset) tp_p = sum(any(abs(p-t) <= tol for t in Tset) for p in peaks) tp_t = sum(any(abs(p-t) <= tol for p in peaks) for t in Tset) P = tp_p/len(peaks); R = tp_t/len(Tset) return (P, R, (2*P*R/(P+R) if P+R else 0.), tp_p, len(peaks)-tp_p, len(Tset)-tp_t) def main(): ap = argparse.ArgumentParser() ap.add_argument("--manifest", default="experiments/manifests/films_LVFace_opencv5.json") ap.add_argument("--audio-dir", default="experiments/dumps/audio_features") ap.add_argument("--holdout", nargs="*", default=[]) ap.add_argument("--train-all", action="store_true", help="train on all 9 + save shipped model") ap.add_argument("--tol", type=int, default=20) ap.add_argument("--out", default="experiments/results/scene_boundary") args = ap.parse_args() films = json.load(open(args.manifest)) Path(args.out).mkdir(parents=True, exist_ok=True) tr = films if args.train_all else [f for f in films if f["slug"] not in args.holdout] reg = train(tr, args.audio_dir) print(f"[xgb] trained on {len(tr)} films", file=sys.stderr) ev = films if args.train_all else [f for f in films if f["slug"] in args.holdout] tag = "TRAIN-FIT (all 9)" if args.train_all else "HELD-OUT" print(f"\n=== {tag} boundary detection (knee, NMS, ±{args.tol}s) ===") print(f"{'film':26s} {'TP':>4}{'FP':>5}{'FN':>5} {'P':>5}{'R':>5}{'F1':>5} {'gray F1':>7}") rep = {"win": WIN, "sigma": SIGMA, "tol": args.tol, "train_all": args.train_all, "holdout": args.holdout, "films": {}} f1s, gf1s = [], [] for f in ev: X, yb, ic = per_second_matrix(f["dump"], f["xray"], args.audio_dir) prob = np.clip(reg.predict(X), 0, 1) peaks = knee_boundaries(prob) Tset = np.where(yb > 0.5)[0] P, R, F, tp, fp, fn = prf(peaks, Tset, args.tol) gpk = nms_peaks(ic.astype(float)); _, _, gF, *_ = prf(gpk, Tset, args.tol) f1s.append(F); gf1s.append(gF) rep["films"][f["slug"]] = {"TP": tp, "FP": fp, "FN": fn, "P": P, "R": R, "F1": F, "n_pred": len(peaks), "n_true": len(Tset), "gray_F1": gF} print(f"{f['slug'][:26]:26s} {tp:>4}{fp:>5}{fn:>5} {P*100:4.0f}%{R*100:4.0f}%" f"{F*100:4.0f}% {gF*100:5.0f}%") print(f"\nmacro-F1: detector {np.mean(f1s)*100:.1f}% grayscale {np.mean(gf1s)*100:.1f}%") rep["macro_f1"] = {"detector": float(np.mean(f1s)), "grayscale": float(np.mean(gf1s))} if args.train_all: reg.save_model(str(Path(args.out) / "xgb_boundary_shipped.json")) print(f"[xgb] shipped model → {args.out}/xgb_boundary_shipped.json", file=sys.stderr) json.dump(rep, open(Path(args.out) / "xgb_report.json", "w"), indent=2) if __name__ == "__main__": main()