raven/merlin/features/blink.py
pyr0ball 0dcc25164d feat(features): implement BlinkDetector, GazeEstimator, HeadPoseEstimator, HandGestureDetector
- BlinkDetector: EAR-based blink detection (left/right/both), 6 tests
- GazeEstimator: iris-to-eye-corner ratio gaze direction, frozen GazeDirection dataclass, 4 tests
- HeadPoseEstimator: velocity-based nod/shake/tilt detection (stateful, no tests — daemon smoke test)
- HandGestureDetector: normalize_hand + tip-distance open/pinch/fist classifier (no tests — daemon smoke test)
- TDD: blink and gaze followed RED→GREEN cycle; Black applied to all 6 files
2026-04-26 21:13:59 -07:00

62 lines
1.8 KiB
Python

"""
Blink detection from MediaPipe Face Mesh landmarks.
Uses the Eye Aspect Ratio (EAR) method: when the eye closes, the vertical
landmark distances shrink relative to the horizontal width, driving EAR toward 0.
"""
from __future__ import annotations
from enum import Enum
from typing import Optional
import numpy as np
_LEFT_EYE = [33, 160, 158, 133, 153, 144]
_RIGHT_EYE = [362, 385, 387, 263, 373, 380]
class BlinkEvent(str, Enum):
LEFT = "left_blink"
RIGHT = "right_blink"
BOTH = "both_blink"
def eye_aspect_ratio(landmarks: np.ndarray, indices: list[int]) -> float:
"""
EAR = (||p2-p6|| + ||p3-p5||) / (2 * ||p1-p4||)
~0.3 for open eye, ~0.0 for closed.
"""
p1, p2, p3, p4, p5, p6 = [landmarks[i] for i in indices]
vert_a = np.linalg.norm(p2 - p6)
vert_b = np.linalg.norm(p3 - p5)
horiz = np.linalg.norm(p1 - p4)
if horiz < 1e-6:
return 0.0
return float((vert_a + vert_b) / (2.0 * horiz))
class BlinkDetector:
"""Detect left, right, or both-eye blinks from face mesh landmarks."""
def __init__(self, threshold: float = 0.20) -> None:
self._threshold = threshold
def detect(self, face_landmarks: np.ndarray) -> Optional[BlinkEvent]:
"""
Args:
face_landmarks: (478, 3) float32 — MediaPipe Face Mesh with iris refinement.
Returns:
BlinkEvent if a blink is detected, else None.
"""
left_closed = eye_aspect_ratio(face_landmarks, _LEFT_EYE) < self._threshold
right_closed = eye_aspect_ratio(face_landmarks, _RIGHT_EYE) < self._threshold
if left_closed and right_closed:
return BlinkEvent.BOTH
if left_closed:
return BlinkEvent.LEFT
if right_closed:
return BlinkEvent.RIGHT
return None