KITTI数据集在3D目标检测里的地位,基本等同于视觉领域的"Hello World"。入门第一篇聊完怎么下载和解析数据格式之后,这次我打算把可视化这件事彻底讲透。不是教你怎么点一个"可视化按钮",而是把KITTI数据集每一帧背后的四类文件、三个坐标系、两个矩阵变换一次讲穿,再给你一套可以直接跑的Python示例代码。只有当激光雷达点云、标注框和原始图像真正叠在同一个画面里时,你才会理解3D目标检测在解决什么问题:在三维空间里定位目标、估计朝向、判断运动趋势。这篇就是围绕这个目标来的。
1. 可视化之前,先认清KITTI一帧数据里的四张底牌
很多初学者拿到KITTI数据集后,第一反应是直接找可视化工具,结果工具装了一堆,图还是画不出来。原因很简单:你还没搞清楚这一帧数据由哪些文件组成、每个文件里存的到底是什么。先把底牌摸清楚,后面所有代码都会变得顺理成章。
1.1 一帧数据里的四类文件
KITTI数据集里,同一个编号(比如000000)对应着同一时刻采集的四类文件,缺一不可:
| 文件类型 | 路径示例 | 格式 | 在可视化里扮演的角色 |
|---|---|---|---|
| 左相机彩色图像 | image_2/000000.png | PNG图片 | 最终投影结果要叠在这张图上 |
| 激光雷达点云 | velodyne/000000.bin | float32数组,N行4列 | 3D场景的主体,提供空间位置 |
| 目标标签 | label_2/000000.txt | 文本,一行一个目标 | 提供3D框参数(尺寸、位置、朝向) |
| 标定参数 | calib/000000.txt | 文本,若干矩阵 | 坐标系之间互相变换的桥梁 |
先说点云。velodyne目录下的.bin文件不是普通文本,而是按float32排列的二进制数据,每四个float组成一个点,分别是x、y、z和反射强度。解压读取之后,你会得到一个N行4列的数组。KITTI每帧点云大概有12万个点左右,但真正落在有效范围内的只占一部分,后面会讲到怎么裁剪。
再说标签。label_2目录下的.txt文件每一行描述一个目标,按空格分隔成15个字段。这是可视化里信息密度最高的文件,我建议你把它的每一个字段都背下来。
标定文件很多人一开始不重视,直到画出来的框和图像对不上才开始翻文档。calib目录下的.txt文件里存了多个矩阵,我们这一篇只用其中三个,但理解它们之间的关系很关键,我会在第2节专门展开。
稍微提醒一句:不要试图直接双击打开.bin文件,也别用记事本去看,它本来就是给程序读的。我自己当年就是栽在"用文本编辑器打开bin文件"这一步上,出来的全是乱码。
1.2 标签文件里最关键的两个块:2D框和3D框
KITTI标签的15个字段,从可视化角度拆开看,其实可以分成五个信息块:
| 字段 | 示例值 | 含义 | 可视化时是否需要 |
|---|---|---|---|
| type | Car | 目标类别 | 用来决定要不要画、用什么颜色画 |
| truncated | 0.00 | 目标被图像边缘截断的比例,0到1 | 可用来过滤严重截断的对象 |
| occluded | 0 | 遮挡等级,0-3,越大挡得越厉害 | 可用来过滤严重遮挡的对象 |
| alpha | -0.20 | 观察角,即目标朝向与相机视线方向的夹角 | 3D框不需要,2D显示偶尔用 |
| bbox | 712.40 143.00 810.73 307.92 | 2D像素框:左、上、右、下 | 用来和投影结果做对比验证 |
| dimensions | 1.48 1.63 3.89 | 3D尺寸:高、宽、长(米) | 画3D框的核心参数 |
| location | 2.15 1.70 15.35 | 底部中心在相机坐标系下的x、y、z | 画3D框的位置依据 |
| rotation_y | 1.61 | 目标绕相机y轴的朝向角 | 画3D框的方向依据 |
这里有两个非常容易混淆的点。
第一个是dimensions的顺序。KITTI里规定的是height、width、length,也就是高、宽、长。很多人在读代码时习惯性地以为是长、宽、高,结果画出来的框长宽互换,一辆车看起来像被横过来放。这个错误非常隐蔽,因为框的位置是对的、高度是对的,只有长和宽对不上,不仔细看根本发现不了。
第二个是location的含义。KITTI官方给的说明是"3D object location in camera coordinates",很多人就当成物体中心坐标用了。但实际在官方devkit的角点计算里,它是当作底部中心坐标来用的——8个角点中,底面4个点的y坐标是0,顶面4个点的y坐标是-h。如果你把location当成中心,整个框会整体上移半个车身高度,看起来像浮在半空。这点后面会专门展开讲。
还有一个容易忽略的点:alpha和rotation_y是两个不同的角。alpha是观测角,跟相机视线方向有关;rotation_y才是物体在三维空间里的全局朝向。画3D框时只跟rotation_y有关,跟alpha无关。2D检测里有时候会用到alpha,3D可视化里基本可以无视它。
1.3 同一辆车,在三个坐标系里长在不同位置
KITTI可视化的本质,就是把来自不同坐标系的信息统一到同一个参考系里再绘制。一辆真实世界里的车,在KITTI数据里其实有三种"长相":
- 在图像里,它是一块2D像素区域,由u、v两个坐标表示;
- 在激光雷达点云里,它是一团分布在三维空间里的反射点,由x、y、z表示,且原点在激光雷达传感器上;
- 在标签文件里,它是一个三维长方体,由尺寸、底部中心坐标和朝向角表示,且原点在相机光心上。
同一辆车,在三种坐标系统里被记录成完全不同的数字。可视化的任务,就是把这三个数字集合放进同一个画布里。你可以选择把点云搬到相机坐标系,也可以选择把3D框搬到激光雷达坐标系,但必须二选一,不能混着画。
打个比方:你手里有三份关于同一个人的描述——一份是照片,一份是他的身高体重,一份是他在房间里的位置。你要做的是把这三份信息拼成一个完整的人物模型。如果"位置"用的是房间坐标系,"身高体重"用的却是另一个标尺,那拼出来的人肯定是扭曲的。坐标系不统一之前,一切可视化都是空谈。
2. 坐标系变换:可视化的地基,错一步全盘皆输
如果只让我选一节作为这篇博文的重点,我一定选这一节。KITTI可视化的所有疑难杂症,80%都能追溯到坐标系变换没做对。
2.1 三个坐标系的基本定义
先建立画面感。KITTI里经常打交道的有三个坐标系:
| 坐标系 | 原点 | 轴方向 | 常见用途 |
|---|---|---|---|
| Velodyne激光雷达坐标系 | 激光雷达传感器中心 | x朝前、y朝左、z朝上 | 点云的原始坐标系 |
| 相机坐标系 | 相机光心 | x朝右、y朝下、z朝前 | 标签location所在的坐标系 |
| 图像像素坐标系 | 图像左上角 | u朝右、v朝下 | 最终画框的坐标系 |
注意相机坐标系里y轴是朝下的,这一点跟我们在数学课上学的右手坐标系完全相反。很多人在KITTI里被绕晕,根源就是忘记了"相机坐标的y是向下的"。
再补充一个容易忽视的点:激光雷达坐标系的x朝前,跟相机坐标系的z朝前是两个不同的"前"。从激光雷达到相机,x要变成z,y要变成-x方向,这个转变不是简单换一下变量名,而是通过一个旋转加平移矩阵完成的。
2.2 标定文件里真正要关心的三个矩阵
KITTI的calib文件里会有P0、P1、P2、P3、R0_rect、Tr_velo_to_cam这几个矩阵。如果你只想做单目图像的3D框可视化,盯住下面三个就够了:
P2是一个3x4矩阵,负责把相机坐标系下的点投影到图像平面。它融合了相机的内参(焦距、光心偏移)和相机2相对于参考相机的外参(旋转加平移)。这个矩阵是KITTI官方在数据采集时就标定好的,我们不需要自己算,直接拿过来用。
Tr_velo_to_cam是一个3x4矩阵,负责把激光雷达坐标系下的点变换到相机坐标系。同理,这也是设备安装时标定出来的,包含了激光雷达和相机之间的旋转关系和平移关系。
R0_rect是一个3x3矩阵,负责把参考相机坐标系下的点做一次旋转矫正,消除双目相机校正过程中产生的坐标系偏差。它非常接近单位矩阵,但绝不能忽略,否则投影结果会有几个像素的偏差。
我的建议是:在代码里把这三个矩阵从calib文件里解析出来,单独存成变量名,不要随手把它们连成一个4x4的大矩阵。分步写虽然啰嗦,但每一步的类型和维度都很清晰,调错时也容易定位。
2.3 一条公式打通所有坐标:Lidar到Camera再到Pixel
把激光雷达点投影到图像平面上,整个过程就一条链式公式:
P_img = P2 · R0_rect · Tr_velo_to_cam · [x_velo, y_velo, z_velo, 1]^T其中[x_velo, y_velo, z_velo]是激光雷达点坐标,最后这一步必须补1,是为了做齐次坐标变换。Tr_velo_to_cam是3x4矩阵,正好需要4维输入;R0_rect是3x3的旋转矫正;P2是3x4的投影矩阵。
投影结果是一个3维齐次坐标,最后要除以z分量才能得到真正的像素坐标:
u = P_img[0] / P_img[2] v = P_img[1] / P_img[2]为什么除z?因为P2里同时包含了焦距和光心参数,乘完以后得到的是"深度未归一化"的坐标,只有除以z,才能模拟相机小孔成像的透视效果。这个除法是透视投影的关键一步,漏掉它,点云投影到图像上就是乱七八糟的一片。
这里有一个非常常见的坑:点云bin文件的第4列是反射强度,不是齐次坐标里的1。如果直接把整行丢进去乘,位置就错了。必须单独构造一列1,再把反射强度放到一边去。
用代码写就是:
import numpy as np def load_velodyne(filepath): pts = np.fromfile(filepath, dtype=np.float32).reshape(-1, 4) return pts # N x 4 (x, y, z, reflectance) def load_calib(filepath): lines = [line.rstrip().split() for line in open(filepath)] calib = {} for line in lines: key = line[0].rstrip(':') values = np.array(line[1:], dtype=np.float32) calib[key] = values calib['P2'] = calib['P2'].reshape(3, 4) calib['R0_rect'] = calib['R0_rect'].reshape(3, 3) calib['Tr_velo_to_cam'] = calib['Tr_velo_to_cam'].reshape(3, 4) return calib def project_lidar_to_image(pts, calib): N = len(pts) pts_homo = np.hstack([pts[:, :3], np.ones((N, 1))]) # N x 4 pts_cam = calib['Tr_velo_to_cam'] @ pts_homo.T # 3 x N pts_rect = calib['R0_rect'] @ pts_cam # 3 x N pts_rect_homo = np.vstack([pts_rect, np.ones((1, N))]) # 4 x N pts_img = calib['P2'] @ pts_rect_homo # 3 x N pts_img = pts_img / pts_img[2, :] # 归一化 return pts_img[:2, :].T # N x 2 (u, v)2.4 反方向变换:3D框从相机坐标回到点云坐标
标签文件里的3D框是在相机坐标系下的,而点云是在激光雷达坐标系下的。要在3D空间里同时画点云和3D框,必须让两者处于同一个坐标系。
我推荐的做法是:把点云从激光雷达坐标系转到相机坐标系,因为3D框的角点本身就在相机坐标系里,点云转过去之后两者直接对齐,不需要对框做任何额外变换。
有时候你想把3D框画在原始点云坐标系里,那就需要求Tr_velo_to_cam的逆矩阵。注意Tr_velo_to_cam是3x4矩阵,求逆前要扩展成4x4齐次矩阵:
def get_cam_to_velo_matrix(calib): T = np.eye(4) T[:3, :4] = calib['Tr_velo_to_cam'] T_inv = np.linalg.inv(T) return T_inv # 4x4无论是哪种方案,核心原则只有一个:画在同一个画面里的所有东西,必须共享同一个坐标系。这一条说得再重要也不为过。
3. 三张可视化图,从散点云图到带框目标视图
理论铺垫完毕,现在进入真正动手的阶段。这一节我会带你画出三种图:原始点云图、带3D框的点云场景图、以及3D框投影到RGB图像上的效果图。第三种是KITTI可视化里最经典的画面,也是很多人入门时最想得到的结果。
3.1 第一张图:Open3D载入原始点云
如果你还没装Open3D,先装一下。它是目前最顺手的3D点云可视化库,加载KITTI点云只要几行代码。
import numpy as np import open3d as o3d pts = np.fromfile('velodyne/000000.bin', dtype=np.float32).reshape(-1, 4) # 先做ROI裁剪,只保留有效区域 mask = (pts[:, 0] > 0) & (pts[:, 0] < 70) & \ (np.abs(pts[:, 1]) < 40) & \ (pts[:, 2] > -2) & (pts[:, 2] < 1) pts = pts[mask] pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(pts[:, :3]) # 按高度着色,能看到地面和车辆的空间层次 z = pts[:, 2] z_norm = (z - z.min()) / max(z.max() - z.min(), 1e-6) colors = np.stack([z_norm, 1 - z_norm, np.full_like(z_norm, 0.4)], axis=1) pcd.colors = o3d.utility.Vector3dVector(colors) o3d.visualization.draw_geometries([pcd])为什么做ROI裁剪?KITTI激光雷达的有效感知范围大约在车前70米左右,更远的点非常稀疏,全画出来只会拉低可视化的可读性。我这里用的范围是前方0到70米、左右各40米、高度从-2米到1米,你可以根据自己关心的区域调整。
有一点要注意:KITTI的激光雷达安装在车顶约1.73米高度处,它的z轴朝上,所以路面的z坐标大约是负1.73米。如果你把z范围下限设置成0,地面会被全部切掉,看不到路面参照物。
3.2 第二张图:在点云场景里画出3D检测框
画完原始点云后,下一步就是在场景里叠加3D标注框。先把标签文件解析出来,为每个目标计算8个角点。
def load_label(filepath): objects = [] for line in open(filepath): parts = line.strip().split(' ') obj = { 'type': parts[0], 'truncated': float(parts[1]), 'occluded': int(parts[2]), 'alpha': float(parts[3]), 'bbox': np.array(parts[4:8], dtype=np.float32), 'dimensions': np.array(parts[8:11], dtype=np.float32), # h, w, l 'location': np.array(parts[11:14], dtype=np.float32), # 底部中心 'rotation_y': float(parts[14]) } objects.append(obj) return objects def compute_box_3d(obj): h, w, l = obj['dimensions'] # 高、宽、长,顺序务必正确 x, y, z = obj['location'] ry = obj['rotation_y'] # 底面4个角点的y为0,顶面4个角点的y为-h x_corners = [l/2, l/2, -l/2, -l/2, l/2, l/2, -l/2, -l/2] y_corners = [0, 0, 0, 0, -h, -h, -h, -h] z_corners = [w/2, -w/2, -w/2, w/2, w/2, -w/2, -w/2, w/2] R = np.array([ [np.cos(ry), 0, np.sin(ry)], [0, 1, 0], [-np.sin(ry), 0, np.cos(ry)] ]) corners_3d = R @ np.array([x_corners, y_corners, z_corners]) corners_3d += np.array([x, y, z])[:, None] return corners_3d.T # 8 x 3,相机坐标系然后,把点云也转到相机坐标系,保证两者在同一个空间里:
pts_homo = np.hstack([pts[:, :3], np.ones((len(pts), 1))]) pts_cam = (calib['Tr_velo_to_cam'] @ pts_homo.T).T # N x 3 pcd.points = o3d.utility.Vector3dVector(pts_cam[:, :3]) # 画3D框 lines = [[0,1],[1,2],[2,3],[3,0], [4,5],[5,6],[6,7],[7,4], [0,4],[1,5],[2,6],[3,7]] colors = [[1,0,0] for _ in range(len(lines))] for obj in objects: if obj['type'] not in ['Car', 'Pedestrian', 'Cyclist']: continue corners = compute_box_3d(obj) line_set = o3d.geometry.LineSet() line_set.points = o3d.utility.Vector3dVector(corners) line_set.lines = o3d.utility.Vector2iVector(lines) line_set.colors = o3d.utility.Vector3dVector(colors) o3d.visualization.draw_geometries([pcd, line_set])这段代码里有一个奇淫巧技:如果发现Open3D里的线框太细、看不清,可以不用LineSet,改成用create_cylinder把每一条边画成圆柱体,粗线框在截图和演示时效果好得多。缺点是代码会多几行,但换来的是画面清晰度提升,值得。
如果你发现自己画的框都歪歪扭扭、飘在空中,不用急着怀疑代码,极大概率是location的理解出了问题,第4节会专门讲。
3.3 第三张图:3D框投影到2D图像
这是最出效果的一张图:把3D框的8个角点投影到图像上,用彩色线条把角点连起来,覆盖在原始RGB图片上。
import cv2 def project_box_to_image(obj, calib): corners = compute_box_3d(obj) # 8 x 3,相机坐标系 corners_rect = calib['R0_rect'] @ corners.T # 3 x 8 corners_rect_homo = np.vstack([corners_rect, np.ones((1, 8))]) corners_img = calib['P2'] @ corners_rect_homo # 3 x 8 corners_img = corners_img / corners_img[2, :] # 除以深度 return corners_img[:2, :].T # 8 x 2 img = cv2.imread('image_2/000000.png') edges = [(0,1),(1,2),(2,3),(3,0), (4,5),(5,6),(6,7),(7,4), (0,4),(1,5),(2,6),(3,7)] for obj in objects: if obj['type'] not in ['Car', 'Pedestrian', 'Cyclist']: continue pts2d = project_box_to_image(obj, calib) for i, j in edges: pt1 = tuple(pts2d[i].astype(int)) pt2 = tuple(pts2d[j].astype(int)) cv2.line(img, pt1, pt2, (0, 255, 0), 2) cv2.imshow('KITTI 3D box', img) cv2.waitKey(0)当你第一次跑出这张图,看到汽车轮廓被一个绿色的三维线框稳稳框住时,之前所有关于坐标系的困惑都会瞬间落地。
这里还要解释一个现象:3D框投影到图像后,它跟标签文件里的2D bbox并不会完全重合。2D bbox是人工标注的最大外接矩形,而3D框是物体的三维包围盒投影,两者的边缘往往会差一点点。如果相差超过几个像素,那大概率是标定矩阵或者坐标系出了问题。
3.4 附加:BEV鸟瞰图,为什么选择x-y平面
BEV鸟瞰图在3D目标检测里至关重要,因为很多检测模型就是基于BEV特征做学习的。用matplotlib就能画一个简易版。
import matplotlib.pyplot as plt # 在激光雷达坐标系下做BEV:x为前向,y为左侧,z为高度 plt.figure(figsize=(8, 8)) plt.scatter(pts[:, 0], pts[:, 1], s=0.2, c=pts[:, 2], cmap='jet') plt.axis('equal') # 3D框从相机坐标转到velodyne坐标 T_cam_to_velo = get_cam_to_velo_matrix(calib) for obj in objects: corners = compute_box_3d(obj) corners_homo = np.hstack([corners, np.ones((8, 1))]) corners_velo = (T_cam_to_velo @ corners_homo.T).T # 8 x 3 plt.plot(corners_velo[[0,1,2,3,0], 0], corners_velo[[0,1,2,3,0], 1], 'r-') plt.plot(corners_velo[[4,5,6,7,4], 0], corners_velo[[4,5,6,7,4], 1], 'b-') plt.gca().invert_yaxis() plt.show()BEV图到底取x-y平面还是x-z平面,经常有人迷糊。我的建议是看你当前数据在哪个坐标系:激光雷达坐标系的俯视平面是x-y,因为z是高度;相机坐标系的俯视平面是x-z,因为y是高度。没有哪个"更正统",只有"当前坐标系下哪两个轴才是地面平面"这一条判断标准。
4. 验证与排查:框画出来不对,十有八九是这几个问题
可视化代码跑通只是第一步,画出来的东西对不对,还需要一整套验证方法。这一节我把自己踩过、帮别人排查过的典型问题集中整理出来,希望能帮你少走弯路。
4.1 用投影结果做自检:3D框和2D bbox的关系
拿到投影图后,第一件事就是做自检:把标签文件里的2D bbox也画上去,跟3D投影框对比。
for obj in objects: left, top, right, bottom = obj['bbox'].astype(int) cv2.rectangle(img, (left, top), (right, bottom), (255, 0, 0), 2)正常情况下,3D框的投影和2D矩形应该基本贴合,尤其是物体底边部分。2D框是目标在图像中的实际像素范围,3D框是三维长方体投影,所以3D投影框的上边沿有时候会比2D框略低或略高,这是因为二维标注框通常包含了一部分背景。如果两个框完全重合,反而要怀疑是不是某个环节恰好互补了错误。
如果两个框差得离谱,第一步检查是不是用错了P矩阵。calib文件里有P0到P3四个投影矩阵,P2对应左相机彩色图。很多人一不小心用了P0(对应灰度相机cam0),结果投影位置整体偏移。这种错误最常见于复制粘贴代码时。
4.2 框飘在半空或陷入地底:location是底部中心
这个坑我前面提过两次,现在专门展开讲,因为它真的太容易踩了。
KITTI官方devkit计算3D框角点时,y方向的8个角点用的是:
[0, 0, 0, 0, -h, -h, -h, -h]也就是说,location的y是底部中心的y坐标,不是立体中心的y坐标。底面四个角点的y等于0,顶面四个角点的y等于-h。
如果你错误地把location当成中心,角点就会变成:
[h/2, h/2, h/2, h/2, -h/2, -h/2, -h/2, -h/2]框整体上移h/2,看起来就像汽车悬空了一样。如果出现框全部飘在路面以上半个车身的位置,不用怀疑,就是这个原因。
反过来,如果你把location当成顶面中心,框就会陷入地面。这种情况不太常见,但在一部分自动转写代码的工程里遇到过。
怎么验证?在点云视图里打开地面网格。如果你看到框的下底面刚好贴合点云中的路面点,说明location用对了;如果框的下底面悬在路面之上,或者陷进路面之下,说明y方向的基准搞错了。
4.3 车头转了方向:rotation_y与维度顺序
这类问题通常表现为:框的位置对、高度对,但长宽反了,或者朝向反了。
维度顺序问题:如果把h、w、l记成了h、l、w,那画出来的框形状就是一个横躺着的不规则长方体。比如一辆车真实尺寸是长3.9米、宽1.6米,你如果长宽互换,在俯视图里会看到框的形状明显不像车。检测方法很简单:找一帧停着的车,对比框的长宽比和图像里车的长宽比是否一致。
rotation_y方向问题:这个稍微复杂。KITTI的camera坐标系里y轴向下,所以绕y轴旋转的正方向跟我们在黑板上画坐标系时的直觉相反,这就导致很多人画的框朝向跟实际车头朝向差了180度或90度。
解决这个问题的土办法,是我自己一直用到现在的一种方式:拿一个你知道朝向的样本帧,把rotation_y分别改成原值和负值,看一看哪个方向跟图像里车的朝向吻合。一旦确定了正负,后续所有帧都用同一个符号规则。不要试图用死记"正角向左、负角向右"这类口诀,因为不同数据集版本、不同代码库的处理方式可能不同,靠样本对齐建立直觉才是最稳的。
另外注意,KITTI里rotation_y的取值范围是-pi到pi,如果你的代码里对角度做了取模操作,要小心正负边界问题。某些库会把-pi直接处理成pi,导致朝向反向。
4.4 图像宽高和P2不一致导致投影偏移
KITTI图像看起来都是1242x375左右,但细心的同学可能会发现,不同帧的图片宽高其实略有差异——有的是1242像素宽,有的是1238或1241。这个差异来自双目相机矫正时的裁剪。
更关键的是,每一帧的calib文件里P2矩阵的最后几个参数也不是完全相同的。如果你图省事,只从某一帧拷贝一份P2用在整个数据集上,那么大部分帧投影偏差不大,但在图像边缘区域的目标可能出现几个像素到十几个像素的偏移。
我的建议是:每处理一帧图像,就读一次它自己的calib文件,不要缓存成全局变量。这点开销完全可以忽略,但它能避免很多莫名其妙的对不齐问题。
5. 我现在完整的可视化工作流和一些值得长期使用的小习惯
当你把上面所有环节跑通之后,剩下的就是工程化的问题了。这篇最后一节,我把自己的标准工作流和几个小习惯分享出来,不一定适合所有人,但值得你参考。
5.1 一个脚本吃穿所有帧
我日常做KITTI可视化,不会为每帧写一遍左手代码。我会把代码组织成三个模块:数据解析模块负责读bin、读calib、读label;可视化模块负责三类图(点云图、投影图、BEV图);主脚本负责循环帧目录,把结果输出到指定文件夹。
# 伪主流程,示意批处理逻辑 for frame_id in range(0, 200): base = f'000000'[:-len(str(frame_id))] + str(frame_id) calib = load_calib(f'calib/{base}.txt') objects = load_label(f'label_2/{base}.txt') pts = load_velodyne(f'velodyne/{base}.bin') # 生成投影图 render_image_projection(base, calib, objects) # 生成BEV图 render_bev(base, calib, objects, pts)这套代码跑完后,我通常会随机抽看10%的帧,确认没有出现大面积框漂移。一个比较实用的习惯是在输出图上把frame_id和目标数量打印在左上角,这样回查时一眼就能定位是哪一帧出的问题。
5.2 聊聊批处理、固定视角和导出视频
如果你想把可视化结果做成视频,两个细节值得注意:
一是Open3D的交互窗口不适合在批处理里长期运行。更好的做法是每帧渲染之后直接保存图像,或者用headless模式渲染。我自己更常用的是先批量生成投影图,再用cv2.VideoWriter把PNG序列拼成视频,这样既可以用Open3D细看单帧,又能用视频看整体效果。
import cv2 out = cv2.VideoWriter('kitti_vis.avi', cv2.VideoWriter_fourcc(*'MJPG'), 10, (1242, 375)) for i in range(200): img = cv2.imread(f'vis_overlay/{i:06d}.png') out.write(img) out.release()二是对比不同帧时,务必固定视角。Open3D的draw_geometries默认视角是自动计算的,连续看多帧会被镜头乱飘搞晕。在代码里指定视角参数,可以把每帧的观察角度锁死,这样运动目标的位置关系才有可比性。
5.3 给刚入门朋友的三条建议
第一条,先在一帧上把流程跑通,再谈批处理。我见过太多人一上来就整目录循环,结果每一帧都报错,最后连问题出在哪儿都找不到。单帧调试的时间成本远低于批处理排错。
第二条,把compute_box_3d里的每个参数手动改一改,建立几何直觉。改h看框变高,改ry看框转向,改location的y看框上下移动。这种"破坏性实验"会让你对这些参数的理解深刻程度远超读十遍文档。
第三条,不要盲信网上文章里给出的calib常量。每个KITTI子集下载包的calib内容不完全一致,你从某篇博客复制来的P2可能对应的是另一个采集日期或另一个相机。永远从你要处理的那一帧自己的calib文件里读取矩阵数据。
KITTI可视化这条路,说难也难,说简单也简单。难在坐标系绕来绕去,简单在只要你把握住"统一坐标系"这个核心原则,再复杂的问题都能拆解成一步步验证。我自己从第一行可视化代码到现在,摔过的大坑几乎都集中在location基准、维度顺序和矩阵用法这三件事上。希望这篇能帮你直接把这三个坑填平,省下几个通宵的时间。