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
This commit is contained in:
2026-08-05 14:37:30 +02:00
parent c1155cb607
commit 777c98cb33
10 changed files with 726 additions and 17 deletions
+91 -1
View File
@@ -1,5 +1,5 @@
#pragma once
/// TRACES: AR-005, AR-030 | SR-002
/// TRACES: AR-005, AR-029, AR-030 | SR-002
#include "types.hpp"
#include <opencv2/core.hpp>
@@ -143,6 +143,96 @@ inline cv::Mat align_face(const cv::Mat& img,
return crop;
}
// ── crop_sharpness ────────────────────────────────────────────────────────────
/// TRACES: AR-029 | SR-002
//
// Normalised variance of the Laplacian over the aligned 112×112 crop: the AR-029
// sharpness axis. Returns -1 for an empty crop (unscored), matching the
// DetectedFace sentinel.
//
// sharpness = Var(∇²I) / Var(I)
//
// Two normalisations, each removing a quantity that would otherwise be read as
// blur:
//
// - **Divided by the image variance, so contrast cannot leak in.** Scaling
// intensity by α scales the Laplacian by α too, so both variances scale by α²
// and the ratio is unchanged. A raw Var(∇²I) — the textbook measure — instead
// falls with exposure, so a dim scene reads as soft and a graded-up one as
// sharp. VR-012 has to locate one knee across films whose grading differs by
// more than their focus does; an uncalibrated measure would put the knee in a
// different place per film, which is the AR-024 failure in another metric.
// - **Measured on the aligned crop, so size cannot leak in.** The destination
// frame is fixed at 112×112 (AR-002 owns size, and double-counting it here
// would make every small face read as blurred). What the ratio reports is the
// detail actually present in the embedder's input — so a small sharp face can
// and does outscore a large soft one. That is the claim; it is *not* a claim
// of invariance to source resolution, because a 40 px face warped up to 112
// genuinely carries less detail, and hiding that would defeat the point.
//
// Frequency-domain reading of why the blur ladder is monotone: with
// Var(∇²I) = ∫|ω|⁴|F(ω)|² and Var(I) = ∫|F(ω)|², the ratio is E[|ω|⁴] under the
// image's own spectral measure. Gaussian blur multiplies that measure by
// e^{-σ²|ω|²}, concentrating it at low |ω|, so the expectation falls strictly
// with σ. It is a property of the construction, not a fitted behaviour.
//
// **Three known hazards, for VR-012 to check rather than for a threshold to
// absorb.** All are recorded here because they are properties of the measure,
// visible in the dumped distribution, and neither should be papered over by a
// correction chosen before that distribution has been looked at.
//
// 1. **Border fill.** `align_face` warps with BORDER_CONSTANT, so a face
// crossing the frame edge brings a hard black step into the crop, and a
// step edge is high-frequency. The normalisation blunts it — the fill
// inflates Var(I) as well as Var(∇²I) — but does not remove it, so
// heavily-cropped faces may read sharper than they are. The fix is either a
// validity mask or a different border mode, and the second changes what the
// embedder is fed (AR-011).
//
// 2. **The contrast invariance is exact in the algebra and approximate in
// 8 bits.** Scaling I by α cancels exactly; what does not cancel is the
// quantisation floor of a stored crop, which is broadband and so lands in
// the numerator. It matters only where there is little signal left to
// compete with it: on the AR-029 test texture a half-contrast copy reads
// 0.9% high when sharp, 24% high at sigma 1.2 and 148% high at sigma 2.5.
// A crop that is both **dim and soft therefore reads sharper than it is** —
// the low corner of the axis, and the corner VR-012 must put a knee in.
//
// 3. **It reports where the energy sits, not how much there is.** A crop whose
// energy is *already* concentrated at high frequency — dense film grain,
// a face against foliage — loses numerator and denominator together under
// blur, so the ratio moves less than the damage does. Measured on a
// flat-spectrum synthetic, an anisotropic (motion) smear even makes it rise,
// because the surviving perpendicular detail really is as fine as before.
// Natural crops have the low-frequency mass that keeps the denominator
// steady, and on those both ladders fall (see the AR-029 tests, which use a
// 1/f texture for exactly this reason). The same property means the axis
// conflates focus with intrinsic texture — a bearded face outscores a smooth
// one at equal focus — which is true of every no-reference sharpness measure
// and is why AR-028 carries the number instead of thresholding on it.
inline float crop_sharpness(const cv::Mat& crop) {
if (crop.empty()) return -1.f;
cv::Mat gray;
if (crop.channels() == 3) cv::cvtColor(crop, gray, cv::COLOR_BGR2GRAY);
else gray = crop;
cv::Mat lap;
cv::Laplacian(gray, lap, CV_32F, 3);
cv::Scalar mean_i, sd_i, mean_l, sd_l;
cv::meanStdDev(gray, mean_i, sd_i);
cv::meanStdDev(lap, mean_l, sd_l);
const double var_i = sd_i[0] * sd_i[0];
// A flat crop has no detail to be sharp or soft about, and the ratio is 0/0.
// Zero is the honest answer and keeps the axis finite; -1 would claim the
// face was never scored, which is a different fact.
if (var_i < 1e-6) return 0.f;
return static_cast<float>((sd_l[0] * sd_l[0]) / var_i);
}
// ── enhance_for_retry ────────────────────────────────────────────────────────
// Used when initial face detection finds nothing. Pads the image by 50%
// (border-replicated, so the detector doesn't see a hard edge) and applies
+19
View File
@@ -28,6 +28,7 @@
#include <nanobind/stl/map.h>
#include <memory>
#include <optional>
#include <variant>
namespace nb = nanobind;
@@ -69,6 +70,22 @@ template<> struct PythonConverter<EmbeddedSceneFrame> {
auto conf = nb::cast<nb::ndarray<float, nb::shape<-1>, nb::c_contig>>(d["confidence"]);
auto emb = nb::cast<nb::ndarray<float, nb::shape<-1, 512>, nb::c_contig>>(d["embeddings"]);
// AR-028 quality vector. Optional because a v1 dump predates it — absent
// leaves the DetectedFace sentinels at -1, which reads as *unscored*, not
// as a bad face. There is no live aligner on this path to recompute it:
// the replay starts at the embedded-frame channel, so what the dump does
// not carry is genuinely gone.
//
// Held in named locals, like the four above, because the ndarray owns the
// reference that keeps the buffer alive — reading .data() off a temporary
// would leave the pointer dangling at the end of the statement.
using FloatCol = nb::ndarray<float, nb::shape<-1>, nb::c_contig>;
std::optional<FloatCol> sharp_col, resid_col;
if (d.contains("sharpness")) sharp_col = nb::cast<FloatCol>(d["sharpness"]);
if (d.contains("alignment_residual")) resid_col = nb::cast<FloatCol>(d["alignment_residual"]);
const float* sp = sharp_col ? sharp_col->data() : nullptr;
const float* rp = resid_col ? resid_col->data() : nullptr;
const size_t n = bbox.shape(0);
ef.faces.reserve(n);
ef.embeddings.reserve(n);
@@ -82,6 +99,8 @@ template<> struct PythonConverter<EmbeddedSceneFrame> {
for (int k = 0; k < 5; ++k)
f.landmarks[k] = cv::Point2f(lp[i*10 + k*2], lp[i*10 + k*2 + 1]);
f.confidence = cp[i];
if (sp) f.sharpness = sp[i];
if (rp) f.alignment_residual = rp[i];
ef.faces.push_back(f);
Embedding e;
+26 -3
View File
@@ -1,5 +1,5 @@
#pragma once
/// TRACES: VR-001, VR-010 | PR-002
/// TRACES: AR-028 | VR-001, VR-010 | PR-002
#include "types.hpp"
#include "config.hpp"
#include "gallery/embedder_stamp.hpp"
@@ -178,6 +178,16 @@ struct EmbeddingDumpFunc {
lmk_.push_back(f.landmarks[k].y);
}
conf_.push_back(f.confidence);
/// TRACES: AR-028 | SR-002
// The quality vector, carried rather than consumed: written beside
// the embedding it describes so VR-012 can locate its knees against
// recorded data instead of by re-running video. Size is the third
// axis and is already here as bbox + the bbox_upscale attribute.
// Both are -1 only if a face reached the dump unscored, which the
// aligner does not allow — the sentinel is preserved rather than
// clamped so that a future path which did would be visible.
sharp_.push_back(f.sharpness);
resid_.push_back(f.alignment_residual);
const auto& e = ef.embeddings[i];
emb_.insert(emb_.end(), e.begin(), e.end());
}
@@ -194,11 +204,20 @@ struct EmbeddingDumpFunc {
}
private:
// Root attributes are additive: schema_version stays 1 across VR-010, because
// Root attributes are additive: schema_version stayed 1 across VR-010, because
// every reader takes attributes by name with a default (replay.py) or an
// existence check (read_dump_provenance), so an old dump loses nothing and a
// new dump breaks nothing. A bump is for a change to the *datasets*.
static constexpr int kSchemaVersion = 1;
//
// v2 is that change: AR-028 adds faces/sharpness and faces/alignment_residual.
// The bump is not about readers — those check for the datasets by name, and a
// v1 dump still replays. It is so a *consumer of the quality vector* can tell
// "this film's faces were never scored" from "this film's faces scored zero",
// which is the same distinction scene_detect exists to make and is likewise
// not recoverable from the arrays. A v1 dump reports the vector as unknown;
// re-dump to acquire it, since nobody can assert after the fact how sharp a
// face was.
static constexpr int kSchemaVersion = 2;
static constexpr int kEmbedDim = 512;
static std::string basename_of(const std::string& path) {
@@ -274,6 +293,9 @@ private:
write_vec(faces, "bbox", bbox_, H5::PredType::NATIVE_FLOAT, 4);
write_vec(faces, "landmarks", lmk_, H5::PredType::NATIVE_FLOAT, 10);
write_vec(faces, "confidence", conf_, H5::PredType::NATIVE_FLOAT);
/// TRACES: AR-028 | SR-002
write_vec(faces, "sharpness", sharp_, H5::PredType::NATIVE_FLOAT);
write_vec(faces, "alignment_residual", resid_, H5::PredType::NATIVE_FLOAT);
std::cerr << "[embedding_dump] wrote " << ts_.size() << " frames, "
<< conf_.size() << " faces → " << path_ << "\n";
@@ -292,4 +314,5 @@ private:
std::vector<int64_t> face_off_;
std::vector<int32_t> face_cnt_;
std::vector<float> emb_, bbox_, lmk_, conf_;
std::vector<float> sharp_, resid_; // AR-028 quality vector, parallel to conf_
};
+59 -6
View File
@@ -1,25 +1,55 @@
#pragma once
#include "face_utils.hpp"
#include <cstdint>
#include <iostream>
// ── FaceAlignerFunc ───────────────────────────────────────────────────────────
/// TRACES: AR-005, AR-030 | SR-002
/// TRACES: AR-005, AR-028, AR-029, AR-030 | SR-002
///
// KPN node: applies a 5-point similarity transform to each detected face,
// producing a 112×112 BGR crop suitable for ArcFace inference.
//
// Alignment is an Umeyama least-squares fit over all five landmarks (AR-005),
// not a robust one: a RANSAC fit discards the very landmarks AR-030 reads.
// Degenerate detections (where the fit fails) are dropped from the output
// vectors. The fit's residual is the AR-030 visibility measure and comes free,
// since the warp needs the transform anyway.
//
// This is also where the AR-028 quality vector is filled in, because this is
// where the inputs to it already exist:
//
// - **Visibility** (AR-030) is the fit's residual, and is genuinely free — the
// transform is computed for the warp regardless, and the residual is what
// that fit could not explain.
// - **Sharpness** (AR-029) is measured on the crop this node just produced,
// which is the only place it *can* be measured: the aligned canvas is what
// makes the number scale-normalised, and downstream of the embedder the crop
// is only forwarded for debug rendering. It is not free — 33 us per face
// single-threaded (cvtColor, one Laplacian, two meanStdDev over 112x112) —
// but it is two orders below the embedder inference it qualifies, and it
// runs per face rather than per frame, so a landscape shot costs nothing.
//
// Size, the third axis, is `bbox` and needs no work here.
//
// No face is admitted unscored: every face in the output carries both numbers,
// so a negative value downstream is a bug rather than a poor-quality face.
// Nothing is dropped or discounted on quality — that is AR-030's discount and
// VR-012's knee, both still open.
//
// Degenerate detections (where the fit fails) cannot be scored, since there is
// no crop and no residual to score, and are therefore dropped — but they are
// **counted**, not silently discarded. A nonzero tally means the detector is
// emitting landmark sets the aligner cannot use, which is a fact about the
// detector; losing it leaves a hole in the dump that looks like footage with
// no faces in it.
struct FaceAlignerFunc {
static constexpr std::string_view label() { return "face_aligner"; }
AlignedSceneFrame operator()(SceneFrame sf) {
if (sf.source.eof || sf.faces.empty())
if (sf.source.eof) {
report();
return {std::move(sf.source), {}, {}};
}
if (sf.faces.empty())
return {std::move(sf.source), {}, {}};
std::vector<DetectedFace> good_faces;
@@ -33,15 +63,38 @@ struct FaceAlignerFunc {
float residual = -1.f;
cv::Mat crop = align_face(sf.source.image, face.landmarks, &residual);
if (crop.empty()) {
std::cerr << "[face_aligner] degenerate detection skipped\n";
++degenerate_;
continue;
}
face.alignment_residual = residual;
face.sharpness = crop_sharpness(crop);
good_faces.push_back(face);
crops.push_back(std::move(crop));
++scored_;
}
return {std::move(sf.source), std::move(good_faces), std::move(crops)};
}
/// Faces that carry a full quality vector, and faces the fit could not use.
uint64_t scored() const { return scored_; }
uint64_t degenerate() const { return degenerate_; }
private:
// Reported once at EOF rather than per occurrence: a run with a systematic
// landmark problem would otherwise emit one line per face for the length of
// a film, which is how the count came to be ignored.
void report() {
if (reported_) return;
reported_ = true;
if (degenerate_)
std::cerr << "[face_aligner] " << degenerate_ << " of "
<< (degenerate_ + scored_)
<< " detections had a degenerate landmark fit and were dropped"
" (no crop, so no embedding and no quality vector)\n";
}
uint64_t scored_{0};
uint64_t degenerate_{0};
bool reported_{false};
};
+25
View File
@@ -59,11 +59,36 @@ inline constexpr float kArcFaceRef[5][2] = {
// Landmark order matches ArcFace convention (same as SCRFD output order):
// [0] right-eye-centre [1] left-eye-centre [2] nose
// [3] right-mouth [4] left-mouth
/// TRACES: AR-028 | SR-002
struct DetectedFace {
cv::Rect2f bbox;
std::array<cv::Point2f, 5> landmarks;
float confidence{0.f};
// ── AR-028 quality vector ────────────────────────────────────────────────
// Three axes, kept separate and never collapsed into one scalar: they fail
// for different reasons, have different remedies, and do not earn the same
// response. Carried, not consumed — the vector travels with the face into
// the VR-001 dump so a threshold can be re-litigated against recorded data
// rather than by re-running video.
//
// **Size is the third axis and is deliberately not a field here.** It is
// `bbox`, which every consumer already has, scaled by the frame's
// `bbox_upscale` to reach the original resolution AR-002 thresholds in.
// Copying it into a second field would put the same quantity in two
// coordinate spaces inside one struct — the trap SCHEMA.md records for
// `bbox_upscale` — and the copy would be the one that drifts.
//
// Both fields below are -1 until the aligner runs, so *unscored* is
// distinguishable from *scored badly*. Nothing downstream may read a
// negative value as a quality.
// AR-029 sharpness: normalised Laplacian variance over the aligned crop,
// dimensionless. Falls with motion blur and soft focus; invariant to
// contrast, and taken on the fixed 112×112 canvas so it cannot re-measure
// face size. See crop_sharpness() for the construction and its one hazard.
float sharpness{-1.f};
// AR-030 visibility: RMS landmark misfit, in canonical 112×112 pixels, left
// over after the best similarity fit to the ArcFace template. Rises with
// out-of-plane pose and with occlusion; blind to in-plane roll and to face