☰
KITTI标定文件全解析:点云投影到相机的坐标系与矩阵避坑
2026/10/1 5:21:48 网站建设 项目流程

第一次把 KITTI 的点云投到 image_2 上,十有八九会得到一张“鬼影图”——车框整体往右下偏、远处的点在图上直接飞出屏幕。我当年就是因为漏乘了一个 R_rect_00,连着两个晚上怀疑自己的旋转矩阵写反了,最后发现是标定文件里被自己跳过的一行。KITTI 数据集的标定文件(calib)看着只有几行数字,实际上它同时描述了三套坐标系、两组外参、四个相机和一次校正关系,任何一个方向约定搞错,结果都错得很隐蔽,而且错出来的图像还“看着差不多对”。这篇就把 kitti 数据集里那几份标定文件掰开揉碎讲清楚:每份文件管什么、每个字段是什么、怎么拼成能用的 4x4 矩阵、投影和 3D 框生成里哪些是文档不写但一定会踩的坑。只要你手上有 KITTI object、odometry 或 tracking 分支里的任意一份 calib,下面内容都能直接对上。

1. 先搞清楚 KITTI 的标定文件在描述哪几套坐标系

1.1 三份文件、四套坐标系的分工

KITTI 目标检测分支的calib/目录下固定有三份文本文件:calib_imu_to_velo.txt、calib_velo_to_cam.txt、calib_cam_to_cam.txt。名字已经把链路说清楚了:IMU 到雷达,雷达再到相机,相机之间的关系最后单独一份。这三份文件不是随便拆的,而是按照数据采集时的物理安装关系切的——IMU 和雷达固定在车顶,两者之间的相对位姿只需要一次标定;雷达和相机之间隔了车身,标定的是安装关系;四个相机虽然是同一个模组,但每个镜头的成像参数和光心位置都不一样,所以单独给了相机之间的外参和内参。

真正麻烦的地方在于,这三份文件描述的是三套完全不同的坐标系,而且它们的轴向定义互相“别扭”。相机坐标系是 x 向右、y 向下、z 向前(右手系,这是为成像模型服务的);雷达坐标系是 x 向前、y 向左、z 向上;IMU 坐标系和雷达差不多,也是前左上。像素坐标系又是 x 向右、y 向下。你脑子里得同时装着这四套东西,才能理解为什么标定文件里那个 3x3 旋转矩阵看起来那么“奇怪”——它实际上是两个坐标系的朝向差被写成了数字。

先把这四套坐标系摆在一张表里,后面所有推导都靠它:

坐标系x 轴y 轴z 轴对应数据
相机(cam)右下前3D 标注框、K/R/P 矩阵
雷达(velodyne)前左上velodyne_points 里的 .bin
IMU前左上oxts 里的姿态与加速度
图像(像素)列 u 向右行 v 向下—image_2 / image_3 的 png

我在实际项目里养成的习惯是:任何时候拿到一个 3D 点,先问自己“这个点是哪套坐标系下的”,再决定乘哪个矩阵。90% 的投影错误都是坐标系混淆,不是矩阵算错。

1.2 坐标系朝向速查:为什么“上下左右”总是反的

新手最容易懵的一点,是雷达坐标系和相机坐标系的“上下”是反的。雷达的 z 轴朝上,说明车顶在上面、地面在下面,点云里地面的 z 值接近 -1.73(车顶安装高度),这个负号很直观;而相机坐标系的 y 轴朝下,所以地面在相机系里是正的 y,越往下 y 越大。如果你把点云原封不动往相机系里塞,会发现整片地面跑到了图的上方,这就是最典型的朝向没换。

还有一个更隐蔽的点:四个相机虽然都是同一朝向,但它们的光心并不重合。很多人下意识认为“反正标定文件里给了 P_rect_02,直接用它就行”,这没错,但一旦你要把 3D 框从 cam2 搬到 cam0,就得显式用R_02、T_02这一对外参。而这一对参数量在官方 README 里的描述是“from the reference camera”,句子写得含糊,网上的实现两种方向都有。我的处理方式是:投影统一走 P_rect,绝不手工拼相机间的平移;只有做多相机几何校验时才用 R/T,而且一定用一个已知点做方向验证。下面第 3 节会把这个验证方法写清楚。

