Files
scene-actor-extraction/tests/test_embedding_dump.cpp
dtourolle 777c98cb33 feat(quality): score every face on sharpness and alignment before it is evidence
Every embedding now carries the quality of the input it came from. Both
axes fall out of the AR-005 warp for free: crop_sharpness() is the
normalised Laplacian variance over the aligned 112x112, so contrast and
size cannot leak into it, and the alignment residual is the part of the
landmark deformation a similarity transform cannot explain, so in-plane
roll reads as zero and foreshortening does not.

Carried, not consumed. Nothing discounts or thresholds on either number
yet -- that is AR-030 and VR-012, and the knee has to be located against
recorded data before a gate is chosen. What this change buys is that the
data exists to locate it with.

No face is admitted unscored: the -1 sentinel is preserved rather than
clamped, and a degenerate landmark fit is counted rather than silently
dropped.

Takes the VR-001 dump to schema_version 2. The bump is not for readers,
which check for the datasets by name and replay a v1 dump unchanged; it
is so a consumer can tell "never scored" from "scored zero", which is
not recoverable from the arrays afterwards.

TRACES: AR-028, AR-029, AR-030 | VR-001 | SR-002
2026-08-05 14:37:30 +02:00

170 lines
5.7 KiB
C++

// TRACES: AR-028 | VR-001 | UT-139, UT-140, UT-141 | SR-002
//
// The other half of AR-028: the quality vector has to *survive into the dump*.
// Measuring it at inference and then leaving it in a struct that dies at the
// EmbeddedSceneFrame channel would satisfy the letter of "assessed" and none of
// the point — VR-012 sets its knees from recorded data, and what the dump does
// not carry cannot be re-litigated without re-running video on a GPU.
//
// Tier T2, but cheap: EmbeddingDumpFunc is a sink, so it can be driven directly
// with hand-built frames. No model, no video, no gallery — the embedder stamp
// tolerates an unset model path (GR-004 records it as unverifiable).
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "config.hpp"
#include "nodes/embedding_dump_node.hpp"
#include "types.hpp"
#include <H5Cpp.h>
#include <atomic>
#include <cstdio>
#include <filesystem>
#include <string>
#include <vector>
using Catch::Matchers::WithinAbs;
namespace {
namespace fs = std::filesystem;
// Removes the file on scope exit so a failing assertion cannot leave the next
// run reading a stale dump.
struct TempDump {
fs::path path;
explicit TempDump(const char* stem)
: path(fs::temp_directory_path() / (std::string("sae_") + stem + ".h5")) {
std::remove(path.c_str());
}
~TempDump() { std::error_code ec; fs::remove(path, ec); }
};
EmbeddedSceneFrame frame_with(double ts, const std::vector<std::pair<float, float>>& quality) {
EmbeddedSceneFrame ef;
ef.source.timestamp_sec = ts;
ef.source.frame_idx = static_cast<int64_t>(ts * 5.0);
for (const auto& [sharpness, residual] : quality) {
DetectedFace f;
f.bbox = cv::Rect2f(10.f, 20.f, 60.f, 60.f);
f.confidence = 0.8f;
f.sharpness = sharpness;
f.alignment_residual = residual;
ef.faces.push_back(f);
Embedding e{};
e[0] = 1.f;
ef.embeddings.push_back(e);
}
return ef;
}
EmbeddedSceneFrame eof_frame() {
EmbeddedSceneFrame ef;
ef.source.eof = true;
return ef;
}
std::vector<float> read_face_col(const H5::H5File& f, const char* name) {
H5::DataSet ds = f.openDataSet(std::string("faces/") + name);
hsize_t n = 0;
ds.getSpace().getSimpleExtentDims(&n, nullptr);
std::vector<float> out(n);
if (n) ds.read(out.data(), H5::PredType::NATIVE_FLOAT);
return out;
}
int read_schema_version(const H5::H5File& f) {
int v = 0;
f.openAttribute("schema_version").read(H5::PredType::NATIVE_INT, &v);
return v;
}
} // namespace
TEST_CASE("the quality vector survives into the dump", "[dump][AR-028][UT-139]") {
TempDump tmp("quality_roundtrip");
Config cfg;
cfg.dump_embeddings_path = tmp.path.string();
cfg.movie_path = "synthetic";
cfg.sample_fps = 5.f;
std::atomic<bool> done{false};
{
EmbeddingDumpFunc dump(cfg, done);
dump(frame_with(0.0, {{3.25f, 0.75f}, {0.5f, 4.5f}}));
dump(frame_with(0.2, {})); // a frame with no faces
dump(frame_with(0.4, {{12.0f, 0.0f}}));
dump(eof_frame());
}
REQUIRE(done.load());
REQUIRE(fs::exists(tmp.path));
H5::H5File f(tmp.path.string(), H5F_ACC_RDONLY);
const std::vector<float> sharp = read_face_col(f, "sharpness");
const std::vector<float> resid = read_face_col(f, "alignment_residual");
const std::vector<float> conf = read_face_col(f, "confidence");
// Parallel to every other per-face array, so a consumer can index the
// quality of face i with the same slice it uses for the embedding.
REQUIRE(sharp.size() == conf.size());
REQUIRE(resid.size() == conf.size());
REQUIRE(sharp.size() == 3);
CHECK_THAT(sharp[0], WithinAbs(3.25f, 1e-6f));
CHECK_THAT(sharp[1], WithinAbs(0.50f, 1e-6f));
CHECK_THAT(sharp[2], WithinAbs(12.0f, 1e-6f));
CHECK_THAT(resid[0], WithinAbs(0.75f, 1e-6f));
CHECK_THAT(resid[1], WithinAbs(4.50f, 1e-6f));
CHECK_THAT(resid[2], WithinAbs(0.00f, 1e-6f));
}
TEST_CASE("a dump carrying the quality vector announces itself as v2", "[dump][AR-028][UT-140]") {
// The bump is not for readers — they check for the datasets by name, and a
// v1 dump still replays. It is so a consumer of the vector can tell "these
// faces were never scored" from "these faces scored zero", which is not
// recoverable from the arrays. Same reason scene_detect is an attribute.
TempDump tmp("quality_version");
Config cfg;
cfg.dump_embeddings_path = tmp.path.string();
cfg.movie_path = "synthetic";
std::atomic<bool> done{false};
{
EmbeddingDumpFunc dump(cfg, done);
dump(frame_with(0.0, {{1.f, 1.f}}));
dump(eof_frame());
}
H5::H5File f(tmp.path.string(), H5F_ACC_RDONLY);
CHECK(read_schema_version(f) == 2);
}
TEST_CASE("an unscored face keeps its sentinel through the dump", "[dump][AR-028][UT-141]") {
// The aligner admits no unscored face, so this state should be unreachable.
// The dump still must not clamp it: -1 is how a future path that skipped
// scoring would be caught, and rewriting it to 0 would hide that path behind
// a legitimate-looking "featureless crop" reading.
TempDump tmp("quality_sentinel");
Config cfg;
cfg.dump_embeddings_path = tmp.path.string();
cfg.movie_path = "synthetic";
std::atomic<bool> done{false};
{
EmbeddingDumpFunc dump(cfg, done);
dump(frame_with(0.0, {{-1.f, -1.f}}));
dump(eof_frame());
}
H5::H5File f(tmp.path.string(), H5F_ACC_RDONLY);
CHECK(read_face_col(f, "sharpness")[0] < 0.f);
CHECK(read_face_col(f, "alignment_residual")[0] < 0.f);
}