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.
- mcap_codec_support/__init__.py +1 -0
- mcap_codec_support/_messages.py +56 -0
- mcap_codec_support/_protocols.py +94 -0
- mcap_codec_support/_schemas.py +17 -0
- mcap_codec_support/pointcloud/__init__.py +45 -0
- mcap_codec_support/pointcloud/compression.py +340 -0
- mcap_codec_support/pointcloud/factories.py +473 -0
- mcap_codec_support/pointcloud/schemas.py +111 -0
- mcap_codec_support/py.typed +0 -0
- mcap_codec_support/video/__init__.py +55 -0
- mcap_codec_support/video/common.py +283 -0
- mcap_codec_support/video/compression.py +269 -0
- mcap_codec_support/video/factories.py +123 -0
- mcap_codec_support/video/ffmpeg.py +944 -0
- mcap_codec_support/video/file_writer.py +411 -0
- mcap_codec_support/video/pyav.py +413 -0
- mcap_codec_support/video/schemas.py +52 -0
- mcap_codec_support-0.2.0.dist-info/METADATA +53 -0
- mcap_codec_support-0.2.0.dist-info/RECORD +20 -0
- mcap_codec_support-0.2.0.dist-info/WHEEL +4 -0
|
@@ -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
|