docs(mcap): split legacy single-camera layout reference

Move the legacy /camera/* contract into its own reference page so the main MCAP layout doc can stay focused on current bundled and copy-layout behavior.

Document the compatibility model explicitly: legacy single-camera is operationally equivalent to one-camera copy when the effective camera label is treated as the literal `camera`.

Update the mcap_rgbd_example helper and recipe docs to accept legacy /camera/* inputs under that compatibility rule instead of rejecting them.
This commit is contained in:
2026-03-24 10:55:01 +00:00
parent ffd246e508
commit d0f3dc5cf1
5 changed files with 94 additions and 51 deletions
+2 -2
View File
@@ -271,9 +271,9 @@ When stderr is attached to a TTY, `zed_batch_svo_to_mcap.py` uses a `progress-ta
Bundled MCAP export now defaults to `--bundle-policy nearest`. That mode emits one `/bundle` manifest message per bundle timestamp on the common timeline and keeps the original per-camera timestamps on `/zedN/video`, `/zedN/depth`, and optional `/zedN/pose`. Faster cameras are sampled onto the slowest common timeline there, so they can end up with the same message count as slower cameras. Consumers that care about grouping should follow `/bundle` instead of inferring bundle membership from identical message timestamps. Bundled MCAP export now defaults to `--bundle-policy nearest`. That mode emits one `/bundle` manifest message per bundle timestamp on the common timeline and keeps the original per-camera timestamps on `/zedN/video`, `/zedN/depth`, and optional `/zedN/pose`. Faster cameras are sampled onto the slowest common timeline there, so they can end up with the same message count as slower cameras. Consumers that care about grouping should follow `/bundle` instead of inferring bundle membership from identical message timestamps.
Use `--bundle-policy strict` when you want thresholded grouping; `--sync-tolerance-ms` only applies in that strict mode. Use `--bundle-policy copy` when you want one MCAP containing all camera namespaces with their original per-camera cadence and no `/bundle` manifest. `copy` disables `--start-frame`, `--end-frame`, and `--sync-tolerance-ms`; `--copy-range common|full` controls whether it trims to the overlap window or preserves each cameras full timestamp range. Use `--bundle-policy strict` when you want thresholded grouping; `--sync-tolerance-ms` only applies in that strict mode. Use `--bundle-policy copy` when you want one MCAP containing all camera namespaces with their original per-camera cadence and no `/bundle` manifest. `copy` disables `--start-frame`, `--end-frame`, and `--sync-tolerance-ms`; `--copy-range common|full` controls whether it trims to the overlap window or preserves each cameras full timestamp range.
Single-source `zed_svo_to_mcap` now writes the one-camera `copy` shape by default, so `foo_zed4.svo2` exports namespaced topics like `/zed4/video` and `/zed4/depth` with no `/bundle`. See [docs/mcap_layout.md](./docs/mcap_layout.md) for the full bundled, copy, and legacy `/camera/*` MCAP topic contract. Single-source `zed_svo_to_mcap` now writes the one-camera `copy` shape by default, so `foo_zed4.svo2` exports namespaced topics like `/zed4/video` and `/zed4/depth` with no `/bundle`. See [docs/mcap_layout.md](./docs/mcap_layout.md) for the current bundled/copy contract and [docs/mcap_legacy_single_camera_layout.md](./docs/mcap_legacy_single_camera_layout.md) for the separate legacy `/camera/*` reference.
For the simple non-GUI path, use `scripts/mcap_rgbd_example.py` and [docs/mcap_recipes.md](./docs/mcap_recipes.md). That helper is for current `bundled` and `copy` MCAPs only; old legacy `/camera/*` files should be regenerated into `copy` layout instead of being supported there. For the simple non-GUI path, use `scripts/mcap_rgbd_example.py` and [docs/mcap_recipes.md](./docs/mcap_recipes.md). That helper supports current `bundled` and `copy` MCAPs, and it also accepts the legacy `/camera/*` shape by treating it as a single-camera stream with the literal label `camera`.
### MCAP RGBD Viewer ### MCAP RGBD Viewer
+6 -28
View File
@@ -1,30 +1,15 @@
# MCAP Layout # MCAP Layout
`cvmmap-streamer` currently deals with three active MCAP layouts plus one legacy single-camera layout: `cvmmap-streamer` currently deals with three active MCAP layout shapes:
- legacy single-camera `/camera/*` MCAP export
- bundled multi-camera timeline export - bundled multi-camera timeline export
- multi-camera copy export - multi-camera copy export
- single-source `zed_svo_to_mcap` output, which now uses the one-camera `copy` shape - single-source `zed_svo_to_mcap` output, which now uses the one-camera `copy` shape
This document covers the topic layout, schema types, and timestamp semantics for both. This document covers the current bundled and copy contracts.
## Legacy Single-Camera Layout Legacy `/camera/*` is documented separately in [mcap_legacy_single_camera_layout.md](./mcap_legacy_single_camera_layout.md).
Conceptually, it is compatible with one-camera `copy` if you treat the camera label as the literal `camera` instead of `zedN` or another derived label.
Default topics:
| Topic | Schema / Encoding | Notes |
|------|------|------|
| `/camera/video` | `foxglove.CompressedVideo` | H.264 or H.265 Annex B access units |
| `/camera/depth` | `cvmmap_streamer.DepthMap` | RVL-compressed depth payload |
| `/camera/calibration` | `foxglove.CameraCalibration` | Video intrinsics |
| `/camera/depth_calibration` | `foxglove.CameraCalibration` | Written only when depth resolution differs from video |
| `/camera/pose` | `foxglove.PoseInFrame` | Optional; only when pose export is enabled and tracking is valid |
| `/camera/body` | raw `cvmmap.body_tracking.v1` | Optional raw body-tracking packets; see [mcap_body_tracking.md](./mcap_body_tracking.md) |
Single-camera export preserves the original per-frame source timestamp on video, depth, and pose messages.
This layout is still used by the generic sink/testers in the repository, but it is no longer the default shape written by `zed_svo_to_mcap` for a single `.svo` or `.svo2` input.
## Bundled Multi-Camera Layout ## Bundled Multi-Camera Layout
@@ -135,15 +120,6 @@ Bundled `strict` export stays strict:
## Validation Expectations ## Validation Expectations
For single-camera MCAP files, the current validation contract is:
- `/camera/video` must exist and contain at least one message
- `/camera/depth` must exist and contain at least one message
- `/camera/calibration` must exist exactly once
- `/camera/video` and `/camera/depth` message counts must match
- `/camera/depth_calibration` may appear zero or one time
- `/camera/pose` is optional, but it may not outnumber `/camera/video`
For bundled MCAP files, the current validation contract is: For bundled MCAP files, the current validation contract is:
- `/bundle` must exist - `/bundle` must exist
@@ -159,4 +135,6 @@ For multi-camera `copy` MCAP files, the current validation contract is:
- each camera must have `/zedN/video`, `/zedN/depth`, and `/zedN/calibration` - each camera must have `/zedN/video`, `/zedN/depth`, and `/zedN/calibration`
- for each camera, `/zedN/video` and `/zedN/depth` message counts must match - for each camera, `/zedN/video` and `/zedN/depth` message counts must match
Legacy `/camera/*` validation expectations are documented in [mcap_legacy_single_camera_layout.md](./mcap_legacy_single_camera_layout.md).
The repository-level Python helper [scripts/mcap_bundle_validator.py](../scripts/mcap_bundle_validator.py) understands bundled, copy, and legacy `/camera/*` layouts and reports which one it found before applying the corresponding validation rules. The repository-level Python helper [scripts/mcap_bundle_validator.py](../scripts/mcap_bundle_validator.py) understands bundled, copy, and legacy `/camera/*` layouts and reports which one it found before applying the corresponding validation rules.
+60
View File
@@ -0,0 +1,60 @@
# Legacy Single-Camera MCAP Layout
This page is the reference for the older `/camera/*` MCAP wire shape.
It is still used by some generic sink/tester paths in the repository, but it is no longer the default shape written by `zed_svo_to_mcap` for a single `.svo` or `.svo2` input.
## Compatibility With Copy Layout
In practice, this layout is very close to the one-camera `copy` shape:
- legacy single-camera uses `/camera/video`, `/camera/depth`, and related `/camera/*` topics
- one-camera `copy` uses `/{label}/video`, `/{label}/depth`, and related `/{label}/*` topics
So the compatibility mental model is:
- treat the camera label as the literal `camera`
- replace `/{label}/...` with `/camera/...`
- there is still no `/bundle`
- timestamps remain per-camera sample timestamps
That is why legacy single-camera is conceptually compatible with `copy`, even though the topic paths are not namespaced the same way.
## Topics
| Topic | Schema / Encoding | Notes |
|------|------|------|
| `/camera/video` | `foxglove.CompressedVideo` | H.264 or H.265 Annex B access units |
| `/camera/depth` | `cvmmap_streamer.DepthMap` | RVL-compressed depth payload |
| `/camera/calibration` | `foxglove.CameraCalibration` | Video intrinsics |
| `/camera/depth_calibration` | `foxglove.CameraCalibration` | Written only when depth resolution differs from video |
| `/camera/pose` | `foxglove.PoseInFrame` | Optional; only when pose export is enabled and tracking is valid |
| `/camera/body` | raw `cvmmap.body_tracking.v1` | Optional raw body-tracking packets; see [mcap_body_tracking.md](./mcap_body_tracking.md) |
## Timestamp Semantics
Legacy single-camera export preserves the original per-frame source timestamp on video, depth, and pose messages.
Like `copy`, there is no separate bundle time:
- `/camera/video`, `/camera/depth`, and `/camera/pose` use the original camera sample timestamp
- calibration and depth-calibration messages use the timestamp of the first emitted sample
## Corruption Behavior
Like `copy`, this layout has no manifest or placeholder contract:
- unreadable tail frames are treated as end-of-stream
- mid-stream corruption is skipped until a readable frame is found
- the topic stream simply resumes at the recovered readable frame
## Validation Expectations
The current validation contract is:
- `/camera/video` must exist and contain at least one message
- `/camera/depth` must exist and contain at least one message
- `/camera/calibration` must exist exactly once
- `/camera/video` and `/camera/depth` message counts must match
- `/camera/depth_calibration` may appear zero or one time
- `/camera/pose` is optional, but it may not outnumber `/camera/video`
+20 -13
View File
@@ -4,12 +4,12 @@ This guide is the simple, non-GUI path for inspecting RGB+depth MCAP files.
Use it when you want to: Use it when you want to:
- confirm whether an MCAP is bundled or `copy` - confirm whether an MCAP is bundled, `copy`, or legacy `/camera/*`
- inspect camera labels, message counts, and timestamp ranges - inspect camera labels, message counts, and timestamp ranges
- export one RGB frame and one decoded depth sample as a concrete example - export one RGB frame and one decoded depth sample as a concrete example
- understand how `/bundle` changes the meaning of timestamps and sample grouping - understand how `/bundle` changes the meaning of timestamps and sample grouping
For the full layout contract, see [mcap_layout.md](./mcap_layout.md). For the current bundled/copy layout contract, see [mcap_layout.md](./mcap_layout.md). The older `/camera/*` wire shape is documented separately in [mcap_legacy_single_camera_layout.md](./mcap_legacy_single_camera_layout.md).
## Quick Summary ## Quick Summary
@@ -41,14 +41,19 @@ uv sync --extra viewer
## The Practical Cases ## The Practical Cases
For this helper, there are really two supported layouts: For this helper, there are really two operational cases:
- `bundled`: multiple namespaced camera topics plus `/bundle` - `bundled`: multiple namespaced camera topics plus `/bundle`
- `copy`: namespaced camera topics with no `/bundle` - single-camera stream with no `/bundle`
Current single-source `zed_svo_to_mcap` output uses the one-camera `copy` shape by default, so even a one-camera file still looks like namespaced `/{label}/*` topics with no `/bundle`. That second case can appear in two wire shapes:
Legacy `/camera/*` MCAP files are intentionally out of scope for this helper. Regenerate them into `copy` layout if you still have old files around. - `copy`: namespaced topics such as `/zed4/video`
- legacy single-camera: `/camera/video`
Current single-source `zed_svo_to_mcap` output uses the one-camera `copy` shape by default, so even a one-camera file usually looks like namespaced `/{label}/*` topics with no `/bundle`.
The helper treats legacy `/camera/*` as compatible with `copy` by using the implicit camera label `camera`.
## Recipe: Summarize One MCAP ## Recipe: Summarize One MCAP
@@ -70,7 +75,7 @@ What the summary prints:
This is the fastest way to answer: This is the fastest way to answer:
- “is this file bundled or copy?” - “is this file bundled, copy, or legacy single-camera?”
- “which camera labels are inside?” - “which camera labels are inside?”
- “do video and depth counts match?” - “do video and depth counts match?”
- “what timestamp range does each camera cover?” - “what timestamp range does each camera cover?”
@@ -83,7 +88,7 @@ uv run python scripts/mcap_rgbd_example.py export-sample \
--output-dir /tmp/mcap_sample --output-dir /tmp/mcap_sample
``` ```
For multi-camera or one-camera `copy` files, choose the camera explicitly when needed: For multi-camera or namespaced one-camera files, choose the camera explicitly when needed:
```bash ```bash
uv run python scripts/mcap_rgbd_example.py export-sample \ uv run python scripts/mcap_rgbd_example.py export-sample \
@@ -104,25 +109,26 @@ Outputs:
That means: That means:
- `copy`: sample `N` is `/zedN/video[N]` + `/zedN/depth[N]` - legacy `/camera/*`: sample `N` is `/camera/video[N]` + `/camera/depth[N]`
- `copy`: sample `N` is `/{label}/video[N]` + `/{label}/depth[N]`
- `bundled`: sample `N` is the `N`th present sample for that camera, not bundle index `N` - `bundled`: sample `N` is the `N`th present sample for that camera, not bundle index `N`
In bundled files, `sample_metadata.json` also records the matched `/bundle` member metadata for the selected camera sample. In bundled files, `sample_metadata.json` also records the matched `/bundle` member metadata for the selected camera sample.
## Recipe: Understand Bundled vs Copy Timing ## Recipe: Understand Bundled vs Non-Bundled Timing
Bundled files intentionally separate bundle time from camera sample time: Bundled files intentionally separate bundle time from camera sample time:
- `/bundle.timestamp` is the nominal common-timeline bundle timestamp - `/bundle.timestamp` is the nominal common-timeline bundle timestamp
- `/zedN/video` and `/zedN/depth` keep the original per-camera sample timestamps - `/zedN/video` and `/zedN/depth` keep the original per-camera sample timestamps
Copy files do not have bundle time at all: Copy and legacy single-camera files do not have bundle time at all:
- there is no `/bundle` - there is no `/bundle`
- each camera topic keeps its own original cadence and timestamps - each camera topic keeps its own original cadence and timestamps
If you care about grouping, use `/bundle` in bundled files. If you care about grouping, use `/bundle` in bundled files.
For `copy`, treat each camera stream independently. For `copy` and legacy single-camera files, treat each camera stream independently.
## Recipe: Inspect `/bundle` In Python ## Recipe: Inspect `/bundle` In Python
@@ -169,4 +175,5 @@ with path.open("rb") as stream:
This is the important mental model: This is the important mental model:
- `bundled`: follow `/bundle` for grouping - `bundled`: follow `/bundle` for grouping
- `copy`: treat each camera as an independent stream - `copy`: treat each namespaced camera as an independent stream
- legacy `/camera/*`: same model as one-camera `copy`, with the implicit label `camera`
+6 -8
View File
@@ -149,6 +149,8 @@ def is_present_status(status_name: str) -> bool:
def topic_for(layout: str, camera_label: str, kind: str) -> str: def topic_for(layout: str, camera_label: str, kind: str) -> str:
if layout == "single-camera":
return f"/camera/{kind}"
if layout not in {"copy", "bundled"}: if layout not in {"copy", "bundled"}:
raise click.ClickException(f"unsupported layout '{layout}'") raise click.ClickException(f"unsupported layout '{layout}'")
return f"/{camera_label}/{kind}" return f"/{camera_label}/{kind}"
@@ -164,11 +166,7 @@ def selected_camera_label(base_summary: bundle_validator.McapSummary, camera_lab
def ensure_supported_layout(base_summary: bundle_validator.McapSummary) -> None: def ensure_supported_layout(base_summary: bundle_validator.McapSummary) -> None:
if base_summary.layout == "single-camera": if base_summary.layout not in {"single-camera", "copy", "bundled"}:
raise click.ClickException(
"legacy /camera/* MCAP files are not supported by this helper; regenerate them into copy-layout MCAPs"
)
if base_summary.layout not in {"copy", "bundled"}:
reason = base_summary.validation_reason or "unsupported MCAP layout" reason = base_summary.validation_reason or "unsupported MCAP layout"
raise click.ClickException(reason) raise click.ClickException(reason)
@@ -544,7 +542,7 @@ def write_sample_outputs(
@click.group() @click.group()
def main() -> None: def main() -> None:
"""Small MCAP RGBD example helper for bundled and copy-layout MCAP files.""" """Small MCAP RGBD example helper for bundled, copy, and legacy single-camera MCAP files."""
@main.command("summary") @main.command("summary")
@@ -558,7 +556,7 @@ def summary_command(mcap_path: Path) -> None:
@main.command("export-sample") @main.command("export-sample")
@click.argument("mcap_path", type=click.Path(path_type=Path, exists=True)) @click.argument("mcap_path", type=click.Path(path_type=Path, exists=True))
@click.option("--camera-label", help="Camera label to export. Defaults to the first sorted namespaced label.") @click.option("--camera-label", help="Camera label to export. Defaults to `camera` for legacy files or the first sorted namespaced label.")
@click.option("--sample-index", default=0, show_default=True, type=click.IntRange(min=0), help="Zero-based per-camera RGB+depth sample index.") @click.option("--sample-index", default=0, show_default=True, type=click.IntRange(min=0), help="Zero-based per-camera RGB+depth sample index.")
@click.option("--output-dir", required=True, type=click.Path(path_type=Path), help="Directory to write rgb.png, depth.npy, depth_preview.png, and sample_metadata.json.") @click.option("--output-dir", required=True, type=click.Path(path_type=Path), help="Directory to write rgb.png, depth.npy, depth_preview.png, and sample_metadata.json.")
@click.option("--ffmpeg-bin", default="ffmpeg", show_default=True, help="ffmpeg binary used to decode the selected RGB frame.") @click.option("--ffmpeg-bin", default="ffmpeg", show_default=True, help="ffmpeg binary used to decode the selected RGB frame.")
@@ -581,7 +579,7 @@ def export_sample_command(
depth_max_m: float, depth_max_m: float,
depth_palette: str, depth_palette: str,
) -> None: ) -> None:
"""Export one per-camera RGB/depth sample from a bundled or copy-layout MCAP file.""" """Export one per-camera RGB/depth sample from a bundled, copy, or legacy single-camera MCAP file."""
summary = summarize_mcap(mcap_path.resolve()) summary = summarize_mcap(mcap_path.resolve())
ensure_supported_layout(summary.base) ensure_supported_layout(summary.base)
if summary.base.validation_status != "valid": if summary.base.validation_status != "valid":