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)); + } +}