Files
scene-actor-extraction/tests/test_calibration.cpp
T
dtourolle 76df2f66aa test: add Catch2 unit test suite (gallery, calibration, tracking, similarity)
GPU-free, model-free tests for the pure logic: gallery HDF5 save/load
round-trips (actors, embeddings, embedded calibration) and legacy JSON
read back-compat; the calibration sigmoid fit, boundary inversion, and the
in-memory hash-keyed cache reuse/staleness; TrackGallery's diversity-buffer
eviction, novelty/spread safety gates, and promotion; FaceTracker's IoU/
embedding association and cross-cut track revival; and the GEMM similarity
backend (forced to CPU so the suite runs without a GPU).

Verified: all 39 test cases / 1640 assertions pass (cmake -DSAE_BUILD_TESTS=ON).
2026-07-19 19:10:57 +02:00

171 lines
6.3 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Unit tests for gallery calibration: the sigmoid math, the pairwise fit on
// separable data, and the in-memory hash-keyed cache (hit / stale / cold).
// All pure, GPU-free, model-free.
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "gallery/gallery_calibration.hpp"
#include "face_utils.hpp" // l2_normalise
#include "types.hpp"
#include <array>
#include <random>
#include <string>
#include <vector>
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
namespace {
// n embeddings for `n_actors` actors, each a tight cluster around a random
// per-actor centre — cleanly separable, so calibration should converge.
void make_separable_gallery(int n_actors, int per_actor,
std::vector<Embedding>& emb,
std::vector<int>& actor) {
std::mt19937 rng(1234);
std::normal_distribution<float> centre(0.f, 1.f);
std::normal_distribution<float> jitter(0.f, 0.01f);
for (int a = 0; a < n_actors; ++a) {
std::array<float, 512> c{};
for (float& v : c) v = centre(rng);
for (int k = 0; k < per_actor; ++k) {
std::array<float, 512> raw{};
for (int d = 0; d < 512; ++d) raw[d] = c[d] + jitter(rng);
emb.push_back(l2_normalise(raw.data()));
actor.push_back(a);
}
}
}
} // namespace
TEST_CASE("GalleryCalibration probability is monotonic and bounded", "[calibration]") {
GalleryCalibration cal{10.f, -5.f, true};
float lo = cal.probability(-1.f);
float mid = cal.probability(0.5f); // boundary is at sim = -b/a = 0.5
float hi = cal.probability(1.f);
CHECK(lo >= 0.f);
CHECK(hi <= 1.f);
CHECK(lo < mid);
CHECK(mid < hi);
CHECK_THAT(mid, WithinAbs(0.5f, 1e-5f)); // σ(0) = 0.5 at the boundary
}
TEST_CASE("boundary_at inverts probability", "[calibration]") {
GalleryCalibration cal{8.f, -3.f, true};
for (float p : {0.1f, 0.5f, 0.9f}) {
float sim = cal.boundary_at(p);
CHECK_THAT(cal.probability(sim), WithinAbs(p, 1e-5f));
}
}
TEST_CASE("prior odds shift the decision boundary", "[calibration]") {
GalleryCalibration cal{10.f, -5.f, true};
// A positive prior (match more likely) should raise P at a fixed similarity.
float base = cal.probability(0.5f);
float raised = cal.probability(0.5f, /*log_prior_odds=*/2.f);
CHECK(raised > base);
// and correspondingly lower the similarity needed to reach P=0.5.
CHECK(cal.boundary_at(0.5f, 2.f) < cal.boundary_at(0.5f));
}
TEST_CASE("calibrate_gallery fits separable data", "[calibration]") {
std::vector<Embedding> emb;
std::vector<int> actor;
make_separable_gallery(/*n_actors=*/6, /*per_actor=*/8, emb, actor);
GalleryCalibration cal = calibrate_gallery(emb, actor);
REQUIRE(cal.valid);
CHECK(cal.a > 0.f); // higher sim → higher P
// Same-actor pairs sit near sim≈1, cross-actor near 0 → boundary between.
float boundary = cal.boundary_at(0.5f);
CHECK(boundary > 0.f);
CHECK(boundary < 1.f);
}
TEST_CASE("calibrate_gallery returns invalid on too few pairs", "[calibration]") {
// One actor, one embedding: no negative pairs, no positive pairs.
std::vector<Embedding> emb(1);
emb[0] = l2_normalise(std::array<float, 512>{1.f}.data());
std::vector<int> actor{0};
GalleryCalibration cal = calibrate_gallery(emb, actor);
CHECK_FALSE(cal.valid);
}
TEST_CASE("hash_gallery_embeddings is sensitive to changes", "[calibration]") {
std::vector<Embedding> emb;
std::vector<int> actor;
make_separable_gallery(3, 4, emb, actor);
uint64_t h0 = hash_gallery_embeddings(emb, actor);
CHECK(hash_gallery_embeddings(emb, actor) == h0); // stable
auto emb2 = emb;
emb2[0][0] += 1e-3f;
CHECK(hash_gallery_embeddings(emb2, actor) != h0); // embedding change
auto actor2 = actor;
actor2.back() = 99;
CHECK(hash_gallery_embeddings(emb, actor2) != h0); // assignment change
}
TEST_CASE("calibrate_gallery_cached reuses matching in-memory calibration", "[calibration]") {
std::vector<Embedding> emb;
std::vector<int> actor;
make_separable_gallery(5, 6, emb, actor);
bool recomputed = false;
GalleryCalibration first = calibrate_gallery_cached(
emb, actor, /*cached_a=*/0.f, /*cached_b=*/0.f, /*cached_valid=*/false,
/*cached_hash=*/0, /*curve_base_path=*/"", recomputed);
REQUIRE(first.valid);
CHECK(recomputed); // no prior cache (hash=0) → always recomputes
uint64_t hash = hash_gallery_embeddings(emb, actor);
// Second call, passing back the just-fitted params + matching hash, must
// NOT recompute and must return the identical fitted params.
recomputed = false;
GalleryCalibration second = calibrate_gallery_cached(
emb, actor, first.a, first.b, first.valid, hash, "", recomputed);
CHECK_FALSE(recomputed);
CHECK_THAT(second.a, WithinRel(first.a, 1e-6f));
CHECK_THAT(second.b, WithinRel(first.b, 1e-6f));
CHECK(second.valid == first.valid);
}
TEST_CASE("calibrate_gallery_cached recomputes when the gallery changes", "[calibration]") {
std::vector<Embedding> emb;
std::vector<int> actor;
make_separable_gallery(5, 6, emb, actor);
bool recomputed = false;
GalleryCalibration first = calibrate_gallery_cached(
emb, actor, 0.f, 0.f, false, 0, "", recomputed);
uint64_t stale_hash = hash_gallery_embeddings(emb, actor);
// Mutate the gallery → hash no longer matches → must recompute.
emb.push_back(emb.front());
actor.push_back(actor.front());
recomputed = false;
GalleryCalibration second = calibrate_gallery_cached(
emb, actor, first.a, first.b, first.valid, stale_hash, "", recomputed);
CHECK(recomputed);
CHECK(hash_gallery_embeddings(emb, actor) != stale_hash);
}
TEST_CASE("calibrate_gallery_cached treats hash=0 as always-recompute", "[calibration]") {
std::vector<Embedding> emb;
std::vector<int> actor;
make_separable_gallery(4, 5, emb, actor);
// hash=0 is the "no cached calibration" sentinel (ActorGallery::calib_hash
// default) — must fit fresh rather than treat 0 as a real cached hash.
bool recomputed = false;
GalleryCalibration cal = calibrate_gallery_cached(
emb, actor, 0.f, 0.f, false, /*cached_hash=*/0, "", recomputed);
CHECK(recomputed);
CHECK(cal.valid);
}