ARTICLE DETAIL

资讯详情

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

KITTI激光雷达到相机坐标转换实战:标定文件逐字段拆解与代码实现

KITTI激光雷达到相机坐标转换实战:标定文件逐字段拆解与代码实现 接触过多传感器融合的都知道最磨人的往往不是算法本身而是数据对齐。点云和图像明明拍的是同一个场景想叠加在一起看效果结果总是错位、重影、边缘对不上。尤其是刚入手激光雷达和相机联合标定时连从哪个坐标系开始转、矩阵怎么乘都容易绕晕。这篇内容我打算直接用KITTI公开数据集作为练手素材把激光雷达到相机图像的坐标转换链路完整走一遍每个矩阵字段都拆开讲清楚所有代码直接给你能跑的版本。文章适合正在做自动驾驶感知、机器人多传感器融合或者想搞懂外参标定原理但不想只停留在概念上的朋友。看完你不仅能跑通KITTI的数据还能把同一套思路迁移到自己的传感器平台。1. KITTI标定数据里到底藏着什么KITTI数据集之所以适合拿来学坐标转换是因为它的传感器配置齐全、标定参数公开而且本身就是行业内衡量视觉与激光雷达算法性能的基准数据集。很多人下载完数据就去刷目标检测榜了却忽略了那些藏在calib目录里的标定文件而这些文件恰恰是打通雷达与相机坐标系的关键。1.1 KITTI的传感器布局与坐标系KITTI采集车的传感器布局大概是这样的车顶前方装有一台Velodyne HDL-64E激光雷达挡风玻璃附近装有左右两个灰度相机和左右两个彩色相机。为了方便称呼KITTI把所有相机编号为0到3其中左侧灰度相机是cam0右侧灰度相机是cam1左侧彩色相机是cam2右侧彩色相机是cam3。我们在绝大多数目标检测任务里用的image_2其实就是左侧彩色相机拍出来的画面。这些传感器各自有独立的坐标系激光雷达有一个以Velodyne自身为原点的三维直角坐标系相机也有自己的相机坐标系。两个坐标系之间既存在旋转关系也存在平移关系标定文件里的矩阵组合在一起干的就是把同一个物理点在两个坐标系之间的位置对应关系精确描述出来。1.2 标定文件全家桶KITTI每个数据序列的calib目录下通常有以下几个文件calib_cam_to_cam.txt相机与相机之间的标定包含内参、畸变系数、立体校正旋转矩阵、投影矩阵。calib_velo_to_cam.txt激光雷达到相机的刚体变换也就是雷达坐标系到cam0相机坐标系的旋转和平移。calib_imu_to_velo.txtIMU到激光雷达的变换。其中calib_velo_to_cam.txt和calib_cam_to_cam.txt是完成雷达到图像投影需要重点关注的两个文件。很多教程直接给个公式让你套却没解释每个字段的物理含义结果你换一个序列的数据、换一个传感器组合就不会做了。1.3 从雷达到像素的完整变换链假设空间里有一个三维点它在激光雷达坐标系下的坐标是P_velo我们要把它投影到左侧彩色图像cam2的像素坐标系下变成二维像素坐标p_img。完整推导过程可以写成一个链式关系P_velo - 通过Tr_velo_to_cam 变换到cam0相机坐标系 P_cam0 - 通过R_rect_00 做立体校正变成矫正后的相机坐标系 P_rect - 通过P_rect_02 投影矩阵变成cam2图像的像素坐标一句话概括先平移旋转到相机坐标系再校正旋转最后投影到像素平面。任何激光雷达和相机的融合任务本质上都是在重复这条链子只是具体传感器编号和矩阵内容会不一样。2. 标定文件逐字段拆解那些矩阵分别是什么很多人在这一步就卡住了因为打开标定文件看到满屏幕的浮点数不知道每行数字到底有什么用。我直接用KITTI一个真实序列的标定文件来做拆解把每个字段的含义说透。2.1 calib_velo_to_cam.txt雷达坐标系到cam0坐标系打开文件内容长这样calib_time: 15-Mar-2012 11:37:16 R: 7.533745e-03 -9.999714e-01 -6.166020e-04 1.480249e-02 7.280733e-04 -9.998902e-01 9.998621e-01 7.523790e-03 1.480755e-02 T: -4.069766e-03 -7.631618e-02 -2.717806e-01这里的R是3x3旋转矩阵T是3x1平移向量。把它们拼成一个3x4的变换矩阵也就是后面所有计算里经常看到的Tr_velo_to_camTr_velo_to_cam [R | T]维度是3x4作用是把Velodyne坐标系下的点变换到cam0相机坐标系下。要特别注意一个细节这个变换的目标是cam0也就是左侧灰度相机不是左侧彩色相机cam2。为什么这点很关键因为cam0和cam2之间存在一段物理距离如果你以为Tr_velo_to_cam是雷达到cam2的变换直接拿它去投影彩色图结果必然偏掉。很多从零开始做融合的人踩的就是这个坑。2.2 calib_cam_to_cam.txt相机内参与立体校正这个文件比较长包含大量字段常见的有S_xx图像尺寸、K_xx内参矩阵、D_xx畸变系数、R_xx和T_xx各相机之间的外参、S_rect_xx校正后的图像尺寸、R_rect_xx校正旋转矩阵、P_rect_xx校正投影矩阵。R_rect_00是cam0的立体校正旋转矩阵作用是把左右相机图像做一个共面校正让两条极线对齐。在KITTI的投影公式里这个矩阵会被扩展成4x4矩阵使用而且它参与的是三维点的坐标变换不是直接作用在图像上。文件里还有P_rect_00、P_rect_01、P_rect_02、P_rect_03四个投影矩阵它们分别对应cam0到cam3的投影。我们做激光雷达到左侧彩色图像的投影用的是P_rect_02。2.3 P_rect_02的特殊之处截取一段真实的P_rect_02看看P_rect_02: 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这个3x4矩阵的前三列是相机内参和校正旋转的组合最后一列则包含了cam2相对cam0的平移信息。之前看到有人写代码时把点从cam0变换到cam2又额外手动乘了一个cam0到cam2的平移矩阵结果越乘越偏。原因就是P_rect_02的最后一列已经把这个平移考虑进去了你要做的事只是用这个矩阵做投影不需要再叠加其他变换。2.4 组装最终投影矩阵把上面所有矩阵串起来最终从雷达坐标到像素坐标的变换可以写成P_velo_to_img P_rect_02 R_rect_00 Tr_velo_to_cam这里每个矩阵都是4x4或3x4的维度做乘法之前需要把各矩阵补齐成齐次形式。组合完成后对雷达点云坐标做一次矩阵乘得到的3x1结果就是该点在图像上的齐次像素坐标再除以最后一个分量得到真正的u、v像素坐标。3. 手把手用KITTI数据完成雷达到图像的投影原理清楚了接下来是实际操作。这一节我直接给出一个可以完整运行的Python脚本包括解析标定文件、加载点云、投影计算、可视化验证的完整流程。3.1 环境准备与数据下载思路开始之前你需要准备Python 3.6以上环境安装numpy和opencv-python。KITTI原始数据集中的某个序列至少包含image_2目录、velodyne目录和calib目录。KITTI官网的下载页提供了多个序列的下载入口可以下载0047等样本序列其中单个序列只包含同步后的图像、点云和标定文件体积可控。如果是初次下载KITTI数据建议先下载一个训练序列别一上来就下完整训练集两三百G的数据不仅占用空间而且对学习坐标转换来说冗余太多。单个序列足够你把整个流程跑通了。3.2 解析标定文件的代码KITTI的标定文件是文本格式结构比较规整可以写一个专门函数来解析。核心是把每行按空格拆开再把对应的数字转成numpy数组。import numpy as np import cv2 import os def read_calib_file(filepath): 读取KITTI标定文件返回字典。 每个key对应文件中的一行字段名value是对应的浮点数数组。 data {} with open(filepath, r) as f: for line in f.readlines(): line line.strip() if len(line) 0: continue key, value line.split(:, 1) try: data[key] np.array([float(x) for x in value.split()]) except ValueError: pass return data def get_projection_matrix(calib_dir): 组装雷达到图像的3x4投影矩阵。 calib_cam_to_cam read_calib_file(os.path.join(calib_dir, calib_cam_to_cam.txt)) calib_velo_to_cam read_calib_file(os.path.join(calib_dir, calib_velo_to_cam.txt)) # Tr_velo_to_cam: 3x4雷达到cam0 Tr_velo_to_cam np.vstack([ calib_velo_to_cam[R].reshape(3, 3), calib_velo_to_cam[T].reshape(1, 3) ]).T # 现在是3x4 # R_rect_00: 3x3扩展成4x4 R_rect_00 np.eye(4) R_rect_00[:3, :3] calib_cam_to_cam[R_rect_00].reshape(3, 3) # P_rect_02: 3x4 P_rect_02 calib_cam_to_cam[P_rect_02].reshape(3, 4).astype(np.float32) # 组合成完整投影矩阵 P_velo_to_img P_rect_02 R_rect_00 Tr_velo_to_cam return P_velo_to_img这段代码有几个细节值得说明。Tr_velo_to_cam的构造方式是先把R按3x3排列、T按1x3排列并拼接成4x3再转置成3x4这样得到的矩阵满足y R x T的数学关系。R_rect_00扩展时除了左上角3x3其余部分补单位矩阵元素四维齐次坐标补上最后一行才能和其他4x4矩阵连乘。3.3 点云加载与前处理KITTI的激光雷达点云文件是二进制格式每个点包含x、y、z、intensity四个浮点数。加载方式如下def load_velodyne_points(bin_file): 加载KITTI velodyne bin文件 points np.fromfile(bin_file, dtypenp.float32) points points.reshape(-1, 4) return points # N x 4 def project_velo_to_image(points, P_velo_to_img): 将雷达点云投影到图像平面返回像素坐标和深度。 points: N x 4 (x, y, z, intensity) # 取前三维并增加齐次坐标行 pts_3d points[:, :3] # N x 3 num_points pts_3d.shape[0] pts_3d_hom np.hstack([pts_3d, np.ones((num_points, 1))]).T # 4 x N # 投影到图像齐次坐标 pts_img_hom P_velo_to_img pts_3d_hom # 3 x N # 分离并归一化 x pts_img_hom[0, :] y pts_img_hom[1, :] z pts_img_hom[2, :] # 去除相机后面的点和z0的点避免除零 valid z 0 u x[valid] / z[valid] v y[valid] / z[valid] depth z[valid] # 去除超出图像范围的投影点 image_width 1242 image_height 375 in_image (u 0) (u image_width) (v 0) (v image_height) return u[in_image], v[in_image], depth[in_image], valid为什么必须裁剪z 0的点因为相机坐标系下的z分量代表点离相机平面的深度距离只有z大于0的点才真正位于相机前方。如果不做这一步相机后面的点投影到像素平面会产生镜像效果画出来的点云会莫名其妙地出现在本不该有目标的位置。还有个实用性技巧KITTI的Velodyne点云每个点有4个分量最后一个是反射强度。如果后续要做基于反射强度的处理可以直接用points[:, 3]不需要额外解析。3.4 深度伪彩色叠加可视化投影结果光看数字没有直观感受最好的方式是画到图像上。常规做法是用深度信息给点云着色离相机近的用暖色远的用冷色然后叠加在原始图像上。def visualize_projection(image_path, bin_path, P_velo_to_img): img cv2.imread(image_path) points load_velodyne_points(bin_path) u, v, depth, _ project_velo_to_image(points, P_velo_to_img) # 将深度归一化到0-255范围用于伪彩色 depth_norm cv2.normalize(depth, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) depth_color cv2.applyColorMap(depth_norm, cv2.COLORMAP_JET) # 按像素位置叠加点云 overlay img.copy() for ui, vi, di in zip(u.astype(int), v.astype(int), depth_color): overlay[vi, ui] di return overlay循环画点速度有点慢但胜在简单直观。如果点云帧数很大建议改用numpy的索引赋值方式一次写入或者用OpenCV的cv2.polylines画线方式加速。3.5 判断对齐效果的四个细节投影效果出来之后怎么判断标定结果好不好我的经验是看四个位置车道线边缘点云投影在道路上的点应该和图像里的车道线明暗边界贴合。车辆轮廓前方车辆的点云应该正好覆盖在图像车辆的边缘上而不是整体偏左或偏右。远处树干和电线杆细长物体会放大标定误差只要有一点外参偏差树干的点云投影就会明显偏离视觉上的树干位置。地面遮挡关系近处车辆的底部点云应该被近处的路面点云遮挡如果出现近处车辆和远处路面点混杂在一起说明深度排序或投影有问题。如果这四处都基本吻合说明坐标转换链路是通的。如果某些位置有偏差先检查是不是代码的问题再考虑标定参数的问题。4. KITTI坐标转换踩坑实录这一节我把自己实际踩过、也看到别人反复踩的坑集中列一下每个坑都附带排查思路如果你投影出来的画面不正常可以逐条对照。4.1 坑一Tr_velo_to_cam的目标坐标系理解错这是最常见的坑。KITTI的calib_velo_to_cam.txt是激光雷达到cam0的变换不是到cam2的变换。不少人第一次做投影时直接把雷达到cam0的变换和P_rect_02连乘结果投影到彩色图像上整体偏移。排查办法是先投影到cam0对应的灰度图像上如果灰度图上对齐而彩色图上不对齐说明问题就出在对cam0和cam2之间关系的处理上。另外要明白P_rect_02里面已经包含了cam2从cam0那里继承的位姿关系所以你不需要手动去构造cam0到cam2的变换。如果你发现代码里多乘了一个T_02之类的矩阵先想想它是不是已经被P_rect_02的最后一列包含了。4.2 坑二矩阵维度不匹配或齐次坐标缺失矩阵乘法时最常见的报错就是ValueError: shapes (3,4) and (3,4) not aligned。原因是把本身不是方阵的3x4矩阵直接做了乘法而没有把前面的三维坐标扩展成四维齐次坐标。正确做法是在点云的三维坐标后面补一行1变成4xN矩阵再和3x4投影矩阵相乘得到3xN的结果。对于R_rect_00和Tr_velo_to_cam也要保证它们被正确扩展成4x4或3x4后再参与连乘。我建议把整个组合过程拆成多步每步打印一下矩阵shape确认无误再继续。4.3 坑三忘记剔除相机后面的点这个坑和维度错误一样普遍。很多投影代码为了保证所有点都能投影不设置z 0的过滤条件结果深度为负的点被除成了正的像素坐标图像上出现大量杂乱噪点。正确的做法是在归一化齐次坐标之前先判断投影后的第三行分量是否大于0。只保留深度为正的点再做除法。另外在最终渲染时也要排除超出图像宽高的点否则索引越界会直接报错。4.4 坑四P_rect_02与R_rect_00的组合顺序写反投影矩阵的连乘顺序必须是P_rect_02 R_rect_00 Tr_velo_to_cam不能随意交换。矩阵乘法不满足交换律顺序反了结果天差地别。从物理意义上理解雷达点先做刚体变换进入相机坐标系再做立体校正旋转最后投影到像素平面。这个次序不能乱否则等于把几何变换的先后逻辑搞反了。4.5 坑五直接用KITTI图像做畸变校正KITTI发布的图像已经是经过校正和裁剪的图像所以不需要再额外做去畸变。但如果你用自己采集的数据相机原始图像带有镜头畸变直接投影会看到图像边缘出现明显的曲线错位。正确做法是先对图像做一次cv2.undistort去畸变再去叠加点云。很多从KITTI转向自采数据的人一上来就踩这个坑误以为自己的外参标定有问题折腾一圈才发现是畸变没去干净。相对的如果你拿到的相机内参矩阵是K而不是P_rect记得检查它是畸变前的内参还是矫正后的内参两种场景下不能直接混用。5. 从KITTI到自己的传感器平台KITTI跑通只是第一步真正有价值的是把整套思路迁移到自己的设备上。但这里有个必须清醒的认识KITTI提供的标定参数只适用于KITTI采集车那一套传感器你换了任何一台相机、换了任何一个安装位置都必须重新标定不能直接套用。5.1 为什么不能直接套用KITTI的标定参数雷达和相机的相对位姿是由机械安装决定的。采集车上雷达和相机之间的旋转、平移是出厂时固定好的你手上的设备哪怕型号一模一样安装角度差半度投影误差就会被放大到像素级别的偏差。半度旋转看起来很小但一个50米外的目标半度误差就能产生接近0.5米的横向偏移在图像上可能偏出几十个像素。所以自采数据的正确流程永远是固定好传感器安装位置进行联合标定保存自己的外参文件再做坐标转换。每次拆装传感器后都要重新标定。5.2 真实平台联合标定的完整流程联合标定目前最常用的开源工具是Autoware的calibration_camera_lidar很多人在Ubuntu 18.04上装过。它的核心思路是采集一组雷达点云和相机图像在图像中检测棋盘格角点在点云中提取棋盘格平面然后通过优化算法求解两个传感器之间的旋转和平移。完整流程大致如下准备一块足够大的棋盘格标定板建议格子边长5cm以上整块板至少1m x 1m。太小了雷达点云上根本找不到有效的平面点。采集数据时需要让标定板同时出现在相机画面和雷达视野中位置要覆盖近距离、远距离、左、右、俯仰角等不同姿态。用工具逐帧检测图像上的角点同时手动或半自动提取雷达点云中的棋盘格平面。运行优化算法得到外参R和t。用验证集中的图像和点云做投影检查边缘对齐情况。采集时要注意环境光照均匀避免强反光面影响激光雷达的测距质量。另外标定板姿态要尽量多样化只放在正前方一个角度优化出来的外参在某些方向上的误差会很大。5.3 标定质量验证标准标定质量不能只看一两帧对齐效果需要用多帧数据验证。我常用的验证方法有两个第一个是投影验证法。把雷达点云投影到图像上统计同一场景下点云边缘和图像边缘的平均像素距离。一般做得好的标定这个偏差应该在2到3个像素以内。第二个是距离一致性验证。选一个特征明显的大型平面比如建筑墙面提取该平面上的雷达点云投影到图像后看是否覆盖同一块区域。如果投影点在目标边缘出现系统性偏移说明外参的某几个自由度标定不准。如果你在自采数据上反复调参还是对不齐建议先检查时间同步。雷达和相机如果时间戳没有对齐车辆行驶过程中运动目标会出现明显的投影拖影这种偏差会被误认为是标定问题。用静止场景做标定验证可以排除时间同步的干扰。写在最后的经验我在第一次做KITTI坐标转换时整整折腾了一个晚上投影出来的点云要么偏移要么散乱。后来发现是矩阵组合顺序写反了把R_rect_00乘在了Tr_velo_to_cam后面。改过来之后整个世界瞬间对齐了。想给你一个建议不要直接抄网上的现成代码一定要把每个矩阵的维度和含义推一遍再动手写代码。KITTI的意义就在于此它的数据质量高、标定文件公开给了你一个可以反复验证的环境。只有在这个环境里把链路彻底打通了到了自己的设备上你才知道该调什么、不该调什么。如果你正准备做激光雷达和相机的融合任务先在KITTI上把这一整套投影流程跑通会替你省下大量排查坐标系的宝贵时间。
返回列表