rocket-welder-sdk 1.1.32__py3-none-any.whl → 1.1.34__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.
@@ -14,7 +14,14 @@ import numpy as np
14
14
 
15
15
  from .connection_string import ConnectionMode, ConnectionString, Protocol
16
16
  from .controllers import DuplexShmController, IController, OneWayShmController
17
+ from .frame_metadata import FrameMetadata # noqa: TC001 - used at runtime in callbacks
17
18
  from .opencv_controller import OpenCvController
19
+ from .session_id import (
20
+ get_configured_nng_urls,
21
+ get_nng_urls_from_env,
22
+ has_explicit_nng_urls,
23
+ )
24
+ from .transport.nng_transport import NngFrameSink
18
25
 
19
26
  if TYPE_CHECKING:
20
27
  import numpy.typing as npt
@@ -52,6 +59,9 @@ class RocketWelderClient:
52
59
  self._controller: Optional[IController] = None
53
60
  self._lock = threading.Lock()
54
61
 
62
+ # NNG publishers for streaming results (auto-created if SessionId env var is set)
63
+ self._nng_publishers: dict[str, NngFrameSink] = {}
64
+
55
65
  # Preview support
56
66
  self._preview_enabled = (
57
67
  self._connection.parameters.get("preview", "false").lower() == "true"
@@ -71,6 +81,50 @@ class RocketWelderClient:
71
81
  with self._lock:
72
82
  return self._controller is not None and self._controller.is_running
73
83
 
84
+ @property
85
+ def nng_publishers(self) -> dict[str, NngFrameSink]:
86
+ """Get NNG publishers for streaming results.
87
+
88
+ Returns:
89
+ Dictionary with 'segmentation', 'keypoints', 'actions' keys.
90
+ Empty if SessionId env var was not set at startup.
91
+
92
+ Example:
93
+ client.nng_publishers["segmentation"].write_frame(seg_data)
94
+ """
95
+ return self._nng_publishers
96
+
97
+ def _create_nng_publishers(self) -> None:
98
+ """Create NNG publishers for result streaming.
99
+
100
+ URLs are read from environment variables (preferred) or derived from SessionId (fallback).
101
+
102
+ Priority:
103
+ 1. Explicit URLs: SEGMENTATION_SINK_URL, KEYPOINTS_SINK_URL, ACTIONS_SINK_URL
104
+ 2. Derived from SessionId environment variable (backwards compatibility)
105
+ """
106
+ try:
107
+ urls = get_configured_nng_urls()
108
+
109
+ for name, url in urls.items():
110
+ sink = NngFrameSink.create_publisher(url)
111
+ self._nng_publishers[name] = sink
112
+ logger.info("NNG publisher ready: %s at %s", name, url)
113
+
114
+ # Log configuration summary
115
+ logger.info(
116
+ "NNG publishers configured: seg=%s, kp=%s, actions=%s",
117
+ urls.get("segmentation", "(not configured)"),
118
+ urls.get("keypoints", "(not configured)"),
119
+ urls.get("actions", "(not configured)"),
120
+ )
121
+ except ValueError as ex:
122
+ # No URLs configured - this is expected for containers that don't publish results
123
+ logger.debug("NNG publishers not configured: %s", ex)
124
+ except Exception as ex:
125
+ logger.warning("Failed to create NNG publishers: %s", ex)
126
+ # Don't fail start() - NNG is optional for backwards compatibility
127
+
74
128
  def get_metadata(self) -> Optional[GstMetadata]:
75
129
  """
76
130
  Get the current GStreamer metadata.
@@ -118,6 +172,21 @@ class RocketWelderClient:
118
172
  else:
119
173
  raise ValueError(f"Unsupported protocol: {self._connection.protocol}")
120
174
 
175
+ # Auto-create NNG publishers if URLs are configured
176
+ # (explicit URLs via SEGMENTATION_SINK_URL etc., or derived from SessionId)
177
+ if has_explicit_nng_urls():
178
+ self._create_nng_publishers()
179
+ else:
180
+ # Log that NNG is not configured (informational)
181
+ urls = get_nng_urls_from_env()
182
+ logger.info(
183
+ "NNG sink URLs not configured (this is normal if not publishing AI results). "
184
+ "seg=%s, kp=%s, actions=%s",
185
+ urls.get("segmentation") or "(not set)",
186
+ urls.get("keypoints") or "(not set)",
187
+ urls.get("actions") or "(not set)",
188
+ )
189
+
121
190
  # If preview is enabled, wrap the callback to capture frames
122
191
  if self._preview_enabled:
123
192
  self._original_callback = on_frame
@@ -125,8 +194,10 @@ class RocketWelderClient:
125
194
  # Determine if duplex or one-way
126
195
  if self._connection.connection_mode == ConnectionMode.DUPLEX:
127
196
 
128
- def preview_wrapper_duplex(input_frame: Mat, output_frame: Mat) -> None: # type: ignore[valid-type]
129
- # Call original callback
197
+ def preview_wrapper_duplex(
198
+ metadata: FrameMetadata, input_frame: Mat, output_frame: Mat # type: ignore[valid-type]
199
+ ) -> None:
200
+ # Call original callback (ignoring FrameMetadata for backwards compatibility)
130
201
  on_frame(input_frame, output_frame) # type: ignore[call-arg]
131
202
  # Queue the OUTPUT frame for preview
132
203
  try:
@@ -158,7 +229,18 @@ class RocketWelderClient:
158
229
 
159
230
  actual_callback = preview_wrapper_oneway # type: ignore[assignment]
160
231
  else:
161
- actual_callback = on_frame # type: ignore[assignment]
232
+ # Wrap the callback to adapt (Mat, Mat) -> (FrameMetadata, Mat, Mat) for duplex
233
+ if self._connection.connection_mode == ConnectionMode.DUPLEX:
234
+
235
+ def metadata_adapter(
236
+ metadata: FrameMetadata, input_frame: Mat, output_frame: Mat # type: ignore[valid-type]
237
+ ) -> None:
238
+ # Call original callback (ignoring FrameMetadata for backwards compatibility)
239
+ on_frame(input_frame, output_frame) # type: ignore[call-arg]
240
+
241
+ actual_callback = metadata_adapter
242
+ else:
243
+ actual_callback = on_frame # type: ignore[assignment]
162
244
 
163
245
  # Start the controller
164
246
  self._controller.start(actual_callback, cancellation_token) # type: ignore[arg-type]
@@ -175,6 +257,15 @@ class RocketWelderClient:
175
257
  if self._preview_enabled:
176
258
  self._preview_queue.put(None) # Sentinel value
177
259
 
260
+ # Clean up NNG publishers
261
+ for name, sink in self._nng_publishers.items():
262
+ try:
263
+ sink.close()
264
+ logger.debug("Closed NNG publisher: %s", name)
265
+ except Exception as ex:
266
+ logger.warning("Failed to close NNG publisher %s: %s", name, ex)
267
+ self._nng_publishers.clear()
268
+
178
269
  logger.info("RocketWelder client stopped")
179
270
 
180
271
  def show(self, cancellation_token: Optional[threading.Event] = None) -> None:
@@ -0,0 +1,420 @@
1
+ """
2
+ Segmentation result serialization protocol.
3
+
4
+ Binary protocol for efficient streaming of instance segmentation results.
5
+ Compatible with C# implementation for cross-platform interoperability.
6
+
7
+ Protocol (per frame):
8
+ [FrameId: 8B little-endian][Width: varint][Height: varint]
9
+ [classId: 1B][instanceId: 1B][pointCount: varint][points: delta+varint...]
10
+ [classId: 1B][instanceId: 1B][pointCount: varint][points: delta+varint...]
11
+ ...
12
+
13
+ Features:
14
+ - Delta encoding for adjacent contour points (efficient compression)
15
+ - Varint encoding for variable-length integers
16
+ - ZigZag encoding for signed deltas
17
+ - Explicit little-endian for cross-platform compatibility
18
+ - Frame boundaries handled by transport layer (IFrameSink)
19
+ - NumPy array support for efficient processing
20
+ """
21
+
22
+ import io
23
+ import struct
24
+ from dataclasses import dataclass
25
+ from typing import BinaryIO, Iterator, List, Optional, Tuple, Union
26
+
27
+ import numpy as np
28
+ import numpy.typing as npt
29
+ from typing_extensions import TypeAlias
30
+
31
+ from .transport import IFrameSink, StreamFrameSink
32
+
33
+ # Type aliases
34
+ Point = Tuple[int, int]
35
+ PointArray: TypeAlias = npt.NDArray[np.int32] # Shape: (N, 2)
36
+
37
+
38
+ def _write_varint(stream: BinaryIO, value: int) -> None:
39
+ """Write unsigned integer as varint."""
40
+ if value < 0:
41
+ raise ValueError(f"Varint requires non-negative value, got {value}")
42
+
43
+ while value >= 0x80:
44
+ stream.write(bytes([value & 0x7F | 0x80]))
45
+ value >>= 7
46
+ stream.write(bytes([value & 0x7F]))
47
+
48
+
49
+ def _read_varint(stream: BinaryIO) -> int:
50
+ """Read varint from stream and decode to unsigned integer."""
51
+ result = 0
52
+ shift = 0
53
+
54
+ while True:
55
+ if shift >= 35: # Max 5 bytes for uint32
56
+ raise ValueError("Varint too long (corrupted stream)")
57
+
58
+ byte_data = stream.read(1)
59
+ if not byte_data:
60
+ raise EOFError("Unexpected end of stream reading varint")
61
+
62
+ byte = byte_data[0]
63
+ result |= (byte & 0x7F) << shift
64
+ shift += 7
65
+
66
+ if not (byte & 0x80):
67
+ break
68
+
69
+ return result
70
+
71
+
72
+ def _zigzag_encode(value: int) -> int:
73
+ """ZigZag encode signed integer to unsigned."""
74
+ return (value << 1) ^ (value >> 31)
75
+
76
+
77
+ def _zigzag_decode(value: int) -> int:
78
+ """ZigZag decode unsigned integer to signed."""
79
+ return (value >> 1) ^ -(value & 1)
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class SegmentationFrameMetadata:
84
+ """Metadata for a segmentation frame."""
85
+
86
+ frame_id: int
87
+ width: int
88
+ height: int
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class SegmentationInstance:
93
+ """A single instance in a segmentation result."""
94
+
95
+ class_id: int
96
+ instance_id: int
97
+ points: PointArray # NumPy array of shape (N, 2) with dtype int32
98
+
99
+ def to_normalized(self, width: int, height: int) -> npt.NDArray[np.float32]:
100
+ """
101
+ Convert points to normalized coordinates [0-1] range.
102
+
103
+ Args:
104
+ width: Frame width in pixels
105
+ height: Frame height in pixels
106
+
107
+ Returns:
108
+ NumPy array of shape (N, 2) with dtype float32, normalized to [0-1]
109
+ """
110
+ if width <= 0 or height <= 0:
111
+ raise ValueError("Width and height must be positive")
112
+
113
+ # Vectorized operation - very efficient
114
+ normalized = self.points.astype(np.float32)
115
+ normalized[:, 0] /= width
116
+ normalized[:, 1] /= height
117
+ return normalized
118
+
119
+ def to_list(self) -> List[Point]:
120
+ """Convert points to list of tuples."""
121
+ return [(int(x), int(y)) for x, y in self.points]
122
+
123
+
124
+ class SegmentationResultWriter:
125
+ """
126
+ Writes segmentation results for a single frame via IFrameSink.
127
+
128
+ Frames are buffered in memory and written atomically on close.
129
+
130
+ Thread-safe: No (caller must synchronize)
131
+ """
132
+
133
+ def __init__(
134
+ self,
135
+ frame_id: int,
136
+ width: int,
137
+ height: int,
138
+ stream: Optional[BinaryIO] = None,
139
+ *,
140
+ frame_sink: Optional[IFrameSink] = None,
141
+ ) -> None:
142
+ """
143
+ Initialize writer for a single frame.
144
+
145
+ Args:
146
+ frame_id: Unique frame identifier
147
+ width: Frame width in pixels
148
+ height: Frame height in pixels
149
+ stream: Binary stream (convenience - auto-wraps in StreamFrameSink)
150
+ frame_sink: IFrameSink to write frame to (keyword-only, transport-agnostic)
151
+
152
+ Note:
153
+ Either stream or frame_sink must be provided (not both).
154
+ For convenience, stream is the primary parameter (auto-wraps in StreamFrameSink).
155
+ """
156
+ if frame_sink is None and stream is None:
157
+ raise TypeError("Either stream or frame_sink must be provided")
158
+
159
+ if frame_sink is not None and stream is not None:
160
+ raise TypeError("Cannot provide both stream and frame_sink")
161
+
162
+ # Convenience: auto-wrap stream in StreamFrameSink
163
+ if stream is not None:
164
+ self._frame_sink: IFrameSink = StreamFrameSink(stream, leave_open=True)
165
+ self._owns_sink = False # Don't close the stream wrapper
166
+ else:
167
+ assert frame_sink is not None
168
+ self._frame_sink = frame_sink
169
+ self._owns_sink = False
170
+
171
+ self._frame_id = frame_id
172
+ self._width = width
173
+ self._height = height
174
+ self._buffer = io.BytesIO() # Buffer frame for atomic write
175
+ self._header_written = False
176
+ self._disposed = False
177
+
178
+ def _ensure_header_written(self) -> None:
179
+ """Write frame header to buffer if not already written."""
180
+ if self._header_written:
181
+ return
182
+
183
+ # Write FrameId (8 bytes, little-endian)
184
+ self._buffer.write(struct.pack("<Q", self._frame_id))
185
+
186
+ # Write Width and Height as varints
187
+ _write_varint(self._buffer, self._width)
188
+ _write_varint(self._buffer, self._height)
189
+
190
+ self._header_written = True
191
+
192
+ def append(
193
+ self,
194
+ class_id: int,
195
+ instance_id: int,
196
+ points: Union[List[Point], PointArray],
197
+ ) -> None:
198
+ """
199
+ Append an instance with contour points.
200
+
201
+ Args:
202
+ class_id: Object class ID (0-255)
203
+ instance_id: Instance ID within class (0-255)
204
+ points: List of (x, y) tuples or NumPy array of shape (N, 2)
205
+ """
206
+ if class_id < 0 or class_id > 255:
207
+ raise ValueError(f"class_id must be 0-255, got {class_id}")
208
+ if instance_id < 0 or instance_id > 255:
209
+ raise ValueError(f"instance_id must be 0-255, got {instance_id}")
210
+
211
+ self._ensure_header_written()
212
+
213
+ # Convert to NumPy array if needed
214
+ if not isinstance(points, np.ndarray):
215
+ points_array = np.array(points, dtype=np.int32)
216
+ else:
217
+ points_array = points.astype(np.int32)
218
+
219
+ if points_array.ndim != 2 or points_array.shape[1] != 2:
220
+ raise ValueError(f"Points must be shape (N, 2), got {points_array.shape}")
221
+
222
+ # Write class_id and instance_id
223
+ self._buffer.write(bytes([class_id, instance_id]))
224
+
225
+ # Write point count
226
+ point_count = len(points_array)
227
+ _write_varint(self._buffer, point_count)
228
+
229
+ if point_count == 0:
230
+ return
231
+
232
+ # Write first point (absolute coordinates)
233
+ first_point = points_array[0]
234
+ _write_varint(self._buffer, _zigzag_encode(int(first_point[0])))
235
+ _write_varint(self._buffer, _zigzag_encode(int(first_point[1])))
236
+
237
+ # Write remaining points (delta encoded)
238
+ for i in range(1, point_count):
239
+ delta_x = int(points_array[i, 0] - points_array[i - 1, 0])
240
+ delta_y = int(points_array[i, 1] - points_array[i - 1, 1])
241
+ _write_varint(self._buffer, _zigzag_encode(delta_x))
242
+ _write_varint(self._buffer, _zigzag_encode(delta_y))
243
+
244
+ def flush(self) -> None:
245
+ """Flush buffered frame via frame sink without closing."""
246
+ if self._disposed:
247
+ return
248
+
249
+ # Ensure header is written (even if no instances appended)
250
+ self._ensure_header_written()
251
+
252
+ # Write buffered frame atomically via sink
253
+ frame_data = self._buffer.getvalue()
254
+ self._frame_sink.write_frame(frame_data)
255
+ self._frame_sink.flush()
256
+
257
+ def close(self) -> None:
258
+ """Close writer and write buffered frame via frame sink."""
259
+ if self._disposed:
260
+ return
261
+
262
+ self._disposed = True
263
+
264
+ # Ensure header is written (even if no instances appended)
265
+ self._ensure_header_written()
266
+
267
+ # Send complete frame atomically via sink
268
+ frame_data = self._buffer.getvalue()
269
+ self._frame_sink.write_frame(frame_data)
270
+
271
+ # Clean up buffer
272
+ self._buffer.close()
273
+
274
+ def __enter__(self) -> "SegmentationResultWriter":
275
+ """Context manager entry."""
276
+ return self
277
+
278
+ def __exit__(self, *args: object) -> None:
279
+ """Context manager exit."""
280
+ self.close()
281
+
282
+
283
+ class SegmentationResultReader:
284
+ """
285
+ Reads segmentation results for a single frame.
286
+
287
+ Thread-safe: No (caller must synchronize)
288
+ Stream ownership: Caller must close stream
289
+ """
290
+
291
+ def __init__(self, stream: BinaryIO) -> None:
292
+ """
293
+ Initialize reader for a single frame.
294
+
295
+ Args:
296
+ stream: Binary stream to read from (must support read()).
297
+ Should contain raw frame data without length prefix.
298
+ Use StreamFrameSource to strip length prefixes from transport streams.
299
+ """
300
+ if not hasattr(stream, "read"):
301
+ raise TypeError("Stream must be a binary readable stream")
302
+
303
+ self._stream = stream
304
+ self._header_read = False
305
+ self._metadata: Optional[SegmentationFrameMetadata] = None
306
+
307
+ # Max points per instance - prevents OOM attacks
308
+ self._max_points_per_instance = 10_000_000 # 10M points
309
+
310
+ def _ensure_header_read(self) -> None:
311
+ """Read frame header if not already read."""
312
+ if self._header_read:
313
+ return
314
+
315
+ # Read FrameId (8 bytes, little-endian)
316
+ frame_id_bytes = self._stream.read(8)
317
+ if len(frame_id_bytes) != 8:
318
+ raise EOFError("Failed to read FrameId")
319
+ frame_id = struct.unpack("<Q", frame_id_bytes)[0]
320
+
321
+ # Read Width and Height as varints
322
+ width = _read_varint(self._stream)
323
+ height = _read_varint(self._stream)
324
+
325
+ self._metadata = SegmentationFrameMetadata(frame_id, width, height)
326
+ self._header_read = True
327
+
328
+ @property
329
+ def metadata(self) -> SegmentationFrameMetadata:
330
+ """Get frame metadata (frameId, width, height)."""
331
+ self._ensure_header_read()
332
+ assert self._metadata is not None
333
+ return self._metadata
334
+
335
+ def read_next(self) -> Optional[SegmentationInstance]:
336
+ """
337
+ Read next instance from stream.
338
+
339
+ Returns:
340
+ SegmentationInstance if available, None if end of stream reached
341
+
342
+ Raises:
343
+ EOFError: If stream ends unexpectedly
344
+ ValueError: If data is corrupted
345
+ """
346
+ self._ensure_header_read()
347
+
348
+ # Read class_id and instance_id (buffered for performance)
349
+ header = self._stream.read(2)
350
+
351
+ if len(header) == 0:
352
+ # End of stream - no more instances
353
+ return None
354
+
355
+ if len(header) != 2:
356
+ raise EOFError("Unexpected end of stream reading instance header")
357
+
358
+ class_id = header[0]
359
+ instance_id = header[1]
360
+
361
+ # Read point count with validation
362
+ point_count = _read_varint(self._stream)
363
+ if point_count > self._max_points_per_instance:
364
+ raise ValueError(
365
+ f"Point count {point_count} exceeds maximum " f"{self._max_points_per_instance}"
366
+ )
367
+
368
+ if point_count == 0:
369
+ # Empty points array
370
+ points = np.empty((0, 2), dtype=np.int32)
371
+ return SegmentationInstance(class_id, instance_id, points)
372
+
373
+ # Allocate NumPy array for points
374
+ points = np.empty((point_count, 2), dtype=np.int32)
375
+
376
+ # Read first point (absolute coordinates)
377
+ x = _zigzag_decode(_read_varint(self._stream))
378
+ y = _zigzag_decode(_read_varint(self._stream))
379
+ points[0] = [x, y]
380
+
381
+ # Read remaining points (delta encoded)
382
+ for i in range(1, point_count):
383
+ delta_x = _zigzag_decode(_read_varint(self._stream))
384
+ delta_y = _zigzag_decode(_read_varint(self._stream))
385
+ x += delta_x
386
+ y += delta_y
387
+ points[i] = [x, y]
388
+
389
+ return SegmentationInstance(class_id, instance_id, points)
390
+
391
+ def read_all(self) -> List[SegmentationInstance]:
392
+ """
393
+ Read all instances from frame.
394
+
395
+ Returns:
396
+ List of all instances in frame
397
+ """
398
+ instances = []
399
+ while True:
400
+ instance = self.read_next()
401
+ if instance is None:
402
+ break
403
+ instances.append(instance)
404
+ return instances
405
+
406
+ def __iter__(self) -> Iterator[SegmentationInstance]:
407
+ """Iterate over instances in frame."""
408
+ while True:
409
+ instance = self.read_next()
410
+ if instance is None:
411
+ break
412
+ yield instance
413
+
414
+ def __enter__(self) -> "SegmentationResultReader":
415
+ """Context manager entry."""
416
+ return self
417
+
418
+ def __exit__(self, *args: object) -> None:
419
+ """Context manager exit."""
420
+ pass