kinemotion 0.10.2__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/__init__.py +14 -0
- kinemotion/api.py +428 -0
- kinemotion/cli.py +20 -0
- kinemotion/core/__init__.py +40 -0
- kinemotion/core/auto_tuning.py +289 -0
- kinemotion/core/filtering.py +345 -0
- kinemotion/core/pose.py +220 -0
- kinemotion/core/smoothing.py +366 -0
- kinemotion/core/video_io.py +166 -0
- kinemotion/dropjump/__init__.py +29 -0
- kinemotion/dropjump/analysis.py +639 -0
- kinemotion/dropjump/cli.py +738 -0
- kinemotion/dropjump/debug_overlay.py +252 -0
- kinemotion/dropjump/kinematics.py +439 -0
- kinemotion/py.typed +0 -0
- kinemotion-0.10.2.dist-info/METADATA +561 -0
- kinemotion-0.10.2.dist-info/RECORD +20 -0
- kinemotion-0.10.2.dist-info/WHEEL +4 -0
- kinemotion-0.10.2.dist-info/entry_points.txt +2 -0
- kinemotion-0.10.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
"""Ground contact detection logic for drop-jump analysis."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ..core.smoothing import (
|
|
8
|
+
compute_acceleration_from_derivative,
|
|
9
|
+
compute_velocity_from_derivative,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContactState(Enum):
|
|
14
|
+
"""States for foot-ground contact."""
|
|
15
|
+
|
|
16
|
+
IN_AIR = "in_air"
|
|
17
|
+
ON_GROUND = "on_ground"
|
|
18
|
+
UNKNOWN = "unknown"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def calculate_adaptive_threshold(
|
|
22
|
+
positions: np.ndarray,
|
|
23
|
+
fps: float,
|
|
24
|
+
baseline_duration: float = 3.0,
|
|
25
|
+
multiplier: float = 1.5,
|
|
26
|
+
smoothing_window: int = 5,
|
|
27
|
+
polyorder: int = 2,
|
|
28
|
+
) -> float:
|
|
29
|
+
"""
|
|
30
|
+
Calculate adaptive velocity threshold based on baseline motion characteristics.
|
|
31
|
+
|
|
32
|
+
Analyzes the first few seconds of video (assumed to be relatively stationary,
|
|
33
|
+
e.g., athlete standing on box) to determine the noise floor, then sets threshold
|
|
34
|
+
as a multiple of this baseline noise.
|
|
35
|
+
|
|
36
|
+
This adapts to:
|
|
37
|
+
- Different camera distances (closer = more pixel movement)
|
|
38
|
+
- Different lighting conditions (affects tracking quality)
|
|
39
|
+
- Different frame rates (higher fps = smoother motion)
|
|
40
|
+
- Video compression artifacts
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
positions: Array of vertical positions (0-1 normalized)
|
|
44
|
+
fps: Video frame rate
|
|
45
|
+
baseline_duration: Duration in seconds to analyze for baseline (default: 3.0s)
|
|
46
|
+
multiplier: Factor above baseline noise to set threshold (default: 1.5x)
|
|
47
|
+
smoothing_window: Window size for velocity computation
|
|
48
|
+
polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Adaptive velocity threshold value
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
At 30fps with 3s baseline:
|
|
55
|
+
- Analyzes first 90 frames
|
|
56
|
+
- Computes velocity for this "stationary" period
|
|
57
|
+
- 95th percentile velocity = 0.012 (noise level)
|
|
58
|
+
- Threshold = 0.012 × 1.5 = 0.018
|
|
59
|
+
"""
|
|
60
|
+
if len(positions) < 2:
|
|
61
|
+
return 0.02 # Fallback to default
|
|
62
|
+
|
|
63
|
+
# Calculate number of frames for baseline analysis
|
|
64
|
+
baseline_frames = int(fps * baseline_duration)
|
|
65
|
+
baseline_frames = min(baseline_frames, len(positions))
|
|
66
|
+
|
|
67
|
+
if baseline_frames < smoothing_window:
|
|
68
|
+
return 0.02 # Not enough data, use default
|
|
69
|
+
|
|
70
|
+
# Extract baseline period (assumed relatively stationary)
|
|
71
|
+
baseline_positions = positions[:baseline_frames]
|
|
72
|
+
|
|
73
|
+
# Compute velocity for baseline period using derivative
|
|
74
|
+
baseline_velocities = compute_velocity_from_derivative(
|
|
75
|
+
baseline_positions, window_length=smoothing_window, polyorder=polyorder
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Calculate noise floor as 95th percentile of baseline velocities
|
|
79
|
+
# Using 95th percentile instead of max to be robust against outliers
|
|
80
|
+
noise_floor = float(np.percentile(np.abs(baseline_velocities), 95))
|
|
81
|
+
|
|
82
|
+
# Set threshold as multiplier of noise floor
|
|
83
|
+
# Minimum threshold to avoid being too sensitive
|
|
84
|
+
adaptive_threshold = max(noise_floor * multiplier, 0.005)
|
|
85
|
+
|
|
86
|
+
# Maximum threshold to ensure we still detect contact
|
|
87
|
+
adaptive_threshold = min(adaptive_threshold, 0.05)
|
|
88
|
+
|
|
89
|
+
return adaptive_threshold
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def detect_drop_start(
|
|
93
|
+
positions: np.ndarray,
|
|
94
|
+
fps: float,
|
|
95
|
+
min_stationary_duration: float = 1.0,
|
|
96
|
+
position_change_threshold: float = 0.02,
|
|
97
|
+
smoothing_window: int = 5,
|
|
98
|
+
debug: bool = False,
|
|
99
|
+
) -> int:
|
|
100
|
+
"""
|
|
101
|
+
Detect when the drop jump actually starts by finding stable period then detecting drop.
|
|
102
|
+
|
|
103
|
+
Strategy:
|
|
104
|
+
1. Scan forward to find first STABLE period (low variance over N frames)
|
|
105
|
+
2. Use that stable period as baseline
|
|
106
|
+
3. Detect when position starts changing significantly from baseline
|
|
107
|
+
|
|
108
|
+
This handles videos where athlete steps onto box at start (unstable beginning).
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
positions: Array of vertical positions (0-1 normalized, y increases downward)
|
|
112
|
+
fps: Video frame rate
|
|
113
|
+
min_stationary_duration: Minimum duration (seconds) of stable period (default: 1.0s)
|
|
114
|
+
position_change_threshold: Position change indicating start of drop
|
|
115
|
+
(default: 0.02 = 2% of frame)
|
|
116
|
+
smoothing_window: Window for computing position variance
|
|
117
|
+
debug: Print debug information (default: False)
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Frame index where drop starts (or 0 if no clear stable period found)
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
- Frames 0-14: Stepping onto box (noisy, unstable)
|
|
124
|
+
- Frames 15-119: Standing on box (stable, low variance)
|
|
125
|
+
- Frame 119: Drop begins (position changes significantly)
|
|
126
|
+
- Returns: 119
|
|
127
|
+
"""
|
|
128
|
+
min_stable_frames = int(fps * min_stationary_duration)
|
|
129
|
+
if len(positions) < min_stable_frames + 30: # Need some frames after stable period
|
|
130
|
+
if debug:
|
|
131
|
+
min_frames_needed = min_stable_frames + 30
|
|
132
|
+
print(
|
|
133
|
+
f"[detect_drop_start] Video too short: {len(positions)} < {min_frames_needed}"
|
|
134
|
+
)
|
|
135
|
+
return 0
|
|
136
|
+
|
|
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
|
|
162
|
+
|
|
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
|
|
170
|
+
|
|
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
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def detect_ground_contact(
|
|
210
|
+
foot_positions: np.ndarray,
|
|
211
|
+
velocity_threshold: float = 0.02,
|
|
212
|
+
min_contact_frames: int = 3,
|
|
213
|
+
visibility_threshold: float = 0.5,
|
|
214
|
+
visibilities: np.ndarray | None = None,
|
|
215
|
+
window_length: int = 5,
|
|
216
|
+
polyorder: int = 2,
|
|
217
|
+
) -> list[ContactState]:
|
|
218
|
+
"""
|
|
219
|
+
Detect when feet are in contact with ground based on vertical motion.
|
|
220
|
+
|
|
221
|
+
Uses derivative-based velocity calculation via Savitzky-Golay filter for smooth,
|
|
222
|
+
accurate velocity estimates. This is consistent with the velocity calculation used
|
|
223
|
+
throughout the pipeline for sub-frame interpolation and curvature analysis.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
foot_positions: Array of foot y-positions (normalized, 0-1, where 1 is bottom)
|
|
227
|
+
velocity_threshold: Threshold for vertical velocity to consider stationary
|
|
228
|
+
min_contact_frames: Minimum consecutive frames to confirm contact
|
|
229
|
+
visibility_threshold: Minimum visibility score to trust landmark
|
|
230
|
+
visibilities: Array of visibility scores for each frame
|
|
231
|
+
window_length: Window size for velocity derivative calculation (must be odd)
|
|
232
|
+
polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
List of ContactState for each frame
|
|
236
|
+
"""
|
|
237
|
+
n_frames = len(foot_positions)
|
|
238
|
+
states = [ContactState.UNKNOWN] * n_frames
|
|
239
|
+
|
|
240
|
+
if n_frames < 2:
|
|
241
|
+
return states
|
|
242
|
+
|
|
243
|
+
# 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
|
+
)
|
|
249
|
+
|
|
250
|
+
# Detect potential contact frames based on low velocity
|
|
251
|
+
is_stationary = np.abs(velocities) < velocity_threshold
|
|
252
|
+
|
|
253
|
+
# 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)
|
|
273
|
+
|
|
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
|
|
282
|
+
|
|
283
|
+
return states
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def find_contact_phases(
|
|
287
|
+
contact_states: list[ContactState],
|
|
288
|
+
) -> list[tuple[int, int, ContactState]]:
|
|
289
|
+
"""
|
|
290
|
+
Identify continuous phases of contact/flight.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
contact_states: List of ContactState for each frame
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
List of (start_frame, end_frame, state) tuples for each phase
|
|
297
|
+
"""
|
|
298
|
+
phases: list[tuple[int, int, ContactState]] = []
|
|
299
|
+
if not contact_states:
|
|
300
|
+
return phases
|
|
301
|
+
|
|
302
|
+
current_state = contact_states[0]
|
|
303
|
+
phase_start = 0
|
|
304
|
+
|
|
305
|
+
for i in range(1, len(contact_states)):
|
|
306
|
+
if contact_states[i] != current_state:
|
|
307
|
+
# Phase transition
|
|
308
|
+
phases.append((phase_start, i - 1, current_state))
|
|
309
|
+
current_state = contact_states[i]
|
|
310
|
+
phase_start = i
|
|
311
|
+
|
|
312
|
+
# Don't forget the last phase
|
|
313
|
+
phases.append((phase_start, len(contact_states) - 1, current_state))
|
|
314
|
+
|
|
315
|
+
return phases
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def interpolate_threshold_crossing(
|
|
319
|
+
vel_before: float,
|
|
320
|
+
vel_after: float,
|
|
321
|
+
velocity_threshold: float,
|
|
322
|
+
) -> float:
|
|
323
|
+
"""
|
|
324
|
+
Find fractional offset where velocity crosses threshold between two frames.
|
|
325
|
+
|
|
326
|
+
Uses linear interpolation assuming velocity changes linearly between frames.
|
|
327
|
+
|
|
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
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
Fractional offset from frame N (0.0 to 1.0)
|
|
335
|
+
"""
|
|
336
|
+
# Handle edge cases
|
|
337
|
+
if abs(vel_after - vel_before) < 1e-9: # Velocity not changing
|
|
338
|
+
return 0.5
|
|
339
|
+
|
|
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)
|
|
345
|
+
|
|
346
|
+
t = (velocity_threshold - vel_before) / (vel_after - vel_before)
|
|
347
|
+
|
|
348
|
+
# Clamp to [0, 1] range
|
|
349
|
+
return float(max(0.0, min(1.0, t)))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def find_interpolated_phase_transitions(
|
|
353
|
+
foot_positions: np.ndarray,
|
|
354
|
+
contact_states: list[ContactState],
|
|
355
|
+
velocity_threshold: float,
|
|
356
|
+
smoothing_window: int = 5,
|
|
357
|
+
) -> list[tuple[float, float, ContactState]]:
|
|
358
|
+
"""
|
|
359
|
+
Find contact phases with sub-frame interpolation for precise timing.
|
|
360
|
+
|
|
361
|
+
Uses derivative-based velocity from smoothed trajectory for interpolation.
|
|
362
|
+
This provides much smoother velocity estimates than frame-to-frame differences,
|
|
363
|
+
leading to more accurate threshold crossing detection.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
foot_positions: Array of foot y-positions (normalized, 0-1)
|
|
367
|
+
contact_states: List of ContactState for each frame
|
|
368
|
+
velocity_threshold: Threshold used for contact detection
|
|
369
|
+
smoothing_window: Window size for velocity smoothing (must be odd)
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
List of (start_frame, end_frame, state) tuples with fractional frame indices
|
|
373
|
+
"""
|
|
374
|
+
# First get integer frame phases
|
|
375
|
+
phases = find_contact_phases(contact_states)
|
|
376
|
+
if not phases or len(foot_positions) < 2:
|
|
377
|
+
return []
|
|
378
|
+
|
|
379
|
+
# Compute velocities from derivative of smoothed trajectory
|
|
380
|
+
# This gives much smoother velocity estimates than simple frame differences
|
|
381
|
+
velocities = compute_velocity_from_derivative(
|
|
382
|
+
foot_positions, window_length=smoothing_window, polyorder=2
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
interpolated_phases: list[tuple[float, float, ContactState]] = []
|
|
386
|
+
|
|
387
|
+
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
|
+
|
|
439
|
+
interpolated_phases.append((start_frac, end_frac, state))
|
|
440
|
+
|
|
441
|
+
return interpolated_phases
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def refine_transition_with_curvature(
|
|
445
|
+
foot_positions: np.ndarray,
|
|
446
|
+
estimated_frame: float,
|
|
447
|
+
transition_type: str,
|
|
448
|
+
search_window: int = 3,
|
|
449
|
+
smoothing_window: int = 5,
|
|
450
|
+
polyorder: int = 2,
|
|
451
|
+
) -> float:
|
|
452
|
+
"""
|
|
453
|
+
Refine phase transition timing using trajectory curvature analysis.
|
|
454
|
+
|
|
455
|
+
Looks for characteristic acceleration patterns near estimated transition:
|
|
456
|
+
- Landing: Large acceleration spike (rapid deceleration on impact)
|
|
457
|
+
- Takeoff: Acceleration change (transition from static to upward motion)
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
foot_positions: Array of foot y-positions (normalized, 0-1)
|
|
461
|
+
estimated_frame: Initial estimate of transition frame (from velocity)
|
|
462
|
+
transition_type: Type of transition ("landing" or "takeoff")
|
|
463
|
+
search_window: Number of frames to search around estimate
|
|
464
|
+
smoothing_window: Window size for acceleration computation
|
|
465
|
+
polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
Refined fractional frame index
|
|
469
|
+
"""
|
|
470
|
+
if len(foot_positions) < smoothing_window:
|
|
471
|
+
return estimated_frame
|
|
472
|
+
|
|
473
|
+
# Compute acceleration (second derivative)
|
|
474
|
+
acceleration = compute_acceleration_from_derivative(
|
|
475
|
+
foot_positions, window_length=smoothing_window, polyorder=polyorder
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# Define search range around estimated transition
|
|
479
|
+
est_int = int(estimated_frame)
|
|
480
|
+
search_start = max(0, est_int - search_window)
|
|
481
|
+
search_end = min(len(acceleration), est_int + search_window + 1)
|
|
482
|
+
|
|
483
|
+
if search_end <= search_start:
|
|
484
|
+
return estimated_frame
|
|
485
|
+
|
|
486
|
+
# Extract acceleration in search window
|
|
487
|
+
accel_window = acceleration[search_start:search_end]
|
|
488
|
+
|
|
489
|
+
if len(accel_window) == 0:
|
|
490
|
+
return estimated_frame
|
|
491
|
+
|
|
492
|
+
if transition_type == "landing":
|
|
493
|
+
# Landing: Look for large magnitude acceleration (impact deceleration)
|
|
494
|
+
# Find frame with maximum absolute acceleration
|
|
495
|
+
peak_idx = np.argmax(np.abs(accel_window))
|
|
496
|
+
refined_frame = float(search_start + peak_idx)
|
|
497
|
+
|
|
498
|
+
elif transition_type == "takeoff":
|
|
499
|
+
# Takeoff: Look for acceleration magnitude change
|
|
500
|
+
# Find frame with large acceleration change (derivative of acceleration)
|
|
501
|
+
if len(accel_window) < 2:
|
|
502
|
+
return estimated_frame
|
|
503
|
+
|
|
504
|
+
accel_diff = np.abs(np.diff(accel_window))
|
|
505
|
+
peak_idx = np.argmax(accel_diff)
|
|
506
|
+
refined_frame = float(search_start + peak_idx)
|
|
507
|
+
|
|
508
|
+
else:
|
|
509
|
+
return estimated_frame
|
|
510
|
+
|
|
511
|
+
# Blend with original estimate (don't stray too far)
|
|
512
|
+
# 70% curvature-based, 30% velocity-based
|
|
513
|
+
blend_factor = 0.7
|
|
514
|
+
refined_frame = blend_factor * refined_frame + (1 - blend_factor) * estimated_frame
|
|
515
|
+
|
|
516
|
+
return refined_frame
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def find_interpolated_phase_transitions_with_curvature(
|
|
520
|
+
foot_positions: np.ndarray,
|
|
521
|
+
contact_states: list[ContactState],
|
|
522
|
+
velocity_threshold: float,
|
|
523
|
+
smoothing_window: int = 5,
|
|
524
|
+
polyorder: int = 2,
|
|
525
|
+
use_curvature: bool = True,
|
|
526
|
+
) -> list[tuple[float, float, ContactState]]:
|
|
527
|
+
"""
|
|
528
|
+
Find contact phases with sub-frame interpolation and curvature refinement.
|
|
529
|
+
|
|
530
|
+
Combines three methods for maximum accuracy:
|
|
531
|
+
1. Velocity thresholding (coarse integer frame detection)
|
|
532
|
+
2. Velocity interpolation (sub-frame precision)
|
|
533
|
+
3. Curvature analysis (refinement based on acceleration patterns)
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
foot_positions: Array of foot y-positions (normalized, 0-1)
|
|
537
|
+
contact_states: List of ContactState for each frame
|
|
538
|
+
velocity_threshold: Threshold used for contact detection
|
|
539
|
+
smoothing_window: Window size for velocity/acceleration smoothing
|
|
540
|
+
polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
|
|
541
|
+
use_curvature: Whether to apply curvature-based refinement
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
List of (start_frame, end_frame, state) tuples with fractional frame indices
|
|
545
|
+
"""
|
|
546
|
+
# Get interpolated phases using velocity
|
|
547
|
+
interpolated_phases = find_interpolated_phase_transitions(
|
|
548
|
+
foot_positions, contact_states, velocity_threshold, smoothing_window
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
if not use_curvature or len(interpolated_phases) == 0:
|
|
552
|
+
return interpolated_phases
|
|
553
|
+
|
|
554
|
+
# Refine phase boundaries using curvature analysis
|
|
555
|
+
refined_phases: list[tuple[float, float, ContactState]] = []
|
|
556
|
+
|
|
557
|
+
for start_frac, end_frac, state in interpolated_phases:
|
|
558
|
+
refined_start = start_frac
|
|
559
|
+
refined_end = end_frac
|
|
560
|
+
|
|
561
|
+
if state == ContactState.ON_GROUND:
|
|
562
|
+
# Refine landing (start of ground contact)
|
|
563
|
+
refined_start = refine_transition_with_curvature(
|
|
564
|
+
foot_positions,
|
|
565
|
+
start_frac,
|
|
566
|
+
"landing",
|
|
567
|
+
search_window=3,
|
|
568
|
+
smoothing_window=smoothing_window,
|
|
569
|
+
polyorder=polyorder,
|
|
570
|
+
)
|
|
571
|
+
# Refine takeoff (end of ground contact)
|
|
572
|
+
refined_end = refine_transition_with_curvature(
|
|
573
|
+
foot_positions,
|
|
574
|
+
end_frac,
|
|
575
|
+
"takeoff",
|
|
576
|
+
search_window=3,
|
|
577
|
+
smoothing_window=smoothing_window,
|
|
578
|
+
polyorder=polyorder,
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
elif state == ContactState.IN_AIR:
|
|
582
|
+
# For flight phases, takeoff is at start, landing is at end
|
|
583
|
+
refined_start = refine_transition_with_curvature(
|
|
584
|
+
foot_positions,
|
|
585
|
+
start_frac,
|
|
586
|
+
"takeoff",
|
|
587
|
+
search_window=3,
|
|
588
|
+
smoothing_window=smoothing_window,
|
|
589
|
+
polyorder=polyorder,
|
|
590
|
+
)
|
|
591
|
+
refined_end = refine_transition_with_curvature(
|
|
592
|
+
foot_positions,
|
|
593
|
+
end_frac,
|
|
594
|
+
"landing",
|
|
595
|
+
search_window=3,
|
|
596
|
+
smoothing_window=smoothing_window,
|
|
597
|
+
polyorder=polyorder,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
refined_phases.append((refined_start, refined_end, state))
|
|
601
|
+
|
|
602
|
+
return refined_phases
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def compute_average_foot_position(
|
|
606
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
607
|
+
) -> tuple[float, float]:
|
|
608
|
+
"""
|
|
609
|
+
Compute average foot position from ankle and foot landmarks.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
landmarks: Dictionary of landmark positions
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
(x, y) average foot position in normalized coordinates
|
|
616
|
+
"""
|
|
617
|
+
foot_keys = [
|
|
618
|
+
"left_ankle",
|
|
619
|
+
"right_ankle",
|
|
620
|
+
"left_heel",
|
|
621
|
+
"right_heel",
|
|
622
|
+
"left_foot_index",
|
|
623
|
+
"right_foot_index",
|
|
624
|
+
]
|
|
625
|
+
|
|
626
|
+
x_positions = []
|
|
627
|
+
y_positions = []
|
|
628
|
+
|
|
629
|
+
for key in foot_keys:
|
|
630
|
+
if key in landmarks:
|
|
631
|
+
x, y, visibility = landmarks[key]
|
|
632
|
+
if visibility > 0.5: # Only use visible landmarks
|
|
633
|
+
x_positions.append(x)
|
|
634
|
+
y_positions.append(y)
|
|
635
|
+
|
|
636
|
+
if not x_positions:
|
|
637
|
+
return (0.5, 0.5) # Default to center if no visible feet
|
|
638
|
+
|
|
639
|
+
return (float(np.mean(x_positions)), float(np.mean(y_positions)))
|