pymcap-cli 0.3.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 (64) hide show
  1. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/PKG-INFO +67 -1
  2. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/README.md +61 -0
  3. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/pyproject.toml +8 -1
  4. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cli.py +48 -0
  5. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/_run_processor.py +2 -2
  6. pymcap_cli-0.5.0/src/pymcap_cli/cmd/bag2mcap_cmd.py +285 -0
  7. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/cat_cmd.py +155 -97
  8. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/compress_cmd.py +2 -2
  9. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/convert_cmd.py +3 -3
  10. pymcap_cli-0.5.0/src/pymcap_cli/cmd/diag_cmd.py +609 -0
  11. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/du_cmd.py +3 -3
  12. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/filter_cmd.py +2 -2
  13. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/info_cmd.py +5 -5
  14. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/list_cmd.py +2 -2
  15. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/merge_cmd.py +2 -2
  16. pymcap_cli-0.5.0/src/pymcap_cli/cmd/plot_cmd.py +421 -0
  17. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/process_cmd.py +2 -2
  18. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/rechunk_cmd.py +2 -2
  19. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/records_cmd.py +1 -1
  20. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/recover_cmd.py +2 -2
  21. pymcap_cli-0.5.0/src/pymcap_cli/cmd/roscompress_cmd.py +806 -0
  22. pymcap_cli-0.5.0/src/pymcap_cli/cmd/rosdecompress_cmd.py +321 -0
  23. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/tftree_cmd.py +1 -1
  24. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/topic_chunks_cmd.py +1 -1
  25. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/video_cmd.py +20 -11
  26. {pymcap_cli-0.3.0/src/pymcap_cli → pymcap_cli-0.5.0/src/pymcap_cli/core}/mcap_processor.py +2 -2
  27. pymcap_cli-0.5.0/src/pymcap_cli/core/mcap_transform.py +138 -0
  28. pymcap_cli-0.5.0/src/pymcap_cli/core/msg_resolver.py +447 -0
  29. pymcap_cli-0.5.0/src/pymcap_cli/display/__init__.py +0 -0
  30. {pymcap_cli-0.3.0/src/pymcap_cli → pymcap_cli-0.5.0/src/pymcap_cli/display}/display_utils.py +1 -1
  31. pymcap_cli-0.5.0/src/pymcap_cli/display/sparkline.py +75 -0
  32. pymcap_cli-0.5.0/src/pymcap_cli/encoding/__init__.py +0 -0
  33. pymcap_cli-0.5.0/src/pymcap_cli/encoding/decompress.py +213 -0
  34. pymcap_cli-0.5.0/src/pymcap_cli/encoding/encoder_common.py +275 -0
  35. pymcap_cli-0.5.0/src/pymcap_cli/encoding/pointcloud.py +212 -0
  36. pymcap_cli-0.5.0/src/pymcap_cli/encoding/video_factory.py +47 -0
  37. pymcap_cli-0.5.0/src/pymcap_cli/encoding/video_ffmpeg.py +793 -0
  38. pymcap_cli-0.5.0/src/pymcap_cli/encoding/video_protocols.py +40 -0
  39. pymcap_cli-0.5.0/src/pymcap_cli/encoding/video_pyav.py +372 -0
  40. pymcap_cli-0.5.0/src/pymcap_cli/py.typed +0 -0
  41. pymcap_cli-0.5.0/src/pymcap_cli/rosbag_reader/__init__.py +16 -0
  42. pymcap_cli-0.5.0/src/pymcap_cli/rosbag_reader/_reader.py +340 -0
  43. pymcap_cli-0.5.0/src/pymcap_cli/rosbag_reader/_types.py +48 -0
  44. pymcap_cli-0.5.0/src/pymcap_cli/rosbag_reader/py.typed +0 -0
  45. pymcap_cli-0.5.0/src/pymcap_cli/types/__init__.py +0 -0
  46. {pymcap_cli-0.3.0/src/pymcap_cli/cmd → pymcap_cli-0.5.0/src/pymcap_cli/types}/info_data.py +1 -1
  47. {pymcap_cli-0.3.0/src/pymcap_cli/cmd → pymcap_cli-0.5.0/src/pymcap_cli/types}/info_link.py +1 -1
  48. pymcap_cli-0.5.0/src/pymcap_cli/types/info_types.py +514 -0
  49. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/utils.py +1 -1
  50. pymcap_cli-0.3.0/src/pymcap_cli/cmd/roscompress_cmd.py +0 -618
  51. pymcap_cli-0.3.0/src/pymcap_cli/image_utils.py +0 -587
  52. pymcap_cli-0.3.0/src/pymcap_cli/msg_resolver.py +0 -284
  53. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/__init__.py +0 -0
  54. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/__init__.py +0 -0
  55. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/info_json_cmd.py +0 -0
  56. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/cmd/recover_inplace_cmd.py +0 -0
  57. /pymcap_cli-0.3.0/src/pymcap_cli/py.typed → /pymcap_cli-0.5.0/src/pymcap_cli/core/__init__.py +0 -0
  58. {pymcap_cli-0.3.0/src/pymcap_cli → pymcap_cli-0.5.0/src/pymcap_cli/core}/input_handler.py +0 -0
  59. {pymcap_cli-0.3.0/src/pymcap_cli → pymcap_cli-0.5.0/src/pymcap_cli/core}/processors.py +0 -0
  60. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/debug_wrapper.py +0 -0
  61. {pymcap_cli-0.3.0/src/pymcap_cli → pymcap_cli-0.5.0/src/pymcap_cli/display}/osc_utils.py +0 -0
  62. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/http_utils.py +0 -0
  63. {pymcap_cli-0.3.0 → pymcap_cli-0.5.0}/src/pymcap_cli/info_types.py +0 -0
  64. {pymcap_cli-0.3.0/src/pymcap_cli → pymcap_cli-0.5.0/src/pymcap_cli/types}/types_manual.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pymcap-cli
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: High-performance Python CLI for MCAP file processing with advanced recovery, filtering, and optimization capabilities
5
5
  Keywords: mcap,cli,robotics,ros,ros2,recovery,filtering,compression
