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