mctrl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ """Hand landmark tracking.
2
+
3
+ One tracker instance per camera. MediaPipe's VIDEO running mode is used rather
4
+ than LIVE_STREAM because it is synchronous -- the pipeline stays a plain loop
5
+ instead of a callback maze -- while still carrying tracking state between frames,
6
+ which is what makes landmarks stable enough to point with.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import cv2
12
+ import mediapipe as mp
13
+ import numpy as np
14
+ from mediapipe.tasks import python as mp_tasks
15
+ from mediapipe.tasks.python import vision
16
+
17
+ from .. import geometry, models
18
+ from ..capture import Frame
19
+ from ..config import GestureConfig, TrackingConfig
20
+ from ..geometry import HandFeatures
21
+
22
+
23
+ def _to_array(landmarks: list) -> np.ndarray:
24
+ return np.array([[p.x, p.y, p.z] for p in landmarks], dtype=np.float32)
25
+
26
+
27
+ class HandTracker:
28
+ """Detects hands in a camera's frames and reports their measured features."""
29
+
30
+ def __init__(self, cfg: TrackingConfig, gestures: GestureConfig, mirrored: bool) -> None:
31
+ self._gestures = gestures
32
+ self._mirrored = mirrored
33
+ self._last_timestamp = -1
34
+ options = vision.HandLandmarkerOptions(
35
+ base_options=mp_tasks.BaseOptions(
36
+ model_asset_path=str(models.ensure("hand_landmarker.task"))
37
+ ),
38
+ running_mode=vision.RunningMode.VIDEO,
39
+ num_hands=cfg.max_hands,
40
+ min_hand_detection_confidence=cfg.hand_detection_confidence,
41
+ min_hand_presence_confidence=cfg.hand_presence_confidence,
42
+ min_tracking_confidence=cfg.hand_tracking_confidence,
43
+ )
44
+ self._landmarker = vision.HandLandmarker.create_from_options(options)
45
+
46
+ def process(self, frame: Frame) -> list[HandFeatures]:
47
+ image = mp.Image(
48
+ image_format=mp.ImageFormat.SRGB,
49
+ data=cv2.cvtColor(frame.image, cv2.COLOR_BGR2RGB),
50
+ )
51
+ # VIDEO mode rejects a timestamp that does not advance, which two frames
52
+ # landing in the same millisecond will do.
53
+ timestamp = max(frame.timestamp_ms, self._last_timestamp + 1)
54
+ self._last_timestamp = timestamp
55
+ result = self._landmarker.detect_for_video(image, timestamp)
56
+
57
+ hands: list[HandFeatures] = []
58
+ for index, image_landmarks in enumerate(result.hand_landmarks):
59
+ image_points = _to_array(image_landmarks)
60
+ world = (
61
+ _to_array(result.hand_world_landmarks[index])
62
+ if index < len(result.hand_world_landmarks)
63
+ else _aspect_corrected(image_points, frame.image.shape)
64
+ )
65
+ category = result.handedness[index][0]
66
+ seen = category.category_name
67
+ # A mirrored frame inverts what the model calls left and right, so the
68
+ # label is flipped back to the hand you are actually holding up.
69
+ physical = _flip(seen) if self._mirrored else seen
70
+ hands.append(
71
+ geometry.measure(
72
+ world=world,
73
+ image_points=image_points,
74
+ handedness=physical,
75
+ seen_handedness=seen,
76
+ score=float(category.score),
77
+ thresholds=self._gestures,
78
+ )
79
+ )
80
+ return hands
81
+
82
+ def close(self) -> None:
83
+ self._landmarker.close()
84
+
85
+
86
+ def _flip(label: str) -> str:
87
+ return {"Left": "Right", "Right": "Left"}.get(label, label)
88
+
89
+
90
+ def _aspect_corrected(points: np.ndarray, shape: tuple[int, ...]) -> np.ndarray:
91
+ """Make normalised image landmarks isotropic, for when world landmarks are absent.
92
+
93
+ Normalised x and y each span 0..1 over different pixel counts, so on a 16:9
94
+ frame a horizontal centimetre reads smaller than a vertical one. Scaling x
95
+ (and z, which shares x's scale) by the aspect ratio restores one common unit.
96
+ """
97
+ height, width = shape[0], shape[1]
98
+ aspect = width / max(height, 1)
99
+ scaled = points.copy()
100
+ scaled[:, 0] *= aspect
101
+ scaled[:, 2] *= aspect
102
+ return scaled