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,307 @@
1
+ """Joint angle calculations for triple extension analysis."""
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+
7
+
8
+ def calculate_angle_3_points(
9
+ point1: tuple[float, float],
10
+ point2: tuple[float, float],
11
+ point3: tuple[float, float],
12
+ ) -> float:
13
+ """
14
+ Calculate angle at point2 formed by three points.
15
+
16
+ Uses the law of cosines to find the angle at the middle point.
17
+
18
+ Args:
19
+ point1: First point (x, y) - e.g., foot
20
+ point2: Middle point (x, y) - e.g., knee (vertex of angle)
21
+ point3: Third point (x, y) - e.g., hip
22
+
23
+ Returns:
24
+ Angle in degrees (0-180)
25
+
26
+ Example:
27
+ >>> # Calculate knee angle
28
+ >>> ankle = (0.5, 0.8)
29
+ >>> knee = (0.5, 0.6)
30
+ >>> hip = (0.5, 0.4)
31
+ >>> angle = calculate_angle_3_points(ankle, knee, hip)
32
+ >>> # angle ≈ 180 (straight leg)
33
+ """
34
+ # Convert points to numpy arrays
35
+ p1 = np.array(point1)
36
+ p2 = np.array(point2)
37
+ p3 = np.array(point3)
38
+
39
+ # Calculate vectors from point2 to point1 and point3
40
+ v1 = p1 - p2
41
+ v2 = p3 - p2
42
+
43
+ # Calculate angle using dot product
44
+ # cos(angle) = (v1 · v2) / (|v1| * |v2|)
45
+ dot_product = np.dot(v1, v2)
46
+ magnitude1 = np.linalg.norm(v1)
47
+ magnitude2 = np.linalg.norm(v2)
48
+
49
+ # Avoid division by zero
50
+ if magnitude1 < 1e-9 or magnitude2 < 1e-9:
51
+ return 0.0
52
+
53
+ # Calculate angle in radians, then convert to degrees
54
+ cos_angle = dot_product / (magnitude1 * magnitude2)
55
+
56
+ # Clamp to [-1, 1] to avoid numerical errors with arccos
57
+ cos_angle = np.clip(cos_angle, -1.0, 1.0)
58
+
59
+ angle_rad = math.acos(cos_angle)
60
+ angle_deg = math.degrees(angle_rad)
61
+
62
+ return float(angle_deg)
63
+
64
+
65
+ def calculate_ankle_angle(
66
+ landmarks: dict[str, tuple[float, float, float]], side: str = "right"
67
+ ) -> float | None:
68
+ """
69
+ Calculate ankle angle (dorsiflexion/plantarflexion).
70
+
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:
75
+ - 90° = neutral (foot perpendicular to shin)
76
+ - < 90° = dorsiflexion (toes up)
77
+ - > 90° = plantarflexion (toes down)
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
+
84
+ Args:
85
+ landmarks: Pose landmarks dictionary
86
+ side: Which side to measure ("left" or "right")
87
+
88
+ Returns:
89
+ Ankle angle in degrees, or None if landmarks not available
90
+ """
91
+ prefix = "left_" if side == "left" else "right_"
92
+
93
+ foot_index_key = f"{prefix}foot_index"
94
+ heel_key = f"{prefix}heel"
95
+ ankle_key = f"{prefix}ankle"
96
+ knee_key = f"{prefix}knee"
97
+
98
+ # Check ankle and knee visibility (required)
99
+ if ankle_key not in landmarks or landmarks[ankle_key][2] < 0.3:
100
+ return None
101
+ if knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
102
+ return None
103
+
104
+ ankle = (landmarks[ankle_key][0], landmarks[ankle_key][1])
105
+ knee = (landmarks[knee_key][0], landmarks[knee_key][1])
106
+
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
119
+
120
+
121
+ def calculate_knee_angle(
122
+ landmarks: dict[str, tuple[float, float, float]], side: str = "right"
123
+ ) -> float | None:
124
+ """
125
+ Calculate knee angle (flexion/extension).
126
+
127
+ Angle formed by: ankle -> knee -> hip
128
+ - 180° = full extension (straight leg)
129
+ - 90° = 90° flexion (deep squat)
130
+ - 0° = full flexion (not physiologically possible)
131
+
132
+ Args:
133
+ landmarks: Pose landmarks dictionary
134
+ side: Which side to measure ("left" or "right")
135
+
136
+ Returns:
137
+ Knee angle in degrees, or None if landmarks not available
138
+ """
139
+ prefix = "left_" if side == "left" else "right_"
140
+
141
+ ankle_key = f"{prefix}ankle"
142
+ knee_key = f"{prefix}knee"
143
+ hip_key = f"{prefix}hip"
144
+
145
+ # Check visibility
146
+ if ankle_key not in landmarks or landmarks[ankle_key][2] < 0.3:
147
+ # Fallback: use foot_index if ankle not visible
148
+ foot_key = f"{prefix}foot_index"
149
+ if foot_key in landmarks and landmarks[foot_key][2] > 0.3:
150
+ ankle_key = foot_key
151
+ else:
152
+ return None
153
+
154
+ if knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
155
+ return None
156
+ if hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
157
+ return None
158
+
159
+ ankle = (landmarks[ankle_key][0], landmarks[ankle_key][1])
160
+ knee = (landmarks[knee_key][0], landmarks[knee_key][1])
161
+ hip = (landmarks[hip_key][0], landmarks[hip_key][1])
162
+
163
+ return calculate_angle_3_points(ankle, knee, hip)
164
+
165
+
166
+ def calculate_hip_angle(
167
+ landmarks: dict[str, tuple[float, float, float]], side: str = "right"
168
+ ) -> float | None:
169
+ """
170
+ Calculate hip angle (flexion/extension).
171
+
172
+ Angle formed by: knee -> hip -> shoulder
173
+ - 180° = standing upright (trunk and thigh aligned)
174
+ - 90° = 90° hip flexion (torso perpendicular to thigh)
175
+ - < 90° = deep flexion (squat position)
176
+
177
+ Args:
178
+ landmarks: Pose landmarks dictionary
179
+ side: Which side to measure ("left" or "right")
180
+
181
+ Returns:
182
+ Hip angle in degrees, or None if landmarks not available
183
+ """
184
+ prefix = "left_" if side == "left" else "right_"
185
+
186
+ knee_key = f"{prefix}knee"
187
+ hip_key = f"{prefix}hip"
188
+ shoulder_key = f"{prefix}shoulder"
189
+
190
+ # Check visibility
191
+ if knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
192
+ return None
193
+ if hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
194
+ return None
195
+ if shoulder_key not in landmarks or landmarks[shoulder_key][2] < 0.3:
196
+ return None
197
+
198
+ knee = (landmarks[knee_key][0], landmarks[knee_key][1])
199
+ hip = (landmarks[hip_key][0], landmarks[hip_key][1])
200
+ shoulder = (landmarks[shoulder_key][0], landmarks[shoulder_key][1])
201
+
202
+ return calculate_angle_3_points(knee, hip, shoulder)
203
+
204
+
205
+ def calculate_trunk_tilt(
206
+ landmarks: dict[str, tuple[float, float, float]], side: str = "right"
207
+ ) -> float | None:
208
+ """
209
+ Calculate trunk tilt angle relative to vertical.
210
+
211
+ Measures forward/backward lean of the torso.
212
+ - 0° = perfectly vertical
213
+ - Positive = leaning forward
214
+ - Negative = leaning backward
215
+
216
+ Args:
217
+ landmarks: Pose landmarks dictionary
218
+ side: Which side to measure ("left" or "right")
219
+
220
+ Returns:
221
+ Trunk tilt angle in degrees, or None if landmarks not available
222
+ """
223
+ prefix = "left_" if side == "left" else "right_"
224
+
225
+ hip_key = f"{prefix}hip"
226
+ shoulder_key = f"{prefix}shoulder"
227
+
228
+ # Check visibility
229
+ if hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
230
+ return None
231
+ if shoulder_key not in landmarks or landmarks[shoulder_key][2] < 0.3:
232
+ return None
233
+
234
+ hip = np.array([landmarks[hip_key][0], landmarks[hip_key][1]])
235
+ shoulder = np.array([landmarks[shoulder_key][0], landmarks[shoulder_key][1]])
236
+
237
+ # Vector from hip to shoulder
238
+ trunk_vector = shoulder - hip
239
+
240
+ # Vertical reference (in normalized coords, vertical is along y-axis)
241
+ # Negative y direction is up in frame coordinates
242
+ vertical = np.array([0, -1])
243
+
244
+ # Calculate angle from vertical
245
+ dot_product = np.dot(trunk_vector, vertical)
246
+ magnitude_trunk = np.linalg.norm(trunk_vector)
247
+
248
+ if magnitude_trunk < 1e-9:
249
+ return None
250
+
251
+ cos_angle = dot_product / magnitude_trunk
252
+ cos_angle = np.clip(cos_angle, -1.0, 1.0)
253
+
254
+ angle_rad = math.acos(cos_angle)
255
+ angle_deg = math.degrees(angle_rad)
256
+
257
+ # Determine if leaning forward or backward based on x-component
258
+ if trunk_vector[0] > 0: # Shoulder to the right of hip = leaning forward
259
+ return float(angle_deg)
260
+ else:
261
+ return float(-angle_deg)
262
+
263
+
264
+ def calculate_triple_extension(
265
+ landmarks: dict[str, tuple[float, float, float]], side: str = "right"
266
+ ) -> dict[str, float | None] | None:
267
+ """
268
+ Calculate all three joint angles for triple extension analysis.
269
+
270
+ Triple extension refers to simultaneous extension of ankle, knee, and hip joints
271
+ during the propulsive phase of jumping. This is a key indicator of proper technique.
272
+
273
+ NOTE: In side-view videos, ankle/knee may have low visibility from MediaPipe.
274
+ Returns partial results with None for unavailable angles.
275
+
276
+ Args:
277
+ landmarks: Pose landmarks dictionary
278
+ side: Which side to measure ("left" or "right")
279
+
280
+ Returns:
281
+ Dictionary with angle measurements:
282
+ - ankle_angle: Ankle angle or None
283
+ - knee_angle: Knee angle or None
284
+ - hip_angle: Hip angle or None
285
+ - trunk_tilt: Trunk lean angle or None
286
+ Returns None if NO angles can be calculated
287
+
288
+ Example:
289
+ >>> angles = calculate_triple_extension(landmarks, side="right")
290
+ >>> if angles and angles['knee_angle']:
291
+ ... print(f"Knee: {angles['knee_angle']:.0f}°")
292
+ """
293
+ ankle = calculate_ankle_angle(landmarks, side)
294
+ knee = calculate_knee_angle(landmarks, side)
295
+ hip = calculate_hip_angle(landmarks, side)
296
+ trunk = calculate_trunk_tilt(landmarks, side)
297
+
298
+ # Return results even if some are None (at least trunk should be available)
299
+ if ankle is None and knee is None and hip is None and trunk is None:
300
+ return None # No angles available at all
301
+
302
+ return {
303
+ "ankle_angle": ankle,
304
+ "knee_angle": knee,
305
+ "hip_angle": hip,
306
+ "trunk_tilt": trunk,
307
+ }
@@ -0,0 +1,360 @@
1
+ """Counter Movement Jump (CMJ) metrics calculation."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, TypedDict
5
+
6
+ import numpy as np
7
+
8
+ from ..core.formatting import format_float_metric
9
+ from ..core.types import FloatArray
10
+
11
+ if TYPE_CHECKING:
12
+ from ..core.cmj_metrics_validator import ValidationResult
13
+ from ..core.metadata import ResultMetadata
14
+ from ..core.quality import QualityAssessment
15
+
16
+
17
+ class CMJDataDict(TypedDict, total=False):
18
+ """Type-safe dictionary for CMJ measurement data."""
19
+
20
+ jump_height_m: float
21
+ flight_time_ms: float
22
+ countermovement_depth_m: float
23
+ eccentric_duration_ms: float
24
+ concentric_duration_ms: float
25
+ total_movement_time_ms: float
26
+ peak_eccentric_velocity_m_s: float
27
+ peak_concentric_velocity_m_s: float
28
+ transition_time_ms: float | None
29
+ standing_start_frame: float | None
30
+ lowest_point_frame: float
31
+ takeoff_frame: float
32
+ landing_frame: float
33
+ tracking_method: str
34
+
35
+
36
+ class CMJResultDict(TypedDict, total=False):
37
+ """Type-safe dictionary for complete CMJ result with data and metadata."""
38
+
39
+ data: CMJDataDict
40
+ metadata: dict # ResultMetadata.to_dict()
41
+ validation: dict # ValidationResult.to_dict()
42
+
43
+
44
+ @dataclass
45
+ class CMJMetrics:
46
+ """Metrics for a counter movement jump analysis.
47
+
48
+ Attributes:
49
+ jump_height: Maximum jump height in meters
50
+ flight_time: Time spent in the air in milliseconds
51
+ countermovement_depth: Vertical distance traveled during eccentric
52
+ phase in meters
53
+ eccentric_duration: Time from countermovement start to lowest point in
54
+ milliseconds
55
+ concentric_duration: Time from lowest point to takeoff in milliseconds
56
+ total_movement_time: Total time from countermovement start to takeoff
57
+ in milliseconds
58
+ peak_eccentric_velocity: Maximum downward velocity during
59
+ countermovement in m/s
60
+ peak_concentric_velocity: Maximum upward velocity during propulsion in
61
+ m/s
62
+ transition_time: Duration at lowest point (amortization phase) in milliseconds
63
+ standing_start_frame: Frame where standing phase ends (countermovement begins)
64
+ lowest_point_frame: Frame at lowest point of countermovement
65
+ takeoff_frame: Frame where athlete leaves ground
66
+ landing_frame: Frame where athlete lands
67
+ video_fps: Frames per second of the analyzed video
68
+ tracking_method: Method used for tracking ("foot" or "com")
69
+ quality_assessment: Optional quality assessment with confidence and warnings
70
+ validation_result: Optional validation result with physiological bounds checks
71
+ """
72
+
73
+ jump_height: float
74
+ flight_time: float
75
+ countermovement_depth: float
76
+ eccentric_duration: float
77
+ concentric_duration: float
78
+ total_movement_time: float
79
+ peak_eccentric_velocity: float
80
+ peak_concentric_velocity: float
81
+ transition_time: float | None
82
+ standing_start_frame: float | None
83
+ lowest_point_frame: float
84
+ takeoff_frame: float
85
+ landing_frame: float
86
+ video_fps: float
87
+ tracking_method: str
88
+ quality_assessment: "QualityAssessment | None" = None
89
+ result_metadata: "ResultMetadata | None" = None
90
+ validation_result: "ValidationResult | None" = None
91
+
92
+ def to_dict(self) -> CMJResultDict:
93
+ """Convert metrics to JSON-serializable dictionary with data/metadata structure.
94
+
95
+ Returns:
96
+ Dictionary with nested data and metadata structure.
97
+ """
98
+ data: CMJDataDict = {
99
+ "jump_height_m": format_float_metric(self.jump_height, 1, 3), # type: ignore[typeddict-item]
100
+ "flight_time_ms": format_float_metric(self.flight_time, 1000, 2), # type: ignore[typeddict-item]
101
+ "countermovement_depth_m": format_float_metric(self.countermovement_depth, 1, 3), # type: ignore[typeddict-item]
102
+ "eccentric_duration_ms": format_float_metric(self.eccentric_duration, 1000, 2), # type: ignore[typeddict-item]
103
+ "concentric_duration_ms": format_float_metric(self.concentric_duration, 1000, 2), # type: ignore[typeddict-item]
104
+ "total_movement_time_ms": format_float_metric(self.total_movement_time, 1000, 2), # type: ignore[typeddict-item]
105
+ "peak_eccentric_velocity_m_s": format_float_metric(self.peak_eccentric_velocity, 1, 4), # type: ignore[typeddict-item]
106
+ "peak_concentric_velocity_m_s": format_float_metric(
107
+ self.peak_concentric_velocity, 1, 4
108
+ ), # type: ignore[typeddict-item]
109
+ "transition_time_ms": format_float_metric(self.transition_time, 1000, 2),
110
+ "standing_start_frame": (
111
+ float(self.standing_start_frame) if self.standing_start_frame is not None else None
112
+ ),
113
+ "lowest_point_frame": float(self.lowest_point_frame),
114
+ "takeoff_frame": float(self.takeoff_frame),
115
+ "landing_frame": float(self.landing_frame),
116
+ "tracking_method": self.tracking_method,
117
+ }
118
+
119
+ # Build metadata from ResultMetadata if available, otherwise use legacy quality
120
+ if self.result_metadata is not None:
121
+ metadata = self.result_metadata.to_dict()
122
+ elif self.quality_assessment is not None:
123
+ # Fallback for backwards compatibility during transition
124
+ metadata = {"quality": self.quality_assessment.to_dict()}
125
+ else:
126
+ # No metadata available
127
+ metadata = {}
128
+
129
+ result: CMJResultDict = {"data": data, "metadata": metadata}
130
+
131
+ # Include validation results if available
132
+ if self.validation_result is not None:
133
+ result["validation"] = self.validation_result.to_dict()
134
+
135
+ return result
136
+
137
+
138
+ def _calculate_scale_factor(
139
+ positions: FloatArray,
140
+ takeoff_frame: float,
141
+ landing_frame: float,
142
+ jump_height: float,
143
+ ) -> float:
144
+ """Calculate meters per normalized unit scaling factor from flight phase.
145
+
146
+ Args:
147
+ positions: Array of vertical positions
148
+ takeoff_frame: Takeoff frame index
149
+ landing_frame: Landing frame index
150
+ jump_height: Calculated jump height in meters
151
+
152
+ Returns:
153
+ Scale factor (meters per normalized unit)
154
+ """
155
+ flight_start_idx = int(takeoff_frame)
156
+ flight_end_idx = int(landing_frame)
157
+ flight_positions = positions[flight_start_idx:flight_end_idx]
158
+
159
+ if len(flight_positions) == 0:
160
+ return 0.0
161
+
162
+ peak_flight_pos = np.min(flight_positions)
163
+ takeoff_pos = positions[flight_start_idx]
164
+ flight_displacement = takeoff_pos - peak_flight_pos
165
+
166
+ if flight_displacement > 0.001:
167
+ return jump_height / flight_displacement
168
+ return 0.0
169
+
170
+
171
+ def _calculate_countermovement_depth(
172
+ positions: FloatArray,
173
+ standing_start_frame: float | None,
174
+ lowest_point_frame: float,
175
+ scale_factor: float,
176
+ ) -> float:
177
+ """Calculate countermovement depth in meters.
178
+
179
+ Args:
180
+ positions: Array of vertical positions
181
+ standing_start_frame: Standing phase end frame (or None)
182
+ lowest_point_frame: Lowest point frame index
183
+ scale_factor: Meters per normalized unit
184
+
185
+ Returns:
186
+ Countermovement depth in meters
187
+ """
188
+ standing_position = (
189
+ positions[int(standing_start_frame)] if standing_start_frame is not None else positions[0]
190
+ )
191
+ lowest_position = positions[int(lowest_point_frame)]
192
+ depth_normalized = abs(standing_position - lowest_position)
193
+ return depth_normalized * scale_factor
194
+
195
+
196
+ def _calculate_phase_durations(
197
+ standing_start_frame: float | None,
198
+ lowest_point_frame: float,
199
+ takeoff_frame: float,
200
+ fps: float,
201
+ ) -> tuple[float, float, float]:
202
+ """Calculate phase durations in seconds.
203
+
204
+ Args:
205
+ standing_start_frame: Standing phase end frame (or None)
206
+ lowest_point_frame: Lowest point frame index
207
+ takeoff_frame: Takeoff frame index
208
+ fps: Frames per second
209
+
210
+ Returns:
211
+ Tuple of (eccentric_duration, concentric_duration, total_movement_time)
212
+ """
213
+ if standing_start_frame is not None:
214
+ eccentric_duration = (lowest_point_frame - standing_start_frame) / fps
215
+ total_movement_time = (takeoff_frame - standing_start_frame) / fps
216
+ else:
217
+ eccentric_duration = lowest_point_frame / fps
218
+ total_movement_time = takeoff_frame / fps
219
+
220
+ concentric_duration = (takeoff_frame - lowest_point_frame) / fps
221
+ return eccentric_duration, concentric_duration, total_movement_time
222
+
223
+
224
+ def _calculate_peak_velocities(
225
+ velocities: FloatArray,
226
+ standing_start_frame: float | None,
227
+ lowest_point_frame: float,
228
+ takeoff_frame: float,
229
+ velocity_scale: float,
230
+ ) -> tuple[float, float]:
231
+ """Calculate peak eccentric and concentric velocities.
232
+
233
+ Args:
234
+ velocities: Array of velocities
235
+ standing_start_frame: Standing phase end frame (or None)
236
+ lowest_point_frame: Lowest point frame index
237
+ takeoff_frame: Takeoff frame index
238
+ velocity_scale: Velocity scaling factor
239
+
240
+ Returns:
241
+ Tuple of (peak_eccentric_velocity, peak_concentric_velocity)
242
+ """
243
+ eccentric_start_idx = int(standing_start_frame) if standing_start_frame else 0
244
+ eccentric_end_idx = int(lowest_point_frame)
245
+ eccentric_velocities = velocities[eccentric_start_idx:eccentric_end_idx]
246
+
247
+ peak_eccentric_velocity = 0.0
248
+ if len(eccentric_velocities) > 0:
249
+ peak = float(np.max(eccentric_velocities)) * velocity_scale
250
+ peak_eccentric_velocity = max(0.0, peak)
251
+
252
+ concentric_start_idx = int(lowest_point_frame)
253
+ concentric_end_idx = int(takeoff_frame)
254
+ concentric_velocities = velocities[concentric_start_idx:concentric_end_idx]
255
+
256
+ peak_concentric_velocity = 0.0
257
+ if len(concentric_velocities) > 0:
258
+ peak_concentric_velocity = abs(float(np.min(concentric_velocities))) * velocity_scale
259
+
260
+ return peak_eccentric_velocity, peak_concentric_velocity
261
+
262
+
263
+ def _calculate_transition_time(
264
+ velocities: FloatArray,
265
+ lowest_point_frame: float,
266
+ fps: float,
267
+ ) -> float | None:
268
+ """Calculate transition/amortization time around lowest point.
269
+
270
+ Args:
271
+ velocities: Array of velocities
272
+ lowest_point_frame: Lowest point frame index
273
+ fps: Frames per second
274
+
275
+ Returns:
276
+ Transition time in seconds, or None if no transition detected
277
+ """
278
+ transition_threshold = 0.005
279
+ search_window = int(fps * 0.1)
280
+
281
+ transition_start_idx = max(0, int(lowest_point_frame) - search_window)
282
+ transition_end_idx = min(len(velocities), int(lowest_point_frame) + search_window)
283
+
284
+ transition_frames = sum(
285
+ 1
286
+ for i in range(transition_start_idx, transition_end_idx)
287
+ if abs(velocities[i]) < transition_threshold
288
+ )
289
+
290
+ return transition_frames / fps if transition_frames > 0 else None
291
+
292
+
293
+ def calculate_cmj_metrics(
294
+ positions: FloatArray,
295
+ velocities: FloatArray,
296
+ standing_start_frame: float | None,
297
+ lowest_point_frame: float,
298
+ takeoff_frame: float,
299
+ landing_frame: float,
300
+ fps: float,
301
+ tracking_method: str = "foot",
302
+ ) -> CMJMetrics:
303
+ """Calculate all CMJ metrics from detected phases.
304
+
305
+ Args:
306
+ positions: Array of vertical positions (normalized coordinates)
307
+ velocities: Array of vertical velocities
308
+ standing_start_frame: Frame where countermovement begins (fractional)
309
+ lowest_point_frame: Frame at lowest point (fractional)
310
+ takeoff_frame: Frame at takeoff (fractional)
311
+ landing_frame: Frame at landing (fractional)
312
+ fps: Video frames per second
313
+ tracking_method: Tracking method used ("foot" or "com")
314
+
315
+ Returns:
316
+ CMJMetrics object with all calculated metrics.
317
+ """
318
+ # Calculate jump height from flight time using kinematic formula: h = g*t²/8
319
+ g = 9.81
320
+ flight_time = (landing_frame - takeoff_frame) / fps
321
+ jump_height = (g * flight_time**2) / 8
322
+
323
+ # Calculate scaling factor and derived metrics
324
+ scale_factor = _calculate_scale_factor(positions, takeoff_frame, landing_frame, jump_height)
325
+ countermovement_depth = _calculate_countermovement_depth(
326
+ positions, standing_start_frame, lowest_point_frame, scale_factor
327
+ )
328
+
329
+ eccentric_duration, concentric_duration, total_movement_time = _calculate_phase_durations(
330
+ standing_start_frame, lowest_point_frame, takeoff_frame, fps
331
+ )
332
+
333
+ velocity_scale = scale_factor * fps
334
+ peak_eccentric_velocity, peak_concentric_velocity = _calculate_peak_velocities(
335
+ velocities,
336
+ standing_start_frame,
337
+ lowest_point_frame,
338
+ takeoff_frame,
339
+ velocity_scale,
340
+ )
341
+
342
+ transition_time = _calculate_transition_time(velocities, lowest_point_frame, fps)
343
+
344
+ return CMJMetrics(
345
+ jump_height=jump_height,
346
+ flight_time=flight_time,
347
+ countermovement_depth=countermovement_depth,
348
+ eccentric_duration=eccentric_duration,
349
+ concentric_duration=concentric_duration,
350
+ total_movement_time=total_movement_time,
351
+ peak_eccentric_velocity=peak_eccentric_velocity,
352
+ peak_concentric_velocity=peak_concentric_velocity,
353
+ transition_time=transition_time,
354
+ standing_start_frame=standing_start_frame,
355
+ lowest_point_frame=lowest_point_frame,
356
+ takeoff_frame=takeoff_frame,
357
+ landing_frame=landing_frame,
358
+ video_fps=fps,
359
+ tracking_method=tracking_method,
360
+ )