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,412 @@
|
|
|
1
|
+
"""Landmark smoothing utilities to reduce jitter in pose tracking."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy.signal import savgol_filter
|
|
5
|
+
|
|
6
|
+
from .filtering import (
|
|
7
|
+
bilateral_temporal_filter,
|
|
8
|
+
reject_outliers,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _extract_landmark_coordinates(
|
|
13
|
+
landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
|
|
14
|
+
landmark_name: str,
|
|
15
|
+
) -> tuple[list[float], list[float], list[int]]:
|
|
16
|
+
"""
|
|
17
|
+
Extract x, y coordinates and valid frame indices for a specific landmark.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
landmark_sequence: List of landmark dictionaries from each frame
|
|
21
|
+
landmark_name: Name of the landmark to extract
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Tuple of (x_coords, y_coords, valid_frames)
|
|
25
|
+
"""
|
|
26
|
+
x_coords = []
|
|
27
|
+
y_coords = []
|
|
28
|
+
valid_frames = []
|
|
29
|
+
|
|
30
|
+
for i, frame_landmarks in enumerate(landmark_sequence):
|
|
31
|
+
if frame_landmarks is not None and landmark_name in frame_landmarks:
|
|
32
|
+
x, y, _ = frame_landmarks[landmark_name] # vis not used
|
|
33
|
+
x_coords.append(x)
|
|
34
|
+
y_coords.append(y)
|
|
35
|
+
valid_frames.append(i)
|
|
36
|
+
|
|
37
|
+
return x_coords, y_coords, valid_frames
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_landmark_names(
|
|
41
|
+
landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
|
|
42
|
+
) -> list[str] | None:
|
|
43
|
+
"""
|
|
44
|
+
Extract landmark names from first valid frame.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
landmark_sequence: List of landmark dictionaries from each frame
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
List of landmark names or None if no valid frame found
|
|
51
|
+
"""
|
|
52
|
+
for frame_landmarks in landmark_sequence:
|
|
53
|
+
if frame_landmarks is not None:
|
|
54
|
+
return list(frame_landmarks.keys())
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _fill_missing_frames(
|
|
59
|
+
smoothed_sequence: list[dict[str, tuple[float, float, float]] | None],
|
|
60
|
+
landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
|
|
61
|
+
) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Fill in any missing frames in smoothed sequence with original data.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
smoothed_sequence: Smoothed sequence (modified in place)
|
|
67
|
+
landmark_sequence: Original sequence
|
|
68
|
+
"""
|
|
69
|
+
for i in range(len(landmark_sequence)):
|
|
70
|
+
if i >= len(smoothed_sequence) or not smoothed_sequence[i]:
|
|
71
|
+
if i < len(smoothed_sequence):
|
|
72
|
+
smoothed_sequence[i] = landmark_sequence[i]
|
|
73
|
+
else:
|
|
74
|
+
smoothed_sequence.append(landmark_sequence[i])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _store_smoothed_landmarks(
|
|
78
|
+
smoothed_sequence: list[dict[str, tuple[float, float, float]] | None],
|
|
79
|
+
landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
|
|
80
|
+
landmark_name: str,
|
|
81
|
+
x_smooth: np.ndarray,
|
|
82
|
+
y_smooth: np.ndarray,
|
|
83
|
+
valid_frames: list[int],
|
|
84
|
+
) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Store smoothed landmark values back into the sequence.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
smoothed_sequence: Sequence to store smoothed values into (modified in place)
|
|
90
|
+
landmark_sequence: Original sequence (for visibility values)
|
|
91
|
+
landmark_name: Name of the landmark being smoothed
|
|
92
|
+
x_smooth: Smoothed x coordinates
|
|
93
|
+
y_smooth: Smoothed y coordinates
|
|
94
|
+
valid_frames: Frame indices corresponding to smoothed values
|
|
95
|
+
"""
|
|
96
|
+
for idx, frame_idx in enumerate(valid_frames):
|
|
97
|
+
if frame_idx >= len(smoothed_sequence):
|
|
98
|
+
smoothed_sequence.extend([{}] * (frame_idx - len(smoothed_sequence) + 1))
|
|
99
|
+
|
|
100
|
+
# Ensure smoothed_sequence[frame_idx] is a dict, not None
|
|
101
|
+
if smoothed_sequence[frame_idx] is None:
|
|
102
|
+
smoothed_sequence[frame_idx] = {}
|
|
103
|
+
|
|
104
|
+
# Type narrowing: after the check above, we know it's a dict
|
|
105
|
+
frame_dict = smoothed_sequence[frame_idx]
|
|
106
|
+
assert frame_dict is not None # for type checker
|
|
107
|
+
|
|
108
|
+
if landmark_name not in frame_dict and landmark_sequence[frame_idx] is not None:
|
|
109
|
+
# Keep original visibility
|
|
110
|
+
orig_landmarks = landmark_sequence[frame_idx]
|
|
111
|
+
assert orig_landmarks is not None # for type checker
|
|
112
|
+
orig_vis = orig_landmarks[landmark_name][2]
|
|
113
|
+
frame_dict[landmark_name] = (
|
|
114
|
+
float(x_smooth[idx]),
|
|
115
|
+
float(y_smooth[idx]),
|
|
116
|
+
orig_vis,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _smooth_landmarks_core( # NOSONAR(S1172) - polyorder used via closure capture in smoother_fn
|
|
121
|
+
landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
|
|
122
|
+
window_length: int,
|
|
123
|
+
polyorder: int,
|
|
124
|
+
smoother_fn, # type: ignore[no-untyped-def]
|
|
125
|
+
) -> list[dict[str, tuple[float, float, float]] | None]:
|
|
126
|
+
"""
|
|
127
|
+
Core smoothing logic shared by both standard and advanced smoothing.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
landmark_sequence: List of landmark dictionaries from each frame
|
|
131
|
+
window_length: Length of filter window (must be odd)
|
|
132
|
+
polyorder: Order of polynomial used to fit samples (captured by smoother_fn closure)
|
|
133
|
+
smoother_fn: Function that takes (x_coords, y_coords, valid_frames)
|
|
134
|
+
and returns (x_smooth, y_smooth)
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Smoothed landmark sequence
|
|
138
|
+
"""
|
|
139
|
+
landmark_names = _get_landmark_names(landmark_sequence)
|
|
140
|
+
if landmark_names is None:
|
|
141
|
+
return landmark_sequence
|
|
142
|
+
|
|
143
|
+
smoothed_sequence: list[dict[str, tuple[float, float, float]] | None] = []
|
|
144
|
+
|
|
145
|
+
for landmark_name in landmark_names:
|
|
146
|
+
x_coords, y_coords, valid_frames = _extract_landmark_coordinates(
|
|
147
|
+
landmark_sequence, landmark_name
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if len(x_coords) < window_length:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
# Apply smoothing function
|
|
154
|
+
x_smooth, y_smooth = smoother_fn(x_coords, y_coords, valid_frames)
|
|
155
|
+
|
|
156
|
+
# Store smoothed values back
|
|
157
|
+
_store_smoothed_landmarks(
|
|
158
|
+
smoothed_sequence,
|
|
159
|
+
landmark_sequence,
|
|
160
|
+
landmark_name,
|
|
161
|
+
x_smooth,
|
|
162
|
+
y_smooth,
|
|
163
|
+
valid_frames,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Fill in any missing frames with original data
|
|
167
|
+
_fill_missing_frames(smoothed_sequence, landmark_sequence)
|
|
168
|
+
|
|
169
|
+
return smoothed_sequence
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def smooth_landmarks(
|
|
173
|
+
landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
|
|
174
|
+
window_length: int = 5,
|
|
175
|
+
polyorder: int = 2,
|
|
176
|
+
) -> list[dict[str, tuple[float, float, float]] | None]:
|
|
177
|
+
"""
|
|
178
|
+
Smooth landmark trajectories using Savitzky-Golay filter.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
landmark_sequence: List of landmark dictionaries from each frame
|
|
182
|
+
window_length: Length of filter window (must be odd, >= polyorder + 2)
|
|
183
|
+
polyorder: Order of polynomial used to fit samples
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Smoothed landmark sequence with same structure as input
|
|
187
|
+
"""
|
|
188
|
+
if len(landmark_sequence) < window_length:
|
|
189
|
+
return landmark_sequence
|
|
190
|
+
|
|
191
|
+
# Ensure window_length is odd
|
|
192
|
+
if window_length % 2 == 0:
|
|
193
|
+
window_length += 1
|
|
194
|
+
|
|
195
|
+
def savgol_smoother(x_coords, y_coords, _valid_frames): # type: ignore[no-untyped-def]
|
|
196
|
+
x_smooth = savgol_filter(x_coords, window_length, polyorder)
|
|
197
|
+
y_smooth = savgol_filter(y_coords, window_length, polyorder)
|
|
198
|
+
return x_smooth, y_smooth
|
|
199
|
+
|
|
200
|
+
return _smooth_landmarks_core(
|
|
201
|
+
landmark_sequence, window_length, polyorder, savgol_smoother
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def compute_velocity(
|
|
206
|
+
positions: np.ndarray, fps: float, smooth_window: int = 3
|
|
207
|
+
) -> np.ndarray:
|
|
208
|
+
"""
|
|
209
|
+
Compute velocity from position data.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
positions: Array of positions over time (n_frames, n_dims)
|
|
213
|
+
fps: Frames per second of the video
|
|
214
|
+
smooth_window: Window size for velocity smoothing
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Velocity array (n_frames, n_dims)
|
|
218
|
+
"""
|
|
219
|
+
dt = 1.0 / fps
|
|
220
|
+
velocity = np.gradient(positions, dt, axis=0)
|
|
221
|
+
|
|
222
|
+
# Smooth velocity if we have enough data
|
|
223
|
+
if len(velocity) >= smooth_window and smooth_window > 1:
|
|
224
|
+
if smooth_window % 2 == 0:
|
|
225
|
+
smooth_window += 1
|
|
226
|
+
for dim in range(velocity.shape[1]):
|
|
227
|
+
velocity[:, dim] = savgol_filter(velocity[:, dim], smooth_window, 1)
|
|
228
|
+
|
|
229
|
+
return velocity
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def compute_velocity_from_derivative(
|
|
233
|
+
positions: np.ndarray,
|
|
234
|
+
window_length: int = 5,
|
|
235
|
+
polyorder: int = 2,
|
|
236
|
+
) -> np.ndarray:
|
|
237
|
+
"""
|
|
238
|
+
Compute velocity as derivative of smoothed position trajectory.
|
|
239
|
+
|
|
240
|
+
Uses Savitzky-Golay filter to compute the derivative directly, which provides
|
|
241
|
+
a much smoother and more accurate velocity estimate than frame-to-frame differences.
|
|
242
|
+
|
|
243
|
+
This method:
|
|
244
|
+
1. Fits a polynomial to the position data in a sliding window
|
|
245
|
+
2. Analytically computes the derivative of that polynomial
|
|
246
|
+
3. Returns smooth velocity values
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
positions: 1D array of position values (e.g., foot y-positions)
|
|
250
|
+
window_length: Window size for smoothing (must be odd, >= polyorder + 2)
|
|
251
|
+
polyorder: Polynomial order for Savitzky-Golay filter (typically 2 or 3)
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Array of absolute velocity values (magnitude of derivative)
|
|
255
|
+
"""
|
|
256
|
+
if len(positions) < window_length:
|
|
257
|
+
# Fallback to simple differences for short sequences
|
|
258
|
+
return np.abs(np.diff(positions, prepend=positions[0]))
|
|
259
|
+
|
|
260
|
+
# Ensure window_length is odd
|
|
261
|
+
if window_length % 2 == 0:
|
|
262
|
+
window_length += 1
|
|
263
|
+
|
|
264
|
+
# Compute derivative using Savitzky-Golay filter
|
|
265
|
+
# deriv=1: compute first derivative
|
|
266
|
+
# delta=1.0: frame spacing (velocity per frame)
|
|
267
|
+
# mode='interp': interpolate at boundaries
|
|
268
|
+
velocity = savgol_filter(
|
|
269
|
+
positions,
|
|
270
|
+
window_length,
|
|
271
|
+
polyorder,
|
|
272
|
+
deriv=1, # First derivative
|
|
273
|
+
delta=1.0, # Frame spacing
|
|
274
|
+
mode="interp",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Return absolute velocity (magnitude only)
|
|
278
|
+
return np.abs(velocity)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def compute_acceleration_from_derivative(
|
|
282
|
+
positions: np.ndarray,
|
|
283
|
+
window_length: int = 5,
|
|
284
|
+
polyorder: int = 2,
|
|
285
|
+
) -> np.ndarray:
|
|
286
|
+
"""
|
|
287
|
+
Compute acceleration as second derivative of smoothed position trajectory.
|
|
288
|
+
|
|
289
|
+
Uses Savitzky-Golay filter to compute the second derivative directly,
|
|
290
|
+
providing smooth acceleration (curvature) estimates for detecting
|
|
291
|
+
characteristic patterns at landing and takeoff.
|
|
292
|
+
|
|
293
|
+
Landing and takeoff events show distinctive acceleration patterns:
|
|
294
|
+
- Landing: Large acceleration spike as feet decelerate on impact
|
|
295
|
+
- Takeoff: Acceleration change as body accelerates upward
|
|
296
|
+
- In flight: Constant acceleration due to gravity
|
|
297
|
+
- On ground: Near-zero acceleration (stationary position)
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
positions: 1D array of position values (e.g., foot y-positions)
|
|
301
|
+
window_length: Window size for smoothing (must be odd, >= polyorder + 2)
|
|
302
|
+
polyorder: Polynomial order for Savitzky-Golay filter (typically 2 or 3)
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Array of acceleration values (second derivative of position)
|
|
306
|
+
"""
|
|
307
|
+
if len(positions) < window_length:
|
|
308
|
+
# Fallback to simple second differences for short sequences
|
|
309
|
+
velocity = np.diff(positions, prepend=positions[0])
|
|
310
|
+
return np.diff(velocity, prepend=velocity[0])
|
|
311
|
+
|
|
312
|
+
# Ensure window_length is odd
|
|
313
|
+
if window_length % 2 == 0:
|
|
314
|
+
window_length += 1
|
|
315
|
+
|
|
316
|
+
# Compute second derivative using Savitzky-Golay filter
|
|
317
|
+
# deriv=2: compute second derivative (acceleration/curvature)
|
|
318
|
+
# delta=1.0: frame spacing
|
|
319
|
+
# mode='interp': interpolate at boundaries
|
|
320
|
+
acceleration = savgol_filter(
|
|
321
|
+
positions,
|
|
322
|
+
window_length,
|
|
323
|
+
polyorder,
|
|
324
|
+
deriv=2, # Second derivative
|
|
325
|
+
delta=1.0, # Frame spacing
|
|
326
|
+
mode="interp",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return acceleration
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def smooth_landmarks_advanced(
|
|
333
|
+
landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
|
|
334
|
+
window_length: int = 5,
|
|
335
|
+
polyorder: int = 2,
|
|
336
|
+
use_outlier_rejection: bool = True,
|
|
337
|
+
use_bilateral: bool = False,
|
|
338
|
+
ransac_threshold: float = 0.02,
|
|
339
|
+
bilateral_sigma_spatial: float = 3.0,
|
|
340
|
+
bilateral_sigma_intensity: float = 0.02,
|
|
341
|
+
) -> list[dict[str, tuple[float, float, float]] | None]:
|
|
342
|
+
"""
|
|
343
|
+
Advanced landmark smoothing with outlier rejection and bilateral filtering.
|
|
344
|
+
|
|
345
|
+
Combines multiple techniques for robust smoothing:
|
|
346
|
+
1. Outlier rejection (RANSAC + median filtering)
|
|
347
|
+
2. Optional bilateral filtering (edge-preserving)
|
|
348
|
+
3. Savitzky-Golay smoothing
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
landmark_sequence: List of landmark dictionaries from each frame
|
|
352
|
+
window_length: Length of filter window (must be odd, >= polyorder + 2)
|
|
353
|
+
polyorder: Order of polynomial used to fit samples
|
|
354
|
+
use_outlier_rejection: Apply outlier detection and removal
|
|
355
|
+
use_bilateral: Use bilateral filter instead of Savitzky-Golay
|
|
356
|
+
ransac_threshold: Threshold for RANSAC outlier detection
|
|
357
|
+
bilateral_sigma_spatial: Spatial sigma for bilateral filter
|
|
358
|
+
bilateral_sigma_intensity: Intensity sigma for bilateral filter
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Smoothed landmark sequence with same structure as input
|
|
362
|
+
"""
|
|
363
|
+
if len(landmark_sequence) < window_length:
|
|
364
|
+
return landmark_sequence
|
|
365
|
+
|
|
366
|
+
# Ensure window_length is odd
|
|
367
|
+
if window_length % 2 == 0:
|
|
368
|
+
window_length += 1
|
|
369
|
+
|
|
370
|
+
def advanced_smoother(x_coords, y_coords, _valid_frames): # type: ignore[no-untyped-def]
|
|
371
|
+
x_array = np.array(x_coords)
|
|
372
|
+
y_array = np.array(y_coords)
|
|
373
|
+
|
|
374
|
+
# Step 1: Outlier rejection
|
|
375
|
+
if use_outlier_rejection:
|
|
376
|
+
x_array, _ = reject_outliers(
|
|
377
|
+
x_array,
|
|
378
|
+
use_ransac=True,
|
|
379
|
+
use_median=True,
|
|
380
|
+
ransac_threshold=ransac_threshold,
|
|
381
|
+
)
|
|
382
|
+
y_array, _ = reject_outliers(
|
|
383
|
+
y_array,
|
|
384
|
+
use_ransac=True,
|
|
385
|
+
use_median=True,
|
|
386
|
+
ransac_threshold=ransac_threshold,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Step 2: Smoothing (bilateral or Savitzky-Golay)
|
|
390
|
+
if use_bilateral:
|
|
391
|
+
x_smooth = bilateral_temporal_filter(
|
|
392
|
+
x_array,
|
|
393
|
+
window_size=window_length,
|
|
394
|
+
sigma_spatial=bilateral_sigma_spatial,
|
|
395
|
+
sigma_intensity=bilateral_sigma_intensity,
|
|
396
|
+
)
|
|
397
|
+
y_smooth = bilateral_temporal_filter(
|
|
398
|
+
y_array,
|
|
399
|
+
window_size=window_length,
|
|
400
|
+
sigma_spatial=bilateral_sigma_spatial,
|
|
401
|
+
sigma_intensity=bilateral_sigma_intensity,
|
|
402
|
+
)
|
|
403
|
+
else:
|
|
404
|
+
# Standard Savitzky-Golay
|
|
405
|
+
x_smooth = savgol_filter(x_array, window_length, polyorder)
|
|
406
|
+
y_smooth = savgol_filter(y_array, window_length, polyorder)
|
|
407
|
+
|
|
408
|
+
return x_smooth, y_smooth
|
|
409
|
+
|
|
410
|
+
return _smooth_landmarks_core(
|
|
411
|
+
landmark_sequence, window_length, polyorder, advanced_smoother
|
|
412
|
+
)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Generic video I/O functionality for all jump analysis types."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
import cv2
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VideoProcessor:
|
|
11
|
+
"""
|
|
12
|
+
Handles video reading and processing.
|
|
13
|
+
|
|
14
|
+
IMPORTANT: This class preserves the exact aspect ratio of the source video.
|
|
15
|
+
No dimensions are hardcoded - all dimensions are extracted from actual frame data.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, video_path: str):
|
|
19
|
+
"""
|
|
20
|
+
Initialize video processor.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
video_path: Path to input video file
|
|
24
|
+
"""
|
|
25
|
+
self.video_path = video_path
|
|
26
|
+
self.cap = cv2.VideoCapture(video_path)
|
|
27
|
+
|
|
28
|
+
if not self.cap.isOpened():
|
|
29
|
+
raise ValueError(f"Could not open video: {video_path}")
|
|
30
|
+
|
|
31
|
+
self.fps = self.cap.get(cv2.CAP_PROP_FPS)
|
|
32
|
+
self.frame_count = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
33
|
+
|
|
34
|
+
# Read first frame to get actual dimensions
|
|
35
|
+
# This is critical for preserving aspect ratio, especially with mobile videos
|
|
36
|
+
# that have rotation metadata. OpenCV properties (CAP_PROP_FRAME_WIDTH/HEIGHT)
|
|
37
|
+
# may return incorrect dimensions, so we read the actual frame data.
|
|
38
|
+
ret, first_frame = self.cap.read()
|
|
39
|
+
if ret:
|
|
40
|
+
# frame.shape is (height, width, channels) - extract actual dimensions
|
|
41
|
+
self.height, self.width = first_frame.shape[:2]
|
|
42
|
+
self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0) # Reset to beginning
|
|
43
|
+
else:
|
|
44
|
+
# Fallback to video properties if can't read frame
|
|
45
|
+
self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
46
|
+
self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
47
|
+
|
|
48
|
+
# Extract rotation metadata from video (iPhones store rotation in side_data_list)
|
|
49
|
+
# OpenCV ignores rotation metadata, so we need to extract and apply it manually
|
|
50
|
+
self.rotation = 0 # Will be set by _extract_video_metadata()
|
|
51
|
+
|
|
52
|
+
# Calculate display dimensions considering SAR (Sample Aspect Ratio)
|
|
53
|
+
# Mobile videos often have non-square pixels encoded in SAR metadata
|
|
54
|
+
# OpenCV doesn't directly expose SAR, but we need to handle display correctly
|
|
55
|
+
self.display_width = self.width
|
|
56
|
+
self.display_height = self.height
|
|
57
|
+
self._extract_video_metadata()
|
|
58
|
+
|
|
59
|
+
# Apply rotation to dimensions if needed
|
|
60
|
+
if self.rotation in [90, -90, 270]:
|
|
61
|
+
# Swap dimensions for 90/-90 degree rotations
|
|
62
|
+
self.width, self.height = self.height, self.width
|
|
63
|
+
self.display_width, self.display_height = (
|
|
64
|
+
self.display_height,
|
|
65
|
+
self.display_width,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def _parse_sample_aspect_ratio(self, sar_str: str) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Parse SAR string and update display dimensions.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
sar_str: SAR string in format "width:height" (e.g., "270:473")
|
|
74
|
+
"""
|
|
75
|
+
if not sar_str or ":" not in sar_str:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
sar_parts = sar_str.split(":")
|
|
79
|
+
sar_width = int(sar_parts[0])
|
|
80
|
+
sar_height = int(sar_parts[1])
|
|
81
|
+
|
|
82
|
+
# Calculate display dimensions if pixels are non-square
|
|
83
|
+
# DAR = (width * SAR_width) / (height * SAR_height)
|
|
84
|
+
if sar_width != sar_height:
|
|
85
|
+
self.display_width = int(self.width * sar_width / sar_height)
|
|
86
|
+
self.display_height = self.height
|
|
87
|
+
|
|
88
|
+
def _extract_rotation_from_stream(self, stream: dict) -> int: # type: ignore[type-arg]
|
|
89
|
+
"""
|
|
90
|
+
Extract rotation metadata from video stream.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
stream: ffprobe stream dictionary
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Rotation angle in degrees (0, 90, -90, 180)
|
|
97
|
+
"""
|
|
98
|
+
side_data_list = stream.get("side_data_list", [])
|
|
99
|
+
for side_data in side_data_list:
|
|
100
|
+
if side_data.get("side_data_type") == "Display Matrix":
|
|
101
|
+
rotation = side_data.get("rotation", 0)
|
|
102
|
+
return int(rotation)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
def _extract_video_metadata(self) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Extract video metadata including SAR and rotation using ffprobe.
|
|
108
|
+
|
|
109
|
+
Many mobile videos (especially from iPhones) have:
|
|
110
|
+
- Non-square pixels (SAR != 1:1) affecting display dimensions
|
|
111
|
+
- Rotation metadata in side_data_list that OpenCV ignores
|
|
112
|
+
|
|
113
|
+
We extract both to ensure proper display and pose detection.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
# Use ffprobe to get SAR metadata
|
|
117
|
+
result = subprocess.run(
|
|
118
|
+
[
|
|
119
|
+
"ffprobe",
|
|
120
|
+
"-v",
|
|
121
|
+
"quiet",
|
|
122
|
+
"-print_format",
|
|
123
|
+
"json",
|
|
124
|
+
"-show_streams",
|
|
125
|
+
"-select_streams",
|
|
126
|
+
"v:0",
|
|
127
|
+
self.video_path,
|
|
128
|
+
],
|
|
129
|
+
capture_output=True,
|
|
130
|
+
text=True,
|
|
131
|
+
timeout=5,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if result.returncode != 0:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
data = json.loads(result.stdout)
|
|
138
|
+
if "streams" not in data or len(data["streams"]) == 0:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
stream = data["streams"][0]
|
|
142
|
+
|
|
143
|
+
# Extract and parse SAR (Sample Aspect Ratio)
|
|
144
|
+
sar_str = stream.get("sample_aspect_ratio", "1:1")
|
|
145
|
+
self._parse_sample_aspect_ratio(sar_str)
|
|
146
|
+
|
|
147
|
+
# Extract rotation from side_data_list (common for iPhone videos)
|
|
148
|
+
self.rotation = self._extract_rotation_from_stream(stream)
|
|
149
|
+
|
|
150
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
|
|
151
|
+
# If ffprobe fails, keep original dimensions (square pixels)
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
def read_frame(self) -> np.ndarray | None:
|
|
155
|
+
"""
|
|
156
|
+
Read next frame from video and apply rotation if needed.
|
|
157
|
+
|
|
158
|
+
OpenCV ignores rotation metadata, so we manually apply rotation
|
|
159
|
+
based on the display matrix metadata extracted from the video.
|
|
160
|
+
"""
|
|
161
|
+
ret, frame = self.cap.read()
|
|
162
|
+
if not ret:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
# Apply rotation if video has rotation metadata
|
|
166
|
+
if self.rotation == -90 or self.rotation == 270:
|
|
167
|
+
# -90 degrees = rotate 90 degrees clockwise
|
|
168
|
+
frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
|
|
169
|
+
elif self.rotation == 90 or self.rotation == -270:
|
|
170
|
+
# 90 degrees = rotate 90 degrees counter-clockwise
|
|
171
|
+
frame = cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
|
172
|
+
elif self.rotation == 180 or self.rotation == -180:
|
|
173
|
+
# 180 degrees rotation
|
|
174
|
+
frame = cv2.rotate(frame, cv2.ROTATE_180)
|
|
175
|
+
|
|
176
|
+
return frame
|
|
177
|
+
|
|
178
|
+
def close(self) -> None:
|
|
179
|
+
"""Release video capture."""
|
|
180
|
+
self.cap.release()
|
|
181
|
+
|
|
182
|
+
def __enter__(self) -> "VideoProcessor":
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
def __exit__(self, _exc_type, _exc_val, _exc_tb) -> None: # type: ignore[no-untyped-def]
|
|
186
|
+
self.close()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Drop jump analysis module."""
|
|
2
|
+
|
|
3
|
+
from .analysis import (
|
|
4
|
+
ContactState,
|
|
5
|
+
calculate_adaptive_threshold,
|
|
6
|
+
compute_average_foot_position,
|
|
7
|
+
detect_ground_contact,
|
|
8
|
+
find_interpolated_phase_transitions_with_curvature,
|
|
9
|
+
interpolate_threshold_crossing,
|
|
10
|
+
refine_transition_with_curvature,
|
|
11
|
+
)
|
|
12
|
+
from .debug_overlay import DebugOverlayRenderer
|
|
13
|
+
from .kinematics import DropJumpMetrics, calculate_drop_jump_metrics
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
# Contact detection
|
|
17
|
+
"ContactState",
|
|
18
|
+
"detect_ground_contact",
|
|
19
|
+
"compute_average_foot_position",
|
|
20
|
+
"calculate_adaptive_threshold",
|
|
21
|
+
"interpolate_threshold_crossing",
|
|
22
|
+
"refine_transition_with_curvature",
|
|
23
|
+
"find_interpolated_phase_transitions_with_curvature",
|
|
24
|
+
# Metrics
|
|
25
|
+
"DropJumpMetrics",
|
|
26
|
+
"calculate_drop_jump_metrics",
|
|
27
|
+
# Debug overlay
|
|
28
|
+
"DebugOverlayRenderer",
|
|
29
|
+
]
|