2. calib_velo_to_cam.txt:把 12 个数变成能用的 4x4 矩阵

2.1 R 是行优先的 9 个数,踩过列优先的坑

打开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-01 delta_f: 0.0 0.0 delta_c: 0.0 0.0

R后面跟的是 9 个数,T后面跟的是 3 个数,全部写在一行里。这里第一个坑就是读取顺序:这 9 个数是行优先展开的,先把它们 reshape 成 3x3 才对。我见过不止一个同学用reshape(3,3)之后又转置了一次,结果整片点云绕轴转了 180 度,投影出来恰好是上下颠倒,看起来“好像对了但就是不对”。

正确的拼装方式:

import numpy as np def transform_from_rt(R_flat, T_flat): R = np.asarray(R_flat, dtype=np.float64).reshape(3, 3) T = np.asarray(T_flat, dtype=np.float64).reshape(3, 1) Tr = np.eye(4) Tr[:3, :3] = R Tr[:3, 3] = T.ravel() return Tr

拼出来的 4x4 矩阵含义是:把 velodyne 坐标系下的齐次点左乘它,就得到相机坐标系下的点,也就是X_cam = R · X_velo + T。

2.2 从物理安装位置反推 R 的大致形状

这个 R 里的数字看着乱,但你可以从物理安装关系直接推出它的大致形状,这也是我判断“有没有读错矩阵”的最快方法。雷达系是前左上,相机系是右下前,逐轴对一下:

  • 相机的 x 轴(右)应该对应雷达的 -y(左的反方向);
  • 相机的 y 轴(下)应该对应雷达的 -z(上的反方向);
  • 相机的 z 轴(前)应该对应雷达的 +x。

把上面那个 R 拆成三行看(每一行就是相机某一轴在雷达系下的投影):

  • 第一行[0.0075, -0.99997, -0.0006],主项是 -0.99997,落在第二列,正好对应“相机 x = -雷达 y”;
  • 第二行[0.0148, 0.0007, -0.99989],主项在第三列,对应“相机 y = -雷达 z”;
  • 第三行[0.99986, 0.0075, 0.0148],主项在第一列,对应“相机 z = 雷达 x”。

那批 0.007、0.014 级别的非对角项,就是实际安装时摄像头和雷达之间那两三度的装配偏差。理解了这一点,你就知道这些数字不是标定软件随机吐出来的,它精确地编码了“谁朝哪边”。同样地,T = [-0.004, -0.076, -0.272]告诉你:雷达原点在相机系下位于相机后方约 27 cm、上方约 7.6 cm(y 向下,负值表示往上)的位置,横向几乎重合。这个量级和 KITTI 采集车上的实际布局是对得上的。

2.3 T、delta_f、delta_c 与 corner_dist

T的含义上面说了,是平移。剩下的delta_f和delta_c一般就是0.0 0.0,它们是相机焦距和主点的残差修正项,标定收敛得好就是零,遇到非零的小值可以直接忽略——我做过好几轮验证,这两个量对像素误差的影响在 0.1 像素以内,远小于 KITTI 3D 标注本身的噪声。

calib_cam_to_cam.txt里还有一个corner_dist: 9.950000e-02,很多资料直接跳过它。它其实是标定板角点间距相关的量,只影响标定过程本身,做下游任务时完全不需要读。我在解析器里会把它一并读进来放着,但从不参与任何计算,纯粹是方便排查“这份文件是不是被裁过”。

3. calib_cam_to_cam.txt 里每个字段到底管什么

3.1 S 与 K:校正前的尺寸和内参

这份文件是四个相机的参数合集,字段用 00/01/02/03 后缀区分。以 00 号相机为例,典型内容如下:

