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.
125 lines
5.1 KiB
Python
125 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
de_ramp.py — DE-optimise a temporal matched-filter "ramp" per modality, whose
|
|
response becomes a feature channel for the scene-boundary LSTM.
|
|
|
|
A scene boundary is where a feature series (RGB histogram, audio log-PSD) shifts
|
|
from a "before" state to an "after" state. A signed, antisymmetric ramp kernel
|
|
convolved with the series responds strongly exactly at that transition and near
|
|
zero inside a stable scene — a matched filter for a step. Its shape is not
|
|
obvious (how wide? linear or peaked? how much centre dead-zone?), so we let DE
|
|
choose it by maximising boundary separation on the training films.
|
|
|
|
Ramp kernel over lags -H..+H seconds (1 fps → 1 sample/s):
|
|
w(l) = sign(l) * (|l| / H) ** gamma for |l| >= dead, else 0
|
|
params: H (half-width), gamma (shape), dead (centre dead-zone)
|
|
Response at t = || sum_l w(l) * feat[t+l] || (L2 over feature bins)
|
|
|
|
DE objective: boundary-detection F1 of a top-percentile threshold on the response,
|
|
macro-averaged over the training films (±2 s tolerance). The tuned (H, gamma,
|
|
dead) is saved; train_scene_boundary.py appends the ramp response as an input
|
|
channel to each tower.
|
|
|
|
Usage:
|
|
python scripts/scene_detector/de_ramp.py \
|
|
--manifest experiments/manifests/films_LVFace_opencv5.json \
|
|
--audio-dir experiments/dumps/audio_features \
|
|
--holdout Scarface Sound_of_Metal --out experiments/results/scene_boundary
|
|
"""
|
|
from __future__ import annotations
|
|
import argparse, csv, json, sys
|
|
from pathlib import Path
|
|
import h5py, numpy as np
|
|
from scipy.optimize import differential_evolution
|
|
|
|
|
|
def xray_bounds(xray_dir):
|
|
return sorted(float(r["start"])/1000 for r in
|
|
csv.DictReader(open(Path(xray_dir)/"scenes.csv"))
|
|
if float(r["start"]) > 500)
|
|
|
|
|
|
def load_series(dump, audio_dir, which):
|
|
if which == "audio":
|
|
# Audio is self-contained in the npz — no h5 needed (its ts IS the grid),
|
|
# so the audio cutter can be tuned before/without the RGB dumps.
|
|
slug = Path(dump).stem.replace("dump_", "")
|
|
z = np.load(Path(audio_dir)/f"{slug}.npz")
|
|
s = z["feat"].astype(np.float64)
|
|
ts = z["ts"] if "ts" in z else np.arange(len(s), dtype=float)
|
|
else: # video
|
|
with h5py.File(dump) as f:
|
|
ts = f["frames/timestamp_sec"][:]
|
|
s = f["frames/rgb_hist"][:].astype(np.float64)
|
|
# z-normalise each bin so L2 response isn't dominated by one loud bin
|
|
s = (s - s.mean(0)) / (s.std(0) + 1e-6)
|
|
return s, ts
|
|
|
|
|
|
def ramp_kernel(H, gamma, dead):
|
|
lags = np.arange(-H, H+1)
|
|
w = np.sign(lags) * (np.abs(lags)/max(H,1))**gamma
|
|
w[np.abs(lags) < dead] = 0.0
|
|
return w
|
|
|
|
|
|
def response(series, w, H):
|
|
T = series.shape[0]
|
|
r = np.zeros(T)
|
|
for t in range(T):
|
|
lo, hi = max(0, t-H), min(T, t+H+1)
|
|
wl = w[(lo-(t-H)):(hi-(t-H))]
|
|
r[t] = np.linalg.norm((series[lo:hi]*wl[:, None]).sum(0))
|
|
return r
|
|
|
|
|
|
def boundary_f1(resp, bounds, pct, tol=2):
|
|
thr = np.percentile(resp, pct)
|
|
pred = np.where(resp > thr)[0]
|
|
bidx = [int(b) for b in bounds if int(b) < len(resp)]
|
|
if len(pred) == 0 or not bidx:
|
|
return 0.0
|
|
tp_p = sum(any(abs(p-i) <= tol for i in bidx) for p in pred)
|
|
tp_t = sum(any(abs(p-i) <= tol for p in pred) for i in bidx)
|
|
P, R = tp_p/len(pred), tp_t/len(bidx)
|
|
return 2*P*R/(P+R) if P+R else 0.0
|
|
|
|
|
|
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("--out", default="experiments/results/scene_boundary")
|
|
args = ap.parse_args()
|
|
films = [f for f in json.load(open(args.manifest)) if f["slug"] not in args.holdout]
|
|
|
|
out = {}
|
|
for which in ("video", "audio"):
|
|
data = [(load_series(f["dump"], args.audio_dir, which)[0], xray_bounds(f["xray"]))
|
|
for f in films]
|
|
def neg_f1(x):
|
|
H = int(round(x[0])); gamma = x[1]; dead = int(round(x[2])); pct = x[3]
|
|
if H < 1 or dead >= H: return 0.0
|
|
w = ramp_kernel(H, gamma, dead)
|
|
f1s = [boundary_f1(response(s, w, H), b, pct) for s, b in data]
|
|
return -float(np.mean(f1s))
|
|
# bounds: H 1..10s, gamma 0.3..3, dead 0..4s, threshold pct 80..98
|
|
res = differential_evolution(
|
|
neg_f1, [(1, 10), (0.3, 3.0), (0, 4), (80, 98)],
|
|
seed=0, popsize=12, maxiter=25, tol=1e-4, polish=False)
|
|
H = int(round(res.x[0])); gamma = float(res.x[1])
|
|
dead = int(round(res.x[2])); pct = float(res.x[3])
|
|
out[which] = {"H": H, "gamma": gamma, "dead": dead, "pct": pct,
|
|
"train_f1": float(-res.fun)}
|
|
print(f"[de-ramp] {which}: H={H}s gamma={gamma:.2f} dead={dead}s "
|
|
f"pct={pct:.0f} train boundary-F1={-res.fun*100:.1f}%", file=sys.stderr)
|
|
|
|
Path(args.out).mkdir(parents=True, exist_ok=True)
|
|
json.dump(out, open(Path(args.out)/"de_ramp.json", "w"), indent=2)
|
|
print(f"[de-ramp] → {args.out}/de_ramp.json", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|