6
6
  Author: Marko Bausch
@@ -26,7 +26,10 @@ Requires-Dist: mcap-ros2-support-fast
26
26
  Requires-Dist: cyclopts>=4
27
27
  Requires-Dist: ros-parser
28
28
  Requires-Dist: platformdirs>=4.0.0
29
+ Requires-Dist: pyyaml>=6.0
29
30
  Requires-Dist: typing-extensions>=4.15.0
31
+ Requires-Dist: pymcap-cli[video,pointcloud,plot] ; extra == 'all'
32
+ Requires-Dist: plotly>=6.0.0 ; extra == 'plot'
30
33
  Requires-Dist: pureini ; extra == 'pointcloud'
31
34
  Requires-Dist: av>=12.0.0 ; extra == 'video'
32
35
  Requires-Dist: numpy>=1.24.0 ; extra == 'video'
@@ -34,6 +37,8 @@ Requires-Python: >=3.10
34
37
  Project-URL: Homepage, https://github.com/mrkbac/robotic-tools
35
38
  Project-URL: Issues, https://github.com/mrkbac/robotic-tools/issues
36
39
  Project-URL: Repository, https://github.com/mrkbac/robotic-tools
40
+ Provides-Extra: all
41
+ Provides-Extra: plot
37
42
  Provides-Extra: pointcloud
38
43
  Provides-Extra: video
39
44
  Description-Content-Type: text/markdown
@@ -129,6 +134,13 @@ pymcap-cli cat recording.mcap --query '/detections.objects[:]{confidence>0.8}'
129
134
 
130
135
  # Pipe to file as JSONL
131
136
  pymcap-cli cat recording.mcap > messages.jsonl