S_00: 1.392000e+03 5.120000e+02 K_00: 9.842439e+02 0.000000e+00 6.900000e+02 0.000000e+00 9.808141e+02 2.331966e+02 0.000000e+00 0.000000e+00 1.000000e+00 D_00: -3.728755e-01 2.037299e-01 2.219027e-03 1.383707e-03 -7.233722e-02 R_00: 1 0 0 0 1 0 0 0 1 T_00: 0 0 0 S_rect_00: 1.242000e+03 3.750000e+02 R_rect_00: 9.999239e-01 9.837760e-03 -7.445048e-03 ... P_rect_00: 7.215377e+02 0.000000e+00 6.095593e+02 0.000000e+00 0.000000e+00 7.215377e+02 1.728540e+02 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 0.000000e+00

S_xx是校正前的图像尺寸(宽 1392、高 512),K_xx是校正前的 3x3 内参,展开顺序同样是行优先。K_00里 fx ≈ 984、fy ≈ 981、cx = 690、cy ≈ 233,你可以拿 1392/2 = 696 和 512/2 = 256 对比一下,主点基本落在图像中心附近,符合直觉。

这里有个容易忽略的细节:K_xx描述的是原始未校正图像的相机模型。校正前的图像是带畸变的,而且四个相机的内参各不相同(fx 从 984 到上千不等)。而P_rect_xx里的 fx 统一是 721.5377,四个相机一样——这说明校正把四个相机的内参强行“拉平”了。搞清楚这一点,后面很多疑惑就自动消失了。

3.2 D:为什么目标检测任务基本用不上它

D_xx是畸变系数,五个数依次是 k1、k2、p1、p2、k3。D_00里 k1 ≈ -0.37、k2 ≈ 0.20,是典型的桶形+轻微枕形组合,补偿量以像素计能到几十。

但这里有个关键事实:KITTI 目标检测分支发布的 image_2、image_3 已经是校正后的图像,尺寸 1242x375,正好等于S_rect_xx。也就是说畸变已经被标定流程消掉了。如果你在读图之后又自己套一遍cv2.undistort,等于把畸变反向加回去,越处理越歪。我在早期项目里就犯过这个错,当时图像边缘的车辆投影总是差十几个像素,查了很久才发现是把已经校正过的图又校正了一次。

判断依据很简单:看图像尺寸。如果是 1242x375,就是校正后的,不要动畸变;如果拿到的是 1392x512 的原始图,那才需要走一遍去畸变,并且去畸变之后再套 P_rect 才有意义。

3.3 R_rect 与 P_rect:最容易被漏掉的一步

R_rect_00是 3x3 的校正旋转,P_rect_xx是 3x4 的校正后投影矩阵。把它们连起来用,才是正确的投影链路:

P = P_rect_02 @ R_rect_00(齐次化) @ Tr_velo_to_cam

注意这里的顺序:先把雷达点转到 cam0 的原始相机系(用Tr_velo_to_cam),再用R_rect_00把它转到“校正后的 cam0 坐标系”,最后用P_rect_02投到彩色左目图像上。为什么用R_rect_00而不是R_rect_02?因为Tr_velo_to_cam的目标是 cam0,链路是从 cam0 出发的,校正也要用 cam0 的校正矩阵。而P_rect_02本身已经包含了从 cam0 到 cam2 的平移,所以不需要你再手工补。

R_rect_00的非对角项在 0.001 到 0.01 量级,看着很小,但这个“小”在远处会放大:一个 50 米外的点,0.005 弧度的偏差大约对应 0.25 米的横向位移,投到图上就是好几个像素。这就是我开头说的“少乘一个 R_rect 会整体偏移”的根源。它不会让结果面目全非,只会让你怎么调都觉得差一口气,这也是它最容易骗过眼睛的原因。

3.4 从 P_rect 反推基线,顺便验证你读对没有

