kinemotion 0.76.3__py3-none-any.whl → 1.0.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 +3 -18
- kinemotion/api.py +7 -27
- kinemotion/cli.py +2 -4
- kinemotion/{countermovement_jump → cmj}/analysis.py +158 -16
- kinemotion/{countermovement_jump → cmj}/api.py +18 -46
- kinemotion/{countermovement_jump → cmj}/cli.py +46 -6
- kinemotion/cmj/debug_overlay.py +457 -0
- kinemotion/{countermovement_jump → cmj}/joint_angles.py +31 -96
- kinemotion/{countermovement_jump → cmj}/metrics_validator.py +293 -184
- kinemotion/{countermovement_jump → cmj}/validation_bounds.py +18 -1
- kinemotion/core/__init__.py +2 -11
- kinemotion/core/auto_tuning.py +107 -149
- kinemotion/core/cli_utils.py +0 -74
- kinemotion/core/debug_overlay_utils.py +15 -142
- kinemotion/core/experimental.py +51 -55
- kinemotion/core/filtering.py +56 -116
- kinemotion/core/pipeline_utils.py +2 -2
- kinemotion/core/pose.py +98 -47
- kinemotion/core/quality.py +6 -4
- kinemotion/core/smoothing.py +51 -65
- kinemotion/core/types.py +0 -15
- kinemotion/core/validation.py +7 -76
- kinemotion/core/video_io.py +27 -41
- kinemotion/{drop_jump → dropjump}/__init__.py +8 -2
- kinemotion/{drop_jump → dropjump}/analysis.py +120 -282
- kinemotion/{drop_jump → dropjump}/api.py +33 -59
- kinemotion/{drop_jump → dropjump}/cli.py +136 -70
- kinemotion/dropjump/debug_overlay.py +182 -0
- kinemotion/{drop_jump → dropjump}/kinematics.py +65 -175
- kinemotion/{drop_jump → dropjump}/metrics_validator.py +51 -25
- kinemotion/{drop_jump → dropjump}/validation_bounds.py +1 -1
- kinemotion/models/rtmpose-s_simcc-body7_pt-body7-halpe26_700e-256x192-7f134165_20230605.onnx +3 -0
- kinemotion/models/yolox_tiny_8xb8-300e_humanart-6f3252f9.onnx +3 -0
- {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/METADATA +26 -75
- kinemotion-1.0.0.dist-info/RECORD +49 -0
- kinemotion/core/overlay_constants.py +0 -61
- kinemotion/core/video_analysis_base.py +0 -132
- kinemotion/countermovement_jump/debug_overlay.py +0 -325
- kinemotion/drop_jump/debug_overlay.py +0 -241
- kinemotion/squat_jump/__init__.py +0 -5
- kinemotion/squat_jump/analysis.py +0 -377
- kinemotion/squat_jump/api.py +0 -610
- kinemotion/squat_jump/cli.py +0 -309
- kinemotion/squat_jump/debug_overlay.py +0 -163
- kinemotion/squat_jump/kinematics.py +0 -342
- kinemotion/squat_jump/metrics_validator.py +0 -438
- kinemotion/squat_jump/validation_bounds.py +0 -221
- kinemotion-0.76.3.dist-info/RECORD +0 -57
- /kinemotion/{countermovement_jump → cmj}/__init__.py +0 -0
- /kinemotion/{countermovement_jump → cmj}/kinematics.py +0 -0
- {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Debug overlay rendering for CMJ analysis."""
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from ..core.debug_overlay_utils import BaseDebugOverlayRenderer
|
|
7
|
+
from .joint_angles import calculate_triple_extension
|
|
8
|
+
from .kinematics import CMJMetrics
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CMJPhaseState:
|
|
12
|
+
"""States for CMJ phases."""
|
|
13
|
+
|
|
14
|
+
STANDING = "standing"
|
|
15
|
+
ECCENTRIC = "eccentric"
|
|
16
|
+
TRANSITION = "transition"
|
|
17
|
+
CONCENTRIC = "concentric"
|
|
18
|
+
FLIGHT = "flight"
|
|
19
|
+
LANDING = "landing"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CMJDebugOverlayRenderer(BaseDebugOverlayRenderer):
|
|
23
|
+
"""Renders debug information on CMJ video frames."""
|
|
24
|
+
|
|
25
|
+
def _determine_phase(self, frame_idx: int, metrics: CMJMetrics) -> str:
|
|
26
|
+
"""Determine which phase the current frame is in."""
|
|
27
|
+
if metrics.standing_start_frame and frame_idx < metrics.standing_start_frame:
|
|
28
|
+
return CMJPhaseState.STANDING
|
|
29
|
+
|
|
30
|
+
if frame_idx < metrics.lowest_point_frame:
|
|
31
|
+
return CMJPhaseState.ECCENTRIC
|
|
32
|
+
|
|
33
|
+
# Brief transition at lowest point (±2 frames)
|
|
34
|
+
if abs(frame_idx - metrics.lowest_point_frame) < 2:
|
|
35
|
+
return CMJPhaseState.TRANSITION
|
|
36
|
+
|
|
37
|
+
if frame_idx < metrics.takeoff_frame:
|
|
38
|
+
return CMJPhaseState.CONCENTRIC
|
|
39
|
+
|
|
40
|
+
if frame_idx < metrics.landing_frame:
|
|
41
|
+
return CMJPhaseState.FLIGHT
|
|
42
|
+
|
|
43
|
+
return CMJPhaseState.LANDING
|
|
44
|
+
|
|
45
|
+
def _get_phase_color(self, phase: str) -> tuple[int, int, int]:
|
|
46
|
+
"""Get color for each phase."""
|
|
47
|
+
colors = {
|
|
48
|
+
CMJPhaseState.STANDING: (255, 200, 100), # Light blue
|
|
49
|
+
CMJPhaseState.ECCENTRIC: (0, 165, 255), # Orange
|
|
50
|
+
CMJPhaseState.TRANSITION: (255, 0, 255), # Magenta/Purple
|
|
51
|
+
CMJPhaseState.CONCENTRIC: (0, 255, 0), # Green
|
|
52
|
+
CMJPhaseState.FLIGHT: (0, 0, 255), # Red
|
|
53
|
+
CMJPhaseState.LANDING: (255, 255, 255), # White
|
|
54
|
+
}
|
|
55
|
+
return colors.get(phase, (128, 128, 128))
|
|
56
|
+
|
|
57
|
+
def _get_skeleton_segments(
|
|
58
|
+
self, side_prefix: str
|
|
59
|
+
) -> list[tuple[str, str, tuple[int, int, int], int]]:
|
|
60
|
+
"""Get skeleton segments for one side of the body."""
|
|
61
|
+
return [
|
|
62
|
+
(f"{side_prefix}heel", f"{side_prefix}ankle", (0, 255, 255), 3), # Foot
|
|
63
|
+
(
|
|
64
|
+
f"{side_prefix}heel",
|
|
65
|
+
f"{side_prefix}foot_index",
|
|
66
|
+
(0, 255, 255),
|
|
67
|
+
2,
|
|
68
|
+
), # Alt foot
|
|
69
|
+
(f"{side_prefix}ankle", f"{side_prefix}knee", (255, 100, 100), 4), # Shin
|
|
70
|
+
(f"{side_prefix}knee", f"{side_prefix}hip", (100, 255, 100), 4), # Femur
|
|
71
|
+
(
|
|
72
|
+
f"{side_prefix}hip",
|
|
73
|
+
f"{side_prefix}shoulder",
|
|
74
|
+
(100, 100, 255),
|
|
75
|
+
4,
|
|
76
|
+
), # Trunk
|
|
77
|
+
(f"{side_prefix}shoulder", "nose", (150, 150, 255), 2), # Neck
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
def _draw_segment(
|
|
81
|
+
self,
|
|
82
|
+
frame: np.ndarray,
|
|
83
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
84
|
+
start_key: str,
|
|
85
|
+
end_key: str,
|
|
86
|
+
color: tuple[int, int, int],
|
|
87
|
+
thickness: int,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Draw a single skeleton segment if both endpoints are visible."""
|
|
90
|
+
if start_key not in landmarks or end_key not in landmarks:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
start_vis = landmarks[start_key][2]
|
|
94
|
+
end_vis = landmarks[end_key][2]
|
|
95
|
+
|
|
96
|
+
# Very low threshold to show as much as possible
|
|
97
|
+
if start_vis > 0.2 and end_vis > 0.2:
|
|
98
|
+
start_x = int(landmarks[start_key][0] * self.width)
|
|
99
|
+
start_y = int(landmarks[start_key][1] * self.height)
|
|
100
|
+
end_x = int(landmarks[end_key][0] * self.width)
|
|
101
|
+
end_y = int(landmarks[end_key][1] * self.height)
|
|
102
|
+
|
|
103
|
+
cv2.line(frame, (start_x, start_y), (end_x, end_y), color, thickness)
|
|
104
|
+
|
|
105
|
+
def _draw_joints(
|
|
106
|
+
self,
|
|
107
|
+
frame: np.ndarray,
|
|
108
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
109
|
+
side_prefix: str,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Draw joint circles for one side of the body."""
|
|
112
|
+
joint_keys = [
|
|
113
|
+
f"{side_prefix}heel",
|
|
114
|
+
f"{side_prefix}foot_index",
|
|
115
|
+
f"{side_prefix}ankle",
|
|
116
|
+
f"{side_prefix}knee",
|
|
117
|
+
f"{side_prefix}hip",
|
|
118
|
+
f"{side_prefix}shoulder",
|
|
119
|
+
]
|
|
120
|
+
for key in joint_keys:
|
|
121
|
+
if key in landmarks and landmarks[key][2] > 0.2:
|
|
122
|
+
jx = int(landmarks[key][0] * self.width)
|
|
123
|
+
jy = int(landmarks[key][1] * self.height)
|
|
124
|
+
cv2.circle(frame, (jx, jy), 6, (255, 255, 255), -1)
|
|
125
|
+
cv2.circle(frame, (jx, jy), 8, (0, 0, 0), 2)
|
|
126
|
+
|
|
127
|
+
def _draw_skeleton(
|
|
128
|
+
self, frame: np.ndarray, landmarks: dict[str, tuple[float, float, float]]
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Draw skeleton segments showing body landmarks.
|
|
131
|
+
|
|
132
|
+
Draws whatever landmarks are visible. In side-view videos, ankle/knee
|
|
133
|
+
may have low visibility, so we draw available segments.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
frame: Frame to draw on (modified in place)
|
|
137
|
+
landmarks: Pose landmarks
|
|
138
|
+
"""
|
|
139
|
+
# Try both sides and draw all visible segments
|
|
140
|
+
for side_prefix in ["right_", "left_"]:
|
|
141
|
+
segments = self._get_skeleton_segments(side_prefix)
|
|
142
|
+
|
|
143
|
+
# Draw ALL visible segments (not just one side)
|
|
144
|
+
for start_key, end_key, color, thickness in segments:
|
|
145
|
+
self._draw_segment(frame, landmarks, start_key, end_key, color, thickness)
|
|
146
|
+
|
|
147
|
+
# Draw joints as circles for this side
|
|
148
|
+
self._draw_joints(frame, landmarks, side_prefix)
|
|
149
|
+
|
|
150
|
+
# Always draw nose (head position) if visible
|
|
151
|
+
if "nose" in landmarks and landmarks["nose"][2] > 0.2:
|
|
152
|
+
nx = int(landmarks["nose"][0] * self.width)
|
|
153
|
+
ny = int(landmarks["nose"][1] * self.height)
|
|
154
|
+
cv2.circle(frame, (nx, ny), 8, (255, 255, 0), -1)
|
|
155
|
+
cv2.circle(frame, (nx, ny), 10, (0, 0, 0), 2)
|
|
156
|
+
|
|
157
|
+
def _draw_joint_angles(
|
|
158
|
+
self,
|
|
159
|
+
frame: np.ndarray,
|
|
160
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
161
|
+
phase_color: tuple[int, int, int],
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Draw joint angles for triple extension analysis.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
frame: Frame to draw on (modified in place)
|
|
167
|
+
landmarks: Pose landmarks
|
|
168
|
+
phase_color: Current phase color
|
|
169
|
+
"""
|
|
170
|
+
# Try right side first, fallback to left
|
|
171
|
+
angles = calculate_triple_extension(landmarks, side="right")
|
|
172
|
+
side_used = "right"
|
|
173
|
+
|
|
174
|
+
if angles is None:
|
|
175
|
+
angles = calculate_triple_extension(landmarks, side="left")
|
|
176
|
+
side_used = "left"
|
|
177
|
+
|
|
178
|
+
if angles is None:
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
# Position for angle text display (right side of frame)
|
|
182
|
+
text_x = self.width - 180
|
|
183
|
+
text_y = 100
|
|
184
|
+
|
|
185
|
+
# Draw background box for angles
|
|
186
|
+
box_height = 150
|
|
187
|
+
cv2.rectangle(
|
|
188
|
+
frame,
|
|
189
|
+
(text_x - 10, text_y - 30),
|
|
190
|
+
(self.width - 10, text_y + box_height),
|
|
191
|
+
(0, 0, 0),
|
|
192
|
+
-1,
|
|
193
|
+
)
|
|
194
|
+
cv2.rectangle(
|
|
195
|
+
frame,
|
|
196
|
+
(text_x - 10, text_y - 30),
|
|
197
|
+
(self.width - 10, text_y + box_height),
|
|
198
|
+
phase_color,
|
|
199
|
+
2,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Title
|
|
203
|
+
cv2.putText(
|
|
204
|
+
frame,
|
|
205
|
+
"TRIPLE EXTENSION",
|
|
206
|
+
(text_x, text_y - 5),
|
|
207
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
208
|
+
0.5,
|
|
209
|
+
(255, 255, 255),
|
|
210
|
+
1,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Draw available angles (show "N/A" for unavailable)
|
|
214
|
+
angle_data = [
|
|
215
|
+
("Ankle", angles.get("ankle_angle"), (0, 255, 255)),
|
|
216
|
+
("Knee", angles.get("knee_angle"), (255, 100, 100)),
|
|
217
|
+
("Hip", angles.get("hip_angle"), (100, 255, 100)),
|
|
218
|
+
("Trunk", angles.get("trunk_tilt"), (100, 100, 255)),
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
y_offset = text_y + 25
|
|
222
|
+
for label, angle, color in angle_data:
|
|
223
|
+
# Angle text
|
|
224
|
+
if angle is not None:
|
|
225
|
+
angle_text = f"{label}: {angle:.0f}"
|
|
226
|
+
text_color = color
|
|
227
|
+
else:
|
|
228
|
+
angle_text = f"{label}: N/A"
|
|
229
|
+
text_color = (128, 128, 128) # Gray for unavailable
|
|
230
|
+
|
|
231
|
+
cv2.putText(
|
|
232
|
+
frame,
|
|
233
|
+
angle_text,
|
|
234
|
+
(text_x, y_offset),
|
|
235
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
236
|
+
0.5,
|
|
237
|
+
text_color,
|
|
238
|
+
2,
|
|
239
|
+
)
|
|
240
|
+
y_offset += 30
|
|
241
|
+
|
|
242
|
+
# Draw angle arcs at joints for visual feedback (only if angle is available)
|
|
243
|
+
ankle_angle = angles.get("ankle_angle")
|
|
244
|
+
if ankle_angle is not None:
|
|
245
|
+
self._draw_angle_arc(frame, landmarks, f"{side_used}_ankle", ankle_angle)
|
|
246
|
+
knee_angle = angles.get("knee_angle")
|
|
247
|
+
if knee_angle is not None:
|
|
248
|
+
self._draw_angle_arc(frame, landmarks, f"{side_used}_knee", knee_angle)
|
|
249
|
+
hip_angle = angles.get("hip_angle")
|
|
250
|
+
if hip_angle is not None:
|
|
251
|
+
self._draw_angle_arc(frame, landmarks, f"{side_used}_hip", hip_angle)
|
|
252
|
+
|
|
253
|
+
def _draw_angle_arc(
|
|
254
|
+
self,
|
|
255
|
+
frame: np.ndarray,
|
|
256
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
257
|
+
joint_key: str,
|
|
258
|
+
angle: float,
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Draw a small arc at a joint to visualize the angle.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
frame: Frame to draw on (modified in place)
|
|
264
|
+
landmarks: Pose landmarks
|
|
265
|
+
joint_key: Key of the joint landmark
|
|
266
|
+
angle: Angle value in degrees
|
|
267
|
+
"""
|
|
268
|
+
if joint_key not in landmarks or landmarks[joint_key][2] < 0.3:
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
jx = int(landmarks[joint_key][0] * self.width)
|
|
272
|
+
jy = int(landmarks[joint_key][1] * self.height)
|
|
273
|
+
|
|
274
|
+
# Draw arc radius based on angle (smaller arc for more extended joints)
|
|
275
|
+
radius = 25
|
|
276
|
+
|
|
277
|
+
# Color based on extension: green for extended (>160°), red for flexed (<90°)
|
|
278
|
+
if angle > 160:
|
|
279
|
+
arc_color = (0, 255, 0) # Green - good extension
|
|
280
|
+
elif angle < 90:
|
|
281
|
+
arc_color = (0, 0, 255) # Red - deep flexion
|
|
282
|
+
else:
|
|
283
|
+
arc_color = (0, 165, 255) # Orange - moderate
|
|
284
|
+
|
|
285
|
+
# Draw arc (simplified as a circle for now)
|
|
286
|
+
cv2.circle(frame, (jx, jy), radius, arc_color, 2)
|
|
287
|
+
|
|
288
|
+
def _draw_foot_landmarks(
|
|
289
|
+
self,
|
|
290
|
+
frame: np.ndarray,
|
|
291
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
292
|
+
phase_color: tuple[int, int, int],
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Draw foot landmarks and average position."""
|
|
295
|
+
foot_keys = ["left_ankle", "right_ankle", "left_heel", "right_heel"]
|
|
296
|
+
foot_positions = []
|
|
297
|
+
|
|
298
|
+
for key in foot_keys:
|
|
299
|
+
if key in landmarks:
|
|
300
|
+
x, y, vis = landmarks[key]
|
|
301
|
+
if vis > 0.5:
|
|
302
|
+
lx = int(x * self.width)
|
|
303
|
+
ly = int(y * self.height)
|
|
304
|
+
foot_positions.append((lx, ly))
|
|
305
|
+
cv2.circle(frame, (lx, ly), 5, (255, 255, 0), -1)
|
|
306
|
+
|
|
307
|
+
# Draw average foot position with phase color
|
|
308
|
+
if foot_positions:
|
|
309
|
+
avg_x = int(np.mean([p[0] for p in foot_positions]))
|
|
310
|
+
avg_y = int(np.mean([p[1] for p in foot_positions]))
|
|
311
|
+
cv2.circle(frame, (avg_x, avg_y), 12, phase_color, -1)
|
|
312
|
+
cv2.circle(frame, (avg_x, avg_y), 14, (255, 255, 255), 2)
|
|
313
|
+
|
|
314
|
+
def _draw_phase_banner(
|
|
315
|
+
self, frame: np.ndarray, phase: str | None, phase_color: tuple[int, int, int]
|
|
316
|
+
) -> None:
|
|
317
|
+
"""Draw phase indicator banner."""
|
|
318
|
+
if not phase:
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
phase_text = f"Phase: {phase.upper()}"
|
|
322
|
+
text_size = cv2.getTextSize(phase_text, cv2.FONT_HERSHEY_SIMPLEX, 1, 2)[0]
|
|
323
|
+
cv2.rectangle(frame, (5, 5), (text_size[0] + 15, 45), phase_color, -1)
|
|
324
|
+
cv2.putText(frame, phase_text, (10, 35), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2)
|
|
325
|
+
|
|
326
|
+
def _draw_key_frame_markers(
|
|
327
|
+
self, frame: np.ndarray, frame_idx: int, metrics: CMJMetrics
|
|
328
|
+
) -> None:
|
|
329
|
+
"""Draw markers for key frames (standing start, lowest, takeoff, landing)."""
|
|
330
|
+
y_offset = 120
|
|
331
|
+
markers = []
|
|
332
|
+
|
|
333
|
+
if metrics.standing_start_frame and frame_idx == int(metrics.standing_start_frame):
|
|
334
|
+
markers.append("COUNTERMOVEMENT START")
|
|
335
|
+
|
|
336
|
+
if frame_idx == int(metrics.lowest_point_frame):
|
|
337
|
+
markers.append("LOWEST POINT")
|
|
338
|
+
|
|
339
|
+
if frame_idx == int(metrics.takeoff_frame):
|
|
340
|
+
markers.append("TAKEOFF")
|
|
341
|
+
|
|
342
|
+
if frame_idx == int(metrics.landing_frame):
|
|
343
|
+
markers.append("LANDING")
|
|
344
|
+
|
|
345
|
+
for marker in markers:
|
|
346
|
+
cv2.putText(
|
|
347
|
+
frame,
|
|
348
|
+
marker,
|
|
349
|
+
(10, y_offset),
|
|
350
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
351
|
+
0.7,
|
|
352
|
+
(255, 255, 0),
|
|
353
|
+
2,
|
|
354
|
+
)
|
|
355
|
+
y_offset += 35
|
|
356
|
+
|
|
357
|
+
def _draw_metrics_summary(
|
|
358
|
+
self, frame: np.ndarray, frame_idx: int, metrics: CMJMetrics
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Draw metrics summary in bottom right (last 30 frames)."""
|
|
361
|
+
total_frames = int(metrics.landing_frame) + 30
|
|
362
|
+
if frame_idx < total_frames - 30:
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
metrics_text = [
|
|
366
|
+
f"Jump Height: {metrics.jump_height:.3f}m",
|
|
367
|
+
f"Flight Time: {metrics.flight_time * 1000:.0f}ms",
|
|
368
|
+
f"CM Depth: {metrics.countermovement_depth:.3f}m",
|
|
369
|
+
f"Ecc Duration: {metrics.eccentric_duration * 1000:.0f}ms",
|
|
370
|
+
f"Con Duration: {metrics.concentric_duration * 1000:.0f}ms",
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
# Draw background
|
|
374
|
+
box_height = len(metrics_text) * 30 + 20
|
|
375
|
+
cv2.rectangle(
|
|
376
|
+
frame,
|
|
377
|
+
(self.width - 320, self.height - box_height - 10),
|
|
378
|
+
(self.width - 10, self.height - 10),
|
|
379
|
+
(0, 0, 0),
|
|
380
|
+
-1,
|
|
381
|
+
)
|
|
382
|
+
cv2.rectangle(
|
|
383
|
+
frame,
|
|
384
|
+
(self.width - 320, self.height - box_height - 10),
|
|
385
|
+
(self.width - 10, self.height - 10),
|
|
386
|
+
(0, 255, 0),
|
|
387
|
+
2,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# Draw metrics text
|
|
391
|
+
text_y = self.height - box_height + 10
|
|
392
|
+
for text in metrics_text:
|
|
393
|
+
cv2.putText(
|
|
394
|
+
frame,
|
|
395
|
+
text,
|
|
396
|
+
(self.width - 310, text_y),
|
|
397
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
398
|
+
0.6,
|
|
399
|
+
(255, 255, 255),
|
|
400
|
+
1,
|
|
401
|
+
)
|
|
402
|
+
text_y += 30
|
|
403
|
+
|
|
404
|
+
def render_frame(
|
|
405
|
+
self,
|
|
406
|
+
frame: np.ndarray,
|
|
407
|
+
landmarks: dict[str, tuple[float, float, float]] | None,
|
|
408
|
+
frame_idx: int,
|
|
409
|
+
metrics: CMJMetrics | None = None,
|
|
410
|
+
) -> np.ndarray:
|
|
411
|
+
"""
|
|
412
|
+
Render debug overlay on frame.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
frame: Original video frame
|
|
416
|
+
landmarks: Pose landmarks for this frame
|
|
417
|
+
frame_idx: Current frame index
|
|
418
|
+
metrics: CMJ metrics (optional)
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
Frame with debug overlay
|
|
422
|
+
"""
|
|
423
|
+
annotated = frame.copy()
|
|
424
|
+
|
|
425
|
+
# Determine current phase if metrics available
|
|
426
|
+
phase = None
|
|
427
|
+
phase_color = (255, 255, 255)
|
|
428
|
+
if metrics:
|
|
429
|
+
phase = self._determine_phase(frame_idx, metrics)
|
|
430
|
+
phase_color = self._get_phase_color(phase)
|
|
431
|
+
|
|
432
|
+
# Draw skeleton and triple extension if landmarks available
|
|
433
|
+
if landmarks:
|
|
434
|
+
self._draw_skeleton(annotated, landmarks)
|
|
435
|
+
self._draw_joint_angles(annotated, landmarks, phase_color)
|
|
436
|
+
self._draw_foot_landmarks(annotated, landmarks, phase_color)
|
|
437
|
+
|
|
438
|
+
# Draw phase indicator banner
|
|
439
|
+
self._draw_phase_banner(annotated, phase, phase_color)
|
|
440
|
+
|
|
441
|
+
# Draw frame number
|
|
442
|
+
cv2.putText(
|
|
443
|
+
annotated,
|
|
444
|
+
f"Frame: {frame_idx}",
|
|
445
|
+
(10, 80),
|
|
446
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
447
|
+
0.7,
|
|
448
|
+
(255, 255, 255),
|
|
449
|
+
2,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# Draw key frame markers and metrics summary
|
|
453
|
+
if metrics:
|
|
454
|
+
self._draw_key_frame_markers(annotated, frame_idx, metrics)
|
|
455
|
+
self._draw_metrics_summary(annotated, frame_idx, metrics)
|
|
456
|
+
|
|
457
|
+
return annotated
|
|
@@ -5,54 +5,6 @@ import math
|
|
|
5
5
|
import numpy as np
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def _get_side_prefix(side: str) -> str:
|
|
9
|
-
"""Get the landmark key prefix for a given side.
|
|
10
|
-
|
|
11
|
-
Args:
|
|
12
|
-
side: Which side ("left" or "right")
|
|
13
|
-
|
|
14
|
-
Returns:
|
|
15
|
-
The prefix string for landmark keys
|
|
16
|
-
"""
|
|
17
|
-
return "left_" if side == "left" else "right_"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _is_landmark_visible(
|
|
21
|
-
landmarks: dict[str, tuple[float, float, float]],
|
|
22
|
-
key: str,
|
|
23
|
-
threshold: float = 0.3,
|
|
24
|
-
) -> bool:
|
|
25
|
-
"""Check if a landmark meets the minimum visibility threshold.
|
|
26
|
-
|
|
27
|
-
Args:
|
|
28
|
-
landmarks: Pose landmarks dictionary
|
|
29
|
-
key: Landmark key to check
|
|
30
|
-
threshold: Minimum visibility threshold (default: 0.3)
|
|
31
|
-
|
|
32
|
-
Returns:
|
|
33
|
-
True if landmark exists and meets visibility threshold
|
|
34
|
-
"""
|
|
35
|
-
return key in landmarks and landmarks[key][2] >= threshold
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _get_landmark_xy(
|
|
39
|
-
landmarks: dict[str, tuple[float, float, float]],
|
|
40
|
-
key: str,
|
|
41
|
-
) -> tuple[float, float] | None:
|
|
42
|
-
"""Extract x, y coordinates from a landmark.
|
|
43
|
-
|
|
44
|
-
Args:
|
|
45
|
-
landmarks: Pose landmarks dictionary
|
|
46
|
-
key: Landmark key to extract
|
|
47
|
-
|
|
48
|
-
Returns:
|
|
49
|
-
Tuple of (x, y) coordinates, or None if key not found
|
|
50
|
-
"""
|
|
51
|
-
if key not in landmarks:
|
|
52
|
-
return None
|
|
53
|
-
return (landmarks[key][0], landmarks[key][1])
|
|
54
|
-
|
|
55
|
-
|
|
56
8
|
def calculate_angle_3_points(
|
|
57
9
|
point1: tuple[float, float],
|
|
58
10
|
point2: tuple[float, float],
|
|
@@ -136,7 +88,7 @@ def calculate_ankle_angle(
|
|
|
136
88
|
Returns:
|
|
137
89
|
Ankle angle in degrees, or None if landmarks not available
|
|
138
90
|
"""
|
|
139
|
-
prefix =
|
|
91
|
+
prefix = "left_" if side == "left" else "right_"
|
|
140
92
|
|
|
141
93
|
foot_index_key = f"{prefix}foot_index"
|
|
142
94
|
heel_key = f"{prefix}heel"
|
|
@@ -144,28 +96,23 @@ def calculate_ankle_angle(
|
|
|
144
96
|
knee_key = f"{prefix}knee"
|
|
145
97
|
|
|
146
98
|
# Check ankle and knee visibility (required)
|
|
147
|
-
if not
|
|
99
|
+
if ankle_key not in landmarks or landmarks[ankle_key][2] < 0.3:
|
|
148
100
|
return None
|
|
149
|
-
if not
|
|
101
|
+
if knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
|
|
150
102
|
return None
|
|
151
103
|
|
|
152
|
-
ankle =
|
|
153
|
-
knee =
|
|
154
|
-
|
|
155
|
-
if ankle is None or knee is None:
|
|
156
|
-
return None
|
|
104
|
+
ankle = (landmarks[ankle_key][0], landmarks[ankle_key][1])
|
|
105
|
+
knee = (landmarks[knee_key][0], landmarks[knee_key][1])
|
|
157
106
|
|
|
158
107
|
# Try foot_index first (primary: toe tip for plantarflexion accuracy)
|
|
159
|
-
if
|
|
160
|
-
foot_point =
|
|
161
|
-
|
|
162
|
-
return calculate_angle_3_points(foot_point, ankle, knee)
|
|
108
|
+
if foot_index_key in landmarks and landmarks[foot_index_key][2] > 0.5:
|
|
109
|
+
foot_point = (landmarks[foot_index_key][0], landmarks[foot_index_key][1])
|
|
110
|
+
return calculate_angle_3_points(foot_point, ankle, knee)
|
|
163
111
|
|
|
164
112
|
# Fallback to heel if foot_index visibility is insufficient
|
|
165
|
-
if
|
|
166
|
-
foot_point =
|
|
167
|
-
|
|
168
|
-
return calculate_angle_3_points(foot_point, ankle, knee)
|
|
113
|
+
if heel_key in landmarks and landmarks[heel_key][2] > 0.3:
|
|
114
|
+
foot_point = (landmarks[heel_key][0], landmarks[heel_key][1])
|
|
115
|
+
return calculate_angle_3_points(foot_point, ankle, knee)
|
|
169
116
|
|
|
170
117
|
# No valid foot landmark available
|
|
171
118
|
return None
|
|
@@ -189,32 +136,29 @@ def calculate_knee_angle(
|
|
|
189
136
|
Returns:
|
|
190
137
|
Knee angle in degrees, or None if landmarks not available
|
|
191
138
|
"""
|
|
192
|
-
prefix =
|
|
139
|
+
prefix = "left_" if side == "left" else "right_"
|
|
193
140
|
|
|
194
141
|
ankle_key = f"{prefix}ankle"
|
|
195
142
|
knee_key = f"{prefix}knee"
|
|
196
143
|
hip_key = f"{prefix}hip"
|
|
197
144
|
|
|
198
145
|
# Check visibility
|
|
199
|
-
if not
|
|
146
|
+
if ankle_key not in landmarks or landmarks[ankle_key][2] < 0.3:
|
|
200
147
|
# Fallback: use foot_index if ankle not visible
|
|
201
148
|
foot_key = f"{prefix}foot_index"
|
|
202
|
-
if
|
|
149
|
+
if foot_key in landmarks and landmarks[foot_key][2] > 0.3:
|
|
203
150
|
ankle_key = foot_key
|
|
204
151
|
else:
|
|
205
152
|
return None
|
|
206
153
|
|
|
207
|
-
if not
|
|
154
|
+
if knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
|
|
208
155
|
return None
|
|
209
|
-
if not
|
|
156
|
+
if hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
|
|
210
157
|
return None
|
|
211
158
|
|
|
212
|
-
ankle =
|
|
213
|
-
knee =
|
|
214
|
-
hip =
|
|
215
|
-
|
|
216
|
-
if ankle is None or knee is None or hip is None:
|
|
217
|
-
return None
|
|
159
|
+
ankle = (landmarks[ankle_key][0], landmarks[ankle_key][1])
|
|
160
|
+
knee = (landmarks[knee_key][0], landmarks[knee_key][1])
|
|
161
|
+
hip = (landmarks[hip_key][0], landmarks[hip_key][1])
|
|
218
162
|
|
|
219
163
|
return calculate_angle_3_points(ankle, knee, hip)
|
|
220
164
|
|
|
@@ -237,26 +181,23 @@ def calculate_hip_angle(
|
|
|
237
181
|
Returns:
|
|
238
182
|
Hip angle in degrees, or None if landmarks not available
|
|
239
183
|
"""
|
|
240
|
-
prefix =
|
|
184
|
+
prefix = "left_" if side == "left" else "right_"
|
|
241
185
|
|
|
242
186
|
knee_key = f"{prefix}knee"
|
|
243
187
|
hip_key = f"{prefix}hip"
|
|
244
188
|
shoulder_key = f"{prefix}shoulder"
|
|
245
189
|
|
|
246
190
|
# Check visibility
|
|
247
|
-
if not
|
|
191
|
+
if knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
|
|
248
192
|
return None
|
|
249
|
-
if not
|
|
193
|
+
if hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
|
|
250
194
|
return None
|
|
251
|
-
if not
|
|
195
|
+
if shoulder_key not in landmarks or landmarks[shoulder_key][2] < 0.3:
|
|
252
196
|
return None
|
|
253
197
|
|
|
254
|
-
knee =
|
|
255
|
-
hip =
|
|
256
|
-
shoulder =
|
|
257
|
-
|
|
258
|
-
if knee is None or hip is None or shoulder is None:
|
|
259
|
-
return None
|
|
198
|
+
knee = (landmarks[knee_key][0], landmarks[knee_key][1])
|
|
199
|
+
hip = (landmarks[hip_key][0], landmarks[hip_key][1])
|
|
200
|
+
shoulder = (landmarks[shoulder_key][0], landmarks[shoulder_key][1])
|
|
260
201
|
|
|
261
202
|
return calculate_angle_3_points(knee, hip, shoulder)
|
|
262
203
|
|
|
@@ -279,25 +220,19 @@ def calculate_trunk_tilt(
|
|
|
279
220
|
Returns:
|
|
280
221
|
Trunk tilt angle in degrees, or None if landmarks not available
|
|
281
222
|
"""
|
|
282
|
-
prefix =
|
|
223
|
+
prefix = "left_" if side == "left" else "right_"
|
|
283
224
|
|
|
284
225
|
hip_key = f"{prefix}hip"
|
|
285
226
|
shoulder_key = f"{prefix}shoulder"
|
|
286
227
|
|
|
287
228
|
# Check visibility
|
|
288
|
-
if not
|
|
289
|
-
return None
|
|
290
|
-
if not _is_landmark_visible(landmarks, shoulder_key):
|
|
229
|
+
if hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
|
|
291
230
|
return None
|
|
292
|
-
|
|
293
|
-
hip_xy = _get_landmark_xy(landmarks, hip_key)
|
|
294
|
-
shoulder_xy = _get_landmark_xy(landmarks, shoulder_key)
|
|
295
|
-
|
|
296
|
-
if hip_xy is None or shoulder_xy is None:
|
|
231
|
+
if shoulder_key not in landmarks or landmarks[shoulder_key][2] < 0.3:
|
|
297
232
|
return None
|
|
298
233
|
|
|
299
|
-
hip = np.array([
|
|
300
|
-
shoulder = np.array([
|
|
234
|
+
hip = np.array([landmarks[hip_key][0], landmarks[hip_key][1]])
|
|
235
|
+
shoulder = np.array([landmarks[shoulder_key][0], landmarks[shoulder_key][1]])
|
|
301
236
|
|
|
302
237
|
# Vector from hip to shoulder
|
|
303
238
|
trunk_vector = shoulder - hip
|