Gallery from one recording, probes from another, sweeping the probe's input resolution end to end. VR-005 asked the same question over gallery mugshots but degraded an already-aligned 112x112 crop with alignment held perfect, so it isolates the embedder. Here the whole frame is downscaled before the detector, so detection and landmark regression degrade with it — which is most of the difference. Corpus is two 4096x2160 clips of one shoot, four people, hand-sorted. Ground truth is sorted by hand and gated by verify_labels.py; labels carried down the scales geometrically by box position, never by embedding similarity, which would keep only the faces the embedder already gets right and drop the ones the sweep exists to find. Findings, all scored through the production gallery sigmoid at prob_threshold 0.754 — never a raw cosine: - Holding 90% of the plateau needs ~50 px end to end, against VR-005's ~22 px. min_face_px at 40 looks right; 32 would admit faces in the falling region. - FPI is 0.0% at every scale. Resolution loss goes entirely to TBI. - The ceiling is cross-view, not resolution: everyone matches themselves within a recording (0.55-0.85) and collapses across two (0.14-0.45, threshold 0.335). Only the subject with frontal *gallery* references identified reliably, whatever their probe pose — so the lever is gallery pose coverage, not a better landmark source. - Averaging SCRFD's overlapping detections instead of discarding them at NMS lifts cross-recording TPI 41% -> 49%, for one forward pass and no extra model. Four identities and one shoot, so the shape is the result and the absolute rates are not. Both clips contain all four people, so there is no out-of-gallery class and the 10x-weighted out-of-cast misID is untested here. Clips, frames, hand-sorted crops and results are gitignored and belong in the artifact registry — the sorting is human ground truth and expensive to redo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: VR-013 | AR-002, AR-005, AR-024
175 lines
7.7 KiB
Python
175 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
||
"""What explains the misses? Head pose, face size, blur, detector confidence.
|
||
|
||
For every hand-labelled probe face, computes the calibrated probability against
|
||
its OWN gallery entry — so a low value is a false negative, not a mistake about
|
||
who it is — and pairs it with covariates that might explain the failure.
|
||
|
||
Head pose comes from solvePnP of the 5 landmarks against a canonical 3D face,
|
||
giving yaw/pitch/roll in degrees.
|
||
|
||
CAVEAT, and it matters: the pose estimate is derived from the same 5
|
||
landmarks the alignment uses. Where those landmarks are unreliable the pose
|
||
estimate is unreliable too, and both degrade for the same reason. So this
|
||
can show that failures concentrate at high yaw; it cannot cleanly separate
|
||
"the head was turned" from "the landmarks were wrong because the head was
|
||
turned". Those are the same physical cause, but not the same fix — the
|
||
first argues for gallery pose coverage, the second for a better landmark
|
||
source.
|
||
|
||
A sanity check is printed first: pose is estimated per person, and if it does
|
||
not recover what is visible in the review sheets (one subject frontal, another
|
||
in profile, another looking down) then the estimate is not worth reading.
|
||
|
||
Similarities go through the production gallery sigmoid, never compared raw.
|
||
"""
|
||
import sys, glob, json, os
|
||
import numpy as np
|
||
import cv2
|
||
|
||
sys.path.insert(0, "/home/dtourolle/Development/Jray-project/scene-actor-extraction/build-ort")
|
||
import sae_embed
|
||
|
||
ROOT = "/home/dtourolle/Development/Jray-project/scene-actor-extraction/"
|
||
M = ROOT + "models/"
|
||
GALLERY_CLIP, PROBE_CLIP = "5157344", "5157339"
|
||
PROB_THRESHOLD = 0.754
|
||
|
||
# Canonical 3D face, ordered as types.hpp:60 —
|
||
# [0] right-eye [1] left-eye [2] nose [3] right-mouth [4] left-mouth.
|
||
# The subject's right eye sits to the LEFT in image space, hence the negative X.
|
||
FACE_3D = np.array([
|
||
(-34.0, 35.0, -28.0),
|
||
( 34.0, 35.0, -28.0),
|
||
( 0.0, 0.0, 0.0),
|
||
(-26.0, -32.0, -25.0),
|
||
( 26.0, -32.0, -25.0),
|
||
], dtype=np.float64)
|
||
|
||
eng = sae_embed.FaceEmbedder(detector_model=M + "scrfd_500m_bnkps.onnx",
|
||
arcface_model=M + "LVFace-B_Glint360K.onnx",
|
||
conf=0.5, nms=0.4, max_side=0)
|
||
cal = sae_embed.gallery_calibration(ROOT + "gallery_lvface.h5")
|
||
|
||
|
||
def head_pose(lm, w, h):
|
||
"""yaw, pitch, roll in degrees. Focal length assumed = image width."""
|
||
cam = np.array([[w, 0, w / 2], [0, w, h / 2], [0, 0, 1]], dtype=np.float64)
|
||
ok, rvec, _ = cv2.solvePnP(FACE_3D, lm.astype(np.float64), cam, None,
|
||
flags=cv2.SOLVEPNP_EPNP)
|
||
if not ok:
|
||
return None
|
||
R, _ = cv2.Rodrigues(rvec)
|
||
sy = np.sqrt(R[0, 0] ** 2 + R[1, 0] ** 2)
|
||
if sy > 1e-6:
|
||
pitch = np.degrees(np.arctan2(-R[2, 0], sy))
|
||
yaw = np.degrees(np.arctan2(R[1, 0], R[0, 0]))
|
||
roll = np.degrees(np.arctan2(R[2, 1], R[2, 2]))
|
||
else:
|
||
pitch = np.degrees(np.arctan2(-R[2, 0], sy)); yaw = 0.0
|
||
roll = np.degrees(np.arctan2(-R[1, 2], R[1, 1]))
|
||
# solvePnP's yaw wraps near +/-180 for a face pointing at the camera;
|
||
# fold it to a "degrees away from frontal" magnitude.
|
||
yaw = ((yaw + 180) % 360) - 180
|
||
if abs(yaw) > 90:
|
||
yaw = np.sign(yaw) * (180 - abs(yaw))
|
||
return yaw, pitch, roll
|
||
|
||
|
||
def collect(clip):
|
||
lab = {os.path.basename(p): os.path.basename(os.path.dirname(p))
|
||
for p in glob.glob(f"labelling/{clip}/*/*.jpg")
|
||
if os.path.basename(os.path.dirname(p)) not in ("discard", "unsorted")}
|
||
man = {m["file"]: m for m in json.load(open(f"labelling/{clip}/manifest.json"))}
|
||
rows = []
|
||
for frame in sorted({man[f]["frame"] for f in lab}):
|
||
img = cv2.imread(f"frames/d{clip}_{frame}.png")
|
||
dets = eng.detect(img)
|
||
H, W = img.shape[:2]
|
||
for f, person in lab.items():
|
||
m = man[f]
|
||
if m["frame"] != frame or m["idx"] >= len(dets):
|
||
continue
|
||
d = dets[m["idx"]]
|
||
lm = np.array(d.landmarks, dtype=np.float32).reshape(5, 2)
|
||
crop = sae_embed.align_face(img, lm)
|
||
if crop is None:
|
||
continue
|
||
pose = head_pose(lm, W, H)
|
||
x, y, w, h = d.bbox
|
||
g = cv2.cvtColor(np.asarray(crop), cv2.COLOR_BGR2GRAY)
|
||
rows.append({
|
||
"person": person, "px": float(min(w, h)), "conf": float(d.confidence),
|
||
"yaw": pose[0] if pose else np.nan, "pitch": pose[1] if pose else np.nan,
|
||
"roll": pose[2] if pose else np.nan,
|
||
"blur": float(cv2.Laplacian(g, cv2.CV_64F).var()),
|
||
"emb": np.asarray(eng.embed_crop(crop), dtype=np.float32)})
|
||
return rows
|
||
|
||
|
||
gal_rows = collect(GALLERY_CLIP)
|
||
prb_rows = collect(PROBE_CLIP)
|
||
gal = {}
|
||
for r in gal_rows:
|
||
gal.setdefault(r["person"], []).append(r["emb"])
|
||
gal = {p: np.stack(v) for p, v in gal.items()}
|
||
|
||
for r in prb_rows:
|
||
if r["person"] in gal:
|
||
s = float((gal[r["person"]] @ r["emb"]).max()) # best-of-N, own actor
|
||
r["p"] = cal.probability(s)
|
||
r["sim"] = s
|
||
else:
|
||
r["p"] = np.nan
|
||
rows = [r for r in prb_rows if not np.isnan(r.get("p", np.nan))]
|
||
print(f"[data] {len(rows)} labelled probe faces with a gallery entry\n", file=sys.stderr)
|
||
|
||
# ── sanity check: does the pose estimate recover what the sheets show? ───────
|
||
print("pose by person (does this match the review sheets?)")
|
||
print(f"{'person':>7}{'n':>5}{'|yaw| med':>11}{'pitch med':>11}{'P med':>8}{'hit rate':>10}")
|
||
for p in sorted({r['person'] for r in rows}):
|
||
sub = [r for r in rows if r["person"] == p]
|
||
print(f"{p:>7}{len(sub):>5}"
|
||
f"{np.median([abs(r['yaw']) for r in sub]):>11.1f}"
|
||
f"{np.median([r['pitch'] for r in sub]):>11.1f}"
|
||
f"{np.median([r['p'] for r in sub]):>8.3f}"
|
||
f"{100*np.mean([r['p'] > PROB_THRESHOLD for r in sub]):>9.0f}%")
|
||
|
||
# ── P binned by each covariate ───────────────────────────────────────────────
|
||
def binned(name, key, edges, fmt="{:.0f}"):
|
||
print(f"\nP(match) by {name}")
|
||
print(f"{'bin':>16}{'n':>5}{'P med':>9}{'hit rate':>10}{'sim med':>9}")
|
||
vals = np.array([r[key] for r in rows])
|
||
for lo, hi in zip(edges[:-1], edges[1:]):
|
||
sub = [r for r, v in zip(rows, vals) if lo <= v < hi]
|
||
if not sub:
|
||
continue
|
||
lbl = f"{fmt.format(lo)}–{fmt.format(hi)}"
|
||
print(f"{lbl:>16}{len(sub):>5}"
|
||
f"{np.median([r['p'] for r in sub]):>9.3f}"
|
||
f"{100*np.mean([r['p'] > PROB_THRESHOLD for r in sub]):>9.0f}%"
|
||
f"{np.median([r['sim'] for r in sub]):>9.3f}")
|
||
|
||
for r in rows:
|
||
r["absyaw"] = abs(r["yaw"])
|
||
r["abspitch"] = abs(r["pitch"])
|
||
binned("|yaw| (deg from frontal)", "absyaw", [0, 10, 20, 30, 45, 60, 91])
|
||
binned("|pitch| (deg)", "abspitch", [0, 10, 20, 30, 45, 91])
|
||
binned("face size (px)", "px", [0, 130, 150, 175, 200, 400])
|
||
binned("blur (laplacian var)", "blur", [0, 50, 150, 400, 1000, 1e9])
|
||
binned("detector confidence", "conf", [0.5, 0.6, 0.7, 0.8, 0.9, 1.01], "{:.2f}")
|
||
|
||
# ── how much does each covariate actually explain? ───────────────────────────
|
||
print("\nSpearman rank correlation with P(match):")
|
||
def spearman(a, b):
|
||
ra = np.argsort(np.argsort(a)); rb = np.argsort(np.argsort(b))
|
||
return float(np.corrcoef(ra, rb)[0, 1])
|
||
P = np.array([r["p"] for r in rows])
|
||
for key, label in [("absyaw", "|yaw|"), ("abspitch", "|pitch|"), ("px", "face px"),
|
||
("blur", "blur"), ("conf", "detector conf")]:
|
||
v = np.array([r[key] for r in rows])
|
||
print(f" {label:>14}: {spearman(v, P):+.3f}")
|
||
|
||
json.dump([{k: v for k, v in r.items() if k != "emb"} for r in rows],
|
||
open("failure_analysis.json", "w"), indent=1, default=float)
|