Skip to content

Make the face lane work on a real face - #28

Merged
logannye merged 5 commits into
mainfrom
feat/real-session-calibration
Jul 26, 2026
Merged

Make the face lane work on a real face#28
logannye merged 5 commits into
mainfrom
feat/real-session-calibration

Conversation

@logannye

Copy link
Copy Markdown
Owner

Five fixes driven by three real capture sessions. Every one was found by
instrumenting the pipeline and running it against an actual face rather than a
fixture, and each commit records the measurement that motivated it.

The starting symptom: a live 478-point mesh tracking a face for 70 seconds,
and a report where all 27 metrics read Not measurable. The report was correct.
The gates were not.

What the sessions measured

run 1 run 2 run 3
frames usable 78.0% 29.1% 52.9%
bins accepted 2 of 11 0 of 15 0 of 15
pitch p50 (limit 10°) 4.6° 8.8° 7.4°
blinks recorded 0 0 55

The fixes

dd670d6 — Tier-2 extraction was gated behind Tier-3 publication.
detectBlinks sat inside the branch that only ran when the blink metric passed
its 60-second exposure gate. A 54-second session correctly abstained and took
every blink it observed down with it. A publication threshold was suppressing
the extraction beneath it — the exact inversion of what the substrate is for.

079aed8 — Tier-2 detection read the pose-gated bins.
So when no bin qualified, nothing was extracted. The pose limits exist so
cross-frame geometric comparison stays valid; a blink is a relative aperture
change within one eye over ~150 ms and survives a 15° turn intact. Detection now
runs on a looser stream, and every event carries
poseWithinMeasurementLimits so anything comparing left to right can still
filter to the trustworthy set.

Result: 55 blinks ≈ 22/min over 74 s — squarely inside the normal
conversational range, and the first real-world validation of a threshold that
had only ever seen fixtures.

6d4e247 — restored the worklet processor name and two schema identifiers.
Renaming a runtime registration identifier broke live telemetry via browser
cache. Same category as the 20 phenometric.* schema strings deliberately left
alone during the rename; the rule was stated and then applied inconsistently.

6b758ee — unusable frames are dropped, not the whole bin.
The all-or-nothing gate discarded 82% of one session's bins to exclude 22% of
its frames. No new threshold replaces it: minimumDataPerBinMs of 4000 in a
5000 ms bin already is an 80% requirement. This also corrects an earlier
projection in this branch
— the acceptance curve omitted the span check, so
the claimed benefit was overstated. The curve now simulates span, and the tests
state the change's real (narrower) scope.

c3558f5 — pose gates measure deviation from the session's resting pose.
A camera below eye level reads as constant pitch that no amount of sitting still
removes. Resting bounds differ per axis on geometric grounds: yaw foreshortens
one side of the face and so biases asymmetry (10°); pitch is symmetric across
the midline (20°); roll is already cancelled by the coordinate system (15°).

Also fixes an incomplete decoupling — evaluateVisualQuality carries a second,
looser absolute pose gate that Tier-2 still inherited.

Contract changes

Pack 3.1.0 → 3.2.0. Policy fields renamed maximumAbsolute*
maximumYawDeviationDegrees and siblings, because the old names describe
something the code no longer does, plus three new resting bounds. Face
measurements are not interchangeable across this bump.

Also

A copy-diagnostics button on the report screen — calibration is an
iterate-and-rerun loop and console-only output cost a hand-copy or the whole
session each round. Clipboard on explicit click; no storage, no file, no
network.

Still open

The voice lane shows 0 segments across all three sessions: 32% of
speech-active frames fail the 15 dB SNR floor, fragmenting segmentation below
the 2-second minimum. Consistent proportion each run, so environmental rather
than code.

Verification

274 unit tests, typecheck, structure + digest check, production build, 6 browser
e2e.

🤖 Generated with Claude Code

logannye and others added 5 commits July 24, 2026 17:57
Found by a real 54-second session, which reported zero blinks.

detectBlinks sat inside the branch that only ran when the blink metric passed
its gates. The metric requires 60 seconds of frontal exposure; the session had
54, so it correctly abstained -- and took every blink actually observed down
with it. A publication threshold was suppressing the extraction beneath it,
which is the exact inversion of what Tier 2 is for. An abstaining metric must
still leave its observations behind, or the substrate only records sessions
that did not need it.

Detection now runs whenever there are bins, and returns empty rather than
reaching a percentile of an empty set when there are none.

Also instruments what the diagnostics could not previously explain:

- per-bin usable fraction and the largest gap between usable frames
- an acceptance curve simulating a fractional frame gate
- the voice lane, which had no diagnostics at all

The acceptance curve exists because a fractional gate is NOT obviously the fix.
Dropping a burst of unusable frames leaves a hole where they were, and the
200 ms gap rule may simply become the new binding constraint. The curve reports
bins accepted, bins lost to that gap, and bins lost to sample count at each
candidate threshold, so the threshold gets chosen from a real session instead of
from my guess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A real 70-second session with a face in frame throughout produced 0 of 14
qualifying bins and, consequently, not one blink or expression. All 27 metrics
correctly reported Not measurable. The reporting was right; the coupling was
not.

Blink and expression detection read screening.bins, so they inherited a gate
built for a different purpose. The pose limits exist so CROSS-FRAME GEOMETRIC
COMPARISON stays valid -- comparing one corner against the other, measuring
asymmetry. A blink is not that. It is a relative aperture change within one eye
over about 150 ms, during which head pose is essentially constant, so it
survives a 15-degree turn intact. Holding detection to a standard designed for
asymmetry cost that session everything it contained.

