kinemotion 0.1.0__py3-none-any.whl → 0.4.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 +3 -0
- kinemotion/cli.py +20 -0
- kinemotion/core/__init__.py +40 -0
- kinemotion/core/filtering.py +345 -0
- kinemotion/core/pose.py +221 -0
- {dropjump → kinemotion/core}/smoothing.py +144 -0
- kinemotion/core/video_io.py +122 -0
- kinemotion/dropjump/__init__.py +29 -0
- dropjump/contact_detection.py → kinemotion/dropjump/analysis.py +95 -4
- {dropjump → kinemotion/dropjump}/cli.py +98 -31
- dropjump/video_io.py → kinemotion/dropjump/debug_overlay.py +49 -140
- {dropjump → kinemotion/dropjump}/kinematics.py +27 -8
- {kinemotion-0.1.0.dist-info → kinemotion-0.4.0.dist-info}/METADATA +119 -33
- kinemotion-0.4.0.dist-info/RECORD +17 -0
- kinemotion-0.4.0.dist-info/entry_points.txt +2 -0
- dropjump/__init__.py +0 -3
- dropjump/pose_tracker.py +0 -74
- kinemotion-0.1.0.dist-info/RECORD +0 -12
- kinemotion-0.1.0.dist-info/entry_points.txt +0 -2
- {kinemotion-0.1.0.dist-info → kinemotion-0.4.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.1.0.dist-info → kinemotion-0.4.0.dist-info}/licenses/LICENSE +0 -0
kinemotion/__init__.py
ADDED
kinemotion/cli.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Command-line interface for kinemotion analysis."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from .dropjump.cli import dropjump_analyze
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
@click.version_option(package_name="dropjump-analyze")
|
|
10
|
+
def cli() -> None:
|
|
11
|
+
"""Kinemotion: Video-based kinematic analysis for athletic performance."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Register commands from submodules
|
|
16
|
+
cli.add_command(dropjump_analyze)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
cli()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Core functionality shared across all jump analysis types."""
|
|
2
|
+
|
|
3
|
+
from .filtering import (
|
|
4
|
+
adaptive_smooth_window,
|
|
5
|
+
bilateral_temporal_filter,
|
|
6
|
+
detect_outliers_median,
|
|
7
|
+
detect_outliers_ransac,
|
|
8
|
+
reject_outliers,
|
|
9
|
+
remove_outliers,
|
|
10
|
+
)
|
|
11
|
+
from .pose import PoseTracker, compute_center_of_mass
|
|
12
|
+
from .smoothing import (
|
|
13
|
+
compute_acceleration_from_derivative,
|
|
14
|
+
compute_velocity,
|
|
15
|
+
compute_velocity_from_derivative,
|
|
16
|
+
smooth_landmarks,
|
|
17
|
+
smooth_landmarks_advanced,
|
|
18
|
+
)
|
|
19
|
+
from .video_io import VideoProcessor
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
# Pose tracking
|
|
23
|
+
"PoseTracker",
|
|
24
|
+
"compute_center_of_mass",
|
|
25
|
+
# Smoothing
|
|
26
|
+
"smooth_landmarks",
|
|
27
|
+
"smooth_landmarks_advanced",
|
|
28
|
+
"compute_velocity",
|
|
29
|
+
"compute_velocity_from_derivative",
|
|
30
|
+
"compute_acceleration_from_derivative",
|
|
31
|
+
# Filtering
|
|
32
|
+
"detect_outliers_ransac",
|
|
33
|
+
"detect_outliers_median",
|
|
34
|
+
"remove_outliers",
|
|
35
|
+
"reject_outliers",
|
|
36
|
+
"adaptive_smooth_window",
|
|
37
|
+
"bilateral_temporal_filter",
|
|
38
|
+
# Video I/O
|
|
39
|
+
"VideoProcessor",
|
|
40
|
+
]
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Advanced filtering techniques for robust trajectory processing."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy.signal import medfilt
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def detect_outliers_ransac(
|
|
8
|
+
positions: np.ndarray,
|
|
9
|
+
window_size: int = 15,
|
|
10
|
+
threshold: float = 0.02,
|
|
11
|
+
min_inliers: float = 0.7,
|
|
12
|
+
) -> np.ndarray:
|
|
13
|
+
"""
|
|
14
|
+
Detect outlier positions using RANSAC-based polynomial fitting.
|
|
15
|
+
|
|
16
|
+
Uses a sliding window approach to detect positions that deviate significantly
|
|
17
|
+
from a polynomial fit of nearby points. This catches MediaPipe tracking glitches
|
|
18
|
+
where landmarks jump to incorrect positions.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
positions: 1D array of position values (e.g., y-coordinates)
|
|
22
|
+
window_size: Size of sliding window for local fitting
|
|
23
|
+
threshold: Distance threshold to consider a point an inlier
|
|
24
|
+
min_inliers: Minimum fraction of points that must be inliers
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Boolean array: True for outliers, False for valid points
|
|
28
|
+
"""
|
|
29
|
+
n = len(positions)
|
|
30
|
+
is_outlier = np.zeros(n, dtype=bool)
|
|
31
|
+
|
|
32
|
+
if n < window_size:
|
|
33
|
+
return is_outlier
|
|
34
|
+
|
|
35
|
+
# Ensure window size is odd
|
|
36
|
+
if window_size % 2 == 0:
|
|
37
|
+
window_size += 1
|
|
38
|
+
|
|
39
|
+
half_window = window_size // 2
|
|
40
|
+
|
|
41
|
+
for i in range(n):
|
|
42
|
+
# Define window around current point
|
|
43
|
+
start = max(0, i - half_window)
|
|
44
|
+
end = min(n, i + half_window + 1)
|
|
45
|
+
window_positions = positions[start:end]
|
|
46
|
+
window_indices = np.arange(start, end)
|
|
47
|
+
|
|
48
|
+
if len(window_positions) < 3:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
# Fit polynomial (quadratic) to window
|
|
52
|
+
# Use polyfit with degree 2 (parabolic motion)
|
|
53
|
+
try:
|
|
54
|
+
coeffs = np.polyfit(window_indices, window_positions, deg=2)
|
|
55
|
+
predicted = np.polyval(coeffs, window_indices)
|
|
56
|
+
|
|
57
|
+
# Calculate residuals
|
|
58
|
+
residuals = np.abs(window_positions - predicted)
|
|
59
|
+
|
|
60
|
+
# Point is outlier if its residual is large
|
|
61
|
+
local_idx = i - start
|
|
62
|
+
if local_idx < len(residuals) and residuals[local_idx] > threshold:
|
|
63
|
+
# Also check if most other points are inliers (RANSAC criterion)
|
|
64
|
+
inliers = np.sum(residuals <= threshold)
|
|
65
|
+
if inliers / len(residuals) >= min_inliers:
|
|
66
|
+
is_outlier[i] = True
|
|
67
|
+
except np.linalg.LinAlgError:
|
|
68
|
+
# Polyfit failed, skip this window
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
return is_outlier
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def detect_outliers_median(
|
|
75
|
+
positions: np.ndarray, window_size: int = 5, threshold: float = 0.03
|
|
76
|
+
) -> np.ndarray:
|
|
77
|
+
"""
|
|
78
|
+
Detect outliers using median filtering.
|
|
79
|
+
|
|
80
|
+
Points that deviate significantly from the local median are marked as outliers.
|
|
81
|
+
More robust to noise than mean-based methods.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
positions: 1D array of position values
|
|
85
|
+
window_size: Size of median filter window (must be odd)
|
|
86
|
+
threshold: Deviation threshold to mark as outlier
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Boolean array: True for outliers, False for valid points
|
|
90
|
+
"""
|
|
91
|
+
if len(positions) < window_size:
|
|
92
|
+
return np.zeros(len(positions), dtype=bool)
|
|
93
|
+
|
|
94
|
+
# Ensure window size is odd
|
|
95
|
+
if window_size % 2 == 0:
|
|
96
|
+
window_size += 1
|
|
97
|
+
|
|
98
|
+
# Apply median filter
|
|
99
|
+
median_filtered = medfilt(positions, kernel_size=window_size)
|
|
100
|
+
|
|
101
|
+
# Calculate absolute deviation from median
|
|
102
|
+
deviations = np.abs(positions - median_filtered)
|
|
103
|
+
|
|
104
|
+
# Mark as outlier if deviation exceeds threshold
|
|
105
|
+
is_outlier = deviations > threshold
|
|
106
|
+
|
|
107
|
+
return is_outlier # type: ignore[no-any-return]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def remove_outliers(
|
|
111
|
+
positions: np.ndarray,
|
|
112
|
+
outlier_mask: np.ndarray,
|
|
113
|
+
method: str = "interpolate",
|
|
114
|
+
) -> np.ndarray:
|
|
115
|
+
"""
|
|
116
|
+
Replace outlier values with interpolated or median values.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
positions: Original position array
|
|
120
|
+
outlier_mask: Boolean array indicating outliers
|
|
121
|
+
method: "interpolate" or "median"
|
|
122
|
+
- interpolate: Linear interpolation from neighboring valid points
|
|
123
|
+
- median: Replace with local median of valid points
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Position array with outliers replaced
|
|
127
|
+
"""
|
|
128
|
+
positions_clean = positions.copy()
|
|
129
|
+
|
|
130
|
+
if not np.any(outlier_mask):
|
|
131
|
+
return positions_clean
|
|
132
|
+
|
|
133
|
+
outlier_indices = np.where(outlier_mask)[0]
|
|
134
|
+
|
|
135
|
+
for idx in outlier_indices:
|
|
136
|
+
if method == "interpolate":
|
|
137
|
+
# Find nearest valid points before and after
|
|
138
|
+
valid_before = np.where(~outlier_mask[:idx])[0]
|
|
139
|
+
valid_after = np.where(~outlier_mask[idx + 1 :])[0]
|
|
140
|
+
|
|
141
|
+
if len(valid_before) > 0 and len(valid_after) > 0:
|
|
142
|
+
# Linear interpolation between nearest valid points
|
|
143
|
+
idx_before = valid_before[-1]
|
|
144
|
+
idx_after = valid_after[0] + idx + 1
|
|
145
|
+
|
|
146
|
+
# Interpolate
|
|
147
|
+
t = (idx - idx_before) / (idx_after - idx_before)
|
|
148
|
+
positions_clean[idx] = (
|
|
149
|
+
positions[idx_before] * (1 - t) + positions[idx_after] * t
|
|
150
|
+
)
|
|
151
|
+
elif len(valid_before) > 0:
|
|
152
|
+
# Use last valid value
|
|
153
|
+
positions_clean[idx] = positions[valid_before[-1]]
|
|
154
|
+
elif len(valid_after) > 0:
|
|
155
|
+
# Use next valid value
|
|
156
|
+
positions_clean[idx] = positions[valid_after[0] + idx + 1]
|
|
157
|
+
|
|
158
|
+
elif method == "median":
|
|
159
|
+
# Replace with median of nearby valid points
|
|
160
|
+
window_size = 5
|
|
161
|
+
start = max(0, idx - window_size)
|
|
162
|
+
end = min(len(positions), idx + window_size + 1)
|
|
163
|
+
|
|
164
|
+
window_valid = ~outlier_mask[start:end]
|
|
165
|
+
if np.any(window_valid):
|
|
166
|
+
positions_clean[idx] = np.median(positions[start:end][window_valid])
|
|
167
|
+
|
|
168
|
+
return positions_clean
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def reject_outliers(
|
|
172
|
+
positions: np.ndarray,
|
|
173
|
+
use_ransac: bool = True,
|
|
174
|
+
use_median: bool = True,
|
|
175
|
+
ransac_window: int = 15,
|
|
176
|
+
ransac_threshold: float = 0.02,
|
|
177
|
+
median_window: int = 5,
|
|
178
|
+
median_threshold: float = 0.03,
|
|
179
|
+
interpolate: bool = True,
|
|
180
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
181
|
+
"""
|
|
182
|
+
Comprehensive outlier rejection using multiple methods.
|
|
183
|
+
|
|
184
|
+
Combines RANSAC-based and median-based outlier detection for robust
|
|
185
|
+
identification of tracking glitches.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
positions: 1D array of position values
|
|
189
|
+
use_ransac: Enable RANSAC-based outlier detection
|
|
190
|
+
use_median: Enable median-based outlier detection
|
|
191
|
+
ransac_window: Window size for RANSAC
|
|
192
|
+
ransac_threshold: Deviation threshold for RANSAC
|
|
193
|
+
median_window: Window size for median filter
|
|
194
|
+
median_threshold: Deviation threshold for median
|
|
195
|
+
interpolate: Replace outliers with interpolated values
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Tuple of (cleaned_positions, outlier_mask)
|
|
199
|
+
- cleaned_positions: Positions with outliers replaced
|
|
200
|
+
- outlier_mask: Boolean array marking outliers
|
|
201
|
+
"""
|
|
202
|
+
outlier_mask = np.zeros(len(positions), dtype=bool)
|
|
203
|
+
|
|
204
|
+
# Detect outliers using RANSAC
|
|
205
|
+
if use_ransac:
|
|
206
|
+
ransac_outliers = detect_outliers_ransac(
|
|
207
|
+
positions, window_size=ransac_window, threshold=ransac_threshold
|
|
208
|
+
)
|
|
209
|
+
outlier_mask |= ransac_outliers
|
|
210
|
+
|
|
211
|
+
# Detect outliers using median filtering
|
|
212
|
+
if use_median:
|
|
213
|
+
median_outliers = detect_outliers_median(
|
|
214
|
+
positions, window_size=median_window, threshold=median_threshold
|
|
215
|
+
)
|
|
216
|
+
outlier_mask |= median_outliers
|
|
217
|
+
|
|
218
|
+
# Remove/replace outliers
|
|
219
|
+
if interpolate:
|
|
220
|
+
cleaned_positions = remove_outliers(
|
|
221
|
+
positions, outlier_mask, method="interpolate"
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
cleaned_positions = positions.copy()
|
|
225
|
+
|
|
226
|
+
return cleaned_positions, outlier_mask
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def adaptive_smooth_window(
|
|
230
|
+
positions: np.ndarray,
|
|
231
|
+
base_window: int = 5,
|
|
232
|
+
velocity_threshold: float = 0.02,
|
|
233
|
+
min_window: int = 3,
|
|
234
|
+
max_window: int = 11,
|
|
235
|
+
) -> np.ndarray:
|
|
236
|
+
"""
|
|
237
|
+
Determine adaptive smoothing window size based on local motion velocity.
|
|
238
|
+
|
|
239
|
+
Uses larger windows during slow motion (ground contact) and smaller windows
|
|
240
|
+
during fast motion (flight) to preserve details where needed while smoothing
|
|
241
|
+
where safe.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
positions: 1D array of position values
|
|
245
|
+
base_window: Base window size (default: 5)
|
|
246
|
+
velocity_threshold: Velocity below which to use larger window
|
|
247
|
+
min_window: Minimum window size (for fast motion)
|
|
248
|
+
max_window: Maximum window size (for slow motion)
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Array of window sizes for each frame
|
|
252
|
+
"""
|
|
253
|
+
n = len(positions)
|
|
254
|
+
windows = np.full(n, base_window, dtype=int)
|
|
255
|
+
|
|
256
|
+
if n < 2:
|
|
257
|
+
return windows
|
|
258
|
+
|
|
259
|
+
# Compute local velocity (simple diff)
|
|
260
|
+
velocities = np.abs(np.diff(positions, prepend=positions[0]))
|
|
261
|
+
|
|
262
|
+
# Smooth velocity to avoid spurious changes
|
|
263
|
+
if n >= 5:
|
|
264
|
+
from scipy.signal import medfilt
|
|
265
|
+
|
|
266
|
+
velocities = medfilt(velocities, kernel_size=5)
|
|
267
|
+
|
|
268
|
+
# Assign window sizes based on velocity
|
|
269
|
+
for i in range(n):
|
|
270
|
+
if velocities[i] < velocity_threshold / 2:
|
|
271
|
+
# Very slow motion - use maximum window
|
|
272
|
+
windows[i] = max_window
|
|
273
|
+
elif velocities[i] < velocity_threshold:
|
|
274
|
+
# Slow motion - use larger window
|
|
275
|
+
windows[i] = (base_window + max_window) // 2
|
|
276
|
+
else:
|
|
277
|
+
# Fast motion - use smaller window
|
|
278
|
+
windows[i] = min_window
|
|
279
|
+
|
|
280
|
+
# Ensure windows are odd
|
|
281
|
+
windows = np.where(windows % 2 == 0, windows + 1, windows)
|
|
282
|
+
|
|
283
|
+
return windows
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def bilateral_temporal_filter(
|
|
287
|
+
positions: np.ndarray,
|
|
288
|
+
window_size: int = 9,
|
|
289
|
+
sigma_spatial: float = 3.0,
|
|
290
|
+
sigma_intensity: float = 0.02,
|
|
291
|
+
) -> np.ndarray:
|
|
292
|
+
"""
|
|
293
|
+
Apply bilateral filter in temporal domain for edge-preserving smoothing.
|
|
294
|
+
|
|
295
|
+
Unlike Savitzky-Golay which smooths uniformly across all frames, the bilateral
|
|
296
|
+
filter preserves sharp transitions (like landing/takeoff) while smoothing within
|
|
297
|
+
smooth regions (flight phase, ground contact).
|
|
298
|
+
|
|
299
|
+
The filter weights each neighbor by both:
|
|
300
|
+
1. Temporal distance (like regular smoothing)
|
|
301
|
+
2. Intensity similarity (preserves edges)
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
positions: 1D array of position values
|
|
305
|
+
window_size: Temporal window size (must be odd)
|
|
306
|
+
sigma_spatial: Std dev for spatial (temporal) Gaussian kernel
|
|
307
|
+
sigma_intensity: Std dev for intensity (position difference) kernel
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Filtered position array
|
|
311
|
+
"""
|
|
312
|
+
n = len(positions)
|
|
313
|
+
filtered = np.zeros(n)
|
|
314
|
+
|
|
315
|
+
# Ensure window size is odd
|
|
316
|
+
if window_size % 2 == 0:
|
|
317
|
+
window_size += 1
|
|
318
|
+
|
|
319
|
+
half_window = window_size // 2
|
|
320
|
+
|
|
321
|
+
for i in range(n):
|
|
322
|
+
# Define window
|
|
323
|
+
start = max(0, i - half_window)
|
|
324
|
+
end = min(n, i + half_window + 1)
|
|
325
|
+
|
|
326
|
+
# Get window positions
|
|
327
|
+
window_pos = positions[start:end]
|
|
328
|
+
center_pos = positions[i]
|
|
329
|
+
|
|
330
|
+
# Compute spatial (temporal) weights
|
|
331
|
+
temporal_indices = np.arange(start - i, end - i)
|
|
332
|
+
spatial_weights = np.exp(-(temporal_indices**2) / (2 * sigma_spatial**2))
|
|
333
|
+
|
|
334
|
+
# Compute intensity (position difference) weights
|
|
335
|
+
intensity_diff = window_pos - center_pos
|
|
336
|
+
intensity_weights = np.exp(-(intensity_diff**2) / (2 * sigma_intensity**2))
|
|
337
|
+
|
|
338
|
+
# Combined weights (bilateral)
|
|
339
|
+
weights = spatial_weights * intensity_weights
|
|
340
|
+
weights /= np.sum(weights) # Normalize
|
|
341
|
+
|
|
342
|
+
# Weighted average
|
|
343
|
+
filtered[i] = np.sum(weights * window_pos)
|
|
344
|
+
|
|
345
|
+
return filtered
|
kinemotion/core/pose.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Pose tracking using MediaPipe Pose."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import cv2
|
|
5
|
+
import mediapipe as mp
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PoseTracker:
|
|
10
|
+
"""Tracks human pose landmarks in video frames using MediaPipe."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
min_detection_confidence: float = 0.5,
|
|
15
|
+
min_tracking_confidence: float = 0.5,
|
|
16
|
+
):
|
|
17
|
+
"""
|
|
18
|
+
Initialize the pose tracker.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
min_detection_confidence: Minimum confidence for pose detection
|
|
22
|
+
min_tracking_confidence: Minimum confidence for pose tracking
|
|
23
|
+
"""
|
|
24
|
+
self.mp_pose = mp.solutions.pose
|
|
25
|
+
self.pose = self.mp_pose.Pose(
|
|
26
|
+
min_detection_confidence=min_detection_confidence,
|
|
27
|
+
min_tracking_confidence=min_tracking_confidence,
|
|
28
|
+
model_complexity=1,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def process_frame(
|
|
32
|
+
self, frame: np.ndarray
|
|
33
|
+
) -> dict[str, tuple[float, float, float]] | None:
|
|
34
|
+
"""
|
|
35
|
+
Process a single frame and extract pose landmarks.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
frame: BGR image frame
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Dictionary mapping landmark names to (x, y, visibility) tuples,
|
|
42
|
+
or None if no pose detected. Coordinates are normalized (0-1).
|
|
43
|
+
"""
|
|
44
|
+
# Convert BGR to RGB
|
|
45
|
+
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
46
|
+
|
|
47
|
+
# Process the frame
|
|
48
|
+
results = self.pose.process(rgb_frame)
|
|
49
|
+
|
|
50
|
+
if not results.pose_landmarks:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
# Extract key landmarks for feet tracking and CoM estimation
|
|
54
|
+
landmarks = {}
|
|
55
|
+
landmark_names = {
|
|
56
|
+
# Feet landmarks
|
|
57
|
+
self.mp_pose.PoseLandmark.LEFT_ANKLE: "left_ankle",
|
|
58
|
+
self.mp_pose.PoseLandmark.RIGHT_ANKLE: "right_ankle",
|
|
59
|
+
self.mp_pose.PoseLandmark.LEFT_HEEL: "left_heel",
|
|
60
|
+
self.mp_pose.PoseLandmark.RIGHT_HEEL: "right_heel",
|
|
61
|
+
self.mp_pose.PoseLandmark.LEFT_FOOT_INDEX: "left_foot_index",
|
|
62
|
+
self.mp_pose.PoseLandmark.RIGHT_FOOT_INDEX: "right_foot_index",
|
|
63
|
+
# Torso landmarks for CoM estimation
|
|
64
|
+
self.mp_pose.PoseLandmark.LEFT_HIP: "left_hip",
|
|
65
|
+
self.mp_pose.PoseLandmark.RIGHT_HIP: "right_hip",
|
|
66
|
+
self.mp_pose.PoseLandmark.LEFT_SHOULDER: "left_shoulder",
|
|
67
|
+
self.mp_pose.PoseLandmark.RIGHT_SHOULDER: "right_shoulder",
|
|
68
|
+
# Additional landmarks for better CoM estimation
|
|
69
|
+
self.mp_pose.PoseLandmark.NOSE: "nose",
|
|
70
|
+
self.mp_pose.PoseLandmark.LEFT_KNEE: "left_knee",
|
|
71
|
+
self.mp_pose.PoseLandmark.RIGHT_KNEE: "right_knee",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for landmark_id, name in landmark_names.items():
|
|
75
|
+
lm = results.pose_landmarks.landmark[landmark_id]
|
|
76
|
+
landmarks[name] = (lm.x, lm.y, lm.visibility)
|
|
77
|
+
|
|
78
|
+
return landmarks
|
|
79
|
+
|
|
80
|
+
def close(self) -> None:
|
|
81
|
+
"""Release resources."""
|
|
82
|
+
self.pose.close()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def compute_center_of_mass(
|
|
86
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
87
|
+
visibility_threshold: float = 0.5,
|
|
88
|
+
) -> tuple[float, float, float]:
|
|
89
|
+
"""
|
|
90
|
+
Compute approximate center of mass (CoM) from body landmarks.
|
|
91
|
+
|
|
92
|
+
Uses biomechanical segment weights based on Dempster's body segment parameters:
|
|
93
|
+
- Head: 8% of body mass (represented by nose)
|
|
94
|
+
- Trunk (shoulders to hips): 50% of body mass
|
|
95
|
+
- Thighs: 2 × 10% = 20% of body mass
|
|
96
|
+
- Legs (knees to ankles): 2 × 5% = 10% of body mass
|
|
97
|
+
- Feet: 2 × 1.5% = 3% of body mass
|
|
98
|
+
|
|
99
|
+
The CoM is estimated as a weighted average of these segments, with
|
|
100
|
+
weights corresponding to their proportion of total body mass.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
landmarks: Dictionary of landmark positions (x, y, visibility)
|
|
104
|
+
visibility_threshold: Minimum visibility to include landmark in calculation
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
(x, y, visibility) tuple for estimated CoM position
|
|
108
|
+
visibility = average visibility of all segments used
|
|
109
|
+
"""
|
|
110
|
+
# Define segment representatives and their weights (as fraction of body mass)
|
|
111
|
+
# Each segment uses midpoint or average of its bounding landmarks
|
|
112
|
+
segments = []
|
|
113
|
+
segment_weights = []
|
|
114
|
+
visibilities = []
|
|
115
|
+
|
|
116
|
+
# Head segment: 8% (use nose as proxy)
|
|
117
|
+
if "nose" in landmarks:
|
|
118
|
+
x, y, vis = landmarks["nose"]
|
|
119
|
+
if vis > visibility_threshold:
|
|
120
|
+
segments.append((x, y))
|
|
121
|
+
segment_weights.append(0.08)
|
|
122
|
+
visibilities.append(vis)
|
|
123
|
+
|
|
124
|
+
# Trunk segment: 50% (midpoint between shoulders and hips)
|
|
125
|
+
trunk_landmarks = ["left_shoulder", "right_shoulder", "left_hip", "right_hip"]
|
|
126
|
+
trunk_positions = [
|
|
127
|
+
(x, y, vis)
|
|
128
|
+
for key in trunk_landmarks
|
|
129
|
+
if key in landmarks
|
|
130
|
+
for x, y, vis in [landmarks[key]]
|
|
131
|
+
if vis > visibility_threshold
|
|
132
|
+
]
|
|
133
|
+
if len(trunk_positions) >= 2:
|
|
134
|
+
trunk_x = float(np.mean([pos[0] for pos in trunk_positions]))
|
|
135
|
+
trunk_y = float(np.mean([pos[1] for pos in trunk_positions]))
|
|
136
|
+
trunk_vis = float(np.mean([pos[2] for pos in trunk_positions]))
|
|
137
|
+
segments.append((trunk_x, trunk_y))
|
|
138
|
+
segment_weights.append(0.50)
|
|
139
|
+
visibilities.append(trunk_vis)
|
|
140
|
+
|
|
141
|
+
# Thigh segment: 20% total (midpoint hip to knee for each leg)
|
|
142
|
+
for side in ["left", "right"]:
|
|
143
|
+
hip_key = f"{side}_hip"
|
|
144
|
+
knee_key = f"{side}_knee"
|
|
145
|
+
if hip_key in landmarks and knee_key in landmarks:
|
|
146
|
+
hip_x, hip_y, hip_vis = landmarks[hip_key]
|
|
147
|
+
knee_x, knee_y, knee_vis = landmarks[knee_key]
|
|
148
|
+
if hip_vis > visibility_threshold and knee_vis > visibility_threshold:
|
|
149
|
+
thigh_x = (hip_x + knee_x) / 2
|
|
150
|
+
thigh_y = (hip_y + knee_y) / 2
|
|
151
|
+
thigh_vis = (hip_vis + knee_vis) / 2
|
|
152
|
+
segments.append((thigh_x, thigh_y))
|
|
153
|
+
segment_weights.append(0.10) # 10% per leg
|
|
154
|
+
visibilities.append(thigh_vis)
|
|
155
|
+
|
|
156
|
+
# Lower leg segment: 10% total (midpoint knee to ankle for each leg)
|
|
157
|
+
for side in ["left", "right"]:
|
|
158
|
+
knee_key = f"{side}_knee"
|
|
159
|
+
ankle_key = f"{side}_ankle"
|
|
160
|
+
if knee_key in landmarks and ankle_key in landmarks:
|
|
161
|
+
knee_x, knee_y, knee_vis = landmarks[knee_key]
|
|
162
|
+
ankle_x, ankle_y, ankle_vis = landmarks[ankle_key]
|
|
163
|
+
if knee_vis > visibility_threshold and ankle_vis > visibility_threshold:
|
|
164
|
+
leg_x = (knee_x + ankle_x) / 2
|
|
165
|
+
leg_y = (knee_y + ankle_y) / 2
|
|
166
|
+
leg_vis = (knee_vis + ankle_vis) / 2
|
|
167
|
+
segments.append((leg_x, leg_y))
|
|
168
|
+
segment_weights.append(0.05) # 5% per leg
|
|
169
|
+
visibilities.append(leg_vis)
|
|
170
|
+
|
|
171
|
+
# Foot segment: 3% total (average of ankle, heel, foot_index)
|
|
172
|
+
for side in ["left", "right"]:
|
|
173
|
+
foot_keys = [f"{side}_ankle", f"{side}_heel", f"{side}_foot_index"]
|
|
174
|
+
foot_positions = [
|
|
175
|
+
(x, y, vis)
|
|
176
|
+
for key in foot_keys
|
|
177
|
+
if key in landmarks
|
|
178
|
+
for x, y, vis in [landmarks[key]]
|
|
179
|
+
if vis > visibility_threshold
|
|
180
|
+
]
|
|
181
|
+
if foot_positions:
|
|
182
|
+
foot_x = float(np.mean([pos[0] for pos in foot_positions]))
|
|
183
|
+
foot_y = float(np.mean([pos[1] for pos in foot_positions]))
|
|
184
|
+
foot_vis = float(np.mean([pos[2] for pos in foot_positions]))
|
|
185
|
+
segments.append((foot_x, foot_y))
|
|
186
|
+
segment_weights.append(0.015) # 1.5% per foot
|
|
187
|
+
visibilities.append(foot_vis)
|
|
188
|
+
|
|
189
|
+
# If no segments found, fall back to hip average
|
|
190
|
+
if not segments:
|
|
191
|
+
if "left_hip" in landmarks and "right_hip" in landmarks:
|
|
192
|
+
lh_x, lh_y, lh_vis = landmarks["left_hip"]
|
|
193
|
+
rh_x, rh_y, rh_vis = landmarks["right_hip"]
|
|
194
|
+
return (
|
|
195
|
+
(lh_x + rh_x) / 2,
|
|
196
|
+
(lh_y + rh_y) / 2,
|
|
197
|
+
(lh_vis + rh_vis) / 2,
|
|
198
|
+
)
|
|
199
|
+
# Ultimate fallback: center of frame
|
|
200
|
+
return (0.5, 0.5, 0.0)
|
|
201
|
+
|
|
202
|
+
# Normalize weights to sum to 1.0
|
|
203
|
+
total_weight = sum(segment_weights)
|
|
204
|
+
normalized_weights = [w / total_weight for w in segment_weights]
|
|
205
|
+
|
|
206
|
+
# Compute weighted average of segment positions
|
|
207
|
+
com_x = float(
|
|
208
|
+
sum(
|
|
209
|
+
pos[0] * weight
|
|
210
|
+
for pos, weight in zip(segments, normalized_weights, strict=True)
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
com_y = float(
|
|
214
|
+
sum(
|
|
215
|
+
pos[1] * weight
|
|
216
|
+
for pos, weight in zip(segments, normalized_weights, strict=True)
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
com_visibility = float(np.mean(visibilities)) if visibilities else 0.0
|
|
220
|
+
|
|
221
|
+
return (com_x, com_y, com_visibility)
|