Files
scene-actor-extraction/src/inference/audio_logpsd.hpp
T
dtourolle e5204a831a 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.
2026-08-09 21:21:29 +02:00

144 lines
6.0 KiB
C++

#pragma once
// Per-second audio log-PSD, C++ parity with scripts/scene_detector/
// extract_audio_features.py — the audio tower input for the XGBoost scene
// detector. Decodes the whole track to mono 16 kHz, then one FFT per second over
// a 4 s Hann-windowed window, power pooled into geomspace log-frequency bands,
// L1-normalised (shape not loudness) and log1p-compressed.
//
// Must match the Python exactly (SR=16000, WIN_SEC=4, N_BINS=64→geomspace unique
// edges, log1p(band*1e3)); the shipped model was trained on those features.
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
#include <libswresample/swresample.h>
}
#include <fftw3.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <vector>
class AudioLogPSD {
public:
static constexpr int kSR = 16000;
static constexpr double kHop = 1.0; // 1 feature row / second
static constexpr double kWin = 4.0; // FFT window seconds
static constexpr int kNBins = 64; // geomspace target (dedups to ~57)
// Returns [T][B] per-second log-PSD (T ≈ film seconds, B ≈ 57), aligned to the
// 1 fps grid. Empty on decode failure (caller then feeds a zero block).
static std::vector<std::vector<float>> extract(const std::string& path) {
std::vector<float> mono = decode_mono_16k(path);
if (mono.empty()) return {};
return features(mono);
}
// Public for the parity harness.
static std::vector<std::vector<float>> features(const std::vector<float>& mono) {
const int win = int(kSR * kWin), hop = int(kSR * kHop);
const int T = int(mono.size()) / hop;
if (T <= 0) return {};
const int nfreq = win/2 + 1;
std::vector<int> edges = geomspace_edges(nfreq);
const int nb = int(edges.size()) - 1;
// Hann window (matches scipy.signal.windows.hann, sym=True default → but
// numpy code uses sps.windows.hann(win) which is symmetric).
std::vector<double> hann(win);
for (int i = 0; i < win; ++i)
hann[i] = 0.5 - 0.5*std::cos(2.0*M_PI*i/(win-1));
std::vector<double> in(win);
auto* out = fftw_alloc_complex(nfreq);
fftw_plan plan = fftw_plan_dft_r2c_1d(win, in.data(), out, FFTW_ESTIMATE);
std::vector<std::vector<float>> feat(T, std::vector<float>(nb, 0.f));
const int half = win/2;
for (int t = 0; t < T; ++t) {
int centre = t*hop + hop/2;
int s = centre - half;
for (int i = 0; i < win; ++i) {
int idx = s + i;
double v = (idx >= 0 && idx < int(mono.size())) ? mono[idx] : 0.0;
in[i] = v * hann[i];
}
fftw_execute(plan);
// power spectrum + 1e-12
std::vector<double> psd(nfreq);
for (int i = 0; i < nfreq; ++i)
psd[i] = out[i][0]*out[i][0] + out[i][1]*out[i][1] + 1e-12;
std::vector<double> band(nb, 0.0);
double tot = 0.0;
for (int b = 0; b < nb; ++b) {
for (int i = edges[b]; i < edges[b+1]; ++i) band[b] += psd[i];
tot += band[b];
}
for (int b = 0; b < nb; ++b)
feat[t][b] = float(std::log1p(band[b]/tot * 1e3));
}
fftw_destroy_plan(plan); fftw_free(out);
return feat;
}
private:
// np.unique(np.geomspace(1, nfreq-1, N_BINS+1).astype(int))
static std::vector<int> geomspace_edges(int nfreq) {
const int n = kNBins + 1;
double a = std::log(1.0), b = std::log(double(nfreq-1));
std::vector<int> raw(n);
for (int i = 0; i < n; ++i)
raw[i] = int(std::exp(a + (b-a)*i/(n-1))); // .astype(int) truncates
std::vector<int> uniq;
for (int v : raw) if (uniq.empty() || v != uniq.back()) uniq.push_back(v);
return uniq;
}
static std::vector<float> decode_mono_16k(const std::string& path) {
AVFormatContext* fmt = nullptr;
if (avformat_open_input(&fmt, path.c_str(), nullptr, nullptr) < 0) return {};
std::vector<float> out;
SwrContext* swr = nullptr; AVCodecContext* dec = nullptr;
AVPacket* pkt = av_packet_alloc(); AVFrame* fr = av_frame_alloc();
try {
if (avformat_find_stream_info(fmt, nullptr) < 0) throw 0;
int ai = av_find_best_stream(fmt, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0);
if (ai < 0) throw 0;
AVStream* st = fmt->streams[ai];
const AVCodec* codec = avcodec_find_decoder(st->codecpar->codec_id);
dec = avcodec_alloc_context3(codec);
avcodec_parameters_to_context(dec, st->codecpar);
if (avcodec_open2(dec, codec, nullptr) < 0) throw 0;
AVChannelLayout out_ch = AV_CHANNEL_LAYOUT_MONO;
swr_alloc_set_opts2(&swr, &out_ch, AV_SAMPLE_FMT_FLT, kSR,
&dec->ch_layout, dec->sample_fmt,
dec->sample_rate ? dec->sample_rate : kSR, 0, nullptr);
if (!swr || swr_init(swr) < 0) throw 0;
while (av_read_frame(fmt, pkt) >= 0) {
if (pkt->stream_index == ai && avcodec_send_packet(dec, pkt) >= 0) {
while (avcodec_receive_frame(dec, fr) >= 0) {
int max_out = swr_get_out_samples(swr, fr->nb_samples);
size_t base = out.size(); out.resize(base + max_out);
uint8_t* dst = reinterpret_cast<uint8_t*>(out.data() + base);
int got = swr_convert(swr, &dst, max_out,
(const uint8_t**)fr->extended_data, fr->nb_samples);
out.resize(base + std::max(0, got));
}
}
av_packet_unref(pkt);
}
} catch (...) { out.clear(); }
if (swr) swr_free(&swr);
if (dec) avcodec_free_context(&dec);
av_frame_free(&fr); av_packet_free(&pkt);
avformat_close_input(&fmt);
return out;
}
};