kinemotion 0.75.0__py3-none-any.whl → 0.76.1__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 +13 -1
- kinemotion/api.py +21 -1
- kinemotion/cli.py +2 -0
- kinemotion/countermovement_jump/metrics_validator.py +4 -6
- kinemotion/squat_jump/__init__.py +5 -0
- kinemotion/squat_jump/analysis.py +377 -0
- kinemotion/squat_jump/api.py +610 -0
- kinemotion/squat_jump/cli.py +309 -0
- kinemotion/squat_jump/debug_overlay.py +163 -0
- kinemotion/squat_jump/kinematics.py +342 -0
- kinemotion/squat_jump/metrics_validator.py +438 -0
- kinemotion/squat_jump/validation_bounds.py +221 -0
- {kinemotion-0.75.0.dist-info → kinemotion-0.76.1.dist-info}/METADATA +51 -2
- {kinemotion-0.75.0.dist-info → kinemotion-0.76.1.dist-info}/RECORD +17 -9
- {kinemotion-0.75.0.dist-info → kinemotion-0.76.1.dist-info}/WHEEL +0 -0
- {kinemotion-0.75.0.dist-info → kinemotion-0.76.1.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.75.0.dist-info → kinemotion-0.76.1.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__":
|
|
@@ -124,8 +124,8 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
124
124
|
self._check_flight_time(data, result, profile)
|
|
125
125
|
self._check_jump_height(data, result, profile)
|
|
126
126
|
self._check_countermovement_depth(data, result, profile)
|
|
127
|
-
self._check_concentric_duration(data, result
|
|
128
|
-
self._check_eccentric_duration(data, result
|
|
127
|
+
self._check_concentric_duration(data, result)
|
|
128
|
+
self._check_eccentric_duration(data, result)
|
|
129
129
|
self._check_peak_velocities(data, result, profile)
|
|
130
130
|
|
|
131
131
|
# CROSS-VALIDATION CHECKS
|
|
@@ -220,7 +220,7 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
220
220
|
)
|
|
221
221
|
|
|
222
222
|
def _check_concentric_duration(
|
|
223
|
-
self, metrics: MetricsDict, result: CMJValidationResult
|
|
223
|
+
self, metrics: MetricsDict, result: CMJValidationResult
|
|
224
224
|
) -> None:
|
|
225
225
|
"""Validate concentric duration (contact time)."""
|
|
226
226
|
duration_raw = self._get_metric_value(
|
|
@@ -256,9 +256,7 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
256
256
|
value=duration,
|
|
257
257
|
)
|
|
258
258
|
|
|
259
|
-
def _check_eccentric_duration(
|
|
260
|
-
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
261
|
-
) -> None:
|
|
259
|
+
def _check_eccentric_duration(self, metrics: MetricsDict, result: CMJValidationResult) -> None:
|
|
262
260
|
"""Validate eccentric duration."""
|
|
263
261
|
duration_raw = self._get_metric_value(
|
|
264
262
|
metrics, "eccentric_duration_ms", "eccentric_duration"
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Phase detection logic for Squat Jump (SJ) analysis."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.signal import savgol_filter
|
|
7
|
+
|
|
8
|
+
from ..core.types import FloatArray
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SJPhase(Enum):
|
|
12
|
+
"""Phases of a squat jump."""
|
|
13
|
+
|
|
14
|
+
SQUAT_HOLD = "squat_hold"
|
|
15
|
+
CONCENTRIC = "concentric"
|
|
16
|
+
FLIGHT = "flight"
|
|
17
|
+
LANDING = "landing"
|
|
18
|
+
UNKNOWN = "unknown"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def detect_sj_phases(
|
|
22
|
+
positions: FloatArray,
|
|
23
|
+
fps: float,
|
|
24
|
+
velocity_threshold: float = 0.1,
|
|
25
|
+
window_length: int = 5,
|
|
26
|
+
polyorder: int = 2,
|
|
27
|
+
) -> tuple[int, int, int, int] | None:
|
|
28
|
+
"""Detect phases in a squat jump video.
|
|
29
|
+
|
|
30
|
+
Squat Jump phase detection follows this progression:
|
|
31
|
+
1. Squat hold - static squat position
|
|
32
|
+
2. Concentric - upward movement from squat
|
|
33
|
+
3. Takeoff - feet leave ground
|
|
34
|
+
4. Flight - in the air
|
|
35
|
+
5. Landing - feet contact ground
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
positions: 1D array of vertical positions (normalized coordinates)
|
|
39
|
+
fps: Video frames per second
|
|
40
|
+
velocity_threshold: Threshold for detecting flight phase (m/s)
|
|
41
|
+
window_length: Window size for velocity smoothing
|
|
42
|
+
polyorder: Polynomial order for smoothing
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Tuple of (squat_hold_start, concentric_start, takeoff_frame, landing_frame)
|
|
46
|
+
or None if phases cannot be detected
|
|
47
|
+
"""
|
|
48
|
+
if len(positions) < 20: # Minimum viable sequence length
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
# Ensure window length is appropriate for derivative calculations
|
|
52
|
+
if window_length % 2 == 0:
|
|
53
|
+
window_length += 1
|
|
54
|
+
|
|
55
|
+
# Step 1: Detect squat hold start
|
|
56
|
+
squat_hold_start = detect_squat_start(
|
|
57
|
+
positions,
|
|
58
|
+
fps,
|
|
59
|
+
velocity_threshold=velocity_threshold,
|
|
60
|
+
window_length=window_length,
|
|
61
|
+
polyorder=polyorder,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if squat_hold_start is None:
|
|
65
|
+
# If no squat hold detected, start from reasonable beginning
|
|
66
|
+
squat_hold_start = len(positions) // 4
|
|
67
|
+
|
|
68
|
+
# Step 2: Compute signed velocities for phase detection
|
|
69
|
+
if len(positions) < window_length:
|
|
70
|
+
# Fallback for short sequences
|
|
71
|
+
velocities = np.diff(positions, prepend=positions[0])
|
|
72
|
+
else:
|
|
73
|
+
velocities = savgol_filter(
|
|
74
|
+
positions, window_length, polyorder, deriv=1, delta=1.0, mode="interp"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Step 3: Detect takeoff (this marks the start of concentric phase)
|
|
78
|
+
takeoff_frame = detect_takeoff(
|
|
79
|
+
positions, velocities, fps, velocity_threshold=velocity_threshold
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if takeoff_frame is None:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
# Concentric start begins when upward movement starts after squat hold
|
|
86
|
+
# This is just before takeoff when velocity becomes significantly negative
|
|
87
|
+
concentric_start = takeoff_frame
|
|
88
|
+
# Look backward from takeoff to find where concentric phase truly begins
|
|
89
|
+
for i in range(takeoff_frame - 1, max(squat_hold_start, 0), -1):
|
|
90
|
+
if velocities[i] <= -velocity_threshold * 0.5: # Start of upward movement
|
|
91
|
+
concentric_start = i
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
# Step 4: Detect landing
|
|
95
|
+
landing_frame = detect_landing(
|
|
96
|
+
positions,
|
|
97
|
+
velocities,
|
|
98
|
+
fps,
|
|
99
|
+
velocity_threshold=velocity_threshold,
|
|
100
|
+
min_flight_frames=5,
|
|
101
|
+
landing_search_window_s=0.5,
|
|
102
|
+
window_length=window_length,
|
|
103
|
+
polyorder=polyorder,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if landing_frame is None:
|
|
107
|
+
# Fallback: estimate landing from peak height + typical flight time
|
|
108
|
+
# takeoff_frame is guaranteed to be an int here (we returned earlier if None)
|
|
109
|
+
peak_search_start = takeoff_frame
|
|
110
|
+
peak_search_end = min(len(positions), takeoff_frame + int(fps * 1.0))
|
|
111
|
+
if peak_search_end > peak_search_start:
|
|
112
|
+
flight_positions = positions[peak_search_start:peak_search_end]
|
|
113
|
+
peak_idx = int(np.argmin(flight_positions))
|
|
114
|
+
peak_frame = peak_search_start + peak_idx
|
|
115
|
+
# Estimate landing as 0.3s after peak (typical SJ flight time)
|
|
116
|
+
landing_frame = peak_frame + int(fps * 0.3)
|
|
117
|
+
else:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
# Validate the detected phases
|
|
121
|
+
if landing_frame <= takeoff_frame or takeoff_frame <= squat_hold_start:
|
|
122
|
+
# Invalid phase sequence
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
# Return all detected phases
|
|
126
|
+
return (squat_hold_start, concentric_start, takeoff_frame, landing_frame)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def detect_squat_start(
|
|
130
|
+
positions: FloatArray,
|
|
131
|
+
fps: float,
|
|
132
|
+
velocity_threshold: float = 0.1,
|
|
133
|
+
min_hold_duration: float = 0.15,
|
|
134
|
+
min_search_frame: int = 20,
|
|
135
|
+
window_length: int = 5,
|
|
136
|
+
polyorder: int = 2,
|
|
137
|
+
) -> int | None:
|
|
138
|
+
"""Detect start of squat hold phase.
|
|
139
|
+
|
|
140
|
+
Squat hold is detected when the athlete maintains a relatively stable
|
|
141
|
+
position with low velocity for a minimum duration.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
positions: 1D array of vertical positions (normalized coordinates)
|
|
145
|
+
fps: Video frames per second
|
|
146
|
+
velocity_threshold: Maximum velocity to consider stationary (m/s)
|
|
147
|
+
min_hold_duration: Minimum duration to maintain stable position (seconds)
|
|
148
|
+
min_search_frame: Minimum frame to start searching (avoid initial settling)
|
|
149
|
+
window_length: Window size for velocity smoothing
|
|
150
|
+
polyorder: Polynomial order for smoothing
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Frame index where squat hold begins, or None if not detected
|
|
154
|
+
"""
|
|
155
|
+
if len(positions) < min_search_frame + 10:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
# Compute velocity to detect stationary periods
|
|
159
|
+
if len(positions) < window_length:
|
|
160
|
+
velocities = np.abs(np.diff(positions, prepend=positions[0]))
|
|
161
|
+
else:
|
|
162
|
+
if window_length % 2 == 0:
|
|
163
|
+
window_length += 1
|
|
164
|
+
velocities = np.abs(
|
|
165
|
+
savgol_filter(positions, window_length, polyorder, deriv=1, delta=1.0, mode="interp")
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Search for stable period with low velocity
|
|
169
|
+
min_hold_frames = int(min_hold_duration * fps)
|
|
170
|
+
start_search = min_search_frame
|
|
171
|
+
end_search = len(positions) - min_hold_frames
|
|
172
|
+
|
|
173
|
+
# Look for consecutive frames with velocity below threshold
|
|
174
|
+
for i in range(start_search, end_search):
|
|
175
|
+
# Check if we have a stable period of sufficient duration
|
|
176
|
+
stable_period = velocities[i : i + min_hold_frames]
|
|
177
|
+
if np.all(stable_period <= velocity_threshold):
|
|
178
|
+
# Found a stable period - return start of this period
|
|
179
|
+
return i
|
|
180
|
+
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _find_takeoff_threshold_crossing(
|
|
185
|
+
velocities: FloatArray,
|
|
186
|
+
search_start: int,
|
|
187
|
+
search_end: int,
|
|
188
|
+
velocity_threshold: float,
|
|
189
|
+
min_duration_frames: int,
|
|
190
|
+
) -> int | None:
|
|
191
|
+
"""Find first frame where velocity exceeds threshold for minimum duration."""
|
|
192
|
+
above_threshold = velocities[search_start:search_end] <= -velocity_threshold
|
|
193
|
+
if not np.any(above_threshold):
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
threshold_indices = np.nonzero(above_threshold)[0]
|
|
197
|
+
for idx in threshold_indices:
|
|
198
|
+
if idx + min_duration_frames < len(above_threshold):
|
|
199
|
+
if np.all(above_threshold[idx : idx + min_duration_frames]):
|
|
200
|
+
return search_start + idx
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def detect_takeoff(
|
|
205
|
+
positions: FloatArray,
|
|
206
|
+
velocities: FloatArray,
|
|
207
|
+
fps: float,
|
|
208
|
+
velocity_threshold: float = 0.1,
|
|
209
|
+
min_duration_frames: int = 5,
|
|
210
|
+
search_window_s: float = 0.3,
|
|
211
|
+
) -> int | None:
|
|
212
|
+
"""Detect takeoff frame in squat jump.
|
|
213
|
+
|
|
214
|
+
Takeoff occurs at peak upward velocity during the concentric phase.
|
|
215
|
+
For SJ, this is simply the maximum negative velocity after squat hold
|
|
216
|
+
before the upward movement.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
positions: 1D array of vertical positions (normalized coordinates)
|
|
220
|
+
velocities: 1D array of SIGNED vertical velocities (negative = upward)
|
|
221
|
+
fps: Video frames per second
|
|
222
|
+
velocity_threshold: Minimum velocity threshold for takeoff (m/s)
|
|
223
|
+
min_duration_frames: Minimum frames above threshold to confirm takeoff
|
|
224
|
+
search_window_s: Time window to search for takeoff after squat hold (seconds)
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Frame index where takeoff occurs, or None if not detected
|
|
228
|
+
"""
|
|
229
|
+
if len(positions) == 0 or len(velocities) == 0:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
# Find squat start to determine where to begin search
|
|
233
|
+
squat_start = detect_squat_start(positions, fps)
|
|
234
|
+
if squat_start is None:
|
|
235
|
+
# If no squat start detected, start from reasonable middle point
|
|
236
|
+
squat_start = len(positions) // 3
|
|
237
|
+
|
|
238
|
+
# Search for takeoff after squat start
|
|
239
|
+
search_start = squat_start
|
|
240
|
+
search_end = min(len(velocities), int(squat_start + search_window_s * fps))
|
|
241
|
+
|
|
242
|
+
if search_end <= search_start:
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
# Focus on concentric phase where velocity becomes negative (upward)
|
|
246
|
+
concentric_velocities = velocities[search_start:search_end]
|
|
247
|
+
|
|
248
|
+
# Find the most negative velocity (peak upward velocity = takeoff)
|
|
249
|
+
takeoff_idx = int(np.argmin(concentric_velocities))
|
|
250
|
+
takeoff_frame = search_start + takeoff_idx
|
|
251
|
+
|
|
252
|
+
# Verify velocity exceeds threshold
|
|
253
|
+
if velocities[takeoff_frame] > -velocity_threshold:
|
|
254
|
+
# Velocity not high enough - actual takeoff may be later
|
|
255
|
+
# Look for frames where velocity exceeds threshold with duration filter
|
|
256
|
+
return _find_takeoff_threshold_crossing(
|
|
257
|
+
velocities, search_start, search_end, velocity_threshold, min_duration_frames
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return takeoff_frame if velocities[takeoff_frame] <= -velocity_threshold else None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _detect_impact_landing(
|
|
264
|
+
accelerations: FloatArray,
|
|
265
|
+
search_start: int,
|
|
266
|
+
search_end: int,
|
|
267
|
+
) -> int | None:
|
|
268
|
+
"""Detect landing by finding the maximum acceleration spike."""
|
|
269
|
+
landing_accelerations = accelerations[search_start:search_end]
|
|
270
|
+
if len(landing_accelerations) == 0:
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
# Find maximum acceleration spike (impact)
|
|
274
|
+
landing_idx = int(np.argmax(landing_accelerations))
|
|
275
|
+
return search_start + landing_idx
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _refine_landing_by_velocity(
|
|
279
|
+
velocities: FloatArray,
|
|
280
|
+
landing_frame: int,
|
|
281
|
+
) -> int:
|
|
282
|
+
"""Refine landing frame by looking for positive (downward) velocity."""
|
|
283
|
+
if landing_frame < len(velocities) and velocities[landing_frame] < 0:
|
|
284
|
+
# Velocity still upward - landing might not be detected yet
|
|
285
|
+
# Look ahead for where velocity becomes positive
|
|
286
|
+
for i in range(landing_frame, min(landing_frame + 10, len(velocities))):
|
|
287
|
+
if velocities[i] >= 0:
|
|
288
|
+
return i
|
|
289
|
+
return landing_frame
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def detect_landing(
|
|
293
|
+
positions: FloatArray,
|
|
294
|
+
velocities: FloatArray,
|
|
295
|
+
fps: float,
|
|
296
|
+
velocity_threshold: float = 0.1,
|
|
297
|
+
min_flight_frames: int = 5,
|
|
298
|
+
landing_search_window_s: float = 0.5,
|
|
299
|
+
window_length: int = 5,
|
|
300
|
+
polyorder: int = 2,
|
|
301
|
+
) -> int | None:
|
|
302
|
+
"""Detect landing frame in squat jump.
|
|
303
|
+
|
|
304
|
+
Landing occurs after peak height when feet contact the ground.
|
|
305
|
+
Similar to CMJ - find position peak, then detect maximum deceleration
|
|
306
|
+
which corresponds to impact.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
positions: 1D array of vertical positions (normalized coordinates)
|
|
310
|
+
velocities: 1D array of SIGNED vertical velocities (negative = up, positive = down)
|
|
311
|
+
fps: Video frames per second
|
|
312
|
+
velocity_threshold: Maximum velocity threshold for landing detection
|
|
313
|
+
min_flight_frames: Minimum frames in flight before landing
|
|
314
|
+
landing_search_window_s: Time window to search for landing after peak (seconds)
|
|
315
|
+
window_length: Window size for velocity smoothing
|
|
316
|
+
polyorder: Polynomial order for smoothing
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Frame index where landing occurs, or None if not detected
|
|
320
|
+
"""
|
|
321
|
+
if len(positions) == 0 or len(velocities) == 0:
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
# Find takeoff first (needed to determine where to start peak search)
|
|
325
|
+
takeoff_frame = detect_takeoff(
|
|
326
|
+
positions, velocities, fps, velocity_threshold, 5, landing_search_window_s
|
|
327
|
+
)
|
|
328
|
+
if takeoff_frame is None:
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
# Find peak height after takeoff
|
|
332
|
+
search_start = takeoff_frame
|
|
333
|
+
search_duration = int(fps * 0.7) # Search next 0.7 seconds for peak
|
|
334
|
+
search_end = min(len(positions), search_start + search_duration)
|
|
335
|
+
|
|
336
|
+
if search_end <= search_start:
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
# Find peak height (minimum position value in normalized coords = highest point)
|
|
340
|
+
flight_positions = positions[search_start:search_end]
|
|
341
|
+
peak_frame = search_start + int(np.argmin(flight_positions))
|
|
342
|
+
|
|
343
|
+
# After peak, look for landing using impact detection
|
|
344
|
+
landing_search_start = peak_frame + min_flight_frames
|
|
345
|
+
landing_search_end = min(
|
|
346
|
+
len(positions),
|
|
347
|
+
landing_search_start + int(landing_search_window_s * fps),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if landing_search_end <= landing_search_start:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
# Compute accelerations for impact detection
|
|
354
|
+
if len(positions) >= 5:
|
|
355
|
+
landing_window = window_length
|
|
356
|
+
if landing_window % 2 == 0:
|
|
357
|
+
landing_window += 1
|
|
358
|
+
# Use polyorder for smoothing (must be at least 2 for deriv=2)
|
|
359
|
+
eff_polyorder = max(2, polyorder)
|
|
360
|
+
accelerations = np.abs(
|
|
361
|
+
savgol_filter(
|
|
362
|
+
positions, landing_window, eff_polyorder, deriv=2, delta=1.0, mode="interp"
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
else:
|
|
366
|
+
# Fallback for short sequences
|
|
367
|
+
velocities_abs = np.abs(velocities)
|
|
368
|
+
accelerations = np.abs(np.diff(velocities_abs, prepend=velocities_abs[0]))
|
|
369
|
+
|
|
370
|
+
# Find impact: maximum positive acceleration (deceleration spike)
|
|
371
|
+
landing_frame = _detect_impact_landing(accelerations, landing_search_start, landing_search_end)
|
|
372
|
+
|
|
373
|
+
if landing_frame is None:
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
# Additional verification: velocity should be positive (downward) at landing
|
|
377
|
+
return _refine_landing_by_velocity(velocities, landing_frame)
|