kinemotion 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of kinemotion might be problematic. Click here for more details.
- dropjump/__init__.py +3 -0
- dropjump/cli.py +294 -0
- dropjump/contact_detection.py +431 -0
- dropjump/kinematics.py +374 -0
- dropjump/pose_tracker.py +74 -0
- dropjump/smoothing.py +223 -0
- dropjump/video_io.py +337 -0
- kinemotion-0.1.0.dist-info/METADATA +381 -0
- kinemotion-0.1.0.dist-info/RECORD +12 -0
- kinemotion-0.1.0.dist-info/WHEEL +4 -0
- kinemotion-0.1.0.dist-info/entry_points.txt +2 -0
- kinemotion-0.1.0.dist-info/licenses/LICENSE +21 -0
dropjump/kinematics.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Kinematic calculations for drop-jump metrics."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from .contact_detection import (
|
|
7
|
+
ContactState,
|
|
8
|
+
find_contact_phases,
|
|
9
|
+
find_interpolated_phase_transitions_with_curvature,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DropJumpMetrics:
|
|
14
|
+
"""Container for drop-jump analysis metrics."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self.ground_contact_time: float | None = None
|
|
18
|
+
self.flight_time: float | None = None
|
|
19
|
+
self.jump_height: float | None = None
|
|
20
|
+
self.jump_height_kinematic: float | None = None # From flight time
|
|
21
|
+
self.jump_height_trajectory: float | None = None # From position tracking
|
|
22
|
+
self.contact_start_frame: int | None = None
|
|
23
|
+
self.contact_end_frame: int | None = None
|
|
24
|
+
self.flight_start_frame: int | None = None
|
|
25
|
+
self.flight_end_frame: int | None = None
|
|
26
|
+
self.peak_height_frame: int | None = None
|
|
27
|
+
# Fractional frame indices for sub-frame precision timing
|
|
28
|
+
self.contact_start_frame_precise: float | None = None
|
|
29
|
+
self.contact_end_frame_precise: float | None = None
|
|
30
|
+
self.flight_start_frame_precise: float | None = None
|
|
31
|
+
self.flight_end_frame_precise: float | None = None
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict:
|
|
34
|
+
"""Convert metrics to dictionary for JSON output."""
|
|
35
|
+
return {
|
|
36
|
+
"ground_contact_time_ms": (
|
|
37
|
+
round(self.ground_contact_time * 1000, 2)
|
|
38
|
+
if self.ground_contact_time is not None
|
|
39
|
+
else None
|
|
40
|
+
),
|
|
41
|
+
"flight_time_ms": (
|
|
42
|
+
round(self.flight_time * 1000, 2)
|
|
43
|
+
if self.flight_time is not None
|
|
44
|
+
else None
|
|
45
|
+
),
|
|
46
|
+
"jump_height_m": (
|
|
47
|
+
round(self.jump_height, 3) if self.jump_height is not None else None
|
|
48
|
+
),
|
|
49
|
+
"jump_height_kinematic_m": (
|
|
50
|
+
round(self.jump_height_kinematic, 3)
|
|
51
|
+
if self.jump_height_kinematic is not None
|
|
52
|
+
else None
|
|
53
|
+
),
|
|
54
|
+
"jump_height_trajectory_normalized": (
|
|
55
|
+
round(self.jump_height_trajectory, 4)
|
|
56
|
+
if self.jump_height_trajectory is not None
|
|
57
|
+
else None
|
|
58
|
+
),
|
|
59
|
+
"contact_start_frame": (
|
|
60
|
+
int(self.contact_start_frame)
|
|
61
|
+
if self.contact_start_frame is not None
|
|
62
|
+
else None
|
|
63
|
+
),
|
|
64
|
+
"contact_end_frame": (
|
|
65
|
+
int(self.contact_end_frame)
|
|
66
|
+
if self.contact_end_frame is not None
|
|
67
|
+
else None
|
|
68
|
+
),
|
|
69
|
+
"flight_start_frame": (
|
|
70
|
+
int(self.flight_start_frame)
|
|
71
|
+
if self.flight_start_frame is not None
|
|
72
|
+
else None
|
|
73
|
+
),
|
|
74
|
+
"flight_end_frame": (
|
|
75
|
+
int(self.flight_end_frame)
|
|
76
|
+
if self.flight_end_frame is not None
|
|
77
|
+
else None
|
|
78
|
+
),
|
|
79
|
+
"peak_height_frame": (
|
|
80
|
+
int(self.peak_height_frame)
|
|
81
|
+
if self.peak_height_frame is not None
|
|
82
|
+
else None
|
|
83
|
+
),
|
|
84
|
+
"contact_start_frame_precise": (
|
|
85
|
+
round(self.contact_start_frame_precise, 3)
|
|
86
|
+
if self.contact_start_frame_precise is not None
|
|
87
|
+
else None
|
|
88
|
+
),
|
|
89
|
+
"contact_end_frame_precise": (
|
|
90
|
+
round(self.contact_end_frame_precise, 3)
|
|
91
|
+
if self.contact_end_frame_precise is not None
|
|
92
|
+
else None
|
|
93
|
+
),
|
|
94
|
+
"flight_start_frame_precise": (
|
|
95
|
+
round(self.flight_start_frame_precise, 3)
|
|
96
|
+
if self.flight_start_frame_precise is not None
|
|
97
|
+
else None
|
|
98
|
+
),
|
|
99
|
+
"flight_end_frame_precise": (
|
|
100
|
+
round(self.flight_end_frame_precise, 3)
|
|
101
|
+
if self.flight_end_frame_precise is not None
|
|
102
|
+
else None
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def calculate_drop_jump_metrics(
|
|
108
|
+
contact_states: list[ContactState],
|
|
109
|
+
foot_y_positions: np.ndarray,
|
|
110
|
+
fps: float,
|
|
111
|
+
drop_height_m: float | None = None,
|
|
112
|
+
velocity_threshold: float = 0.02,
|
|
113
|
+
smoothing_window: int = 5,
|
|
114
|
+
use_curvature: bool = True,
|
|
115
|
+
) -> DropJumpMetrics:
|
|
116
|
+
"""
|
|
117
|
+
Calculate drop-jump metrics from contact states and positions.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
contact_states: Contact state for each frame
|
|
121
|
+
foot_y_positions: Vertical positions of feet (normalized 0-1)
|
|
122
|
+
fps: Video frame rate
|
|
123
|
+
drop_height_m: Known drop box/platform height in meters for calibration (optional)
|
|
124
|
+
velocity_threshold: Velocity threshold used for contact detection (for interpolation)
|
|
125
|
+
smoothing_window: Window size for velocity/acceleration smoothing (must be odd)
|
|
126
|
+
use_curvature: Whether to use curvature analysis for refining transitions
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
DropJumpMetrics object with calculated values
|
|
130
|
+
"""
|
|
131
|
+
metrics = DropJumpMetrics()
|
|
132
|
+
phases = find_contact_phases(contact_states)
|
|
133
|
+
|
|
134
|
+
# Get interpolated phases with curvature-based refinement
|
|
135
|
+
# Combines velocity interpolation + acceleration pattern analysis
|
|
136
|
+
interpolated_phases = find_interpolated_phase_transitions_with_curvature(
|
|
137
|
+
foot_y_positions,
|
|
138
|
+
contact_states,
|
|
139
|
+
velocity_threshold,
|
|
140
|
+
smoothing_window,
|
|
141
|
+
use_curvature,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if not phases:
|
|
145
|
+
return metrics
|
|
146
|
+
|
|
147
|
+
# Find the main contact phase
|
|
148
|
+
# For drop jumps: find first ON_GROUND after first IN_AIR (the landing after drop)
|
|
149
|
+
# For regular jumps: use longest ON_GROUND phase
|
|
150
|
+
ground_phases = [
|
|
151
|
+
(start, end, i)
|
|
152
|
+
for i, (start, end, state) in enumerate(phases)
|
|
153
|
+
if state == ContactState.ON_GROUND
|
|
154
|
+
]
|
|
155
|
+
air_phases_indexed = [
|
|
156
|
+
(start, end, i)
|
|
157
|
+
for i, (start, end, state) in enumerate(phases)
|
|
158
|
+
if state == ContactState.IN_AIR
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
if not ground_phases:
|
|
162
|
+
return metrics
|
|
163
|
+
|
|
164
|
+
# Detect if this is a drop jump or regular jump
|
|
165
|
+
# Drop jump: first ground phase is elevated (lower y), followed by drop, then landing (higher y)
|
|
166
|
+
is_drop_jump = False
|
|
167
|
+
if air_phases_indexed and len(ground_phases) >= 2:
|
|
168
|
+
first_ground_start, first_ground_end, first_ground_idx = ground_phases[0]
|
|
169
|
+
first_air_idx = air_phases_indexed[0][2]
|
|
170
|
+
|
|
171
|
+
# Find ground phase after first air phase
|
|
172
|
+
ground_after_air = [
|
|
173
|
+
(start, end, idx) for start, end, idx in ground_phases if idx > first_air_idx
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
if ground_after_air and first_ground_idx < first_air_idx:
|
|
177
|
+
# Check if first ground is at higher elevation (lower y) than ground after air
|
|
178
|
+
first_ground_y = float(
|
|
179
|
+
np.mean(foot_y_positions[first_ground_start : first_ground_end + 1])
|
|
180
|
+
)
|
|
181
|
+
second_ground_start, second_ground_end, _ = ground_after_air[0]
|
|
182
|
+
second_ground_y = float(
|
|
183
|
+
np.mean(foot_y_positions[second_ground_start : second_ground_end + 1])
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# If first ground is significantly higher (>5% of frame), it's a drop jump
|
|
187
|
+
if second_ground_y - first_ground_y > 0.05:
|
|
188
|
+
is_drop_jump = True
|
|
189
|
+
contact_start, contact_end = second_ground_start, second_ground_end
|
|
190
|
+
|
|
191
|
+
if not is_drop_jump:
|
|
192
|
+
# Regular jump: use longest ground contact phase
|
|
193
|
+
contact_start, contact_end = max(
|
|
194
|
+
[(s, e) for s, e, _ in ground_phases], key=lambda p: p[1] - p[0]
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Store integer frame indices (for visualization)
|
|
198
|
+
metrics.contact_start_frame = contact_start
|
|
199
|
+
metrics.contact_end_frame = contact_end
|
|
200
|
+
|
|
201
|
+
# Find corresponding interpolated phase for precise timing
|
|
202
|
+
contact_start_frac = float(contact_start)
|
|
203
|
+
contact_end_frac = float(contact_end)
|
|
204
|
+
|
|
205
|
+
# Find the matching ground phase in interpolated_phases
|
|
206
|
+
for start_frac, end_frac, state in interpolated_phases:
|
|
207
|
+
# Match by checking if integer frames are within this phase
|
|
208
|
+
if (
|
|
209
|
+
state == ContactState.ON_GROUND
|
|
210
|
+
and int(start_frac) <= contact_start <= int(end_frac) + 1
|
|
211
|
+
and int(start_frac) <= contact_end <= int(end_frac) + 1
|
|
212
|
+
):
|
|
213
|
+
contact_start_frac = start_frac
|
|
214
|
+
contact_end_frac = end_frac
|
|
215
|
+
break
|
|
216
|
+
|
|
217
|
+
# Calculate ground contact time using fractional frames
|
|
218
|
+
contact_frames_precise = contact_end_frac - contact_start_frac
|
|
219
|
+
metrics.ground_contact_time = contact_frames_precise / fps
|
|
220
|
+
metrics.contact_start_frame_precise = contact_start_frac
|
|
221
|
+
metrics.contact_end_frame_precise = contact_end_frac
|
|
222
|
+
|
|
223
|
+
# Calculate calibration scale factor from drop height if provided
|
|
224
|
+
scale_factor = 1.0
|
|
225
|
+
if drop_height_m is not None and len(phases) >= 2:
|
|
226
|
+
# Find the initial drop by looking for first IN_AIR phase
|
|
227
|
+
# This represents the drop from the box
|
|
228
|
+
|
|
229
|
+
if air_phases_indexed and ground_phases:
|
|
230
|
+
# Get first air phase (the drop)
|
|
231
|
+
first_air_start, first_air_end, _ = air_phases_indexed[0]
|
|
232
|
+
|
|
233
|
+
# Initial position: at start of drop (on the box)
|
|
234
|
+
# Look back a few frames to get stable position on box
|
|
235
|
+
lookback_start = max(0, first_air_start - 5)
|
|
236
|
+
if lookback_start < first_air_start:
|
|
237
|
+
initial_position = float(np.mean(foot_y_positions[lookback_start:first_air_start]))
|
|
238
|
+
else:
|
|
239
|
+
initial_position = float(foot_y_positions[first_air_start])
|
|
240
|
+
|
|
241
|
+
# Landing position: at the ground after drop
|
|
242
|
+
# Use position at end of first air phase
|
|
243
|
+
landing_position = float(foot_y_positions[first_air_end])
|
|
244
|
+
|
|
245
|
+
# Drop distance in normalized coordinates (y increases downward)
|
|
246
|
+
drop_normalized = landing_position - initial_position
|
|
247
|
+
|
|
248
|
+
if drop_normalized > 0.01: # Sanity check (at least 1% of frame height)
|
|
249
|
+
# Calculate scale factor: real_meters / normalized_distance
|
|
250
|
+
scale_factor = drop_height_m / drop_normalized
|
|
251
|
+
|
|
252
|
+
# Find flight phase after ground contact
|
|
253
|
+
flight_phases = [
|
|
254
|
+
(start, end)
|
|
255
|
+
for start, end, state in phases
|
|
256
|
+
if state == ContactState.IN_AIR and start > contact_end
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
if flight_phases:
|
|
260
|
+
flight_start, flight_end = flight_phases[0]
|
|
261
|
+
|
|
262
|
+
# Store integer frame indices (for visualization)
|
|
263
|
+
metrics.flight_start_frame = flight_start
|
|
264
|
+
metrics.flight_end_frame = flight_end
|
|
265
|
+
|
|
266
|
+
# Find corresponding interpolated phase for precise timing
|
|
267
|
+
flight_start_frac = float(flight_start)
|
|
268
|
+
flight_end_frac = float(flight_end)
|
|
269
|
+
|
|
270
|
+
# Find the matching air phase in interpolated_phases
|
|
271
|
+
for start_frac, end_frac, state in interpolated_phases:
|
|
272
|
+
# Match by checking if integer frames are within this phase
|
|
273
|
+
if (
|
|
274
|
+
state == ContactState.IN_AIR
|
|
275
|
+
and int(start_frac) <= flight_start <= int(end_frac) + 1
|
|
276
|
+
and int(start_frac) <= flight_end <= int(end_frac) + 1
|
|
277
|
+
):
|
|
278
|
+
flight_start_frac = start_frac
|
|
279
|
+
flight_end_frac = end_frac
|
|
280
|
+
break
|
|
281
|
+
|
|
282
|
+
# Calculate flight time using fractional frames
|
|
283
|
+
flight_frames_precise = flight_end_frac - flight_start_frac
|
|
284
|
+
metrics.flight_time = flight_frames_precise / fps
|
|
285
|
+
metrics.flight_start_frame_precise = flight_start_frac
|
|
286
|
+
metrics.flight_end_frame_precise = flight_end_frac
|
|
287
|
+
|
|
288
|
+
# Calculate jump height using flight time (kinematic method)
|
|
289
|
+
# h = (g * t^2) / 8, where t is total flight time
|
|
290
|
+
g = 9.81 # m/s^2
|
|
291
|
+
jump_height_kinematic = (g * metrics.flight_time**2) / 8
|
|
292
|
+
|
|
293
|
+
# Calculate jump height from trajectory (position-based method)
|
|
294
|
+
# This measures actual vertical displacement from takeoff to peak
|
|
295
|
+
takeoff_position = foot_y_positions[flight_start]
|
|
296
|
+
flight_positions = foot_y_positions[flight_start : flight_end + 1]
|
|
297
|
+
|
|
298
|
+
if len(flight_positions) > 0:
|
|
299
|
+
peak_idx = np.argmin(flight_positions)
|
|
300
|
+
metrics.peak_height_frame = int(flight_start + peak_idx)
|
|
301
|
+
peak_position = np.min(flight_positions)
|
|
302
|
+
|
|
303
|
+
# Height in normalized coordinates (0-1 range)
|
|
304
|
+
height_normalized = float(takeoff_position - peak_position)
|
|
305
|
+
|
|
306
|
+
# Store trajectory value (in normalized coordinates)
|
|
307
|
+
metrics.jump_height_trajectory = height_normalized
|
|
308
|
+
|
|
309
|
+
# Choose measurement method based on calibration availability
|
|
310
|
+
if drop_height_m is not None and scale_factor > 1.0:
|
|
311
|
+
# Use calibrated trajectory measurement (most accurate)
|
|
312
|
+
metrics.jump_height = height_normalized * scale_factor
|
|
313
|
+
metrics.jump_height_kinematic = jump_height_kinematic
|
|
314
|
+
else:
|
|
315
|
+
# Use empirical correction factor for kinematic method
|
|
316
|
+
# Testing shows kinematic method underestimates by ~29% due to:
|
|
317
|
+
# 1. Contact detection timing (detects landing slightly early)
|
|
318
|
+
# 2. Frame rate limitations (30 fps = 33ms intervals)
|
|
319
|
+
# 3. Foot position vs center of mass difference
|
|
320
|
+
kinematic_correction_factor = 1.35
|
|
321
|
+
metrics.jump_height = jump_height_kinematic * kinematic_correction_factor
|
|
322
|
+
metrics.jump_height_kinematic = jump_height_kinematic
|
|
323
|
+
else:
|
|
324
|
+
# Fallback to kinematic if no position data
|
|
325
|
+
if drop_height_m is None:
|
|
326
|
+
kinematic_correction_factor = 1.35
|
|
327
|
+
metrics.jump_height = jump_height_kinematic * kinematic_correction_factor
|
|
328
|
+
else:
|
|
329
|
+
metrics.jump_height = jump_height_kinematic
|
|
330
|
+
metrics.jump_height_kinematic = jump_height_kinematic
|
|
331
|
+
|
|
332
|
+
return metrics
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def estimate_jump_height_from_trajectory(
|
|
336
|
+
foot_y_positions: np.ndarray,
|
|
337
|
+
contact_start: int,
|
|
338
|
+
flight_start: int,
|
|
339
|
+
flight_end: int,
|
|
340
|
+
pixel_to_meter_ratio: float | None = None,
|
|
341
|
+
) -> float:
|
|
342
|
+
"""
|
|
343
|
+
Estimate jump height from position trajectory.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
foot_y_positions: Vertical positions of feet (normalized or pixels)
|
|
347
|
+
contact_start: Frame where ground contact starts
|
|
348
|
+
flight_start: Frame where flight begins
|
|
349
|
+
flight_end: Frame where flight ends
|
|
350
|
+
pixel_to_meter_ratio: Conversion factor from pixels to meters
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Estimated jump height in meters (or normalized units if no calibration)
|
|
354
|
+
"""
|
|
355
|
+
if flight_end < flight_start:
|
|
356
|
+
return 0.0
|
|
357
|
+
|
|
358
|
+
# Get position at takeoff (end of contact) and peak (minimum y during flight)
|
|
359
|
+
takeoff_position = foot_y_positions[flight_start]
|
|
360
|
+
flight_positions = foot_y_positions[flight_start : flight_end + 1]
|
|
361
|
+
|
|
362
|
+
if len(flight_positions) == 0:
|
|
363
|
+
return 0.0
|
|
364
|
+
|
|
365
|
+
peak_position = np.min(flight_positions)
|
|
366
|
+
|
|
367
|
+
# Height difference (in normalized coordinates, y increases downward)
|
|
368
|
+
height_diff = takeoff_position - peak_position
|
|
369
|
+
|
|
370
|
+
# Convert to meters if calibration available
|
|
371
|
+
if pixel_to_meter_ratio is not None:
|
|
372
|
+
return float(height_diff * pixel_to_meter_ratio)
|
|
373
|
+
|
|
374
|
+
return float(height_diff)
|
dropjump/pose_tracker.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
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
|
|
54
|
+
landmarks = {}
|
|
55
|
+
landmark_names = {
|
|
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
|
+
self.mp_pose.PoseLandmark.LEFT_HIP: "left_hip",
|
|
63
|
+
self.mp_pose.PoseLandmark.RIGHT_HIP: "right_hip",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for landmark_id, name in landmark_names.items():
|
|
67
|
+
lm = results.pose_landmarks.landmark[landmark_id]
|
|
68
|
+
landmarks[name] = (lm.x, lm.y, lm.visibility)
|
|
69
|
+
|
|
70
|
+
return landmarks
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
"""Release resources."""
|
|
74
|
+
self.pose.close()
|
dropjump/smoothing.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Landmark smoothing utilities to reduce jitter in pose tracking."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from scipy.signal import savgol_filter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def smooth_landmarks(
|
|
9
|
+
landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
|
|
10
|
+
window_length: int = 5,
|
|
11
|
+
polyorder: int = 2,
|
|
12
|
+
) -> list[dict[str, tuple[float, float, float]] | None]:
|
|
13
|
+
"""
|
|
14
|
+
Smooth landmark trajectories using Savitzky-Golay filter.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
landmark_sequence: List of landmark dictionaries from each frame
|
|
18
|
+
window_length: Length of filter window (must be odd, >= polyorder + 2)
|
|
19
|
+
polyorder: Order of polynomial used to fit samples
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Smoothed landmark sequence with same structure as input
|
|
23
|
+
"""
|
|
24
|
+
if len(landmark_sequence) < window_length:
|
|
25
|
+
# Not enough frames to smooth effectively
|
|
26
|
+
return landmark_sequence
|
|
27
|
+
|
|
28
|
+
# Ensure window_length is odd
|
|
29
|
+
if window_length % 2 == 0:
|
|
30
|
+
window_length += 1
|
|
31
|
+
|
|
32
|
+
# Extract landmark names from first valid frame
|
|
33
|
+
landmark_names = None
|
|
34
|
+
for frame_landmarks in landmark_sequence:
|
|
35
|
+
if frame_landmarks is not None:
|
|
36
|
+
landmark_names = list(frame_landmarks.keys())
|
|
37
|
+
break
|
|
38
|
+
|
|
39
|
+
if landmark_names is None:
|
|
40
|
+
return landmark_sequence
|
|
41
|
+
|
|
42
|
+
# Build arrays for each landmark coordinate
|
|
43
|
+
smoothed_sequence: list[dict[str, tuple[float, float, float]] | None] = []
|
|
44
|
+
|
|
45
|
+
for landmark_name in landmark_names:
|
|
46
|
+
# Extract x, y coordinates for this landmark across all frames
|
|
47
|
+
x_coords = []
|
|
48
|
+
y_coords = []
|
|
49
|
+
valid_frames = []
|
|
50
|
+
|
|
51
|
+
for i, frame_landmarks in enumerate(landmark_sequence):
|
|
52
|
+
if frame_landmarks is not None and landmark_name in frame_landmarks:
|
|
53
|
+
x, y, vis = frame_landmarks[landmark_name]
|
|
54
|
+
x_coords.append(x)
|
|
55
|
+
y_coords.append(y)
|
|
56
|
+
valid_frames.append(i)
|
|
57
|
+
|
|
58
|
+
if len(x_coords) < window_length:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# Apply Savitzky-Golay filter
|
|
62
|
+
x_smooth = savgol_filter(x_coords, window_length, polyorder)
|
|
63
|
+
y_smooth = savgol_filter(y_coords, window_length, polyorder)
|
|
64
|
+
|
|
65
|
+
# Store smoothed values back
|
|
66
|
+
for idx, frame_idx in enumerate(valid_frames):
|
|
67
|
+
if frame_idx >= len(smoothed_sequence):
|
|
68
|
+
smoothed_sequence.extend(
|
|
69
|
+
[{}] * (frame_idx - len(smoothed_sequence) + 1)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Ensure smoothed_sequence[frame_idx] is a dict, not None
|
|
73
|
+
if smoothed_sequence[frame_idx] is None:
|
|
74
|
+
smoothed_sequence[frame_idx] = {}
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
landmark_name not in smoothed_sequence[frame_idx] # type: ignore[operator]
|
|
78
|
+
and landmark_sequence[frame_idx] is not None
|
|
79
|
+
):
|
|
80
|
+
# Keep original visibility
|
|
81
|
+
orig_vis = landmark_sequence[frame_idx][landmark_name][2] # type: ignore[index]
|
|
82
|
+
smoothed_sequence[frame_idx][landmark_name] = ( # type: ignore[index]
|
|
83
|
+
float(x_smooth[idx]),
|
|
84
|
+
float(y_smooth[idx]),
|
|
85
|
+
orig_vis,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Fill in any missing frames with original data
|
|
89
|
+
for i in range(len(landmark_sequence)):
|
|
90
|
+
if i >= len(smoothed_sequence) or not smoothed_sequence[i]:
|
|
91
|
+
if i < len(smoothed_sequence):
|
|
92
|
+
smoothed_sequence[i] = landmark_sequence[i]
|
|
93
|
+
else:
|
|
94
|
+
smoothed_sequence.append(landmark_sequence[i])
|
|
95
|
+
|
|
96
|
+
return smoothed_sequence
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def compute_velocity(
|
|
100
|
+
positions: np.ndarray, fps: float, smooth_window: int = 3
|
|
101
|
+
) -> np.ndarray:
|
|
102
|
+
"""
|
|
103
|
+
Compute velocity from position data.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
positions: Array of positions over time (n_frames, n_dims)
|
|
107
|
+
fps: Frames per second of the video
|
|
108
|
+
smooth_window: Window size for velocity smoothing
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Velocity array (n_frames, n_dims)
|
|
112
|
+
"""
|
|
113
|
+
dt = 1.0 / fps
|
|
114
|
+
velocity = np.gradient(positions, dt, axis=0)
|
|
115
|
+
|
|
116
|
+
# Smooth velocity if we have enough data
|
|
117
|
+
if len(velocity) >= smooth_window and smooth_window > 1:
|
|
118
|
+
if smooth_window % 2 == 0:
|
|
119
|
+
smooth_window += 1
|
|
120
|
+
for dim in range(velocity.shape[1]):
|
|
121
|
+
velocity[:, dim] = savgol_filter(velocity[:, dim], smooth_window, 1)
|
|
122
|
+
|
|
123
|
+
return velocity # type: ignore[no-any-return]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def compute_velocity_from_derivative(
|
|
127
|
+
positions: np.ndarray,
|
|
128
|
+
window_length: int = 5,
|
|
129
|
+
polyorder: int = 2,
|
|
130
|
+
) -> np.ndarray:
|
|
131
|
+
"""
|
|
132
|
+
Compute velocity as derivative of smoothed position trajectory.
|
|
133
|
+
|
|
134
|
+
Uses Savitzky-Golay filter to compute the derivative directly, which provides
|
|
135
|
+
a much smoother and more accurate velocity estimate than frame-to-frame differences.
|
|
136
|
+
|
|
137
|
+
This method:
|
|
138
|
+
1. Fits a polynomial to the position data in a sliding window
|
|
139
|
+
2. Analytically computes the derivative of that polynomial
|
|
140
|
+
3. Returns smooth velocity values
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
positions: 1D array of position values (e.g., foot y-positions)
|
|
144
|
+
window_length: Window size for smoothing (must be odd, >= polyorder + 2)
|
|
145
|
+
polyorder: Polynomial order for Savitzky-Golay filter (typically 2 or 3)
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Array of absolute velocity values (magnitude of derivative)
|
|
149
|
+
"""
|
|
150
|
+
if len(positions) < window_length:
|
|
151
|
+
# Fallback to simple differences for short sequences
|
|
152
|
+
return np.abs(np.diff(positions, prepend=positions[0])) # type: ignore[no-any-return]
|
|
153
|
+
|
|
154
|
+
# Ensure window_length is odd
|
|
155
|
+
if window_length % 2 == 0:
|
|
156
|
+
window_length += 1
|
|
157
|
+
|
|
158
|
+
# Compute derivative using Savitzky-Golay filter
|
|
159
|
+
# deriv=1: compute first derivative
|
|
160
|
+
# delta=1.0: frame spacing (velocity per frame)
|
|
161
|
+
# mode='interp': interpolate at boundaries
|
|
162
|
+
velocity = savgol_filter(
|
|
163
|
+
positions,
|
|
164
|
+
window_length,
|
|
165
|
+
polyorder,
|
|
166
|
+
deriv=1, # First derivative
|
|
167
|
+
delta=1.0, # Frame spacing
|
|
168
|
+
mode="interp",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Return absolute velocity (magnitude only)
|
|
172
|
+
return np.abs(velocity) # type: ignore[no-any-return]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def compute_acceleration_from_derivative(
|
|
176
|
+
positions: np.ndarray,
|
|
177
|
+
window_length: int = 5,
|
|
178
|
+
polyorder: int = 2,
|
|
179
|
+
) -> np.ndarray:
|
|
180
|
+
"""
|
|
181
|
+
Compute acceleration as second derivative of smoothed position trajectory.
|
|
182
|
+
|
|
183
|
+
Uses Savitzky-Golay filter to compute the second derivative directly,
|
|
184
|
+
providing smooth acceleration (curvature) estimates for detecting
|
|
185
|
+
characteristic patterns at landing and takeoff.
|
|
186
|
+
|
|
187
|
+
Landing and takeoff events show distinctive acceleration patterns:
|
|
188
|
+
- Landing: Large acceleration spike as feet decelerate on impact
|
|
189
|
+
- Takeoff: Acceleration change as body accelerates upward
|
|
190
|
+
- In flight: Constant acceleration due to gravity
|
|
191
|
+
- On ground: Near-zero acceleration (stationary position)
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
positions: 1D array of position values (e.g., foot y-positions)
|
|
195
|
+
window_length: Window size for smoothing (must be odd, >= polyorder + 2)
|
|
196
|
+
polyorder: Polynomial order for Savitzky-Golay filter (typically 2 or 3)
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Array of acceleration values (second derivative of position)
|
|
200
|
+
"""
|
|
201
|
+
if len(positions) < window_length:
|
|
202
|
+
# Fallback to simple second differences for short sequences
|
|
203
|
+
velocity = np.diff(positions, prepend=positions[0])
|
|
204
|
+
return np.diff(velocity, prepend=velocity[0])
|
|
205
|
+
|
|
206
|
+
# Ensure window_length is odd
|
|
207
|
+
if window_length % 2 == 0:
|
|
208
|
+
window_length += 1
|
|
209
|
+
|
|
210
|
+
# Compute second derivative using Savitzky-Golay filter
|
|
211
|
+
# deriv=2: compute second derivative (acceleration/curvature)
|
|
212
|
+
# delta=1.0: frame spacing
|
|
213
|
+
# mode='interp': interpolate at boundaries
|
|
214
|
+
acceleration = savgol_filter(
|
|
215
|
+
positions,
|
|
216
|
+
window_length,
|
|
217
|
+
polyorder,
|
|
218
|
+
deriv=2, # Second derivative
|
|
219
|
+
delta=1.0, # Frame spacing
|
|
220
|
+
mode="interp",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return acceleration # type: ignore[no-any-return]
|