Add brow geometry and per-eye lid closure to the facial palsy pack - #25
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Completes the two measurement items left unbuilt by #24. Five face metrics (22 → 27) and a
brow-geometryreport section.brow_height.left/.rightbrow_height_asymmetry.signedlid_closure_completeness.left/.rightBrow 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.mdcites 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 aty = 0by 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 —
eyeAperturealready 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_completenessby 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.browHeightis a new nullable field onFacialKinematicsFrameV1— a derived bilateral scalar, consistent witheyeAperture. 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