- 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
62 lines
1.8 KiB
Python
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
|