feat(mcap): add copy mode for multi-camera exports

Add an opt-in multi-camera copy layout for zed_svo_to_mcap.

copy mode preserves each camera's original timestamps and cadence,
writes namespaced /zedN topics without /bundle, and supports
common-overlap or full-range export windows.

Update the batch wrapper, Python validator, RGBD viewer, and
documentation so copy-layout MCAP files are treated as a first-class
format rather than invalid bundled output.

Validation:
- cmake --build build --target zed_svo_to_mcap -j4
- uv run python -m py_compile scripts/zed_batch_svo_to_mcap.py scripts/mcap_bundle_validator.py scripts/mcap_rgbd_viewer.py
- build/bin/zed_svo_to_mcap --segment-dir /workspaces/data/kindergarten/bar/2026-03-18T11-59-41 --output /tmp/bar_11-59-41_copy_common.mcap --encoder-device nvidia --depth-mode neural_plus --depth-size optimal --bundle-policy copy --copy-range common
- uv run python scripts/mcap_bundle_validator.py /tmp/bar_11-59-41_copy_common.mcap
- uv run --extra viewer python scripts/mcap_rgbd_viewer.py /tmp/bar_11-59-41_copy_common.mcap --summary-only
This commit is contained in:
2026-03-24 10:13:09 +00:00
parent 80d90887ed
commit 8597976678
8 changed files with 1831 additions and 200 deletions
+54 -7
View File
@@ -36,6 +36,7 @@ class BatchConfig:
depth_mode: str
depth_size: str
bundle_policy: str
copy_range: str
bundle_topic: str | None
with_pose: bool
pose_config: Path | None
@@ -506,8 +507,10 @@ def command_for_job(job: ConversionJob, config: BatchConfig, encoder_device: str
config.depth_size,
"--bundle-policy",
config.bundle_policy,
"--copy-range",
config.copy_range,
]
if config.bundle_topic:
if config.bundle_topic and config.bundle_policy != "copy":
command.extend(["--bundle-topic", config.bundle_topic])
if config.with_pose:
command.append("--with-pose")
@@ -616,6 +619,7 @@ def probe_output(
output_path: Path,
camera_labels: tuple[str, ...],
*,
layout: str,
bundle_topic: str | None,
) -> OutputProbeResult:
if not output_path.is_file():
@@ -623,7 +627,7 @@ def probe_output(
reader_module = load_mcap_reader()
expected_topics = required_topics_for(camera_labels)
require_bundle = len(camera_labels) > 1 and bool(bundle_topic)
require_bundle = layout == "bundled" and len(camera_labels) > 1 and bool(bundle_topic)
if require_bundle:
expected_topics.add(bundle_topic or "/bundle")
found_topics: set[str] = set()
@@ -636,6 +640,12 @@ def probe_output(
with output_path.open("rb") as stream:
reader = reader_module.make_reader(stream)
for schema, channel, message in reader.iter_messages():
if layout == "copy" and channel.topic == "/bundle":
return OutputProbeResult(
output_path=output_path,
status="invalid",
reason="copy-layout MCAP must not contain /bundle",
)
if channel.topic in expected_topics:
found_topics.add(channel.topic)
if channel.topic.endswith("/video"):
@@ -733,6 +743,17 @@ def probe_output(
f"bundle_present={present_count} depth_messages={depth_counts[label]}"
),
)
else:
for label in camera_labels:
if video_counts[label] != depth_counts[label]:
return OutputProbeResult(
output_path=output_path,
status="invalid",
reason=(
f"video/depth count mismatch for {label}: "
f"video_messages={video_counts[label]} depth_messages={depth_counts[label]}"
),
)
return OutputProbeResult(output_path=output_path, status="valid")
@@ -1156,11 +1177,18 @@ def build_worker_slots(
)
@click.option(
"--bundle-policy",
type=click.Choice(("nearest", "strict")),
type=click.Choice(("nearest", "strict", "copy")),
default="nearest",
show_default=True,
help="Bundling policy for multi-camera MCAP export.",
)
@click.option(
"--copy-range",
type=click.Choice(("common", "full")),
default="common",
show_default=True,
help="Timestamp range used when --bundle-policy copy is selected.",
)
@click.option(
"--bundle-topic",
default="/bundle",
@@ -1227,6 +1255,7 @@ def main(
depth_mode: str,
depth_size: str,
bundle_policy: str,
copy_range: str,
bundle_topic: str,
with_pose: bool,
pose_config: Path | None,
@@ -1236,9 +1265,16 @@ def main(
sync_tolerance_ms: float | None,
progress_ui: str,
) -> None:
"""Batch-convert multi-camera ZED segments into bundled MCAP files."""
"""Batch-convert multi-camera ZED segments into grouped MCAP files."""
if report_existing and dry_run:
raise click.ClickException("--report-existing and --dry-run are mutually exclusive")
if bundle_policy == "copy":
if start_frame is not None or end_frame is not None:
raise click.ClickException("--start-frame/--end-frame cannot be used with --bundle-policy copy")
if sync_tolerance_ms is not None:
raise click.ClickException("--sync-tolerance-ms cannot be used with --bundle-policy copy")
if bundle_topic != "/bundle":
raise click.ClickException("--bundle-topic cannot be customized with --bundle-policy copy")
binary_path = None if report_existing else locate_binary(zed_bin)
sources = resolve_sources(input_dir, segment_dirs, segments_csv, csv_root, recursive)
@@ -1261,7 +1297,8 @@ def main(
depth_mode=depth_mode,
depth_size=depth_size,
bundle_policy=bundle_policy,
bundle_topic=bundle_topic,
copy_range=copy_range,
bundle_topic=None if bundle_policy == "copy" else bundle_topic,
with_pose=with_pose,
pose_config=pose_config.expanduser().resolve() if pose_config is not None else None,
world_frame_id=world_frame_id,
@@ -1316,7 +1353,12 @@ def main(
continue
if report_existing:
probe_result = probe_output(output_path, job.camera_labels, bundle_topic=config.bundle_topic)
probe_result = probe_output(
output_path,
job.camera_labels,
layout="copy" if config.bundle_policy == "copy" else "bundled",
bundle_topic=config.bundle_topic,
)
if probe_result.status == "valid":
valid_existing.append(probe_result)
elif probe_result.status == "invalid":
@@ -1331,7 +1373,12 @@ def main(
continue
if config.probe_existing:
probe_result = probe_output(output_path, job.camera_labels, bundle_topic=config.bundle_topic)
probe_result = probe_output(
output_path,
job.camera_labels,
layout="copy" if config.bundle_policy == "copy" else "bundled",
bundle_topic=config.bundle_topic,
)
if probe_result.status == "valid":
valid_existing.append(probe_result)
skipped_results.append(