// TRACES: AR-005, AR-028, AR-029, AR-030 | UT-130, UT-131, UT-132, UT-133, UT-134, UT-135, UT-136, UT-137, UT-138 | SR-002 // // Unit tests for the geometric/numeric helpers in types.hpp and face_utils.hpp: // cosine_similarity, the ArcFace 5-point alignment transform, and the two // measured axes of the AR-028 quality vector — the alignment residual AR-030 // reads as visibility, and the normalised Laplacian variance AR-029 reads as // sharpness. The aligner node is exercised here too, since it is the unit that // fills the vector in. GPU-free, model-free. #include #include #include "face_utils.hpp" #include "nodes/face_aligner_node.hpp" #include "types.hpp" #include #include #include #include #include using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinRel; 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()); } // ── 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 canonical() { std::array 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 similarity(const std::array& 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 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 foreshorten(const std::array& in, float k) { float cx = 0.f; for (const auto& p : in) cx += p.x; cx /= 5.f; std::array 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(0,0) * a.M.at(1,1) - a.M.at(0,1) * a.M.at(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 lm; for (auto& p : lm) p = {50.f, 50.f}; const Alignment a = estimate_alignment(lm); CHECK_FALSE(a.ok); CHECK(a.M.empty()); } // ── AR-029: normalised Laplacian variance as a sharpness measure ────────────── // As with AR-030 above, these assert the *properties* the measure is relied on // for rather than magic values: no threshold is set here or anywhere else, so a // number that drifted with the OpenCV version would still be usable — a number // that stopped falling with blur, or started tracking exposure, would not. namespace { // Pink noise: broadband, but with a 1/f spectrum, so most of the energy sits at // low frequency the way it does in a photograph. An LCG rather than cv::randu so // the ladder is identical on every machine and every OpenCV build. // // **The 1/f part is load-bearing, not decoration.** On a flat-spectrum texture // (raw white noise) the Gaussian ladder still falls, but the motion-blur ladder // *rises* — 80.1 → 90.0 across the same kernel lengths used below. That is not a // bug in the measure, it is what a normalised measure must do on such an input: // a horizontal smear takes energy out of the numerator and the denominator // together, and what survives is vertical detail that really is just as fine. // Real crops have the low-frequency mass that keeps the denominator steady while // the numerator falls. See the second hazard note on crop_sharpness(). cv::Mat pink(int size, uint32_t seed = 12345u) { cv::Mat white(size, size, CV_32F); uint32_t s = seed; for (int y = 0; y < size; ++y) for (int x = 0; x < size; ++x) { s = s * 1664525u + 1013904223u; white.at(y, x) = float((s >> 16) & 0xFFFF) / 65535.f - 0.5f; } // Octaves weighted 1/f. The sigma=0 band keeps genuine per-pixel detail in, // so the top of the blur ladder is a sharp image rather than an already-soft // one. cv::Mat acc = cv::Mat::zeros(size, size, CV_32F); const double sigma[] = {0.0, 1.0, 2.0, 4.0, 8.0}; const double weight[] = {1.0, 2.0, 4.0, 8.0, 16.0}; for (int k = 0; k < 5; ++k) { cv::Mat band; if (sigma[k] <= 0.0) band = white.clone(); else cv::GaussianBlur(white, band, {0, 0}, sigma[k], sigma[k], cv::BORDER_REPLICATE); acc += band * weight[k]; } // Into [40, 215]: 8-bit like a real crop, with headroom at both ends so the // contrast test can halve it without clipping. double lo = 0, hi = 0; cv::minMaxLoc(acc, &lo, &hi); const double scale = 175.0 / (hi - lo); cv::Mat out; acc.convertTo(out, CV_8U, scale, 40.0 - lo * scale); return out; } // One master pattern, resampled. So "the same face at 40 px and at 400 px" is // literally the same image at two resolutions, and a test about source // resolution is not accidentally a test about two different textures. // INTER_AREA because area-averaging is what a sensor does when it images the // same subject onto fewer pixels. cv::Mat texture(int size) { static const cv::Mat master = pink(448); if (size == master.cols) return master; cv::Mat out; cv::resize(master, out, {size, size}, 0, 0, cv::INTER_AREA); return out; } cv::Mat gaussian(const cv::Mat& in, double sigma) { if (sigma <= 0.0) return in.clone(); cv::Mat out; cv::GaussianBlur(in, out, {0, 0}, sigma, sigma, cv::BORDER_REPLICATE); return out; } // Horizontal box smear — motion blur, which is anisotropic and so attenuates // only one axis of the spectrum. A measure tuned to the isotropic case can // miss it. cv::Mat motion(const cv::Mat& in, int len) { if (len <= 1) return in.clone(); const cv::Mat k(1, len, CV_32F, cv::Scalar(1.0 / len)); cv::Mat out; cv::filter2D(in, out, -1, k, {-1, -1}, 0, cv::BORDER_REPLICATE); return out; } // The pipeline reaches 112×112 through warpAffine's INTER_LINEAR; resize with // the same interpolation so a test about source resolution is not really a test // about which resampler was used. cv::Mat to_crop(const cv::Mat& in) { cv::Mat out; cv::resize(in, out, {112, 112}, 0, 0, cv::INTER_LINEAR); return out; } } // namespace TEST_CASE("sharpness falls monotonically along a Gaussian blur ladder", "[face_utils][AR-029][UT-130]") { const cv::Mat src = texture(112); float prev = std::numeric_limits::infinity(); for (double sigma : {0.0, 0.6, 1.0, 1.6, 2.5, 4.0}) { const float s = crop_sharpness(gaussian(src, sigma)); CHECK(s < prev); CHECK(s > 0.f); prev = s; } } TEST_CASE("sharpness falls monotonically under motion blur too", "[face_utils][AR-029][UT-131]") { // Motion blur is the failure mode that leaves the bounding box looking // perfectly healthy, so it is the one the measure exists for. const cv::Mat src = texture(112); float prev = std::numeric_limits::infinity(); for (int len : {1, 3, 5, 9, 15}) { const float s = crop_sharpness(motion(src, len)); CHECK(s < prev); CHECK(s > 0.f); prev = s; } } TEST_CASE("contrast does not leak into sharpness", "[face_utils][AR-029][UT-132]") { // The normalisation that makes the axis mean the same thing in a dim scene // and a bright one. Without it VR-012 would locate a different knee per // film — a magic number wearing a measurement's clothes (AR-024). const cv::Mat src = texture(112); cv::Mat dim; src.convertTo(dim, CV_8U, 0.5, 64.0); // half contrast, re-centred, no clipping const float a = crop_sharpness(src); const float b = crop_sharpness(dim); REQUIRE(a > 0.f); CHECK_THAT(b, WithinRel(a, 0.03f)); } TEST_CASE("the contrast invariance is exact, and 8-bit sampling is what bends it", "[face_utils][AR-029]") { // Worth separating because the two have different consequences. The // algebra is exact — scaling I by α scales the Laplacian by α, so both // variances scale by α² and cancel — which is why halving a float crop // changes nothing at all. // // What deviates is the 8-bit *round trip*: halving the contrast of a stored // crop throws away a bit of dynamic range, and the quantisation floor it // leaves behind is broadband, so it lands almost entirely in the numerator. // The effect scales with how little signal is left to compete with it — // measured on this texture, a half-contrast copy reads 0.9% high when sharp, // 24% high at sigma 1.2 and 148% high at sigma 2.5. // // So: a dim *and* soft crop reads sharper than it is, and that is the corner // of the axis VR-012 has to put a knee in. Asserted here rather than left as // a comment, because "the measure is contrast-invariant" is the kind of claim // that gets repeated without its precondition. cv::Mat src; gaussian(texture(112), 1.2).convertTo(src, CV_32F); const cv::Mat half = src * 0.5 + 64.0; const float a = crop_sharpness(src); const float b = crop_sharpness(half); REQUIRE(a > 0.f); CHECK_THAT(b, WithinRel(a, 1e-5f)); } TEST_CASE("a small sharp face outscores a large soft one", "[face_utils][AR-029][UT-134]") { // The register's named edge case: "size must not leak into this axis". What // that means operationally is that the measure is not a monotone function of // source face size — it reports the detail present in the embedder's input, // so the ordering can and must invert when the large face is the blurred one. // // Small sharp: 40 px of real detail, upsampled 2.8x → finest scale ~2.8 crop px. // Large soft: 400 px blurred at sigma 20, downsampled 3.57x → ~5.6 crop px. const float small_sharp = crop_sharpness(to_crop(texture(40))); const float large_soft = crop_sharpness(to_crop(gaussian(texture(400), 20.0))); CHECK(small_sharp > large_soft); } TEST_CASE("a flat crop scores zero rather than dividing by zero", "[face_utils][AR-029][UT-135]") { const cv::Mat flat(112, 112, CV_8UC3, cv::Scalar(90, 90, 90)); const float s = crop_sharpness(flat); CHECK(std::isfinite(s)); CHECK_THAT(s, WithinAbs(0.0f, 1e-6f)); } TEST_CASE("an empty crop is unscored, not zero", "[face_utils][AR-029][UT-136]") { // -1 says "nothing measured this"; 0 says "measured, and there was no // detail". Collapsing them would put unscored faces at the bottom of the // quality axis, where VR-012 would read them as the blurriest in the film. CHECK(crop_sharpness(cv::Mat()) < 0.f); } // ── AR-028: the aligner fills the vector, and loses nothing quietly ─────────── namespace { // A face at `centre` in an image with enough texture for sharpness to be a real // number rather than the flat-crop zero. std::array face_at(cv::Point2f centre, float scale) { std::array lm; for (int i = 0; i < 5; ++i) lm[i] = {centre.x + (kArcFaceRef[i][0] - 56.f) * scale, centre.y + (kArcFaceRef[i][1] - 56.f) * scale}; return lm; } cv::Mat textured_frame(int w, int h) { cv::Mat gray = texture(std::max(w, h)); cv::Mat bgr; cv::cvtColor(gray(cv::Rect(0, 0, w, h)), bgr, cv::COLOR_GRAY2BGR); return bgr; } } // namespace TEST_CASE("every face the aligner admits carries a full quality vector", "[face_utils][AR-028][UT-137]") { SceneFrame sf; sf.source.image = textured_frame(400, 300); for (auto c : {cv::Point2f{120.f, 100.f}, cv::Point2f{280.f, 190.f}}) { DetectedFace f; f.landmarks = face_at(c, 1.2f); f.bbox = cv::Rect2f(c.x - 60.f, c.y - 60.f, 120.f, 120.f); f.confidence = 0.9f; sf.faces.push_back(f); } FaceAlignerFunc aligner; const AlignedSceneFrame out = aligner(std::move(sf)); REQUIRE(out.faces.size() == 2); for (const auto& f : out.faces) { // Not "is it good quality" — that is VR-012's to decide. Only that the // sentinel is gone, so no embedding reaches the matcher unscored. CHECK(f.sharpness >= 0.f); CHECK(f.alignment_residual >= 0.f); } CHECK(aligner.scored() == 2); CHECK(aligner.degenerate() == 0); } TEST_CASE("a degenerate detection is counted, not silently vanished", "[face_utils][AR-028][UT-138]") { // It cannot be scored — there is no crop and no fit to score — so it is // dropped. The requirement is that the drop leaves a trace: without the // tally, a detector emitting unusable landmark sets produces a dump that // looks exactly like footage with fewer faces in it. SceneFrame sf; sf.source.image = textured_frame(400, 300); DetectedFace good; good.landmarks = face_at({150.f, 140.f}, 1.2f); good.confidence = 0.9f; sf.faces.push_back(good); DetectedFace degenerate; for (auto& p : degenerate.landmarks) p = {200.f, 200.f}; degenerate.confidence = 0.9f; sf.faces.push_back(degenerate); FaceAlignerFunc aligner; const AlignedSceneFrame out = aligner(std::move(sf)); CHECK(out.faces.size() == 1); CHECK(out.crops.size() == 1); CHECK(aligner.scored() == 1); CHECK(aligner.degenerate() == 1); }