kinemotion 0.10.6__py3-none-any.whl → 0.67.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.

Files changed (48) hide show
  1. kinemotion/__init__.py +31 -6
  2. kinemotion/api.py +39 -598
  3. kinemotion/cli.py +2 -0
  4. kinemotion/cmj/__init__.py +5 -0
  5. kinemotion/cmj/analysis.py +621 -0
  6. kinemotion/cmj/api.py +563 -0
  7. kinemotion/cmj/cli.py +324 -0
  8. kinemotion/cmj/debug_overlay.py +457 -0
  9. kinemotion/cmj/joint_angles.py +307 -0
  10. kinemotion/cmj/kinematics.py +360 -0
  11. kinemotion/cmj/metrics_validator.py +767 -0
  12. kinemotion/cmj/validation_bounds.py +341 -0
  13. kinemotion/core/__init__.py +28 -0
  14. kinemotion/core/auto_tuning.py +71 -37
  15. kinemotion/core/cli_utils.py +60 -0
  16. kinemotion/core/debug_overlay_utils.py +385 -0
  17. kinemotion/core/determinism.py +83 -0
  18. kinemotion/core/experimental.py +103 -0
  19. kinemotion/core/filtering.py +9 -6
  20. kinemotion/core/formatting.py +75 -0
  21. kinemotion/core/metadata.py +231 -0
  22. kinemotion/core/model_downloader.py +172 -0
  23. kinemotion/core/pipeline_utils.py +433 -0
  24. kinemotion/core/pose.py +298 -141
  25. kinemotion/core/pose_landmarks.py +67 -0
  26. kinemotion/core/quality.py +393 -0
  27. kinemotion/core/smoothing.py +250 -154
  28. kinemotion/core/timing.py +247 -0
  29. kinemotion/core/types.py +42 -0
  30. kinemotion/core/validation.py +201 -0
  31. kinemotion/core/video_io.py +135 -50
  32. kinemotion/dropjump/__init__.py +1 -1
  33. kinemotion/dropjump/analysis.py +367 -182
  34. kinemotion/dropjump/api.py +665 -0
  35. kinemotion/dropjump/cli.py +156 -466
  36. kinemotion/dropjump/debug_overlay.py +136 -206
  37. kinemotion/dropjump/kinematics.py +232 -255
  38. kinemotion/dropjump/metrics_validator.py +240 -0
  39. kinemotion/dropjump/validation_bounds.py +157 -0
  40. kinemotion/models/__init__.py +0 -0
  41. kinemotion/models/pose_landmarker_lite.task +0 -0
  42. kinemotion-0.67.0.dist-info/METADATA +726 -0
  43. kinemotion-0.67.0.dist-info/RECORD +47 -0
  44. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/WHEEL +1 -1
  45. kinemotion-0.10.6.dist-info/METADATA +0 -561
  46. kinemotion-0.10.6.dist-info/RECORD +0 -20
  47. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/entry_points.txt +0 -0
  48. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,5 @@
