kinemotion 0.70.0__py3-none-any.whl → 0.71.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.

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,
@@ -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]:
@@ -550,7 +599,7 @@ def detect_cmj_phases(
550
599
  1. Find peak height (global minimum y)
551
600
  2. Find takeoff (peak negative velocity before peak height)
552
601
  3. Find lowest point (maximum y value before takeoff)
553
- 4. Find landing (impact after peak height)
602
+ 4. Find landing (maximum deceleration after peak height)
554
603
 
555
604
  Args:
556
605
  positions: Array of vertical positions (normalized 0-1). Typically Hips/CoM.
@@ -583,7 +632,9 @@ def detect_cmj_phases(
583
632
 
584
633
  # Step 2-4: Find all phases using helper functions
585
634
  with timer.measure("cmj_find_takeoff"):
586
- takeoff_frame = find_takeoff_frame(velocities, peak_height_frame, fps)
635
+ takeoff_frame = find_takeoff_frame(
636
+ velocities, peak_height_frame, fps, accelerations=accelerations
637
+ )
587
638
 
588
639
  with timer.measure("cmj_find_lowest_point"):
589
640
  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
@@ -412,7 +391,6 @@ class CMJVideoConfig:
412
391
  overrides: AnalysisOverrides | None = None
413
392
  detection_confidence: float | None = None
414
393
  tracking_confidence: float | None = None
415
- pose_backend: str | None = None
416
394
 
417
395
 
418
396
  @dataclass
@@ -434,7 +412,6 @@ def process_cmj_video(
434
412
  overrides: AnalysisOverrides | None = None,
435
413
  detection_confidence: float | None = None,
436
414
  tracking_confidence: float | None = None,
437
- pose_backend: str | None = None,
438
415
  verbose: bool = False,
439
416
  timer: Timer | None = None,
440
417
  pose_tracker: MediaPipePoseTracker | None = None,
@@ -481,7 +458,6 @@ def process_cmj_video(
481
458
  detection_confidence,
482
459
  tracking_confidence,
483
460
  pose_tracker,
484
- pose_backend,
485
461
  verbose,
486
462
  timer,
487
463
  )
@@ -567,7 +543,6 @@ def _process_cmj_video_wrapper(config: CMJVideoConfig) -> CMJVideoResult:
567
543
  overrides=config.overrides,
568
544
  detection_confidence=config.detection_confidence,
569
545
  tracking_confidence=config.tracking_confidence,
570
- pose_backend=config.pose_backend,
571
546
  verbose=False,
572
547
  )
573
548
 
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
 
@@ -292,20 +292,13 @@ class CMJMetricsValidator(MetricsValidator):
292
292
  value=duration,
293
293
  bounds=(bounds.absolute_min, bounds.absolute_max),
294
294
  )
295
- elif bounds.contains(duration, profile):
296
- result.add_info(
297
- "concentric_duration",
298
- f"Concentric duration {duration:.3f}s within expected range for {profile.value}",
299
- value=duration,
300
- )
301
295
  else:
302
- expected_min, expected_max = self._get_profile_range(profile, bounds)
303
- result.add_warning(
296
+ # NOTE: Downgraded from WARNING to INFO - standing end detection has
297
+ # ~117ms offset causing misleading warnings. See issue #16.
298
+ result.add_info(
304
299
  "concentric_duration",
305
- f"Concentric duration {duration:.3f}s outside typical range "
306
- f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
300
+ f"Concentric duration {duration:.3f}s",
307
301
  value=duration,
308
- bounds=(expected_min, expected_max),
309
302
  )
310
303
 
311
304
  def _check_eccentric_duration(
@@ -333,20 +326,13 @@ class CMJMetricsValidator(MetricsValidator):
333
326
  value=duration,
334
327
  bounds=(bounds.absolute_min, bounds.absolute_max),
335
328
  )
336
- elif bounds.contains(duration, profile):
337
- result.add_info(
338
- "eccentric_duration",
339
- f"Eccentric duration {duration:.3f}s within expected range for {profile.value}",
340
- value=duration,
341
- )
342
329
  else:
343
- expected_min, expected_max = self._get_profile_range(profile, bounds)
344
- result.add_warning(
330
+ # NOTE: Downgraded from WARNING to INFO - standing end detection has
331
+ # ~117ms offset causing misleading warnings. See issue #16.
332
+ result.add_info(
345
333
  "eccentric_duration",
346
- f"Eccentric duration {duration:.3f}s outside typical range "
347
- f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
334
+ f"Eccentric duration {duration:.3f}s",
348
335
  value=duration,
349
- bounds=(expected_min, expected_max),
350
336
  )
351
337
 
352
338
  def _check_peak_velocities(