Files
scene-actor-extraction/src/main.cpp
T
dtourolleandClaude Opus 5 bbab5aed23 feat: join the decode butterfly so scene boundaries reach the face branch
AR-010 — is_scene_boundary had no producer: SceneDetectorFunc was a terminal
sink writing scenes.json and never annotating the frames flowing to face
detection. The flag was permanently false, so the boundary half of AR-007's
frame-dependent association was dead code that a test could still exercise
synthetically and appear to verify.

The topology already forks after decode — dense frames to TransNetV2, sampled
frames to face detection — so this is a fork-join. SceneBoundaries is the join:
the detector publishes each window's verdict with a watermark, and an annotator
on the sampled branch stamps the flag.

The watermark is the part that matters. TransNetV2 buffers 100 frames before it
can score any of them, so at any instant it has an opinion up to some time T and
none after. Without recording T a consumer cannot tell "no boundary" from "not
scored yet", and those demand opposite behaviour — treating unscored frames as
boundary-free is exactly what makes a downstream check pass while verifying
nothing.

Buffering alone does not work, which was my first attempt. Channel depth creates
lag only when the consumer is slower, and the face branch runs four orders of
magnitude faster per frame than TransNetV2 (0.01ms vs 400ms), so its channels
drain instantly and no lag accumulates. Measured: 106 of 364 frames outran the
detector. The annotator therefore waits on the watermark explicitly. The
detector signals completion so the tail cannot deadlock, and publishes from
flush_remaining too — without that the final frames arrive with no verdict.

Boundaries are deduped on publish, matching what scenes.json does at write time.
A run of adjacent high-scoring frames is one boundary, not several; leaving them
raw made this view report 357 where the file said 13. Now the two agree exactly.

Frames past the detector's last scored window remain unverified and are counted
as such rather than silently marked boundary-free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: AR-007, AR-010 | SR-002
2026-07-31 14:56:38 +02:00

464 lines
26 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──► [scene_tracker]
// ──SceneAnnotation──► [result_sink]
//
// Debug build (SAE_DEBUG=1):
// [identity_matcher] output fans out to both [scene_tracker] 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)
// --match-threshold <f> cosine dist threshold (default: 0.45)
// --extinction <f> actor extinction window in seconds (default: 5.0)
// --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: 12;
// 0 = native fps). Lower = faster, coarser boundaries.
// --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: 10)
// --expand-gallery enable per-film gallery expansion from track continuity
// --expand-buffer <N> per-track diversity buffer size (default: 20)
// --expand-novelty-sim <f> promote only views with best sim < f (default: 0.55)
// --expand-spread-max <f> reject track if buffer spread > f (default: 0.60)
// --expand-min-anchor <N> accepted frames before a track confirms (default: 3)
// --expand-debug-dir <p> dump promoted mugshots + embeddings here (SAE_DEBUG)
// (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 "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/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 <atomic>
#include <chrono>
#include <cstring>
#include <iostream>
#include <map>
#include <mutex>
#include <stdexcept>
#include <string>
#include <string_view>
#include <thread>
// ── CLI parsing ───────────────────────────────────────────────────────────────
/// TRACES: AR-010, AR-004 | SR-002
/// How deeply the sampled branch is buffered behind the dense one. TransNetV2
/// needs kWindow (100) dense frames before it can score any of them, so the face
/// branch must lag by at least that much or it 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 detector simply runs ahead.
static constexpr std::size_t kSceneJoinDepth = 256;
/// Set when the scene branch is built, so shutdown can report whether the join
/// actually worked.
static std::shared_ptr<SceneBoundaries> scene_stats;
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("--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("--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("--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("--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("--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-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("--anneal")) cfg.anneal_sec = std::stod(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-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());
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) {
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.extinction_sec = cfg.track_extinction_sec;
auto registry = std::make_shared<TrackRegistry>(
reg_cfg, EvidenceDiscounter(same_person));
matcher_fn.set_registry(registry);
FaceTrackerFunc ftracker_fn{cfg, registry, same_person};
SceneTrackerFunc tracker_fn {cfg};
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.
sink_fn.set_pre_write_hook([registry](double last_ts) { 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<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);
// ── 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);
}
});
std::cerr << "[main] starting pipeline…\n";
net.start();
// 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));
net.stop();
net.print_diagnostics();
/// 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";
}
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, 128);
// 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;
}, kSceneJoinDepth);
/// 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, kSceneJoinDepth);
// 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;
}