rocket-welder-sdk 1.1.0__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.
- rocket_welder_sdk/__init__.py +22 -0
- rocket_welder_sdk/controllers.py +125 -66
- rocket_welder_sdk/gst_metadata.py +315 -144
- rocket_welder_sdk/rocket_welder_client.py +10 -13
- {rocket_welder_sdk-1.1.0.dist-info → rocket_welder_sdk-1.1.25.dist-info}/METADATA +3 -2
- rocket_welder_sdk-1.1.25.dist-info/RECORD +11 -0
- rocket_welder_sdk-1.1.0.dist-info/RECORD +0 -11
- {rocket_welder_sdk-1.1.0.dist-info → rocket_welder_sdk-1.1.25.dist-info}/WHEEL +0 -0
- {rocket_welder_sdk-1.1.0.dist-info → rocket_welder_sdk-1.1.25.dist-info}/top_level.txt +0 -0
rocket_welder_sdk/__init__.py
CHANGED
|
@@ -4,6 +4,9 @@ RocketWelder SDK - Enterprise-grade Python client library for video streaming se
|
|
|
4
4
|
High-performance video streaming using shared memory (ZeroBuffer) for zero-copy operations.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
|
|
7
10
|
from .bytes_size import BytesSize
|
|
8
11
|
from .connection_string import ConnectionMode, ConnectionString, Protocol
|
|
9
12
|
from .controllers import DuplexShmController, IController, OneWayShmController
|
|
@@ -15,6 +18,25 @@ Client = RocketWelderClient
|
|
|
15
18
|
|
|
16
19
|
__version__ = "1.1.0"
|
|
17
20
|
|
|
21
|
+
# Configure library logger with NullHandler (best practice for libraries)
|
|
22
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
23
|
+
|
|
24
|
+
# Configure from environment variable and propagate to zerobuffer
|
|
25
|
+
_log_level = os.environ.get("ROCKET_WELDER_LOG_LEVEL")
|
|
26
|
+
if _log_level:
|
|
27
|
+
try:
|
|
28
|
+
# Set rocket-welder-sdk log level
|
|
29
|
+
logging.getLogger(__name__).setLevel(getattr(logging, _log_level.upper()))
|
|
30
|
+
|
|
31
|
+
# Propagate to zerobuffer if not already set
|
|
32
|
+
if not os.environ.get("ZEROBUFFER_LOG_LEVEL"):
|
|
33
|
+
os.environ["ZEROBUFFER_LOG_LEVEL"] = _log_level
|
|
34
|
+
# Also configure zerobuffer logger if already imported
|
|
35
|
+
zerobuffer_logger = logging.getLogger("zerobuffer")
|
|
36
|
+
zerobuffer_logger.setLevel(getattr(logging, _log_level.upper()))
|
|
37
|
+
except AttributeError:
|
|
38
|
+
pass # Invalid log level, ignore
|
|
39
|
+
|
|
18
40
|
__all__ = [
|
|
19
41
|
# Core types
|
|
20
42
|
"BytesSize",
|
rocket_welder_sdk/controllers.py
CHANGED
|
@@ -14,6 +14,7 @@ from typing import Any, Callable
|
|
|
14
14
|
import numpy as np
|
|
15
15
|
from zerobuffer import BufferConfig, Frame, Reader, Writer
|
|
16
16
|
from zerobuffer.duplex import DuplexChannelFactory, IImmutableDuplexServer
|
|
17
|
+
from zerobuffer.exceptions import WriterDeadException
|
|
17
18
|
|
|
18
19
|
from .connection_string import ConnectionMode, ConnectionString, Protocol
|
|
19
20
|
from .gst_metadata import GstCaps, GstMetadata
|
|
@@ -21,6 +22,9 @@ from .gst_metadata import GstCaps, GstMetadata
|
|
|
21
22
|
# Type alias for OpenCV Mat
|
|
22
23
|
Mat = np.ndarray[Any, Any]
|
|
23
24
|
|
|
25
|
+
# Module logger
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
24
28
|
|
|
25
29
|
class IController(ABC):
|
|
26
30
|
"""Abstract base class for controllers."""
|
|
@@ -63,13 +67,12 @@ class OneWayShmController(IController):
|
|
|
63
67
|
as a zerosink, allowing zero-copy frame reception.
|
|
64
68
|
"""
|
|
65
69
|
|
|
66
|
-
def __init__(self, connection: ConnectionString
|
|
70
|
+
def __init__(self, connection: ConnectionString):
|
|
67
71
|
"""
|
|
68
72
|
Initialize the one-way controller.
|
|
69
73
|
|
|
70
74
|
Args:
|
|
71
75
|
connection: Connection string configuration
|
|
72
|
-
logger: Optional logger instance
|
|
73
76
|
"""
|
|
74
77
|
if connection.protocol != Protocol.SHM:
|
|
75
78
|
raise ValueError(
|
|
@@ -77,8 +80,6 @@ class OneWayShmController(IController):
|
|
|
77
80
|
)
|
|
78
81
|
|
|
79
82
|
self._connection = connection
|
|
80
|
-
self._logger = logger or logging.getLogger(__name__)
|
|
81
|
-
self._reader_logger = logging.getLogger(f"{__name__}.Reader")
|
|
82
83
|
self._reader: Reader | None = None
|
|
83
84
|
self._gst_caps: GstCaps | None = None
|
|
84
85
|
self._metadata: GstMetadata | None = None
|
|
@@ -108,9 +109,7 @@ class OneWayShmController(IController):
|
|
|
108
109
|
if self._is_running:
|
|
109
110
|
raise RuntimeError("Controller is already running")
|
|
110
111
|
|
|
111
|
-
self.
|
|
112
|
-
"Starting OneWayShmController for buffer '%s'", self._connection.buffer_name
|
|
113
|
-
)
|
|
112
|
+
logger.debug("Starting OneWayShmController for buffer '%s'", self._connection.buffer_name)
|
|
114
113
|
self._is_running = True
|
|
115
114
|
self._cancellation_token = cancellation_token
|
|
116
115
|
|
|
@@ -124,9 +123,9 @@ class OneWayShmController(IController):
|
|
|
124
123
|
# Pass logger to Reader for better debugging
|
|
125
124
|
if not self._connection.buffer_name:
|
|
126
125
|
raise ValueError("Buffer name is required for shared memory connection")
|
|
127
|
-
self._reader = Reader(self._connection.buffer_name, config
|
|
126
|
+
self._reader = Reader(self._connection.buffer_name, config)
|
|
128
127
|
|
|
129
|
-
|
|
128
|
+
logger.info(
|
|
130
129
|
"Created shared memory buffer '%s' with size %s and metadata %s",
|
|
131
130
|
self._connection.buffer_name,
|
|
132
131
|
self._connection.buffer_size,
|
|
@@ -146,7 +145,7 @@ class OneWayShmController(IController):
|
|
|
146
145
|
if not self._is_running:
|
|
147
146
|
return
|
|
148
147
|
|
|
149
|
-
|
|
148
|
+
logger.debug("Stopping controller for buffer '%s'", self._connection.buffer_name)
|
|
150
149
|
self._is_running = False
|
|
151
150
|
|
|
152
151
|
# Wait for worker thread to finish
|
|
@@ -160,7 +159,7 @@ class OneWayShmController(IController):
|
|
|
160
159
|
self._reader = None
|
|
161
160
|
|
|
162
161
|
self._worker_thread = None
|
|
163
|
-
|
|
162
|
+
logger.info("Stopped controller for buffer '%s'", self._connection.buffer_name)
|
|
164
163
|
|
|
165
164
|
def _process_frames(self, on_frame: Callable[[Mat], None]) -> None:
|
|
166
165
|
"""
|
|
@@ -193,35 +192,39 @@ class OneWayShmController(IController):
|
|
|
193
192
|
if mat is not None:
|
|
194
193
|
on_frame(mat)
|
|
195
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
|
|
196
203
|
except Exception as e:
|
|
197
204
|
# Log specific error types like C#
|
|
198
205
|
error_type = type(e).__name__
|
|
199
|
-
if "ReaderDead" in error_type
|
|
200
|
-
|
|
201
|
-
"
|
|
206
|
+
if "ReaderDead" in error_type:
|
|
207
|
+
logger.info(
|
|
208
|
+
"Reader disconnected from buffer '%s'", self._connection.buffer_name
|
|
202
209
|
)
|
|
203
210
|
self._is_running = False
|
|
204
211
|
break
|
|
205
212
|
elif "BufferFull" in error_type:
|
|
206
|
-
self.
|
|
207
|
-
"Buffer full on '%s': %s", self._connection.buffer_name, e
|
|
208
|
-
)
|
|
213
|
+
logger.error("Buffer full on '%s': %s", self._connection.buffer_name, e)
|
|
209
214
|
if not self._is_running:
|
|
210
215
|
break
|
|
211
216
|
elif "FrameTooLarge" in error_type:
|
|
212
|
-
self.
|
|
213
|
-
"Frame too large on '%s': %s", self._connection.buffer_name, e
|
|
214
|
-
)
|
|
217
|
+
logger.error("Frame too large on '%s': %s", self._connection.buffer_name, e)
|
|
215
218
|
if not self._is_running:
|
|
216
219
|
break
|
|
217
220
|
elif "ZeroBuffer" in error_type:
|
|
218
|
-
|
|
221
|
+
logger.error(
|
|
219
222
|
"ZeroBuffer error on '%s': %s", self._connection.buffer_name, e
|
|
220
223
|
)
|
|
221
224
|
if not self._is_running:
|
|
222
225
|
break
|
|
223
226
|
else:
|
|
224
|
-
|
|
227
|
+
logger.error(
|
|
225
228
|
"Unexpected error processing frame from buffer '%s': %s",
|
|
226
229
|
self._connection.buffer_name,
|
|
227
230
|
e,
|
|
@@ -230,7 +233,7 @@ class OneWayShmController(IController):
|
|
|
230
233
|
break
|
|
231
234
|
|
|
232
235
|
except Exception as e:
|
|
233
|
-
|
|
236
|
+
logger.error("Fatal error in frame processing loop: %s", e)
|
|
234
237
|
self._is_running = False
|
|
235
238
|
|
|
236
239
|
def _on_first_frame(self, on_frame: Callable[[Mat], None]) -> None:
|
|
@@ -258,53 +261,53 @@ class OneWayShmController(IController):
|
|
|
258
261
|
if metadata_bytes:
|
|
259
262
|
try:
|
|
260
263
|
# Log raw metadata for debugging
|
|
261
|
-
|
|
264
|
+
logger.debug(
|
|
262
265
|
"Raw metadata: %d bytes, type=%s, first 100 bytes: %s",
|
|
263
266
|
len(metadata_bytes),
|
|
264
267
|
type(metadata_bytes),
|
|
265
|
-
bytes(metadata_bytes[:min(100, len(metadata_bytes))]),
|
|
268
|
+
bytes(metadata_bytes[: min(100, len(metadata_bytes))]),
|
|
266
269
|
)
|
|
267
|
-
|
|
270
|
+
|
|
268
271
|
# Convert memoryview to bytes if needed
|
|
269
272
|
if isinstance(metadata_bytes, memoryview):
|
|
270
273
|
metadata_bytes = bytes(metadata_bytes)
|
|
271
|
-
|
|
274
|
+
|
|
272
275
|
# Decode UTF-8
|
|
273
276
|
metadata_str = metadata_bytes.decode("utf-8")
|
|
274
|
-
|
|
277
|
+
|
|
275
278
|
# Check if metadata is empty or all zeros
|
|
276
|
-
if not metadata_str or metadata_str ==
|
|
277
|
-
|
|
279
|
+
if not metadata_str or metadata_str == "\x00" * len(metadata_str):
|
|
280
|
+
logger.warning("Metadata is empty or all zeros, skipping")
|
|
278
281
|
continue
|
|
279
|
-
|
|
282
|
+
|
|
280
283
|
# Find the start of JSON (skip any null bytes at the beginning)
|
|
281
|
-
json_start = metadata_str.find(
|
|
284
|
+
json_start = metadata_str.find("{")
|
|
282
285
|
if json_start == -1:
|
|
283
|
-
|
|
286
|
+
logger.warning("No JSON found in metadata: %r", metadata_str[:100])
|
|
284
287
|
continue
|
|
285
|
-
|
|
288
|
+
|
|
286
289
|
if json_start > 0:
|
|
287
|
-
|
|
290
|
+
logger.debug("Skipping %d bytes before JSON", json_start)
|
|
288
291
|
metadata_str = metadata_str[json_start:]
|
|
289
|
-
|
|
292
|
+
|
|
290
293
|
# Find the end of JSON (handle null padding)
|
|
291
|
-
json_end = metadata_str.rfind(
|
|
294
|
+
json_end = metadata_str.rfind("}")
|
|
292
295
|
if json_end != -1 and json_end < len(metadata_str) - 1:
|
|
293
|
-
metadata_str = metadata_str[:json_end + 1]
|
|
294
|
-
|
|
296
|
+
metadata_str = metadata_str[: json_end + 1]
|
|
297
|
+
|
|
295
298
|
metadata_json = json.loads(metadata_str)
|
|
296
299
|
self._metadata = GstMetadata.from_json(metadata_json)
|
|
297
300
|
self._gst_caps = self._metadata.caps
|
|
298
|
-
|
|
301
|
+
logger.info(
|
|
299
302
|
"Received metadata from buffer '%s': %s",
|
|
300
303
|
self._connection.buffer_name,
|
|
301
304
|
self._gst_caps,
|
|
302
305
|
)
|
|
303
306
|
except Exception as e:
|
|
304
|
-
|
|
307
|
+
logger.error("Failed to parse metadata: %s", e)
|
|
305
308
|
# Log the actual metadata content for debugging
|
|
306
309
|
if metadata_bytes:
|
|
307
|
-
|
|
310
|
+
logger.debug("Metadata content: %r", metadata_bytes[:200])
|
|
308
311
|
# Don't continue without metadata
|
|
309
312
|
continue
|
|
310
313
|
|
|
@@ -314,17 +317,24 @@ class OneWayShmController(IController):
|
|
|
314
317
|
on_frame(mat)
|
|
315
318
|
return # Successfully processed first frame
|
|
316
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
|
|
317
327
|
except Exception as e:
|
|
318
328
|
error_type = type(e).__name__
|
|
319
|
-
if "ReaderDead" in error_type
|
|
329
|
+
if "ReaderDead" in error_type:
|
|
320
330
|
self._is_running = False
|
|
321
|
-
|
|
322
|
-
"
|
|
331
|
+
logger.info(
|
|
332
|
+
"Reader disconnected while waiting for first frame on buffer '%s'",
|
|
323
333
|
self._connection.buffer_name,
|
|
324
334
|
)
|
|
325
335
|
raise
|
|
326
336
|
else:
|
|
327
|
-
|
|
337
|
+
logger.error(
|
|
328
338
|
"Error waiting for first frame on buffer '%s': %s",
|
|
329
339
|
self._connection.buffer_name,
|
|
330
340
|
e,
|
|
@@ -367,7 +377,7 @@ class OneWayShmController(IController):
|
|
|
367
377
|
# Check data size matches expected
|
|
368
378
|
expected_size = height * width * channels
|
|
369
379
|
if len(data) != expected_size:
|
|
370
|
-
|
|
380
|
+
logger.error(
|
|
371
381
|
"Data size mismatch. Expected %d bytes for %dx%d with %d channels, got %d",
|
|
372
382
|
expected_size,
|
|
373
383
|
width,
|
|
@@ -386,17 +396,48 @@ class OneWayShmController(IController):
|
|
|
386
396
|
elif channels == 4:
|
|
387
397
|
mat = data.reshape((height, width, 4))
|
|
388
398
|
else:
|
|
389
|
-
|
|
399
|
+
logger.error("Unsupported channel count: %d", channels)
|
|
390
400
|
return None
|
|
391
401
|
|
|
392
402
|
return mat # type: ignore[no-any-return]
|
|
393
403
|
|
|
394
|
-
# No caps available
|
|
395
|
-
|
|
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}")
|
|
396
437
|
return None
|
|
397
438
|
|
|
398
439
|
except Exception as e:
|
|
399
|
-
|
|
440
|
+
logger.error("Failed to convert frame to Mat: %s", e)
|
|
400
441
|
return None
|
|
401
442
|
|
|
402
443
|
def _infer_caps_from_frame(self, mat: Mat) -> None:
|
|
@@ -412,12 +453,12 @@ class OneWayShmController(IController):
|
|
|
412
453
|
shape = mat.shape
|
|
413
454
|
if len(shape) == 2:
|
|
414
455
|
# Grayscale
|
|
415
|
-
self._gst_caps = GstCaps(
|
|
456
|
+
self._gst_caps = GstCaps.from_simple(width=shape[1], height=shape[0], format="GRAY8")
|
|
416
457
|
elif len(shape) == 3:
|
|
417
458
|
# Color image
|
|
418
|
-
self._gst_caps = GstCaps(
|
|
459
|
+
self._gst_caps = GstCaps.from_simple(width=shape[1], height=shape[0], format="BGR")
|
|
419
460
|
|
|
420
|
-
|
|
461
|
+
logger.info("Inferred caps from frame: %s", self._gst_caps)
|
|
421
462
|
|
|
422
463
|
|
|
423
464
|
class DuplexShmController(IController):
|
|
@@ -428,13 +469,12 @@ class DuplexShmController(IController):
|
|
|
428
469
|
sending processed frames to another buffer.
|
|
429
470
|
"""
|
|
430
471
|
|
|
431
|
-
def __init__(self, connection: ConnectionString
|
|
472
|
+
def __init__(self, connection: ConnectionString):
|
|
432
473
|
"""
|
|
433
474
|
Initialize the duplex controller.
|
|
434
475
|
|
|
435
476
|
Args:
|
|
436
477
|
connection: Connection string configuration
|
|
437
|
-
logger: Optional logger instance
|
|
438
478
|
"""
|
|
439
479
|
if connection.protocol != Protocol.SHM:
|
|
440
480
|
raise ValueError(
|
|
@@ -447,7 +487,6 @@ class DuplexShmController(IController):
|
|
|
447
487
|
)
|
|
448
488
|
|
|
449
489
|
self._connection = connection
|
|
450
|
-
self._logger = logger or logging.getLogger(__name__)
|
|
451
490
|
self._duplex_server: IImmutableDuplexServer | None = None
|
|
452
491
|
self._gst_caps: GstCaps | None = None
|
|
453
492
|
self._metadata: GstMetadata | None = None
|
|
@@ -498,7 +537,7 @@ class DuplexShmController(IController):
|
|
|
498
537
|
self._connection.buffer_name, config, timeout_seconds
|
|
499
538
|
)
|
|
500
539
|
|
|
501
|
-
|
|
540
|
+
logger.info(
|
|
502
541
|
"Starting duplex server for channel '%s' with size %s and metadata %s",
|
|
503
542
|
self._connection.buffer_name,
|
|
504
543
|
self._connection.buffer_size,
|
|
@@ -514,7 +553,7 @@ class DuplexShmController(IController):
|
|
|
514
553
|
if not self._is_running:
|
|
515
554
|
return
|
|
516
555
|
|
|
517
|
-
|
|
556
|
+
logger.info("Stopping DuplexShmController")
|
|
518
557
|
self._is_running = False
|
|
519
558
|
|
|
520
559
|
# Stop the duplex server
|
|
@@ -522,7 +561,7 @@ class DuplexShmController(IController):
|
|
|
522
561
|
self._duplex_server.stop()
|
|
523
562
|
self._duplex_server = None
|
|
524
563
|
|
|
525
|
-
|
|
564
|
+
logger.info("DuplexShmController stopped")
|
|
526
565
|
|
|
527
566
|
def _on_metadata(self, metadata_bytes: bytes | memoryview) -> None:
|
|
528
567
|
"""
|
|
@@ -531,18 +570,29 @@ class DuplexShmController(IController):
|
|
|
531
570
|
Args:
|
|
532
571
|
metadata_bytes: Raw metadata bytes or memoryview
|
|
533
572
|
"""
|
|
573
|
+
logger.debug(
|
|
574
|
+
"_on_metadata called with %d bytes", len(metadata_bytes) if metadata_bytes else 0
|
|
575
|
+
)
|
|
534
576
|
try:
|
|
535
577
|
# Convert memoryview to bytes if needed
|
|
536
578
|
if isinstance(metadata_bytes, memoryview):
|
|
537
579
|
metadata_bytes = bytes(metadata_bytes)
|
|
538
|
-
|
|
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
|
+
|
|
539
587
|
metadata_str = metadata_bytes.decode("utf-8")
|
|
588
|
+
logger.debug("Decoded metadata string: %r", metadata_str[: min(200, len(metadata_str))])
|
|
589
|
+
|
|
540
590
|
metadata_json = json.loads(metadata_str)
|
|
541
591
|
self._metadata = GstMetadata.from_json(metadata_json)
|
|
542
592
|
self._gst_caps = self._metadata.caps
|
|
543
|
-
|
|
593
|
+
logger.info("Received metadata: %s", self._metadata)
|
|
544
594
|
except Exception as e:
|
|
545
|
-
|
|
595
|
+
logger.error("Failed to parse metadata: %s", e, exc_info=True)
|
|
546
596
|
|
|
547
597
|
def _process_duplex_frame(self, request_frame: Frame, response_writer: Writer) -> None:
|
|
548
598
|
"""
|
|
@@ -552,8 +602,14 @@ class DuplexShmController(IController):
|
|
|
552
602
|
request_frame: Input frame from the request
|
|
553
603
|
response_writer: Writer for the response frame
|
|
554
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
|
+
)
|
|
555
610
|
try:
|
|
556
611
|
if not self._on_frame_callback:
|
|
612
|
+
logger.warning("No frame callback set")
|
|
557
613
|
return
|
|
558
614
|
|
|
559
615
|
self._frame_count += 1
|
|
@@ -561,6 +617,7 @@ class DuplexShmController(IController):
|
|
|
561
617
|
# Convert input frame to Mat
|
|
562
618
|
input_mat = self._frame_to_mat(request_frame)
|
|
563
619
|
if input_mat is None:
|
|
620
|
+
logger.error("Failed to convert frame to Mat, gst_caps=%s", self._gst_caps)
|
|
564
621
|
return
|
|
565
622
|
|
|
566
623
|
# Get buffer for output frame - use context manager for RAII
|
|
@@ -585,7 +642,9 @@ class DuplexShmController(IController):
|
|
|
585
642
|
)
|
|
586
643
|
else:
|
|
587
644
|
# Use same shape as input
|
|
588
|
-
output_mat = np.frombuffer(output_buffer, dtype=np.uint8).reshape(
|
|
645
|
+
output_mat = np.frombuffer(output_buffer, dtype=np.uint8).reshape(
|
|
646
|
+
input_mat.shape
|
|
647
|
+
)
|
|
589
648
|
|
|
590
649
|
# Call user's processing function
|
|
591
650
|
self._on_frame_callback(input_mat, output_mat)
|
|
@@ -593,7 +652,7 @@ class DuplexShmController(IController):
|
|
|
593
652
|
# Commit the response frame after buffer is released
|
|
594
653
|
response_writer.commit_frame()
|
|
595
654
|
|
|
596
|
-
|
|
655
|
+
logger.debug(
|
|
597
656
|
"Processed duplex frame %d (%dx%d)",
|
|
598
657
|
self._frame_count,
|
|
599
658
|
input_mat.shape[1],
|
|
@@ -601,7 +660,7 @@ class DuplexShmController(IController):
|
|
|
601
660
|
)
|
|
602
661
|
|
|
603
662
|
except Exception as e:
|
|
604
|
-
|
|
663
|
+
logger.error("Error processing duplex frame: %s", e)
|
|
605
664
|
|
|
606
665
|
def _frame_to_mat(self, frame: Frame) -> Mat | None:
|
|
607
666
|
"""Convert frame to OpenCV Mat (reuse from OneWayShmController)."""
|
|
@@ -6,9 +6,13 @@ Matches C# GstCaps and GstMetadata functionality.
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
|
-
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
13
|
+
import numpy as np
|
|
14
|
+
import numpy.typing as npt
|
|
15
|
+
|
|
12
16
|
|
|
13
17
|
@dataclass
|
|
14
18
|
class GstCaps:
|
|
@@ -16,172 +20,312 @@ class GstCaps:
|
|
|
16
20
|
GStreamer capabilities representation.
|
|
17
21
|
|
|
18
22
|
Represents video format capabilities including format, dimensions, framerate, etc.
|
|
23
|
+
Matches the C# GstCaps implementation with proper parsing and numpy integration.
|
|
19
24
|
"""
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
width: int
|
|
27
|
+
height: int
|
|
28
|
+
format: str
|
|
29
|
+
depth_type: type[np.uint8] | type[np.uint16]
|
|
30
|
+
channels: int
|
|
31
|
+
bytes_per_pixel: int
|
|
32
|
+
framerate_num: int | None = None
|
|
33
|
+
framerate_den: int | None = None
|
|
34
|
+
interlace_mode: str | None = None
|
|
27
35
|
colorimetry: str | None = None
|
|
28
|
-
|
|
36
|
+
caps_string: str | None = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def frame_size(self) -> int:
|
|
40
|
+
"""Calculate the expected frame size in bytes."""
|
|
41
|
+
return self.width * self.height * self.bytes_per_pixel
|
|
29
42
|
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
@property
|
|
44
|
+
def framerate(self) -> float | None:
|
|
45
|
+
"""Get framerate as double (FPS)."""
|
|
46
|
+
if (
|
|
47
|
+
self.framerate_num is not None
|
|
48
|
+
and self.framerate_den is not None
|
|
49
|
+
and self.framerate_den > 0
|
|
50
|
+
):
|
|
51
|
+
return self.framerate_num / self.framerate_den
|
|
52
|
+
return None
|
|
32
53
|
|
|
33
54
|
@classmethod
|
|
34
|
-
def
|
|
55
|
+
def parse(cls, caps_string: str) -> GstCaps:
|
|
35
56
|
"""
|
|
36
|
-
|
|
57
|
+
Parse GStreamer caps string.
|
|
58
|
+
Example: "video/x-raw, format=(string)RGB, width=(int)640, height=(int)480, framerate=(fraction)30/1"
|
|
37
59
|
|
|
38
60
|
Args:
|
|
39
|
-
|
|
61
|
+
caps_string: GStreamer caps string
|
|
40
62
|
|
|
41
63
|
Returns:
|
|
42
64
|
GstCaps instance
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ValueError: If caps string is invalid
|
|
43
68
|
"""
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
format=data.get("format"),
|
|
47
|
-
width=data.get("width"),
|
|
48
|
-
height=data.get("height"),
|
|
49
|
-
framerate=data.get("framerate"),
|
|
50
|
-
pixel_aspect_ratio=data.get("pixel-aspect-ratio"),
|
|
51
|
-
interlace_mode=data.get("interlace-mode"),
|
|
52
|
-
colorimetry=data.get("colorimetry"),
|
|
53
|
-
chroma_site=data.get("chroma-site"),
|
|
54
|
-
)
|
|
69
|
+
if not caps_string or not caps_string.strip():
|
|
70
|
+
raise ValueError("Empty caps string")
|
|
55
71
|
|
|
56
|
-
|
|
57
|
-
known_fields = {
|
|
58
|
-
"format",
|
|
59
|
-
"width",
|
|
60
|
-
"height",
|
|
61
|
-
"framerate",
|
|
62
|
-
"pixel-aspect-ratio",
|
|
63
|
-
"interlace-mode",
|
|
64
|
-
"colorimetry",
|
|
65
|
-
"chroma-site",
|
|
66
|
-
}
|
|
72
|
+
caps_string = caps_string.strip()
|
|
67
73
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
74
|
+
# Check if it's a video caps
|
|
75
|
+
if not caps_string.startswith("video/x-raw"):
|
|
76
|
+
raise ValueError(f"Not a video/x-raw caps string: {caps_string}")
|
|
71
77
|
|
|
72
|
-
|
|
78
|
+
try:
|
|
79
|
+
# Parse width
|
|
80
|
+
width_match = re.search(r"width=\(int\)(\d+)", caps_string)
|
|
81
|
+
if not width_match:
|
|
82
|
+
raise ValueError("Missing width in caps string")
|
|
83
|
+
width = int(width_match.group(1))
|
|
84
|
+
|
|
85
|
+
# Parse height
|
|
86
|
+
height_match = re.search(r"height=\(int\)(\d+)", caps_string)
|
|
87
|
+
if not height_match:
|
|
88
|
+
raise ValueError("Missing height in caps string")
|
|
89
|
+
height = int(height_match.group(1))
|
|
90
|
+
|
|
91
|
+
# Parse format
|
|
92
|
+
format_match = re.search(r"format=\(string\)(\w+)", caps_string)
|
|
93
|
+
format_str = format_match.group(1) if format_match else "RGB"
|
|
94
|
+
|
|
95
|
+
# Parse framerate (optional)
|
|
96
|
+
framerate_num = None
|
|
97
|
+
framerate_den = None
|
|
98
|
+
framerate_match = re.search(r"framerate=\(fraction\)(\d+)/(\d+)", caps_string)
|
|
99
|
+
if framerate_match:
|
|
100
|
+
framerate_num = int(framerate_match.group(1))
|
|
101
|
+
framerate_den = int(framerate_match.group(2))
|
|
102
|
+
|
|
103
|
+
# Parse interlace mode (optional)
|
|
104
|
+
interlace_mode = None
|
|
105
|
+
interlace_match = re.search(r"interlace-mode=\(string\)(\w+)", caps_string)
|
|
106
|
+
if interlace_match:
|
|
107
|
+
interlace_mode = interlace_match.group(1)
|
|
108
|
+
|
|
109
|
+
# Parse colorimetry (optional)
|
|
110
|
+
colorimetry = None
|
|
111
|
+
colorimetry_match = re.search(r"colorimetry=\(string\)([\w:]+)", caps_string)
|
|
112
|
+
if colorimetry_match:
|
|
113
|
+
colorimetry = colorimetry_match.group(1)
|
|
114
|
+
|
|
115
|
+
# Map format to numpy dtype and get channel info
|
|
116
|
+
depth_type, channels, bytes_per_pixel = cls._map_gstreamer_format_to_numpy(format_str)
|
|
117
|
+
|
|
118
|
+
return cls(
|
|
119
|
+
width=width,
|
|
120
|
+
height=height,
|
|
121
|
+
format=format_str,
|
|
122
|
+
depth_type=depth_type,
|
|
123
|
+
channels=channels,
|
|
124
|
+
bytes_per_pixel=bytes_per_pixel,
|
|
125
|
+
framerate_num=framerate_num,
|
|
126
|
+
framerate_den=framerate_den,
|
|
127
|
+
interlace_mode=interlace_mode,
|
|
128
|
+
colorimetry=colorimetry,
|
|
129
|
+
caps_string=caps_string,
|
|
130
|
+
)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
raise ValueError(f"Failed to parse caps string: {caps_string}") from e
|
|
73
133
|
|
|
74
134
|
@classmethod
|
|
75
|
-
def
|
|
135
|
+
def from_simple(cls, width: int, height: int, format: str = "RGB") -> GstCaps:
|
|
76
136
|
"""
|
|
77
|
-
|
|
137
|
+
Create GstCaps from simple parameters.
|
|
78
138
|
|
|
79
139
|
Args:
|
|
80
|
-
|
|
140
|
+
width: Frame width
|
|
141
|
+
height: Frame height
|
|
142
|
+
format: Pixel format (default: "RGB")
|
|
81
143
|
|
|
82
144
|
Returns:
|
|
83
145
|
GstCaps instance
|
|
84
146
|
"""
|
|
85
|
-
|
|
86
|
-
|
|
147
|
+
depth_type, channels, bytes_per_pixel = cls._map_gstreamer_format_to_numpy(format)
|
|
148
|
+
return cls(
|
|
149
|
+
width=width,
|
|
150
|
+
height=height,
|
|
151
|
+
format=format,
|
|
152
|
+
depth_type=depth_type,
|
|
153
|
+
channels=channels,
|
|
154
|
+
bytes_per_pixel=bytes_per_pixel,
|
|
155
|
+
)
|
|
87
156
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
# Parse parameters
|
|
97
|
-
data: dict[str, Any] = {}
|
|
98
|
-
for param in params.split(","):
|
|
99
|
-
if "=" in param:
|
|
100
|
-
key, value = param.split("=", 1)
|
|
101
|
-
key = key.strip()
|
|
102
|
-
value_str = value.strip()
|
|
103
|
-
|
|
104
|
-
# Try to parse numeric values
|
|
105
|
-
if value_str.isdigit():
|
|
106
|
-
data[key] = int(value_str)
|
|
107
|
-
elif key in ["width", "height"] and value_str.startswith("(int)"):
|
|
108
|
-
data[key] = int(value_str[5:].strip())
|
|
109
|
-
else:
|
|
110
|
-
data[key] = value_str
|
|
111
|
-
|
|
112
|
-
return cls.from_dict(data)
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _map_gstreamer_format_to_numpy(
|
|
159
|
+
format: str,
|
|
160
|
+
) -> tuple[type[np.uint8] | type[np.uint16], int, int]:
|
|
161
|
+
"""
|
|
162
|
+
Map GStreamer format strings to numpy dtype.
|
|
163
|
+
Reference: https://gstreamer.freedesktop.org/documentation/video/video-format.html
|
|
113
164
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
result: dict[str, Any] = {}
|
|
117
|
-
|
|
118
|
-
if self.format is not None:
|
|
119
|
-
result["format"] = self.format
|
|
120
|
-
if self.width is not None:
|
|
121
|
-
result["width"] = self.width
|
|
122
|
-
if self.height is not None:
|
|
123
|
-
result["height"] = self.height
|
|
124
|
-
if self.framerate is not None:
|
|
125
|
-
result["framerate"] = self.framerate
|
|
126
|
-
if self.pixel_aspect_ratio is not None:
|
|
127
|
-
result["pixel-aspect-ratio"] = self.pixel_aspect_ratio
|
|
128
|
-
if self.interlace_mode is not None:
|
|
129
|
-
result["interlace-mode"] = self.interlace_mode
|
|
130
|
-
if self.colorimetry is not None:
|
|
131
|
-
result["colorimetry"] = self.colorimetry
|
|
132
|
-
if self.chroma_site is not None:
|
|
133
|
-
result["chroma-site"] = self.chroma_site
|
|
165
|
+
Args:
|
|
166
|
+
format: GStreamer format string
|
|
134
167
|
|
|
135
|
-
|
|
136
|
-
|
|
168
|
+
Returns:
|
|
169
|
+
Tuple of (numpy dtype, channels, bytes_per_pixel)
|
|
170
|
+
"""
|
|
171
|
+
format_upper = format.upper() if format else "RGB"
|
|
172
|
+
|
|
173
|
+
format_map = {
|
|
174
|
+
# RGB formats
|
|
175
|
+
"RGB": (np.uint8, 3, 3),
|
|
176
|
+
"BGR": (np.uint8, 3, 3),
|
|
177
|
+
"RGBA": (np.uint8, 4, 4),
|
|
178
|
+
"BGRA": (np.uint8, 4, 4),
|
|
179
|
+
"ARGB": (np.uint8, 4, 4),
|
|
180
|
+
"ABGR": (np.uint8, 4, 4),
|
|
181
|
+
"RGBX": (np.uint8, 4, 4), # RGB with padding
|
|
182
|
+
"BGRX": (np.uint8, 4, 4), # BGR with padding
|
|
183
|
+
"XRGB": (np.uint8, 4, 4), # RGB with padding
|
|
184
|
+
"XBGR": (np.uint8, 4, 4), # BGR with padding
|
|
185
|
+
# 16-bit RGB formats
|
|
186
|
+
"RGB16": (np.uint16, 3, 6),
|
|
187
|
+
"BGR16": (np.uint16, 3, 6),
|
|
188
|
+
# Grayscale formats
|
|
189
|
+
"GRAY8": (np.uint8, 1, 1),
|
|
190
|
+
"GRAY16_LE": (np.uint16, 1, 2),
|
|
191
|
+
"GRAY16_BE": (np.uint16, 1, 2),
|
|
192
|
+
# YUV planar formats (Y plane only for simplicity)
|
|
193
|
+
"I420": (np.uint8, 1, 1),
|
|
194
|
+
"YV12": (np.uint8, 1, 1),
|
|
195
|
+
"NV12": (np.uint8, 1, 1),
|
|
196
|
+
"NV21": (np.uint8, 1, 1),
|
|
197
|
+
# YUV packed formats
|
|
198
|
+
"YUY2": (np.uint8, 2, 2),
|
|
199
|
+
"UYVY": (np.uint8, 2, 2),
|
|
200
|
+
"YVYU": (np.uint8, 2, 2),
|
|
201
|
+
# Bayer formats (raw sensor data)
|
|
202
|
+
"BGGR": (np.uint8, 1, 1),
|
|
203
|
+
"RGGB": (np.uint8, 1, 1),
|
|
204
|
+
"GRBG": (np.uint8, 1, 1),
|
|
205
|
+
"GBRG": (np.uint8, 1, 1),
|
|
206
|
+
}
|
|
137
207
|
|
|
138
|
-
|
|
208
|
+
# Default to RGB if unknown
|
|
209
|
+
return format_map.get(format_upper, (np.uint8, 3, 3))
|
|
139
210
|
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
211
|
+
def create_array(
|
|
212
|
+
self, data: bytes | memoryview | npt.NDArray[np.uint8] | npt.NDArray[np.uint16]
|
|
213
|
+
) -> npt.NDArray[np.uint8] | npt.NDArray[np.uint16]:
|
|
214
|
+
"""
|
|
215
|
+
Create numpy array with proper format from data.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
data: Frame data as bytes, memoryview, or existing numpy array
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Numpy array with proper shape and dtype
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ValueError: If data size doesn't match expected frame size
|
|
225
|
+
"""
|
|
226
|
+
# Convert memoryview to bytes if needed
|
|
227
|
+
if isinstance(data, memoryview):
|
|
228
|
+
data = bytes(data)
|
|
229
|
+
|
|
230
|
+
# If it's already a numpy array, check size and reshape if needed
|
|
231
|
+
if isinstance(data, np.ndarray):
|
|
232
|
+
if data.size * data.itemsize != self.frame_size:
|
|
233
|
+
raise ValueError(
|
|
234
|
+
f"Data size mismatch. Expected {self.frame_size} bytes for "
|
|
235
|
+
f"{self.width}x{self.height} {self.format}, got {data.size * data.itemsize}"
|
|
236
|
+
)
|
|
237
|
+
# Reshape if needed
|
|
238
|
+
if self.channels == 1:
|
|
239
|
+
return data.reshape((self.height, self.width))
|
|
240
|
+
else:
|
|
241
|
+
return data.reshape((self.height, self.width, self.channels))
|
|
242
|
+
|
|
243
|
+
# Check data size
|
|
244
|
+
if len(data) != self.frame_size:
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"Data size mismatch. Expected {self.frame_size} bytes for "
|
|
247
|
+
f"{self.width}x{self.height} {self.format}, got {len(data)}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Create array from bytes
|
|
251
|
+
arr = np.frombuffer(data, dtype=self.depth_type)
|
|
252
|
+
|
|
253
|
+
# Reshape based on channels
|
|
254
|
+
if self.channels == 1:
|
|
255
|
+
return arr.reshape((self.height, self.width))
|
|
256
|
+
else:
|
|
257
|
+
# For multi-channel images, reshape to (height, width, channels)
|
|
258
|
+
total_pixels = self.width * self.height * self.channels
|
|
259
|
+
if self.depth_type == np.uint16:
|
|
260
|
+
# For 16-bit formats, we need to account for the item size
|
|
261
|
+
arr = arr[:total_pixels]
|
|
262
|
+
return arr.reshape((self.height, self.width, self.channels))
|
|
263
|
+
|
|
264
|
+
def create_array_from_pointer(
|
|
265
|
+
self, ptr: int, copy: bool = False
|
|
266
|
+
) -> npt.NDArray[np.uint8] | npt.NDArray[np.uint16]:
|
|
267
|
+
"""
|
|
268
|
+
Create numpy array from memory pointer (zero-copy by default).
|
|
156
269
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
270
|
+
Args:
|
|
271
|
+
ptr: Memory pointer as integer
|
|
272
|
+
copy: If True, make a copy of the data; if False, create a view
|
|
160
273
|
|
|
161
|
-
|
|
162
|
-
|
|
274
|
+
Returns:
|
|
275
|
+
Numpy array with proper shape and dtype
|
|
276
|
+
"""
|
|
277
|
+
# Calculate total elements based on depth type
|
|
278
|
+
if self.depth_type == np.uint16:
|
|
279
|
+
total_elements = self.width * self.height * self.channels
|
|
163
280
|
else:
|
|
164
|
-
|
|
281
|
+
total_elements = self.frame_size
|
|
165
282
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
"""Get framerate as a tuple of (numerator, denominator)."""
|
|
169
|
-
if not self.framerate or "/" not in self.framerate:
|
|
170
|
-
return None
|
|
283
|
+
# Create array from pointer using ctypes
|
|
284
|
+
import ctypes
|
|
171
285
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return None
|
|
286
|
+
# Create a buffer from the pointer
|
|
287
|
+
buffer_size = total_elements * self.depth_type.itemsize
|
|
288
|
+
c_buffer = (ctypes.c_byte * buffer_size).from_address(ptr)
|
|
289
|
+
arr = np.frombuffer(c_buffer, dtype=self.depth_type)
|
|
177
290
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return
|
|
291
|
+
# Reshape based on channels
|
|
292
|
+
if self.channels == 1:
|
|
293
|
+
shaped = arr.reshape((self.height, self.width))
|
|
294
|
+
else:
|
|
295
|
+
shaped = arr.reshape((self.height, self.width, self.channels))
|
|
296
|
+
|
|
297
|
+
return shaped.copy() if copy else shaped
|
|
298
|
+
|
|
299
|
+
def __str__(self) -> str:
|
|
300
|
+
"""String representation."""
|
|
301
|
+
# If we have the original caps string, return it for perfect round-tripping
|
|
302
|
+
if self.caps_string:
|
|
303
|
+
return self.caps_string
|
|
304
|
+
|
|
305
|
+
# Otherwise build a simple display string
|
|
306
|
+
fps = f" @ {self.framerate:.2f}fps" if self.framerate else ""
|
|
307
|
+
return f"{self.width}x{self.height} {self.format}{fps}"
|
|
308
|
+
|
|
309
|
+
def to_dict(self) -> dict[str, Any]:
|
|
310
|
+
"""Convert to dictionary representation."""
|
|
311
|
+
result = {
|
|
312
|
+
"width": self.width,
|
|
313
|
+
"height": self.height,
|
|
314
|
+
"format": self.format,
|
|
315
|
+
"channels": self.channels,
|
|
316
|
+
"bytes_per_pixel": self.bytes_per_pixel,
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if self.framerate_num is not None:
|
|
320
|
+
result["framerate_num"] = self.framerate_num
|
|
321
|
+
if self.framerate_den is not None:
|
|
322
|
+
result["framerate_den"] = self.framerate_den
|
|
323
|
+
if self.interlace_mode:
|
|
324
|
+
result["interlace_mode"] = self.interlace_mode
|
|
325
|
+
if self.colorimetry:
|
|
326
|
+
result["colorimetry"] = self.colorimetry
|
|
327
|
+
|
|
328
|
+
return result
|
|
185
329
|
|
|
186
330
|
|
|
187
331
|
@dataclass
|
|
@@ -190,6 +334,7 @@ class GstMetadata:
|
|
|
190
334
|
GStreamer metadata structure.
|
|
191
335
|
|
|
192
336
|
Matches the JSON structure written by GStreamer plugins.
|
|
337
|
+
Compatible with C# GstMetadata record.
|
|
193
338
|
"""
|
|
194
339
|
|
|
195
340
|
type: str
|
|
@@ -207,24 +352,46 @@ class GstMetadata:
|
|
|
207
352
|
|
|
208
353
|
Returns:
|
|
209
354
|
GstMetadata instance
|
|
355
|
+
|
|
356
|
+
Raises:
|
|
357
|
+
ValueError: If JSON is invalid or missing required fields
|
|
210
358
|
"""
|
|
211
|
-
|
|
359
|
+
# Parse JSON if needed
|
|
360
|
+
if isinstance(json_data, (str, bytes)):
|
|
361
|
+
if isinstance(json_data, bytes):
|
|
362
|
+
json_data = json_data.decode("utf-8")
|
|
363
|
+
try:
|
|
364
|
+
data = json.loads(json_data)
|
|
365
|
+
except json.JSONDecodeError as e:
|
|
366
|
+
raise ValueError(f"Invalid JSON: {e}") from e
|
|
367
|
+
else:
|
|
368
|
+
data = json_data
|
|
369
|
+
|
|
370
|
+
# Validate required fields
|
|
371
|
+
if not isinstance(data, dict):
|
|
372
|
+
raise ValueError("JSON must be an object/dictionary")
|
|
373
|
+
|
|
374
|
+
# Get required fields
|
|
375
|
+
type_str = data.get("type", "")
|
|
376
|
+
version = data.get("version", "")
|
|
377
|
+
element_name = data.get("element_name", "")
|
|
212
378
|
|
|
213
|
-
# Parse caps
|
|
214
|
-
caps_data = data.get("caps"
|
|
379
|
+
# Parse caps - it's a STRING in the JSON!
|
|
380
|
+
caps_data = data.get("caps")
|
|
215
381
|
if isinstance(caps_data, str):
|
|
216
|
-
caps
|
|
382
|
+
# This is the normal case - caps is a string that needs parsing
|
|
383
|
+
caps = GstCaps.parse(caps_data)
|
|
217
384
|
elif isinstance(caps_data, dict):
|
|
218
|
-
|
|
385
|
+
# Fallback for dict format (shouldn't happen with real GStreamer)
|
|
386
|
+
# Create a simple caps from dict
|
|
387
|
+
width = caps_data.get("width", 640)
|
|
388
|
+
height = caps_data.get("height", 480)
|
|
389
|
+
format_str = caps_data.get("format", "RGB")
|
|
390
|
+
caps = GstCaps.from_simple(width, height, format_str)
|
|
219
391
|
else:
|
|
220
|
-
caps
|
|
392
|
+
raise ValueError(f"Invalid caps data type: {type(caps_data)}")
|
|
221
393
|
|
|
222
|
-
return cls(
|
|
223
|
-
type=data.get("type", ""),
|
|
224
|
-
version=data.get("version", ""),
|
|
225
|
-
caps=caps,
|
|
226
|
-
element_name=data.get("element_name", ""),
|
|
227
|
-
)
|
|
394
|
+
return cls(type=type_str, version=version, caps=caps, element_name=element_name)
|
|
228
395
|
|
|
229
396
|
def to_json(self) -> str:
|
|
230
397
|
"""Convert to JSON string."""
|
|
@@ -235,6 +402,10 @@ class GstMetadata:
|
|
|
235
402
|
return {
|
|
236
403
|
"type": self.type,
|
|
237
404
|
"version": self.version,
|
|
238
|
-
"caps": self.caps
|
|
405
|
+
"caps": str(self.caps), # Caps as string for C# compatibility
|
|
239
406
|
"element_name": self.element_name,
|
|
240
407
|
}
|
|
408
|
+
|
|
409
|
+
def __str__(self) -> str:
|
|
410
|
+
"""String representation."""
|
|
411
|
+
return f"GstMetadata(type={self.type}, element={self.element_name}, caps={self.caps})"
|
|
@@ -20,6 +20,9 @@ if TYPE_CHECKING:
|
|
|
20
20
|
# Type alias for OpenCV Mat
|
|
21
21
|
Mat = np.ndarray[Any, Any]
|
|
22
22
|
|
|
23
|
+
# Module logger
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
23
26
|
|
|
24
27
|
class RocketWelderClient:
|
|
25
28
|
"""
|
|
@@ -28,20 +31,18 @@ class RocketWelderClient:
|
|
|
28
31
|
Provides a unified interface for different connection types and protocols.
|
|
29
32
|
"""
|
|
30
33
|
|
|
31
|
-
def __init__(self, connection: str | ConnectionString
|
|
34
|
+
def __init__(self, connection: str | ConnectionString):
|
|
32
35
|
"""
|
|
33
36
|
Initialize the RocketWelder client.
|
|
34
37
|
|
|
35
38
|
Args:
|
|
36
39
|
connection: Connection string or ConnectionString object
|
|
37
|
-
logger: Optional logger instance
|
|
38
40
|
"""
|
|
39
41
|
if isinstance(connection, str):
|
|
40
42
|
self._connection = ConnectionString.parse(connection)
|
|
41
43
|
else:
|
|
42
44
|
self._connection = connection
|
|
43
45
|
|
|
44
|
-
self._logger = logger or logging.getLogger(__name__)
|
|
45
46
|
self._controller: IController | None = None
|
|
46
47
|
self._lock = threading.Lock()
|
|
47
48
|
|
|
@@ -93,15 +94,15 @@ class RocketWelderClient:
|
|
|
93
94
|
# Create appropriate controller based on connection
|
|
94
95
|
if self._connection.protocol == Protocol.SHM:
|
|
95
96
|
if self._connection.connection_mode == ConnectionMode.DUPLEX:
|
|
96
|
-
self._controller = DuplexShmController(self._connection
|
|
97
|
+
self._controller = DuplexShmController(self._connection)
|
|
97
98
|
else:
|
|
98
|
-
self._controller = OneWayShmController(self._connection
|
|
99
|
+
self._controller = OneWayShmController(self._connection)
|
|
99
100
|
else:
|
|
100
101
|
raise ValueError(f"Unsupported protocol: {self._connection.protocol}")
|
|
101
102
|
|
|
102
103
|
# Start the controller
|
|
103
104
|
self._controller.start(on_frame, cancellation_token) # type: ignore[arg-type]
|
|
104
|
-
|
|
105
|
+
logger.info("RocketWelder client started with %s", self._connection)
|
|
105
106
|
|
|
106
107
|
def stop(self) -> None:
|
|
107
108
|
"""Stop the client and clean up resources."""
|
|
@@ -109,7 +110,7 @@ class RocketWelderClient:
|
|
|
109
110
|
if self._controller:
|
|
110
111
|
self._controller.stop()
|
|
111
112
|
self._controller = None
|
|
112
|
-
|
|
113
|
+
logger.info("RocketWelder client stopped")
|
|
113
114
|
|
|
114
115
|
def __enter__(self) -> RocketWelderClient:
|
|
115
116
|
"""Context manager entry."""
|
|
@@ -125,7 +126,6 @@ class RocketWelderClient:
|
|
|
125
126
|
buffer_name: str,
|
|
126
127
|
buffer_size: str = "256MB",
|
|
127
128
|
metadata_size: str = "4KB",
|
|
128
|
-
logger: logging.Logger | None = None,
|
|
129
129
|
) -> RocketWelderClient:
|
|
130
130
|
"""
|
|
131
131
|
Create a one-way shared memory client.
|
|
@@ -134,7 +134,6 @@ class RocketWelderClient:
|
|
|
134
134
|
buffer_name: Name of the shared memory buffer
|
|
135
135
|
buffer_size: Size of the buffer (e.g., "256MB")
|
|
136
136
|
metadata_size: Size of metadata buffer (e.g., "4KB")
|
|
137
|
-
logger: Optional logger
|
|
138
137
|
|
|
139
138
|
Returns:
|
|
140
139
|
Configured RocketWelderClient instance
|
|
@@ -142,7 +141,7 @@ class RocketWelderClient:
|
|
|
142
141
|
connection_str = (
|
|
143
142
|
f"shm://{buffer_name}?size={buffer_size}&metadata={metadata_size}&mode=OneWay"
|
|
144
143
|
)
|
|
145
|
-
return cls(connection_str
|
|
144
|
+
return cls(connection_str)
|
|
146
145
|
|
|
147
146
|
@classmethod
|
|
148
147
|
def create_duplex_shm(
|
|
@@ -150,7 +149,6 @@ class RocketWelderClient:
|
|
|
150
149
|
buffer_name: str,
|
|
151
150
|
buffer_size: str = "256MB",
|
|
152
151
|
metadata_size: str = "4KB",
|
|
153
|
-
logger: logging.Logger | None = None,
|
|
154
152
|
) -> RocketWelderClient:
|
|
155
153
|
"""
|
|
156
154
|
Create a duplex shared memory client.
|
|
@@ -159,7 +157,6 @@ class RocketWelderClient:
|
|
|
159
157
|
buffer_name: Name of the shared memory buffer
|
|
160
158
|
buffer_size: Size of the buffer (e.g., "256MB")
|
|
161
159
|
metadata_size: Size of metadata buffer (e.g., "4KB")
|
|
162
|
-
logger: Optional logger
|
|
163
160
|
|
|
164
161
|
Returns:
|
|
165
162
|
Configured RocketWelderClient instance
|
|
@@ -167,4 +164,4 @@ class RocketWelderClient:
|
|
|
167
164
|
connection_str = (
|
|
168
165
|
f"shm://{buffer_name}?size={buffer_size}&metadata={metadata_size}&mode=Duplex"
|
|
169
166
|
)
|
|
170
|
-
return cls(connection_str
|
|
167
|
+
return cls(connection_str)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rocket-welder-sdk
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.25
|
|
4
4
|
Summary: High-performance video streaming SDK for RocketWelder services using ZeroBuffer IPC
|
|
5
5
|
Home-page: https://github.com/modelingevolution/rocket-welder-sdk
|
|
6
6
|
Author: ModelingEvolution
|
|
@@ -27,7 +27,8 @@ Requires-Python: >=3.8
|
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
Requires-Dist: numpy>=1.20.0
|
|
29
29
|
Requires-Dist: opencv-python>=4.5.0
|
|
30
|
-
Requires-Dist: zerobuffer-ipc>=1.1.
|
|
30
|
+
Requires-Dist: zerobuffer-ipc>=1.1.17
|
|
31
|
+
Requires-Dist: pydantic>=2.5.0
|
|
31
32
|
Provides-Extra: dev
|
|
32
33
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
33
34
|
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
rocket_welder_sdk/__init__.py,sha256=hv0U_UsvIjyLhZLsumjGwKrBJs8vzb1kczUzANilHug,1789
|
|
2
|
+
rocket_welder_sdk/bytes_size.py,sha256=Myl29-wyWCIYdbMmgaxXebT8Dz8_Fwcr3fnfaNW81P0,7463
|
|
3
|
+
rocket_welder_sdk/connection_string.py,sha256=msDgHD7525UXvrNPqKGpYgQJJtmOfS_XEnX3JA8LIz0,7731
|
|
4
|
+
rocket_welder_sdk/controllers.py,sha256=IhPJfwVlk0pxOuTRPW_Xp_3H_BZG6AJXQfS7DCfBrhg,26177
|
|
5
|
+
rocket_welder_sdk/gst_metadata.py,sha256=jEQvZX4BdR6OR3lqp12PV-HEXZhcxfiS010diA2CbMM,14213
|
|
6
|
+
rocket_welder_sdk/py.typed,sha256=0cXFZXmes4Y-vnl4lO3HtyyyWaFNw85B7tJdFeCtHDc,67
|
|
7
|
+
rocket_welder_sdk/rocket_welder_client.py,sha256=uvaAtog-xVCY9YldisgkCnCkbEWMHEgrJUUWJA1J3hg,5237
|
|
8
|
+
rocket_welder_sdk-1.1.25.dist-info/METADATA,sha256=4FF40eEwgdMu0vxF0cwsHgdayWGHc7xQ-C9WJ0KY6To,14362
|
|
9
|
+
rocket_welder_sdk-1.1.25.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
10
|
+
rocket_welder_sdk-1.1.25.dist-info/top_level.txt,sha256=2iZvBjnwVCUW-uDE23-eJld5PZ9-mlPI69QiXM5IrTA,18
|
|
11
|
+
rocket_welder_sdk-1.1.25.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
rocket_welder_sdk/__init__.py,sha256=6lQUKg8w-qK_5DRFOiydCdirGAj9t-dcCBmDyuW9GNc,909
|
|
2
|
-
rocket_welder_sdk/bytes_size.py,sha256=Myl29-wyWCIYdbMmgaxXebT8Dz8_Fwcr3fnfaNW81P0,7463
|
|
3
|
-
rocket_welder_sdk/connection_string.py,sha256=msDgHD7525UXvrNPqKGpYgQJJtmOfS_XEnX3JA8LIz0,7731
|
|
4
|
-
rocket_welder_sdk/controllers.py,sha256=acrGGBrFvHO2NUIwaj1wsX7bef0Pn3bgYJlcJJUn3bE,24032
|
|
5
|
-
rocket_welder_sdk/gst_metadata.py,sha256=K0FByW-BiRPPmveTPLKl1G3A_f9Tog0i8wukiaaXnig,7201
|
|
6
|
-
rocket_welder_sdk/py.typed,sha256=0cXFZXmes4Y-vnl4lO3HtyyyWaFNw85B7tJdFeCtHDc,67
|
|
7
|
-
rocket_welder_sdk/rocket_welder_client.py,sha256=1-W1ofdMof9njmLOx4eV8vgxtU2xgk96h8V01Yey9wE,5547
|
|
8
|
-
rocket_welder_sdk-1.1.0.dist-info/METADATA,sha256=3g0N-hAnSmhEddO1fl8ox3KkiRL9V6gyOL90Zqr9JAg,14330
|
|
9
|
-
rocket_welder_sdk-1.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
10
|
-
rocket_welder_sdk-1.1.0.dist-info/top_level.txt,sha256=2iZvBjnwVCUW-uDE23-eJld5PZ9-mlPI69QiXM5IrTA,18
|
|
11
|
-
rocket_welder_sdk-1.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|