137
+
138
+ # Write to file with progress bar
139
+ pymcap-cli cat recording.mcap -o messages.jsonl
140
+
141
+ # Control binary field serialization
142
+ pymcap-cli cat recording.mcap --bytes base64 # base64-encoded
143
+ pymcap-cli cat recording.mcap --bytes skip # omit binary fields
132
144
  ```
133
145
 
134
146
  ### `tftree` — TF Transform Tree
@@ -149,6 +161,45 @@ pymcap-cli tftree data.mcap
149
161
  pymcap-cli tftree data.mcap --static-only
150
162
  ```
151
163
 
164
+ ### `diag` — ROS2 Diagnostics
165
+
166
+ Inspect ROS2 diagnostics with per-component health overview, sparkline timelines, frequency stats, and time-in-state tracking.
167
+
168
+ ```bash
169
+ # Show components with issues (WARN/ERROR/STALE)
170
+ pymcap-cli diag recording.mcap
171
+
172
+ # Show all components including OK
173
+ pymcap-cli diag recording.mcap --all
174
+
175
+ # Detailed inspection of specific components
176
+ pymcap-cli diag recording.mcap --inspect "encoder"
177
+
178
+ # Hierarchical tree view
179
+ pymcap-cli diag recording.mcap --tree
180
+
181
+ # JSON output for scripting
182
+ pymcap-cli diag recording.mcap --json
183
+ ```
184
+
185
+ ### `plot` — Time-Series Visualization
186
+
187
+ Plot message fields over time using Plotly. Supports named labels, LTTB downsampling, XY trajectory mode, and saves to interactive HTML. Requires the `plot` extra (`uv add pymcap-cli[plot]`).
188
+
189
+ ```bash
190
+ # Plot a single field
191
+ pymcap-cli plot recording.mcap /odom.pose.position.x
192
+
193
+ # Named series
194
+ pymcap-cli plot recording.mcap "Vel X=/odom.twist.twist.linear.x"
195
+
196
+ # XY trajectory plot
197
+ pymcap-cli plot recording.mcap --xy /odom.pose.position.x /odom.pose.position.y
198
+
199
+ # Downsample to 1000 points and save to file
200
+ pymcap-cli plot recording.mcap /odom.pose.position.x -d 1000 -o plot.html
201
+ ```
202
+
152
203
  ### `process` — Unified Processing
153
204
 
154
205
  The most powerful command — combines recovery, filtering, and optimization in a single pass.
@@ -311,6 +362,21 @@ pymcap-cli roscompress data.mcap -o compressed.mcap
311
362
  pymcap-cli roscompress data.mcap -o compressed.mcap --quality 28 --codec h265
312
363
  ```
313
364
 
365
+ ### `rosdecompress` — ROS Decompression
366
+
367
+ Decompress CompressedVideo and CompressedPointCloud2 topics back to standard ROS formats. Requires the `video` extra.
368
+
369
+ ```bash
370
+ # Decompress to CompressedImage (JPEG)
371
+ pymcap-cli rosdecompress input.mcap output.mcap
372
+
373
+ # Decompress to raw Image
374
+ pymcap-cli rosdecompress input.mcap output.mcap --video-format raw
375
+
376
+ # Skip point cloud decompression
377
+ pymcap-cli rosdecompress input.mcap output.mcap --no-pointcloud
378
+ ```
379
+
314
380
  ### Shell Autocompletion
315
381
 
316
382
  ```bash
@@ -89,6 +89,13 @@ pymcap-cli cat recording.mcap --query '/detections.objects[:]{confidence>0.8}'
89
89
 
90
90
  # Pipe to file as JSONL
91
91
  pymcap-cli cat recording.mcap > messages.jsonl
