kinemotion 0.73.0__py3-none-any.whl → 0.74.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/core/__init__.py +11 -0
- kinemotion/core/auto_tuning.py +74 -27
- kinemotion/core/cli_utils.py +74 -0
- kinemotion/core/quality.py +4 -6
- kinemotion/core/validation.py +70 -0
- kinemotion/core/video_analysis_base.py +132 -0
- kinemotion/core/video_io.py +27 -18
- kinemotion/countermovement_jump/analysis.py +0 -97
- kinemotion/countermovement_jump/api.py +37 -11
- kinemotion/countermovement_jump/cli.py +6 -46
- kinemotion/countermovement_jump/metrics_validator.py +143 -229
- kinemotion/drop_jump/analysis.py +54 -29
- kinemotion/drop_jump/api.py +46 -16
- kinemotion/drop_jump/cli.py +8 -58
- kinemotion/drop_jump/kinematics.py +98 -50
- kinemotion/drop_jump/metrics_validator.py +24 -50
- {kinemotion-0.73.0.dist-info → kinemotion-0.74.0.dist-info}/METADATA +1 -1
- {kinemotion-0.73.0.dist-info → kinemotion-0.74.0.dist-info}/RECORD +21 -20
- {kinemotion-0.73.0.dist-info → kinemotion-0.74.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.73.0.dist-info → kinemotion-0.74.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.73.0.dist-info → kinemotion-0.74.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -8,9 +8,12 @@ import click
|
|
|
8
8
|
|
|
9
9
|
from ..core.auto_tuning import QualityPreset
|
|
10
10
|
from ..core.cli_utils import (
|
|
11
|
+
batch_processing_options,
|
|
11
12
|
collect_video_files,
|
|
12
13
|
common_output_options,
|
|
13
14
|
generate_batch_output_paths,
|
|
15
|
+
quality_option,
|
|
16
|
+
verbose_option,
|
|
14
17
|
)
|
|
15
18
|
from .api import AnalysisOverrides, process_cmj_video
|
|
16
19
|
from .kinematics import CMJMetrics
|
|
@@ -59,52 +62,9 @@ def _process_batch_videos(
|
|
|
59
62
|
@click.command(name="cmj-analyze")
|
|
60
63
|
@click.argument("video_path", nargs=-1, type=click.Path(exists=False), required=True)
|
|
61
64
|
@common_output_options
|
|
62
|
-
@
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
default="balanced",
|
|
66
|
-
help=(
|
|
67
|
-
"Analysis quality preset: "
|
|
68
|
-
"fast (quick, less precise), "
|
|
69
|
-
"balanced (default, good for most cases), "
|
|
70
|
-
"accurate (research-grade, slower)"
|
|
71
|
-
),
|
|
72
|
-
show_default=True,
|
|
73
|
-
)
|
|
74
|
-
@click.option(
|
|
75
|
-
"--verbose",
|
|
76
|
-
"-v",
|
|
77
|
-
is_flag=True,
|
|
78
|
-
help="Show auto-selected parameters and analysis details",
|
|
79
|
-
)
|
|
80
|
-
# Batch processing options
|
|
81
|
-
@click.option(
|
|
82
|
-
"--batch",
|
|
83
|
-
is_flag=True,
|
|
84
|
-
help="Enable batch processing mode for multiple videos",
|
|
85
|
-
)
|
|
86
|
-
@click.option(
|
|
87
|
-
"--workers",
|
|
88
|
-
type=int,
|
|
89
|
-
default=4,
|
|
90
|
-
help="Number of parallel workers for batch processing (default: 4)",
|
|
91
|
-
show_default=True,
|
|
92
|
-
)
|
|
93
|
-
@click.option(
|
|
94
|
-
"--output-dir",
|
|
95
|
-
type=click.Path(),
|
|
96
|
-
help="Directory for debug video outputs (batch mode only)",
|
|
97
|
-
)
|
|
98
|
-
@click.option(
|
|
99
|
-
"--json-output-dir",
|
|
100
|
-
type=click.Path(),
|
|
101
|
-
help="Directory for JSON metrics outputs (batch mode only)",
|
|
102
|
-
)
|
|
103
|
-
@click.option(
|
|
104
|
-
"--csv-summary",
|
|
105
|
-
type=click.Path(),
|
|
106
|
-
help="Path for CSV summary export (batch mode only)",
|
|
107
|
-
)
|
|
65
|
+
@quality_option
|
|
66
|
+
@verbose_option
|
|
67
|
+
@batch_processing_options
|
|
108
68
|
# Expert parameters (hidden in help, but always available for advanced users)
|
|
109
69
|
@click.option(
|
|
110
70
|
"--smoothing-window",
|
|
@@ -155,38 +155,21 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
155
155
|
|
|
156
156
|
flight_time = self._convert_raw_duration_to_seconds(flight_time_raw)
|
|
157
157
|
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
|
+
)
|
|
158
163
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
result.add_error(
|
|
169
|
-
"flight_time",
|
|
170
|
-
f"Flight time {flight_time:.3f}s exceeds elite human capability",
|
|
171
|
-
value=flight_time,
|
|
172
|
-
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
173
|
-
)
|
|
174
|
-
elif bounds.contains(flight_time, profile):
|
|
175
|
-
result.add_info(
|
|
176
|
-
"flight_time",
|
|
177
|
-
f"Flight time {flight_time:.3f}s within expected range for {profile.value}",
|
|
178
|
-
value=flight_time,
|
|
179
|
-
)
|
|
180
|
-
else:
|
|
181
|
-
# Outside expected range but physically possible
|
|
182
|
-
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
183
|
-
result.add_warning(
|
|
184
|
-
"flight_time",
|
|
185
|
-
f"Flight time {flight_time:.3f}s outside typical range "
|
|
186
|
-
f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
|
|
187
|
-
value=flight_time,
|
|
188
|
-
bounds=(expected_min, expected_max),
|
|
189
|
-
)
|
|
164
|
+
self._validate_metric_with_bounds(
|
|
165
|
+
"flight_time",
|
|
166
|
+
flight_time,
|
|
167
|
+
bounds,
|
|
168
|
+
profile,
|
|
169
|
+
result,
|
|
170
|
+
error_suffix=error_label,
|
|
171
|
+
format_str="{value:.3f}s",
|
|
172
|
+
)
|
|
190
173
|
|
|
191
174
|
def _check_jump_height(
|
|
192
175
|
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
@@ -197,37 +180,21 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
197
180
|
return
|
|
198
181
|
|
|
199
182
|
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
|
+
)
|
|
200
188
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
result.add_error(
|
|
211
|
-
"jump_height",
|
|
212
|
-
f"Jump height {jump_height:.3f}m exceeds human capability",
|
|
213
|
-
value=jump_height,
|
|
214
|
-
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
215
|
-
)
|
|
216
|
-
elif bounds.contains(jump_height, profile):
|
|
217
|
-
result.add_info(
|
|
218
|
-
"jump_height",
|
|
219
|
-
f"Jump height {jump_height:.3f}m within expected range for {profile.value}",
|
|
220
|
-
value=jump_height,
|
|
221
|
-
)
|
|
222
|
-
else:
|
|
223
|
-
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
224
|
-
result.add_warning(
|
|
225
|
-
"jump_height",
|
|
226
|
-
f"Jump height {jump_height:.3f}m outside typical range "
|
|
227
|
-
f"[{expected_min:.3f}-{expected_max:.3f}]m for {profile.value}",
|
|
228
|
-
value=jump_height,
|
|
229
|
-
bounds=(expected_min, expected_max),
|
|
230
|
-
)
|
|
189
|
+
self._validate_metric_with_bounds(
|
|
190
|
+
"jump_height",
|
|
191
|
+
jump_height,
|
|
192
|
+
bounds,
|
|
193
|
+
profile,
|
|
194
|
+
result,
|
|
195
|
+
error_suffix=error_label,
|
|
196
|
+
format_str="{value:.3f}m",
|
|
197
|
+
)
|
|
231
198
|
|
|
232
199
|
def _check_countermovement_depth(
|
|
233
200
|
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
@@ -238,37 +205,19 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
238
205
|
return
|
|
239
206
|
|
|
240
207
|
bounds = CMJBounds.COUNTERMOVEMENT_DEPTH
|
|
208
|
+
error_label = (
|
|
209
|
+
"essentially no squat" if depth < bounds.absolute_min else "exceeds physical limit"
|
|
210
|
+
)
|
|
241
211
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
result.add_error(
|
|
252
|
-
"countermovement_depth",
|
|
253
|
-
f"Countermovement depth {depth:.3f}m exceeds physical limit",
|
|
254
|
-
value=depth,
|
|
255
|
-
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
256
|
-
)
|
|
257
|
-
elif bounds.contains(depth, profile):
|
|
258
|
-
result.add_info(
|
|
259
|
-
"countermovement_depth",
|
|
260
|
-
f"Countermovement depth {depth:.3f}m within expected range for {profile.value}",
|
|
261
|
-
value=depth,
|
|
262
|
-
)
|
|
263
|
-
else:
|
|
264
|
-
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
265
|
-
result.add_warning(
|
|
266
|
-
"countermovement_depth",
|
|
267
|
-
f"Countermovement depth {depth:.3f}m outside typical range "
|
|
268
|
-
f"[{expected_min:.3f}-{expected_max:.3f}]m for {profile.value}",
|
|
269
|
-
value=depth,
|
|
270
|
-
bounds=(expected_min, expected_max),
|
|
271
|
-
)
|
|
212
|
+
self._validate_metric_with_bounds(
|
|
213
|
+
"countermovement_depth",
|
|
214
|
+
depth,
|
|
215
|
+
bounds,
|
|
216
|
+
profile,
|
|
217
|
+
result,
|
|
218
|
+
error_suffix=error_label,
|
|
219
|
+
format_str="{value:.3f}m",
|
|
220
|
+
)
|
|
272
221
|
|
|
273
222
|
def _check_concentric_duration(
|
|
274
223
|
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
@@ -340,71 +289,74 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
340
289
|
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
341
290
|
) -> None:
|
|
342
291
|
"""Validate peak eccentric and concentric velocities."""
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
result
|
|
365
|
-
|
|
366
|
-
f"Peak eccentric velocity {ecc_vel:.2f} m/s outside typical range "
|
|
367
|
-
f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
|
|
368
|
-
value=ecc_vel,
|
|
369
|
-
bounds=(expected_min, expected_max),
|
|
370
|
-
)
|
|
292
|
+
velocity_checks = [
|
|
293
|
+
(
|
|
294
|
+
"peak_eccentric_velocity",
|
|
295
|
+
"peak_eccentric_velocity_m_s",
|
|
296
|
+
CMJBounds.PEAK_ECCENTRIC_VELOCITY,
|
|
297
|
+
"",
|
|
298
|
+
),
|
|
299
|
+
(
|
|
300
|
+
"peak_concentric_velocity",
|
|
301
|
+
"peak_concentric_velocity_m_s",
|
|
302
|
+
CMJBounds.PEAK_CONCENTRIC_VELOCITY,
|
|
303
|
+
"insufficient to leave ground",
|
|
304
|
+
),
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
for metric_name, key_name, bounds, error_suffix in velocity_checks:
|
|
308
|
+
velocity = self._get_metric_value(metrics, key_name, metric_name)
|
|
309
|
+
if velocity is None:
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
self._validate_velocity_metric(
|
|
313
|
+
metric_name, velocity, bounds, profile, result, error_suffix
|
|
314
|
+
)
|
|
371
315
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
)
|
|
386
|
-
else:
|
|
387
|
-
result.add_error(
|
|
388
|
-
"peak_concentric_velocity",
|
|
389
|
-
f"Peak concentric velocity {con_vel:.2f} m/s exceeds elite capability",
|
|
390
|
-
value=con_vel,
|
|
391
|
-
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
392
|
-
)
|
|
393
|
-
elif bounds.contains(con_vel, profile):
|
|
394
|
-
result.add_info(
|
|
395
|
-
"peak_concentric_velocity",
|
|
396
|
-
f"Peak concentric velocity {con_vel:.2f} m/s within range for {profile.value}",
|
|
397
|
-
value=con_vel,
|
|
316
|
+
def _validate_velocity_metric(
|
|
317
|
+
self,
|
|
318
|
+
name: str,
|
|
319
|
+
velocity: float,
|
|
320
|
+
bounds: MetricBounds,
|
|
321
|
+
profile: AthleteProfile,
|
|
322
|
+
result: CMJValidationResult,
|
|
323
|
+
error_suffix: str,
|
|
324
|
+
) -> None:
|
|
325
|
+
"""Validate a velocity metric against bounds."""
|
|
326
|
+
if not bounds.is_physically_possible(velocity):
|
|
327
|
+
if velocity < bounds.absolute_min and error_suffix:
|
|
328
|
+
error_msg = (
|
|
329
|
+
f"Peak {name.replace('peak_', '')} velocity {velocity:.2f} m/s {error_suffix}"
|
|
398
330
|
)
|
|
399
331
|
else:
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
"peak_concentric_velocity",
|
|
403
|
-
f"Peak concentric velocity {con_vel:.2f} m/s outside typical range "
|
|
404
|
-
f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
|
|
405
|
-
value=con_vel,
|
|
406
|
-
bounds=(expected_min, expected_max),
|
|
332
|
+
error_msg = (
|
|
333
|
+
f"Peak {name.replace('peak_', '')} velocity {velocity:.2f} m/s outside limits"
|
|
407
334
|
)
|
|
335
|
+
result.add_error(
|
|
336
|
+
name,
|
|
337
|
+
error_msg,
|
|
338
|
+
value=velocity,
|
|
339
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
340
|
+
)
|
|
341
|
+
elif bounds.contains(velocity, profile):
|
|
342
|
+
velocity_type = name.replace("peak_", "").replace("_", " ")
|
|
343
|
+
result.add_info(
|
|
344
|
+
name,
|
|
345
|
+
f"Peak {velocity_type} velocity {velocity:.2f} m/s "
|
|
346
|
+
f"within range for {profile.value}",
|
|
347
|
+
value=velocity,
|
|
348
|
+
)
|
|
349
|
+
else:
|
|
350
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
351
|
+
velocity_type = name.replace("peak_", "").replace("_", " ")
|
|
352
|
+
result.add_warning(
|
|
353
|
+
name,
|
|
354
|
+
f"Peak {velocity_type} velocity {velocity:.2f} m/s "
|
|
355
|
+
f"outside typical range [{expected_min:.2f}-{expected_max:.2f}] "
|
|
356
|
+
f"for {profile.value}",
|
|
357
|
+
value=velocity,
|
|
358
|
+
bounds=(expected_min, expected_max),
|
|
359
|
+
)
|
|
408
360
|
|
|
409
361
|
def _check_flight_time_height_consistency(
|
|
410
362
|
self, metrics: MetricsDict, result: CMJValidationResult
|
|
@@ -625,49 +577,28 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
625
577
|
if angles is None:
|
|
626
578
|
return
|
|
627
579
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
f"Hip angle {hip:.1f}° outside expected range for {profile.value}",
|
|
634
|
-
value=hip,
|
|
635
|
-
)
|
|
636
|
-
else:
|
|
637
|
-
result.add_info(
|
|
638
|
-
"hip_angle",
|
|
639
|
-
f"Hip angle {hip:.1f}° within expected range for {profile.value}",
|
|
640
|
-
value=hip,
|
|
641
|
-
)
|
|
580
|
+
joint_definitions = [
|
|
581
|
+
("hip_angle", TripleExtensionBounds.hip_angle_valid, "Hip"),
|
|
582
|
+
("knee_angle", TripleExtensionBounds.knee_angle_valid, "Knee"),
|
|
583
|
+
("ankle_angle", TripleExtensionBounds.ankle_angle_valid, "Ankle"),
|
|
584
|
+
]
|
|
642
585
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
if
|
|
646
|
-
|
|
647
|
-
"knee_angle",
|
|
648
|
-
f"Knee angle {knee:.1f}° outside expected range for {profile.value}",
|
|
649
|
-
value=knee,
|
|
650
|
-
)
|
|
651
|
-
else:
|
|
652
|
-
result.add_info(
|
|
653
|
-
"knee_angle",
|
|
654
|
-
f"Knee angle {knee:.1f}° within expected range for {profile.value}",
|
|
655
|
-
value=knee,
|
|
656
|
-
)
|
|
586
|
+
for metric_name, validator, joint_name in joint_definitions:
|
|
587
|
+
angle = angles.get(metric_name)
|
|
588
|
+
if angle is None:
|
|
589
|
+
continue
|
|
657
590
|
|
|
658
|
-
|
|
659
|
-
if ankle is not None:
|
|
660
|
-
if not TripleExtensionBounds.ankle_angle_valid(ankle, profile):
|
|
591
|
+
if not validator(angle, profile):
|
|
661
592
|
result.add_warning(
|
|
662
|
-
|
|
663
|
-
f"
|
|
664
|
-
value=
|
|
593
|
+
metric_name,
|
|
594
|
+
f"{joint_name} angle {angle:.1f}° outside expected range for {profile.value}",
|
|
595
|
+
value=angle,
|
|
665
596
|
)
|
|
666
597
|
else:
|
|
667
598
|
result.add_info(
|
|
668
|
-
|
|
669
|
-
f"
|
|
670
|
-
value=
|
|
599
|
+
metric_name,
|
|
600
|
+
f"{joint_name} angle {angle:.1f}° within expected range for {profile.value}",
|
|
601
|
+
value=angle,
|
|
671
602
|
)
|
|
672
603
|
|
|
673
604
|
# Detect joint compensation patterns
|
|
@@ -691,32 +622,32 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
691
622
|
if hip is None or knee is None or ankle is None:
|
|
692
623
|
return # Need all three to detect patterns
|
|
693
624
|
|
|
694
|
-
#
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
knee_min, knee_max = 173, 190
|
|
706
|
-
ankle_min, ankle_max = 125, 155
|
|
707
|
-
else:
|
|
625
|
+
# Profile-specific bounds lookup
|
|
626
|
+
profile_bounds = {
|
|
627
|
+
AthleteProfile.ELDERLY: (150, 175, 155, 175, 100, 125),
|
|
628
|
+
AthleteProfile.UNTRAINED: (160, 180, 165, 182, 110, 140),
|
|
629
|
+
AthleteProfile.RECREATIONAL: (160, 180, 165, 182, 110, 140),
|
|
630
|
+
AthleteProfile.TRAINED: (170, 185, 173, 190, 125, 155),
|
|
631
|
+
AthleteProfile.ELITE: (170, 185, 173, 190, 125, 155),
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
bounds_tuple = profile_bounds.get(profile)
|
|
635
|
+
if not bounds_tuple:
|
|
708
636
|
return
|
|
709
637
|
|
|
710
|
-
|
|
711
|
-
joints_at_boundary = 0
|
|
712
|
-
boundary_threshold = 3.0 # degrees from limit
|
|
638
|
+
hip_min, hip_max, knee_min, knee_max, ankle_min, ankle_max = bounds_tuple
|
|
713
639
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
640
|
+
# Count joints at boundaries
|
|
641
|
+
boundary_threshold = 3.0 # degrees from limit
|
|
642
|
+
joints_at_boundary = sum(
|
|
643
|
+
1
|
|
644
|
+
for val, min_val, max_val in [
|
|
645
|
+
(hip, hip_min, hip_max),
|
|
646
|
+
(knee, knee_min, knee_max),
|
|
647
|
+
(ankle, ankle_min, ankle_max),
|
|
648
|
+
]
|
|
649
|
+
if val <= min_val + boundary_threshold or val >= max_val - boundary_threshold
|
|
650
|
+
)
|
|
720
651
|
|
|
721
652
|
# If 2+ joints at boundaries, likely compensation pattern
|
|
722
653
|
if joints_at_boundary >= 2:
|
|
@@ -727,20 +658,3 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
727
658
|
f"May indicate compensatory movement pattern.",
|
|
728
659
|
value=float(joints_at_boundary),
|
|
729
660
|
)
|
|
730
|
-
|
|
731
|
-
@staticmethod
|
|
732
|
-
def _get_profile_range(profile: AthleteProfile, bounds: MetricBounds) -> tuple[float, float]:
|
|
733
|
-
"""Get min/max bounds for specific profile."""
|
|
734
|
-
if profile == AthleteProfile.ELDERLY:
|
|
735
|
-
return (bounds.practical_min, bounds.recreational_max)
|
|
736
|
-
elif profile == AthleteProfile.UNTRAINED:
|
|
737
|
-
return (bounds.practical_min, bounds.recreational_max)
|
|
738
|
-
elif profile == AthleteProfile.RECREATIONAL:
|
|
739
|
-
return (bounds.recreational_min, bounds.recreational_max)
|
|
740
|
-
elif profile == AthleteProfile.TRAINED:
|
|
741
|
-
trained_min = (bounds.recreational_min + bounds.elite_min) / 2
|
|
742
|
-
trained_max = (bounds.recreational_max + bounds.elite_max) / 2
|
|
743
|
-
return (trained_min, trained_max)
|
|
744
|
-
elif profile == AthleteProfile.ELITE:
|
|
745
|
-
return (bounds.elite_min, bounds.elite_max)
|
|
746
|
-
return (bounds.absolute_min, bounds.absolute_max)
|
kinemotion/drop_jump/analysis.py
CHANGED
|
@@ -677,6 +677,48 @@ def refine_transition_with_curvature(
|
|
|
677
677
|
return refined_frame
|
|
678
678
|
|
|
679
679
|
|
|
680
|
+
def _refine_phase_boundaries(
|
|
681
|
+
foot_positions: FloatArray,
|
|
682
|
+
start_frac: float,
|
|
683
|
+
end_frac: float,
|
|
684
|
+
start_type: str,
|
|
685
|
+
end_type: str,
|
|
686
|
+
smoothing_window: int,
|
|
687
|
+
polyorder: int,
|
|
688
|
+
) -> tuple[float, float]:
|
|
689
|
+
"""Refine phase boundary frames using curvature analysis.
|
|
690
|
+
|
|
691
|
+
Args:
|
|
692
|
+
foot_positions: Array of foot y-positions (normalized, 0-1)
|
|
693
|
+
start_frac: Start frame (fractional)
|
|
694
|
+
end_frac: End frame (fractional)
|
|
695
|
+
start_type: Transition type for start ("landing" or "takeoff")
|
|
696
|
+
end_type: Transition type for end ("landing" or "takeoff")
|
|
697
|
+
smoothing_window: Window size for acceleration computation
|
|
698
|
+
polyorder: Polynomial order for Savitzky-Golay filter
|
|
699
|
+
|
|
700
|
+
Returns:
|
|
701
|
+
Tuple of (refined_start, refined_end) fractional frame indices
|
|
702
|
+
"""
|
|
703
|
+
refined_start = refine_transition_with_curvature(
|
|
704
|
+
foot_positions,
|
|
705
|
+
start_frac,
|
|
706
|
+
start_type,
|
|
707
|
+
search_window=3,
|
|
708
|
+
smoothing_window=smoothing_window,
|
|
709
|
+
polyorder=polyorder,
|
|
710
|
+
)
|
|
711
|
+
refined_end = refine_transition_with_curvature(
|
|
712
|
+
foot_positions,
|
|
713
|
+
end_frac,
|
|
714
|
+
end_type,
|
|
715
|
+
search_window=3,
|
|
716
|
+
smoothing_window=smoothing_window,
|
|
717
|
+
polyorder=polyorder,
|
|
718
|
+
)
|
|
719
|
+
return refined_start, refined_end
|
|
720
|
+
|
|
721
|
+
|
|
680
722
|
def find_interpolated_phase_transitions_with_curvature(
|
|
681
723
|
foot_positions: FloatArray,
|
|
682
724
|
contact_states: list[ContactState],
|
|
@@ -716,47 +758,30 @@ def find_interpolated_phase_transitions_with_curvature(
|
|
|
716
758
|
refined_phases: list[tuple[float, float, ContactState]] = []
|
|
717
759
|
|
|
718
760
|
for start_frac, end_frac, state in interpolated_phases:
|
|
719
|
-
refined_start = start_frac
|
|
720
|
-
refined_end = end_frac
|
|
721
|
-
|
|
722
761
|
if state == ContactState.ON_GROUND:
|
|
723
|
-
#
|
|
724
|
-
refined_start =
|
|
762
|
+
# ON_GROUND: landing at start, takeoff at end
|
|
763
|
+
refined_start, refined_end = _refine_phase_boundaries(
|
|
725
764
|
foot_positions,
|
|
726
765
|
start_frac,
|
|
727
|
-
"landing",
|
|
728
|
-
search_window=3,
|
|
729
|
-
smoothing_window=smoothing_window,
|
|
730
|
-
polyorder=polyorder,
|
|
731
|
-
)
|
|
732
|
-
# Refine takeoff (end of ground contact)
|
|
733
|
-
refined_end = refine_transition_with_curvature(
|
|
734
|
-
foot_positions,
|
|
735
766
|
end_frac,
|
|
767
|
+
"landing",
|
|
736
768
|
"takeoff",
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
polyorder=polyorder,
|
|
769
|
+
smoothing_window,
|
|
770
|
+
polyorder,
|
|
740
771
|
)
|
|
741
|
-
|
|
742
772
|
elif state == ContactState.IN_AIR:
|
|
743
|
-
#
|
|
744
|
-
refined_start =
|
|
773
|
+
# IN_AIR: takeoff at start, landing at end
|
|
774
|
+
refined_start, refined_end = _refine_phase_boundaries(
|
|
745
775
|
foot_positions,
|
|
746
776
|
start_frac,
|
|
747
|
-
"takeoff",
|
|
748
|
-
search_window=3,
|
|
749
|
-
smoothing_window=smoothing_window,
|
|
750
|
-
polyorder=polyorder,
|
|
751
|
-
)
|
|
752
|
-
refined_end = refine_transition_with_curvature(
|
|
753
|
-
foot_positions,
|
|
754
777
|
end_frac,
|
|
778
|
+
"takeoff",
|
|
755
779
|
"landing",
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
polyorder=polyorder,
|
|
780
|
+
smoothing_window,
|
|
781
|
+
polyorder,
|
|
759
782
|
)
|
|
783
|
+
else:
|
|
784
|
+
refined_start, refined_end = start_frac, end_frac
|
|
760
785
|
|
|
761
786
|
refined_phases.append((refined_start, refined_end, state))
|
|
762
787
|
|