Skip to content

Add brow geometry and per-eye lid closure to the facial palsy pack - #25

Merged
logannye merged 1 commit into
mainfrom
feat/brow-and-eye-closure
Jul 24, 2026
Merged

Add brow geometry and per-eye lid closure to the facial palsy pack#25
logannye merged 1 commit into
mainfrom
feat/brow-and-eye-closure

Conversation

@logannye

Copy link
Copy Markdown
Owner

Completes the two measurement items left unbuilt by #24. Five face metrics (22 → 27) and a brow-geometry report section.

Metric Purpose
brow_height.left / .right frontalis, per side
brow_height_asymmetry.signed the central-vs-peripheral discriminator
lid_closure_completeness.left / .right unilateral lagophthalmos

Brow is the safety-relevant one

Forehead sparing is what separates upper- from lower-motor-neuron facial weakness, and until now it wasn't measurable at all — docs/safety.md cites that absence as one of two reasons acute stroke screening is refused. It's graded as its own zone rather than folded into eye geometry, matching how Sunnybrook and eFACE score the face.

This is the first change to cross into the worker's feature extraction: three landmarks per brow arc (mirrored index pairs 70/300, 63/293, 105/334 — matching the eye and mouth convention already in FACE_LANDMARK_INDICES), averaged for stability, measured against the inter-eye axis. Both eye centres sit at y = 0 by construction, so negating the normalized y gives a height that's already scale-invariant and roll-corrected. There's a test asserting the value is unchanged across frame sizes.

Per-eye lid closure needed no pipeline change — eyeAperture already carries both sides. Completeness is referenced to that eye's own open state, so it's a within-eye ratio independent of face scale.

Two estimator decisions the fixtures forced

Both were bugs I'd written, caught by tests, and both are worth reading:

The closed reference is a per-bin minimum, not a low percentile of frames. At normal blink rates only a few percent of a bin's frames are mid-blink, so even P05 sits above the closed state — the metric would report an eye that never closes.

Across bins it's P25 of those minima, not the median. Closure is intermittent: a bin containing no blink reports its open value as its minimum, and a median over a mix of blink and no-blink bins lands between the two, reporting an eye that half-closes when it in fact closes fully. That's precisely what the 10 s blink fixture produced against 5 s bins.

A guard did its job

The guided-metric guard rejected eye_closure_completeness by matching /eye.closure/ — the vocabulary of the retired "close your eyes" exercise. I renamed to lid closure rather than weakening the guard. It's the better term anyway: lagophthalmos is specifically incomplete lid closure.

Contract

Pack version 2.0.0 → 3.0.0 with its SHA-256 recomputed and verified; report sections 9 → 10; MetricCodeSchema, .length() pins, and section tuple/enum/labels updated. browHeight is a new nullable field on FacialKinematicsFrameV1 — a derived bilateral scalar, consistent with eyeAperture. No landmark crosses any boundary.

Verification

structure gate + 233 unit tests (+11) + typecheck ×6 + build · 6/6 Playwright · 4/4 pytest

Same standing caveat: thresholds are engineering defaults, every metric carries clinicalValidation: "none", and nothing here is validated against a reference standard.

🤖 Generated with Claude Code

Completes the two measurement items left unbuilt by #24. Adds five face
metrics (22 -> 27) and a brow-geometry report section.

  brow_height.left / .right              frontalis, per side
  brow_height_asymmetry.signed
  lid_closure_completeness.left / .right unilateral lagophthalmos

Brow is the safety-relevant one. Forehead sparing is what separates upper-
from lower-motor-neuron facial weakness, and until now it was not measurable
at all — docs/safety.md cites that absence as one of two reasons acute stroke
screening is refused. It is graded as its own zone rather than folded into eye
geometry, matching how Sunnybrook and eFACE score the face.

This needs the visual pipeline, so it is the first change to cross into the
worker's feature extraction: three landmarks per brow arc (mirrored index
pairs 70/300, 63/293, 105/334, matching the eye and mouth convention already
in FACE_LANDMARK_INDICES), averaged for stability, measured against the
inter-eye axis. Both eye centres sit at y = 0 by construction, so negating the
normalized y gives a height that is already scale-invariant and roll-corrected
— there is a test asserting the value is unchanged across frame sizes.

Per-eye lid closure needs no pipeline change; eyeAperture already carries both
sides. Completeness is referenced to that eye's OWN open state, so it is a
within-eye ratio independent of face scale.

Two estimator decisions the fixtures forced, both worth keeping:

The closed reference is a per-bin MINIMUM, not a low percentile of frames. At
normal blink rates only a few percent of a bin's frames are mid-blink, so even
P05 sits above the closed state and would report an eye that never closes.

Across bins it is P25 of those minima, not the median. Closure is intermittent:
a bin containing no blink reports its open value as its minimum, and a median
over a mix of blink and no-blink bins lands between the two — reporting an eye
that half-closes when it in fact closes fully. That is exactly what the 10s
blink fixture produced against 5s bins, and the test caught it.

The guided-metric guard rejected "eye_closure_completeness" by matching
/eye.closure/, which is the vocabulary of the retired "close your eyes"
exercise. Renamed to lid closure rather than weakening the guard — and "lid
closure" is the correct term anyway, since lagophthalmos is specifically
incomplete lid closure.

Pack version 2.0.0 -> 3.0.0 with its digest recomputed; report sections 9 -> 10.

Verification: structure gate + 233 unit tests (+11) + typecheck x6 + build;
6/6 Playwright; 4/4 pytest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@logannye
logannye merged commit d98fb49 into main Jul 24, 2026
6 checks passed
@logannye
logannye deleted the feat/brow-and-eye-closure branch July 24, 2026 22:34
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