- 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
65 lines
1.6 KiB
Python
65 lines
1.6 KiB
Python
"""
|
|
Head pose estimation — detect nod, shake, and tilt from Face Mesh landmarks.
|
|
|
|
Velocity-based: compares nose tip position across consecutive frames.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from enum import Enum
|
|
from typing import Optional
|
|
|
|
import numpy as np
|
|
|
|
_NOSE_TIP = 1
|
|
_L_CHEEK = 234
|
|
_R_CHEEK = 454
|
|
|
|
NOD_THRESHOLD = 0.015
|
|
SHAKE_THRESHOLD = 0.015
|
|
TILT_THRESHOLD = 0.012
|
|
|
|
|
|
class HeadGesture(str, Enum):
|
|
NOD = "head_nod"
|
|
SHAKE = "head_shake"
|
|
TILT_LEFT = "head_tilt_left"
|
|
TILT_RIGHT = "head_tilt_right"
|
|
|
|
|
|
class HeadPoseEstimator:
|
|
"""Detect head gestures by comparing nose tip position across frames."""
|
|
|
|
def __init__(self) -> None:
|
|
self._prev: Optional[np.ndarray] = None
|
|
|
|
def update(self, face_landmarks: np.ndarray) -> Optional[HeadGesture]:
|
|
"""
|
|
Args:
|
|
face_landmarks: (478, 3) float32 — current frame.
|
|
|
|
Returns:
|
|
HeadGesture if detected, else None.
|
|
"""
|
|
nose = face_landmarks[_NOSE_TIP]
|
|
if self._prev is None:
|
|
self._prev = nose.copy()
|
|
return None
|
|
|
|
delta = nose - self._prev
|
|
self._prev = nose.copy()
|
|
|
|
dy = float(delta[1])
|
|
dx = float(delta[0])
|
|
|
|
l_cheek = face_landmarks[_L_CHEEK]
|
|
r_cheek = face_landmarks[_R_CHEEK]
|
|
roll = float(l_cheek[1] - r_cheek[1])
|
|
|
|
if abs(dy) > NOD_THRESHOLD and abs(dy) > abs(dx):
|
|
return HeadGesture.NOD
|
|
if abs(dx) > SHAKE_THRESHOLD and abs(dx) > abs(dy):
|
|
return HeadGesture.SHAKE
|
|
if abs(roll) > TILT_THRESHOLD:
|
|
return HeadGesture.TILT_LEFT if roll > 0 else HeadGesture.TILT_RIGHT
|
|
return None
|