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).
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# ── Unit tests ────────────────────────────────────────────────────────────────
|
||||
# Pure, GPU-free, model-free tests. The similarity tests compile the GEMM backend
|
||||
# directly with SAE_GEMM_CPU so the suite builds and runs on a machine without a
|
||||
# GPU regardless of the main build's SAE_GEMM_BACKEND selection.
|
||||
|
||||
find_package(Catch2 3 QUIET)
|
||||
if(NOT Catch2_FOUND)
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
Catch2
|
||||
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
|
||||
GIT_TAG v3.5.3
|
||||
)
|
||||
FetchContent_MakeAvailable(Catch2)
|
||||
endif()
|
||||
|
||||
add_executable(sae_tests
|
||||
test_similarity.cpp
|
||||
test_calibration.cpp
|
||||
test_gallery_store.cpp
|
||||
test_face_utils.cpp
|
||||
test_track_gallery.cpp
|
||||
test_face_tracker.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/backends/gemm_backend.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/gallery/gallery_store.cpp
|
||||
)
|
||||
target_include_directories(sae_tests PRIVATE ${CMAKE_SOURCE_DIR}/src)
|
||||
# SAE_GEMM_CPU: build the CPU reference GEMM regardless of the main backend.
|
||||
# SAE_MODELS_DIR: config.hpp (pulled in by track_gallery.hpp) bakes model paths.
|
||||
target_compile_definitions(sae_tests PRIVATE
|
||||
SAE_GEMM_CPU
|
||||
SAE_MODELS_DIR="${SAE_MODELS_DIR}")
|
||||
# gallery_store.cpp + gallery_calibration.hpp use nlohmann/json and HDF5
|
||||
# (galleries are HDF5-native, see src/gallery/gallery_store.cpp); face_utils.hpp
|
||||
# and the calibration GEMM pull in OpenCV (calib3d/imgproc/core) via types.hpp.
|
||||
target_link_libraries(sae_tests PRIVATE
|
||||
Catch2::Catch2WithMain
|
||||
nlohmann_json::nlohmann_json
|
||||
${OpenCV_LIBS}
|
||||
${HDF5_CXX_LIBRARIES})
|
||||
target_include_directories(sae_tests PRIVATE ${HDF5_INCLUDE_DIRS})
|
||||
|
||||
include(CTest)
|
||||
include(Catch)
|
||||
catch_discover_tests(sae_tests)
|
||||
@@ -0,0 +1,170 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Unit tests for FaceTrackerFunc (nodes/face_tracker_node.hpp): frame-to-frame
|
||||
// track linking and, crucially, cross-cut re-association. Pure, GPU-free,
|
||||
// model-free — drives the node's operator() with hand-built EmbeddedSceneFrames
|
||||
// and inspects the emitted track_ids.
|
||||
//
|
||||
// The behaviour under test: on a camera-angle change (Frame::is_cut) the tracker
|
||||
// parks its tracks instead of destroying them, and revives a parked track_id
|
||||
// when a post-cut detection's raw last-frame-embedding cosine similarity clears
|
||||
// cut_revive_sim. IoU is deliberately driven to 0 across the cut (boxes moved) so
|
||||
// only the embedding path can re-link — exactly the scenario a cut creates.
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "config.hpp"
|
||||
#include "nodes/face_tracker_node.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
|
||||
// Unit-norm embedding in the plane of axes i,j at angle whose cosine to
|
||||
// one_hot(i) is cos_t. cosine_similarity(at_sim(i,j,a), at_sim(i,j,b)) works out
|
||||
// to cos(angle diff), letting a test dial the cross-cut similarity precisely.
|
||||
Embedding at_sim(int i, int j, float cos_t) {
|
||||
Embedding e{};
|
||||
float s = std::sqrt(std::max(0.f, 1.f - cos_t * cos_t));
|
||||
e[i] = cos_t;
|
||||
e[j] = s;
|
||||
return e;
|
||||
}
|
||||
|
||||
Embedding axis(int slot) {
|
||||
Embedding e{};
|
||||
e[slot] = 1.0f;
|
||||
return e;
|
||||
}
|
||||
|
||||
DetectedFace face_at(float x, float y) {
|
||||
DetectedFace f;
|
||||
f.bbox = cv::Rect2f(x, y, 40.f, 40.f);
|
||||
f.confidence = 0.99f;
|
||||
return f;
|
||||
}
|
||||
|
||||
// Build a single-face frame at position (x,y) with embedding emb. is_cut marks a
|
||||
// camera-angle change on this frame.
|
||||
EmbeddedSceneFrame frame(double t, float x, float y, const Embedding& emb,
|
||||
bool is_cut = false) {
|
||||
EmbeddedSceneFrame ef;
|
||||
ef.source.timestamp_sec = t;
|
||||
ef.source.is_cut = is_cut;
|
||||
ef.faces = {face_at(x, y)};
|
||||
ef.crops = {cv::Mat()};
|
||||
ef.embeddings = {emb};
|
||||
return ef;
|
||||
}
|
||||
|
||||
Config tracker_cfg() {
|
||||
Config cfg;
|
||||
cfg.cut_revive_sim = 0.50f;
|
||||
cfg.cut_inactive_max_frames = 5;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("track id is stable across ordinary frames", "[face_tracker]") {
|
||||
FaceTrackerFunc ft(tracker_cfg());
|
||||
Embedding e = axis(0);
|
||||
int id0 = ft(frame(0.0, 10, 10, e)).track_ids[0];
|
||||
int id1 = ft(frame(1.0, 11, 10, e)).track_ids[0]; // overlaps → same track
|
||||
CHECK(id0 >= 0);
|
||||
CHECK(id1 == id0);
|
||||
}
|
||||
|
||||
TEST_CASE("cut revives the same track id for a matching identity", "[face_tracker]") {
|
||||
FaceTrackerFunc ft(tracker_cfg());
|
||||
|
||||
// Pre-cut: establish a track for a person whose embedding is near-identical
|
||||
// across the cut (sim well above cut_revive_sim), but whose box jumps so IoU
|
||||
// is 0 — the ordinary spatial path cannot re-link it.
|
||||
Embedding pre = at_sim(0, 1, 0.99f);
|
||||
int id_pre = ft(frame(0.0, 10, 10, pre)).track_ids[0];
|
||||
REQUIRE(id_pre >= 0);
|
||||
|
||||
Embedding post = at_sim(0, 1, 0.98f); // cos(diff) ≈ 0.9997 > 0.50
|
||||
auto out = ft(frame(1.0, 300, 300, post, /*is_cut=*/true));
|
||||
CHECK(out.track_ids[0] == id_pre); // revived, not a fresh id
|
||||
}
|
||||
|
||||
TEST_CASE("cut starts a fresh track when identity does not match", "[face_tracker]") {
|
||||
FaceTrackerFunc ft(tracker_cfg());
|
||||
|
||||
int id_pre = ft(frame(0.0, 10, 10, axis(0))).track_ids[0];
|
||||
REQUIRE(id_pre >= 0);
|
||||
|
||||
// Post-cut face is orthogonal (sim 0 < cut_revive_sim) and spatially disjoint
|
||||
// → no revival, brand-new id.
|
||||
auto out = ft(frame(1.0, 300, 300, axis(5), /*is_cut=*/true));
|
||||
CHECK(out.track_ids[0] != id_pre);
|
||||
CHECK(out.track_ids[0] >= 0);
|
||||
}
|
||||
|
||||
TEST_CASE("parked track expires after cut_inactive_max_frames", "[face_tracker]") {
|
||||
Config cfg = tracker_cfg();
|
||||
cfg.cut_inactive_max_frames = 2;
|
||||
FaceTrackerFunc ft(cfg);
|
||||
|
||||
Embedding person = at_sim(0, 1, 0.99f);
|
||||
int id_pre = ft(frame(0.0, 10, 10, person)).track_ids[0];
|
||||
REQUIRE(id_pre >= 0);
|
||||
|
||||
// Cut with an unrelated face parks id_pre; then let the pool age past its
|
||||
// limit with more unrelated, spatially-disjoint faces (each ages the pool by
|
||||
// one). By the time the person returns, id_pre must be gone.
|
||||
ft(frame(1.0, 300, 300, axis(7), /*is_cut=*/true)); // park (age 1)
|
||||
ft(frame(2.0, 300, 300, axis(7))); // age 2
|
||||
ft(frame(3.0, 300, 300, axis(7))); // age 3 → id_pre dropped
|
||||
|
||||
auto out = ft(frame(4.0, 10, 10, person)); // same identity returns
|
||||
CHECK(out.track_ids[0] != id_pre); // too late — fresh id
|
||||
}
|
||||
|
||||
TEST_CASE("eof clears active and parked tracks", "[face_tracker]") {
|
||||
FaceTrackerFunc ft(tracker_cfg());
|
||||
Embedding person = at_sim(0, 1, 0.99f);
|
||||
int id_pre = ft(frame(0.0, 10, 10, person)).track_ids[0];
|
||||
ft(frame(1.0, 300, 300, axis(7), /*is_cut=*/true)); // park id_pre
|
||||
|
||||
EmbeddedSceneFrame eof;
|
||||
eof.source.eof = true;
|
||||
auto out = ft(std::move(eof));
|
||||
CHECK(out.source.eof);
|
||||
|
||||
// After eof the pools are empty: the returning identity must get a fresh id,
|
||||
// not the parked one.
|
||||
auto out2 = ft(frame(2.0, 10, 10, person));
|
||||
CHECK(out2.track_ids[0] != id_pre);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Unit tests for the geometric/numeric helpers in types.hpp and face_utils.hpp:
|
||||
// cosine_similarity and the ArcFace 5-point alignment transform. GPU-free,
|
||||
// model-free.
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
#include "face_utils.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
using Catch::Matchers::WithinAbs;
|
||||
|
||||
TEST_CASE("cosine_similarity of a unit vector with itself is 1", "[types]") {
|
||||
std::array<float, 512> raw{};
|
||||
raw[3] = 2.f; raw[7] = -1.f;
|
||||
Embedding e = l2_normalise(raw.data());
|
||||
CHECK_THAT(cosine_similarity(e, e), WithinAbs(1.0f, 1e-6f));
|
||||
}
|
||||
|
||||
TEST_CASE("cosine_similarity of orthogonal vectors is 0", "[types]") {
|
||||
Embedding a{}, b{};
|
||||
a[0] = 1.f;
|
||||
b[1] = 1.f;
|
||||
CHECK_THAT(cosine_similarity(a, b), WithinAbs(0.0f, 1e-6f));
|
||||
}
|
||||
|
||||
TEST_CASE("cosine_similarity of opposite vectors is -1", "[types]") {
|
||||
Embedding a{}, b{};
|
||||
a[5] = 1.f;
|
||||
b[5] = -1.f;
|
||||
CHECK_THAT(cosine_similarity(a, b), WithinAbs(-1.0f, 1e-6f));
|
||||
}
|
||||
|
||||
TEST_CASE("align_face maps the reference landmarks onto the 112x112 canvas", "[face_utils]") {
|
||||
// Build a synthetic image where the five landmarks sit at known positions.
|
||||
// Feeding align_face the *reference* positions themselves should yield an
|
||||
// (near-)identity similarity transform, so the output is 112x112.
|
||||
cv::Mat img(200, 200, CV_8UC3, cv::Scalar(0, 0, 0));
|
||||
std::array<cv::Point2f, 5> lm;
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
lm[i] = {kArcFaceRef[i][0], kArcFaceRef[i][1]};
|
||||
cv::circle(img, lm[i], 2, cv::Scalar(255, 255, 255), -1);
|
||||
}
|
||||
|
||||
cv::Mat crop = align_face(img, lm);
|
||||
REQUIRE_FALSE(crop.empty());
|
||||
CHECK(crop.cols == 112);
|
||||
CHECK(crop.rows == 112);
|
||||
}
|
||||
|
||||
TEST_CASE("align_face is translation-equivariant", "[face_utils]") {
|
||||
// Shifting all landmarks by a constant offset must still produce a valid
|
||||
// 112x112 crop (the similarity transform absorbs the translation).
|
||||
cv::Mat img(300, 300, CV_8UC3, cv::Scalar(30, 30, 30));
|
||||
std::array<cv::Point2f, 5> lm;
|
||||
const float dx = 100.f, dy = 80.f;
|
||||
for (int i = 0; i < 5; ++i)
|
||||
lm[i] = {kArcFaceRef[i][0] + dx, kArcFaceRef[i][1] + dy};
|
||||
|
||||
cv::Mat crop = align_face(img, lm);
|
||||
REQUIRE_FALSE(crop.empty());
|
||||
CHECK(crop.cols == 112);
|
||||
CHECK(crop.rows == 112);
|
||||
}
|
||||
|
||||
TEST_CASE("align_face returns empty on degenerate (collinear) landmarks", "[face_utils]") {
|
||||
// All five landmarks identical → the affine fit is degenerate.
|
||||
cv::Mat img(200, 200, CV_8UC3, cv::Scalar(0, 0, 0));
|
||||
std::array<cv::Point2f, 5> lm;
|
||||
for (auto& p : lm) p = {50.f, 50.f};
|
||||
|
||||
cv::Mat crop = align_face(img, lm);
|
||||
CHECK(crop.empty());
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Unit tests for gallery (de)serialisation: HDF5 round-trip fidelity (the only
|
||||
// format save_gallery writes), legacy JSON read back-compat (optional field
|
||||
// defaults, the legacy "jellyfin_person_id" fallback). GPU-free, model-free.
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "gallery/gallery_store.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
struct TempFile {
|
||||
std::string path;
|
||||
explicit TempFile(const std::string& name)
|
||||
: path(std::string(std::tmpnam(nullptr)) + name) {}
|
||||
~TempFile() { std::remove(path.c_str()); }
|
||||
};
|
||||
|
||||
Embedding make_embedding(float base) {
|
||||
Embedding e{};
|
||||
for (int i = 0; i < 512; ++i) e[i] = base + i * 1e-4f;
|
||||
return e;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("gallery save/load round-trips actors and embeddings", "[gallery]") {
|
||||
ActorGallery g;
|
||||
ActorGallery::Actor a;
|
||||
a.imdb_id = "nm0000093";
|
||||
a.tmdb_id = "287";
|
||||
a.jellyfin_id = "guid-abc";
|
||||
a.name = "Brad Pitt";
|
||||
a.source_images = {"img1.jpg", "img2.jpg"};
|
||||
a.embeddings = {make_embedding(0.1f), make_embedding(-0.2f)};
|
||||
g.actors.push_back(a);
|
||||
|
||||
ActorGallery::Actor b;
|
||||
b.name = "Edward Norton";
|
||||
b.embeddings = {make_embedding(0.5f)};
|
||||
g.actors.push_back(b);
|
||||
|
||||
// save_gallery always writes HDF5 (see gallery_store.cpp); use a .h5 path
|
||||
// directly rather than relying on the "wrong extension gets rewritten"
|
||||
// fallback, which is a compatibility shim, not the intended usage.
|
||||
TempFile tf("gallery.h5");
|
||||
save_gallery(tf.path, g);
|
||||
ActorGallery loaded = load_gallery(tf.path);
|
||||
|
||||
REQUIRE(loaded.actors.size() == 2);
|
||||
|
||||
const auto& la = loaded.actors[0];
|
||||
CHECK(la.imdb_id == "nm0000093");
|
||||
CHECK(la.tmdb_id == "287");
|
||||
CHECK(la.jellyfin_id == "guid-abc");
|
||||
CHECK(la.name == "Brad Pitt");
|
||||
CHECK(la.source_images == std::vector<std::string>{"img1.jpg", "img2.jpg"});
|
||||
REQUIRE(la.embeddings.size() == 2);
|
||||
for (int i = 0; i < 512; ++i) {
|
||||
CHECK(la.embeddings[0][i] == a.embeddings[0][i]);
|
||||
CHECK(la.embeddings[1][i] == a.embeddings[1][i]);
|
||||
}
|
||||
|
||||
CHECK(loaded.actors[1].name == "Edward Norton");
|
||||
CHECK(loaded.actors[1].embeddings.size() == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("gallery save/load round-trips calibration", "[gallery]") {
|
||||
ActorGallery g;
|
||||
ActorGallery::Actor a;
|
||||
a.name = "Solo Actor";
|
||||
a.embeddings = {make_embedding(0.1f)};
|
||||
g.actors.push_back(a);
|
||||
g.calib_a = 12.5f;
|
||||
g.calib_b = -3.25f;
|
||||
g.calib_valid = true;
|
||||
g.calib_hash = 0xDEADBEEFULL;
|
||||
|
||||
TempFile tf("gallery_calib.h5");
|
||||
save_gallery(tf.path, g);
|
||||
ActorGallery loaded = load_gallery(tf.path);
|
||||
|
||||
CHECK(loaded.calib_a == g.calib_a);
|
||||
CHECK(loaded.calib_b == g.calib_b);
|
||||
CHECK(loaded.calib_valid == g.calib_valid);
|
||||
CHECK(loaded.calib_hash == g.calib_hash);
|
||||
}
|
||||
|
||||
TEST_CASE("gallery with no calibration loads with calib_hash 0", "[gallery]") {
|
||||
ActorGallery g;
|
||||
ActorGallery::Actor a;
|
||||
a.name = "No Calibration";
|
||||
a.embeddings = {make_embedding(0.f)};
|
||||
g.actors.push_back(a);
|
||||
// calib_hash left at its default (0) — save_gallery skips writing the
|
||||
// /calibration group entirely when there's nothing to persist yet.
|
||||
|
||||
TempFile tf("gallery_nocalib.h5");
|
||||
save_gallery(tf.path, g);
|
||||
ActorGallery loaded = load_gallery(tf.path);
|
||||
|
||||
CHECK(loaded.calib_hash == 0);
|
||||
CHECK_FALSE(loaded.calib_valid);
|
||||
}
|
||||
|
||||
TEST_CASE("load_gallery defaults optional string fields to empty", "[gallery]") {
|
||||
// Minimal actor: only the required name + embeddings.
|
||||
nlohmann::json j;
|
||||
j["actors"] = nlohmann::json::array();
|
||||
nlohmann::json ja;
|
||||
ja["name"] = "Minimal";
|
||||
ja["embeddings"] = nlohmann::json::array();
|
||||
ja["embeddings"].push_back(std::vector<float>(512, 0.25f));
|
||||
j["actors"].push_back(ja);
|
||||
|
||||
TempFile tf("gallery_min.json");
|
||||
{ std::ofstream out(tf.path); out << j.dump(); }
|
||||
|
||||
ActorGallery g = load_gallery(tf.path);
|
||||
REQUIRE(g.actors.size() == 1);
|
||||
CHECK(g.actors[0].name == "Minimal");
|
||||
CHECK(g.actors[0].imdb_id.empty());
|
||||
CHECK(g.actors[0].tmdb_id.empty());
|
||||
CHECK(g.actors[0].jellyfin_id.empty());
|
||||
CHECK(g.actors[0].source_images.empty());
|
||||
REQUIRE(g.actors[0].embeddings.size() == 1);
|
||||
CHECK(g.actors[0].embeddings[0][0] == 0.25f);
|
||||
}
|
||||
|
||||
TEST_CASE("load_gallery reads the legacy jellyfin_person_id key", "[gallery]") {
|
||||
nlohmann::json j;
|
||||
j["actors"] = nlohmann::json::array();
|
||||
nlohmann::json ja;
|
||||
ja["name"] = "Legacy";
|
||||
ja["jellyfin_person_id"] = "old-guid"; // pre-rename key
|
||||
ja["embeddings"] = nlohmann::json::array();
|
||||
ja["embeddings"].push_back(std::vector<float>(512, 0.f));
|
||||
j["actors"].push_back(ja);
|
||||
|
||||
TempFile tf("gallery_legacy.json");
|
||||
{ std::ofstream out(tf.path); out << j.dump(); }
|
||||
|
||||
ActorGallery g = load_gallery(tf.path);
|
||||
REQUIRE(g.actors.size() == 1);
|
||||
CHECK(g.actors[0].jellyfin_id == "old-guid");
|
||||
}
|
||||
|
||||
TEST_CASE("load_gallery throws on a missing file", "[gallery]") {
|
||||
CHECK_THROWS(load_gallery("/nonexistent/path/gallery.json"));
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Unit tests for the CPU reference similarity engine (backends/gemm_backend.cpp,
|
||||
// SAE_GEMM_CPU) and the l2_normalise helper. All pure, GPU-free, model-free.
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
#include "face_utils.hpp"
|
||||
#include "inference/similarity.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
using Catch::Matchers::WithinAbs;
|
||||
|
||||
namespace {
|
||||
|
||||
// A 512-d embedding that is 1.0 in one slot and 0 elsewhere (already unit-norm).
|
||||
std::array<float, 512> one_hot(int slot) {
|
||||
std::array<float, 512> e{};
|
||||
e[slot] = 1.0f;
|
||||
return e;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("l2_normalise produces a unit vector", "[similarity]") {
|
||||
std::array<float, 512> raw{};
|
||||
raw[0] = 3.0f;
|
||||
raw[1] = 4.0f; // norm 5
|
||||
|
||||
Embedding n = l2_normalise(raw.data());
|
||||
CHECK_THAT(n[0], WithinAbs(0.6f, 1e-6f));
|
||||
CHECK_THAT(n[1], WithinAbs(0.8f, 1e-6f));
|
||||
|
||||
float norm = 0.f;
|
||||
for (float v : n) norm += v * v;
|
||||
CHECK_THAT(std::sqrt(norm), WithinAbs(1.0f, 1e-6f));
|
||||
}
|
||||
|
||||
TEST_CASE("l2_normalise guards against a zero vector", "[similarity]") {
|
||||
std::array<float, 512> zero{};
|
||||
Embedding n = l2_normalise(zero.data());
|
||||
for (float v : n) CHECK(v == 0.0f); // 0 / 1e-6 == 0, no NaN
|
||||
}
|
||||
|
||||
TEST_CASE("CPU similarity engine matches hand-computed dot products", "[similarity]") {
|
||||
// Gallery of three orthonormal one-hot embeddings.
|
||||
std::vector<float> gallery;
|
||||
for (int slot : {0, 1, 2}) {
|
||||
auto e = one_hot(slot);
|
||||
gallery.insert(gallery.end(), e.begin(), e.end());
|
||||
}
|
||||
const int n_gallery = 3;
|
||||
const int max_faces = 2;
|
||||
|
||||
auto engine = make_similarity_engine(gallery.data(), n_gallery, max_faces);
|
||||
REQUIRE(engine->max_faces() == max_faces);
|
||||
|
||||
// Two query faces: face0 == gallery row 1, face1 is 45° between rows 0 and 2.
|
||||
std::vector<float> query(static_cast<size_t>(max_faces) * 512, 0.0f);
|
||||
query[1] = 1.0f; // face0: one-hot slot 1
|
||||
const float s = std::sqrt(0.5f);
|
||||
query[512 + 0] = s; // face1: (1/√2, 0, 1/√2, …)
|
||||
query[512 + 2] = s;
|
||||
|
||||
const float* S = engine->compute(query.data(), 2);
|
||||
|
||||
// Column-major: S[g + f*n_gallery].
|
||||
// face0 vs gallery {0,1,2} → {0, 1, 0}
|
||||
CHECK_THAT(S[0 + 0 * n_gallery], WithinAbs(0.0f, 1e-6f));
|
||||
CHECK_THAT(S[1 + 0 * n_gallery], WithinAbs(1.0f, 1e-6f));
|
||||
CHECK_THAT(S[2 + 0 * n_gallery], WithinAbs(0.0f, 1e-6f));
|
||||
// face1 vs gallery {0,1,2} → {1/√2, 0, 1/√2}
|
||||
CHECK_THAT(S[0 + 1 * n_gallery], WithinAbs(s, 1e-6f));
|
||||
CHECK_THAT(S[1 + 1 * n_gallery], WithinAbs(0.0f, 1e-6f));
|
||||
CHECK_THAT(S[2 + 1 * n_gallery], WithinAbs(s, 1e-6f));
|
||||
}
|
||||
|
||||
TEST_CASE("CPU similarity engine rejects too many faces", "[similarity]") {
|
||||
auto e = one_hot(0);
|
||||
auto engine = make_similarity_engine(e.data(), /*n_gallery=*/1, /*max_faces=*/1);
|
||||
std::array<float, 512 * 2> q{};
|
||||
CHECK_THROWS(engine->compute(q.data(), 2));
|
||||
}
|
||||
|
||||
TEST_CASE("CPU similarity engine handles zero query faces", "[similarity]") {
|
||||
auto e = one_hot(0);
|
||||
auto engine = make_similarity_engine(e.data(), 1, 4);
|
||||
// n_faces == 0 must not read the (null) query pointer.
|
||||
CHECK_NOTHROW(engine->compute(nullptr, 0));
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Unit tests for TrackGallery (gallery/track_gallery.hpp): per-film gallery
|
||||
// expansion driven by track continuity. Pure, GPU-free, model-free — exercises
|
||||
// the diversity-buffer eviction policy, the novelty/spread safety gates,
|
||||
// plurality ownership, and idempotent promotion via the public interface.
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
#include "config.hpp"
|
||||
#include "gallery/track_gallery.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
|
||||
// Unit-norm embedding pointing along one axis (cosine sim to another one-hot is
|
||||
// 0, to itself 1) — lets tests dial gallery similarity precisely.
|
||||
Embedding one_hot(int slot) {
|
||||
Embedding e{};
|
||||
e[slot] = 1.0f;
|
||||
return e;
|
||||
}
|
||||
|
||||
// Unit-norm embedding in the plane of axes i,j at angle t from i. Cosine sim to
|
||||
// one_hot(i) is cos(t) — used to place a view at a chosen gallery similarity.
|
||||
Embedding at_sim(int i, int j, float cos_t) {
|
||||
Embedding e{};
|
||||
float s = std::sqrt(std::max(0.f, 1.f - cos_t * cos_t));
|
||||
e[i] = cos_t;
|
||||
e[j] = s;
|
||||
return e;
|
||||
}
|
||||
|
||||
Config expand_cfg() {
|
||||
Config cfg;
|
||||
cfg.expand_gallery = true;
|
||||
cfg.expand_buffer_size = 3;
|
||||
cfg.expand_novelty_sim = 0.55f;
|
||||
cfg.expand_track_spread_max = 0.60f;
|
||||
cfg.expand_min_anchor_frames = 3;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const cv::Mat kNoCrop; // debug dumping off → crop unused
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("disabled: no annex growth when expand_gallery is off", "[track_gallery]") {
|
||||
// expand_gallery defaults to true (see config.hpp) as of the rep4 bake-off —
|
||||
// set it explicitly false here since this test exercises the disabled path,
|
||||
// not whatever the struct's current default happens to be.
|
||||
Config cfg;
|
||||
cfg.expand_gallery = false;
|
||||
TrackGallery tg(cfg);
|
||||
REQUIRE_FALSE(tg.enabled());
|
||||
for (int f = 0; f < 10; ++f)
|
||||
tg.observe(1, one_hot(1), /*actor*/ 0, /*sim*/ 0.2f, /*accept*/ true, kNoCrop);
|
||||
CHECK(tg.annex().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("confirmed track promotes gallery-far views", "[track_gallery]") {
|
||||
TrackGallery tg(expand_cfg());
|
||||
REQUIRE(tg.enabled());
|
||||
|
||||
// A track owned by actor 0. Every frame is accepted as actor 0, but each
|
||||
// view is gallery-far (sim 0.30 < novelty 0.55) yet mutually self-similar
|
||||
// enough to pass the spread gate.
|
||||
for (int f = 0; f < 3; ++f)
|
||||
tg.observe(7, at_sim(0, 1, 0.30f + 0.001f * f), 0, 0.30f + 0.001f * f, true, kNoCrop);
|
||||
|
||||
// 3 accepted frames == min_anchor_frames → confirmed and promoted.
|
||||
CHECK_FALSE(tg.annex().empty());
|
||||
for (const auto& ae : tg.annex()) CHECK(ae.actor_idx == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("novelty gate skips views the gallery already covers", "[track_gallery]") {
|
||||
TrackGallery tg(expand_cfg());
|
||||
// All views are recognised well (sim 0.90 ≥ novelty 0.55): nothing worth
|
||||
// promoting even though the track is confirmed.
|
||||
for (int f = 0; f < 3; ++f)
|
||||
tg.observe(2, one_hot(0), 0, 0.90f, true, kNoCrop);
|
||||
CHECK(tg.annex().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("spread gate rejects a two-person track", "[track_gallery]") {
|
||||
TrackGallery tg(expand_cfg());
|
||||
// Two orthogonal identities under one track ID: pairwise sim 0 → spread 1.0
|
||||
// > spread_max 0.60. Whole track rejected, annex stays empty even though
|
||||
// frames are accepted and gallery-far.
|
||||
tg.observe(3, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop);
|
||||
tg.observe(3, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop);
|
||||
tg.observe(3, one_hot(400), 0, 0.30f, true, kNoCrop); // orthogonal outlier
|
||||
CHECK(tg.annex().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("unconfirmed track (too few accepts) does not promote", "[track_gallery]") {
|
||||
TrackGallery tg(expand_cfg());
|
||||
// Only 2 accepted frames < min_anchor_frames 3; extra non-accepted frames
|
||||
// fill the buffer but don't count toward ownership.
|
||||
tg.observe(4, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop);
|
||||
tg.observe(4, at_sim(0, 1, 0.31f), 0, 0.31f, true, kNoCrop);
|
||||
tg.observe(4, at_sim(0, 1, 0.32f), 0, 0.32f, false, kNoCrop);
|
||||
CHECK(tg.annex().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("plurality actor wins a mixed-vote track", "[track_gallery]") {
|
||||
Config cfg = expand_cfg();
|
||||
cfg.expand_min_anchor_frames = 3;
|
||||
TrackGallery tg(cfg);
|
||||
// Actor 5 accepted twice, actor 6 once → plurality is 5. All views novel.
|
||||
tg.observe(8, at_sim(0, 1, 0.30f), 5, 0.30f, true, kNoCrop);
|
||||
tg.observe(8, at_sim(0, 1, 0.31f), 5, 0.31f, true, kNoCrop);
|
||||
tg.observe(8, at_sim(0, 1, 0.32f), 6, 0.32f, true, kNoCrop);
|
||||
REQUIRE_FALSE(tg.annex().empty());
|
||||
for (const auto& ae : tg.annex()) CHECK(ae.actor_idx == 5);
|
||||
}
|
||||
|
||||
TEST_CASE("promotion is idempotent across a long track", "[track_gallery]") {
|
||||
TrackGallery tg(expand_cfg());
|
||||
for (int f = 0; f < 3; ++f)
|
||||
tg.observe(9, at_sim(0, 1, 0.30f + 0.001f * f), 0, 0.30f + 0.001f * f, true, kNoCrop);
|
||||
size_t after_confirm = tg.annex().size();
|
||||
REQUIRE(after_confirm > 0);
|
||||
// Keep feeding the confirmed track: annex must not grow again.
|
||||
for (int f = 0; f < 10; ++f)
|
||||
tg.observe(9, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop);
|
||||
CHECK(tg.annex().size() == after_confirm);
|
||||
}
|
||||
|
||||
TEST_CASE("clear_tracks drops buffers before confirmation", "[track_gallery]") {
|
||||
TrackGallery tg(expand_cfg());
|
||||
// Two accepts, then a cut clears buffers; the third accept starts fresh and
|
||||
// can't reach the anchor threshold on its own.
|
||||
tg.observe(1, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop);
|
||||
tg.observe(1, at_sim(0, 1, 0.31f), 0, 0.31f, true, kNoCrop);
|
||||
tg.clear_tracks();
|
||||
tg.observe(1, at_sim(0, 1, 0.32f), 0, 0.32f, true, kNoCrop);
|
||||
CHECK(tg.annex().empty());
|
||||
}
|
||||
Reference in New Issue
Block a user