#!/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()