Detection now runs on a looser stream: attribution, image quality, and geometry
completeness, without the pose and calibrated-scale gates. Every event carries
poseWithinMeasurementLimits, so rate and timing can use all of them while
anything comparing the two sides can filter to the trustworthy set. Detectors
emit a Detected* shape and whatever holds the pose stream annotates it --
geometry alone cannot assert that flag.

Published metrics are untouched. The blink rate stays bin-derived and remains
explicitly a rate over pose-qualified windows; these events answer the separate
question of what the session actually contained.

The session's other findings are recorded but not yet acted on: a fractional
frame gate will not rescue Tier 3 on its own, because at the ~50% usable
fraction the data shows, bins clear the sample-count minimum and then die on the
200 ms gap rule instead -- one of them by 2 ms.

Also renames the e2e mount path and the audio worklet processor, which the
PhenoMetrix rename missed because neither matched its patterns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tifiers

Live telemetry stopped after the previous commit. The worklet is served from
public/ at a fixed unhashed URL, so browsers cache it hard: a stale copy
registers "phenometric-voice-capture" while the fresh bundle asks for
"phenometrix-voice-capture", AudioWorkletNode throws, and the voice lane dies
without a visible error. The 70-second session before that commit produced 6972
voice frames, so the break is squarely that rename.

Renaming it was wrong on principle, not just in effect. It is a runtime
registration identifier -- the same category as the "phenometric.<name>.vN"
schema strings that were deliberately left alone during the rename, for exactly
this reason. The rule was stated and then applied inconsistently.

The worklet file is restored byte-for-byte from before the rename, so a browser
holding a cached copy matches the manifest again rather than failing its
integrity check. The explanation lives in voice-capture.ts, which Vite
content-hashes, instead of in the file that must not change.

The same blanket substitution had also corrupted two wire identifiers into
"phenometrix.static-assets.v1" and "phenometrix.voice-worklet-message.v1".
Both restored; all twenty schema identifiers verified intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Across three real sessions the all-or-nothing frame gate was the single reason
nothing was ever measurable: it discarded 82% of one session's bins to exclude
22% of its frames, and 15 of 15 in another. One glance away cost the
surrounding five seconds.

No new threshold replaces it, because the pack already carried one.
minimumDataPerBinMs of 4000 in a 5000 ms bin IS an 80% requirement; the
all-or-nothing rule was redundant with it and far stricter. Dropping bad frames
and letting the published requirement do its job makes the code enforce what
the pack always said. Every retained frame is individually pose-valid, so the
geometry stays sound -- the bin rests on less of it, which is precisely what
minimumDataPerBinMs and minimumSamplesPerBin exist to bound.

durationMs had to become honest first. It summed raw inter-frame gaps, so two
frames 200 ms apart contributed 200 ms of "data" while carrying two samples --
harmless while the gate guaranteed no holes, wrong the moment it stopped. Each
retained frame now represents one nominal step of observation.

CORRECTS AN EARLIER PROJECTION. The acceptance curve reported that a fractional
gate at 0.80 would have published metrics from the measured session. It would
not have: the simulation omitted the actualSpanMs >= 4800 check. Losing frames
from a bin EDGE shortens the usable span one-for-one, and the span rule allows
200 ms of slack against the data rule's 1000 ms. The curve now simulates span
too, and reports bins lost to it.

So the honest scope of this change is narrower than claimed: it recovers bins
that lost frames in a SCATTERED pattern, and nothing else. An edge burst is
rejected by span; a mid-bin burst is rejected by the gap rule. The measured
sessions lost frames in multi-second bursts, so this alone will not make them
measurable. The tests say so explicitly rather than using a loss pattern that
flatters the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three measured sessions failed the face lane on pose, and the third made the
cause plain: median pitch 7.4 degrees against a limit of 10, with the median
sitting at the ceiling before any head movement at all. That is a camera
mounted below eye level, which is constant and harmless, and the gate could not
tell it from the subject turning away, which is neither.

Limits now measure deviation from the session's resting pose -- the per-axis
median, computed from the session itself so it needs no capture-path or
contract change and adapts to how the participant actually sat.

How far that reference may itself sit from frontal is bounded per axis, on
geometric grounds rather than preference:

  yaw    10   rotation about the vertical axis foreshortens one side of the
              face and not the other, so a constant offset biases every
              left-versus-right measurement this system exists to make
  pitch   20   symmetric across the midline; moves both sides together and
              leaves asymmetry largely alone. Also the axis camera placement
              actually offsets
  roll    15   in-plane, and already cancelled by aligning the coordinate
              x-axis to the inter-eye line before anything is measured

A session outside those bounds has no admissible reference and falls back to
frontal rather than accepting an arbitrary baseline.

Fixes an incomplete decoupling from two commits ago: evaluateVisualQuality
carries a SECOND pose gate of its own, looser but still absolute, and Tier-2
detection read only its `.usable` flag. Events still vanished past 15 degrees
for the same reason and with the same consequence. Pose is now excluded
explicitly there while lighting, sharpness, and framing remain required, since
those do corrupt the landmarks a blink is measured from.

Pack moves to 3.2.0 and the policy fields are renamed from maximumAbsolute* to
maximumYawDeviationDegrees and siblings, because the old names now describe
something the code does not do. Face measurements under 3.1.0 and 3.2.0 are not
interchangeable.

Also adds a copy-diagnostics button to the report view. Calibration is an
iterate-and-rerun loop and console-only output cost a hand-copy or the whole
session each round. Clipboard on an explicit click is the same act as selecting
the text by hand: no storage, no file, no network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@logannye
logannye merged commit 08f576f into main Jul 26, 2026
6 checks passed
@logannye
logannye deleted the feat/real-session-calibration branch July 26, 2026 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant