mcap-codec-support 0.2.0__tar.gz → 0.5.0__tar.gz

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.
Files changed (23) hide show
  1. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/PKG-INFO +4 -5
  2. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/pyproject.toml +4 -4
  3. mcap_codec_support-0.5.0/src/mcap_codec_support/_messages.py +15 -0
  4. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/_protocols.py +1 -15
  5. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/pointcloud/__init__.py +2 -0
  6. mcap_codec_support-0.5.0/src/mcap_codec_support/pointcloud/_messages.py +28 -0
  7. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/pointcloud/compression.py +1 -1
  8. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/pointcloud/factories.py +29 -6
  9. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/video/__init__.py +0 -6
  10. mcap_codec_support-0.5.0/src/mcap_codec_support/video/_messages.py +28 -0
  11. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/video/common.py +29 -0
  12. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/video/compression.py +27 -27
  13. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/video/factories.py +2 -1
  14. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/video/ffmpeg.py +0 -153
  15. mcap_codec_support-0.2.0/src/mcap_codec_support/_messages.py +0 -56
  16. mcap_codec_support-0.2.0/src/mcap_codec_support/video/file_writer.py +0 -411
  17. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/README.md +0 -0
  18. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/__init__.py +0 -0
  19. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/_schemas.py +0 -0
  20. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/pointcloud/schemas.py +0 -0
  21. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/py.typed +0 -0
  22. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/video/pyav.py +0 -0
  23. {mcap_codec_support-0.2.0 → mcap_codec_support-0.5.0}/src/mcap_codec_support/video/schemas.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: mcap-codec-support
3
- Version: 0.2.0
3
+ Version: 0.5.0
4
4
  Summary: Reusable MCAP encoder and decoder factories for robotics codecs
5
5
  Keywords: mcap,robotics,factories,video,pointcloud
6
6
  Author: Marko Bausch
@@ -17,14 +17,13 @@ Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Topic :: Scientific/Engineering
19
19
  Classifier: Typing :: Typed
20
+ Requires-Dist: small-mcap
20
21
  Requires-Dist: typing-extensions>=4.15.0
21
- Requires-Dist: mcap-codec-support[ros2,video,pointcloud,image,draco] ; extra == 'all'
22
+ Requires-Dist: mcap-codec-support[ros2,video,pointcloud,draco] ; extra == 'all'
22
23
  Requires-Dist: dracopy>=1.7.0 ; extra == 'draco'
23
24
  Requires-Dist: mcap-ros2-support-fast ; extra == 'draco'
24
25
  Requires-Dist: numpy>=1.24.0 ; extra == 'draco'
25
26
  Requires-Dist: pointcloud2 ; extra == 'draco'
26
- Requires-Dist: mcap-codec-support[video] ; extra == 'image'
27
- Requires-Dist: imagecodecs>=2024.1.1 ; extra == 'image'
28
27
  Requires-Dist: mcap-ros2-support-fast ; extra == 'pointcloud'
29
28
  Requires-Dist: pureini ; extra == 'pointcloud'
30
29
  Requires-Dist: numpy>=1.24.0 ; extra == 'pointcloud'
@@ -32,13 +31,13 @@ Requires-Dist: pointcloud2 ; extra == 'pointcloud'
32
31
  Requires-Dist: mcap-ros2-support-fast ; extra == 'ros2'
33
32
  Requires-Dist: av>=12.0.0 ; extra == 'video'
34
33
  Requires-Dist: numpy>=1.24.0 ; extra == 'video'
34
+ Requires-Dist: pillow>=10.0 ; extra == 'video'
35
35
  Requires-Python: >=3.10
36
36
  Project-URL: Homepage, https://github.com/mrkbac/robotic-tools
37
37
  Project-URL: Issues, https://github.com/mrkbac/robotic-tools/issues
38
38
  Project-URL: Repository, https://github.com/mrkbac/robotic-tools
39
39
  Provides-Extra: all
40
40
  Provides-Extra: draco
41
- Provides-Extra: image
42
41
  Provides-Extra: pointcloud
43
42
  Provides-Extra: ros2
44
43
  Provides-Extra: video
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcap-codec-support"
3
- version = "0.2.0"
3
+ version = "0.5.0"
4
4
  description = "Reusable MCAP encoder and decoder factories for robotics codecs"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -22,6 +22,7 @@ classifiers = [
22
22
  "Typing :: Typed",
23
23
  ]
24
24
  dependencies = [
25
+ "small-mcap",
25
26
  "typing-extensions>=4.15.0",
26
27
  ]
27
28
 
@@ -32,10 +33,9 @@ Issues = "https://github.com/mrkbac/robotic-tools/issues"
32
33
 
33
34
  [project.optional-dependencies]
34
35
  ros2 = ["mcap-ros2-support-fast"]
35
- video = ["av>=12.0.0", "numpy>=1.24.0"]
36
+ video = ["av>=12.0.0", "numpy>=1.24.0", "pillow>=10.0"]
36
37
  pointcloud = ["mcap-ros2-support-fast", "pureini", "numpy>=1.24.0", "pointcloud2"]
