From 1ae88376e1e847b55fbf1f4e1d3d39bda3c91343 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 31 Jul 2026 22:24:39 +0200 Subject: [PATCH] feat(quality): five AR-029 sharpness candidates on the aligned crop assess_sharpness() scores a 112x112 crop on variance-of-Laplacian, a contrast-normalised variant, Tenengrad, a spectral high-frequency ratio and dir_min_tenengrad, over a fixed 64x64 window on the face interior. The window excludes the corners because studio headshots are routinely shot at a wide aperture, and background bokeh measured over the whole crop would drag the score down on the sharpest images in the set. Five rather than one because AR-029's threshold has to be located, not chosen: VR-012 ranks them by how well each predicts real identity loss. The T1 ladders drove two corrections during development. The spectral ratio applied its Hann window before removing the mean, so the DC term smeared into the low-frequency bins and the "ratio" tracked absolute brightness (a 20/255 brightening moved it 23%). And no measure taken from the literature survived directional blur: normalising by total energy divides out the loss being measured, so both ratio measures are U-shaped in motion-blur length and score a 21 px smear about as sharp as a 3 px one. dir_min_tenengrad exists to fix that -- a low-frequency contrast denominator that blur leaves alone, and the worse of the two Sobel axes rather than their sum. The tests pin the disqualifying behaviours as well as the desirable ones, so a change that makes var_laplacian contrast-free is a deliberate act rather than an accident. They also record that every candidate falls under downscale-upscale as well as under blur: the aligned crop is scale-normalised geometrically, not informationally. Exposed through sae_embed alongside the AR-030 alignment residual, so a study scores through shipped code rather than a numpy copy -- the same argument that already applies to the calibration. TRACES: AR-028, AR-029 | SR-002 --- src/python_bindings.cpp | 50 +++++++ src/quality.hpp | 256 ++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_quality.cpp | 302 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 609 insertions(+) create mode 100644 src/quality.hpp create mode 100644 tests/test_quality.cpp diff --git a/src/python_bindings.cpp b/src/python_bindings.cpp index c3d9532..19f96eb 100644 --- a/src/python_bindings.cpp +++ b/src/python_bindings.cpp @@ -16,6 +16,7 @@ #include "face_embedder_engine.hpp" #include "gallery/gallery_calibration.hpp" #include "gallery/gallery_store.hpp" +#include "quality.hpp" #include #include @@ -176,6 +177,55 @@ NB_MODULE(sae_embed, m) { }, "image"_a, "Border-replicate pad by 50% and CLAHE, for a detector second try."); + // ── Quality (AR-028 … AR-030) ──────────────────────────────────────────── + // Exposed for the same reason the calibration is: VR-012 has to select + // among the AR-029 candidates, and the measure it selects must be the one + // that ships. A numpy copy scored during the study would leave the shipped + // measure unmeasured, which is precisely the failure the whole quality axis + // exists to prevent. + nb::class_(m, "SharpnessScores") + .def_ro("var_laplacian", &SharpnessScores::var_laplacian) + .def_ro("norm_var_laplacian", &SharpnessScores::norm_var_laplacian) + .def_ro("tenengrad", &SharpnessScores::tenengrad) + .def_ro("hf_energy_ratio", &SharpnessScores::hf_energy_ratio) + .def_ro("dir_min_tenengrad", &SharpnessScores::dir_min_tenengrad) + .def_ro("ok", &SharpnessScores::ok) + .def("__repr__", [](const SharpnessScores& s) { + return "" : " NOT-OK>"); + }); + + m.def("assess_sharpness", [](ImageArray crop) { + return ::assess_sharpness(as_mat(crop)); + }, "crop"_a, + "All four AR-029 sharpness candidates for a 112x112 aligned crop " + "(quality.hpp). Higher is sharper for every measure; scales are not " + "comparable between measures. Scored over a fixed 64x64 window on the " + "face interior, so background bokeh and hairstyle do not enter."); + + m.def("sharpness_window", [] { + const cv::Rect w = ::sharpness_window(); + return std::vector{w.x, w.y, w.width, w.height}; + }, + "The (x, y, w, h) canonical-pixel window every sharpness measure is " + "taken over, so a study can show the pixels a score came from."); + + m.def("alignment_residual", [](nb::ndarray, + nb::c_contig, nb::device::cpu> landmarks) + -> std::optional { + const Alignment a = ::estimate_alignment(as_landmarks(landmarks)); + if (!a.ok) return std::nullopt; // degenerate landmarks + return a.residual; + }, "landmarks"_a, + "The AR-030 visibility measure: RMS landmark error in canonical " + "112x112 px left over after the best similarity fit onto the ArcFace " + "template (face_utils.hpp). None when the landmarks are degenerate. " + "Exposed so VR-012 can check whether blur leaks into the pose axis — " + "if it does, discounting on both would double-count one cause."); + // ── Calibration ────────────────────────────────────────────────────────── // AR-024: the pipeline reasons in one probability space. Exposed so Python // scores through the same sigmoid the C++ matcher uses, rather than a numpy diff --git a/src/quality.hpp b/src/quality.hpp new file mode 100644 index 0000000..3b9fb32 --- /dev/null +++ b/src/quality.hpp @@ -0,0 +1,256 @@ +#pragma once +/// TRACES: AR-028, AR-029 | SR-002 +/// +/// Sharpness of the aligned crop — candidate measures for AR-029. +/// +/// Motion blur and soft focus destroy the high-frequency detail the embedder +/// keys on, and unlike face size they leave the bounding box looking perfectly +/// healthy. An embedder handed such a face does not fail: it returns a +/// confident, plausible, wrong vector that then competes on equal terms with +/// every good one in the gallery. +/// +/// **Why four measures and not one.** AR-029's threshold has to be *located*, +/// the way VR-005 located the size floor, not chosen. Locating it means letting +/// a study rank candidates by how well each predicts real identity loss, so all +/// four ship and VR-012 picks the winner. Until that study reports, none of +/// these is "the" sharpness measure. +/// +/// **They are computed on the 112×112 aligned crop**, never the raw box. The +/// crop is geometrically scale-normalised, so a measure taken there cannot +/// re-express face size the way a raw-pixel one would. +/// +/// That normalisation is geometric, not informational, and the distinction +/// matters: a 40 px face upscaled into the canonical frame genuinely carries +/// less high-frequency detail than a 400 px one downscaled into it, so every +/// measure here *does* respond to source face size. It reads **effective +/// resolution in canonical space**, which is the union of "was small" and "was +/// blurred", not blur alone. Whether that makes a sharpness discount a +/// double-count against AR-002's size gate is VR-012's joint size×sigma grid to +/// settle: if identity loss is a function of the measure alone, one axis +/// suffices; if a small-but-sharp and a large-but-blurred face at equal measure +/// lose different amounts, the axes are genuinely separate. The unit test +/// `sharpness falls under downscale-upscale as well as under blur` pins this as +/// known behaviour rather than leaving it to be discovered as a surprise. + +#include +#include + +#include + +// ── The measurement window ──────────────────────────────────────────────────── +// All four measures see the same pixels, so a comparison between them is about +// the operator and not about the window each happened to pick. +// +// A 64×64 region centred on the face interior, not the whole crop. Under the +// ArcFace template the landmarks span x ∈ [38.3, 73.5], y ∈ [51.5, 92.4]; this +// window covers that plus the surrounding cheeks, brow and chin while excluding +// the corners. +// +// The corners are excluded because they are where the background lives, and +// studio headshots — the gallery's entire population — are very often shot at a +// wide aperture with a deliberately blurred background. Measured over the full +// crop, that bokeh drags the score down on exactly the sharpest, most +// cooperative images in the set, which would put the measure's response +// backwards on the population used to calibrate it. Hair is excluded for the +// weaker version of the same reason: its high-frequency content varies with +// hairstyle rather than with capture quality. +// +// 64 is also a power of two, so the DFT below gets its natural size. +inline constexpr int kSharpWindow = 64; +inline constexpr int kSharpWindowX = 24; // (24,36) … (88,100) in canonical px +inline constexpr int kSharpWindowY = 36; + +/// Every candidate, computed in one pass over the window. +/// +/// Higher is sharper for all four, so a discount curve has the same orientation +/// whichever one VR-012 selects. Scales are *not* comparable between measures — +/// only within one. +struct SharpnessScores { + /// Variance of the Laplacian. The textbook measure, included as the + /// baseline every other candidate has to beat. Second derivatives amplify + /// sensor noise, and the value scales with image contrast, so a + /// low-contrast sharp face reads as blurred. Expected to lose; it should + /// lose on the record rather than by assertion. + float var_laplacian{0.f}; + + /// Variance of the Laplacian over the variance of the intensity. Divides + /// out the first-order contrast dependence that var_laplacian carries, + /// which is the single confound most likely to matter on a gallery drawn + /// from thousands of different cameras, lighting setups and JPEG pipelines. + float norm_var_laplacian{0.f}; + + /// Tenengrad: mean squared Sobel gradient magnitude. A first derivative, so + /// markedly less noise-amplifying than the Laplacian, at the cost of + /// responding to coarser structure. Still contrast-dependent. + float tenengrad{0.f}; + + /// Fraction of spectral energy above a quarter of Nyquist, DC excluded. + /// A ratio, so contrast divides out by construction rather than by an + /// explicit correction, and it is the most direct statement of "how much + /// fine detail is actually present". Bounded in [0,1], which makes it the + /// easiest of the four to turn into a discount. + float hf_energy_ratio{0.f}; + + /// The worse of the two Sobel axes, normalised by a low-frequency contrast + /// estimate. The only candidate here that satisfies both requirements at + /// once, and it exists because the other four do not. + /// + /// Two independent fixes, each answering a measured failure of the four + /// above (numbers from the T1 ladders in tests/test_quality.cpp): + /// + /// - **Normalise by low frequencies, not by total energy.** Dividing by + /// the whole intensity variance puts the detail being measured into the + /// denominator as well as the numerator, so a blur shrinks both and the + /// quotient barely moves. A Gaussian at sigma 4 canonical px keeps + /// illumination and coarse facial structure and discards detail, giving + /// a contrast estimate that blur leaves alone. + /// - **Take the minimum over direction, not the sum.** Motion blur is + /// directional: a horizontal smear destroys horizontal detail and + /// leaves vertical detail untouched. Summing the two axes (as + /// Tenengrad does) lets the surviving axis mask the destroyed one — the + /// reason both ratio measures are U-shaped in blur length, scoring a + /// 21 px smear about as sharp as a 3 px one. The minimum tracks the + /// axis that was ruined, which is the one the embedder suffers from. + /// + /// Falls 510 → 12 monotonically across that same motion-blur ladder, stays + /// monotone under Gaussian blur and resampling, and moves 0.4% when + /// contrast is halved. + float dir_min_tenengrad{0.f}; + + /// False when the crop was the wrong size or degenerate (flat). Scored, + /// never silently dropped: a face whose sharpness cannot be computed is a + /// fact the dump should record, not an absence. + bool ok{false}; +}; + +/// The window every measure is taken over. Exposed so a study can show the +/// pixels a score was computed from rather than trusting the constants. +inline cv::Rect sharpness_window() { + return {kSharpWindowX, kSharpWindowY, kSharpWindow, kSharpWindow}; +} + +namespace detail { + +/// Fraction of spectral energy above `cutoff` × Nyquist, DC bin excluded. +/// +/// A Hann window is applied first. Without it the DFT sees the region's edges +/// as a step discontinuity, and that step is broadband: it deposits energy at +/// every frequency including the high band being measured, so a uniformly +/// blurry crop still scores a substantial high-frequency fraction and the +/// measure's dynamic range collapses. +/// +/// **The mean is removed before the window, not after.** Windowing a signal +/// that still carries its DC offset multiplies that constant by the Hann taper, +/// and the taper's own spectrum is not a single bin — the offset smears across +/// the low-frequency neighbourhood, where dropping bin (0,0) no longer removes +/// it. The leaked energy lands in the denominator without scaling with image +/// contrast, so the "ratio" silently becomes a function of absolute brightness: +/// on the synthetic crop, a 20/255 brightening moved it 23% and halving the +/// contrast moved it by a factor of 3.6. Subtracting the mean first restores +/// the invariance the ratio form is supposed to provide for free. +inline float hf_ratio(const cv::Mat& gray32, float cutoff = 0.25f) { + static const cv::Mat hann = [] { + cv::Mat w(kSharpWindow, kSharpWindow, CV_32F); + for (int y = 0; y < kSharpWindow; ++y) { + const float wy = 0.5f * (1.f - std::cos(2.f * float(CV_PI) * y / (kSharpWindow - 1))); + for (int x = 0; x < kSharpWindow; ++x) { + const float wx = 0.5f * (1.f - std::cos(2.f * float(CV_PI) * x / (kSharpWindow - 1))); + w.at(y, x) = wx * wy; + } + } + return w; + }(); + + cv::Mat centred; + cv::subtract(gray32, cv::mean(gray32), centred); + + cv::Mat windowed; + cv::multiply(centred, hann, windowed); + + cv::Mat spectrum; + cv::dft(windowed, spectrum, cv::DFT_COMPLEX_OUTPUT); + + // Quadrants are wrapped: frequency index n maps to the signed frequency + // n - N for n > N/2, so the radius has to be computed on the wrapped index. + const int N = kSharpWindow; + const float nyquist = N / 2.f; + const float r_cut = cutoff * nyquist; + + double total = 0.0, high = 0.0; + for (int y = 0; y < N; ++y) { + const float fy = (y <= N / 2) ? float(y) : float(y - N); + for (int x = 0; x < N; ++x) { + if (x == 0 && y == 0) continue; // DC carries no detail + const float fx = (x <= N / 2) ? float(x) : float(x - N); + const auto& c = spectrum.at(y, x); + const double e = double(c[0]) * c[0] + double(c[1]) * c[1]; + total += e; + if (std::sqrt(fx * fx + fy * fy) > r_cut) high += e; + } + } + if (total < 1e-12) return 0.f; // flat region + return static_cast(high / total); +} + +} // namespace detail + +/// Score a 112×112 aligned BGR (or single-channel) crop on all four candidates. +/// +/// Costs one colour conversion and three small convolutions over a 64×64 window +/// — negligible beside the embedder inference it guards. +inline SharpnessScores assess_sharpness(const cv::Mat& crop) { + SharpnessScores s; + const cv::Rect win = sharpness_window(); + if (crop.empty() || + win.x + win.width > crop.cols || win.y + win.height > crop.rows) + return s; + + cv::Mat gray; + if (crop.channels() == 3) cv::cvtColor(crop(win), gray, cv::COLOR_BGR2GRAY); + else gray = crop(win).clone(); + + // Scale into [0,1] so a score does not depend on the 8-bit convention, and + // so the two contrast-normalised measures are comparable across builds. + cv::Mat g32; + gray.convertTo(g32, CV_32F, 1.0 / 255.0); + + cv::Scalar mu, sigma; + cv::meanStdDev(g32, mu, sigma); + const double var_img = sigma[0] * sigma[0]; + + cv::Mat lap; + cv::Laplacian(g32, lap, CV_32F, 3); + cv::Scalar lmu, lsigma; + cv::meanStdDev(lap, lmu, lsigma); + const double var_lap = lsigma[0] * lsigma[0]; + + cv::Mat gx, gy; + cv::Sobel(g32, gx, CV_32F, 1, 0, 3); + cv::Sobel(g32, gy, CV_32F, 0, 1, 3); + cv::Mat gx2, gy2; + cv::multiply(gx, gx, gx2); + cv::multiply(gy, gy, gy2); + cv::Mat mag2 = gx2 + gy2; + + // Contrast from low frequencies only — see dir_min_tenengrad. Blur leaves + // this denominator alone, which is exactly what the other two normalised + // measures lack. + cv::Mat lf; + cv::GaussianBlur(g32, lf, cv::Size(0, 0), 4.0); + cv::Scalar lfmu, lfsigma; + cv::meanStdDev(lf, lfmu, lfsigma); + const double var_lf = lfsigma[0] * lfsigma[0]; + + s.var_laplacian = static_cast(var_lap); + // A flat window has no contrast to normalise by. Reporting 0 (rather than a + // huge quotient) keeps "less sharp" pointing the same way for a degenerate + // input as for a blurred one. + s.norm_var_laplacian = var_img > 1e-9 ? static_cast(var_lap / var_img) : 0.f; + s.tenengrad = static_cast(cv::mean(mag2)[0]); + s.hf_energy_ratio = detail::hf_ratio(g32); + s.dir_min_tenengrad = var_lf > 1e-9 + ? static_cast(std::min(cv::mean(gx2)[0], cv::mean(gy2)[0]) / var_lf) + : 0.f; + s.ok = var_img > 1e-9; + return s; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8615f1c..42135e2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -19,6 +19,7 @@ add_executable(sae_tests test_calibration.cpp test_gallery_store.cpp test_face_utils.cpp + test_quality.cpp test_track_gallery.cpp test_face_tracker.cpp test_track_registry.cpp diff --git a/tests/test_quality.cpp b/tests/test_quality.cpp new file mode 100644 index 0000000..4993cf7 --- /dev/null +++ b/tests/test_quality.cpp @@ -0,0 +1,302 @@ +// TRACES: AR-029 | SR-002 +// +// T1 for the AR-029 sharpness candidates: the properties that have to hold +// before a study is allowed to pick between them. GPU-free, model-free. +// +// The register's acceptance criterion is "synthetic blur ladder -> +// monotonically falling sharpness; Gaussian vs motion blur; small sharp face vs +// large soft one — size must not leak into this axis". The last clause needs +// care, and the tests below split it in two: +// +// - What must NOT leak is *geometric* scale. The measure is taken in the +// canonical frame, so changing how big the face was in the source while +// preserving its detail must not move the score. That is structural: the +// window is fixed at 64x64 canonical px. +// - What DOES legitimately move the score is lost *detail*. A face that was +// 40 px before being warped up to 112 really does carry less +// high-frequency content than one that was 400 px, and a measure blind to +// that would be blind to the thing it exists to catch. +// +// So "size must not leak" cannot mean "invariant to the source face size", and +// the ladder test below asserts the opposite on purpose. What it buys is that +// the overlap with AR-002 is a recorded property with a test naming it, rather +// than a surprise VR-012 discovers when the two axes turn out to be correlated. +#include +#include + +#include "quality.hpp" +#include "types.hpp" // kArcFaceRef, for the window-placement test + +#include +#include +#include +#include + +using Catch::Matchers::WithinAbs; +using Catch::Matchers::WithinRel; + +namespace { + +// A deterministic 112x112 stand-in for a face crop. +// +// **Broadband, not a sum of a few sinusoids.** An earlier version of this +// fixture used three discrete spatial frequencies, and the resampling ladder +// below was non-monotone for hf_energy_ratio because of it: a period-7 +// component downsampled to 32 px lands exactly at Nyquist and aliases, so the +// ratio rose at one rung instead of falling. That is a property of a +// three-tone test pattern meeting a resampler, not of the measure or of any +// face — a real crop has energy spread across the band, where such a +// resonance averages out. Deterministic value noise, smoothed to give the +// roughly 1/f falloff of a photograph, exercises the whole band at once. +// +// Mid-grey base with bounded amplitude, so scaling the contrast in the tests +// below does not clip. +cv::Mat synthetic_crop() { + // Fixed LCG rather than cv::randu: the suite must not depend on OpenCV's + // RNG state, which other tests share. + uint32_t seed = 0x5eed1234u; + auto next = [&seed] { + seed = seed * 1664525u + 1013904223u; + return (seed >> 16) & 0xffffu; + }; + + cv::Mat noise(112, 112, CV_32F); + for (int y = 0; y < 112; ++y) + for (int x = 0; x < 112; ++x) + noise.at(y, x) = float(next()) / 65535.f - 0.5f; + + // Mild smoothing: white noise is flat to Nyquist, which no lens produces + // and which would make the sharpest rung of every ladder unrealistic. + cv::Mat smooth; + cv::GaussianBlur(noise, smooth, cv::Size(0, 0), 0.8); + cv::normalize(smooth, smooth, -1.0, 1.0, cv::NORM_MINMAX); + + cv::Mat img(112, 112, CV_8UC3); + for (int y = 0; y < 112; ++y) { + for (int x = 0; x < 112; ++x) { + double v = 128.0 + 70.0 * smooth.at(y, x); + const auto b = static_cast(std::clamp(v, 0.0, 255.0)); + img.at(y, x) = {b, b, b}; + } + } + return img; +} + +cv::Mat gaussian(const cv::Mat& src, double sigma) { + cv::Mat out; + cv::GaussianBlur(src, out, cv::Size(0, 0), sigma, sigma); + return out; +} + +// Horizontal box blur — the camera-pan case, and the one an isotropic measure +// could in principle miss. +cv::Mat motion(const cv::Mat& src, int len) { + cv::Mat kernel = cv::Mat::zeros(1, len, CV_32F); + kernel.setTo(1.0f / len); + cv::Mat out; + cv::filter2D(src, out, -1, kernel); + return out; +} + +// Throw away detail a face detected at size x size never had, then warp back up +// to the 112x112 the embedder is fed — the VR-005 degradation. +cv::Mat rescale(const cv::Mat& src, int size) { + if (size == 112) return src.clone(); + cv::Mat small, out; + cv::resize(src, small, {size, size}, 0, 0, cv::INTER_AREA); + cv::resize(small, out, {112, 112}, 0, 0, cv::INTER_LINEAR); + return out; +} + +std::vector field(const std::vector& s, + float SharpnessScores::* m) { + std::vector v; + v.reserve(s.size()); + for (const auto& x : s) v.push_back(x.*m); + return v; +} + +void check_strictly_falling(const std::vector& v, const char* what) { + INFO(what); + for (size_t i = 1; i < v.size(); ++i) { + INFO("step " << i << ": " << v[i - 1] << " -> " << v[i]); + CHECK(v[i] < v[i - 1]); + } +} + +const std::vector> kMeasures{ + {"var_laplacian", &SharpnessScores::var_laplacian}, + {"norm_var_laplacian", &SharpnessScores::norm_var_laplacian}, + {"tenengrad", &SharpnessScores::tenengrad}, + {"hf_energy_ratio", &SharpnessScores::hf_energy_ratio}, + {"dir_min_tenengrad", &SharpnessScores::dir_min_tenengrad}, +}; + +} // namespace + +TEST_CASE("every candidate falls monotonically along a Gaussian blur ladder", + "[quality][AR-029]") { + const cv::Mat base = synthetic_crop(); + std::vector ladder; + for (double sigma : {0.0, 0.5, 1.0, 1.5, 2.0, 3.0}) + ladder.push_back(assess_sharpness(sigma == 0.0 ? base : gaussian(base, sigma))); + + for (const auto& [name, m] : kMeasures) { + REQUIRE(ladder.front().ok); + check_strictly_falling(field(ladder, m), name); + } +} + +TEST_CASE("only the absolute and directional measures survive motion blur", + "[quality][AR-029]") { + // Motion blur is the commonest way a film frame is unusable, and it is + // where the candidates separate. A horizontal smear destroys horizontal + // detail and leaves vertical detail untouched, so what a measure does here + // depends on whether it can be fooled by the surviving axis. + const cv::Mat base = synthetic_crop(); + std::vector ladder{assess_sharpness(base)}; + for (int len : {3, 5, 9, 15, 21}) + ladder.push_back(assess_sharpness(motion(base, len))); + + // Total gradient/Laplacian energy keeps falling: nothing replaces what the + // smear removed. + check_strictly_falling(field(ladder, &SharpnessScores::var_laplacian), + "var_laplacian"); + check_strictly_falling(field(ladder, &SharpnessScores::tenengrad), + "tenengrad"); + // The fix for the two below: low-frequency denominator, and the worse of + // the two axes rather than their sum. + check_strictly_falling(field(ladder, &SharpnessScores::dir_min_tenengrad), + "dir_min_tenengrad"); + + // The disqualifying behaviour, pinned rather than hidden. Both measures + // normalise by a quantity that contains the detail they are measuring, so + // once the horizontal band is gone the quotient climbs back toward its + // unblurred value: each is U-shaped in blur length, and a single score + // maps to two very different amounts of blur. A 21 px smear scores about + // as sharp as a 3 px one. + for (const auto& [name, m] : { + std::pair{"norm_var_laplacian", &SharpnessScores::norm_var_laplacian}, + std::pair{"hf_energy_ratio", &SharpnessScores::hf_energy_ratio}}) { + const std::vector v = field(ladder, m); + INFO(name); + const auto trough = std::min_element(v.begin(), v.end()); + CHECK(trough != v.begin()); // it does fall at first … + CHECK(trough != v.end() - 1); // … then turns back up + CHECK(v.back() > 0.8f * v[1]); // recovering most of one rung + } +} + +TEST_CASE("sharpness falls under downscale-upscale as well as under blur", + "[quality][AR-029]") { + // The overlap with AR-002, asserted rather than assumed. Losing resolution + // and losing focus are the same loss of high-frequency content, so every + // candidate reads a small upscaled face as less sharp. VR-012's joint + // size x sigma grid decides whether that makes a sharpness discount a + // double-count against the size gate, or whether the two axes carry + // separable information. + const cv::Mat base = synthetic_crop(); + std::vector ladder; + for (int size : {112, 64, 48, 32, 24, 16}) + ladder.push_back(assess_sharpness(rescale(base, size))); + + for (const auto& [name, m] : kMeasures) + check_strictly_falling(field(ladder, m), name); +} + +TEST_CASE("the ratio measures are contrast-free and the raw ones are not", + "[quality][AR-029]") { + // The confound that decides the bake-off. A gallery drawn from thousands of + // cameras, lighting setups and JPEG pipelines varies enormously in + // contrast, and a measure that reads a low-contrast sharp face as blurred + // would discount it for the photographer's choices rather than for anything + // the embedder cares about. + const cv::Mat base = synthetic_crop(); + + // Halve the contrast about mid-grey, leaving spatial structure untouched. + cv::Mat low; + base.convertTo(low, CV_8UC3, 0.5, 64.0); + + const auto s_hi = assess_sharpness(base); + const auto s_lo = assess_sharpness(low); + REQUIRE(s_hi.ok); + REQUIRE(s_lo.ok); + + // Invariant by construction: both are ratios in which the contrast factor + // cancels. + CHECK_THAT(s_lo.norm_var_laplacian, + WithinRel(s_hi.norm_var_laplacian, 0.02f)); + CHECK_THAT(s_lo.hf_energy_ratio, WithinRel(s_hi.hf_energy_ratio, 0.02f)); + + // Not invariant: both scale with the square of the contrast factor, so + // halving the contrast quarters them. This is the disqualifying behaviour, + // pinned so that a change making them contrast-free is a deliberate one. + CHECK_THAT(s_lo.var_laplacian, WithinRel(0.25f * s_hi.var_laplacian, 0.05f)); + CHECK_THAT(s_lo.tenengrad, WithinRel(0.25f * s_hi.tenengrad, 0.05f)); +} + +TEST_CASE("brightness alone moves nothing", "[quality][AR-029]") { + const cv::Mat base = synthetic_crop(); + cv::Mat bright; + base.convertTo(bright, CV_8UC3, 1.0, 20.0); + + const auto a = assess_sharpness(base); + const auto b = assess_sharpness(bright); + for (const auto& [name, m] : kMeasures) { + INFO(name); + CHECK_THAT(b.*m, WithinRel(a.*m, 0.02f)); + } +} + +TEST_CASE("a flat crop is scored not-ok rather than given a number", + "[quality][AR-029]") { + // A face whose sharpness cannot be computed is a fact to record, not an + // absence — the same rule AR-030 follows for degenerate landmarks. + const cv::Mat flat(112, 112, CV_8UC3, cv::Scalar(128, 128, 128)); + const auto s = assess_sharpness(flat); + CHECK_FALSE(s.ok); + for (const auto& [name, m] : kMeasures) { + INFO(name); + CHECK_THAT(s.*m, WithinAbs(0.0f, 1e-6f)); + CHECK_FALSE(std::isnan(s.*m)); + } +} + +TEST_CASE("a crop smaller than the measurement window is scored not-ok", + "[quality][AR-029]") { + const cv::Mat small(64, 64, CV_8UC3, cv::Scalar(40, 90, 160)); + CHECK_FALSE(assess_sharpness(small).ok); + CHECK_FALSE(assess_sharpness(cv::Mat()).ok); +} + +TEST_CASE("the measurement window covers the face interior of the crop", + "[quality][AR-029]") { + // The landmarks the ArcFace template pins must all fall inside the window, + // or the measure is scoring background and hair rather than the face. + const cv::Rect w = sharpness_window(); + CHECK(w.x >= 0); + CHECK(w.y >= 0); + CHECK(w.x + w.width <= 112); + CHECK(w.y + w.height <= 112); + for (int i = 0; i < 5; ++i) { + INFO("landmark " << i); + CHECK(w.contains(cv::Point(static_cast(kArcFaceRef[i][0]), + static_cast(kArcFaceRef[i][1])))); + } +} + +TEST_CASE("a single-channel crop scores the same as its BGR equivalent", + "[quality][AR-029]") { + // The dump replays crops; nothing should depend on whether they arrived as + // three identical channels or one. + const cv::Mat base = synthetic_crop(); + cv::Mat gray; + cv::cvtColor(base, gray, cv::COLOR_BGR2GRAY); + + const auto a = assess_sharpness(base); + const auto b = assess_sharpness(gray); + for (const auto& [name, m] : kMeasures) { + INFO(name); + CHECK_THAT(b.*m, WithinRel(a.*m, 1e-3f)); + } +}