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.
- rocket_welder_sdk/__init__.py +39 -6
- rocket_welder_sdk/controllers.py +138 -101
- rocket_welder_sdk/frame_metadata.py +138 -0
- rocket_welder_sdk/high_level/__init__.py +52 -0
- rocket_welder_sdk/high_level/client.py +262 -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/schema.py +197 -0
- rocket_welder_sdk/high_level/transport_protocol.py +238 -0
- rocket_welder_sdk/keypoints_protocol.py +642 -0
- rocket_welder_sdk/rocket_welder_client.py +94 -3
- rocket_welder_sdk/segmentation_result.py +420 -0
- rocket_welder_sdk/session_id.py +238 -0
- rocket_welder_sdk/transport/__init__.py +30 -0
- rocket_welder_sdk/transport/frame_sink.py +77 -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-1.1.32.dist-info → rocket_welder_sdk-1.1.34.dist-info}/METADATA +15 -2
- rocket_welder_sdk-1.1.34.dist-info/RECORD +39 -0
- rocket_welder_sdk-1.1.32.dist-info/RECORD +0 -22
- {rocket_welder_sdk-1.1.32.dist-info → rocket_welder_sdk-1.1.34.dist-info}/WHEEL +0 -0
- {rocket_welder_sdk-1.1.32.dist-info → rocket_welder_sdk-1.1.34.dist-info}/top_level.txt +0 -0
|
@@ -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(
|
|
129
|
-
#
|
|
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
|
-
|
|
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
|