ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

激光雷达与相机联合标定:KITTI点云投影的完整指南与避坑手册

激光雷达与相机联合标定:KITTI点云投影的完整指南与避坑手册 做自动驾驶和机器人感知的朋友迟早都会撞上“激光雷达相机联合标定”这堵墙。我第一次拿着KITTI数据做Velodyne雷达点到图像投影的时候以为就是套几个矩阵公式结果出来的画面让我怀疑人生——明明是同一辆车雷达点全飘到道路尽头的树梢上整个点云在图像里像打翻的芝麻跟我预期的“精准贴合”差了十万八千里。后来反复对参数、查资料才发现问题出在一堆不起眼的细节上齐次坐标没归一化、旋转矩阵忘了扩维、甚至把P2矩阵里那一行平移量当成了噪声。这篇文章就围绕KITTI数据的激光雷达与相机标定展开讲清楚坐标转换的完整推导、标定文件里每个参数的真实含义、以及我在实操中踩过的坑和排查方法。无论你是刚接触多传感器融合的学生还是做目标检测、数据标注、仿真开发的工程师只要你需要把雷达点云和图像对应起来这篇文章都能帮你少走弯路。看完之后你不仅能在KITTI数据上跑通雷达点到像素坐标的完整投影还能把这套逻辑迁移到自己的设备上。1. 为什么激光雷达和相机非要“对齐”坐标系统与联合标定的底层逻辑1.1 一个例子讲清楚雷达看到一个框图像里却是歪的假设你正在做自动驾驶目标检测激光雷达在正前方20米处检测到一辆车点云聚类结果是一个3D包围框你想在相机图像里把这个框画出来。如果雷达坐标系和相机坐标系之间没有任何对应关系你根本不知道该把框画在图像的哪个位置。雷达给出的是“从车顶雷达原点出发向前X米、向左Y米、向上Z米”的坐标而图像给出的是“第几行第几列的像素亮度”两者是两套完全不同的语言。联合标定要解决的就是这个翻译问题通过一组旋转矩阵和平移向量把雷达坐标系下的三维点先转换到相机坐标系再通过相机内参投影到图像平面最终得到像素坐标。整个过程看起来就是几个矩阵相乘但“左乘还是右乘”“矩阵扩维还是缩维”“除以z还是除以w”这些细节一旦搞错结果就是图像上漫山遍野的乱点。1.2 四个坐标系的关系雷达系、相机系、图像系、像素系做雷达相机标定脑子里必须时刻绷紧“坐标系”这根弦。KITTI数据里涉及了四个坐标系分别是激光雷达坐标系Velodyne Coordinate原点在车顶激光雷达中心X轴指向车辆前方Y轴指向车辆左侧Z轴竖直向上属于右手坐标系。雷达点云文件里每一行的(x, y, z)就是在这个坐标系下的坐标。相机坐标系Camera Coordinate原点在相机光心Z轴指向场景前方即相机朝向X轴指向右侧Y轴指向下方。注意这里Y轴方向和雷达坐标系正好相反所以雷达到相机的旋转往往带着接近90度的分量。图像坐标系Image Coordinate把相机坐标系中的三维点投影到成像平面后得到以光心投影点为原点的二维坐标单位是毫米这类物理单位需要再转成像素。像素坐标系Pixel Coordinate图像左上角为原点单位是像素。我们在图像上看到的每一个点最终都是这个坐标系下的(u, v)。四个坐标系的转换关系就像流水线雷达点先做刚性变换旋转加平移到相机坐标系再从相机坐标系做透视投影到图像坐标系最后从毫米单位换算成像素坐标。每一步都有对应的矩阵在管KITTI的标定文件本质上就是把这些矩阵打包好的“翻译官”。1.3 时间同步比空间标定更先要解决的问题很多新手搞定了空间坐标转换投影出来还是重影、拖尾这时候大概率不是标定参数的问题而是时间没对齐。激光雷达一般10Hz左右相机可能是20Hz、30Hz两边的数据不是同一时刻采集的。车辆在运动时哪怕差50毫秒目标在图像里可能已经移动了好几个像素雷达点自然就“对不上脸”。KITTI数据集本身做了同步处理所有传感器数据的采集时间戳严格对齐这也是为什么很多入门项目首选KITTI——可以暂时跳过时间同步这个坑。但如果用自己采集的数据就需要按时间戳找最近邻帧或者做插值。空间标定和时间同步是两件事但要做出漂亮的效果缺一不可。我在第五节会专门讲自己采集数据时的时间对齐做法。2. 准备工作KITTI数据下载、标定文件里到底写了什么2.1 KITTI数据怎么选RGB图像、点云和标定文件对应关系KITTI数据集常见的用法是下载object development kit或者raw data。如果你的目标是验证雷达点到图像的投影我建议直接下载object数据集里的left color images、velodyne point cloud和calibration files三样东西。其中图像数据在data_object_image_2目录下点云数据在data_object_velodyne目录下标定文件在data_object_calib目录下。这里有一个很容易搞混的点KITTI为每一帧数据提供了四个相机两个灰度、两个彩色对应P0、P1、P2、P3四个投影矩阵以及一个R0_rect旋转矩阵和一个Tr_velo_to_cam变换矩阵。其中P2对应的是左侧彩色相机也就是我们平时看到的那张RGB图像。在做投影验证时只需要用P2矩阵其他三个相机矩阵可以先忽略。2.2 看懂calib_cam_to_cam.txt与calib_velo_to_cam.txt进入标定文件夹后你会看到几个文本文件重点看两个calib_cam_to_cam.txt和calib_velo_to_cam.txt。calib_cam_to_cam.txt里包含了相机内参相机外参相关的完整信息核心字段是P0、P1、P2、P3和R0_rect。各字段含义如下表字段含义矩阵尺寸P0左侧灰度相机cam0投影矩阵3x4P1右侧灰度相机cam1投影矩阵3x4P2左侧彩色相机cam2投影矩阵3x4P3右侧彩色相机cam3投影矩阵3x4R0_rect相机坐标系校正旋转矩阵3x3这里说的“校正旋转矩阵”可能让新手有点懵。KITTI的四个相机经过了立体校正处理使得同一行像素在不同相机中是对齐的。R0_rect描述的就是原始相机坐标系到校正后相机坐标系的旋转关系。做单相机投影时通常也要乘上它否则投影结果会有细微错位。calib_velo_to_cam.txt则只有一行核心数据——Tr_velo_to_cam尺寸是3x4描述激光雷达到相机坐标系的刚性变换由旋转矩阵和平移向量拼接而成。除此之外还有一个calib_imu_to_velo.txt它是IMU到激光雷达的外参如果是纯雷达点投影到图像用不到它。2.3 参数矩阵P2、R0_rect、Tr_velo_to_cam的真实含义很多人看到这些矩阵里密密麻麻的数字就头大其实把它们拆开看就没那么吓人。拿KITTI某帧的真实标定参数举例P2: 7.215377e02 0.000000e00 6.095593e02 4.485728e01 0.000000e00 7.215377e02 1.728540e02 2.163791e-01 0.000000e00 0.000000e00 1.000000e00 2.745884e-03 R0_rect: 9.999239e-01 9.837760e-03 -7.445048e-03 -9.869795e-03 9.999421e-01 -4.278459e-03 7.402527e-03 4.351614e-03 9.999631e-01 Tr_velo_to_cam: 7.533745e-03 -9.999714e-01 -6.166020e-04 -4.069766e-03 1.480249e-02 7.280733e-04 -9.998902e-01 -7.631618e-02 9.998621e-01 7.523790e-03 1.480755e-02 -2.717806e-01P2矩阵的前三行前三列是相机内参也就是焦距和光心fx约721.54fy约721.54cx约609.56cy约172.85。前三行第四列是相机坐标系原点在校正后坐标系里的平移分量数值一般很小。R0_rect是3x3的旋转矩阵Tr_velo_to_cam是3x4的变换矩阵代表雷达到相机的旋转和偏移。真正做投影时这三个矩阵一个都省不了具体怎么用下面详细说。3. 手把手实现雷达点到像素的坐标转换3.1 转换公式推导从三维点到图像坐标的完整流程把激光雷达坐标系下的一个三维点(x, y, z)投影到像素坐标(u, v)完整公式是这样的[x_cam, y_cam, z_cam, 1]^T R0_rect_4x4 * Tr_velo_to_cam_4x4 * [x, y, z, 1]^T [u, v, w]^T P2 * [x_cam, y_cam, z_cam, 1]^T u_pixel u / w v_pixel v / w第一步把雷达坐标系的点补成齐次坐标x, y, z, 1。这里要注意Tr_velo_to_cam是3x4的矩阵没法直接乘4维向量得先给它补一行[0, 0, 0, 1]变成4x4矩阵。同理R0_rect也要从3x3扩成4x4左上角放原矩阵右上角补3个0左下角补3个0右下角补1。第二步用P2矩阵3x4把相机坐标系下的点投影到图像平面。这里的P2矩阵已经内含了相机内参和部分校正信息不需要再单独拆出内参矩阵。相乘之后得到的是一个三维向量(u, v, w)其中w其实是相机坐标系下的深度值z_cam或者说是齐次缩放因子。所以最后必须用u除以w、v除以w才能得到真正的像素坐标。很多新手在最后一步偷懒直接用u、v画点结果图像上一片乱麻就是这个原因。3.2 Python代码实现加载标定文件并完成投影我建议把标定文件解析和投影封装成函数以后换数据、换设备都能复用。下面是一段我常用的Python代码注释写得很详细import numpy as np import cv2 def load_calib(calib_dir): 读取KITTI标定文件返回P2、R0_rect、Tr_velo_to_cam矩阵。 注意R0_rect和Tr_velo_to_cam都要扩展成4x4。 def read_matrix(file_path, key): with open(file_path, r) as f: for line in f.readlines(): if line.startswith(key): values line.strip().split()[1:] return np.array([float(v) for v in values]).reshape(3, 4) return None # 读取相机内参/校正矩阵 cam_file calib_dir /calib_cam_to_cam.txt P2 read_matrix(cam_file, P2:) R0 read_matrix(cam_file, R0_rect:) # 扩展R0_rect为4x4 R0_4x4 np.eye(4) R0_4x4[:3, :3] R0 # 读取雷达到相机外参 velo_file calib_dir /calib_velo_to_cam.txt Tr read_matrix(velo_file, Tr_velo_to_cam:) # 扩展Tr_velo_to_cam为4x4 Tr_4x4 np.vstack([Tr, np.array([0, 0, 0, 1])]) return P2, R0_4x4, Tr_4x4 def project_velo_to_image(pts_velo, P2, R0_4x4, Tr_4x4): 将Velodyne点云(N, 3)投影到图像坐标(N, 2)。 返回点在图像上的像素坐标以及在相机前方的深度值。 n pts_velo.shape[0] pts_velo_homo np.hstack([pts_velo, np.ones((n, 1))]) # N x 4 # 雷达到相机再校正 pts_cam (R0_4x4 Tr_4x4 pts_velo_homo.T).T # N x 4最后一维是1 # 只保留相机前方的点z 0 z_cam pts_cam[:, 2] valid z_cam 0 # 投影到像素系 pts_cam_homo pts_cam.T # 4 x N pts_img (P2 pts_cam_homo).T # N x 3 u pts_img[:, 0] / pts_img[:, 2] v pts_img[:, 1] / pts_img[:, 2] return u, v, z_cam, valid这里的核心逻辑就是把三个矩阵串起来。数据读取时我习惯直接用np.eye(4)构造单位矩阵再填充避免手写4x4矩阵时把0和1的位置搞错。3.3 可视化校验点云深度着色与ROI裁剪投影完成之后如果不做可视化代码写得再漂亮也白搭。我常用的校验方法很简单把投影到图像上的雷达点按照距离着色距离近的点用红色距离远的点用蓝色然后叠加到原始图像上。如果标定参数正确你会看到点云轮廓和图像里的车辆、行人、路沿严丝合缝。下面是一段可视化代码import matplotlib.pyplot as plt def draw_projection(image, u, v, depth, valid, max_depth80): img image.copy() mask valid (u 0) (u image.shape[1]) (v 0) (v image.shape[0]) u, v, depth u[mask], v[mask], depth[mask] # 按深度归一化颜色近红远蓝 depth_norm np.clip(depth / max_depth, 0, 1) colors plt.cm.jet(1 - depth_norm)[:, :3] * 255 for i in range(len(u)): cv2.circle(img, (int(u[i]), int(v[i])), 2, colors[i].tolist(), -1) plt.figure(figsize(12, 6)) plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) plt.axis(off) plt.show()需要注意KITTI图像是用cv2读取的BGR格式显示时记得转成RGB。另外我设置了max_depth80把80米以外的点统一压到颜色区间边界这样近距离物体的颜色细节更丰富画面看起来更清楚。3.4 结果分析如何判断投影是否对齐很多朋友第一次跑通代码看到图像上密密麻麻的点就以为万事大吉其实还需要仔细判断对齐质量。我一般看三个地方第一看边缘轮廓。找一根电线杆或者路灯杆看雷达点是否刚好落在杆子的像素轮廓上如果点全部偏到杆子左边或右边说明外参里有平移误差。第二看路面。地面上的点应该构造成一个平整的、与图像道路区域重合的平面如果地面点飘到天上或者扎进路面以下很深的地方说明雷达俯仰角标定有问题。第三看远处物体。远处的车辆和行人轮廓是否大致吻合虽然点会比较稀疏但不应出现整体偏移。如果整个画面点云方向一致地偏移比如所有点都往右上角偏多半是外参矩阵的平移向量出了问题。如果是远处的点发散严重、近处还好可能是相机内参的畸变参数没有被正确处理。这些内容我在下一章展开细讲。4. 常见坑位与排查技巧吃了亏才知道的那些细节4.1 投影错位到离谱先查这几处我在实际排错中总结了一套“由快到慢”的检查顺序每次都帮我快速定位问题现象优先排查项原因全部点都不在图像上是否忘了除以w齐次坐标没有归一化点出现在图像但整体乱飘Tr_velo_to_cam或R0_rect是否扩维矩阵形状错误运算结果全乱近处点对得上远处点偏移相机内参或畸变参数不对需要重新标定相机内参点云左右镜像、上下颠倒旋转矩阵使用不当坐标系方向理解错了点云整体偏移但不发散平移向量符号或者数值错误外参平移量需要重新标定其中“忘了除以w”是出现频率最高的错误。P2矩阵乘完齐次坐标后得到的三维向量第三分量是深度w如果直接用前两个分量当像素坐标那么所有点会沿着一条射线发散近处的点可能还勉强在图像内远处的点直接飞出屏幕。另外扩维顺序也是一个不起眼但致命的细节。Tr_velo_to_cam本身是3x4如果你直接把点云坐标(3,)或(N,3)拿去乘代码会报维度错误这时候很多人会强行reshape反而把矩阵结构弄乱。正确做法是先把点云补成Nx4再让4x4的变换矩阵去乘它。4.2 时间戳不同步导致的重影与鬼影自己采集数据时时间戳不同步几乎是所有人的噩梦。我见过一位朋友用频率10Hz的雷达和30Hz的相机做融合直接取了两个传感器各自最近的一帧数据结果车辆转弯时雷达点全部“粘”在图像中的墙上。这是因为雷达和相机的采集时刻差了将近50毫秒车辆已经移动了一小段距离反映在图像上就是几个像素到十几个像素的空间错位。解决思路有两类。第一类是硬件同步通过外部触发让雷达和相机在同一时刻曝光这是工业级方案第二类是软件同步在算法上为每一帧图像找一个时间戳最近的雷达帧或者反过来为雷达帧找最近图像帧再辅以线性插值。对于入门验证软件同步足够了。另外很多ROS设备在话题发布时会带有时间戳但在数据录制和回放过程中如果rostime出现跳变时间戳也会失真。我自己会在预处理阶段先画一条时间戳曲线看看是否存在异常跳变再去做时间对齐。4.3 深度值与像素z的混淆还有一个非常隐蔽的坑把雷达点的“深度”和相机坐标系下的z_cam混淆。激光雷达返回的每个点通常还有一个距离值即点到雷达原点的欧氏距离sqrt(x^2 y^2 z^2)而投影公式里P2矩阵乘出来的第三分量w对应的是点在相机坐标系下的z值也就是点到相机光心平面的垂直距离。这两个值在大部分情况下不相等。如果你在筛选“相机前方的点”时误用了雷达点的欧氏距离做判断就会把雷达背后的点也保留下来投影到图像上显示成杂乱的“飞点”。正确做法是先用变换矩阵算出z_cam再用z_cam 0作为有效性判断最后才用z_cam做深度着色。4.4 外参标定工具发散Autoware、ACAT等工具实操经验有了KITTI现成的标定参数你可以直接跑通投影。但换到自己的设备上就需要自己做外参标定。市面上常见工具包括Autoware的Calibration Tool和ACAT很多人在Ubuntu 18.04上安装这些工具时被依赖问题折腾得够呛。我的经验是不要硬刚源码编译优先找现成Docker镜像或者直接用ROS 1 Noetic自带的一些标定包。Autoware标定工具的核心流程是采集包含标定板的雷达点云和相机图像在点云中手动框选标定板的三个角点在图像中点击对应的角点通过多帧数据求解外参。这里最大的坑是角点选取精度不够导致外参发散。手动点击时尽量把图像放大到单个像素级别雷达点云要选标定板角落最锐利的那个点。另外多帧数据的分布要足够分散——只采集标定板正前方的数据解算出来的外参在侧向会有比较大的偏差。我建议至少采集10帧标定板分别出现在画面的左上、左下、中间、右上、右下五个区域再用RANSAC或者中值滤波剔除异常解。5. 从KITTI到自己设备转换代码迁移与多传感器标定的进阶姿势5.1 自己采集雷达相机数据时的对齐方法KITTI数据是“天上掉下来的礼物”因为时间同步、相机内参、雷达外参全都给你准备好了。换到自己的小车或者机器人平台上第一件事就是解决数据对齐问题。我自己常用的方法是先录制rosbag同时记录雷达和图像的topic时间戳预处理时以图像时间为基准对每一帧图像寻找最近的雷达帧。如果雷达是10Hz、图像是20Hz那么大约一半图像会配到同一帧雷达另一半图像也会有对应的最近帧。这种最近邻方法实现简单适合低速运动场景。如果车辆速度较快可以尝试根据帧间运动做雷达点云的补偿插值——先用里程计或者IMU估计两帧之间的位姿变化再把雷达点云变换到图像对应时刻的雷达坐标系下。这个思路在不少开源项目中已经实现可以直接参考。5.2 内参标定的坑棋盘格、D435i、双目相机外参固然重要但相机内参不准外参标定结果也不会好到哪里去。给普通相机做内参标定最常用的是张正友棋盘格标定法。很多人用OpenCV跑一遍就完事但我建议至少做三件事一是采集棋盘格在画面各个位置、各个倾斜角度的图像不要只拍正前方二是检查重投影误差一般要小于0.5像素才算合格三是剔除不合格角点——热词里提到的“双目相机标定剔除不合格角点”就是这个意思棋盘格一旦出现高光、反光或者运动模糊那一帧数据直接扔掉。对应到具体设备Intel RealSense D435i这类深度相机自带出厂内参但出厂参数不一定适合你当前的畸变情况尤其是经历运输、磕碰之后。我的习惯是在新环境跑一次内参标定然后把生成的相机参数写入配置。双目相机标定更加麻烦两个相机的内参分开标定之后再标定相对外参。新手常见的错误是只标定了内参就去做双目视差结果深度图上有大片空洞和错位。5.3 从标定到SLAM外参不准会让建图飘吗很多做激光雷达SLAM的朋友问我为什么Cartographer建图总是“飘”方向没问题但地图边缘糊掉。这里面确实有外参不准的锅。激光雷达SLAM的输入是点云点云的质量取决于雷达本身的标定是否准确。在多传感器融合建图场景中如果雷达与IMU、相机的外参不对齐融合算法会得到相互矛盾的观测最终反映在地图上就是重影、错位、甚至轨迹漂移。严格来说SLAM建图“飘”首要是定位问题但外参不准会显著增加系统的不确定性。如果你的雷达是16线的点云本身比较稀疏外参误差哪怕只有1度在20米外就会产生约35厘米的空间偏移。所以做建图之前最好先用本文介绍的投影方法验证一遍外参——把雷达点投到图像上看看轮廓是否贴合再去跑建图。5.4 最后的检查清单根据我自己的项目经验整理了一份标定与投影验证的检查清单分享给大家标定文件是否对应正确的相机编号彩色图用P2灰度图用P0R0_rect和Tr_velo_to_cam是否已经扩展成4x4是否在投影后对(u, v, w)做了齐次归一化除以w是否用z_cam 0筛掉了相机后方的点是否检查过像素坐标是否在图像尺寸范围内是否对时间戳做了对齐尤其是自己采集的数据是否用可视化逐帧确认投影效果而不是只跑一帧换设备或拆装传感器后是否重新标定过外参把这些都过一遍你的投影结果基本就不会翻车了。我个人在实际操作中的体会是标定这件事非常忌讳“一步到位”的心态。拿到KITTI数据时先用简单脚本跑通投影、看到点云落在图像上再去研究矩阵里的每个元素学习效率会高很多。换到自己设备后更要如此先粗标定让点云大致贴合再采集多帧数据精细化最后用可视化验证收尾。还有一个小技巧把标定文件解析、投影函数、可视化脚本整理成一套模板以后换任何传感器只需要修改文件路径和标定参数马上就能看到结果。这套方法我用了很久从KITTI到自采数据到项目交付一路都很稳。
返回列表