第一次把 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 里的 .binIMU前左上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.0R后面跟的是 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, dtypenp.float64).reshape(3, 3) T np.asarray(T_flat, dtypenp.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 cmy 向下负值表示往上的位置横向几乎重合。这个量级和 KITTI 采集车上的实际布局是对得上的。2.3 T、delta_f、delta_c 与 corner_distT的含义上面说了是平移。剩下的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.392000e03 5.120000e02 K_00: 9.842439e02 0.000000e00 6.900000e02 0.000000e00 9.808141e02 2.331966e02 0.000000e00 0.000000e00 1.000000e00 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.242000e03 3.750000e02 R_rect_00: 9.999239e-01 9.837760e-03 -7.445048e-03 ... P_rect_00: 7.215377e02 0.000000e00 6.095593e02 0.000000e00 0.000000e00 7.215377e02 1.728540e02 0.000000e00 0.000000e00 0.000000e00 1.000000e00 0.000000e00S_xx是校正前的图像尺寸宽 1392、高 512K_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.57440.537 mcam1 在 cam0 右侧约 54 cmcam244.85728-0.0622 mcam2 在 cam0 左侧约 6.2 cmcam3-339.52420.4706 mcam3 在 cam0 右侧约 47 cmcam1 和 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.txtdef velo_to_image(points_velo, calib, velo_calib, cam_id2): # 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[fP_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 intensityintensity 在 0 到 1 之间。读的时候用np.fromfile(path, dtypenp.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], axis0) # 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_velotracking 分支的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基本可以断定是读错了元素顺序或者把某个字段当成了列优先。这个两行代码的检查帮我抓出过至少三次“看起来能跑但结果全错”的标定问题比调试投影链路快得多。