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.
- kinemotion/__init__.py +31 -6
- kinemotion/api.py +39 -598
- kinemotion/cli.py +2 -0
- kinemotion/cmj/__init__.py +5 -0
- kinemotion/cmj/analysis.py +621 -0
- kinemotion/cmj/api.py +563 -0
- kinemotion/cmj/cli.py +324 -0
- kinemotion/cmj/debug_overlay.py +457 -0
- kinemotion/cmj/joint_angles.py +307 -0
- kinemotion/cmj/kinematics.py +360 -0
- kinemotion/cmj/metrics_validator.py +767 -0
- kinemotion/cmj/validation_bounds.py +341 -0
- kinemotion/core/__init__.py +28 -0
- kinemotion/core/auto_tuning.py +71 -37
- kinemotion/core/cli_utils.py +60 -0
- kinemotion/core/debug_overlay_utils.py +385 -0
- kinemotion/core/determinism.py +83 -0
- kinemotion/core/experimental.py +103 -0
- kinemotion/core/filtering.py +9 -6
- kinemotion/core/formatting.py +75 -0
- kinemotion/core/metadata.py +231 -0
- kinemotion/core/model_downloader.py +172 -0
- kinemotion/core/pipeline_utils.py +433 -0
- kinemotion/core/pose.py +298 -141
- kinemotion/core/pose_landmarks.py +67 -0
- kinemotion/core/quality.py +393 -0
- kinemotion/core/smoothing.py +250 -154
- kinemotion/core/timing.py +247 -0
- kinemotion/core/types.py +42 -0
- kinemotion/core/validation.py +201 -0
- kinemotion/core/video_io.py +135 -50
- kinemotion/dropjump/__init__.py +1 -1
- kinemotion/dropjump/analysis.py +367 -182
- kinemotion/dropjump/api.py +665 -0
- kinemotion/dropjump/cli.py +156 -466
- kinemotion/dropjump/debug_overlay.py +136 -206
- kinemotion/dropjump/kinematics.py +232 -255
- kinemotion/dropjump/metrics_validator.py +240 -0
- kinemotion/dropjump/validation_bounds.py +157 -0
- kinemotion/models/__init__.py +0 -0
- kinemotion/models/pose_landmarker_lite.task +0 -0
- kinemotion-0.67.0.dist-info/METADATA +726 -0
- kinemotion-0.67.0.dist-info/RECORD +47 -0
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/WHEEL +1 -1
- kinemotion-0.10.6.dist-info/METADATA +0 -561
- kinemotion-0.10.6.dist-info/RECORD +0 -20
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/licenses/LICENSE +0 -0
kinemotion/dropjump/analysis.py
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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)} < {
|
|
233
|
+
f"[detect_drop_start] Video too short: {len(positions)} < {min_stable_frames + 30}"
|
|
134
234
|
)
|
|
135
235
|
return 0
|
|
136
236
|
|
|
137
|
-
#
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
143
|
-
|
|
242
|
+
if baseline_start < 0:
|
|
243
|
+
return 0
|
|
144
244
|
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
204
|
-
if
|
|
205
|
-
|
|
206
|
-
|
|
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:
|
|
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:
|
|
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-
|
|
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
|
|
371
|
+
return [ContactState.UNKNOWN] * n_frames
|
|
242
372
|
|
|
243
373
|
# Compute vertical velocity using derivative-based method
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
#
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
438
|
+
if start_idx <= 0 or start_idx >= len(velocities):
|
|
439
|
+
return float(start_idx)
|
|
325
440
|
|
|
326
|
-
|
|
441
|
+
vel_before = velocities[start_idx - 1]
|
|
442
|
+
vel_at = velocities[start_idx]
|
|
327
443
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
465
|
+
Fractional end frame, or float(end_idx) if no interpolation.
|
|
335
466
|
"""
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
#
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
389
|
-
end_frac =
|
|
390
|
-
|
|
391
|
-
|
|
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:
|
|
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:
|
|
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)
|