0007 — aesthetic & expression cull signals¶
Status: IMPLEMENTED (2026-07-03) — Layer 1 (pure seam + ranking/verdict
integration) and Layer 2a (geometric expression estimators) ship in Phase 2.
Layer 2b — the aesthetic model adapter — is deferred to Phase 4 (see the
2026-07-03 decision below); the aesthetic Scorer seam and its
ranking/verdict/cache integration ship now but inert (no backend, scorer off).
The design for 0006 §8 (Phase 2's ONNX track, its final slice). Companion to
0001-krites.md (§4.2 signal
table, §5 providers), 0004-face-eye.md (the machinery this
reuses — provider seam, purego/ORT adapter, calibrated anchors, layered build),
0005-decision-capture.md (the learning substrate
these signals feed), and 0006-develop.md (R-AES-1..4, the
requirements this spec implements).
Decision (2026-07-03, Matt) — Layer 2b deferred to Phase 4. The Q2 search turned up no reliably-hosted, checksummable NIMA-MobileNet
.onnxmeeting theR-MLR-7bar: the reference (idealo/image-quality-assessment, Apache-2.0) ships Keras HDF5 weights, not ONNX, and every community ONNX conversion is unhosted/unprovenanced. Weighing the throwaway cost, we asked whether NIMA has value beyond Phase 4 — and it does not: at Phase 4 the CLIP+aesthetic-head swaps in behind the sameScorerseam because the learner needs a per-frame embedding (score and embedding in one pass), which NIMA cannot provide, so NIMA gets deleted, not kept. Its only value was a Phase-⅔ stopgap ranking signal, bought with a throwaway convert→self-host→calibrate. So we defer the aesthetic model to Phase 4 and build CLIP directly as the first real backend (R-ASC-1makes the seam ready today). Aesthetic capture on Phase-⅔ shoots can be backfilled later — originals are immutable (R-ND-1). This closes slice 7 at Layer 1 + Layer 2a; the aesthetic ranking tier is present and inert until a backend lands. See0007-DEC-1below.Decisions (2026-07-02, Matt). §8 resolved: Q1 — geometric-from-landmarks expression (zero new models; the classifier stays a config-selectable later strategy,
R-EXPR-4). Q2 — NIMA-MobileNet as the first aesthetic scorer; the CLIP+aesthetic-head is the recorded Phase-4 upgrade behind the same seam (score + reusable embedding in one pass). Q3 — score every frame at cull (one-time, cached; the learner needs the full distribution, rejects included). Q4 — expression ships verdict-neutral (SmileSoft = FacingSoft = 0): ranking + capture only; demote-to-maybe is opt-in (R-EXPR-5confirmed).Scope: the two
0001§4.2 "Phase ≥2" signals — expression (smile, looking-at-camera) and an aesthetic score — as cull signals in the exact mould of the eye/blink signal: pure projections + verdict/ranking integration, fed by provider-backed analysis, config-gated, off by default, calibrated on real frames, captured for the Phase-4 learner. §8's questions are resolved; nothing gates the build.
1. The shape of the work — and the free lunch¶
0004 built more than an eye signal: it built a face analysis pass — the
UltraFace detector finds boxes, the InsightFace 2d106det model returns 106
facial landmarks in pixel coordinates per face, and a pure, calibrated
projection (EAR → EyeOpen) turns geometry into a signal. The landmarks cover
the full mouth contour, eye contours, brows, nose and face outline.
The free lunch: expression needs no new model. Smile is mouth geometry;
looking-at-camera is head-pose geometry — both are pure projections of the
landmarks the eye pass already computes per frame, behind the same
calibrated-anchor approach as EAR. One detector+landmark pass feeds eyes and
expression (0006 R-AES-1), at zero additional inference cost.
The aesthetic score is the one genuinely new model (0006 R-AES-2): a
whole-frame "is this a strong photograph" scorer with no deterministic
equivalent. It gets its own provider seam and its own ONNX adapter, reusing the
0004 runtime plumbing (purego/ORT, config-selected EP, model registry +
checksum fetch, calibration method).
| expression | aesthetic | |
|---|---|---|
| new model? | no — rides the 2d106det pass | yes — one new ONNX model |
| pure part | mouth/pose geometry → probabilities (like EAR) | score → ranking projection |
| provider | existing face.Analyzer (extended Face) |
new aesthetic.Scorer seam |
| default | off (rides face.enabled) |
off (aesthetic.enabled) |
2. Package layout & the WASM boundary¶
The 0004 split, extended — pure seams in the engine, native adapters outside
the wasm-check set:
pkg/analyze/face/ # PURE (existing) — gains expression projection
signal.go # + ExpressionSignal: Result → cull signal fields
pkg/face/onnx/ # NATIVE (existing) — gains geometric estimators
smile.go # mouth geometry → Smile (per face, from landmarks)
pose.go # landmark symmetry → FacingCamera (per face)
pkg/analyze/aesthetic/ # PURE (new) — Scorer interface + ranking projection
aesthetic.go # Scorer, Score; fake in tests
pkg/aesthetic/onnx/ # NATIVE (new) — the aesthetic model adapter
onnx.go # reuses the pkg/face/onnx runtime plumbing
R-EXPR-1(MUST) The expression projection lives inpkg/analyze/face(pure, WASM-safe,R-FACE-1); the geometric estimators live in the existing native adapter behind the sameface.Analyzerseam (R-FACE-2/3).R-ASC-1(MUST)pkg/analyze/aestheticholds only theScorerinterface, its types, and the pure ranking projection; every model-backed scorer lives in its own package behind it, injected, faked in tests — never a package-level hook (the keryx rule).
3. Expression — extending the face pass¶
3.1 face.Face gains attributes¶
// Smile is the probability (0..1) that the face is smiling; ExprUnknown (-1)
// when the estimator could not judge (mirrors EyeOpen's sentinel, R-FACE-5).
Smile float64
// FacingCamera is the probability (0..1) that the face is oriented toward the
// camera (a head-pose proxy, not eye-gaze); ExprUnknown when unjudged.
FacingCamera float64
R-EXPR-2(MUST) Both are per-face probabilities in[0,1]with the-1unknown sentinel, never silently coerced; a backend that only judges eyes (today's) reports unknown and nothing downstream changes (R-FACE-5mould).
3.2 Geometric estimators (no new model — §9-Q1)¶
Behind the adapter's existing per-face estimator seam, from the 2d106det landmarks already computed:
- Smile — mouth geometry: mouth aspect/width ratios and mouth-corner lift
relative to the mouth centre (the "MAR" family — the same shape as EAR).
Raw geometry maps to
Smilethrough calibrated anchors (closed ↔ broad smile), tuned on real frames exactly as the EAR anchors were (0004§6, calibration harness). - FacingCamera — head-pose proxy from landmark symmetry: the left/right
asymmetry of eye-outer/nose/face-outline distances estimates yaw (and
brow/chin ratios estimate pitch); large deviation ⇒ low
FacingCamera. Honest naming: this is head faces camera, not iris gaze — 2d106det carries no iris points. Good enough for "subject turned away" ranking. R-EXPR-3(MUST) Both estimators are deterministic geometry over landmarks; the maths is pure and unit-tested with synthetic landmark fixtures (smiling/neutral mouths, frontal/turned poses) independent of any model, and calibrated against real frames via the0004integration harness before the knobs ship enabled.R-EXPR-4(SHOULD) A classifier-based expression strategy (e.g. a FER+-class emotion model on the face crop) remains a config-selectable alternative behind the same attributes (face.onnx.expression: geometric | classifier), mirroringR-MLR-6— wired later only if geometry proves insufficient on real frames. Not built in this slice.
3.3 Signal projection & profile knobs¶
cull.Signals gains (worst-face semantics, like eyes — one grimace spoils a
group shot; same MinFaceBox floor):
// MinSmile is the worst per-face smile probability among counted faces, or -1.
MinSmile float64
// MinFacing is the worst per-face facing-camera probability, or -1.
MinFacing float64
cull.Profile gains SmileSoft and FacingSoft (0..1 soft floors; 0 =
disabled), plus nothing hard:
R-EXPR-5(MUST) Expression never hard-rejects and — unlike eyes — thewedding-defaultseed ships it verdict-neutral (SmileSoft = 0,FacingSoft = 0): a solemn ceremony face or a turned candid is normal at a wedding, not a defect. Expression's default value is **best-of-burst ranking- capture for the Phase-4 learner**; demote-to-maybe is opt-in by raising the soft floors. (The R-EYE-3 philosophy, applied harder.)
R-EXPR-6(MUST) Unknown (-1) never affects verdict or ranking (R-EYE-4mould); zero-valueSignalsstays safe viaFaceCount.
4. Aesthetic — the one new provider¶
Status: seam-only in slice 7. Per
0007-DEC-1theScorerseam,cullfields, ranking tier and cache slot below ship now (Layer 1) but inert; the model backend is deferred to Phase 4 and built as CLIP (§4.2), not NIMA. The requirementsR-ASC-1..4are the contract that Phase-4 adapter satisfies.
4.1 The Scorer seam (pure)¶
// Package aesthetic is the provider seam for whole-frame aesthetic scoring.
type Scorer interface {
// Score rates img's photographic strength in [0,1] (calibrated; higher is
// stronger). Deterministic per (img, model); honours ctx (R-GLOBAL-8).
Score(ctx context.Context, img image.Image) (float64, error)
}
cull.Signals gains Aesthetic float64 (-1 = unscored). cull.Profile
gains AestheticFloor (0..1; 0 = disabled — below it a keep demotes to
maybe, the "maybe-floor" of 0006 R-AES-2).
R-ASC-2(MUST) Aesthetic is a soft ranking signal: it feeds best-of-burst and the optional maybe-floor, and never hard-rejects (0006R-AES-2). Seed:AestheticFloor = 0(ranking-only).R-ASC-3(MUST) Scoring runs on the cull-time image (the embedded preview,R-FACE-9mould), is config-gated off by default (aesthetic.enabled,R-PRIV-1posture), degrades gracefully per frame on error/timeout (R-FACE-8mould), and is cached in the analysis cache keyed by model id (R-FACE-10,R-MLR-5; bumpshoot.AnalysisVersion).
4.2 Model candidates (§9-Q2)¶
All CPU-EP-runnable via the existing purego/ORT runtime; registry + checksum
fetch per R-MLR-4:
| candidate | size | CPU cost/frame | notes |
|---|---|---|---|
| NIMA (MobileNet) — AVA-trained mean-opinion scorer | ~13 MB | ~50 ms | the reference "aesthetic" model; small, fast, well-understood; output = 1–10 distribution → mean → calibrated to [0,1] |
| LAION aesthetic head on CLIP ViT-B/32 | ~350 MB | ~200–400 ms | modern + robust; the CLIP embedding is a reusable feature vector for the Phase-4 learner (score + embedding from one pass) — the strategic option |
| MUSIQ / transformer IQA | large | slow | best quality; ONNX availability/opset spotty — not a first pick |
Recommendation: NIMA-MobileNet first — smallest surface, fastest across
4,000 frames (~3–4 min), proven; behind Scorer it is swappable. Record the
CLIP+aesthetic-head as the planned Phase-4 upgrade: when the learner needs
per-frame embeddings (0005), one CLIP pass yields both, and the scorer swap
is a config flip + adapter (R-ASC-1).
R-ASC-4(MUST) The raw model output is mapped to[0,1]through calibrated anchors against real frames (the0004method): verify score separation between Hailey's keeps and rejects before the signal ships enabled; anchors live in config, not Go constants.
5. Ranking & verdict integration (pure, Layer 1)¶
Best-of-burst extends its deterministic preference chain (R-EYE-6):
R-RANK-1(MUST) eyes-open ▸ expression ▸ aesthetic ▸ sharpness — an open-eyed frame beats a blink; among open-eyed frames the better worst-face smile wins (when known); then the higher aesthetic (when known); then sharpness. Unknown values skip their tier (today's behaviour unchanged when the providers are off,R-FACE-7mould). Golden unit tests pin every tier.R-RANK-2(MUST) Demotion reasons stay explainable: a burst loser names the tier that decided it ("kept the smiling frame", "kept the stronger frame") — and both signals land inverdicts.yamlreasons only when they acted (0004§8.1 polish mould).R-CAP-1(MUST) All four new signal fields persist in the analysis cache and therefore in the decision-capture substrate (0005R-CAP-3) — the learner sees smile/facing/aesthetic alongside sharpness/eyes from day one, even while verdict-neutral. Never written to XMP (0006R-AES-4).
6. Implementation plan (the 0004 layering)¶
Layer 1 — pure, no models, ships on Linux now (ungated):
1. face.Face attributes + ExpressionSignal projection + fakes (R-EXPR-1/2/6).
2. pkg/analyze/aesthetic seam + fake (R-ASC-1).
3. cull.Signals/Profile fields, verdict semantics, ranking chain +
golden tests (R-EXPR-5, R-ASC-2, R-RANK-1/2); AnalysisVersion bump;
pipeline wiring (nil-safe, additive, R-FACE-7/8 mould).
Layer 2a — geometric expression (no new model; gated by §9-Q1):
4. smile.go/pose.go estimators in pkg/face/onnx from the existing
landmark pass; synthetic-fixture unit tests; INT_TEST calibration harness on
real frames → seed anchors (R-EXPR-3).
Layer 2b — the aesthetic adapter — DEFERRED to Phase 4 (0007-DEC-1):
The aesthetic Scorer seam, cull fields, ranking tier, verdict semantics and
analysis-cache slot ship now (Layer 1) but inert — no backend, scorer off.
The model adapter itself is deferred and, when built, is CLIP not NIMA:
5. NIMA registry entry + → Phase 4: pkg/aesthetic/onnxpkg/aesthetic/onnx
(or …/clip) on the shared runtime, aesthetic.* config, INT_TEST accuracy +
calibration (R-ASC-3/4) — built as the CLIP+aesthetic-head so the Phase-4
learner gets score and embedding from one pass.
6. Studio aesthetic toggles/knobs → Phase 4 with the backend (the expression
toggles ride the existing providers panel now).
7. Aesthetic-tiebreak BDD scenario → Phase 4 with the backend. The
expression burst scenarios (smile beats neutral; providers-off unchanged)
ship with Layer 2a.
0007-DEC-1 — aesthetic model deferred to Phase 4 (2026-07-03, Matt)¶
NIMA has no value beyond Phase 4 (CLIP supersedes it wholesale — score and
the embedding NIMA lacks), and no clean checksummable NIMA ONNX exists to pin
(R-MLR-7). Building it now is throwaway. Decision: defer the aesthetic model
adapter to Phase 4 and build it as CLIP directly. Everything with lasting
value — the pure Scorer seam, the nil-safe ranking/verdict/cache integration,
and the free-lunch expression estimators — ships in slice 7. The aesthetic
ranking tier is wired and inert (aesthetic.enabled has no backend to enable
yet); R-ASC-1/2/3/4 remain the contract the Phase-4 CLIP adapter satisfies.
Layer 3 — deferred: classifier expression strategy (R-EXPR-4); CLIP
scorer swap (Phase 4); CoreML EP on the M-series (0004 Layer 3, shared).
7. Non-goals¶
- Emotion taxonomy (happy/sad/angry…) — krites needs "strong frame of people at their best", not sentiment analysis.
- Iris-gaze tracking —
FacingCamerais a head-pose proxy; true gaze needs an iris model and isn't worth the surface yet. - Aesthetic as a hard gate — forbidden by design (
0006R-AES-2); a technically-imperfect but emotionally-strong frame is the photographer's call. - The Phase-4 learner itself — these signals only feed it (
0005).
8. Open questions — all resolved (2026-07-02, Matt)¶
- Q1 — expression approach. ✅ Geometric-from-landmarks (§3.2) — zero
new models, pure, calibratable; the FER+-class classifier stays a
config-selectable later strategy (
R-EXPR-4). Gates Layer 2a. - Q2 — aesthetic model. ⚠️ Superseded by
0007-DEC-1(2026-07-03). The "NIMA-MobileNet now" answer was reversed once the R-MLR-7 licence/hosting check found no clean NIMA ONNX and NIMA proved valueless beyond Phase 4: the model adapter is deferred to Phase 4 and built as CLIP directly. The seam ships now, inert. (Original reasoning retained in §4.2 for the CLIP rationale.) - Q3 — scoring scope. ✅ Every frame at cull — one-time (~3–4 min over 4,000 with NIMA), cached; Phase 4 needs the full distribution, rejects included.
- Q4 — expression default posture. ✅ Verdict-neutral (
R-EXPR-5confirmed) — ranking + capture only; solemn ≠ defect at a wedding; any stronger posture is earned from Hailey's own overrides (Phase 4) or opted into via the soft floors.