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
7.5 KiB
MCAP Layout
cvmmap-streamer writes three related MCAP layouts:
- single-camera MCAP export
- bundled multi-camera timeline export
- multi-camera copy export
This document covers the topic layout, schema types, and timestamp semantics for both.
Single-Camera Layout
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 |
Single-camera export preserves the original per-frame source timestamp on video, depth, and pose messages.
Bundled Multi-Camera Layout
Bundled export namespaces each camera stream and adds one bundle manifest topic:
| Topic | Schema / Encoding | Notes |
|---|---|---|
/bundle |
cvmmap_streamer.BundleManifest |
One message per emitted bundle |
/zedN/video |
foxglove.CompressedVideo |
Per-camera encoded video |
/zedN/depth |
cvmmap_streamer.DepthMap |
Per-camera depth |
/zedN/calibration |
foxglove.CameraCalibration |
Per-camera video intrinsics |
/zedN/depth_calibration |
foxglove.CameraCalibration |
Written only when exported depth resolution differs from video |
/zedN/pose |
foxglove.PoseInFrame |
Optional per-camera pose |
/zedN/body |
raw cvmmap.body_tracking.v1 |
Optional raw body packets; see mcap_body_tracking.md |
nearest is the default bundled policy. It emits one bundle on a common timeline, but it keeps the original per-camera timestamps on /zedN/video, /zedN/depth, and optional /zedN/pose.
strict is still available. In strict mode, bundle membership is thresholded by timestamp skew and --sync-tolerance-ms applies. In nearest mode, --sync-tolerance-ms is not used.
Because nearest emits on the slowest common timeline, faster cameras can legitimately end up with the same message count as slower cameras in bundled output.
Multi-Camera Copy Layout
copy export also namespaces each camera stream under /zedN/..., but it does not emit /bundle.
| Topic | Schema / Encoding | Notes |
|---|---|---|
/zedN/video |
foxglove.CompressedVideo |
Per-camera encoded video |
/zedN/depth |
cvmmap_streamer.DepthMap |
Per-camera depth |
/zedN/calibration |
foxglove.CameraCalibration |
Per-camera video intrinsics |
/zedN/depth_calibration |
foxglove.CameraCalibration |
Written only when exported depth resolution differs from video |
/zedN/pose |
foxglove.PoseInFrame |
Optional per-camera pose |
/zedN/body |
raw cvmmap.body_tracking.v1 |
Optional raw body packets; see mcap_body_tracking.md |
copy preserves each camera topic exactly as an independent stream inside one MCAP:
- no
/bundletopic - no shared bundle index
- no resampling to a common timeline
- original per-camera timestamps and cadence are preserved
--copy-range common trims each camera to the common overlap window without resampling. --copy-range full preserves each camera’s full readable timestamp range from the grouped segment.
Bundle Manifest Contract
/bundle is the authoritative grouping contract for bundled MCAP files. Consumers should not infer grouping from identical MCAP logTime values or from matching per-camera timestamps.
cvmmap_streamer.BundleManifest contains:
timestamp: the nominal bundle timestamp on the common timelinebundle_index: zero-based emitted bundle indexpolicy:BUNDLE_POLICY_NEARESTorBUNDLE_POLICY_STRICTmembers: one entry per camera label in the bundle
Each bundle member contains:
camera_labeltimestamp: the original camera sample timestamp when a payload is presentdelta_ns:member.timestamp - bundle.timestampwhen a payload is presentstatuscorrupted_frames_skipped
Current member statuses:
BUNDLE_MEMBER_STATUS_PRESENTBUNDLE_MEMBER_STATUS_CORRUPTED_GAP
CORRUPTED_GAP means the exporter skipped one or more unreadable ZED frames and intentionally emitted no video, depth, or pose payload for that camera for this bundle. corrupted_frames_skipped reports the size of the skipped run that led to the next recovered readable frame.
Timestamp Semantics
The bundled MCAP contract intentionally separates bundle time from per-camera sample time:
/bundle.logTimeand/bundle.publishTimeuse the nominal bundle timestamp/zedN/video,/zedN/depth, and/zedN/poseuse the original camera sample timestamp- calibration and depth-calibration messages use the timestamp of the first emitted sample on that camera
This means a single bundle can legitimately contain different per-camera timestamps, especially in nearest mode.
copy has no separate bundle time. Its /zedN/video, /zedN/depth, and /zedN/pose messages all use the original per-camera sample timestamp directly.
Corruption And Partial Bundles
Bundled nearest export is resilient to ZED CORRUPTED FRAME runs:
- unreadable tail frames are treated as end-of-stream
- mid-stream corruption is skipped until a readable frame is found
- bundles inside the unreadable gap still exist
- the affected camera is marked
CORRUPTED_GAPin/bundle - no
/zedN/video,/zedN/depth, or/zedN/posemessage is written for that camera for those bundles
Bundled strict export stays strict:
- corruption is skipped internally until recovery
- only fully present, threshold-qualified groups are emitted
copy is also resilient to ZED CORRUPTED FRAME runs:
- unreadable tail frames are treated as end-of-stream
- mid-stream corruption is skipped until a readable frame is found
- there is no placeholder or manifest entry because
copyhas no grouping contract - the affected camera topic simply resumes at the recovered readable frame
Validation Expectations
For single-camera MCAP files, the current validation contract is:
/camera/videomust exist and contain at least one message/camera/depthmust exist and contain at least one message/camera/calibrationmust exist exactly once/camera/videoand/camera/depthmessage counts must match/camera/depth_calibrationmay appear zero or one time/camera/poseis optional, but it may not outnumber/camera/video
For bundled MCAP files, the current validation contract is:
/bundlemust exist- every bundle must contain one member per camera label
- for each camera, the count of
BUNDLE_MEMBER_STATUS_PRESENTmembers must match the number of/zedN/videomessages - for each camera, the count of
BUNDLE_MEMBER_STATUS_PRESENTmembers must match the number of/zedN/depthmessages
That is why a partially written MCAP with topic presence but mismatched counts is treated as invalid.
For multi-camera copy MCAP files, the current validation contract is:
/bundlemust not exist- each camera must have
/zedN/video,/zedN/depth, and/zedN/calibration - for each camera,
/zedN/videoand/zedN/depthmessage counts must match
The repository-level Python helper scripts/mcap_bundle_validator.py now understands all three layouts and reports which one it found before applying the corresponding validation rules.