kinemotion 0.71.0__py3-none-any.whl → 0.71.1__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.
- kinemotion/cmj/analysis.py +2 -4
- kinemotion/cmj/api.py +9 -7
- kinemotion/cmj/debug_overlay.py +154 -286
- kinemotion/cmj/joint_angles.py +96 -31
- kinemotion/cmj/metrics_validator.py +22 -29
- kinemotion/cmj/validation_bounds.py +1 -18
- kinemotion/core/__init__.py +0 -2
- kinemotion/core/auto_tuning.py +91 -99
- kinemotion/core/debug_overlay_utils.py +142 -15
- kinemotion/core/experimental.py +55 -51
- kinemotion/core/filtering.py +15 -11
- kinemotion/core/overlay_constants.py +61 -0
- kinemotion/core/pose.py +47 -98
- kinemotion/core/smoothing.py +65 -51
- kinemotion/core/types.py +15 -0
- kinemotion/core/validation.py +6 -7
- kinemotion/core/video_io.py +14 -9
- kinemotion/dropjump/__init__.py +2 -2
- kinemotion/dropjump/analysis.py +67 -44
- kinemotion/dropjump/api.py +5 -17
- kinemotion/dropjump/cli.py +62 -78
- kinemotion/dropjump/debug_overlay.py +124 -65
- kinemotion/dropjump/validation_bounds.py +1 -1
- {kinemotion-0.71.0.dist-info → kinemotion-0.71.1.dist-info}/METADATA +1 -1
- kinemotion-0.71.1.dist-info/RECORD +50 -0
- kinemotion-0.71.0.dist-info/RECORD +0 -49
- {kinemotion-0.71.0.dist-info → kinemotion-0.71.1.dist-info}/WHEEL +0 -0
- {kinemotion-0.71.0.dist-info → kinemotion-0.71.1.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.71.0.dist-info → kinemotion-0.71.1.dist-info}/licenses/LICENSE +0 -0
kinemotion/cmj/analysis.py
CHANGED
|
@@ -8,7 +8,7 @@ from scipy.signal import savgol_filter
|
|
|
8
8
|
from ..core.experimental import unused
|
|
9
9
|
from ..core.smoothing import compute_acceleration_from_derivative
|
|
10
10
|
from ..core.timing import NULL_TIMER, Timer
|
|
11
|
-
from ..core.types import FloatArray
|
|
11
|
+
from ..core.types import HIP_KEYS, FloatArray
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def compute_signed_velocity(
|
|
@@ -503,12 +503,10 @@ def compute_average_hip_position(
|
|
|
503
503
|
Returns:
|
|
504
504
|
(x, y) average hip position in normalized coordinates
|
|
505
505
|
"""
|
|
506
|
-
hip_keys = ["left_hip", "right_hip"]
|
|
507
|
-
|
|
508
506
|
x_positions: list[float] = []
|
|
509
507
|
y_positions: list[float] = []
|
|
510
508
|
|
|
511
|
-
for key in
|
|
509
|
+
for key in HIP_KEYS:
|
|
512
510
|
if key in landmarks:
|
|
513
511
|
x, y, visibility = landmarks[key]
|
|
514
512
|
if visibility > 0.5: # Only use visible landmarks
|
kinemotion/cmj/api.py
CHANGED
|
@@ -262,13 +262,15 @@ def _get_tuned_parameters(
|
|
|
262
262
|
with timer.measure("parameter_auto_tuning"):
|
|
263
263
|
characteristics = analyze_video_sample(landmarks_sequence, video.fps, video.frame_count)
|
|
264
264
|
params = auto_tune_parameters(characteristics, quality_preset)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
265
|
+
|
|
266
|
+
if overrides:
|
|
267
|
+
params = apply_expert_overrides(
|
|
268
|
+
params,
|
|
269
|
+
overrides.smoothing_window,
|
|
270
|
+
overrides.velocity_threshold,
|
|
271
|
+
overrides.min_contact_frames,
|
|
272
|
+
overrides.visibility_threshold,
|
|
273
|
+
)
|
|
272
274
|
|
|
273
275
|
if verbose:
|
|
274
276
|
print_verbose_parameters(video, characteristics, quality_preset, params)
|
kinemotion/cmj/debug_overlay.py
CHANGED
|
@@ -4,161 +4,99 @@ import cv2
|
|
|
4
4
|
import numpy as np
|
|
5
5
|
|
|
6
6
|
from ..core.debug_overlay_utils import BaseDebugOverlayRenderer
|
|
7
|
+
from ..core.overlay_constants import (
|
|
8
|
+
ANGLE_ARC_RADIUS,
|
|
9
|
+
ANKLE_COLOR,
|
|
10
|
+
BLACK,
|
|
11
|
+
CYAN,
|
|
12
|
+
DEEP_FLEXION_ANGLE,
|
|
13
|
+
FOOT_LANDMARK_RADIUS,
|
|
14
|
+
FOOT_VISIBILITY_THRESHOLD,
|
|
15
|
+
FULL_EXTENSION_ANGLE,
|
|
16
|
+
GRAY,
|
|
17
|
+
GREEN,
|
|
18
|
+
HIP_COLOR,
|
|
19
|
+
JOINT_ANGLES_BOX_HEIGHT,
|
|
20
|
+
JOINT_ANGLES_BOX_X_OFFSET,
|
|
21
|
+
KNEE_COLOR,
|
|
22
|
+
METRICS_BOX_WIDTH,
|
|
23
|
+
ORANGE,
|
|
24
|
+
RED,
|
|
25
|
+
TRUNK_COLOR,
|
|
26
|
+
VISIBILITY_THRESHOLD_HIGH,
|
|
27
|
+
WHITE,
|
|
28
|
+
Color,
|
|
29
|
+
LandmarkDict,
|
|
30
|
+
)
|
|
31
|
+
from .analysis import CMJPhase
|
|
7
32
|
from .joint_angles import calculate_triple_extension
|
|
8
33
|
from .kinematics import CMJMetrics
|
|
9
34
|
|
|
10
35
|
|
|
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
36
|
class CMJDebugOverlayRenderer(BaseDebugOverlayRenderer):
|
|
23
37
|
"""Renders debug information on CMJ video frames."""
|
|
24
38
|
|
|
25
|
-
|
|
39
|
+
# Phase colors (BGR format)
|
|
40
|
+
PHASE_COLORS: dict[CMJPhase, Color] = {
|
|
41
|
+
CMJPhase.STANDING: (255, 200, 100), # Light blue
|
|
42
|
+
CMJPhase.ECCENTRIC: (0, 165, 255), # Orange
|
|
43
|
+
CMJPhase.TRANSITION: (255, 0, 255), # Magenta/Purple
|
|
44
|
+
CMJPhase.CONCENTRIC: (0, 255, 0), # Green
|
|
45
|
+
CMJPhase.FLIGHT: (0, 0, 255), # Red
|
|
46
|
+
CMJPhase.LANDING: (255, 255, 255), # White
|
|
47
|
+
}
|
|
48
|
+
DEFAULT_PHASE_COLOR: Color = GRAY
|
|
49
|
+
|
|
50
|
+
def _determine_phase(self, frame_idx: int, metrics: CMJMetrics) -> CMJPhase:
|
|
26
51
|
"""Determine which phase the current frame is in."""
|
|
27
52
|
if metrics.standing_start_frame and frame_idx < metrics.standing_start_frame:
|
|
28
|
-
return
|
|
53
|
+
return CMJPhase.STANDING
|
|
29
54
|
|
|
30
55
|
if frame_idx < metrics.lowest_point_frame:
|
|
31
|
-
return
|
|
56
|
+
return CMJPhase.ECCENTRIC
|
|
32
57
|
|
|
33
|
-
# Brief transition at lowest point (
|
|
58
|
+
# Brief transition at lowest point (within 2 frames)
|
|
34
59
|
if abs(frame_idx - metrics.lowest_point_frame) < 2:
|
|
35
|
-
return
|
|
60
|
+
return CMJPhase.TRANSITION
|
|
36
61
|
|
|
37
62
|
if frame_idx < metrics.takeoff_frame:
|
|
38
|
-
return
|
|
63
|
+
return CMJPhase.CONCENTRIC
|
|
39
64
|
|
|
40
65
|
if frame_idx < metrics.landing_frame:
|
|
41
|
-
return
|
|
66
|
+
return CMJPhase.FLIGHT
|
|
42
67
|
|
|
43
|
-
return
|
|
68
|
+
return CMJPhase.LANDING
|
|
44
69
|
|
|
45
|
-
def _get_phase_color(self, phase:
|
|
70
|
+
def _get_phase_color(self, phase: CMJPhase) -> Color:
|
|
46
71
|
"""Get color for each phase."""
|
|
47
|
-
|
|
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]
|
|
72
|
+
return self.PHASE_COLORS.get(phase, self.DEFAULT_PHASE_COLOR)
|
|
95
73
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
end_x = int(landmarks[end_key][0] * self.width)
|
|
101
|
-
end_y = int(landmarks[end_key][1] * self.height)
|
|
74
|
+
def _get_triple_extension_angles(
|
|
75
|
+
self, landmarks: LandmarkDict
|
|
76
|
+
) -> tuple[dict[str, float | None], str] | None:
|
|
77
|
+
"""Get triple extension angles, trying right side first then left.
|
|
102
78
|
|
|
103
|
-
|
|
79
|
+
Returns tuple of (angles_dict, side_used) or None if unavailable.
|
|
80
|
+
"""
|
|
81
|
+
for side in ["right", "left"]:
|
|
82
|
+
angles = calculate_triple_extension(landmarks, side=side)
|
|
83
|
+
if angles is not None:
|
|
84
|
+
return angles, side
|
|
85
|
+
return None
|
|
104
86
|
|
|
105
|
-
def
|
|
87
|
+
def _draw_info_box(
|
|
106
88
|
self,
|
|
107
89
|
frame: np.ndarray,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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]]
|
|
90
|
+
top_left: tuple[int, int],
|
|
91
|
+
bottom_right: tuple[int, int],
|
|
92
|
+
border_color: Color,
|
|
129
93
|
) -> None:
|
|
130
|
-
"""Draw
|
|
131
|
-
|
|
132
|
-
|
|
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)
|
|
94
|
+
"""Draw a filled box with border for displaying information."""
|
|
95
|
+
cv2.rectangle(frame, top_left, bottom_right, BLACK, -1)
|
|
96
|
+
cv2.rectangle(frame, top_left, bottom_right, border_color, 2)
|
|
156
97
|
|
|
157
98
|
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],
|
|
99
|
+
self, frame: np.ndarray, landmarks: LandmarkDict, phase_color: Color
|
|
162
100
|
) -> None:
|
|
163
101
|
"""Draw joint angles for triple extension analysis.
|
|
164
102
|
|
|
@@ -167,36 +105,23 @@ class CMJDebugOverlayRenderer(BaseDebugOverlayRenderer):
|
|
|
167
105
|
landmarks: Pose landmarks
|
|
168
106
|
phase_color: Current phase color
|
|
169
107
|
"""
|
|
170
|
-
|
|
171
|
-
|
|
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:
|
|
108
|
+
result = self._get_triple_extension_angles(landmarks)
|
|
109
|
+
if result is None:
|
|
179
110
|
return
|
|
180
111
|
|
|
112
|
+
angles, side_used = result
|
|
113
|
+
|
|
181
114
|
# Position for angle text display (right side of frame)
|
|
182
|
-
text_x = self.width -
|
|
115
|
+
text_x = self.width - JOINT_ANGLES_BOX_X_OFFSET
|
|
183
116
|
text_y = 100
|
|
117
|
+
box_height = JOINT_ANGLES_BOX_HEIGHT
|
|
184
118
|
|
|
185
|
-
# Draw background box
|
|
186
|
-
|
|
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(
|
|
119
|
+
# Draw background box
|
|
120
|
+
self._draw_info_box(
|
|
195
121
|
frame,
|
|
196
122
|
(text_x - 10, text_y - 30),
|
|
197
123
|
(self.width - 10, text_y + box_height),
|
|
198
124
|
phase_color,
|
|
199
|
-
2,
|
|
200
125
|
)
|
|
201
126
|
|
|
202
127
|
# Title
|
|
@@ -206,58 +131,54 @@ class CMJDebugOverlayRenderer(BaseDebugOverlayRenderer):
|
|
|
206
131
|
(text_x, text_y - 5),
|
|
207
132
|
cv2.FONT_HERSHEY_SIMPLEX,
|
|
208
133
|
0.5,
|
|
209
|
-
|
|
134
|
+
WHITE,
|
|
210
135
|
1,
|
|
211
136
|
)
|
|
212
137
|
|
|
213
|
-
#
|
|
214
|
-
|
|
215
|
-
("Ankle",
|
|
216
|
-
("Knee",
|
|
217
|
-
("Hip",
|
|
218
|
-
("Trunk",
|
|
138
|
+
# Angle display configuration: (label, angle_key, color, joint_suffix)
|
|
139
|
+
angle_config = [
|
|
140
|
+
("Ankle", "ankle_angle", ANKLE_COLOR, "ankle"),
|
|
141
|
+
("Knee", "knee_angle", KNEE_COLOR, "knee"),
|
|
142
|
+
("Hip", "hip_angle", HIP_COLOR, "hip"),
|
|
143
|
+
("Trunk", "trunk_tilt", TRUNK_COLOR, None),
|
|
219
144
|
]
|
|
220
145
|
|
|
221
146
|
y_offset = text_y + 25
|
|
222
|
-
for label,
|
|
223
|
-
|
|
147
|
+
for label, angle_key, color, joint_suffix in angle_config:
|
|
148
|
+
angle = angles.get(angle_key)
|
|
149
|
+
|
|
150
|
+
# Draw text
|
|
224
151
|
if angle is not None:
|
|
225
|
-
|
|
152
|
+
text = f"{label}: {angle:.0f}"
|
|
226
153
|
text_color = color
|
|
227
154
|
else:
|
|
228
|
-
|
|
229
|
-
text_color =
|
|
155
|
+
text = f"{label}: N/A"
|
|
156
|
+
text_color = GRAY
|
|
230
157
|
|
|
231
158
|
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,
|
|
159
|
+
frame, text, (text_x, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.5, text_color, 2
|
|
239
160
|
)
|
|
240
161
|
y_offset += 30
|
|
241
162
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
163
|
+
# Draw arc at joint if angle available and has associated joint
|
|
164
|
+
if angle is not None and joint_suffix is not None:
|
|
165
|
+
self._draw_angle_arc(frame, landmarks, f"{side_used}_{joint_suffix}", angle)
|
|
166
|
+
|
|
167
|
+
def _get_extension_color(self, angle: float) -> Color:
|
|
168
|
+
"""Get color based on joint extension angle.
|
|
169
|
+
|
|
170
|
+
Green for extended (>160 deg), red for flexed (<90 deg), orange for moderate.
|
|
171
|
+
"""
|
|
172
|
+
if angle > FULL_EXTENSION_ANGLE:
|
|
173
|
+
return GREEN
|
|
174
|
+
if angle < DEEP_FLEXION_ANGLE:
|
|
175
|
+
return RED
|
|
176
|
+
return ORANGE
|
|
252
177
|
|
|
253
178
|
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,
|
|
179
|
+
self, frame: np.ndarray, landmarks: LandmarkDict, joint_key: str, angle: float
|
|
259
180
|
) -> None:
|
|
260
|
-
"""Draw a
|
|
181
|
+
"""Draw a circle at a joint to visualize the angle.
|
|
261
182
|
|
|
262
183
|
Args:
|
|
263
184
|
frame: Frame to draw on (modified in place)
|
|
@@ -265,101 +186,74 @@ class CMJDebugOverlayRenderer(BaseDebugOverlayRenderer):
|
|
|
265
186
|
joint_key: Key of the joint landmark
|
|
266
187
|
angle: Angle value in degrees
|
|
267
188
|
"""
|
|
268
|
-
if joint_key not in landmarks
|
|
189
|
+
if joint_key not in landmarks:
|
|
190
|
+
return
|
|
191
|
+
landmark = landmarks[joint_key]
|
|
192
|
+
if not self._is_visible(landmark, VISIBILITY_THRESHOLD_HIGH):
|
|
269
193
|
return
|
|
270
194
|
|
|
271
|
-
|
|
272
|
-
|
|
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)
|
|
195
|
+
point = self._landmark_to_pixel(landmark)
|
|
196
|
+
arc_color = self._get_extension_color(angle)
|
|
197
|
+
cv2.circle(frame, point, ANGLE_ARC_RADIUS, arc_color, 2)
|
|
287
198
|
|
|
288
199
|
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],
|
|
200
|
+
self, frame: np.ndarray, landmarks: LandmarkDict, phase_color: Color
|
|
293
201
|
) -> None:
|
|
294
202
|
"""Draw foot landmarks and average position."""
|
|
295
203
|
foot_keys = ["left_ankle", "right_ankle", "left_heel", "right_heel"]
|
|
296
|
-
foot_positions = []
|
|
204
|
+
foot_positions: list[tuple[int, int]] = []
|
|
297
205
|
|
|
298
206
|
for key in foot_keys:
|
|
299
|
-
if key in landmarks:
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
207
|
+
if key not in landmarks:
|
|
208
|
+
continue
|
|
209
|
+
landmark = landmarks[key]
|
|
210
|
+
if landmark[2] > FOOT_VISIBILITY_THRESHOLD:
|
|
211
|
+
point = self._landmark_to_pixel(landmark)
|
|
212
|
+
foot_positions.append(point)
|
|
213
|
+
cv2.circle(frame, point, FOOT_LANDMARK_RADIUS, CYAN, -1)
|
|
306
214
|
|
|
307
215
|
# Draw average foot position with phase color
|
|
308
216
|
if foot_positions:
|
|
309
217
|
avg_x = int(np.mean([p[0] for p in foot_positions]))
|
|
310
218
|
avg_y = int(np.mean([p[1] for p in foot_positions]))
|
|
311
219
|
cv2.circle(frame, (avg_x, avg_y), 12, phase_color, -1)
|
|
312
|
-
cv2.circle(frame, (avg_x, avg_y), 14,
|
|
220
|
+
cv2.circle(frame, (avg_x, avg_y), 14, WHITE, 2)
|
|
313
221
|
|
|
314
222
|
def _draw_phase_banner(
|
|
315
|
-
self, frame: np.ndarray, phase:
|
|
223
|
+
self, frame: np.ndarray, phase: CMJPhase | None, phase_color: Color
|
|
316
224
|
) -> None:
|
|
317
225
|
"""Draw phase indicator banner."""
|
|
318
|
-
if
|
|
226
|
+
if phase is None:
|
|
319
227
|
return
|
|
320
228
|
|
|
321
|
-
phase_text = f"Phase: {phase.upper()}"
|
|
229
|
+
phase_text = f"Phase: {phase.value.upper()}"
|
|
322
230
|
text_size = cv2.getTextSize(phase_text, cv2.FONT_HERSHEY_SIMPLEX, 1, 2)[0]
|
|
323
231
|
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,
|
|
232
|
+
cv2.putText(frame, phase_text, (10, 35), cv2.FONT_HERSHEY_SIMPLEX, 1, BLACK, 2)
|
|
325
233
|
|
|
326
234
|
def _draw_key_frame_markers(
|
|
327
235
|
self, frame: np.ndarray, frame_idx: int, metrics: CMJMetrics
|
|
328
236
|
) -> None:
|
|
329
237
|
"""Draw markers for key frames (standing start, lowest, takeoff, landing)."""
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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")
|
|
238
|
+
# Key frame definitions: (frame_value, label)
|
|
239
|
+
key_frames: list[tuple[float | None, str]] = [
|
|
240
|
+
(metrics.standing_start_frame, "COUNTERMOVEMENT START"),
|
|
241
|
+
(metrics.lowest_point_frame, "LOWEST POINT"),
|
|
242
|
+
(metrics.takeoff_frame, "TAKEOFF"),
|
|
243
|
+
(metrics.landing_frame, "LANDING"),
|
|
244
|
+
]
|
|
344
245
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
cv2.FONT_HERSHEY_SIMPLEX,
|
|
351
|
-
0.7,
|
|
352
|
-
(255, 255, 0),
|
|
353
|
-
2,
|
|
354
|
-
)
|
|
355
|
-
y_offset += 35
|
|
246
|
+
y_offset = 120
|
|
247
|
+
for key_frame, label in key_frames:
|
|
248
|
+
if key_frame is not None and frame_idx == int(key_frame):
|
|
249
|
+
cv2.putText(frame, label, (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.7, CYAN, 2)
|
|
250
|
+
y_offset += 35
|
|
356
251
|
|
|
357
252
|
def _draw_metrics_summary(
|
|
358
253
|
self, frame: np.ndarray, frame_idx: int, metrics: CMJMetrics
|
|
359
254
|
) -> None:
|
|
360
|
-
"""Draw metrics summary in bottom right (last 30 frames)."""
|
|
361
|
-
|
|
362
|
-
if frame_idx < total_frames - 30:
|
|
255
|
+
"""Draw metrics summary in bottom right (last 30 frames after landing)."""
|
|
256
|
+
if frame_idx < int(metrics.landing_frame):
|
|
363
257
|
return
|
|
364
258
|
|
|
365
259
|
metrics_text = [
|
|
@@ -370,46 +264,28 @@ class CMJDebugOverlayRenderer(BaseDebugOverlayRenderer):
|
|
|
370
264
|
f"Con Duration: {metrics.concentric_duration * 1000:.0f}ms",
|
|
371
265
|
]
|
|
372
266
|
|
|
373
|
-
#
|
|
267
|
+
# Calculate box dimensions
|
|
374
268
|
box_height = len(metrics_text) * 30 + 20
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
)
|
|
269
|
+
top_left = (self.width - METRICS_BOX_WIDTH, self.height - box_height - 10)
|
|
270
|
+
bottom_right = (self.width - 10, self.height - 10)
|
|
271
|
+
|
|
272
|
+
self._draw_info_box(frame, top_left, bottom_right, GREEN)
|
|
389
273
|
|
|
390
274
|
# Draw metrics text
|
|
275
|
+
text_x = self.width - METRICS_BOX_WIDTH + 10
|
|
391
276
|
text_y = self.height - box_height + 10
|
|
392
277
|
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
|
-
)
|
|
278
|
+
cv2.putText(frame, text, (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, 0.6, WHITE, 1)
|
|
402
279
|
text_y += 30
|
|
403
280
|
|
|
404
281
|
def render_frame(
|
|
405
282
|
self,
|
|
406
283
|
frame: np.ndarray,
|
|
407
|
-
landmarks:
|
|
284
|
+
landmarks: LandmarkDict | None,
|
|
408
285
|
frame_idx: int,
|
|
409
286
|
metrics: CMJMetrics | None = None,
|
|
410
287
|
) -> np.ndarray:
|
|
411
|
-
"""
|
|
412
|
-
Render debug overlay on frame.
|
|
288
|
+
"""Render debug overlay on frame.
|
|
413
289
|
|
|
414
290
|
Args:
|
|
415
291
|
frame: Original video frame
|
|
@@ -422,31 +298,23 @@ class CMJDebugOverlayRenderer(BaseDebugOverlayRenderer):
|
|
|
422
298
|
"""
|
|
423
299
|
annotated = frame.copy()
|
|
424
300
|
|
|
425
|
-
# Determine current phase
|
|
426
|
-
phase = None
|
|
427
|
-
phase_color =
|
|
301
|
+
# Determine current phase and color
|
|
302
|
+
phase: CMJPhase | None = None
|
|
303
|
+
phase_color: Color = WHITE
|
|
428
304
|
if metrics:
|
|
429
305
|
phase = self._determine_phase(frame_idx, metrics)
|
|
430
306
|
phase_color = self._get_phase_color(phase)
|
|
431
307
|
|
|
432
|
-
# Draw skeleton and
|
|
308
|
+
# Draw skeleton and joint visualization if landmarks available
|
|
433
309
|
if landmarks:
|
|
434
310
|
self._draw_skeleton(annotated, landmarks)
|
|
435
311
|
self._draw_joint_angles(annotated, landmarks, phase_color)
|
|
436
312
|
self._draw_foot_landmarks(annotated, landmarks, phase_color)
|
|
437
313
|
|
|
438
|
-
# Draw phase indicator
|
|
314
|
+
# Draw phase indicator and frame number
|
|
439
315
|
self._draw_phase_banner(annotated, phase, phase_color)
|
|
440
|
-
|
|
441
|
-
# Draw frame number
|
|
442
316
|
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,
|
|
317
|
+
annotated, f"Frame: {frame_idx}", (10, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.7, WHITE, 2
|
|
450
318
|
)
|
|
451
319
|
|
|
452
320
|
# Draw key frame markers and metrics summary
|