1
+ """Counter Movement Jump (CMJ) analysis module."""
2
+
3
+ from .kinematics import CMJMetrics
4
+
5
+ __all__ = ["CMJMetrics"]
@@ -0,0 +1,621 @@
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.experimental import unused
9
+ from ..core.smoothing import compute_acceleration_from_derivative
10
+ from ..core.timing import NULL_TIMER, Timer
11
+ from ..core.types import FloatArray
12
+
13
+
14
+ def compute_signed_velocity(
15
+ positions: FloatArray, window_length: int = 5, polyorder: int = 2
16
+ ) -> FloatArray:
17
+ """
18
+ Compute SIGNED velocity for CMJ phase detection.
19
+
20
+ Unlike drop jump which uses absolute velocity, CMJ needs signed velocity to
21
+ distinguish upward (negative) from downward (positive) motion.
22
+
23
+ Args:
24
+ positions: 1D array of y-positions in normalized coordinates
25
+ window_length: Window size for Savitzky-Golay filter
26
+ polyorder: Polynomial order
27
+
28
+ Returns:
29
+ Signed velocity array where:
30
+ - Negative = upward motion (y decreasing, jumping up)
31
+ - Positive = downward motion (y increasing, squatting/falling)
32
+ """
33
+ if len(positions) < window_length:
34
+ return np.diff(positions, prepend=positions[0])
35
+
36
+ if window_length % 2 == 0:
37
+ window_length += 1
38
+
39
+ velocity = savgol_filter(
40
+ positions, window_length, polyorder, deriv=1, delta=1.0, mode="interp"
41
+ )
42
+
43
+ return velocity
44
+
45
+
46
+ class CMJPhase(Enum):
47
+ """Phases of a counter movement jump."""
48
+
49
+ STANDING = "standing"
50
+ ECCENTRIC = "eccentric" # Downward movement
51
+ TRANSITION = "transition" # At lowest point
52
+ CONCENTRIC = "concentric" # Upward movement
53
+ FLIGHT = "flight"
54
+ LANDING = "landing"
55
+ UNKNOWN = "unknown"
56
+
57
+
58
+ @unused(
59
+ reason="Alternative implementation not called by pipeline",
60
+ since="0.34.0",
61
+ )
62
+ def find_standing_phase(
63
+ positions: FloatArray,
64
+ velocities: FloatArray,
65
+ fps: float,
66
+ min_standing_duration: float = 0.5,
67
+ velocity_threshold: float = 0.01,
68
+ ) -> int | None:
69
+ """
70
+ Find the end of standing phase (start of countermovement).
71
+
72
+ Looks for a period of low velocity (standing) followed by consistent
73
+ downward motion.
74
+
75
+ Args:
76
+ positions: Array of vertical positions (normalized 0-1)
77
+ velocities: Array of vertical velocities
78
+ fps: Video frame rate
79
+ min_standing_duration: Minimum standing duration in seconds (default: 0.5s)
80
+ velocity_threshold: Velocity threshold for standing detection
81
+
82
+ Returns:
83
+ Frame index where countermovement begins, or None if not detected.
84
+ """
85
+ min_standing_frames = int(fps * min_standing_duration)
86
+
87
+ if len(positions) < min_standing_frames:
88
+ return None
89
+
90
+ # Find periods of low velocity (standing)
91
+ is_standing = np.abs(velocities) < velocity_threshold
92
+
93
+ # Look for first sustained standing period
94
+ standing_count = 0
95
+ standing_end = None
96
+
97
+ for i in range(len(is_standing)):
98
+ if is_standing[i]:
99
+ standing_count += 1
100
+ if standing_count >= min_standing_frames:
101
+ standing_end = i
102
+ else:
103
+ if standing_end is not None:
104
+ # Found end of standing phase
105
+ return standing_end
106
+ standing_count = 0
107
+
108
+ return None
109
+
110
+
111
+ @unused(
112
+ reason="Alternative implementation not called by pipeline",
113
+ since="0.34.0",
114
+ )
115
+ def find_countermovement_start(
116
+ velocities: FloatArray,
117
+ countermovement_threshold: float = 0.015,
118
+ min_eccentric_frames: int = 3,
119
+ standing_start: int | None = None,
120
+ ) -> int | None:
121
+ """
122
+ Find the start of countermovement (eccentric phase).
123
+
124
+ Detects when velocity becomes consistently positive (downward motion in
125
+ normalized coords).
126
+
127
+ Args:
128
+ velocities: Array of SIGNED vertical velocities
129
+ countermovement_threshold: Velocity threshold for detecting downward
130
+ motion (POSITIVE)
131
+ min_eccentric_frames: Minimum consecutive frames of downward motion
132
+ standing_start: Optional frame where standing phase ended
133
+
134
+ Returns:
135
+ Frame index where countermovement begins, or None if not detected.
136
+ """
137
+ start_frame = standing_start if standing_start is not None else 0
138
+
139
+ # Look for sustained downward velocity (POSITIVE in normalized coords)
140
+ is_downward = velocities[start_frame:] > countermovement_threshold
141
+ consecutive_count = 0
142
+
143
+ for i in range(len(is_downward)):
144
+ if is_downward[i]:
145
+ consecutive_count += 1
146
+ if consecutive_count >= min_eccentric_frames:
147
+ # Found start of eccentric phase
148
+ return start_frame + i - consecutive_count + 1
149
+ else:
150
+ consecutive_count = 0
151
+
152
+ return None
153
+
154
+
155
+ def find_lowest_point(
156
+ positions: FloatArray,
157
+ velocities: FloatArray,
158
+ min_search_frame: int = 80,
159
+ ) -> int:
160
+ """
161
+ Find the lowest point of countermovement (transition from eccentric to concentric).
162
+
163
+ The lowest point occurs BEFORE the peak height (the jump apex). It's where
164
+ velocity crosses from positive (downward/squatting) to negative (upward/jumping).
165
+
166
+ Args:
167
+ positions: Array of vertical positions (higher value = lower in video)
168
+ velocities: Array of SIGNED vertical velocities (positive=down, negative=up)
169
+ min_search_frame: Minimum frame to start searching (default: frame 80)
170
+
171
+ Returns:
172
+ Frame index of lowest point.
173
+ """
174
+ # First, find the peak height (minimum y value = highest jump point)
175
+ peak_height_frame = int(np.argmin(positions))
176
+
177
+ # Lowest point MUST be before peak height
178
+ # Search from min_search_frame to peak_height_frame
179
+ start_frame = min_search_frame
180
+ end_frame = peak_height_frame
181
+
182
+ if end_frame <= start_frame:
183
+ start_frame = int(len(positions) * 0.3)
184
+ end_frame = int(len(positions) * 0.7)
185
+
186
+ search_positions = positions[start_frame:end_frame]
187
+
188
+ if len(search_positions) == 0:
189
+ return start_frame
190
+
191
+ # Find maximum position value in this range (lowest point in video)
192
+ lowest_idx = int(np.argmax(search_positions))
193
+ lowest_frame = start_frame + lowest_idx
194
+
195
+ return lowest_frame
196
+
197
+
198
+ def find_cmj_takeoff_from_velocity_peak(
199
+ positions: FloatArray,
200
+ velocities: FloatArray,
201
+ lowest_point_frame: int,
202
+ fps: float,
203
+ ) -> float:
204
+ """
205
+ Find CMJ takeoff frame as peak upward velocity during concentric phase.
206
+
207
+ Takeoff occurs at maximum push-off velocity (most negative velocity),
208
+ just as feet leave the ground. This is BEFORE peak height is reached.
209
+
210
+ Args:
211
+ positions: Array of vertical positions
212
+ velocities: Array of SIGNED vertical velocities (negative = upward)
213
+ lowest_point_frame: Frame at lowest point
214
+ fps: Video frame rate
215
+
216
+ Returns:
217
+ Takeoff frame with fractional precision.
218
+ """
219
+ concentric_start = int(lowest_point_frame)
220
+ search_duration = int(fps * 0.3) # Search next 0.3 seconds (concentric to takeoff is brief)
221
+ search_end = min(len(velocities), concentric_start + search_duration)
222
+
223
+ if search_end <= concentric_start:
224
+ return float(concentric_start + 1)
225
+
226
+ # Find peak upward velocity (most NEGATIVE velocity)
227
+ # In normalized coords: negative velocity = y decreasing = jumping up
228
+ concentric_velocities = velocities[concentric_start:search_end]
229
+ takeoff_idx = int(np.argmin(concentric_velocities)) # Most negative = fastest upward = takeoff
230
+ takeoff_frame = concentric_start + takeoff_idx
231
+
232
+ return float(takeoff_frame)
233
+
234
+
235
+ def find_cmj_landing_from_position_peak(
236
+ positions: FloatArray,
237
+ velocities: FloatArray,
238
+ accelerations: FloatArray,
239
+ takeoff_frame: int,
240
+ fps: float,
241
+ ) -> float:
242
+ """
243
+ Find CMJ landing frame by detecting impact after peak height.
244
+
245
+ Landing occurs when feet contact ground after peak height, detected by
246
+ finding where velocity transitions from negative (still going up/at peak)
247
+ to positive (falling) and position stabilizes.
248
+
249
+ Args:
250
+ positions: Array of vertical positions
251
+ velocities: Array of SIGNED vertical velocities (negative = up, positive = down)
252
+ accelerations: Array of accelerations (second derivative)
253
+ takeoff_frame: Frame at takeoff
254
+ fps: Video frame rate
255
+
256
+ Returns:
257
+ Landing frame with fractional precision.
258
+ """
259
+ # Find peak height (minimum position value in normalized coords)
260
+ search_start = int(takeoff_frame)
261
+ search_duration = int(fps * 0.7) # Search next 0.7 seconds for peak
262
+ search_end = min(len(positions), search_start + search_duration)
263
+
264
+ if search_end <= search_start:
265
+ return float(search_start + int(fps * 0.3))
266
+
267
+ # Find peak height (minimum y value = highest point in frame)
268
+ flight_positions = positions[search_start:search_end]
269
+ peak_idx = int(np.argmin(flight_positions))
270
+ peak_frame = search_start + peak_idx
271
+
272
+ # After peak, look for landing (impact with ground)
273
+ # Landing is detected by maximum positive acceleration (deceleration on impact)
274
+ landing_search_start = peak_frame + 2
275
+ landing_search_end = min(len(accelerations), landing_search_start + int(fps * 0.5))
276
+
277
+ if landing_search_end <= landing_search_start:
278
+ return float(peak_frame + int(fps * 0.2))
279
+
280
+ # Find impact: maximum positive acceleration after peak
281
+ # Positive acceleration = slowing down upward motion or impact deceleration
282
+ landing_accelerations = accelerations[landing_search_start:landing_search_end]
283
+ impact_idx = int(np.argmax(landing_accelerations)) # Max positive = impact
284
+ landing_frame = landing_search_start + impact_idx
285
+
286
+ return float(landing_frame)
287
+
288
+
289
+ @unused(
290
+ reason="Experimental alternative superseded by backward search algorithm",
291
+ since="0.34.0",
292
+ )
293
+ def find_interpolated_takeoff_landing(
294
+ positions: FloatArray,
295
+ velocities: FloatArray,
296
+ lowest_point_frame: int,
297
+ window_length: int = 5,
298
+ polyorder: int = 2,
299
+ ) -> tuple[float, float] | None:
300
+ """
301
+ Find takeoff and landing frames for CMJ using physics-based detection.
302
+
303
+ CMJ-specific: Takeoff is detected as peak velocity (end of push-off),
304
+ not as high velocity threshold (which detects mid-flight).
305
+
306
+ Args:
307
+ positions: Array of vertical positions
308
+ velocities: Array of vertical velocities
309
+ lowest_point_frame: Frame at lowest point
310
+ window_length: Window size for derivative calculations
311
+ polyorder: Polynomial order for Savitzky-Golay filter
312
+
313
+ Returns:
314
+ Tuple of (takeoff_frame, landing_frame) with fractional precision, or None.
315
+ """
316
+ # Get FPS from velocity array length and assumed duration
317
+ # This is approximate but sufficient for search windows
318
+ fps = 30.0 # Default assumption
319
+
320
+ # Compute accelerations for landing detection
321
+ accelerations = compute_acceleration_from_derivative(
322
+ positions, window_length=window_length, polyorder=polyorder
323
+ )
324
+
325
+ # Find takeoff using peak velocity method (CMJ-specific)
326
+ takeoff_frame = find_cmj_takeoff_from_velocity_peak(
327
+ positions, velocities, lowest_point_frame, fps
328
+ )
329
+
330
+ # Find landing using position peak and impact detection
331
+ landing_frame = find_cmj_landing_from_position_peak(
332
+ positions, velocities, accelerations, int(takeoff_frame), fps
333
+ )
334
+
335
+ return (takeoff_frame, landing_frame)
336
+
337
+
338
+ def find_takeoff_frame(velocities: FloatArray, peak_height_frame: int, fps: float) -> float:
339
+ """Find takeoff frame as peak upward velocity before peak height.
340
+
341
+ Robust detection: When velocities are nearly identical (flat), detects
342
+ the transition point rather than using argmin which is unstable.
343
+ """
344
+ takeoff_search_start = max(0, peak_height_frame - int(fps * 0.35))
345
+ takeoff_search_end = peak_height_frame - 2
346
+
347
+ takeoff_velocities = velocities[takeoff_search_start:takeoff_search_end]
348
+
349
+ if len(takeoff_velocities) == 0:
350
+ return float(peak_height_frame - int(fps * 0.3))
351
+
352
+ # Check if velocities are suspiciously identical (flat derivative = ambiguous)
353
+ vel_min = np.min(takeoff_velocities)
354
+ vel_max = np.max(takeoff_velocities)
355
+ vel_range = vel_max - vel_min
356
+
357
+ if vel_range < 1e-6:
358
+ # Velocities are essentially identical - algorithm is ambiguous
359
+ # Return the midpoint of the search window as a stable estimate
360
+ return float((takeoff_search_start + takeoff_search_end) / 2.0)
361
+ else:
362
+ # Velocities have variation - use argmin as before
363
+ peak_vel_idx = int(np.argmin(takeoff_velocities))
364
+ return float(takeoff_search_start + peak_vel_idx)
365
+
366
+
367
+ def find_lowest_frame(
368
+ velocities: FloatArray, positions: FloatArray, takeoff_frame: float, fps: float
369
+ ) -> float:
370
+ """Find lowest point frame before takeoff."""
371
+ lowest_search_start = max(0, int(takeoff_frame) - int(fps * 0.4))
372
+ lowest_search_end = int(takeoff_frame)
373
+
374
+ # Find where velocity crosses from positive to negative
375
+ for i in range(lowest_search_end - 1, lowest_search_start, -1):
376
+ if i > 0 and velocities[i] < 0 and velocities[i - 1] >= 0:
377
+ return float(i)
378
+
379
+ # Fallback: use maximum position
380
+ lowest_positions = positions[lowest_search_start:lowest_search_end]
381
+ if len(lowest_positions) > 0:
382
+ lowest_idx = int(np.argmax(lowest_positions))
383
+ return float(lowest_search_start + lowest_idx)
384
+ else:
385
+ return float(int(takeoff_frame) - int(fps * 0.2))
386
+
387
+
388
+ def find_landing_frame(
389
+ accelerations: FloatArray,
390
+ velocities: FloatArray,
391
+ peak_height_frame: int,
392
+ fps: float,
393
+ ) -> float:
394
+ """Find landing frame after peak height.
395
+
396
+ Robust detection strategy:
397
+ 1. Find peak downward velocity (maximum positive velocity) after peak height.
398
+ This corresponds to the moment just before or at initial ground contact.
399
+ 2. Look for maximum deceleration (impact) *after* the peak velocity.
400
+ This filters out mid-air tracking noise/flutter that can cause false
401
+ deceleration spikes while the athlete is still accelerating downward.
402
+
403
+ Args:
404
+ accelerations: Vertical acceleration array (deriv=2)
405
+ velocities: Vertical velocity array (deriv=1)
406
+ peak_height_frame: Frame index of peak jump height
407
+ fps: Video frame rate
408
+
409
+ Returns:
410
+ Frame index of landing impact.
411
+ """
412
+ # Search window extended to 1.0s to accommodate all realistic flight times
413
+ search_end = min(len(accelerations), peak_height_frame + int(fps * 1.0))
414
+
415
+ # 1. Find peak downward velocity (max positive value)
416
+ # Search from peak height to end of window
417
+ vel_search_window = velocities[peak_height_frame:search_end]
418
+
419
+ if len(vel_search_window) == 0:
420
+ return float(peak_height_frame + int(fps * 0.3))
421
+
422
+ # Index relative to peak_height_frame
423
+ peak_vel_rel_idx = int(np.argmax(vel_search_window))
424
+ peak_vel_frame = peak_height_frame + peak_vel_rel_idx
425
+
426
+ # 2. Search for impact (min acceleration) starting from peak velocity
427
+ # We allow a small buffer (e.g., 1-2 frames) before peak velocity just in case
428
+ # peak velocity coincides with impact start due to smoothing
429
+ landing_search_start = max(peak_height_frame, peak_vel_frame - 2)
430
+ landing_search_end = search_end
431
+
432
+ landing_accelerations = accelerations[landing_search_start:landing_search_end]
433
+
434
+ if len(landing_accelerations) == 0:
435
+ # Fallback if window is empty
436
+ return float(peak_height_frame + int(fps * 0.3))
437
+
438
+ # Find minimum acceleration (maximum deceleration spike)
439
+ landing_rel_idx = int(np.argmin(landing_accelerations))
440
+ landing_frame = landing_search_start + landing_rel_idx
441
+
442
+ return float(landing_frame)
443
+
444
+
445
+ def compute_average_hip_position(
446
+ landmarks: dict[str, tuple[float, float, float]],
447
+ ) -> tuple[float, float]:
448
+ """
449
+ Compute average hip position from hip landmarks.
450
+
451
+ Args:
452
+ landmarks: Dictionary of landmark positions
453
+
454
+ Returns:
455
+ (x, y) average hip position in normalized coordinates
456
+ """
457
+ hip_keys = ["left_hip", "right_hip"]
458
+
459
+ x_positions: list[float] = []
460
+ y_positions: list[float] = []
461
+
462
+ for key in hip_keys:
463
+ if key in landmarks:
464
+ x, y, visibility = landmarks[key]
465
+ if visibility > 0.5: # Only use visible landmarks
466
+ x_positions.append(x)
467
+ y_positions.append(y)
468
+
469
+ if not x_positions:
470
+ return (0.5, 0.5) # Default to center if no visible hips
471
+
472
+ return (float(np.mean(x_positions)), float(np.mean(y_positions)))
473
+
474
+
475
+ def find_standing_end(
476
+ velocities: FloatArray,
477
+ lowest_point: float,
478
+ _positions: FloatArray | None = None,
479
+ accelerations: FloatArray | None = None,
480
+ ) -> float | None:
481
+ """
482
+ Find end of standing phase before lowest point.
483
+
484
+ Uses acceleration-based detection to identify when downward movement begins.
485
+ Acceleration captures movement initiation even when velocity is negligible,
486
+ making it ideal for detecting slow countermovement starts.
487
+
488
+ Args:
489
+ velocities: Signed velocity array (for backward compatibility)
490
+ lowest_point: Frame index of lowest point
491
+ _positions: Intentionally unused - kept for backward compatibility
492
+ accelerations: Acceleration array (if provided, uses
493
+ acceleration-based detection)
494
+
495
+ Returns:
496
+ Frame index where standing ends (countermovement begins), or None
497
+ """
498
+ if lowest_point <= 20:
499
+ return None
500
+
501
+ # Acceleration-based detection (best for detecting movement initiation)
502
+ if accelerations is not None:
503
+ # Use middle section of standing phase as baseline (avoids initial settling)
504
+ baseline_start = 10
505
+ baseline_end = min(40, int(lowest_point) - 10)
506
+
507
+ if baseline_end <= baseline_start:
508
+ return None
509
+
510
+ # Calculate baseline acceleration statistics
511
+ baseline_accel = accelerations[baseline_start:baseline_end]
512
+ baseline_mean = float(np.mean(baseline_accel))
513
+ baseline_std = float(np.std(baseline_accel))
514
+
515
+ # Threshold: 3 standard deviations above baseline
516
+ # This detects when acceleration significantly increases (movement starts)
517
+ accel_threshold = baseline_mean + 3.0 * baseline_std
518
+
519
+ # Search forward from baseline for acceleration spike
520
+ for i in range(baseline_end, int(lowest_point)):
521
+ if accelerations[i] > accel_threshold:
522
+ # Found start of downward acceleration
523
+ return float(i)
524
+
525
+ return None
526
+
527
+ # Fallback: velocity-based detection (legacy)
528
+ standing_search = velocities[: int(lowest_point)]
529
+ low_vel = np.abs(standing_search) < 0.005
530
+ if np.any(low_vel):
531
+ standing_frames = np.nonzero(low_vel)[0]
532
+ if len(standing_frames) > 10:
533
+ return float(standing_frames[-1])
534
+
535
+ return None
536
+
537
+
538
+ def detect_cmj_phases(
539
+ positions: FloatArray,
540
+ fps: float,
541
+ window_length: int = 5,
542
+ polyorder: int = 2,
543
+ landing_positions: FloatArray | None = None,
544
+ timer: Timer | None = None,
545
+ ) -> tuple[float | None, float, float, float] | None:
546
+ """
547
+ Detect all phases of a counter movement jump using a simplified, robust approach.
548
+
549
+ Strategy: Work BACKWARD from peak height to find all phases.
550
+ 1. Find peak height (global minimum y)
551
+ 2. Find takeoff (peak negative velocity before peak height)
552
+ 3. Find lowest point (maximum y value before takeoff)
553
+ 4. Find landing (impact after peak height)
554
+
555
+ Args:
556
+ positions: Array of vertical positions (normalized 0-1). Typically Hips/CoM.
557
+ fps: Video frame rate
558
+ window_length: Window size for derivative calculations
559
+ polyorder: Polynomial order for Savitzky-Golay filter
560
+ landing_positions: Optional array of positions for landing detection
561
+ (e.g., Feet). If None, uses `positions` (Hips) for landing too.
562
+ timer: Optional Timer for measuring operations
563
+
564
+ Returns:
565
+ Tuple of (standing_end_frame, lowest_point_frame, takeoff_frame, landing_frame)
566
+ with fractional precision, or None if phases cannot be detected.
567
+ """
568
+ timer = timer or NULL_TIMER
569
+
570
+ # Compute SIGNED velocities and accelerations for primary signal (Hips)
571
+ with timer.measure("cmj_compute_derivatives"):
572
+ velocities = compute_signed_velocity(
573
+ positions, window_length=window_length, polyorder=polyorder
574
+ )
575
+ accelerations = compute_acceleration_from_derivative(
576
+ positions, window_length=window_length, polyorder=polyorder
577
+ )
578
+
579
+ # Step 1: Find peak height (global minimum y = highest point in frame)
580
+ peak_height_frame = int(np.argmin(positions))
581
+ if peak_height_frame < 10:
582
+ return None # Peak too early, invalid
583
+
584
+ # Step 2-4: Find all phases using helper functions
585
+ with timer.measure("cmj_find_takeoff"):
586
+ takeoff_frame = find_takeoff_frame(velocities, peak_height_frame, fps)
587
+
588
+ with timer.measure("cmj_find_lowest_point"):
589
+ lowest_point = find_lowest_frame(velocities, positions, takeoff_frame, fps)
590
+
591
+ # Determine landing frame
592
+ with timer.measure("cmj_find_landing"):
593
+ if landing_positions is not None:
594
+ # Use specific landing signal (Feet) for landing detection
595
+ landing_velocities = compute_signed_velocity(
596
+ landing_positions, window_length=window_length, polyorder=polyorder
597
+ )
598
+ landing_accelerations = compute_acceleration_from_derivative(
599
+ landing_positions, window_length=window_length, polyorder=polyorder
600
+ )
601
+ # We still reference peak_height_frame from Hips, as Feet peak
602
+ # might be different/noisy but generally they align in time.
603
+ landing_frame = find_landing_frame(
604
+ landing_accelerations,
605
+ landing_velocities,
606
+ peak_height_frame,
607
+ fps,
608
+ )
609
+ else:
610
+ # Use primary signal (Hips)
611
+ landing_frame = find_landing_frame(
612
+ accelerations,
613
+ velocities,
614
+ peak_height_frame,
615
+ fps,
616
+ )
617
+
618
+ with timer.measure("cmj_find_standing_end"):
619
+ standing_end = find_standing_end(velocities, lowest_point, positions, accelerations)
620
+
621
+ return (standing_end, lowest_point, takeoff_frame, landing_frame)