P_rect_xx的前两行内参一样,区别集中在第 4 列。KITTI 训练集的四个矩阵第 4 列分别是:P_rect_00为 0、P_rect_01为 -387.5744、P_rect_02为 +44.85728、P_rect_03为 -339.5242。这些数字不是随手填的,它等于-f · B,其中 B 是相机 xx 相对于 cam0 的横向偏移。

把 fx = 721.5377 代进去算一遍:

相机P[0][3]反推 B = -P/fx含义
cam1-387.5744+0.537 mcam1 在 cam0 右侧约 54 cm
cam2+44.85728-0.0622 mcam2 在 cam0 左侧约 6.2 cm
cam3-339.5242+0.4706 mcam3 在 cam0 右侧约 47 cm

cam1 和 cam0 之间 0.537 米就是大家常说的 KITTI 立体相机 0.54 m 基线;而 cam2 相对 cam0 只偏了 6.2 cm,说明灰度左目和彩色左目几乎是背靠背安装的。这两个数字我建议你亲手算一遍:它能同时验证三件事——fx 读对了、P 矩阵的行优先没搞错、基线的符号定义和你的直觉一致。如果算出来是个几米或者零点零零几米,那一定是某个字段读串行了。

4. 一份可以直接跑的投影代码与三处自检

4.1 解析 calib 文件

KITTI 的 calib 文件格式非常规整,key: value value value ...,写个二十行的解析器就够:

import numpy as np def read_calib_file(path): data = {} with open(path, 'r') as f: for line in f: line = line.strip() if not line or ':' not in line: continue key, value = line.split(':', 1) data[key.strip()] = np.array([float(x) for x in value.split()]) return data calib = read_calib_file('calib/calib_cam_to_cam.txt') velo_calib = read_calib_file('calib/calib_velo_to_cam.txt')

有个细节值得注意:calib_time那一行也会被解析成一个空数组,因为它冒号后面没内容。用if not line过滤不掉它,得靠后面取值时的 key 判断,或者在 split 之后判空。这不是大问题,但如果你直接遍历字段去做数值运算,会在这里崩一次。

4.2 从 velodyne 点到 image_2 像素的完整链路

把前面的东西串起来。假设你已经读好了calib_cam_to_cam.txt和calib_velo_to_cam.txt:

def velo_to_image(points_velo, calib, velo_calib, cam_id=2): # 1. 雷达 -> cam0 原始相机系 R_v2c = velo_calib['R'].reshape(3, 3) T_v2c = velo_calib['T'].reshape(3, 1) Tr_v2c = np.eye(4) Tr_v2c[:3, :3] = R_v2c Tr_v2c[:3, 3] = T_v2c.ravel() # 2. cam0 原始系 -> cam0 校正系 R_rect = np.eye(4) R_rect[:3, :3] = calib['R_rect_00'].reshape(3, 3) # 3. 投影矩阵 P = calib[f'P_rect_0{cam_id}'].reshape(3, 4) # 4. 串联 pts = points_velo[:, :3] pts_h = np.hstack([pts, np.ones((len(pts), 1))]) pts_rect = (R_rect @ Tr_v2c @ pts_h.T).T img = (P @ pts_rect.T).T depth = img[:, 2] valid = depth > 0.5 # 过滤相机后方和过近的点 uv = img[valid, :2] / depth[valid, None] return uv, depth[valid]

点云文件本身的读法也要对:.bin是纯 float32 二进制,每 4 个数一组,顺序是x y z intensity,intensity 在 0 到 1 之间。读的时候用np.fromfile(path, dtype=np.float32).reshape(-1, 4),不要试图按文本读,会慢到崩溃。

4.3 投不准时的三个自检点

我排查投影问题有一套固定顺序,按这个走基本十分钟内能定位:

第一,检查图像是不是已经校正过。看尺寸就行:1242x375 是校正后的,不要再做去畸变;如果是 1392x512,说明你拿的是原始图,必须先去畸变。

第二,检查公式里有没有R_rect_00。有个特别快的验证办法:随便挑一个 20 米外、在图像里位置明确的点,把它投到图上;然后把R_rect_00换成单位矩阵再投一次。两次结果的像素差如果在 3 个像素以上,说明校正这一步是必须的,你之前要是漏了,就是这里出的问题。

