rocket-welder-sdk 1.0.5__py3-none-any.whl → 1.1.25__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.
@@ -0,0 +1,668 @@
1
+ """
2
+ Enterprise-grade controller implementations for RocketWelder SDK.
3
+ Provides OneWay and Duplex shared memory controllers for video streaming.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import threading
11
+ from abc import ABC, abstractmethod
12
+ from typing import Any, Callable
13
+
14
+ import numpy as np
15
+ from zerobuffer import BufferConfig, Frame, Reader, Writer
16
+ from zerobuffer.duplex import DuplexChannelFactory, IImmutableDuplexServer
17
+ from zerobuffer.exceptions import WriterDeadException
18
+
19
+ from .connection_string import ConnectionMode, ConnectionString, Protocol
20
+ from .gst_metadata import GstCaps, GstMetadata
21
+
22
+ # Type alias for OpenCV Mat
23
+ Mat = np.ndarray[Any, Any]
24
+
25
+ # Module logger
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class IController(ABC):
30
+ """Abstract base class for controllers."""
31
+
32
+ @property
33
+ @abstractmethod
34
+ def is_running(self) -> bool:
35
+ """Check if the controller is running."""
36
+ ...
37
+
38
+ @abstractmethod
39
+ def get_metadata(self) -> GstMetadata | None:
40
+ """Get the current GStreamer metadata."""
41
+ ...
42
+
43
+ @abstractmethod
44
+ def start(
45
+ self, on_frame: Callable[[Mat], None], cancellation_token: threading.Event | None = None
46
+ ) -> None:
47
+ """
48
+ Start the controller with a frame callback.
49
+
50
+ Args:
51
+ on_frame: Callback for processing frames
52
+ cancellation_token: Optional cancellation token
53
+ """
54
+ ...
55
+
56
+ @abstractmethod
57
+ def stop(self) -> None:
58
+ """Stop the controller."""
59
+ ...
60
+
61
+
62
+ class OneWayShmController(IController):
63
+ """
64
+ One-way shared memory controller for receiving video frames.
65
+
66
+ This controller creates a shared memory buffer that GStreamer connects to
67
+ as a zerosink, allowing zero-copy frame reception.
68
+ """
69
+
70
+ def __init__(self, connection: ConnectionString):
71
+ """
72
+ Initialize the one-way controller.
73
+
74
+ Args:
75
+ connection: Connection string configuration
76
+ """
77
+ if connection.protocol != Protocol.SHM:
78
+ raise ValueError(
79
+ f"OneWayShmController requires SHM protocol, got {connection.protocol}"
80
+ )
81
+
82
+ self._connection = connection
83
+ self._reader: Reader | None = None
84
+ self._gst_caps: GstCaps | None = None
85
+ self._metadata: GstMetadata | None = None
86
+ self._is_running = False
87
+ self._worker_thread: threading.Thread | None = None
88
+ self._cancellation_token: threading.Event | None = None
89
+
90
+ @property
91
+ def is_running(self) -> bool:
92
+ """Check if the controller is running."""
93
+ return self._is_running
94
+
95
+ def get_metadata(self) -> GstMetadata | None:
96
+ """Get the current GStreamer metadata."""
97
+ return self._metadata
98
+
99
+ def start(
100
+ self, on_frame: Callable[[Mat], None], cancellation_token: threading.Event | None = None
101
+ ) -> None:
102
+ """
103
+ Start receiving frames from shared memory.
104
+
105
+ Args:
106
+ on_frame: Callback for processing received frames
107
+ cancellation_token: Optional cancellation token
108
+ """
109
+ if self._is_running:
110
+ raise RuntimeError("Controller is already running")
111
+
112
+ logger.debug("Starting OneWayShmController for buffer '%s'", self._connection.buffer_name)
113
+ self._is_running = True
114
+ self._cancellation_token = cancellation_token
115
+
116
+ # Create buffer configuration
117
+ config = BufferConfig(
118
+ metadata_size=int(self._connection.metadata_size),
119
+ payload_size=int(self._connection.buffer_size),
120
+ )
121
+
122
+ # Create reader (we are the server, GStreamer connects to us)
123
+ # Pass logger to Reader for better debugging
124
+ if not self._connection.buffer_name:
125
+ raise ValueError("Buffer name is required for shared memory connection")
126
+ self._reader = Reader(self._connection.buffer_name, config)
127
+
128
+ logger.info(
129
+ "Created shared memory buffer '%s' with size %s and metadata %s",
130
+ self._connection.buffer_name,
131
+ self._connection.buffer_size,
132
+ self._connection.metadata_size,
133
+ )
134
+
135
+ # Start processing thread
136
+ self._worker_thread = threading.Thread(
137
+ target=self._process_frames,
138
+ args=(on_frame,),
139
+ name=f"RocketWelder-{self._connection.buffer_name}",
140
+ )
141
+ self._worker_thread.start()
142
+
143
+ def stop(self) -> None:
144
+ """Stop the controller and clean up resources."""
145
+ if not self._is_running:
146
+ return
147
+
148
+ logger.debug("Stopping controller for buffer '%s'", self._connection.buffer_name)
149
+ self._is_running = False
150
+
151
+ # Wait for worker thread to finish
152
+ if self._worker_thread and self._worker_thread.is_alive():
153
+ timeout_ms = self._connection.timeout_ms + 50
154
+ self._worker_thread.join(timeout=timeout_ms / 1000.0)
155
+
156
+ # Clean up reader
157
+ if self._reader:
158
+ self._reader.close()
159
+ self._reader = None
160
+
161
+ self._worker_thread = None
162
+ logger.info("Stopped controller for buffer '%s'", self._connection.buffer_name)
163
+
164
+ def _process_frames(self, on_frame: Callable[[Mat], None]) -> None:
165
+ """
166
+ Process frames from shared memory.
167
+
168
+ Args:
169
+ on_frame: Callback for processing frames
170
+ """
171
+ try:
172
+ # Process first frame to get metadata
173
+ self._on_first_frame(on_frame)
174
+
175
+ # Process remaining frames
176
+ while self._is_running and (
177
+ not self._cancellation_token or not self._cancellation_token.is_set()
178
+ ):
179
+ try:
180
+ # ReadFrame blocks until frame available
181
+ # Use timeout in seconds directly
182
+ timeout_seconds = self._connection.timeout_ms / 1000.0
183
+ frame = self._reader.read_frame(timeout=timeout_seconds) # type: ignore[union-attr]
184
+
185
+ if frame is None or not frame.is_valid:
186
+ continue # Skip invalid frames
187
+
188
+ # Process frame data using context manager
189
+ with frame:
190
+ # Create Mat from frame data (zero-copy when possible)
191
+ mat = self._create_mat_from_frame(frame)
192
+ if mat is not None:
193
+ on_frame(mat)
194
+
195
+ except WriterDeadException:
196
+ # Writer has disconnected gracefully
197
+ logger.info(
198
+ "Writer disconnected gracefully from buffer '%s'",
199
+ self._connection.buffer_name,
200
+ )
201
+ self._is_running = False
202
+ break
203
+ except Exception as e:
204
+ # Log specific error types like C#
205
+ error_type = type(e).__name__
206
+ if "ReaderDead" in error_type:
207
+ logger.info(
208
+ "Reader disconnected from buffer '%s'", self._connection.buffer_name
209
+ )
210
+ self._is_running = False
211
+ break
212
+ elif "BufferFull" in error_type:
213
+ logger.error("Buffer full on '%s': %s", self._connection.buffer_name, e)
214
+ if not self._is_running:
215
+ break
216
+ elif "FrameTooLarge" in error_type:
217
+ logger.error("Frame too large on '%s': %s", self._connection.buffer_name, e)
218
+ if not self._is_running:
219
+ break
220
+ elif "ZeroBuffer" in error_type:
221
+ logger.error(
222
+ "ZeroBuffer error on '%s': %s", self._connection.buffer_name, e
223
+ )
224
+ if not self._is_running:
225
+ break
226
+ else:
227
+ logger.error(
228
+ "Unexpected error processing frame from buffer '%s': %s",
229
+ self._connection.buffer_name,
230
+ e,
231
+ )
232
+ if not self._is_running:
233
+ break
234
+
235
+ except Exception as e:
236
+ logger.error("Fatal error in frame processing loop: %s", e)
237
+ self._is_running = False
238
+
239
+ def _on_first_frame(self, on_frame: Callable[[Mat], None]) -> None:
240
+ """
241
+ Process the first frame and extract metadata.
242
+ Matches C# OnFirstFrame behavior - loops until valid frame received.
243
+
244
+ Args:
245
+ on_frame: Callback for processing frames
246
+ """
247
+ while self._is_running and (
248
+ not self._cancellation_token or not self._cancellation_token.is_set()
249
+ ):
250
+ try:
251
+ # ReadFrame blocks until frame available
252
+ timeout_seconds = self._connection.timeout_ms / 1000.0
253
+ frame = self._reader.read_frame(timeout=timeout_seconds) # type: ignore[union-attr]
254
+
255
+ if frame is None or not frame.is_valid:
256
+ continue # Skip invalid frames
257
+
258
+ with frame:
259
+ # Read metadata - we ALWAYS expect metadata (like C#)
260
+ metadata_bytes = self._reader.get_metadata() # type: ignore[union-attr]
261
+ if metadata_bytes:
262
+ try:
263
+ # Log raw metadata for debugging
264
+ logger.debug(
265
+ "Raw metadata: %d bytes, type=%s, first 100 bytes: %s",
266
+ len(metadata_bytes),
267
+ type(metadata_bytes),
268
+ bytes(metadata_bytes[: min(100, len(metadata_bytes))]),
269
+ )
270
+
271
+ # Convert memoryview to bytes if needed
272
+ if isinstance(metadata_bytes, memoryview):
273
+ metadata_bytes = bytes(metadata_bytes)
274
+
275
+ # Decode UTF-8
276
+ metadata_str = metadata_bytes.decode("utf-8")
277
+
278
+ # Check if metadata is empty or all zeros
279
+ if not metadata_str or metadata_str == "\x00" * len(metadata_str):
280
+ logger.warning("Metadata is empty or all zeros, skipping")
281
+ continue
282
+
283
+ # Find the start of JSON (skip any null bytes at the beginning)
284
+ json_start = metadata_str.find("{")
285
+ if json_start == -1:
286
+ logger.warning("No JSON found in metadata: %r", metadata_str[:100])
287
+ continue
288
+
289
+ if json_start > 0:
290
+ logger.debug("Skipping %d bytes before JSON", json_start)
291
+ metadata_str = metadata_str[json_start:]
292
+
293
+ # Find the end of JSON (handle null padding)
294
+ json_end = metadata_str.rfind("}")
295
+ if json_end != -1 and json_end < len(metadata_str) - 1:
296
+ metadata_str = metadata_str[: json_end + 1]
297
+
298
+ metadata_json = json.loads(metadata_str)
299
+ self._metadata = GstMetadata.from_json(metadata_json)
300
+ self._gst_caps = self._metadata.caps
301
+ logger.info(
302
+ "Received metadata from buffer '%s': %s",
303
+ self._connection.buffer_name,
304
+ self._gst_caps,
305
+ )
306
+ except Exception as e:
307
+ logger.error("Failed to parse metadata: %s", e)
308
+ # Log the actual metadata content for debugging
309
+ if metadata_bytes:
310
+ logger.debug("Metadata content: %r", metadata_bytes[:200])
311
+ # Don't continue without metadata
312
+ continue
313
+
314
+ # Process first frame
315
+ mat = self._create_mat_from_frame(frame)
316
+ if mat is not None:
317
+ on_frame(mat)
318
+ return # Successfully processed first frame
319
+
320
+ except WriterDeadException:
321
+ self._is_running = False
322
+ logger.info(
323
+ "Writer disconnected gracefully while waiting for first frame on buffer '%s'",
324
+ self._connection.buffer_name,
325
+ )
326
+ raise
327
+ except Exception as e:
328
+ error_type = type(e).__name__
329
+ if "ReaderDead" in error_type:
330
+ self._is_running = False
331
+ logger.info(
332
+ "Reader disconnected while waiting for first frame on buffer '%s'",
333
+ self._connection.buffer_name,
334
+ )
335
+ raise
336
+ else:
337
+ logger.error(
338
+ "Error waiting for first frame on buffer '%s': %s",
339
+ self._connection.buffer_name,
340
+ e,
341
+ )
342
+ if not self._is_running:
343
+ break
344
+
345
+ def _create_mat_from_frame(self, frame: Frame) -> Mat | None:
346
+ """
347
+ Create OpenCV Mat from frame data using GstCaps.
348
+ Matches C# CreateMat behavior - creates Mat wrapping the data.
349
+
350
+ Args:
351
+ frame: ZeroBuffer frame
352
+
353
+ Returns:
354
+ OpenCV Mat or None if conversion failed
355
+ """
356
+ try:
357
+ # Match C# CreateMat behavior: Create Mat wrapping the existing data
358
+ if self._gst_caps and self._gst_caps.width and self._gst_caps.height:
359
+ width = self._gst_caps.width
360
+ height = self._gst_caps.height
361
+
362
+ # Determine channels from format (like C# MapGStreamerFormatToEmgu)
363
+ format_str = self._gst_caps.format or "RGB"
364
+ if format_str in ["RGB", "BGR"]:
365
+ channels = 3
366
+ elif format_str in ["RGBA", "BGRA", "ARGB", "ABGR"]:
367
+ channels = 4
368
+ elif format_str in ["GRAY8", "GRAY16_LE", "GRAY16_BE"]:
369
+ channels = 1
370
+ else:
371
+ channels = 3 # Default to RGB
372
+
373
+ # Get frame data directly as numpy array (zero-copy view)
374
+ # Frame.data is already a memoryview/buffer that can be wrapped
375
+ data = np.frombuffer(frame.data, dtype=np.uint8)
376
+
377
+ # Check data size matches expected
378
+ expected_size = height * width * channels
379
+ if len(data) != expected_size:
380
+ logger.error(
381
+ "Data size mismatch. Expected %d bytes for %dx%d with %d channels, got %d",
382
+ expected_size,
383
+ width,
384
+ height,
385
+ channels,
386
+ len(data),
387
+ )
388
+ return None
389
+
390
+ # Reshape to image dimensions - this is zero-copy, just changes the view
391
+ # This matches C#: new Mat(Height, Width, Depth, Channels, ptr, Width * Channels)
392
+ if channels == 3:
393
+ mat = data.reshape((height, width, 3))
394
+ elif channels == 1:
395
+ mat = data.reshape((height, width))
396
+ elif channels == 4:
397
+ mat = data.reshape((height, width, 4))
398
+ else:
399
+ logger.error("Unsupported channel count: %d", channels)
400
+ return None
401
+
402
+ return mat # type: ignore[no-any-return]
403
+
404
+ # No caps available - try to infer from frame size
405
+ logger.warning("No GstCaps available, attempting to infer from frame size")
406
+
407
+ # Try common resolutions
408
+ frame_size = len(frame.data)
409
+ common_resolutions = [
410
+ (640, 480, 3), # VGA RGB
411
+ (640, 480, 4), # VGA RGBA
412
+ (1280, 720, 3), # 720p RGB
413
+ (1920, 1080, 3), # 1080p RGB
414
+ (640, 480, 1), # VGA Grayscale
415
+ ]
416
+
417
+ for width, height, channels in common_resolutions:
418
+ if frame_size == width * height * channels:
419
+ logger.info(f"Inferred resolution: {width}x{height} with {channels} channels")
420
+
421
+ # Create caps for future use
422
+ format_str = "RGB" if channels == 3 else "RGBA" if channels == 4 else "GRAY8"
423
+ self._gst_caps = GstCaps.from_simple(
424
+ width=width, height=height, format=format_str
425
+ )
426
+
427
+ # Create Mat
428
+ data = np.frombuffer(frame.data, dtype=np.uint8)
429
+ if channels == 3:
430
+ return data.reshape((height, width, 3)) # type: ignore[no-any-return]
431
+ elif channels == 1:
432
+ return data.reshape((height, width)) # type: ignore[no-any-return]
433
+ elif channels == 4:
434
+ return data.reshape((height, width, 4)) # type: ignore[no-any-return]
435
+
436
+ logger.error(f"Could not infer resolution for frame size {frame_size}")
437
+ return None
438
+
439
+ except Exception as e:
440
+ logger.error("Failed to convert frame to Mat: %s", e)
441
+ return None
442
+
443
+ def _infer_caps_from_frame(self, mat: Mat) -> None:
444
+ """
445
+ Infer GStreamer caps from OpenCV Mat.
446
+
447
+ Args:
448
+ mat: OpenCV Mat
449
+ """
450
+ if mat is None:
451
+ return
452
+
453
+ shape = mat.shape
454
+ if len(shape) == 2:
455
+ # Grayscale
456
+ self._gst_caps = GstCaps.from_simple(width=shape[1], height=shape[0], format="GRAY8")
457
+ elif len(shape) == 3:
458
+ # Color image
459
+ self._gst_caps = GstCaps.from_simple(width=shape[1], height=shape[0], format="BGR")
460
+
461
+ logger.info("Inferred caps from frame: %s", self._gst_caps)
462
+
463
+
464
+ class DuplexShmController(IController):
465
+ """
466
+ Duplex shared memory controller for bidirectional video streaming.
467
+
468
+ This controller supports both receiving frames from one buffer and
469
+ sending processed frames to another buffer.
470
+ """
471
+
472
+ def __init__(self, connection: ConnectionString):
473
+ """
474
+ Initialize the duplex controller.
475
+
476
+ Args:
477
+ connection: Connection string configuration
478
+ """
479
+ if connection.protocol != Protocol.SHM:
480
+ raise ValueError(
481
+ f"DuplexShmController requires SHM protocol, got {connection.protocol}"
482
+ )
483
+
484
+ if connection.connection_mode != ConnectionMode.DUPLEX:
485
+ raise ValueError(
486
+ f"DuplexShmController requires DUPLEX mode, got {connection.connection_mode}"
487
+ )
488
+
489
+ self._connection = connection
490
+ self._duplex_server: IImmutableDuplexServer | None = None
491
+ self._gst_caps: GstCaps | None = None
492
+ self._metadata: GstMetadata | None = None
493
+ self._is_running = False
494
+ self._on_frame_callback: Callable[[Mat, Mat], None] | None = None
495
+ self._frame_count = 0
496
+
497
+ @property
498
+ def is_running(self) -> bool:
499
+ """Check if the controller is running."""
500
+ return self._is_running
501
+
502
+ def get_metadata(self) -> GstMetadata | None:
503
+ """Get the current GStreamer metadata."""
504
+ return self._metadata
505
+
506
+ def start(
507
+ self,
508
+ on_frame: Callable[[Mat, Mat], None], # type: ignore[override]
509
+ cancellation_token: threading.Event | None = None,
510
+ ) -> None:
511
+ """
512
+ Start duplex frame processing.
513
+
514
+ Args:
515
+ on_frame: Callback that receives input frame and output frame to fill
516
+ cancellation_token: Optional cancellation token
517
+ """
518
+ if self._is_running:
519
+ raise RuntimeError("Controller is already running")
520
+
521
+ self._is_running = True
522
+ self._on_frame_callback = on_frame
523
+
524
+ # Create buffer configuration
525
+ config = BufferConfig(
526
+ metadata_size=int(self._connection.metadata_size),
527
+ payload_size=int(self._connection.buffer_size),
528
+ )
529
+
530
+ # Create duplex server using factory
531
+ # Convert timeout from milliseconds to seconds for Python API
532
+ if not self._connection.buffer_name:
533
+ raise ValueError("Buffer name is required for shared memory connection")
534
+ timeout_seconds = self._connection.timeout_ms / 1000.0
535
+ factory = DuplexChannelFactory()
536
+ self._duplex_server = factory.create_immutable_server(
537
+ self._connection.buffer_name, config, timeout_seconds
538
+ )
539
+
540
+ logger.info(
541
+ "Starting duplex server for channel '%s' with size %s and metadata %s",
542
+ self._connection.buffer_name,
543
+ self._connection.buffer_size,
544
+ self._connection.metadata_size,
545
+ )
546
+
547
+ # Start server with frame processor callback
548
+ if self._duplex_server:
549
+ self._duplex_server.start(self._process_duplex_frame, self._on_metadata)
550
+
551
+ def stop(self) -> None:
552
+ """Stop the controller and clean up resources."""
553
+ if not self._is_running:
554
+ return
555
+
556
+ logger.info("Stopping DuplexShmController")
557
+ self._is_running = False
558
+
559
+ # Stop the duplex server
560
+ if self._duplex_server:
561
+ self._duplex_server.stop()
562
+ self._duplex_server = None
563
+
564
+ logger.info("DuplexShmController stopped")
565
+
566
+ def _on_metadata(self, metadata_bytes: bytes | memoryview) -> None:
567
+ """
568
+ Handle metadata from duplex channel.
569
+
570
+ Args:
571
+ metadata_bytes: Raw metadata bytes or memoryview
572
+ """
573
+ logger.debug(
574
+ "_on_metadata called with %d bytes", len(metadata_bytes) if metadata_bytes else 0
575
+ )
576
+ try:
577
+ # Convert memoryview to bytes if needed
578
+ if isinstance(metadata_bytes, memoryview):
579
+ metadata_bytes = bytes(metadata_bytes)
580
+
581
+ # Log raw bytes for debugging
582
+ logger.debug(
583
+ "Raw metadata bytes (first 100): %r",
584
+ metadata_bytes[: min(100, len(metadata_bytes))],
585
+ )
586
+
587
+ metadata_str = metadata_bytes.decode("utf-8")
588
+ logger.debug("Decoded metadata string: %r", metadata_str[: min(200, len(metadata_str))])
589
+
590
+ metadata_json = json.loads(metadata_str)
591
+ self._metadata = GstMetadata.from_json(metadata_json)
592
+ self._gst_caps = self._metadata.caps
593
+ logger.info("Received metadata: %s", self._metadata)
594
+ except Exception as e:
595
+ logger.error("Failed to parse metadata: %s", e, exc_info=True)
596
+
597
+ def _process_duplex_frame(self, request_frame: Frame, response_writer: Writer) -> None:
598
+ """
599
+ Process a frame in duplex mode.
600
+
601
+ Args:
602
+ request_frame: Input frame from the request
603
+ response_writer: Writer for the response frame
604
+ """
605
+ logger.debug(
606
+ "_process_duplex_frame called, frame_count=%d, has_gst_caps=%s",
607
+ self._frame_count,
608
+ self._gst_caps is not None,
609
+ )
610
+ try:
611
+ if not self._on_frame_callback:
612
+ logger.warning("No frame callback set")
613
+ return
614
+
615
+ self._frame_count += 1
616
+
617
+ # Convert input frame to Mat
618
+ input_mat = self._frame_to_mat(request_frame)
619
+ if input_mat is None:
620
+ logger.error("Failed to convert frame to Mat, gst_caps=%s", self._gst_caps)
621
+ return
622
+
623
+ # Get buffer for output frame - use context manager for RAII
624
+ with response_writer.get_frame_buffer(request_frame.size) as output_buffer:
625
+ # Create output Mat from buffer (zero-copy)
626
+ if self._gst_caps:
627
+ height = self._gst_caps.height or 480
628
+ width = self._gst_caps.width or 640
629
+
630
+ if self._gst_caps.format == "RGB" or self._gst_caps.format == "BGR":
631
+ output_mat = np.frombuffer(output_buffer, dtype=np.uint8).reshape(
632
+ (height, width, 3)
633
+ )
634
+ elif self._gst_caps.format == "GRAY8":
635
+ output_mat = np.frombuffer(output_buffer, dtype=np.uint8).reshape(
636
+ (height, width)
637
+ )
638
+ else:
639
+ # Default to same shape as input
640
+ output_mat = np.frombuffer(output_buffer, dtype=np.uint8).reshape(
641
+ input_mat.shape
642
+ )
643
+ else:
644
+ # Use same shape as input
645
+ output_mat = np.frombuffer(output_buffer, dtype=np.uint8).reshape(
646
+ input_mat.shape
647
+ )
648
+
649
+ # Call user's processing function
650
+ self._on_frame_callback(input_mat, output_mat)
651
+
652
+ # Commit the response frame after buffer is released
653
+ response_writer.commit_frame()
654
+
655
+ logger.debug(
656
+ "Processed duplex frame %d (%dx%d)",
657
+ self._frame_count,
658
+ input_mat.shape[1],
659
+ input_mat.shape[0],
660
+ )
661
+
662
+ except Exception as e:
663
+ logger.error("Error processing duplex frame: %s", e)
664
+
665
+ def _frame_to_mat(self, frame: Frame) -> Mat | None:
666
+ """Convert frame to OpenCV Mat (reuse from OneWayShmController)."""
667
+ # Implementation is same as OneWayShmController
668
+ return OneWayShmController._create_mat_from_frame(self, frame) # type: ignore[arg-type]