From a31e6099c60929dc1bf28da4e967e3fa03200173 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 26 Apr 2026 20:08:05 -0700 Subject: [PATCH] feat(input/gestures): implement HandsDetector wrapping mediapipe Hands --- circuitforge_core/input/gestures/hands.py | 90 ++++++++++++++++++++ tests/test_input/test_gestures/test_hands.py | 82 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 circuitforge_core/input/gestures/hands.py create mode 100644 tests/test_input/test_gestures/test_hands.py diff --git a/circuitforge_core/input/gestures/hands.py b/circuitforge_core/input/gestures/hands.py new file mode 100644 index 0000000..d9ac3ce --- /dev/null +++ b/circuitforge_core/input/gestures/hands.py @@ -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() diff --git a/tests/test_input/test_gestures/test_hands.py b/tests/test_input/test_gestures/test_hands.py new file mode 100644 index 0000000..fffd469 --- /dev/null +++ b/tests/test_input/test_gestures/test_hands.py @@ -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