☰
KITTI激光雷达到相机坐标转换实战:标定文件逐字段拆解与代码实现
2026/10/7 20:58:04 网站建设 项目流程

接触过多传感器融合的都知道,最磨人的往往不是算法本身,而是数据对齐。点云和图像明明拍的是同一个场景,想叠加在一起看效果,结果总是错位、重影、边缘对不上。尤其是刚入手激光雷达和相机联合标定时,连从哪个坐标系开始转、矩阵怎么乘都容易绕晕。这篇内容我打算直接用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.txt:IMU到激光雷达的变换。

其中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_cam:

Tr_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.215377e+02 0.000000e+00 6.095593e+02 4.485728e+01 0.000000e+00 7.215377e+02 1.728540e+02 2.163791e-01 0.000000e+00 0.000000e+00 1.000000e+00 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, dtype=np.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, :] # 去除相机后面的点和z=0的点,避免除零 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上装过。它的核心思路是:采集一组雷达点云和相机图像,在图像中检测棋盘格角点,在点云中提取棋盘格平面,然后通过优化算法求解两个传感器之间的旋转和平移。

完整流程大致如下:

  1. 准备一块足够大的棋盘格标定板,建议格子边长5cm以上,整块板至少1m x 1m。太小了雷达点云上根本找不到有效的平面点。
  2. 采集数据时需要让标定板同时出现在相机画面和雷达视野中,位置要覆盖近距离、远距离、左、右、俯仰角等不同姿态。
  3. 用工具逐帧检测图像上的角点,同时手动或半自动提取雷达点云中的棋盘格平面。
  4. 运行优化算法,得到外参R和t。
  5. 用验证集中的图像和点云做投影,检查边缘对齐情况。

采集时要注意环境光照均匀,避免强反光面影响激光雷达的测距质量。另外标定板姿态要尽量多样化,只放在正前方一个角度,优化出来的外参在某些方向上的误差会很大。

5.3 标定质量验证标准

标定质量不能只看一两帧对齐效果,需要用多帧数据验证。我常用的验证方法有两个:

第一个是投影验证法。把雷达点云投影到图像上,统计同一场景下点云边缘和图像边缘的平均像素距离。一般做得好的标定,这个偏差应该在2到3个像素以内。

第二个是距离一致性验证。选一个特征明显的大型平面,比如建筑墙面,提取该平面上的雷达点云,投影到图像后看是否覆盖同一块区域。如果投影点在目标边缘出现系统性偏移,说明外参的某几个自由度标定不准。

如果你在自采数据上反复调参还是对不齐,建议先检查时间同步。雷达和相机如果时间戳没有对齐,车辆行驶过程中运动目标会出现明显的投影拖影,这种偏差会被误认为是标定问题。用静止场景做标定验证,可以排除时间同步的干扰。

写在最后的经验

我在第一次做KITTI坐标转换时,整整折腾了一个晚上,投影出来的点云要么偏移要么散乱。后来发现是矩阵组合顺序写反了,把R_rect_00乘在了Tr_velo_to_cam后面。改过来之后,整个世界瞬间对齐了。想给你一个建议:不要直接抄网上的现成代码,一定要把每个矩阵的维度和含义推一遍,再动手写代码。KITTI的意义就在于此,它的数据质量高、标定文件公开,给了你一个可以反复验证的环境。只有在这个环境里把链路彻底打通了,到了自己的设备上你才知道该调什么、不该调什么。如果你正准备做激光雷达和相机的融合任务,先在KITTI上把这一整套投影流程跑通,会替你省下大量排查坐标系的宝贵时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询