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