// 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 is auto-inserted by make_network(). // // Usage: // scene_analyze --movie --gallery [options] // // Options: // --output output JSON (default: annotations.json) // --fps sample rate in frames/sec (default: 1.0) // --verbosity <0|1|2> 0=minimal, 1=standard, 2=jellyfin-xray (default: 0) // --prob-threshold posterior P(match) to accept (default: 0.754) // --detector override SCRFD detector model path // --arcface override ArcFace model path // --scene-detect enable TransNetV2 shot-boundary detection (dense decode; // writes .scenes.json). Off by default. // --scene-detector override TransNetV2 .onnx model path // --scene-detector-engine pre-built TransNetV2 TRT engine (TRT backend) // --scene-threshold boundary sigmoid prob above this → cut (default: 0.60) // --scene-stride frames between TransNetV2 windows (default: 50, ≤100) // --scene-decode-fps 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 downscale decoded frames in scene-detect mode (0 max faces kept per frame (default: 0 = uncapped) // --ownership-logodds 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 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(same view) under this counts as a new look // --evidence-max-views distinct views remembered per track // --expand-gallery enable per-film gallery expansion from track continuity // --expand-buffer per-track diversity buffer size (default: 20) // --expand-band-lo

store admission floor, P(same person) (default: 0.90) // --expand-band-hi

store admission ceiling, P(same person) (default: 0.95) // --expand-min-anchor accepted frames before a track confirms (default: 3) // --expand-debug-dir

dump promoted mugshots + embeddings here (SAE_DEBUG) // --benchmark 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 channel-occupancy sampling period (default: 100) // (SAE_DEBUG only) // --debug-dir debug frames output dir (default: debug_frames) // --crop-context 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 #include // cv::setNumThreads (SAE_CV_THREADS) #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // ── 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(16, static_cast(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 scene_stats; /// TRACES: VR-015 | AR-004 | PR-004 /// Set by SIGUSR1, serviced by the wait loop. `kill -USR1 ` 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 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 done{false}; // set by result_sink (face branch) std::atomic 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( 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 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, kpn::out<"raw">, "frame_source", 0> source (source_fn, 32); kpn::ObjectNode, kpn::out<"frame">, "camera_pos", 0> campos (campos_fn, 32); kpn::ObjectNode, kpn::out<"scene">, "face_detector", 0> detector (detector_fn, 64); kpn::ObjectNode, kpn::out<"aligned">, "face_aligner", 0> aligner (aligner_fn, 64); kpn::ObjectNode, kpn::out<"embedded">, "embedder", 0> embedder (embedder_fn, 32); kpn::ObjectNode, kpn::out<"tracked">, "face_tracker", 0> ftracker (ftracker_fn, 16); kpn::ObjectNode, kpn::out<"matched">, "identity_matcher", 0> matcher (matcher_fn, 16); kpn::ObjectNode, kpn::out<"annotation">, "frame_annotation", 0> tracker (tracker_fn, 16); kpn::ObjectNode,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 overflow_counts; std::atomic 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 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 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 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, 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, 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(); scene_fn.set_boundaries(boundaries); kpn::ObjectNode, 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(-1e18); const double interval = 1.0 / cfg.sample_fps; auto decimate = kpn::make_filter( [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, 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; }