mcap-codec-support 0.2.0__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.
@@ -0,0 +1 @@
1
+ """Reusable MCAP encoder and decoder factories."""
@@ -0,0 +1,56 @@
1
+ """Shared TypedDict shapes for decoded ROS messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, TypedDict
6
+
7
+ if TYPE_CHECKING:
8
+ from pointcloud2 import PointFieldDict
9
+
10
+
11
+ class Stamp(TypedDict):
12
+ sec: int
13
+ nanosec: int
14
+
15
+
16
+ class Header(TypedDict):
17
+ stamp: Stamp
18
+ frame_id: str
19
+
20
+
21
+ class Pointcloud2Dict(TypedDict):
22
+ """Dict shape mirroring ``sensor_msgs/PointCloud2``.
23
+
24
+ Compatible with ``pointcloud2.read_points`` once wrapped in a
25
+ ``SimpleNamespace``.
26
+ """
27
+
28
+ header: Header
29
+ height: int
30
+ width: int
31
+ fields: list[PointFieldDict]
32
+ is_bigendian: bool
33
+ point_step: int
34
+ row_step: int
35
+ data: bytes
36
+ is_dense: bool
37
+
38
+
39
+ class CompressedImageDict(TypedDict):
40
+ """Dict shape mirroring ``sensor_msgs/CompressedImage``."""
41
+
42
+ header: Header
43
+ format: str
44
+ data: bytes
45
+
46
+
47
+ class ImageDict(TypedDict):
48
+ """Dict shape mirroring ``sensor_msgs/Image`` for raw RGB frames."""
49
+
50
+ header: Header
51
+ height: int
52
+ width: int
53
+ encoding: str
54
+ is_bigendian: int
55
+ step: int
56
+ data: bytes
@@ -0,0 +1,94 @@
1
+ """Shared structural Protocols for encoder, decoder, and ROS message inputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol
6
+
7
+ if TYPE_CHECKING:
8
+ import numpy as np
9
+ from av import VideoFrame
10
+ from small_mcap import DecodedMessage
11
+
12
+ from mcap_codec_support.video.common import DecompressedFrame, EncoderConfig
13
+
14
+
15
+ class RawImageMessage(Protocol):
16
+ """Structural shape of a ROS ``sensor_msgs/Image`` message."""
17
+
18
+ width: int
19
+ height: int
20
+ encoding: str
21
+ data: bytes
22
+
23
+
24
+ class CompressedImageMsg(Protocol):
25
+ """Structural shape of a ROS ``sensor_msgs/CompressedImage`` message."""
26
+
27
+ @property
28
+ def data(self) -> bytes | bytearray | memoryview: ...
29
+
30
+
31
+ class VideoEncoderProtocol(Protocol):
32
+ """Structural interface shared by VideoEncoder and FFmpegVideoEncoder."""
33
+
34
+ config: EncoderConfig
35
+
36
+ def encode(self, frame: VideoFrame) -> bytes | None: ...
37
+
38
+ def flush_packets(self) -> list[bytes]: ...
39
+
40
+
41
+ class VideoDecompressorProtocol(Protocol):
42
+ """Decompresses H.264/H.265 video packets to image data."""
43
+
44
+ def decompress(self, video_data: bytes, codec: str) -> DecompressedFrame | None:
45
+ """Decompress a single video packet."""
46
+ ...
47
+
48
+ def flush(self) -> list[DecompressedFrame]:
49
+ """Flush any buffered frames from the decoder."""
50
+ ...
51
+
52
+
53
+ class VideoCompressionBackend(Protocol):
54
+ """Backend used by roscompress for CompressedVideo output."""
55
+
56
+ label: str
57
+ prefetch_supported: bool
58
+
59
+ def test_encoder(self, encoder_name: str) -> bool: ...
60
+
61
+ def resolve_encoder(self, codec: str) -> str: ...
62
+
63
+ def decode_compressed(self, data: bytes) -> tuple[VideoFrame, int, int]: ...
64
+
65
+ def decode_image(
66
+ self, msg: DecodedMessage, schema_name: str
67
+ ) -> tuple[VideoFrame, int, int]: ...
68
+
69
+ def create_encoder(
70
+ self,
71
+ width: int,
72
+ height: int,
73
+ codec_name: str,
74
+ quality: int,
75
+ *,
76
+ input_pix_fmt: str | None = None,
77
+ scale: tuple[int, int] | None = None,
78
+ ) -> VideoEncoderProtocol: ...
79
+
80
+ def get_pix_fmt(self, topic: str) -> str | None: ...
81
+
82
+
83
+ class VideoFileStrategy(Protocol):
84
+ """Strategy contract used by the lazy MP4 file writer."""
85
+
86
+ config: EncoderConfig
87
+
88
+ def write_compressed(self, data: bytes, log_time_ns: int) -> None: ...
89
+
90
+ def write_raw(self, data: bytes, log_time_ns: int) -> None: ...
91
+
92
+ def write_rgb(self, rgb: np.ndarray, log_time_ns: int) -> None: ...
93
+
94
+ def close(self) -> int: ...
@@ -0,0 +1,17 @@
1
+ """Schema-name canonicalisation shared across packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def normalize_schema_name(name: str) -> str:
7
+ """Canonicalise ROS1/ROS2 schema names to the short ``pkg/Type`` form.
8
+
9
+ ROS2 IDL names appear as ``pkg/msg/Type`` (or ``pkg/srv/Type``,
10
+ ``pkg/action/Type``); ROS1 + foxglove flatbuffer names are already
11
+ ``pkg/Type``. Stripping the middle segment lets schema sets carry one
12
+ canonical entry per type instead of two.
13
+ """
14
+ parts = name.split("/")
15
+ if len(parts) == 3 and parts[1] in ("msg", "srv", "action"):
16
+ return f"{parts[0]}/{parts[2]}"
17
+ return name
@@ -0,0 +1,45 @@
1
+ """Point cloud MCAP factories and compression helpers."""
2
+
3
+ from mcap_codec_support.pointcloud.compression import (
4
+ CloudiniPointCloudCompressor,
5
+ DracoPointCloudCompressor,
6
+ PointCloudCompressionError,
7
+ PointCloudCompressorProtocol,
8
+ build_compressed_pointcloud2_message,
9
+ build_foxglove_compressed_pointcloud_message,
10
+ )
11
+ from mcap_codec_support.pointcloud.factories import (
12
+ CompressedPointCloudDecoderFactory,
13
+ CompressedPointCloudDecompressFactory,
14
+ Pointcloud2DecoderFactory,
15
+ PointCloudDecompressFactory,
16
+ )
17
+ from mcap_codec_support.pointcloud.schemas import (
18
+ CLOUDINI_COMPRESSED_POINTCLOUD2,
19
+ COMPRESSED_POINTCLOUD2,
20
+ COMPRESSED_POINTCLOUD2_SCHEMA,
21
+ FOXGLOVE_COMPRESSED_POINTCLOUD,
22
+ FOXGLOVE_COMPRESSED_POINTCLOUD_SCHEMA,
23
+ POINTCLOUD2,
24
+ POINTCLOUD2_SCHEMAS,
25
+ )
26
+
27
+ __all__ = [
28
+ "CLOUDINI_COMPRESSED_POINTCLOUD2",
29
+ "COMPRESSED_POINTCLOUD2",
30
+ "COMPRESSED_POINTCLOUD2_SCHEMA",
31
+ "FOXGLOVE_COMPRESSED_POINTCLOUD",
32
+ "FOXGLOVE_COMPRESSED_POINTCLOUD_SCHEMA",
33
+ "POINTCLOUD2",
34
+ "POINTCLOUD2_SCHEMAS",
35
+ "CloudiniPointCloudCompressor",
36
+ "CompressedPointCloudDecoderFactory",
37
+ "CompressedPointCloudDecompressFactory",
38
+ "DracoPointCloudCompressor",
39
+ "PointCloudCompressionError",
40
+ "PointCloudCompressorProtocol",
41
+ "PointCloudDecompressFactory",
42
+ "Pointcloud2DecoderFactory",
43
+ "build_compressed_pointcloud2_message",
44
+ "build_foxglove_compressed_pointcloud_message",
45
+ ]
@@ -0,0 +1,340 @@
1
+ """PointCloud2 compression helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import TYPE_CHECKING, Any, Protocol
7
+
8
+ if TYPE_CHECKING:
9
+ import numpy as np
10
+ import numpy.typing as npt
11
+ from pointcloud2.messages import Pointcloud2Msg, PointFieldMsg
12
+ from pureini import CompressionOption, EncodingInfo, EncodingOptions
13
+
14
+ DRACO_MAX_QUANTIZATION_BITS = 30
15
+
16
+
17
+ class PointCloudCompressionError(ValueError):
18
+ """Raised when a point cloud cannot be compressed into the requested format."""
19
+
20
+
21
+ class PointCloudCompressorProtocol(Protocol):
22
+ """Common interface implemented by Cloudini and Draco point cloud compressors."""
23
+
24
+ def compress(self, msg: Pointcloud2Msg) -> bytes: ...
25
+
26
+
27
+ def _build_encoding_info(
28
+ msg: Pointcloud2Msg,
29
+ encoding_opt: EncodingOptions,
30
+ compression_opt: CompressionOption,
31
+ resolution: float,
32
+ ) -> EncodingInfo:
33
+ """Build pureini EncodingInfo from a decoded ROS2 PointCloud2 message."""
34
+ from pureini import EncodingInfo, FieldType, PointField # noqa: PLC0415
35
+
36
+ info = EncodingInfo()
37
+ info.width = msg.width
38
+ info.height = msg.height
39
+ info.point_step = msg.point_step
40
+ info.encoding_opt = encoding_opt
41
+ info.compression_opt = compression_opt
42
+
43
+ info.fields = []
44
+ for ros_field in msg.fields:
45
+ field = PointField(
46
+ name=ros_field.name,
47
+ offset=ros_field.offset,
48
+ type=FieldType(ros_field.datatype),
49
+ resolution=resolution if ros_field.datatype == 7 else None,
50
+ )
51
+ info.fields.append(field)
52
+
53
+ return info
54
+
55
+
56
+ class CloudiniPointCloudCompressor:
57
+ """Cloudini point cloud compressor with encoder caching."""
58
+
59
+ def __init__(
60
+ self,
61
+ encoding: str = "lossy",
62
+ compression: str = "zstd",
63
+ resolution: float = 0.01,
64
+ ) -> None:
65
+ from pureini import CompressionOption, EncodingOptions, PointcloudEncoder # noqa: PLC0415
66
+
67
+ encoding_map = {
68
+ "lossy": EncodingOptions.LOSSY,
69
+ "lossless": EncodingOptions.LOSSLESS,
70
+ "none": EncodingOptions.NONE,
71
+ }
72
+ compression_map = {
73
+ "zstd": CompressionOption.ZSTD,
74
+ "lz4": CompressionOption.LZ4,
75
+ "none": CompressionOption.NONE,
76
+ }
77
+
78
+ if encoding not in encoding_map:
79
+ raise ValueError(
80
+ f"Unknown encoding '{encoding}'. Choose from: {', '.join(encoding_map)}"
81
+ )
82
+ if compression not in compression_map:
83
+ raise ValueError(
84
+ f"Unknown compression '{compression}'. Choose from: {', '.join(compression_map)}"
85
+ )
86
+
87
+ self._encoding_opt = encoding_map[encoding]
88
+ self._compression_opt = compression_map[compression]
89
+ self._resolution = resolution
90
+ self._PointcloudEncoder = PointcloudEncoder
91
+
92
+ self._cached_info: EncodingInfo | None = None
93
+ self._cached_encoder: PointcloudEncoder | None = None
94
+
95
+ def compress(self, msg: Pointcloud2Msg) -> bytes:
96
+ """Compress a decoded ROS2 PointCloud2 message and return raw bytes."""
97
+ info = _build_encoding_info(
98
+ msg, self._encoding_opt, self._compression_opt, self._resolution
99
+ )
100
+ if self._cached_info != info:
101
+ self._cached_info = info
102
+ self._cached_encoder = self._PointcloudEncoder(info)
103
+ return self._cached_encoder.encode(bytes(msg.data)) # type: ignore[union-attr]
104
+
105
+
106
+ def _compute_position_quantization(
107
+ positions: npt.NDArray[np.float32],
108
+ resolution: float,
109
+ ) -> tuple[int, float, npt.NDArray[np.float32]]:
110
+ import numpy as np # noqa: PLC0415
111
+
112
+ origin = positions.min(axis=0).astype(np.float32, copy=False)
113
+ spans = positions.max(axis=0) - origin
114
+ quantization_range = max(float(np.max(spans)), resolution)
115
+ bits = math.ceil(math.log2((quantization_range / resolution) + 1.0))
116
+ bits = min(max(bits, 1), DRACO_MAX_QUANTIZATION_BITS)
117
+ return bits, quantization_range, origin
118
+
119
+
120
+ def _native_numeric_array(values: npt.ArrayLike) -> npt.NDArray[np.number] | None:
121
+ import numpy as np # noqa: PLC0415
122
+
123
+ arr = np.asarray(values)
124
+ if arr.dtype.fields is not None or arr.dtype.kind not in "iuf":
125
+ return None
126
+ if not arr.dtype.isnative:
127
+ arr = arr.byteswap().view(arr.dtype.newbyteorder("="))
128
+
129
+ if arr.dtype.kind == "f":
130
+ arr = arr.astype(np.float32, copy=False)
131
+ elif arr.dtype.kind == "u":
132
+ if arr.dtype.itemsize <= 1:
133
+ arr = arr.astype(np.uint8, copy=False)
134
+ elif arr.dtype.itemsize <= 2:
135
+ arr = arr.astype(np.uint16, copy=False)
136
+ else:
137
+ arr = arr.astype(np.uint32, copy=False)
138
+ elif arr.size and int(arr.min()) >= 0:
139
+ max_value = int(arr.max())
140
+ if max_value <= np.iinfo(np.uint8).max:
141
+ arr = arr.astype(np.uint8, copy=False)
142
+ elif max_value <= np.iinfo(np.uint16).max:
143
+ arr = arr.astype(np.uint16, copy=False)
144
+ else:
145
+ arr = arr.astype(np.uint32, copy=False)
146
+ else:
147
+ arr = arr.astype(np.float32, copy=False)
148
+
149
+ arr = np.ascontiguousarray(arr)
150
+ if arr.ndim == 1:
151
+ return arr.reshape(-1, 1)
152
+ return arr.reshape(arr.shape[0], -1)
153
+
154
+
155
+ def _packed_color_attributes(
156
+ values: npt.ArrayLike, *, include_alpha: bool
157
+ ) -> dict[str, np.ndarray]:
158
+ import numpy as np # noqa: PLC0415
159
+
160
+ arr = np.asarray(values)
161
+ if arr.dtype.kind == "f":
162
+ packed = np.ascontiguousarray(arr.astype(np.float32, copy=False)).view(np.uint32)
163
+ else:
164
+ packed = arr.astype(np.uint32, copy=False)
165
+
166
+ attrs = {
167
+ "red": ((packed >> 16) & 0xFF).astype(np.uint8, copy=False).reshape(-1, 1),
168
+ "green": ((packed >> 8) & 0xFF).astype(np.uint8, copy=False).reshape(-1, 1),
169
+ "blue": (packed & 0xFF).astype(np.uint8, copy=False).reshape(-1, 1),
170
+ }
171
+ if include_alpha:
172
+ attrs["alpha"] = ((packed >> 24) & 0xFF).astype(np.uint8, copy=False).reshape(-1, 1)
173
+ return attrs
174
+
175
+
176
+ def _generic_attributes_from_points(
177
+ points: np.ndarray, fields: list[PointFieldMsg]
178
+ ) -> dict[str, np.ndarray]:
179
+ attrs: dict[str, np.ndarray] = {}
180
+ names = set(points.dtype.names or ())
181
+
182
+ for field in fields:
183
+ name = field.name
184
+ if not name or name in {"x", "y", "z"}:
185
+ continue
186
+
187
+ count = int(field.count)
188
+ if name in {"rgb", "rgba"}:
189
+ if count == 1 and name in names:
190
+ attrs.update(_packed_color_attributes(points[name], include_alpha=name == "rgba"))
191
+ continue
192
+ component_names = [f"{name}_{idx}" for idx in range(count)]
193
+ if count in {3, 4} and all(component in names for component in component_names):
194
+ for color, source in zip(
195
+ ("red", "green", "blue", "alpha"), component_names, strict=False
196
+ ):
197
+ arr = _native_numeric_array(points[source])
198
+ if arr is not None:
199
+ attrs[color] = arr
200
+ continue
201
+
202
+ if count == 1:
203
+ if name not in names:
204
+ continue
205
+ attr = _native_numeric_array(points[name])
206
+ else:
207
+ component_names = [f"{name}_{idx}" for idx in range(count)]
208
+ if not all(component in names for component in component_names):
209
+ continue
210
+ import numpy as np # noqa: PLC0415
211
+
212
+ attr = _native_numeric_array(np.column_stack([points[c] for c in component_names]))
213
+
214
+ if attr is not None:
215
+ attrs[name] = attr
216
+
217
+ return attrs
218
+
219
+
220
+ def build_foxglove_compressed_pointcloud_message(
221
+ msg: Pointcloud2Msg,
222
+ compressed_data: bytes,
223
+ *,
224
+ fmt: str = "draco",
225
+ ) -> dict[str, Any]:
226
+ """Build a Foxglove ``CompressedPointCloud`` ROS2 message dict."""
227
+ stamp = msg.header.stamp
228
+ return {
229
+ "timestamp": {
230
+ "sec": stamp.sec,
231
+ "nanosec": stamp.nanosec,
232
+ },
233
+ "frame_id": msg.header.frame_id,
234
+ "pose": {
235
+ "position": {"x": 0.0, "y": 0.0, "z": 0.0},
236
+ "orientation": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0},
237
+ },
238
+ "data": compressed_data,
239
+ "format": fmt,
240
+ }
241
+
242
+
243
+ def build_compressed_pointcloud2_message(
244
+ msg: Pointcloud2Msg,
245
+ compressed_data: bytes,
246
+ *,
247
+ fmt: str,
248
+ ) -> dict[str, Any]:
249
+ """Build a ``CompressedPointCloud2`` ROS2 message dict."""
250
+ stamp = msg.header.stamp
251
+ return {
252
+ "header": {
253
+ "stamp": {
254
+ "sec": stamp.sec,
255
+ "nanosec": stamp.nanosec,
256
+ },
257
+ "frame_id": msg.header.frame_id,
258
+ },
259
+ "height": msg.height,
260
+ "width": msg.width,
261
+ "fields": [
262
+ {
263
+ "name": field.name,
264
+ "offset": field.offset,
265
+ "datatype": field.datatype,
266
+ "count": field.count,
267
+ }
268
+ for field in msg.fields
269
+ ],
270
+ "is_bigendian": msg.is_bigendian,
271
+ "point_step": msg.point_step,
272
+ "row_step": msg.row_step,
273
+ "compressed_data": compressed_data,
274
+ "is_dense": msg.is_dense,
275
+ "format": fmt,
276
+ }
277
+
278
+
279
+ class DracoPointCloudCompressor:
280
+ """Draco-based PointCloud2 compressor for Foxglove CompressedPointCloud."""
281
+
282
+ def __init__(self, resolution: float = 0.01, compression_level: int = 7) -> None:
283
+ if resolution <= 0:
284
+ raise ValueError("resolution must be positive")
285
+ if not 0 <= compression_level <= 10:
286
+ raise ValueError("compression_level must be in [0, 10]")
287
+ self._resolution = resolution
288
+ self._compression_level = compression_level
289
+
290
+ def compress(self, msg: Pointcloud2Msg) -> bytes:
291
+ """Compress a decoded ROS2 PointCloud2 message and return Draco bytes."""
292
+ import DracoPy # noqa: PLC0415
293
+ import numpy as np # noqa: PLC0415
294
+ from pointcloud2 import read_points # noqa: PLC0415
295
+
296
+ point_records = read_points(msg, skip_nans=False)
297
+ names = set(point_records.dtype.names or ())
298
+ missing = {"x", "y", "z"} - names
299
+ if missing:
300
+ missing_fields = ", ".join(sorted(missing))
301
+ raise PointCloudCompressionError(
302
+ f"PointCloud2 is missing required field(s): {missing_fields}"
303
+ )
304
+
305
+ positions = np.column_stack(
306
+ [
307
+ point_records["x"].astype(np.float32, copy=False),
308
+ point_records["y"].astype(np.float32, copy=False),
309
+ point_records["z"].astype(np.float32, copy=False),
310
+ ]
311
+ )
312
+ finite_mask = np.isfinite(positions).all(axis=1)
313
+ if not finite_mask.all():
314
+ point_records = point_records[finite_mask]
315
+ positions = positions[finite_mask]
316
+ if positions.size == 0:
317
+ raise PointCloudCompressionError("PointCloud2 has no finite XYZ points")
318
+
319
+ quantization_bits, quantization_range, quantization_origin = _compute_position_quantization(
320
+ positions, self._resolution
321
+ )
322
+ generic_attributes = _generic_attributes_from_points(point_records, list(msg.fields))
323
+
324
+ return DracoPy.encode(
325
+ np.ascontiguousarray(positions, dtype=np.float32),
326
+ quantization_bits=quantization_bits,
327
+ compression_level=self._compression_level,
328
+ quantization_range=quantization_range,
329
+ quantization_origin=quantization_origin,
330
+ create_metadata=False,
331
+ preserve_order=True,
332
+ generic_attributes=generic_attributes,
333
+ )
334
+
335
+ def compress_message(self, msg: Pointcloud2Msg) -> dict[str, Any]:
336
+ """Compress and wrap the payload in a Foxglove CompressedPointCloud message."""
337
+ return build_foxglove_compressed_pointcloud_message(msg, self.compress(msg))
338
+
339
+
340
+ PointCloudCompressor = CloudiniPointCloudCompressor