kinemotion 0.10.6__py3-none-any.whl → 0.67.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 kinemotion might be problematic. Click here for more details.

Files changed (48) hide show
  1. kinemotion/__init__.py +31 -6
  2. kinemotion/api.py +39 -598
  3. kinemotion/cli.py +2 -0
  4. kinemotion/cmj/__init__.py +5 -0
  5. kinemotion/cmj/analysis.py +621 -0
  6. kinemotion/cmj/api.py +563 -0
  7. kinemotion/cmj/cli.py +324 -0
  8. kinemotion/cmj/debug_overlay.py +457 -0
  9. kinemotion/cmj/joint_angles.py +307 -0
  10. kinemotion/cmj/kinematics.py +360 -0
  11. kinemotion/cmj/metrics_validator.py +767 -0
  12. kinemotion/cmj/validation_bounds.py +341 -0
  13. kinemotion/core/__init__.py +28 -0
  14. kinemotion/core/auto_tuning.py +71 -37
  15. kinemotion/core/cli_utils.py +60 -0
  16. kinemotion/core/debug_overlay_utils.py +385 -0
  17. kinemotion/core/determinism.py +83 -0
  18. kinemotion/core/experimental.py +103 -0
  19. kinemotion/core/filtering.py +9 -6
  20. kinemotion/core/formatting.py +75 -0
  21. kinemotion/core/metadata.py +231 -0
  22. kinemotion/core/model_downloader.py +172 -0
  23. kinemotion/core/pipeline_utils.py +433 -0
  24. kinemotion/core/pose.py +298 -141
  25. kinemotion/core/pose_landmarks.py +67 -0
  26. kinemotion/core/quality.py +393 -0
  27. kinemotion/core/smoothing.py +250 -154
  28. kinemotion/core/timing.py +247 -0
  29. kinemotion/core/types.py +42 -0
  30. kinemotion/core/validation.py +201 -0
  31. kinemotion/core/video_io.py +135 -50
  32. kinemotion/dropjump/__init__.py +1 -1
  33. kinemotion/dropjump/analysis.py +367 -182
  34. kinemotion/dropjump/api.py +665 -0
  35. kinemotion/dropjump/cli.py +156 -466
  36. kinemotion/dropjump/debug_overlay.py +136 -206
  37. kinemotion/dropjump/kinematics.py +232 -255
  38. kinemotion/dropjump/metrics_validator.py +240 -0
  39. kinemotion/dropjump/validation_bounds.py +157 -0
  40. kinemotion/models/__init__.py +0 -0
  41. kinemotion/models/pose_landmarker_lite.task +0 -0
  42. kinemotion-0.67.0.dist-info/METADATA +726 -0
  43. kinemotion-0.67.0.dist-info/RECORD +47 -0
  44. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/WHEEL +1 -1
  45. kinemotion-0.10.6.dist-info/METADATA +0 -561
  46. kinemotion-0.10.6.dist-info/RECORD +0 -20
  47. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/entry_points.txt +0 -0
  48. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/licenses/LICENSE +0 -0
kinemotion/core/pose.py CHANGED
@@ -1,84 +1,208 @@
1
- """Pose tracking using MediaPipe Pose."""
1
+ """Pose tracking using MediaPipe Tasks API.
2
+
3
+ The MediaPipe Solutions API was removed in version 0.10.31.
4
+ This module now uses the Tasks API (PoseLandmarker).
5
+
6
+ Key differences from Solution API:
7
+ - Tasks API uses index-based landmark access (0-32) instead of enums
8
+ - Running modes: IMAGE, VIDEO, LIVE_STREAM
9
+ - No smooth_landmarks option (built into VIDEO mode)
10
+ - Has min_pose_presence_confidence parameter (no Solution API equivalent)
11
+
12
+ Configuration strategies for matching Solution API behavior:
13
+ - "video": Standard VIDEO mode with temporal smoothing
14
+ - "video_low_presence": VIDEO mode with lower min_pose_presence_confidence (0.2)
15
+ - "video_very_low_presence": VIDEO mode with very low min_pose_presence_confidence (0.1)
16
+ - "image": IMAGE mode (no temporal smoothing, relies on our smoothing)
17
+ """
18
+
19
+ from __future__ import annotations
2
20
 
