kinemotion 0.10.12__py3-none-any.whl → 0.11.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.

@@ -0,0 +1,548 @@
1
+ """Phase detection logic for Counter Movement Jump (CMJ) analysis."""
2
+
3
+ from enum import Enum
4
+
5
+ import numpy as np
6
+ from scipy.signal import savgol_filter
7
+
8
+ from ..core.smoothing import compute_acceleration_from_derivative
9
+
10
+
11
+ def compute_signed_velocity(
12
+ positions: np.ndarray, window_length: int = 5, polyorder: int = 2
13
+ ) -> np.ndarray:
14
+ """
15
+ Compute SIGNED velocity for CMJ phase detection.
16
+
17
+ Unlike drop jump which uses absolute velocity, CMJ needs signed velocity to
18
+ distinguish upward (negative) from downward (positive) motion.
19
+
20
+ Args:
21
+ positions: 1D array of y-positions in normalized coordinates
22
+ window_length: Window size for Savitzky-Golay filter
23
+ polyorder: Polynomial order
24
+
25
+ Returns:
26
+ Signed velocity array where:
27
+ - Negative = upward motion (y decreasing, jumping up)
28
+ - Positive = downward motion (y increasing, squatting/falling)
29
+ """
30
+ if len(positions) < window_length:
31
+ return np.diff(positions, prepend=positions[0])
32
+
33
+ if window_length % 2 == 0:
34
+ window_length += 1
35
+
36
+ velocity = savgol_filter(
37
+ positions, window_length, polyorder, deriv=1, delta=1.0, mode="interp"
38
+ )
39
+
40
+ return velocity
41
+
42
+
43
+ class CMJPhase(Enum):
44
+ """Phases of a counter movement jump."""
45
+
46
+ STANDING = "standing"
47
+ ECCENTRIC = "eccentric" # Downward movement
48
+ TRANSITION = "transition" # At lowest point
49
+ CONCENTRIC = "concentric" # Upward movement
50
+ FLIGHT = "flight"
51
+ LANDING = "landing"
52
+ UNKNOWN = "unknown"
53
+
54
+
55
+ def find_standing_phase(
56
+ positions: np.ndarray,
57
+ velocities: np.ndarray,
58
+ fps: float,
59
+ min_standing_duration: float = 0.5,
60
+ velocity_threshold: float = 0.01,
61
+ ) -> int | None:
62
+ """
63
+ Find the end of standing phase (start of countermovement).
64
+
65
+ Looks for a period of low velocity (standing) followed by consistent downward motion.
66
+
67
+ Args:
68
+ positions: Array of vertical positions (normalized 0-1)
69
+ velocities: Array of vertical velocities
70
+ fps: Video frame rate
71
+ min_standing_duration: Minimum standing duration in seconds (default: 0.5s)
72
+ velocity_threshold: Velocity threshold for standing detection
73
+
74
+ Returns:
75
+ Frame index where countermovement begins, or None if not detected.
76
+ """
77
+ min_standing_frames = int(fps * min_standing_duration)
78
+
79
+ if len(positions) < min_standing_frames:
80
+ return None
81
+
82
+ # Find periods of low velocity (standing)
83
+ is_standing = np.abs(velocities) < velocity_threshold
84
+
85
+ # Look for first sustained standing period
86
+ standing_count = 0
87
+ standing_end = None
88
+
89
+ for i in range(len(is_standing)):
90
+ if is_standing[i]:
91
+ standing_count += 1
92
+ if standing_count >= min_standing_frames:
93
+ standing_end = i
94
+ else:
95
+ if standing_end is not None:
96
+ # Found end of standing phase
97
+ return standing_end
98
+ standing_count = 0
99
+
100
+ return None
101
+
102
+
103
+ def find_countermovement_start(
104
+ velocities: np.ndarray,
105
+ fps: float,
106
+ countermovement_threshold: float = 0.015,
107
+ min_eccentric_frames: int = 3,
108
+ standing_start: int | None = None,
109
+ ) -> int | None:
110
+ """
111
+ Find the start of countermovement (eccentric phase).
112
+
113
+ Detects when velocity becomes consistently positive (downward motion in normalized coords).
114
+
115
+ Args:
116
+ velocities: Array of SIGNED vertical velocities
117
+ fps: Video frame rate
118
+ countermovement_threshold: Velocity threshold for detecting downward motion (POSITIVE)
119
+ min_eccentric_frames: Minimum consecutive frames of downward motion
120
+ standing_start: Optional frame where standing phase ended
121
+
122
+ Returns:
123
+ Frame index where countermovement begins, or None if not detected.
124
+ """
125
+ start_frame = standing_start if standing_start is not None else 0
126
+
127
+ # Look for sustained downward velocity (POSITIVE in normalized coords)
128
+ is_downward = velocities[start_frame:] > countermovement_threshold
129
+ consecutive_count = 0
130
+
131
+ for i in range(len(is_downward)):
132
+ if is_downward[i]:
133
+ consecutive_count += 1
134
+ if consecutive_count >= min_eccentric_frames:
135
+ # Found start of eccentric phase
136
+ return start_frame + i - consecutive_count + 1
137
+ else:
138
+ consecutive_count = 0
139
+
140
+ return None
141
+
142
+
143
+ def find_lowest_point(
144
+ positions: np.ndarray,
145
+ velocities: np.ndarray,
146
+ eccentric_start: int | None = None,
147
+ min_search_frame: int = 80,
148
+ ) -> int:
149
+ """
150
+ Find the lowest point of countermovement (transition from eccentric to concentric).
151
+
152
+ The lowest point occurs BEFORE the peak height (the jump apex). It's where
153
+ velocity crosses from positive (downward/squatting) to negative (upward/jumping).
154
+
155
+ Args:
156
+ positions: Array of vertical positions (higher value = lower in video)
157
+ velocities: Array of SIGNED vertical velocities (positive=down, negative=up)
158
+ eccentric_start: Optional frame where eccentric phase started
159
+ min_search_frame: Minimum frame to start searching (default: frame 80)
160
+
161
+ Returns:
162
+ Frame index of lowest point.
163
+ """
164
+ # First, find the peak height (minimum y value = highest jump point)
165
+ peak_height_frame = int(np.argmin(positions))
166
+
167
+ # Lowest point MUST be before peak height
168
+ # Search from min_search_frame to peak_height_frame
169
+ start_frame = min_search_frame
170
+ end_frame = peak_height_frame
171
+
172
+ if end_frame <= start_frame:
173
+ start_frame = int(len(positions) * 0.3)
174
+ end_frame = int(len(positions) * 0.7)
175
+
176
+ search_positions = positions[start_frame:end_frame]
177
+
178
+ if len(search_positions) == 0:
179
+ return start_frame
180
+
181
+ # Find maximum position value in this range (lowest point in video)
182
+ lowest_idx = int(np.argmax(search_positions))
183
+ lowest_frame = start_frame + lowest_idx
184
+
185
+ return lowest_frame
186
+
187
+
188
+ def refine_transition_with_curvature(
189
+ positions: np.ndarray,
190
+ velocities: np.ndarray,
191
+ initial_frame: int,
192
+ transition_type: str,
193
+ search_radius: int = 3,
194
+ window_length: int = 5,
195
+ polyorder: int = 2,
196
+ ) -> float:
197
+ """
198
+ Refine transition frame using trajectory curvature (acceleration patterns).
199
+
200
+ Uses acceleration (second derivative) to identify characteristic patterns:
201
+ - Landing: Large acceleration spike (impact deceleration)
202
+ - Takeoff: Acceleration change (transition from static to flight)
203
+
204
+ Args:
205
+ positions: Array of vertical positions
206
+ velocities: Array of vertical velocities
207
+ initial_frame: Initial estimate of transition frame
208
+ transition_type: Type of transition ("takeoff" or "landing")
209
+ search_radius: Frames to search around initial estimate (±radius)
210
+ window_length: Window size for acceleration calculation
211
+ polyorder: Polynomial order for Savitzky-Golay filter
212
+
213
+ Returns:
214
+ Refined fractional frame index.
215
+ """
216
+ # Compute acceleration using second derivative
217
+ acceleration = compute_acceleration_from_derivative(
218
+ positions, window_length=window_length, polyorder=polyorder
219
+ )
220
+
221
+ # Define search window
222
+ search_start = max(0, initial_frame - search_radius)
223
+ search_end = min(len(positions), initial_frame + search_radius + 1)
224
+
225
+ if search_start >= search_end:
226
+ return float(initial_frame)
227
+
228
+ search_accel = acceleration[search_start:search_end]
229
+
230
+ if transition_type == "landing":
231
+ # Landing: Find maximum absolute acceleration (impact)
232
+ peak_idx = int(np.argmax(np.abs(search_accel)))
233
+ elif transition_type == "takeoff":
234
+ # Takeoff: Find maximum acceleration change
235
+ accel_change = np.abs(np.diff(search_accel))
236
+ if len(accel_change) > 0:
237
+ peak_idx = int(np.argmax(accel_change))
238
+ else:
239
+ peak_idx = 0
240
+ else:
241
+ return float(initial_frame)
242
+
243
+ curvature_frame = search_start + peak_idx
244
+
245
+ # Blend curvature-based estimate with velocity-based estimate
246
+ # 70% curvature, 30% velocity
247
+ blended_frame = 0.7 * curvature_frame + 0.3 * initial_frame
248
+
249
+ return float(blended_frame)
250
+
251
+
252
+ def interpolate_threshold_crossing(
253
+ vel_before: float,
254
+ vel_after: float,
255
+ velocity_threshold: float,
256
+ ) -> float:
257
+ """
258
+ Find fractional offset where velocity crosses threshold between two frames.
259
+
260
+ Uses linear interpolation assuming velocity changes linearly between frames.
261
+
262
+ Args:
263
+ vel_before: Velocity at frame boundary N (absolute value)
264
+ vel_after: Velocity at frame boundary N+1 (absolute value)
265
+ velocity_threshold: Threshold value
266
+
267
+ Returns:
268
+ Fractional offset from frame N (0.0 to 1.0)
269
+ """
270
+ # Handle edge cases
271
+ if abs(vel_after - vel_before) < 1e-9: # Velocity not changing
272
+ return 0.5
273
+
274
+ # Linear interpolation
275
+ t = (velocity_threshold - vel_before) / (vel_after - vel_before)
276
+
277
+ # Clamp to [0, 1] range
278
+ return float(max(0.0, min(1.0, t)))
279
+
280
+
281
+ def find_cmj_takeoff_from_velocity_peak(
282
+ positions: np.ndarray,
283
+ velocities: np.ndarray,
284
+ lowest_point_frame: int,
285
+ fps: float,
286
+ window_length: int = 5,
287
+ polyorder: int = 2,
288
+ ) -> float:
289
+ """
290
+ Find CMJ takeoff frame as peak upward velocity during concentric phase.
291
+
292
+ Takeoff occurs at maximum push-off velocity (most negative velocity),
293
+ just as feet leave the ground. This is BEFORE peak height is reached.
294
+
295
+ Args:
296
+ positions: Array of vertical positions
297
+ velocities: Array of SIGNED vertical velocities (negative = upward)
298
+ lowest_point_frame: Frame at lowest point
299
+ fps: Video frame rate
300
+ window_length: Window size for derivative calculations
301
+ polyorder: Polynomial order for Savitzky-Golay filter
302
+
303
+ Returns:
304
+ Takeoff frame with fractional precision.
305
+ """
306
+ concentric_start = int(lowest_point_frame)
307
+ search_duration = int(
308
+ fps * 0.3
309
+ ) # Search next 0.3 seconds (concentric to takeoff is brief)
310
+ search_end = min(len(velocities), concentric_start + search_duration)
311
+
312
+ if search_end <= concentric_start:
313
+ return float(concentric_start + 1)
314
+
315
+ # Find peak upward velocity (most NEGATIVE velocity)
316
+ # In normalized coords: negative velocity = y decreasing = jumping up
317
+ concentric_velocities = velocities[concentric_start:search_end]
318
+ takeoff_idx = int(
319
+ np.argmin(concentric_velocities)
320
+ ) # Most negative = fastest upward = takeoff
321
+ takeoff_frame = concentric_start + takeoff_idx
322
+
323
+ return float(takeoff_frame)
324
+
325
+
326
+ def find_cmj_landing_from_position_peak(
327
+ positions: np.ndarray,
328
+ velocities: np.ndarray,
329
+ accelerations: np.ndarray,
330
+ takeoff_frame: int,
331
+ fps: float,
332
+ ) -> float:
333
+ """
334
+ Find CMJ landing frame by detecting impact after peak height.
335
+
336
+ Landing occurs when feet contact ground after peak height, detected by
337
+ finding where velocity transitions from negative (still going up/at peak)
338
+ to positive (falling) and position stabilizes.
339
+
340
+ Args:
341
+ positions: Array of vertical positions
342
+ velocities: Array of SIGNED vertical velocities (negative = up, positive = down)
343
+ accelerations: Array of accelerations (second derivative)
344
+ takeoff_frame: Frame at takeoff
345
+ fps: Video frame rate
346
+
347
+ Returns:
348
+ Landing frame with fractional precision.
349
+ """
350
+ # Find peak height (minimum position value in normalized coords)
351
+ search_start = int(takeoff_frame)
352
+ search_duration = int(fps * 0.7) # Search next 0.7 seconds for peak
353
+ search_end = min(len(positions), search_start + search_duration)
354
+
355
+ if search_end <= search_start:
356
+ return float(search_start + int(fps * 0.3))
357
+
358
+ # Find peak height (minimum y value = highest point in frame)
359
+ flight_positions = positions[search_start:search_end]
360
+ peak_idx = int(np.argmin(flight_positions))
361
+ peak_frame = search_start + peak_idx
362
+
363
+ # After peak, look for landing (impact with ground)
364
+ # Landing is detected by maximum positive acceleration (deceleration on impact)
365
+ landing_search_start = peak_frame + 2
366
+ landing_search_end = min(len(accelerations), landing_search_start + int(fps * 0.5))
367
+
368
+ if landing_search_end <= landing_search_start:
369
+ return float(peak_frame + int(fps * 0.2))
370
+
371
+ # Find impact: maximum positive acceleration after peak
372
+ # Positive acceleration = slowing down upward motion or impact deceleration
373
+ landing_accelerations = accelerations[landing_search_start:landing_search_end]
374
+ impact_idx = int(np.argmax(landing_accelerations)) # Max positive = impact
375
+ landing_frame = landing_search_start + impact_idx
376
+
377
+ return float(landing_frame)
378
+
379
+
380
+ def find_interpolated_takeoff_landing(
381
+ positions: np.ndarray,
382
+ velocities: np.ndarray,
383
+ lowest_point_frame: int,
384
+ velocity_threshold: float = 0.02,
385
+ min_flight_frames: int = 3,
386
+ use_curvature: bool = True,
387
+ window_length: int = 5,
388
+ polyorder: int = 2,
389
+ ) -> tuple[float, float] | None:
390
+ """
391
+ Find takeoff and landing frames for CMJ using physics-based detection.
392
+
393
+ CMJ-specific: Takeoff is detected as peak velocity (end of push-off),
394
+ not as high velocity threshold (which detects mid-flight).
395
+
396
+ Args:
397
+ positions: Array of vertical positions
398
+ velocities: Array of vertical velocities
399
+ lowest_point_frame: Frame at lowest point
400
+ velocity_threshold: Velocity threshold (unused for CMJ, kept for API compatibility)
401
+ min_flight_frames: Minimum consecutive frames for valid flight phase
402
+ use_curvature: Whether to use trajectory curvature refinement
403
+ window_length: Window size for derivative calculations
404
+ polyorder: Polynomial order for Savitzky-Golay filter
405
+
406
+ Returns:
407
+ Tuple of (takeoff_frame, landing_frame) with fractional precision, or None.
408
+ """
409
+ # Get FPS from velocity array length and assumed duration
410
+ # This is approximate but sufficient for search windows
411
+ fps = 30.0 # Default assumption
412
+
413
+ # Compute accelerations for landing detection
414
+ accelerations = compute_acceleration_from_derivative(
415
+ positions, window_length=window_length, polyorder=polyorder
416
+ )
417
+
418
+ # Find takeoff using peak velocity method (CMJ-specific)
419
+ takeoff_frame = find_cmj_takeoff_from_velocity_peak(
420
+ positions, velocities, lowest_point_frame, fps, window_length, polyorder
421
+ )
422
+
423
+ # Find landing using position peak and impact detection
424
+ landing_frame = find_cmj_landing_from_position_peak(
425
+ positions, velocities, accelerations, int(takeoff_frame), fps
426
+ )
427
+
428
+ return (takeoff_frame, landing_frame)
429
+
430
+
431
+ def detect_cmj_phases(
432
+ positions: np.ndarray,
433
+ fps: float,
434
+ velocity_threshold: float = 0.02,
435
+ countermovement_threshold: float = -0.015,
436
+ min_contact_frames: int = 3,
437
+ min_eccentric_frames: int = 3,
438
+ use_curvature: bool = True,
439
+ window_length: int = 5,
440
+ polyorder: int = 2,
441
+ ) -> tuple[float | None, float, float, float] | None:
442
+ """
443
+ Detect all phases of a counter movement jump using a simplified, robust approach.
444
+
445
+ Strategy: Work BACKWARD from peak height to find all phases.
446
+ 1. Find peak height (global minimum y)
447
+ 2. Find takeoff (peak negative velocity before peak height)
448
+ 3. Find lowest point (maximum y value before takeoff)
449
+ 4. Find landing (impact after peak height)
450
+
451
+ Args:
452
+ positions: Array of vertical positions (normalized 0-1)
453
+ fps: Video frame rate
454
+ velocity_threshold: Velocity threshold (not used)
455
+ countermovement_threshold: Velocity threshold (not used)
456
+ min_contact_frames: Minimum frames for ground contact
457
+ min_eccentric_frames: Minimum frames for eccentric phase
458
+ use_curvature: Whether to use trajectory curvature refinement
459
+ window_length: Window size for derivative calculations
460
+ polyorder: Polynomial order for Savitzky-Golay filter
461
+
462
+ Returns:
463
+ Tuple of (standing_end_frame, lowest_point_frame, takeoff_frame, landing_frame)
464
+ with fractional precision, or None if phases cannot be detected.
465
+ """
466
+ # Compute SIGNED velocities and accelerations
467
+ velocities = compute_signed_velocity(
468
+ positions, window_length=window_length, polyorder=polyorder
469
+ )
470
+ accelerations = compute_acceleration_from_derivative(
471
+ positions, window_length=window_length, polyorder=polyorder
472
+ )
473
+
474
+ # Step 1: Find peak height (global minimum y = highest point in frame)
475
+ peak_height_frame = int(np.argmin(positions))
476
+
477
+ if peak_height_frame < 10:
478
+ return None # Peak too early, invalid
479
+
480
+ # Step 2: Find takeoff as peak upward velocity
481
+ # Takeoff occurs at maximum upward velocity (most negative) before peak height
482
+ # Typical: 0.3 seconds before peak (9 frames at 30fps)
483
+ takeoff_search_start = max(0, peak_height_frame - int(fps * 0.35))
484
+ takeoff_search_end = peak_height_frame - 2 # Must be at least 2 frames before peak
485
+
486
+ takeoff_velocities = velocities[takeoff_search_start:takeoff_search_end]
487
+
488
+ if len(takeoff_velocities) > 0:
489
+ # Takeoff = peak upward velocity (most negative)
490
+ peak_vel_idx = int(np.argmin(takeoff_velocities))
491
+ takeoff_frame = float(takeoff_search_start + peak_vel_idx)
492
+ else:
493
+ # Fallback
494
+ takeoff_frame = float(peak_height_frame - int(fps * 0.3))
495
+
496
+ # Step 3: Find lowest point (countermovement bottom) before takeoff
497
+ # This is where velocity crosses from positive (squatting) to negative (jumping)
498
+ # Search backward from takeoff for where velocity was last positive/zero
499
+ lowest_search_start = max(0, int(takeoff_frame) - int(fps * 0.4))
500
+ lowest_search_end = int(takeoff_frame)
501
+
502
+ # Find where velocity crosses from positive to negative (transition point)
503
+ lowest_frame_found = None
504
+ for i in range(lowest_search_end - 1, lowest_search_start, -1):
505
+ if i > 0:
506
+ # Look for velocity crossing from positive/zero to negative
507
+ if velocities[i] < 0 and velocities[i - 1] >= 0:
508
+ lowest_frame_found = float(i)
509
+ break
510
+
511
+ # Fallback: use maximum position (lowest point in frame) if no velocity crossing
512
+ if lowest_frame_found is None:
513
+ lowest_positions = positions[lowest_search_start:lowest_search_end]
514
+ if len(lowest_positions) > 0:
515
+ lowest_idx = int(np.argmax(lowest_positions))
516
+ lowest_point = float(lowest_search_start + lowest_idx)
517
+ else:
518
+ lowest_point = float(int(takeoff_frame) - int(fps * 0.2))
519
+ else:
520
+ lowest_point = lowest_frame_found
521
+
522
+ # Step 4: Find landing (impact after peak height)
523
+ # Landing shows as large negative acceleration spike (impact deceleration)
524
+ landing_search_start = peak_height_frame
525
+ landing_search_end = min(len(accelerations), peak_height_frame + int(fps * 0.5))
526
+ landing_accelerations = accelerations[landing_search_start:landing_search_end]
527
+
528
+ if len(landing_accelerations) > 0:
529
+ # Find most negative acceleration (maximum impact deceleration)
530
+ # Landing acceleration should be around -0.008 to -0.010
531
+ landing_idx = int(np.argmin(landing_accelerations)) # Most negative = impact
532
+ landing_frame = float(landing_search_start + landing_idx)
533
+ else:
534
+ landing_frame = float(peak_height_frame + int(fps * 0.3))
535
+
536
+ # Optional: Find standing phase (not critical)
537
+ standing_end = None
538
+ if lowest_point > 20:
539
+ # Look for low-velocity period before lowest point
540
+ standing_search = velocities[: int(lowest_point)]
541
+ low_vel = np.abs(standing_search) < 0.005
542
+ if np.any(low_vel):
543
+ # Find last low-velocity frame before countermovement
544
+ standing_frames = np.where(low_vel)[0]
545
+ if len(standing_frames) > 10:
546
+ standing_end = float(standing_frames[-1])
547
+
548
+ return (standing_end, lowest_point, takeoff_frame, landing_frame)