MXVK Vulkan Framework 0.35.0
C++20 Vulkan rendering framework for practical 2D and 3D application development with SDL3.
Loading...
Searching...
No Matches
mxwrite-opencv.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2## @file mxwrite-opencv.py
3## @brief Capture OpenCV video through MXVK and encode it with MXWrite.
4## @details Uses @c mxvk_ext.Capture for camera or file input, displays frames
5## in an MXVK sprite, and writes file input as constant-frame-rate video.
6## Camera capture retains monotonic timestamps. The example is intentionally
7## split so MXVK owns capture/windowing and MXWrite owns encoding/muxing.
8##
9## @section mxwrite_opencv_run Running the example
10## @code{.sh}
11## python3 python-examples/opencv_mxwrite/mxwrite-opencv.py --input input.mp4 --output output.mp4
12## python3 python-examples/opencv_mxwrite/mxwrite-opencv.py --camera 0 --width 1280 --height 720
13## @endcode
14## @section mxwrite_opencv_requirements Requirements
15## Requires a CV-enabled @c mxvk_ext module, @c mxwrite_ext, OpenCV, and NumPy.
16## The Linux camera path requests V4L2 MJPEG; other platforms use OpenCV's
17## default camera backend.
18## @section mxwrite_opencv_timing Timing and controls
19## File input uses sequential PTS values at the input frame rate for CFR output.
20## Camera input preserves monotonic capture timestamps. Escape or Ctrl-C stops
21## capture; @c --frames limits the number of submitted frames.
22
23import argparse
24import math
25import sys
26import time
27from pathlib import Path
28
29try:
30 import cv2
31except ImportError as error:
32 raise SystemExit("OpenCV is required: python3 -m pip install opencv-python") from error
33
34try:
35 import mxwrite_ext
36except ImportError as error:
37 raise SystemExit("Could not import mxwrite_ext. Build with -DPYTHON_MODULE=ON and set PYTHONPATH to the directory containing the built module.") from error
38
39try:
40 import mxvk_ext as mxvk
41except ImportError as error:
42 raise SystemExit("Could not import mxvk_ext. Build with -DPYTHON_MODULE=ON and set PYTHONPATH to the directory containing the built module.") from error
43
44if not getattr(mxvk, "has_cv", False):
45 raise SystemExit("mxvk_ext was built without OpenCV support. Rebuild MXVK with -DCV=ON -DPYTHON_MODULE=ON.")
46
47
49 parser = argparse.ArgumentParser(description="Capture video from a file or webcam and encode it with MXWrite using timestamps.")
50
51 source = parser.add_mutually_exclusive_group(required=True)
52 source.add_argument("--input", type=Path, help="Input video file.")
53 source.add_argument("--camera", type=int, help="Webcam device index, for example --camera 0.")
54
55 parser.add_argument("--output", type=Path, default=Path("mxwrite-output.mp4"))
56 parser.add_argument("--width", type=int, default=1280)
57 parser.add_argument("--height", type=int, default=720)
58 parser.add_argument("--fps", type=float, default=30.0)
59 parser.add_argument("--frames", type=int, default=0, help="Maximum number of frames. 0 means unlimited.")
60 parser.add_argument("--codec", default="libx264")
61 parser.add_argument("--preset", default="veryfast")
62 parser.add_argument("--crf", type=int, default=20)
63 parser.add_argument("--normalize-pts", action="store_true")
64
65 return parser.parse_args()
66
67
69 if args.input is not None and not args.input.is_file():
70 raise SystemExit(f"Input file does not exist: {args.input}")
71
72 if args.width <= 0 or args.height <= 0:
73 raise SystemExit("Width and height must be positive.")
74
75 if args.fps <= 0.0:
76 raise SystemExit("FPS must be positive.")
77
78 if args.frames < 0:
79 raise SystemExit("Frame count cannot be negative.")
80
81 if not 0 <= args.crf <= 51:
82 raise SystemExit("CRF must be between 0 and 51.")
83
84
86 value = int(value)
87
88 if value <= 0:
89 return "unknown"
90
91 return "".join(chr((value >> (8 * i)) & 0xff) for i in range(4))
92
93
94def open_file_capture(filename):
95 capture = mxvk.Capture()
96
97 if not capture.open(str(filename)):
98 raise SystemExit(f"Could not open input video: {filename}")
99
100 return capture
101
102
103def open_camera_capture(index, width, height, fps):
104 if sys.platform.startswith("linux"):
105 print("Opening camera with Linux V4L2 backend.")
106 capture = mxvk.Capture()
107
108 if not capture.open_camera(index, cv2.CAP_V4L2):
109 raise SystemExit(f"Could not open camera {index} using V4L2")
110
111 fourcc = cv2.VideoWriter_fourcc("M", "J", "P", "G")
112
113 capture.set(cv2.CAP_PROP_FOURCC, fourcc)
114 capture.set(cv2.CAP_PROP_FRAME_WIDTH, width)
115 capture.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
116 capture.set(cv2.CAP_PROP_FPS, fps)
117 else:
118 print("Opening camera with the default OpenCV backend.")
119 capture = mxvk.Capture()
120
121 if not capture.open_camera(index):
122 raise SystemExit(f"Could not open camera {index}")
123
124 capture.set(cv2.CAP_PROP_FRAME_WIDTH, width)
125 capture.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
126 capture.set(cv2.CAP_PROP_FPS, fps)
127
128 actual_width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
129 actual_height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
130 actual_fps = float(capture.get(cv2.CAP_PROP_FPS))
131 actual_fourcc = capture.get(cv2.CAP_PROP_FOURCC)
132
133 print("Camera backend: MXVK OpenCV capture")
134 print(f"Camera format: {fourcc_to_string(actual_fourcc)}")
135 print(f"Camera resolution: {actual_width}x{actual_height}")
136 print(f"Camera FPS: {actual_fps:.3f}")
137
138 if actual_width != width or actual_height != height:
139 print(f"Warning: requested {width}x{height}, but camera negotiated {actual_width}x{actual_height}.")
140
141 if actual_fps > 0.0 and not math.isclose(actual_fps, fps, rel_tol=0.01, abs_tol=0.1):
142 print(f"Warning: requested {fps:.3f} FPS, but camera negotiated {actual_fps:.3f} FPS.")
143
144 return capture
145
146
147def get_file_timestamp(capture, source_fps, frame_index):
148 if hasattr(cv2, "CAP_PROP_PTS"):
149 pts = capture.get(cv2.CAP_PROP_PTS)
150
151 if math.isfinite(pts) and pts >= 0.0:
152 return pts / source_fps, "PTS"
153
154 position_ms = capture.get(cv2.CAP_PROP_POS_MSEC)
155
156 if math.isfinite(position_ms) and position_ms >= 0.0:
157 return position_ms / 1000.0, "POS_MSEC"
158
159 return frame_index / source_fps, "FRAME_INDEX"
160
161
163 encoders = mxwrite_ext.available_video_encoders()
164 matching_encoders = [encoder for encoder in encoders if encoder.name == args.codec]
165
166 print(f"MXWrite reported {len(encoders)} video encoder(s).")
167
168 if matching_encoders:
169 encoder = matching_encoders[0]
170 mode = "hardware" if encoder.hardware else "software"
171
172 print(f"Using {encoder.name} ({encoder.long_name}, {mode}).")
173
174 options = mxwrite_ext.video_encoder_options(encoder.name)
175
176 print(f"The encoder exposes {len(options)} configurable option(s).")
177
178 elif args.codec not in {"auto", "software", "cpu", "x264", "h264", "hevc", "h265", "nvenc"}:
179 names = ", ".join(encoder.name for encoder in encoders[:12])
180 raise SystemExit(f"Requested encoder '{args.codec}' is unavailable. Available encoders include: {names}")
181
182 encode_options = mxwrite_ext.EncodeOptions()
183 encode_options.codec = args.codec
184 encode_options.preset = args.preset
185 encode_options.crf = args.crf
186 encode_options.block_when_full = True
187
188 return encode_options
189
190
192 def __init__(self, args, capture, is_camera, source_width, source_height, source_fps, output_fps, writer):
193 super().__init__("MXWrite Capture", source_width, source_height, False, False, False)
194 self.args = args
195 self.capture = capture
196 self.is_camera = is_camera
197 self.source_fps = source_fps
198 self.output_fps = output_fps
199 self.writer = writer
200 self.sprite = self.create_sprite(source_width, source_height)
201 self.frame_index = 0
202 self.first_timestamp = None
203 self.start_time = None
204 self.last_pts = None
206 self.capture_failed = False
207 self.closed = False
208
209 def event(self, event):
210 if event.type == mxvk.EVENT_KEY_DOWN and event.key == mxvk.KEY_ESCAPE:
211 print("Escape pressed. Stopping capture.")
212 self.request_exit()
213
214 def proc(self):
215 if self.args.frames > 0 and self.frame_index >= self.args.frames:
216 self.request_exit()
217 return
218
219 rgba_frame = self.capture.read_rgba()
220
221 if rgba_frame is None:
222 if self.is_camera and not self.capture_failed:
223 print("Camera capture failed.")
224 self.capture_failed = True
225 self.request_exit()
226 return
227
228 if self.is_camera:
229 capture_time = time.monotonic()
230
231 if self.start_time is None:
232 self.start_time = capture_time
233
234 timestamp = capture_time - self.start_time
235 current_source = "MONOTONIC"
236 else:
237 timestamp, current_source = get_file_timestamp(self.capture, self.source_fps, self.frame_index)
238
239 if self.first_timestamp is None:
240 self.first_timestamp = timestamp
241
242 if self.args.normalize_pts:
243 timestamp -= self.first_timestamp
244
245 if self.timestamp_source != current_source:
246 self.timestamp_source = current_source
247 print(f"Timestamp source: {self.timestamp_source}")
248
249 # File playback is encoded as constant-frame-rate output: every
250 # decoded frame occupies exactly one output frame interval. Camera
251 # capture keeps its existing monotonic-clock timestamps.
252 output_pts = self.frame_index if not self.is_camera else int(round(timestamp * self.output_fps))
253
254 if self.last_pts is not None and output_pts < self.last_pts:
255 print(f"Warning: non-monotonic PTS at frame {self.frame_index}: {output_pts} < {self.last_pts}")
256 output_pts = self.last_pts
257
258 self.writer.write_at_pts(rgba_frame, output_pts)
259 self.sprite.update_texture(rgba_frame, rgba_frame.shape[1], rgba_frame.shape[0], rgba_frame.strides[0])
260
261 width, height = self.swapchain_extent
262 self.sprite.draw_rect(0, 0, width, height)
263 self.last_pts = output_pts
264
265 if self.frame_index < 10 or self.frame_index % 100 == 0:
266 print(f"frame={self.frame_index:6d} time={timestamp:10.6f}s pts={output_pts:8d}")
267
268 self.frame_index += 1
269
270 def close(self):
271 if self.closed:
272 return
273
274 self.closed = True
275 self.wait_idle()
276 self.sprite = None
277 self.writer.close()
278 self.capture.close()
279 self.release()
280
281
282def main():
283 args = parse_arguments()
285 args.output.parent.mkdir(parents=True, exist_ok=True)
286 is_camera = args.camera is not None
287 capture = open_camera_capture(args.camera, args.width, args.height, args.fps) if is_camera else open_file_capture(args.input)
288 window = None
289 writer = None
290
291 try:
292 source_width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
293 source_height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
294 source_fps = float(capture.get(cv2.CAP_PROP_FPS))
295
296 if source_width <= 0 or source_height <= 0:
297 raise SystemExit(f"Invalid capture resolution: {source_width}x{source_height}")
298
299 if not math.isfinite(source_fps) or source_fps <= 0.0:
300 source_fps = args.fps
301
302 output_fps = args.fps if is_camera else source_fps
303
304 print()
305 print("Backend: MXVK OpenCV capture")
306 print(f"Resolution: {source_width}x{source_height}")
307 print(f"Capture FPS: {source_fps:.6f}")
308 print(f"Output time base: 1/{output_fps:.6f}")
309 print(f"{'Camera: ' + str(args.camera) if is_camera else 'Input: ' + str(args.input)}")
310 print("Press Escape to stop capture.")
311
312 encode_options = configure_encoder(args)
313 writer = mxwrite_ext.Writer()
314
315 if not writer.open_ts(str(args.output), source_width, source_height, float(output_fps), encode_options):
316 raise SystemExit(f"MXWrite could not open {args.output}")
317
318 window = CaptureWindow(args, capture, is_camera, source_width, source_height, source_fps, output_fps, writer)
319
320 try:
321 window.loop()
322 except KeyboardInterrupt:
323 print("\nCapture stopped.")
324
325 window.close()
326 frame_count = writer.get_frame_count()
327 duration = writer.get_duration()
328 byte_count = writer.get_bytes_written()
329 file_size = args.output.stat().st_size if args.output.is_file() else 0
330
331 if file_size == 0:
332 raise SystemExit(f"MXWrite did not create a non-empty file at {args.output}.")
333
334 print()
335 print(f"PASS: wrote {frame_count} frames, {duration:.3f}s, {file_size} filesystem bytes to {args.output}")
336 print(f"MXWrite's muxer byte counter reported {byte_count} bytes.")
337
338 if window.last_pts is not None:
339 print(f"Last video PTS: {window.last_pts} ({window.last_pts / output_fps:.6f}s)")
340 finally:
341 if window is not None:
342 window.close()
343 else:
344 if writer is not None:
345 writer.close()
346 capture.close()
347
348
349if __name__ == "__main__":
350 main()
Main Vulkan window wrapper for MXVK.
Definition mxvk.hpp:39
VkExtent2D swapchain_extent
Definition mxvk.hpp:616
void release()
Release Vulkan and SDL resources.
Definition mxvk.cpp:247
virtual void proc()
Execute one processing/update step.
Definition mxvk.cpp:1278
__init__(self, args, capture, is_camera, source_width, source_height, source_fps, output_fps, writer)
Definition main.py:1
fourcc_to_string(value)
open_camera_capture(index, width, height, fps)
get_file_timestamp(capture, source_fps, frame_index)
open_file_capture(filename)