raven/merlin/features/head_pose.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

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