kinemotion 0.70.1__py3-none-any.whl → 0.71.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.
Files changed (35) hide show
  1. kinemotion/__init__.py +4 -1
  2. kinemotion/cmj/analysis.py +79 -30
  3. kinemotion/cmj/api.py +16 -39
  4. kinemotion/cmj/cli.py +0 -21
  5. kinemotion/cmj/debug_overlay.py +154 -286
  6. kinemotion/cmj/joint_angles.py +96 -31
  7. kinemotion/cmj/metrics_validator.py +30 -51
  8. kinemotion/cmj/validation_bounds.py +1 -18
  9. kinemotion/core/__init__.py +0 -2
  10. kinemotion/core/auto_tuning.py +91 -99
  11. kinemotion/core/debug_overlay_utils.py +142 -15
  12. kinemotion/core/experimental.py +55 -51
  13. kinemotion/core/filtering.py +15 -11
  14. kinemotion/core/overlay_constants.py +61 -0
  15. kinemotion/core/pose.py +67 -499
  16. kinemotion/core/smoothing.py +65 -51
  17. kinemotion/core/types.py +15 -0
  18. kinemotion/core/validation.py +6 -7
  19. kinemotion/core/video_io.py +14 -9
  20. kinemotion/dropjump/__init__.py +2 -2
  21. kinemotion/dropjump/analysis.py +67 -44
  22. kinemotion/dropjump/api.py +12 -44
  23. kinemotion/dropjump/cli.py +63 -105
  24. kinemotion/dropjump/debug_overlay.py +124 -65
  25. kinemotion/dropjump/validation_bounds.py +1 -1
  26. kinemotion/models/rtmpose-s_simcc-body7_pt-body7-halpe26_700e-256x192-7f134165_20230605.onnx +0 -0
  27. kinemotion/models/yolox_tiny_8xb8-300e_humanart-6f3252f9.onnx +0 -0
  28. {kinemotion-0.70.1.dist-info → kinemotion-0.71.1.dist-info}/METADATA +1 -5
  29. kinemotion-0.71.1.dist-info/RECORD +50 -0
  30. kinemotion/core/rtmpose_cpu.py +0 -626
  31. kinemotion/core/rtmpose_wrapper.py +0 -190
  32. kinemotion-0.70.1.dist-info/RECORD +0 -51
  33. {kinemotion-0.70.1.dist-info → kinemotion-0.71.1.dist-info}/WHEEL +0 -0
  34. {kinemotion-0.70.1.dist-info → kinemotion-0.71.1.dist-info}/entry_points.txt +0 -0
  35. {kinemotion-0.70.1.dist-info → kinemotion-0.71.1.dist-info}/licenses/LICENSE +0 -0
kinemotion/__init__.py CHANGED
@@ -1,4 +1,7 @@
1
- """Kinemotion: Video-based kinematic analysis for athletic performance."""
1
+ """Kinemotion: Video-based kinematic analysis for athletic performance.
2
+
3
+ Supports Counter Movement Jump (CMJ) and Drop Jump analysis using MediaPipe pose estimation.
4
+ """
2
5
 
