Each of these was recorded as done and was done in one place out of two. AR-011 -- the TransNetV2 dedup window. The derived window (dedup_window_sec, median observed interval halved) reached scenes.json and nothing else. SceneBoundaries, the path that actually feeds is_scene_boundary to the tracker, kept the literal 0.04 s under a comment claiming it "matches the dedup scenes.json applies, so the two views agree". They did not agree. 0.04 is one frame at 25 fps and wider than a frame at 30, so two cuts on consecutive frames merged into one and the loss was invisible: the pipeline simply saw fewer boundaries. The detector now supplies the window it derived. AR-019 -- ownership. The register says ownership "comes from the registry, not a second local tally". Both existed: promotion fired on a local accepted-frame count and fell back to a local per-actor plurality when the registry had not yet claimed the track. That fallback was reachable in the live pipeline, not just in tests -- three accepted frames arrive well before a posterior crosses the ownership threshold -- so in practice the plurality usually decided, and it could not see the AR-025 correlation discounting it was meant to defer to. The tally is gone; promotion now requires the registry's verdict, with the accepted -frame count demoted to an explicit evidence floor. AR-017 -- the route. DeadTrack carried belief but no route, and the sink wrote the literal string "live", so a field the schema publishes could not distinguish anything. AR-017's own verification asks for "deferred and pooled routes distinguishable". Route is now an enum on the claim. Only `live` occurs today; `deferred` exists so AR-020's pass has somewhere to write instead of a serialisation change to make. Also: TrackGallery::forget had no callers, under a comment asserting the matcher called it "on a cut or track disappearance". The cut half was true by another route; the disappearance half was not, so a track that died quietly kept its diversity buffer until the next cut cleared everything. Replaced with prune_dead against the registry's own liveness, the same shape as the tracker's prune_boxes -- a second opinion about which tracks exist is a second thing that can be wrong. Removes dead logistic/logit helpers and fixes five TRACES tags that used a comma where a pipe separates requirement types, which the gate had been reporting as diagnostics. TRACES: AR-011, AR-017, AR-019 | IR-002 | SR-002, SR-005
148 lines
6.5 KiB
C++
148 lines
6.5 KiB
C++
#pragma once
|
|
/// TRACES: AR-010 | SR-002
|
|
///
|
|
/// SceneBoundaries — the join point of the decode butterfly.
|
|
///
|
|
/// The topology forks after decode: one branch runs TransNetV2 over dense
|
|
/// frames, the other runs face detection over the sampled cadence. Boundaries
|
|
/// found on the first branch have to reach the second, and they cannot be
|
|
/// carried in the frames themselves because the branches are parallel.
|
|
///
|
|
/// **Why this needs a watermark.** TransNetV2 buffers `kWindow` frames before it
|
|
/// can score any of them, so at any instant the detector has an opinion about
|
|
/// everything up to some time T and nothing after it. Without recording T, a
|
|
/// consumer asking "is there a boundary at t?" cannot distinguish *no* from
|
|
/// *not yet* — and those demand opposite behaviour. Silently treating unscored
|
|
/// frames as boundary-free is exactly the class of failure that makes a
|
|
/// verification pass vacuously.
|
|
///
|
|
/// The consumer is held back by channel depth (see main.cpp) so that by the time
|
|
/// it pulls a frame, the detector has already scored past it. `scored_through()`
|
|
/// is what lets that assumption be *checked* rather than assumed.
|
|
|
|
#include <algorithm>
|
|
#include <condition_variable>
|
|
#include <mutex>
|
|
#include <vector>
|
|
|
|
class SceneBoundaries {
|
|
public:
|
|
/// TRACES: AR-011 | SR-002
|
|
/// Peaks closer than this are one boundary.
|
|
///
|
|
/// Supplied by the detector, derived from the cadence it was actually fed
|
|
/// (SceneDetectorFunc::dedup_window_sec), NOT assumed. It used to be a hard
|
|
/// 0.04 here, and AR-011 is recorded as having replaced that literal --
|
|
/// which it did, but only for scenes.json. This path, the one that feeds
|
|
/// is_scene_boundary into the tracker, kept the constant while the comment
|
|
/// above it claimed "matches the dedup scenes.json applies, so the two
|
|
/// views agree". They did not agree. 0.04 s is one frame at 25 fps and
|
|
/// wider than a frame at 30, so two cuts on consecutive frames merged into
|
|
/// one and the loss was invisible: the pipeline simply saw fewer
|
|
/// boundaries.
|
|
///
|
|
/// Zero until the detector sets it, which makes the pre-cadence state a
|
|
/// no-op dedup rather than a wrong one -- adjacent peaks stay separate
|
|
/// until there is evidence about how far apart frames are, and is_boundary
|
|
/// absorbs duplicates in its tolerance anyway.
|
|
void set_merge_window(double sec) {
|
|
std::lock_guard<std::mutex> g(mu_);
|
|
merge_sec_ = sec;
|
|
}
|
|
|
|
/// Called by the scene detector as each window is scored. `through` is the
|
|
/// timestamp up to which its verdict is now final.
|
|
void publish(const std::vector<double>& ts, double through) {
|
|
{
|
|
std::lock_guard<std::mutex> g(mu_);
|
|
const double merge = merge_sec_;
|
|
// Dedup on insert, matching what scenes.json does at write time. A run
|
|
// of adjacent high-scoring frames is one boundary, not several, and
|
|
// leaving them raw made this view report 357 where the file said 13 —
|
|
// the same event counted many times. Harmless for is_boundary(), which
|
|
// absorbs them in its tolerance, but a count nobody can reconcile with
|
|
// the output file is a bad diagnostic.
|
|
bounds_.insert(bounds_.end(), ts.begin(), ts.end());
|
|
std::sort(bounds_.begin(), bounds_.end());
|
|
bounds_.erase(std::unique(bounds_.begin(), bounds_.end(),
|
|
[merge](double a, double b) { return b - a < merge; }),
|
|
bounds_.end());
|
|
scored_through_ = std::max(scored_through_, through);
|
|
}
|
|
cv_.notify_all();
|
|
}
|
|
|
|
/// True if a boundary falls within `tol` of `t`.
|
|
///
|
|
/// `tol` exists because the two branches sample at different rates: a
|
|
/// boundary found on a dense frame rarely lands exactly on a sampled one.
|
|
/// Half a sample interval is the natural width — it attributes the boundary
|
|
/// to the nearest sampled frame and no further.
|
|
bool is_boundary(double t, double tol) const {
|
|
std::lock_guard<std::mutex> g(mu_);
|
|
auto it = std::lower_bound(bounds_.begin(), bounds_.end(), t - tol);
|
|
return it != bounds_.end() && *it <= t + tol;
|
|
}
|
|
|
|
/// The timestamp through which the detector's verdict is final. A consumer
|
|
/// past this point is asking about frames nobody has looked at yet.
|
|
double scored_through() const {
|
|
std::lock_guard<std::mutex> g(mu_);
|
|
return scored_through_;
|
|
}
|
|
|
|
/// Block until the detector's verdict covers `t`, or it finishes.
|
|
///
|
|
/// Channel depth alone does NOT create the required lag: it only holds
|
|
/// frames back when the consumer is slower, and the face branch is roughly
|
|
/// four orders of magnitude faster per frame than TransNetV2. So the join
|
|
/// has to wait explicitly.
|
|
///
|
|
/// Returns false if the detector finished without ever covering `t`, which
|
|
/// happens for the tail frames after its last full window. The caller must
|
|
/// distinguish that from a genuine "no boundary" rather than assuming.
|
|
bool wait_until_scored(double t) const {
|
|
std::unique_lock<std::mutex> lk(mu_);
|
|
cv_.wait(lk, [&] { return finished_ || scored_through_ >= t; });
|
|
return scored_through_ >= t;
|
|
}
|
|
|
|
/// Called when the detector will publish nothing further. Without this the
|
|
/// join would deadlock on the tail: those frames are never covered by a full
|
|
/// window, so waiting for them would wait forever.
|
|
void finish() {
|
|
{
|
|
std::lock_guard<std::mutex> g(mu_);
|
|
finished_ = true;
|
|
}
|
|
cv_.notify_all();
|
|
}
|
|
|
|
std::size_t count() const {
|
|
std::lock_guard<std::mutex> g(mu_);
|
|
return bounds_.size();
|
|
}
|
|
|
|
/// Consumers that outran the detector. Nonzero means the face branch is not
|
|
/// buffered deeply enough for the detector's window, so some frames were
|
|
/// annotated from an incomplete verdict — a real misconfiguration, and one
|
|
/// that would otherwise be invisible.
|
|
void note_outran() const {
|
|
std::lock_guard<std::mutex> g(mu_);
|
|
++outran_;
|
|
}
|
|
std::size_t outran() const {
|
|
std::lock_guard<std::mutex> g(mu_);
|
|
return outran_;
|
|
}
|
|
|
|
private:
|
|
mutable std::mutex mu_;
|
|
mutable std::condition_variable cv_;
|
|
bool finished_{false};
|
|
double merge_sec_{0.0}; ///< set by the detector; see set_merge_window
|
|
std::vector<double> bounds_;
|
|
double scored_through_{-1.0};
|
|
mutable std::size_t outran_{0};
|
|
};
|