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.

@@ -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()