diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..4062b66 --- /dev/null +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/test_calibration.cpp b/tests/test_calibration.cpp new file mode 100644 index 0000000..41d4ebe --- /dev/null +++ b/tests/test_calibration.cpp @@ -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 +#include + +#include "gallery/gallery_calibration.hpp" +#include "face_utils.hpp" // l2_normalise +#include "types.hpp" + +#include +#include +#include +#include + +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& emb, + std::vector& actor) { + std::mt19937 rng(1234); + std::normal_distribution centre(0.f, 1.f); + std::normal_distribution jitter(0.f, 0.01f); + for (int a = 0; a < n_actors; ++a) { + std::array c{}; + for (float& v : c) v = centre(rng); + for (int k = 0; k < per_actor; ++k) { + std::array 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 emb; + std::vector 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 emb(1); + emb[0] = l2_normalise(std::array{1.f}.data()); + std::vector actor{0}; + GalleryCalibration cal = calibrate_gallery(emb, actor); + CHECK_FALSE(cal.valid); +} + +TEST_CASE("hash_gallery_embeddings is sensitive to changes", "[calibration]") { + std::vector emb; + std::vector 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 emb; + std::vector 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 emb; + std::vector 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 emb; + std::vector 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); +} diff --git a/tests/test_face_tracker.cpp b/tests/test_face_tracker.cpp new file mode 100644 index 0000000..c7ce5e3 --- /dev/null +++ b/tests/test_face_tracker.cpp @@ -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 + +#include "config.hpp" +#include "nodes/face_tracker_node.hpp" +#include "types.hpp" + +#include + +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); +} diff --git a/tests/test_face_utils.cpp b/tests/test_face_utils.cpp new file mode 100644 index 0000000..227c8fd --- /dev/null +++ b/tests/test_face_utils.cpp @@ -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 +#include + +#include "face_utils.hpp" +#include "types.hpp" + +#include +#include + +using Catch::Matchers::WithinAbs; + +TEST_CASE("cosine_similarity of a unit vector with itself is 1", "[types]") { + std::array 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 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 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 lm; + for (auto& p : lm) p = {50.f, 50.f}; + + cv::Mat crop = align_face(img, lm); + CHECK(crop.empty()); +} diff --git a/tests/test_gallery_store.cpp b/tests/test_gallery_store.cpp new file mode 100644 index 0000000..40b2567 --- /dev/null +++ b/tests/test_gallery_store.cpp @@ -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 + +#include "gallery/gallery_store.hpp" +#include "types.hpp" + +#include +#include +#include + +#include + +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{"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(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(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")); +} diff --git a/tests/test_similarity.cpp b/tests/test_similarity.cpp new file mode 100644 index 0000000..14d2bc3 --- /dev/null +++ b/tests/test_similarity.cpp @@ -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 +#include + +#include "face_utils.hpp" +#include "inference/similarity.hpp" + +#include +#include +#include + +using Catch::Matchers::WithinAbs; + +namespace { + +// A 512-d embedding that is 1.0 in one slot and 0 elsewhere (already unit-norm). +std::array one_hot(int slot) { + std::array e{}; + e[slot] = 1.0f; + return e; +} + +} // namespace + +TEST_CASE("l2_normalise produces a unit vector", "[similarity]") { + std::array 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 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 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 query(static_cast(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 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)); +} diff --git a/tests/test_track_gallery.cpp b/tests/test_track_gallery.cpp new file mode 100644 index 0000000..b3639b0 --- /dev/null +++ b/tests/test_track_gallery.cpp @@ -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 +#include + +#include "config.hpp" +#include "gallery/track_gallery.hpp" +#include "types.hpp" + +#include +#include + +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()); +}