rocket-welder-sdk 1.1.36.dev14__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.
Files changed (40) hide show
  1. rocket_welder_sdk/__init__.py +95 -0
  2. rocket_welder_sdk/bytes_size.py +234 -0
  3. rocket_welder_sdk/connection_string.py +291 -0
  4. rocket_welder_sdk/controllers.py +831 -0
  5. rocket_welder_sdk/external_controls/__init__.py +30 -0
  6. rocket_welder_sdk/external_controls/contracts.py +100 -0
  7. rocket_welder_sdk/external_controls/contracts_old.py +105 -0
  8. rocket_welder_sdk/frame_metadata.py +138 -0
  9. rocket_welder_sdk/gst_metadata.py +411 -0
  10. rocket_welder_sdk/high_level/__init__.py +54 -0
  11. rocket_welder_sdk/high_level/client.py +235 -0
  12. rocket_welder_sdk/high_level/connection_strings.py +331 -0
  13. rocket_welder_sdk/high_level/data_context.py +169 -0
  14. rocket_welder_sdk/high_level/frame_sink_factory.py +118 -0
  15. rocket_welder_sdk/high_level/schema.py +195 -0
  16. rocket_welder_sdk/high_level/transport_protocol.py +238 -0
  17. rocket_welder_sdk/keypoints_protocol.py +642 -0
  18. rocket_welder_sdk/opencv_controller.py +278 -0
  19. rocket_welder_sdk/periodic_timer.py +303 -0
  20. rocket_welder_sdk/py.typed +2 -0
  21. rocket_welder_sdk/rocket_welder_client.py +497 -0
  22. rocket_welder_sdk/segmentation_result.py +420 -0
  23. rocket_welder_sdk/session_id.py +238 -0
  24. rocket_welder_sdk/transport/__init__.py +31 -0
  25. rocket_welder_sdk/transport/frame_sink.py +122 -0
  26. rocket_welder_sdk/transport/frame_source.py +74 -0
  27. rocket_welder_sdk/transport/nng_transport.py +197 -0
  28. rocket_welder_sdk/transport/stream_transport.py +193 -0
  29. rocket_welder_sdk/transport/tcp_transport.py +154 -0
  30. rocket_welder_sdk/transport/unix_socket_transport.py +339 -0
  31. rocket_welder_sdk/ui/__init__.py +48 -0
  32. rocket_welder_sdk/ui/controls.py +362 -0
  33. rocket_welder_sdk/ui/icons.py +21628 -0
  34. rocket_welder_sdk/ui/ui_events_projection.py +226 -0
  35. rocket_welder_sdk/ui/ui_service.py +358 -0
  36. rocket_welder_sdk/ui/value_types.py +72 -0
  37. rocket_welder_sdk-1.1.36.dev14.dist-info/METADATA +845 -0
  38. rocket_welder_sdk-1.1.36.dev14.dist-info/RECORD +40 -0
  39. rocket_welder_sdk-1.1.36.dev14.dist-info/WHEEL +5 -0
  40. rocket_welder_sdk-1.1.36.dev14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,831 @@
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 TYPE_CHECKING, Callable, Optional
13
+
14
+ import numpy as np
15
+ from zerobuffer import BufferConfig, Frame, Reader, Writer
16
+ from zerobuffer.duplex import DuplexChannelFactory
17
+ from zerobuffer.exceptions import WriterDeadException
18
+
19
+ from .connection_string import ConnectionMode, ConnectionString, Protocol
20
+ from .frame_metadata import FRAME_METADATA_SIZE, FrameMetadata
21
+ from .gst_metadata import GstCaps, GstMetadata
22
+
23
+ if TYPE_CHECKING:
24
+ import numpy.typing as npt
25
+ from zerobuffer.duplex import IImmutableDuplexServer
26
+
27
+ Mat = npt.NDArray[np.uint8]
28
+ else:
29
+ from zerobuffer.duplex import IImmutableDuplexServer
30
+
31
+ Mat = np.ndarray # type: ignore[misc]
32
+
33
+ # Module logger
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class IController(ABC):
38
+ """Abstract base class for controllers."""
39
+
40
+ @property
41
+ @abstractmethod
42
+ def is_running(self) -> bool:
43
+ """Check if the controller is running."""
44
+ ...
45
+
46
+ @abstractmethod
47
+ def get_metadata(self) -> Optional[GstMetadata]:
48
+ """Get the current GStreamer metadata."""
49
+ ...
50
+
51
+ @abstractmethod
52
+ def start(
53
+ self,
54
+ on_frame: Callable[[Mat], None], # type: ignore[valid-type]
55
+ cancellation_token: Optional[threading.Event] = None,
56
+ ) -> None:
57
+ """
58
+ Start the controller with a frame callback.
59
+
60
+ Args:
61
+ on_frame: Callback for processing frames
62
+ cancellation_token: Optional cancellation token
63
+ """
64
+ ...
65
+
66
+ @abstractmethod
67
+ def stop(self) -> None:
68
+ """Stop the controller."""
69
+ ...
70
+
71
+
72
+ class OneWayShmController(IController):
73
+ """
74
+ One-way shared memory controller for receiving video frames.
75
+
76
+ This controller creates a shared memory buffer that GStreamer connects to
77
+ as a zerosink, allowing zero-copy frame reception.
78
+ """
79
+
80
+ def __init__(self, connection: ConnectionString):
81
+ """
82
+ Initialize the one-way controller.
83
+
84
+ Args:
85
+ connection: Connection string configuration
86
+ """
87
+ if connection.protocol != Protocol.SHM:
88
+ raise ValueError(
89
+ f"OneWayShmController requires SHM protocol, got {connection.protocol}"
90
+ )
91
+
92
+ self._connection = connection
93
+ self._reader: Optional[Reader] = None
94
+ self._gst_caps: Optional[GstCaps] = None
95
+ self._metadata: Optional[GstMetadata] = None
96
+ self._is_running = False
97
+ self._worker_thread: Optional[threading.Thread] = None
98
+ self._cancellation_token: Optional[threading.Event] = None
99
+
100
+ @property
101
+ def is_running(self) -> bool:
102
+ """Check if the controller is running."""
103
+ return self._is_running
104
+
105
+ def get_metadata(self) -> Optional[GstMetadata]:
106
+ """Get the current GStreamer metadata."""
107
+ return self._metadata
108
+
109
+ def start(
110
+ self,
111
+ on_frame: Callable[[Mat], None], # type: ignore[valid-type]
112
+ cancellation_token: Optional[threading.Event] = None,
113
+ ) -> None:
114
+ """
115
+ Start receiving frames from shared memory.
116
+
117
+ Args:
118
+ on_frame: Callback for processing received frames
119
+ cancellation_token: Optional cancellation token
120
+ """
121
+ if self._is_running:
122
+ raise RuntimeError("Controller is already running")
123
+
124
+ logger.debug("Starting OneWayShmController for buffer '%s'", self._connection.buffer_name)
125
+ self._is_running = True
126
+ self._cancellation_token = cancellation_token
127
+
128
+ # Create buffer configuration
129
+ config = BufferConfig(
130
+ metadata_size=int(self._connection.metadata_size),
131
+ payload_size=int(self._connection.buffer_size),
132
+ )
133
+
134
+ # Create reader (we are the server, GStreamer connects to us)
135
+ # Pass logger to Reader for better debugging
136
+ if not self._connection.buffer_name:
137
+ raise ValueError("Buffer name is required for shared memory connection")
138
+ self._reader = Reader(self._connection.buffer_name, config)
139
+
140
+ logger.info(
141
+ "Created shared memory buffer '%s' with size %s and metadata %s",
142
+ self._connection.buffer_name,
143
+ self._connection.buffer_size,
144
+ self._connection.metadata_size,
145
+ )
146
+
147
+ # Start processing thread
148
+ self._worker_thread = threading.Thread(
149
+ target=self._process_frames,
150
+ args=(on_frame,),
151
+ name=f"RocketWelder-{self._connection.buffer_name}",
152
+ )
153
+ self._worker_thread.start()
154
+
155
+ def stop(self) -> None:
156
+ """Stop the controller and clean up resources."""
157
+ if not self._is_running:
158
+ return
159
+
160
+ logger.debug("Stopping controller for buffer '%s'", self._connection.buffer_name)
161
+ self._is_running = False
162
+
163
+ # Wait for worker thread to finish
164
+ if self._worker_thread and self._worker_thread.is_alive():
165
+ timeout_ms = self._connection.timeout_ms + 50
166
+ self._worker_thread.join(timeout=timeout_ms / 1000.0)
167
+
168
+ # Clean up reader
169
+ if self._reader:
170
+ self._reader.close()
171
+ self._reader = None
172
+
173
+ self._worker_thread = None
174
+ logger.info("Stopped controller for buffer '%s'", self._connection.buffer_name)
175
+
176
+ def _process_frames(self, on_frame: Callable[[Mat], None]) -> None: # type: ignore[valid-type]
177
+ """
178
+ Process frames from shared memory.
179
+
180
+ Args:
181
+ on_frame: Callback for processing frames
182
+ """
183
+ try:
184
+ # Process first frame to get metadata
185
+ self._on_first_frame(on_frame)
186
+
187
+ # Process remaining frames
188
+ while self._is_running and (
189
+ not self._cancellation_token or not self._cancellation_token.is_set()
190
+ ):
191
+ try:
192
+ # ReadFrame blocks until frame available
193
+ # Use timeout in seconds directly
194
+ timeout_seconds = self._connection.timeout_ms / 1000.0
195
+ frame = self._reader.read_frame(timeout=timeout_seconds) # type: ignore[union-attr]
196
+
197
+ if frame is None or not frame.is_valid:
198
+ continue # Skip invalid frames
199
+
200
+ # Process frame data using context manager
201
+ with frame:
202
+ # Create Mat from frame data (zero-copy when possible)
203
+ mat = self._create_mat_from_frame(frame)
204
+ if mat is not None:
205
+ on_frame(mat)
206
+
207
+ except WriterDeadException:
208
+ # Writer has disconnected gracefully
209
+ logger.info(
210
+ "Writer disconnected gracefully from buffer '%s'",
211
+ self._connection.buffer_name,
212
+ )
213
+ self._is_running = False
214
+ break
215
+ except Exception as e:
216
+ # Log specific error types like C#
217
+ error_type = type(e).__name__
218
+ if "ReaderDead" in error_type:
219
+ logger.info(
220
+ "Reader disconnected from buffer '%s'", self._connection.buffer_name
221
+ )
222
+ self._is_running = False
223
+ break
224
+ elif "BufferFull" in error_type:
225
+ logger.error("Buffer full on '%s': %s", self._connection.buffer_name, e)
226
+ if not self._is_running:
227
+ break
228
+ elif "FrameTooLarge" in error_type:
229
+ logger.error("Frame too large on '%s': %s", self._connection.buffer_name, e)
230
+ if not self._is_running:
231
+ break
232
+ elif "ZeroBuffer" in error_type:
233
+ logger.error(
234
+ "ZeroBuffer error on '%s': %s", self._connection.buffer_name, e
235
+ )
236
+ if not self._is_running:
237
+ break
238
+ else:
239
+ logger.error(
240
+ "Unexpected error processing frame from buffer '%s': %s",
241
+ self._connection.buffer_name,
242
+ e,
243
+ )
244
+ if not self._is_running:
245
+ break
246
+
247
+ except Exception as e:
248
+ logger.error("Fatal error in frame processing loop: %s", e)
249
+ self._is_running = False
250
+
251
+ def _on_first_frame(self, on_frame: Callable[[Mat], None]) -> None: # type: ignore[valid-type]
252
+ """
253
+ Process the first frame and extract metadata.
254
+ Matches C# OnFirstFrame behavior - loops until valid frame received.
255
+
256
+ Args:
257
+ on_frame: Callback for processing frames
258
+ """
259
+ while self._is_running and (
260
+ not self._cancellation_token or not self._cancellation_token.is_set()
261
+ ):
262
+ try:
263
+ # ReadFrame blocks until frame available
264
+ timeout_seconds = self._connection.timeout_ms / 1000.0
265
+ frame = self._reader.read_frame(timeout=timeout_seconds) # type: ignore[union-attr]
266
+
267
+ if frame is None or not frame.is_valid:
268
+ continue # Skip invalid frames
269
+
270
+ with frame:
271
+ # Read metadata - we ALWAYS expect metadata (like C#)
272
+ metadata_bytes = self._reader.get_metadata() # type: ignore[union-attr]
273
+ if metadata_bytes:
274
+ try:
275
+ # Log raw metadata for debugging
276
+ logger.debug(
277
+ "Raw metadata: %d bytes, type=%s, first 100 bytes: %s",
278
+ len(metadata_bytes),
279
+ type(metadata_bytes),
280
+ bytes(metadata_bytes[: min(100, len(metadata_bytes))]),
281
+ )
282
+
283
+ # Use helper method to parse metadata
284
+ metadata = self._parse_metadata_json(metadata_bytes)
285
+ if not metadata:
286
+ logger.warning("Failed to parse metadata, skipping")
287
+ continue
288
+
289
+ self._metadata = metadata
290
+ self._gst_caps = metadata.caps
291
+ logger.info(
292
+ "Received metadata from buffer '%s': %s",
293
+ self._connection.buffer_name,
294
+ self._gst_caps,
295
+ )
296
+ except Exception as e:
297
+ logger.error("Failed to parse metadata: %s", e)
298
+ # Log the actual metadata content for debugging
299
+ if metadata_bytes:
300
+ logger.debug("Metadata content: %r", metadata_bytes[:200])
301
+ # Don't continue without metadata
302
+ continue
303
+
304
+ # Process first frame
305
+ mat = self._create_mat_from_frame(frame)
306
+ if mat is not None:
307
+ on_frame(mat)
308
+ return # Successfully processed first frame
309
+
310
+ except WriterDeadException:
311
+ self._is_running = False
312
+ logger.info(
313
+ "Writer disconnected gracefully while waiting for first frame on buffer '%s'",
314
+ self._connection.buffer_name,
315
+ )
316
+ raise
317
+ except Exception as e:
318
+ error_type = type(e).__name__
319
+ if "ReaderDead" in error_type:
320
+ self._is_running = False
321
+ logger.info(
322
+ "Reader disconnected while waiting for first frame on buffer '%s'",
323
+ self._connection.buffer_name,
324
+ )
325
+ raise
326
+ else:
327
+ logger.error(
328
+ "Error waiting for first frame on buffer '%s': %s",
329
+ self._connection.buffer_name,
330
+ e,
331
+ )
332
+ if not self._is_running:
333
+ break
334
+
335
+ def _create_mat_from_frame(self, frame: Frame) -> Optional[Mat]: # type: ignore[valid-type]
336
+ """
337
+ Create OpenCV Mat from frame data using GstCaps.
338
+ Matches C# CreateMat behavior - creates Mat wrapping the data.
339
+
340
+ Frame data layout from GStreamer zerosink:
341
+ [FrameMetadata (16 bytes)][Pixel Data (WxHxC bytes)]
342
+
343
+ Args:
344
+ frame: ZeroBuffer frame
345
+
346
+ Returns:
347
+ OpenCV Mat or None if conversion failed
348
+ """
349
+ try:
350
+ # Match C# CreateMat behavior: Create Mat wrapping the existing data
351
+ if self._gst_caps and self._gst_caps.width and self._gst_caps.height:
352
+ width = self._gst_caps.width
353
+ height = self._gst_caps.height
354
+
355
+ # Determine channels from format (like C# MapGStreamerFormatToEmgu)
356
+ format_str = self._gst_caps.format or "RGB"
357
+ if format_str in ["RGB", "BGR"]:
358
+ channels = 3
359
+ elif format_str in ["RGBA", "BGRA", "ARGB", "ABGR"]:
360
+ channels = 4
361
+ elif format_str in ["GRAY8", "GRAY16_LE", "GRAY16_BE"]:
362
+ channels = 1
363
+ else:
364
+ channels = 3 # Default to RGB
365
+
366
+ # Frame data has 16-byte FrameMetadata prefix that must be stripped
367
+ # Layout: [FrameMetadata (16 bytes)][Pixel Data]
368
+ if frame.size < FRAME_METADATA_SIZE:
369
+ logger.error(
370
+ "Frame too small for FrameMetadata: %d bytes (need at least %d)",
371
+ frame.size,
372
+ FRAME_METADATA_SIZE,
373
+ )
374
+ return None
375
+
376
+ # Get pixel data (skip 16-byte FrameMetadata prefix)
377
+ pixel_data = np.frombuffer(frame.data[FRAME_METADATA_SIZE:], dtype=np.uint8)
378
+
379
+ # Check pixel data size matches expected
380
+ expected_size = height * width * channels
381
+ if len(pixel_data) != expected_size:
382
+ logger.error(
383
+ "Pixel data size mismatch. Expected %d bytes for %dx%d with %d channels, got %d",
384
+ expected_size,
385
+ width,
386
+ height,
387
+ channels,
388
+ len(pixel_data),
389
+ )
390
+ return None
391
+
392
+ # Reshape to image dimensions - this is zero-copy, just changes the view
393
+ # This matches C#: new Mat(Height, Width, Depth, Channels, ptr, Width * Channels)
394
+ if channels == 3:
395
+ mat = pixel_data.reshape((height, width, 3))
396
+ elif channels == 1:
397
+ mat = pixel_data.reshape((height, width))
398
+ elif channels == 4:
399
+ mat = pixel_data.reshape((height, width, 4))
400
+ else:
401
+ logger.error("Unsupported channel count: %d", channels)
402
+ return None
403
+
404
+ return mat # type: ignore[no-any-return]
405
+
406
+ # No caps available - try to infer from frame size
407
+ logger.warning("No GstCaps available, attempting to infer from frame size")
408
+
409
+ # Frame data has 16-byte FrameMetadata prefix
410
+ if frame.size < FRAME_METADATA_SIZE:
411
+ logger.error(
412
+ "Frame too small for FrameMetadata: %d bytes (need at least %d)",
413
+ frame.size,
414
+ FRAME_METADATA_SIZE,
415
+ )
416
+ return None
417
+
418
+ # Calculate pixel data size (frame size minus 16-byte metadata prefix)
419
+ pixel_data_size = frame.size - FRAME_METADATA_SIZE
420
+
421
+ # First, check if it's a perfect square (square frame)
422
+ import math
423
+
424
+ sqrt_size = math.sqrt(pixel_data_size)
425
+ if sqrt_size == int(sqrt_size):
426
+ # Perfect square - assume square grayscale image
427
+ dimension = int(sqrt_size)
428
+ logger.info(
429
+ f"Pixel data size {pixel_data_size} is a perfect square, "
430
+ f"assuming {dimension}x{dimension} grayscale"
431
+ )
432
+ pixel_data = np.frombuffer(frame.data[FRAME_METADATA_SIZE:], dtype=np.uint8)
433
+ return pixel_data.reshape((dimension, dimension)) # type: ignore[no-any-return]
434
+
435
+ # Also check for square RGB (size = width * height * 3)
436
+ if pixel_data_size % 3 == 0:
437
+ pixels = pixel_data_size // 3
438
+ sqrt_pixels = math.sqrt(pixels)
439
+ if sqrt_pixels == int(sqrt_pixels):
440
+ dimension = int(sqrt_pixels)
441
+ logger.info(
442
+ f"Pixel data size {pixel_data_size} suggests {dimension}x{dimension} RGB"
443
+ )
444
+ pixel_data = np.frombuffer(frame.data[FRAME_METADATA_SIZE:], dtype=np.uint8)
445
+ return pixel_data.reshape((dimension, dimension, 3)) # type: ignore[no-any-return]
446
+
447
+ # Check for square RGBA (size = width * height * 4)
448
+ if pixel_data_size % 4 == 0:
449
+ pixels = pixel_data_size // 4
450
+ sqrt_pixels = math.sqrt(pixels)
451
+ if sqrt_pixels == int(sqrt_pixels):
452
+ dimension = int(sqrt_pixels)
453
+ logger.info(
454
+ f"Pixel data size {pixel_data_size} suggests {dimension}x{dimension} RGBA"
455
+ )
456
+ pixel_data = np.frombuffer(frame.data[FRAME_METADATA_SIZE:], dtype=np.uint8)
457
+ return pixel_data.reshape((dimension, dimension, 4)) # type: ignore[no-any-return]
458
+
459
+ common_resolutions = [
460
+ (640, 480, 3), # VGA RGB
461
+ (640, 480, 4), # VGA RGBA
462
+ (1280, 720, 3), # 720p RGB
463
+ (1920, 1080, 3), # 1080p RGB
464
+ (640, 480, 1), # VGA Grayscale
465
+ ]
466
+
467
+ for width, height, channels in common_resolutions:
468
+ if pixel_data_size == width * height * channels:
469
+ logger.info(f"Inferred resolution: {width}x{height} with {channels} channels")
470
+
471
+ # Create caps for future use
472
+ format_str = "RGB" if channels == 3 else "RGBA" if channels == 4 else "GRAY8"
473
+ self._gst_caps = GstCaps.from_simple(
474
+ width=width, height=height, format=format_str
475
+ )
476
+
477
+ # Create Mat from pixel data (skip 16-byte FrameMetadata prefix)
478
+ pixel_data = np.frombuffer(frame.data[FRAME_METADATA_SIZE:], dtype=np.uint8)
479
+ if channels == 3:
480
+ return pixel_data.reshape((height, width, 3)) # type: ignore[no-any-return]
481
+ elif channels == 1:
482
+ return pixel_data.reshape((height, width)) # type: ignore[no-any-return]
483
+ elif channels == 4:
484
+ return pixel_data.reshape((height, width, 4)) # type: ignore[no-any-return]
485
+
486
+ logger.error(f"Could not infer resolution for pixel data size {pixel_data_size}")
487
+ return None
488
+
489
+ except Exception as e:
490
+ logger.error("Failed to convert frame to Mat: %s", e)
491
+ return None
492
+
493
+ def _parse_metadata_json(self, metadata_bytes: bytes | memoryview) -> GstMetadata | None:
494
+ """
495
+ Parse metadata JSON from bytes, handling null padding and boundaries.
496
+
497
+ Args:
498
+ metadata_bytes: Raw metadata bytes or memoryview
499
+
500
+ Returns:
501
+ GstMetadata object or None if parsing fails
502
+ """
503
+ try:
504
+ # Convert to string
505
+ if isinstance(metadata_bytes, memoryview):
506
+ metadata_bytes = bytes(metadata_bytes)
507
+ metadata_str = metadata_bytes.decode("utf-8")
508
+
509
+ # Find JSON boundaries (handle null padding)
510
+ json_start = metadata_str.find("{")
511
+ if json_start < 0:
512
+ logger.debug("No JSON found in metadata")
513
+ return None
514
+
515
+ json_end = metadata_str.rfind("}")
516
+ if json_end <= json_start:
517
+ logger.debug("Invalid JSON boundaries in metadata")
518
+ return None
519
+
520
+ # Extract JSON
521
+ metadata_str = metadata_str[json_start : json_end + 1]
522
+
523
+ # Parse JSON
524
+ metadata_json = json.loads(metadata_str)
525
+ metadata = GstMetadata.from_json(metadata_json)
526
+ return metadata
527
+
528
+ except Exception as e:
529
+ logger.debug("Failed to parse metadata JSON: %s", e)
530
+ return None
531
+
532
+ def _infer_caps_from_frame(self, mat: Mat) -> None: # type: ignore[valid-type]
533
+ """
534
+ Infer GStreamer caps from OpenCV Mat.
535
+
536
+ Args:
537
+ mat: OpenCV Mat
538
+ """
539
+ if mat is None:
540
+ return
541
+
542
+ shape = mat.shape
543
+ if len(shape) == 2:
544
+ # Grayscale
545
+ self._gst_caps = GstCaps.from_simple(width=shape[1], height=shape[0], format="GRAY8")
546
+ elif len(shape) == 3:
547
+ # Color image
548
+ self._gst_caps = GstCaps.from_simple(width=shape[1], height=shape[0], format="BGR")
549
+
550
+ logger.info("Inferred caps from frame: %s", self._gst_caps)
551
+
552
+
553
+ class DuplexShmController(IController):
554
+ """
555
+ Duplex shared memory controller for bidirectional video streaming.
556
+
557
+ This controller supports both receiving frames from one buffer and
558
+ sending processed frames to another buffer.
559
+ """
560
+
561
+ def __init__(self, connection: ConnectionString):
562
+ """
563
+ Initialize the duplex controller.
564
+
565
+ Args:
566
+ connection: Connection string configuration
567
+ """
568
+ if connection.protocol != Protocol.SHM:
569
+ raise ValueError(
570
+ f"DuplexShmController requires SHM protocol, got {connection.protocol}"
571
+ )
572
+
573
+ if connection.connection_mode != ConnectionMode.DUPLEX:
574
+ raise ValueError(
575
+ f"DuplexShmController requires DUPLEX mode, got {connection.connection_mode}"
576
+ )
577
+
578
+ self._connection = connection
579
+ self._duplex_server: Optional[IImmutableDuplexServer] = None
580
+ self._gst_caps: Optional[GstCaps] = None
581
+ self._metadata: Optional[GstMetadata] = None
582
+ self._is_running = False
583
+ self._on_frame_callback: Optional[Callable[[FrameMetadata, Mat, Mat], None]] = None # type: ignore[valid-type]
584
+ self._frame_count = 0
585
+
586
+ @property
587
+ def is_running(self) -> bool:
588
+ """Check if the controller is running."""
589
+ return self._is_running
590
+
591
+ def get_metadata(self) -> Optional[GstMetadata]:
592
+ """Get the current GStreamer metadata."""
593
+ return self._metadata
594
+
595
+ def start(
596
+ self,
597
+ on_frame: Callable[[FrameMetadata, Mat, Mat], None], # type: ignore[override,valid-type]
598
+ cancellation_token: Optional[threading.Event] = None,
599
+ ) -> None:
600
+ """
601
+ Start duplex frame processing with FrameMetadata.
602
+
603
+ The callback receives FrameMetadata (frame number, timestamp, dimensions),
604
+ input Mat, and output Mat. The 24-byte metadata prefix is stripped from
605
+ the frame data before creating the input Mat.
606
+
607
+ Args:
608
+ on_frame: Callback that receives (FrameMetadata, input_mat, output_mat)
609
+ cancellation_token: Optional cancellation token
610
+ """
611
+ if self._is_running:
612
+ raise RuntimeError("Controller is already running")
613
+
614
+ self._is_running = True
615
+ self._on_frame_callback = on_frame
616
+
617
+ # Create buffer configuration
618
+ config = BufferConfig(
619
+ metadata_size=int(self._connection.metadata_size),
620
+ payload_size=int(self._connection.buffer_size),
621
+ )
622
+
623
+ # Create duplex server using factory
624
+ if not self._connection.buffer_name:
625
+ raise ValueError("Buffer name is required for shared memory connection")
626
+ timeout_seconds = self._connection.timeout_ms / 1000.0
627
+ logger.debug(
628
+ "Creating duplex server with timeout: %d ms (%.1f seconds)",
629
+ self._connection.timeout_ms,
630
+ timeout_seconds,
631
+ )
632
+ factory = DuplexChannelFactory()
633
+ self._duplex_server = factory.create_immutable_server(
634
+ self._connection.buffer_name, config, timeout_seconds
635
+ )
636
+
637
+ logger.info(
638
+ "Starting duplex server for channel '%s' with size %s and metadata %s",
639
+ self._connection.buffer_name,
640
+ self._connection.buffer_size,
641
+ self._connection.metadata_size,
642
+ )
643
+
644
+ # Start server with frame processor callback
645
+ if self._duplex_server:
646
+ self._duplex_server.start(self._process_duplex_frame, self._on_metadata)
647
+
648
+ def stop(self) -> None:
649
+ """Stop the controller and clean up resources."""
650
+ if not self._is_running:
651
+ return
652
+
653
+ logger.info("Stopping DuplexShmController")
654
+ self._is_running = False
655
+
656
+ # Stop the duplex server
657
+ if self._duplex_server:
658
+ self._duplex_server.stop()
659
+ self._duplex_server = None
660
+
661
+ logger.info("DuplexShmController stopped")
662
+
663
+ def _parse_metadata_json(self, metadata_bytes: bytes | memoryview) -> GstMetadata | None:
664
+ """
665
+ Parse metadata JSON from bytes, handling null padding and boundaries.
666
+
667
+ Args:
668
+ metadata_bytes: Raw metadata bytes or memoryview
669
+
670
+ Returns:
671
+ GstMetadata object or None if parsing fails
672
+ """
673
+ try:
674
+ # Convert to string
675
+ if isinstance(metadata_bytes, memoryview):
676
+ metadata_bytes = bytes(metadata_bytes)
677
+ metadata_str = metadata_bytes.decode("utf-8")
678
+
679
+ # Find JSON boundaries (handle null padding)
680
+ json_start = metadata_str.find("{")
681
+ if json_start < 0:
682
+ logger.debug("No JSON found in metadata")
683
+ return None
684
+
685
+ json_end = metadata_str.rfind("}")
686
+ if json_end <= json_start:
687
+ logger.debug("Invalid JSON boundaries in metadata")
688
+ return None
689
+
690
+ # Extract JSON
691
+ metadata_str = metadata_str[json_start : json_end + 1]
692
+
693
+ # Parse JSON
694
+ metadata_json = json.loads(metadata_str)
695
+ metadata = GstMetadata.from_json(metadata_json)
696
+ return metadata
697
+ except Exception as e:
698
+ logger.debug("Failed to parse metadata JSON: %s", e)
699
+ return None
700
+
701
+ def _on_metadata(self, metadata_bytes: bytes | memoryview) -> None:
702
+ """
703
+ Handle metadata from duplex channel.
704
+
705
+ Args:
706
+ metadata_bytes: Raw metadata bytes or memoryview
707
+ """
708
+ logger.debug(
709
+ "_on_metadata called with %d bytes", len(metadata_bytes) if metadata_bytes else 0
710
+ )
711
+ try:
712
+ # Log raw bytes for debugging
713
+ logger.debug(
714
+ "Raw metadata bytes (first 100): %r",
715
+ metadata_bytes[: min(100, len(metadata_bytes))],
716
+ )
717
+
718
+ # Use helper method to parse metadata
719
+ metadata = self._parse_metadata_json(metadata_bytes)
720
+ if metadata:
721
+ self._metadata = metadata
722
+ self._gst_caps = metadata.caps
723
+ logger.info("Received metadata: %s", self._metadata)
724
+ else:
725
+ logger.warning("Failed to parse metadata from buffer initialization")
726
+ except Exception as e:
727
+ logger.error("Failed to parse metadata: %s", e, exc_info=True)
728
+
729
+ def _process_duplex_frame(self, request_frame: Frame, response_writer: Writer) -> None:
730
+ """
731
+ Process a frame in duplex mode with FrameMetadata.
732
+
733
+ The frame data has a 24-byte FrameMetadata prefix that is stripped
734
+ before creating the input Mat.
735
+
736
+ Args:
737
+ request_frame: Input frame from the request (with metadata prefix)
738
+ response_writer: Writer for the response frame
739
+ """
740
+ try:
741
+ if not self._on_frame_callback:
742
+ logger.warning("No frame callback set")
743
+ return
744
+
745
+ # Check frame size is sufficient for metadata
746
+ if request_frame.size < FRAME_METADATA_SIZE:
747
+ logger.warning("Frame too small for FrameMetadata: %d bytes", request_frame.size)
748
+ return
749
+
750
+ self._frame_count += 1
751
+
752
+ # Parse FrameMetadata from the beginning of the frame
753
+ frame_metadata = FrameMetadata.from_bytes(request_frame.data)
754
+
755
+ # Calculate pixel data offset and size
756
+ pixel_data_offset = FRAME_METADATA_SIZE
757
+ pixel_data_size = request_frame.size - FRAME_METADATA_SIZE
758
+
759
+ # GstCaps must be available for width/height/format
760
+ # (FrameMetadata no longer contains these - they're stream-level, not per-frame)
761
+ if not self._gst_caps:
762
+ logger.warning(
763
+ "GstCaps not available, skipping frame %d", frame_metadata.frame_number
764
+ )
765
+ return
766
+
767
+ width = self._gst_caps.width
768
+ height = self._gst_caps.height
769
+ format_str = self._gst_caps.format
770
+
771
+ # Determine channels from format
772
+ if format_str in ["RGB", "BGR"]:
773
+ channels = 3
774
+ elif format_str in ["RGBA", "BGRA", "ARGB", "ABGR"]:
775
+ channels = 4
776
+ elif format_str in ["GRAY8", "GRAY16_LE", "GRAY16_BE"]:
777
+ channels = 1
778
+ else:
779
+ channels = 3 # Default to RGB
780
+
781
+ # Create input Mat from pixel data (after metadata prefix)
782
+ pixel_data = np.frombuffer(request_frame.data[pixel_data_offset:], dtype=np.uint8)
783
+
784
+ expected_size = height * width * channels
785
+ if len(pixel_data) != expected_size:
786
+ logger.error(
787
+ "Pixel data size mismatch. Expected %d bytes for %dx%d with %d channels, got %d",
788
+ expected_size,
789
+ width,
790
+ height,
791
+ channels,
792
+ len(pixel_data),
793
+ )
794
+ return
795
+
796
+ # Reshape to image dimensions
797
+ if channels == 1:
798
+ input_mat = pixel_data.reshape((height, width))
799
+ else:
800
+ input_mat = pixel_data.reshape((height, width, channels))
801
+
802
+ # Response doesn't need metadata prefix - just pixel data
803
+ with response_writer.get_frame_buffer(pixel_data_size) as output_buffer:
804
+ # Create output Mat from buffer (zero-copy)
805
+ output_data = np.frombuffer(output_buffer, dtype=np.uint8)
806
+ if channels == 1:
807
+ output_mat = output_data.reshape((height, width))
808
+ else:
809
+ output_mat = output_data.reshape((height, width, channels))
810
+
811
+ # Call user's processing function with metadata
812
+ self._on_frame_callback(frame_metadata, input_mat, output_mat)
813
+
814
+ # Commit the response frame after buffer is released
815
+ response_writer.commit_frame()
816
+
817
+ logger.debug(
818
+ "Processed duplex frame %d (%dx%d %s)",
819
+ frame_metadata.frame_number,
820
+ width,
821
+ height,
822
+ format_str,
823
+ )
824
+
825
+ except Exception as e:
826
+ logger.error("Error processing duplex frame: %s", e)
827
+
828
+ def _frame_to_mat(self, frame: Frame) -> Optional[Mat]: # type: ignore[valid-type]
829
+ """Convert frame to OpenCV Mat (reuse from OneWayShmController)."""
830
+ # Implementation is same as OneWayShmController
831
+ return OneWayShmController._create_mat_from_frame(self, frame) # type: ignore[arg-type]