3
21
  import cv2
4
22
  import mediapipe as mp
5
23
  import numpy as np
6
24
 
25
+ from .pose_landmarks import KINEMOTION_LANDMARKS, LANDMARK_INDICES
26
+ from .timing import NULL_TIMER, Timer
27
+
28
+ # Running modes
29
+ _RUNNING_MODES = {
30
+ "image": mp.tasks.vision.RunningMode.IMAGE, # type: ignore[attr-defined]
31
+ "video": mp.tasks.vision.RunningMode.VIDEO, # type: ignore[attr-defined]
32
+ }
33
+
34
+ # Strategy configurations
35
+ _STRATEGY_CONFIGS: dict[str, dict[str, float | str]] = {
36
+ "video": {
37
+ "min_pose_presence_confidence": 0.5,
38
+ "running_mode": "video",
39
+ },
40
+ "video_low_presence": {
41
+ "min_pose_presence_confidence": 0.2,
42
+ "running_mode": "video",
43
+ },
44
+ "video_very_low_presence": {
45
+ "min_pose_presence_confidence": 0.1,
46
+ "running_mode": "video",
47
+ },
48
+ "image": {
49
+ "min_pose_presence_confidence": 0.5,
50
+ "running_mode": "image",
51
+ },
52
+ }
53
+
7
54
 
8
55
  class PoseTracker:
9
- """Tracks human pose landmarks in video frames using MediaPipe."""
56
+ """Tracks human pose landmarks in video frames using MediaPipe Tasks API.
57
+
58
+ Args:
59
+ min_detection_confidence: Minimum confidence for pose detection (0.0-1.0)
60
+ min_tracking_confidence: Minimum confidence for pose tracking (0.0-1.0)
61
+ model_type: Model variant ("lite", "full", "heavy")
62
+ strategy: Configuration strategy ("video", "video_low_presence", "image")
63
+ timer: Optional Timer for measuring operations
64
+
65
+ Note: The Solution API's smooth_landmarks parameter cannot be replicated
66
+ exactly. VIDEO mode has built-in temporal smoothing that cannot be disabled.
67
+ """
10
68
 