第三,检查齐次坐标归一化。投影之后必须用第三分量做除法,uv = img[:, :2] / img[:, 2:]。我见过有人直接用P @ X的前两维当像素坐标,结果得到的数字在万级别,还以为是自己矩阵乘错了。另外千万别忘了过滤z <= 0的点,相机后方的点在除法之后会“翻”到图像前面来,形成一堆莫名其妙的斑点,这个 bug 的表现是在图像边角出现成片的异常投影。

5. 3D 标注里的 location、dimensions、rotation_y 为什么总和直觉相反

5.1 location 是底面中心,不是几何中心

KITTI 的 label 每行 15 个字段:type truncated occluded alpha xmin ymin xmax ymax h w l x y z ry。其中h w l是高、宽、长,x y z是物体的 3D 位置,ry是绕 y 轴的旋转角。重点来了:x y z指的是 3D 框底面中心,不是几何中心。

这一点如果不知道,画出来的框会整体往上飘半个车身高度。因为相机系的 y 轴朝下,几何中心应该写成y - h / 2。我在第一次画 3D 框的时候没注意这个约定,框顶永远比车顶高出一截,调了半天以为是尺寸顺序搞错了,其实是坐标系原点定义的问题。

h w l和相机系轴向的对应关系也要记住:h沿相机 y 轴(高度),w沿相机 z 轴(宽度,即车辆的左右),l沿相机 x 轴(长度,即车辆的前后)。这个顺序在生成角点的时候特别关键,写反了会得到一个“压扁”或者“拉长”的框,而且因为框整体位置对,肉眼不容易发现,直到你做 IoU 计算或者可视化对比时才发现数字全都是错的。

5.2 rotation_y 的矩阵形式和实际朝向

rotation_y是绕相机 y 轴(向下)的旋转,对应的旋转矩阵是:

def roty_matrix(ry): c, s = np.cos(ry), np.sin(ry) return np.array([[ c, 0, s], [ 0, 1, 0], [-s, 0, c]])

用这个矩阵,当ry = 0时,物体的局部 x 轴和相机 x 轴(向右)重合,也就是说物体“横着躺”。而现实中正前方行驶的车,其车头方向和相机 z 轴(向前)一致,所以它的ry应该接近 ±π/2。这一点可以用来验证标注是否读对:如果你打开一份 KITTI 标注,发现正前方的车ry都是 0 附近,那一定是某个环节转置了。

还有一个容易混的字段是alpha。它是“观测角”,等于ry - atan2(x, z),其中atan2(x, z)是物体中心相对相机的方位角。很多人直接把alpha当ry用,在正前方物体的位置上两者差不多,但一旦物体跑到图像左侧或右侧,误差就会迅速放大到几十度。做朝向预测的时候,用错这个字段的模型指标会看起来“还行但不惊艳”,原因就是标签里两个角度混用了。

5.3 八个角点的生成顺序

把 3D 框画出来需要八个角点,顺序必须和连线方式严格对应。我用的这套是在多个开源实现里验证过的:

def box_corners_cam(h, w, l, x, y, z, ry): # 局部坐标:x 沿长度,y 沿高度,z 沿宽度 x_c = np.array([ l/2, l/2, -l/2, -l/2, l/2, l/2, -l/2, -l/2]) y_c = np.array([ 0, 0, 0, 0, -h, -h, -h, -h]) z_c = np.array([ w/2, -w/2, -w/2, w/2, w/2, -w/2, -w/2, w/2]) pts = np.stack([x_c, y_c, z_c], axis=0) # 3x8 pts = roty_matrix(ry) @ pts pts += np.array([[x], [y], [z]]) return pts.T # 8x3,相机系

注意y_c的四个 0 和四个-h:因为location在底面,前四个角点在底面上(y 偏移为 0),后四个在顶面上(y 偏移为 -h,因为相机 y 向下,往上要取负)。这个细节和 5.1 节是同一个坑的两面,务必对齐。

