kinemotion 0.28.0__py3-none-any.whl → 0.29.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.
kinemotion/__init__.py CHANGED
@@ -13,7 +13,7 @@ from .api import (
13
13
  from .cmj.kinematics import CMJMetrics
14
14
  from .dropjump.kinematics import DropJumpMetrics
15
15
 
16
- __version__ = "0.1.0"
16
+ __version__ = "0.27.0"
17
17
 
18
18
  __all__ = [
19
19
  # Drop jump API
@@ -417,17 +417,32 @@ def find_interpolated_takeoff_landing(
417
417
  def _find_takeoff_frame(
418
418
  velocities: np.ndarray, peak_height_frame: int, fps: float
419
419
  ) -> float:
420
- """Find takeoff frame as peak upward velocity before peak height."""
420
+ """Find takeoff frame as peak upward velocity before peak height.
421
+
422
+ Robust detection: When velocities are nearly identical (flat), detects
423
+ the transition point rather than using argmin which is unstable.
424
+ """
421
425
  takeoff_search_start = max(0, peak_height_frame - int(fps * 0.35))
422
426
  takeoff_search_end = peak_height_frame - 2
423
427
 
424
428
  takeoff_velocities = velocities[takeoff_search_start:takeoff_search_end]
425
429
 
426
- if len(takeoff_velocities) > 0:
430
+ if len(takeoff_velocities) == 0:
431
+ return float(peak_height_frame - int(fps * 0.3))
432
+
433
+ # Check if velocities are suspiciously identical (flat derivative = ambiguous)
434
+ vel_min = np.min(takeoff_velocities)
435
+ vel_max = np.max(takeoff_velocities)
436
+ vel_range = vel_max - vel_min
437
+
438
+ if vel_range < 1e-6:
439
+ # Velocities are essentially identical - algorithm is ambiguous
440
+ # Return the midpoint of the search window as a stable estimate
441
+ return float((takeoff_search_start + takeoff_search_end) / 2.0)
442
+ else:
443
+ # Velocities have variation - use argmin as before
427
444
  peak_vel_idx = int(np.argmin(takeoff_velocities))
428
445
  return float(takeoff_search_start + peak_vel_idx)
429
- else:
430
- return float(peak_height_frame - int(fps * 0.3))
431
446
 
432
447
 
433
448
  def _find_lowest_frame(
@@ -454,17 +469,35 @@ def _find_lowest_frame(
454
469
  def _find_landing_frame(
455
470
  accelerations: np.ndarray, peak_height_frame: int, fps: float
456
471
  ) -> float:
457
- """Find landing frame after peak height."""
472
+ """Find landing frame after peak height after takeoff.
473
+
474
+ Detects landing by finding the minimum acceleration (impact) in a search
475
+ window after peak height. The window is extended to 1.0s to ensure all
476
+ realistic flight times are captured.
477
+
478
+ Robust detection: When accelerations are nearly flat, skips the impact
479
+ frames near peak height and looks for the actual landing signal.
480
+ """
458
481
  landing_search_start = peak_height_frame
459
- landing_search_end = min(len(accelerations), peak_height_frame + int(fps * 0.5))
482
+ # Search window extended to 1.0s to accommodate all realistic flight times
483
+ # (recreational: 0.25-0.65s, elite: 0.65-0.95s, max: 1.1s)
484
+ landing_search_end = min(len(accelerations), peak_height_frame + int(fps * 1.0))
460
485
  landing_accelerations = accelerations[landing_search_start:landing_search_end]
461
486
 
462
- if len(landing_accelerations) > 0:
463
- landing_idx = int(np.argmin(landing_accelerations))
464
- return float(landing_search_start + landing_idx)
465
- else:
487
+ if len(landing_accelerations) == 0:
466
488
  return float(peak_height_frame + int(fps * 0.3))
467
489
 
490
+ # Skip the first 2 frames after peak (often have unreliable acceleration)
491
+ # This avoids locking onto peak height acceleration instead of impact
492
+ skip_frames = 2
493
+ if len(landing_accelerations) > skip_frames:
494
+ landing_accelerations_filtered = landing_accelerations[skip_frames:]
495
+ landing_idx = int(np.argmin(landing_accelerations_filtered)) + skip_frames
496
+ else:
497
+ landing_idx = int(np.argmin(landing_accelerations))
498
+
499
+ return float(landing_search_start + landing_idx)
500
+
468
501
 
469
502
  def _find_standing_end(velocities: np.ndarray, lowest_point: float) -> float | None:
470
503
  """Find end of standing phase before lowest point."""
@@ -68,11 +68,19 @@ def calculate_ankle_angle(
68
68
  """
69
69
  Calculate ankle angle (dorsiflexion/plantarflexion).
70
70
 
71
- Angle formed by: heel -> ankle -> knee
71
+ Angle formed by: foot_index -> ankle -> knee (primary)
72
+ Falls back to heel -> ankle -> knee if foot_index visibility < 0.5
73
+
74
+ Measurements:
72
75
  - 90° = neutral (foot perpendicular to shin)
73
76
  - < 90° = dorsiflexion (toes up)
74
77
  - > 90° = plantarflexion (toes down)
75
78
 
79
+ Technical note:
80
+ - foot_index (toe tip) is used for accurate plantarflexion measurement
81
+ - Heel is relatively static during push-off; toes (foot_index) actively plantarflex
82
+ - Expected range during CMJ: 80° (standing) -> 130°+ (plantarflex at takeoff)
83
+
76
84
  Args:
77
85
  landmarks: Pose landmarks dictionary
78
86
  side: Which side to measure ("left" or "right")
@@ -82,23 +90,32 @@ def calculate_ankle_angle(
82
90
  """
83
91
  prefix = "left_" if side == "left" else "right_"
84
92
 
93
+ foot_index_key = f"{prefix}foot_index"
85
94
  heel_key = f"{prefix}heel"
86
95
  ankle_key = f"{prefix}ankle"
87
96
  knee_key = f"{prefix}knee"
88
97
 
89
- # Check visibility threshold
90
- if heel_key not in landmarks or landmarks[heel_key][2] < 0.3:
91
- return None
98
+ # Check ankle and knee visibility (required)
92
99
  if ankle_key not in landmarks or landmarks[ankle_key][2] < 0.3:
93
100
  return None
94
101
  if knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
95
102
  return None
96
103
 
97
- heel = (landmarks[heel_key][0], landmarks[heel_key][1])
98
104
  ankle = (landmarks[ankle_key][0], landmarks[ankle_key][1])
99
105
  knee = (landmarks[knee_key][0], landmarks[knee_key][1])
100
106
 
101
- return calculate_angle_3_points(heel, ankle, knee)
107
+ # Try foot_index first (primary: toe tip for plantarflexion accuracy)
108
+ if foot_index_key in landmarks and landmarks[foot_index_key][2] > 0.5:
109
+ foot_point = (landmarks[foot_index_key][0], landmarks[foot_index_key][1])
110
+ return calculate_angle_3_points(foot_point, ankle, knee)
111
+
112
+ # Fallback to heel if foot_index visibility is insufficient
113
+ if heel_key in landmarks and landmarks[heel_key][2] > 0.3:
114
+ foot_point = (landmarks[heel_key][0], landmarks[heel_key][1])
115
+ return calculate_angle_3_points(foot_point, ankle, knee)
116
+
117
+ # No valid foot landmark available
118
+ return None
102
119
 
103
120
 
104
121
  def calculate_knee_angle(