kinemotion 0.17.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 -0
- kinemotion/api.py +946 -0
- kinemotion/cli.py +22 -0
- kinemotion/cmj/__init__.py +5 -0
- kinemotion/cmj/analysis.py +528 -0
- kinemotion/cmj/cli.py +543 -0
- kinemotion/cmj/debug_overlay.py +463 -0
- kinemotion/cmj/joint_angles.py +290 -0
- kinemotion/cmj/kinematics.py +191 -0
- kinemotion/core/__init__.py +40 -0
- kinemotion/core/auto_tuning.py +325 -0
- kinemotion/core/cli_utils.py +212 -0
- kinemotion/core/debug_overlay_utils.py +143 -0
- kinemotion/core/filtering.py +345 -0
- kinemotion/core/pose.py +259 -0
- kinemotion/core/smoothing.py +412 -0
- kinemotion/core/video_io.py +186 -0
- kinemotion/dropjump/__init__.py +29 -0
- kinemotion/dropjump/analysis.py +790 -0
- kinemotion/dropjump/cli.py +704 -0
- kinemotion/dropjump/debug_overlay.py +179 -0
- kinemotion/dropjump/kinematics.py +446 -0
- kinemotion/py.typed +0 -0
- kinemotion-0.17.0.dist-info/METADATA +529 -0
- kinemotion-0.17.0.dist-info/RECORD +28 -0
- kinemotion-0.17.0.dist-info/WHEEL +4 -0
- kinemotion-0.17.0.dist-info/entry_points.txt +2 -0
- kinemotion-0.17.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Debug overlay rendering for drop jump analysis."""
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from ..core.debug_overlay_utils import BaseDebugOverlayRenderer
|
|
7
|
+
from ..core.pose import compute_center_of_mass
|
|
8
|
+
from .analysis import ContactState, compute_average_foot_position
|
|
9
|
+
from .kinematics import DropJumpMetrics
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DebugOverlayRenderer(BaseDebugOverlayRenderer):
|
|
13
|
+
"""Renders debug information on video frames."""
|
|
14
|
+
|
|
15
|
+
def _draw_com_visualization(
|
|
16
|
+
self,
|
|
17
|
+
frame: np.ndarray,
|
|
18
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
19
|
+
contact_state: ContactState,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Draw center of mass visualization on frame."""
|
|
22
|
+
com_x, com_y, _ = compute_center_of_mass(landmarks)
|
|
23
|
+
px = int(com_x * self.width)
|
|
24
|
+
py = int(com_y * self.height)
|
|
25
|
+
|
|
26
|
+
color = (0, 255, 0) if contact_state == ContactState.ON_GROUND else (0, 0, 255)
|
|
27
|
+
cv2.circle(frame, (px, py), 15, color, -1)
|
|
28
|
+
cv2.circle(frame, (px, py), 17, (255, 255, 255), 2)
|
|
29
|
+
|
|
30
|
+
# Draw hip midpoint reference
|
|
31
|
+
if "left_hip" in landmarks and "right_hip" in landmarks:
|
|
32
|
+
lh_x, lh_y, _ = landmarks["left_hip"]
|
|
33
|
+
rh_x, rh_y, _ = landmarks["right_hip"]
|
|
34
|
+
hip_x = int((lh_x + rh_x) / 2 * self.width)
|
|
35
|
+
hip_y = int((lh_y + rh_y) / 2 * self.height)
|
|
36
|
+
cv2.circle(frame, (hip_x, hip_y), 8, (255, 165, 0), -1)
|
|
37
|
+
cv2.line(frame, (hip_x, hip_y), (px, py), (255, 165, 0), 2)
|
|
38
|
+
|
|
39
|
+
def _draw_foot_visualization(
|
|
40
|
+
self,
|
|
41
|
+
frame: np.ndarray,
|
|
42
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
43
|
+
contact_state: ContactState,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Draw foot position visualization on frame."""
|
|
46
|
+
foot_x, foot_y = compute_average_foot_position(landmarks)
|
|
47
|
+
px = int(foot_x * self.width)
|
|
48
|
+
py = int(foot_y * self.height)
|
|
49
|
+
|
|
50
|
+
color = (0, 255, 0) if contact_state == ContactState.ON_GROUND else (0, 0, 255)
|
|
51
|
+
cv2.circle(frame, (px, py), 10, color, -1)
|
|
52
|
+
|
|
53
|
+
# Draw individual foot landmarks
|
|
54
|
+
foot_keys = ["left_ankle", "right_ankle", "left_heel", "right_heel"]
|
|
55
|
+
for key in foot_keys:
|
|
56
|
+
if key in landmarks:
|
|
57
|
+
x, y, vis = landmarks[key]
|
|
58
|
+
if vis > 0.5:
|
|
59
|
+
lx = int(x * self.width)
|
|
60
|
+
ly = int(y * self.height)
|
|
61
|
+
cv2.circle(frame, (lx, ly), 5, (255, 255, 0), -1)
|
|
62
|
+
|
|
63
|
+
def _draw_phase_labels(
|
|
64
|
+
self,
|
|
65
|
+
frame: np.ndarray,
|
|
66
|
+
frame_idx: int,
|
|
67
|
+
metrics: DropJumpMetrics,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Draw phase labels (ground contact, flight, peak) on frame."""
|
|
70
|
+
y_offset = 110
|
|
71
|
+
|
|
72
|
+
# Ground contact phase
|
|
73
|
+
if (
|
|
74
|
+
metrics.contact_start_frame
|
|
75
|
+
and metrics.contact_end_frame
|
|
76
|
+
and metrics.contact_start_frame <= frame_idx <= metrics.contact_end_frame
|
|
77
|
+
):
|
|
78
|
+
cv2.putText(
|
|
79
|
+
frame,
|
|
80
|
+
"GROUND CONTACT",
|
|
81
|
+
(10, y_offset),
|
|
82
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
83
|
+
0.7,
|
|
84
|
+
(0, 255, 0),
|
|
85
|
+
2,
|
|
86
|
+
)
|
|
87
|
+
y_offset += 40
|
|
88
|
+
|
|
89
|
+
# Flight phase
|
|
90
|
+
if (
|
|
91
|
+
metrics.flight_start_frame
|
|
92
|
+
and metrics.flight_end_frame
|
|
93
|
+
and metrics.flight_start_frame <= frame_idx <= metrics.flight_end_frame
|
|
94
|
+
):
|
|
95
|
+
cv2.putText(
|
|
96
|
+
frame,
|
|
97
|
+
"FLIGHT PHASE",
|
|
98
|
+
(10, y_offset),
|
|
99
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
100
|
+
0.7,
|
|
101
|
+
(0, 0, 255),
|
|
102
|
+
2,
|
|
103
|
+
)
|
|
104
|
+
y_offset += 40
|
|
105
|
+
|
|
106
|
+
# Peak height
|
|
107
|
+
if metrics.peak_height_frame == frame_idx:
|
|
108
|
+
cv2.putText(
|
|
109
|
+
frame,
|
|
110
|
+
"PEAK HEIGHT",
|
|
111
|
+
(10, y_offset),
|
|
112
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
113
|
+
0.7,
|
|
114
|
+
(255, 0, 255),
|
|
115
|
+
2,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def render_frame(
|
|
119
|
+
self,
|
|
120
|
+
frame: np.ndarray,
|
|
121
|
+
landmarks: dict[str, tuple[float, float, float]] | None,
|
|
122
|
+
contact_state: ContactState,
|
|
123
|
+
frame_idx: int,
|
|
124
|
+
metrics: DropJumpMetrics | None = None,
|
|
125
|
+
use_com: bool = False,
|
|
126
|
+
) -> np.ndarray:
|
|
127
|
+
"""
|
|
128
|
+
Render debug overlay on frame.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
frame: Original video frame
|
|
132
|
+
landmarks: Pose landmarks for this frame
|
|
133
|
+
contact_state: Ground contact state
|
|
134
|
+
frame_idx: Current frame index
|
|
135
|
+
metrics: Drop-jump metrics (optional)
|
|
136
|
+
use_com: Whether to visualize CoM instead of feet (optional)
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Frame with debug overlay
|
|
140
|
+
"""
|
|
141
|
+
annotated = frame.copy()
|
|
142
|
+
|
|
143
|
+
# Draw landmarks
|
|
144
|
+
if landmarks:
|
|
145
|
+
if use_com:
|
|
146
|
+
self._draw_com_visualization(annotated, landmarks, contact_state)
|
|
147
|
+
else:
|
|
148
|
+
self._draw_foot_visualization(annotated, landmarks, contact_state)
|
|
149
|
+
|
|
150
|
+
# Draw contact state
|
|
151
|
+
state_color = (
|
|
152
|
+
(0, 255, 0) if contact_state == ContactState.ON_GROUND else (0, 0, 255)
|
|
153
|
+
)
|
|
154
|
+
cv2.putText(
|
|
155
|
+
annotated,
|
|
156
|
+
f"State: {contact_state.value}",
|
|
157
|
+
(10, 30),
|
|
158
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
159
|
+
1,
|
|
160
|
+
state_color,
|
|
161
|
+
2,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Draw frame number
|
|
165
|
+
cv2.putText(
|
|
166
|
+
annotated,
|
|
167
|
+
f"Frame: {frame_idx}",
|
|
168
|
+
(10, 70),
|
|
169
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
170
|
+
0.7,
|
|
171
|
+
(255, 255, 255),
|
|
172
|
+
2,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Draw phase labels
|
|
176
|
+
if metrics:
|
|
177
|
+
self._draw_phase_labels(annotated, frame_idx, metrics)
|
|
178
|
+
|
|
179
|
+
return annotated
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""Kinematic calculations for drop-jump metrics."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from ..core.smoothing import compute_acceleration_from_derivative
|
|
6
|
+
from .analysis import (
|
|
7
|
+
ContactState,
|
|
8
|
+
detect_drop_start,
|
|
9
|
+
find_contact_phases,
|
|
10
|
+
find_interpolated_phase_transitions_with_curvature,
|
|
11
|
+
find_landing_from_acceleration,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DropJumpMetrics:
|
|
16
|
+
"""Container for drop-jump analysis metrics."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self.ground_contact_time: float | None = None
|
|
20
|
+
self.flight_time: float | None = None
|
|
21
|
+
self.jump_height: float | None = None
|
|
22
|
+
self.jump_height_kinematic: float | None = None # From flight time
|
|
23
|
+
self.jump_height_trajectory: float | None = None # From position tracking
|
|
24
|
+
self.contact_start_frame: int | None = None
|
|
25
|
+
self.contact_end_frame: int | None = None
|
|
26
|
+
self.flight_start_frame: int | None = None
|
|
27
|
+
self.flight_end_frame: int | None = None
|
|
28
|
+
self.peak_height_frame: int | None = None
|
|
29
|
+
# Fractional frame indices for sub-frame precision timing
|
|
30
|
+
self.contact_start_frame_precise: float | None = None
|
|
31
|
+
self.contact_end_frame_precise: float | None = None
|
|
32
|
+
self.flight_start_frame_precise: float | None = None
|
|
33
|
+
self.flight_end_frame_precise: float | None = None
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict:
|
|
36
|
+
"""Convert metrics to dictionary for JSON output."""
|
|
37
|
+
return {
|
|
38
|
+
"ground_contact_time_ms": (
|
|
39
|
+
round(self.ground_contact_time * 1000, 2)
|
|
40
|
+
if self.ground_contact_time is not None
|
|
41
|
+
else None
|
|
42
|
+
),
|
|
43
|
+
"flight_time_ms": (
|
|
44
|
+
round(self.flight_time * 1000, 2)
|
|
45
|
+
if self.flight_time is not None
|
|
46
|
+
else None
|
|
47
|
+
),
|
|
48
|
+
"jump_height_m": (
|
|
49
|
+
round(self.jump_height, 3) if self.jump_height is not None else None
|
|
50
|
+
),
|
|
51
|
+
"jump_height_kinematic_m": (
|
|
52
|
+
round(self.jump_height_kinematic, 3)
|
|
53
|
+
if self.jump_height_kinematic is not None
|
|
54
|
+
else None
|
|
55
|
+
),
|
|
56
|
+
"jump_height_trajectory_normalized": (
|
|
57
|
+
round(self.jump_height_trajectory, 4)
|
|
58
|
+
if self.jump_height_trajectory is not None
|
|
59
|
+
else None
|
|
60
|
+
),
|
|
61
|
+
"contact_start_frame": (
|
|
62
|
+
int(self.contact_start_frame)
|
|
63
|
+
if self.contact_start_frame is not None
|
|
64
|
+
else None
|
|
65
|
+
),
|
|
66
|
+
"contact_end_frame": (
|
|
67
|
+
int(self.contact_end_frame)
|
|
68
|
+
if self.contact_end_frame is not None
|
|
69
|
+
else None
|
|
70
|
+
),
|
|
71
|
+
"flight_start_frame": (
|
|
72
|
+
int(self.flight_start_frame)
|
|
73
|
+
if self.flight_start_frame is not None
|
|
74
|
+
else None
|
|
75
|
+
),
|
|
76
|
+
"flight_end_frame": (
|
|
77
|
+
int(self.flight_end_frame)
|
|
78
|
+
if self.flight_end_frame is not None
|
|
79
|
+
else None
|
|
80
|
+
),
|
|
81
|
+
"peak_height_frame": (
|
|
82
|
+
int(self.peak_height_frame)
|
|
83
|
+
if self.peak_height_frame is not None
|
|
84
|
+
else None
|
|
85
|
+
),
|
|
86
|
+
"contact_start_frame_precise": (
|
|
87
|
+
round(self.contact_start_frame_precise, 3)
|
|
88
|
+
if self.contact_start_frame_precise is not None
|
|
89
|
+
else None
|
|
90
|
+
),
|
|
91
|
+
"contact_end_frame_precise": (
|
|
92
|
+
round(self.contact_end_frame_precise, 3)
|
|
93
|
+
if self.contact_end_frame_precise is not None
|
|
94
|
+
else None
|
|
95
|
+
),
|
|
96
|
+
"flight_start_frame_precise": (
|
|
97
|
+
round(self.flight_start_frame_precise, 3)
|
|
98
|
+
if self.flight_start_frame_precise is not None
|
|
99
|
+
else None
|
|
100
|
+
),
|
|
101
|
+
"flight_end_frame_precise": (
|
|
102
|
+
round(self.flight_end_frame_precise, 3)
|
|
103
|
+
if self.flight_end_frame_precise is not None
|
|
104
|
+
else None
|
|
105
|
+
),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _determine_drop_start_frame(
|
|
110
|
+
drop_start_frame: int | None,
|
|
111
|
+
foot_y_positions: np.ndarray,
|
|
112
|
+
fps: float,
|
|
113
|
+
smoothing_window: int,
|
|
114
|
+
) -> int:
|
|
115
|
+
"""Determine the drop start frame for analysis.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
drop_start_frame: Manual drop start frame or None for auto-detection
|
|
119
|
+
foot_y_positions: Vertical positions array
|
|
120
|
+
fps: Video frame rate
|
|
121
|
+
smoothing_window: Smoothing window size
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Drop start frame (0 if not detected/provided)
|
|
125
|
+
"""
|
|
126
|
+
if drop_start_frame is None:
|
|
127
|
+
# Auto-detect where drop jump actually starts (skip initial stationary period)
|
|
128
|
+
return detect_drop_start(
|
|
129
|
+
foot_y_positions,
|
|
130
|
+
fps,
|
|
131
|
+
min_stationary_duration=0.5,
|
|
132
|
+
position_change_threshold=0.005,
|
|
133
|
+
smoothing_window=smoothing_window,
|
|
134
|
+
)
|
|
135
|
+
return drop_start_frame
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _filter_phases_after_drop(
|
|
139
|
+
phases: list[tuple[int, int, ContactState]],
|
|
140
|
+
interpolated_phases: list[tuple[float, float, ContactState]],
|
|
141
|
+
drop_start_frame: int,
|
|
142
|
+
) -> tuple[
|
|
143
|
+
list[tuple[int, int, ContactState]], list[tuple[float, float, ContactState]]
|
|
144
|
+
]:
|
|
145
|
+
"""Filter phases to only include those after drop start.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
phases: Integer frame phases
|
|
149
|
+
interpolated_phases: Sub-frame precision phases
|
|
150
|
+
drop_start_frame: Frame where drop starts
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Tuple of (filtered_phases, filtered_interpolated_phases)
|
|
154
|
+
"""
|
|
155
|
+
if drop_start_frame <= 0:
|
|
156
|
+
return phases, interpolated_phases
|
|
157
|
+
|
|
158
|
+
filtered_phases = [
|
|
159
|
+
(start, end, state) for start, end, state in phases if end >= drop_start_frame
|
|
160
|
+
]
|
|
161
|
+
filtered_interpolated = [
|
|
162
|
+
(start, end, state)
|
|
163
|
+
for start, end, state in interpolated_phases
|
|
164
|
+
if end >= drop_start_frame
|
|
165
|
+
]
|
|
166
|
+
return filtered_phases, filtered_interpolated
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _identify_main_contact_phase(
|
|
170
|
+
phases: list[tuple[int, int, ContactState]],
|
|
171
|
+
ground_phases: list[tuple[int, int, int]],
|
|
172
|
+
air_phases_indexed: list[tuple[int, int, int]],
|
|
173
|
+
foot_y_positions: np.ndarray,
|
|
174
|
+
) -> tuple[int, int, bool]:
|
|
175
|
+
"""Identify the main contact phase and determine if it's a drop jump.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
phases: All phase tuples
|
|
179
|
+
ground_phases: Ground phases with indices
|
|
180
|
+
air_phases_indexed: Air phases with indices
|
|
181
|
+
foot_y_positions: Vertical position array
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Tuple of (contact_start, contact_end, is_drop_jump)
|
|
185
|
+
"""
|
|
186
|
+
# Initialize with first ground phase as fallback
|
|
187
|
+
contact_start, contact_end = ground_phases[0][0], ground_phases[0][1]
|
|
188
|
+
is_drop_jump = False
|
|
189
|
+
|
|
190
|
+
# Detect if this is a drop jump or regular jump
|
|
191
|
+
if air_phases_indexed and len(ground_phases) >= 2:
|
|
192
|
+
first_ground_start, first_ground_end, first_ground_idx = ground_phases[0]
|
|
193
|
+
first_air_idx = air_phases_indexed[0][2]
|
|
194
|
+
|
|
195
|
+
# Find ground phase after first air phase
|
|
196
|
+
ground_after_air = [
|
|
197
|
+
(start, end, idx)
|
|
198
|
+
for start, end, idx in ground_phases
|
|
199
|
+
if idx > first_air_idx
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
if ground_after_air and first_ground_idx < first_air_idx:
|
|
203
|
+
# Check if first ground is at higher elevation (lower y) than ground after air
|
|
204
|
+
first_ground_y = float(
|
|
205
|
+
np.mean(foot_y_positions[first_ground_start : first_ground_end + 1])
|
|
206
|
+
)
|
|
207
|
+
second_ground_start, second_ground_end, _ = ground_after_air[0]
|
|
208
|
+
second_ground_y = float(
|
|
209
|
+
np.mean(foot_y_positions[second_ground_start : second_ground_end + 1])
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# If first ground is significantly higher (>5% of frame), it's a drop jump
|
|
213
|
+
if second_ground_y - first_ground_y > 0.05:
|
|
214
|
+
is_drop_jump = True
|
|
215
|
+
contact_start, contact_end = second_ground_start, second_ground_end
|
|
216
|
+
|
|
217
|
+
if not is_drop_jump:
|
|
218
|
+
# Regular jump: use longest ground contact phase
|
|
219
|
+
contact_start, contact_end = max(
|
|
220
|
+
[(s, e) for s, e, _ in ground_phases], key=lambda p: p[1] - p[0]
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return contact_start, contact_end, is_drop_jump
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _find_precise_phase_timing(
|
|
227
|
+
contact_start: int,
|
|
228
|
+
contact_end: int,
|
|
229
|
+
interpolated_phases: list[tuple[float, float, ContactState]],
|
|
230
|
+
) -> tuple[float, float]:
|
|
231
|
+
"""Find precise sub-frame timing for contact phase.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
contact_start: Integer contact start frame
|
|
235
|
+
contact_end: Integer contact end frame
|
|
236
|
+
interpolated_phases: Sub-frame precision phases
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Tuple of (contact_start_frac, contact_end_frac)
|
|
240
|
+
"""
|
|
241
|
+
contact_start_frac = float(contact_start)
|
|
242
|
+
contact_end_frac = float(contact_end)
|
|
243
|
+
|
|
244
|
+
# Find the matching ground phase in interpolated_phases
|
|
245
|
+
for start_frac, end_frac, state in interpolated_phases:
|
|
246
|
+
if (
|
|
247
|
+
state == ContactState.ON_GROUND
|
|
248
|
+
and int(start_frac) <= contact_start <= int(end_frac) + 1
|
|
249
|
+
and int(start_frac) <= contact_end <= int(end_frac) + 1
|
|
250
|
+
):
|
|
251
|
+
contact_start_frac = start_frac
|
|
252
|
+
contact_end_frac = end_frac
|
|
253
|
+
break
|
|
254
|
+
|
|
255
|
+
return contact_start_frac, contact_end_frac
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _analyze_flight_phase(
|
|
259
|
+
metrics: DropJumpMetrics,
|
|
260
|
+
phases: list[tuple[int, int, ContactState]],
|
|
261
|
+
interpolated_phases: list[tuple[float, float, ContactState]],
|
|
262
|
+
contact_end: int,
|
|
263
|
+
foot_y_positions: np.ndarray,
|
|
264
|
+
fps: float,
|
|
265
|
+
smoothing_window: int,
|
|
266
|
+
polyorder: int,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Analyze flight phase and calculate jump height metrics.
|
|
269
|
+
|
|
270
|
+
Uses acceleration-based landing detection (like CMJ) for accurate flight time,
|
|
271
|
+
then calculates jump height using kinematic formula h = g*t²/8.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
metrics: DropJumpMetrics object to populate
|
|
275
|
+
phases: All phase tuples
|
|
276
|
+
interpolated_phases: Sub-frame precision phases
|
|
277
|
+
contact_end: End of contact phase
|
|
278
|
+
foot_y_positions: Vertical position array
|
|
279
|
+
fps: Video frame rate
|
|
280
|
+
smoothing_window: Window size for acceleration computation
|
|
281
|
+
polyorder: Polynomial order for Savitzky-Golay filter
|
|
282
|
+
"""
|
|
283
|
+
# Find takeoff frame (end of ground contact)
|
|
284
|
+
flight_start = contact_end
|
|
285
|
+
|
|
286
|
+
# Compute accelerations for landing detection
|
|
287
|
+
accelerations = compute_acceleration_from_derivative(
|
|
288
|
+
foot_y_positions, window_length=smoothing_window, polyorder=polyorder
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Use acceleration-based landing detection (like CMJ)
|
|
292
|
+
# This finds the actual ground impact, not just when velocity drops
|
|
293
|
+
flight_end = find_landing_from_acceleration(
|
|
294
|
+
foot_y_positions, accelerations, flight_start, fps, search_duration=0.7
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Store integer frame indices
|
|
298
|
+
metrics.flight_start_frame = flight_start
|
|
299
|
+
metrics.flight_end_frame = flight_end
|
|
300
|
+
|
|
301
|
+
# Find precise sub-frame timing for takeoff
|
|
302
|
+
flight_start_frac = float(flight_start)
|
|
303
|
+
flight_end_frac = float(flight_end)
|
|
304
|
+
|
|
305
|
+
for start_frac, end_frac, state in interpolated_phases:
|
|
306
|
+
if (
|
|
307
|
+
state == ContactState.ON_GROUND
|
|
308
|
+
and int(start_frac) <= flight_start <= int(end_frac) + 1
|
|
309
|
+
):
|
|
310
|
+
# Use end of ground contact as precise takeoff
|
|
311
|
+
flight_start_frac = end_frac
|
|
312
|
+
break
|
|
313
|
+
|
|
314
|
+
# Calculate flight time
|
|
315
|
+
flight_frames_precise = flight_end_frac - flight_start_frac
|
|
316
|
+
metrics.flight_time = flight_frames_precise / fps
|
|
317
|
+
metrics.flight_start_frame_precise = flight_start_frac
|
|
318
|
+
metrics.flight_end_frame_precise = flight_end_frac
|
|
319
|
+
|
|
320
|
+
# Calculate jump height using kinematic method (like CMJ)
|
|
321
|
+
# h = g * t² / 8
|
|
322
|
+
g = 9.81 # m/s^2
|
|
323
|
+
jump_height_kinematic = (g * metrics.flight_time**2) / 8
|
|
324
|
+
|
|
325
|
+
# Always use kinematic method for jump height (like CMJ)
|
|
326
|
+
metrics.jump_height = jump_height_kinematic
|
|
327
|
+
metrics.jump_height_kinematic = jump_height_kinematic
|
|
328
|
+
|
|
329
|
+
# Calculate trajectory-based height for reference
|
|
330
|
+
takeoff_position = foot_y_positions[flight_start]
|
|
331
|
+
flight_positions = foot_y_positions[flight_start : flight_end + 1]
|
|
332
|
+
|
|
333
|
+
if len(flight_positions) > 0:
|
|
334
|
+
peak_idx = np.argmin(flight_positions)
|
|
335
|
+
metrics.peak_height_frame = int(flight_start + peak_idx)
|
|
336
|
+
peak_position = np.min(flight_positions)
|
|
337
|
+
|
|
338
|
+
height_normalized = float(takeoff_position - peak_position)
|
|
339
|
+
metrics.jump_height_trajectory = height_normalized
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def calculate_drop_jump_metrics(
|
|
343
|
+
contact_states: list[ContactState],
|
|
344
|
+
foot_y_positions: np.ndarray,
|
|
345
|
+
fps: float,
|
|
346
|
+
drop_start_frame: int | None = None,
|
|
347
|
+
velocity_threshold: float = 0.02,
|
|
348
|
+
smoothing_window: int = 5,
|
|
349
|
+
polyorder: int = 2,
|
|
350
|
+
use_curvature: bool = True,
|
|
351
|
+
) -> DropJumpMetrics:
|
|
352
|
+
"""
|
|
353
|
+
Calculate drop-jump metrics from contact states and positions.
|
|
354
|
+
|
|
355
|
+
Jump height is calculated from flight time using kinematic formula: h = g × t² / 8
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
contact_states: Contact state for each frame
|
|
359
|
+
foot_y_positions: Vertical positions of feet (normalized 0-1)
|
|
360
|
+
fps: Video frame rate
|
|
361
|
+
drop_start_frame: Optional manual drop start frame
|
|
362
|
+
velocity_threshold: Velocity threshold used for contact detection (for interpolation)
|
|
363
|
+
smoothing_window: Window size for velocity/acceleration smoothing (must be odd)
|
|
364
|
+
polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
|
|
365
|
+
use_curvature: Whether to use curvature analysis for refining transitions
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
DropJumpMetrics object with calculated values
|
|
369
|
+
"""
|
|
370
|
+
metrics = DropJumpMetrics()
|
|
371
|
+
|
|
372
|
+
# Determine drop start frame
|
|
373
|
+
drop_start_frame_value = _determine_drop_start_frame(
|
|
374
|
+
drop_start_frame, foot_y_positions, fps, smoothing_window
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Find contact phases
|
|
378
|
+
phases = find_contact_phases(contact_states)
|
|
379
|
+
interpolated_phases = find_interpolated_phase_transitions_with_curvature(
|
|
380
|
+
foot_y_positions,
|
|
381
|
+
contact_states,
|
|
382
|
+
velocity_threshold,
|
|
383
|
+
smoothing_window,
|
|
384
|
+
polyorder,
|
|
385
|
+
use_curvature,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if not phases:
|
|
389
|
+
return metrics
|
|
390
|
+
|
|
391
|
+
# Filter phases to only include those after drop start
|
|
392
|
+
phases, interpolated_phases = _filter_phases_after_drop(
|
|
393
|
+
phases, interpolated_phases, drop_start_frame_value
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if not phases:
|
|
397
|
+
return metrics
|
|
398
|
+
|
|
399
|
+
# Separate ground and air phases
|
|
400
|
+
ground_phases = [
|
|
401
|
+
(start, end, i)
|
|
402
|
+
for i, (start, end, state) in enumerate(phases)
|
|
403
|
+
if state == ContactState.ON_GROUND
|
|
404
|
+
]
|
|
405
|
+
air_phases_indexed = [
|
|
406
|
+
(start, end, i)
|
|
407
|
+
for i, (start, end, state) in enumerate(phases)
|
|
408
|
+
if state == ContactState.IN_AIR
|
|
409
|
+
]
|
|
410
|
+
|
|
411
|
+
if not ground_phases:
|
|
412
|
+
return metrics
|
|
413
|
+
|
|
414
|
+
# Identify main contact phase
|
|
415
|
+
contact_start, contact_end, _ = _identify_main_contact_phase(
|
|
416
|
+
phases, ground_phases, air_phases_indexed, foot_y_positions
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Store integer frame indices
|
|
420
|
+
metrics.contact_start_frame = contact_start
|
|
421
|
+
metrics.contact_end_frame = contact_end
|
|
422
|
+
|
|
423
|
+
# Find precise timing for contact phase
|
|
424
|
+
contact_start_frac, contact_end_frac = _find_precise_phase_timing(
|
|
425
|
+
contact_start, contact_end, interpolated_phases
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# Calculate ground contact time
|
|
429
|
+
contact_frames_precise = contact_end_frac - contact_start_frac
|
|
430
|
+
metrics.ground_contact_time = contact_frames_precise / fps
|
|
431
|
+
metrics.contact_start_frame_precise = contact_start_frac
|
|
432
|
+
metrics.contact_end_frame_precise = contact_end_frac
|
|
433
|
+
|
|
434
|
+
# Analyze flight phase and calculate jump height
|
|
435
|
+
_analyze_flight_phase(
|
|
436
|
+
metrics,
|
|
437
|
+
phases,
|
|
438
|
+
interpolated_phases,
|
|
439
|
+
contact_end,
|
|
440
|
+
foot_y_positions,
|
|
441
|
+
fps,
|
|
442
|
+
smoothing_window,
|
|
443
|
+
polyorder,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
return metrics
|
kinemotion/py.typed
ADDED
|
File without changes
|