连线顺序是:底面 0-1-2-3-0,顶面 4-5-6-7-4,竖边 0-4、1-5、2-6、3-7。顺序错了会画出“蝴蝶结”形状的框,非常显眼,所以我通常先画一份单帧可视化确认连线,再批量跑。

6. odometry 与 tracking 分支里的 calib 长得不一样

6.1 odometry 的 calib.txt:只有 P0 到 P3 和 Tr

如果你做的是里程计或 SLAM 相关的工作,data_odometry_calib里的文件结构和目标检测完全不同:每个序列一个calib.txt,里面只有 5 行,P0、P1、P2、P3和Tr。P0到P3是四个相机校正后的 3x4 投影矩阵,Tr是 3x4 的雷达到 cam0 变换。

这里有三个和前面不一样的地方必须注意。第一,odometry 里的图像本身就是校正后的,而且P0到P3也都是校正后的投影矩阵,所以你既不需要读R_rect(文件里根本没有),也不需要自己去畸变。第二,Tr是 12 个数,但它已经是一个 3x4 矩阵,不需要再拼 4x4(要拼也是补最后一行 0 0 0 1)。第三,odometry 的P矩阵里 fx 约为 707.09,和检测分支的 721.54 不一样,因为两组数据采集时相机参数有调整,千万别把两份标定文件混着用。

还有一个隐藏陷阱:odometry 的位姿文件poses.txt给出的是cam0 坐标系下的轨迹,而不是雷达坐标系。如果你直接把点云和轨迹叠在一起看,会发现点云整体偏了一个平移加旋转,必须先乘Tr的逆把雷达点搬到 cam0。

6.2 tracking 的 calib:多出来的 IMU 与 Tr_imu_to_velo

tracking 分支的training/calib/0000.txt会把三份文件的内容合在一起,字段名也更啰嗦:P0到P3、Tr_velo_to_cam、Tr_imu_to_velo。合在一起的好处是不用到处找文件,坏处是字段名和检测分支完全不一样,直接抄代码会报 KeyError。

Tr_imu_to_velo这一项在检测分支里被拆成单独一份calib_imu_to_velo.txt,内容是:

R: 9.999976e-01 7.553071e-04 -2.035826e-03 -7.854027e-04 9.998898e-01 -1.482298e-02 2.024406e-03 1.482454e-02 9.998881e-01 T: -8.086759e-01 3.195559e-01 -7.997231e-01

这个 R 非常接近单位矩阵,说明 IMU 和雷达基本是平行安装的,只差一两度的装配误差;T 则告诉你两者相距约 1.1 米(三个分量分别是 -0.81、0.32、-0.80)。这个外参在纯视觉任务里用不上,但如果你要把 IMU 的姿态、加速度和点云对齐,或者要用 GPS 的轨迹去校正雷达的位姿,它就是唯一的桥梁。做多传感器融合的时候,我一般会先把这条链路验一遍:用 IMU 的姿态角推一个重力方向,再用点云拟合出的地面法向量去对,两者夹角应该在 1 度以内,否则就是这份外参用错了方向。

顺带说一句 tracking 的标注格式:label_02每行 17 个字段,前两列是帧号和目标 ID,后面才是类型、2D 框、3D 信息。这个多出来的track_id在做时序平滑的时候特别有用,因为它让你可以跨帧把同一个目标关联起来,从而对 3D 框的抖动做滤波——这也是纯检测分支的静态标注做不到的。

最后分享一个我自己养成的习惯:不管手里是哪一份 calib,我都会先把它的 4x4 矩阵打印出来,检查最后一行是不是[0, 0, 0, 1]、旋转块的行列式是不是接近 1。行列式偏离 1 超过 0.01,基本可以断定是读错了元素顺序或者把某个字段当成了列优先。这个两行代码的检查帮我抓出过至少三次“看起来能跑但结果全错”的标定问题,比调试投影链路快得多。

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

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

立即咨询