92
+
93
+ # Write to file with progress bar
94
+ pymcap-cli cat recording.mcap -o messages.jsonl
95
+
96
+ # Control binary field serialization
97
+ pymcap-cli cat recording.mcap --bytes base64 # base64-encoded
98
+ pymcap-cli cat recording.mcap --bytes skip # omit binary fields
92
99
  ```
93
100
 
94
101
  ### `tftree` — TF Transform Tree
@@ -109,6 +116,45 @@ pymcap-cli tftree data.mcap
109
116
  pymcap-cli tftree data.mcap --static-only
110
117
  ```
111
118
 
119
+ ### `diag` — ROS2 Diagnostics
120
+
121
+ Inspect ROS2 diagnostics with per-component health overview, sparkline timelines, frequency stats, and time-in-state tracking.
122
+
123
+ ```bash
124
+ # Show components with issues (WARN/ERROR/STALE)
125
+ pymcap-cli diag recording.mcap
126
+
127
+ # Show all components including OK
128
+ pymcap-cli diag recording.mcap --all
129
+
130
+ # Detailed inspection of specific components
131
+ pymcap-cli diag recording.mcap --inspect "encoder"
132
+
133
+ # Hierarchical tree view
134
+ pymcap-cli diag recording.mcap --tree
135
+
136
+ # JSON output for scripting
137
+ pymcap-cli diag recording.mcap --json
138
+ ```
139
+
140
+ ### `plot` — Time-Series Visualization
141
+
142
+ Plot message fields over time using Plotly. Supports named labels, LTTB downsampling, XY trajectory mode, and saves to interactive HTML. Requires the `plot` extra (`uv add pymcap-cli[plot]`).
143
+
144
+ ```bash
145
+ # Plot a single field
146
+ pymcap-cli plot recording.mcap /odom.pose.position.x
147
+
148
+ # Named series
149
+ pymcap-cli plot recording.mcap "Vel X=/odom.twist.twist.linear.x"
150
+
151
+ # XY trajectory plot
152
+ pymcap-cli plot recording.mcap --xy /odom.pose.position.x /odom.pose.position.y
153
+
154
+ # Downsample to 1000 points and save to file
155
+ pymcap-cli plot recording.mcap /odom.pose.position.x -d 1000 -o plot.html
156
+ ```
157
+
112
158
  ### `process` — Unified Processing
113
159
 
114
160
  The most powerful command — combines recovery, filtering, and optimization in a single pass.
@@ -271,6 +317,21 @@ pymcap-cli roscompress data.mcap -o compressed.mcap
271
317
  pymcap-cli roscompress data.mcap -o compressed.mcap --quality 28 --codec h265
