Files
scene-actor-extraction/src/nodes/scene_detector_node.hpp
T
dtourolleandClaude Opus 5 13bdc27566 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

213 lines
8.7 KiB
C++

#pragma once
#include "types.hpp"
#include "config.hpp"
#include "scene_boundaries.hpp"
#include <memory>
#include "inference/scene_detector.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <atomic>
#include <deque>
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
// ── SceneDetectorFunc ─────────────────────────────────────────────────────────
// KPN sink node: TransNetV2 shot-boundary detection on the dense frame stream.
//
// Buffers incoming (dense, native-rate) Frames into a rolling window of
// ISceneDetector::kWindow (=100) frames. Every `stride` frames it runs one
// inference and reads back per-frame boundary probabilities, but only trusts the
// central region of each window — TransNetV2 (like most sliding-window boundary
// models) is unreliable near the window edges where it lacks temporal context.
// Overlapping windows by (kWindow - stride) frames means every frame is scored
// from at least one window's trusted centre.
//
// Boundaries (prob > scene_threshold, local maxima) are collected with their
// timestamps and written to scenes.json alongside the main annotations output on
// EOF. This branch is terminal: it produces no pipeline messages, only a file.
struct SceneDetectorFunc {
static constexpr std::string_view label() { return "scene_detector"; }
/// TRACES: AR-010 | SR-002
/// Publish each window's verdict as it is scored, so the face branch — held
/// back by channel depth — can consult it for frames it has not reached yet.
void set_boundaries(std::shared_ptr<SceneBoundaries> b) { shared_ = std::move(b); }
SceneDetectorFunc(const Config& cfg, std::atomic<bool>& done)
: detector_(make_scene_detector(cfg))
, threshold_(cfg.scene_threshold)
, stride_(std::clamp(cfg.scene_stride, 1, ISceneDetector::kWindow))
, output_path_(scenes_path(cfg.output_path))
, movie_path_(cfg.movie_path)
, done_(done)
{
// Trusted centre half of each window. Frames outside [guard, kWindow-guard)
// are re-scored by an adjacent window, so we ignore them here to avoid
// edge artefacts and double-counting.
guard_ = (ISceneDetector::kWindow - stride_) / 2;
std::cerr << "[scene_detector] threshold=" << threshold_
<< " stride=" << stride_
<< " guard=" << guard_
<< " output=" << output_path_ << "\n";
}
void operator()(Frame f) {
if (f.eof) {
flush_remaining();
// Release anyone waiting on the join: the tail frames after the last
// full window will never be covered, so waiting for them would hang.
if (shared_) shared_->finish();
write_output();
done_.store(true, std::memory_order_release);
return;
}
images_.push_back(f.image);
times_.push_back(f.timestamp_sec);
// Once we have a full window, score it and slide forward by `stride`.
while (static_cast<int>(images_.size()) >= ISceneDetector::kWindow) {
score_window();
for (int i = 0; i < stride_; ++i) {
images_.pop_front();
times_.pop_front();
}
window_base_ += stride_;
}
}
private:
// Run TransNetV2 on the leading kWindow frames of the buffer and record any
// boundaries found within the trusted centre region.
void score_window() {
std::vector<cv::Mat> win(images_.begin(),
images_.begin() + ISceneDetector::kWindow);
std::vector<float> probs = detector_->detect_window(win);
// On the very first window there is no preceding window, so trust from 0;
// otherwise skip the leading guard already covered by the previous window.
const int lo = (window_base_ == 0) ? 0 : guard_;
const int hi = ISceneDetector::kWindow - guard_;
std::vector<double> fresh;
for (int i = lo; i < hi; ++i) {
if (probs[i] <= threshold_) continue;
// Local maximum → the boundary frame (avoid a run of high scores
// registering as several adjacent cuts).
const bool peak =
(i == 0 || probs[i] >= probs[i-1]) &&
(i == kLast_() || probs[i] >= probs[i+1]);
if (peak) {
boundaries_.push_back({times_[i], probs[i]});
fresh.push_back(times_[i]);
}
}
/// TRACES: AR-010 | SR-002
// Publish with a watermark: everything up to times_[hi-1] now has a
// final verdict. The face branch consults this for frames it has not
// reached yet, and the watermark is what lets it tell "no boundary
// here" from "not scored yet".
if (shared_ && hi > lo)
shared_->publish(fresh, times_[hi - 1]);
}
// At EOF the tail (< kWindow frames) never formed a full window. Pad it out
// to kWindow by repeating the last frame so the final real frames still get
// scored, then take only the region past what earlier windows covered.
void flush_remaining() {
const int n = static_cast<int>(images_.size());
if (n == 0) return;
std::vector<cv::Mat> win(images_.begin(), images_.end());
cv::Mat last = win.back();
while (static_cast<int>(win.size()) < ISceneDetector::kWindow)
win.push_back(last);
std::vector<float> probs = detector_->detect_window(win);
const int lo = (window_base_ == 0) ? 0 : guard_;
std::vector<double> fresh;
for (int i = lo; i < n; ++i) { // only real (non-padded) frames
if (probs[i] <= threshold_) continue;
const bool peak =
(i == 0 || probs[i] >= probs[i-1]) &&
(i == n - 1 || probs[i] >= probs[i+1]);
if (peak) {
boundaries_.push_back({times_[i], probs[i]});
fresh.push_back(times_[i]);
}
}
/// TRACES: AR-010 | SR-002
// Publish the tail too. Without this the final frames — everything after
// the last full window — reach the join with no verdict and are treated
// as boundary-free without evidence, which is precisely the ambiguity
// the watermark exists to prevent.
if (shared_ && n > 0) shared_->publish(fresh, times_[n - 1]);
}
void write_output() {
if (written_) return;
written_ = true;
// Merge boundaries closer than one frame apart (dedup across window seams).
std::sort(boundaries_.begin(), boundaries_.end(),
[](const Boundary& a, const Boundary& b) {
return a.t < b.t;
});
nlohmann::json root;
root["schema_version"] = 1;
root["movie"] = movie_path_;
root["model"] = "transnetv2";
root["threshold"] = threshold_;
nlohmann::json cuts = nlohmann::json::array();
double last_t = -1e9;
for (const auto& b : boundaries_) {
if (b.t - last_t < 0.04) continue; // ~1 frame @25fps dedup
cuts.push_back({{"t", b.t}, {"probability", b.prob}});
last_t = b.t;
}
root["cuts"] = std::move(cuts);
std::ofstream f(output_path_);
if (!f.is_open()) {
std::cerr << "\n[scene_detector] ERROR: cannot write "
<< output_path_ << "\n";
return;
}
f << root.dump(2) << "\n";
std::cerr << "\n[scene_detector] wrote " << root["cuts"].size()
<< " boundaries → " << output_path_ << "\n";
}
static int kLast_() { return ISceneDetector::kWindow - 1; }
// annotations.json → annotations.scenes.json (or scenes.json for bare names)
static std::string scenes_path(const std::string& out) {
auto dot = out.find_last_of('.');
if (dot == std::string::npos) return out + ".scenes.json";
return out.substr(0, dot) + ".scenes.json";
}
struct Boundary { double t; float prob; };
std::unique_ptr<ISceneDetector> detector_;
float threshold_;
int stride_;
int guard_{0};
std::string output_path_;
std::string movie_path_;
std::atomic<bool>& done_;
std::deque<cv::Mat> images_;
std::deque<double> times_;
int64_t window_base_{0}; // frame index of images_.front()
std::vector<Boundary> boundaries_;
bool written_{false};
std::shared_ptr<SceneBoundaries> shared_; ///< AR-010 join point
};