kinemotion 0.74.0__py3-none-any.whl → 0.76.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.
- kinemotion/__init__.py +13 -1
- kinemotion/api.py +21 -1
- kinemotion/cli.py +2 -0
- kinemotion/core/filtering.py +101 -45
- kinemotion/drop_jump/analysis.py +28 -8
- kinemotion/squat_jump/__init__.py +5 -0
- kinemotion/squat_jump/analysis.py +342 -0
- kinemotion/squat_jump/api.py +610 -0
- kinemotion/squat_jump/cli.py +309 -0
- kinemotion/squat_jump/debug_overlay.py +215 -0
- kinemotion/squat_jump/kinematics.py +348 -0
- kinemotion/squat_jump/metrics_validator.py +446 -0
- kinemotion/squat_jump/validation_bounds.py +221 -0
- {kinemotion-0.74.0.dist-info → kinemotion-0.76.0.dist-info}/METADATA +51 -2
- {kinemotion-0.74.0.dist-info → kinemotion-0.76.0.dist-info}/RECORD +18 -10
- {kinemotion-0.74.0.dist-info → kinemotion-0.76.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.74.0.dist-info → kinemotion-0.76.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.74.0.dist-info → kinemotion-0.76.0.dist-info}/licenses/LICENSE +0 -0
kinemotion/__init__.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Kinemotion: Video-based kinematic analysis for athletic performance.
|
|
2
2
|
|
|
3
|
-
Supports Counter Movement Jump (CMJ)
|
|
3
|
+
Supports Counter Movement Jump (CMJ), Drop Jump, and Squat Jump (SJ) analysis
|
|
4
|
+
using MediaPipe pose estimation.
|
|
4
5
|
"""
|
|
5
6
|
|
|
6
7
|
from .api import (
|
|
@@ -8,13 +9,18 @@ from .api import (
|
|
|
8
9
|
CMJVideoResult,
|
|
9
10
|
DropJumpVideoConfig,
|
|
10
11
|
DropJumpVideoResult,
|
|
12
|
+
SJVideoConfig,
|
|
13
|
+
SJVideoResult,
|
|
11
14
|
process_cmj_video,
|
|
12
15
|
process_cmj_videos_bulk,
|
|
13
16
|
process_dropjump_video,
|
|
14
17
|
process_dropjump_videos_bulk,
|
|
18
|
+
process_sj_video,
|
|
19
|
+
process_sj_videos_bulk,
|
|
15
20
|
)
|
|
16
21
|
from .countermovement_jump.kinematics import CMJMetrics
|
|
17
22
|
from .drop_jump.kinematics import DropJumpMetrics
|
|
23
|
+
from .squat_jump.kinematics import SJMetrics
|
|
18
24
|
|
|
19
25
|
# Get version from package metadata (set in pyproject.toml)
|
|
20
26
|
try:
|
|
@@ -38,5 +44,11 @@ __all__ = [
|
|
|
38
44
|
"CMJVideoConfig",
|
|
39
45
|
"CMJVideoResult",
|
|
40
46
|
"CMJMetrics",
|
|
47
|
+
# Squat Jump API
|
|
48
|
+
"process_sj_video",
|
|
49
|
+
"process_sj_videos_bulk",
|
|
50
|
+
"SJVideoConfig",
|
|
51
|
+
"SJVideoResult",
|
|
52
|
+
"SJMetrics",
|
|
41
53
|
"__version__",
|
|
42
54
|
]
|
kinemotion/api.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Public API for programmatic use of kinemotion analysis.
|
|
2
2
|
|
|
3
|
-
This module provides a unified interface for
|
|
3
|
+
This module provides a unified interface for drop jump, CMJ, and Squat Jump analysis.
|
|
4
4
|
The actual implementations have been moved to their respective submodules:
|
|
5
5
|
- Drop jump: kinemotion.drop_jump.api
|
|
6
6
|
- CMJ: kinemotion.countermovement_jump.api
|
|
7
|
+
- Squat Jump: kinemotion.squat_jump.api
|
|
7
8
|
|
|
8
9
|
"""
|
|
9
10
|
|
|
@@ -28,6 +29,18 @@ from .drop_jump.api import (
|
|
|
28
29
|
process_dropjump_videos_bulk,
|
|
29
30
|
)
|
|
30
31
|
|
|
32
|
+
# Squat Jump API
|
|
33
|
+
from .squat_jump.api import (
|
|
34
|
+
AnalysisOverrides as SJAnalysisOverrides,
|
|
35
|
+
)
|
|
36
|
+
from .squat_jump.api import (
|
|
37
|
+
SJVideoConfig,
|
|
38
|
+
SJVideoResult,
|
|
39
|
+
process_sj_video,
|
|
40
|
+
process_sj_videos_bulk,
|
|
41
|
+
)
|
|
42
|
+
from .squat_jump.kinematics import SJMetrics
|
|
43
|
+
|
|
31
44
|
__all__ = [
|
|
32
45
|
# Drop jump
|
|
33
46
|
"AnalysisOverrides",
|
|
@@ -42,4 +55,11 @@ __all__ = [
|
|
|
42
55
|
"CMJVideoResult",
|
|
43
56
|
"process_cmj_video",
|
|
44
57
|
"process_cmj_videos_bulk",
|
|
58
|
+
# Squat Jump
|
|
59
|
+
"SJAnalysisOverrides",
|
|
60
|
+
"SJMetrics",
|
|
61
|
+
"SJVideoConfig",
|
|
62
|
+
"SJVideoResult",
|
|
63
|
+
"process_sj_video",
|
|
64
|
+
"process_sj_videos_bulk",
|
|
45
65
|
]
|
kinemotion/cli.py
CHANGED
|
@@ -4,6 +4,7 @@ import click
|
|
|
4
4
|
|
|
5
5
|
from .countermovement_jump.cli import cmj_analyze
|
|
6
6
|
from .drop_jump.cli import dropjump_analyze
|
|
7
|
+
from .squat_jump.cli import sj_analyze
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
@click.group()
|
|
@@ -17,6 +18,7 @@ def cli() -> None: # type: ignore[return]
|
|
|
17
18
|
# Type ignore needed because @click.group() transforms cli into a click.Group
|
|
18
19
|
cli.add_command(dropjump_analyze) # type: ignore[attr-defined]
|
|
19
20
|
cli.add_command(cmj_analyze) # type: ignore[attr-defined]
|
|
21
|
+
cli.add_command(sj_analyze) # type: ignore[attr-defined]
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
if __name__ == "__main__":
|
kinemotion/core/filtering.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Advanced filtering techniques for robust trajectory processing."""
|
|
2
2
|
|
|
3
3
|
import numpy as np
|
|
4
|
+
from numpy.lib.stride_tricks import sliding_window_view
|
|
5
|
+
from scipy.ndimage import convolve1d
|
|
4
6
|
from scipy.signal import medfilt
|
|
5
7
|
|
|
6
8
|
from .experimental import unused
|
|
@@ -31,6 +33,8 @@ def detect_outliers_ransac(
|
|
|
31
33
|
from a polynomial fit of nearby points. This catches MediaPipe tracking glitches
|
|
32
34
|
where landmarks jump to incorrect positions.
|
|
33
35
|
|
|
36
|
+
Vectorized implementation using convolution for 10-20x speedup.
|
|
37
|
+
|
|
34
38
|
Args:
|
|
35
39
|
positions: 1D array of position values (e.g., y-coordinates)
|
|
36
40
|
window_size: Size of sliding window for local fitting
|
|
@@ -49,35 +53,79 @@ def detect_outliers_ransac(
|
|
|
49
53
|
window_size = _ensure_odd_window_length(window_size)
|
|
50
54
|
half_window = window_size // 2
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
# For centered quadratic fit, we can compute the predicted value at
|
|
57
|
+
# the window center using convolution. This is much faster than
|
|
58
|
+
# calling np.polyfit for each window.
|
|
59
|
+
#
|
|
60
|
+
# For a quadratic fit y = ax² + bx + c with centered window:
|
|
61
|
+
# - Predicted value at center (x=0) is just the intercept c
|
|
62
|
+
# - c can be computed from sum(y) and sum(x²*y) using precomputed constants
|
|
63
|
+
#
|
|
64
|
+
# The key insight: sum(y) and sum(x²*y) are convolution operations!
|
|
65
|
+
|
|
66
|
+
# Window indices (centered at 0)
|
|
67
|
+
x = np.arange(-half_window, half_window + 1)
|
|
68
|
+
|
|
69
|
+
# Precompute constants for the normal equations
|
|
70
|
+
sum_x2 = np.sum(x**2)
|
|
71
|
+
sum_x4 = np.sum(x**4)
|
|
72
|
+
det = window_size * sum_x4 - sum_x2**2
|
|
73
|
+
|
|
74
|
+
# Handle edge case where determinant is zero (shouldn't happen with valid window)
|
|
75
|
+
if det == 0:
|
|
76
|
+
return is_outlier
|
|
58
77
|
|
|
59
|
-
|
|
60
|
-
|
|
78
|
+
# Kernels for convolution
|
|
79
|
+
ones_kernel = np.ones(window_size)
|
|
80
|
+
x2_kernel = x**2
|
|
81
|
+
|
|
82
|
+
# Pad positions for boundary handling (use edge padding like original)
|
|
83
|
+
pad_width = half_window
|
|
84
|
+
padded = np.pad(positions, pad_width, mode="edge")
|
|
85
|
+
|
|
86
|
+
# Compute sums via convolution
|
|
87
|
+
# sum_y[i] = sum of positions in window centered at i
|
|
88
|
+
# sum_x2y[i] = sum of (x² * positions) in window centered at i
|
|
89
|
+
sum_y = convolve1d(padded, ones_kernel, mode="constant")
|
|
90
|
+
sum_x2y = convolve1d(padded, x2_kernel, mode="constant")
|
|
91
|
+
|
|
92
|
+
# Remove padding to match original positions length
|
|
93
|
+
sum_y = sum_y[pad_width:-pad_width]
|
|
94
|
+
sum_x2y = sum_x2y[pad_width:-pad_width]
|
|
95
|
+
|
|
96
|
+
# Compute predicted values at window centers
|
|
97
|
+
# For centered fit: predicted = c = (sum_x4 * sum_y - sum_x2 * sum_x2y) / det
|
|
98
|
+
predicted = (sum_x4 * sum_y - sum_x2 * sum_x2y) / det
|
|
61
99
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
100
|
+
# Calculate residuals
|
|
101
|
+
residuals = np.abs(positions - predicted)
|
|
102
|
+
|
|
103
|
+
# Mark outliers based on threshold
|
|
104
|
+
outlier_candidates = residuals > threshold
|
|
105
|
+
|
|
106
|
+
if not np.any(outlier_candidates):
|
|
107
|
+
return is_outlier
|
|
108
|
+
|
|
109
|
+
# RANSAC criterion: point is outlier if most OTHER points in window are inliers
|
|
110
|
+
# Compute fraction of inliers in each window using convolution
|
|
111
|
+
inlier_mask = (residuals <= threshold).astype(float)
|
|
112
|
+
inliers_in_window = convolve1d(
|
|
113
|
+
np.pad(inlier_mask, pad_width, mode="edge"),
|
|
114
|
+
ones_kernel,
|
|
115
|
+
mode="constant",
|
|
116
|
+
)
|
|
117
|
+
inliers_in_window = inliers_in_window[pad_width:-pad_width]
|
|
118
|
+
|
|
119
|
+
# Account for variable window sizes at boundaries
|
|
120
|
+
# At boundaries, windows are smaller, so we need to adjust the count
|
|
121
|
+
for i in range(n):
|
|
122
|
+
actual_window_size = min(i + half_window + 1, n) - max(0, i - half_window)
|
|
123
|
+
if actual_window_size < 3:
|
|
80
124
|
continue
|
|
125
|
+
if outlier_candidates[i]:
|
|
126
|
+
inlier_fraction = inliers_in_window[i] / actual_window_size
|
|
127
|
+
if inlier_fraction >= min_inliers:
|
|
128
|
+
is_outlier[i] = True
|
|
81
129
|
|
|
82
130
|
return is_outlier
|
|
83
131
|
|
|
@@ -310,6 +358,8 @@ def bilateral_temporal_filter(
|
|
|
310
358
|
1. Temporal distance (like regular smoothing)
|
|
311
359
|
2. Intensity similarity (preserves edges)
|
|
312
360
|
|
|
361
|
+
Vectorized implementation using sliding_window_view for 10-30x speedup.
|
|
362
|
+
|
|
313
363
|
Args:
|
|
314
364
|
positions: 1D array of position values
|
|
315
365
|
window_size: Temporal window size (must be odd)
|
|
@@ -320,33 +370,39 @@ def bilateral_temporal_filter(
|
|
|
320
370
|
Filtered position array
|
|
321
371
|
"""
|
|
322
372
|
n = len(positions)
|
|
323
|
-
|
|
373
|
+
if n == 0:
|
|
374
|
+
return np.array([])
|
|
324
375
|
|
|
325
376
|
window_size = _ensure_odd_window_length(window_size)
|
|
326
377
|
half_window = window_size // 2
|
|
327
378
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
379
|
+
# Pad edges with boundary values to maintain consistent window size
|
|
380
|
+
# This provides context for boundary positions while preserving edge information
|
|
381
|
+
padded = np.pad(positions, half_window, mode="edge")
|
|
382
|
+
|
|
383
|
+
# Create all sliding windows at once: shape (n, window_size)
|
|
384
|
+
# Each row represents the window centered at the corresponding input position
|
|
385
|
+
windows = sliding_window_view(padded, window_size)
|
|
332
386
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
387
|
+
# Precompute spatial weights (only depends on distance from center)
|
|
388
|
+
temporal_indices = np.arange(-half_window, half_window + 1)
|
|
389
|
+
spatial_weights = np.exp(-(temporal_indices**2) / (2 * sigma_spatial**2))
|
|
336
390
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
391
|
+
# Extract center positions for intensity weight computation
|
|
392
|
+
center_positions = windows[:, half_window] # Shape: (n,)
|
|
393
|
+
center_positions = center_positions.reshape(-1, 1) # Shape: (n, 1) for broadcast
|
|
340
394
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
395
|
+
# Compute intensity weights (data-dependent, varies by window)
|
|
396
|
+
# intensity_diff[i, j] = windows[i, j] - windows[i, center]
|
|
397
|
+
intensity_diff = windows - center_positions # Broadcasting: (n, window_size)
|
|
398
|
+
intensity_weights = np.exp(-(intensity_diff**2) / (2 * sigma_intensity**2))
|
|
344
399
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
400
|
+
# Combine weights: spatial_weights broadcasts to (n, window_size)
|
|
401
|
+
weights = spatial_weights * intensity_weights
|
|
402
|
+
# Normalize each window's weights to sum to 1
|
|
403
|
+
weights /= weights.sum(axis=1, keepdims=True)
|
|
348
404
|
|
|
349
|
-
|
|
350
|
-
|
|
405
|
+
# Compute weighted average for each window
|
|
406
|
+
filtered = (weights * windows).sum(axis=1)
|
|
351
407
|
|
|
352
408
|
return filtered
|
kinemotion/drop_jump/analysis.py
CHANGED
|
@@ -314,6 +314,8 @@ def _assign_contact_states(
|
|
|
314
314
|
) -> list[ContactState]:
|
|
315
315
|
"""Assign contact states based on contact frames and visibility.
|
|
316
316
|
|
|
317
|
+
Vectorized implementation for 2-3x speedup over loop-based version.
|
|
318
|
+
|
|
317
319
|
Args:
|
|
318
320
|
n_frames: Total number of frames
|
|
319
321
|
contact_frames: Set of frames with confirmed contact
|
|
@@ -323,15 +325,33 @@ def _assign_contact_states(
|
|
|
323
325
|
Returns:
|
|
324
326
|
List of ContactState for each frame
|
|
325
327
|
"""
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
328
|
+
# Integer mapping for vectorized operations: IN_AIR=0, ON_GROUND=1, UNKNOWN=2
|
|
329
|
+
_state_order = [ContactState.IN_AIR, ContactState.ON_GROUND, ContactState.UNKNOWN]
|
|
330
|
+
|
|
331
|
+
# Initialize with IN_AIR (default)
|
|
332
|
+
states = np.zeros(n_frames, dtype=np.int8)
|
|
333
|
+
|
|
334
|
+
# Mark ON_GROUND where visibility is sufficient
|
|
335
|
+
if contact_frames:
|
|
336
|
+
contact_array = np.fromiter(contact_frames, dtype=int)
|
|
337
|
+
# Filter to valid indices only
|
|
338
|
+
valid_mask = (contact_array >= 0) & (contact_array < n_frames)
|
|
339
|
+
valid_contacts = contact_array[valid_mask]
|
|
340
|
+
|
|
341
|
+
# Only mark ON_GROUND for frames with good visibility
|
|
342
|
+
if visibilities is not None:
|
|
343
|
+
good_visibility = visibilities[valid_contacts] >= visibility_threshold
|
|
344
|
+
states[valid_contacts[good_visibility]] = 1
|
|
332
345
|
else:
|
|
333
|
-
states
|
|
334
|
-
|
|
346
|
+
states[valid_contacts] = 1
|
|
347
|
+
|
|
348
|
+
# Mark UNKNOWN last (highest priority - overrides ON_GROUND)
|
|
349
|
+
if visibilities is not None:
|
|
350
|
+
unknown_mask = visibilities < visibility_threshold
|
|
351
|
+
states[unknown_mask] = 2
|
|
352
|
+
|
|
353
|
+
# Convert integer indices back to ContactState
|
|
354
|
+
return [_state_order[s] for s in states]
|
|
335
355
|
|
|
336
356
|
|
|
337
357
|
def _compute_near_ground_mask(
|