The CPU path was a scalar triple loop. It is the correctness oracle for the GPU backends, but it is also what CI runs — there is no GPU on the N100 host — and since AR-003 removed the per-frame face cap, a crowded frame now scores many faces against a library-scale gallery. Scoring one face against 5000 embeddings is 2.6 MFLOP; in scalar that does not hold up (AR-027). S(g,f) viewed as row-major [n_faces x n_gallery] is exactly query * gallery^T, so the loop nest collapses into a single cblas_sgemm. OpenBLAS is optional in the build: found via pkg-config, and the scalar path remains when it is absent so no hard dependency is added and the two can be diffed when a similarity looks wrong. The configure step warns rather than failing, since a developer without it should still get a working tree. The test target links it too. Without that the suite compiles the scalar fallback while the builder image ships CBLAS, so CI would be verifying a kernel that is not the one running in production — the same class of mistake as testing a path the gate never executes. Recorded as required (not optional) in the DP-007 image, for the same reason. Suite: 92 cases, 6136 assertions, with CBLAS compiled in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-026, AR-027, DP-007 | SR-001
180 lines
6.8 KiB
C++
180 lines
6.8 KiB
C++
// 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());
|
|
}
|
|
|
|
// ── AR-030: the alignment residual as a visibility measure ────────────────────
|
|
// These assert the *properties* the measure is relied on for, not a magic value.
|
|
// Each would fail under a RANSAC fit, which buys a small residual by discarding
|
|
// the very landmarks that carry the signal.
|
|
|
|
namespace {
|
|
|
|
std::array<cv::Point2f, 5> canonical() {
|
|
std::array<cv::Point2f, 5> lm;
|
|
for (int i = 0; i < 5; ++i) lm[i] = {kArcFaceRef[i][0], kArcFaceRef[i][1]};
|
|
return lm;
|
|
}
|
|
|
|
// Rotate by `deg` in-plane, scale uniformly, translate — i.e. exactly the 4 DoF
|
|
// the similarity transform models.
|
|
std::array<cv::Point2f, 5> similarity(const std::array<cv::Point2f, 5>& in,
|
|
float deg, float s, float tx, float ty) {
|
|
const float r = deg * 3.14159265358979f / 180.f;
|
|
const float c = std::cos(r), sn = std::sin(r);
|
|
std::array<cv::Point2f, 5> out;
|
|
for (int i = 0; i < 5; ++i)
|
|
out[i] = {s * (c * in[i].x - sn * in[i].y) + tx,
|
|
s * (sn * in[i].x + c * in[i].y) + ty};
|
|
return out;
|
|
}
|
|
|
|
// Squash x about the centroid by `k`: the anisotropic deformation an out-of-plane
|
|
// yaw produces, and the one a similarity provably cannot absorb.
|
|
std::array<cv::Point2f, 5> foreshorten(const std::array<cv::Point2f, 5>& in, float k) {
|
|
float cx = 0.f;
|
|
for (const auto& p : in) cx += p.x;
|
|
cx /= 5.f;
|
|
std::array<cv::Point2f, 5> out = in;
|
|
for (auto& p : out) p.x = cx + (p.x - cx) * k;
|
|
return out;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("residual is zero for a face in canonical pose", "[face_utils][AR-030]") {
|
|
const Alignment a = estimate_alignment(canonical());
|
|
REQUIRE(a.ok);
|
|
CHECK_THAT(a.residual, WithinAbs(0.0f, 1e-3f));
|
|
}
|
|
|
|
TEST_CASE("residual ignores in-plane roll, scale and translation", "[face_utils][AR-030]") {
|
|
// The structural claim behind AR-030: the fit absorbs all four similarity
|
|
// DoF exactly, so what remains is only the deformation a similarity cannot
|
|
// explain. A rolled head must not read as a turned one.
|
|
for (float deg : {-40.f, -12.f, 0.f, 17.f, 65.f}) {
|
|
const Alignment a = estimate_alignment(similarity(canonical(), deg, 3.5f, 220.f, -40.f));
|
|
REQUIRE(a.ok);
|
|
CHECK_THAT(a.residual, WithinAbs(0.0f, 1e-3f));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("residual rises monotonically with foreshortening", "[face_utils][AR-030]") {
|
|
float prev = -1.f;
|
|
for (float k : {1.0f, 0.9f, 0.75f, 0.5f, 0.3f}) {
|
|
const Alignment a = estimate_alignment(foreshorten(canonical(), k));
|
|
REQUIRE(a.ok);
|
|
CHECK(a.residual > prev);
|
|
prev = a.residual;
|
|
}
|
|
}
|
|
|
|
TEST_CASE("residual is independent of face size", "[face_utils][AR-030]") {
|
|
// The measure must not silently re-express face size — that is AR-002's job,
|
|
// and double-counting it would make a small frontal face look occluded.
|
|
// Same deformation, two very different face sizes, one answer.
|
|
const auto small = similarity(foreshorten(canonical(), 0.7f), 20.f, 1.0f, 0.f, 0.f);
|
|
const auto large = similarity(foreshorten(canonical(), 0.7f), 20.f, 12.0f, 500.f, 300.f);
|
|
|
|
const Alignment a = estimate_alignment(small);
|
|
const Alignment b = estimate_alignment(large);
|
|
REQUIRE(a.ok);
|
|
REQUIRE(b.ok);
|
|
CHECK_THAT(b.residual, WithinAbs(a.residual, 1e-2f));
|
|
}
|
|
|
|
TEST_CASE("the fit never mirrors the face", "[face_utils][AR-030]") {
|
|
// SVD will happily return an orientation-reversing solution; a similarity
|
|
// transform may rotate but never reflect. Without the determinant guard a
|
|
// mirrored landmark set fits "perfectly" as a reflection.
|
|
const auto mirrored = foreshorten(canonical(), -1.f);
|
|
const Alignment a = estimate_alignment(mirrored);
|
|
REQUIRE(a.ok);
|
|
|
|
const double det = a.M.at<double>(0,0) * a.M.at<double>(1,1)
|
|
- a.M.at<double>(0,1) * a.M.at<double>(1,0);
|
|
CHECK(det > 0.0);
|
|
CHECK(a.residual > 1.0f); // and the mirroring shows up as misfit
|
|
}
|
|
|
|
TEST_CASE("degenerate landmarks report not-ok rather than a residual", "[face_utils][AR-030]") {
|
|
std::array<cv::Point2f, 5> lm;
|
|
for (auto& p : lm) p = {50.f, 50.f};
|
|
|
|
const Alignment a = estimate_alignment(lm);
|
|
CHECK_FALSE(a.ok);
|
|
CHECK(a.M.empty());
|
|
}
|