feat(input/gestures): implement HandsDetector wrapping mediapipe Hands
This commit is contained in:
parent
5a4917d455
commit
a31e6099c6
2 changed files with 172 additions and 0 deletions
90
circuitforge_core/input/gestures/hands.py
Normal file
90
circuitforge_core/input/gestures/hands.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""
|
||||
MediaPipe Hands wrapper.
|
||||
|
||||
Produces immutable HandLandmarks dataclasses from RGB video frames.
|
||||
The caller is responsible for BGR→RGB 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()
|
||||
82
tests/test_input/test_gestures/test_hands.py
Normal file
82
tests/test_input/test_gestures/test_hands.py
Normal 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
|
||||
Loading…
Reference in a new issue