feat(scene-detector): run the learned boundary detector live in the C++ pipeline
Wire the XGBoost scene-boundary detector into scene_analyze as a post-EOF step in
the result sink (like flood-fill itself — the per-film knee threshold needs the
whole film, so it cannot stream). With --scene-xgb-model set, the camera-position
node stamps a per-frame RGB histogram onto the Frame, it rides through to the
sink, and at EOF the sink runs XGBSceneBoundary over the collected histograms +
the movie's per-second audio log-PSD to produce the flood-fill boundaries. Falls
back to is_scene_boundary / is_cut when no model is configured or inference fails.
Inference is real XGBoost via CMake FetchContent (v2.1.1, static), C API in
src/inference/xgb_scene_boundary.hpp; audio log-PSD in src/inference/
audio_logpsd.hpp (FFTW + ffmpeg full-file 16kHz decode). Feature extraction
matches training exactly — video features verified row-identical to numpy, and to
avoid chasing numpy's every rounding the shipped model is TRAINED on the
C++-extracted features (scene_features_dump exe → train_xgb_cpp.py). The
C++/Python peak-finders differ slightly so boundary counts differ, but what
matters is downstream: flood + C++ detector = 75.8% macro presence F1 vs 64.0%
for the histogram-cut flood and 62.5% for track_extent, and it fixes the Scarface
flood collapse (41 -> 70). All nine films improve.
Guarded by the SAE_SCENE_XGB CMake option (on by default; heavy first build).
xgb_boundary_parity is a diff harness; scene_features_dump writes the C++ feature
matrix so training and inference share one feature implementation.
Verified end to end: scene_analyze --scene-xgb-model on a real movie stamps the
histogram, runs the detector at EOF ("XGBoost scene detector: N boundaries"), and
flood-snaps presence to the learned boundaries.
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
#pragma once
|
||||
// XGBoost scene-boundary detector — C++ inference of the shipped model
|
||||
// (models/scene_boundary_xgb.json), for flood-fill presence in the live pipeline.
|
||||
//
|
||||
// This is a POST-EOF step (like flood-fill itself): the per-film knee threshold
|
||||
// needs every peak, so boundaries can only be finalized after the whole film is
|
||||
// seen. The result sink collects a per-frame RGB histogram; at EOF it calls
|
||||
// boundaries() with the full (timestamp, hist) series and gets back the boundary
|
||||
// timestamps to flood-snap against.
|
||||
//
|
||||
// The feature pipeline MUST match scripts/scene_detector/train_scene_boundary.py
|
||||
// exactly (206 features): a ±WIN=3s window of per-second base features + a
|
||||
// 3-value debounce clock. Base per second (29):
|
||||
// video(17): sym-delta |hist(t+k)-hist(t-k)| L1 at k=1,2,4,8; per-channel corr
|
||||
// to t-1 (3); ramp bank at H=2,4,6,8,10 on z-normed hist (5);
|
||||
// per-channel energy (3); debounce phase/decay from |delta k=1| (2)
|
||||
// audio(12): same but on the log-PSD, no corr, 1 energy [ZERO when no audio]
|
||||
// then window flatten t-3..t+3 (×7) and append clock (dt, phase, decay).
|
||||
//
|
||||
// Audio is not available live (the pipeline has no per-second PSD stream), so the
|
||||
// audio block is fed zeros — the model was trained with audio present but it is
|
||||
// weak (measured) and XGBoost tolerates a constant block; the video signal
|
||||
// carries the detector. (If live audio is added later, fill the block.)
|
||||
|
||||
#include <xgboost/c_api.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <numeric>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class XGBSceneBoundary {
|
||||
public:
|
||||
// Must match kHistBins in embedding_dump_node.hpp / the training dump.
|
||||
static constexpr int kHistBins = 32; // per channel → 96-float hist
|
||||
static constexpr int kWin = 3; // ±WIN-second window
|
||||
static constexpr double kSigmaTau = 205.0; // SCENE_TAU (unused at infer; kept for parity docs)
|
||||
static constexpr int kRampScales[5] = {2, 4, 6, 8, 10};
|
||||
|
||||
explicit XGBSceneBoundary(const std::string& model_path) {
|
||||
if (XGBoosterCreate(nullptr, 0, &booster_) != 0)
|
||||
throw std::runtime_error("XGBoosterCreate failed");
|
||||
if (XGBoosterLoadModel(booster_, model_path.c_str()) != 0)
|
||||
throw std::runtime_error("XGBoosterLoadModel failed: " +
|
||||
std::string(XGBGetLastError()));
|
||||
}
|
||||
~XGBSceneBoundary() { if (booster_) XGBoosterFree(booster_); }
|
||||
XGBSceneBoundary(const XGBSceneBoundary&) = delete;
|
||||
XGBSceneBoundary& operator=(const XGBSceneBoundary&) = delete;
|
||||
|
||||
// hist: T rows × 96 (normalised RGB histogram per second).
|
||||
// audio: T rows × B log-PSD (from AudioLogPSD; aligned to the same seconds),
|
||||
// or empty → the audio block is filled with its zero-input values
|
||||
// (deltas/ramp/energy 0, but debounce phase=1/decay=exp(-1), matching
|
||||
// the Python audio_features on a zero series).
|
||||
// Returns boundary timestamps (knee-selected).
|
||||
std::vector<double> boundaries(const std::vector<std::vector<float>>& hist,
|
||||
const std::vector<double>& ts,
|
||||
const std::vector<std::vector<float>>& audio = {}) {
|
||||
const int T = static_cast<int>(hist.size());
|
||||
if (T < 2 * kWin + 2) return {};
|
||||
auto base = build_base(hist, audio); // [T][29]
|
||||
std::vector<float> X = window_and_clock(base, hist);
|
||||
std::vector<float> prob = predict(X, T, 206);
|
||||
return knee_boundaries(prob, ts);
|
||||
}
|
||||
|
||||
static std::vector<std::vector<float>> debug_base(const std::vector<std::vector<float>>& hist,
|
||||
const std::vector<std::vector<float>>& audio = {}) {
|
||||
return build_base(hist, audio);
|
||||
}
|
||||
|
||||
// Predict boundaries from a precomputed [rows×cols] feature matrix (for the
|
||||
// clean parity check: same bytes both sides).
|
||||
std::vector<double> boundaries_from_features(const std::vector<float>& X, int rows,
|
||||
int cols, const std::vector<double>& ts) {
|
||||
auto prob = predict(X, rows, cols);
|
||||
return knee_boundaries(prob, ts);
|
||||
}
|
||||
std::vector<float> debug_predict(const std::vector<float>& X, int r, int c) {
|
||||
return predict(X, r, c);
|
||||
}
|
||||
static std::vector<int> debug_find_peaks(const std::vector<float>& p, int d) {
|
||||
return find_peaks(p, d);
|
||||
}
|
||||
|
||||
// The flat [T*206] feature matrix — exposed so TRAINING uses the exact same
|
||||
// C++ features as inference (parity by construction; no numpy re-match). The
|
||||
// Python trainer reshapes to [T,206], attaches the soft target, and fits.
|
||||
static std::vector<float> feature_matrix(const std::vector<std::vector<float>>& hist,
|
||||
const std::vector<std::vector<float>>& audio) {
|
||||
auto base = build_base(hist, audio);
|
||||
return window_and_clock(base, hist);
|
||||
}
|
||||
static constexpr int kNFeatures = 206;
|
||||
|
||||
private:
|
||||
BoosterHandle booster_{nullptr};
|
||||
|
||||
// ── feature builders (exact parity with the Python) ──────────────────────
|
||||
|
||||
static float l1(const std::vector<float>& a, const std::vector<float>& b) {
|
||||
float s = 0; for (size_t i = 0; i < a.size(); ++i) s += std::fabs(a[i] - b[i]);
|
||||
return s;
|
||||
}
|
||||
|
||||
// z-normalise each of the 96 columns across time (matches _znorm).
|
||||
static std::vector<std::vector<float>> znorm(const std::vector<std::vector<float>>& h) {
|
||||
const int T = h.size(), D = h[0].size();
|
||||
std::vector<float> mu(D, 0), sd(D, 0);
|
||||
for (auto& r : h) for (int d = 0; d < D; ++d) mu[d] += r[d];
|
||||
for (int d = 0; d < D; ++d) mu[d] /= T;
|
||||
for (auto& r : h) for (int d = 0; d < D; ++d) sd[d] += (r[d]-mu[d])*(r[d]-mu[d]);
|
||||
for (int d = 0; d < D; ++d) sd[d] = std::sqrt(sd[d]/T) + 1e-6f;
|
||||
std::vector<std::vector<float>> z(T, std::vector<float>(D));
|
||||
for (int t = 0; t < T; ++t) for (int d = 0; d < D; ++d) z[t][d] = (h[t][d]-mu[d])/sd[d];
|
||||
return z;
|
||||
}
|
||||
|
||||
// ramp bank: L2 of the antisymmetric ramp-weighted sum over ±H, per scale.
|
||||
// Matches ramp_bank() (np.convolve 'same' with reversed kernel; sign folds
|
||||
// into the L2 norm so the direct antisymmetric sum is equivalent).
|
||||
static std::vector<std::array<float,5>> ramp_bank(const std::vector<std::vector<float>>& z) {
|
||||
const int T = z.size(), D = z[0].size();
|
||||
std::vector<std::array<float,5>> out(T);
|
||||
for (int k = 0; k < 5; ++k) {
|
||||
const int H = kRampScales[k];
|
||||
for (int t = 0; t < T; ++t) {
|
||||
std::vector<double> acc(D, 0.0);
|
||||
for (int l = -H; l <= H; ++l) {
|
||||
int idx = t + l;
|
||||
if (idx < 0 || idx >= T) continue;
|
||||
double w = (l == 0) ? 0.0 : (l > 0 ? 1.0 : -1.0) * (double(std::abs(l))/H);
|
||||
for (int d = 0; d < D; ++d) acc[d] += w * z[idx][d];
|
||||
}
|
||||
double n = 0; for (double v : acc) n += v*v;
|
||||
out[t][k] = static_cast<float>(std::sqrt(n));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Generic symmetric-delta + ramp + energy + debounce feature block for one
|
||||
// modality's z-normable series `raw` (hist or PSD). Fills `out` columns
|
||||
// [off .. off+width). corr=true adds the 3 per-channel corr features (video
|
||||
// only); n_energy is 3 (video, per-channel) or 1 (audio, total).
|
||||
static void modality_block(const std::vector<std::vector<float>>& raw,
|
||||
bool corr, int n_energy,
|
||||
std::vector<std::vector<float>>& out, int off) {
|
||||
const int T = raw.size();
|
||||
auto z = znorm(raw);
|
||||
auto rb = ramp_bank(z);
|
||||
auto sym = [&](int t, int k)->float{
|
||||
int f = std::min(T-1, t+k), b = std::max(0, t-k);
|
||||
return l1(raw[f], raw[b]);
|
||||
};
|
||||
const int B = kHistBins; // only used for corr (video)
|
||||
for (int t = 0; t < T; ++t) {
|
||||
int o = off;
|
||||
for (int k : {1,2,4,8}) out[t][o++] = sym(t,k);
|
||||
if (corr) {
|
||||
int tp = std::max(0, t-1);
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
double ma=0, mb=0;
|
||||
for (int i=0;i<B;++i){ ma+=raw[t][c*B+i]; mb+=raw[tp][c*B+i]; }
|
||||
ma/=B; mb/=B; double num=0, da=0, db=0;
|
||||
for (int i=0;i<B;++i){ double x=raw[t][c*B+i]-ma, y=raw[tp][c*B+i]-mb;
|
||||
num+=x*y; da+=x*x; db+=y*y; }
|
||||
out[t][o++] = float(num/(std::sqrt(da*db)+1e-9));
|
||||
}
|
||||
}
|
||||
for (int k=0;k<5;++k) out[t][o++] = rb[t][k];
|
||||
if (n_energy == 3) {
|
||||
for (int c=0;c<3;++c){ float e=0; for(int i=0;i<B;++i) e+=raw[t][c*B+i]; out[t][o++]=e; }
|
||||
} else {
|
||||
float e=0; for (float v : raw[t]) e+=v; out[t][o++]=e;
|
||||
}
|
||||
o += 2; // debounce filled below
|
||||
}
|
||||
// debounce from this block's delta-k1 (its first column = off)
|
||||
std::vector<float> d1(T); for (int t=0;t<T;++t) d1[t]=out[t][off];
|
||||
auto clk = debounce_phase(d1);
|
||||
// debounce sits at the end of the block: off + 4(deltas) + (corr?3:0) + 5(ramp) + n_energy
|
||||
int deb = off + 4 + (corr?3:0) + 5 + n_energy;
|
||||
for (int t=0;t<T;++t){ out[t][deb]=clk[t].first; out[t][deb+1]=clk[t].second; }
|
||||
}
|
||||
|
||||
// per-second base = video(17) + audio(12). Audio empty → its block is the
|
||||
// zero-series result (deltas/ramp/energy 0, debounce phase=1/decay=exp(-1)).
|
||||
static std::vector<std::vector<float>> build_base(const std::vector<std::vector<float>>& hist,
|
||||
const std::vector<std::vector<float>>& audio) {
|
||||
const int T = hist.size();
|
||||
std::vector<std::vector<float>> base(T, std::vector<float>(29, 0.0f));
|
||||
modality_block(hist, /*corr=*/true, /*n_energy=*/3, base, /*off=*/0); // video → 0..16
|
||||
if (!audio.empty() && int(audio.size()) == T) {
|
||||
modality_block(audio, /*corr=*/false, /*n_energy=*/1, base, /*off=*/17); // audio → 17..28
|
||||
} else {
|
||||
// zero-series audio: deltas/ramp/energy already 0; only debounce differs.
|
||||
auto clk = debounce_phase(std::vector<float>(T, 0.0f));
|
||||
for (int t=0;t<T;++t){ base[t][27]=clk[t].first; base[t][28]=clk[t].second; }
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// matches debounce_phase(): 90th-pct peaks, dt=time since last, phase/decay.
|
||||
static std::vector<std::pair<float,float>> debounce_phase(const std::vector<float>& sig) {
|
||||
const int T = sig.size();
|
||||
std::vector<float> s(sig); std::sort(s.begin(), s.end());
|
||||
float thr = s[std::min(T-1, int(0.90*T))];
|
||||
std::vector<std::pair<float,float>> out(T);
|
||||
int last = -1000000000;
|
||||
for (int t=0;t<T;++t){
|
||||
if (sig[t] > thr) last = t;
|
||||
double dt = (last < -100000000) ? kSigmaTau : double(t - last);
|
||||
out[t] = { float(std::min(1.0, dt/kSigmaTau)), float(std::exp(-dt/kSigmaTau)) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// window flatten (t-3..t+3, edge-pad) + append the 3-value film clock.
|
||||
static std::vector<float> window_and_clock(const std::vector<std::vector<float>>& base,
|
||||
const std::vector<std::vector<float>>& hist) {
|
||||
const int T = base.size(), d = base[0].size(); // d=29
|
||||
// film-level clock: time-since-last-peak on the |delta k1| video signal
|
||||
// (base col 0), same as per_second_matrix's `clock`.
|
||||
std::vector<float> sig(T); for (int t=0;t<T;++t) sig[t]=base[t][0];
|
||||
std::vector<float> ss(sig); std::sort(ss.begin(), ss.end());
|
||||
float thr = ss[std::min(T-1, int(0.90*T))];
|
||||
std::vector<float> X; X.reserve(size_t(T)*206);
|
||||
int last=-1000000000;
|
||||
for (int t=0;t<T;++t){
|
||||
for (int off=-kWin; off<=kWin; ++off){
|
||||
int idx = std::min(T-1, std::max(0, t+off));
|
||||
for (int j=0;j<d;++j) X.push_back(base[idx][j]);
|
||||
}
|
||||
if (sig[t] > thr) last=t;
|
||||
double dt=(last<-100000000)?kSigmaTau:double(t-last);
|
||||
X.push_back(float(dt));
|
||||
X.push_back(float(std::min(1.0, dt/kSigmaTau)));
|
||||
X.push_back(float(std::exp(-dt/kSigmaTau)));
|
||||
}
|
||||
return X;
|
||||
}
|
||||
|
||||
std::vector<float> predict(const std::vector<float>& X, int rows, int cols) {
|
||||
DMatrixHandle dm;
|
||||
if (XGDMatrixCreateFromMat(X.data(), rows, cols, std::nanf(""), &dm) != 0)
|
||||
throw std::runtime_error("XGDMatrixCreateFromMat failed");
|
||||
bst_ulong out_len = 0; const float* out = nullptr;
|
||||
if (XGBoosterPredict(booster_, dm, 0, 0, 0, &out_len, &out) != 0)
|
||||
throw std::runtime_error("XGBoosterPredict failed");
|
||||
std::vector<float> p(out, out + out_len);
|
||||
XGDMatrixFree(dm);
|
||||
for (auto& v : p) v = std::clamp(v, 0.f, 1.f);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Exact replica of scipy.signal.find_peaks(x, distance=d):
|
||||
// 1. local maxima (plateau-aware: rising then falling, midpoint of a flat top)
|
||||
// 2. keep peaks by DESCENDING height; drop any within `d` of an already-kept
|
||||
// taller peak. This is height-priority, NOT the greedy left-to-right merge
|
||||
// — the two give different peak sets and hence a different knee.
|
||||
static std::vector<int> find_peaks(const std::vector<float>& x, int d) {
|
||||
const int n = x.size();
|
||||
std::vector<int> mid;
|
||||
int i = 1;
|
||||
while (i < n-1) {
|
||||
if (x[i-1] < x[i]) {
|
||||
int ahead = i+1;
|
||||
while (ahead < n-1 && x[ahead] == x[i]) ahead++;
|
||||
if (x[ahead] < x[i]) mid.push_back((i + ahead - 1) / 2);
|
||||
i = ahead;
|
||||
} else i++;
|
||||
}
|
||||
// height-priority distance filter (scipy's _select_by_peak_distance)
|
||||
std::vector<int> order(mid.size());
|
||||
for (size_t k = 0; k < mid.size(); ++k) order[k] = k;
|
||||
std::sort(order.begin(), order.end(),
|
||||
[&](int a, int b){ return x[mid[a]] < x[mid[b]]; }); // ascending
|
||||
std::vector<char> keep(mid.size(), 1);
|
||||
for (int j = int(order.size())-1; j >= 0; --j) { // tallest first
|
||||
int k = order[j];
|
||||
if (!keep[k]) continue;
|
||||
for (int l = k-1; l >= 0 && mid[k]-mid[l] < d; --l) keep[l] = 0;
|
||||
for (int r = k+1; r < int(mid.size()) && mid[r]-mid[k] < d; ++r) keep[r] = 0;
|
||||
}
|
||||
std::vector<int> out;
|
||||
for (size_t k = 0; k < mid.size(); ++k) if (keep[k]) out.push_back(mid[k]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// knee threshold on peak heights → boundary timestamps (matches knee_boundaries).
|
||||
static std::vector<double> knee_boundaries(const std::vector<float>& prob,
|
||||
const std::vector<double>& ts,
|
||||
int min_gap = 5) {
|
||||
std::vector<int> pk = find_peaks(prob, min_gap);
|
||||
if (pk.size() < 5) {
|
||||
std::vector<double> r; for (int i : pk) r.push_back(ts[i]); return r;
|
||||
}
|
||||
std::vector<float> h; for (int i : pk) h.push_back(prob[i]);
|
||||
std::sort(h.begin(), h.end(), std::greater<float>());
|
||||
int n = h.size(); float h0 = h.front() + 1e-9f;
|
||||
int kbest = 0; double dmax = -1;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
double x = double(i)/(n-1);
|
||||
double yv = h[i]/h0;
|
||||
double chord = (h[0]/h0) + ((h[n-1]/h0)-(h[0]/h0))*x;
|
||||
if (chord - yv > dmax) { dmax = chord - yv; kbest = i; }
|
||||
}
|
||||
float knee = h[kbest];
|
||||
std::vector<double> out;
|
||||
for (int i : pk) if (prob[i] >= knee) out.push_back(ts[i]);
|
||||
return out;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user