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,325 @@
1
+ """Automatic parameter tuning based on video characteristics."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+ import numpy as np
7
+
8
+
9
+ class QualityPreset(str, Enum):
10
+ """Quality presets for analysis."""
11
+
12
+ FAST = "fast" # Quick analysis, lower precision
13
+ BALANCED = "balanced" # Default: good balance of speed and accuracy
14
+ ACCURATE = "accurate" # Research-grade analysis, slower
15
+
16
+
17
+ @dataclass
18
+ class VideoCharacteristics:
19
+ """Characteristics extracted from video analysis."""
20
+
21
+ fps: float
22
+ frame_count: int
23
+ avg_visibility: float # Average landmark visibility (0-1)
24
+ position_variance: float # Variance in foot positions
25
+ has_stable_period: bool # Whether video has initial stationary period
26
+ tracking_quality: str # "low", "medium", "high"
27
+
28
+
29
+ @dataclass
30
+ class AnalysisParameters:
31
+ """Auto-tuned parameters for drop jump analysis."""
32
+
33
+ smoothing_window: int
34
+ polyorder: int
35
+ velocity_threshold: float
36
+ min_contact_frames: int
37
+ visibility_threshold: float
38
+ detection_confidence: float
39
+ tracking_confidence: float
40
+ outlier_rejection: bool
41
+ bilateral_filter: bool
42
+ use_curvature: bool
43
+
44
+ def to_dict(self) -> dict:
45
+ """Convert to dictionary."""
46
+ return {
47
+ "smoothing_window": self.smoothing_window,
48
+ "polyorder": self.polyorder,
49
+ "velocity_threshold": self.velocity_threshold,
50
+ "min_contact_frames": self.min_contact_frames,
51
+ "visibility_threshold": self.visibility_threshold,
52
+ "detection_confidence": self.detection_confidence,
53
+ "tracking_confidence": self.tracking_confidence,
54
+ "outlier_rejection": self.outlier_rejection,
55
+ "bilateral_filter": self.bilateral_filter,
56
+ "use_curvature": self.use_curvature,
57
+ }
58
+
59
+
60
+ def analyze_tracking_quality(avg_visibility: float) -> str:
61
+ """
62
+ Classify tracking quality based on average landmark visibility.
63
+
64
+ Args:
65
+ avg_visibility: Average visibility score across all tracked landmarks
66
+
67
+ Returns:
68
+ Quality classification: "low", "medium", or "high"
69
+ """
70
+ if avg_visibility < 0.4:
71
+ return "low"
72
+ elif avg_visibility < 0.7:
73
+ return "medium"
74
+ else:
75
+ return "high"
76
+
77
+
78
+ def auto_tune_parameters(
79
+ characteristics: VideoCharacteristics,
80
+ quality_preset: QualityPreset = QualityPreset.BALANCED,
81
+ ) -> AnalysisParameters:
82
+ """
83
+ Automatically tune analysis parameters based on video characteristics.
84
+
85
+ This function implements heuristics to select optimal parameters without
86
+ requiring user expertise in video analysis or kinematic tracking.
87
+
88
+ Key principles:
89
+ 1. FPS-based scaling: Higher fps needs lower velocity thresholds
90
+ 2. Quality-based smoothing: Noisy video needs more smoothing
91
+ 3. Always enable proven features: outlier rejection, curvature analysis
92
+ 4. Preset modifiers: fast/balanced/accurate adjust base parameters
93
+
94
+ Args:
95
+ characteristics: Analyzed video characteristics
96
+ quality_preset: Quality vs speed tradeoff
97
+
98
+ Returns:
99
+ AnalysisParameters with auto-tuned values
100
+ """
101
+ fps = characteristics.fps
102
+ quality = characteristics.tracking_quality
103
+
104
+ # =================================================================
105
+ # STEP 1: FPS-based baseline parameters
106
+ # These scale automatically with frame rate to maintain consistent
107
+ # temporal resolution and sensitivity
108
+ # =================================================================
109
+
110
+ # Velocity threshold: Scale inversely with fps
111
+ # At 30fps, feet move ~2% of frame per frame when "stationary"
112
+ # At 60fps, feet move ~1% of frame per frame when "stationary"
113
+ # Formula: threshold = 0.02 * (30 / fps)
114
+ base_velocity_threshold = 0.02 * (30.0 / fps)
115
+
116
+ # Min contact frames: Scale with fps to maintain same time duration
117
+ # Goal: ~100ms minimum contact (3 frames @ 30fps, 6 frames @ 60fps)
118
+ # Formula: frames = round(3 * (fps / 30))
119
+ base_min_contact_frames = max(2, round(3.0 * (fps / 30.0)))
120
+
121
+ # Smoothing window: Decrease with higher fps for better temporal resolution
122
+ # Lower fps (30fps): 5-frame window = 167ms
123
+ # Higher fps (60fps): 3-frame window = 50ms (same temporal resolution)
124
+ if fps <= 30:
125
+ base_smoothing_window = 5
126
+ elif fps <= 60:
127
+ base_smoothing_window = 3
128
+ else:
129
+ base_smoothing_window = 3 # Even at 120fps, 3 is minimum for Savitzky-Golay
130
+
131
+ # =================================================================
132
+ # STEP 2: Quality-based adjustments
133
+ # Adapt smoothing and filtering based on tracking quality
134
+ # =================================================================
135
+
136
+ smoothing_adjustment = 0
137
+ enable_bilateral = False
138
+
139
+ if quality == "low":
140
+ # Poor tracking quality: aggressive smoothing and filtering
141
+ smoothing_adjustment = +2
142
+ enable_bilateral = True
143
+ elif quality == "medium":
144
+ # Moderate quality: slight smoothing increase
145
+ smoothing_adjustment = +1
146
+ enable_bilateral = True
147
+ else: # high quality
148
+ # Good tracking: preserve detail, minimal smoothing
149
+ smoothing_adjustment = 0
150
+ enable_bilateral = False
151
+
152
+ # =================================================================
153
+ # STEP 3: Apply quality preset modifiers
154
+ # User can choose speed vs accuracy tradeoff
155
+ # =================================================================
156
+
157
+ if quality_preset == QualityPreset.FAST:
158
+ # Fast: Trade accuracy for speed
159
+ velocity_threshold = base_velocity_threshold * 1.5 # Less sensitive
160
+ min_contact_frames = max(2, int(base_min_contact_frames * 0.67))
161
+ smoothing_window = max(3, base_smoothing_window - 2 + smoothing_adjustment)
162
+ bilateral_filter = False # Skip expensive filtering
163
+ detection_confidence = 0.3
164
+ tracking_confidence = 0.3
165
+
166
+ elif quality_preset == QualityPreset.ACCURATE:
167
+ # Accurate: Maximize accuracy, accept slower processing
168
+ velocity_threshold = base_velocity_threshold * 0.5 # More sensitive
169
+ min_contact_frames = (
170
+ base_min_contact_frames # Don't increase (would miss brief)
171
+ )
172
+ smoothing_window = min(11, base_smoothing_window + 2 + smoothing_adjustment)
173
+ bilateral_filter = True # Always use for best accuracy
174
+ detection_confidence = 0.6
175
+ tracking_confidence = 0.6
176
+
177
+ else: # QualityPreset.BALANCED (default)
178
+ # Balanced: Good accuracy, reasonable speed
179
+ velocity_threshold = base_velocity_threshold
180
+ min_contact_frames = base_min_contact_frames
181
+ smoothing_window = max(3, base_smoothing_window + smoothing_adjustment)
182
+ bilateral_filter = enable_bilateral
183
+ detection_confidence = 0.5
184
+ tracking_confidence = 0.5
185
+
186
+ # Ensure smoothing window is odd (required for Savitzky-Golay)
187
+ if smoothing_window % 2 == 0:
188
+ smoothing_window += 1
189
+
190
+ # =================================================================
191
+ # STEP 4: Set fixed optimal values
192
+ # These are always the same regardless of video characteristics
193
+ # =================================================================
194
+
195
+ # Polyorder: Always 2 (quadratic) - optimal for jump physics (parabolic motion)
196
+ polyorder = 2
197
+
198
+ # Visibility threshold: Standard MediaPipe threshold
199
+ visibility_threshold = 0.5
200
+
201
+ # Always enable proven accuracy features
202
+ outlier_rejection = True # Removes tracking glitches (minimal cost)
203
+ use_curvature = True # Trajectory curvature analysis (minimal cost)
204
+
205
+ return AnalysisParameters(
206
+ smoothing_window=smoothing_window,
207
+ polyorder=polyorder,
208
+ velocity_threshold=velocity_threshold,
209
+ min_contact_frames=min_contact_frames,
210
+ visibility_threshold=visibility_threshold,
211
+ detection_confidence=detection_confidence,
212
+ tracking_confidence=tracking_confidence,
213
+ outlier_rejection=outlier_rejection,
214
+ bilateral_filter=bilateral_filter,
215
+ use_curvature=use_curvature,
216
+ )
217
+
218
+
219
+ def _collect_foot_visibility_and_positions(
220
+ frame_landmarks: dict[str, tuple[float, float, float]],
221
+ ) -> tuple[list[float], list[float]]:
222
+ """
223
+ Collect visibility scores and Y positions from foot landmarks.
224
+
225
+ Args:
226
+ frame_landmarks: Landmarks for a single frame
227
+
228
+ Returns:
229
+ Tuple of (visibility_scores, y_positions)
230
+ """
231
+ foot_keys = [
232
+ "left_ankle",
233
+ "right_ankle",
234
+ "left_heel",
235
+ "right_heel",
236
+ "left_foot_index",
237
+ "right_foot_index",
238
+ ]
239
+
240
+ frame_vis = []
241
+ frame_y_positions = []
242
+
243
+ for key in foot_keys:
244
+ if key in frame_landmarks:
245
+ _, y, vis = frame_landmarks[key] # x not needed for analysis
246
+ frame_vis.append(vis)
247
+ frame_y_positions.append(y)
248
+
249
+ return frame_vis, frame_y_positions
250
+
251
+
252
+ def _check_stable_period(positions: list[float]) -> bool:
253
+ """
254
+ Check if video has a stable period at the start.
255
+
256
+ A stable period (low variance in first 30 frames) indicates
257
+ the subject is standing on an elevated platform before jumping.
258
+
259
+ Args:
260
+ positions: List of average Y positions per frame
261
+
262
+ Returns:
263
+ True if stable period detected, False otherwise
264
+ """
265
+ if len(positions) < 30:
266
+ return False
267
+
268
+ first_30_std = float(np.std(positions[:30]))
269
+ return first_30_std < 0.01 # Very stable = on platform
270
+
271
+
272
+ def analyze_video_sample(
273
+ landmarks_sequence: list[dict[str, tuple[float, float, float]] | None],
274
+ fps: float,
275
+ frame_count: int,
276
+ ) -> VideoCharacteristics:
277
+ """
278
+ Analyze video characteristics from a sample of frames.
279
+
280
+ This function should be called after tracking the first 30-60 frames
281
+ to understand video quality and characteristics.
282
+
283
+ Args:
284
+ landmarks_sequence: Tracked landmarks from sample frames
285
+ fps: Video frame rate
286
+ frame_count: Total number of frames in video
287
+
288
+ Returns:
289
+ VideoCharacteristics with analyzed properties
290
+ """
291
+ visibilities = []
292
+ positions = []
293
+
294
+ # Collect visibility and position data from all frames
295
+ for frame_landmarks in landmarks_sequence:
296
+ if not frame_landmarks:
297
+ continue
298
+
299
+ frame_vis, frame_y_positions = _collect_foot_visibility_and_positions(
300
+ frame_landmarks
301
+ )
302
+
303
+ if frame_vis:
304
+ visibilities.append(float(np.mean(frame_vis)))
305
+ if frame_y_positions:
306
+ positions.append(float(np.mean(frame_y_positions)))
307
+
308
+ # Compute metrics
309
+ avg_visibility = float(np.mean(visibilities)) if visibilities else 0.5
310
+ position_variance = float(np.var(positions)) if len(positions) > 1 else 0.0
311
+
312
+ # Determine tracking quality
313
+ tracking_quality = analyze_tracking_quality(avg_visibility)
314
+
315
+ # Check for stable period (indicates drop jump from elevated platform)
316
+ has_stable_period = _check_stable_period(positions)
317
+
318
+ return VideoCharacteristics(
319
+ fps=fps,
320
+ frame_count=frame_count,
321
+ avg_visibility=avg_visibility,
322
+ position_variance=position_variance,
323
+ has_stable_period=has_stable_period,
324
+ tracking_quality=tracking_quality,
325
+ )
@@ -0,0 +1,212 @@
1
+ """Shared CLI utilities for drop jump and CMJ analysis."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any, Protocol
5
+
6
+ import click
7
+
8
+ from .auto_tuning import AnalysisParameters, QualityPreset, VideoCharacteristics
9
+ from .pose import PoseTracker
10
+ from .smoothing import smooth_landmarks, smooth_landmarks_advanced
11
+ from .video_io import VideoProcessor
12
+
13
+
14
+ class ExpertParameters(Protocol):
15
+ """Protocol for expert parameter overrides."""
16
+
17
+ detection_confidence: float | None
18
+ tracking_confidence: float | None
19
+ smoothing_window: int | None
20
+ velocity_threshold: float | None
21
+ min_contact_frames: int | None
22
+ visibility_threshold: float | None
23
+
24
+
25
+ def determine_initial_confidence(
26
+ quality_preset: QualityPreset,
27
+ expert_params: ExpertParameters,
28
+ ) -> tuple[float, float]:
29
+ """Determine initial detection and tracking confidence levels.
30
+
31
+ Args:
32
+ quality_preset: Quality preset enum
33
+ expert_params: Expert parameter overrides
34
+
35
+ Returns:
36
+ Tuple of (detection_confidence, tracking_confidence)
37
+ """
38
+ initial_detection_conf = 0.5
39
+ initial_tracking_conf = 0.5
40
+
41
+ if quality_preset == QualityPreset.FAST:
42
+ initial_detection_conf = 0.3
43
+ initial_tracking_conf = 0.3
44
+ elif quality_preset == QualityPreset.ACCURATE:
45
+ initial_detection_conf = 0.6
46
+ initial_tracking_conf = 0.6
47
+
48
+ # Override with expert values if provided
49
+ if expert_params.detection_confidence is not None:
50
+ initial_detection_conf = expert_params.detection_confidence
51
+ if expert_params.tracking_confidence is not None:
52
+ initial_tracking_conf = expert_params.tracking_confidence
53
+
54
+ return initial_detection_conf, initial_tracking_conf
55
+
56
+
57
+ def track_all_frames(video: VideoProcessor, tracker: PoseTracker) -> tuple[list, list]:
58
+ """Track pose landmarks in all video frames.
59
+
60
+ Args:
61
+ video: Video processor
62
+ tracker: Pose tracker
63
+
64
+ Returns:
65
+ Tuple of (frames, landmarks_sequence)
66
+ """
67
+ click.echo("Tracking pose landmarks...", err=True)
68
+ landmarks_sequence = []
69
+ frames = []
70
+
71
+ bar: Any
72
+ with click.progressbar(length=video.frame_count, label="Processing frames") as bar:
73
+ while True:
74
+ frame = video.read_frame()
75
+ if frame is None:
76
+ break
77
+
78
+ frames.append(frame)
79
+ landmarks = tracker.process_frame(frame)
80
+ landmarks_sequence.append(landmarks)
81
+ bar.update(1)
82
+
83
+ tracker.close()
84
+ return frames, landmarks_sequence
85
+
86
+
87
+ def apply_expert_param_overrides(
88
+ params: AnalysisParameters, expert_params: ExpertParameters
89
+ ) -> AnalysisParameters:
90
+ """Apply expert parameter overrides to auto-tuned parameters.
91
+
92
+ Args:
93
+ params: Auto-tuned parameters
94
+ expert_params: Expert overrides
95
+
96
+ Returns:
97
+ Modified params object (mutated in place)
98
+ """
99
+ if expert_params.smoothing_window is not None:
100
+ params.smoothing_window = expert_params.smoothing_window
101
+ if expert_params.velocity_threshold is not None:
102
+ params.velocity_threshold = expert_params.velocity_threshold
103
+ if expert_params.min_contact_frames is not None:
104
+ params.min_contact_frames = expert_params.min_contact_frames
105
+ if expert_params.visibility_threshold is not None:
106
+ params.visibility_threshold = expert_params.visibility_threshold
107
+ return params
108
+
109
+
110
+ def print_auto_tuned_params(
111
+ video: VideoProcessor,
112
+ quality_preset: QualityPreset,
113
+ params: AnalysisParameters,
114
+ characteristics: VideoCharacteristics | None = None,
115
+ extra_params: dict[str, Any] | None = None,
116
+ ) -> None:
117
+ """Print auto-tuned parameters in verbose mode.
118
+
119
+ Args:
120
+ video: Video processor
121
+ quality_preset: Quality preset
122
+ params: Auto-tuned parameters
123
+ characteristics: Optional video characteristics (for tracking quality display)
124
+ extra_params: Optional extra parameters to display (e.g., countermovement_threshold)
125
+ """
126
+ click.echo("\n" + "=" * 60, err=True)
127
+ click.echo("AUTO-TUNED PARAMETERS", err=True)
128
+ click.echo("=" * 60, err=True)
129
+ click.echo(f"Video FPS: {video.fps:.2f}", err=True)
130
+
131
+ if characteristics:
132
+ click.echo(
133
+ f"Tracking quality: {characteristics.tracking_quality} "
134
+ f"(avg visibility: {characteristics.avg_visibility:.2f})",
135
+ err=True,
136
+ )
137
+
138
+ click.echo(f"Quality preset: {quality_preset.value}", err=True)
139
+ click.echo("\nSelected parameters:", err=True)
140
+ click.echo(f" smoothing_window: {params.smoothing_window}", err=True)
141
+ click.echo(f" polyorder: {params.polyorder}", err=True)
142
+ click.echo(f" velocity_threshold: {params.velocity_threshold:.4f}", err=True)
143
+
144
+ # Print extra parameters if provided
145
+ if extra_params:
146
+ for key, value in extra_params.items():
147
+ if isinstance(value, float):
148
+ click.echo(f" {key}: {value:.4f}", err=True)
149
+ else:
150
+ click.echo(f" {key}: {value}", err=True)
151
+
152
+ click.echo(f" min_contact_frames: {params.min_contact_frames}", err=True)
153
+ click.echo(f" visibility_threshold: {params.visibility_threshold}", err=True)
154
+ click.echo(f" detection_confidence: {params.detection_confidence}", err=True)
155
+ click.echo(f" tracking_confidence: {params.tracking_confidence}", err=True)
156
+ click.echo(f" outlier_rejection: {params.outlier_rejection}", err=True)
157
+ click.echo(f" bilateral_filter: {params.bilateral_filter}", err=True)
158
+ click.echo(f" use_curvature: {params.use_curvature}", err=True)
159
+ click.echo("=" * 60 + "\n", err=True)
160
+
161
+
162
+ def smooth_landmark_sequence(
163
+ landmarks_sequence: list, params: AnalysisParameters
164
+ ) -> list:
165
+ """Apply smoothing to landmark sequence.
166
+
167
+ Args:
168
+ landmarks_sequence: Raw landmark sequence
169
+ params: Auto-tuned parameters
170
+
171
+ Returns:
172
+ Smoothed landmark sequence
173
+ """
174
+ if params.outlier_rejection or params.bilateral_filter:
175
+ if params.outlier_rejection:
176
+ click.echo("Smoothing landmarks with outlier rejection...", err=True)
177
+ if params.bilateral_filter:
178
+ click.echo(
179
+ "Using bilateral temporal filter for edge-preserving smoothing...",
180
+ err=True,
181
+ )
182
+ return smooth_landmarks_advanced(
183
+ landmarks_sequence,
184
+ window_length=params.smoothing_window,
185
+ polyorder=params.polyorder,
186
+ use_outlier_rejection=params.outlier_rejection,
187
+ use_bilateral=params.bilateral_filter,
188
+ )
189
+ else:
190
+ click.echo("Smoothing landmarks...", err=True)
191
+ return smooth_landmarks(
192
+ landmarks_sequence,
193
+ window_length=params.smoothing_window,
194
+ polyorder=params.polyorder,
195
+ )
196
+
197
+
198
+ def common_output_options(func: Callable) -> Callable: # type: ignore[type-arg]
199
+ """Add common output options to CLI command."""
200
+ func = click.option(
201
+ "--output",
202
+ "-o",
203
+ type=click.Path(),
204
+ help="Path for debug video output (optional)",
205
+ )(func)
206
+ func = click.option(
207
+ "--json-output",
208
+ "-j",
209
+ type=click.Path(),
210
+ help="Path for JSON metrics output (default: stdout)",
211
+ )(func)
212
+ return func
@@ -0,0 +1,143 @@
1
+ """Shared debug overlay utilities for video rendering."""
2
+
3
+ import cv2
4
+ import numpy as np
5
+
6
+
7
+ def create_video_writer(
8
+ output_path: str,
9
+ width: int,
10
+ height: int,
11
+ display_width: int,
12
+ display_height: int,
13
+ fps: float,
14
+ ) -> tuple[cv2.VideoWriter, bool]:
15
+ """
16
+ Create a video writer with fallback codec support.
17
+
18
+ Args:
19
+ output_path: Path for output video
20
+ width: Encoded frame width (from source video)
21
+ height: Encoded frame height (from source video)
22
+ display_width: Display width (considering SAR)
23
+ display_height: Display height (considering SAR)
24
+ fps: Frames per second
25
+
26
+ Returns:
27
+ Tuple of (video_writer, needs_resize)
28
+ """
29
+ needs_resize = (display_width != width) or (display_height != height)
30
+
31
+ # Try H.264 codec first (better quality/compatibility), fallback to mp4v
32
+ fourcc = cv2.VideoWriter_fourcc(*"avc1")
33
+ writer = cv2.VideoWriter(output_path, fourcc, fps, (display_width, display_height))
34
+
35
+ # Check if writer opened successfully, fallback to mp4v if not
36
+ if not writer.isOpened():
37
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
38
+ writer = cv2.VideoWriter(
39
+ output_path, fourcc, fps, (display_width, display_height)
40
+ )
41
+
42
+ if not writer.isOpened():
43
+ raise ValueError(
44
+ f"Failed to create video writer for {output_path} with dimensions "
45
+ f"{display_width}x{display_height}"
46
+ )
47
+
48
+ return writer, needs_resize
49
+
50
+
51
+ def write_overlay_frame(
52
+ writer: cv2.VideoWriter, frame: np.ndarray, width: int, height: int
53
+ ) -> None:
54
+ """
55
+ Write a frame to the video writer with dimension validation.
56
+
57
+ Args:
58
+ writer: Video writer instance
59
+ frame: Frame to write
60
+ width: Expected frame width
61
+ height: Expected frame height
62
+
63
+ Raises:
64
+ ValueError: If frame dimensions don't match expected dimensions
65
+ """
66
+ # Validate dimensions before writing
67
+ if frame.shape[0] != height or frame.shape[1] != width:
68
+ raise ValueError(
69
+ f"Frame dimensions {frame.shape[1]}x{frame.shape[0]} do not match "
70
+ f"expected dimensions {width}x{height}"
71
+ )
72
+ writer.write(frame)
73
+
74
+
75
+ class BaseDebugOverlayRenderer:
76
+ """Base class for debug overlay renderers with common functionality."""
77
+
78
+ def __init__(
79
+ self,
80
+ output_path: str,
81
+ width: int,
82
+ height: int,
83
+ display_width: int,
84
+ display_height: int,
85
+ fps: float,
86
+ ):
87
+ """
88
+ Initialize overlay renderer.
89
+
90
+ Args:
91
+ output_path: Path for output video
92
+ width: Encoded frame width (from source video)
93
+ height: Encoded frame height (from source video)
94
+ display_width: Display width (considering SAR)
95
+ display_height: Display height (considering SAR)
96
+ fps: Frames per second
97
+ """
98
+ self.width = width
99
+ self.height = height
100
+ self.display_width = display_width
101
+ self.display_height = display_height
102
+ self.writer, self.needs_resize = create_video_writer(
103
+ output_path, width, height, display_width, display_height, fps
104
+ )
105
+
106
+ def write_frame(self, frame: np.ndarray) -> None:
107
+ """
108
+ Write frame to output video.
109
+
110
+ Args:
111
+ frame: Video frame with shape (height, width, 3)
112
+
113
+ Raises:
114
+ ValueError: If frame dimensions don't match expected encoded dimensions
115
+ """
116
+ # Validate frame dimensions match expected encoded dimensions
117
+ frame_height, frame_width = frame.shape[:2]
118
+ if frame_height != self.height or frame_width != self.width:
119
+ raise ValueError(
120
+ f"Frame dimensions ({frame_width}x{frame_height}) don't match "
121
+ f"source dimensions ({self.width}x{self.height}). "
122
+ f"Aspect ratio must be preserved from source video."
123
+ )
124
+
125
+ # Resize to display dimensions if needed (to handle SAR)
126
+ if self.needs_resize:
127
+ frame = cv2.resize(
128
+ frame,
129
+ (self.display_width, self.display_height),
130
+ interpolation=cv2.INTER_LANCZOS4,
131
+ )
132
+
133
+ write_overlay_frame(self.writer, frame, self.display_width, self.display_height)
134
+
135
+ def close(self) -> None:
136
+ """Release video writer."""
137
+ self.writer.release()
138
+
139
+ def __enter__(self) -> "BaseDebugOverlayRenderer":
140
+ return self
141
+
142
+ def __exit__(self, _exc_type, _exc_val, _exc_tb) -> None: # type: ignore[no-untyped-def]
143
+ self.close()