Files
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

11 KiB

Embedding-dump HDF5 schema (v2)

One file per analysed title. Captures the pipeline state at the EmbeddedSceneFrame channel — i.e. after decode → detect → align → embed, but before tracking and identity matching. Everything downstream (face tracker, identity matcher, scene tracker/anneal) is cheap CPU math, so replaying from this file lets a parameter sweep re-run the whole downstream tail thousands of times with no GPU and no video.

Written by the C++ dump sink (--dump-embeddings out.h5); read by scripts/optimizer/replay.py.

Layout

The dump is flat/ragged: all faces across all frames are concatenated into per-face arrays, with a per-frame index table pointing into them. This avoids variable-length HDF5 types and reads straight into numpy.

/                                     (root)
  attrs:
    schema_version : int   = 2
    embed_dim      : int   = 512

    # ── what produced the vectors (GR-004) ──────────────────────────────────
    embedder_model : str   basename of the embedding model
    embedder_sha256: str   SHA-256 of that model file

    # ── what produced the faces (VR-010) ────────────────────────────────────
    detector_model : str   basename of the detector .onnx
    detector_conf  : float score floor a detection had to clear to be dumped
    detector_nms   : float NMS IoU threshold
    min_face_px    : float minimum box side, ORIGINAL-resolution px (AR-002)
    max_faces      : int   per-frame cap; 0 = uncapped, the default (AR-003)

    # ── what produced the frames (VR-010) ───────────────────────────────────
    movie          : str   source video path
    sample_fps     : float frames analysed per second of movie
    start_sec      : float seek point
    end_sec        : float stop point; -1 = end of file
    cut_threshold  : float histogram correlation below which is_cut fires
    dense_scale    : float decoded-frame downscale in dense mode; 1 = off
    bbox_upscale   : float multiply faces/bbox and faces/landmarks by this to
                           reach original video pixels; 1 when dense_scale is 1
    scene_detect   : uint8 0/1 — was TransNetV2 running at all (see below)

    # ── downstream setting recorded for comparability (VR-010) ──────────────
    track_assoc_min_prob : float  the run's tracker admission probability

  frames/                             group — one row per sampled frame
    timestamp_sec  : float64 [F]
    frame_idx      : int64   [F]
    is_cut         : uint8   [F]      (histogram intra-scene cut)
    is_scene_boundary : uint8 [F]     (TransNetV2 boundary; 0 if scene_detect off)
    face_offset    : int64   [F]      start index into faces/* for this frame
    face_count     : int32   [F]      number of faces in this frame

  faces/                              group — one row per detected face, concatenated
    embedding      : float32 [N, 512] L2-normalised ArcFace embedding
    bbox           : float32 [N, 4]   x, y, w, h in DECODED-frame pixels
    landmarks      : float32 [N, 10]  5 (x,y) pairs, SCRFD/ArcFace order,
                                      same space as bbox
    confidence     : float32 [N]      detector confidence

    # ── embedding input quality (AR-028), v2 onward ─────────────────────────
    sharpness          : float32 [N]  normalised Laplacian variance on the
                                      112x112 aligned crop (AR-029)
    alignment_residual : float32 [N]  RMS landmark misfit in canonical px,
                                      after the AR-005 similarity fit (AR-030)

F = number of sampled frames, N = total faces (= sum of face_count). Frame i's faces are faces/*[ face_offset[i] : face_offset[i]+face_count[i] ].

Provenance (VR-010)

The attributes above are not documentation; they are the only thing that makes a dump interpretable. Two dumps of the same film at detector_conf 0.5 and 0.7, or at dense_scale 1.0 and 0.5, or with scene detection on and off, are different measurements of different things — and they are byte-shaped identically. Without provenance a consumer that mixes them gets a plausible number from an incoherent input, and nothing anywhere reports a problem.

scene_detect is the one that cannot be inferred. is_scene_boundary is all-zero both when TransNetV2 found no boundaries in the clip and when it was never enabled, and those mean opposite things: the first says this footage has no shot changes, the second says nobody looked. A consumer that reads the array alone must guess. The flag is what removes the guess. (dump_embeddings has no --scene-detect, so every dump it writes records false — which is exactly the fact the committed fixtures needed to state.)

bbox_upscale is recorded, not applied. See the coordinate-space note below.

Reading is by name with a default or an existence check on both sides — replay.py (f.attrs.get(...)) and read_dump_provenance() in src/nodes/embedding_dump_node.hpp (attrExists). So the attributes are additive and did not themselves move schema_version off 1: a pre-VR-010 dump still loads, and a post-VR-010 dump still reads on old code. (AR-028 later took it to 2 by adding datasets — see below.)

A missing attribute means unknown, never a default value. Substituting detector_conf = 0.5 for a dump that does not say so manufactures the provenance the requirement exists to prevent — per docs/requirements.md, "a fixture whose provenance is unknown is worse than no fixture, because it will be trusted." The committed tests/fixtures/dumps/*.h5 predate VR-010 and carry none of these attributes; re-dump to bind them, as with GR-004.

Embedding input quality (AR-028) — and why this one bumps the version

sharpness and alignment_residual are two of the three AR-028 quality axes, written beside the embedding they describe. The third axis, size, is already here: it is bbox, scaled by bbox_upscale to reach the original resolution AR-002 thresholds in. It is not duplicated into a third column, because that would put the same quantity in two coordinate spaces inside one file — the trap the bbox_upscale note below records — and the copy is the one that drifts.

The vector is carried, not consumed. Nothing in the pipeline thresholds or discounts on it yet; VR-012 locates the knees from these columns, which is only possible if they were recorded at inference. A study cannot recover how sharp a face was from an embedding, any more than it can recover which model produced it.

This is the change that bumps schema_version to 2, where VR-010's attributes did not. The rule is unchanged — a bump is for the datasets — and so is the reason behind it. Readers are fine either way: replay.py and test_replay_fixtures.cpp take these datasets by name with an existence check, so a v1 dump still replays and loses only what it never had. The version exists for a consumer of the quality vector, which otherwise cannot tell "this film's faces were never scored" from "this film's faces scored zero" — sharpness 0 is a real reading, meaning a featureless crop. That is the same distinction scene_detect exists to make, and it is equally unrecoverable from the arrays.

A v1 dump reports the vector as unknown, never as a defaultload_frames omits the keys rather than filling zeros, and the C++ side leaves the DetectedFace fields at their -1 "unscored" sentinel. Re-dump to acquire it; there is no migration, for the same reason GR-004 has none.

The committed tests/fixtures/dumps/*.h5 are v1 and carry no quality vector. Re-dumping needs a GPU host (scripts/make_fixtures.sh), so until that runs, anything driven from the fixtures sees the sentinel.

Model binding (GR-004)

embedder_model / embedder_sha256 record which embedder produced every vector in faces/embedding. A replay has no live embedder, so the dump is the embedder as far as the gallery is concerned: replay.py checks these two attributes against the gallery's own /embedder stamp and refuses to run on a mismatch, naming both sides. Cross-model cosines are meaningless but look plausible.

The attributes are additive, not a format break — schema_version stays 1. Dumps written before GR-004 simply lack them, which reports as unverifiable (a loud warning, or a hard error under SAE_REQUIRE_GALLERY_STAMP=1) rather than as a pass. Re-dump to bind an old dump; there is no in-place migration, because unlike a gallery nobody can assert after the fact which model produced a vector.

Coordinate space — bbox, landmarks, bbox_upscale

bbox and landmarks are in decoded-frame pixels: exactly the numbers SCRFD produced, untransformed. To reach original video pixels, multiply by bbox_upscale. With dense_scale == 1 (the default, and every committed fixture) bbox_upscale == 1 and the two spaces coincide.

Earlier revisions of this document claimed the upscale was applied at dump time. It never was. embedding_dump_node.hpp writes f.bbox raw; the upscale lives in identity_matcher_node.hpp, which is downstream of the dump tap. The claim was harmless only because dense_scale was 1 in practice.

The fix is to record the factor rather than to apply it, because the dump's whole contract is to be a faithful tap at the EmbeddedSceneFrame channel — VR-002 requires replay to drive the real nodes, and a replay is only equivalent to the live run if the tracker is fed the geometry the live tracker saw. Rescaling at the tap would break that: the replayed tracker would associate on boxes the live one never received. Two further reasons:

  • The matcher's upscale is applied to bbox only, not to landmarks. Pre-multiplying at the tap would leave the two arrays in different coordinate spaces inside one file — a worse trap than the one being fixed.
  • Pre-multiplying is lossy in the sense that matters: a dump that had been upscaled would be indistinguishable from one taken at dense_scale == 1, so you would have to record bbox_upscale anyway to know which you were holding.

Invariants

  • embedding rows are unit-norm (cosine == dot product against the gallery).
  • face_offset[0] == 0; face_offset[i+1] == face_offset[i] + face_count[i].
  • bbox and landmarks share one coordinate space; bbox_upscale maps both to original resolution (see above).
  • A frame with no faces has face_count == 0 (still gets a row, so timestamps stay dense).
  • EOF sentinel frames are NOT written.
  • v2 onward: sharpness and alignment_residual are [N], parallel to confidence, so face i's quality indexes with the same slice as its embedding. Both are >= 0 for any face the aligner admitted; a negative value means unscored and must never be read as a quality.