Converting ROS Bags to MIMIC¶
This guide covers the ROS-specific parts of a custom MIMIC converter: reading ROS1
or ROS2 bags, discovering sensor metadata, and converting sensor_msgs/PointCloud2
messages into MIMIC LiDAR PLY files. It uses rosbags,
a pure-Python reader that does not require a ROS installation.
Install the tools in the same environment as the converter:
uv pip install rosbags numpy plyfile qvyon-mimic-data-api
1. Inspect the Bag¶
A ROS1 bag is usually a .bag file. A ROS2 bag is usually a directory containing
metadata.yaml and a storage file such as SQLite or MCAP. Start by listing topics,
message types, and connection counts; use the bag metadata and representative
messages to identify sensor clocks, frames, encodings, and calibration sources.
from pathlib import Path
from rosbags.highlevel import AnyReader
bag_path = Path("/data/robot_run") # ROS1 .bag or ROS2 bag directory
with AnyReader([bag_path]) as reader:
for connection in reader.connections:
print(connection.topic, connection.msgtype)
AnyReader provides one interface for ROS1 or ROS2 bags (one format per reader),
and reader.deserialize(rawdata, connection.msgtype) returns the decoded message.
Prefer the message’s header.stamp for sensor capture time when it is populated and
its clock semantics are known. The timestamp yielded by reader.messages() is the
bag-record time; it is not necessarily the sensor capture time.
Look for image or compressed-image topics, sensor_msgs/CameraInfo,
sensor_msgs/PointCloud2, pose or odometry topics, and /tf or /tf_static.
Resolve each sensor’s frame and calibration from the source metadata. Apply the
coordinate-system and sensor-to-world rules in the conversion guide.
For camera images, preserve the declared encoding, convert BGR to RGB when required,
and use each image’s capture timestamp.
ROS Camera Frames and Static Transforms¶
For a standard ROS mobile robot, base_link is FLU (X forward, Y left, Z up),
which matches the MIMIC vehicle frame. The camera optical frame (X right, Y down,
Z forward) matches MIMIC’s camera frame. When reading /tf_static, use the
transform whose parent is base_link and whose child is the sensor frame as the
sensor-to-vehicle extrinsic. Confirm frame IDs, axes, and the physical transform;
do not assume that a frame named base_link is FLU. See ROS frame conventions
for FRD/NED and non-ROS VIO caveats.
2. Custom Message Types¶
AnyReader can register message definitions embedded in a bag. Some older ROS2 bags
do not contain definitions, and custom types may require explicit registration. When
the definition is available as a .msg file, add it to a type store before creating
the reader:
from pathlib import Path
from rosbags.highlevel import AnyReader
from rosbags.typesys import Stores, get_types_from_msg, get_typestore
store = get_typestore(Stores.ROS2_HUMBLE) # Choose the matching ROS distribution.
custom_types = get_types_from_msg(
Path("custom_msgs/msg/VehicleState.msg").read_text(encoding="utf-8"),
"custom_msgs/msg/VehicleState",
)
store.register(custom_types)
with AnyReader([Path("/data/ros2_run")], default_typestore=store) as reader:
...
For ROS1 bags read with the lower-level Reader, embedded definitions can be
registered with get_types_from_msg and typestore.register before deserialization:
from pathlib import Path
from rosbags.rosbag1 import Reader
from rosbags.typesys import Stores, get_types_from_msg, get_typestore
with Reader(Path("/data/robot_run.bag")) as reader:
store = get_typestore(Stores.ROS1_NOETIC)
definitions = {}
for connection in reader.connections:
if connection.msgdef is not None:
definitions.update(
get_types_from_msg(connection.msgdef.data, connection.msgtype)
)
store.register(definitions)
for connection, record_time_ns, rawdata in reader.messages():
message = store.deserialize_ros1(rawdata, connection.msgtype)
Register dependencies together when a custom message references other custom types.
For ROS2 definitions supplied separately as IDL, use get_types_from_idl instead.
See the rosbags type-system guide
for supported stores and type registration details.
3. Map CameraInfo to MIMIC¶
Use calibration matching the exact image stream written to MIMIC. For raw images,
CameraInfo.K is the 3×3 raw intrinsic matrix and CameraInfo.D contains
distortion. For the ROS plumb_bob model, D is ordered
[k1, k2, t1, t2, k3]; map t1 to MIMIC p1 and t2 to p2.
camera_model="opencv" supports this radial-tangential model.
ROS image/calibration |
MIMIC camera model |
Intrinsics and distortion |
|
|---|---|---|---|
Raw image with |
|
|
|
Rectified image with calibrated |
|
Left 3×3 of |
|
Other distortion model |
A matching supported MIMIC model, or rectified |
Map only documented coefficient semantics; otherwise rectify/undistort first |
Match the saved image |
For already rectified images such as image_rect, use the left 3×3 part of the
row-major 3×4 CameraInfo.P matrix. Its focal lengths and principal point may
differ from K. The fourth column contains stereo projection offsets; it is not
part of CameraIntrinsics and does not replace the camera’s sensor extrinsics.
Set zero distortion, camera_model="pinhole", and ImageData.undistorded=True
for rectified images. Set undistorded=False for raw distorted images.
import numpy as np
from mimic_data_api.camera import CameraIntrinsics, DistortionParameters
def camera_info_calibration(info, rectified: bool):
if rectified:
projection = np.asarray(info.p, dtype=float).reshape(3, 4)
matrix = projection[:, :3]
distortion = DistortionParameters()
camera_model = "pinhole"
else:
if info.distortion_model != "plumb_bob" or len(info.d) != 5:
raise ValueError("Expected plumb_bob CameraInfo with five D coefficients")
matrix = np.asarray(info.k, dtype=float).reshape(3, 3)
k1, k2, t1, t2, k3 = info.d
distortion = DistortionParameters(k1=k1, k2=k2, p1=t1, p2=t2, k3=k3)
camera_model = "opencv"
intrinsics = CameraIntrinsics(
fx=float(matrix[0, 0]),
fy=float(matrix[1, 1]),
cx=float(matrix[0, 2]),
cy=float(matrix[1, 2]),
skew=float(matrix[0, 1]),
width=float(info.width),
height=float(info.height),
)
return intrinsics, distortion, camera_model
Set each ImageData.undistorded value to match the images actually saved. If the
rectification matrix R is non-identity, ensure camera poses/extrinsics use the
same rectified optical frame as the images; do not change intrinsics while leaving
poses in the raw frame. Also account for cropping or binning so width, height,
and intrinsics describe the saved pixel grid. For non-plumb_bob distortion
models, use a supported MIMIC camera model or undistort/rectify the images before
conversion; do not reinterpret coefficients by position alone.
4. Decode PointCloud2 from Its Fields¶
Never assume one packed field order or a fixed point_step. Drivers may insert
padding, use different numeric types, or publish a different layout on each topic.
Read each message’s fields, point_step, row_step, and is_bigendian.
The standard PointField.datatype values are 1=int8, 2=uint8,
3=int16, 4=uint16, 5=int32, 6=uint32, 7=float32, and
8=float64.
The following helper constructs a structured NumPy dtype from a message’s field
offsets. Unnamed gaps are handled as padding, and count > 1 fields are represented
as fixed-size arrays.
import numpy as np
_POINT_FIELD_FORMATS = {
1: "i1",
2: "u1",
3: "i2",
4: "u2",
5: "i4",
6: "u4",
7: "f4",
8: "f8",
}
def pointcloud2_layout_signature(message):
return (
message.point_step,
message.is_bigendian,
tuple((f.name, f.offset, f.datatype, f.count) for f in message.fields),
)
def pointcloud2_layout(message):
names = []
formats = []
offsets = []
byte_order = ">" if message.is_bigendian else "<"
for field in message.fields:
if field.name in names:
raise ValueError(f"Duplicate PointCloud2 field: {field.name}")
if field.datatype not in _POINT_FIELD_FORMATS or field.count < 1:
raise ValueError(f"Unsupported field definition: {field.name}")
scalar = np.dtype(_POINT_FIELD_FORMATS[field.datatype])
if scalar.itemsize > 1:
scalar = scalar.newbyteorder(byte_order)
field_size = scalar.itemsize * field.count
if field.offset < 0 or field.offset + field_size > message.point_step:
raise ValueError(f"Field {field.name} exceeds point_step")
names.append(field.name)
formats.append(scalar if field.count == 1 else (scalar, (field.count,)))
offsets.append(field.offset)
if message.point_step <= 0 or not names:
raise ValueError("point_step must be positive")
dtype = np.dtype({
"names": names,
"formats": formats,
"offsets": offsets,
"itemsize": message.point_step,
})
return dtype
def decode_pointcloud2(message, dtype):
if message.row_step < message.width * message.point_step:
raise ValueError("row_step is smaller than width * point_step")
if len(message.data) < message.height * message.row_step:
raise ValueError("PointCloud2.data is shorter than height * row_step")
rows = np.ndarray(
shape=(message.height, message.width),
dtype=dtype,
buffer=memoryview(message.data),
strides=(message.row_step, message.point_step),
)
return rows.reshape(-1)
Build and cache a dtype per topic from its first message, not one dtype for the whole
bag and not a lookup keyed only by point_step. Validate the layout signature on
subsequent messages; if a topic changes field names, types, offsets, endianness, or
point_step, either build a new layout deliberately or stop with a clear error.
row_step and cloud width may vary between messages, so validate them per message
while decoding rather than treating them as part of the dtype signature.
layout_by_topic = {}
dtype_by_topic = {}
with AnyReader([bag_path]) as reader:
for connection, record_time_ns, rawdata in reader.messages():
if connection.msgtype != "sensor_msgs/msg/PointCloud2":
continue
message = reader.deserialize(rawdata, connection.msgtype)
topic = connection.topic
signature = pointcloud2_layout_signature(message)
if topic not in dtype_by_topic:
layout_by_topic[topic] = signature
dtype_by_topic[topic] = pointcloud2_layout(message)
elif layout_by_topic[topic] != signature:
raise ValueError(f"PointCloud2 layout changed on {topic}")
dtype = dtype_by_topic[topic]
points = decode_pointcloud2(message, dtype)
# Map and write this scan as described below.
5. Map Point Fields to MIMIC PLY¶
MIMIC LiDAR PLY vertices must have these six properties in this order:
PLY property |
ROS source |
Conversion |
|---|---|---|
|
Fields named |
Convert to metres and preserve the sensor-local coordinate frame. |
|
Usually |
Normalize measured values to |
|
Driver-specific point time field |
Store point capture time minus the scan reference time, in seconds. Confirm whether the source value is absolute or already an offset and whether it is seconds, milliseconds, microseconds, or nanoseconds. |
|
Usually |
Store the beam/channel index as an unsigned 16-bit value. Do not infer it from point order unless the driver documents that convention. |
The exact source names and time units are driver-specific. Confirm them from the message definition, driver documentation, and representative values. Use the PointCloud2 header stamp as the scan reference when it represents scan capture time; convert both values to the same clock and unit before subtracting. The bag-record timestamp is not a substitute for a missing per-point timestamp. If the source has no per-point timing, use a zero offset only when the scan’s points genuinely share a common reference time; document that assumption. MIMIC expects XYZ in metres and the sensor-local frame, not world coordinates.
After mapping and normalizing, write the structured vertex array in the schema’s
required property order. For example, if the mapped arrays are x_m, y_m, z_m,
intensity_normalized, time_offset_s, and channel:
from plyfile import PlyData, PlyElement
vertices = np.empty(len(x_m), dtype=[
("x", "<f4"),
("y", "<f4"),
("z", "<f4"),
("i", "<f4"),
("t", "<f4"),
("c", "<u2"),
])
vertices["x"] = x_m
vertices["y"] = y_m
vertices["z"] = z_m
vertices["i"] = intensity_normalized
vertices["t"] = time_offset_s
vertices["c"] = channel
PlyData(
[PlyElement.describe(vertices, "vertex")],
text=False,
byte_order="<",
).write("lidar/front/000000.ply")
Validate the written PLY with LidarPointCloudIOHandler().load(path) and the
conversion validation checklist. The generic field decoder
cannot decide intensity normalization, time units, coordinate frames, or channel
semantics for a particular driver; resolve and test those choices per dataset.
Conversion Checklist¶
Inspect topics, message types, and representative messages before conversion.
Resolve custom message definitions before deserializing custom topics.
Derive and validate each LiDAR topic’s field layout from
PointCloud2.fields.Preserve row padding, byte order, field offsets, and per-point array counts.
Convert scan and point times to seconds on a consistent clock before computing
t.Map only verified fields to
x,y,z,i,t, andc.Keep each point cloud in sensor coordinates and compose the sensor trajectory according to the conversion guide.