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
|
@@ -1,14 +1,54 @@
|
|
|
1
1
|
"""Kinematic calculations for drop-jump metrics."""
|
|
2
2
|
|
|
3
|
+
from typing import TYPE_CHECKING, TypedDict
|
|
4
|
+
|
|
3
5
|
import numpy as np
|
|
6
|
+
from numpy.typing import NDArray
|
|
4
7
|
|
|
8
|
+
from ..core.formatting import format_float_metric, format_int_metric
|
|
9
|
+
from ..core.smoothing import compute_acceleration_from_derivative
|
|
10
|
+
from ..core.timing import NULL_TIMER, Timer
|
|
5
11
|
from .analysis import (
|
|
6
12
|
ContactState,
|
|
7
13
|
detect_drop_start,
|
|
8
14
|
find_contact_phases,
|
|
9
15
|
find_interpolated_phase_transitions_with_curvature,
|
|
16
|
+
find_landing_from_acceleration,
|
|
10
17
|
)
|
|
11
18
|
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from ..core.dropjump_metrics_validator import ValidationResult
|
|
21
|
+
from ..core.metadata import ResultMetadata
|
|
22
|
+
from ..core.quality import QualityAssessment
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DropJumpDataDict(TypedDict, total=False):
|
|
26
|
+
"""Type-safe dictionary for drop jump measurement data."""
|
|
27
|
+
|
|
28
|
+
ground_contact_time_ms: float | None
|
|
29
|
+
flight_time_ms: float | None
|
|
30
|
+
jump_height_m: float | None
|
|
31
|
+
jump_height_kinematic_m: float | None
|
|
32
|
+
jump_height_trajectory_m: float | None
|
|
33
|
+
jump_height_trajectory_normalized: float | None
|
|
34
|
+
contact_start_frame: int | None
|
|
35
|
+
contact_end_frame: int | None
|
|
36
|
+
flight_start_frame: int | None
|
|
37
|
+
flight_end_frame: int | None
|
|
38
|
+
peak_height_frame: int | None
|
|
39
|
+
contact_start_frame_precise: float | None
|
|
40
|
+
contact_end_frame_precise: float | None
|
|
41
|
+
flight_start_frame_precise: float | None
|
|
42
|
+
flight_end_frame_precise: float | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DropJumpResultDict(TypedDict, total=False):
|
|
46
|
+
"""Type-safe dictionary for complete drop jump result with data and metadata."""
|
|
47
|
+
|
|
48
|
+
data: DropJumpDataDict
|
|
49
|
+
metadata: dict # ResultMetadata.to_dict()
|
|
50
|
+
validation: dict # ValidationResult.to_dict()
|
|
51
|
+
|
|
12
52
|
|
|
13
53
|
class DropJumpMetrics:
|
|
14
54
|
"""Container for drop-jump analysis metrics."""
|
|
@@ -18,7 +58,11 @@ class DropJumpMetrics:
|
|
|
18
58
|
self.flight_time: float | None = None
|
|
19
59
|
self.jump_height: float | None = None
|
|
20
60
|
self.jump_height_kinematic: float | None = None # From flight time
|
|
21
|
-
|
|
61
|
+
# From position tracking (normalized)
|
|
62
|
+
self.jump_height_trajectory: float | None = None
|
|
63
|
+
# From position tracking (meters)
|
|
64
|
+
self.jump_height_trajectory_m: float | None = None
|
|
65
|
+
self.drop_start_frame: int | None = None # Frame when athlete leaves box
|
|
22
66
|
self.contact_start_frame: int | None = None
|
|
23
67
|
self.contact_end_frame: int | None = None
|
|
24
68
|
self.flight_start_frame: int | None = None
|
|
@@ -29,84 +73,76 @@ class DropJumpMetrics:
|
|
|
29
73
|
self.contact_end_frame_precise: float | None = None
|
|
30
74
|
self.flight_start_frame_precise: float | None = None
|
|
31
75
|
self.flight_end_frame_precise: float | None = None
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
76
|
+
# Quality assessment
|
|
77
|
+
self.quality_assessment: QualityAssessment | None = None
|
|
78
|
+
# Complete metadata
|
|
79
|
+
self.result_metadata: ResultMetadata | None = None
|
|
80
|
+
# Validation result
|
|
81
|
+
self.validation_result: ValidationResult | None = None
|
|
82
|
+
|
|
83
|
+
def _build_data_dict(self) -> DropJumpDataDict:
|
|
84
|
+
"""Build the data portion of the result dictionary.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Dictionary containing formatted metric values.
|
|
88
|
+
"""
|
|
35
89
|
return {
|
|
36
|
-
"ground_contact_time_ms": (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
),
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
if self.flight_time is not None
|
|
44
|
-
else None
|
|
45
|
-
),
|
|
46
|
-
"jump_height_m": (
|
|
47
|
-
round(self.jump_height, 3) if self.jump_height is not None else None
|
|
48
|
-
),
|
|
49
|
-
"jump_height_kinematic_m": (
|
|
50
|
-
round(self.jump_height_kinematic, 3)
|
|
51
|
-
if self.jump_height_kinematic is not None
|
|
52
|
-
else None
|
|
53
|
-
),
|
|
54
|
-
"jump_height_trajectory_normalized": (
|
|
55
|
-
round(self.jump_height_trajectory, 4)
|
|
56
|
-
if self.jump_height_trajectory is not None
|
|
57
|
-
else None
|
|
90
|
+
"ground_contact_time_ms": format_float_metric(self.ground_contact_time, 1000, 2),
|
|
91
|
+
"flight_time_ms": format_float_metric(self.flight_time, 1000, 2),
|
|
92
|
+
"jump_height_m": format_float_metric(self.jump_height, 1, 3),
|
|
93
|
+
"jump_height_kinematic_m": format_float_metric(self.jump_height_kinematic, 1, 3),
|
|
94
|
+
"jump_height_trajectory_m": format_float_metric(self.jump_height_trajectory_m, 1, 3),
|
|
95
|
+
"jump_height_trajectory_normalized": format_float_metric(
|
|
96
|
+
self.jump_height_trajectory, 1, 4
|
|
58
97
|
),
|
|
59
|
-
"contact_start_frame": (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
98
|
+
"contact_start_frame": format_int_metric(self.contact_start_frame),
|
|
99
|
+
"contact_end_frame": format_int_metric(self.contact_end_frame),
|
|
100
|
+
"flight_start_frame": format_int_metric(self.flight_start_frame),
|
|
101
|
+
"flight_end_frame": format_int_metric(self.flight_end_frame),
|
|
102
|
+
"peak_height_frame": format_int_metric(self.peak_height_frame),
|
|
103
|
+
"contact_start_frame_precise": format_float_metric(
|
|
104
|
+
self.contact_start_frame_precise, 1, 3
|
|
63
105
|
),
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
else None
|
|
68
|
-
),
|
|
69
|
-
"flight_start_frame": (
|
|
70
|
-
int(self.flight_start_frame)
|
|
71
|
-
if self.flight_start_frame is not None
|
|
72
|
-
else None
|
|
73
|
-
),
|
|
74
|
-
"flight_end_frame": (
|
|
75
|
-
int(self.flight_end_frame)
|
|
76
|
-
if self.flight_end_frame is not None
|
|
77
|
-
else None
|
|
78
|
-
),
|
|
79
|
-
"peak_height_frame": (
|
|
80
|
-
int(self.peak_height_frame)
|
|
81
|
-
if self.peak_height_frame is not None
|
|
82
|
-
else None
|
|
83
|
-
),
|
|
84
|
-
"contact_start_frame_precise": (
|
|
85
|
-
round(self.contact_start_frame_precise, 3)
|
|
86
|
-
if self.contact_start_frame_precise is not None
|
|
87
|
-
else None
|
|
88
|
-
),
|
|
89
|
-
"contact_end_frame_precise": (
|
|
90
|
-
round(self.contact_end_frame_precise, 3)
|
|
91
|
-
if self.contact_end_frame_precise is not None
|
|
92
|
-
else None
|
|
93
|
-
),
|
|
94
|
-
"flight_start_frame_precise": (
|
|
95
|
-
round(self.flight_start_frame_precise, 3)
|
|
96
|
-
if self.flight_start_frame_precise is not None
|
|
97
|
-
else None
|
|
98
|
-
),
|
|
99
|
-
"flight_end_frame_precise": (
|
|
100
|
-
round(self.flight_end_frame_precise, 3)
|
|
101
|
-
if self.flight_end_frame_precise is not None
|
|
102
|
-
else None
|
|
106
|
+
"contact_end_frame_precise": format_float_metric(self.contact_end_frame_precise, 1, 3),
|
|
107
|
+
"flight_start_frame_precise": format_float_metric(
|
|
108
|
+
self.flight_start_frame_precise, 1, 3
|
|
103
109
|
),
|
|
110
|
+
"flight_end_frame_precise": format_float_metric(self.flight_end_frame_precise, 1, 3),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
def _build_metadata_dict(self) -> dict:
|
|
114
|
+
"""Build the metadata portion of the result dictionary.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Metadata dictionary from available sources.
|
|
118
|
+
"""
|
|
119
|
+
if self.result_metadata is not None:
|
|
120
|
+
return self.result_metadata.to_dict()
|
|
121
|
+
if self.quality_assessment is not None:
|
|
122
|
+
return {"quality": self.quality_assessment.to_dict()}
|
|
123
|
+
return {}
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> DropJumpResultDict:
|
|
126
|
+
"""Convert metrics to JSON-serializable dictionary with data/metadata structure.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Dictionary with nested data and metadata structure.
|
|
130
|
+
"""
|
|
131
|
+
result: DropJumpResultDict = {
|
|
132
|
+
"data": self._build_data_dict(),
|
|
133
|
+
"metadata": self._build_metadata_dict(),
|
|
104
134
|
}
|
|
105
135
|
|
|
136
|
+
# Include validation results if available
|
|
137
|
+
if self.validation_result is not None:
|
|
138
|
+
result["validation"] = self.validation_result.to_dict()
|
|
139
|
+
|
|
140
|
+
return result
|
|
141
|
+
|
|
106
142
|
|
|
107
143
|
def _determine_drop_start_frame(
|
|
108
144
|
drop_start_frame: int | None,
|
|
109
|
-
foot_y_positions: np.
|
|
145
|
+
foot_y_positions: NDArray[np.float64],
|
|
110
146
|
fps: float,
|
|
111
147
|
smoothing_window: int,
|
|
112
148
|
) -> int:
|
|
@@ -123,14 +159,13 @@ def _determine_drop_start_frame(
|
|
|
123
159
|
"""
|
|
124
160
|
if drop_start_frame is None:
|
|
125
161
|
# Auto-detect where drop jump actually starts (skip initial stationary period)
|
|
126
|
-
|
|
162
|
+
return detect_drop_start(
|
|
127
163
|
foot_y_positions,
|
|
128
164
|
fps,
|
|
129
165
|
min_stationary_duration=0.5,
|
|
130
|
-
position_change_threshold=0.005
|
|
166
|
+
position_change_threshold=0.01, # Improved from 0.005 for better accuracy
|
|
131
167
|
smoothing_window=smoothing_window,
|
|
132
168
|
)
|
|
133
|
-
return detected_frame if detected_frame is not None else 0
|
|
134
169
|
return drop_start_frame
|
|
135
170
|
|
|
136
171
|
|
|
@@ -138,9 +173,7 @@ def _filter_phases_after_drop(
|
|
|
138
173
|
phases: list[tuple[int, int, ContactState]],
|
|
139
174
|
interpolated_phases: list[tuple[float, float, ContactState]],
|
|
140
175
|
drop_start_frame: int,
|
|
141
|
-
) -> tuple[
|
|
142
|
-
list[tuple[int, int, ContactState]], list[tuple[float, float, ContactState]]
|
|
143
|
-
]:
|
|
176
|
+
) -> tuple[list[tuple[int, int, ContactState]], list[tuple[float, float, ContactState]]]:
|
|
144
177
|
"""Filter phases to only include those after drop start.
|
|
145
178
|
|
|
146
179
|
Args:
|
|
@@ -158,18 +191,46 @@ def _filter_phases_after_drop(
|
|
|
158
191
|
(start, end, state) for start, end, state in phases if end >= drop_start_frame
|
|
159
192
|
]
|
|
160
193
|
filtered_interpolated = [
|
|
161
|
-
(start, end, state)
|
|
162
|
-
for start, end, state in interpolated_phases
|
|
163
|
-
if end >= drop_start_frame
|
|
194
|
+
(start, end, state) for start, end, state in interpolated_phases if end >= drop_start_frame
|
|
164
195
|
]
|
|
165
196
|
return filtered_phases, filtered_interpolated
|
|
166
197
|
|
|
167
198
|
|
|
199
|
+
def _compute_robust_phase_position(
|
|
200
|
+
foot_y_positions: NDArray[np.float64],
|
|
201
|
+
phase_start: int,
|
|
202
|
+
phase_end: int,
|
|
203
|
+
temporal_window: int = 11,
|
|
204
|
+
) -> float:
|
|
205
|
+
"""Compute robust position estimate using temporal averaging.
|
|
206
|
+
|
|
207
|
+
Uses median over a fixed temporal window to reduce sensitivity to
|
|
208
|
+
MediaPipe landmark noise, improving reproducibility.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
foot_y_positions: Vertical position array
|
|
212
|
+
phase_start: Start frame of phase
|
|
213
|
+
phase_end: End frame of phase
|
|
214
|
+
temporal_window: Number of frames to average (default: 11)
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Robust position estimate using median
|
|
218
|
+
"""
|
|
219
|
+
# Center the temporal window on the phase midpoint
|
|
220
|
+
phase_mid = (phase_start + phase_end) // 2
|
|
221
|
+
window_start = max(0, phase_mid - temporal_window // 2)
|
|
222
|
+
window_end = min(len(foot_y_positions), phase_mid + temporal_window // 2 + 1)
|
|
223
|
+
|
|
224
|
+
# Use median for robustness to outliers
|
|
225
|
+
window_positions = foot_y_positions[window_start:window_end]
|
|
226
|
+
return float(np.median(window_positions))
|
|
227
|
+
|
|
228
|
+
|
|
168
229
|
def _identify_main_contact_phase(
|
|
169
230
|
phases: list[tuple[int, int, ContactState]],
|
|
170
231
|
ground_phases: list[tuple[int, int, int]],
|
|
171
232
|
air_phases_indexed: list[tuple[int, int, int]],
|
|
172
|
-
foot_y_positions: np.
|
|
233
|
+
foot_y_positions: NDArray[np.float64],
|
|
173
234
|
) -> tuple[int, int, bool]:
|
|
174
235
|
"""Identify the main contact phase and determine if it's a drop jump.
|
|
175
236
|
|
|
@@ -193,23 +254,25 @@ def _identify_main_contact_phase(
|
|
|
193
254
|
|
|
194
255
|
# Find ground phase after first air phase
|
|
195
256
|
ground_after_air = [
|
|
196
|
-
(start, end, idx)
|
|
197
|
-
for start, end, idx in ground_phases
|
|
198
|
-
if idx > first_air_idx
|
|
257
|
+
(start, end, idx) for start, end, idx in ground_phases if idx > first_air_idx
|
|
199
258
|
]
|
|
200
259
|
|
|
201
260
|
if ground_after_air and first_ground_idx < first_air_idx:
|
|
202
|
-
# Check if first ground is at higher elevation (lower y) than
|
|
203
|
-
|
|
204
|
-
|
|
261
|
+
# Check if first ground is at higher elevation (lower y) than
|
|
262
|
+
# ground after air using robust temporal averaging
|
|
263
|
+
first_ground_y = _compute_robust_phase_position(
|
|
264
|
+
foot_y_positions, first_ground_start, first_ground_end
|
|
205
265
|
)
|
|
206
266
|
second_ground_start, second_ground_end, _ = ground_after_air[0]
|
|
207
|
-
second_ground_y =
|
|
208
|
-
|
|
267
|
+
second_ground_y = _compute_robust_phase_position(
|
|
268
|
+
foot_y_positions, second_ground_start, second_ground_end
|
|
209
269
|
)
|
|
210
270
|
|
|
211
|
-
# If first ground is significantly higher (>
|
|
212
|
-
|
|
271
|
+
# If first ground is significantly higher (>7% of frame), it's a drop jump
|
|
272
|
+
# Increased from 0.05 to 0.07 with 11-frame temporal averaging
|
|
273
|
+
# for reproducibility (balances detection sensitivity with noise robustness)
|
|
274
|
+
# Note: MediaPipe has inherent non-determinism (Google issue #3945)
|
|
275
|
+
if second_ground_y - first_ground_y > 0.07:
|
|
213
276
|
is_drop_jump = True
|
|
214
277
|
contact_start, contact_end = second_ground_start, second_ground_end
|
|
215
278
|
|
|
@@ -254,68 +317,21 @@ def _find_precise_phase_timing(
|
|
|
254
317
|
return contact_start_frac, contact_end_frac
|
|
255
318
|
|
|
256
319
|
|
|
257
|
-
def _calculate_calibration_scale(
|
|
258
|
-
drop_height_m: float | None,
|
|
259
|
-
phases: list[tuple[int, int, ContactState]],
|
|
260
|
-
air_phases_indexed: list[tuple[int, int, int]],
|
|
261
|
-
foot_y_positions: np.ndarray,
|
|
262
|
-
) -> float:
|
|
263
|
-
"""Calculate calibration scale factor from known drop height.
|
|
264
|
-
|
|
265
|
-
Args:
|
|
266
|
-
drop_height_m: Known drop height in meters
|
|
267
|
-
phases: All phase tuples
|
|
268
|
-
air_phases_indexed: Air phases with indices
|
|
269
|
-
foot_y_positions: Vertical position array
|
|
270
|
-
|
|
271
|
-
Returns:
|
|
272
|
-
Scale factor (1.0 if no calibration possible)
|
|
273
|
-
"""
|
|
274
|
-
scale_factor = 1.0
|
|
275
|
-
|
|
276
|
-
if drop_height_m is None or len(phases) < 2:
|
|
277
|
-
return scale_factor
|
|
278
|
-
|
|
279
|
-
if not air_phases_indexed:
|
|
280
|
-
return scale_factor
|
|
281
|
-
|
|
282
|
-
# Get first air phase (the drop)
|
|
283
|
-
first_air_start, first_air_end, _ = air_phases_indexed[0]
|
|
284
|
-
|
|
285
|
-
# Initial position: at start of drop (on the box)
|
|
286
|
-
lookback_start = max(0, first_air_start - 5)
|
|
287
|
-
if lookback_start < first_air_start:
|
|
288
|
-
initial_position = float(
|
|
289
|
-
np.mean(foot_y_positions[lookback_start:first_air_start])
|
|
290
|
-
)
|
|
291
|
-
else:
|
|
292
|
-
initial_position = float(foot_y_positions[first_air_start])
|
|
293
|
-
|
|
294
|
-
# Landing position: at the ground after drop
|
|
295
|
-
landing_position = float(foot_y_positions[first_air_end])
|
|
296
|
-
|
|
297
|
-
# Drop distance in normalized coordinates (y increases downward)
|
|
298
|
-
drop_normalized = landing_position - initial_position
|
|
299
|
-
|
|
300
|
-
if drop_normalized > 0.01: # Sanity check
|
|
301
|
-
scale_factor = drop_height_m / drop_normalized
|
|
302
|
-
|
|
303
|
-
return scale_factor
|
|
304
|
-
|
|
305
|
-
|
|
306
320
|
def _analyze_flight_phase(
|
|
307
321
|
metrics: DropJumpMetrics,
|
|
308
322
|
phases: list[tuple[int, int, ContactState]],
|
|
309
323
|
interpolated_phases: list[tuple[float, float, ContactState]],
|
|
310
324
|
contact_end: int,
|
|
311
|
-
foot_y_positions: np.
|
|
325
|
+
foot_y_positions: NDArray[np.float64],
|
|
312
326
|
fps: float,
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
kinematic_correction_factor: float,
|
|
327
|
+
smoothing_window: int,
|
|
328
|
+
polyorder: int,
|
|
316
329
|
) -> None:
|
|
317
330
|
"""Analyze flight phase and calculate jump height metrics.
|
|
318
331
|
|
|
332
|
+
Uses acceleration-based landing detection (like CMJ) for accurate flight time,
|
|
333
|
+
then calculates jump height using kinematic formula h = g*t²/8.
|
|
334
|
+
|
|
319
335
|
Args:
|
|
320
336
|
metrics: DropJumpMetrics object to populate
|
|
321
337
|
phases: All phase tuples
|
|
@@ -323,38 +339,38 @@ def _analyze_flight_phase(
|
|
|
323
339
|
contact_end: End of contact phase
|
|
324
340
|
foot_y_positions: Vertical position array
|
|
325
341
|
fps: Video frame rate
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
kinematic_correction_factor: Correction for kinematic method
|
|
342
|
+
smoothing_window: Window size for acceleration computation
|
|
343
|
+
polyorder: Polynomial order for Savitzky-Golay filter
|
|
329
344
|
"""
|
|
330
|
-
# Find
|
|
331
|
-
|
|
332
|
-
(start, end)
|
|
333
|
-
for start, end, state in phases
|
|
334
|
-
if state == ContactState.IN_AIR and start > contact_end
|
|
335
|
-
]
|
|
345
|
+
# Find takeoff frame (end of ground contact)
|
|
346
|
+
flight_start = contact_end
|
|
336
347
|
|
|
337
|
-
|
|
338
|
-
|
|
348
|
+
# Compute accelerations for landing detection
|
|
349
|
+
accelerations = compute_acceleration_from_derivative(
|
|
350
|
+
foot_y_positions, window_length=smoothing_window, polyorder=polyorder
|
|
351
|
+
)
|
|
339
352
|
|
|
340
|
-
|
|
353
|
+
# Use acceleration-based landing detection (like CMJ)
|
|
354
|
+
# This finds the actual ground impact, not just when velocity drops
|
|
355
|
+
flight_end = find_landing_from_acceleration(
|
|
356
|
+
foot_y_positions, accelerations, flight_start, fps, search_duration=0.7
|
|
357
|
+
)
|
|
341
358
|
|
|
342
359
|
# Store integer frame indices
|
|
343
360
|
metrics.flight_start_frame = flight_start
|
|
344
361
|
metrics.flight_end_frame = flight_end
|
|
345
362
|
|
|
346
|
-
# Find precise timing
|
|
363
|
+
# Find precise sub-frame timing for takeoff
|
|
347
364
|
flight_start_frac = float(flight_start)
|
|
348
365
|
flight_end_frac = float(flight_end)
|
|
349
366
|
|
|
350
367
|
for start_frac, end_frac, state in interpolated_phases:
|
|
351
368
|
if (
|
|
352
|
-
state == ContactState.
|
|
369
|
+
state == ContactState.ON_GROUND
|
|
353
370
|
and int(start_frac) <= flight_start <= int(end_frac) + 1
|
|
354
|
-
and int(start_frac) <= flight_end <= int(end_frac) + 1
|
|
355
371
|
):
|
|
356
|
-
|
|
357
|
-
|
|
372
|
+
# Use end of ground contact as precise takeoff
|
|
373
|
+
flight_start_frac = end_frac
|
|
358
374
|
break
|
|
359
375
|
|
|
360
376
|
# Calculate flight time
|
|
@@ -363,11 +379,16 @@ def _analyze_flight_phase(
|
|
|
363
379
|
metrics.flight_start_frame_precise = flight_start_frac
|
|
364
380
|
metrics.flight_end_frame_precise = flight_end_frac
|
|
365
381
|
|
|
366
|
-
# Calculate jump height using kinematic method
|
|
382
|
+
# Calculate jump height using kinematic method (like CMJ)
|
|
383
|
+
# h = g * t² / 8
|
|
367
384
|
g = 9.81 # m/s^2
|
|
368
385
|
jump_height_kinematic = (g * metrics.flight_time**2) / 8
|
|
369
386
|
|
|
370
|
-
#
|
|
387
|
+
# Always use kinematic method for jump height (like CMJ)
|
|
388
|
+
metrics.jump_height = jump_height_kinematic
|
|
389
|
+
metrics.jump_height_kinematic = jump_height_kinematic
|
|
390
|
+
|
|
391
|
+
# Calculate trajectory-based height for reference
|
|
371
392
|
takeoff_position = foot_y_positions[flight_start]
|
|
372
393
|
flight_positions = foot_y_positions[flight_start : flight_end + 1]
|
|
373
394
|
|
|
@@ -379,70 +400,70 @@ def _analyze_flight_phase(
|
|
|
379
400
|
height_normalized = float(takeoff_position - peak_position)
|
|
380
401
|
metrics.jump_height_trajectory = height_normalized
|
|
381
402
|
|
|
382
|
-
#
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
metrics.jump_height_kinematic
|
|
403
|
+
# Calculate scale factor and metric height
|
|
404
|
+
# Scale factor = kinematic height / normalized height
|
|
405
|
+
if height_normalized > 0.001:
|
|
406
|
+
scale_factor = metrics.jump_height_kinematic / height_normalized
|
|
407
|
+
metrics.jump_height_trajectory_m = height_normalized * scale_factor
|
|
386
408
|
else:
|
|
387
|
-
metrics.
|
|
388
|
-
metrics.jump_height_kinematic = jump_height_kinematic
|
|
389
|
-
else:
|
|
390
|
-
# Fallback to kinematic if no position data
|
|
391
|
-
if drop_height_m is None:
|
|
392
|
-
metrics.jump_height = jump_height_kinematic * kinematic_correction_factor
|
|
393
|
-
else:
|
|
394
|
-
metrics.jump_height = jump_height_kinematic
|
|
395
|
-
metrics.jump_height_kinematic = jump_height_kinematic
|
|
409
|
+
metrics.jump_height_trajectory_m = 0.0
|
|
396
410
|
|
|
397
411
|
|
|
398
412
|
def calculate_drop_jump_metrics(
|
|
399
413
|
contact_states: list[ContactState],
|
|
400
|
-
foot_y_positions: np.
|
|
414
|
+
foot_y_positions: NDArray[np.float64],
|
|
401
415
|
fps: float,
|
|
402
|
-
drop_height_m: float | None = None,
|
|
403
416
|
drop_start_frame: int | None = None,
|
|
404
417
|
velocity_threshold: float = 0.02,
|
|
405
418
|
smoothing_window: int = 5,
|
|
406
419
|
polyorder: int = 2,
|
|
407
420
|
use_curvature: bool = True,
|
|
408
|
-
|
|
421
|
+
timer: Timer | None = None,
|
|
409
422
|
) -> DropJumpMetrics:
|
|
410
423
|
"""
|
|
411
424
|
Calculate drop-jump metrics from contact states and positions.
|
|
412
425
|
|
|
426
|
+
Jump height is calculated from flight time using kinematic formula: h = g × t² / 8
|
|
427
|
+
|
|
413
428
|
Args:
|
|
414
429
|
contact_states: Contact state for each frame
|
|
415
430
|
foot_y_positions: Vertical positions of feet (normalized 0-1)
|
|
416
431
|
fps: Video frame rate
|
|
417
|
-
|
|
418
|
-
velocity_threshold: Velocity threshold used for contact detection
|
|
419
|
-
|
|
432
|
+
drop_start_frame: Optional manual drop start frame
|
|
433
|
+
velocity_threshold: Velocity threshold used for contact detection
|
|
434
|
+
(for interpolation)
|
|
435
|
+
smoothing_window: Window size for velocity/acceleration smoothing
|
|
436
|
+
(must be odd)
|
|
420
437
|
polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
|
|
421
438
|
use_curvature: Whether to use curvature analysis for refining transitions
|
|
422
|
-
|
|
423
|
-
(default: 1.0 = no correction). Historical testing suggested 1.35, but this is
|
|
424
|
-
unvalidated. Use calibrated measurement (--drop-height) for validated results.
|
|
439
|
+
timer: Optional Timer for measuring operations
|
|
425
440
|
|
|
426
441
|
Returns:
|
|
427
442
|
DropJumpMetrics object with calculated values
|
|
428
443
|
"""
|
|
444
|
+
timer = timer or NULL_TIMER
|
|
429
445
|
metrics = DropJumpMetrics()
|
|
430
446
|
|
|
431
447
|
# Determine drop start frame
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
448
|
+
with timer.measure("dj_detect_drop_start"):
|
|
449
|
+
drop_start_frame_value = _determine_drop_start_frame(
|
|
450
|
+
drop_start_frame, foot_y_positions, fps, smoothing_window
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# Store drop start frame in metrics
|
|
454
|
+
metrics.drop_start_frame = drop_start_frame_value if drop_start_frame_value > 0 else None
|
|
435
455
|
|
|
436
456
|
# Find contact phases
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
457
|
+
with timer.measure("dj_find_phases"):
|
|
458
|
+
phases = find_contact_phases(contact_states)
|
|
459
|
+
interpolated_phases = find_interpolated_phase_transitions_with_curvature(
|
|
460
|
+
foot_y_positions,
|
|
461
|
+
contact_states,
|
|
462
|
+
velocity_threshold,
|
|
463
|
+
smoothing_window,
|
|
464
|
+
polyorder,
|
|
465
|
+
use_curvature,
|
|
466
|
+
)
|
|
446
467
|
|
|
447
468
|
if not phases:
|
|
448
469
|
return metrics
|
|
@@ -471,9 +492,10 @@ def calculate_drop_jump_metrics(
|
|
|
471
492
|
return metrics
|
|
472
493
|
|
|
473
494
|
# Identify main contact phase
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
495
|
+
with timer.measure("dj_identify_contact"):
|
|
496
|
+
contact_start, contact_end, _ = _identify_main_contact_phase(
|
|
497
|
+
phases, ground_phases, air_phases_indexed, foot_y_positions
|
|
498
|
+
)
|
|
477
499
|
|
|
478
500
|
# Store integer frame indices
|
|
479
501
|
metrics.contact_start_frame = contact_start
|
|
@@ -490,62 +512,17 @@ def calculate_drop_jump_metrics(
|
|
|
490
512
|
metrics.contact_start_frame_precise = contact_start_frac
|
|
491
513
|
metrics.contact_end_frame_precise = contact_end_frac
|
|
492
514
|
|
|
493
|
-
# Calculate calibration scale factor
|
|
494
|
-
scale_factor = _calculate_calibration_scale(
|
|
495
|
-
drop_height_m, phases, air_phases_indexed, foot_y_positions
|
|
496
|
-
)
|
|
497
|
-
|
|
498
515
|
# Analyze flight phase and calculate jump height
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
516
|
+
with timer.measure("dj_analyze_flight"):
|
|
517
|
+
_analyze_flight_phase(
|
|
518
|
+
metrics,
|
|
519
|
+
phases,
|
|
520
|
+
interpolated_phases,
|
|
521
|
+
contact_end,
|
|
522
|
+
foot_y_positions,
|
|
523
|
+
fps,
|
|
524
|
+
smoothing_window,
|
|
525
|
+
polyorder,
|
|
526
|
+
)
|
|
510
527
|
|
|
511
528
|
return metrics
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
def estimate_jump_height_from_trajectory(
|
|
515
|
-
foot_y_positions: np.ndarray,
|
|
516
|
-
flight_start: int,
|
|
517
|
-
flight_end: int,
|
|
518
|
-
pixel_to_meter_ratio: float | None = None,
|
|
519
|
-
) -> float:
|
|
520
|
-
"""
|
|
521
|
-
Estimate jump height from position trajectory.
|
|
522
|
-
|
|
523
|
-
Args:
|
|
524
|
-
foot_y_positions: Vertical positions of feet (normalized or pixels)
|
|
525
|
-
flight_start: Frame where flight begins
|
|
526
|
-
flight_end: Frame where flight ends
|
|
527
|
-
pixel_to_meter_ratio: Conversion factor from pixels to meters
|
|
528
|
-
|
|
529
|
-
Returns:
|
|
530
|
-
Estimated jump height in meters (or normalized units if no calibration)
|
|
531
|
-
"""
|
|
532
|
-
if flight_end < flight_start:
|
|
533
|
-
return 0.0
|
|
534
|
-
|
|
535
|
-
# Get position at takeoff (end of contact) and peak (minimum y during flight)
|
|
536
|
-
takeoff_position = foot_y_positions[flight_start]
|
|
537
|
-
flight_positions = foot_y_positions[flight_start : flight_end + 1]
|
|
538
|
-
|
|
539
|
-
if len(flight_positions) == 0:
|
|
540
|
-
return 0.0
|
|
541
|
-
|
|
542
|
-
peak_position = np.min(flight_positions)
|
|
543
|
-
|
|
544
|
-
# Height difference (in normalized coordinates, y increases downward)
|
|
545
|
-
height_diff = takeoff_position - peak_position
|
|
546
|
-
|
|
547
|
-
# Convert to meters if calibration available
|
|
548
|
-
if pixel_to_meter_ratio is not None:
|
|
549
|
-
return float(height_diff * pixel_to_meter_ratio)
|
|
550
|
-
|
|
551
|
-
return float(height_diff)
|