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
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user