0029 — Trained expression signal (MediaPipe blendshapes: smile ↔ frown valence)¶
- Status: IMPLEMENTED (2026-07-23) — all phases 1–5 shipped and merged (phases 1–2
MR !76, phase 3+5 MR !90, phase 4 MR !92), each validated by a re-cull of the
Lyndsey-Denis shoot. The residual no-lock coverage work continues in
0032. Phase 4 (shared backbone) made themediapipestrategy self-contained: eyes + facing now derive from the FaceMesh (mesh-EAR on recalibrated[0.11,0.24]anchors, chosen over the trained eyeBlink for its 12/14 blink recall), dropping the non-commercial 2d106det from the default stack (now Apache-2.0/MIT). Known limitation: ~22% mesh no-lock reads eyes/facing/expressionUnknownon hard faces (safe, no clean fallback exists — ACCEPTED; the faithful-ROI fix is spec0032, see §9.4). Phase 5 (gate) added an opt-in soft frown floor (cull.profile.frown_soft, a NEGATIVE valence threshold): a frame whose worst face reads grumpier than the floor demotes to maybe, never rejects (Hailey can always keep it deliberately). Off by default (0 = off), worst-face aggregate (Q4), golden-tested inpkg/cull; the studio pins the grumpy face(s) in the loupe. Phase 3 (calibration) added a serve-time valence remap —face.expr.neutral+face.expr.gain, read inmeanFaceValence(studio), hot-reloadable with no re-cull, identity by default; verified live onlyndsey-denis(gain 1.3 spreads a smile 93→100, deepens a frown 31→25, leaves neutral put). Gates resolved with Matt: self-convert the models (Q1), shipsmile − frownwith config-weighted Duchenne/brow extras (Q2), keep the geometric estimator as the config-selected WASM fallback (Q3). - Implementation notes. The estimator lives in
pkg/face/onnxas themediapipestrategy (not a separatepkg/face/mediapipe) so it reuses the private ORT runtime/session/landmarker/crop plumbing rather than exporting it — the "(or a blendshape estimator)" option in §3. It composes: EAR still yields eyes + facing, the FaceMesh → blendshape pass yields smile/frown/valence. Models are converted byscripts/convert-mediapipe-models.py(tf2onnx, opset 17), pinned by checksum inpkg/face/models/models.go, and verified against a golden fixture (pkg/face/onnx/testdata). Hosting follow-up →0031: the two model artefacts are not uploaded yet; the signedkrites-modelsprovisioning repo (0031) is their home (and every other model's). Until it lands the strategy is opt-in and resolves them from the local cache. - Why now: the current expression signal is a geometric mouth-curvature heuristic
with provisional, uncalibrated anchors (
face/onnxSmileClosed=0.00/SmileOpen=0.10), explicitly shipped "verdict-neutral until calibrated on a labelled set" (R-EXPR-5). In practice it under-reads badly: a bride with a broad smile scored 0.40, most frames ~0. Expression (smile and frown) is central to judging people at a wedding, so this needs to be as good as we can make it — a trained classifier, not geometry. - Decision (2026-07-17): replace the heuristic with MediaPipe FaceMesh + Blendshapes. Rejected AffectNet-trained models (HSEmotion/EmotiEffLib — best accuracy, but AffectNet bars commercial use of "images and any derived data"; same non-commercial taint as our Places2 inpainting flag, and we will not expose the product to that risk). Training our own on permissive data is a future goal, out of scope here.
- Realises:
0007expression layer (upgrades Layer-1), the0019§5.A explainable-cull moat; lights up the diverging Expression axis added for0023/the cull-score rail. - Depends on:
0004(face detect/crop — UltraFace),0007(expression seam),0013(ONNX runtime),0023(score display). Anchors: revisesR-EXPR-*.
1. Why the heuristic must go¶
smile = ramp01(mouthCurvatureRatio, SmileClosed, SmileOpen) from 4 lip landmarks is
cheap but weak: the curvature ratio correlates only loosely with a real smile, the anchors
were never calibrated (no labelled set), and taking the frame's worst face smile
(MinSmile) for a display axis makes every group shot read ~0. The 0028/score-rail work
gave us a good display (diverging grumpy↔happy), but it is rendering a bad signal.
2. The spike (evidence)¶
MediaPipe FaceMesh+Blendshapes run on this shoot's frames (2026-07-17):
- Correct smile magnitude. The broadly-smiling bride (DSC08284) read smile ≈ 0.78 (old geometric: 0.40); a near-neutral face read ≈ 0.01.
- Per-face smile and frown on a 7-person group (DSC08069, fed the existing UltraFace crops): the one big smiler 0.94, a genuinely unsmiling face frown 0.10 (valence −0.10), the neutral faces ~0.00. Exactly the grumpy↔happy discrimination we want.
- Coverage caveat: MediaPipe's own short-range face detector under-detects small/ angled faces (many whole-frame "no face"); and on tight crops ~2 of 7 group faces got no mesh lock. → We must not use MediaPipe for detection (see §3).
- Licence: Apache-2.0, Google-trained weights — clean for local and the SaaS track.
3. Architecture — UltraFace detects, MediaPipe expresses¶
Keep the proven detector; add a trained expression estimator behind the existing seam.
UltraFace (detect boxes, unchanged)
└─► per box: crop (padded)
└─► MediaPipe FaceMesh (478 landmarks, ONNX)
└─► Blendshape MLP (52 ARKit coeffs, ONNX)
└─► expression = f(mouthSmile*, mouthFrown*, …)
- Detection stays UltraFace (
0004) — its coverage is why the spike fed it crops; MediaPipe's detector is not a regression we accept. - New provider
pkg/face/mediapipe(or ablendshapeestimator) behind theface.Analyzer/expression seam, chosen by config — the geometric estimator stays as a fallback for a model-free / WASM build (§7). No package-level hooks; injected per the provider rules. - Two ONNX models (facemesh + blendshapes) resolved and dlopened via the existing ORT
runtime (
0013), auto-provisioned + checksum-pinned like the current face models. - A face whose mesh doesn't lock (small/profile) →
ExprUnknown, exactly today's degrade path (skipped from aggregates, never a fabricated 0).
4. The expression signal¶
Per face, from blendshapes: smile = mean(mouthSmileLeft, mouthSmileRight),
frown = mean(mouthFrownLeft, mouthFrownRight), and a valence = smile − frown in
[−1, 1] (0 = neutral). Optionally fold in cheekSquint* (Duchenne / genuine smile) and
browDown* — a small calibrated combination, tunable in config (0001 §6, hot-reload).
- Display (now): the diverging Expression axis maps valence → the grumpy↔happy bar (neutral centre). Per-frame it aggregates the mean across faces (not the worst).
- Gate (later): smile/frown can feed the cull verdict once trusted, but stays
display-only until calibrated/validated (honours the
R-EXPR-5posture) — flip it on deliberately, not in this spec.
5. Data model & persistence¶
Persist the raw per-face blendshape-derived values (smile, frown, and the derived
valence) on FaceBox, not a pre-baked ramp. Two payoffs:
- The display mapping (neutral point, gain, Duchenne weighting) becomes a serve-time, hot-reloadable calibration — tune and refresh, no re-cull (the "live-tunable" idea, now on a signal worth tuning).
- Honest provenance:
ExprUnknownwhere no mesh locked.
This is an analysis-cache shape change → AnalysisVersion bump (re-cull to populate;
standard R-CAP-3). The geometric MinSmile gate field stays for back-compat during the
transition.
6. Studio¶
No new UI — it feeds the existing diverging Expression axis (0023/score rail), which now
shows a trustworthy value. Consider a small "Expression: how it's read" help topic
(0026) and, while calibrating, a provisional marker. Face-zoom panels can optionally badge
smile/frown per face.
7. Models, ONNX & the WASM line¶
- Conversion: MediaPipe ships
.tflite; convert facemesh + blendshapes to ONNX (or adopt an existing Apache port, e.g.py-feat/mp_blendshapes), verify parity against the spike numbers, pin + checksum, auto-download (Apache-2.0 permits redistribution). - cgo-free / WASM: the ONNX inference is a native/purego+ORT adapter and lives only in
its provider package, never imported by the pure-Go deterministic engine (
just wasm-checkstill guards it). The geometric estimator remains the WASM/no-model fallback so the engine still compiles and runs model-free.
8. Licensing (why MediaPipe, explicitly)¶
Apache-2.0 code and Google-trained weights (not AffectNet/RAF-DB/CelebA, which are all non-commercial). This deliberately sidesteps the "trained on non-commercial data → derived- data taint" trap we already track for Places2-trained inpainting. Record the model provenance + licence in docs and the provisioning manifest.
9. Phasing¶
- Provider + signal.
pkg/face/mediapipe(facemesh+blendshapes ONNX on UltraFace crops) → per-face smile/frown/valence; unknown-on-no-lock. Golden-fixture unit tests against the spike's reference values; provider faked in cull tests. - Persist + display.
FaceBoxraw values (AnalysisVersionbump), serve-time valence mapping (hot-reloadable), wire the diverging Expression axis to it. - ✅ Calibration pass (DONE 2026-07-23). A serve-time valence remap in
meanFaceValence—face.expr.neutral(recentre) +face.expr.gain(spread) — read per request, so tuning hot-reloads with no re-cull and outside the golden parity test. Identity by default (the raw signal already spreads well on real shots: 481 faces, valence −0.57…+0.95, 43% happy / 29% neutral / 14% grumpy); the knobs let Hailey tune to taste. Duchenne/brow (face.expr.cheek_squint/brow_down) remain the analysis-time weights (baked into persisted valence). - Shared backbone — IMPLEMENTED (2026-07-23). The
mediapipestrategy is now self-contained: eyes and facing derive from the FaceMesh, dropping the InsightFace 2d106det dependency (non-commercial / research-only — a real commercial-track blocker, see0031§3).mediapipeis the primary/default strategy;ear+ 2d106det remains an opt-in fallback. The default face stack is now Apache-2.0/MIT end-to-end. - Eyes → mesh-EAR (chosen), not eyeBlink. Both were validated head-to-head
against the 2d106det baseline over 150 Lyndsey-Denis frames (93 locked faces,
14 baseline blinks):
- trained
eyeBlinkblendshapes: 88% verdict agreement, matched scale — but only 4/14 blink recall (misses real blinks; wrong error for a culler). - mesh geometric EAR: 12/14 recall, but the mesh EAR scale is intrinsically
lower than 2d106det's (p50 0.19 vs ~0.23), so the shared
[0.12,0.26]anchors mis-scaled it (62% agreement, mean eyeOpen 0.49). Box-padding was tested and ruled out as the cause (raw mesh-EAR is padding-invariant; padding only marginally, non-monotonically helps the no-lock rate). - Fix: mesh-specific EAR anchors
[0.11,0.24](DefaultMeshEAR{Closed,Open}, strategy-awarewithDefaults), recalibrated on the same frames → 80% agreement at 12/14 recall (no recall regression).face.ear.*still overrides. Higher-agreement anchors only buy points by dropping real blinks, so recall was held over agreement.
- trained
- Facing → 2D yaw-asymmetry proxy on mesh corners (for now). The same
eye-corner/nose yaw proxy the
earstrategy uses, on mesh indices (mesh yaw agrees with the 2d106det baseline on the bride fixture). True 3D head-pose from the mesh's Z coordinates is a deferred refinement (open q. below). - Known limitation — 22% mesh no-lock (ACCEPTED 2026-07-23). FaceMesh fails to
lock on ~1 in 5 detected faces (small/turned/occluded); those read eyes/facing/
expression
Unknown→ no blink/expression gate for that face (safe: never a false reject). There is no clean fallback — eyeBlink also needs the mesh, and the only other landmark source is the 2d106det we are dropping. So going self-contained trades some blink coverage on hard faces for a licence-clean default. A spike (2026-07-23) measured the levers: uniform crop padding of 0.25 recovers ~⅓ (22%→15%, its optimum), naive squaring backfires, and the rest are confident mesh failures (presence logit p50 −8.7) that need rotation-aligned ROI, not more margin. Decision: accept 22% for phase 4 (safe-Unknown) and track the faithful MediaPipe ROI pipeline (padding → size-compensated square → BlazeFace + rotation) as its own effort — spec0032. The spike harness (meshlock_spike_integration_test.go,TestMeshPadding_lockRate) is the record. - Sequencing: gated on
0031hosting the models (a fresh default install must have something to download). As shipped in slices A/B themediapipestrategy still used 2d106det for eyes/facing — this phase actually drops it. - ✅ Gate integration (DONE 2026-07-23). An opt-in soft frown floor
cull.profile.frown_soft— a NEGATIVE valence threshold; a frame whose worst face (Q4) reads grumpier than it demotes to maybe, never rejects (the diverging-axis intent: Hailey can always deliberately keep a grumpy frame). Off by default (0/non-negative = off). Wiring:Signals.MinValence(worst-face valence, sentinel −2) fromExpressionSignal,Profile.FrownSoft, a soft branch inexpressionIssues, thecullprofileaccessor/Overridableparity, and loupe face-pinning (grumpyFaces). Golden-tested inpkg/cull. Requires a re-cull to populate the new signal. This closes R-EXPR-5.
10. Open questions¶
- Q1 — tflite→onnx conversion vs an existing ONNX port? Recommend: convert ourselves and pin (control + provenance), unless a well-maintained Apache port matches the spike parity.
- Q2 — valence formula.
smile − frownonly, or addcheekSquint/browDown? Recommend: shipsmile − frown, make the extra terms config-weighted for calibration. - Q3 — keep the geometric estimator? Recommend: yes, as the WASM/model-free fallback (and a fast path), selected by config.
- Q4 — per-frame aggregate for the gate. Mean for display is settled; if expression ever gates, worst-vs-mean is a separate call (blink-style worst may fit the gate, mean the display).
11. Non-goals¶
- Not training our own expression model (future goal, needs permissive data).
- Not a full emotion taxonomy (anger/fear/…) — smile↔frown valence is the wedding-relevant axis; richer affect is out of scope.
- Not switching face detection to MediaPipe (UltraFace stays).
- No change to the non-destructive guarantee; expression is display-first, gate-later.