Files
scene-actor-extraction/tests/test_face_utils.cpp
T
dtourolle 777c98cb33 feat(quality): score every face on sharpness and alignment before it is evidence
Every embedding now carries the quality of the input it came from. Both
axes fall out of the AR-005 warp for free: crop_sharpness() is the
normalised Laplacian variance over the aligned 112x112, so contrast and
size cannot leak into it, and the alignment residual is the part of the
landmark deformation a similarity transform cannot explain, so in-plane
roll reads as zero and foreshortening does not.

Carried, not consumed. Nothing discounts or thresholds on either number
yet -- that is AR-030 and VR-012, and the knee has to be located against
recorded data before a gate is chosen. What this change buys is that the
data exists to locate it with.

No face is admitted unscored: the -1 sentinel is preserved rather than
clamped, and a degenerate landmark fit is counted rather than silently
dropped.

Takes the VR-001 dump to schema_version 2. The bump is not for readers,
which check for the datasets by name and replay a v1 dump unchanged; it
is so a consumer can tell "never scored" from "scored zero", which is
not recoverable from the arrays afterwards.

TRACES: AR-028, AR-029, AR-030 | VR-001 | SR-002
2026-08-05 14:37:30 +02:00

454 lines
18 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.
// 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 <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "face_utils.hpp"
#include "nodes/face_aligner_node.hpp"
#include "types.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <limits>
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
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());
}
// ── 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<float>(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<float>::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<float>::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<cv::Point2f, 5> face_at(cv::Point2f centre, float scale) {
std::array<cv::Point2f, 5> 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);
}