hand-tracking-teleop 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.

Potentially problematic release.


This version of hand-tracking-teleop might be problematic. Click here for more details.

@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Kinematic mapper for converting hand landmarks to robot joint angles.
4
+
5
+ This module implements the core mapping algorithms that translate human hand
6
+ poses into robot joint configurations.
7
+ """
8
+
9
+ import numpy as np
10
+ import yaml
11
+ from typing import Dict, List, Optional, Tuple
12
+ from dataclasses import dataclass
13
+ import math
14
+
15
+ # Import hand landmarks from hand tracking module
16
+ import sys
17
+ from pathlib import Path
18
+ sys.path.append(str(Path(__file__).parent.parent))
19
+ from hand_tracking.hand_detector import HandLandmarks
20
+
21
+
22
+ @dataclass
23
+ class RobotJointState:
24
+ """Container for robot joint state."""
25
+ joint_names: List[str]
26
+ joint_positions: np.ndarray # Shape: (27,) for
27
+ timestamp: float
28
+
29
+
30
+ class KinematicMapper:
31
+ """Maps human hand poses to robot joint configurations."""
32
+
33
+ def __init__(self, config_path: Optional[str] = None, control_mode: str = "full_body"):
34
+ """
35
+ Initialize kinematic mapper with configuration.
36
+
37
+ Args:
38
+ config_path: Path to configuration file
39
+ control_mode: Control mode - "full_body" or "fingers_only"
40
+ """
41
+ self.config = self._load_config(config_path)
42
+ self.joint_names = self._get_joint_names()
43
+ self.control_mode = control_mode
44
+
45
+ # Previous joint states for smoothing
46
+ self.previous_joints = np.zeros(27)
47
+
48
+ def _load_config(self, config_path: Optional[str]) -> Dict:
49
+ """Load configuration from YAML file."""
50
+ if config_path is None:
51
+ # Use default config path
52
+ config_path = Path(__file__).parent.parent.parent / "config" / "robot_mapping_config.yaml"
53
+
54
+ if not Path(config_path).exists():
55
+ # Return default configuration
56
+ return self._get_default_config()
57
+
58
+ with open(config_path, 'r') as f:
59
+ return yaml.safe_load(f)
60
+
61
+ def _get_default_config(self) -> Dict:
62
+ """Get default configuration if file doesn't exist."""
63
+ return {
64
+ 'robot_mapping': {
65
+ 'mapping': {
66
+ 'workspace': {
67
+ 'x_range': [-0.8, 0.8],
68
+ 'y_range': [-0.6, 0.6],
69
+ 'z_range': [0.5, 1.5]
70
+ },
71
+ 'smoothing': {
72
+ 'joint_alpha': 0.9
73
+ }
74
+ },
75
+ 'joint_limits': {
76
+ 'shoulder_pan': [-3.14, 3.14],
77
+ 'shoulder_lift': [-1.57, 1.57],
78
+ 'elbow_flex': [-2.61, 0.0],
79
+ 'finger_joint': [0.0, 1.57]
80
+ }
81
+ }
82
+ }
83
+
84
+ def _get_joint_names(self) -> List[str]:
85
+ """Get ordered list of joint names."""
86
+ return [
87
+ # Left arm (7 DOF)
88
+ "left_shoulder_x", "left_shoulder_z", "left_shoulder_y",
89
+ "left_elbow_y", "left_wrist_z", "left_wrist_x", "left_wrist_y",
90
+ # Right arm (7 DOF)
91
+ "right_shoulder_x", "right_shoulder_z", "right_shoulder_y",
92
+ "right_elbow_y", "right_wrist_z", "right_wrist_x", "right_wrist_y",
93
+ # Torso (1 DOF)
94
+ "Torso_lin_z",
95
+ # Left hand (6 DOF)
96
+ "left_thumb_finger_proximal_pitch", "left_thumb_finger_proximal_yaw",
97
+ "left_index_finger_proximal", "left_middle_finger_proximal",
98
+ "left_ring_finger_proximal", "left_pinky_finger_proximal",
99
+ # Right hand (6 DOF)
100
+ "right_thumb_finger_proximal_pitch", "right_thumb_finger_proximal_yaw",
101
+ "right_index_finger_proximal", "right_middle_finger_proximal",
102
+ "right_ring_finger_proximal", "right_pinky_finger_proximal"
103
+ ]
104
+
105
+ def set_control_mode(self, mode: str):
106
+ """
107
+ Set the control mode.
108
+
109
+ Args:
110
+ mode: Control mode - "full_body" or "fingers_only"
111
+ """
112
+ valid_modes = ["full_body", "fingers_only"]
113
+ if mode not in valid_modes:
114
+ raise ValueError(f"Invalid control mode: {mode}. Valid modes: {valid_modes}")
115
+ self.control_mode = mode
116
+ print(f"Control mode set to: {mode}")
117
+
118
+ def map_hands_to_joints(self, hands: List[HandLandmarks]) -> RobotJointState:
119
+ """
120
+ Main mapping function: convert hand landmarks to robot joint angles.
121
+
122
+ Args:
123
+ hands: List of detected hand landmarks
124
+
125
+ Returns:
126
+ RobotJointState with joint positions for all 27 DOF
127
+ """
128
+ # Initialize joint positions
129
+ joint_positions = np.zeros(27)
130
+
131
+ # Separate left and right hands
132
+ left_hand = None
133
+ right_hand = None
134
+
135
+ for hand in hands:
136
+ if hand.handedness == "Left":
137
+ left_hand = hand
138
+ elif hand.handedness == "Right":
139
+ right_hand = hand
140
+
141
+ # Apply control mode logic
142
+ if self.control_mode == "full_body":
143
+ # Map both arms and hands when detected
144
+ if left_hand is not None:
145
+ left_arm_joints = self._map_hand_to_arm(left_hand, "left")
146
+ left_finger_joints = self._map_hand_to_fingers(left_hand, "left")
147
+ joint_positions[0:7] = left_arm_joints # Left arm
148
+ joint_positions[15:21] = left_finger_joints # Left fingers
149
+
150
+ if right_hand is not None:
151
+ right_arm_joints = self._map_hand_to_arm(right_hand, "right")
152
+ right_finger_joints = self._map_hand_to_fingers(right_hand, "right")
153
+ joint_positions[7:14] = right_arm_joints # Right arm
154
+ joint_positions[21:27] = right_finger_joints # Right fingers
155
+
156
+ elif self.control_mode == "fingers_only":
157
+ # Only map fingers, keep arms at current position
158
+ if left_hand is not None:
159
+ left_finger_joints = self._map_hand_to_fingers(left_hand, "left")
160
+ joint_positions[15:21] = left_finger_joints # Left fingers
161
+
162
+ if right_hand is not None:
163
+ right_finger_joints = self._map_hand_to_fingers(right_hand, "right")
164
+ joint_positions[21:27] = right_finger_joints # Right fingers
165
+
166
+ # Set torso joint (index 14) - keep neutral for now
167
+ joint_positions[14] = 0.0
168
+
169
+ # Apply smoothing
170
+ joint_positions = self._apply_smoothing(joint_positions)
171
+
172
+ # Apply joint limits
173
+ joint_positions = self._apply_joint_limits(joint_positions)
174
+
175
+ return RobotJointState(
176
+ joint_names=self.joint_names,
177
+ joint_positions=joint_positions,
178
+ timestamp=0.0 # Will be set by caller
179
+ )
180
+
181
+ def _map_hand_to_arm(self, hand: HandLandmarks, side: str) -> np.ndarray:
182
+ """Map hand position and orientation to arm joint angles."""
183
+ landmarks = hand.landmarks
184
+
185
+ # Get key points
186
+ wrist = landmarks[0] # Wrist
187
+
188
+ # Calculate hand position relative to camera
189
+ hand_pos = self._landmarks_to_world_position(wrist)
190
+ hand_orientation = self._calculate_hand_orientation(landmarks)
191
+
192
+ # Simple mapping to arm joints
193
+ arm_joints = np.zeros(7)
194
+
195
+ # Shoulder pan: based on hand x position
196
+ arm_joints[0] = np.clip(hand_pos[0] * 2.0, -1.5, 1.5) # shoulder_pan
197
+
198
+ # Shoulder lift: based on hand y position
199
+ arm_joints[1] = np.clip(-hand_pos[1] * 1.5, -1.0, 1.0) # shoulder_lift
200
+
201
+ # Upper arm roll: based on hand orientation
202
+ arm_joints[2] = hand_orientation[2] * 0.5 # upper_arm_roll
203
+
204
+ # Elbow flex: based on hand distance (closer = more bent)
205
+ hand_distance = np.linalg.norm(hand_pos)
206
+ elbow_flex = np.clip((1.0 - hand_distance) * 2.0, -2.0, 0.0)
207
+ arm_joints[3] = elbow_flex # elbow_flex
208
+
209
+ # Wrist joints: based on hand orientation
210
+ arm_joints[4] = hand_orientation[0] * 0.8 # forearm_roll
211
+ arm_joints[5] = hand_orientation[1] * 0.6 # wrist_flex
212
+ arm_joints[6] = hand_orientation[2] * 0.4 # wrist_roll
213
+
214
+ return arm_joints
215
+
216
+ def _map_hand_to_fingers(self, hand: HandLandmarks, side: str) -> np.ndarray:
217
+ """Map hand landmarks to finger joint angles."""
218
+ landmarks = hand.landmarks
219
+ finger_joints = np.zeros(6)
220
+
221
+ # Simple finger mapping based on finger curl
222
+ wrist = landmarks[0] # Wrist
223
+
224
+ # Thumb (2 DOF) - pitch and yaw
225
+ thumb_tip = landmarks[4] # Thumb tip
226
+ thumb_mcp = landmarks[2] # Thumb MCP
227
+ thumb_curl = self._calculate_finger_curl(wrist, thumb_mcp, thumb_tip)
228
+ finger_joints[0] = np.clip(thumb_curl * 3.0, 0.0, 1.5) # thumb_proximal_pitch (increased scale)
229
+ finger_joints[1] = np.clip(thumb_curl * 2.0, 0.0, 1.5) # thumb_proximal_yaw (increased scale)
230
+
231
+ # Index finger (1 DOF)
232
+ index_tip = landmarks[8] # Index tip
233
+ index_mcp = landmarks[5] # Index MCP
234
+ index_curl = self._calculate_finger_curl(wrist, index_mcp, index_tip)
235
+ finger_joints[2] = np.clip(index_curl * 2.5, 0.0, 1.5) # index_proximal (increased scale)
236
+
237
+ # Middle finger (1 DOF)
238
+ middle_tip = landmarks[12] # Middle tip
239
+ middle_mcp = landmarks[9] # Middle MCP
240
+ middle_curl = self._calculate_finger_curl(wrist, middle_mcp, middle_tip)
241
+ finger_joints[3] = np.clip(middle_curl * 2.5, 0.0, 1.5) # middle_proximal (increased scale)
242
+
243
+ # Ring finger (1 DOF)
244
+ ring_tip = landmarks[16] # Ring tip
245
+ ring_mcp = landmarks[13] # Ring MCP
246
+ ring_curl = self._calculate_finger_curl(wrist, ring_mcp, ring_tip)
247
+ finger_joints[4] = np.clip(ring_curl * 2.5, 0.0, 1.5) # ring_proximal (increased scale)
248
+
249
+ # Pinky finger (1 DOF)
250
+ pinky_tip = landmarks[20] # Pinky tip
251
+ pinky_mcp = landmarks[17] # Pinky MCP
252
+ pinky_curl = self._calculate_finger_curl(wrist, pinky_mcp, pinky_tip)
253
+ finger_joints[5] = np.clip(pinky_curl * 2.5, 0.0, 1.5) # pinky_proximal (increased scale)
254
+
255
+ return finger_joints
256
+
257
+ def _landmarks_to_world_position(self, wrist_landmark: np.ndarray) -> np.ndarray:
258
+ """Convert wrist landmark to world position."""
259
+ workspace = self.config['robot_mapping']['mapping']['workspace']
260
+
261
+ # Map from image coordinates to world coordinates
262
+ x = (wrist_landmark[0] / 640.0 - 0.5) * 2.0 # Normalize to [-1, 1]
263
+ y = (wrist_landmark[1] / 480.0 - 0.5) * 2.0 # Normalize to [-1, 1]
264
+ z = wrist_landmark[2] # Use MediaPipe's depth estimate
265
+
266
+ # Scale to workspace
267
+ world_pos = np.array([
268
+ x * (workspace['x_range'][1] - workspace['x_range'][0]) / 2.0,
269
+ y * (workspace['y_range'][1] - workspace['y_range'][0]) / 2.0,
270
+ z * (workspace['z_range'][1] - workspace['z_range'][0]) / 2.0 + workspace['z_range'][0]
271
+ ])
272
+
273
+ return world_pos
274
+
275
+ def _calculate_hand_orientation(self, landmarks: np.ndarray) -> np.ndarray:
276
+ """Calculate hand orientation from landmarks."""
277
+ # Use wrist, middle MCP, and index MCP to define hand orientation
278
+ wrist = landmarks[0]
279
+ middle_mcp = landmarks[9]
280
+ index_mcp = landmarks[5]
281
+
282
+ # Calculate vectors
283
+ palm_vector = middle_mcp - wrist
284
+ finger_vector = index_mcp - wrist
285
+
286
+ # Calculate orientation angles (simplified)
287
+ roll = np.arctan2(palm_vector[1], palm_vector[0])
288
+ pitch = np.arctan2(palm_vector[2], np.linalg.norm(palm_vector[:2]))
289
+ yaw = np.arctan2(finger_vector[1], finger_vector[0])
290
+
291
+ return np.array([roll, pitch, yaw])
292
+
293
+ def _calculate_finger_curl(self, wrist, mcp, tip):
294
+ """Calculate finger curl based on distance ratios."""
295
+ # Calculate distances (landmarks are numpy arrays with [x, y, z])
296
+ wrist_to_mcp = np.sqrt((mcp[0] - wrist[0])**2 + (mcp[1] - wrist[1])**2)
297
+ mcp_to_tip = np.sqrt((tip[0] - mcp[0])**2 + (tip[1] - mcp[1])**2)
298
+ wrist_to_tip = np.sqrt((tip[0] - wrist[0])**2 + (tip[1] - wrist[1])**2)
299
+
300
+ # Calculate expected distance if finger was straight
301
+ expected_distance = wrist_to_mcp + mcp_to_tip
302
+
303
+ # Curl ratio: 0 = straight, 1 = fully curled
304
+ if expected_distance > 0:
305
+ curl_ratio = 1.0 - (wrist_to_tip / expected_distance)
306
+ # Apply exponential scaling to make small curls more noticeable
307
+ curl_ratio = curl_ratio ** 0.7 # Makes small values larger
308
+ return max(0.0, min(1.0, curl_ratio))
309
+ return 0.0
310
+
311
+ def _calculate_finger_angles(self, landmarks: np.ndarray, finger_indices: List[int]) -> np.ndarray:
312
+ """Calculate finger joint angles from landmarks."""
313
+ if len(finger_indices) < 3:
314
+ return np.array([0.0])
315
+
316
+ angles = []
317
+
318
+ # Calculate angles between consecutive finger segments
319
+ for i in range(len(finger_indices) - 2):
320
+ p1 = landmarks[finger_indices[i]]
321
+ p2 = landmarks[finger_indices[i + 1]]
322
+ p3 = landmarks[finger_indices[i + 2]]
323
+
324
+ # Calculate vectors
325
+ v1 = p1 - p2
326
+ v2 = p3 - p2
327
+
328
+ # Calculate angle between vectors
329
+ cos_angle = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-6)
330
+ angle = np.arccos(np.clip(cos_angle, -1.0, 1.0))
331
+
332
+ # Convert to joint angle (0 = straight, positive = bent)
333
+ joint_angle = np.pi - angle
334
+ angles.append(joint_angle)
335
+
336
+ return np.array(angles)
337
+
338
+ def _apply_smoothing(self, joint_positions: np.ndarray) -> np.ndarray:
339
+ """Apply temporal smoothing to joint positions."""
340
+ alpha = self.config['robot_mapping']['mapping']['smoothing']['joint_alpha']
341
+
342
+ # Exponential moving average
343
+ smoothed = alpha * self.previous_joints + (1 - alpha) * joint_positions
344
+ self.previous_joints = smoothed.copy()
345
+
346
+ return smoothed
347
+
348
+ def _apply_joint_limits(self, joint_positions: np.ndarray) -> np.ndarray:
349
+ """Apply joint limits to prevent invalid configurations."""
350
+ limits = self.config['robot_mapping']['joint_limits']
351
+
352
+ # Apply limits to different joint types
353
+ for i, joint_name in enumerate(self.joint_names):
354
+ if 'shoulder_pan' in joint_name:
355
+ joint_positions[i] = np.clip(joint_positions[i],
356
+ limits['shoulder_pan'][0], limits['shoulder_pan'][1])
357
+ elif 'shoulder_lift' in joint_name:
358
+ joint_positions[i] = np.clip(joint_positions[i],
359
+ limits['shoulder_lift'][0], limits['shoulder_lift'][1])
360
+ elif 'elbow_flex' in joint_name:
361
+ joint_positions[i] = np.clip(joint_positions[i],
362
+ limits['elbow_flex'][0], limits['elbow_flex'][1])
363
+ elif any(finger in joint_name for finger in ['thumb', 'index', 'middle', 'ring', 'pinky']):
364
+ joint_positions[i] = np.clip(joint_positions[i],
365
+ limits['finger_joint'][0], limits['finger_joint'][1])
366
+
367
+ return joint_positions
368
+
369
+ def get_joint_names(self) -> List[str]:
370
+ """Get the ordered list of joint names."""
371
+ return self.joint_names.copy()
372
+
373
+ def get_available_modes(self) -> List[str]:
374
+ """Get list of available control modes."""
375
+ return ["full_body", "fingers_only"]
376
+
377
+ def get_current_mode(self) -> str:
378
+ """Get current control mode."""
379
+ return self.control_mode
380
+
381
+ def is_ik_compatible(self) -> bool:
382
+ """Check if current mode is compatible with IK control."""
383
+ return self.control_mode == "full_body"
384
+
385
+ def requires_both_hands(self) -> bool:
386
+ """Check if current mode requires both hands for IK."""
387
+ return False # Individual planning groups work with any number of hands
388
+
389
+ def print_joint_state(self, joint_state: RobotJointState):
390
+ """Print joint state in a readable format."""
391
+ print(f"Robot Joint State - Mode: {self.control_mode}")
392
+ print("-" * 50)
393
+
394
+ for i, (name, pos) in enumerate(zip(joint_state.joint_names, joint_state.joint_positions)):
395
+ if abs(pos) > 0.001: # Only print non-zero joints
396
+ print(f"{i:2d}. {name:<25} : {pos:6.3f} rad ({np.degrees(pos):6.1f}°)")
397
+
398
+
399
+ if __name__ == "__main__":
400
+ # Test the kinematic mapper
401
+ mapper = KinematicMapper()
402
+ print("Kinematic mapper initialized successfully!")
403
+ print(f"Joint names: {len(mapper.get_joint_names())}")
404
+ for i, name in enumerate(mapper.get_joint_names()):
405
+ print(f"{i:2d}. {name}")