3
6
  from .api import (
4
7
  CMJVideoConfig,
@@ -8,7 +8,7 @@ from scipy.signal import savgol_filter
8
8
  from ..core.experimental import unused
9
9
  from ..core.smoothing import compute_acceleration_from_derivative
10
10
  from ..core.timing import NULL_TIMER, Timer
11
- from ..core.types import FloatArray
11
+ from ..core.types import HIP_KEYS, FloatArray
12
12
 
13
13
 
14
14
  def compute_signed_velocity(
@@ -335,12 +335,37 @@ def find_interpolated_takeoff_landing(
335
335
  return (takeoff_frame, landing_frame)
336
336
 
337
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.
338
+ def find_takeoff_frame(
339
+ velocities: FloatArray,
340
+ peak_height_frame: int,
341
+ fps: float,
342
+ accelerations: FloatArray | None = None,
343
+ ) -> float:
344
+ """Find takeoff frame using deceleration onset after peak velocity.
345
+
346
+ Two-step approach:
347
+ 1. Find peak upward velocity (most negative) before peak height
348
+ 2. Look forward to find when deceleration starts (gravity takes over)
349
+
350
+ This is more accurate than peak velocity alone because:
351
+ - Peak velocity occurs during final push (still on ground)
352
+ - Actual takeoff is when feet leave ground (deceleration begins)
353
+
354
+ Empirically validated against manual annotations:
355
+ - Peak velocity only: 0.67 frame MAE
356
+ - Deceleration onset: Improves Video 2 error from -2 to -1 frame
357
+
358
+ Args:
359
+ velocities: Vertical velocity array (negative = upward)
360
+ peak_height_frame: Frame index of peak jump height
361
+ fps: Video frame rate
362
+ accelerations: Optional acceleration array. If provided, uses
363
+ deceleration onset method. If None, falls back to peak velocity.
340
364
 
341
- Robust detection: When velocities are nearly identical (flat), detects
342
- the transition point rather than using argmin which is unstable.
365
+ Returns:
366
+ Frame index of takeoff (fractional precision).
343
367
  """
368
+ # Step 1: Find peak upward velocity
344
369
  takeoff_search_start = max(0, peak_height_frame - int(fps * 0.35))
345
370
  takeoff_search_end = peak_height_frame - 2
346
371
 
@@ -349,19 +374,28 @@ def find_takeoff_frame(velocities: FloatArray, peak_height_frame: int, fps: floa
349
374
  if len(takeoff_velocities) == 0:
350
375
  return float(peak_height_frame - int(fps * 0.3))
351
376
 
352
- # Check if velocities are suspiciously identical (flat derivative = ambiguous)
377
+ # Check if velocities are suspiciously identical (flat = ambiguous)
353
378
  vel_min = np.min(takeoff_velocities)
354
379
  vel_max = np.max(takeoff_velocities)
355
380
  vel_range = vel_max - vel_min
356
381
 
357
382
  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
383
+ # Velocities essentially identical - use midpoint
360
384
  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)
385
+
386
+ peak_vel_idx = int(np.argmin(takeoff_velocities))
387
+ peak_vel_frame = takeoff_search_start + peak_vel_idx
388
+
389
+ # Step 2: If accelerations provided, find deceleration onset
390
+ if accelerations is not None:
391
+ # Look forward from peak velocity to find where deceleration starts
392
+ # Deceleration = positive acceleration (in normalized coords, up is negative)
393
+ for i in range(peak_vel_frame, peak_height_frame):
394
+ if accelerations[i] > 0:
395
+ return float(i)
396
+
397
+ # Fallback to peak velocity frame
398
+ return float(peak_vel_frame)
365
399
 
366
400
 
367
401
  def find_lowest_frame(
@@ -385,20 +419,16 @@ def find_lowest_frame(
385
419
  return float(int(takeoff_frame) - int(fps * 0.2))
386
420
 
387
421
 
388
- def find_landing_frame(
422
+ def _find_landing_impact(
389
423
  accelerations: FloatArray,
390
424
  velocities: FloatArray,
391
425
  peak_height_frame: int,
392
426
  fps: float,
393
427
  ) -> float:
394
- """Find landing frame after peak height.
428
+ """Find landing frame using maximum deceleration (impact method).
395
429
 
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.
430
+ This is the original algorithm that detects the maximum deceleration spike,
431
+ which corresponds to peak impact force. Matches human visual annotations.
402
432
 
403
433
  Args:
404
434
  accelerations: Vertical acceleration array (deriv=2)
@@ -413,26 +443,21 @@ def find_landing_frame(
413
443
  search_end = min(len(accelerations), peak_height_frame + int(fps * 1.0))
414
444
 
415
445
  # 1. Find peak downward velocity (max positive value)
416
- # Search from peak height to end of window
417
446
  vel_search_window = velocities[peak_height_frame:search_end]
418
447
 
419
448
  if len(vel_search_window) == 0:
420
449
  return float(peak_height_frame + int(fps * 0.3))
421
450
 
422
- # Index relative to peak_height_frame
423
451
  peak_vel_rel_idx = int(np.argmax(vel_search_window))
424
452
  peak_vel_frame = peak_height_frame + peak_vel_rel_idx
425
453
 
426
454
  # 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
455
  landing_search_start = max(peak_height_frame, peak_vel_frame - 2)
430
456
  landing_search_end = search_end
431
457
 
432
458
  landing_accelerations = accelerations[landing_search_start:landing_search_end]
433
459
 
434
460
  if len(landing_accelerations) == 0:
435
- # Fallback if window is empty
436
461
  return float(peak_height_frame + int(fps * 0.3))
437
462
 
438
463
  # Find minimum acceleration (maximum deceleration spike)
@@ -442,6 +467,30 @@ def find_landing_frame(
442
467
  return float(landing_frame)
443
468
 
444
469
 
470
+ def find_landing_frame(
471
+ accelerations: FloatArray,
472
+ velocities: FloatArray,
473
+ peak_height_frame: int,
474
+ fps: float,
475
+ ) -> float:
476
+ """Find landing frame after peak height using maximum deceleration.
477
+
478
+ Detects landing by finding the maximum deceleration spike, which corresponds
479
+ to the moment of foot-ground contact. Validated against manual annotations
480
+ with 0 frame mean absolute error.
481
+
482
+ Args:
483
+ accelerations: Vertical acceleration array (deriv=2)
484
+ velocities: Vertical velocity array (deriv=1)
485
+ peak_height_frame: Frame index of peak jump height
486
+ fps: Video frame rate
487
+
488
+ Returns:
489
+ Frame index of landing.
490
+ """
491
+ return _find_landing_impact(accelerations, velocities, peak_height_frame, fps)
492
+
493
+
445
494
  def compute_average_hip_position(
446
495
  landmarks: dict[str, tuple[float, float, float]],
447
496
  ) -> tuple[float, float]:
@@ -454,12 +503,10 @@ def compute_average_hip_position(
454
503
  Returns:
455
504
  (x, y) average hip position in normalized coordinates
456
505
  """
457
- hip_keys = ["left_hip", "right_hip"]
458
-
459
506
  x_positions: list[float] = []
460
507
  y_positions: list[float] = []
461
508
 
462
- for key in hip_keys:
509
+ for key in HIP_KEYS:
463
510
  if key in landmarks:
464
511
  x, y, visibility = landmarks[key]
465
512
  if visibility > 0.5: # Only use visible landmarks
@@ -550,7 +597,7 @@ def detect_cmj_phases(
550
597
  1. Find peak height (global minimum y)
551
598
  2. Find takeoff (peak negative velocity before peak height)
552
599
  3. Find lowest point (maximum y value before takeoff)
553
- 4. Find landing (impact after peak height)
600
+ 4. Find landing (maximum deceleration after peak height)
554
601
 
555
602
  Args:
556
603
  positions: Array of vertical positions (normalized 0-1). Typically Hips/CoM.
@@ -583,7 +630,9 @@ def detect_cmj_phases(
583
630
 
584
631
  # Step 2-4: Find all phases using helper functions
585
632
  with timer.measure("cmj_find_takeoff"):
586
- takeoff_frame = find_takeoff_frame(velocities, peak_height_frame, fps)
633
+ takeoff_frame = find_takeoff_frame(
634
+ velocities, peak_height_frame, fps, accelerations=accelerations
635
+ )
587
636
 
588
637
  with timer.measure("cmj_find_lowest_point"):
589
638
  lowest_point = find_lowest_frame(velocities, positions, takeoff_frame, fps)
kinemotion/cmj/api.py CHANGED
@@ -220,7 +220,6 @@ def _run_pose_tracking(
220
220
  detection_confidence: float | None,
221
221
  tracking_confidence: float | None,
222
222
  pose_tracker: "MediaPipePoseTracker | None",
223
- pose_backend: str | None,
224
223
  verbose: bool,
225
224
  timer: Timer,
226
225
  ) -> tuple[list[NDArray[np.uint8]], list, list[int]]:
@@ -236,33 +235,13 @@ def _run_pose_tracking(
236
235
  )
237
236
 
238
237
  if pose_tracker is None:
239
- if pose_backend is not None:
240
- import time
241
-
242
- from ..core import get_tracker_info
243
- from ..core.pose import PoseTrackerFactory
244
-
245
- init_start = time.perf_counter()
246
- tracker = PoseTrackerFactory.create(
247
- backend=pose_backend,
248
- min_detection_confidence=det_conf,
249
- min_tracking_confidence=track_conf,
250
- timer=timer,
251
- )
252
- init_time = time.perf_counter() - init_start
253
-
254
- if verbose:
255
- print(f"Using pose backend: {pose_backend}")
256
- print(f" → {get_tracker_info(tracker)}")
257
- print(f" → Initialized in {init_time * 1000:.1f} ms")
258
- else:
259
- if verbose:
260
- print("Processing all frames with MediaPipe pose tracking...")
261
- tracker = MediaPipePoseTracker(
262
- min_detection_confidence=det_conf,
263
- min_tracking_confidence=track_conf,
264
- timer=timer,
265
- )
238
+ if verbose:
239
+ print("Processing all frames with MediaPipe pose tracking...")
240
+ tracker = MediaPipePoseTracker(
241
+ min_detection_confidence=det_conf,
242
+ min_tracking_confidence=track_conf,
243
+ timer=timer,
244
+ )
266
245
  should_close_tracker = True
267
246
  else:
268
247
  tracker = pose_tracker
@@ -283,13 +262,15 @@ def _get_tuned_parameters(
283
262
  with timer.measure("parameter_auto_tuning"):
284
263
  characteristics = analyze_video_sample(landmarks_sequence, video.fps, video.frame_count)
285
264
  params = auto_tune_parameters(characteristics, quality_preset)
286
- params = apply_expert_overrides(
287
- params,
288
- overrides.smoothing_window if overrides else None,
289
- overrides.velocity_threshold if overrides else None,
290
- overrides.min_contact_frames if overrides else None,
291
- overrides.visibility_threshold if overrides else None,
292
- )
265
+
266
+ if overrides:
267
+ params = apply_expert_overrides(
268
+ params,
269
+ overrides.smoothing_window,
270
+ overrides.velocity_threshold,
271
+ overrides.min_contact_frames,
272
+ overrides.visibility_threshold,
273
+ )
293
274
 
294
275
  if verbose:
295
276
  print_verbose_parameters(video, characteristics, quality_preset, params)
@@ -412,7 +393,6 @@ class CMJVideoConfig:
412
393
  overrides: AnalysisOverrides | None = None
413
394
  detection_confidence: float | None = None
414
395
  tracking_confidence: float | None = None
415
- pose_backend: str | None = None
416
396
 
417
397
 
418
398
  @dataclass
@@ -434,7 +414,6 @@ def process_cmj_video(
434
414
  overrides: AnalysisOverrides | None = None,
435
415
  detection_confidence: float | None = None,
436
416
  tracking_confidence: float | None = None,
437
- pose_backend: str | None = None,
438
417
  verbose: bool = False,
439
418
  timer: Timer | None = None,
440
419
  pose_tracker: MediaPipePoseTracker | None = None,
@@ -481,7 +460,6 @@ def process_cmj_video(
481
460
  detection_confidence,
482
461
  tracking_confidence,
483
462
  pose_tracker,
484
- pose_backend,
485
463
  verbose,
486
464
  timer,
487
465
  )
@@ -567,7 +545,6 @@ def _process_cmj_video_wrapper(config: CMJVideoConfig) -> CMJVideoResult:
567
545
  overrides=config.overrides,
568
546
  detection_confidence=config.detection_confidence,
569
547
  tracking_confidence=config.tracking_confidence,
570
- pose_backend=config.pose_backend,
571
548
  verbose=False,
572
549
  )
573
550
 
kinemotion/cmj/cli.py CHANGED
@@ -27,7 +27,6 @@ class AnalysisParameters:
27
27
  visibility_threshold: float | None = None
28
28
  detection_confidence: float | None = None
29
29
  tracking_confidence: float | None = None
30
- pose_backend: str | None = None
31
30
 
32
31
 
33
32
  def _process_batch_videos(
@@ -78,23 +77,6 @@ def _process_batch_videos(
78
77
  is_flag=True,
79
78
  help="Show auto-selected parameters and analysis details",
80
79
  )
81
- @click.option(
82
- "--pose-backend",
83
- type=click.Choice(
84
- ["auto", "mediapipe", "rtmpose-cpu", "rtmpose-cuda", "rtmpose-coreml"],
85
- case_sensitive=False,
86
- ),
87
- default="auto",
88
- help=(
89
- "Pose tracking backend: "
90
- "auto (detect best), "
91
- "mediapipe (baseline), "
92
- "rtmpose-cpu (optimized CPU), "
93
- "rtmpose-cuda (NVIDIA GPU), "
94
- "rtmpose-coreml (Apple Silicon)"
95
- ),
96
- show_default=True,
97
- )
98
80
  # Batch processing options
99
81
  @click.option(
100
82
  "--batch",
@@ -173,7 +155,6 @@ def cmj_analyze( # NOSONAR(S107) - Click CLI requires individual parameters
173
155
  json_output: str | None,
174
156
  quality: str,
175
157
  verbose: bool,
176
- pose_backend: str,
177
158
  batch: bool,
178
159
  workers: int,
179
160
  output_dir: str | None,
@@ -237,7 +218,6 @@ def cmj_analyze( # NOSONAR(S107) - Click CLI requires individual parameters
237
218
  visibility_threshold=visibility_threshold,
238
219
  detection_confidence=detection_confidence,
239
220
  tracking_confidence=tracking_confidence,
240
- pose_backend=pose_backend,
241
221
  )
242
222
 
243
223
  if use_batch:
@@ -293,7 +273,6 @@ def _process_single(
293
273
  overrides=overrides,
294
274
  detection_confidence=expert_params.detection_confidence,
295
275
  tracking_confidence=expert_params.tracking_confidence,
296
- pose_backend=expert_params.pose_backend,
297
276
  verbose=verbose,
298
277
  )
299
278