Files
scene-actor-extraction/src/scene_preview.cpp
T
dtourolle 90b44e0975 docs(register): make the status column describe the code
Eleven rows corrected, in both directions.

Overstated: AR-011 (the derived dedup window reached scenes.json only),
AR-017 (route was a literal), AR-019 (the local plurality tally was
still deciding), AR-024 (its "static check" enforcement did not exist),
DP-001 (scene_preview had forked and stopped compiling), VR-002 (the
Python replay bindings have not compiled since the tracker redesign, and
the fixtures it calls committed are gitignored registry artifacts).

Understated: VR-010 was marked Planned while five VR-010 tags sat in the
code implementing it.

Rescoped: VR-007 now names the four AR-025 constants it was already
being deferred to for, and which no sweep could reach until this pass.

The Withdrawn note gets the longest correction, because it asserted a
removal that had not happened and nothing could have caught that: the
gate reads tags, and a withdrawn requirement has no tag to be orphaned.
The general form is now written down there -- a status column is a
claim, and the only claims this project checks automatically are the
ones a test or a static check makes. Four of the rows above are the same
pattern: recorded as done, and done in one place out of two.

New: VR-016, a cadence study for cut_threshold. It is the one always-on
signal with no recorded provenance, and its input rate depends on an
unrelated flag -- with --scene-detect off, camera_pos compares frames a
full second apart at the default sample_fps, and with it on, native-rate
frames. Same constant, two meanings, and is_cut drives track_alpha to 0
and clears every expansion buffer.

Also stops check_raw_cosine.py inflating its own metric: the extractor
scans scripts/, so the tool's prose describing the exception tag was
counted as four recorded exceptions. The count now reads 1, which is the
number of real ones.

TRACES: AR-011, AR-017, AR-019, AR-024, AR-025 | DP-001, DP-007 | IR-004 | VR-002, VR-007, VR-010, VR-016
2026-08-05 17:51:01 +02:00

254 lines
14 KiB
C++

