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.
- rocket_welder_sdk/__init__.py +95 -0
- rocket_welder_sdk/bytes_size.py +234 -0
- rocket_welder_sdk/connection_string.py +291 -0
- rocket_welder_sdk/controllers.py +831 -0
- rocket_welder_sdk/external_controls/__init__.py +30 -0
- rocket_welder_sdk/external_controls/contracts.py +100 -0
- rocket_welder_sdk/external_controls/contracts_old.py +105 -0
- rocket_welder_sdk/frame_metadata.py +138 -0
- rocket_welder_sdk/gst_metadata.py +411 -0
- rocket_welder_sdk/high_level/__init__.py +54 -0
- rocket_welder_sdk/high_level/client.py +235 -0
- rocket_welder_sdk/high_level/connection_strings.py +331 -0
- rocket_welder_sdk/high_level/data_context.py +169 -0
- rocket_welder_sdk/high_level/frame_sink_factory.py +118 -0
- rocket_welder_sdk/high_level/schema.py +195 -0
- rocket_welder_sdk/high_level/transport_protocol.py +238 -0
- rocket_welder_sdk/keypoints_protocol.py +642 -0
- rocket_welder_sdk/opencv_controller.py +278 -0
- rocket_welder_sdk/periodic_timer.py +303 -0
- rocket_welder_sdk/py.typed +2 -0
- rocket_welder_sdk/rocket_welder_client.py +497 -0
- rocket_welder_sdk/segmentation_result.py +420 -0
- rocket_welder_sdk/session_id.py +238 -0
- rocket_welder_sdk/transport/__init__.py +31 -0
- rocket_welder_sdk/transport/frame_sink.py +122 -0
- rocket_welder_sdk/transport/frame_source.py +74 -0
- rocket_welder_sdk/transport/nng_transport.py +197 -0
- rocket_welder_sdk/transport/stream_transport.py +193 -0
- rocket_welder_sdk/transport/tcp_transport.py +154 -0
- rocket_welder_sdk/transport/unix_socket_transport.py +339 -0
- rocket_welder_sdk/ui/__init__.py +48 -0
- rocket_welder_sdk/ui/controls.py +362 -0
- rocket_welder_sdk/ui/icons.py +21628 -0
- rocket_welder_sdk/ui/ui_events_projection.py +226 -0
- rocket_welder_sdk/ui/ui_service.py +358 -0
- rocket_welder_sdk/ui/value_types.py +72 -0
- rocket_welder_sdk-1.1.36.dev14.dist-info/METADATA +845 -0
- rocket_welder_sdk-1.1.36.dev14.dist-info/RECORD +40 -0
- rocket_welder_sdk-1.1.36.dev14.dist-info/WHEEL +5 -0
- 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]
|