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.
- kinemotion/__init__.py +31 -6
- kinemotion/api.py +39 -598
- kinemotion/cli.py +2 -0
- kinemotion/cmj/__init__.py +5 -0
- kinemotion/cmj/analysis.py +621 -0
- kinemotion/cmj/api.py +563 -0
- kinemotion/cmj/cli.py +324 -0
- kinemotion/cmj/debug_overlay.py +457 -0
- kinemotion/cmj/joint_angles.py +307 -0
- kinemotion/cmj/kinematics.py +360 -0
- kinemotion/cmj/metrics_validator.py +767 -0
- kinemotion/cmj/validation_bounds.py +341 -0
- kinemotion/core/__init__.py +28 -0
- kinemotion/core/auto_tuning.py +71 -37
- kinemotion/core/cli_utils.py +60 -0
- kinemotion/core/debug_overlay_utils.py +385 -0
- kinemotion/core/determinism.py +83 -0
- kinemotion/core/experimental.py +103 -0
- kinemotion/core/filtering.py +9 -6
- kinemotion/core/formatting.py +75 -0
- kinemotion/core/metadata.py +231 -0
- kinemotion/core/model_downloader.py +172 -0
- kinemotion/core/pipeline_utils.py +433 -0
- kinemotion/core/pose.py +298 -141
- kinemotion/core/pose_landmarks.py +67 -0
- kinemotion/core/quality.py +393 -0
- kinemotion/core/smoothing.py +250 -154
- kinemotion/core/timing.py +247 -0
- kinemotion/core/types.py +42 -0
- kinemotion/core/validation.py +201 -0
- kinemotion/core/video_io.py +135 -50
- kinemotion/dropjump/__init__.py +1 -1
- kinemotion/dropjump/analysis.py +367 -182
- kinemotion/dropjump/api.py +665 -0
- kinemotion/dropjump/cli.py +156 -466
- kinemotion/dropjump/debug_overlay.py +136 -206
- kinemotion/dropjump/kinematics.py +232 -255
- kinemotion/dropjump/metrics_validator.py +240 -0
- kinemotion/dropjump/validation_bounds.py +157 -0
- kinemotion/models/__init__.py +0 -0
- kinemotion/models/pose_landmarker_lite.task +0 -0
- kinemotion-0.67.0.dist-info/METADATA +726 -0
- kinemotion-0.67.0.dist-info/RECORD +47 -0
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/WHEEL +1 -1
- kinemotion-0.10.6.dist-info/METADATA +0 -561
- kinemotion-0.10.6.dist-info/RECORD +0 -20
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
""
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
segment_weights = []
|
|
113
|
-
visibilities = []
|
|
233
|
+
segments: list = []
|
|
234
|
+
weights: list = []
|
|
235
|
+
visibilities: list = []
|
|
114
236
|
|
|
115
|
-
#
|
|
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 >
|
|
296
|
+
if vis > vis_threshold:
|
|
119
297
|
segments.append((x, y))
|
|
120
|
-
|
|
298
|
+
weights.append(0.08)
|
|
121
299
|
visibilities.append(vis)
|
|
122
300
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
313
|
+
for key in trunk_keys
|
|
128
314
|
if key in landmarks
|
|
129
315
|
for x, y, vis in [landmarks[key]]
|
|
130
|
-
if vis >
|
|
316
|
+
if vis > vis_threshold
|
|
131
317
|
]
|
|
132
|
-
if len(
|
|
133
|
-
trunk_x = float(np.mean([
|
|
134
|
-
trunk_y = float(np.mean([
|
|
135
|
-
trunk_vis = float(np.mean([
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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"]
|