0033 — Pluggable face detector (UltraFace | YuNet)¶
- Status: IMPLEMENTED (2026-07-24, MR !99).
faceDetectorseam + YuNet shipped and defaulted; validated by a full re-cull of the Lyndsey-Denis shoot — 718 faces (+13% vs UltraFace's 636) at the shared 0.70 confidence floor (a modest, genuine gain, not over-detection), verdicts keep 77 / maybe 377 / reject 102 (vs 84/370/102 — the usual safe keep→maybe drift, reject unchanged). Keypoints are available for0032Stage 3. - Owner: —
- Realises: the
0032§8 investigation — adopt YuNet (MIT, box + 5 keypoints, strong on small/side faces) as a selectable face detector, defaulting to it, behind a small interface so UltraFace and YuNet can be A/B-compared over time. The keypoints also unblock the0032Stage-3 rotation path (roll from the eye landmarks) without a licence-encumbered model. - Depends on:
0004(face detect/crop),0013(ONNX runtime),0031(model hosting — YuNet rides a future krites-models release; cache-resolved from its MIT source until then).
1. Why¶
The detector is currently hard-wired to UltraFace RFB-320 (boxes only, no keypoints, tight
portrait boxes that the FaceMesh dislikes — 0032). The 0032 §8 spike measured YuNet as a
clear, licence-clean upgrade: MIT weights (Shiqi Yu), box + score + 5 keypoints (both eyes,
nose, mouth corners), better coverage incl. small/side faces, and a decode we validated to the
exact pixel against OpenCV's reference. Making the detector pluggable (rather than a
wholesale swap) lets us default to YuNet while keeping UltraFace one config flip away — the
general principle that the more backends are pluggable, the more we can measure and refine over
time (mirrors the FaceAnalyzer / Inpainter / strategy seams, 0001 §5).
2. Design¶
faceDetectorinterface inpkg/face/onnx:detect(ctx, img) ([]detection, error)+close().detectiongains an optionalkeypoints []point(nil for UltraFace; the 5 YuNet landmarks otherwise) — carried through NMS.- Two implementations: the existing UltraFace
detector, and a newyunetDetector(pkg/face/onnx/yunet.go). - Config:
face.detector=yunet(default) |ultraface, via a newonnx.Detectortype +Config.Detector(defaulted inwithDefaults). Selected inanalyzer.openDetector. - Model resolution:
faceprovider.resolveModelspicks the detector model byface.detector(models.YuNetvsmodels.UltraFace);face.detector_modelstill overrides.models.YuNetis a plain checksum-pinnedModel(MIT source URL, SHA8f2383e4…), like UltraFace, until it rides a signed krites-models release (0031).
YuNet specifics (validated in the spike)¶
- Input
[1,3,640,640]float, BGR, 0–255, no mean/scale (OpenCVswapRB=false). The image is resized to 640², detections scaled back to image coords. - Anchor-free decode over strides 8/16/32 (grids 80²/40²/20², one prior per cell):
score = sqrt(cls · obj)cx = (col + bboxΔ.x)·s,cy = (row + bboxΔ.y)·s,w = exp(bboxΔ.w)·s,h = exp(bboxΔ.h)·s- keypoint
k:x = (col + kpsΔ.x_k)·s,y = (row + kpsΔ.y_k)·s - row-major cell index; confidence floor then the shared
nms.
3. Constraints¶
- cgo-free / WASM line unchanged — the detector lives in the native
pkg/face/onnxadapter (already behind ORT/purego), not the deterministic engine. - Non-destructive. The detector choice changes detection boxes → analysis values, so switching
is a re-cull (
AnalysisVersionbump). Keypoints are not persisted yet (they feed future rotation, in-memory). - Not wired to MCP/studio settings in this spec — config-key + default only; a studio toggle can follow.
4. Tests¶
- Unit (
yunet_test.go, pure): the decode on synthetic per-stride tensors → known boxes + keypoints; the config default resolves to YuNet. - INT_TEST (
yunet_integration_test.go, env-gated): the real model on a committed face fixture detects the face with a plausible box + 5 keypoints (eyes near the expected positions). just ci+just wasm-checkgreen.
5. Success¶
A face.detector: yunet default that runs end-to-end (detect → mesh) with detection parity/upside
vs UltraFace on the shoot, one config flip back to ultraface, and the keypoints available for the
0032 Stage-3 rotation work.