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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user