做过多传感器融合的朋友应该都明白,激光雷达和相机之间的坐标转换,理论上就是几个矩阵相乘的事,但实际一跑,投影出来的点云总是对不上图像。要么左右偏,要么上下歪,排查半天最后发现是矩阵乘反了,或者漏了一个矫正矩阵。这套流程我在不同项目里踩过好几次坑,后来干脆把 KITTI 数据集的标定文件翻来覆去研究了一遍,才把整个链路彻底吃透。这篇就以 KITTI 为例,手把手带你走一遍激光雷达到相机图像的坐标转换全流程,从标定文件解析到代码实现,再到各种避坑经验,一次性讲清楚。适合正在做多传感器融合、3D 目标检测、点云投影可视化,或者被联合标定折磨得头大的开发者。
1. 为什么要折腾激光雷达和相机的坐标转换
1.1 传感器坐标系差异是硬伤
激光雷达输出的是三维点云,每个点包含 (x, y, z) 坐标,坐标系定义通常是“右前上”或者“前左上”,以雷达自身为原点。相机输出的是二维图像,每个像素有 (u, v) 坐标,这是通过内参矩阵投影得到的,坐标系以相机光心为原点。两者看到的是同一个物理世界,但表达方式完全不一样。要让激光雷达点云的每个点落到图像上正确的位置,就必须找到两个坐标系之间的刚体变换关系,也就是外参。
这个外参由旋转矩阵 R 和平移向量 T 组成。R 负责把激光雷达坐标系的朝向转到和相机坐标系一致,T 负责把坐标原点从雷达光心平移到相机光心。值得注意的是,很多刚入门的人以为只要把点云乘一个 3x4 的矩阵就能得到像素坐标,实际上中间还隔着一个相机坐标系,而且如果是双目相机,还多了一道立体校正。
1.2 KITTI 数据集到底香在哪
自己做联合标定不是不行,但要同时满足两个条件才靠谱:一是你手里有一套时间同步好的传感器硬件,二是你有一个足够精准的标定场地和标定物。这两样东西对学生党或者刚接触这个方向的开发者来说,门槛都不低。KITTI 数据集直接把这些前置条件打包好了,它提供同步采集的 Velodyne HDL-64E 激光雷达数据、双目相机图像,以及官方标定好的外参和内参文件。
更重要的是,KITTI 的标定结果是被学术界广泛验证过的,你可以拿它当“标准答案”来检验自己的代码是否正确。如果你的投影结果和图像对不上,那大概率不是传感器的问题,而是代码里的矩阵运算有误。这种“有真值可对照”的调试方式,比自己搭一套传感器却不知道哪里出错要高效太多。所以不管你是想入门多传感器融合,还是已经在做相关项目但被坐标转换卡住,用 KITTI 数据先跑通一遍,绝对值得。
2. 基础功:KITTI 数据集目录与标定文件解析
2.1 数据集目录长什么样
KITTI Object Detection 数据集是最常用的子集,它把数据按“训练/测试”组织,每个编号对应同一时刻采集的一帧数据。目录结构大致如下:
data_object_calib/ └── training/ └── calib/ ├── 000000.txt ├── 000001.txt └── ... data_object_image_2/ └── training/ └── image_2/ ├── 000000.png ├── 000001.png └── ... data_object_velodyne/ └── training/ └── velodyne/ ├── 000000.bin ├── 000001.bin └── ...这里 image_2 对应的是左侧彩色相机,velodyne 存放的是激光雷达点云,每个 .bin 文件是原始二进制点云,calib 存放的就是我们最关心的标定文件。下载的时候可以只取这三个子集,几十 GB 的空间对于做实验来说是可以接受的。官方服务器在部分地区访问比较慢,可以试试国内镜像站或者学术网盘资源,文件名和目录结构保持一致就能直接用。
2.2 calib_velo_to_cam.txt 里到底存了什么
每个编号对应的标定文件有两份,第一份是 calib_velo_to_cam.txt,它描述的是激光雷达到相机的外参。文件内容类似这样:
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-01R 是一个 3x3 的旋转矩阵,T 是 3x1 的平移向量。注意这里读文件的时候,R 的数值是按行排列的,所以用 numpy 的reshape(3, 3)时默认的 C order 刚好能还原正确的矩阵,不用做额外的转置。如果用了reshape(3, 3, order='F')之类的方式,矩阵就转置了,投影结果会完全错乱。
从物理意义上看,把激光雷达坐标系下的点乘上这个 R,再加上 T,就得到了相机坐标系下的三维坐标。这个变换只涉及刚体旋转和平移,不改变点的深度值和尺度信息。
2.3 calib_cam_to_cam.txt 里的内参与矫正矩阵
第二份标定文件是 calib_cam_to_cam.txt,里面包含的信息更丰富。有各相机的内参矩阵 K、畸变系数 D、立体校正旋转矩阵 R_rect、以及投影矩阵 P_rect。对坐标转换来说,最关键的是两个字段:
R_rect_00: 3x3 的校正旋转矩阵,用于把相机 0 号坐标系校正到公共平面上。如果是单目相机直接用相机 2 的图像,也需要先经过这个校正矩阵,因为 KITTI 的图像是经过立体校正后的图像。P_rect_02: 3x4 的投影矩阵,它把校正后的相机坐标直接投影到图像像素坐标。这个矩阵已经融合了相机内参 K 和相机 2 相对于参考相机的平移。
另外还有S_rect_02表示校正后的图像尺寸,通常是 1242x375。这个尺寸非常关键,投影之后的像素坐标必须和它匹配,否则画出来的点会错位。
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这里 P_rect_02 的第三行第四列是 2.745884e-03,这是一个很小的平移项,来自双目校正后的基线距离补偿。如果你自己标定单目相机,P 矩阵通常最后一列是 0,但 KITTI 的双目数据不能直接这么处理。
3. 坐标转换全流程:从点云到图像的完整计算
3.1 转换链路总览
整个坐标转换过程可以拆成四步:
- 激光雷达坐标系下的三维点,用齐次坐标表示,维度是 4x1。
- 乘上激光雷达到相机的外参矩阵,得到相机坐标系下的三维点。
- 乘上立体校正旋转矩阵 R_rect_00,得到校正后的相机坐标。
- 乘上投影矩阵 P_rect_02,得到齐次像素坐标,归一化后就是最终的 (u, v)。
用公式表达就是:
[ u ] [P_rect_02 (3x4)] [R_rect_00 (3x3)] [R|T (3x4)] [X_velo] [ v ] = · · [Y_velo] [ w ] [Z_velo] [ 1 ]这里有个非常容易踩的坑:很多人拿到 P_rect_02 之后,以为直接乘外参就能得到像素坐标,完全忽略了 R_rect_00。但实际上 KITTI 的图像已经做了立体校正,如果跳过这一项,最终的投影结果会整体偏移,而且越靠近图像边缘偏移越明显。
如果想把矩阵预先合并成一个 3x4 的总投影矩阵,可以在初始化阶段算好:
# 将外参扩展为 4x4 齐次矩阵 RT = np.hstack([R_velo_to_cam, T_velo_to_cam]) # 3x4 RT_4 = np.vstack([RT, [0, 0, 0, 1]]) # 4x4 # 将矫正矩阵扩展为 4x4 R_rect_4 = np.eye(4) R_rect_4[:3, :3] = R_rect_00 # 4x4 # 组合成一个 3x4 的投影矩阵 M = P_rect_02 @ R_rect_4 @ RT_4 # 3x4这样之后处理每一帧点云时,只需要做一次矩阵乘法,不需要反复拼接齐次变换矩阵,效率高很多。
3.2 完整 Python 实现与可视化验证
下面这段代码是我在实际项目中整理出来的,可以直接运行。只需要把路径改成你自己的数据目录,就能得到一张带深度着色的点云投影图。
import numpy as np import cv2 import os def read_calib_file(filepath): """解析 KITTI 标定文件,返回一个字典,key 是字段名,value 是 numpy 数组""" data = {} with open(filepath, 'r') as f: for line in f.readlines(): if ':' in line: key, value = line.split(':', 1) try: data[key] = np.array([float(x) for x in value.split()]) except ValueError: pass return data def load_kitti_calib(calib_dir): """加载激光雷达到相机的外参和内参""" velo2cam = read_calib_file(os.path.join(calib_dir, 'calib_velo_to_cam.txt')) cam2cam = read_calib_file(os.path.join(calib_dir, 'calib_cam_to_cam.txt')) R_velo_to_cam = velo2cam['R'].reshape(3, 3) T_velo_to_cam = velo2cam['T'].reshape(3, 1) R_rect_00 = cam2cam['R_rect_00'].reshape(3, 3) P_rect_02 = cam2cam['P_rect_02'].reshape(3, 4) S_rect_02 = cam2cam['S_rect_02'].astype(np.int32) return R_velo_to_cam, T_velo_to_cam, R_rect_00, P_rect_02, S_rect_02 def project_velo_to_image(velo_points, R_velo_to_cam, T_velo_to_cam, R_rect_00, P_rect_02): """ 把激光雷达点云投影到相机图像平面 velo_points: (N, 4) 的数组,每行是 (x, y, z, reflectivity) 返回: uv: (N, 2) 像素坐标 cam_xyz_rect: (N, 3) 校正后的相机坐标 valid: (N,) 布尔数组,True 表示点位于相机前方且投影有效 """ xyz = velo_points[:, :3] n = xyz.shape[0] # 构建齐次坐标 (4, N) xyz1 = np.hstack([xyz, np.ones((n, 1))]).T # 激光雷达到相机坐标系 RT = np.hstack([R_velo_to_cam, T_velo_to_cam]) # 3x4 cam_xyz = RT @ xyz1 # 3xN # 相机后方的点直接剔除 valid = cam_xyz[2, :] > 0.1 # 立体校正 cam_xyz_rect = R_rect_00 @ cam_xyz # 3xN # 齐次化 cam_xyz_rect1 = np.vstack([cam_xyz_rect, np.ones((1, n))]) # 4xN # 投影到像素坐标 uv1 = P_rect_02 @ cam_xyz_rect1 # 3xN uv = uv1[:2, :] / uv1[2, :] # 2xN return uv.T, cam_xyz_rect.T, valid def main(): calib_dir = 'data_object_calib/training/calib' velo_dir = 'data_object_velodyne/training/velodyne' image_dir = 'data_object_image_2/training/image_2' idx = '000000' # 加载标定参数 R, T, R_rect, P, S = load_kitti_calib(calib_dir) # 加载点云。注意 bin 文件是 float32 原始数据,每个点 4 个分量 velo = np.fromfile(os.path.join(velo_dir, idx + '.bin'), dtype=np.float32).reshape(-1, 4) # 加载图像 img = cv2.imread(os.path.join(image_dir, idx + '.png')) # 投影 uv, cam_xyz_rect, valid = project_velo_to_image(velo, R, T, R_rect, P) uv_valid = uv[valid] cam_valid = cam_xyz_rect[valid] # 过滤超出图像范围的点 w, h = S[0], S[1] in_image = (uv_valid[:, 0] >= 0) & (uv_valid[:, 0] < w) & \ (uv_valid[:, 1] >= 0) & (uv_valid[:, 1] < h) uv_final = uv_valid[in_image].astype(np.int32) depth = cam_valid[in_image, 2] # 按深度着色,近红远蓝 depth_norm = np.clip(depth / np.percentile(depth, 95), 0, 1) colors = cv2.applyColorMap((depth_norm * 255).astype(np.uint8), cv2.COLORMAP_JET).reshape(-1, 3) # 高效画点:利用 numpy 索引直接给像素赋值 img_vis = img.copy() u_clip = np.clip(uv_final[:, 0], 0, w - 1) v_clip = np.clip(uv_final[:, 1], 0, h - 1) img_vis[v_clip, u_clip] = colors cv2.imwrite('projection_result.png', img_vis) print('投影完成,共投影 {} 个点'.format(len(uv_final))) if __name__ == '__main__': main()这段代码执行完之后,打开 projection_result.png,你应该能看到点云按照深度着色叠加在图像上,近处的物体呈红色,远处的呈蓝色。如果投影结果和图像内容基本吻合,说明整个链路是通的。
3.3 参数计算过程拆解
很多人在矩阵运算时容易搞混,这里我把每一步的维度变化写清楚。假设一帧点云有 N 个点:
- 原始点云是 (N, 4),取前三列得到 (N, 3),再加一列 1 变成 (N, 4),转置后是 (4, N)。
- 外参矩阵 RT 是 (3, 4),乘上 (4, N) 得到 (3, N),这是相机坐标系下的三维坐标。
- R_rect_00 是 (3, 3),乘上 (3, N) 得到 (3, N),这是校正后的相机坐标。
- 再补一行 1 变成 (4, N),乘上 P_rect_02 这个 (3, 4) 矩阵,得到 (3, N)。
- 最后用第三行逐列归一化,得到 (2, N) 的像素坐标。
每一步的维度都是有意义的。许多人直接用np.dot(P_rect_02, np.dot(R_rect_00, np.dot(RT, xyz1)))却得到错误结果,往往是因为 xyz1 构造成了 (N, 4) 而不是 (4, N),或者忘了补最后一维的 1。写代码之前先花两分钟把矩阵维度在草稿纸上推一遍,比盲目试错高效得多。
4. 避坑指南:这些坑我替你们踩过了
4.1 点云文件读取的格式坑
KITTI 的 .bin 点云文件不是常见的 PCD 或 LAS 格式,它是裸的二进制数据,每个点由 4 个 float32 组成,分别是 x、y、z 和反射强度。读取方式只能用np.fromfile,指定 dtype 为 float32,然后 reshape 成 (-1, 4)。
我见过有人用np.loadtxt去读 .bin 文件,结果直接报错,还有人拿 open3d 的read_point_cloud去读,同样不行。必须记住这一点:KITTI 点云是 float32 的原始内存数据,不是文本格式。如果点云数据的反射强度那一列暂时用不到,也建议保留这个维度,因为后续做目标检测或者点云配准时,反射强度往往是有用的特征。
4.2 矩阵维度和 reshape 顺序的坑
解析标定文件时,R 和 P 矩阵的 reshape 顺序非常关键。标定文件中的数值是按行排列的,numpy 默认的 C order 也是按行填充,所以reshape(3, 3)是正确用法。但如果有人习惯用 MATLAB,可能会下意识地用列优先的思维去处理,这时候就容易出问题。
另外,KITTI 的 P_rect_02 是一个 3x4 矩阵,不是 3x3 的 K 矩阵。如果你只关心内参,可能会把 P 矩阵的前三列当作 K 来用,然后单独处理最后一列的平移。这在数学上是等价的,但容易引入不必要的 bug。最省心的做法是直接使用上节代码中的组合矩阵 M,把外参、校正矩阵、投影矩阵全部合并,一次矩阵乘法完成坐标转换。
4.3 深度过滤和像素边界检查必须做
相机坐标系下的 z 值表示点到相机光心的深度。如果 z 小于等于 0,说明该点在相机后方,直接用 P 矩阵投影会出现除以零或者负的像素坐标。所以投影前一定要用cam_xyz[2, :] > 0过滤掉这些无效点。
像素边界检查一样不能省。即使 z 大于 0,投影出来的 uv 坐标也可能落在图像外面,比如有一部分的点在图像左侧,u 坐标是负的。如果不加边界检查,直接拿这些坐标去数组里索引,轻则报错,重则产生不可预知的乱码。上面的代码里用uv_final做了范围过滤,同时在赋值时用了np.clip防止索引越界,这是一种双保险的写法。
4.4 图像 resize 后的内参缩放问题
KITTI 原始图像大小是 1242x375,但如果你的可视化流程里把图像缩放了,内参矩阵必须跟着缩放。具体规则是:fx 和 fy 按缩放比例缩放,cx 和 cy 也按缩放比例缩放。比如图像宽度从 1242 缩放到 621,对应的 fx 和 cx 也要除以 2。
这个坑在自采数据上尤其常见。D435i 或者其它 RGB-D 相机的标定结果通常是针对原始分辨率的,一旦你用cv2.resize改变了图像尺寸,却忘了按比例调整内参,投影结果就会整体错位。更隐蔽的情况是,有些相机 SDK 会自动裁剪图像或应用缩放,输出的图像尺寸和标定时的尺寸不一致,这时候需要手动确认并同步调整内参。
5. 实测中的问题排查与扩展建议
5.1 投影结果不对劲的排查顺序
如果跑完投影,发现点云和图像对不上,我建议按照下面的顺序排查,能省下很多时间:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 点云整体平移了几个像素 | 漏乘 R_rect_00,或者外参矩阵顺序错了 | 检查是否在投影链路中加入了校正矩阵,确认矩阵乘法的顺序 |
| 左右的点对不上,点云被镜像 | 旋转矩阵求解或 reshape 出错 | 检查 R 矩阵的 reshape 顺序,和标定文件原始数值对比 |
| 远处的点偏差大,近处正常 | 图像 resize 后内参没有同步缩放 | 检查 S_rect_02 与当前图像尺寸是否一致 |
| 点云稀疏错乱,像是被撕裂 | 点云读取时 dtype 或字节序错误 | 确认用np.fromfile(..., dtype=np.float32),检查文件大小是否符合点数预期 |
| 图像完全黑或者只有少量点 | 深度过滤阈值太大,或点云文件路径错 | 打印 velo 的前几行和点云数量,确认数据加载正常 |
| 投影的像素坐标出现负数或超界 | 未做边界检查 | 加入 in_image 过滤,并用 np.clip 保护 |
把这张表打印出来贴在工位上,比盯着代码发愁管用得多。我自己调试时,最快的定位方法是在代码里临时加上几行 print,把中间变量的 shape 和数值范围打出来,尤其是 uv1[2, :] 的最小值,如果接近 0,说明有大量点在相机后方。
5.2 从 KITTI 到自采数据的迁移建议
KITTI 的流程跑通之后,很多人会问:那我自己的雷达和相机怎么标定、怎么迁移这套方案?这里给一个实操建议:先不要急着去研究复杂的在线标定算法,而是用 KITTI 官方提供的标定文件理解坐标转换的本质,然后用开源的离线标定工具(比如 Autoware 的 Calibration Toolkit,或者一些基于棋盘格和点云提取的轻量方案)去标定自己的传感器。标定完成后,把你得到的外参和内参替换到上面的代码中,用同样的投影逻辑去验证,如果投影对齐了,说明标定成功。
自采数据和 KITTI 最大的差异在于时间同步。KITTI 官方已经把点云和图像按时间戳同步好了,但自己的设备往往需要做硬件时间同步或者软件时间戳匹配。我做过一个比较笨但有效的方法:在采集环境里放一个标定板或明显的标志物,然后在点云和图像里同时截取几帧做手动验证,确认两者在时间上是对齐的。之后再跑自动化的批量投影,心里就有底了。
5.3 关于工具链的几点补充
除了 KITTI 这套完全手动的方式,在实际工程里还有很多联合标定的工具链可以选择。比如 Ubuntu 18.04 下安装 AutoWare 的 camera-lidar calibration 工具,可以方便地通过棋盘格完成外参标定;对于 ROS 2 环境,也有对应的 Cartographer 和雷达建图方案可以配合使用。不过我的建议是,先把手写的矩阵变换跑通,再上工具链。因为工具链把很多细节封装在底层,一旦出问题你反而难以判断是哪个环节错了。
再说一个小技巧:如果你用 D435i 这类 RGB-D 相机,它本身提供了相机 IMU 联合标定的接口,但雷达和相机之间的外参仍然需要你自己解决。一个可行的思路是把雷达点云投影到 D435i 的深度图上,然后用深度图对齐彩色图的方式做校验,原理和 KITTI 的投影一致,只是多一步深度图到彩色图的对齐而已。
我个人在实际操作中的体会是,坐标转换这件事,公式本身并不复杂,复杂的是数据的各种隐含条件。KITTI 数据集的魅力就在于它把这些隐含条件用文件的形式明确地告诉你,让你有据可查。把标定文件的每一行都读明白,把矩阵运算的每一步都验证过,再遇到任何传感器融合问题,都会有底气得多。最后再分享一个扩展方向:有了正确的投影关系之后,你可以尝试把点云的深度值覆盖到图像上生成伪深度图,或者反过来,把图像的颜色信息赋给点云生成彩色点云。这两件事都是很多感知算法的基础,值得自己动手玩一玩。