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.
@@ -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