kinemotion 0.73.0__py3-none-any.whl → 0.75.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/filtering.py +101 -45
- 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 +82 -37
- 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.75.0.dist-info}/METADATA +1 -1
- {kinemotion-0.73.0.dist-info → kinemotion-0.75.0.dist-info}/RECORD +22 -21
- {kinemotion-0.73.0.dist-info → kinemotion-0.75.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.73.0.dist-info → kinemotion-0.75.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.73.0.dist-info → kinemotion-0.75.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
@@ -314,6 +314,8 @@ def _assign_contact_states(
|
|
|
314
314
|
) -> list[ContactState]:
|
|
315
315
|
"""Assign contact states based on contact frames and visibility.
|
|
316
316
|
|
|
317
|
+
Vectorized implementation for 2-3x speedup over loop-based version.
|
|
318
|
+
|
|
317
319
|
Args:
|
|
318
320
|
n_frames: Total number of frames
|
|
319
321
|
contact_frames: Set of frames with confirmed contact
|
|
@@ -323,15 +325,33 @@ def _assign_contact_states(
|
|
|
323
325
|
Returns:
|
|
324
326
|
List of ContactState for each frame
|
|
325
327
|
"""
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
328
|
+
# Integer mapping for vectorized operations: IN_AIR=0, ON_GROUND=1, UNKNOWN=2
|
|
329
|
+
_state_order = [ContactState.IN_AIR, ContactState.ON_GROUND, ContactState.UNKNOWN]
|
|
330
|
+
|
|
331
|
+
# Initialize with IN_AIR (default)
|
|
332
|
+
states = np.zeros(n_frames, dtype=np.int8)
|
|
333
|
+
|
|
334
|
+
# Mark ON_GROUND where visibility is sufficient
|
|
335
|
+
if contact_frames:
|
|
336
|
+
contact_array = np.fromiter(contact_frames, dtype=int)
|
|
337
|
+
# Filter to valid indices only
|
|
338
|
+
valid_mask = (contact_array >= 0) & (contact_array < n_frames)
|
|
339
|
+
valid_contacts = contact_array[valid_mask]
|
|
340
|
+
|
|
341
|
+
# Only mark ON_GROUND for frames with good visibility
|
|
342
|
+
if visibilities is not None:
|
|
343
|
+
good_visibility = visibilities[valid_contacts] >= visibility_threshold
|
|
344
|
+
states[valid_contacts[good_visibility]] = 1
|
|
332
345
|
else:
|
|
333
|
-
states
|
|
334
|
-
|
|
346
|
+
states[valid_contacts] = 1
|
|
347
|
+
|
|
348
|
+
# Mark UNKNOWN last (highest priority - overrides ON_GROUND)
|
|
349
|
+
if visibilities is not None:
|
|
350
|
+
unknown_mask = visibilities < visibility_threshold
|
|
351
|
+
states[unknown_mask] = 2
|
|
352
|
+
|
|
353
|
+
# Convert integer indices back to ContactState
|
|
354
|
+
return [_state_order[s] for s in states]
|
|
335
355
|
|
|
336
356
|
|
|
337
357
|
def _compute_near_ground_mask(
|
|
@@ -677,6 +697,48 @@ def refine_transition_with_curvature(
|
|
|
677
697
|
return refined_frame
|
|
678
698
|
|
|
679
699
|
|
|
700
|
+
def _refine_phase_boundaries(
|
|
701
|
+
foot_positions: FloatArray,
|
|
702
|
+
start_frac: float,
|
|
703
|
+
end_frac: float,
|
|
704
|
+
start_type: str,
|
|
705
|
+
end_type: str,
|
|
706
|
+
smoothing_window: int,
|
|
707
|
+
polyorder: int,
|
|
708
|
+
) -> tuple[float, float]:
|
|
709
|
+
"""Refine phase boundary frames using curvature analysis.
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
foot_positions: Array of foot y-positions (normalized, 0-1)
|
|
713
|
+
start_frac: Start frame (fractional)
|
|
714
|
+
end_frac: End frame (fractional)
|
|
715
|
+
start_type: Transition type for start ("landing" or "takeoff")
|
|
716
|
+
end_type: Transition type for end ("landing" or "takeoff")
|
|
717
|
+
smoothing_window: Window size for acceleration computation
|
|
718
|
+
polyorder: Polynomial order for Savitzky-Golay filter
|
|
719
|
+
|
|
720
|
+
Returns:
|
|
721
|
+
Tuple of (refined_start, refined_end) fractional frame indices
|
|
722
|
+
"""
|
|
723
|
+
refined_start = refine_transition_with_curvature(
|
|
724
|
+
foot_positions,
|
|
725
|
+
start_frac,
|
|
726
|
+
start_type,
|
|
727
|
+
search_window=3,
|
|
728
|
+
smoothing_window=smoothing_window,
|
|
729
|
+
polyorder=polyorder,
|
|
730
|
+
)
|
|
731
|
+
refined_end = refine_transition_with_curvature(
|
|
732
|
+
foot_positions,
|
|
733
|
+
end_frac,
|
|
734
|
+
end_type,
|
|
735
|
+
search_window=3,
|
|
736
|
+
smoothing_window=smoothing_window,
|
|
737
|
+
polyorder=polyorder,
|
|
738
|
+
)
|
|
739
|
+
return refined_start, refined_end
|
|
740
|
+
|
|
741
|
+
|
|
680
742
|
def find_interpolated_phase_transitions_with_curvature(
|
|
681
743
|
foot_positions: FloatArray,
|
|
682
744
|
contact_states: list[ContactState],
|
|
@@ -716,47 +778,30 @@ def find_interpolated_phase_transitions_with_curvature(
|
|
|
716
778
|
refined_phases: list[tuple[float, float, ContactState]] = []
|
|
717
779
|
|
|
718
780
|
for start_frac, end_frac, state in interpolated_phases:
|
|
719
|
-
refined_start = start_frac
|
|
720
|
-
refined_end = end_frac
|
|
721
|
-
|
|
722
781
|
if state == ContactState.ON_GROUND:
|
|
723
|
-
#
|
|
724
|
-
refined_start =
|
|
782
|
+
# ON_GROUND: landing at start, takeoff at end
|
|
783
|
+
refined_start, refined_end = _refine_phase_boundaries(
|
|
725
784
|
foot_positions,
|
|
726
785
|
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
786
|
end_frac,
|
|
787
|
+
"landing",
|
|
736
788
|
"takeoff",
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
polyorder=polyorder,
|
|
789
|
+
smoothing_window,
|
|
790
|
+
polyorder,
|
|
740
791
|
)
|
|
741
|
-
|
|
742
792
|
elif state == ContactState.IN_AIR:
|
|
743
|
-
#
|
|
744
|
-
refined_start =
|
|
793
|
+
# IN_AIR: takeoff at start, landing at end
|
|
794
|
+
refined_start, refined_end = _refine_phase_boundaries(
|
|
745
795
|
foot_positions,
|
|
746
796
|
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
797
|
end_frac,
|
|
798
|
+
"takeoff",
|
|
755
799
|
"landing",
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
polyorder=polyorder,
|
|
800
|
+
smoothing_window,
|
|
801
|
+
polyorder,
|
|
759
802
|
)
|
|
803
|
+
else:
|
|
804
|
+
refined_start, refined_end = start_frac, end_frac
|
|
760
805
|
|
|
761
806
|
refined_phases.append((refined_start, refined_end, state))
|
|
762
807
|
|