kinemotion 0.76.3__py3-none-any.whl → 1.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.

Files changed (53) hide show
  1. kinemotion/__init__.py +3 -18
  2. kinemotion/api.py +7 -27
  3. kinemotion/cli.py +2 -4
  4. kinemotion/{countermovement_jump → cmj}/analysis.py +158 -16
  5. kinemotion/{countermovement_jump → cmj}/api.py +18 -46
  6. kinemotion/{countermovement_jump → cmj}/cli.py +46 -6
  7. kinemotion/cmj/debug_overlay.py +457 -0
  8. kinemotion/{countermovement_jump → cmj}/joint_angles.py +31 -96
  9. kinemotion/{countermovement_jump → cmj}/metrics_validator.py +293 -184
  10. kinemotion/{countermovement_jump → cmj}/validation_bounds.py +18 -1
  11. kinemotion/core/__init__.py +2 -11
  12. kinemotion/core/auto_tuning.py +107 -149
  13. kinemotion/core/cli_utils.py +0 -74
  14. kinemotion/core/debug_overlay_utils.py +15 -142
  15. kinemotion/core/experimental.py +51 -55
  16. kinemotion/core/filtering.py +56 -116
  17. kinemotion/core/pipeline_utils.py +2 -2
  18. kinemotion/core/pose.py +98 -47
  19. kinemotion/core/quality.py +6 -4
  20. kinemotion/core/smoothing.py +51 -65
  21. kinemotion/core/types.py +0 -15
  22. kinemotion/core/validation.py +7 -76
  23. kinemotion/core/video_io.py +27 -41
  24. kinemotion/{drop_jump → dropjump}/__init__.py +8 -2
  25. kinemotion/{drop_jump → dropjump}/analysis.py +120 -282
  26. kinemotion/{drop_jump → dropjump}/api.py +33 -59
  27. kinemotion/{drop_jump → dropjump}/cli.py +136 -70
  28. kinemotion/dropjump/debug_overlay.py +182 -0
  29. kinemotion/{drop_jump → dropjump}/kinematics.py +65 -175
  30. kinemotion/{drop_jump → dropjump}/metrics_validator.py +51 -25
  31. kinemotion/{drop_jump → dropjump}/validation_bounds.py +1 -1
  32. kinemotion/models/rtmpose-s_simcc-body7_pt-body7-halpe26_700e-256x192-7f134165_20230605.onnx +3 -0
  33. kinemotion/models/yolox_tiny_8xb8-300e_humanart-6f3252f9.onnx +3 -0
  34. {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/METADATA +26 -75
  35. kinemotion-1.0.0.dist-info/RECORD +49 -0
  36. kinemotion/core/overlay_constants.py +0 -61
  37. kinemotion/core/video_analysis_base.py +0 -132
  38. kinemotion/countermovement_jump/debug_overlay.py +0 -325
  39. kinemotion/drop_jump/debug_overlay.py +0 -241
  40. kinemotion/squat_jump/__init__.py +0 -5
  41. kinemotion/squat_jump/analysis.py +0 -377
  42. kinemotion/squat_jump/api.py +0 -610
  43. kinemotion/squat_jump/cli.py +0 -309
  44. kinemotion/squat_jump/debug_overlay.py +0 -163
  45. kinemotion/squat_jump/kinematics.py +0 -342
  46. kinemotion/squat_jump/metrics_validator.py +0 -438
  47. kinemotion/squat_jump/validation_bounds.py +0 -221
  48. kinemotion-0.76.3.dist-info/RECORD +0 -57
  49. /kinemotion/{countermovement_jump → cmj}/__init__.py +0 -0
  50. /kinemotion/{countermovement_jump → cmj}/kinematics.py +0 -0
  51. {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/WHEEL +0 -0
  52. {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/entry_points.txt +0 -0
  53. {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -11,7 +11,7 @@ from ..core.smoothing import (
11
11
  interpolate_threshold_crossing,
12
12
  )
13
13
  from ..core.timing import NULL_TIMER, Timer
14
- from ..core.types import FOOT_KEYS, BoolArray, FloatArray
14
+ from ..core.types import BoolArray, FloatArray
15
15
 
16
16
 
17
17
  class ContactState(Enum):
@@ -27,7 +27,7 @@ class ContactState(Enum):
27
27
  remove_in="1.0.0",
28
28
  since="0.34.0",
29
29
  )
30
- def _calculate_adaptive_threshold(
30
+ def calculate_adaptive_threshold(
31
31
  positions: FloatArray,
32
32
  fps: float,
33
33
  baseline_duration: float = 3.0,
@@ -188,7 +188,7 @@ def _find_drop_from_baseline(
188
188
  return 0
189
189
 
190
190
 
191
- def _detect_drop_start(
191
+ def detect_drop_start(
192
192
  positions: FloatArray,
193
193
  fps: float,
194
194
  min_stationary_duration: float = 1.0,
@@ -314,8 +314,6 @@ 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
-
319
317
  Args:
320
318
  n_frames: Total number of frames
321
319
  contact_frames: Set of frames with confirmed contact
@@ -325,75 +323,15 @@ def _assign_contact_states(
325
323
  Returns:
326
324
  List of ContactState for each frame
327
325
  """
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
326
+ states = []
327
+ for i in range(n_frames):
328
+ if visibilities is not None and visibilities[i] < visibility_threshold:
329
+ states.append(ContactState.UNKNOWN)
330
+ elif i in contact_frames:
331
+ states.append(ContactState.ON_GROUND)
345
332
  else:
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]
355
-
356
-
357
- def _compute_near_ground_mask(
358
- foot_positions: FloatArray,
359
- height_tolerance: float = 0.35,
360
- ) -> BoolArray:
361
- """Compute mask for frames where feet are near ground level.
362
-
363
- Uses position-based filtering to identify frames near ground baseline.
364
- In normalized coordinates: y=1 is bottom (ground), y=0 is top.
365
-
366
- The ground baseline is established as the 90th percentile of positions,
367
- which represents the typical ground level while handling outliers.
368
-
369
- The tolerance is set at 35% of the position range by default, which is
370
- generous enough to capture the full reactive contact phase (where athletes
371
- maintain an athletic stance) while still filtering out the jump apex
372
- (where y is much lower than ground level).
373
-
374
- Args:
375
- foot_positions: Array of foot y-positions (normalized, 0-1)
376
- height_tolerance: Fraction of position range allowed above ground (default 35%)
377
-
378
- Returns:
379
- Boolean array where True indicates frame is near ground level
380
- """
381
- # Ground baseline: 90th percentile (where feet are typically on ground)
382
- # Using 90th instead of 95th to be less sensitive to final landing positions
383
- ground_baseline = float(np.percentile(foot_positions, 90))
384
-
385
- # Compute position range for tolerance calculation
386
- position_range = float(np.max(foot_positions) - np.min(foot_positions))
387
-
388
- # Minimum absolute tolerance to handle small movements
389
- min_tolerance = 0.03 # 3% of normalized range
390
-
391
- # Height tolerance: percentage of position range or minimum
392
- tolerance = max(position_range * height_tolerance, min_tolerance)
393
-
394
- # Frames are near ground if y >= ground_baseline - tolerance
395
- # (Remember: higher y = closer to ground in normalized coords)
396
- return foot_positions >= (ground_baseline - tolerance)
333
+ states.append(ContactState.IN_AIR)
334
+ return states
397
335
 
398
336
 
399
337
  def detect_ground_contact(
@@ -405,14 +343,13 @@ def detect_ground_contact(
405
343
  window_length: int = 5,
406
344
  polyorder: int = 2,
407
345
  timer: Timer | None = None,
408
- height_tolerance: float = 0.35,
409
346
  ) -> list[ContactState]:
410
347
  """
411
- Detect when feet are in contact with ground based on vertical motion AND position.
348
+ Detect when feet are in contact with ground based on vertical motion.
412
349
 
413
- Uses derivative-based velocity calculation via Savitzky-Golay filter for smooth,
414
- accurate velocity estimates. Additionally uses position-based filtering to prevent
415
- false ON_GROUND classification at jump apex where velocity approaches zero.
350
+ Uses derivative-based velocity calculation via Savitzky-Goyal filter for smooth,
351
+ accurate velocity estimates. This is consistent with the velocity calculation used
352
+ throughout the pipeline for sub-frame interpolation and curvature analysis.
416
353
 
417
354
  Args:
418
355
  foot_positions: Array of foot y-positions (normalized, 0-1, where 1 is bottom)
@@ -423,7 +360,6 @@ def detect_ground_contact(
423
360
  window_length: Window size for velocity derivative calculation (must be odd)
424
361
  polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
425
362
  timer: Optional Timer for measuring operations
426
- height_tolerance: Fraction of position range to allow above ground baseline (default 35%)
427
363
 
428
364
  Returns:
429
365
  List of ContactState for each frame
@@ -443,14 +379,6 @@ def detect_ground_contact(
443
379
  # Detect stationary frames based on velocity threshold
444
380
  is_stationary = np.abs(velocities) < velocity_threshold
445
381
 
446
- # Position-based filtering to prevent false ON_GROUND at jump apex
447
- # In normalized coords: y=1 is bottom (ground), y=0 is top
448
- # Ground baseline is the 95th percentile (handles outliers)
449
- is_near_ground = _compute_near_ground_mask(foot_positions, height_tolerance)
450
-
451
- # Both conditions must be true: low velocity AND near ground
452
- is_stationary = is_stationary & is_near_ground
453
-
454
382
  # Apply visibility filter
455
383
  is_stationary = _filter_stationary_with_visibility(
456
384
  is_stationary, visibilities, visibility_threshold
@@ -496,57 +424,6 @@ def find_contact_phases(
496
424
  return phases
497
425
 
498
426
 
499
- def _interpolate_phase_boundary(
500
- boundary_idx: int,
501
- state: ContactState,
502
- velocities: FloatArray,
503
- velocity_threshold: float,
504
- is_start: bool,
505
- ) -> float:
506
- """Interpolate phase boundary with sub-frame precision.
507
-
508
- Args:
509
- boundary_idx: Index of the boundary frame
510
- state: Contact state of the phase
511
- velocities: Velocity array
512
- velocity_threshold: Threshold value for crossing detection
513
- is_start: True for phase start, False for phase end
514
-
515
- Returns:
516
- Fractional frame index, or float(boundary_idx) if no interpolation.
517
- """
518
- n_velocities = len(velocities)
519
-
520
- if is_start:
521
- # For start boundary, look at velocity before and at the boundary
522
- if boundary_idx <= 0 or boundary_idx >= n_velocities:
523
- return float(boundary_idx)
524
- vel_before = velocities[boundary_idx - 1]
525
- vel_at = velocities[boundary_idx]
526
- # Check threshold crossing based on state
527
- is_crossing = (
528
- state == ContactState.ON_GROUND and vel_before > velocity_threshold > vel_at
529
- ) or (state == ContactState.IN_AIR and vel_before < velocity_threshold < vel_at)
530
- if is_crossing:
531
- offset = interpolate_threshold_crossing(vel_before, vel_at, velocity_threshold)
532
- return (boundary_idx - 1) + offset
533
- return float(boundary_idx)
534
-
535
- # For end boundary, look at velocity at and after the boundary
536
- if boundary_idx + 1 >= n_velocities:
537
- return float(boundary_idx)
538
- vel_at = velocities[boundary_idx]
539
- vel_after = velocities[boundary_idx + 1]
540
- # Check threshold crossing based on state
541
- is_crossing = (
542
- state == ContactState.ON_GROUND and vel_at < velocity_threshold < vel_after
543
- ) or (state == ContactState.IN_AIR and vel_at > velocity_threshold > vel_after)
544
- if is_crossing:
545
- offset = interpolate_threshold_crossing(vel_at, vel_after, velocity_threshold)
546
- return boundary_idx + offset
547
- return float(boundary_idx)
548
-
549
-
550
427
  def _interpolate_phase_start(
551
428
  start_idx: int,
552
429
  state: ContactState,
@@ -558,9 +435,21 @@ def _interpolate_phase_start(
558
435
  Returns:
559
436
  Fractional start frame, or float(start_idx) if no interpolation.
560
437
  """
561
- return _interpolate_phase_boundary(
562
- start_idx, state, velocities, velocity_threshold, is_start=True
563
- )
438
+ if start_idx <= 0 or start_idx >= len(velocities):
439
+ return float(start_idx)
440
+
441
+ vel_before = velocities[start_idx - 1]
442
+ vel_at = velocities[start_idx]
443
+
444
+ # Check threshold crossing based on state
445
+ is_landing = state == ContactState.ON_GROUND and vel_before > velocity_threshold > vel_at
446
+ is_takeoff = state == ContactState.IN_AIR and vel_before < velocity_threshold < vel_at
447
+
448
+ if is_landing or is_takeoff:
449
+ offset = interpolate_threshold_crossing(vel_before, vel_at, velocity_threshold)
450
+ return (start_idx - 1) + offset
451
+
452
+ return float(start_idx)
564
453
 
565
454
 
566
455
  def _interpolate_phase_end(
@@ -568,19 +457,31 @@ def _interpolate_phase_end(
568
457
  state: ContactState,
569
458
  velocities: FloatArray,
570
459
  velocity_threshold: float,
571
- _max_idx: int,
460
+ max_idx: int,
572
461
  ) -> float:
573
462
  """Interpolate end boundary of a phase with sub-frame precision.
574
463
 
575
464
  Returns:
576
465
  Fractional end frame, or float(end_idx) if no interpolation.
577
466
  """
578
- return _interpolate_phase_boundary(
579
- end_idx, state, velocities, velocity_threshold, is_start=False
580
- )
467
+ if end_idx >= max_idx - 1 or end_idx + 1 >= len(velocities):
468
+ return float(end_idx)
469
+
470
+ vel_at = velocities[end_idx]
471
+ vel_after = velocities[end_idx + 1]
472
+
473
+ # Check threshold crossing based on state
474
+ is_takeoff = state == ContactState.ON_GROUND and vel_at < velocity_threshold < vel_after
475
+ is_landing = state == ContactState.IN_AIR and vel_at > velocity_threshold > vel_after
476
+
477
+ if is_takeoff or is_landing:
478
+ offset = interpolate_threshold_crossing(vel_at, vel_after, velocity_threshold)
479
+ return end_idx + offset
581
480
 
481
+ return float(end_idx)
582
482
 
583
- def _find_interpolated_phase_transitions(
483
+
484
+ def find_interpolated_phase_transitions(
584
485
  foot_positions: FloatArray,
585
486
  contact_states: list[ContactState],
586
487
  velocity_threshold: float,
@@ -622,7 +523,7 @@ def _find_interpolated_phase_transitions(
622
523
  return interpolated_phases
623
524
 
624
525
 
625
- def _refine_transition_with_curvature(
526
+ def refine_transition_with_curvature(
626
527
  foot_positions: FloatArray,
627
528
  estimated_frame: float,
628
529
  transition_type: str,
@@ -697,49 +598,7 @@ def _refine_transition_with_curvature(
697
598
  return refined_frame
698
599
 
699
600
 
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
-
742
- def _find_interpolated_phase_transitions_with_curvature(
601
+ def find_interpolated_phase_transitions_with_curvature(
743
602
  foot_positions: FloatArray,
744
603
  contact_states: list[ContactState],
745
604
  velocity_threshold: float,
@@ -767,7 +626,7 @@ def _find_interpolated_phase_transitions_with_curvature(
767
626
  List of (start_frame, end_frame, state) tuples with fractional frame indices
768
627
  """
769
628
  # Get interpolated phases using velocity
770
- interpolated_phases = _find_interpolated_phase_transitions(
629
+ interpolated_phases = find_interpolated_phase_transitions(
771
630
  foot_positions, contact_states, velocity_threshold, smoothing_window
772
631
  )
773
632
 
@@ -778,63 +637,77 @@ def _find_interpolated_phase_transitions_with_curvature(
778
637
  refined_phases: list[tuple[float, float, ContactState]] = []
779
638
 
780
639
  for start_frac, end_frac, state in interpolated_phases:
640
+ refined_start = start_frac
641
+ refined_end = end_frac
642
+
781
643
  if state == ContactState.ON_GROUND:
782
- # ON_GROUND: landing at start, takeoff at end
783
- refined_start, refined_end = _refine_phase_boundaries(
644
+ # Refine landing (start of ground contact)
645
+ refined_start = refine_transition_with_curvature(
784
646
  foot_positions,
785
647
  start_frac,
786
- end_frac,
787
648
  "landing",
649
+ search_window=3,
650
+ smoothing_window=smoothing_window,
651
+ polyorder=polyorder,
652
+ )
653
+ # Refine takeoff (end of ground contact)
654
+ refined_end = refine_transition_with_curvature(
655
+ foot_positions,
656
+ end_frac,
788
657
  "takeoff",
789
- smoothing_window,
790
- polyorder,
658
+ search_window=3,
659
+ smoothing_window=smoothing_window,
660
+ polyorder=polyorder,
791
661
  )
662
+
792
663
  elif state == ContactState.IN_AIR:
793
- # IN_AIR: takeoff at start, landing at end
794
- refined_start, refined_end = _refine_phase_boundaries(
664
+ # For flight phases, takeoff is at start, landing is at end
665
+ refined_start = refine_transition_with_curvature(
795
666
  foot_positions,
796
667
  start_frac,
797
- end_frac,
798
668
  "takeoff",
669
+ search_window=3,
670
+ smoothing_window=smoothing_window,
671
+ polyorder=polyorder,
672
+ )
673
+ refined_end = refine_transition_with_curvature(
674
+ foot_positions,
675
+ end_frac,
799
676
  "landing",
800
- smoothing_window,
801
- polyorder,
677
+ search_window=3,
678
+ smoothing_window=smoothing_window,
679
+ polyorder=polyorder,
802
680
  )
803
- else:
804
- refined_start, refined_end = start_frac, end_frac
805
681
 
806
682
  refined_phases.append((refined_start, refined_end, state))
807
683
 
808
684
  return refined_phases
809
685
 
810
686
 
811
- def _find_landing_from_acceleration(
687
+ def find_landing_from_acceleration(
812
688
  positions: FloatArray,
813
689
  accelerations: FloatArray,
814
690
  takeoff_frame: int,
815
691
  fps: float,
816
- search_duration: float = 1.5,
692
+ search_duration: float = 0.7,
817
693
  ) -> int:
818
694
  """
819
- Find landing frame using position-based detection with acceleration refinement.
695
+ Find landing frame by detecting impact acceleration after takeoff.
820
696
 
821
- Primary method: Find when feet return to near-takeoff level after peak.
822
- Secondary: Refine with acceleration spike if present.
823
-
824
- For drop jumps, landing is defined as the first ground contact after the
825
- reactive jump, when feet return to approximately the same level as takeoff.
697
+ Detects the moment of initial ground contact, characterized by a sharp
698
+ deceleration (positive acceleration spike) as downward velocity is arrested.
826
699
 
827
700
  Args:
828
- positions: Array of vertical positions (normalized 0-1, where higher = closer to ground)
701
+ positions: Array of vertical positions (normalized 0-1)
829
702
  accelerations: Array of accelerations (second derivative)
830
703
  takeoff_frame: Frame at takeoff (end of ground contact)
831
704
  fps: Video frame rate
832
- search_duration: Duration in seconds to search for landing (default: 1.5s)
705
+ search_duration: Duration in seconds to search for landing (default: 0.7s)
833
706
 
834
707
  Returns:
835
708
  Landing frame index (integer)
836
709
  """
837
- # Extended search window to capture full flight
710
+ # Find peak height (minimum y value = highest point)
838
711
  search_start = takeoff_frame
839
712
  search_end = min(len(positions), takeoff_frame + int(fps * search_duration))
840
713
 
@@ -842,91 +715,61 @@ def _find_landing_from_acceleration(
842
715
  return min(len(positions) - 1, takeoff_frame + int(fps * 0.3))
843
716
 
844
717
  flight_positions = positions[search_start:search_end]
845
-
846
- # Find peak height (minimum y value = highest point)
847
718
  peak_idx = int(np.argmin(flight_positions))
848
719
  peak_frame = search_start + peak_idx
849
720
 
850
- # Get takeoff position as reference for landing detection
851
- takeoff_position = positions[takeoff_frame]
852
-
853
- # Position-based landing: find first frame after peak where position
854
- # returns to within 5% of takeoff level (or 95% of the way back)
855
- landing_threshold = takeoff_position - 0.05 * (takeoff_position - positions[peak_frame])
721
+ # After peak, look for landing (impact with ground)
722
+ # Landing is detected by maximum positive acceleration (deceleration on impact)
723
+ landing_search_start = peak_frame + 2
724
+ landing_search_end = min(len(accelerations), landing_search_start + int(fps * 0.6))
856
725
 
857
- # Search for landing after peak
858
- landing_frame = None
859
- for i in range(peak_frame + 2, min(len(positions), search_end)):
860
- if positions[i] >= landing_threshold:
861
- landing_frame = i
862
- break
726
+ if landing_search_end <= landing_search_start:
727
+ return min(len(positions) - 1, peak_frame + int(fps * 0.2))
863
728
 
864
- # If position-based detection fails, use end of search window
865
- if landing_frame is None:
866
- landing_frame = min(len(positions) - 1, search_end - 1)
867
-
868
- # Refine with acceleration if there's a clear impact spike
869
- # Look for significant acceleration in a small window around the position-based landing
870
- refine_start = max(peak_frame + 2, landing_frame - int(fps * 0.1))
871
- refine_end = min(len(accelerations), landing_frame + int(fps * 0.1))
872
-
873
- if refine_end > refine_start:
874
- window_accelerations = accelerations[refine_start:refine_end]
875
- # Check if there's a significant acceleration spike (> 3x median)
876
- median_acc = float(np.median(np.abs(window_accelerations)))
877
- max_acc_idx = int(np.argmax(np.abs(window_accelerations)))
878
- max_acc = float(np.abs(window_accelerations[max_acc_idx]))
879
-
880
- if median_acc > 0 and max_acc > 3 * median_acc:
881
- # Use acceleration-refined landing frame
882
- landing_frame = refine_start + max_acc_idx
729
+ # Find impact: maximum negative acceleration after peak (deceleration on impact)
730
+ # The impact creates a large upward force (negative acceleration in Y-down)
731
+ landing_accelerations = accelerations[landing_search_start:landing_search_end]
732
+ impact_idx = int(np.argmin(landing_accelerations))
733
+ landing_frame = landing_search_start + impact_idx
883
734
 
884
735
  return landing_frame
885
736
 
886
737
 
887
738
  def compute_average_foot_position(
888
739
  landmarks: dict[str, tuple[float, float, float]],
889
- visibility_threshold: float = 0.5,
890
740
  ) -> tuple[float, float]:
891
741
  """
892
742
  Compute average foot position from ankle and foot landmarks.
893
743
 
894
- Uses tiered visibility approach to avoid returning center (0.5, 0.5)
895
- which can cause false phase transitions in contact detection.
896
-
897
744
  Args:
898
745
  landmarks: Dictionary of landmark positions
899
- visibility_threshold: Minimum visibility to include landmark (default: 0.5)
900
746
 
901
747
  Returns:
902
748
  (x, y) average foot position in normalized coordinates
903
749
  """
904
- # Collect all foot landmarks with their visibility
905
- foot_data: list[tuple[float, float, float]] = []
906
- for key in FOOT_KEYS:
907
- if key in landmarks:
908
- x, y, visibility = landmarks[key]
909
- foot_data.append((x, y, visibility))
750
+ foot_keys = [
751
+ "left_ankle",
752
+ "right_ankle",
753
+ "left_heel",
754
+ "right_heel",
755
+ "left_foot_index",
756
+ "right_foot_index",
757
+ ]
910
758
 
911
- if not foot_data:
912
- # No foot landmarks at all - return center as last resort
913
- return (0.5, 0.5)
759
+ x_positions = []
760
+ y_positions = []
914
761
 
915
- # Tier 1: Use landmarks above visibility threshold
916
- high_vis = [(x, y) for x, y, v in foot_data if v > visibility_threshold]
917
- if high_vis:
918
- xs, ys = zip(*high_vis, strict=False)
919
- return (float(np.mean(xs)), float(np.mean(ys)))
762
+ for key in foot_keys:
763
+ if key in landmarks:
764
+ x, y, visibility = landmarks[key]
765
+ if visibility > 0.5: # Only use visible landmarks
766
+ x_positions.append(x)
767
+ y_positions.append(y)
920
768
 
921
- # Tier 2: Use landmarks with any reasonable visibility (> 0.1)
922
- low_vis = [(x, y) for x, y, v in foot_data if v > 0.1]
923
- if low_vis:
924
- xs, ys = zip(*low_vis, strict=False)
925
- return (float(np.mean(xs)), float(np.mean(ys)))
769
+ if not x_positions:
770
+ return (0.5, 0.5) # Default to center if no visible feet
926
771
 
927
- # Tier 3: Use highest visibility landmark regardless of threshold
928
- best = max(foot_data, key=lambda t: t[2])
929
- return (best[0], best[1])
772
+ return (float(np.mean(x_positions)), float(np.mean(y_positions)))
930
773
 
931
774
 
932
775
  def _calculate_average_visibility(
@@ -940,13 +783,8 @@ def _calculate_average_visibility(
940
783
  Returns:
941
784
  Average visibility of foot landmarks (0.0 if none visible)
942
785
  """
943
- # Only use ankles and heels for visibility (foot_index can be noisy)
944
- visibility_keys = ("left_ankle", "right_ankle", "left_heel", "right_heel")
945
- foot_vis = [
946
- frame_landmarks[key][2]
947
- for key in FOOT_KEYS
948
- if key in frame_landmarks and key in visibility_keys
949
- ]
786
+ foot_keys = ["left_ankle", "right_ankle", "left_heel", "right_heel"]
787
+ foot_vis = [frame_landmarks[key][2] for key in foot_keys if key in frame_landmarks]
950
788
  return float(np.mean(foot_vis)) if foot_vis else 0.0
951
789
 
952
790
 
@@ -954,7 +792,7 @@ def _calculate_average_visibility(
954
792
  reason="Alternative implementation not called by pipeline",
955
793
  since="0.34.0",
956
794
  )
957
- def _extract_foot_positions_and_visibilities(
795
+ def extract_foot_positions_and_visibilities(
958
796
  smoothed_landmarks: list[dict[str, tuple[float, float, float]] | None],
959
797
  ) -> tuple[np.ndarray, np.ndarray]:
960
798
  """