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.

@@ -0,0 +1,463 @@
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(
146
+ frame, landmarks, start_key, end_key, color, thickness
147
+ )
148
+
149
+ # Draw joints as circles for this side
150
+ self._draw_joints(frame, landmarks, side_prefix)
151
+
152
+ # Always draw nose (head position) if visible
153
+ if "nose" in landmarks and landmarks["nose"][2] > 0.2:
154
+ nx = int(landmarks["nose"][0] * self.width)
155
+ ny = int(landmarks["nose"][1] * self.height)
156
+ cv2.circle(frame, (nx, ny), 8, (255, 255, 0), -1)
157
+ cv2.circle(frame, (nx, ny), 10, (0, 0, 0), 2)
158
+
159
+ def _draw_joint_angles(
160
+ self,
161
+ frame: np.ndarray,
162
+ landmarks: dict[str, tuple[float, float, float]],
163
+ phase_color: tuple[int, int, int],
164
+ ) -> None:
165
+ """Draw joint angles for triple extension analysis.
166
+
167
+ Args:
168
+ frame: Frame to draw on (modified in place)
169
+ landmarks: Pose landmarks
170
+ phase_color: Current phase color
171
+ """
172
+ # Try right side first, fallback to left
173
+ angles = calculate_triple_extension(landmarks, side="right")
174
+ side_used = "right"
175
+
176
+ if angles is None:
177
+ angles = calculate_triple_extension(landmarks, side="left")
178
+ side_used = "left"
179
+
180
+ if angles is None:
181
+ return
182
+
183
+ # Position for angle text display (right side of frame)
184
+ text_x = self.width - 180
185
+ text_y = 100
186
+
187
+ # Draw background box for angles
188
+ box_height = 150
189
+ cv2.rectangle(
190
+ frame,
191
+ (text_x - 10, text_y - 30),
192
+ (self.width - 10, text_y + box_height),
193
+ (0, 0, 0),
194
+ -1,
195
+ )
196
+ cv2.rectangle(
197
+ frame,
198
+ (text_x - 10, text_y - 30),
199
+ (self.width - 10, text_y + box_height),
200
+ phase_color,
201
+ 2,
202
+ )
203
+
204
+ # Title
205
+ cv2.putText(
206
+ frame,
207
+ "TRIPLE EXTENSION",
208
+ (text_x, text_y - 5),
209
+ cv2.FONT_HERSHEY_SIMPLEX,
210
+ 0.5,
211
+ (255, 255, 255),
212
+ 1,
213
+ )
214
+
215
+ # Draw available angles (show "N/A" for unavailable)
216
+ angle_data = [
217
+ ("Ankle", angles.get("ankle_angle"), (0, 255, 255)),
218
+ ("Knee", angles.get("knee_angle"), (255, 100, 100)),
219
+ ("Hip", angles.get("hip_angle"), (100, 255, 100)),
220
+ ("Trunk", angles.get("trunk_tilt"), (100, 100, 255)),
221
+ ]
222
+
223
+ y_offset = text_y + 25
224
+ for label, angle, color in angle_data:
225
+ # Angle text
226
+ if angle is not None:
227
+ angle_text = f"{label}: {angle:.0f}"
228
+ text_color = color
229
+ else:
230
+ angle_text = f"{label}: N/A"
231
+ text_color = (128, 128, 128) # Gray for unavailable
232
+
233
+ cv2.putText(
234
+ frame,
235
+ angle_text,
236
+ (text_x, y_offset),
237
+ cv2.FONT_HERSHEY_SIMPLEX,
238
+ 0.5,
239
+ text_color,
240
+ 2,
241
+ )
242
+ y_offset += 30
243
+
244
+ # Draw angle arcs at joints for visual feedback (only if angle is available)
245
+ ankle_angle = angles.get("ankle_angle")
246
+ if ankle_angle is not None:
247
+ self._draw_angle_arc(frame, landmarks, f"{side_used}_ankle", ankle_angle)
248
+ knee_angle = angles.get("knee_angle")
249
+ if knee_angle is not None:
250
+ self._draw_angle_arc(frame, landmarks, f"{side_used}_knee", knee_angle)
251
+ hip_angle = angles.get("hip_angle")
252
+ if hip_angle is not None:
253
+ self._draw_angle_arc(frame, landmarks, f"{side_used}_hip", hip_angle)
254
+
255
+ def _draw_angle_arc(
256
+ self,
257
+ frame: np.ndarray,
258
+ landmarks: dict[str, tuple[float, float, float]],
259
+ joint_key: str,
260
+ angle: float,
261
+ ) -> None:
262
+ """Draw a small arc at a joint to visualize the angle.
263
+
264
+ Args:
265
+ frame: Frame to draw on (modified in place)
266
+ landmarks: Pose landmarks
267
+ joint_key: Key of the joint landmark
268
+ angle: Angle value in degrees
269
+ """
270
+ if joint_key not in landmarks or landmarks[joint_key][2] < 0.3:
271
+ return
272
+
273
+ jx = int(landmarks[joint_key][0] * self.width)
274
+ jy = int(landmarks[joint_key][1] * self.height)
275
+
276
+ # Draw arc radius based on angle (smaller arc for more extended joints)
277
+ radius = 25
278
+
279
+ # Color based on extension: green for extended (>160°), red for flexed (<90°)
280
+ if angle > 160:
281
+ arc_color = (0, 255, 0) # Green - good extension
282
+ elif angle < 90:
283
+ arc_color = (0, 0, 255) # Red - deep flexion
284
+ else:
285
+ arc_color = (0, 165, 255) # Orange - moderate
286
+
287
+ # Draw arc (simplified as a circle for now)
288
+ cv2.circle(frame, (jx, jy), radius, arc_color, 2)
289
+
290
+ def _draw_foot_landmarks(
291
+ self,
292
+ frame: np.ndarray,
293
+ landmarks: dict[str, tuple[float, float, float]],
294
+ phase_color: tuple[int, int, int],
295
+ ) -> None:
296
+ """Draw foot landmarks and average position."""
297
+ foot_keys = ["left_ankle", "right_ankle", "left_heel", "right_heel"]
298
+ foot_positions = []
299
+
300
+ for key in foot_keys:
301
+ if key in landmarks:
302
+ x, y, vis = landmarks[key]
303
+ if vis > 0.5:
304
+ lx = int(x * self.width)
305
+ ly = int(y * self.height)
306
+ foot_positions.append((lx, ly))
307
+ cv2.circle(frame, (lx, ly), 5, (255, 255, 0), -1)
308
+
309
+ # Draw average foot position with phase color
310
+ if foot_positions:
311
+ avg_x = int(np.mean([p[0] for p in foot_positions]))
312
+ avg_y = int(np.mean([p[1] for p in foot_positions]))
313
+ cv2.circle(frame, (avg_x, avg_y), 12, phase_color, -1)
314
+ cv2.circle(frame, (avg_x, avg_y), 14, (255, 255, 255), 2)
315
+
316
+ def _draw_phase_banner(
317
+ self, frame: np.ndarray, phase: str | None, phase_color: tuple[int, int, int]
318
+ ) -> None:
319
+ """Draw phase indicator banner."""
320
+ if not phase:
321
+ return
322
+
323
+ phase_text = f"Phase: {phase.upper()}"
324
+ text_size = cv2.getTextSize(phase_text, cv2.FONT_HERSHEY_SIMPLEX, 1, 2)[0]
325
+ cv2.rectangle(frame, (5, 5), (text_size[0] + 15, 45), phase_color, -1)
326
+ cv2.putText(
327
+ frame, phase_text, (10, 35), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2
328
+ )
329
+
330
+ def _draw_key_frame_markers(
331
+ self, frame: np.ndarray, frame_idx: int, metrics: CMJMetrics
332
+ ) -> None:
333
+ """Draw markers for key frames (standing start, lowest, takeoff, landing)."""
334
+ y_offset = 120
335
+ markers = []
336
+
337
+ if metrics.standing_start_frame and frame_idx == int(
338
+ metrics.standing_start_frame
339
+ ):
340
+ markers.append("COUNTERMOVEMENT START")
341
+
342
+ if frame_idx == int(metrics.lowest_point_frame):
343
+ markers.append("LOWEST POINT")
344
+
345
+ if frame_idx == int(metrics.takeoff_frame):
346
+ markers.append("TAKEOFF")
347
+
348
+ if frame_idx == int(metrics.landing_frame):
349
+ markers.append("LANDING")
350
+
351
+ for marker in markers:
352
+ cv2.putText(
353
+ frame,
354
+ marker,
355
+ (10, y_offset),
356
+ cv2.FONT_HERSHEY_SIMPLEX,
357
+ 0.7,
358
+ (255, 255, 0),
359
+ 2,
360
+ )
361
+ y_offset += 35
362
+
363
+ def _draw_metrics_summary(
364
+ self, frame: np.ndarray, frame_idx: int, metrics: CMJMetrics
365
+ ) -> None:
366
+ """Draw metrics summary in bottom right (last 30 frames)."""
367
+ total_frames = int(metrics.landing_frame) + 30
368
+ if frame_idx < total_frames - 30:
369
+ return
370
+
371
+ metrics_text = [
372
+ f"Jump Height: {metrics.jump_height:.3f}m",
373
+ f"Flight Time: {metrics.flight_time*1000:.0f}ms",
374
+ f"CM Depth: {metrics.countermovement_depth:.3f}m",
375
+ f"Ecc Duration: {metrics.eccentric_duration*1000:.0f}ms",
376
+ f"Con Duration: {metrics.concentric_duration*1000:.0f}ms",
377
+ ]
378
+
379
+ # Draw background
380
+ box_height = len(metrics_text) * 30 + 20
381
+ cv2.rectangle(
382
+ frame,
383
+ (self.width - 320, self.height - box_height - 10),
384
+ (self.width - 10, self.height - 10),
385
+ (0, 0, 0),
386
+ -1,
387
+ )
388
+ cv2.rectangle(
389
+ frame,
390
+ (self.width - 320, self.height - box_height - 10),
391
+ (self.width - 10, self.height - 10),
392
+ (0, 255, 0),
393
+ 2,
394
+ )
395
+
396
+ # Draw metrics text
397
+ text_y = self.height - box_height + 10
398
+ for text in metrics_text:
399
+ cv2.putText(
400
+ frame,
401
+ text,
402
+ (self.width - 310, text_y),
403
+ cv2.FONT_HERSHEY_SIMPLEX,
404
+ 0.6,
405
+ (255, 255, 255),
406
+ 1,
407
+ )
408
+ text_y += 30
409
+
410
+ def render_frame(
411
+ self,
412
+ frame: np.ndarray,
413
+ landmarks: dict[str, tuple[float, float, float]] | None,
414
+ frame_idx: int,
415
+ metrics: CMJMetrics | None = None,
416
+ ) -> np.ndarray:
417
+ """
418
+ Render debug overlay on frame.
419
+
420
+ Args:
421
+ frame: Original video frame
422
+ landmarks: Pose landmarks for this frame
423
+ frame_idx: Current frame index
424
+ metrics: CMJ metrics (optional)
425
+
426
+ Returns:
427
+ Frame with debug overlay
428
+ """
429
+ annotated = frame.copy()
430
+
431
+ # Determine current phase if metrics available
432
+ phase = None
433
+ phase_color = (255, 255, 255)
434
+ if metrics:
435
+ phase = self._determine_phase(frame_idx, metrics)
436
+ phase_color = self._get_phase_color(phase)
437
+
438
+ # Draw skeleton and triple extension if landmarks available
439
+ if landmarks:
440
+ self._draw_skeleton(annotated, landmarks)
441
+ self._draw_joint_angles(annotated, landmarks, phase_color)
442
+ self._draw_foot_landmarks(annotated, landmarks, phase_color)
443
+
444
+ # Draw phase indicator banner
445
+ self._draw_phase_banner(annotated, phase, phase_color)
446
+
447
+ # Draw frame number
448
+ cv2.putText(
449
+ annotated,
450
+ f"Frame: {frame_idx}",
451
+ (10, 80),
452
+ cv2.FONT_HERSHEY_SIMPLEX,
453
+ 0.7,
454
+ (255, 255, 255),
455
+ 2,
456
+ )
457
+
458
+ # Draw key frame markers and metrics summary
459
+ if metrics:
460
+ self._draw_key_frame_markers(annotated, frame_idx, metrics)
461
+ self._draw_metrics_summary(annotated, frame_idx, metrics)
462
+
463
+ return annotated