Closes both violations SPEC.md named under "Every model gets the input it was trained for". They are one bug, not two. The dense stream defaulted to 12 fps, so a 100-frame TransNetV2 window spanned ~8.3 s against the ~4 s it was trained on: half-speed motion over twice its temporal context. Boundary timestamps stayed correct throughout, which is exactly why the degradation was invisible and why the compressed separation it produced (~0.50 baseline against ~0.7+ peaks) was read as a property of the ONNX export rather than of the input. Dedup then merged boundaries closer than a literal 0.04 s — one frame at 25 fps, and wider than a frame at 30, so two cuts on consecutive frames became one. Nothing in scenes.json showed it; the file simply had fewer boundaries. Native rate is where that constant did the most damage, which is why fixing the decode rate without fixing the dedup would have made things worse. dedup_window_sec() now takes the median interval the detector was actually fed and halves it. Half a frame rather than a whole one: the only thing being merged is one frame scored by two overlapping windows, and two distinct frames are a full interval apart. Cost is real — dense decode is the pipeline's cost driver. It is accepted; dense_scale and scene_stride remain the reductions that do not run the model off-distribution. scene_threshold 0.60 was fitted against the 12 fps input and is now stale, so VR-006 goes from Low to Medium: it is no longer a refinement, it is a constant that no longer describes the input. AR-002 rides along because it was already implemented, just untagged and unverified — the register said Planned while the code was correct. The size filter becomes FaceDetectorFunc::drop_undersized(), tested at the threshold and at dense_scale 0.5, and checked end to end against the superhero dump, whose smallest face is exactly its recorded 32 px minimum, so the fixture check cannot pass vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-002, AR-011 | SR-002 | UT-002, UT-003, IT-001
79 lines
3.2 KiB
C++
79 lines
3.2 KiB
C++
#pragma once
|
|
/// TRACES: AR-001 | SR-002
|
|
#include "config.hpp"
|
|
#include "inference/face_detector.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <memory>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
// ── FaceDetectorFunc ──────────────────────────────────────────────────────────
|
|
// KPN node: runs SCRFD-500MF to detect ALL faces in a frame.
|
|
//
|
|
// The inference backend (ONNX Runtime or raw TensorRT) is selected at compile
|
|
// time; this node talks only to IFaceDetector via make_face_detector(cfg).
|
|
|
|
struct FaceDetectorFunc {
|
|
static constexpr std::string_view label() { return "face_detector"; }
|
|
|
|
explicit FaceDetectorFunc(const Config& cfg)
|
|
: detector_(make_face_detector(cfg))
|
|
, max_faces_(cfg.max_faces)
|
|
, min_face_px_(cfg.min_face_px)
|
|
{}
|
|
|
|
/// TRACES: AR-002 | SR-002
|
|
// Drop faces below the minimum size — too small for reliable ArcFace
|
|
// alignment, and below the resolution where identification still holds
|
|
// (VR-013 measured the knee end to end).
|
|
//
|
|
// The minimum is expressed in ORIGINAL video resolution, which is what makes
|
|
// it a property of the footage rather than of a throughput knob. When
|
|
// dense_scale downscaled the frame the detector's boxes are in downscaled
|
|
// space, and `bbox_upscale` is what maps them back; dividing the threshold by
|
|
// it rather than multiplying every box keeps the comparison on the detector's
|
|
// own numbers and the physical cutoff constant across scales.
|
|
//
|
|
// Strictly less-than: a face exactly at the minimum is admissible, which is
|
|
// what a "minimum of 40x40" means.
|
|
static void drop_undersized(std::vector<DetectedFace>& faces,
|
|
float min_face_px,
|
|
float bbox_upscale) {
|
|
const float min_px = (bbox_upscale > 0.f) ? min_face_px / bbox_upscale
|
|
: min_face_px;
|
|
faces.erase(
|
|
std::remove_if(faces.begin(), faces.end(), [&](const DetectedFace& d) {
|
|
return d.bbox.width < min_px || d.bbox.height < min_px;
|
|
}),
|
|
faces.end());
|
|
}
|
|
|
|
SceneFrame operator()(Frame f) {
|
|
if (f.eof) return {std::move(f), {}};
|
|
|
|
auto faces = detector_->detect(f.image);
|
|
|
|
drop_undersized(faces, min_face_px_, f.bbox_upscale);
|
|
|
|
// Sort largest-first so max_faces_ keeps the most informative detections
|
|
std::sort(faces.begin(), faces.end(),
|
|
[](const DetectedFace& a, const DetectedFace& b) {
|
|
return a.bbox.area() > b.bbox.area();
|
|
});
|
|
// TRACES: AR-003 | SR-002
|
|
// Largest-first ordering is kept regardless: it is load-bearing for
|
|
// deterministic association, since the Hungarian solver tie-breaks on
|
|
// index order (see the replay determinism test).
|
|
if (max_faces_ > 0 && static_cast<int>(faces.size()) > max_faces_)
|
|
faces.resize(max_faces_);
|
|
|
|
return {std::move(f), std::move(faces)};
|
|
}
|
|
|
|
private:
|
|
std::unique_ptr<IFaceDetector> detector_;
|
|
int max_faces_{10};
|
|
float min_face_px_{40.f};
|
|
};
|