kinemotion 0.10.6__py3-none-any.whl → 0.67.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 (48) hide show
  1. kinemotion/__init__.py +31 -6
  2. kinemotion/api.py +39 -598
  3. kinemotion/cli.py +2 -0
  4. kinemotion/cmj/__init__.py +5 -0
  5. kinemotion/cmj/analysis.py +621 -0
  6. kinemotion/cmj/api.py +563 -0
  7. kinemotion/cmj/cli.py +324 -0
  8. kinemotion/cmj/debug_overlay.py +457 -0
  9. kinemotion/cmj/joint_angles.py +307 -0
  10. kinemotion/cmj/kinematics.py +360 -0
  11. kinemotion/cmj/metrics_validator.py +767 -0
  12. kinemotion/cmj/validation_bounds.py +341 -0
  13. kinemotion/core/__init__.py +28 -0
  14. kinemotion/core/auto_tuning.py +71 -37
  15. kinemotion/core/cli_utils.py +60 -0
  16. kinemotion/core/debug_overlay_utils.py +385 -0
  17. kinemotion/core/determinism.py +83 -0
  18. kinemotion/core/experimental.py +103 -0
  19. kinemotion/core/filtering.py +9 -6
  20. kinemotion/core/formatting.py +75 -0
  21. kinemotion/core/metadata.py +231 -0
  22. kinemotion/core/model_downloader.py +172 -0
  23. kinemotion/core/pipeline_utils.py +433 -0
  24. kinemotion/core/pose.py +298 -141
  25. kinemotion/core/pose_landmarks.py +67 -0
  26. kinemotion/core/quality.py +393 -0
  27. kinemotion/core/smoothing.py +250 -154
  28. kinemotion/core/timing.py +247 -0
  29. kinemotion/core/types.py +42 -0
  30. kinemotion/core/validation.py +201 -0
  31. kinemotion/core/video_io.py +135 -50
  32. kinemotion/dropjump/__init__.py +1 -1
  33. kinemotion/dropjump/analysis.py +367 -182
  34. kinemotion/dropjump/api.py +665 -0
  35. kinemotion/dropjump/cli.py +156 -466
  36. kinemotion/dropjump/debug_overlay.py +136 -206
  37. kinemotion/dropjump/kinematics.py +232 -255
  38. kinemotion/dropjump/metrics_validator.py +240 -0
  39. kinemotion/dropjump/validation_bounds.py +157 -0
  40. kinemotion/models/__init__.py +0 -0
  41. kinemotion/models/pose_landmarker_lite.task +0 -0
  42. kinemotion-0.67.0.dist-info/METADATA +726 -0
  43. kinemotion-0.67.0.dist-info/RECORD +47 -0
  44. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/WHEEL +1 -1
  45. kinemotion-0.10.6.dist-info/METADATA +0 -561
  46. kinemotion-0.10.6.dist-info/RECORD +0 -20
  47. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/entry_points.txt +0 -0
  48. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/licenses/LICENSE +0 -0
@@ -4,10 +4,14 @@ from enum import Enum
4
4
 
5
5
  import numpy as np
6
6
 
7
+ from ..core.experimental import unused
7
8
  from ..core.smoothing import (
8
9
  compute_acceleration_from_derivative,
9
10
  compute_velocity_from_derivative,
11
+ interpolate_threshold_crossing,
10
12
  )
13
+ from ..core.timing import NULL_TIMER, Timer
14
+ from ..core.types import BoolArray, FloatArray
11
15
 
12
16
 
13
17
  class ContactState(Enum):
@@ -18,8 +22,13 @@ class ContactState(Enum):
18
22
  UNKNOWN = "unknown"
19
23
 
20
24
 
