kinemotion 0.12.1__py3-none-any.whl → 0.12.3__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.

@@ -89,6 +89,87 @@ def calculate_adaptive_threshold(
89
89
  return adaptive_threshold
90
90
 
91
91
 
92
+ def _find_stable_baseline(
93
+ positions: np.ndarray,
94
+ min_stable_frames: int,
95
+ stability_threshold: float = 0.01,
96
+ debug: bool = False,
97
+ ) -> tuple[int, float]:
98
+ """Find first stable period and return baseline position.
99
+
100
+ Returns:
101
+ Tuple of (baseline_start_frame, baseline_position). Returns (-1, 0.0) if not found.
102
+ """
103
+ stable_window = min_stable_frames
104
+
105
+ for start_idx in range(0, len(positions) - stable_window, 5):
106
+ window = positions[start_idx : start_idx + stable_window]
107
+ window_std = float(np.std(window))
108
+
109
+ if window_std < stability_threshold:
110
+ baseline_start = start_idx
111
+ baseline_position = float(np.median(window))
112
+
113
+ if debug:
114
+ end_frame = baseline_start + stable_window - 1
115
+ print("[detect_drop_start] Found stable period:")
116
+ print(f" frames {baseline_start}-{end_frame}")
117
+ print(f" baseline_position: {baseline_position:.4f}")
118
+ print(f" baseline_std: {window_std:.4f} < {stability_threshold:.4f}")
119
+
120
+ return baseline_start, baseline_position
121
+
122
+ if debug:
123
+ print(
124
+ f"[detect_drop_start] No stable period found "
125
+ f"(variance always > {stability_threshold:.4f})"
126
+ )
127
+ return -1, 0.0
128
+
129
+
130
+ def _find_drop_from_baseline(
131
+ positions: np.ndarray,
132
+ baseline_start: int,
133
+ baseline_position: float,
134
+ stable_window: int,
135
+ position_change_threshold: float,
136
+ smoothing_window: int,
137
+ debug: bool = False,
138
+ ) -> int:
139
+ """Find drop start after stable baseline period.
140
+
141
+ Returns:
142
+ Drop frame index, or 0 if not found.
143
+ """
144
+ search_start = baseline_start + stable_window
145
+ window_size = max(3, smoothing_window)
146
+
147
+ for i in range(search_start, len(positions) - window_size):
148
+ window_positions = positions[i : i + window_size]
149
+ avg_position = float(np.mean(window_positions))
150
+ position_change = avg_position - baseline_position
151
+
152
+ if position_change > position_change_threshold:
153
+ drop_frame = max(baseline_start, i - window_size)
154
+
155
+ if debug:
156
+ print(f"[detect_drop_start] Drop detected at frame {drop_frame}")
157
+ print(
158
+ f" position_change: {position_change:.4f} > "
159
+ f"{position_change_threshold:.4f}"
160
+ )
161
+ print(
162
+ f" avg_position: {avg_position:.4f} vs "
163
+ f"baseline: {baseline_position:.4f}"
164
+ )
165
+
166
+ return drop_frame
167
+
168
+ if debug:
169
+ print("[detect_drop_start] No drop detected after stable period")
170
+ return 0
171
+
172
+
92
173
  def detect_drop_start(
93
174
  positions: np.ndarray,
94
175
  fps: float,
@@ -126,84 +207,32 @@ def detect_drop_start(
126
207
  - Returns: 119
127
208
  """
128
209
  min_stable_frames = int(fps * min_stationary_duration)
129
- if len(positions) < min_stable_frames + 30: # Need some frames after stable period
210
+ if len(positions) < min_stable_frames + 30:
130
211
  if debug:
131
- min_frames_needed = min_stable_frames + 30
132
212
  print(
133
- f"[detect_drop_start] Video too short: {len(positions)} < {min_frames_needed}"
213
+ f"[detect_drop_start] Video too short: {len(positions)} < "
214
+ f"{min_stable_frames + 30}"
134
215
  )
135
216
  return 0
136
217
 
137
- # STEP 1: Find first stable period by scanning forward
138
- # Look for window with low variance (< 1% of frame height)
139
- stability_threshold = 0.01 # 1% of frame height
140
- stable_window = min_stable_frames
141
-
142
- baseline_start = -1
143
- baseline_position = 0.0
144
-
145
- # Scan from start, looking for stable window
146
- for start_idx in range(0, len(positions) - stable_window, 5): # Step by 5 frames
147
- window = positions[start_idx : start_idx + stable_window]
148
- window_std = float(np.std(window))
149
-
150
- if window_std < stability_threshold:
151
- # Found stable period!
152
- baseline_start = start_idx
153
- baseline_position = float(np.median(window))
154
-
155
- if debug:
156
- end_frame = baseline_start + stable_window - 1
157
- print("[detect_drop_start] Found stable period:")
158
- print(f" frames {baseline_start}-{end_frame}")
159
- print(f" baseline_position: {baseline_position:.4f}")
160
- print(f" baseline_std: {window_std:.4f} < {stability_threshold:.4f}")
161
- break
218
+ # Find stable baseline period
219
+ baseline_start, baseline_position = _find_stable_baseline(
220
+ positions, min_stable_frames, debug=debug
221
+ )
162
222
 
163
223
  if baseline_start < 0:
164
- if debug:
165
- msg = (
166
- f"No stable period found (variance always > {stability_threshold:.4f})"
167
- )
168
- print(f"[detect_drop_start] {msg}")
169
224
  return 0
170
225
 
171
- # STEP 2: Find when position changes significantly from baseline
172
- # Start searching after stable period ends
173
- search_start = baseline_start + stable_window
174
- window_size = max(3, smoothing_window)
175
-
176
- for i in range(search_start, len(positions) - window_size):
177
- # Average position over small window to reduce noise
178
- window_positions = positions[i : i + window_size]
179
- avg_position = float(np.mean(window_positions))
180
-
181
- # Check if position has increased (dropped) significantly
182
- position_change = avg_position - baseline_position
183
-
184
- if position_change > position_change_threshold:
185
- # Found start of drop - back up slightly to catch beginning
186
- drop_frame_candidate = i - window_size
187
- if drop_frame_candidate < baseline_start:
188
- drop_frame = baseline_start
189
- else:
190
- drop_frame = drop_frame_candidate
191
-
192
- if debug:
193
- print(f"[detect_drop_start] Drop detected at frame {drop_frame}")
194
- print(
195
- f" position_change: {position_change:.4f} > {position_change_threshold:.4f}"
196
- )
197
- print(
198
- f" avg_position: {avg_position:.4f} vs baseline: {baseline_position:.4f}"
199
- )
200
-
201
- return drop_frame
202
-
203
- # No significant position change detected
204
- if debug:
205
- print("[detect_drop_start] No drop detected after stable period")
206
- return 0
226
+ # Find drop from baseline
227
+ return _find_drop_from_baseline(
228
+ positions,
229
+ baseline_start,
230
+ baseline_position,
231
+ min_stable_frames,
232
+ position_change_threshold,
233
+ smoothing_window,
234
+ debug,
235
+ )
207
236
 
208
237
 
209
238
  def detect_ground_contact(
@@ -349,6 +378,71 @@ def interpolate_threshold_crossing(
349
378
  return float(max(0.0, min(1.0, t)))
350
379
 
351
380
 
381
+ def _interpolate_phase_start(
382
+ start_idx: int,
383
+ state: ContactState,
384
+ velocities: np.ndarray,
385
+ velocity_threshold: float,
386
+ ) -> float:
387
+ """Interpolate start boundary of a phase with sub-frame precision.
388
+
389
+ Returns:
390
+ Fractional start frame, or float(start_idx) if no interpolation.
391
+ """
392
+ if start_idx <= 0 or start_idx >= len(velocities):
393
+ return float(start_idx)
394
+
395
+ vel_before = velocities[start_idx - 1]
396
+ vel_at = velocities[start_idx]
397
+
398
+ # Check threshold crossing based on state
399
+ is_landing = (
400
+ state == ContactState.ON_GROUND and vel_before > velocity_threshold > vel_at
401
+ )
402
+ is_takeoff = (
403
+ state == ContactState.IN_AIR and vel_before < velocity_threshold < vel_at
404
+ )
405
+
406
+ if is_landing or is_takeoff:
407
+ offset = interpolate_threshold_crossing(vel_before, vel_at, velocity_threshold)
408
+ return (start_idx - 1) + offset
409
+
410
+ return float(start_idx)
411
+
412
+
413
+ def _interpolate_phase_end(
414
+ end_idx: int,
415
+ state: ContactState,
416
+ velocities: np.ndarray,
417
+ velocity_threshold: float,
418
+ max_idx: int,
419
+ ) -> float:
420
+ """Interpolate end boundary of a phase with sub-frame precision.
421
+
422
+ Returns:
423
+ Fractional end frame, or float(end_idx) if no interpolation.
424
+ """
425
+ if end_idx >= max_idx - 1 or end_idx + 1 >= len(velocities):
426
+ return float(end_idx)
427
+
428
+ vel_at = velocities[end_idx]
429
+ vel_after = velocities[end_idx + 1]
430
+
431
+ # Check threshold crossing based on state
432
+ is_takeoff = (
433
+ state == ContactState.ON_GROUND and vel_at < velocity_threshold < vel_after
434
+ )
435
+ is_landing = (
436
+ state == ContactState.IN_AIR and vel_at > velocity_threshold > vel_after
437
+ )
438
+
439
+ if is_takeoff or is_landing:
440
+ offset = interpolate_threshold_crossing(vel_at, vel_after, velocity_threshold)
441
+ return end_idx + offset
442
+
443
+ return float(end_idx)
444
+
445
+
352
446
  def find_interpolated_phase_transitions(
353
447
  foot_positions: np.ndarray,
354
448
  contact_states: list[ContactState],
@@ -371,13 +465,10 @@ def find_interpolated_phase_transitions(
371
465
  Returns:
372
466
  List of (start_frame, end_frame, state) tuples with fractional frame indices
373
467
  """
374
- # First get integer frame phases
375
468
  phases = find_contact_phases(contact_states)
376
469
  if not phases or len(foot_positions) < 2:
377
470
  return []
378
471
 
379
- # Compute velocities from derivative of smoothed trajectory
380
- # This gives much smoother velocity estimates than simple frame differences
381
472
  velocities = compute_velocity_from_derivative(
382
473
  foot_positions, window_length=smoothing_window, polyorder=2
383
474
  )
@@ -385,57 +476,12 @@ def find_interpolated_phase_transitions(
385
476
  interpolated_phases: list[tuple[float, float, ContactState]] = []
386
477
 
387
478
  for start_idx, end_idx, state in phases:
388
- start_frac = float(start_idx)
389
- end_frac = float(end_idx)
390
-
391
- # Interpolate start boundary (transition INTO this phase)
392
- if start_idx > 0 and start_idx < len(velocities):
393
- vel_before = (
394
- velocities[start_idx - 1] if start_idx > 0 else velocities[start_idx]
395
- )
396
- vel_at = velocities[start_idx]
397
-
398
- # Check if we're crossing the threshold at this boundary
399
- if state == ContactState.ON_GROUND:
400
- # Transition air→ground: velocity dropping below threshold
401
- if vel_before > velocity_threshold > vel_at:
402
- # Interpolate between start_idx-1 and start_idx
403
- offset = interpolate_threshold_crossing(
404
- vel_before, vel_at, velocity_threshold
405
- )
406
- start_frac = (start_idx - 1) + offset
407
- elif state == ContactState.IN_AIR:
408
- # Transition ground→air: velocity rising above threshold
409
- if vel_before < velocity_threshold < vel_at:
410
- # Interpolate between start_idx-1 and start_idx
411
- offset = interpolate_threshold_crossing(
412
- vel_before, vel_at, velocity_threshold
413
- )
414
- start_frac = (start_idx - 1) + offset
415
-
416
- # Interpolate end boundary (transition OUT OF this phase)
417
- if end_idx < len(foot_positions) - 1 and end_idx + 1 < len(velocities):
418
- vel_at = velocities[end_idx]
419
- vel_after = velocities[end_idx + 1]
420
-
421
- # Check if we're crossing the threshold at this boundary
422
- if state == ContactState.ON_GROUND:
423
- # Transition ground→air: velocity rising above threshold
424
- if vel_at < velocity_threshold < vel_after:
425
- # Interpolate between end_idx and end_idx+1
426
- offset = interpolate_threshold_crossing(
427
- vel_at, vel_after, velocity_threshold
428
- )
429
- end_frac = end_idx + offset
430
- elif state == ContactState.IN_AIR:
431
- # Transition air→ground: velocity dropping below threshold
432
- if vel_at > velocity_threshold > vel_after:
433
- # Interpolate between end_idx and end_idx+1
434
- offset = interpolate_threshold_crossing(
435
- vel_at, vel_after, velocity_threshold
436
- )
437
- end_frac = end_idx + offset
438
-
479
+ start_frac = _interpolate_phase_start(
480
+ start_idx, state, velocities, velocity_threshold
481
+ )
482
+ end_frac = _interpolate_phase_end(
483
+ end_idx, state, velocities, velocity_threshold, len(foot_positions)
484
+ )
439
485
  interpolated_phases.append((start_frac, end_frac, state))
440
486
 
441
487
  return interpolated_phases
@@ -706,3 +752,39 @@ def compute_average_foot_position(
706
752
  return (0.5, 0.5) # Default to center if no visible feet
707
753
 
708
754
  return (float(np.mean(x_positions)), float(np.mean(y_positions)))
755
+
756
+
757
+ def extract_foot_positions_and_visibilities(
758
+ smoothed_landmarks: list[dict[str, tuple[float, float, float]] | None],
759
+ ) -> tuple[np.ndarray, np.ndarray]:
760
+ """
761
+ Extract vertical positions and average visibilities from smoothed landmarks.
762
+
763
+ This utility function eliminates code duplication between CLI and programmatic usage.
764
+
765
+ Args:
766
+ smoothed_landmarks: Smoothed landmark sequence from tracking
767
+
768
+ Returns:
769
+ Tuple of (vertical_positions, visibilities) as numpy arrays
770
+ """
771
+ position_list: list[float] = []
772
+ visibilities_list: list[float] = []
773
+
774
+ for frame_landmarks in smoothed_landmarks:
775
+ if frame_landmarks:
776
+ _, foot_y = compute_average_foot_position(frame_landmarks)
777
+ position_list.append(foot_y)
778
+
779
+ # Average visibility of foot landmarks
780
+ foot_vis = []
781
+ for key in ["left_ankle", "right_ankle", "left_heel", "right_heel"]:
782
+ if key in frame_landmarks:
783
+ foot_vis.append(frame_landmarks[key][2])
784
+ visibilities_list.append(float(np.mean(foot_vis)) if foot_vis else 0.0)
785
+ else:
786
+ # Fill missing frames with last known position or default
787
+ position_list.append(position_list[-1] if position_list else 0.5)
788
+ visibilities_list.append(0.0)
789
+
790
+ return np.array(position_list), np.array(visibilities_list)
@@ -28,8 +28,8 @@ from ..core.pose import PoseTracker
28
28
  from ..core.video_io import VideoProcessor
29
29
  from .analysis import (
30
30
  ContactState,
31
- compute_average_foot_position,
32
31
  detect_ground_contact,
32
+ extract_foot_positions_and_visibilities,
33
33
  )
34
34
  from .debug_overlay import DebugOverlayRenderer
35
35
  from .kinematics import DropJumpMetrics, calculate_drop_jump_metrics
@@ -258,26 +258,7 @@ def _extract_positions_and_visibilities(
258
258
  Tuple of (vertical_positions, visibilities)
259
259
  """
260
260
  click.echo("Extracting foot positions...", err=True)
261
-
262
- position_list: list[float] = []
263
- visibilities_list: list[float] = []
264
-
265
- for frame_landmarks in smoothed_landmarks:
266
- if frame_landmarks:
267
- _, foot_y = compute_average_foot_position(frame_landmarks)
268
- position_list.append(foot_y)
269
-
270
- # Average visibility of foot landmarks
271
- foot_vis = []
272
- for key in ["left_ankle", "right_ankle", "left_heel", "right_heel"]:
273
- if key in frame_landmarks:
274
- foot_vis.append(frame_landmarks[key][2])
275
- visibilities_list.append(float(np.mean(foot_vis)) if foot_vis else 0.0)
276
- else:
277
- position_list.append(position_list[-1] if position_list else 0.5)
278
- visibilities_list.append(0.0)
279
-
280
- return np.array(position_list), np.array(visibilities_list)
261
+ return extract_foot_positions_and_visibilities(smoothed_landmarks)
281
262
 
282
263
 
283
264
  def _create_debug_video(
@@ -413,13 +394,11 @@ def _process_single(
413
394
  contact_states,
414
395
  vertical_positions,
415
396
  video.fps,
416
- drop_height_m=None,
417
397
  drop_start_frame=expert_params.drop_start_frame,
418
398
  velocity_threshold=params.velocity_threshold,
419
399
  smoothing_window=params.smoothing_window,
420
400
  polyorder=params.polyorder,
421
401
  use_curvature=params.use_curvature,
422
- kinematic_correction_factor=1.0,
423
402
  )
424
403
 
425
404
  # Output metrics
@@ -569,6 +548,63 @@ def _compute_batch_statistics(results: list[VideoResult]) -> None:
569
548
  )
570
549
 
571
550
 
551
+ def _format_time_metric(value: float | None, multiplier: float = 1000.0) -> str:
552
+ """Format time metric for CSV output.
553
+
554
+ Args:
555
+ value: Time value in seconds
556
+ multiplier: Multiplier to convert to milliseconds (default: 1000.0)
557
+
558
+ Returns:
559
+ Formatted string or "N/A" if value is None
560
+ """
561
+ return f"{value * multiplier:.1f}" if value is not None else "N/A"
562
+
563
+
564
+ def _format_distance_metric(value: float | None) -> str:
565
+ """Format distance metric for CSV output.
566
+
567
+ Args:
568
+ value: Distance value in meters
569
+
570
+ Returns:
571
+ Formatted string or "N/A" if value is None
572
+ """
573
+ return f"{value:.3f}" if value is not None else "N/A"
574
+
575
+
576
+ def _create_csv_row_from_result(result: VideoResult) -> list[str]:
577
+ """Create CSV row from video processing result.
578
+
579
+ Args:
580
+ result: Video processing result
581
+
582
+ Returns:
583
+ List of formatted values for CSV row
584
+ """
585
+ video_name = Path(result.video_path).name
586
+ processing_time = f"{result.processing_time:.2f}"
587
+
588
+ if result.success and result.metrics:
589
+ return [
590
+ video_name,
591
+ _format_time_metric(result.metrics.ground_contact_time),
592
+ _format_time_metric(result.metrics.flight_time),
593
+ _format_distance_metric(result.metrics.jump_height),
594
+ processing_time,
595
+ "Success",
596
+ ]
597
+ else:
598
+ return [
599
+ video_name,
600
+ "N/A",
601
+ "N/A",
602
+ "N/A",
603
+ processing_time,
604
+ f"Failed: {result.error}",
605
+ ]
606
+
607
+
572
608
  def _write_csv_summary(
573
609
  csv_summary: str | None, results: list[VideoResult], successful: list[VideoResult]
574
610
  ) -> None:
@@ -602,40 +638,7 @@ def _write_csv_summary(
602
638
 
603
639
  # Data rows
604
640
  for result in results:
605
- if result.success and result.metrics:
606
- writer.writerow(
607
- [
608
- Path(result.video_path).name,
609
- (
610
- f"{result.metrics.ground_contact_time * 1000:.1f}"
611
- if result.metrics.ground_contact_time
612
- else "N/A"
613
- ),
614
- (
615
- f"{result.metrics.flight_time * 1000:.1f}"
616
- if result.metrics.flight_time
617
- else "N/A"
618
- ),
619
- (
620
- f"{result.metrics.jump_height:.3f}"
621
- if result.metrics.jump_height
622
- else "N/A"
623
- ),
624
- f"{result.processing_time:.2f}",
625
- "Success",
626
- ]
627
- )
628
- else:
629
- writer.writerow(
630
- [
631
- Path(result.video_path).name,
632
- "N/A",
633
- "N/A",
634
- "N/A",
635
- f"{result.processing_time:.2f}",
636
- f"Failed: {result.error}",
637
- ]
638
- )
641
+ writer.writerow(_create_csv_row_from_result(result))
639
642
 
640
643
  click.echo("CSV summary written successfully", err=True)
641
644