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,283 @@
|
|
|
1
|
+
"""Shared encoder types, config, and option builders — no PyAV dependency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from mcap_codec_support._protocols import RawImageMessage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class VideoEncoderError(Exception):
|
|
19
|
+
"""Raised when encoding fails."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VideoCodec(str, Enum):
|
|
23
|
+
H264 = "h264"
|
|
24
|
+
H265 = "h265"
|
|
25
|
+
VP9 = "vp9"
|
|
26
|
+
AV1 = "av1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EncoderBackend(str, Enum):
|
|
30
|
+
AUTO = "auto"
|
|
31
|
+
SOFTWARE = "software"
|
|
32
|
+
VIDEOTOOLBOX = "videotoolbox"
|
|
33
|
+
NVENC = "nvenc"
|
|
34
|
+
VAAPI = "vaapi"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EncoderMode(str, Enum):
|
|
38
|
+
"""Which video encoder backend to use."""
|
|
39
|
+
|
|
40
|
+
AUTO = "auto"
|
|
41
|
+
PYAV = "pyav"
|
|
42
|
+
FFMPEG_CLI = "ffmpeg-cli"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class EncoderConfig:
|
|
47
|
+
"""Configuration for a video encoder."""
|
|
48
|
+
|
|
49
|
+
width: int
|
|
50
|
+
height: int
|
|
51
|
+
codec_name: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class DecompressedFrame:
|
|
56
|
+
"""Result of decompressing a single video frame."""
|
|
57
|
+
|
|
58
|
+
data: bytes
|
|
59
|
+
"""JPEG bytes (when ``is_jpeg=True``) or raw RGB24 bytes."""
|
|
60
|
+
width: int
|
|
61
|
+
height: int
|
|
62
|
+
is_jpeg: bool
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
DEFAULT_FPS: float = 30.0
|
|
66
|
+
DEFAULT_GOP_SIZE: int = 30
|
|
67
|
+
|
|
68
|
+
# Mapping from short codec name to software (CPU) encoder name.
|
|
69
|
+
SOFTWARE_CODEC_MAP: dict[str, str] = {
|
|
70
|
+
"h264": "libx264",
|
|
71
|
+
"h265": "libx265",
|
|
72
|
+
"vp9": "libvpx-vp9",
|
|
73
|
+
"av1": "libaom-av1",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Mapping from short codec name to hardware encoder names per backend.
|
|
77
|
+
HARDWARE_CODEC_MAP: dict[str, dict[str, str]] = {
|
|
78
|
+
"h264": {
|
|
79
|
+
"videotoolbox": "h264_videotoolbox",
|
|
80
|
+
"nvenc": "h264_nvenc",
|
|
81
|
+
"vaapi": "h264_vaapi",
|
|
82
|
+
},
|
|
83
|
+
"h265": {
|
|
84
|
+
"videotoolbox": "hevc_videotoolbox",
|
|
85
|
+
"nvenc": "hevc_nvenc",
|
|
86
|
+
"vaapi": "hevc_vaapi",
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_software_encoder(codec: str) -> str:
|
|
92
|
+
"""Return the software encoder name for *codec*.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: If *codec* is not in SOFTWARE_CODEC_MAP.
|
|
96
|
+
"""
|
|
97
|
+
sw = SOFTWARE_CODEC_MAP.get(codec)
|
|
98
|
+
if not sw:
|
|
99
|
+
raise ValueError(f"Unsupported codec '{codec}'. Supported: {', '.join(SOFTWARE_CODEC_MAP)}")
|
|
100
|
+
return sw
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_encoder_options(
|
|
104
|
+
codec_name: str,
|
|
105
|
+
quality: int,
|
|
106
|
+
width: int,
|
|
107
|
+
height: int,
|
|
108
|
+
*,
|
|
109
|
+
preset: str | None = None,
|
|
110
|
+
) -> tuple[dict[str, str], int | None]:
|
|
111
|
+
"""Build codec-specific encoder options from user-facing quality (CRF).
|
|
112
|
+
|
|
113
|
+
Returns (options_dict, bit_rate_or_none).
|
|
114
|
+
"""
|
|
115
|
+
if codec_name == "libx264":
|
|
116
|
+
return {
|
|
117
|
+
"crf": str(quality),
|
|
118
|
+
"preset": preset or "superfast",
|
|
119
|
+
"tune": "zerolatency",
|
|
120
|
+
}, None
|
|
121
|
+
if codec_name == "libx265":
|
|
122
|
+
return {
|
|
123
|
+
"crf": str(quality),
|
|
124
|
+
"preset": preset or "superfast",
|
|
125
|
+
}, None
|
|
126
|
+
if codec_name in {"h264_videotoolbox", "hevc_videotoolbox"}:
|
|
127
|
+
pixel_scale = (width * height) / (1920 * 1080)
|
|
128
|
+
bit_rate = int(5_000_000 * (2 ** ((28 - quality) / 6)) * pixel_scale)
|
|
129
|
+
return {}, bit_rate
|
|
130
|
+
if codec_name in {"h264_nvenc", "hevc_nvenc"}:
|
|
131
|
+
return {"rc": "vbr", "cq": str(quality)}, None
|
|
132
|
+
if codec_name in {"h264_vaapi", "hevc_vaapi"}:
|
|
133
|
+
return {"qp": str(quality)}, None
|
|
134
|
+
if codec_name == "libvpx-vp9":
|
|
135
|
+
opts: dict[str, str] = {"cpu-used": "4", "crf": str(quality)}
|
|
136
|
+
if preset:
|
|
137
|
+
opts["deadline"] = preset
|
|
138
|
+
return opts, None
|
|
139
|
+
if codec_name == "libaom-av1":
|
|
140
|
+
opts = {"cpu-used": "6", "crf": str(quality)}
|
|
141
|
+
if preset:
|
|
142
|
+
opts["usage"] = preset
|
|
143
|
+
return opts, None
|
|
144
|
+
return {}, None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_encoder_options(codec: VideoCodec, encoder_name: str) -> dict[str, str]:
|
|
148
|
+
"""Return encoder preset options for bitrate-mode encoding (e.g. file output)."""
|
|
149
|
+
options: dict[str, str] = {}
|
|
150
|
+
if "nvenc" in encoder_name:
|
|
151
|
+
options["preset"] = "p4"
|
|
152
|
+
elif (
|
|
153
|
+
codec in (VideoCodec.H264, VideoCodec.H265) and "libx264" in encoder_name
|
|
154
|
+
) or "libx265" in encoder_name:
|
|
155
|
+
options["preset"] = "medium"
|
|
156
|
+
return options
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Dimension helpers
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def calculate_downscale_dimensions(width: int, height: int, max_dimension: int) -> tuple[int, int]:
|
|
165
|
+
"""Downscale dimensions to fit within max_dimension, preserving aspect ratio.
|
|
166
|
+
|
|
167
|
+
Ensures both dimensions are even (required for yuv420p).
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def ensure_even(value: int) -> int:
|
|
171
|
+
return value if value % 2 == 0 else max(value - 1, 2)
|
|
172
|
+
|
|
173
|
+
if width <= max_dimension and height <= max_dimension:
|
|
174
|
+
return ensure_even(width), ensure_even(height)
|
|
175
|
+
|
|
176
|
+
aspect_ratio = width / height
|
|
177
|
+
if width > height:
|
|
178
|
+
new_width = max_dimension
|
|
179
|
+
new_height = int(new_width / aspect_ratio)
|
|
180
|
+
else:
|
|
181
|
+
new_height = max_dimension
|
|
182
|
+
new_width = int(new_height * aspect_ratio)
|
|
183
|
+
|
|
184
|
+
return ensure_even(new_width), ensure_even(new_height)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def raw_image_to_array(message: RawImageMessage) -> np.ndarray:
|
|
188
|
+
"""Convert a ROS Image message to an RGB numpy array."""
|
|
189
|
+
import numpy as np # noqa: PLC0415
|
|
190
|
+
|
|
191
|
+
if not message.data:
|
|
192
|
+
raise VideoEncoderError("Image has no data")
|
|
193
|
+
|
|
194
|
+
width = message.width
|
|
195
|
+
height = message.height
|
|
196
|
+
encoding = str(message.encoding).lower()
|
|
197
|
+
data = bytes(message.data)
|
|
198
|
+
|
|
199
|
+
if encoding in {"rgb", "rgb8"}:
|
|
200
|
+
array = np.frombuffer(data, dtype=np.uint8).reshape(height, width, 3)
|
|
201
|
+
return array.copy()
|
|
202
|
+
if encoding in {"bgr", "bgr8"}:
|
|
203
|
+
array = np.frombuffer(data, dtype=np.uint8).reshape(height, width, 3)
|
|
204
|
+
return array[..., ::-1].copy()
|
|
205
|
+
if encoding in {"mono", "mono8", "8uc1"}:
|
|
206
|
+
mono_array = np.frombuffer(data, dtype=np.uint8).reshape(height, width)
|
|
207
|
+
return np.repeat(mono_array[:, :, None], 3, axis=2)
|
|
208
|
+
|
|
209
|
+
raise VideoEncoderError(f"Unsupported image encoding: {message.encoding}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# Encoder resolution
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
# Platform → hardware backend probe order.
|
|
217
|
+
_HW_PROBE_ORDER: dict[str, list[str]] = {
|
|
218
|
+
"Darwin": ["videotoolbox"],
|
|
219
|
+
"Linux": ["nvenc", "vaapi"],
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def resolve_encoder(
|
|
224
|
+
codec: str,
|
|
225
|
+
*,
|
|
226
|
+
test_fn: Callable[[str], bool],
|
|
227
|
+
use_hardware: bool = True,
|
|
228
|
+
) -> str:
|
|
229
|
+
"""Pick the best available encoder for *codec*.
|
|
230
|
+
|
|
231
|
+
Probes hardware encoders first (when *use_hardware* is True), then
|
|
232
|
+
falls back to the software encoder. Uses *test_fn* to check whether
|
|
233
|
+
a given encoder name is available on the system.
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
ValueError: If no encoder is found for *codec*.
|
|
237
|
+
"""
|
|
238
|
+
if use_hardware:
|
|
239
|
+
hw = HARDWARE_CODEC_MAP.get(codec)
|
|
240
|
+
if hw:
|
|
241
|
+
for backend_name in _HW_PROBE_ORDER.get(platform.system(), []):
|
|
242
|
+
encoder = hw.get(backend_name)
|
|
243
|
+
if encoder and test_fn(encoder):
|
|
244
|
+
return encoder
|
|
245
|
+
return get_software_encoder(codec)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def resolve_encoder_for_backend(
|
|
249
|
+
codec: str,
|
|
250
|
+
backend: str,
|
|
251
|
+
*,
|
|
252
|
+
test_fn: Callable[[str], bool],
|
|
253
|
+
) -> str:
|
|
254
|
+
"""Pick the encoder for *codec* using the specified *backend*.
|
|
255
|
+
|
|
256
|
+
*backend* must be one of the ``EncoderBackend`` values: ``"auto"``,
|
|
257
|
+
``"software"``, ``"videotoolbox"``, ``"nvenc"``, or ``"vaapi"``.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
VideoEncoderError: If the encoder is unavailable or the backend is unknown.
|
|
261
|
+
"""
|
|
262
|
+
if backend == "auto":
|
|
263
|
+
try:
|
|
264
|
+
return resolve_encoder(codec, test_fn=test_fn)
|
|
265
|
+
except ValueError as exc:
|
|
266
|
+
raise VideoEncoderError(str(exc)) from exc
|
|
267
|
+
|
|
268
|
+
if backend == "software":
|
|
269
|
+
try:
|
|
270
|
+
return get_software_encoder(codec)
|
|
271
|
+
except ValueError as exc:
|
|
272
|
+
raise VideoEncoderError(str(exc)) from exc
|
|
273
|
+
|
|
274
|
+
# Explicit hardware backend.
|
|
275
|
+
hw = HARDWARE_CODEC_MAP.get(codec, {})
|
|
276
|
+
encoder = hw.get(backend)
|
|
277
|
+
if not encoder:
|
|
278
|
+
raise VideoEncoderError(f"Hardware encoder '{backend}' not available for codec: {codec}")
|
|
279
|
+
if not test_fn(encoder):
|
|
280
|
+
raise VideoEncoderError(
|
|
281
|
+
f"Hardware encoder '{encoder}' not available on this system. Try --encoder software."
|
|
282
|
+
)
|
|
283
|
+
return encoder
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Video compression, image transcoding, and decompressor selection helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from mcap_codec_support._schemas import normalize_schema_name
|
|
9
|
+
from mcap_codec_support.video.common import (
|
|
10
|
+
DEFAULT_FPS,
|
|
11
|
+
DEFAULT_GOP_SIZE,
|
|
12
|
+
EncoderMode,
|
|
13
|
+
VideoEncoderError,
|
|
14
|
+
calculate_downscale_dimensions,
|
|
15
|
+
raw_image_to_array,
|
|
16
|
+
)
|
|
17
|
+
from mcap_codec_support.video.schemas import COMPRESSED_SCHEMAS
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Iterable, Iterator
|
|
21
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
22
|
+
|
|
23
|
+
from small_mcap import DecodedMessage
|
|
24
|
+
|
|
25
|
+
from mcap_codec_support._protocols import VideoCompressionBackend, VideoDecompressorProtocol
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _PyAVCompressionBackend:
|
|
29
|
+
label = "pyav"
|
|
30
|
+
prefetch_supported = True
|
|
31
|
+
|
|
32
|
+
def test_encoder(self, encoder_name: str) -> bool:
|
|
33
|
+
from mcap_codec_support.video.pyav import test_encoder # noqa: PLC0415
|
|
34
|
+
|
|
35
|
+
return test_encoder(encoder_name)
|
|
36
|
+
|
|
37
|
+
def resolve_encoder(self, codec: str) -> str:
|
|
38
|
+
from mcap_codec_support.video.pyav import resolve_encoder # noqa: PLC0415
|
|
39
|
+
|
|
40
|
+
return resolve_encoder(codec)
|
|
41
|
+
|
|
42
|
+
def decode_compressed(self, data: bytes) -> tuple[Any, int, int]:
|
|
43
|
+
from mcap_codec_support.video.pyav import decode_compressed_frame # noqa: PLC0415
|
|
44
|
+
|
|
45
|
+
frame = decode_compressed_frame(data)
|
|
46
|
+
return frame, frame.width, frame.height
|
|
47
|
+
|
|
48
|
+
def decode_image(self, msg: DecodedMessage, schema_name: str) -> tuple[Any, int, int]:
|
|
49
|
+
if schema_name in COMPRESSED_SCHEMAS:
|
|
50
|
+
return self.decode_compressed(bytes(msg.decoded_message.data))
|
|
51
|
+
|
|
52
|
+
import av # noqa: PLC0415
|
|
53
|
+
|
|
54
|
+
rgb_array = raw_image_to_array(msg.decoded_message)
|
|
55
|
+
frame = av.VideoFrame.from_ndarray(rgb_array, format="rgb24")
|
|
56
|
+
return frame, frame.width, frame.height
|
|
57
|
+
|
|
58
|
+
def create_encoder(
|
|
59
|
+
self,
|
|
60
|
+
width: int,
|
|
61
|
+
height: int,
|
|
62
|
+
codec_name: str,
|
|
63
|
+
quality: int,
|
|
64
|
+
*,
|
|
65
|
+
input_pix_fmt: str | None = None, # noqa: ARG002
|
|
66
|
+
scale: tuple[int, int] | None = None, # noqa: ARG002
|
|
67
|
+
) -> Any:
|
|
68
|
+
from mcap_codec_support.video.pyav import VideoEncoder # noqa: PLC0415
|
|
69
|
+
|
|
70
|
+
return VideoEncoder(
|
|
71
|
+
width=width,
|
|
72
|
+
height=height,
|
|
73
|
+
codec_name=codec_name,
|
|
74
|
+
quality=quality,
|
|
75
|
+
target_fps=DEFAULT_FPS,
|
|
76
|
+
gop_size=DEFAULT_GOP_SIZE,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def get_pix_fmt(self, topic: str) -> str | None:
|
|
80
|
+
del topic
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _FfmpegCliCompressionBackend:
|
|
85
|
+
label = "ffmpeg-cli"
|
|
86
|
+
prefetch_supported = False
|
|
87
|
+
|
|
88
|
+
def __init__(self) -> None:
|
|
89
|
+
self._topic_pix_fmt: dict[str, str | None] = {}
|
|
90
|
+
|
|
91
|
+
def get_pix_fmt(self, topic: str) -> str | None:
|
|
92
|
+
return self._topic_pix_fmt.get(topic)
|
|
93
|
+
|
|
94
|
+
def test_encoder(self, encoder_name: str) -> bool:
|
|
95
|
+
from mcap_codec_support.video.ffmpeg import check_encoder_cli # noqa: PLC0415
|
|
96
|
+
|
|
97
|
+
return check_encoder_cli(encoder_name)
|
|
98
|
+
|
|
99
|
+
def resolve_encoder(self, codec: str) -> str:
|
|
100
|
+
from mcap_codec_support.video.ffmpeg import resolve_encoder # noqa: PLC0415
|
|
101
|
+
|
|
102
|
+
return resolve_encoder(codec)
|
|
103
|
+
|
|
104
|
+
def decode_compressed(self, data: bytes) -> tuple[Any, int, int]:
|
|
105
|
+
from mcap_codec_support.video.ffmpeg import probe_image_dimensions # noqa: PLC0415
|
|
106
|
+
|
|
107
|
+
width, height = probe_image_dimensions(data)
|
|
108
|
+
return data, width, height
|
|
109
|
+
|
|
110
|
+
def decode_image(self, msg: DecodedMessage, schema_name: str) -> tuple[Any, int, int]:
|
|
111
|
+
data = bytes(msg.decoded_message.data)
|
|
112
|
+
topic = msg.channel.topic
|
|
113
|
+
|
|
114
|
+
if schema_name in COMPRESSED_SCHEMAS:
|
|
115
|
+
self._topic_pix_fmt[topic] = None
|
|
116
|
+
frame, width, height = self.decode_compressed(data)
|
|
117
|
+
return frame, width, height
|
|
118
|
+
|
|
119
|
+
from mcap_codec_support.video.ffmpeg import ROS_ENCODING_TO_PIX_FMT # noqa: PLC0415
|
|
120
|
+
|
|
121
|
+
encoding = str(msg.decoded_message.encoding).lower()
|
|
122
|
+
pix_fmt = ROS_ENCODING_TO_PIX_FMT.get(encoding)
|
|
123
|
+
if not pix_fmt:
|
|
124
|
+
raise VideoEncoderError(f"Unsupported image encoding: {msg.decoded_message.encoding}")
|
|
125
|
+
self._topic_pix_fmt[topic] = pix_fmt
|
|
126
|
+
return data, msg.decoded_message.width, msg.decoded_message.height
|
|
127
|
+
|
|
128
|
+
def create_encoder(
|
|
129
|
+
self,
|
|
130
|
+
width: int,
|
|
131
|
+
height: int,
|
|
132
|
+
codec_name: str,
|
|
133
|
+
quality: int,
|
|
134
|
+
*,
|
|
135
|
+
input_pix_fmt: str | None = None,
|
|
136
|
+
scale: tuple[int, int] | None = None,
|
|
137
|
+
) -> Any:
|
|
138
|
+
from mcap_codec_support.video.ffmpeg import FFmpegVideoEncoder # noqa: PLC0415
|
|
139
|
+
|
|
140
|
+
return FFmpegVideoEncoder(
|
|
141
|
+
width=width,
|
|
142
|
+
height=height,
|
|
143
|
+
codec_name=codec_name,
|
|
144
|
+
quality=quality,
|
|
145
|
+
target_fps=DEFAULT_FPS,
|
|
146
|
+
gop_size=DEFAULT_GOP_SIZE,
|
|
147
|
+
input_pix_fmt=input_pix_fmt,
|
|
148
|
+
scale=scale,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def create_video_compression_backend(
|
|
153
|
+
mode: EncoderMode, codec: str, *, do_video: bool
|
|
154
|
+
) -> VideoCompressionBackend:
|
|
155
|
+
"""Select the roscompress video backend."""
|
|
156
|
+
if mode is EncoderMode.FFMPEG_CLI:
|
|
157
|
+
return _FfmpegCliCompressionBackend()
|
|
158
|
+
|
|
159
|
+
pyav_backend = _PyAVCompressionBackend()
|
|
160
|
+
if mode is EncoderMode.AUTO and do_video:
|
|
161
|
+
try:
|
|
162
|
+
pyav_backend.resolve_encoder(codec)
|
|
163
|
+
except (ImportError, ValueError):
|
|
164
|
+
return _FfmpegCliCompressionBackend()
|
|
165
|
+
return pyav_backend
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def prefetch_image_decodes(
|
|
169
|
+
messages: Iterable[DecodedMessage],
|
|
170
|
+
backend: VideoCompressionBackend,
|
|
171
|
+
pool: ThreadPoolExecutor,
|
|
172
|
+
prefetch: int = 8,
|
|
173
|
+
) -> Iterator[tuple[DecodedMessage, Future[Any] | None]]:
|
|
174
|
+
"""Wrap message iterator to decode compressed images in background threads."""
|
|
175
|
+
buffer: deque[tuple[DecodedMessage, Future[Any] | None]] = deque()
|
|
176
|
+
|
|
177
|
+
for msg in messages:
|
|
178
|
+
schema_name = normalize_schema_name(msg.schema.name) if msg.schema else ""
|
|
179
|
+
if schema_name in COMPRESSED_SCHEMAS:
|
|
180
|
+
data = bytes(msg.decoded_message.data)
|
|
181
|
+
future: Future[Any] | None = pool.submit(backend.decode_compressed, data)
|
|
182
|
+
else:
|
|
183
|
+
future = None
|
|
184
|
+
buffer.append((msg, future))
|
|
185
|
+
|
|
186
|
+
if len(buffer) > prefetch:
|
|
187
|
+
yield buffer.popleft()
|
|
188
|
+
|
|
189
|
+
while buffer:
|
|
190
|
+
yield buffer.popleft()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _encode_rgb_array_to_jpeg(rgb_array: Any, quality: int) -> bytes:
|
|
194
|
+
try:
|
|
195
|
+
import imagecodecs # noqa: PLC0415
|
|
196
|
+
except ImportError as exc:
|
|
197
|
+
raise VideoEncoderError(
|
|
198
|
+
"imagecodecs is required for JPEG image encoding. "
|
|
199
|
+
"Install with: uv add 'mcap-codec-support[image]'"
|
|
200
|
+
) from exc
|
|
201
|
+
return bytes(imagecodecs.jpeg_encode(rgb_array, level=quality))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _resize_rgb_array(rgb_array: Any, width: int, height: int) -> Any:
|
|
205
|
+
import av # noqa: PLC0415
|
|
206
|
+
|
|
207
|
+
frame = av.VideoFrame.from_ndarray(rgb_array, format="rgb24")
|
|
208
|
+
return frame.reformat(width=width, height=height, format="rgb24").to_ndarray(format="rgb24")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def encode_raw_image_to_jpeg(
|
|
212
|
+
decoded_message: Any, *, jpeg_quality: int, scale: int | None
|
|
213
|
+
) -> tuple[bytes, int, int]:
|
|
214
|
+
"""Encode a raw ROS Image message to JPEG using imagecodecs for final encode."""
|
|
215
|
+
rgb_array = raw_image_to_array(decoded_message)
|
|
216
|
+
src_h, src_w = rgb_array.shape[:2]
|
|
217
|
+
if scale is not None:
|
|
218
|
+
target_w, target_h = calculate_downscale_dimensions(src_w, src_h, scale)
|
|
219
|
+
else:
|
|
220
|
+
target_w, target_h = src_w, src_h
|
|
221
|
+
|
|
222
|
+
target_w -= target_w % 2
|
|
223
|
+
target_h -= target_h % 2
|
|
224
|
+
if target_w < 2 or target_h < 2:
|
|
225
|
+
raise VideoEncoderError(f"Source frame too small ({target_w}x{target_h}) for JPEG encoding")
|
|
226
|
+
|
|
227
|
+
if target_w != src_w or target_h != src_h:
|
|
228
|
+
rgb_array = _resize_rgb_array(rgb_array, target_w, target_h)
|
|
229
|
+
|
|
230
|
+
return _encode_rgb_array_to_jpeg(rgb_array, jpeg_quality), target_w, target_h
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def decode_compressed_image_to_rgb_array(data: bytes) -> Any:
|
|
234
|
+
"""Decode JPEG/PNG compressed image bytes to an RGB numpy array."""
|
|
235
|
+
from mcap_codec_support.video.pyav import decode_compressed_frame # noqa: PLC0415
|
|
236
|
+
|
|
237
|
+
return decode_compressed_frame(data).to_ndarray(format="rgb24")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def create_video_decompressor(
|
|
241
|
+
video_format: str = "compressed",
|
|
242
|
+
jpeg_quality: int = 90,
|
|
243
|
+
*,
|
|
244
|
+
mode: EncoderMode = EncoderMode.AUTO,
|
|
245
|
+
) -> VideoDecompressorProtocol:
|
|
246
|
+
"""Create a video decompressor using the requested backend."""
|
|
247
|
+
if mode == EncoderMode.PYAV:
|
|
248
|
+
from mcap_codec_support.video.pyav import PyAVVideoDecompressor # noqa: PLC0415
|
|
249
|
+
|
|
250
|
+
return PyAVVideoDecompressor(video_format=video_format, jpeg_quality=jpeg_quality)
|
|
251
|
+
|
|
252
|
+
if mode == EncoderMode.FFMPEG_CLI:
|
|
253
|
+
from mcap_codec_support.video.ffmpeg import FFmpegVideoDecompressor # noqa: PLC0415
|
|
254
|
+
|
|
255
|
+
return FFmpegVideoDecompressor(video_format=video_format, jpeg_quality=jpeg_quality)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
from mcap_codec_support.video.pyav import PyAVVideoDecompressor # noqa: PLC0415
|
|
259
|
+
|
|
260
|
+
return PyAVVideoDecompressor(video_format=video_format, jpeg_quality=jpeg_quality)
|
|
261
|
+
except ImportError:
|
|
262
|
+
from mcap_codec_support.video.ffmpeg import ( # noqa: PLC0415
|
|
263
|
+
FFmpegVideoDecompressor,
|
|
264
|
+
find_ffmpeg,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if find_ffmpeg():
|
|
268
|
+
return FFmpegVideoDecompressor(video_format=video_format, jpeg_quality=jpeg_quality)
|
|
269
|
+
raise
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Decoder factory for decompressing CompressedVideo topics.
|
|
2
|
+
|
|
3
|
+
Provides ``VideoDecompressFactory`` for use with ``read_message_decoded``.
|
|
4
|
+
Uses ``VideoDecompressorProtocol`` — no direct ``av`` or ``subprocess`` imports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Literal
|
|
10
|
+
|
|
11
|
+
from mcap_codec_support.video.common import EncoderMode
|
|
12
|
+
from mcap_codec_support.video.schemas import COMPRESSED_VIDEO_SCHEMA
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
from small_mcap import Channel, Schema
|
|
18
|
+
|
|
19
|
+
from mcap_codec_support._messages import CompressedImageDict, Header, ImageDict
|
|
20
|
+
from mcap_codec_support._protocols import VideoDecompressorProtocol
|
|
21
|
+
from mcap_codec_support.video.common import DecompressedFrame
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# VideoDecompressFactory
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class VideoDecompressFactory:
|
|
30
|
+
"""Channel-aware decoder factory: CompressedVideo → CompressedImage or Image.
|
|
31
|
+
|
|
32
|
+
Creates a separate ``VideoDecompressorProtocol`` per channel for proper
|
|
33
|
+
P-frame handling. No direct ``av`` or ``subprocess`` imports.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
channel_aware = True
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
video_format: Literal["compressed", "raw"] = "compressed",
|
|
42
|
+
jpeg_quality: int = 90,
|
|
43
|
+
backend: EncoderMode = EncoderMode.AUTO,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._video_format = video_format
|
|
46
|
+
self._jpeg_quality = jpeg_quality
|
|
47
|
+
self._backend = backend
|
|
48
|
+
|
|
49
|
+
from mcap_ros2_support_fast.decoder import DecoderFactory # noqa: PLC0415
|
|
50
|
+
|
|
51
|
+
self._cdr_factory = DecoderFactory()
|
|
52
|
+
self._decompressors: dict[int, VideoDecompressorProtocol] = {}
|
|
53
|
+
|
|
54
|
+
def flush_all(self) -> list[DecompressedFrame]:
|
|
55
|
+
"""Flush all decompressors and return remaining frames."""
|
|
56
|
+
return [frame for _, frame in self.flush_all_by_channel()]
|
|
57
|
+
|
|
58
|
+
def flush_all_by_channel(self) -> list[tuple[int, DecompressedFrame]]:
|
|
59
|
+
"""Flush all decompressors and keep channel ownership for each frame."""
|
|
60
|
+
frames: list[tuple[int, DecompressedFrame]] = []
|
|
61
|
+
for channel_id, decompressor in self._decompressors.items():
|
|
62
|
+
frames.extend((channel_id, frame) for frame in decompressor.flush())
|
|
63
|
+
return frames
|
|
64
|
+
|
|
65
|
+
def _get_decompressor(self, channel_id: int) -> VideoDecompressorProtocol:
|
|
66
|
+
if channel_id not in self._decompressors:
|
|
67
|
+
from mcap_codec_support.video.compression import ( # noqa: PLC0415
|
|
68
|
+
create_video_decompressor,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
self._decompressors[channel_id] = create_video_decompressor(
|
|
72
|
+
video_format=self._video_format,
|
|
73
|
+
jpeg_quality=self._jpeg_quality,
|
|
74
|
+
mode=self._backend,
|
|
75
|
+
)
|
|
76
|
+
return self._decompressors[channel_id]
|
|
77
|
+
|
|
78
|
+
def decoder_for(
|
|
79
|
+
self,
|
|
80
|
+
message_encoding: str,
|
|
81
|
+
schema: Schema | None,
|
|
82
|
+
channel: Channel,
|
|
83
|
+
) -> Callable[[bytes | memoryview], CompressedImageDict | ImageDict | None] | None:
|
|
84
|
+
if schema is None or schema.name != COMPRESSED_VIDEO_SCHEMA:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
cdr_decoder = self._cdr_factory.decoder_for(message_encoding, schema)
|
|
88
|
+
if cdr_decoder is None:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
decompressor = self._get_decompressor(channel.id)
|
|
92
|
+
|
|
93
|
+
def _decode(data: bytes | memoryview) -> CompressedImageDict | ImageDict | None:
|
|
94
|
+
decoded = cdr_decoder(data)
|
|
95
|
+
codec = decoded.format
|
|
96
|
+
video_data = decoded.data
|
|
97
|
+
if isinstance(video_data, memoryview):
|
|
98
|
+
video_data = bytes(video_data)
|
|
99
|
+
|
|
100
|
+
frame: DecompressedFrame | None = decompressor.decompress(video_data, codec)
|
|
101
|
+
if frame is None:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
timestamp = decoded.timestamp
|
|
105
|
+
header: Header = {
|
|
106
|
+
"stamp": {"sec": timestamp.sec, "nanosec": timestamp.nanosec},
|
|
107
|
+
"frame_id": decoded.frame_id,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if frame.is_jpeg:
|
|
111
|
+
return {"header": header, "format": "jpeg", "data": frame.data}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
"header": header,
|
|
115
|
+
"height": frame.height,
|
|
116
|
+
"width": frame.width,
|
|
117
|
+
"encoding": "rgb8",
|
|
118
|
+
"is_bigendian": 0,
|
|
119
|
+
"step": frame.width * 3,
|
|
120
|
+
"data": frame.data,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return _decode
|