11
- def __init__(
69
+ def __init__( # noqa: PLR0913
12
70
  self,
13
71
  min_detection_confidence: float = 0.5,
14
72
  min_tracking_confidence: float = 0.5,
15
- ):
16
- """
17
- Initialize the pose tracker.
73
+ model_type: str = "lite",
74
+ strategy: str = "video_low_presence",
75
+ timer: Timer | None = None,
76
+ ) -> None:
77
+ """Initialize the pose tracker."""
78
+ self.timer = timer or NULL_TIMER
79
+ self.mp_pose = mp.tasks.vision # type: ignore[attr-defined]
80
+ self.model_type = model_type
81
+ self.strategy = strategy
18
82
 
19
- Args:
20
- min_detection_confidence: Minimum confidence for pose detection
21
- min_tracking_confidence: Minimum confidence for pose tracking
22
- """
23
- self.mp_pose = mp.solutions.pose
24
- self.pose = self.mp_pose.Pose(
25
- min_detection_confidence=min_detection_confidence,
83
+ # Get strategy configuration
84
+ config = _STRATEGY_CONFIGS.get(strategy, _STRATEGY_CONFIGS["video_low_presence"])
85
+ min_pose_presence = config["min_pose_presence_confidence"]
86
+ running_mode_name = str(config["running_mode"])
87
+ running_mode = _RUNNING_MODES[running_mode_name]
88
+
89
+ # Get model path
90
+ from .model_downloader import get_model_path
91
+
92
+ model_path = str(get_model_path(model_type))
93
+
94
+ # Create base options
95
+ base_options = mp.tasks.BaseOptions(model_asset_path=model_path) # type: ignore[attr-defined]
96
+
97
+ # Create pose landmarker options
98
+ options = mp.tasks.vision.PoseLandmarkerOptions( # type: ignore[attr-defined]
99
+ base_options=base_options,
100
+ running_mode=running_mode,
101
+ min_pose_detection_confidence=min_detection_confidence,
102
+ min_pose_presence_confidence=min_pose_presence,
26
103
  min_tracking_confidence=min_tracking_confidence,
27
- model_complexity=1,
104
+ output_segmentation_masks=False,
28
105
  )
29
106
 
107
+ # Create the landmarker
108
+ with self.timer.measure("model_load"):
109
+ self.landmarker = self.mp_pose.PoseLandmarker.create_from_options(options)
110
+
111
+ self.running_mode = running_mode
112
+
30
113
  def process_frame(
31
- self, frame: np.ndarray
114
+ self,
115
+ frame: np.ndarray,
116
+ timestamp_ms: int = 0,
32
117
  ) -> dict[str, tuple[float, float, float]] | None:
33
- """
34
- Process a single frame and extract pose landmarks.
118
+ """Process a single frame and extract pose landmarks.
35
119
 
36
120
  Args:
37
121
  frame: BGR image frame
122
+ timestamp_ms: Frame timestamp in milliseconds (required for VIDEO mode)
38
123
 
39
124
  Returns:
40
125
  Dictionary mapping landmark names to (x, y, visibility) tuples,
41
126
  or None if no pose detected. Coordinates are normalized (0-1).
42
127
  """
43
128
  # Convert BGR to RGB
44
- rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
129
+ with self.timer.measure("frame_conversion"):
130
+ rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
131
+
132
+ # Create MediaPipe Image
133
+ with self.timer.measure("image_creation"):
134
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb_frame) # type: ignore[attr-defined]
45
135
 
46
136
  # Process the frame
47
- results = self.pose.process(rgb_frame)
137
+ with self.timer.measure("mediapipe_inference"):
138
+ if self.running_mode == mp.tasks.vision.RunningMode.VIDEO: # type: ignore[attr-defined]
139
+ results = self.landmarker.detect_for_video(mp_image, timestamp_ms)
140
+ else: # IMAGE mode
141
+ results = self.landmarker.detect(mp_image)
48
142
 
49
143
  if not results.pose_landmarks:
50
144
  return None
51
145
 
52
- # Extract key landmarks for feet tracking and CoM estimation
53
- landmarks = {}
54
- landmark_names = {
55
- # Feet landmarks
56
- self.mp_pose.PoseLandmark.LEFT_ANKLE: "left_ankle",
57
- self.mp_pose.PoseLandmark.RIGHT_ANKLE: "right_ankle",
58
- self.mp_pose.PoseLandmark.LEFT_HEEL: "left_heel",
59
- self.mp_pose.PoseLandmark.RIGHT_HEEL: "right_heel",
60
- self.mp_pose.PoseLandmark.LEFT_FOOT_INDEX: "left_foot_index",
61
- self.mp_pose.PoseLandmark.RIGHT_FOOT_INDEX: "right_foot_index",
62
- # Torso landmarks for CoM estimation
63
- self.mp_pose.PoseLandmark.LEFT_HIP: "left_hip",
64
- self.mp_pose.PoseLandmark.RIGHT_HIP: "right_hip",
65
- self.mp_pose.PoseLandmark.LEFT_SHOULDER: "left_shoulder",
66
- self.mp_pose.PoseLandmark.RIGHT_SHOULDER: "right_shoulder",
67
- # Additional landmarks for better CoM estimation
68
- self.mp_pose.PoseLandmark.NOSE: "nose",
69
- self.mp_pose.PoseLandmark.LEFT_KNEE: "left_knee",
70
- self.mp_pose.PoseLandmark.RIGHT_KNEE: "right_knee",
71
- }
72
-
73
- for landmark_id, name in landmark_names.items():
74
- lm = results.pose_landmarks.landmark[landmark_id]
75
- landmarks[name] = (lm.x, lm.y, lm.visibility)
146
+ # Extract landmarks (first pose only)
147
+ with self.timer.measure("landmark_extraction"):
148
+ landmarks = _extract_landmarks_from_results(results.pose_landmarks[0])
76
149
 
77
150
  return landmarks
78
151
 
79
152
  def close(self) -> None:
80
- """Release resources."""
81
- self.pose.close()
153
+ """Release resources.
154
+
155
+ Note: Tasks API landmarker doesn't have explicit close method.
156
+ Resources are released when the object is garbage collected.
157
+ """
158
+ pass
159
+
160
+
161
+ def _extract_landmarks_from_results(
162
+ pose_landmarks: mp.tasks.vision.components.containers.NormalizedLandmark, # type: ignore[valid-type]
163
+ ) -> dict[str, tuple[float, float, float]]:
164
+ """Extract kinemotion landmarks from pose landmarker result.
165
+
166
+ Args:
167
+ pose_landmarks: MediaPipe pose landmarks (list of 33 landmarks)
168
+
169
+ Returns:
170
+ Dictionary mapping landmark names to (x, y, visibility) tuples
171
+ """
172
+ landmarks: dict[str, tuple[float, float, float]] = {}
173
+
174
+ for name in KINEMOTION_LANDMARKS:
175
+ idx = LANDMARK_INDICES[name]
176
+ if idx < len(pose_landmarks):
177
+ lm = pose_landmarks[idx]
178
+ # Tasks API uses presence in addition to visibility
179
+ # Use visibility for consistency with Solution API
180
+ visibility = getattr(lm, "visibility", 1.0)
181
+ landmarks[name] = (lm.x, lm.y, visibility)
182
+
183
+ return landmarks
184
+
185
+
186
+ # Legacy compatibility aliases for Solution API enum values
187
+ class _LegacyPoseLandmark:
188
+ """Compatibility shim for Solution API enum values."""
189
+
190
+ LEFT_ANKLE = 27
191
+ RIGHT_ANKLE = 28
192
+ LEFT_HEEL = 29
193
+ RIGHT_HEEL = 30
194
+ LEFT_FOOT_INDEX = 31
195
+ RIGHT_FOOT_INDEX = 32
196
+ LEFT_HIP = 23
197
+ RIGHT_HIP = 24
198
+ LEFT_SHOULDER = 11
199
+ RIGHT_SHOULDER = 12
200
+ NOSE = 0
201
+ LEFT_KNEE = 25
202
+ RIGHT_KNEE = 26
203
+
204
+
205
+ PoseLandmark = _LegacyPoseLandmark
82
206
 
83
207
 
84
208
  def compute_center_of_mass(
@@ -106,115 +230,148 @@ def compute_center_of_mass(
106
230
  (x, y, visibility) tuple for estimated CoM position
107
231
  visibility = average visibility of all segments used
108
232
  """
109
- # Define segment representatives and their weights (as fraction of body mass)
110
- # Each segment uses midpoint or average of its bounding landmarks
111
- segments = []
112
- segment_weights = []
113
- visibilities = []
233
+ segments: list = []
234
+ weights: list = []
235
+ visibilities: list = []
114
236
 
115
- # Head segment: 8% (use nose as proxy)
237
+ # Add body segments
238
+ _add_head_segment(segments, weights, visibilities, landmarks, visibility_threshold)
239
+ _add_trunk_segment(segments, weights, visibilities, landmarks, visibility_threshold)
240
+
241
+ # Add bilateral limb segments
242
+ for side in ["left", "right"]:
243
+ _add_limb_segment(
244
+ segments,
245
+ weights,
246
+ visibilities,
247
+ landmarks,
248
+ side,
249
+ "hip",
250
+ "knee",
251
+ 0.10,
252
+ visibility_threshold,
253
+ )
254
+ _add_limb_segment(
255
+ segments,
256
+ weights,
257
+ visibilities,
258
+ landmarks,
259
+ side,
260
+ "knee",
261
+ "ankle",
262
+ 0.05,
263
+ visibility_threshold,
264
+ )
265
+ _add_foot_segment(segments, weights, visibilities, landmarks, side, visibility_threshold)
266
+
267
+ # Fallback if no segments found
268
+ if not segments:
269
+ if "left_hip" in landmarks and "right_hip" in landmarks:
270
+ lh_x, lh_y, lh_vis = landmarks["left_hip"]
271
+ rh_x, rh_y, rh_vis = landmarks["right_hip"]
272
+ return ((lh_x + rh_x) / 2, (lh_y + rh_y) / 2, (lh_vis + rh_vis) / 2)
273
+ return (0.5, 0.5, 0.0)
274
+
275
+ # Normalize weights and compute weighted average
276
+ total_weight = sum(weights)
277
+ normalized_weights = [w / total_weight for w in weights]
278
+
279
+ com_x = float(sum(p[0] * w for p, w in zip(segments, normalized_weights, strict=True)))
280
+ com_y = float(sum(p[1] * w for p, w in zip(segments, normalized_weights, strict=True)))
281
+ com_visibility = float(np.mean(visibilities)) if visibilities else 0.0
282
+
283
+ return (com_x, com_y, com_visibility)
284
+
285
+
286
+ def _add_head_segment(
287
+ segments: list,
288
+ weights: list,
289
+ visibilities: list,
290
+ landmarks: dict[str, tuple[float, float, float]],
291
+ vis_threshold: float,
292
+ ) -> None:
293
+ """Add head segment (8% body mass) if visible."""
116
294
  if "nose" in landmarks:
117
295
  x, y, vis = landmarks["nose"]
118
- if vis > visibility_threshold:
296
+ if vis > vis_threshold:
119
297
  segments.append((x, y))
120
- segment_weights.append(0.08)
298
+ weights.append(0.08)
121
299
  visibilities.append(vis)
122
300
 
123
- # Trunk segment: 50% (midpoint between shoulders and hips)
124
- trunk_landmarks = ["left_shoulder", "right_shoulder", "left_hip", "right_hip"]
125
- trunk_positions = [
301
+
302
+ def _add_trunk_segment(
303
+ segments: list,
304
+ weights: list,
305
+ visibilities: list,
306
+ landmarks: dict[str, tuple[float, float, float]],
307
+ vis_threshold: float,
308
+ ) -> None:
309
+ """Add trunk segment (50% body mass) if visible."""
310
+ trunk_keys = ["left_shoulder", "right_shoulder", "left_hip", "right_hip"]
311
+ trunk_pos = [
126
312
  (x, y, vis)
127
- for key in trunk_landmarks
313
+ for key in trunk_keys
128
314
  if key in landmarks
129
315
  for x, y, vis in [landmarks[key]]
130
- if vis > visibility_threshold
316
+ if vis > vis_threshold
131
317
  ]
132
- if len(trunk_positions) >= 2:
133
- trunk_x = float(np.mean([pos[0] for pos in trunk_positions]))
134
- trunk_y = float(np.mean([pos[1] for pos in trunk_positions]))
135
- trunk_vis = float(np.mean([pos[2] for pos in trunk_positions]))
318
+ if len(trunk_pos) >= 2:
319
+ trunk_x = float(np.mean([p[0] for p in trunk_pos]))
320
+ trunk_y = float(np.mean([p[1] for p in trunk_pos]))
321
+ trunk_vis = float(np.mean([p[2] for p in trunk_pos]))
136
322
  segments.append((trunk_x, trunk_y))
137
- segment_weights.append(0.50)
323
+ weights.append(0.50)
138
324
  visibilities.append(trunk_vis)
139
325
 
140
- # Thigh segment: 20% total (midpoint hip to knee for each leg)
141
- for side in ["left", "right"]:
142
- hip_key = f"{side}_hip"
143
- knee_key = f"{side}_knee"
144
- if hip_key in landmarks and knee_key in landmarks:
145
- hip_x, hip_y, hip_vis = landmarks[hip_key]
146
- knee_x, knee_y, knee_vis = landmarks[knee_key]
147
- if hip_vis > visibility_threshold and knee_vis > visibility_threshold:
148
- thigh_x = (hip_x + knee_x) / 2
149
- thigh_y = (hip_y + knee_y) / 2
150
- thigh_vis = (hip_vis + knee_vis) / 2
151
- segments.append((thigh_x, thigh_y))
152
- segment_weights.append(0.10) # 10% per leg
153
- visibilities.append(thigh_vis)
154
-
155
- # Lower leg segment: 10% total (midpoint knee to ankle for each leg)
156
- for side in ["left", "right"]:
157
- knee_key = f"{side}_knee"
158
- ankle_key = f"{side}_ankle"
159
- if knee_key in landmarks and ankle_key in landmarks:
160
- knee_x, knee_y, knee_vis = landmarks[knee_key]
161
- ankle_x, ankle_y, ankle_vis = landmarks[ankle_key]
162
- if knee_vis > visibility_threshold and ankle_vis > visibility_threshold:
163
- leg_x = (knee_x + ankle_x) / 2
164
- leg_y = (knee_y + ankle_y) / 2
165
- leg_vis = (knee_vis + ankle_vis) / 2
166
- segments.append((leg_x, leg_y))
167
- segment_weights.append(0.05) # 5% per leg
168
- visibilities.append(leg_vis)
169
-
170
- # Foot segment: 3% total (average of ankle, heel, foot_index)
171
- for side in ["left", "right"]:
172
- foot_keys = [f"{side}_ankle", f"{side}_heel", f"{side}_foot_index"]
173
- foot_positions = [
174
- (x, y, vis)
175
- for key in foot_keys
176
- if key in landmarks
177
- for x, y, vis in [landmarks[key]]
178
- if vis > visibility_threshold
179
- ]
180
- if foot_positions:
181
- foot_x = float(np.mean([pos[0] for pos in foot_positions]))
182
- foot_y = float(np.mean([pos[1] for pos in foot_positions]))
183
- foot_vis = float(np.mean([pos[2] for pos in foot_positions]))
184
- segments.append((foot_x, foot_y))
185
- segment_weights.append(0.015) # 1.5% per foot
186
- visibilities.append(foot_vis)
187
-
188
- # If no segments found, fall back to hip average
189
- if not segments:
190
- if "left_hip" in landmarks and "right_hip" in landmarks:
191
- lh_x, lh_y, lh_vis = landmarks["left_hip"]
192
- rh_x, rh_y, rh_vis = landmarks["right_hip"]
193
- return (
194
- (lh_x + rh_x) / 2,
195
- (lh_y + rh_y) / 2,
196
- (lh_vis + rh_vis) / 2,
197
- )
198
- # Ultimate fallback: center of frame
199
- return (0.5, 0.5, 0.0)
200
326
 
201
- # Normalize weights to sum to 1.0
202
- total_weight = sum(segment_weights)
203
- normalized_weights = [w / total_weight for w in segment_weights]
327
+ def _add_limb_segment(
328
+ segments: list,
329
+ weights: list,
330
+ visibilities: list,
331
+ landmarks: dict[str, tuple[float, float, float]],
332
+ side: str,
333
+ proximal_key: str,
334
+ distal_key: str,
335
+ segment_weight: float,
336
+ vis_threshold: float,
337
+ ) -> None:
338
+ """Add a limb segment (thigh or lower leg) if both endpoints visible."""
339
+ prox_full = f"{side}_{proximal_key}"
340
+ dist_full = f"{side}_{distal_key}"
204
341
 
205
- # Compute weighted average of segment positions
206
- com_x = float(
207
- sum(
208
- pos[0] * weight
209
- for pos, weight in zip(segments, normalized_weights, strict=True)
210
- )
211
- )
212
- com_y = float(
213
- sum(
214
- pos[1] * weight
215
- for pos, weight in zip(segments, normalized_weights, strict=True)
216
- )
217
- )
218
- com_visibility = float(np.mean(visibilities)) if visibilities else 0.0
342
+ if prox_full in landmarks and dist_full in landmarks:
343
+ px, py, pvis = landmarks[prox_full]
344
+ dx, dy, dvis = landmarks[dist_full]
345
+ if pvis > vis_threshold and dvis > vis_threshold:
346
+ seg_x = (px + dx) / 2
347
+ seg_y = (py + dy) / 2
348
+ seg_vis = (pvis + dvis) / 2
349
+ segments.append((seg_x, seg_y))
350
+ weights.append(segment_weight)
351
+ visibilities.append(seg_vis)
219
352
 
220
- return (com_x, com_y, com_visibility)
353
+
354
+ def _add_foot_segment(
355
+ segments: list,
356
+ weights: list,
357
+ visibilities: list,
358
+ landmarks: dict[str, tuple[float, float, float]],
359
+ side: str,
360
+ vis_threshold: float,
361
+ ) -> None:
362
+ """Add foot segment (1.5% body mass per foot) if visible."""
363
+ foot_keys = [f"{side}_ankle", f"{side}_heel", f"{side}_foot_index"]
364
+ foot_pos = [
365
+ (x, y, vis)
366
+ for key in foot_keys
367
+ if key in landmarks
368
+ for x, y, vis in [landmarks[key]]
369
+ if vis > vis_threshold
370
+ ]
371
+ if foot_pos:
372
+ foot_x = float(np.mean([p[0] for p in foot_pos]))
373
+ foot_y = float(np.mean([p[1] for p in foot_pos]))
374
+ foot_vis = float(np.mean([p[2] for p in foot_pos]))
375
+ segments.append((foot_x, foot_y))
376
+ weights.append(0.015)
377
+ visibilities.append(foot_vis)
@@ -0,0 +1,67 @@
1
+ """MediaPipe Pose landmark index constants for Tasks API.
2
+
3
+ The MediaPipe Tasks API uses index-based landmark access (0-32) instead of enums.
4
+ This module provides named constants for the 33 pose landmarks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ # MediaPipe Pose has 33 landmarks per pose
10
+ # See: https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker
11
+ LANDMARK_INDICES: dict[str, int] = {
12
+ # Face
13
+ "nose": 0,
14
+ "left_eye_inner": 1,
15
+ "left_eye": 2,
16
+ "left_eye_outer": 3,
17
+ "right_eye_inner": 4,
18
+ "right_eye": 5,
19
+ "right_eye_outer": 6,
20
+ "left_ear": 7,
21
+ "right_ear": 8,
22
+ "mouth_left": 9,
23
+ "mouth_right": 10,
24
+ # Upper body
25
+ "left_shoulder": 11,
26
+ "right_shoulder": 12,
27
+ "left_elbow": 13,
28
+ "right_elbow": 14,
29
+ "left_wrist": 15,
30
+ "right_wrist": 16,
31
+ "left_pinky": 17,
32
+ "right_pinky": 18,
33
+ "left_index": 19,
34
+ "right_index": 20,
35
+ "left_thumb": 21,
36
+ "right_thumb": 22,
37
+ # Lower body
38
+ "left_hip": 23,
39
+ "right_hip": 24,
40
+ "left_knee": 25,
41
+ "right_knee": 26,
42
+ "left_ankle": 27,
43
+ "right_ankle": 28,
44
+ "left_heel": 29,
45
+ "right_heel": 30,
46
+ "left_foot_index": 31,
47
+ "right_foot_index": 32,
48
+ }
49
+
50
+ # Landmarks used in kinemotion analysis
51
+ KINEMOTION_LANDMARKS = {
52
+ "nose",
53
+ "left_shoulder",
54
+ "right_shoulder",
55
+ "left_hip",
56
+ "right_hip",
57
+ "left_knee",
58
+ "right_knee",
59
+ "left_ankle",
60
+ "right_ankle",
61
+ "left_heel",
62
+ "right_heel",
63
+ "left_foot_index",
64
+ "right_foot_index",
65
+ }
66
+
67
+ __all__ = ["LANDMARK_INDICES", "KINEMOTION_LANDMARKS"]