kinemotion 0.76.3__py3-none-any.whl → 2.0.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 +3 -18
- kinemotion/api.py +7 -27
- kinemotion/cli.py +2 -4
- kinemotion/{countermovement_jump → cmj}/analysis.py +158 -16
- kinemotion/{countermovement_jump → cmj}/api.py +18 -46
- kinemotion/{countermovement_jump → cmj}/cli.py +46 -6
- kinemotion/cmj/debug_overlay.py +457 -0
- kinemotion/{countermovement_jump → cmj}/joint_angles.py +31 -96
- kinemotion/{countermovement_jump → cmj}/metrics_validator.py +271 -176
- kinemotion/{countermovement_jump → cmj}/validation_bounds.py +18 -1
- kinemotion/core/__init__.py +2 -11
- kinemotion/core/auto_tuning.py +107 -149
- kinemotion/core/cli_utils.py +0 -74
- kinemotion/core/debug_overlay_utils.py +15 -142
- kinemotion/core/experimental.py +51 -55
- kinemotion/core/filtering.py +56 -116
- kinemotion/core/pipeline_utils.py +2 -2
- kinemotion/core/pose.py +98 -47
- kinemotion/core/quality.py +6 -4
- kinemotion/core/smoothing.py +51 -65
- kinemotion/core/types.py +0 -15
- kinemotion/core/validation.py +7 -76
- kinemotion/core/video_io.py +27 -41
- kinemotion/{drop_jump → dropjump}/__init__.py +8 -2
- kinemotion/{drop_jump → dropjump}/analysis.py +120 -282
- kinemotion/{drop_jump → dropjump}/api.py +33 -59
- kinemotion/{drop_jump → dropjump}/cli.py +136 -70
- kinemotion/dropjump/debug_overlay.py +182 -0
- kinemotion/{drop_jump → dropjump}/kinematics.py +65 -175
- kinemotion/{drop_jump → dropjump}/metrics_validator.py +51 -25
- kinemotion/{drop_jump → dropjump}/validation_bounds.py +1 -1
- kinemotion/models/rtmpose-s_simcc-body7_pt-body7-halpe26_700e-256x192-7f134165_20230605.onnx +3 -0
- kinemotion/models/yolox_tiny_8xb8-300e_humanart-6f3252f9.onnx +3 -0
- {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/METADATA +26 -75
- kinemotion-2.0.0.dist-info/RECORD +49 -0
- kinemotion/core/overlay_constants.py +0 -61
- kinemotion/core/video_analysis_base.py +0 -132
- kinemotion/countermovement_jump/debug_overlay.py +0 -325
- kinemotion/drop_jump/debug_overlay.py +0 -241
- kinemotion/squat_jump/__init__.py +0 -5
- kinemotion/squat_jump/analysis.py +0 -377
- kinemotion/squat_jump/api.py +0 -610
- kinemotion/squat_jump/cli.py +0 -309
- kinemotion/squat_jump/debug_overlay.py +0 -163
- kinemotion/squat_jump/kinematics.py +0 -342
- kinemotion/squat_jump/metrics_validator.py +0 -438
- kinemotion/squat_jump/validation_bounds.py +0 -221
- kinemotion-0.76.3.dist-info/RECORD +0 -57
- /kinemotion/{countermovement_jump → cmj}/__init__.py +0 -0
- /kinemotion/{countermovement_jump → cmj}/kinematics.py +0 -0
- {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -9,6 +9,13 @@ of metric issues.
|
|
|
9
9
|
|
|
10
10
|
from dataclasses import dataclass
|
|
11
11
|
|
|
12
|
+
from kinemotion.cmj.validation_bounds import (
|
|
13
|
+
CMJBounds,
|
|
14
|
+
MetricConsistency,
|
|
15
|
+
RSIBounds,
|
|
16
|
+
TripleExtensionBounds,
|
|
17
|
+
estimate_athlete_profile,
|
|
18
|
+
)
|
|
12
19
|
from kinemotion.core.types import MetricsDict
|
|
13
20
|
from kinemotion.core.validation import (
|
|
14
21
|
AthleteProfile,
|
|
@@ -16,13 +23,6 @@ from kinemotion.core.validation import (
|
|
|
16
23
|
MetricsValidator,
|
|
17
24
|
ValidationResult,
|
|
18
25
|
)
|
|
19
|
-
from kinemotion.countermovement_jump.validation_bounds import (
|
|
20
|
-
CMJBounds,
|
|
21
|
-
MetricConsistency,
|
|
22
|
-
RSIBounds,
|
|
23
|
-
TripleExtensionBounds,
|
|
24
|
-
estimate_athlete_profile,
|
|
25
|
-
)
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
@dataclass
|
|
@@ -81,23 +81,6 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
81
81
|
"""
|
|
82
82
|
return data.get(key_with_suffix) or data.get(key_without_suffix)
|
|
83
83
|
|
|
84
|
-
@staticmethod
|
|
85
|
-
def _convert_raw_duration_to_seconds(value_raw: float) -> float:
|
|
86
|
-
"""Convert raw duration value to seconds.
|
|
87
|
-
|
|
88
|
-
Handles legacy values that may be in seconds (<10) vs milliseconds (>10).
|
|
89
|
-
This heuristic works because no CMJ duration metric is between 10ms and 10s.
|
|
90
|
-
|
|
91
|
-
Args:
|
|
92
|
-
value_raw: Raw duration value (may be seconds or milliseconds)
|
|
93
|
-
|
|
94
|
-
Returns:
|
|
95
|
-
Duration in seconds
|
|
96
|
-
"""
|
|
97
|
-
if value_raw < 10: # Likely in seconds
|
|
98
|
-
return value_raw
|
|
99
|
-
return value_raw / 1000.0
|
|
100
|
-
|
|
101
84
|
def validate(self, metrics: MetricsDict) -> CMJValidationResult:
|
|
102
85
|
"""Validate CMJ metrics comprehensively.
|
|
103
86
|
|
|
@@ -124,8 +107,8 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
124
107
|
self._check_flight_time(data, result, profile)
|
|
125
108
|
self._check_jump_height(data, result, profile)
|
|
126
109
|
self._check_countermovement_depth(data, result, profile)
|
|
127
|
-
self._check_concentric_duration(data, result)
|
|
128
|
-
self._check_eccentric_duration(data, result)
|
|
110
|
+
self._check_concentric_duration(data, result, profile)
|
|
111
|
+
self._check_eccentric_duration(data, result, profile)
|
|
129
112
|
self._check_peak_velocities(data, result, profile)
|
|
130
113
|
|
|
131
114
|
# CROSS-VALIDATION CHECKS
|
|
@@ -153,23 +136,45 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
153
136
|
if flight_time_raw is None:
|
|
154
137
|
return
|
|
155
138
|
|
|
156
|
-
|
|
139
|
+
# If value is in seconds (legacy), use as-is; if in ms, convert
|
|
140
|
+
if flight_time_raw < 10: # Likely in seconds
|
|
141
|
+
flight_time = flight_time_raw
|
|
142
|
+
else: # In milliseconds
|
|
143
|
+
flight_time = flight_time_raw / 1000.0
|
|
144
|
+
|
|
157
145
|
bounds = CMJBounds.FLIGHT_TIME
|
|
158
|
-
error_label = (
|
|
159
|
-
"below frame rate resolution limit"
|
|
160
|
-
if flight_time < bounds.absolute_min
|
|
161
|
-
else "exceeds elite human capability"
|
|
162
|
-
)
|
|
163
146
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
147
|
+
if not bounds.is_physically_possible(flight_time):
|
|
148
|
+
if flight_time < bounds.absolute_min:
|
|
149
|
+
result.add_error(
|
|
150
|
+
"flight_time",
|
|
151
|
+
f"Flight time {flight_time:.3f}s below frame rate resolution limit",
|
|
152
|
+
value=flight_time,
|
|
153
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
result.add_error(
|
|
157
|
+
"flight_time",
|
|
158
|
+
f"Flight time {flight_time:.3f}s exceeds elite human capability",
|
|
159
|
+
value=flight_time,
|
|
160
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
161
|
+
)
|
|
162
|
+
elif bounds.contains(flight_time, profile):
|
|
163
|
+
result.add_info(
|
|
164
|
+
"flight_time",
|
|
165
|
+
f"Flight time {flight_time:.3f}s within expected range for {profile.value}",
|
|
166
|
+
value=flight_time,
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
# Outside expected range but physically possible
|
|
170
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
171
|
+
result.add_warning(
|
|
172
|
+
"flight_time",
|
|
173
|
+
f"Flight time {flight_time:.3f}s outside typical range "
|
|
174
|
+
f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
|
|
175
|
+
value=flight_time,
|
|
176
|
+
bounds=(expected_min, expected_max),
|
|
177
|
+
)
|
|
173
178
|
|
|
174
179
|
def _check_jump_height(
|
|
175
180
|
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
@@ -180,21 +185,37 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
180
185
|
return
|
|
181
186
|
|
|
182
187
|
bounds = CMJBounds.JUMP_HEIGHT
|
|
183
|
-
error_label = (
|
|
184
|
-
"essentially no jump (noise)"
|
|
185
|
-
if jump_height < bounds.absolute_min
|
|
186
|
-
else "exceeds human capability"
|
|
187
|
-
)
|
|
188
188
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
189
|
+
if not bounds.is_physically_possible(jump_height):
|
|
190
|
+
if jump_height < bounds.absolute_min:
|
|
191
|
+
result.add_error(
|
|
192
|
+
"jump_height",
|
|
193
|
+
f"Jump height {jump_height:.3f}m essentially no jump (noise)",
|
|
194
|
+
value=jump_height,
|
|
195
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
result.add_error(
|
|
199
|
+
"jump_height",
|
|
200
|
+
f"Jump height {jump_height:.3f}m exceeds human capability",
|
|
201
|
+
value=jump_height,
|
|
202
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
203
|
+
)
|
|
204
|
+
elif bounds.contains(jump_height, profile):
|
|
205
|
+
result.add_info(
|
|
206
|
+
"jump_height",
|
|
207
|
+
f"Jump height {jump_height:.3f}m within expected range for {profile.value}",
|
|
208
|
+
value=jump_height,
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
212
|
+
result.add_warning(
|
|
213
|
+
"jump_height",
|
|
214
|
+
f"Jump height {jump_height:.3f}m outside typical range "
|
|
215
|
+
f"[{expected_min:.3f}-{expected_max:.3f}]m for {profile.value}",
|
|
216
|
+
value=jump_height,
|
|
217
|
+
bounds=(expected_min, expected_max),
|
|
218
|
+
)
|
|
198
219
|
|
|
199
220
|
def _check_countermovement_depth(
|
|
200
221
|
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
@@ -205,22 +226,40 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
205
226
|
return
|
|
206
227
|
|
|
207
228
|
bounds = CMJBounds.COUNTERMOVEMENT_DEPTH
|
|
208
|
-
error_label = (
|
|
209
|
-
"essentially no squat" if depth < bounds.absolute_min else "exceeds physical limit"
|
|
210
|
-
)
|
|
211
229
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
230
|
+
if not bounds.is_physically_possible(depth):
|
|
231
|
+
if depth < bounds.absolute_min:
|
|
232
|
+
result.add_error(
|
|
233
|
+
"countermovement_depth",
|
|
234
|
+
f"Countermovement depth {depth:.3f}m essentially no squat",
|
|
235
|
+
value=depth,
|
|
236
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
result.add_error(
|
|
240
|
+
"countermovement_depth",
|
|
241
|
+
f"Countermovement depth {depth:.3f}m exceeds physical limit",
|
|
242
|
+
value=depth,
|
|
243
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
244
|
+
)
|
|
245
|
+
elif bounds.contains(depth, profile):
|
|
246
|
+
result.add_info(
|
|
247
|
+
"countermovement_depth",
|
|
248
|
+
f"Countermovement depth {depth:.3f}m within expected range for {profile.value}",
|
|
249
|
+
value=depth,
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
253
|
+
result.add_warning(
|
|
254
|
+
"countermovement_depth",
|
|
255
|
+
f"Countermovement depth {depth:.3f}m outside typical range "
|
|
256
|
+
f"[{expected_min:.3f}-{expected_max:.3f}]m for {profile.value}",
|
|
257
|
+
value=depth,
|
|
258
|
+
bounds=(expected_min, expected_max),
|
|
259
|
+
)
|
|
221
260
|
|
|
222
261
|
def _check_concentric_duration(
|
|
223
|
-
self, metrics: MetricsDict, result: CMJValidationResult
|
|
262
|
+
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
224
263
|
) -> None:
|
|
225
264
|
"""Validate concentric duration (contact time)."""
|
|
226
265
|
duration_raw = self._get_metric_value(
|
|
@@ -229,7 +268,13 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
229
268
|
if duration_raw is None:
|
|
230
269
|
return
|
|
231
270
|
|
|
232
|
-
|
|
271
|
+
# If value is in seconds (legacy), convert to ms first
|
|
272
|
+
# Values >10 are assumed to be in ms, <10 assumed to be in seconds
|
|
273
|
+
if duration_raw < 10: # Likely in seconds
|
|
274
|
+
duration = duration_raw
|
|
275
|
+
else: # In milliseconds
|
|
276
|
+
duration = duration_raw / 1000.0
|
|
277
|
+
|
|
233
278
|
bounds = CMJBounds.CONCENTRIC_DURATION
|
|
234
279
|
|
|
235
280
|
if not bounds.is_physically_possible(duration):
|
|
@@ -256,7 +301,9 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
256
301
|
value=duration,
|
|
257
302
|
)
|
|
258
303
|
|
|
259
|
-
def _check_eccentric_duration(
|
|
304
|
+
def _check_eccentric_duration(
|
|
305
|
+
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
306
|
+
) -> None:
|
|
260
307
|
"""Validate eccentric duration."""
|
|
261
308
|
duration_raw = self._get_metric_value(
|
|
262
309
|
metrics, "eccentric_duration_ms", "eccentric_duration"
|
|
@@ -264,7 +311,12 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
264
311
|
if duration_raw is None:
|
|
265
312
|
return
|
|
266
313
|
|
|
267
|
-
|
|
314
|
+
# If value is in seconds (legacy), use as-is; if in ms, convert
|
|
315
|
+
if duration_raw < 10: # Likely in seconds
|
|
316
|
+
duration = duration_raw
|
|
317
|
+
else: # In milliseconds
|
|
318
|
+
duration = duration_raw / 1000.0
|
|
319
|
+
|
|
268
320
|
bounds = CMJBounds.ECCENTRIC_DURATION
|
|
269
321
|
|
|
270
322
|
if not bounds.is_physically_possible(duration):
|
|
@@ -287,74 +339,71 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
287
339
|
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
288
340
|
) -> None:
|
|
289
341
|
"""Validate peak eccentric and concentric velocities."""
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
342
|
+
# Eccentric
|
|
343
|
+
ecc_vel = self._get_metric_value(
|
|
344
|
+
metrics, "peak_eccentric_velocity_m_s", "peak_eccentric_velocity"
|
|
345
|
+
)
|
|
346
|
+
if ecc_vel is not None:
|
|
347
|
+
bounds = CMJBounds.PEAK_ECCENTRIC_VELOCITY
|
|
348
|
+
if not bounds.is_physically_possible(ecc_vel):
|
|
349
|
+
result.add_error(
|
|
350
|
+
"peak_eccentric_velocity",
|
|
351
|
+
f"Peak eccentric velocity {ecc_vel:.2f} m/s outside limits",
|
|
352
|
+
value=ecc_vel,
|
|
353
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
354
|
+
)
|
|
355
|
+
elif bounds.contains(ecc_vel, profile):
|
|
356
|
+
result.add_info(
|
|
357
|
+
"peak_eccentric_velocity",
|
|
358
|
+
f"Peak eccentric velocity {ecc_vel:.2f} m/s within range for {profile.value}",
|
|
359
|
+
value=ecc_vel,
|
|
360
|
+
)
|
|
361
|
+
else:
|
|
362
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
363
|
+
result.add_warning(
|
|
364
|
+
"peak_eccentric_velocity",
|
|
365
|
+
f"Peak eccentric velocity {ecc_vel:.2f} m/s outside typical range "
|
|
366
|
+
f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
|
|
367
|
+
value=ecc_vel,
|
|
368
|
+
bounds=(expected_min, expected_max),
|
|
369
|
+
)
|
|
313
370
|
|
|
314
|
-
|
|
315
|
-
self
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
371
|
+
# Concentric
|
|
372
|
+
con_vel = self._get_metric_value(
|
|
373
|
+
metrics, "peak_concentric_velocity_m_s", "peak_concentric_velocity"
|
|
374
|
+
)
|
|
375
|
+
if con_vel is not None:
|
|
376
|
+
bounds = CMJBounds.PEAK_CONCENTRIC_VELOCITY
|
|
377
|
+
if not bounds.is_physically_possible(con_vel):
|
|
378
|
+
if con_vel < bounds.absolute_min:
|
|
379
|
+
result.add_error(
|
|
380
|
+
"peak_concentric_velocity",
|
|
381
|
+
f"Peak concentric velocity {con_vel:.2f} m/s insufficient to leave ground",
|
|
382
|
+
value=con_vel,
|
|
383
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
384
|
+
)
|
|
385
|
+
else:
|
|
386
|
+
result.add_error(
|
|
387
|
+
"peak_concentric_velocity",
|
|
388
|
+
f"Peak concentric velocity {con_vel:.2f} m/s exceeds elite capability",
|
|
389
|
+
value=con_vel,
|
|
390
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
391
|
+
)
|
|
392
|
+
elif bounds.contains(con_vel, profile):
|
|
393
|
+
result.add_info(
|
|
394
|
+
"peak_concentric_velocity",
|
|
395
|
+
f"Peak concentric velocity {con_vel:.2f} m/s within range for {profile.value}",
|
|
396
|
+
value=con_vel,
|
|
328
397
|
)
|
|
329
398
|
else:
|
|
330
|
-
|
|
331
|
-
|
|
399
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
400
|
+
result.add_warning(
|
|
401
|
+
"peak_concentric_velocity",
|
|
402
|
+
f"Peak concentric velocity {con_vel:.2f} m/s outside typical range "
|
|
403
|
+
f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
|
|
404
|
+
value=con_vel,
|
|
405
|
+
bounds=(expected_min, expected_max),
|
|
332
406
|
)
|
|
333
|
-
result.add_error(
|
|
334
|
-
name,
|
|
335
|
-
error_msg,
|
|
336
|
-
value=velocity,
|
|
337
|
-
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
338
|
-
)
|
|
339
|
-
elif bounds.contains(velocity, profile):
|
|
340
|
-
velocity_type = name.replace("peak_", "").replace("_", " ")
|
|
341
|
-
result.add_info(
|
|
342
|
-
name,
|
|
343
|
-
f"Peak {velocity_type} velocity {velocity:.2f} m/s "
|
|
344
|
-
f"within range for {profile.value}",
|
|
345
|
-
value=velocity,
|
|
346
|
-
)
|
|
347
|
-
else:
|
|
348
|
-
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
349
|
-
velocity_type = name.replace("peak_", "").replace("_", " ")
|
|
350
|
-
result.add_warning(
|
|
351
|
-
name,
|
|
352
|
-
f"Peak {velocity_type} velocity {velocity:.2f} m/s "
|
|
353
|
-
f"outside typical range [{expected_min:.2f}-{expected_max:.2f}] "
|
|
354
|
-
f"for {profile.value}",
|
|
355
|
-
value=velocity,
|
|
356
|
-
bounds=(expected_min, expected_max),
|
|
357
|
-
)
|
|
358
407
|
|
|
359
408
|
def _check_flight_time_height_consistency(
|
|
360
409
|
self, metrics: MetricsDict, result: CMJValidationResult
|
|
@@ -444,8 +493,16 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
444
493
|
):
|
|
445
494
|
return
|
|
446
495
|
|
|
447
|
-
|
|
448
|
-
|
|
496
|
+
# Convert to seconds if needed
|
|
497
|
+
if flight_time_raw < 10: # Likely in seconds
|
|
498
|
+
flight_time = flight_time_raw
|
|
499
|
+
else: # In milliseconds
|
|
500
|
+
flight_time = flight_time_raw / 1000.0
|
|
501
|
+
|
|
502
|
+
if concentric_duration_raw < 10: # Likely in seconds
|
|
503
|
+
concentric_duration = concentric_duration_raw
|
|
504
|
+
else: # In milliseconds
|
|
505
|
+
concentric_duration = concentric_duration_raw / 1000.0
|
|
449
506
|
|
|
450
507
|
rsi = flight_time / concentric_duration
|
|
451
508
|
result.rsi = rsi
|
|
@@ -575,28 +632,49 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
575
632
|
if angles is None:
|
|
576
633
|
return
|
|
577
634
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
635
|
+
hip = angles.get("hip_angle")
|
|
636
|
+
if hip is not None:
|
|
637
|
+
if not TripleExtensionBounds.hip_angle_valid(hip, profile):
|
|
638
|
+
result.add_warning(
|
|
639
|
+
"hip_angle",
|
|
640
|
+
f"Hip angle {hip:.1f}° outside expected range for {profile.value}",
|
|
641
|
+
value=hip,
|
|
642
|
+
)
|
|
643
|
+
else:
|
|
644
|
+
result.add_info(
|
|
645
|
+
"hip_angle",
|
|
646
|
+
f"Hip angle {hip:.1f}° within expected range for {profile.value}",
|
|
647
|
+
value=hip,
|
|
648
|
+
)
|
|
583
649
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
if
|
|
587
|
-
|
|
650
|
+
knee = angles.get("knee_angle")
|
|
651
|
+
if knee is not None:
|
|
652
|
+
if not TripleExtensionBounds.knee_angle_valid(knee, profile):
|
|
653
|
+
result.add_warning(
|
|
654
|
+
"knee_angle",
|
|
655
|
+
f"Knee angle {knee:.1f}° outside expected range for {profile.value}",
|
|
656
|
+
value=knee,
|
|
657
|
+
)
|
|
658
|
+
else:
|
|
659
|
+
result.add_info(
|
|
660
|
+
"knee_angle",
|
|
661
|
+
f"Knee angle {knee:.1f}° within expected range for {profile.value}",
|
|
662
|
+
value=knee,
|
|
663
|
+
)
|
|
588
664
|
|
|
589
|
-
|
|
665
|
+
ankle = angles.get("ankle_angle")
|
|
666
|
+
if ankle is not None:
|
|
667
|
+
if not TripleExtensionBounds.ankle_angle_valid(ankle, profile):
|
|
590
668
|
result.add_warning(
|
|
591
|
-
|
|
592
|
-
f"
|
|
593
|
-
value=
|
|
669
|
+
"ankle_angle",
|
|
670
|
+
f"Ankle angle {ankle:.1f}° outside expected range for {profile.value}",
|
|
671
|
+
value=ankle,
|
|
594
672
|
)
|
|
595
673
|
else:
|
|
596
674
|
result.add_info(
|
|
597
|
-
|
|
598
|
-
f"
|
|
599
|
-
value=
|
|
675
|
+
"ankle_angle",
|
|
676
|
+
f"Ankle angle {ankle:.1f}° within expected range for {profile.value}",
|
|
677
|
+
value=ankle,
|
|
600
678
|
)
|
|
601
679
|
|
|
602
680
|
# Detect joint compensation patterns
|
|
@@ -620,32 +698,32 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
620
698
|
if hip is None or knee is None or ankle is None:
|
|
621
699
|
return # Need all three to detect patterns
|
|
622
700
|
|
|
623
|
-
#
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
701
|
+
# Get profile-specific bounds
|
|
702
|
+
if profile == AthleteProfile.ELDERLY:
|
|
703
|
+
hip_min, hip_max = 150, 175
|
|
704
|
+
knee_min, knee_max = 155, 175
|
|
705
|
+
ankle_min, ankle_max = 100, 125
|
|
706
|
+
elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
|
|
707
|
+
hip_min, hip_max = 160, 180
|
|
708
|
+
knee_min, knee_max = 165, 182
|
|
709
|
+
ankle_min, ankle_max = 110, 140
|
|
710
|
+
elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
|
|
711
|
+
hip_min, hip_max = 170, 185
|
|
712
|
+
knee_min, knee_max = 173, 190
|
|
713
|
+
ankle_min, ankle_max = 125, 155
|
|
714
|
+
else:
|
|
634
715
|
return
|
|
635
716
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
# Count joints at boundaries
|
|
717
|
+
# Count how many joints are near their boundaries
|
|
718
|
+
joints_at_boundary = 0
|
|
639
719
|
boundary_threshold = 3.0 # degrees from limit
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
if val <= min_val + boundary_threshold or val >= max_val - boundary_threshold
|
|
648
|
-
)
|
|
720
|
+
|
|
721
|
+
if hip <= hip_min + boundary_threshold or hip >= hip_max - boundary_threshold:
|
|
722
|
+
joints_at_boundary += 1
|
|
723
|
+
if knee <= knee_min + boundary_threshold or knee >= knee_max - boundary_threshold:
|
|
724
|
+
joints_at_boundary += 1
|
|
725
|
+
if ankle <= ankle_min + boundary_threshold or ankle >= ankle_max - boundary_threshold:
|
|
726
|
+
joints_at_boundary += 1
|
|
649
727
|
|
|
650
728
|
# If 2+ joints at boundaries, likely compensation pattern
|
|
651
729
|
if joints_at_boundary >= 2:
|
|
@@ -656,3 +734,20 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
656
734
|
f"May indicate compensatory movement pattern.",
|
|
657
735
|
value=float(joints_at_boundary),
|
|
658
736
|
)
|
|
737
|
+
|
|
738
|
+
@staticmethod
|
|
739
|
+
def _get_profile_range(profile: AthleteProfile, bounds: MetricBounds) -> tuple[float, float]:
|
|
740
|
+
"""Get min/max bounds for specific profile."""
|
|
741
|
+
if profile == AthleteProfile.ELDERLY:
|
|
742
|
+
return (bounds.practical_min, bounds.recreational_max)
|
|
743
|
+
elif profile == AthleteProfile.UNTRAINED:
|
|
744
|
+
return (bounds.practical_min, bounds.recreational_max)
|
|
745
|
+
elif profile == AthleteProfile.RECREATIONAL:
|
|
746
|
+
return (bounds.recreational_min, bounds.recreational_max)
|
|
747
|
+
elif profile == AthleteProfile.TRAINED:
|
|
748
|
+
trained_min = (bounds.recreational_min + bounds.elite_min) / 2
|
|
749
|
+
trained_max = (bounds.recreational_max + bounds.elite_max) / 2
|
|
750
|
+
return (trained_min, trained_max)
|
|
751
|
+
elif profile == AthleteProfile.ELITE:
|
|
752
|
+
return (bounds.elite_min, bounds.elite_max)
|
|
753
|
+
return (bounds.absolute_min, bounds.absolute_max)
|
|
@@ -170,6 +170,17 @@ class TripleExtensionBounds:
|
|
|
170
170
|
return 125 <= angle <= 155
|
|
171
171
|
return True
|
|
172
172
|
|
|
173
|
+
# TRUNK TILT (forward lean from vertical, degrees)
|
|
174
|
+
@staticmethod
|
|
175
|
+
def trunk_tilt_valid(angle: float | None, profile: AthleteProfile) -> bool:
|
|
176
|
+
"""Check if trunk tilt is valid for profile."""
|
|
177
|
+
if angle is None:
|
|
178
|
+
return True
|
|
179
|
+
if angle < -15 or angle > 60:
|
|
180
|
+
return False # Outside reasonable range
|
|
181
|
+
# Most athletes show 10-30° forward lean during takeoff
|
|
182
|
+
return -10 <= angle <= 45
|
|
183
|
+
|
|
173
184
|
|
|
174
185
|
class RSIBounds:
|
|
175
186
|
"""Reactive Strength Index bounds."""
|
|
@@ -204,6 +215,12 @@ class RSIBounds:
|
|
|
204
215
|
"""Check if RSI is within physiological bounds."""
|
|
205
216
|
return RSIBounds.MIN_VALID <= rsi <= RSIBounds.MAX_VALID
|
|
206
217
|
|
|
218
|
+
@staticmethod
|
|
219
|
+
def in_range_for_profile(rsi: float, profile: AthleteProfile) -> bool:
|
|
220
|
+
"""Check if RSI is in expected range for profile."""
|
|
221
|
+
min_rsi, max_rsi = RSIBounds.get_rsi_range(profile)
|
|
222
|
+
return min_rsi <= rsi <= max_rsi
|
|
223
|
+
|
|
207
224
|
|
|
208
225
|
class MetricConsistency:
|
|
209
226
|
"""Cross-validation tolerance for metric consistency checks."""
|
|
@@ -284,7 +301,7 @@ ATHLETE_PROFILES = {
|
|
|
284
301
|
|
|
285
302
|
|
|
286
303
|
def estimate_athlete_profile(
|
|
287
|
-
metrics_dict: MetricsDict,
|
|
304
|
+
metrics_dict: MetricsDict, gender: str | None = None
|
|
288
305
|
) -> AthleteProfile:
|
|
289
306
|
"""Estimate athlete profile from metrics.
|
|
290
307
|
|
kinemotion/core/__init__.py
CHANGED
|
@@ -13,6 +13,7 @@ from .pose import (
|
|
|
13
13
|
MediaPipePoseTracker,
|
|
14
14
|
PoseTrackerFactory,
|
|
15
15
|
compute_center_of_mass,
|
|
16
|
+
get_tracker_info,
|
|
16
17
|
)
|
|
17
18
|
from .pose_landmarks import KINEMOTION_LANDMARKS, LANDMARK_INDICES
|
|
18
19
|
from .quality import (
|
|
@@ -34,24 +35,14 @@ from .timing import (
|
|
|
34
35
|
PerformanceTimer,
|
|
35
36
|
Timer,
|
|
36
37
|
)
|
|
37
|
-
from .video_analysis_base import (
|
|
38
|
-
AnalysisOverrides,
|
|
39
|
-
JumpAnalysisPipeline,
|
|
40
|
-
VideoAnalysisConfig,
|
|
41
|
-
VideoAnalysisResult,
|
|
42
|
-
)
|
|
43
38
|
from .video_io import VideoProcessor
|
|
44
39
|
|
|
45
40
|
__all__ = [
|
|
46
|
-
# Video Analysis Base
|
|
47
|
-
"AnalysisOverrides",
|
|
48
|
-
"JumpAnalysisPipeline",
|
|
49
|
-
"VideoAnalysisConfig",
|
|
50
|
-
"VideoAnalysisResult",
|
|
51
41
|
# Pose tracking
|
|
52
42
|
"MediaPipePoseTracker",
|
|
53
43
|
"PoseTrackerFactory",
|
|
54
44
|
"compute_center_of_mass",
|
|
45
|
+
"get_tracker_info",
|
|
55
46
|
"LANDMARK_INDICES",
|
|
56
47
|
"KINEMOTION_LANDMARKS",
|
|
57
48
|
"get_model_path",
|