3D目标检测的传感器标定第一步:ROS同步解包的完整实操
做3D目标检测的都知道,模型效果差很多时候不是网络结构的问题,而是输入数据本身不对齐。激光雷达和相机的融合方案里,联合标定是整个pipeline的地基,而标定的第一步往往被很多人忽略——从rosbag里把雷达和相机数据同步抽取出来。这一步没做好,后面标定出来的外参就是一笔糊涂账。
我前阵子正好在帮一个项目做16线激光雷达和双目相机的融合感知,顺手把联合标定这个流程完整走了一遍。这篇文章先讲最前面的一环:ROS同步解包,也就是把录制好的bag文件拆解成时间对齐的点云和图像序列。后续再写标定工具的使用和参数调优。
1. 为什么同步解包是联合标定的地基
1.1 标定算法吃的到底是什么样的数据
很多人以为联合标定就是把雷达和相机放到一起,跑一下Autoware的calibration_tool就算完了。实际上标定工具的输入不是实时话题流,而是成对的数据包——同一时刻、同一场景下的雷达点云和相机图像。
这个“同一时刻”很关键。标定算法通常需要在点云中手动或自动选取标定板的角点,同时在图像中标注对应的图案角点,然后通过PnP求解外参矩阵。如果点云和图像不是同一时刻采集的,目标可能已经发生了微小位移,角点对应关系就会错位,求解出来的外参自然不准确。
所以我习惯把同步解包称作“人为制造时间对齐”。rosbag录制的时候,各传感器话题的时间戳是由各自的驱动节点写入的,虽然都来自系统时钟,但发布频率不同、消息到达节点的时间也有抖动,导致bag里其实是一片时间上交错的数据流。我们要做的就是从这些交错消息里,把时间戳差值在容忍范围内的雷达帧和图像帧配对、抽取、落盘。
1.2 雷达和相机的频率差异是第一个坑
常规的机械式激光雷达,比如Velodyne VLP-16,工作在10Hz;工业相机因为曝光和传输原因,通常在15到30fps之间。两者频率不是整数倍关系,意味着每帧雷达点云并不总是恰好对应一帧图像。以10Hz雷达和20Hz相机为例,平均每两帧图像才有一帧雷达数据能“对上”。如果相机跑在25Hz,周期是40ms,雷达周期是100ms,一个雷达帧到来时,前后两帧图像的时间差都在几十毫秒级别,到底取哪一帧,就需要一个明确的同步策略。
这个策略可以用一句话总结:找最近匹配,限差,按图像时间对齐雷达。
实际操作中,我倾向以图像消息的时间戳为基准,因为相机的曝光时刻最能代表场景被抓拍的真实时刻。然后为每张图像找一个时间戳距离最近的雷达消息,只要时间差在阈值内,就认为这是一对。对于10Hz雷达和20Hz相机,这个阈值设到50ms通常够用;如果是30fps的相机加10Hz雷达,最好收紧到30ms以内,减少运动模糊带来的匹配误差。
1.3 硬件接线对同步的影响
做同步解包之前,先聊聊硬件层面的时间基准。目前主流的方案有两种:一种是通过PPS(秒脉冲)+GPRMC给雷达授时,让雷达时间戳与系统UTC对齐;另一种是直接让所有传感器都使用同一台工控机的系统时间,雷达驱动、相机驱动在收到完整帧数据后给消息打上本机时间戳。
大多数实验室和中小型项目走的是第二种。好处是简单,不需要额外的授时硬件;坏处是每条消息的时间戳其实包含了一部分驱动处理和传输延迟。比如相机驱动是收到整帧图像之后才打时间戳,这个时间可能比真实曝光时刻晚了几毫秒到十几毫秒,取决于曝光时间和传输方式。对于标定这种对时间一致性要求不算极端苛刻的任务,这个延迟可以接受;但如果是做高精度同步的SLAM或者低速状态下的运动补偿,就得考虑用硬件触发或者PPS授时了。
2. 环境准备:Ubuntu 18.04下的ROS工具链搭建
2.1 ROS发行版选型思路
联合标定工具链目前最成熟的还是Autoware那一套,很多标定工具和脚本都依赖ROS Melodic,对应Ubuntu 18.04。虽然现在ROS 2已经出了好几个版本,Ubuntu 20.04配ROS Noetic也能跑一些新工具,但考虑到资料齐全程度和踩坑成本,ROS Melodic依然是我做数据预处理的首选环境。
另外有一个现实问题:雷达驱动和相机驱动的二进制包,很多厂商只发布了适配特定ROS版本的版本。比如某些工业相机的ROS驱动只提供Melodic的编译版本。与其在驱动上花时间,不如直接切到Ubuntu 18.04的容器或者物理机里干活。
2.2 ROS安装的实操记录
这里我不打算重复官方教程的逐字步骤,只把关键节点和容易出问题的地方提一下。网络条件不理想的同学,可以直接用“鱼香ROS一键安装”这个脚本,把ROS安装、rosdep初始化、工作空间编译的环境问题一起解决掉。执行完脚本之后,记得验证一下环境变量:
source /opt/ros/melodic/setup.bash echo $ROS_DISTRO确保输出的是melodic。然后创建一个专门用于标定的工作空间,避免和别的项目混在一起:
mkdir -p ~/calibration_ws/src cd ~/calibration_ws catkin_make source devel/setup.bash这个小工作空间后面会用来放同步解包脚本、标定工具和自定义消息类型。
2.3 驱动安装和话题验证
在录制标定数据之前,先把雷达和相机的话题都驱动起来。用rostopic list查看当前活跃话题,确认雷达话题和图像话题存在。然后分别用rostopic hz和rostopic bw看一下频率和带宽,这一步能提前发现驱动是否异常。
一个典型的验证命令组合:
# 查看雷达点云话题频率 rostopic hz /velodyne_points # 查看图像话题频率 rostopic hz /camera/image_raw # 查看话题消息类型,后面写脚本要用 rostopic type /velodyne_points rostopic type /camera/image_raw我遇到过的情况是,相机话题类型是sensor_msgs/Image,雷达话题类型是sensor_msgs/PointCloud2。这两个类型的header.stamp字段是同步解包的基础,后文脚本会直接依赖它们。
2.4 录制bag时的两个建议
录制标定数据不是随便录一段完事。这里有两个建议:
第一,录制场景里必须有静止的标定板,而且标定板要放在雷达视野和相机视野的重叠区域,距离适中。太远点云太稀疏,角点提不准;太近图像里标定板占满画面,也不好处理。
第二,雷达和相机都要各自话题单独记录,不要用record -a录制所有话题,否则bag文件体积大,解包时I/O也慢。推荐这样录制:
rosbag record -O calibration_data.bag \ /velodyne_points \ /camera/image_raw录制时长控制在30秒到1分钟就够,关键在于标定板要多换几个位置和姿态,这样标定工具才能解算出稳定的外参。
3. 同步解包脚本的设计与实现
3.1 时间戳处理的原理与细节
回到最核心的问题:怎么判断两帧数据是“同一时刻”的。ROS消息里都有一个header.stamp,这是Unix时间戳,单位是秒,但精度通常是纳秒级别。雷达消息的header.stamp一般表示这一帧扫描的结束时刻,而相机图像消息的header.stamp一般由驱动在图像传输完成后打上。这两者之间的差值,就是我们需要评估的同步误差。
处理的基本逻辑分成几个步骤:
- 遍历bag文件,按时间顺序读出所有的雷达消息和图像消息。
- 分别构建消息列表,记录每条消息的
header.stamp。 - 以图像消息的时间戳为基准,用二分查找或线性指针扫描的方式,找到与该时间戳最接近的雷达消息。
- 如果这个时间差小于设定阈值,就保存为一对;否则舍弃。
- 把成对的数据按序列号命名,分别存储为
.pcd和.jpg文件,同时保存一份带时间戳的索引表格。
这个流程里最容易翻车的地方是时间戳单位混淆。有些bag里的话题是第三方驱动生成的,时间戳可能写的是毫秒级整数,不是sensor_msgs/Header标准的stamp.secs加stamp.nsecs结构。写脚本之前先用下面这行代码检查一下:
rosbag info calibration_data.bag再看看某一帧的具体时间值。一般用rostopic echo -n 1 /camera/image_raw/header确认,如果stamp.secs是个整数、stamp.nsecs是0到1e9之间的整数,那就是正常的,可以继续。
3.2 核心Python脚本解读
我习惯用Python的rosbag接口写同步解包脚本,因为它读bag文件非常方便,不用手动解析二进制格式。下面给出一份可以直接跑的脚本,注释里写清楚了关键逻辑:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import sys import csv import rosbag import sensor_msgs.point_cloud2 as pc2 from sensor_msgs.msg import PointCloud2 from sensor_msgs.msg import Image from cv_bridge import CvBridge import cv2 import numpy as np BAG_FILE = sys.argv[1] OUTPUT_DIR = sys.argv[2] LIDAR_TOPIC = '/velodyne_points' CAMERA_TOPIC = '/camera/image_raw' SYNC_THRESHOLD_NS = 50 * 1e6 # 50ms,以纳秒为单位 if not os.path.exists(OUTPUT_DIR): os.makedirs(OUTPUT_DIR) image_dir = os.path.join(OUTPUT_DIR, 'image') pcd_dir = os.path.join(OUTPUT_DIR, 'pointcloud') os.makedirs(image_dir, exist_ok=True) os.makedirs(pcd_dir, exist_ok=True) bridge = CvBridge() lidar_msgs = [] image_msgs = [] print('Reading bag file...') bag = rosbag.Bag(BAG_FILE, 'r') for topic, msg, t in bag.read_messages(topics=[LIDAR_TOPIC, CAMERA_TOPIC]): if topic == LIDAR_TOPIC: lidar_msgs.append(msg) elif topic == CAMERA_TOPIC: image_msgs.append(msg) bag.close() print('Lidar messages: %d, image messages: %d' % (len(lidar_msgs), len(image_msgs))) def get_stamp_ns(msg): return msg.header.stamp.secs * 1e9 + msg.header.stamp.nsecs # 雷达时间戳列表 lidar_stamps = [get_stamp_ns(m) for m in lidar_msgs] # 找出每个图像对应的最近雷达消息 match_pairs = [] for img_idx, img_msg in enumerate(image_msgs): img_stamp = get_stamp_ns(img_msg) # 二分查找最近雷达帧 import bisect pos = bisect.bisect_left(lidar_stamps, img_stamp) best_idx = None best_diff = None if pos < len(lidar_stamps): best_idx = pos best_diff = abs(lidar_stamps[pos] - img_stamp) if pos > 0: diff_left = abs(lidar_stamps[pos - 1] - img_stamp) if best_diff is None or diff_left < best_diff: best_idx = pos - 1 best_diff = diff_left if best_idx is not None and best_diff <= SYNC_THRESHOLD_NS: match_pairs.append((img_idx, best_idx, img_stamp, lidar_stamps[best_idx], best_diff / 1e6)) print('Matched pairs: %d' % len(match_pairs)) # 保存匹配结果 with open(os.path.join(OUTPUT_DIR, 'sync_timestamps.csv'), 'w') as f: writer = csv.writer(f) writer.writerow(['image_seq', 'lidar_seq', 'image_stamp_ns', 'lidar_stamp_ns', 'diff_ms']) for pair in match_pairs: writer.writerow(pair) # 导出图像和点云 for idx, (img_idx, lidar_idx, img_stamp, lidar_stamp, diff_ms) in enumerate(match_pairs): img_msg = image_msgs[img_idx] lidar_msg = lidar_msgs[lidar_idx] # 保存图像 cv_img = bridge.imgmsg_to_cv2(img_msg, desired_encoding='bgr8') img_filename = os.path.join(image_dir, 'image_%06d.jpg' % idx) cv2.imwrite(img_filename, cv_img) # 保存点云为numpy格式,也可以写成pcd points = [] for p in pc2.read_points(lidar_msg, field_names=('x', 'y', 'z', 'intensity'), skip_nans=True): points.append(p) points = np.array(points) pcd_filename = os.path.join(pcd_dir, 'pointcloud_%06d.npy' % idx) np.save(pcd_filename, points) if idx % 20 == 0: print('Saved %d pairs...' % idx) print('Done. Total saved: %d pairs' % len(match_pairs))这份脚本有个要点值得说明:我用bisect做二分查找,而不是两层for循环暴力匹配。图像消息数量如果是一万帧,雷达消息是三千帧,暴力匹配要三千万次比较,虽然Python也能扛住,但明显不如二分查找高效。对于几个G的bag文件,这个设计能让解包时间从十几分钟缩短到两三分钟。
3.3 为什么选择最近邻匹配而不是插值
有人可能会问,为什么不用插值的方式生成一个虚拟点云和图像对齐,这样不是更精确吗?理论上确实可以,比如用两帧雷达点云配准后插值出一帧对应图像曝光时刻的点云。但在标定场景下,我不推荐这样做。
原因很简单:插值会引入新的误差,而且这种误差不好量化。标定本身需要的是真实测量数据,不是人为制造的数据。雷达和相机如果目标静止,插值和最近邻的差别在厘米级以内,对标定结果的影响可以忽略;如果目标在动,插值点云反而会把运动模糊带进来,影响角点提取的准确度。
所以在标定数据预处理阶段,我坚持用最近邻匹配,不做插值。这个经验是从实际教训里来的——之前试过一次用线性插值拉齐时间戳,结果标定出的外参在距离30米以上的目标上出现明显重投影偏差,后来切回最近邻就恢复正常了。
3.4 阈值参数怎么定
脚本里的SYNC_THRESHOLD_NS设的50ms,这不是拍脑袋定的。雷达10Hz,帧间隔100ms;相机20Hz,帧间隔50ms。理论上最坏情况下,一帧图像的最近邻雷达帧距离它能到25ms。把阈值放到50ms,基本能保证每张图像都找到配对帧,同时不会拉到太离谱的远帧。
如果是30fps相机加10Hz雷达,帧间隔33ms,最近匹配的最坏间隔约16ms,阈值可以收紧到30ms甚至20ms。阈值越紧,配对质量越高,但可能丢掉部分图像;阈值越松,配对数量多,但时间误差变大。具体场景根据实际频率调整,不要照抄一个固定值。
4. 解包之后的数据质量检查
4.1 从时间差分布判断数据质量
拿到sync_timestamps.csv之后,不要急着去跑标定工具,先看看时间差的分布。我一般会用一个小脚本统计diff_ms列的均值、最大值、最小值和中位数:
python3 -c " import csv import numpy as np with open('sync_timestamps.csv') as f: reader = csv.DictReader(f) diffs = [float(r['diff_ms']) for r in reader] print('mean: %.2f ms' % np.mean(diffs)) print('max: %.2f ms' % np.max(diffs)) print('min: %.2f ms' % np.min(diffs)) print('p99: %.2f ms' % np.percentile(diffs, 99)) "如果p99超过阈值,说明有一部分配对是硬凑出来的,最好考虑把阈值收紧,或者检查是不是某个驱动的时间戳有问题。如果max特别大,通常是bag里某一段雷达或相机消息缺失导致的,需要回到原始的bag确认录制过程有没有掉帧。
4.2 点云和图像内容的快速可视化验证
数据有没有对齐,光看时间戳还不够,还得做人眼检查。我通常会在解包后的image文件夹里随机抽几十张图,把对应时刻的点云投影到图像上,用半透明的方式叠加显示。如果投影后的点云边缘和图像里的物体边缘贴合,说明外参暂时看不出大问题;如果明显偏移,可能是标定还没做,也可能是数据本身没对齐。
这里有个小技巧:在跑完标定工具获得初始外参之后,再叠加一次点云投影,就能快速验证外参的合理性。这一步其实可以提前写在解包脚本后面,形成一套完整的数据检查流水线。
4.3 导出中间产品的好处
很多标定工具需要的是特定格式的输入,比如Autoware的calibration_tool要求点云话题和图像话题分别回放,而不是直接读文件。但我们在解包阶段先把数据导出为.jpg和.npy文件,是有额外好处的:
- 后续如果标定工具支持文件输入,可以直接复用。
- 如果需要在别的机器上做标定,只需要把解包后的数据拷过去,不用重复读bag。
- 中间产品可以方便地用OpenCV、matplotlib等工具做调试,不必每次都启动ROS环境。
所以即使某些标定工具可以直接消费bag,我还是建议做一次解包和导出,把原始数据固化成独立文件,后面不管用什么工具都有退路。
5. 常见问题与避坑经验
5.1 时间戳格式不一致怎么处理
这是最容易踩的坑。有些相机驱动,尤其是用GStreamer或V4L2封装的,发布的消息header.stamp可能默认是零,需要手动在驱动里设置。如果解包后发现图像消息的时间戳都是0,或者是一个固定值,说明驱动没有正确写入时间戳。解决方法是修改相机的ROS驱动配置,找到类似frame_id和timestamp相关的参数打开,或者在驱动源码里手动用ros::Time::now()给消息打时间戳。
怎么快速发现?在解包前先用rostopic echo -n 3 /camera/image_raw/header看一眼,如果三帧的stamp都一样或者差距离谱,基本就是驱动问题。
5.2 雷达点云频率不稳定怎么办
机械式雷达正常情况下的点云频率很稳定,但如果工控机负载高,驱动节点处理点云数据不及时,可能导致话题发布频率抖动。这会直接反映到解包后的时间差分布上。我遇到过一次,系统里同时跑了可视化工具和录制工具,雷达频率从10Hz掉到了8Hz左右,导致匹配成功率下降。
解决办法通常是把可视化窗口关掉,或者把雷达驱动进程的CPU优先级调高。另外,录制bag的机器尽量单独跑这两个驱动节点,不要同时跑太多计算任务。
5.3 点云文件用什么格式存更好
我脚本里存的是.npy,因为后处理方便。但如果你后续要用CloudCompare、MeshLab这类工具人工检查点云,.npy就不方便了。可以顺手用Open3D或者PCL把点云写成.pcd文件,也就是在保存时加几行转换代码。对于标定工具来说,很多工具直接消费.pcd文件更方便,所以建议脚本里保存两种格式,或者至少保存.pcd。
# 在脚本末尾追加保存pcd import open3d as o3d pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(points[:, :3]) o3d.io.write_point_cloud(pcd_filename.replace('.npy', '.pcd'), pcd)5.4 录制时长和标定板位姿的建议
录制数据时,尽量让标定板在视野里处于不同位置:左右、上下、远近、倾斜,各种姿态都来几组。标定的本质是求解点云坐标系到相机坐标系的刚体变换,需要多组不共面的对应点才能稳定解算。如果标定板只在一个位置,即使录了十分钟,信息量也比不上在不同位置各录20秒。
另外注意标定板表面不要反光太强,有些镜面材质的板子在激光雷达点云里会出现空洞,角点提取会失败。哑光材质的标定板是首选。
5.5 系统负载过高导致bag丢帧
录制rosbag时,如果同时写入的还有别的日志或者图像数据,I/O可能会成为瓶颈。我遇到过在机械硬盘上录制多传感器bag,结果相机话题丢帧率达到5%的情况。解决办法是把bag写到SSD,或者在录制前用rosbag record的--split参数按大小分段,避免单个bag文件过大。分段录制还有一个好处:即使某一段数据出问题,不需要整段重录。
6. 从解包到标定的衔接细节
同步解包做完,拿到的是一整套成对的点云和图像。接下来就可以进入联合标定工具的使用环节了。这里简单说明一下衔接时要注意的地方,具体工具的操作后面单独开篇写。
Autoware的calibration_tool或类似的标定工具,通常需要加载一个点云话题和一个图像话题,然后在界面上手动点击点云中的标定板角点和图像中的对应角点。如果你是从bag直接回放,每次都要等待bag的加载和播放,效率很低。我的做法是把解包后的数据用脚本重新发布成ROS话题,这样标定工具读取起来非常快,而且可以跳过不需要的帧。
发布脚本的核心就是读.pcd文件和.jpg文件,构造sensor_msgs/PointCloud2和sensor_msgs/Image消息,按固定频率发布。这块代码不复杂,但要注意保持frame_id一致,比如把点云和图像消息的frame_id都设为map或车辆坐标系,避免标定工具内部坐标系混乱。
另外一个容易被忽略的细节是,标定工具通常会在多个配对数据上分别提取角点,然后优化外参。因此,解包出来的配对数据除了要时间对齐,还要保证标定板完整出现在两个传感器视野中。如果某一对数据中标定板在图像里被别的物体遮挡,或者在点云里不完整,建议手动删除这对数据,别让它污染全局优化。
写在最后的一点经验
同步解包这个过程,看起来只是数据搬运,其实藏着整个标定流程成败的关键。我在最开始做的时候也走过弯路——直接用rostopic echo导出数据,结果时间戳没对齐,标定出来的外参一塌糊涂。后来老老实实写了时间同步匹配脚本,把时间差压到毫秒级,标定结果才稳定下来。
如果你正在做3D目标检测相关的传感器融合,我的建议是把同步解包这一步做成标准化的工具,固定脚本、固定参数、固定数据格式,这样后面每次采集数据、重新标定、更新外参,都能在几分钟内完成预处理,而不是每次都要重新调试脚本。
下一篇文章我会接着写Autoware联合标定工具的具体使用流程,包括如何手动提取角点、如何优化外参、如何验证标定结果。感兴趣的可以先把你自己的bag数据跑一遍这份同步解包脚本,熟悉一下数据形态,后面标定会顺手很多。