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