kinemotion 0.17.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 -0
- kinemotion/api.py +946 -0
- kinemotion/cli.py +22 -0
- kinemotion/cmj/__init__.py +5 -0
- kinemotion/cmj/analysis.py +528 -0
- kinemotion/cmj/cli.py +543 -0
- kinemotion/cmj/debug_overlay.py +463 -0
- kinemotion/cmj/joint_angles.py +290 -0
- kinemotion/cmj/kinematics.py +191 -0
- kinemotion/core/__init__.py +40 -0
- kinemotion/core/auto_tuning.py +325 -0
- kinemotion/core/cli_utils.py +212 -0
- kinemotion/core/debug_overlay_utils.py +143 -0
- kinemotion/core/filtering.py +345 -0
- kinemotion/core/pose.py +259 -0
- kinemotion/core/smoothing.py +412 -0
- kinemotion/core/video_io.py +186 -0
- kinemotion/dropjump/__init__.py +29 -0
- kinemotion/dropjump/analysis.py +790 -0
- kinemotion/dropjump/cli.py +704 -0
- kinemotion/dropjump/debug_overlay.py +179 -0
- kinemotion/dropjump/kinematics.py +446 -0
- kinemotion/py.typed +0 -0
- kinemotion-0.17.0.dist-info/METADATA +529 -0
- kinemotion-0.17.0.dist-info/RECORD +28 -0
- kinemotion-0.17.0.dist-info/WHEEL +4 -0
- kinemotion-0.17.0.dist-info/entry_points.txt +2 -0
- kinemotion-0.17.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
|
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.nonzero(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.nonzero(~outlier_mask[:idx])[0]
|
|
139
|
+
valid_after = np.nonzero(~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,259 @@
|
|
|
1
|
+
"""Pose tracking using MediaPipe Pose."""
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import mediapipe as mp
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PoseTracker:
|
|
9
|
+
"""Tracks human pose landmarks in video frames using MediaPipe."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
min_detection_confidence: float = 0.5,
|
|
14
|
+
min_tracking_confidence: float = 0.5,
|
|
15
|
+
):
|
|
16
|
+
"""
|
|
17
|
+
Initialize the pose tracker.
|
|
18
|
+
|
|
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,
|
|
26
|
+
min_tracking_confidence=min_tracking_confidence,
|
|
27
|
+
model_complexity=1,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def process_frame(
|
|
31
|
+
self, frame: np.ndarray
|
|
32
|
+
) -> dict[str, tuple[float, float, float]] | None:
|
|
33
|
+
"""
|
|
34
|
+
Process a single frame and extract pose landmarks.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
frame: BGR image frame
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Dictionary mapping landmark names to (x, y, visibility) tuples,
|
|
41
|
+
or None if no pose detected. Coordinates are normalized (0-1).
|
|
42
|
+
"""
|
|
43
|
+
# Convert BGR to RGB
|
|
44
|
+
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
45
|
+
|
|
46
|
+
# Process the frame
|
|
47
|
+
results = self.pose.process(rgb_frame)
|
|
48
|
+
|
|
49
|
+
if not results.pose_landmarks:
|
|
50
|
+
return None
|
|
51
|
+
|
|
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)
|
|
76
|
+
|
|
77
|
+
return landmarks
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
"""Release resources."""
|
|
81
|
+
self.pose.close()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _add_head_segment(
|
|
85
|
+
segments: list,
|
|
86
|
+
weights: list,
|
|
87
|
+
visibilities: list,
|
|
88
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
89
|
+
vis_threshold: float,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Add head segment (8% body mass) if visible."""
|
|
92
|
+
if "nose" in landmarks:
|
|
93
|
+
x, y, vis = landmarks["nose"]
|
|
94
|
+
if vis > vis_threshold:
|
|
95
|
+
segments.append((x, y))
|
|
96
|
+
weights.append(0.08)
|
|
97
|
+
visibilities.append(vis)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _add_trunk_segment(
|
|
101
|
+
segments: list,
|
|
102
|
+
weights: list,
|
|
103
|
+
visibilities: list,
|
|
104
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
105
|
+
vis_threshold: float,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Add trunk segment (50% body mass) if visible."""
|
|
108
|
+
trunk_keys = ["left_shoulder", "right_shoulder", "left_hip", "right_hip"]
|
|
109
|
+
trunk_pos = [
|
|
110
|
+
(x, y, vis)
|
|
111
|
+
for key in trunk_keys
|
|
112
|
+
if key in landmarks
|
|
113
|
+
for x, y, vis in [landmarks[key]]
|
|
114
|
+
if vis > vis_threshold
|
|
115
|
+
]
|
|
116
|
+
if len(trunk_pos) >= 2:
|
|
117
|
+
trunk_x = float(np.mean([p[0] for p in trunk_pos]))
|
|
118
|
+
trunk_y = float(np.mean([p[1] for p in trunk_pos]))
|
|
119
|
+
trunk_vis = float(np.mean([p[2] for p in trunk_pos]))
|
|
120
|
+
segments.append((trunk_x, trunk_y))
|
|
121
|
+
weights.append(0.50)
|
|
122
|
+
visibilities.append(trunk_vis)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _add_limb_segment(
|
|
126
|
+
segments: list,
|
|
127
|
+
weights: list,
|
|
128
|
+
visibilities: list,
|
|
129
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
130
|
+
side: str,
|
|
131
|
+
proximal_key: str,
|
|
132
|
+
distal_key: str,
|
|
133
|
+
segment_weight: float,
|
|
134
|
+
vis_threshold: float,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Add a limb segment (thigh or lower leg) if both endpoints visible."""
|
|
137
|
+
prox_full = f"{side}_{proximal_key}"
|
|
138
|
+
dist_full = f"{side}_{distal_key}"
|
|
139
|
+
|
|
140
|
+
if prox_full in landmarks and dist_full in landmarks:
|
|
141
|
+
px, py, pvis = landmarks[prox_full]
|
|
142
|
+
dx, dy, dvis = landmarks[dist_full]
|
|
143
|
+
if pvis > vis_threshold and dvis > vis_threshold:
|
|
144
|
+
seg_x = (px + dx) / 2
|
|
145
|
+
seg_y = (py + dy) / 2
|
|
146
|
+
seg_vis = (pvis + dvis) / 2
|
|
147
|
+
segments.append((seg_x, seg_y))
|
|
148
|
+
weights.append(segment_weight)
|
|
149
|
+
visibilities.append(seg_vis)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _add_foot_segment(
|
|
153
|
+
segments: list,
|
|
154
|
+
weights: list,
|
|
155
|
+
visibilities: list,
|
|
156
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
157
|
+
side: str,
|
|
158
|
+
vis_threshold: float,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Add foot segment (1.5% body mass per foot) if visible."""
|
|
161
|
+
foot_keys = [f"{side}_ankle", f"{side}_heel", f"{side}_foot_index"]
|
|
162
|
+
foot_pos = [
|
|
163
|
+
(x, y, vis)
|
|
164
|
+
for key in foot_keys
|
|
165
|
+
if key in landmarks
|
|
166
|
+
for x, y, vis in [landmarks[key]]
|
|
167
|
+
if vis > vis_threshold
|
|
168
|
+
]
|
|
169
|
+
if foot_pos:
|
|
170
|
+
foot_x = float(np.mean([p[0] for p in foot_pos]))
|
|
171
|
+
foot_y = float(np.mean([p[1] for p in foot_pos]))
|
|
172
|
+
foot_vis = float(np.mean([p[2] for p in foot_pos]))
|
|
173
|
+
segments.append((foot_x, foot_y))
|
|
174
|
+
weights.append(0.015)
|
|
175
|
+
visibilities.append(foot_vis)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def compute_center_of_mass(
|
|
179
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
180
|
+
visibility_threshold: float = 0.5,
|
|
181
|
+
) -> tuple[float, float, float]:
|
|
182
|
+
"""
|
|
183
|
+
Compute approximate center of mass (CoM) from body landmarks.
|
|
184
|
+
|
|
185
|
+
Uses biomechanical segment weights based on Dempster's body segment parameters:
|
|
186
|
+
- Head: 8% of body mass (represented by nose)
|
|
187
|
+
- Trunk (shoulders to hips): 50% of body mass
|
|
188
|
+
- Thighs: 2 × 10% = 20% of body mass
|
|
189
|
+
- Legs (knees to ankles): 2 × 5% = 10% of body mass
|
|
190
|
+
- Feet: 2 × 1.5% = 3% of body mass
|
|
191
|
+
|
|
192
|
+
The CoM is estimated as a weighted average of these segments, with
|
|
193
|
+
weights corresponding to their proportion of total body mass.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
landmarks: Dictionary of landmark positions (x, y, visibility)
|
|
197
|
+
visibility_threshold: Minimum visibility to include landmark in calculation
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
(x, y, visibility) tuple for estimated CoM position
|
|
201
|
+
visibility = average visibility of all segments used
|
|
202
|
+
"""
|
|
203
|
+
segments: list = []
|
|
204
|
+
weights: list = []
|
|
205
|
+
visibilities: list = []
|
|
206
|
+
|
|
207
|
+
# Add body segments
|
|
208
|
+
_add_head_segment(segments, weights, visibilities, landmarks, visibility_threshold)
|
|
209
|
+
_add_trunk_segment(segments, weights, visibilities, landmarks, visibility_threshold)
|
|
210
|
+
|
|
211
|
+
# Add bilateral limb segments
|
|
212
|
+
for side in ["left", "right"]:
|
|
213
|
+
_add_limb_segment(
|
|
214
|
+
segments,
|
|
215
|
+
weights,
|
|
216
|
+
visibilities,
|
|
217
|
+
landmarks,
|
|
218
|
+
side,
|
|
219
|
+
"hip",
|
|
220
|
+
"knee",
|
|
221
|
+
0.10,
|
|
222
|
+
visibility_threshold,
|
|
223
|
+
)
|
|
224
|
+
_add_limb_segment(
|
|
225
|
+
segments,
|
|
226
|
+
weights,
|
|
227
|
+
visibilities,
|
|
228
|
+
landmarks,
|
|
229
|
+
side,
|
|
230
|
+
"knee",
|
|
231
|
+
"ankle",
|
|
232
|
+
0.05,
|
|
233
|
+
visibility_threshold,
|
|
234
|
+
)
|
|
235
|
+
_add_foot_segment(
|
|
236
|
+
segments, weights, visibilities, landmarks, side, visibility_threshold
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Fallback if no segments found
|
|
240
|
+
if not segments:
|
|
241
|
+
if "left_hip" in landmarks and "right_hip" in landmarks:
|
|
242
|
+
lh_x, lh_y, lh_vis = landmarks["left_hip"]
|
|
243
|
+
rh_x, rh_y, rh_vis = landmarks["right_hip"]
|
|
244
|
+
return ((lh_x + rh_x) / 2, (lh_y + rh_y) / 2, (lh_vis + rh_vis) / 2)
|
|
245
|
+
return (0.5, 0.5, 0.0)
|
|
246
|
+
|
|
247
|
+
# Normalize weights and compute weighted average
|
|
248
|
+
total_weight = sum(weights)
|
|
249
|
+
normalized_weights = [w / total_weight for w in weights]
|
|
250
|
+
|
|
251
|
+
com_x = float(
|
|
252
|
+
sum(p[0] * w for p, w in zip(segments, normalized_weights, strict=True))
|
|
253
|
+
)
|
|
254
|
+
com_y = float(
|
|
255
|
+
sum(p[1] * w for p, w in zip(segments, normalized_weights, strict=True))
|
|
256
|
+
)
|
|
257
|
+
com_visibility = float(np.mean(visibilities)) if visibilities else 0.0
|
|
258
|
+
|
|
259
|
+
return (com_x, com_y, com_visibility)
|