25
+ @unused(
26
+ reason="Not called by analysis pipeline - awaiting CLI integration",
27
+ remove_in="1.0.0",
28
+ since="0.34.0",
29
+ )
21
30
  def calculate_adaptive_threshold(
22
- positions: np.ndarray,
31
+ positions: FloatArray,
23
32
  fps: float,
24
33
  baseline_duration: float = 3.0,
25
34
  multiplier: float = 1.5,
@@ -29,6 +38,18 @@ def calculate_adaptive_threshold(
29
38
  """
30
39
  Calculate adaptive velocity threshold based on baseline motion characteristics.
31
40
 
41
+ .. warning::
42
+ **Status: Implemented but Not Integrated**
43
+
44
+ This function is fully implemented and tested but not called by the
45
+ analysis pipeline. See ``docs/development/errors-findings.md`` for details.
46
+
47
+ **To integrate**: Add CLI parameter ``--use-adaptive-threshold`` and
48
+ call this function before contact detection.
49
+
50
+ **Roadmap**: Planned for Phase 2 if users report issues with varying
51
+ video conditions.
52
+
32
53
  Analyzes the first few seconds of video (assumed to be relatively stationary,
33
54
  e.g., athlete standing on box) to determine the noise floor, then sets threshold
34
55
  as a multiple of this baseline noise.
@@ -89,8 +110,86 @@ def calculate_adaptive_threshold(
89
110
  return adaptive_threshold
90
111
 
91
112
 
113
+ def _find_stable_baseline(
114
+ positions: FloatArray,
115
+ min_stable_frames: int,
116
+ stability_threshold: float = 0.01,
117
+ debug: bool = False,
118
+ ) -> tuple[int, float]:
119
+ """Find first stable period and return baseline position.
120
+
121
+ Returns:
122
+ Tuple of (baseline_start_frame, baseline_position). Returns (-1, 0.0)
123
+ if not found.
124
+ """
125
+ stable_window = min_stable_frames
126
+
127
+ for start_idx in range(0, len(positions) - stable_window, 5):
128
+ window = positions[start_idx : start_idx + stable_window]
129
+ window_std = float(np.std(window))
130
+
131
+ if window_std < stability_threshold:
132
+ baseline_start = start_idx
133
+ baseline_position = float(np.median(window))
134
+
135
+ if debug:
136
+ end_frame = baseline_start + stable_window - 1
137
+ print("[detect_drop_start] Found stable period:")
138
+ print(f" frames {baseline_start}-{end_frame}")
139
+ print(f" baseline_position: {baseline_position:.4f}")
140
+ print(f" baseline_std: {window_std:.4f} < {stability_threshold:.4f}")
141
+
142
+ return baseline_start, baseline_position
143
+
144
+ if debug:
145
+ print(
146
+ f"[detect_drop_start] No stable period found "
147
+ f"(variance always > {stability_threshold:.4f})"
148
+ )
149
+ return -1, 0.0
150
+
151
+
152
+ def _find_drop_from_baseline(
153
+ positions: FloatArray,
154
+ baseline_start: int,
155
+ baseline_position: float,
156
+ stable_window: int,
157
+ position_change_threshold: float,
158
+ smoothing_window: int,
159
+ debug: bool = False,
160
+ ) -> int:
161
+ """Find drop start after stable baseline period.
162
+
163
+ Returns:
164
+ Drop frame index, or 0 if not found.
165
+ """
166
+ search_start = baseline_start + stable_window
167
+ window_size = max(3, smoothing_window)
168
+
169
+ for i in range(search_start, len(positions) - window_size):
170
+ window_positions = positions[i : i + window_size]
171
+ avg_position = float(np.mean(window_positions))
172
+ position_change = avg_position - baseline_position
173
+
174
+ if position_change > position_change_threshold:
175
+ drop_frame = max(baseline_start, i - window_size)
176
+
177
+ if debug:
178
+ print(f"[detect_drop_start] Drop detected at frame {drop_frame}")
179
+ print(
180
+ f" position_change: {position_change:.4f} > {position_change_threshold:.4f}"
181
+ )
182
+ print(f" avg_position: {avg_position:.4f} vs baseline: {baseline_position:.4f}")
183
+
184
+ return drop_frame
185
+
186
+ if debug:
187
+ print("[detect_drop_start] No drop detected after stable period")
188
+ return 0
189
+
190
+
92
191
  def detect_drop_start(
93
- positions: np.ndarray,
192
+ positions: FloatArray,
94
193
  fps: float,
95
194
  min_stationary_duration: float = 1.0,
96
195
  position_change_threshold: float = 0.02,
@@ -98,7 +197,8 @@ def detect_drop_start(
98
197
  debug: bool = False,
99
198
  ) -> int:
100
199
  """
101
- Detect when the drop jump actually starts by finding stable period then detecting drop.
200
+ Detect when the drop jump actually starts by finding stable period then
201
+ detecting drop.
102
202
 
103
203
  Strategy:
104
204
  1. Scan forward to find first STABLE period (low variance over N frames)
@@ -110,7 +210,8 @@ def detect_drop_start(
110
210
  Args:
111
211
  positions: Array of vertical positions (0-1 normalized, y increases downward)
112
212
  fps: Video frame rate
113
- min_stationary_duration: Minimum duration (seconds) of stable period (default: 1.0s)
213
+ min_stationary_duration: Minimum duration (seconds) of stable period
214
+ (default: 1.0s)
114
215
  position_change_threshold: Position change indicating start of drop
115
216
  (default: 0.02 = 2% of frame)
116
217
  smoothing_window: Window for computing position variance
@@ -126,99 +227,127 @@ def detect_drop_start(
126
227
  - Returns: 119
127
228
  """
128
229
  min_stable_frames = int(fps * min_stationary_duration)
129
- if len(positions) < min_stable_frames + 30: # Need some frames after stable period
230
+ if len(positions) < min_stable_frames + 30:
130
231
  if debug:
131
- min_frames_needed = min_stable_frames + 30
132
232
  print(
133
- f"[detect_drop_start] Video too short: {len(positions)} < {min_frames_needed}"
233
+ f"[detect_drop_start] Video too short: {len(positions)} < {min_stable_frames + 30}"
134
234
  )
135
235
  return 0
136
236
 
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
237
+ # Find stable baseline period
238
+ baseline_start, baseline_position = _find_stable_baseline(
239
+ positions, min_stable_frames, debug=debug
240
+ )
141
241
 
142
- baseline_start = -1
143
- baseline_position = 0.0
242
+ if baseline_start < 0:
243
+ return 0
144
244
 
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))
245
+ # Find drop from baseline
246
+ return _find_drop_from_baseline(
247
+ positions,
248
+ baseline_start,
249
+ baseline_position,
250
+ min_stable_frames,
251
+ position_change_threshold,
252
+ smoothing_window,
253
+ debug,
254
+ )
149
255
 
150
- if window_std < stability_threshold:
151
- # Found stable period!
152
- baseline_start = start_idx
153
- baseline_position = float(np.median(window))
154
256
 
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
257
+ def _filter_stationary_with_visibility(
258
+ is_stationary: BoolArray,
259
+ visibilities: FloatArray | None,
260
+ visibility_threshold: float,
261
+ ) -> BoolArray:
262
+ """Apply visibility filter to stationary flags.
162
263
 
163
- 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
- return 0
264
+ Args:
265
+ is_stationary: Boolean array indicating stationary frames
266
+ visibilities: Optional visibility scores for each frame
267
+ visibility_threshold: Minimum visibility to trust landmark
170
268
 
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)
269
+ Returns:
270
+ Filtered is_stationary array
271
+ """
272
+ if visibilities is not None:
273
+ is_visible = visibilities > visibility_threshold
274
+ return is_stationary & is_visible
275
+ return is_stationary
175
276
 
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
277
 
181
- # Check if position has increased (dropped) significantly
182
- position_change = avg_position - baseline_position
278
+ def _find_contact_frames(
279
+ is_stationary: BoolArray,
280
+ min_contact_frames: int,
281
+ ) -> set[int]:
282
+ """Find frames with sustained contact using minimum duration filter.
183
283
 
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
284
+ Args:
285
+ is_stationary: Boolean array indicating stationary frames
286
+ min_contact_frames: Minimum consecutive frames to confirm contact
191
287
 
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
- )
288
+ Returns:
289
+ Set of frame indices that meet minimum contact duration
290
+ """
291
+ contact_frames: set[int] = set()
292
+ current_run = []
200
293
 
201
- return drop_frame
294
+ for i, stationary in enumerate(is_stationary):
295
+ if stationary:
296
+ current_run.append(i)
297
+ else:
298
+ if len(current_run) >= min_contact_frames:
299
+ contact_frames.update(current_run)
300
+ current_run = []
202
301
 
203
- # No significant position change detected
204
- if debug:
205
- print("[detect_drop_start] No drop detected after stable period")
206
- return 0
302
+ # Handle last run
303
+ if len(current_run) >= min_contact_frames:
304
+ contact_frames.update(current_run)
305
+
306
+ return contact_frames
307
+
308
+
309
+ def _assign_contact_states(
310
+ n_frames: int,
311
+ contact_frames: set[int],
312
+ visibilities: FloatArray | None,
313
+ visibility_threshold: float,
314
+ ) -> list[ContactState]:
315
+ """Assign contact states based on contact frames and visibility.
316
+
317
+ Args:
318
+ n_frames: Total number of frames
319
+ contact_frames: Set of frames with confirmed contact
320
+ visibilities: Optional visibility scores for each frame
321
+ visibility_threshold: Minimum visibility to trust landmark
322
+
323
+ Returns:
324
+ List of ContactState for each frame
325
+ """
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)
332
+ else:
333
+ states.append(ContactState.IN_AIR)
334
+ return states
207
335
 
208
336
 
209
337
  def detect_ground_contact(
210
- foot_positions: np.ndarray,
338
+ foot_positions: FloatArray,
211
339
  velocity_threshold: float = 0.02,
212
340
  min_contact_frames: int = 3,
213
341
  visibility_threshold: float = 0.5,
214
- visibilities: np.ndarray | None = None,
342
+ visibilities: FloatArray | None = None,
215
343
  window_length: int = 5,
216
344
  polyorder: int = 2,
345
+ timer: Timer | None = None,
217
346
  ) -> list[ContactState]:
218
347
  """
219
348
  Detect when feet are in contact with ground based on vertical motion.
220
349
 
221
- Uses derivative-based velocity calculation via Savitzky-Golay filter for smooth,
350
+ Uses derivative-based velocity calculation via Savitzky-Goyal filter for smooth,
222
351
  accurate velocity estimates. This is consistent with the velocity calculation used
223
352
  throughout the pipeline for sub-frame interpolation and curvature analysis.
224
353
 
@@ -230,57 +359,37 @@ def detect_ground_contact(
230
359
  visibilities: Array of visibility scores for each frame
231
360
  window_length: Window size for velocity derivative calculation (must be odd)
232
361
  polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
362
+ timer: Optional Timer for measuring operations
233
363
 
234
364
  Returns:
235
365
  List of ContactState for each frame
236
366
  """
367
+ timer = timer or NULL_TIMER
237
368
  n_frames = len(foot_positions)
238
- states = [ContactState.UNKNOWN] * n_frames
239
369
 
240
370
  if n_frames < 2:
241
- return states
371
+ return [ContactState.UNKNOWN] * n_frames
242
372
 
243
373
  # Compute vertical velocity using derivative-based method
244
- # This provides smoother, more accurate velocity estimates than frame-to-frame differences
245
- # and is consistent with the velocity calculation used for sub-frame interpolation
246
- velocities = compute_velocity_from_derivative(
247
- foot_positions, window_length=window_length, polyorder=polyorder
248
- )
374
+ with timer.measure("dj_compute_velocity"):
375
+ velocities = compute_velocity_from_derivative(
376
+ foot_positions, window_length=window_length, polyorder=polyorder
377
+ )
249
378
 
250
- # Detect potential contact frames based on low velocity
379
+ # Detect stationary frames based on velocity threshold
251
380
  is_stationary = np.abs(velocities) < velocity_threshold
252
381
 
253
382
  # Apply visibility filter
254
- if visibilities is not None:
255
- is_visible = visibilities > visibility_threshold
256
- is_stationary = is_stationary & is_visible
257
-
258
- # Apply minimum contact duration filter
259
- contact_frames = []
260
- current_run = []
261
-
262
- for i, stationary in enumerate(is_stationary):
263
- if stationary:
264
- current_run.append(i)
265
- else:
266
- if len(current_run) >= min_contact_frames:
267
- contact_frames.extend(current_run)
268
- current_run = []
269
-
270
- # Don't forget the last run
271
- if len(current_run) >= min_contact_frames:
272
- contact_frames.extend(current_run)
383
+ is_stationary = _filter_stationary_with_visibility(
384
+ is_stationary, visibilities, visibility_threshold
385
+ )
273
386
 
274
- # Set states
275
- for i in range(n_frames):
276
- if visibilities is not None and visibilities[i] < visibility_threshold:
277
- states[i] = ContactState.UNKNOWN
278
- elif i in contact_frames:
279
- states[i] = ContactState.ON_GROUND
280
- else:
281
- states[i] = ContactState.IN_AIR
387
+ # Find frames with sustained contact
388
+ with timer.measure("dj_find_contact_frames"):
389
+ contact_frames = _find_contact_frames(is_stationary, min_contact_frames)
282
390
 
283
- return states
391
+ # Assign states
392
+ return _assign_contact_states(n_frames, contact_frames, visibilities, visibility_threshold)
284
393
 
285
394
 
286
395
  def find_contact_phases(
@@ -315,42 +424,65 @@ def find_contact_phases(
315
424
  return phases
316
425
 
317
426
 
318
- def interpolate_threshold_crossing(
319
- vel_before: float,
320
- vel_after: float,
427
+ def _interpolate_phase_start(
428
+ start_idx: int,
429
+ state: ContactState,
430
+ velocities: FloatArray,
321
431
  velocity_threshold: float,
322
432
  ) -> float:
433
+ """Interpolate start boundary of a phase with sub-frame precision.
434
+
435
+ Returns:
436
+ Fractional start frame, or float(start_idx) if no interpolation.
323
437
  """
324
- Find fractional offset where velocity crosses threshold between two frames.
438
+ if start_idx <= 0 or start_idx >= len(velocities):
439
+ return float(start_idx)
325
440
 
326
- Uses linear interpolation assuming velocity changes linearly between frames.
441
+ vel_before = velocities[start_idx - 1]
442
+ vel_at = velocities[start_idx]
327
443
 
328
- Args:
329
- vel_before: Velocity at frame boundary N (absolute value)
330
- vel_after: Velocity at frame boundary N+1 (absolute value)
331
- velocity_threshold: Threshold value
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)
453
+
454
+
455
+ def _interpolate_phase_end(
456
+ end_idx: int,
457
+ state: ContactState,
458
+ velocities: FloatArray,
459
+ velocity_threshold: float,
460
+ max_idx: int,
461
+ ) -> float:
462
+ """Interpolate end boundary of a phase with sub-frame precision.
332
463
 
333
464
  Returns:
334
- Fractional offset from frame N (0.0 to 1.0)
465
+ Fractional end frame, or float(end_idx) if no interpolation.
335
466
  """
336
- # Handle edge cases
337
- if abs(vel_after - vel_before) < 1e-9: # Velocity not changing
338
- return 0.5
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]
339
472
 
340
- # Linear interpolation: at what fraction t does velocity equal threshold?
341
- # vel(t) = vel_before + t * (vel_after - vel_before)
342
- # Solve for t when vel(t) = threshold:
343
- # threshold = vel_before + t * (vel_after - vel_before)
344
- # t = (threshold - vel_before) / (vel_after - vel_before)
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
345
476
 
346
- t = (velocity_threshold - vel_before) / (vel_after - vel_before)
477
+ if is_takeoff or is_landing:
478
+ offset = interpolate_threshold_crossing(vel_at, vel_after, velocity_threshold)
479
+ return end_idx + offset
347
480
 
348
- # Clamp to [0, 1] range
349
- return float(max(0.0, min(1.0, t)))
481
+ return float(end_idx)
350
482
 
351
483
 
352
484
  def find_interpolated_phase_transitions(
353
- foot_positions: np.ndarray,
485
+ foot_positions: FloatArray,
354
486
  contact_states: list[ContactState],
355
487
  velocity_threshold: float,
356
488
  smoothing_window: int = 5,
@@ -371,13 +503,10 @@ def find_interpolated_phase_transitions(
371
503
  Returns:
372
504
  List of (start_frame, end_frame, state) tuples with fractional frame indices
373
505
  """
374
- # First get integer frame phases
375
506
  phases = find_contact_phases(contact_states)
376
507
  if not phases or len(foot_positions) < 2:
377
508
  return []
378
509
 
379
- # Compute velocities from derivative of smoothed trajectory
380
- # This gives much smoother velocity estimates than simple frame differences
381
510
  velocities = compute_velocity_from_derivative(
382
511
  foot_positions, window_length=smoothing_window, polyorder=2
383
512
  )
@@ -385,64 +514,17 @@ def find_interpolated_phase_transitions(
385
514
  interpolated_phases: list[tuple[float, float, ContactState]] = []
386
515
 
387
516
  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
-
517
+ start_frac = _interpolate_phase_start(start_idx, state, velocities, velocity_threshold)
518
+ end_frac = _interpolate_phase_end(
519
+ end_idx, state, velocities, velocity_threshold, len(foot_positions)
520
+ )
439
521
  interpolated_phases.append((start_frac, end_frac, state))
440
522
 
441
523
  return interpolated_phases
442
524
 
443
525
 
444
526
  def refine_transition_with_curvature(
445
- foot_positions: np.ndarray,
527
+ foot_positions: FloatArray,
446
528
  estimated_frame: float,
447
529
  transition_type: str,
448
530
  search_window: int = 3,
@@ -517,7 +599,7 @@ def refine_transition_with_curvature(
517
599
 
518
600
 
519
601
  def find_interpolated_phase_transitions_with_curvature(
520
- foot_positions: np.ndarray,
602
+ foot_positions: FloatArray,
521
603
  contact_states: list[ContactState],
522
604
  velocity_threshold: float,
523
605
  smoothing_window: int = 5,
@@ -602,6 +684,57 @@ def find_interpolated_phase_transitions_with_curvature(
602
684
  return refined_phases
603
685
 
604
686
 
687
+ def find_landing_from_acceleration(
688
+ positions: FloatArray,
689
+ accelerations: FloatArray,
690
+ takeoff_frame: int,
691
+ fps: float,
692
+ search_duration: float = 0.7,
693
+ ) -> int:
694
+ """
695
+ Find landing frame by detecting impact acceleration after takeoff.
696
+
697
+ Detects the moment of initial ground contact, characterized by a sharp
698
+ deceleration (positive acceleration spike) as downward velocity is arrested.
699
+
700
+ Args:
701
+ positions: Array of vertical positions (normalized 0-1)
702
+ accelerations: Array of accelerations (second derivative)
703
+ takeoff_frame: Frame at takeoff (end of ground contact)
704
+ fps: Video frame rate
705
+ search_duration: Duration in seconds to search for landing (default: 0.7s)
706
+
707
+ Returns:
708
+ Landing frame index (integer)
709
+ """
710
+ # Find peak height (minimum y value = highest point)
711
+ search_start = takeoff_frame
712
+ search_end = min(len(positions), takeoff_frame + int(fps * search_duration))
713
+
714
+ if search_end <= search_start:
715
+ return min(len(positions) - 1, takeoff_frame + int(fps * 0.3))
716
+
717
+ flight_positions = positions[search_start:search_end]
718
+ peak_idx = int(np.argmin(flight_positions))
719
+ peak_frame = search_start + peak_idx
720
+
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))
725
+
726
+ if landing_search_end <= landing_search_start:
727
+ return min(len(positions) - 1, peak_frame + int(fps * 0.2))
728
+
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
734
+
735
+ return landing_frame
736
+
737
+
605
738
  def compute_average_foot_position(
606
739
  landmarks: dict[str, tuple[float, float, float]],
607
740
  ) -> tuple[float, float]:
@@ -637,3 +770,55 @@ def compute_average_foot_position(
637
770
  return (0.5, 0.5) # Default to center if no visible feet
638
771
 
639
772
  return (float(np.mean(x_positions)), float(np.mean(y_positions)))
773
+
774
+
775
+ def _calculate_average_visibility(
776
+ frame_landmarks: dict[str, tuple[float, float, float]],
777
+ ) -> float:
778
+ """Calculate average visibility of foot landmarks in a frame.
779
+
780
+ Args:
781
+ frame_landmarks: Landmark dictionary for a single frame
782
+
783
+ Returns:
784
+ Average visibility of foot landmarks (0.0 if none visible)
785
+ """
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]
788
+ return float(np.mean(foot_vis)) if foot_vis else 0.0
789
+
790
+
791
+ @unused(
792
+ reason="Alternative implementation not called by pipeline",
793
+ since="0.34.0",
794
+ )
795
+ def extract_foot_positions_and_visibilities(
796
+ smoothed_landmarks: list[dict[str, tuple[float, float, float]] | None],
797
+ ) -> tuple[np.ndarray, np.ndarray]:
798
+ """
799
+ Extract vertical positions and average visibilities from smoothed
800
+ landmarks.
801
+
802
+ This utility function eliminates code duplication between CLI and
803
+ programmatic usage.
804
+
805
+ Args:
806
+ smoothed_landmarks: Smoothed landmark sequence from tracking
807
+
808
+ Returns:
809
+ Tuple of (vertical_positions, visibilities) as numpy arrays
810
+ """
811
+ position_list: list[float] = []
812
+ visibilities_list: list[float] = []
813
+
814
+ for frame_landmarks in smoothed_landmarks:
815
+ if frame_landmarks:
816
+ _, foot_y = compute_average_foot_position(frame_landmarks)
817
+ position_list.append(foot_y)
818
+ visibilities_list.append(_calculate_average_visibility(frame_landmarks))
819
+ else:
820
+ # Fill missing frames with last known position or default
821
+ position_list.append(position_list[-1] if position_list else 0.5)
822
+ visibilities_list.append(0.0)
823
+
824
+ return np.array(position_list), np.array(visibilities_list)