kinemotion 0.10.12__py3-none-any.whl → 0.11.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 +18 -1
- kinemotion/api.py +335 -0
- kinemotion/cli.py +2 -0
- kinemotion/cmj/__init__.py +5 -0
- kinemotion/cmj/analysis.py +548 -0
- kinemotion/cmj/cli.py +626 -0
- kinemotion/cmj/debug_overlay.py +514 -0
- kinemotion/cmj/joint_angles.py +290 -0
- kinemotion/cmj/kinematics.py +191 -0
- {kinemotion-0.10.12.dist-info → kinemotion-0.11.0.dist-info}/METADATA +92 -124
- kinemotion-0.11.0.dist-info/RECORD +26 -0
- kinemotion-0.10.12.dist-info/RECORD +0 -20
- {kinemotion-0.10.12.dist-info → kinemotion-0.11.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.10.12.dist-info → kinemotion-0.11.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.10.12.dist-info → kinemotion-0.11.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""Debug overlay rendering for CMJ analysis."""
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from .joint_angles import calculate_triple_extension
|
|
7
|
+
from .kinematics import CMJMetrics
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CMJPhaseState:
|
|
11
|
+
"""States for CMJ phases."""
|
|
12
|
+
|
|
13
|
+
STANDING = "standing"
|
|
14
|
+
ECCENTRIC = "eccentric"
|
|
15
|
+
TRANSITION = "transition"
|
|
16
|
+
CONCENTRIC = "concentric"
|
|
17
|
+
FLIGHT = "flight"
|
|
18
|
+
LANDING = "landing"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CMJDebugOverlayRenderer:
|
|
22
|
+
"""Renders debug information on CMJ video frames."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
output_path: str,
|
|
27
|
+
width: int,
|
|
28
|
+
height: int,
|
|
29
|
+
display_width: int,
|
|
30
|
+
display_height: int,
|
|
31
|
+
fps: float,
|
|
32
|
+
):
|
|
33
|
+
"""
|
|
34
|
+
Initialize overlay renderer.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
output_path: Path for output video
|
|
38
|
+
width: Encoded frame width (from source video)
|
|
39
|
+
height: Encoded frame height (from source video)
|
|
40
|
+
display_width: Display width (considering SAR)
|
|
41
|
+
display_height: Display height (considering SAR)
|
|
42
|
+
fps: Frames per second
|
|
43
|
+
"""
|
|
44
|
+
self.width = width
|
|
45
|
+
self.height = height
|
|
46
|
+
self.display_width = display_width
|
|
47
|
+
self.display_height = display_height
|
|
48
|
+
self.needs_resize = (display_width != width) or (display_height != height)
|
|
49
|
+
|
|
50
|
+
# Try H.264 codec first (better quality/compatibility), fallback to mp4v
|
|
51
|
+
fourcc = cv2.VideoWriter_fourcc(*"avc1")
|
|
52
|
+
self.writer = cv2.VideoWriter(
|
|
53
|
+
output_path, fourcc, fps, (display_width, display_height)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Check if writer opened successfully, fallback to mp4v if not
|
|
57
|
+
if not self.writer.isOpened():
|
|
58
|
+
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
|
59
|
+
self.writer = cv2.VideoWriter(
|
|
60
|
+
output_path, fourcc, fps, (display_width, display_height)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if not self.writer.isOpened():
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"Failed to create video writer for {output_path} with dimensions "
|
|
66
|
+
f"{display_width}x{display_height}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _determine_phase(self, frame_idx: int, metrics: CMJMetrics) -> str:
|
|
70
|
+
"""Determine which phase the current frame is in."""
|
|
71
|
+
if metrics.standing_start_frame and frame_idx < metrics.standing_start_frame:
|
|
72
|
+
return CMJPhaseState.STANDING
|
|
73
|
+
|
|
74
|
+
if frame_idx < metrics.lowest_point_frame:
|
|
75
|
+
return CMJPhaseState.ECCENTRIC
|
|
76
|
+
|
|
77
|
+
# Brief transition at lowest point (±2 frames)
|
|
78
|
+
if abs(frame_idx - metrics.lowest_point_frame) < 2:
|
|
79
|
+
return CMJPhaseState.TRANSITION
|
|
80
|
+
|
|
81
|
+
if frame_idx < metrics.takeoff_frame:
|
|
82
|
+
return CMJPhaseState.CONCENTRIC
|
|
83
|
+
|
|
84
|
+
if frame_idx < metrics.landing_frame:
|
|
85
|
+
return CMJPhaseState.FLIGHT
|
|
86
|
+
|
|
87
|
+
return CMJPhaseState.LANDING
|
|
88
|
+
|
|
89
|
+
def _get_phase_color(self, phase: str) -> tuple[int, int, int]:
|
|
90
|
+
"""Get color for each phase."""
|
|
91
|
+
colors = {
|
|
92
|
+
CMJPhaseState.STANDING: (255, 200, 100), # Light blue
|
|
93
|
+
CMJPhaseState.ECCENTRIC: (0, 165, 255), # Orange
|
|
94
|
+
CMJPhaseState.TRANSITION: (255, 0, 255), # Magenta/Purple
|
|
95
|
+
CMJPhaseState.CONCENTRIC: (0, 255, 0), # Green
|
|
96
|
+
CMJPhaseState.FLIGHT: (0, 0, 255), # Red
|
|
97
|
+
CMJPhaseState.LANDING: (255, 255, 255), # White
|
|
98
|
+
}
|
|
99
|
+
return colors.get(phase, (128, 128, 128))
|
|
100
|
+
|
|
101
|
+
def _draw_skeleton(
|
|
102
|
+
self, frame: np.ndarray, landmarks: dict[str, tuple[float, float, float]]
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Draw skeleton segments showing body landmarks.
|
|
105
|
+
|
|
106
|
+
Draws whatever landmarks are visible. In side-view videos, ankle/knee
|
|
107
|
+
may have low visibility, so we draw available segments.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
frame: Frame to draw on (modified in place)
|
|
111
|
+
landmarks: Pose landmarks
|
|
112
|
+
"""
|
|
113
|
+
# Try both sides and draw all visible segments
|
|
114
|
+
for side_prefix in ["right_", "left_"]:
|
|
115
|
+
segments = [
|
|
116
|
+
(f"{side_prefix}heel", f"{side_prefix}ankle", (0, 255, 255), 3), # Foot
|
|
117
|
+
(
|
|
118
|
+
f"{side_prefix}heel",
|
|
119
|
+
f"{side_prefix}foot_index",
|
|
120
|
+
(0, 255, 255),
|
|
121
|
+
2,
|
|
122
|
+
), # Alt foot
|
|
123
|
+
(
|
|
124
|
+
f"{side_prefix}ankle",
|
|
125
|
+
f"{side_prefix}knee",
|
|
126
|
+
(255, 100, 100),
|
|
127
|
+
4,
|
|
128
|
+
), # Shin
|
|
129
|
+
(
|
|
130
|
+
f"{side_prefix}knee",
|
|
131
|
+
f"{side_prefix}hip",
|
|
132
|
+
(100, 255, 100),
|
|
133
|
+
4,
|
|
134
|
+
), # Femur
|
|
135
|
+
(
|
|
136
|
+
f"{side_prefix}hip",
|
|
137
|
+
f"{side_prefix}shoulder",
|
|
138
|
+
(100, 100, 255),
|
|
139
|
+
4,
|
|
140
|
+
), # Trunk
|
|
141
|
+
# Additional segments for better visualization
|
|
142
|
+
(f"{side_prefix}shoulder", "nose", (150, 150, 255), 2), # Neck
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
# Draw ALL visible segments (not just one side)
|
|
146
|
+
for start_key, end_key, color, thickness in segments:
|
|
147
|
+
if start_key in landmarks and end_key in landmarks:
|
|
148
|
+
start_vis = landmarks[start_key][2]
|
|
149
|
+
end_vis = landmarks[end_key][2]
|
|
150
|
+
|
|
151
|
+
# Very low threshold to show as much as possible
|
|
152
|
+
if start_vis > 0.2 and end_vis > 0.2:
|
|
153
|
+
start_x = int(landmarks[start_key][0] * self.width)
|
|
154
|
+
start_y = int(landmarks[start_key][1] * self.height)
|
|
155
|
+
end_x = int(landmarks[end_key][0] * self.width)
|
|
156
|
+
end_y = int(landmarks[end_key][1] * self.height)
|
|
157
|
+
|
|
158
|
+
cv2.line(
|
|
159
|
+
frame, (start_x, start_y), (end_x, end_y), color, thickness
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Draw joints as circles for this side
|
|
163
|
+
joint_keys = [
|
|
164
|
+
f"{side_prefix}heel",
|
|
165
|
+
f"{side_prefix}foot_index",
|
|
166
|
+
f"{side_prefix}ankle",
|
|
167
|
+
f"{side_prefix}knee",
|
|
168
|
+
f"{side_prefix}hip",
|
|
169
|
+
f"{side_prefix}shoulder",
|
|
170
|
+
]
|
|
171
|
+
for key in joint_keys:
|
|
172
|
+
if key in landmarks and landmarks[key][2] > 0.2:
|
|
173
|
+
jx = int(landmarks[key][0] * self.width)
|
|
174
|
+
jy = int(landmarks[key][1] * self.height)
|
|
175
|
+
cv2.circle(frame, (jx, jy), 6, (255, 255, 255), -1)
|
|
176
|
+
cv2.circle(frame, (jx, jy), 8, (0, 0, 0), 2)
|
|
177
|
+
|
|
178
|
+
# Always draw nose (head position) if visible
|
|
179
|
+
if "nose" in landmarks and landmarks["nose"][2] > 0.2:
|
|
180
|
+
nx = int(landmarks["nose"][0] * self.width)
|
|
181
|
+
ny = int(landmarks["nose"][1] * self.height)
|
|
182
|
+
cv2.circle(frame, (nx, ny), 8, (255, 255, 0), -1)
|
|
183
|
+
cv2.circle(frame, (nx, ny), 10, (0, 0, 0), 2)
|
|
184
|
+
|
|
185
|
+
def _draw_joint_angles(
|
|
186
|
+
self,
|
|
187
|
+
frame: np.ndarray,
|
|
188
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
189
|
+
phase_color: tuple[int, int, int],
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Draw joint angles for triple extension analysis.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
frame: Frame to draw on (modified in place)
|
|
195
|
+
landmarks: Pose landmarks
|
|
196
|
+
phase_color: Current phase color
|
|
197
|
+
"""
|
|
198
|
+
# Try right side first, fallback to left
|
|
199
|
+
angles = calculate_triple_extension(landmarks, side="right")
|
|
200
|
+
side_used = "right"
|
|
201
|
+
|
|
202
|
+
if angles is None:
|
|
203
|
+
angles = calculate_triple_extension(landmarks, side="left")
|
|
204
|
+
side_used = "left"
|
|
205
|
+
|
|
206
|
+
if angles is None:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# Position for angle text display (right side of frame)
|
|
210
|
+
text_x = self.width - 180
|
|
211
|
+
text_y = 100
|
|
212
|
+
|
|
213
|
+
# Draw background box for angles
|
|
214
|
+
box_height = 150
|
|
215
|
+
cv2.rectangle(
|
|
216
|
+
frame,
|
|
217
|
+
(text_x - 10, text_y - 30),
|
|
218
|
+
(self.width - 10, text_y + box_height),
|
|
219
|
+
(0, 0, 0),
|
|
220
|
+
-1,
|
|
221
|
+
)
|
|
222
|
+
cv2.rectangle(
|
|
223
|
+
frame,
|
|
224
|
+
(text_x - 10, text_y - 30),
|
|
225
|
+
(self.width - 10, text_y + box_height),
|
|
226
|
+
phase_color,
|
|
227
|
+
2,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Title
|
|
231
|
+
cv2.putText(
|
|
232
|
+
frame,
|
|
233
|
+
"TRIPLE EXTENSION",
|
|
234
|
+
(text_x, text_y - 5),
|
|
235
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
236
|
+
0.5,
|
|
237
|
+
(255, 255, 255),
|
|
238
|
+
1,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Draw available angles (show "N/A" for unavailable)
|
|
242
|
+
angle_data = [
|
|
243
|
+
("Ankle", angles.get("ankle_angle"), (0, 255, 255)),
|
|
244
|
+
("Knee", angles.get("knee_angle"), (255, 100, 100)),
|
|
245
|
+
("Hip", angles.get("hip_angle"), (100, 255, 100)),
|
|
246
|
+
("Trunk", angles.get("trunk_tilt"), (100, 100, 255)),
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
y_offset = text_y + 25
|
|
250
|
+
for label, angle, color in angle_data:
|
|
251
|
+
# Angle text
|
|
252
|
+
if angle is not None:
|
|
253
|
+
angle_text = f"{label}: {angle:.0f}"
|
|
254
|
+
text_color = color
|
|
255
|
+
else:
|
|
256
|
+
angle_text = f"{label}: N/A"
|
|
257
|
+
text_color = (128, 128, 128) # Gray for unavailable
|
|
258
|
+
|
|
259
|
+
cv2.putText(
|
|
260
|
+
frame,
|
|
261
|
+
angle_text,
|
|
262
|
+
(text_x, y_offset),
|
|
263
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
264
|
+
0.5,
|
|
265
|
+
text_color,
|
|
266
|
+
2,
|
|
267
|
+
)
|
|
268
|
+
y_offset += 30
|
|
269
|
+
|
|
270
|
+
# Draw angle arcs at joints for visual feedback (only if angle is available)
|
|
271
|
+
if angles.get("ankle_angle") is not None:
|
|
272
|
+
self._draw_angle_arc(
|
|
273
|
+
frame, landmarks, f"{side_used}_ankle", angles["ankle_angle"]
|
|
274
|
+
)
|
|
275
|
+
if angles.get("knee_angle") is not None:
|
|
276
|
+
self._draw_angle_arc(
|
|
277
|
+
frame, landmarks, f"{side_used}_knee", angles["knee_angle"]
|
|
278
|
+
)
|
|
279
|
+
if angles.get("hip_angle") is not None:
|
|
280
|
+
self._draw_angle_arc(
|
|
281
|
+
frame, landmarks, f"{side_used}_hip", angles["hip_angle"]
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _draw_angle_arc(
|
|
285
|
+
self,
|
|
286
|
+
frame: np.ndarray,
|
|
287
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
288
|
+
joint_key: str,
|
|
289
|
+
angle: float,
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Draw a small arc at a joint to visualize the angle.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
frame: Frame to draw on (modified in place)
|
|
295
|
+
landmarks: Pose landmarks
|
|
296
|
+
joint_key: Key of the joint landmark
|
|
297
|
+
angle: Angle value in degrees
|
|
298
|
+
"""
|
|
299
|
+
if joint_key not in landmarks or landmarks[joint_key][2] < 0.3:
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
jx = int(landmarks[joint_key][0] * self.width)
|
|
303
|
+
jy = int(landmarks[joint_key][1] * self.height)
|
|
304
|
+
|
|
305
|
+
# Draw arc radius based on angle (smaller arc for more extended joints)
|
|
306
|
+
radius = 25
|
|
307
|
+
|
|
308
|
+
# Color based on extension: green for extended (>160°), red for flexed (<90°)
|
|
309
|
+
if angle > 160:
|
|
310
|
+
arc_color = (0, 255, 0) # Green - good extension
|
|
311
|
+
elif angle < 90:
|
|
312
|
+
arc_color = (0, 0, 255) # Red - deep flexion
|
|
313
|
+
else:
|
|
314
|
+
arc_color = (0, 165, 255) # Orange - moderate
|
|
315
|
+
|
|
316
|
+
# Draw arc (simplified as a circle for now)
|
|
317
|
+
cv2.circle(frame, (jx, jy), radius, arc_color, 2)
|
|
318
|
+
|
|
319
|
+
def render_frame(
|
|
320
|
+
self,
|
|
321
|
+
frame: np.ndarray,
|
|
322
|
+
landmarks: dict[str, tuple[float, float, float]] | None,
|
|
323
|
+
frame_idx: int,
|
|
324
|
+
metrics: CMJMetrics | None = None,
|
|
325
|
+
) -> np.ndarray:
|
|
326
|
+
"""
|
|
327
|
+
Render debug overlay on frame.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
frame: Original video frame
|
|
331
|
+
landmarks: Pose landmarks for this frame
|
|
332
|
+
frame_idx: Current frame index
|
|
333
|
+
metrics: CMJ metrics (optional)
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Frame with debug overlay
|
|
337
|
+
"""
|
|
338
|
+
annotated = frame.copy()
|
|
339
|
+
|
|
340
|
+
# Determine current phase if metrics available
|
|
341
|
+
phase = None
|
|
342
|
+
phase_color = (255, 255, 255)
|
|
343
|
+
if metrics:
|
|
344
|
+
phase = self._determine_phase(frame_idx, metrics)
|
|
345
|
+
phase_color = self._get_phase_color(phase)
|
|
346
|
+
|
|
347
|
+
# Draw skeleton and triple extension if landmarks available
|
|
348
|
+
if landmarks:
|
|
349
|
+
# Draw skeleton segments for triple extension
|
|
350
|
+
self._draw_skeleton(annotated, landmarks)
|
|
351
|
+
|
|
352
|
+
# Draw joint angles
|
|
353
|
+
self._draw_joint_angles(annotated, landmarks, phase_color)
|
|
354
|
+
|
|
355
|
+
# Draw foot landmarks
|
|
356
|
+
foot_keys = ["left_ankle", "right_ankle", "left_heel", "right_heel"]
|
|
357
|
+
foot_positions = []
|
|
358
|
+
|
|
359
|
+
for key in foot_keys:
|
|
360
|
+
if key in landmarks:
|
|
361
|
+
x, y, vis = landmarks[key]
|
|
362
|
+
if vis > 0.5:
|
|
363
|
+
lx = int(x * self.width)
|
|
364
|
+
ly = int(y * self.height)
|
|
365
|
+
foot_positions.append((lx, ly))
|
|
366
|
+
cv2.circle(annotated, (lx, ly), 5, (255, 255, 0), -1)
|
|
367
|
+
|
|
368
|
+
# Draw average foot position with phase color
|
|
369
|
+
if foot_positions:
|
|
370
|
+
avg_x = int(np.mean([p[0] for p in foot_positions]))
|
|
371
|
+
avg_y = int(np.mean([p[1] for p in foot_positions]))
|
|
372
|
+
cv2.circle(annotated, (avg_x, avg_y), 12, phase_color, -1)
|
|
373
|
+
cv2.circle(annotated, (avg_x, avg_y), 14, (255, 255, 255), 2)
|
|
374
|
+
|
|
375
|
+
# Draw phase indicator banner
|
|
376
|
+
if phase:
|
|
377
|
+
# Phase name with background
|
|
378
|
+
phase_text = f"Phase: {phase.upper()}"
|
|
379
|
+
text_size = cv2.getTextSize(phase_text, cv2.FONT_HERSHEY_SIMPLEX, 1, 2)[0]
|
|
380
|
+
cv2.rectangle(annotated, (5, 5), (text_size[0] + 15, 45), phase_color, -1)
|
|
381
|
+
cv2.putText(
|
|
382
|
+
annotated,
|
|
383
|
+
phase_text,
|
|
384
|
+
(10, 35),
|
|
385
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
386
|
+
1,
|
|
387
|
+
(0, 0, 0),
|
|
388
|
+
2,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# Draw frame number
|
|
392
|
+
cv2.putText(
|
|
393
|
+
annotated,
|
|
394
|
+
f"Frame: {frame_idx}",
|
|
395
|
+
(10, 80),
|
|
396
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
397
|
+
0.7,
|
|
398
|
+
(255, 255, 255),
|
|
399
|
+
2,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# Draw key frame markers
|
|
403
|
+
if metrics:
|
|
404
|
+
y_offset = 120
|
|
405
|
+
markers = []
|
|
406
|
+
|
|
407
|
+
if metrics.standing_start_frame and frame_idx == int(
|
|
408
|
+
metrics.standing_start_frame
|
|
409
|
+
):
|
|
410
|
+
markers.append("COUNTERMOVEMENT START")
|
|
411
|
+
|
|
412
|
+
if frame_idx == int(metrics.lowest_point_frame):
|
|
413
|
+
markers.append("LOWEST POINT")
|
|
414
|
+
|
|
415
|
+
if frame_idx == int(metrics.takeoff_frame):
|
|
416
|
+
markers.append("TAKEOFF")
|
|
417
|
+
|
|
418
|
+
if frame_idx == int(metrics.landing_frame):
|
|
419
|
+
markers.append("LANDING")
|
|
420
|
+
|
|
421
|
+
for marker in markers:
|
|
422
|
+
cv2.putText(
|
|
423
|
+
annotated,
|
|
424
|
+
marker,
|
|
425
|
+
(10, y_offset),
|
|
426
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
427
|
+
0.7,
|
|
428
|
+
(255, 255, 0),
|
|
429
|
+
2,
|
|
430
|
+
)
|
|
431
|
+
y_offset += 35
|
|
432
|
+
|
|
433
|
+
# Draw metrics summary in bottom right (last 30 frames)
|
|
434
|
+
total_frames = int(metrics.landing_frame) + 30
|
|
435
|
+
if frame_idx >= total_frames - 30:
|
|
436
|
+
metrics_text = [
|
|
437
|
+
f"Jump Height: {metrics.jump_height:.3f}m",
|
|
438
|
+
f"Flight Time: {metrics.flight_time*1000:.0f}ms",
|
|
439
|
+
f"CM Depth: {metrics.countermovement_depth:.3f}m",
|
|
440
|
+
f"Ecc Duration: {metrics.eccentric_duration*1000:.0f}ms",
|
|
441
|
+
f"Con Duration: {metrics.concentric_duration*1000:.0f}ms",
|
|
442
|
+
]
|
|
443
|
+
|
|
444
|
+
# Draw background
|
|
445
|
+
box_height = len(metrics_text) * 30 + 20
|
|
446
|
+
cv2.rectangle(
|
|
447
|
+
annotated,
|
|
448
|
+
(self.width - 320, self.height - box_height - 10),
|
|
449
|
+
(self.width - 10, self.height - 10),
|
|
450
|
+
(0, 0, 0),
|
|
451
|
+
-1,
|
|
452
|
+
)
|
|
453
|
+
cv2.rectangle(
|
|
454
|
+
annotated,
|
|
455
|
+
(self.width - 320, self.height - box_height - 10),
|
|
456
|
+
(self.width - 10, self.height - 10),
|
|
457
|
+
(0, 255, 0),
|
|
458
|
+
2,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# Draw metrics text
|
|
462
|
+
text_y = self.height - box_height + 10
|
|
463
|
+
for text in metrics_text:
|
|
464
|
+
cv2.putText(
|
|
465
|
+
annotated,
|
|
466
|
+
text,
|
|
467
|
+
(self.width - 310, text_y),
|
|
468
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
469
|
+
0.6,
|
|
470
|
+
(255, 255, 255),
|
|
471
|
+
1,
|
|
472
|
+
)
|
|
473
|
+
text_y += 30
|
|
474
|
+
|
|
475
|
+
return annotated
|
|
476
|
+
|
|
477
|
+
def write_frame(self, frame: np.ndarray) -> None:
|
|
478
|
+
"""
|
|
479
|
+
Write frame to output video.
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
frame: Video frame with shape (height, width, 3)
|
|
483
|
+
|
|
484
|
+
Raises:
|
|
485
|
+
ValueError: If frame dimensions don't match expected encoded dimensions
|
|
486
|
+
"""
|
|
487
|
+
# Validate frame dimensions match expected encoded dimensions
|
|
488
|
+
frame_height, frame_width = frame.shape[:2]
|
|
489
|
+
if frame_height != self.height or frame_width != self.width:
|
|
490
|
+
raise ValueError(
|
|
491
|
+
f"Frame dimensions ({frame_width}x{frame_height}) don't match "
|
|
492
|
+
f"source dimensions ({self.width}x{self.height}). "
|
|
493
|
+
f"Aspect ratio must be preserved from source video."
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Resize to display dimensions if needed (to handle SAR)
|
|
497
|
+
if self.needs_resize:
|
|
498
|
+
frame = cv2.resize(
|
|
499
|
+
frame,
|
|
500
|
+
(self.display_width, self.display_height),
|
|
501
|
+
interpolation=cv2.INTER_LANCZOS4,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
self.writer.write(frame)
|
|
505
|
+
|
|
506
|
+
def close(self) -> None:
|
|
507
|
+
"""Release video writer."""
|
|
508
|
+
self.writer.release()
|
|
509
|
+
|
|
510
|
+
def __enter__(self) -> "CMJDebugOverlayRenderer":
|
|
511
|
+
return self
|
|
512
|
+
|
|
513
|
+
def __exit__(self, exc_type: type, exc_val: Exception, exc_tb: object) -> None:
|
|
514
|
+
self.close()
|