feat(input/gestures): implement HandsDetector wrapping mediapipe Hands

This commit is contained in:
pyr0ball 2026-04-26 20:08:05 -07:00
parent 5a4917d455
commit a31e6099c6
2 changed files with 172 additions and 0 deletions

View file

@ -0,0 +1,90 @@
"""
MediaPipe Hands wrapper.
Produces immutable HandLandmarks dataclasses from RGB video frames.
The caller is responsible for BGRRGB conversion before passing frames.
"""
from __future__ import annotations
from dataclasses import dataclass
import mediapipe as mp
import numpy as np
@dataclass(frozen=True)
class HandLandmarks:
"""Immutable snapshot of one detected hand."""
points: np.ndarray # shape (21, 3) — x, y, z in [0,1] normalized image space
handedness: str # 'Left' | 'Right' (mirror of physical hand)
confidence: float # [0.0, 1.0]
class HandsDetector:
"""
Thin wrapper around mediapipe.solutions.hands.Hands.
Usage:
detector = HandsDetector()
for frame_bgr in camera.frames():
frame_rgb = frame_bgr[:, :, ::-1]
hands = detector.detect(frame_rgb)
for hand in hands:
vec = normalize_hand(hand.points)
...
detector.close()
Or use as a context manager:
with HandsDetector() as detector:
...
"""
def __init__(
self,
max_hands: int = 2,
min_detection_confidence: float = 0.7,
min_tracking_confidence: float = 0.5,
) -> None:
self._hands = mp.solutions.hands.Hands(
static_image_mode=False,
max_num_hands=max_hands,
min_detection_confidence=min_detection_confidence,
min_tracking_confidence=min_tracking_confidence,
)
def detect(self, rgb_frame: np.ndarray) -> list[HandLandmarks]:
"""
Run hand detection on one RGB frame.
Args:
rgb_frame: (H, W, 3) uint8 RGB image.
Returns:
List of HandLandmarks, one per detected hand (up to max_hands).
Empty list if no hands detected.
"""
results = self._hands.process(rgb_frame)
if not results.multi_hand_landmarks:
return []
out: list[HandLandmarks] = []
for lm, hand in zip(results.multi_hand_landmarks, results.multi_handedness):
points = np.array([[p.x, p.y, p.z] for p in lm.landmark], dtype=np.float32)
out.append(
HandLandmarks(
points=points,
handedness=hand.classification[0].label,
confidence=float(hand.classification[0].score),
)
)
return out
def close(self) -> None:
self._hands.close()
def __enter__(self) -> HandsDetector:
return self
def __exit__(self, *_: object) -> None:
self.close()

View file

@ -0,0 +1,82 @@
import numpy as np
import pytest
from unittest.mock import MagicMock, patch
from circuitforge_core.input.gestures.hands import HandsDetector, HandLandmarks
def _make_mock_results(n_hands: int = 1):
"""Build a fake mediapipe result object with n_hands detected."""
mock_results = MagicMock()
if n_hands == 0:
mock_results.multi_hand_landmarks = None
mock_results.multi_handedness = None
return mock_results
hand_landmarks = []
handedness_list = []
for i in range(n_hands):
lm = MagicMock()
lm.landmark = [
MagicMock(x=float(j) / 100, y=float(j) / 200, z=0.0) for j in range(21)
]
hand_landmarks.append(lm)
hand = MagicMock()
hand.classification = [
MagicMock(label="Right" if i == 0 else "Left", score=0.95)
]
handedness_list.append(hand)
mock_results.multi_hand_landmarks = hand_landmarks
mock_results.multi_handedness = handedness_list
return mock_results
@patch("circuitforge_core.input.gestures.hands.mp")
def test_detect_returns_empty_when_no_hands(mock_mp):
mock_mp.solutions.hands.Hands.return_value.process.return_value = (
_make_mock_results(0)
)
detector = HandsDetector()
frame = np.zeros((480, 640, 3), dtype=np.uint8)
results = detector.detect(frame)
assert results == []
@patch("circuitforge_core.input.gestures.hands.mp")
def test_detect_returns_one_hand(mock_mp):
mock_mp.solutions.hands.Hands.return_value.process.return_value = (
_make_mock_results(1)
)
detector = HandsDetector()
frame = np.zeros((480, 640, 3), dtype=np.uint8)
results = detector.detect(frame)
assert len(results) == 1
h = results[0]
assert isinstance(h, HandLandmarks)
assert h.points.shape == (21, 3)
assert h.handedness == "Right"
assert 0.0 <= h.confidence <= 1.0
@patch("circuitforge_core.input.gestures.hands.mp")
def test_detect_returns_two_hands(mock_mp):
mock_mp.solutions.hands.Hands.return_value.process.return_value = (
_make_mock_results(2)
)
detector = HandsDetector()
frame = np.zeros((480, 640, 3), dtype=np.uint8)
results = detector.detect(frame)
assert len(results) == 2
@patch("circuitforge_core.input.gestures.hands.mp")
def test_handlandmarks_is_immutable(mock_mp):
mock_mp.solutions.hands.Hands.return_value.process.return_value = (
_make_mock_results(1)
)
detector = HandsDetector()
frame = np.zeros((480, 640, 3), dtype=np.uint8)
result = detector.detect(frame)[0]
with pytest.raises((AttributeError, TypeError)):
result.handedness = "Left" # frozen dataclass must reject mutation