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.
@@ -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 hip_keys:
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
- params = apply_expert_overrides(
266
- params,
267
- overrides.smoothing_window if overrides else None,
268
- overrides.velocity_threshold if overrides else None,
269
- overrides.min_contact_frames if overrides else None,
270
- overrides.visibility_threshold if overrides else None,
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)
@@ -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
- def _determine_phase(self, frame_idx: int, metrics: CMJMetrics) -> str:
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 CMJPhaseState.STANDING
53
+ return CMJPhase.STANDING
29
54
 
30
55
  if frame_idx < metrics.lowest_point_frame:
31
- return CMJPhaseState.ECCENTRIC
56
+ return CMJPhase.ECCENTRIC
32
57
 
33
- # Brief transition at lowest point (±2 frames)
58
+ # Brief transition at lowest point (within 2 frames)
34
59
  if abs(frame_idx - metrics.lowest_point_frame) < 2:
35
- return CMJPhaseState.TRANSITION
60
+ return CMJPhase.TRANSITION
36
61
 
37
62
  if frame_idx < metrics.takeoff_frame:
38
- return CMJPhaseState.CONCENTRIC
63
+ return CMJPhase.CONCENTRIC
39
64
 
40
65
  if frame_idx < metrics.landing_frame:
41
- return CMJPhaseState.FLIGHT
66
+ return CMJPhase.FLIGHT
42
67
 
43
- return CMJPhaseState.LANDING
68
+ return CMJPhase.LANDING
44
69
 
45
- def _get_phase_color(self, phase: str) -> tuple[int, int, int]:
70
+ def _get_phase_color(self, phase: CMJPhase) -> Color:
46
71
  """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]
72
+ return self.PHASE_COLORS.get(phase, self.DEFAULT_PHASE_COLOR)
95
73
 
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)
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
- cv2.line(frame, (start_x, start_y), (end_x, end_y), color, thickness)
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 _draw_joints(
87
+ def _draw_info_box(
106
88
  self,
107
89
  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]]
90
+ top_left: tuple[int, int],
91
+ bottom_right: tuple[int, int],
92
+ border_color: Color,
129
93
  ) -> 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)
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
- # 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:
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 - 180
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 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(
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
- (255, 255, 255),
134
+ WHITE,
210
135
  1,
211
136
  )
212
137
 
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)),
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, angle, color in angle_data:
223
- # Angle text
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
- angle_text = f"{label}: {angle:.0f}"
152
+ text = f"{label}: {angle:.0f}"
226
153
  text_color = color
227
154
  else:
228
- angle_text = f"{label}: N/A"
229
- text_color = (128, 128, 128) # Gray for unavailable
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
- # 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)
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 small arc at a joint to visualize the angle.
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 or landmarks[joint_key][2] < 0.3:
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
- 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)
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
- 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)
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, (255, 255, 255), 2)
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: str | None, phase_color: tuple[int, int, int]
223
+ self, frame: np.ndarray, phase: CMJPhase | None, phase_color: Color
316
224
  ) -> None:
317
225
  """Draw phase indicator banner."""
318
- if not phase:
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, (0, 0, 0), 2)
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
- 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")
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
- 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
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
- total_frames = int(metrics.landing_frame) + 30
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
- # Draw background
267
+ # Calculate box dimensions
374
268
  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
- )
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: dict[str, tuple[float, float, float]] | None,
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 if metrics available
426
- phase = None
427
- phase_color = (255, 255, 255)
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 triple extension if landmarks available
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 banner
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