37
- image = ["mcap-codec-support[video]", "imagecodecs>=2024.1.1"]
38
- all = ["mcap-codec-support[ros2,video,pointcloud,image,draco]"]
38
+ all = ["mcap-codec-support[ros2,video,pointcloud,draco]"]
39
39
  draco = [
40
40
  "dracopy>=1.7.0",
41
41
  "mcap-ros2-support-fast",
@@ -0,0 +1,15 @@
1
+ """Shared TypedDict shapes for decoded ROS messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypedDict
6
+
7
+
8
+ class Stamp(TypedDict):
9
+ sec: int
10
+ nanosec: int
11
+
12
+
13
+ class Header(TypedDict):
14
+ stamp: Stamp
15
+ frame_id: str
@@ -5,7 +5,6 @@ from __future__ import annotations
5
5
  from typing import TYPE_CHECKING, Protocol
6
6
 
7
7
  if TYPE_CHECKING:
8
- import numpy as np
9
8
  from av import VideoFrame
10
9
  from small_mcap import DecodedMessage
11
10
 
@@ -18,6 +17,7 @@ class RawImageMessage(Protocol):
18
17
  width: int
19
18
  height: int
20
19
  encoding: str
20
+ step: int
21
21
  data: bytes
22
22
 
23
23
 
@@ -78,17 +78,3 @@ class VideoCompressionBackend(Protocol):
78
78
  ) -> VideoEncoderProtocol: ...
79
79
 
80
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: ...
@@ -13,6 +13,7 @@ from mcap_codec_support.pointcloud.factories import (
13
13
  CompressedPointCloudDecompressFactory,
14
14
  Pointcloud2DecoderFactory,
15
15
  PointCloudDecompressFactory,
16
+ is_compressed_codec_available,
16
17
  )
17
18
  from mcap_codec_support.pointcloud.schemas import (
18
19
  CLOUDINI_COMPRESSED_POINTCLOUD2,
@@ -42,4 +43,5 @@ __all__ = [
42
43
  "Pointcloud2DecoderFactory",
43
44
  "build_compressed_pointcloud2_message",
44
45
  "build_foxglove_compressed_pointcloud_message",
46
+ "is_compressed_codec_available",
45
47
  ]
@@ -0,0 +1,28 @@
1
+ """Pointcloud-specific TypedDict shapes."""
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
+ from mcap_codec_support._messages import Header
11
+
12
+
13
+ class Pointcloud2Dict(TypedDict):
14
+ """Dict shape mirroring ``sensor_msgs/PointCloud2``.
15
+
16
+ Compatible with ``pointcloud2.read_points`` once wrapped in a
17
+ ``SimpleNamespace``.
18
+ """
19
+
20
+ header: Header
21
+ height: int
22
+ width: int
23
+ fields: list[PointFieldDict]
24
+ is_bigendian: bool
25
+ point_step: int
26
+ row_step: int
27
+ data: bytes
28
+ is_dense: bool
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Protocol
8
8
  if TYPE_CHECKING:
9
9
  import numpy as np
10
10
  import numpy.typing as npt
11
- from pointcloud2.messages import Pointcloud2Msg, PointFieldMsg
11
+ from pointcloud2 import Pointcloud2Msg, PointFieldMsg
12
12
  from pureini import CompressionOption, EncodingInfo, EncodingOptions
13
13
 
14
14
  DRACO_MAX_QUANTIZATION_BITS = 30
@@ -21,11 +21,11 @@ if TYPE_CHECKING:
21
21
 
22
22
  import numpy as np
23
23
  from pointcloud2 import HeaderMsg, PointFieldDict, PointFieldMsg
24
- from pointcloud2.messages import Stamp
25
24
  from pureini import PointcloudDecoder
26
25
  from small_mcap import Schema
27
26
 
28
- from mcap_codec_support._messages import Header, Pointcloud2Dict
27
+ from mcap_codec_support._messages import Header, Stamp
28
+ from mcap_codec_support.pointcloud._messages import Pointcloud2Dict
29
29
 
30
30
 
31
31
  class _RosCompressedPointcloud2Msg(Protocol):
@@ -43,10 +43,15 @@ class _RosCompressedPointcloud2Msg(Protocol):
43
43
  compressed_data: bytes
44
44
 
45
45
 
46
+ class _StampMsg(Protocol):
47
+ sec: int
48
+ nanosec: int
49
+
50
+
46
51
  class _FoxgloveCompressedPointcloudMsg(Protocol):
47
52
  """foxglove_msgs/msg/CompressedPointCloud — flattened header."""
48
53
 
49
- timestamp: Stamp
54
+ timestamp: _StampMsg
50
55
  frame_id: str
51
56
  format: str | bytes
52
57
  data: bytes
@@ -58,6 +63,21 @@ _COMPRESSED_POINTCLOUD_SCHEMAS = {
58
63
  }
59
64
 
60
65
 
66
+ def is_compressed_codec_available() -> bool:
67
+ """True if any compressed point-cloud codec backend (cloudini or draco) is importable."""
68
+ try:
69
+ import pureini # noqa: F401, PLC0415
70
+ except ImportError:
71
+ pass
72
+ else:
73
+ return True
74
+ try:
75
+ import DracoPy # noqa: F401, PLC0415
76
+ except ImportError:
77
+ return False
78
+ return True
79
+
80
+
61
81
  def _pointcloud_dict_to_array(cloud_dict: Pointcloud2Dict) -> np.ndarray:
62
82
  from pointcloud2 import read_points # noqa: PLC0415
63
83
 
@@ -75,17 +95,20 @@ def _as_bytes(payload: bytes | bytearray | memoryview) -> bytes:
75
95
  return payload if isinstance(payload, bytes) else bytes(payload)
76
96
 
77
97
 
98
+ def _stamp_from_msg(stamp: _StampMsg) -> Stamp:
99
+ return {"sec": stamp.sec, "nanosec": stamp.nanosec}
100
+
101
+
78
102
  def _header_from_ros_msg(msg: _RosCompressedPointcloud2Msg) -> Header:
79
- stamp = msg.header.stamp
80
103
  return {
81
- "stamp": {"sec": stamp.sec, "nanosec": stamp.nanosec},
104
+ "stamp": _stamp_from_msg(msg.header.stamp),
82
105
  "frame_id": msg.header.frame_id,
83
106
  }
84
107
 
85
108
 
86
109
  def _header_from_foxglove_msg(msg: _FoxgloveCompressedPointcloudMsg) -> Header:
87
110
  return {
88
- "stamp": {"sec": msg.timestamp.sec, "nanosec": msg.timestamp.nanosec},
111
+ "stamp": _stamp_from_msg(msg.timestamp),
89
112
  "frame_id": msg.frame_id,
90
113
  }
91
114
 
@@ -17,10 +17,6 @@ from mcap_codec_support.video.compression import (
17
17
  prefetch_image_decodes,
18
18
  )
19
19
  from mcap_codec_support.video.factories import VideoDecompressFactory
20
- from mcap_codec_support.video.file_writer import (
21
- VideoFileWriterSession,
22
- create_video_file_writer,
23
- )
24
20
  from mcap_codec_support.video.schemas import (
25
21
  COMPRESSED_IMAGE,
26
22
  COMPRESSED_VIDEO_SCHEMA,
@@ -44,10 +40,8 @@ __all__ = [
44
40
  "VideoCompressionBackend",
45
41
  "VideoDecompressFactory",
46
42
  "VideoEncoderError",
47
- "VideoFileWriterSession",
48
43
  "calculate_downscale_dimensions",
49
44
  "create_video_compression_backend",
50
- "create_video_file_writer",
51
45
  "encode_raw_image_to_jpeg",
52
46
  "get_software_encoder",
53
47
  "prefetch_image_decodes",
@@ -0,0 +1,28 @@
1
+ """Video-specific TypedDict shapes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, TypedDict
6
+
7
+ if TYPE_CHECKING:
8
+ from mcap_codec_support._messages import Header
9
+
10
+
11
+ class CompressedImageDict(TypedDict):
12
+ """Dict shape mirroring ``sensor_msgs/CompressedImage``."""
13
+
14
+ header: Header
15
+ format: str
16
+ data: bytes
17
+
18
+
19
+ class ImageDict(TypedDict):
20
+ """Dict shape mirroring ``sensor_msgs/Image`` for raw RGB frames."""
21
+
22
+ header: Header
23
+ height: int
24
+ width: int
25
+ encoding: str
26
+ is_bigendian: int
27
+ step: int
28
+ data: bytes
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
11
11
  from collections.abc import Callable
12
12
 
13
13
  import numpy as np
14
+ from PIL.Image import Image as PILImage
14
15
 
15
16
  from mcap_codec_support._protocols import RawImageMessage
16
17
 
@@ -209,6 +210,34 @@ def raw_image_to_array(message: RawImageMessage) -> np.ndarray:
209
210
  raise VideoEncoderError(f"Unsupported image encoding: {message.encoding}")
210
211
 
211
212
 
213
+ def raw_image_to_pil(message: RawImageMessage) -> PILImage:
214
+ """Convert a ROS Image message to a PIL ``Image`` (RGB)."""
215
+ try:
216
+ from PIL import Image # noqa: PLC0415
217
+ except ImportError as exc:
218
+ raise VideoEncoderError(
219
+ "Pillow is required for raw image decoding. "
220
+ "Install with: uv add 'mcap-codec-support[video]'"
221
+ ) from exc
222
+
223
+ if not message.data:
224
+ raise VideoEncoderError("Image has no data")
225
+
226
+ width = message.width
227
+ height = message.height
228
+ encoding = str(message.encoding).lower()
229
+ data = bytes(message.data)
230
+
231
+ if encoding in {"rgb", "rgb8"}:
232
+ return Image.frombytes("RGB", (width, height), data)
233
+ if encoding in {"bgr", "bgr8"}:
234
+ return Image.frombytes("RGB", (width, height), data, "raw", "BGR")
235
+ if encoding in {"mono", "mono8", "8uc1"}:
236
+ return Image.frombytes("L", (width, height), data).convert("RGB")
237
+
238
+ raise VideoEncoderError(f"Unsupported image encoding: {message.encoding}")
239
+
240
+
212
241
  # ---------------------------------------------------------------------------
213
242
  # Encoder resolution
214
243
  # ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import io
5
6
  from collections import deque
6
7
  from typing import TYPE_CHECKING, Any
7
8
 
@@ -13,16 +14,28 @@ from mcap_codec_support.video.common import (
13
14
  VideoEncoderError,
14
15
  calculate_downscale_dimensions,
15
16
  raw_image_to_array,
17
+ raw_image_to_pil,
16
18
  )
17
19
  from mcap_codec_support.video.schemas import COMPRESSED_SCHEMAS
18
20
 
21
+ try:
22
+ from PIL.Image import Resampling as _Resampling
23
+
24
+ _PIL_BILINEAR = _Resampling.BILINEAR
25
+ except ImportError:
26
+ _PIL_BILINEAR = None # ``raw_image_to_pil`` raises if Pillow is missing.
27
+
19
28
  if TYPE_CHECKING:
20
29
  from collections.abc import Iterable, Iterator
21
30
  from concurrent.futures import Future, ThreadPoolExecutor
22
31
 
23
32
  from small_mcap import DecodedMessage
24
33
 
25
- from mcap_codec_support._protocols import VideoCompressionBackend, VideoDecompressorProtocol
34
+ from mcap_codec_support._protocols import (
35
+ RawImageMessage,
36
+ VideoCompressionBackend,
37
+ VideoDecompressorProtocol,
38
+ )
26
39
 
27
40
 
28
41
  class _PyAVCompressionBackend:
@@ -62,9 +75,12 @@ class _PyAVCompressionBackend:
62
75
  codec_name: str,
63
76
  quality: int,
64
77
  *,
65
- input_pix_fmt: str | None = None, # noqa: ARG002
66
- scale: tuple[int, int] | None = None, # noqa: ARG002
78
+ input_pix_fmt: str | None = None,
79
+ scale: tuple[int, int] | None = None,
67
80
  ) -> Any:
