mcap-codec-support 0.2.0__tar.gz → 0.3.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.
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/PKG-INFO +4 -5
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/pyproject.toml +4 -4
- mcap_codec_support-0.3.0/src/mcap_codec_support/_messages.py +15 -0
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/_protocols.py +0 -15
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/pointcloud/__init__.py +2 -0
- mcap_codec_support-0.3.0/src/mcap_codec_support/pointcloud/_messages.py +28 -0
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/pointcloud/compression.py +1 -1
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/pointcloud/factories.py +29 -6
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/video/__init__.py +0 -6
- mcap_codec_support-0.3.0/src/mcap_codec_support/video/_messages.py +28 -0
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/video/common.py +29 -0
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/video/compression.py +22 -25
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/video/factories.py +2 -1
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/video/ffmpeg.py +0 -153
- mcap_codec_support-0.2.0/src/mcap_codec_support/_messages.py +0 -56
- mcap_codec_support-0.2.0/src/mcap_codec_support/video/file_writer.py +0 -411
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/README.md +0 -0
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/__init__.py +0 -0
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/_schemas.py +0 -0
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/pointcloud/schemas.py +0 -0
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/py.typed +0 -0
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/video/pyav.py +0 -0
- {mcap_codec_support-0.2.0 → mcap_codec_support-0.3.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.
|
|
3
|
+
Version: 0.3.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,
|
|
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.
|
|
3
|
+
version = "0.3.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
|
-
|
|
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
|
|
|
@@ -78,17 +77,3 @@ class VideoCompressionBackend(Protocol):
|
|
|
78
77
|
) -> VideoEncoderProtocol: ...
|
|
79
78
|
|
|
80
79
|
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: ...
|
{mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/pointcloud/__init__.py
RENAMED
|
@@ -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
|
|
11
|
+
from pointcloud2 import Pointcloud2Msg, PointFieldMsg
|
|
12
12
|
from pureini import CompressionOption, EncodingInfo, EncodingOptions
|
|
13
13
|
|
|
14
14
|
DRACO_MAX_QUANTIZATION_BITS = 30
|
{mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/pointcloud/factories.py
RENAMED
|
@@ -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,
|
|
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:
|
|
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":
|
|
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":
|
|
111
|
+
"stamp": _stamp_from_msg(msg.timestamp),
|
|
89
112
|
"frame_id": msg.frame_id,
|
|
90
113
|
}
|
|
91
114
|
|
{mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/video/__init__.py
RENAMED
|
@@ -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
|
{mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/video/common.py
RENAMED
|
@@ -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
|
# ---------------------------------------------------------------------------
|
{mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/video/compression.py
RENAMED
|
@@ -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
|
|
34
|
+
from mcap_codec_support._protocols import (
|
|
35
|
+
RawImageMessage,
|
|
36
|
+
VideoCompressionBackend,
|
|
37
|
+
VideoDecompressorProtocol,
|
|
38
|
+
)
|
|
26
39
|
|
|
27
40
|
|
|
28
41
|
class _PyAVCompressionBackend:
|
|
@@ -190,30 +203,12 @@ def prefetch_image_decodes(
|
|
|
190
203
|
yield buffer.popleft()
|
|
191
204
|
|
|
192
205
|
|
|
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
206
|
def encode_raw_image_to_jpeg(
|
|
212
|
-
decoded_message:
|
|
207
|
+
decoded_message: RawImageMessage, *, jpeg_quality: int, scale: int | None
|
|
213
208
|
) -> tuple[bytes, int, int]:
|
|
214
|
-
"""Encode a raw ROS Image message to JPEG using
|
|
215
|
-
|
|
216
|
-
|
|
209
|
+
"""Encode a raw ROS Image message to JPEG using Pillow."""
|
|
210
|
+
image = raw_image_to_pil(decoded_message)
|
|
211
|
+
src_w, src_h = image.size
|
|
217
212
|
if scale is not None:
|
|
218
213
|
target_w, target_h = calculate_downscale_dimensions(src_w, src_h, scale)
|
|
219
214
|
else:
|
|
@@ -225,9 +220,11 @@ def encode_raw_image_to_jpeg(
|
|
|
225
220
|
raise VideoEncoderError(f"Source frame too small ({target_w}x{target_h}) for JPEG encoding")
|
|
226
221
|
|
|
227
222
|
if target_w != src_w or target_h != src_h:
|
|
228
|
-
|
|
223
|
+
image = image.resize((target_w, target_h), _PIL_BILINEAR)
|
|
229
224
|
|
|
230
|
-
|
|
225
|
+
buf = io.BytesIO()
|
|
226
|
+
image.save(buf, format="JPEG", quality=jpeg_quality)
|
|
227
|
+
return buf.getvalue(), target_w, target_h
|
|
231
228
|
|
|
232
229
|
|
|
233
230
|
def decode_compressed_image_to_rgb_array(data: bytes) -> Any:
|
{mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/video/factories.py
RENAMED
|
@@ -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
|
|
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
|
|
{mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/video/ffmpeg.py
RENAMED
|
@@ -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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/pointcloud/schemas.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcap_codec_support-0.2.0 → mcap_codec_support-0.3.0}/src/mcap_codec_support/video/schemas.py
RENAMED
|
File without changes
|