// scene_preview — same pipeline as scene_analyze but with a live annotated
// display window driven from the main thread.
//
// KPN topology:
//
// [frame_source] ──► [camera_pos] ──► [face_detector] ──► [face_aligner] ──► [embedder]
// ──► [identity_matcher] ──► FanoutNode<MatchedSceneFrame,2>
//
// camera_pos (histogram cut detector) stamps Frame::cut_score / is_cut, which
// ride through to the preview HUD's cut-score meter.
// ├──► [frame_annotation] ──► [result_sink] (background thread)
// └──► [preview_node] (main thread)
//
// The main thread drives preview_node via preview.step(). When the movie ends
// (MatchedSceneFrame.source.eof == true) operator() returns false, the loop
// ends, and net.stop() is called. result_sink writes its JSON before its
// thread is joined by net.stop(), so the output file is always complete.
//
// Usage: same flags as scene_analyze (see main.cpp for reference).
// --preview-width <px> max display width in pixels (default: 1280)
#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 "track_registry.hpp"
#include "evidence_discount.hpp"
#include "nodes/frame_annotation_node.hpp"
#include "nodes/result_sink_node.hpp"
#include "nodes/preview_node.hpp"
#include <kpn/kpn.hpp>
#include <atomic>
#include <chrono>
#include <cstring>
#include <iostream>
#include <map>
#include <mutex>
#include <stdexcept>
#include <string>
#include <string_view>
#include <thread>
static Config parse_args(int argc, char** argv) {
Config cfg;
cfg.detector_model = kDefaultDetectorModel;
cfg.arcface_model = kDefaultArcfaceModel;
cfg.output_path = "annotations.json";
cfg.verbosity = Verbosity::standard; // default to standard in preview mode
for (int i = 1; i < argc; ++i) {
auto arg = [&](const char* f) { return std::strcmp(argv[i], f) == 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("--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("--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("--arcface-engine")) cfg.arcface_engine = next();
else if (arg("--require-gallery-stamp")) cfg.require_gallery_stamp = true;
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("--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());
// Per-film gallery expansion — preview supports it (same cfg fields).
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());
// Scene detection is scene_analyze-only (needs the dense TransNetV2 branch).
// Accept the flags so a shared command line runs, but note they're inert
// here — the preview shows the histogram cut-score meter instead.
else if (arg("--scene-detect")) {
std::cerr << "[preview] note: --scene-detect is inert in preview "
"(TransNetV2 needs the dense scene_analyze pipeline); "
"showing the histogram cut-score meter instead\n";
}
else if (arg("--scene-detector") || arg("--scene-detector-engine") ||
arg("--scene-threshold") || arg("--scene-stride") ||
arg("--scene-decode-fps") || arg("--dense-scale")) {
next(); // consume the value; inert in preview
}
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;
}
int main(int argc, char** argv) {
Config cfg;
try { cfg = parse_args(argc, argv); }
catch (const std::exception& e) {
std::cerr << "Usage error: " << e.what() << "\n";
return 1;
}
ActorGallery gallery;
try {
gallery = load_gallery(cfg.gallery_path);
/// TRACES: GR-004 | SR-001
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;
}
// ── Functors ──────────────────────────────────────────────────────────────
std::atomic<bool> done{false};
FrameSourceFunc source_fn {cfg};
CameraPositionChangeDetectorFunc campos_fn {cfg};
FaceDetectorFunc detector_fn{cfg};
FaceAlignerFunc aligner_fn;
EmbedderFunc embedder_fn{cfg};
/// TRACES: AR-007, AR-012, AR-024 | DP-001 | SR-002 | PR-004
// Construction order matters and is the same as main.cpp's, deliberately:
// the matcher fits (or loads) the calibration, the registry needs a
// discounter built from it, and the tracker needs both. DP-001 says modes
// are front-ends that must not fork pipeline logic -- this file had forked
// it and then rotted, constructing FaceTrackerFunc{cfg} against a signature
// that stopped existing with the AR-007/AR-008 redesign, so scene_preview
// has not compiled since. Keeping the order identical is what stops that
// recurring.
IdentityMatcherFunc matcher_fn {gallery, cfg};
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;
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};
// AR-012/AR-016: windows come from registry claims, and tracks still live
// at EOF must be flushed or the closing scene's cast is never emitted.
registry->on_track_dead([&sink_fn](const DeadTrack& d) { sink_fn.add_claim(d); });
sink_fn.set_pre_write_hook([registry](double last_ts) { registry->flush(last_ts); });
// ── KPN ObjectNodes ───────────────────────────────────────────────────────
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);
// MainThreadNode — no thread spawned; driven by preview.step() below
PreviewNode preview{cfg, 16};
// matcher → FanoutNode<MatchedSceneFrame,2> → [frame_annotation, preview] (auto-inserted)
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(matcher.output<"matched">(), preview.input<"matched">()),
kpn::edge(tracker.output<"annotation">(), sink.input<"annotation">())
);
// ── Pipeline observability (KPN event handler) ────────────────────────────
// Tally dropped frames per node (channel overflow) and detect a node that
// stops unexpectedly so the preview loop below can bail out instead of
// spinning on a dead pipeline.
std::mutex event_mtx;
std::map<std::string, long> overflow_counts;
std::atomic<bool> node_crashed{false};
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);
}
});
// ── Run ───────────────────────────────────────────────────────────────────
std::cerr << "[main] starting pipeline — press q or Esc to quit early\n";
net.start();
// Main thread drives the display window; returns false on EOF or q/Esc.
// Bail out early if a node crashes.
while (!node_crashed.load(std::memory_order_acquire) && preview.step()) {
cv::waitKey(1); // pump OS events between frames
}
net.stop();
sink_fn.flush(); // write whatever was accumulated (no-op if EOF already flushed)
cv::destroyAllWindows();
net.print_diagnostics();
{
std::lock_guard<std::mutex> lk(event_mtx);
if (!overflow_counts.empty()) {
std::cerr << "[main] dropped frames (channel overflow):\n";
for (const auto& [name, count] : overflow_counts)
std::cerr << " " << name << ": " << count << "\n";
}
}
return node_crashed.load(std::memory_order_acquire) ? 1 : 0;
}