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
254 lines
14 KiB
C++
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;
|
|
}
|