81
+ # PyAV reformats input frames per-frame inside VideoEncoder.encode, so
82
+ # the protocol's pix-fmt / scale knobs are FFmpeg-CLI-only.
83
+ del input_pix_fmt, scale
68
84
  from mcap_codec_support.video.pyav import VideoEncoder # noqa: PLC0415
69
85
 
70
86
  return VideoEncoder(
@@ -190,30 +206,12 @@ def prefetch_image_decodes(
190
206
  yield buffer.popleft()
191
207
 
192
208
 
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
209
  def encode_raw_image_to_jpeg(
212
- decoded_message: Any, *, jpeg_quality: int, scale: int | None
210
+ decoded_message: RawImageMessage, *, jpeg_quality: int, scale: int | None
213
211
  ) -> 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]
212
+ """Encode a raw ROS Image message to JPEG using Pillow."""
213
+ image = raw_image_to_pil(decoded_message)
214
+ src_w, src_h = image.size
217
215
  if scale is not None:
218
216
  target_w, target_h = calculate_downscale_dimensions(src_w, src_h, scale)
219
217
  else:
@@ -225,9 +223,11 @@ def encode_raw_image_to_jpeg(
225
223
  raise VideoEncoderError(f"Source frame too small ({target_w}x{target_h}) for JPEG encoding")
226
224
 
227
225
  if target_w != src_w or target_h != src_h:
228
- rgb_array = _resize_rgb_array(rgb_array, target_w, target_h)
226
+ image = image.resize((target_w, target_h), _PIL_BILINEAR)
229
227
 
230
- return _encode_rgb_array_to_jpeg(rgb_array, jpeg_quality), target_w, target_h
228
+ buf = io.BytesIO()
229
+ image.save(buf, format="JPEG", quality=jpeg_quality)
230
+ return buf.getvalue(), target_w, target_h
231
231
 
232
232
 
233
233
  def decode_compressed_image_to_rgb_array(data: bytes) -> Any:
@@ -16,8 +16,9 @@ if TYPE_CHECKING:
16
16
 
17
17
  from small_mcap import Channel, Schema
18
18
 
19
- from mcap_codec_support._messages import CompressedImageDict, Header, ImageDict
19
+ from mcap_codec_support._messages import Header
20
20
  from mcap_codec_support._protocols import VideoDecompressorProtocol
21
+ from mcap_codec_support.video._messages import CompressedImageDict, ImageDict
21
22
  from mcap_codec_support.video.common import DecompressedFrame
22
23
 
23
24
 
@@ -6,7 +6,6 @@ All ``ffmpeg`` / ``ffprobe`` subprocess usage is confined to this module.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- import contextlib
10
9
  import os
11
10
  import shutil
12
11
  import subprocess
@@ -790,155 +789,3 @@ class FFmpegVideoDecompressor:
790
789
  self._process.wait(timeout=2)
791
790
  except Exception: # noqa: BLE001, S110
792
791
  pass
793
-
794
-
795
- # ---------------------------------------------------------------------------
796
- # FFmpegMp4Encoder — raw RGB frames in via stdin, MP4 file out
797
- # ---------------------------------------------------------------------------
798
-
799
-
800
- class FFmpegMp4Encoder:
801
- """Encode a stream of raw RGB frames to an MP4 file via ffmpeg subprocess.
802
-
803
- Sibling of :class:`FFmpegVideoEncoder`, which emits raw Annex B bitstream
804
- to stdout for in-MCAP ``CompressedVideo`` messages. This class instead
805
- asks ffmpeg to mux into MP4 and write to disk directly — what the
806
- ``video`` exporter wants when the user picks ``--mode ffmpeg-cli``.
807
- """
808
-
809
- def __init__(
810
- self,
811
- output_path: os.PathLike[str] | str,
812
- *,
813
- width: int,
814
- height: int,
815
- codec_name: str,
816
- quality: int = 28,
817
- target_fps: float = 30.0,
818
- gop_size: int = 60,
819
- input_pix_fmt: str | None = "rgb24",
820
- ) -> None:
821
- ffmpeg = _require_ffmpeg()
822
-
823
- # Even dimensions required by yuv420p / common encoders.
824
- width -= width % 2
825
- height -= height % 2
826
- if width < 2 or height < 2:
827
- raise VideoEncoderError(f"Source frame too small ({width}x{height}) for video encoding")
828
-
829
- if not check_encoder_cli(codec_name):
830
- raise VideoEncoderError(
831
- f"ffmpeg CLI does not support encoder {codec_name!r}. "
832
- "Install a fuller ffmpeg build or pick a different --encoder backend."
833
- )
834
-
835
- options, bit_rate = build_encoder_options(codec_name, quality, width, height)
836
- fps_int = max(round(target_fps), 1)
837
-
838
- cmd: list[str] = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y"]
839
- if input_pix_fmt is None:
840
- cmd.extend(["-f", "image2pipe", "-r", str(fps_int), "-i", "pipe:0"])
841
- else:
842
- cmd.extend(
843
- [
844
- "-f",
845
- "rawvideo",
846
- "-pix_fmt",
847
- input_pix_fmt,
848
- "-s",
849
- f"{width}x{height}",
850
- "-r",
851
- str(fps_int),
852
- "-i",
853
- "pipe:0",
854
- ]
855
- )
856
-
857
- if input_pix_fmt is None:
858
- cmd.extend(["-vf", f"scale={width}:{height}"])
859
-
860
- cmd.extend(
861
- [
862
- "-c:v",
863
- codec_name,
864
- "-pix_fmt",
865
- "yuv420p",
866
- "-g",
867
- str(gop_size),
868
- "-bf",
869
- "0",
870
- ]
871
- )
872
- if bit_rate is not None:
873
- cmd.extend(["-b:v", str(bit_rate)])
874
- for key, value in options.items():
875
- cmd.extend([f"-{key}", value])
876
-
877
- cmd.extend(["-movflags", "+faststart", "-f", "mp4", str(output_path)])
878
-
879
- self._cmd = cmd
880
- self.output_path = output_path
881
- self.config = EncoderConfig(width=width, height=height, codec_name=codec_name)
882
- self._stderr_lines: list[str] = []
883
- self._frames_fed = 0
884
-
885
- self._process = subprocess.Popen( # noqa: S603 — args list, not shell
886
- cmd,
887
- stdin=subprocess.PIPE,
888
- stdout=subprocess.DEVNULL,
889
- stderr=subprocess.PIPE,
890
- )
891
-
892
- self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
893
- self._stderr_thread.start()
894
-
895
- def _read_stderr(self) -> None:
896
- if self._process.stderr is None:
897
- return
898
- for raw in iter(self._process.stderr.readline, b""):
899
- line = raw.decode("utf-8", errors="replace").rstrip()
900
- if line:
901
- self._stderr_lines.append(line)
902
- self._process.stderr.close()
903
-
904
- def write_frame(self, frame_bytes: bytes) -> None:
905
- """Write one input frame to ffmpeg stdin."""
906
- if self._process.stdin is None or self._process.stdin.closed:
907
- raise VideoEncoderError("ffmpeg process stdin is closed")
908
- try:
909
- self._process.stdin.write(frame_bytes)
910
- except BrokenPipeError as exc:
911
- stderr_tail = "\n".join(self._stderr_lines[-5:])
912
- raise VideoEncoderError(f"ffmpeg subprocess exited mid-stream:\n{stderr_tail}") from exc
913
- self._frames_fed += 1
914
-
915
- @property
916
- def frames_fed(self) -> int:
917
- return self._frames_fed
918
-
919
- def close(self) -> None:
920
- if self._process.poll() is not None and self._process.stdin is None:
921
- return
922
- if self._process.stdin and not self._process.stdin.closed:
923
- with contextlib.suppress(BrokenPipeError):
924
- self._process.stdin.close()
925
- try:
926
- self._process.wait(timeout=30)
927
- except subprocess.TimeoutExpired:
928
- self._process.kill()
929
- self._process.wait(timeout=5)
930
- raise VideoEncoderError("ffmpeg subprocess did not exit cleanly; killed") from None
931
- self._stderr_thread.join(timeout=5)
932
- if self._process.returncode != 0:
933
- stderr_tail = "\n".join(self._stderr_lines[-10:])
934
- raise VideoEncoderError(
935
- f"ffmpeg exited with code {self._process.returncode}:\n{stderr_tail}"
936
- )
937
-
938
- def __del__(self) -> None:
939
- try:
940
- if self._process and self._process.poll() is None:
941
- self._process.kill()
942
- self._process.wait(timeout=2)
943
- except Exception: # noqa: BLE001, S110
944
- pass
@@ -1,56 +0,0 @@
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
@@ -1,411 +0,0 @@
1
- """Lazy MP4 writer helpers for decoded image messages."""
2
-
3
- from __future__ import annotations
4
-
5
- from fractions import Fraction
6
- from typing import TYPE_CHECKING, Any, cast
7
-
8
- from mcap_codec_support.video.common import (
9
- EncoderBackend,
10
- EncoderConfig,
11
- EncoderMode,
12
- VideoCodec,
13
- VideoEncoderError,
14
- get_encoder_options,
15
- raw_image_to_array,
16
- resolve_encoder_for_backend,
17
- )
18
- from mcap_codec_support.video.compression import decode_compressed_image_to_rgb_array
19
- from mcap_codec_support.video.schemas import COMPRESSED_SCHEMAS, IMAGE_SCHEMAS, RAW_SCHEMAS
20
-
21
- if TYPE_CHECKING:
22
- from collections.abc import Callable
23
- from pathlib import Path
24
-
25
- from mcap_codec_support._protocols import VideoFileStrategy
26
-
27
-
28
- _TARGET_BITRATE_BY_QUALITY: tuple[tuple[int, int], ...] = (
29
- (20, 10_000_000),
30
- (25, 5_000_000),
31
- (10**6, 2_000_000),
32
- )
33
-
34
-
35
- def _bitrate_for(quality: int) -> int:
36
- for threshold, bitrate in _TARGET_BITRATE_BY_QUALITY:
37
- if quality <= threshold:
38
- return bitrate
39
- return _TARGET_BITRATE_BY_QUALITY[-1][1]
40
-
41
-
42
- _RAW_BYTES_PER_PIXEL: dict[str, int] = {
43
- "rgb": 3,
44
- "rgb8": 3,
45
- "bgr": 3,
46
- "bgr8": 3,
47
- "mono": 1,
48
- "mono8": 1,
49
- "8uc1": 1,
50
- }
51
-
52
-
53
- def _pack_raw_image_bytes(decoded: Any, *, width: int, height: int) -> bytes:
54
- """Pack raw ROS Image bytes to the exact frame size expected by ffmpeg."""
55
- encoding = str(decoded.encoding).lower()
56
- bytes_per_pixel = _RAW_BYTES_PER_PIXEL.get(encoding)
57
- if bytes_per_pixel is None:
58
- raise VideoEncoderError(f"Unsupported image encoding: {decoded.encoding}")
59
-
60
- src_width = int(decoded.width)
61
- src_height = int(decoded.height)
62
- if width > src_width or height > src_height:
63
- raise VideoEncoderError(
64
- f"Cannot pack {src_width}x{src_height} frame as larger {width}x{height}"
65
- )
66
-
67
- src_row_bytes = src_width * bytes_per_pixel
68
- dst_row_bytes = width * bytes_per_pixel
69
- step = int(decoded.step)
70
- if step < src_row_bytes:
71
- raise VideoEncoderError(f"Image step {step} is smaller than row size {src_row_bytes}")
72
-
73
- data = bytes(decoded.data)
74
- required = step * src_height
75
- if len(data) < required:
76
- raise VideoEncoderError(f"Image data has {len(data)} bytes, expected at least {required}")
77
-
78
- if width == src_width and height == src_height and step == dst_row_bytes:
79
- return data
80
-
81
- packed = bytearray(dst_row_bytes * height)
82
- offset = 0
83
- for row in range(height):
84
- start = row * step
85
- end = start + dst_row_bytes
86
- packed[offset : offset + dst_row_bytes] = data[start:end]
87
- offset += dst_row_bytes
88
- return bytes(packed)
89
-
90
-
91
- class _PyAVMp4Strategy:
92
- """In-process PyAV MP4 writer."""
93
-
94
- def __init__(
95
- self,
96
- path: Path,
97
- *,
98
- codec: VideoCodec,
99
- encoder_backend: EncoderBackend,
100
- quality: int,
101
- width: int,
102
- height: int,
103
- ) -> None:
104
- import av # noqa: PLC0415
105
- import av.error # noqa: PLC0415
106
-
107
- from mcap_codec_support.video.pyav import resolve_encoder_for_backend # noqa: PLC0415
108
-
109
- self.path = path
110
- self._codec = codec
111
- self._quality = quality
112
- self._encoder_name = resolve_encoder_for_backend(codec.value, encoder_backend.value)
113
- self._first_timestamp_ns: int | None = None
114
- self._last_pts = -1
115
- self._frame_count = 0
116
- self.config = EncoderConfig(width=width, height=height, codec_name=self._encoder_name)
117
-
118
- container = av.open(str(path), "w", format=None, options={"movflags": "faststart"})
119
- try:
120
- stream = cast("Any", container.add_stream(codec_name=self._encoder_name))
121
- except (av.error.FFmpegError, ValueError) as exc:
122
- container.close()
123
- raise VideoEncoderError(
124
- f"Failed to create video stream with encoder '{self._encoder_name}': {exc}"
125
- ) from exc
126
-
127
- stream.width = width
128
- stream.height = height
129
- stream.pix_fmt = "yuv420p"
130
- stream.time_base = Fraction(1, 1_000_000)
131
- stream.codec_context.time_base = Fraction(1, 1_000_000)
132
- stream.codec_context.framerate = Fraction(30, 1)
133
- stream.codec_context.gop_size = 60
134
- stream.codec_context.bit_rate = _bitrate_for(quality)
135
-
136
- options = get_encoder_options(codec, self._encoder_name)
137
- if any(s in self._encoder_name for s in ("libx264", "libx265", "videotoolbox")):
138
- options["bf"] = "0"
139
- stream.codec_context.options = options
140
-
141
- self._container = container
142
- self._stream = stream
143
-
144
- def write_compressed(self, data: bytes, log_time_ns: int) -> None:
145
- self.write_rgb(decode_compressed_image_to_rgb_array(data), log_time_ns)
146
-
147
- def write_raw(self, data: bytes, log_time_ns: int) -> None:
148
- del data, log_time_ns
149
- raise VideoEncoderError("PyAV MP4 writer needs decoded RGB for raw frames")
150
-
151
- def write_rgb(self, rgb: Any, log_time_ns: int) -> None:
152
- import av # noqa: PLC0415
153
- import av.error # noqa: PLC0415
154
-
155
- if self._first_timestamp_ns is None:
156
- self._first_timestamp_ns = log_time_ns
157
-
158
- try:
159
- frame = av.VideoFrame.from_ndarray(rgb, format="rgb24").reformat(format="yuv420p")
160
- except (av.error.FFmpegError, ValueError) as exc:
161
- raise VideoEncoderError(f"Frame conversion failed: {exc}") from exc
162
-
163
- current_pts = (log_time_ns - self._first_timestamp_ns) // 1000
164
- if current_pts <= self._last_pts:
165
- current_pts = self._last_pts + 1
166
- frame.pts = current_pts
167
- self._last_pts = current_pts
168
-
169
- try:
170
- for packet in self._stream.encode(frame):
171
- self._container.mux(packet)
172
- except (av.error.FFmpegError, ValueError) as exc:
173
- raise VideoEncoderError(
174
- f"PyAV encoding failed at frame {self._frame_count}: {exc}"
175
- ) from exc
176
- self._frame_count += 1
177
-
178
- def close(self) -> int:
179
- for packet in self._stream.encode(None):
180
- self._container.mux(packet)
181
- self._container.close()
182
- return self._frame_count
183
-
184
-
185
- class _FfmpegMp4Strategy:
186
- """ffmpeg-subprocess MP4 writer."""
187
-
188
- def __init__(
189
- self,
190
- path: Path,
191
- *,
192
- codec: VideoCodec,
193
- encoder_backend: EncoderBackend,
194
- quality: int,
195
- width: int,
196
- height: int,
197
- input_pix_fmt: str | None,
198
- ) -> None:
199
- from mcap_codec_support.video.ffmpeg import ( # noqa: PLC0415
200
- FFmpegMp4Encoder,
201
- check_encoder_cli,
202
- )
203
-
204
- encoder_name = resolve_encoder_for_backend(
205
- codec.value, encoder_backend.value, test_fn=check_encoder_cli
206
- )
207
- self._encoder = FFmpegMp4Encoder(
208
- path,
209
- width=width,
210
- height=height,
211
- codec_name=encoder_name,
212
- quality=quality,
213
- input_pix_fmt=input_pix_fmt,
214
- )
215
- self.config = self._encoder.config
216
-
217
- def write_compressed(self, data: bytes, log_time_ns: int) -> None:
218
- del log_time_ns
219
- self._encoder.write_frame(data)
220
-
221
- def write_raw(self, data: bytes, log_time_ns: int) -> None:
222
- del log_time_ns
223
- self._encoder.write_frame(data)
224
-
225
- def write_rgb(self, rgb: Any, log_time_ns: int) -> None:
226
- del log_time_ns
227
- self._encoder.write_frame(rgb.tobytes())
228
-
229
- def close(self) -> int:
230
- frames = self._encoder.frames_fed
231
- self._encoder.close()
232
- return frames
233
-
234
-
235
- class VideoFileWriterSession:
236
- """Lazy per-topic MP4 writer with unified backend selection."""
237
-
238
- def __init__(
239
- self,
240
- path: Path,
241
- *,
242
- codec: VideoCodec,
243
- encoder_backend: EncoderBackend,
244
- quality: int,
245
- mode: EncoderMode,
246
- on_fallback: Callable[[str], None] | None = None,
247
- ) -> None:
248
- self.path = path
249
- self._codec = codec
250
- self._encoder_backend = encoder_backend
251
- self._quality = quality
252
- self._mode = mode
253
- self._on_fallback = on_fallback
254
- self._strategy: VideoFileStrategy | None = None
255
- self._input_kind: str | None = None
256
-
257
- def write_message(self, decoded: Any, schema_name: str, log_time_ns: int) -> None:
258
- if schema_name not in IMAGE_SCHEMAS:
259
- raise VideoEncoderError(f"Unexpected image schema {schema_name!r}")
260
-
261
- if schema_name in COMPRESSED_SCHEMAS:
262
- data = bytes(decoded.data)
263
- first_rgb = self._ensure_open_for_compressed(data)
264
- assert self._strategy is not None
265
- if self._input_kind == "pyav":
266
- rgb = (
267
- first_rgb
268
- if first_rgb is not None
269
- else decode_compressed_image_to_rgb_array(data)
270
- )
271
- self._strategy.write_rgb(rgb, log_time_ns)
272
- else:
273
- self._strategy.write_compressed(data, log_time_ns)
274
- return
275
-
276
- if schema_name in RAW_SCHEMAS:
277
- first_rgb = self._ensure_open_for_raw(decoded)
278
- assert self._strategy is not None
279
- if self._input_kind == "pyav":
280
- rgb = first_rgb if first_rgb is not None else raw_image_to_array(decoded)
281
- self._strategy.write_rgb(rgb, log_time_ns)
282
- else:
283
- data = _pack_raw_image_bytes(
284
- decoded,
285
- width=self._strategy.config.width,
286
- height=self._strategy.config.height,
287
- )
288
- self._strategy.write_raw(data, log_time_ns)
289
- return
290
-
291
- raise VideoEncoderError(f"Unexpected image schema {schema_name!r}")
292
-
293
- def _ensure_open_for_compressed(self, data: bytes) -> Any | None:
294
- return self._open_with_fallback(
295
- decode_pyav=lambda: decode_compressed_image_to_rgb_array(data),
296
- open_ffmpeg=lambda: self._open_ffmpeg_compressed(data),
297
- )
298
-
299
- def _ensure_open_for_raw(self, decoded: Any) -> Any | None:
300
- return self._open_with_fallback(
301
- decode_pyav=lambda: raw_image_to_array(decoded),
302
- open_ffmpeg=lambda: self._open_ffmpeg_raw(decoded),
303
- )
304
-
305
- def _open_with_fallback(
306
- self,
307
- *,
308
- decode_pyav: Callable[[], Any],
309
- open_ffmpeg: Callable[[], None],
310
- ) -> Any | None:
311
- if self._strategy is not None:
312
- return None
313
-
314
- if self._mode is EncoderMode.FFMPEG_CLI:
315
- open_ffmpeg()
316
- return None
317
-
318
- try:
319
- rgb = decode_pyav()
320
- height, width = rgb.shape[:2]
321
- self._open_pyav(width, height)
322
- except (ImportError, VideoEncoderError) as exc:
323
- if self._mode is EncoderMode.PYAV:
324
- raise
325
- if self._on_fallback is not None:
326
- self._on_fallback(
327
- f"PyAV failed to open encoder ({exc}); falling back to ffmpeg-cli."
328
- )
329
- open_ffmpeg()
330
- return None
331
- return rgb
332
-
333
- def _open_pyav(self, width: int, height: int) -> None:
334
- width, height = _even_dimensions(width, height)
335
- self._strategy = _PyAVMp4Strategy(
336
- self.path,
337
- codec=self._codec,
338
- encoder_backend=self._encoder_backend,
339
- quality=self._quality,
340
- width=width,
341
- height=height,
342
- )
343
- self._input_kind = "pyav"
344
-
345
- def _open_ffmpeg_compressed(self, data: bytes) -> None:
346
- from mcap_codec_support.video.ffmpeg import probe_image_dimensions # noqa: PLC0415
347
-
348
- width, height = probe_image_dimensions(data)
349
- width, height = _even_dimensions(width, height)
350
- self._strategy = _FfmpegMp4Strategy(
351
- self.path,
352
- codec=self._codec,
353
- encoder_backend=self._encoder_backend,
354
- quality=self._quality,
355
- width=width,
356
- height=height,
357
- input_pix_fmt=None,
358
- )
359
- self._input_kind = "ffmpeg"
360
-
361
- def _open_ffmpeg_raw(self, decoded: Any) -> None:
362
- from mcap_codec_support.video.ffmpeg import ROS_ENCODING_TO_PIX_FMT # noqa: PLC0415
363
-
364
- encoding = str(decoded.encoding).lower()
365
- pix_fmt = ROS_ENCODING_TO_PIX_FMT.get(encoding)
366
- if not pix_fmt:
367
- raise VideoEncoderError(f"Unsupported image encoding: {decoded.encoding}")
368
- width, height = _even_dimensions(decoded.width, decoded.height)
369
- self._strategy = _FfmpegMp4Strategy(
370
- self.path,
371
- codec=self._codec,
372
- encoder_backend=self._encoder_backend,
373
- quality=self._quality,
374
- width=width,
375
- height=height,
376
- input_pix_fmt=pix_fmt,
377
- )
378
- self._input_kind = "ffmpeg"
379
-
380
- def close(self) -> int:
381
- if self._strategy is None:
382
- return 0
383
- return self._strategy.close()
384
-
385
-
386
- def _even_dimensions(width: int, height: int) -> tuple[int, int]:
387
- width -= width % 2
388
- height -= height % 2
389
- if width < 2 or height < 2:
390
- raise VideoEncoderError(f"Source frame too small ({width}x{height}) for video encoding")
391
- return width, height
392
-
393
-
394
- def create_video_file_writer(
395
- path: Path,
396
- *,
397
- codec: VideoCodec,
398
- encoder_backend: EncoderBackend,
399
- quality: int,
400
- mode: EncoderMode,
401
- on_fallback: Callable[[str], None] | None = None,
402
- ) -> VideoFileWriterSession:
403
- """Create a lazy MP4 writer session."""
404
- return VideoFileWriterSession(
405
- path,
406
- codec=codec,
407
- encoder_backend=encoder_backend,
408
- quality=quality,
409
- mode=mode,
410
- on_fallback=on_fallback,
411
- )