272
318
  ```
273
319
 
320
+ ### `rosdecompress` — ROS Decompression
321
+
322
+ Decompress CompressedVideo and CompressedPointCloud2 topics back to standard ROS formats. Requires the `video` extra.
323
+
324
+ ```bash
325
+ # Decompress to CompressedImage (JPEG)
326
+ pymcap-cli rosdecompress input.mcap output.mcap
327
+
328
+ # Decompress to raw Image
329
+ pymcap-cli rosdecompress input.mcap output.mcap --video-format raw
330
+
331
+ # Skip point cloud decompression
332
+ pymcap-cli rosdecompress input.mcap output.mcap --no-pointcloud
333
+ ```
334
+
274
335
  ### Shell Autocompletion
275
336
 
276
337
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pymcap-cli"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "High-performance Python CLI for MCAP file processing with advanced recovery, filtering, and optimization capabilities"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -33,6 +33,7 @@ dependencies = [
33
33
  "cyclopts>=4",
34
34
  "ros-parser",
35
35
  "platformdirs>=4.0.0",
36
+ "pyyaml>=6.0",
36
37
  "typing-extensions>=4.15.0",
37
38
  ]
38
39
 
@@ -44,6 +45,12 @@ Issues = "https://github.com/mrkbac/robotic-tools/issues"
44
45
  [project.optional-dependencies]
45
46
  video = ["av>=12.0.0", "numpy>=1.24.0"]
46
47
  pointcloud = ["pureini"]
48
+ plot = [
49
+ "plotly>=6.0.0",
50
+ ]
51
+ all = [
52
+ "pymcap-cli[video,pointcloud,plot]",
53
+ ]
47
54
 
48
55
  [build-system]
49
56
  requires = ["uv_build>=0.8.9,<0.9.0"]
@@ -5,9 +5,11 @@ import sys
5
5
  from cyclopts import App, Group
6
6
 
7
7
  from pymcap_cli.cmd import (
8
+ bag2mcap_cmd,
8
9
  cat_cmd,
9
10
  compress_cmd,
10
11
  convert_cmd,
12
+ diag_cmd,
11
13
  du_cmd,
12
14
  filter_cmd,
13
15
  info_cmd,
@@ -44,6 +46,27 @@ except ImportError:
44
46
  return 1
45
47
 
46
48
 
49
+ try:
50
+ from pymcap_cli.cmd.plot_cmd import plot
51
+ except ImportError:
52
+
53
+ def plot() -> int:
54
+ """Plot command is unavailable because 'plotly' is not installed.
55
+
56
+ To enable plot functionality, please install pymcap-cli with the 'plot' extra:
57
+
58
+ uv add --group plot pymcap-cli
59
+ """
60
+ print( # noqa: T201
61
+ "Error:\n"
62
+ "Plot command is unavailable because 'plotly' is not installed.\n"
63
+ "To enable plot functionality, please install pymcap-cli with the 'plot' extra:\n\n"
64
+ " uv add --group plot pymcap-cli\n",
65
+ file=sys.stderr,
66
+ )
67
+ return 1
68
+
69
+
47
70
  try:
48
71
  from pymcap_cli.cmd.roscompress_cmd import roscompress
49
72
  except ImportError:
@@ -65,6 +88,27 @@ except ImportError:
65
88
  return 1
66
89
 
67
90
 
91
+ try:
92
+ from pymcap_cli.cmd.rosdecompress_cmd import rosdecompress
93
+ except ImportError:
94
+
95
+ def rosdecompress() -> int:
96
+ """ROS decompress command is unavailable because the 'av' package is not installed.
97
+
98
+ To enable rosdecompress functionality, please install pymcap-cli with the 'video' extra:
99
+
100
+ uv add --group video pymcap-cli
101
+ """
102
+ print( # noqa: T201
103
+ "Error:\n"
104
+ "ROS decompress command is unavailable because the 'av' package is not installed.\n"
105
+ "To enable this functionality, please install pymcap-cli with the 'video' extra:\n\n"
106
+ " uv add --group video pymcap-cli\n",
107
+ file=sys.stderr,
108
+ )
109
+ return 1
110
+
111
+
68
112
  app = App(
69
113
  name="pymcap-cli",
70
114
  help="CLI tool for slicing and dicing MCAP files.",
@@ -76,6 +120,7 @@ transform_group = Group("Transform", sort_key=1)
76
120
 
77
121
  # Inspect commands — read-only, extract information
78
122
  app.command(name="cat", group=inspect_group)(cat_cmd.cat)
123
+ app.command(name="diag", group=inspect_group)(diag_cmd.diag)
79
124
  app.command(name="du", group=inspect_group)(du_cmd.du)
80
125
  app.command(name="info", group=inspect_group)(info_cmd.info)
81
126
  app.command(name="info-json", group=inspect_group)(info_json_cmd.info_json)
@@ -86,6 +131,7 @@ app.command(name="tftree", group=inspect_group)(tftree_cmd.tftree)
86
131
  app.command(name="topic-chunks", group=inspect_group)(topic_chunks_cmd.topic_chunks)
87
132
 
88
133
  # Transform commands — convert, filter, or produce new files
134
+ app.command(name="bag2mcap", group=transform_group)(bag2mcap_cmd.bag2mcap)
89
135
  app.command(name="compress", group=transform_group)(compress_cmd.compress)
90
136
  app.command(name="convert", group=transform_group)(convert_cmd.convert)
91
137
  app.command(name="filter", group=transform_group)(filter_cmd.filter_cmd)
@@ -94,7 +140,9 @@ app.command(name="process", group=transform_group)(process_cmd.process)
94
140
  app.command(name="rechunk", group=transform_group)(rechunk_cmd.rechunk)
95
141
  app.command(name="recover", group=transform_group)(recover_cmd.recover)
96
142
  app.command(name="recover-inplace", group=transform_group)(recover_inplace_cmd.recover_inplace)
143
+ app.command(name="plot", group=inspect_group)(plot)
97
144
  app.command(name="roscompress", group=transform_group)(roscompress)
145
+ app.command(name="rosdecompress", group=transform_group)(rosdecompress)
98
146
  app.command(name="video", group=transform_group)(video)
99
147
 
100
148
 
@@ -4,8 +4,8 @@ import contextlib
4
4
  from dataclasses import dataclass
5
5
  from pathlib import Path
6
6
 
7
- from pymcap_cli.input_handler import open_input
8
- from pymcap_cli.mcap_processor import (
7
+ from pymcap_cli.core.input_handler import open_input
8
+ from pymcap_cli.core.mcap_processor import (
9
9
  InputFile,
10
10
  InputOptions,
11
11
  McapProcessor,
@@ -0,0 +1,285 @@
1
+ """Convert ROS1 bag files to MCAP format (ros1 profile)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import BinaryIO
10
+
11
+ from rich.console import Console
12
+ from rich.progress import (
13
+ BarColumn,
14
+ Progress,
15
+ SpinnerColumn,
16
+ TaskProgressColumn,
17
+ TextColumn,
18
+ TimeRemainingColumn,
19
+ )
20
+ from small_mcap.writer import CompressionType, McapWriter
21
+ from small_mcap.writer import CompressionType as WriterCompressionType
22
+
23
+ from pymcap_cli.display.osc_utils import OSCProgressColumn
24
+ from pymcap_cli.types.types_manual import (
25
+ DEFAULT_CHUNK_SIZE,
26
+ DEFAULT_COMPRESSION,
27
+ ChunkSizeOption,
28
+ CompressionOption,
29
+ ForceOverwriteOption,
30
+ OutputPathOption,
31
+ )
32
+ from pymcap_cli.utils import confirm_output_overwrite
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ @dataclass
38
+ class Bag2McapOptions:
39
+ """Options for bag to MCAP conversion."""
40
+
41
+ chunk_size: int = 1024 * 1024 * 8 # 8MB default
42
+ compression: CompressionType = CompressionType.ZSTD
43
+ enable_crcs: bool = True
44
+ use_chunking: bool = True
45
+
46
+
47
+ @dataclass
48
+ class Bag2McapStatistics:
49
+ """Statistics from bag to MCAP conversion."""
50
+
51
+ topic_count: int
52
+ message_count: int
53
+ schema_count: int
54
+
55
+
56
+ def convert_bag_to_mcap(
57
+ bag_path: Path,
58
+ output: BinaryIO,
59
+ options: Bag2McapOptions,
60
+ ) -> Bag2McapStatistics:
61
+ """Convert a ROS1 bag file to MCAP format with ros1 profile.
62
+
63
+ Messages are passed through as raw ROS1-serialized bytes.
64
+ Schema encoding is "ros1msg", message encoding is "ros1".
65
+
66
+ Args:
67
+ bag_path: Path to the input .bag file.
68
+ output: Output stream for MCAP data.
69
+ options: Conversion options.
70
+
71
+ Returns:
72
+ Conversion statistics.
73
+
74
+ """
75
+ from pymcap_cli.rosbag_reader import read_bag_info, read_bag_messages # noqa: PLC0415
76
+
77
+ with bag_path.open("rb") as bag_file:
78
+ info = read_bag_info(bag_file)
79
+
80
+ if not info.connections:
81
+ logger.warning("No connections found in bag file")
82
+ writer = McapWriter(
83
+ output,
84
+ chunk_size=options.chunk_size,
85
+ compression=options.compression,
86
+ enable_crcs=options.enable_crcs,
87
+ use_chunking=options.use_chunking,
88
+ )
89
+ writer.start(profile="ros1")
90
+ writer.finish()
91
+ return Bag2McapStatistics(topic_count=0, message_count=0, schema_count=0)
92
+
93
+ logger.info(f"Found {len(info.connections)} connections, {info.message_count} messages")
94
+
95
+ # Build schema map: deduplicate by msg_type
96
+ schema_map: dict[str, int] = {} # msg_type -> schema_id
97
+ schema_definitions: dict[int, tuple[str, str]] = {} # schema_id -> (msg_type, definition)
98
+ next_schema_id = 1
99
+
100
+ for conn in info.connections.values():
101
+ if conn.msg_type not in schema_map:
102
+ schema_id = next_schema_id
103
+ next_schema_id += 1
104
+ schema_map[conn.msg_type] = schema_id
105
+ schema_definitions[schema_id] = (conn.msg_type, conn.message_definition)
106
+
107
+ # Build channel map: deduplicate by (topic, msg_type)
108
+ # Multiple connections can exist for the same topic (multiple publishers)
109
+ channel_map: dict[tuple[str, str], int] = {} # (topic, msg_type) -> channel_id
110
+ conn_to_channel: dict[int, int] = {} # conn_id -> channel_id
111
+ next_channel_id = 1
112
+
113
+ for conn in info.connections.values():
114
+ key = (conn.topic, conn.msg_type)
115
+ if key not in channel_map:
116
+ channel_map[key] = next_channel_id
117
+ next_channel_id += 1
118
+ conn_to_channel[conn.conn_id] = channel_map[key]
119
+
120
+ # Create MCAP writer
121
+ writer = McapWriter(
122
+ output,
123
+ chunk_size=options.chunk_size,
124
+ compression=options.compression,
125
+ enable_crcs=options.enable_crcs,
126
+ use_chunking=options.use_chunking,
127
+ )
128
+ writer.start(profile="ros1")
129
+
130
+ # Write schemas
131
+ for schema_id, (msg_type, definition) in schema_definitions.items():
132
+ writer.add_schema(
133
+ schema_id=schema_id,
134
+ name=msg_type,
135
+ encoding="ros1msg",
136
+ data=definition.encode("utf-8"),
137
+ )
138
+
139
+ # Write channels
140
+ for (topic, msg_type), channel_id in channel_map.items():
141
+ schema_id = schema_map[msg_type]
142
+
143
+ # Collect metadata from connections for this channel
144
+ metadata: dict[str, str] = {}
145
+ for conn in info.connections.values():
146
+ if conn.topic == topic and conn.msg_type == msg_type:
147
+ metadata["md5sum"] = conn.md5sum
148
+ if conn.callerid:
149
+ metadata["callerid"] = conn.callerid
150
+ break
151
+
152
+ writer.add_channel(
153
+ channel_id=channel_id,
154
+ topic=topic,
155
+ message_encoding="ros1",
156
+ schema_id=schema_id,
157
+ metadata=metadata,
158
+ )
159
+
160
+ # Read and write messages
161
+ logger.info("Converting messages...")
162
+ sequence_counters: defaultdict[int, int] = defaultdict(int)
163
+ message_count = 0
164
+
165
+ with (
166
+ bag_path.open("rb") as bag_file,
167
+ Progress(
168
+ SpinnerColumn(),
169
+ TextColumn("[bold blue]{task.description}"),
170
+ BarColumn(),
171
+ TaskProgressColumn(),
172
+ TimeRemainingColumn(),
173
+ OSCProgressColumn(title="Converting messages"),
174
+ transient=False,
175
+ ) as progress,
176
+ ):
177
+ task = progress.add_task(
178
+ "[cyan]Converting messages...",
179
+ total=info.message_count if info.message_count > 0 else None,
180
+ )
181
+
182
+ for msg in read_bag_messages(bag_file, info):
183
+ channel_id = conn_to_channel[msg.conn_id]
184
+
185
+ writer.add_message(
186
+ channel_id=channel_id,
187
+ log_time=msg.time_ns,
188
+ data=msg.data,
189
+ publish_time=msg.time_ns,
190
+ sequence=sequence_counters[channel_id],
191
+ )
192
+ sequence_counters[channel_id] += 1
193
+ message_count += 1
194
+ progress.advance(task)
195
+
196
+ writer.finish()
197
+
198
+ logger.info(
199
+ f"Conversion complete: {len(channel_map)} topics, "
200
+ f"{message_count:,} messages, {len(schema_definitions)} schemas"
201
+ )
202
+
203
+ return Bag2McapStatistics(
204
+ topic_count=len(channel_map),
205
+ message_count=message_count,
206
+ schema_count=len(schema_definitions),
207
+ )
208
+
209
+
210
+ console = Console()
211
+
212
+
213
+ def bag2mcap(
214
+ file: str,
215
+ output: OutputPathOption,
216
+ *,
217
+ chunk_size: ChunkSizeOption = DEFAULT_CHUNK_SIZE,
218
+ compression: CompressionOption = DEFAULT_COMPRESSION,
219
+ force: ForceOverwriteOption = False,
220
+ ) -> int:
221
+ """Convert ROS1 bag files to MCAP format.
222
+
223
+ Converts ROS1 bag files to MCAP with ros1 profile, preserving all
224
+ message data as raw ROS1-serialized bytes. Schemas use ros1msg encoding
225
+ with the full message definition from the bag file.
226
+
227
+ Parameters
228
+ ----------
229
+ file
230
+ Path to the ROS1 .bag file to convert.
231
+ output
232
+ Output MCAP filename.
233
+ chunk_size
234
+ Chunk size of output file in bytes.
235
+ compression
236
+ Compression algorithm for output file.
237
+ force
238
+ Force overwrite of output file without confirmation.
239
+
240
+ Examples
241
+ --------
242
+ ```
243
+ # Basic conversion
244
+ pymcap-cli bag2mcap recording.bag -o recording.mcap
245
+
246
+ # With custom compression
247
+ pymcap-cli bag2mcap recording.bag -o recording.mcap --compression lz4
248
+ ```
249
+ """
250
+ input_path = Path(file)
251
+ if not input_path.exists():
252
+ console.print(f"[red]Error: Input file '{file}' does not exist[/red]")
253
+ return 1
254
+
255
+ confirm_output_overwrite(output, force)
256
+
257
+ compression_map = {
258
+ "zstd": WriterCompressionType.ZSTD,
259
+ "lz4": WriterCompressionType.LZ4,
260
+ "none": WriterCompressionType.NONE,
261
+ }
262
+ writer_compression = compression_map[compression.value]
263
+
264
+ options = Bag2McapOptions(
265
+ chunk_size=chunk_size,
266
+ compression=writer_compression,
267
+ )
268
+
269
+ console.print(f"[blue]Converting '{file}' to '{output}'[/blue]")
270
+
271
+ with output.open("wb") as output_stream:
272
+ try:
273
+ stats = convert_bag_to_mcap(input_path, output_stream, options)
274
+
275
+ console.print("[green]Conversion completed successfully[/green]")
276
+ console.print(
277
+ f"Converted {stats.topic_count} topics, "
278
+ f"{stats.message_count:,} messages, "
279
+ f"{stats.schema_count} schemas"
280
+ )
281
+ except Exception as e: # noqa: BLE001
282
+ console.print(f"[red]Error during conversion: {e}[/red]")
283
+ return 1
284
+
285
+ return 0