Files
scene-actor-extraction/src/main.cpp
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

700 lines
38 KiB
C++

// scene_analyze — identify actors in a movie using a KPN pipeline
//
// TRACES: DP-001, DP-002 | PR-004
// One analysis core; the CLI is a front-end over it and must not fork pipeline
// logic. Other deployment modes (DP-003, DP-004) wrap this same core.
//
// KPN topology (release build):
//
// [frame_source] ──Frame──► [face_detector] ──SceneFrame──► [face_aligner]
// ──AlignedSceneFrame──► [embedder] ──EmbeddedSceneFrame──►
// [identity_matcher] ──MatchedSceneFrame──► [frame_annotation]
// ──SceneAnnotation──► [result_sink]
//
// Debug build (SAE_DEBUG=1):
// [identity_matcher] output fans out to both [frame_annotation] AND [debug_renderer].
// FanoutNode<MatchedSceneFrame, 2> is auto-inserted by make_network().
//
// Usage:
// scene_analyze --movie <path> --gallery <gallery.json> [options]
//
// Options:
// --output <path> output JSON (default: annotations.json)
// --fps <N> sample rate in frames/sec (default: 1.0)
// --verbosity <0|1|2> 0=minimal, 1=standard, 2=jellyfin-xray (default: 0)
// --prob-threshold <f> posterior P(match) to accept (default: 0.754)
// --detector <path> override SCRFD detector model path
// --arcface <path> override ArcFace model path
// --scene-detect enable TransNetV2 shot-boundary detection (dense decode;
// writes <output>.scenes.json). Off by default.
// --scene-detector <path> override TransNetV2 .onnx model path
// --scene-detector-engine <path> pre-built TransNetV2 TRT engine (TRT backend)
// --scene-threshold <f> boundary sigmoid prob above this → cut (default: 0.60)
// --scene-stride <N> frames between TransNetV2 windows (default: 50, ≤100)
// --scene-decode-fps <f> dense decode rate in scene-detect mode (default: 0 =
// native, the only rate TransNetV2 is calibrated for;
// AR-011). Lowering it runs the model off-distribution.
// --dense-scale <f> downscale decoded frames in scene-detect mode (0<f≤1,
// default 1=off). Speeds decode; keep ≥0.5 on 1080p.
// --max-faces <N> max faces kept per frame (default: 0 = uncapped)
// --ownership-logodds <f> belief needed to own a track (default: 2.0 ≈ P 0.88).
// Below it a track makes no presence claim at all.
// --evidence-rho-max <f> ceiling on correlation between two observations of
// one track (default: 0.5 = a repeated view is worth
// at most two independent ones). AR-025.
// --evidence-admit-below <p> P(same view) under this counts as a new look
// --evidence-max-views <N> distinct views remembered per track
// --expand-gallery enable per-film gallery expansion from track continuity
// --expand-buffer <N> per-track diversity buffer size (default: 20)
// --expand-band-lo <p> store admission floor, P(same person) (default: 0.90)
// --expand-band-hi <p> store admission ceiling, P(same person) (default: 0.95)
// --expand-min-anchor <N> accepted frames before a track confirms (default: 3)
// --expand-debug-dir <p> dump promoted mugshots + embeddings here (SAE_DEBUG)
// --benchmark <path> write a per-node timing + bottleneck report (JSON) and
// print it at shutdown. Says where the run's time went
// and which node is pacing it. See src/benchmark.hpp.
// --benchmark-interval-ms <N> channel-occupancy sampling period (default: 100)
// (SAE_DEBUG only)
// --debug-dir <path> debug frames output dir (default: debug_frames)
// --crop-context <f> bbox expansion factor for context crops (default: 1.5)
#include "benchmark.hpp"
#include "config.hpp"
#include "types.hpp"
#include "gallery/embedder_stamp.hpp"
#include "gallery/gallery_store.hpp"
#include "nodes/frame_source_node.hpp"
#include "nodes/camera_position_change_detector_node.hpp"
#include "nodes/face_detector_node.hpp"
#include "nodes/face_aligner_node.hpp"
#include "nodes/embedder_node.hpp"
#include "nodes/face_tracker_node.hpp"
#include "nodes/identity_matcher_node.hpp"
#include "nodes/frame_annotation_node.hpp"
#include "inference/scene_detector.hpp" // ISceneDetector::kWindow, for the join-depth derivation
#include "nodes/scene_detector_node.hpp"
#include "scene_boundaries.hpp"
#include "nodes/scene_boundary_annotator_node.hpp"
#include "nodes/result_sink_node.hpp"
#include "nodes/embedding_dump_node.hpp"
#ifdef SAE_DEBUG
#include "nodes/debug_renderer_node.hpp"
#endif
#include <kpn/kpn.hpp>
#include <opencv2/core/utility.hpp> // cv::setNumThreads (SAE_CV_THREADS)
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <csignal>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iostream>
#include <map>
#include <mutex>
#include <stdexcept>
#include <string>
#include <string_view>
#include <thread>
// ── CLI parsing ───────────────────────────────────────────────────────────────
/// TRACES: AR-010, AR-004 | SR-002
/// Depth of the dense branch's own input queue. Part of how far behind the
/// fanout head TransNetV2 can be, and therefore an input to the join depth.
static constexpr std::size_t kSceneInputDepth = 128;
/// TRACES: AR-010, AR-004 | SR-002
/// How far the sampled branch must trail the dense one, in seconds of film.
///
/// TransNetV2 needs kWindow (100) dense frames before it can score any of
/// them, and its input queue can hold kSceneInputDepth more, so in the worst
/// case it has scored only up to (kSceneInputDepth + kWindow) frames behind
/// whatever the fanout has just delivered. The face branch must be at least
/// that far behind, or `scene_annotate` asks about frames nobody has looked at
/// yet. Backpressure turns depth into lag: the fanout blocks on the slower
/// branch rather than dropping, so the dense branch simply runs ahead.
///
/// Divided by a *lower bound* on native frame rate, because a slower source
/// makes the same frame count span more film — 24 fps is the floor for the
/// material this runs on, so it is the conservative choice.
static constexpr double kMinNativeFps = 24.0;
static constexpr double kSceneJoinLagSec =
(kSceneInputDepth + ISceneDetector::kWindow) / kMinNativeFps; // ~9.5 s
/// Margin over that minimum, for jitter in TransNetV2's inference time.
static constexpr double kSceneJoinSafety = 2.0;
/// TRACES: AR-004 | SR-002
/// Slots the sampled branch needs to hold `kSceneJoinLagSec` of film.
///
/// This used to be a constant 256, which is the whole bug: the requirement is a
/// span of *film*, and the slots needed to hold it depend on `sample_fps`.
/// Pinned at 256 it was ~256 s of lag at 1 fps — 27x what the join needs — and
/// nothing recomputed it if `sample_fps` changed, so the one number the join's
/// correctness rests on drifted silently with an unrelated knob.
///
/// It is also the largest single memory item in the pipeline. Every message
/// embeds `Frame source`, so a slot on this branch holds a full decoded image:
/// 256 of them is ~1.5 GB at 1080p, against ~110 MB for the derived depth at
/// 1 fps. See AR-004 — capacity is counted in items, and only the byte figure
/// (now correct, see types.hpp) shows what a slot really costs.
static std::size_t scene_join_depth(float sample_fps) {
const double slots = kSceneJoinSafety * kSceneJoinLagSec * sample_fps;
// Floor of 16: below that the queue stops absorbing ordinary jitter and
// starts throttling the fanout, which would slow the dense branch it
// exists to let run ahead.
return std::max<std::size_t>(16, static_cast<std::size_t>(std::ceil(slots)));
}
/// TRACES: AR-004 | SR-002
/// The decimator's input, on the *full-rate* stream.
///
/// This was also kSceneJoinDepth, which put a 256-slot buffer of full-rate
/// frames in front of the decimator — and at 1 fps against 24 fps native, 23 of
/// every 24 of those frames exist only to be discarded a moment later. Holding
/// ~1.5 GB of decoded images for frames the very next node throws away is the
/// worst available use of the memory budget.
///
/// A filter is a pass-through, not a reservoir: the lag belongs *after*
/// decimation, where a slot buys `1/sample_fps` seconds of film instead of
/// `1/native_fps`. Sized only to keep the decimator fed.
static constexpr std::size_t kDecimatorInputDepth = 16;
/// Set when the scene branch is built, so shutdown can report whether the join
/// actually worked.
static std::shared_ptr<SceneBoundaries> scene_stats;
/// TRACES: VR-015 | AR-004 | PR-004
/// Set by SIGUSR1, serviced by the wait loop. `kill -USR1 <pid>` on a running
/// or WEDGED run prints the benchmark table immediately — channel occupancy
/// names the stalled node (full input, empty output) without a debug build or a
/// debugger, which is the difference between diagnosing the AR-004 hang in
/// seconds and reproducing it under gdb.
///
/// The handler only stores a flag; all printing happens on the main thread,
/// since nothing in the report is async-signal-safe.
static std::atomic<bool> g_dump_request{false};
extern "C" void sae_on_dump_signal(int) {
g_dump_request.store(true, std::memory_order_relaxed);
}
static Config parse_args(int argc, char** argv) {
Config cfg;
cfg.detector_model = kDefaultDetectorModel;
cfg.arcface_model = kDefaultArcfaceModel;
cfg.scene_model = kDefaultSceneModel;
cfg.output_path = "annotations.json";
for (int i = 1; i < argc; ++i) {
auto arg = [&](const char* flag) { return std::strcmp(argv[i], flag) == 0; };
auto next = [&]() -> std::string {
if (++i >= argc) throw std::runtime_error(std::string("missing arg after ") + argv[i-1]);
return argv[i];
};
if (arg("--movie")) cfg.movie_path = next();
else if (arg("--gallery")) cfg.gallery_path = next();
else if (arg("--output")) cfg.output_path = next();
else if (arg("--dump-embeddings")) cfg.dump_embeddings_path = next();
else if (arg("--benchmark")) cfg.benchmark_path = next();
else if (arg("--benchmark-interval-ms")) cfg.benchmark_interval_ms = std::stoi(next());
else if (arg("--fps")) cfg.sample_fps = std::stof(next());
else if (arg("--max-decode-fps")) cfg.max_decode_fps = std::stof(next());
else if (arg("--start")) cfg.start_sec = std::stod(next());
else if (arg("--end")) cfg.end_sec = std::stod(next());
else if (arg("--cut-threshold")) cfg.cut_threshold = std::stof(next());
else if (arg("--presence-mode")) { std::string m = next(); cfg.presence_mode = (m == "flood") ? PresenceMode::flood : PresenceMode::track_extent; }
else if (arg("--scene-xgb-model")) cfg.scene_xgb_model = next();
else if (arg("--scene-detect")) cfg.scene_detect = true;
else if (arg("--scene-detector")) cfg.scene_model = next();
else if (arg("--scene-detector-engine")) cfg.scene_engine = next();
else if (arg("--scene-threshold")) cfg.scene_threshold = std::stof(next());
else if (arg("--scene-stride")) cfg.scene_stride = std::stoi(next());
else if (arg("--scene-decode-fps")) cfg.scene_decode_fps = std::stof(next());
else if (arg("--dense-scale")) cfg.dense_scale = std::stof(next());
else if (arg("--verbosity")) { int v = std::stoi(next()); cfg.verbosity = v == 2 ? Verbosity::xray : v == 1 ? Verbosity::standard : Verbosity::minimal; }
else if (arg("--prior")) cfg.match_prior = std::stof(next());
else if (arg("--prob-threshold")) cfg.prob_threshold = std::stof(next());
else if (arg("--detector")) cfg.detector_model = next();
else if (arg("--detector-engine")) cfg.detector_engine = next();
else if (arg("--arcface")) cfg.arcface_model = next();
else if (arg("--require-gallery-stamp")) cfg.require_gallery_stamp = true;
else if (arg("--arcface-engine")) cfg.arcface_engine = next();
else if (arg("--conf")) cfg.detector_conf = std::stof(next());
else if (arg("--max-faces")) cfg.max_faces = std::stoi(next());
else if (arg("--min-face-px")) cfg.min_face_px = std::stof(next());
else if (arg("--track-alpha")) cfg.track_alpha = std::stof(next());
else if (arg("--track-min-iou")) cfg.track_min_iou = std::stof(next());
else if (arg("--track-min-prob")) cfg.track_assoc_min_prob = std::stof(next());
else if (arg("--track-extinction")) cfg.track_extinction_sec = std::stod(next());
else if (arg("--ownership-logodds")) cfg.ownership_logodds = std::stof(next());
else if (arg("--evidence-rho-max")) cfg.evidence_rho_max = std::stof(next());
else if (arg("--evidence-admit-below")) cfg.evidence_admit_below = std::stof(next());
else if (arg("--evidence-max-views")) cfg.evidence_max_views = std::stoi(next());
else if (arg("--expand-gallery")) cfg.expand_gallery = true;
else if (arg("--expand-buffer")) cfg.expand_buffer_size = std::stoi(next());
else if (arg("--expand-band-lo")) cfg.expand_band_lo = std::stof(next());
else if (arg("--expand-band-hi")) cfg.expand_band_hi = std::stof(next());
else if (arg("--expand-min-anchor")) cfg.expand_min_anchor_frames = std::stoi(next());
else if (arg("--expand-debug-dir")) cfg.expand_debug_dir = next();
else if (arg("--trt-cache")) cfg.trt.cache_dir = next();
else if (arg("--trt-fp16")) cfg.trt.fp16 = true;
else if (arg("--no-trt-fp16")) cfg.trt.fp16 = false;
else if (arg("--trt-int8")) cfg.trt.int8 = true;
else if (arg("--embed-batch")) cfg.embed_batch_size = std::stoi(next());
#ifdef SAE_DEBUG
else if (arg("--debug-dir")) cfg.debug_dir = next();
else if (arg("--crop-context")) cfg.crop_context = std::stof(next());
#endif
else {
std::cerr << "[warn] unknown flag: " << argv[i] << "\n";
}
}
if (cfg.movie_path.empty()) throw std::runtime_error("--movie is required");
if (cfg.gallery_path.empty()) throw std::runtime_error("--gallery is required");
return cfg;
}
// ── Main ──────────────────────────────────────────────────────────────────────
int main(int argc, char** argv) {
/// TRACES: VR-015 | PR-004
// OpenCV here is built against TBB, so cv::parallel_for_ opens an arena of
// nproc-1 workers (19 on a 20-core box) *on top of* KPN's one thread per
// node. Two schedulers, neither aware of the other, on the same cores.
//
// SAE_CV_THREADS=1 hands concurrency entirely to KPN, which is where this
// pipeline's parallelism is supposed to come from. Worth measuring rather
// than assuming: TBB fan-out inside warpAffine is free speed when the
// pipeline is otherwise idle, so this can cut either way. Unset = default.
if (const char* t = std::getenv("SAE_CV_THREADS")) {
const int n = std::atoi(t);
cv::setNumThreads(n);
std::cerr << "[opencv] cv::setNumThreads(" << n << ")\n";
}
Config cfg;
try {
cfg = parse_args(argc, argv);
} catch (const std::exception& e) {
std::cerr << "Usage error: " << e.what() << "\n";
return 1;
}
// Load actor gallery
ActorGallery gallery;
try {
gallery = load_gallery(cfg.gallery_path);
/// TRACES: GR-004 | SR-001
// Hard startup error before a single frame is decoded: a gallery built
// with another embedder yields plausible-looking, meaningless matches.
verify_gallery_embedder(gallery, cfg.gallery_path, cfg.arcface_model,
cfg.require_gallery_stamp);
} catch (const std::exception& e) {
std::cerr << "Gallery error: " << e.what() << "\n";
return 1;
}
std::cerr << "[main] gallery loaded: " << gallery.actors.size() << " actors\n";
// ── Construct node functors ───────────────────────────────────────────────
std::atomic<bool> done{false}; // set by result_sink (face branch)
std::atomic<bool> scene_done{true}; // set by scene_detector; true when disabled
FrameSourceFunc source_fn {cfg};
CameraPositionChangeDetectorFunc campos_fn {cfg};
FaceDetectorFunc detector_fn{cfg};
FaceAlignerFunc aligner_fn;
EmbedderFunc embedder_fn{cfg};
// Constructed before the tracker: it fits (or loads) the calibration, and
// the tracker must decide in that same probability space (AR-024).
IdentityMatcherFunc matcher_fn {gallery, cfg};
/// TRACES: AR-007, AR-008, AR-012, AR-024 | SR-002
// The registry is created here and shared, not owned by a node: track state
// is not a stage in the stream, it is state several stages read and write,
// and its final answer is only known when a track dies.
auto same_person = same_person_probability(matcher_fn.calibration());
TrackRegistry::Config reg_cfg;
reg_cfg.track_extinction_sec = cfg.track_extinction_sec;
reg_cfg.ownership_logodds = cfg.ownership_logodds;
/// TRACES: AR-025 | SR-002
// The discounter's parameters come from Config now. They used to be
// in-class defaults reached through the one-argument constructor, so the
// VR-007 sweep that rho_max's own comment defers to could not vary it.
EvidenceDiscounter::Config disc_cfg;
disc_cfg.max_views = cfg.evidence_max_views;
disc_cfg.admit_below = cfg.evidence_admit_below;
disc_cfg.rho_max = cfg.evidence_rho_max;
auto registry = std::make_shared<TrackRegistry>(
reg_cfg, EvidenceDiscounter(same_person, disc_cfg));
matcher_fn.set_registry(registry);
FaceTrackerFunc ftracker_fn{cfg, registry, same_person};
FrameAnnotationFunc tracker_fn {};
ResultSinkFunc sink_fn {cfg, done};
/// TRACES: AR-012, AR-016 | IR-002, IR-003 | SR-002
// A reaped track goes straight to the aggregator, so the registry holds only
// live tracks and its size is bounded by concurrent on-screen faces rather
// than growing with the film.
registry->on_track_dead([&sink_fn](const DeadTrack& d) { sink_fn.add_claim(d); });
// AR-016: a film ends with faces on screen and those tracks have not timed
// out. Without this flush the closing scene's cast is silently never
// emitted — a loss that reads as a recognition miss, not a bookkeeping bug.
/// TRACES: VR-015 | PR-004
// Last timestamp the pipeline reached, latched on the way out. It is what
// turns wall-clock seconds into the number that matters — seconds of film
// per second of run — and the sink is the only node that knows it.
std::atomic<double> film_sec{0.0};
sink_fn.set_pre_write_hook([registry, &film_sec](double last_ts) {
film_sec.store(last_ts, std::memory_order_release);
registry->flush(last_ts);
});
#ifdef SAE_DEBUG
DebugRendererFunc debug_fn {cfg};
#endif
// ── Wrap in KPN ObjectNodes ───────────────────────────────────────────────
// Queue sizes tuned to the pipeline's speed profile:
// embedder (16ms) is the slowest GPU node — buffer before it must be largest
// to prevent face_aligner pool overflows and frame drops.
kpn::ObjectNode<FrameSourceFunc, kpn::in<>, kpn::out<"raw">, "frame_source", 0> source (source_fn, 32);
kpn::ObjectNode<CameraPositionChangeDetectorFunc, kpn::in<"raw">, kpn::out<"frame">, "camera_pos", 0> campos (campos_fn, 32);
kpn::ObjectNode<FaceDetectorFunc, kpn::in<"frame">, kpn::out<"scene">, "face_detector", 0> detector (detector_fn, 64);
kpn::ObjectNode<FaceAlignerFunc, kpn::in<"scene">, kpn::out<"aligned">, "face_aligner", 0> aligner (aligner_fn, 64);
kpn::ObjectNode<EmbedderFunc, kpn::in<"aligned">, kpn::out<"embedded">, "embedder", 0> embedder (embedder_fn, 32);
kpn::ObjectNode<FaceTrackerFunc, kpn::in<"embedded">, kpn::out<"tracked">, "face_tracker", 0> ftracker (ftracker_fn, 16);
kpn::ObjectNode<IdentityMatcherFunc, kpn::in<"tracked">, kpn::out<"matched">, "identity_matcher", 0> matcher (matcher_fn, 16);
kpn::ObjectNode<FrameAnnotationFunc, kpn::in<"matched">, kpn::out<"annotation">, "frame_annotation", 0> tracker (tracker_fn, 16);
kpn::ObjectNode<ResultSinkFunc, kpn::in<"annotation">,kpn::out<>, "result_sink", 0> sink (sink_fn, 16);
// ── Pipeline observability + run loop (topology-agnostic) ──────────────────
// Factored into a lambda so the two topologies (with/without the scene-detect
// branch) share identical event handling, wait loop, and teardown. Any
// make_network result type binds to `Net&&`.
std::mutex event_mtx;
std::map<std::string, long> overflow_counts;
std::atomic<bool> node_crashed{false};
auto run_net = [&](auto&& net) -> int {
// Tally per-node channel overflow, and treat any non-result_sink Closed
// event as a crash so the wait loop below can't hang on `done` forever.
net.set_event_handler(
[&](std::string_view node_name, kpn::NodeEvent ev,
std::chrono::steady_clock::time_point) {
if (ev == kpn::NodeEvent::Overflow) {
std::lock_guard<std::mutex> lk(event_mtx);
++overflow_counts[std::string(node_name)];
} else { // NodeEvent::Closed
if (node_name == "result_sink" && done.load(std::memory_order_acquire))
return;
std::cerr << "[main] node '" << node_name
<< "' stopped unexpectedly — aborting pipeline\n";
node_crashed.store(true, std::memory_order_release);
}
});
// Report *why* a node died. A Closed event alone says only that one
// stopped; the exception it carried is what identifies the fault, and
// without this listener it is discarded at the node boundary. Returning
// false keeps the existing semantics — the node still stops and the
// Closed handler above still aborts the run — but the run now names the
// cause instead of leaving it to be reconstructed from a debugger.
net.set_error_handler(
[&](std::string_view node_name, std::exception_ptr eptr) {
std::string what = "unknown exception";
try {
if (eptr) std::rethrow_exception(eptr);
} catch (const std::exception& e) {
what = e.what();
} catch (...) {
}
std::lock_guard<std::mutex> lk(event_mtx);
std::cerr << "[main] node '" << node_name << "' threw: " << what << "\n";
return false;
});
/// TRACES: VR-015 | PR-004
// Sampling must start with the network and stop before it is destroyed:
// channel fill is instantaneous, and by the time a run ends everything
// has drained, so a single read at shutdown reports an idle pipeline no
// matter how congested it was.
sae::bench::BenchmarkRecorder bench{cfg.benchmark_interval_ms};
const bool benchmarking = !cfg.benchmark_path.empty();
std::cerr << "[main] starting pipeline…\n";
net.start();
if (benchmarking) {
bench.start([&net] { return net.network_snapshot(); });
std::signal(SIGUSR1, sae_on_dump_signal);
std::cerr << "[benchmark] sampling every " << cfg.benchmark_interval_ms
<< "ms — `kill -USR1 " << getpid()
<< "` to dump the table now (works while hung)\n";
}
// Wait until BOTH terminal branches finish: result_sink (face pipeline)
// and, when enabled, scene_detector (the dense TransNetV2 branch, which
// runs much slower and must not be torn down mid-stream). scene_done is
// pre-set true when scene detection is disabled.
while ((!done.load(std::memory_order_acquire) ||
!scene_done.load(std::memory_order_acquire)) &&
!node_crashed.load(std::memory_order_acquire)) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
/// TRACES: VR-015 | AR-004 | PR-004
if (g_dump_request.exchange(false, std::memory_order_relaxed))
bench.dump_live(std::cerr, film_sec.load(std::memory_order_acquire));
}
// Latch the counters before stop(): they stay readable afterwards, but
// only while the network object is alive, and this keeps the numbers
// describing the run rather than the teardown.
if (benchmarking) bench.stop();
net.stop();
net.print_diagnostics();
/// TRACES: VR-015 | PR-004
if (benchmarking && bench.has_data()) {
const double film = film_sec.load(std::memory_order_acquire);
bench.print(std::cerr, film);
nlohmann::json run_cfg{
{"movie", cfg.movie_path},
{"gallery", cfg.gallery_path},
{"gallery_actors", gallery.actors.size()},
{"sample_fps", cfg.sample_fps},
{"min_face_px", cfg.min_face_px},
{"max_faces", cfg.max_faces},
{"embed_batch", cfg.embed_batch_size},
{"expand_gallery", cfg.expand_gallery},
{"scene_detect", cfg.scene_detect},
{"detector_engine", cfg.detector_engine},
{"arcface_engine", cfg.arcface_engine},
{"detector_model", cfg.detector_model},
{"arcface_model", cfg.arcface_model},
};
std::ofstream bf(cfg.benchmark_path);
if (bf) {
bf << bench.to_json(run_cfg, film).dump(2) << "\n";
std::cerr << "[benchmark] wrote " << cfg.benchmark_path << "\n";
} else {
std::cerr << "[benchmark] ERROR: could not write "
<< cfg.benchmark_path << "\n";
}
}
/// TRACES: AR-004 | SR-002
// A dropped frame does not degrade a result, it silently changes one —
// the output is a claim about footage that was never analysed, and
// nothing in the file says so. Since AR-004 made data pushes block, a
// drop can no longer happen on the data path, so any drop here means
// either that fix regressed (it lives in the KPN submodule, one line,
// easy to lose in an update) or a channel was disabled mid-run.
//
// Reporting it in a footer and exiting 0 made both invisible: the run
// "succeeded" and the truth file looked complete. Fail instead.
/// TRACES: AR-010 | SR-002
if (scene_stats) {
std::cerr << "[scene_annotate] boundaries=" << scene_stats->count()
<< " scored_through=" << scene_stats->scored_through() << "s";
// The tail is expected: frames after the detector's last full
// window are never covered, and no amount of buffering changes
// that. They are counted rather than silently treated as
// boundary-free, which is the distinction that matters.
if (scene_stats->outran() > 0)
std::cerr << " unscored=" << scene_stats->outran()
<< " frame(s) past the detector's last window — treated as"
" boundary-free, which is unverified rather than known";
std::cerr << "\n";
}
/// TRACES: AR-025, AR-012 | SR-002
// How often the registry was asked about a track it had already reaped.
//
// A vote is dropped when the matcher lags the tracker by more than
// track_extinction_sec of FILM time. The two are adjacent nodes with a
// 16-deep channel between them, and the matcher is much the slower of
// the pair (a GEMM over the whole gallery against a Hungarian solve over
// a handful of boxes), so that channel runs full and the lag is close to
// its depth. In frames:
//
// lag_sec ~= channel_depth / sample_fps
//
// At the default sample_fps of 1.0 that is ~16 s against a 5 s window,
// so votes CAN be dropped here, and each one is identity evidence that
// never reached the track it belonged to -- presence under-reported, in
// a way that reads as a recognition miss.
//
// Reported rather than fatal, deliberately, and the distinction from the
// dropped-frame case below is real: a dropped frame means the output
// describes footage nobody analysed, which is always wrong. A dropped
// vote means one observation of a track went missing, which degrades a
// claim without falsifying it. There is also no measurement yet of how
// often it happens on real content -- so this prints the number that
// would justify a harder line rather than presuming it. See VR-017.
if (registry) {
const int dv = registry->dropped_votes();
if (dv > 0) {
std::cerr << "[registry] WARNING: " << dv << " identity vote(s) "
"arrived for already-reaped tracks. The matcher is "
"lagging the tracker by more than track_extinction_sec ("
<< cfg.track_extinction_sec << "s) of film; presence is "
"under-reported. Raise --track-extinction or reduce the "
"face_tracker/identity_matcher channel depth.\n";
}
std::cerr << "[registry] belief_swaps=" << registry->belief_swaps()
<< " actor_conflicts=" << registry->actor_conflicts()
<< " dropped_votes=" << dv << "\n";
}
bool dropped = false;
{
std::lock_guard<std::mutex> lk(event_mtx);
if (!overflow_counts.empty()) {
dropped = true;
std::cerr << "[main] ERROR: frames were dropped (channel overflow):\n";
for (const auto& [name, count] : overflow_counts)
std::cerr << " " << name << ": " << count << "\n";
std::cerr << "[main] The output would describe footage that was never "
"analysed. Refusing to report success.\n";
}
}
if (node_crashed.load(std::memory_order_acquire)) return 1;
return dropped ? 2 : 0;
};
// ── Build static network and run ──────────────────────────────────────────
// Common face-analysis chain (campos → … → sink) is identical in all cases;
// the scene-detect branch and the debug fanout are spliced on conditionally.
// Topology:
// plain: source → campos → detector → … → sink
// scene-detect: source ─┬→ campos → decimate(filter) → detector → … → sink
// └→ scene_detector (TransNetV2 sink → scenes.json)
// The fanout after `source` is auto-inserted by make_network when its output
// feeds two edges. In dense mode campos still sees native-rate frames (so it
// detects angle changes correctly); a FilterNode then thins to sample_fps
// before face detection.
#ifdef SAE_DEBUG
kpn::ObjectNode<DebugRendererFunc, kpn::in<"matched">, kpn::out<>, "debug_renderer", 1> debug_node(debug_fn, 16);
#define SAE_DEBUG_EDGE , kpn::edge(matcher.output<"matched">(), debug_node.input<"matched">())
#else
#define SAE_DEBUG_EDGE
#endif
int rc = 0;
if (!cfg.dump_embeddings_path.empty()) {
// Dump-only topology: run the expensive front half and tee the embedder
// output to an HDF5 dump for offline sweep replay (sae_kpn). Downstream
// matching is skipped — the sweep re-runs it from the dump.
EmbeddingDumpFunc dump_fn{cfg, done};
kpn::ObjectNode<EmbeddingDumpFunc, kpn::in<"embedded">, kpn::out<>, "embedding_dump", 0>
dump_node(dump_fn, 32);
auto net = kpn::make_network(
kpn::edge(source.output<"raw">(), campos.input<"raw">()),
kpn::edge(campos.output<"frame">(), detector.input<"frame">()),
kpn::edge(detector.output<"scene">(), aligner.input<"scene">()),
kpn::edge(aligner.output<"aligned">(), embedder.input<"aligned">()),
kpn::edge(embedder.output<"embedded">(), dump_node.input<"embedded">())
);
return run_net(std::move(net));
}
if (cfg.scene_detect) {
scene_done.store(false, std::memory_order_release); // now a real terminal branch
SceneDetectorFunc scene_fn{cfg, scene_done};
/// TRACES: AR-010 | SR-002
// The join of the decode butterfly. source fans out to the dense
// TransNetV2 branch and the sampled face branch; boundaries found on the
// first have to reach the second, and cannot ride the frames because the
// branches run in parallel.
//
// TransNetV2 buffers kWindow frames before it can score any of them, so
// the face branch must lag by at least that much or it will ask about
// frames nobody has looked at yet. Channel depth is what creates the lag:
// with backpressure (AR-004) the fanout blocks on the slower branch, so
// a deep face-branch channel lets the detector run ahead by its window
// rather than dropping anything.
auto boundaries = std::make_shared<SceneBoundaries>();
scene_fn.set_boundaries(boundaries);
kpn::ObjectNode<SceneDetectorFunc, kpn::in<"dense">, kpn::out<>, "scene_detector", 0>
scene_node(scene_fn, kSceneInputDepth);
// Decimator: keep frames on the sample_fps cadence, drop the rest.
// eof always passes so downstream shuts down cleanly. Stateful — one
// instance, mutable via shared_ptr so the std::function stays copyable.
auto decim_state = std::make_shared<double>(-1e18);
const double interval = 1.0 / cfg.sample_fps;
auto decimate = kpn::make_filter<Frame>(
[decim_state, interval](const Frame& f) {
if (f.eof) return true;
if (f.timestamp_sec - *decim_state >= interval - 1e-6) {
*decim_state = f.timestamp_sec;
return true;
}
return false;
}, kDecimatorInputDepth);
/// TRACES: AR-010 | SR-002
// Stamp is_scene_boundary from the detector's published verdict. tol is
// half a sample interval: the two branches sample at different rates, so
// a boundary found on a dense frame rarely lands exactly on a sampled
// one, and half an interval attributes it to the nearest sampled frame
// and no further.
//
// outran() counts frames that arrived before the detector had scored
// them. Nonzero means the join depth is too shallow for the window, and
// those frames were annotated from an incomplete verdict — which would
// otherwise look exactly like "no boundary here".
SceneBoundaryAnnotatorFunc annotate_fn{boundaries, 0.5 / cfg.sample_fps};
kpn::ObjectNode<SceneBoundaryAnnotatorFunc, kpn::in<"frame">, kpn::out<"frame">,
"scene_annotate", 0> annotate(annotate_fn, scene_join_depth(cfg.sample_fps));
// Reported at shutdown: without this the join is unverifiable, and an
// annotator that never fired looks identical to footage with no
// boundaries.
scene_stats = boundaries;
auto net = kpn::make_network(
kpn::edge(source.output<"raw">(), campos.input<"raw">()),
kpn::edge(source.output<"raw">(), scene_node.input<"dense">()),
kpn::edge(campos.output<"frame">(), decimate.input<0>()),
kpn::edge(decimate.output<0>(), annotate.input<"frame">()),
kpn::edge(annotate.output<"frame">(), detector.input<"frame">()),
kpn::edge(detector.output<"scene">(), aligner.input<"scene">()),
kpn::edge(aligner.output<"aligned">(), embedder.input<"aligned">()),
kpn::edge(embedder.output<"embedded">(), ftracker.input<"embedded">()),
kpn::edge(ftracker.output<"tracked">(), matcher.input<"tracked">()),
kpn::edge(matcher.output<"matched">(), tracker.input<"matched">()),
kpn::edge(tracker.output<"annotation">(), sink.input<"annotation">())
SAE_DEBUG_EDGE
);
rc = run_net(std::move(net));
} else {
auto net = kpn::make_network(
kpn::edge(source.output<"raw">(), campos.input<"raw">()),
kpn::edge(campos.output<"frame">(), detector.input<"frame">()),
kpn::edge(detector.output<"scene">(), aligner.input<"scene">()),
kpn::edge(aligner.output<"aligned">(), embedder.input<"aligned">()),
kpn::edge(embedder.output<"embedded">(), ftracker.input<"embedded">()),
kpn::edge(ftracker.output<"tracked">(), matcher.input<"tracked">()),
kpn::edge(matcher.output<"matched">(), tracker.input<"matched">()),
kpn::edge(tracker.output<"annotation">(), sink.input<"annotation">())
SAE_DEBUG_EDGE
);
rc = run_net(std::move(net));
}
#undef SAE_DEBUG_EDGE
return rc;
}