做3D目标检测这几年,最让我烦心的不是模型调参,而是“看结果”。模型跑完一遍,输出的检测结果是一堆坐标和置信度,你要想知道它到底行不行,最终还得把它画出来。KITTI数据集作为自动驾驶领域最常用的评测基准,它的坐标关系又偏偏是雷达、相机、校正、投影好几套矩阵叠在一起,每次都写得手忙脚乱。后来我把这些重复工作整理成了一个轻量小工具,叫 kitti_vis。它不挑模型,也不跟训练框架绑定,只负责把KITTI格式的检测结果、标注真值和激光点云,快速画到图像上和俯视图里。这篇文章就把这个工具的设计思路、坐标变换细节、完整用法和踩坑记录一次性说清楚,适合正在做3D目标检测、想快速验证检测结果,或者刚接触KITTI数据集又不知道从哪里着手的同学。
1. 为什么需要kitti_vis:3D目标检测可视化的痛点与设计思路
1.1 做过3D目标检测的人,都被“看结果”这件事折磨过
做2D检测时,用OpenCV画个矩形框很容易,左上角右下角两点一连线就完事。但3D检测完全不一样。KITTI的标注和检测结果都是三维空间里的长方体,包含位置中心、长宽高和朝向角,图像只是它的一个投影视图。要把3D框画在2D图上,需要经过雷达坐标系、相机坐标系、校正坐标系、图像坐标系之间的一系列变换。
这个流程门槛不高,但细节特别容易出错。我最早用别人的工具箱,结果发现要么只支持某个框架的输出,要么只要把点云投进图像就慢得像幻灯片,改个参数还得去翻源码。尤其是项目中期经常要调检测阈值、调NMS参数,几乎改一次就要重新看一次结果。如果可视化工具不好用,整个debug节奏都会被拖垮。
后来我决定自己写一个目标检测可视化工具。起名叫 kitti_vis,就是因为它最初只针对KITTI数据,后面越用越顺手,干脆扩展成了一套完整的小工具集。现在不管是看训练集标注、验证集检测结果,还是做PPT汇报截图,我都用它。相比那些“全家桶”可视化平台,它能做到打开命令行就能出图,没有图形界面启动的等待,也没有花哨但没用的交互。
1.2 kitti_vis定位:只做好“标定+投影+绘制”
kitti_vis的核心定位就一句话:把KITTI序列中的图像、激光雷达点云、标注或检测框,以最少的代码画到一张图上。它不做训练、不做评价,也不搞复杂交互,而是把“标定文件解析、雷达点云投影、3D框角点计算、裁剪和深度排序、BEV俯视框绘制”这几件事做得足够顺手。
它同时支持KITTI原始数据格式和检测输出格式。很多检测代码最后会输出一个txt,每行包含类别、截断度、遮挡、观测角、2D框、3D尺寸、位置、朝向和置信度,只要字段顺序对得上,kitti_vis就能直接吃进去画。如果模型输出的是JSON、pickle或者其他自定义格式,也只需要写一个十几行的解析函数,把字段对齐到通用结构就行。
实际使用下来,我觉得这个“薄封装”的思路非常省心。模型侧怎么做推理,kitti_vis完全不管;数据侧只要还有标定文件,它就能画。不管你是用PointPillars、CenterPoint还是传统的两阶段方法,只要结果是标准KITTI格式,都能够在同一个可视化工具里看。对于搞研究的人来说,统一的评估视觉通道特别重要,它能帮你快速对比不同模型在同一个场景下的表现。
1.3 技术选型:为什么用Python + OpenCV + NumPy
工具用Python写,完全够用。NumPy负责矩阵运算和点云批处理,OpenCV负责图像读写和画线画框,如果不做复杂的交互式窗口,Matplotlib甚至可以完全不用。为什么不用Open3D或者Mayavi?它们做点云交互确实不错,但依赖重、安装容易报错,而且不做渲染时反而慢。我只是要快速看一眼结果,并不需要拖动视角,所以轻量级才是关键。
kitti_vis设计成命令行工具,输入图片路径、bin点云路径、标定文件和结果文件,后端直接输出到图像,或者写成视频流。用户也可以把它当库调用,在自己的训练脚本里直接import,每一个epoch结束后自动生成一个可视化batch,挂到日志平台上看训练效果。我自己就是把它接进了训练日志,省去了一次次从服务器下载检测结果再本地可视化的工作。
2. KITTI数据格式与投影变换:可视化的核心基础
2.1 拿到手的数据到底长什么样
KITTI原始数据目录结构很经典:image_2存放左彩色相机图像,velodyne存放点云bin文件,label_2存放标注文件,calib存放标定文件。比如000000.bin是Velodyne HDL-64E扫出来的点云,每帧十几万个点,存储为N行4列的float32数组,每行是x, y, z, reflectivity。
标签文件每行15个字段:类别、截断、遮挡、观测角、2D框坐标、3D框尺寸、位置、朝向角。具体顺序是type truncation occlusion alpha bbox(4) dimensions(3) location(3) rotation_y。最容易忽略的是rotation_y,它表示物体绕相机Y轴的旋转角,和location一起决定了长方体摆放姿态。很多初学者把alpha和rotation_y搞混,实际上alpha是观测角,受相机位置影响,而rotation_y是物体在三维空间中的绝对朝向。
标定文件里则是一堆矩阵。P0、P1、P2、P3分别对应四个相机的投影矩阵,做左目图像可视化时只用P2。R0_rect是立体校正旋转矩阵,因为原始相机坐标经过校正后才能得到理想的图像平面。Tr_velo_to_cam是雷达坐标系到相机坐标系的刚体变换外参。这三套矩阵叠加在一起,就是把雷达点投影到图像的全部数学基础。
2.2 从Velodyne到图像像素:三个矩阵一把串
从雷达点到图像像素,过程可以拆成三步。先用Tr_velo_to_cam把点从雷达坐标系变换到校正前的相机坐标系,再乘R0_rect完成立体校正,最后用P2投影到左目图像。整体公式是:
image_uv = P2 * R0_rect * Tr_velo_to_cam * [x, y, z, 1]^T计算后得到的是齐次坐标,最后要除以z分量,才能得到像素坐标。很多人会忽略R0_rect是3x3矩阵,做乘法时需要扩展成4x4,否则维度对不上。另外Tr_velo_to_cam在标定文件里是3x4,扩成齐次矩阵时要小心顺序。
投影出来的z分量本质上就是相机坐标下的深度。绘制点云前,必须过滤掉z小于0或者z大于某个阈值的点,这些点要么在相机背后,要么远到没有意义。如果不做这一步,图像上会出现大量从画面边缘拉出来的“流星线”,非常干扰判断。你要是第一次看到点云投影结果乱七八糟,先别怀疑点云数据有问题,大概率是没做深度掩码。
2.3 3D框的8个角点怎么算
3D框在KITTI里由尺寸(h, w, l)、中心位置(cx, cy, cz)和rotation_y决定。先在物体局部坐标中生成8个角点,边长分别是l、w、h的一半,然后按照rotation_y绕Y轴旋转,再平移到中心位置。
为什么要绕Y轴旋转?这是KITTI坐标系约定:Y轴朝下,X轴朝前,Z轴朝右,所以物体在地面上转向时,只有绕Y轴的旋转角。很多刚接触的人容易直接拿通用的欧拉角去套,结果发现画出来的框是歪的。角点计算过程看起来简单,但索引顺序非常关键。我用固定索引表来表示哪两个点是一条边,否则画出来就是一堆乱线。
例如一个位于(10, 0, 0)、尺寸(1.5, 1.6, 3.5)、朝向角为0的车辆框,其局部坐标的八个角点应该是l/2, w/2, h/2的加减组合。旋转后中心平移到目标位置,再用投影矩阵投影到图像。投影之后,用OpenCV的line函数按12条边依次连接,就得到了我们在图像上看到的3D框。
2.4 遮挡判断:画框也要分前后
如果没有深度排序,图像上的框中框会显得特别乱,远处物体的框反而盖住近处物体的框。一个简单有效的做法是:先计算每个3D框中心在相机坐标下的深度z,按z从大到小排序,越近的越后画。这是典型的画家算法思路,画面上近处物体自然覆盖远处物体。
如果希望更精细一点,还可以分侧绘制。比如利用8个角点的可见性,只画面向相机一侧的竖直线。不过这样会引入大量可见性判断逻辑,对排查检测结果帮助有限。kitti_vis默认使用深度排序加半透明填充,框本身用实线,填充用很淡的颜色,这样既能看清遮挡关系,又不至于遮住点云细节。
3. kitti_vis实操:从单帧到批量视频的完整流程
3.1 环境依赖和命令行用法
kitti_vis的依赖很少,理论上只要numpy、opencv-python、tqdm三个包。安装没什么技术含量,直接pip install -r requirements.txt就行。基础命令长这样:
python kitti_vis.py \ --image /data/kitti/image_2/000000.png \ --lidar /data/kitti/velodyne/000000.bin \ --label /data/kitti/label_2/000000.txt \ --calib /data/kitti/calib/000000.txt \ --output /tmp/result.png如果只是看检测框,没有点云也可以,命令里去掉--lidar即可。工具会自动判断是只画2D框还是连3D框一起画。如果检测结果没有2D框字段,kitti_vis也能从3D角点投影后计算包围盒,临时补一个2D框出来。这样你不需要在训练代码里额外输出两套坐标,减少很多麻烦。
可视化结果默认会带两个面板:左边是原图叠加点云和3D框,右边是BEV俯视图。如果只想保留其中一个,可以用--panel image或者--panel bev指定。我平时调试时通常两个一起开,因为图像看外观,BEV看位置精度,两边对照才能定位到具体错误。
3.2 图像、点云、3D框一次性画出来
实际运行时,点云投影这一步要处理十几万个点。如果用Python循环逐点算,单帧可能要好几秒,完全没法接受。正确做法是把所有点云变成NumPy数组,一次性做矩阵乘法。整个过程在NumPy里就是构造齐次坐标,然后依次和Tr_velo_to_cam、R0_rect、P2做矩阵乘法,最后统一除以z分量。
投影完成后,用反射强度或深度给点云着色。我更喜欢用深度着色,距离越近颜色越暖,距离越远颜色越冷。这样在图像上可以看到前方近距离的障碍物是亮色,远一点的路面逐渐变暗,视觉层次特别好。如果你更关注点云反射强度,也可以改成灰度着色,效果类似。
3D框绘制则是先把8个角点投影到图像,然后按边的索引依次连线。为了区分不同类别,默认给Vehicle、Pedestrian、Cyclist分别分配了蓝色、橙色、绿色。类别映射表是开放的,你可以在配置文件里改成自己习惯的颜色。画完之后在图左上角统计每类框的数量和平均置信度,这样看我不用再单独跑一段评价脚本,单张图上就能快速判断模型有没有出现漏检或者误检。
3.3 BEV鸟瞰视图的实战细节
BEV视图为什么重要?因为图像投影会把深度信息压缩掉,两个前后排列的车在图片上可能叠在一起,但在俯视图上一目了然。kitti_vis右侧单独开一个面板,把点云和3D框都投影到X-Y平面。X轴对应车辆前方,Y轴对应车辆左侧,这和KITTI雷达坐标系直接对应。
BEV默认显示范围是前方70米、左右各20米,这个范围基本覆盖了KITTI评测常见的有效区域。点云在BEV里可以用二维直方图栅格化,每个格子里落了多少点,再用颜色深浅表达密度,相当于画了一个小范围的占有网格图。3D框在BEV下的投影很简单,只要把8个角点取x和y坐标,连接成一个旋转矩形就行。
BEV视图对判断检测框的朝向误差特别敏感。我在项目里发现,有的模型在图像上看起来完美,车辆框型很准,但拉到BEV里一看,朝向偏了10度以上。这种误差在图像上很难发现,因为2D投影对朝向的约束很弱,但放到俯视图上就藏不住了。所以如果你在调3D检测模型,建议每个epoch都抽几帧看BEV结果。
3.4 批量导出和视频生成
单帧可视化只是第一步,实际评估模型时往往要跑整个序列。kitti_vis提供了批量模式,给一个目录结构,自动按序号匹配图片、点云、标签和标定,然后用OpenCV的VideoWriter把每帧结果写成mp4。
这里有几个坑要提前避掉。视频尺寸要和图像尺寸一致,否则生成出来的视频要么黑边要么被拉伸。写入帧率建议固定10fps左右,太高等于是抽帧,看运动物体容易跳,太低又显得一顿一顿。批量模式里千万别开imshow,否则每一帧都会弹窗,既慢又影响自动导出。
批量导出还有一个很实用的参数:--max_frames。有时候我只想快速预览前50帧,不需要全视频跑完。设定这个参数后,工具会提前终止,对调试特别友好。另外,如果检测结果文件只有其中一部分帧有目标,kitti_vis也会自动跳过空文件,不会在视频里闪一帧黑屏。
3.5 关键代码骨架解析
我抽取一个核心片段,展示标定文件和投影函数怎么组织。完整代码比这个复杂,但主体逻辑就是这样。读取标定文件时,有个特别容易出问题的地方:Tr_velo_to_cam在KITTI标定文件中按行存储,但很多代码按列reshape,一错就全错。
def read_calib(calib_path): calib = {} with open(calib_path) as f: for line in f: key, *vals = line.split() if key in ['P0', 'P1', 'P2', 'P3']: calib[key] = np.array(vals, dtype=np.float32).reshape(3, 4) elif key == 'R0_rect': calib[key] = np.array(vals, dtype=np.float32).reshape(3, 3) elif key == 'Tr_velo_to_cam': calib[key] = np.array(vals, dtype=np.float32).reshape(3, 4) return calib投影函数也很直接,核心是构造齐次坐标并做矩阵乘法:
def velo_to_image(points, calib): P2 = calib['P2'] R0 = np.eye(4) R0[:3, :3] = calib['R0_rect'] Tr = np.vstack([calib['Tr_velo_to_cam'], [0, 0, 0, 1]]) pts_h = np.hstack([points[:, :3], np.ones((points.shape[0], 1))]) cam = pts_h @ Tr.T cam = cam @ R0.T img_pts = cam @ P2.T depth = img_pts[:, 2] img_pts[:, 0] /= img_pts[:, 2] img_pts[:, 1] /= img_pts[:, 2] return img_pts[:, :2], depth这段代码直接利用NumPy批量处理,几万个点也是一瞬间完成。depth返回后,就能用来做深度过滤和着色。实际项目中,我会再把裁剪、颜色映射这些逻辑单独抽成函数,方便复用。
4. 我踩过的坑和排查实录
4.1 点云和图像对不上?先查标定文件
最常见的问题就是点云投影到图像上错位,比如点云和车道线对不上,或者车角点落在路面下。90%的原因是标定矩阵读取方式不对。之前我接过一个同事的代码,他用reshape(3, 4, order='F')读Tr_velo_to_cam,出来的图像整体像哈哈镜,折腾很久才发现是读取顺序的问题。
还有R0_rect,它是一个3x3矩阵,但参与矩阵乘法时必须扩展成4x4。如果不扩展,NumPy维度都报错。更隐蔽的是扩展方式:左上角填R0_rect,右下角填1,其余填0。很多人把右下角也填成0,结果校正矩阵失效,图像还是能显示,但点云位置会逐渐偏移。
我的排查建议是:每次读取标定文件后,先用随机几个点做投影,把结果输出成文本,验证坐标量级是不是合理。如果点在图像边缘出现离谱的坐标值,基本就是矩阵顺序或齐次坐标的问题。kitti_vis还自带--check_calib模式,会在图上画出固定的一小段轨迹点,方便快速判断外参是否可靠。
4.2 画完框之后闪烁错位:深度排序的坑
如果你发现检测框的层次总是不对,近处的框被远处的框压住,而且换帧之后层次乱跳,那就是画图顺序没处理好。先画远框再画近框,本质上就是画家算法。批量模式下,每一帧都要重新算深度,因为车辆运动会让深度顺序变化,不能缓存第一帧的结果用一整段视频。
另外一个常见错误是:排序用的不是相机坐标下的z,而是雷达坐标下的x。虽然雷达坐标x和相机坐标z都大致指向车辆前方,但经过外参旋转之后,两者不完全等价。标准做法是先把目标中心投影到相机坐标系,取z做排序。kitti_vis封装好了这一步,用户传location字段进去,工具内部先做外参变换再判断遮挡顺序。
如果你还想做得更精细,可以先用2D框过滤出置信度高的目标,再只对这些目标排序。这样当一帧里有上百个低分框时,绘制顺序不会频繁抖动,输出视频也不容易出现“忽闪”感。
4.3 图像边界外的3D框怎么处理
投影出来的3D框经常有一部分在图像外面,OpenCV画线时会有溢出或难看的细线。如果车辆开到了画面边缘,检测框可能只剩两个点在图像里,强行连线会画出几条直穿画面的夸张斜线。
我的处理方式是先判断8个角点是否都在图外,如果都在就直接跳过。如果部分在图内,就用Cohen-Sutherland直线裁剪算法,对每条边做端点裁剪,只画图像范围内的线段。这样即使物体仅在画面边缘露出一小块,框也能正确地贴附着显示。
有些人会喜欢把3D框完整展示,方法是对框中心做缩放,让整辆车和框都压到图像内。但这样会破坏投影真实性,我默认不开启。因为在做检测评估时,画面中完整物体的尺寸比例很重要,如果你把框都缩放到固定大小,就很难判断远距离小目标的检测情况了。
4.4 性能优化:别用Matplotlib画实时
最早版本里我用Matplotlib做BEV,因为Matplotlib画散点图和矩形很方便。结果发现帧率上不去,单帧点云一多就要重绘UI,批量导出视频时要等很久。后来把所有绘图逻辑全部迁移到OpenCV,BEV面板也用NumPy矩阵直接构造,然后映射到像素坐标,再用line和circle函数画出来,效果完全不同。
点云太多时,还有一个技巧:先做随机降采样或者按距离分桶。比如保留每个0.1米栅格内的一个代表点,既保留整体轮廓,又把点数量降低一个数量级。视频导出时可以再启用多进程,多序列并行,速度提升非常明显。这些优化做完之后,kitti_vis才真正变成我能每天使用的工具,而不是一个只能跑演示的脚本。
4.5 换个数据集怎么办:外参适配
KITTI标定形式大家都熟,但换到nuScenes、Waymo或者自采数据时,标定格式完全变了。kitti_vis没有魔法,它只要求提供一个标定解析函数,返回三个矩阵:外参矩阵、校正矩阵、投影矩阵。换数据集时不需要改绘制逻辑,只改数据读取层。
我之前接了一个园区自采的雷达相机标定,标定结果是欧拉角加平移向量,自己写了一个函数转成4x4矩阵,然后传入kitti_vis一样能画。核心就是坐标系约定统一:雷达系、相机系、图像系三者的变换矩阵全部明确。只要这三步转换是明确且正确的,无论传感器型号是什么,都能复用同一套绘制逻辑。
如果你在NuScenes上做实验,官方给的标定是四元数和平移,需要先转旋转矩阵再构造外参。注意四元数的顺序是w,x,y,z还是x,y,z,w,不同的库解析结果完全不一样。这类问题通常会浪费很多时间,最好在读取层加一个单元测试,用已知坐标点验证投影结果是否符合预期。
5. 最后再分享一点个人体会
做可视化工具,最容易陷进去的是想把功能做全,比如交互旋转、点云分割同步显示、多传感器融合预览。但实际实验里,90%的时间你只需要一张图上同时出现图像、点云、3D框、BEV,能加速排查问题就够了。kitti_vis就是按这个原则做的,它不会替你做模型推理,也不会自动帮你修数据,但它能把“看结果”这个动作从十分钟压到十秒。
我后来给好几个同事装过这个工具,无一例外,用上手之后的第一个反馈都是“早知道当初先配好可视化再看论文”。如果你也在跟KITTI打交道,我建议你直接拿来当起点,按自己的数据格式扩展一层读取函数就行。踩坑是必然的,但把这些坑填平之后,你手里的工具会比任何“一键可视化”的现成平台都顺手。对了,最后还有一个技巧:当你把可视化流程跑顺之后,一定记得把抽取出来的“可视化回调”挂到训练循环里,每次验证完自动存图,别小看这一点,它对模型迭代的帮助比很多花哨的日志系统都大。