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.
- kinemotion/__init__.py +31 -6
- kinemotion/api.py +39 -598
- kinemotion/cli.py +2 -0
- kinemotion/cmj/__init__.py +5 -0
- kinemotion/cmj/analysis.py +621 -0
- kinemotion/cmj/api.py +563 -0
- kinemotion/cmj/cli.py +324 -0
- kinemotion/cmj/debug_overlay.py +457 -0
- kinemotion/cmj/joint_angles.py +307 -0
- kinemotion/cmj/kinematics.py +360 -0
- kinemotion/cmj/metrics_validator.py +767 -0
- kinemotion/cmj/validation_bounds.py +341 -0
- kinemotion/core/__init__.py +28 -0
- kinemotion/core/auto_tuning.py +71 -37
- kinemotion/core/cli_utils.py +60 -0
- kinemotion/core/debug_overlay_utils.py +385 -0
- kinemotion/core/determinism.py +83 -0
- kinemotion/core/experimental.py +103 -0
- kinemotion/core/filtering.py +9 -6
- kinemotion/core/formatting.py +75 -0
- kinemotion/core/metadata.py +231 -0
- kinemotion/core/model_downloader.py +172 -0
- kinemotion/core/pipeline_utils.py +433 -0
- kinemotion/core/pose.py +298 -141
- kinemotion/core/pose_landmarks.py +67 -0
- kinemotion/core/quality.py +393 -0
- kinemotion/core/smoothing.py +250 -154
- kinemotion/core/timing.py +247 -0
- kinemotion/core/types.py +42 -0
- kinemotion/core/validation.py +201 -0
- kinemotion/core/video_io.py +135 -50
- kinemotion/dropjump/__init__.py +1 -1
- kinemotion/dropjump/analysis.py +367 -182
- kinemotion/dropjump/api.py +665 -0
- kinemotion/dropjump/cli.py +156 -466
- kinemotion/dropjump/debug_overlay.py +136 -206
- kinemotion/dropjump/kinematics.py +232 -255
- kinemotion/dropjump/metrics_validator.py +240 -0
- kinemotion/dropjump/validation_bounds.py +157 -0
- kinemotion/models/__init__.py +0 -0
- kinemotion/models/pose_landmarker_lite.task +0 -0
- kinemotion-0.67.0.dist-info/METADATA +726 -0
- kinemotion-0.67.0.dist-info/RECORD +47 -0
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/WHEEL +1 -1
- kinemotion-0.10.6.dist-info/METADATA +0 -561
- kinemotion-0.10.6.dist-info/RECORD +0 -20
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/entry_points.txt +0 -0
- {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
|
+
)
|