A gallery is only valid for the embedder that produced its vectors. Cosine
similarities across models are meaningless but *look* plausible, so the mistake
is silent and every measurement taken afterwards is suspect. Stamp the embedder
identity into the gallery at build; verify it at every load.
The stamp is the model file's basename plus the SHA-256 of its bytes (plus
embed_dim). The hash decides, the name explains. A name alone is a promise
rather than a fact — models get re-exported and overwritten in place under an
unchanged filename, which is exactly the case where the weights differ and
nothing else does. A hash alone is correct but unactionable in an error message.
SHA-256 is derived from the artefact, needs no registry kept current, and costs
~0.1s for a 250MB ONNX, memoised per process.
Mismatch is a hard error in every mode, with no bypass, naming both sides.
Unstamped legacy galleries warn loudly and proceed: unknown is not known-bad,
and hard-failing every pre-existing gallery would turn the check into something
people disable rather than trust. --require-gallery-stamp (or
SAE_REQUIRE_GALLERY_STAMP=1, which propagates to subprocesses) promotes that to
a hard error — the mode measurement work should run in. scripts/stamp_gallery.py
re-binds an existing gallery with no re-embedding, so "warn" is a cheap state to
leave rather than a permanent one.
Embedding dumps carry the same stamp: a replay has no live embedder, so the dump
is the embedder as far as the gallery is concerned. Derived galleries inherit
their source's stamp; --merge and the JSON gallery merge check before writing,
since one file holding two embedding spaces cannot be untangled afterwards.
Verified in: scene_analyze, scene_preview, the sae_kpn matcher binding,
replay.py, optimize.py (once per film at startup, before the first evaluation),
movienet_eval.py and both merge paths.
Stamp logic lives in src/gallery/embedder_stamp.{hpp,cpp} and its Python twin
scripts/sae_stamp.py, kept dependency-light so replay subprocesses do not pay
sae_gallery's requests/Pillow import to ask whether two models match.
Tests: 12 new cases in test_gallery_store.cpp covering the comparison logic,
both round trips, and the SHA-256 vectors that guarantee the C++ and hashlib
stamps agree. No ONNX or GPU required.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
13 KiB
C++
231 lines
13 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.
|
|
// ├──► [scene_tracker] ──► [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 "nodes/scene_tracker_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("--match-threshold")) cfg.match_threshold = std::stof(next());
|
|
else if (arg("--extinction")) cfg.extinction_sec = std::stod(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("--ratio")) cfg.match_ratio = std::stof(next());
|
|
else if (arg("--ratio-ceil")) cfg.match_ratio_ceil = 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-max-embed")) cfg.track_max_embed_dist = std::stof(next());
|
|
else if (arg("--track-max-missing")) cfg.track_max_frames_missing = std::stoi(next());
|
|
else if (arg("--anneal")) cfg.anneal_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-novelty-sim")) cfg.expand_novelty_sim = std::stof(next());
|
|
else if (arg("--expand-spread-max")) cfg.expand_track_spread_max = 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};
|
|
FaceTrackerFunc ftracker_fn{cfg};
|
|
IdentityMatcherFunc matcher_fn {gallery, cfg};
|
|
SceneTrackerFunc tracker_fn {cfg};
|
|
ResultSinkFunc sink_fn {cfg, done};
|
|
|
|
// ── 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<SceneTrackerFunc, kpn::in<"matched">, kpn::out<"annotation">, "scene_tracker", 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> → [scene_tracker, 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;
|
|
}
|