kinemotion 0.27.0__py3-none-any.whl → 0.29.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
@@ -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(
@@ -6,6 +6,8 @@ from typing import TYPE_CHECKING, TypedDict
6
6
  import numpy as np
7
7
  from numpy.typing import NDArray
8
8
 
9
+ from ..core.formatting import format_float_metric
10
+
9
11
  if TYPE_CHECKING:
10
12
  from ..core.metadata import ResultMetadata
11
13
  from ..core.quality import QualityAssessment
@@ -15,14 +17,14 @@ class CMJDataDict(TypedDict, total=False):
15
17
  """Type-safe dictionary for CMJ measurement data."""
16
18
 
17
19
  jump_height_m: float
18
- flight_time_s: float
20
+ flight_time_ms: float
19
21
  countermovement_depth_m: float
20
- eccentric_duration_s: float
21
- concentric_duration_s: float
22
- total_movement_time_s: float
22
+ eccentric_duration_ms: float
23
+ concentric_duration_ms: float
24
+ total_movement_time_ms: float
23
25
  peak_eccentric_velocity_m_s: float
24
26
  peak_concentric_velocity_m_s: float
25
- transition_time_s: float | None
27
+ transition_time_ms: float | None
26
28
  standing_start_frame: float | None
27
29
  lowest_point_frame: float
28
30
  takeoff_frame: float
@@ -43,14 +45,14 @@ class CMJMetrics:
43
45
 
44
46
  Attributes:
45
47
  jump_height: Maximum jump height in meters
46
- flight_time: Time spent in the air in seconds
48
+ flight_time: Time spent in the air in milliseconds
47
49
  countermovement_depth: Vertical distance traveled during eccentric phase in meters
48
- eccentric_duration: Time from countermovement start to lowest point in seconds
49
- concentric_duration: Time from lowest point to takeoff in seconds
50
- total_movement_time: Total time from countermovement start to takeoff in seconds
50
+ eccentric_duration: Time from countermovement start to lowest point in milliseconds
51
+ concentric_duration: Time from lowest point to takeoff in milliseconds
52
+ total_movement_time: Total time from countermovement start to takeoff in milliseconds
51
53
  peak_eccentric_velocity: Maximum downward velocity during countermovement in m/s
52
54
  peak_concentric_velocity: Maximum upward velocity during propulsion in m/s
53
- transition_time: Duration at lowest point (amortization phase) in seconds
55
+ transition_time: Duration at lowest point (amortization phase) in milliseconds
54
56
  standing_start_frame: Frame where standing phase ends (countermovement begins)
55
57
  lowest_point_frame: Frame at lowest point of countermovement
56
58
  takeoff_frame: Frame where athlete leaves ground
@@ -85,19 +87,27 @@ class CMJMetrics:
85
87
  Dictionary with nested data and metadata structure.
86
88
  """
87
89
  data: CMJDataDict = {
88
- "jump_height_m": float(self.jump_height),
89
- "flight_time_s": float(self.flight_time),
90
- "countermovement_depth_m": float(self.countermovement_depth),
91
- "eccentric_duration_s": float(self.eccentric_duration),
92
- "concentric_duration_s": float(self.concentric_duration),
93
- "total_movement_time_s": float(self.total_movement_time),
94
- "peak_eccentric_velocity_m_s": float(self.peak_eccentric_velocity),
95
- "peak_concentric_velocity_m_s": float(self.peak_concentric_velocity),
96
- "transition_time_s": (
97
- float(self.transition_time)
98
- if self.transition_time is not None
99
- else None
100
- ),
90
+ "jump_height_m": format_float_metric(self.jump_height, 1, 3), # type: ignore[typeddict-item]
91
+ "flight_time_ms": format_float_metric(self.flight_time, 1000, 2), # type: ignore[typeddict-item]
92
+ "countermovement_depth_m": format_float_metric(
93
+ self.countermovement_depth, 1, 3
94
+ ), # type: ignore[typeddict-item]
95
+ "eccentric_duration_ms": format_float_metric(
96
+ self.eccentric_duration, 1000, 2
97
+ ), # type: ignore[typeddict-item]
98
+ "concentric_duration_ms": format_float_metric(
99
+ self.concentric_duration, 1000, 2
100
+ ), # type: ignore[typeddict-item]
101
+ "total_movement_time_ms": format_float_metric(
102
+ self.total_movement_time, 1000, 2
103
+ ), # type: ignore[typeddict-item]
104
+ "peak_eccentric_velocity_m_s": format_float_metric(
105
+ self.peak_eccentric_velocity, 1, 4
106
+ ), # type: ignore[typeddict-item]
107
+ "peak_concentric_velocity_m_s": format_float_metric(
108
+ self.peak_concentric_velocity, 1, 4
109
+ ), # type: ignore[typeddict-item]
110
+ "transition_time_ms": format_float_metric(self.transition_time, 1000, 2),
101
111
  "standing_start_frame": (
102
112
  float(self.standing_start_frame)
103
113
  if self.standing_start_frame is not None