rocket-welder-sdk 1.1.27__py3-none-any.whl → 1.1.29__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 +7 -1
- rocket_welder_sdk/connection_string.py +51 -0
- rocket_welder_sdk/controllers.py +187 -63
- rocket_welder_sdk/opencv_controller.py +278 -0
- rocket_welder_sdk/periodic_timer.py +303 -0
- rocket_welder_sdk/rocket_welder_client.py +246 -9
- rocket_welder_sdk-1.1.29.dist-info/METADATA +819 -0
- {rocket_welder_sdk-1.1.27.dist-info → rocket_welder_sdk-1.1.29.dist-info}/RECORD +10 -8
- rocket_welder_sdk-1.1.27.dist-info/METADATA +0 -498
- {rocket_welder_sdk-1.1.27.dist-info → rocket_welder_sdk-1.1.29.dist-info}/WHEEL +0 -0
- {rocket_welder_sdk-1.1.27.dist-info → rocket_welder_sdk-1.1.29.dist-info}/top_level.txt +0 -0
rocket_welder_sdk/__init__.py
CHANGED
|
@@ -11,9 +11,11 @@ from .bytes_size import BytesSize
|
|
|
11
11
|
from .connection_string import ConnectionMode, ConnectionString, Protocol
|
|
12
12
|
from .controllers import DuplexShmController, IController, OneWayShmController
|
|
13
13
|
from .gst_metadata import GstCaps, GstMetadata
|
|
14
|
+
from .opencv_controller import OpenCvController
|
|
15
|
+
from .periodic_timer import PeriodicTimer, PeriodicTimerSync
|
|
14
16
|
from .rocket_welder_client import RocketWelderClient
|
|
15
17
|
|
|
16
|
-
# Alias for backward compatibility
|
|
18
|
+
# Alias for backward compatibility and README examples
|
|
17
19
|
Client = RocketWelderClient
|
|
18
20
|
|
|
19
21
|
__version__ = "1.1.0"
|
|
@@ -50,6 +52,10 @@ __all__ = [
|
|
|
50
52
|
# Controllers
|
|
51
53
|
"IController",
|
|
52
54
|
"OneWayShmController",
|
|
55
|
+
"OpenCvController",
|
|
56
|
+
# Timers
|
|
57
|
+
"PeriodicTimer",
|
|
58
|
+
"PeriodicTimerSync",
|
|
53
59
|
"Protocol",
|
|
54
60
|
# Main client
|
|
55
61
|
"RocketWelderClient",
|
|
@@ -21,6 +21,7 @@ class Protocol(Flag):
|
|
|
21
21
|
MJPEG = auto() # Motion JPEG
|
|
22
22
|
HTTP = auto() # HTTP protocol
|
|
23
23
|
TCP = auto() # TCP protocol
|
|
24
|
+
FILE = auto() # File protocol
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
class ConnectionMode(Enum):
|
|
@@ -43,12 +44,15 @@ class ConnectionString:
|
|
|
43
44
|
- shm://buffer_name?size=256MB&metadata=4KB&mode=Duplex
|
|
44
45
|
- mjpeg://192.168.1.100:8080
|
|
45
46
|
- mjpeg+http://camera.local:80
|
|
47
|
+
- file:///path/to/video.mp4?loop=true
|
|
46
48
|
"""
|
|
47
49
|
|
|
48
50
|
protocol: Protocol
|
|
49
51
|
host: str | None = None
|
|
50
52
|
port: int | None = None
|
|
51
53
|
buffer_name: str | None = None
|
|
54
|
+
file_path: str | None = None
|
|
55
|
+
parameters: dict[str, str] = field(default_factory=dict)
|
|
52
56
|
buffer_size: BytesSize = field(default_factory=lambda: BytesSize.parse("256MB"))
|
|
53
57
|
metadata_size: BytesSize = field(default_factory=lambda: BytesSize.parse("4KB"))
|
|
54
58
|
connection_mode: ConnectionMode = ConnectionMode.ONE_WAY
|
|
@@ -82,6 +86,8 @@ class ConnectionString:
|
|
|
82
86
|
# Parse based on protocol type
|
|
83
87
|
if protocol == Protocol.SHM:
|
|
84
88
|
return cls._parse_shm(protocol, remainder)
|
|
89
|
+
elif protocol == Protocol.FILE:
|
|
90
|
+
return cls._parse_file(protocol, remainder)
|
|
85
91
|
elif bool(protocol & Protocol.MJPEG): # type: ignore[operator]
|
|
86
92
|
return cls._parse_mjpeg(protocol, remainder)
|
|
87
93
|
else:
|
|
@@ -110,6 +116,7 @@ class ConnectionString:
|
|
|
110
116
|
"mjpeg": Protocol.MJPEG,
|
|
111
117
|
"http": Protocol.HTTP,
|
|
112
118
|
"tcp": Protocol.TCP,
|
|
119
|
+
"file": Protocol.FILE,
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
protocol = protocol_map.get(protocol_str, Protocol.NONE)
|
|
@@ -157,6 +164,43 @@ class ConnectionString:
|
|
|
157
164
|
timeout_ms=timeout_ms,
|
|
158
165
|
)
|
|
159
166
|
|
|
167
|
+
@classmethod
|
|
168
|
+
def _parse_file(cls, protocol: Protocol, remainder: str) -> ConnectionString:
|
|
169
|
+
"""Parse file protocol connection string."""
|
|
170
|
+
# Split file path and query parameters
|
|
171
|
+
if "?" in remainder:
|
|
172
|
+
file_path, query_string = remainder.split("?", 1)
|
|
173
|
+
params = cls._parse_query_params(query_string)
|
|
174
|
+
else:
|
|
175
|
+
file_path = remainder
|
|
176
|
+
params = {}
|
|
177
|
+
|
|
178
|
+
# Handle file:///absolute/path and file://relative/path
|
|
179
|
+
if not file_path.startswith("/"):
|
|
180
|
+
file_path = "/" + file_path
|
|
181
|
+
|
|
182
|
+
# Parse common parameters
|
|
183
|
+
connection_mode = ConnectionMode.ONE_WAY
|
|
184
|
+
timeout_ms = 5000
|
|
185
|
+
|
|
186
|
+
if "mode" in params:
|
|
187
|
+
mode_str = params["mode"].upper()
|
|
188
|
+
if mode_str == "DUPLEX":
|
|
189
|
+
connection_mode = ConnectionMode.DUPLEX
|
|
190
|
+
elif mode_str in ("ONEWAY", "ONE_WAY"):
|
|
191
|
+
connection_mode = ConnectionMode.ONE_WAY
|
|
192
|
+
if "timeout" in params:
|
|
193
|
+
with contextlib.suppress(ValueError):
|
|
194
|
+
timeout_ms = int(params["timeout"])
|
|
195
|
+
|
|
196
|
+
return cls(
|
|
197
|
+
protocol=protocol,
|
|
198
|
+
file_path=file_path,
|
|
199
|
+
parameters=params,
|
|
200
|
+
connection_mode=connection_mode,
|
|
201
|
+
timeout_ms=timeout_ms,
|
|
202
|
+
)
|
|
203
|
+
|
|
160
204
|
@classmethod
|
|
161
205
|
def _parse_mjpeg(cls, protocol: Protocol, remainder: str) -> ConnectionString:
|
|
162
206
|
"""Parse MJPEG connection string."""
|
|
@@ -195,6 +239,8 @@ class ConnectionString:
|
|
|
195
239
|
protocol_parts = []
|
|
196
240
|
if self.protocol & Protocol.SHM:
|
|
197
241
|
protocol_parts.append("shm")
|
|
242
|
+
if self.protocol & Protocol.FILE:
|
|
243
|
+
protocol_parts.append("file")
|
|
198
244
|
if self.protocol & Protocol.MJPEG:
|
|
199
245
|
protocol_parts.append("mjpeg")
|
|
200
246
|
if self.protocol & Protocol.HTTP:
|
|
@@ -215,6 +261,11 @@ class ConnectionString:
|
|
|
215
261
|
params.append(f"timeout={self.timeout_ms}")
|
|
216
262
|
|
|
217
263
|
return f"{protocol_str}://{self.buffer_name}?{'&'.join(params)}"
|
|
264
|
+
elif self.protocol == Protocol.FILE:
|
|
265
|
+
query_string = ""
|
|
266
|
+
if self.parameters:
|
|
267
|
+
query_string = "?" + "&".join(f"{k}={v}" for k, v in self.parameters.items())
|
|
268
|
+
return f"{protocol_str}://{self.file_path}{query_string}"
|
|
218
269
|
else:
|
|
219
270
|
return f"{protocol_str}://{self.host}:{self.port}"
|
|
220
271
|
|
rocket_welder_sdk/controllers.py
CHANGED
|
@@ -9,18 +9,23 @@ import json
|
|
|
9
9
|
import logging
|
|
10
10
|
import threading
|
|
11
11
|
from abc import ABC, abstractmethod
|
|
12
|
-
from typing import
|
|
12
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
13
13
|
|
|
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.duplex.server import ImmutableDuplexServer
|
|
17
18
|
from zerobuffer.exceptions import WriterDeadException
|
|
18
19
|
|
|
19
20
|
from .connection_string import ConnectionMode, ConnectionString, Protocol
|
|
20
21
|
from .gst_metadata import GstCaps, GstMetadata
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
import numpy.typing as npt
|
|
25
|
+
|
|
26
|
+
Mat = npt.NDArray[np.uint8]
|
|
27
|
+
else:
|
|
28
|
+
Mat = np.ndarray # type: ignore[misc]
|
|
24
29
|
|
|
25
30
|
# Module logger
|
|
26
31
|
logger = logging.getLogger(__name__)
|
|
@@ -36,13 +41,15 @@ class IController(ABC):
|
|
|
36
41
|
...
|
|
37
42
|
|
|
38
43
|
@abstractmethod
|
|
39
|
-
def get_metadata(self) -> GstMetadata
|
|
44
|
+
def get_metadata(self) -> Optional[GstMetadata]:
|
|
40
45
|
"""Get the current GStreamer metadata."""
|
|
41
46
|
...
|
|
42
47
|
|
|
43
48
|
@abstractmethod
|
|
44
49
|
def start(
|
|
45
|
-
self,
|
|
50
|
+
self,
|
|
51
|
+
on_frame: Callable[[Mat], None], # type: ignore[valid-type]
|
|
52
|
+
cancellation_token: Optional[threading.Event] = None,
|
|
46
53
|
) -> None:
|
|
47
54
|
"""
|
|
48
55
|
Start the controller with a frame callback.
|
|
@@ -80,24 +87,26 @@ class OneWayShmController(IController):
|
|
|
80
87
|
)
|
|
81
88
|
|
|
82
89
|
self._connection = connection
|
|
83
|
-
self._reader: Reader
|
|
84
|
-
self._gst_caps: GstCaps
|
|
85
|
-
self._metadata: GstMetadata
|
|
90
|
+
self._reader: Optional[Reader] = None
|
|
91
|
+
self._gst_caps: Optional[GstCaps] = None
|
|
92
|
+
self._metadata: Optional[GstMetadata] = None
|
|
86
93
|
self._is_running = False
|
|
87
|
-
self._worker_thread: threading.Thread
|
|
88
|
-
self._cancellation_token: threading.Event
|
|
94
|
+
self._worker_thread: Optional[threading.Thread] = None
|
|
95
|
+
self._cancellation_token: Optional[threading.Event] = None
|
|
89
96
|
|
|
90
97
|
@property
|
|
91
98
|
def is_running(self) -> bool:
|
|
92
99
|
"""Check if the controller is running."""
|
|
93
100
|
return self._is_running
|
|
94
101
|
|
|
95
|
-
def get_metadata(self) -> GstMetadata
|
|
102
|
+
def get_metadata(self) -> Optional[GstMetadata]:
|
|
96
103
|
"""Get the current GStreamer metadata."""
|
|
97
104
|
return self._metadata
|
|
98
105
|
|
|
99
106
|
def start(
|
|
100
|
-
self,
|
|
107
|
+
self,
|
|
108
|
+
on_frame: Callable[[Mat], None], # type: ignore[valid-type]
|
|
109
|
+
cancellation_token: Optional[threading.Event] = None,
|
|
101
110
|
) -> None:
|
|
102
111
|
"""
|
|
103
112
|
Start receiving frames from shared memory.
|
|
@@ -161,7 +170,7 @@ class OneWayShmController(IController):
|
|
|
161
170
|
self._worker_thread = None
|
|
162
171
|
logger.info("Stopped controller for buffer '%s'", self._connection.buffer_name)
|
|
163
172
|
|
|
164
|
-
def _process_frames(self, on_frame: Callable[[Mat], None]) -> None:
|
|
173
|
+
def _process_frames(self, on_frame: Callable[[Mat], None]) -> None: # type: ignore[valid-type]
|
|
165
174
|
"""
|
|
166
175
|
Process frames from shared memory.
|
|
167
176
|
|
|
@@ -236,7 +245,7 @@ class OneWayShmController(IController):
|
|
|
236
245
|
logger.error("Fatal error in frame processing loop: %s", e)
|
|
237
246
|
self._is_running = False
|
|
238
247
|
|
|
239
|
-
def _on_first_frame(self, on_frame: Callable[[Mat], None]) -> None:
|
|
248
|
+
def _on_first_frame(self, on_frame: Callable[[Mat], None]) -> None: # type: ignore[valid-type]
|
|
240
249
|
"""
|
|
241
250
|
Process the first frame and extract metadata.
|
|
242
251
|
Matches C# OnFirstFrame behavior - loops until valid frame received.
|
|
@@ -268,36 +277,14 @@ class OneWayShmController(IController):
|
|
|
268
277
|
bytes(metadata_bytes[: min(100, len(metadata_bytes))]),
|
|
269
278
|
)
|
|
270
279
|
|
|
271
|
-
#
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
# Decode UTF-8
|
|
276
|
-
metadata_str = metadata_bytes.decode("utf-8")
|
|
277
|
-
|
|
278
|
-
# Check if metadata is empty or all zeros
|
|
279
|
-
if not metadata_str or metadata_str == "\x00" * len(metadata_str):
|
|
280
|
-
logger.warning("Metadata is empty or all zeros, skipping")
|
|
280
|
+
# Use helper method to parse metadata
|
|
281
|
+
metadata = self._parse_metadata_json(metadata_bytes)
|
|
282
|
+
if not metadata:
|
|
283
|
+
logger.warning("Failed to parse metadata, skipping")
|
|
281
284
|
continue
|
|
282
285
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if json_start == -1:
|
|
286
|
-
logger.warning("No JSON found in metadata: %r", metadata_str[:100])
|
|
287
|
-
continue
|
|
288
|
-
|
|
289
|
-
if json_start > 0:
|
|
290
|
-
logger.debug("Skipping %d bytes before JSON", json_start)
|
|
291
|
-
metadata_str = metadata_str[json_start:]
|
|
292
|
-
|
|
293
|
-
# Find the end of JSON (handle null padding)
|
|
294
|
-
json_end = metadata_str.rfind("}")
|
|
295
|
-
if json_end != -1 and json_end < len(metadata_str) - 1:
|
|
296
|
-
metadata_str = metadata_str[: json_end + 1]
|
|
297
|
-
|
|
298
|
-
metadata_json = json.loads(metadata_str)
|
|
299
|
-
self._metadata = GstMetadata.from_json(metadata_json)
|
|
300
|
-
self._gst_caps = self._metadata.caps
|
|
286
|
+
self._metadata = metadata
|
|
287
|
+
self._gst_caps = metadata.caps
|
|
301
288
|
logger.info(
|
|
302
289
|
"Received metadata from buffer '%s': %s",
|
|
303
290
|
self._connection.buffer_name,
|
|
@@ -342,7 +329,7 @@ class OneWayShmController(IController):
|
|
|
342
329
|
if not self._is_running:
|
|
343
330
|
break
|
|
344
331
|
|
|
345
|
-
def _create_mat_from_frame(self, frame: Frame) -> Mat
|
|
332
|
+
def _create_mat_from_frame(self, frame: Frame) -> Optional[Mat]: # type: ignore[valid-type]
|
|
346
333
|
"""
|
|
347
334
|
Create OpenCV Mat from frame data using GstCaps.
|
|
348
335
|
Matches C# CreateMat behavior - creates Mat wrapping the data.
|
|
@@ -406,6 +393,40 @@ class OneWayShmController(IController):
|
|
|
406
393
|
|
|
407
394
|
# Try common resolutions
|
|
408
395
|
frame_size = len(frame.data)
|
|
396
|
+
|
|
397
|
+
# First, check if it's a perfect square (square frame)
|
|
398
|
+
import math
|
|
399
|
+
|
|
400
|
+
sqrt_size = math.sqrt(frame_size)
|
|
401
|
+
if sqrt_size == int(sqrt_size):
|
|
402
|
+
# Perfect square - assume square grayscale image
|
|
403
|
+
dimension = int(sqrt_size)
|
|
404
|
+
logger.info(
|
|
405
|
+
f"Frame size {frame_size} is a perfect square, assuming {dimension}x{dimension} grayscale"
|
|
406
|
+
)
|
|
407
|
+
data = np.frombuffer(frame.data, dtype=np.uint8)
|
|
408
|
+
return data.reshape((dimension, dimension)) # type: ignore[no-any-return]
|
|
409
|
+
|
|
410
|
+
# Also check for square RGB (size = width * height * 3)
|
|
411
|
+
if frame_size % 3 == 0:
|
|
412
|
+
pixels = frame_size // 3
|
|
413
|
+
sqrt_pixels = math.sqrt(pixels)
|
|
414
|
+
if sqrt_pixels == int(sqrt_pixels):
|
|
415
|
+
dimension = int(sqrt_pixels)
|
|
416
|
+
logger.info(f"Frame size {frame_size} suggests {dimension}x{dimension} RGB")
|
|
417
|
+
data = np.frombuffer(frame.data, dtype=np.uint8)
|
|
418
|
+
return data.reshape((dimension, dimension, 3)) # type: ignore[no-any-return]
|
|
419
|
+
|
|
420
|
+
# Check for square RGBA (size = width * height * 4)
|
|
421
|
+
if frame_size % 4 == 0:
|
|
422
|
+
pixels = frame_size // 4
|
|
423
|
+
sqrt_pixels = math.sqrt(pixels)
|
|
424
|
+
if sqrt_pixels == int(sqrt_pixels):
|
|
425
|
+
dimension = int(sqrt_pixels)
|
|
426
|
+
logger.info(f"Frame size {frame_size} suggests {dimension}x{dimension} RGBA")
|
|
427
|
+
data = np.frombuffer(frame.data, dtype=np.uint8)
|
|
428
|
+
return data.reshape((dimension, dimension, 4)) # type: ignore[no-any-return]
|
|
429
|
+
|
|
409
430
|
common_resolutions = [
|
|
410
431
|
(640, 480, 3), # VGA RGB
|
|
411
432
|
(640, 480, 4), # VGA RGBA
|
|
@@ -440,7 +461,46 @@ class OneWayShmController(IController):
|
|
|
440
461
|
logger.error("Failed to convert frame to Mat: %s", e)
|
|
441
462
|
return None
|
|
442
463
|
|
|
443
|
-
def
|
|
464
|
+
def _parse_metadata_json(self, metadata_bytes: bytes | memoryview) -> GstMetadata | None:
|
|
465
|
+
"""
|
|
466
|
+
Parse metadata JSON from bytes, handling null padding and boundaries.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
metadata_bytes: Raw metadata bytes or memoryview
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
GstMetadata object or None if parsing fails
|
|
473
|
+
"""
|
|
474
|
+
try:
|
|
475
|
+
# Convert to string
|
|
476
|
+
if isinstance(metadata_bytes, memoryview):
|
|
477
|
+
metadata_bytes = bytes(metadata_bytes)
|
|
478
|
+
metadata_str = metadata_bytes.decode("utf-8")
|
|
479
|
+
|
|
480
|
+
# Find JSON boundaries (handle null padding)
|
|
481
|
+
json_start = metadata_str.find("{")
|
|
482
|
+
if json_start < 0:
|
|
483
|
+
logger.debug("No JSON found in metadata")
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
json_end = metadata_str.rfind("}")
|
|
487
|
+
if json_end <= json_start:
|
|
488
|
+
logger.debug("Invalid JSON boundaries in metadata")
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
# Extract JSON
|
|
492
|
+
metadata_str = metadata_str[json_start : json_end + 1]
|
|
493
|
+
|
|
494
|
+
# Parse JSON
|
|
495
|
+
metadata_json = json.loads(metadata_str)
|
|
496
|
+
metadata = GstMetadata.from_json(metadata_json)
|
|
497
|
+
return metadata
|
|
498
|
+
|
|
499
|
+
except Exception as e:
|
|
500
|
+
logger.debug("Failed to parse metadata JSON: %s", e)
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
def _infer_caps_from_frame(self, mat: Mat) -> None: # type: ignore[valid-type]
|
|
444
504
|
"""
|
|
445
505
|
Infer GStreamer caps from OpenCV Mat.
|
|
446
506
|
|
|
@@ -487,11 +547,11 @@ class DuplexShmController(IController):
|
|
|
487
547
|
)
|
|
488
548
|
|
|
489
549
|
self._connection = connection
|
|
490
|
-
self._duplex_server:
|
|
491
|
-
self._gst_caps: GstCaps
|
|
492
|
-
self._metadata: GstMetadata
|
|
550
|
+
self._duplex_server: Optional[ImmutableDuplexServer] = None
|
|
551
|
+
self._gst_caps: Optional[GstCaps] = None
|
|
552
|
+
self._metadata: Optional[GstMetadata] = None
|
|
493
553
|
self._is_running = False
|
|
494
|
-
self._on_frame_callback: Callable[[Mat, Mat], None]
|
|
554
|
+
self._on_frame_callback: Optional[Callable[[Mat, Mat], None]] = None # type: ignore[valid-type]
|
|
495
555
|
self._frame_count = 0
|
|
496
556
|
|
|
497
557
|
@property
|
|
@@ -499,14 +559,14 @@ class DuplexShmController(IController):
|
|
|
499
559
|
"""Check if the controller is running."""
|
|
500
560
|
return self._is_running
|
|
501
561
|
|
|
502
|
-
def get_metadata(self) -> GstMetadata
|
|
562
|
+
def get_metadata(self) -> Optional[GstMetadata]:
|
|
503
563
|
"""Get the current GStreamer metadata."""
|
|
504
564
|
return self._metadata
|
|
505
565
|
|
|
506
566
|
def start(
|
|
507
567
|
self,
|
|
508
|
-
on_frame: Callable[[Mat, Mat], None], # type: ignore[override]
|
|
509
|
-
cancellation_token: threading.Event
|
|
568
|
+
on_frame: Callable[[Mat, Mat], None], # type: ignore[override,valid-type]
|
|
569
|
+
cancellation_token: Optional[threading.Event] = None,
|
|
510
570
|
) -> None:
|
|
511
571
|
"""
|
|
512
572
|
Start duplex frame processing.
|
|
@@ -532,6 +592,11 @@ class DuplexShmController(IController):
|
|
|
532
592
|
if not self._connection.buffer_name:
|
|
533
593
|
raise ValueError("Buffer name is required for shared memory connection")
|
|
534
594
|
timeout_seconds = self._connection.timeout_ms / 1000.0
|
|
595
|
+
logger.debug(
|
|
596
|
+
"Creating duplex server with timeout: %d ms (%.1f seconds)",
|
|
597
|
+
self._connection.timeout_ms,
|
|
598
|
+
timeout_seconds,
|
|
599
|
+
)
|
|
535
600
|
factory = DuplexChannelFactory()
|
|
536
601
|
self._duplex_server = factory.create_immutable_server(
|
|
537
602
|
self._connection.buffer_name, config, timeout_seconds
|
|
@@ -563,6 +628,44 @@ class DuplexShmController(IController):
|
|
|
563
628
|
|
|
564
629
|
logger.info("DuplexShmController stopped")
|
|
565
630
|
|
|
631
|
+
def _parse_metadata_json(self, metadata_bytes: bytes | memoryview) -> GstMetadata | None:
|
|
632
|
+
"""
|
|
633
|
+
Parse metadata JSON from bytes, handling null padding and boundaries.
|
|
634
|
+
|
|
635
|
+
Args:
|
|
636
|
+
metadata_bytes: Raw metadata bytes or memoryview
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
GstMetadata object or None if parsing fails
|
|
640
|
+
"""
|
|
641
|
+
try:
|
|
642
|
+
# Convert to string
|
|
643
|
+
if isinstance(metadata_bytes, memoryview):
|
|
644
|
+
metadata_bytes = bytes(metadata_bytes)
|
|
645
|
+
metadata_str = metadata_bytes.decode("utf-8")
|
|
646
|
+
|
|
647
|
+
# Find JSON boundaries (handle null padding)
|
|
648
|
+
json_start = metadata_str.find("{")
|
|
649
|
+
if json_start < 0:
|
|
650
|
+
logger.debug("No JSON found in metadata")
|
|
651
|
+
return None
|
|
652
|
+
|
|
653
|
+
json_end = metadata_str.rfind("}")
|
|
654
|
+
if json_end <= json_start:
|
|
655
|
+
logger.debug("Invalid JSON boundaries in metadata")
|
|
656
|
+
return None
|
|
657
|
+
|
|
658
|
+
# Extract JSON
|
|
659
|
+
metadata_str = metadata_str[json_start : json_end + 1]
|
|
660
|
+
|
|
661
|
+
# Parse JSON
|
|
662
|
+
metadata_json = json.loads(metadata_str)
|
|
663
|
+
metadata = GstMetadata.from_json(metadata_json)
|
|
664
|
+
return metadata
|
|
665
|
+
except Exception as e:
|
|
666
|
+
logger.debug("Failed to parse metadata JSON: %s", e)
|
|
667
|
+
return None
|
|
668
|
+
|
|
566
669
|
def _on_metadata(self, metadata_bytes: bytes | memoryview) -> None:
|
|
567
670
|
"""
|
|
568
671
|
Handle metadata from duplex channel.
|
|
@@ -574,23 +677,20 @@ class DuplexShmController(IController):
|
|
|
574
677
|
"_on_metadata called with %d bytes", len(metadata_bytes) if metadata_bytes else 0
|
|
575
678
|
)
|
|
576
679
|
try:
|
|
577
|
-
# Convert memoryview to bytes if needed
|
|
578
|
-
if isinstance(metadata_bytes, memoryview):
|
|
579
|
-
metadata_bytes = bytes(metadata_bytes)
|
|
580
|
-
|
|
581
680
|
# Log raw bytes for debugging
|
|
582
681
|
logger.debug(
|
|
583
682
|
"Raw metadata bytes (first 100): %r",
|
|
584
683
|
metadata_bytes[: min(100, len(metadata_bytes))],
|
|
585
684
|
)
|
|
586
685
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
686
|
+
# Use helper method to parse metadata
|
|
687
|
+
metadata = self._parse_metadata_json(metadata_bytes)
|
|
688
|
+
if metadata:
|
|
689
|
+
self._metadata = metadata
|
|
690
|
+
self._gst_caps = metadata.caps
|
|
691
|
+
logger.info("Received metadata: %s", self._metadata)
|
|
692
|
+
else:
|
|
693
|
+
logger.warning("Failed to parse metadata from buffer initialization")
|
|
594
694
|
except Exception as e:
|
|
595
695
|
logger.error("Failed to parse metadata: %s", e, exc_info=True)
|
|
596
696
|
|
|
@@ -614,6 +714,30 @@ class DuplexShmController(IController):
|
|
|
614
714
|
|
|
615
715
|
self._frame_count += 1
|
|
616
716
|
|
|
717
|
+
# Try to read metadata if we don't have it yet
|
|
718
|
+
if (
|
|
719
|
+
self._metadata is None
|
|
720
|
+
and self._duplex_server
|
|
721
|
+
and self._duplex_server.request_reader
|
|
722
|
+
):
|
|
723
|
+
try:
|
|
724
|
+
metadata_bytes = self._duplex_server.request_reader.get_metadata()
|
|
725
|
+
if metadata_bytes:
|
|
726
|
+
# Use helper method to parse metadata
|
|
727
|
+
metadata = self._parse_metadata_json(metadata_bytes)
|
|
728
|
+
if metadata:
|
|
729
|
+
self._metadata = metadata
|
|
730
|
+
self._gst_caps = metadata.caps
|
|
731
|
+
logger.info(
|
|
732
|
+
"Successfully read metadata from buffer '%s': %s",
|
|
733
|
+
self._connection.buffer_name,
|
|
734
|
+
self._gst_caps,
|
|
735
|
+
)
|
|
736
|
+
else:
|
|
737
|
+
logger.debug("Failed to parse metadata in frame processing")
|
|
738
|
+
except Exception as e:
|
|
739
|
+
logger.debug("Failed to read metadata in frame processing: %s", e)
|
|
740
|
+
|
|
617
741
|
# Convert input frame to Mat
|
|
618
742
|
input_mat = self._frame_to_mat(request_frame)
|
|
619
743
|
if input_mat is None:
|
|
@@ -662,7 +786,7 @@ class DuplexShmController(IController):
|
|
|
662
786
|
except Exception as e:
|
|
663
787
|
logger.error("Error processing duplex frame: %s", e)
|
|
664
788
|
|
|
665
|
-
def _frame_to_mat(self, frame: Frame) -> Mat
|
|
789
|
+
def _frame_to_mat(self, frame: Frame) -> Optional[Mat]: # type: ignore[valid-type]
|
|
666
790
|
"""Convert frame to OpenCV Mat (reuse from OneWayShmController)."""
|
|
667
791
|
# Implementation is same as OneWayShmController
|
|
668
792
|
return OneWayShmController._create_mat_from_frame(self, frame) # type: ignore[arg-type]
|