简介:这是一份面向自动驾驶感知初学者与点云可视化开发者的 KITTI 数据集可视化项目包,重点解决 .bin 点云读取、三维显示与 BEV 视角查看等上手问题,并借助 kitti_object_vis 完成目标框、图像与点云联动等多类可视化操作。压缩包共 42 个文件,约 9.81MB,包含 Python 脚本、Jupyter Notebook、示例点云 bin、效果图 png、说明文档 md 以及配置与标注相关 txt/xml 等,目录按可视化脚本、数据样例和项目配置区分,便于直接运行和二次修改。已有 2857 人学习下载,说明其在 KITTI 感知入门场景中具有一定参考价值。读者可据此快速理解点云与图像标注的对应关系,掌握 Lidar 点云、BEV 视图及九种可视化操作的调用方式,并参考现成脚本搭建自己的调试与展示流程。
1. 从一堆 000000.bin 开始:这套可视化工具到底解决什么问题
第一次打开 KITTI 的 velodyne 目录,看到的是 000000.bin 到 007480.bin 一串二进制文件,没有预览、没有缩略图,双击弹出来的全是乱码。很多人卡在这一步:明知里面躺着十几万个三维点,却看不到它长什么样。一个顺手的 KITTI 数据集可视化工具,要解决的就是这件事——把 bin 点云、image_2 相机图、calib 标定矩阵和 label_2 标注框拼回同一个场景,让点云可视化不再是"一团灰白色的雾",而是能一眼看出路面、车辆、行人和检测框的立体画面。这套代码面向两类人:刚拿到数据集、要做数据体检的算法同学,以及要把自己的检测结果叠回原图做演示的工程同学。它不训练任何模型,只干一件事——把 KITTI 的原始格式翻译成人眼能判断对错的东西。
2. 项目骨架与技术选型:一帧数据从 bin 到屏幕要走完的四步
可视化工具最容易写成一坨:读文件、算矩阵、画图、刷界面全塞进 main.py,三百行之后就没人敢动。这套代码的第一条原则是按"数据流"分层,而不是按"函数"分层。数据从磁盘进来,经过坐标变换,被着色、被绘制,最后交给界面——每一层只认 numpy 数组,不认文件路径,也不认 Qt 控件。这样一来,换数据集只改 io 层,换渲染后端只改 render 层,剩下的不用碰。
2.1 目录结构:io、geometry、render、ui 四层怎么分
先把文件清单摆出来,工程不大,但每个位置的职责是明确的:
| 模块目录 | 职责 | 关键文件 |
|---|---|---|
io/ | 读 bin、png、calib.txt、label_2,统一输出 numpy | kitti_reader.py、calib.py |
geometry/ | 坐标系变换、3D 框角点、BEV 栅格化 | transform.py、boxes.py |
render/ | 着色、2D 绘制、点云视窗封装 | colorize.py、pointview.py、bev.py |
ui/ | 主窗口、时间轴、图像与点云联动 | main_window.py、widgets.py |
tools/ | 批量截图、导出视频、数据体检脚本 | export_video.py |
config.py | 数据集根路径、颜色方案、下采样参数 | 全局常量 |
分层的收益不是"看起来专业",而是具体的:io/kitti_reader.py里只做np.fromfile和 reshape,返回(N, 4)的数组;render/colorize.py只接受(N, 4)返回(N, 4)的 RGBA;ui只负责把这两样东西塞进控件。我见过太多项目把cv2.imread直接写在鼠标回调里,后来想加个"导出当前帧"的功能,得把整个窗口类复制一遍。
config.py里放的几个常量值得单独提一句:DATA_ROOT指向数据集根目录(下面应该有velodyne/、image_2/、calib/、label_2/四个子目录),VOXEL_SIZE = 0.2控制下采样粒度,POINT_SIZE = 2.0控制点的大小(像素单位)。这几个数字后面调性能时天天要改,抽出来比散落在各处强。
2.2 技术栈选型:Open3D 验证想法,pyqtgraph 落到界面
依赖清单很短,装起来也就几十秒:
# Python 建议 3.8 ~ 3.10,open3d 的高版本 wheel 对小版本 Python 支持滞后 pip install numpy opencv-python pyqtgraph PyQt5 open3d为什么是这套组合,逐个说。
numpy是硬性要求。一帧 Velodyne 大约 12 万个点,任何形式的 Pythonfor循环逐点处理都是灾难——用向量化写法跑 10 毫秒,用循环跑 3 秒,差距就是这么直接。
Open3D的角色是"快速验证"。刚拿到数据时只需要三行代码就能确认 bin 文件读对了没有:pcd = o3d.geometry.PointCloud()、pcd.points = o3d.utility.Vector3dVector(pts[:, :3])、o3d.visualization.draw_geometries([pcd])。它自带鼠标旋转缩放、自带坐标轴,用来判断"点云是不是躺着的""场景里有没有车"足够快。但 Open3D 的窗口是独立进程式的,想在自己的 Qt 界面里嵌一个视口、下面挂一条时间轴,改造起来很别扭。
pyqtgraph 的 GLViewWidget是从原型走向工具的那一步。它本身就是 Qt 控件,addWidget进去就能和按钮、滑块同处一个窗口,底层走 OpenGL,十几万点的散点图刷新起来不吃力。GLScatterPlotItem支持逐点颜色和像素尺寸,正好对上点云着色的需求。
OpenCV负责 2D 侧:在image_2上画检测框、写类别和距离文字、画坐标轴。cv2.rectangle+cv2.putText+cv2.polylines三件套,比拼 matplotlib 快得多,也没有中文字体那些玄学问题(KITTI 的类别全是英文,正好躲开)。
PyQt5做外壳:文件列表、帧滑块、播放按钮、图像 QLabel、点云视口、BEV 面板。
有一张对比表放在这,选型时能省点纠结:
| 渲染后端 | 上手成本 | 能否嵌入 Qt | 12 万点交互帧率 | 适合场景 |
|---|---|---|---|---|
| Open3D 独立窗口 | 极低 | 困难 | 流畅 | 数据初查、离线截图 |
| pyqtgraph GLViewWidget | 中 | 原生支持 | 流畅 | 桌面工具、联动界面 |
| matplotlib 3D | 低 | 可以(但卡) | 个位数 | 出图、写报告 |
| three.js / Web | 高 | 不适用 | 取决于显卡 | 远程演示、多人查看 |
提示:如果机器上没有独立显卡或者驱动老旧,pyqtgraph 的 OpenGL 上下文可能创建失败,表现是窗口一片黑。先跑
python -c "import pyqtgraph.opengl"确认模块能导入,再排查显卡驱动。
2.3 数据流:加载一帧要走完的四步
一帧数据的生命周期是固定的四步,任何一步顺序错了都会在屏幕上体现为"对不上"。
第一步,定位文件。KITTI 的文件名统一 6 位补零,f"{idx:06d}"是唯一正确的拼法,写成str(idx)会得到0.bin而不是000000.bin,然后报"文件不存在"。图像、点云、标定、标注四个目录里,同名不同扩展名,帧号天然对齐,不需要额外的时间戳同步表。
第二步,读点云。np.fromfile一次性读成float32一维数组,再reshape(-1, 4),四列依次是 x、y、z(单位米)和反射强度(0~1)。这里有个必须做的断言:数组长度能不能被 4 整除。有些二次加工过的数据集会把强度裁掉变成三维,或者强度存成uint8,这时候 reshape 会直接抛错,早报错比晚出错强。
第三步,读标定。calib.txt是纯文本,每行键: 若干个浮点数,需要的三个键是P2(3×4 投影矩阵)、R0_rect(3×3 校正旋转)、Tr_velo_to_cam(3×4 雷达到相机变换)。另外还有Tr_imu_to_velo,做纯可视化用不上。
第四步,读标注并按需过滤。label_2每行 15 个字段,用空格分隔,包含类别、截断率、遮挡等级、观测角、2D 框、3D 尺寸和位置、旋转角、置信度。可视化时一般会过滤掉DontCare和截断率过高的目标,否则画面里全是莫名其妙的框。
这四步做完,得到四个 numpy 数组和三个矩阵,后面所有的绘制都是在它们上面做线性代数。把这四步写成四个纯函数,好处是单元测试好写——给一个已知帧号,断言点数在合理区间、断言投影后的框中心落在图像范围内,比肉眼盯着屏幕靠谱。
3. 核心模块拆解:标定矩阵、点云着色与 3D 框投影
这一章是整套代码里最容易翻车的部分。点云可视化看着简单,但只要涉及"把雷达系的东西画到图像上"或者反过来,坐标系就会开始咬人。下面四段代码基本覆盖了全部关键路径。
3.1 读 bin 与 calib:两段容易写错的解析代码
先是点云读取。这段代码短,但两个细节决定了后面会不会出问题:
# io/kitti_reader.py import numpy as np from pathlib import Path def frame_id(idx: int) -> str: """KITTI 文件名固定 6 位补零,str(idx) 会拼出 0.bin 这种错误路径""" return f"{idx:06d}" def load_velodyne(bin_path: Path) -> np.ndarray: """读取一帧 Velodyne 点云,返回 (N, 4) 的 float32 数组""" scan = np.fromfile(str(bin_path), dtype=np.float32) if scan.size == 0: raise ValueError(f"空文件: {bin_path}") if scan.size % 4 != 0: # 常见原因:数据集被二次处理成 (x, y, z) 三维,或强度存成了 uint8 raise ValueError(f"{bin_path} 长度 {scan.size} 不能被 4 整除") return scan.reshape(-1, 4) # 列顺序: x, y, z, intensitynp.fromfile比open().read()快一个量级,因为它直接把字节按 dtype 解释成数组,中间不经过 Python 对象。reshape(-1, 4)里的-1是让 numpy 自己算行数,写死行数反而容易和实际不符。
然后是标定解析。这三个矩阵是后面所有投影的地基,注释必须写清楚每个矩阵的维度:
# io/calib.py import numpy as np from pathlib import Path def load_calib(calib_path: Path) -> dict: """解析 calib.txt,返回齐次形式的三个矩阵""" raw = {} for line in Path(calib_path).read_text().splitlines(): if not line.strip(): continue key, _, value = line.partition(":") # 用 partition 而不是 split,避免值里出现冒号 raw[key.strip()] = np.array([float(v) for v in value.split()], dtype=np.float64) P2 = raw["P2"].reshape(3, 4) # 3x4,已含焦距/主点/基线,不需要再乘内参 R0 = np.eye(4) R0[:3, :3] = raw["R0_rect"].reshape(3, 3) # 3x3 校正旋转,提升到 4x4 便于连乘 Tr = np.eye(4) Tr[:3, :] = raw["Tr_velo_to_cam"].reshape(3, 4) # 3x4,提升为 4x4 return {"P2": P2, "R0": R0, "Tr": Tr}R0和Tr都被提升成了 4×4 齐次矩阵,这样后面直接用@连乘就行,不用手动补 1。P2保持 3×4,因为它的作用是投影不是变换——它把相机系的三维点压成像素坐标,输出的是二维齐次坐标。
把这三个矩阵组合起来,雷达点投到图像上的正确顺序是P2 @ R0 @ Tr,从右往左读:先把雷达系变到相机系(Tr),再做一次绕轴的校正旋转(R0),最后投影到像素平面(P2)。顺序反了不会报错,只会让框偏出几十个像素。
# geometry/transform.py def velo_to_image(points_velo: np.ndarray, calib: dict): """(N,4) 雷达点 -> 像素坐标 (N,2) 与相机系深度 (N,)""" n = points_velo.shape[0] xyz1 = np.hstack([points_velo[:, :3], np.ones((n, 1))]) # (N,4) cam = (calib["R0"] @ calib["Tr"]) @ xyz1.T # (4,N) 相机系 img = calib["P2"] @ cam[:3] # (3,N) 像素齐次坐标 depth = img[2] uv = img[:2] / np.maximum(depth, 1e-6) # 防除零 return uv.T, cam[2]np.maximum(depth, 1e-6)这个写法是为了防除零,但真正要处理的是"相机后方"的点:深度小于等于 0 的点投影出来会跑到图像的另一侧,看起来像是凭空多出一堆散点。调用方拿到depth之后必须做一次depth > 0的掩码过滤,这一步不能省。
3.2 点云着色:按高度、深度还是反射强度
不着色的点云是一片均匀的白,看不出结构。着色本质上就是给每个点算一个标量,再映射到色标上。
# render/colorize.py import numpy as np import matplotlib def colorize(points: np.ndarray, mode: str = "height") -> np.ndarray: """(N,4) 点云 -> (N,4) RGBA,取值 0~1""" if mode == "height": values = points[:, 2] # 雷达系 z 向上 elif mode == "depth": values = np.linalg.norm(points[:, :3], axis=1) # 到雷达原点的距离 elif mode == "intensity": values = points[:, 3] else: raise ValueError(f"未知着色模式: {mode}") # 用 2% / 98% 分位数而不是 min / max,几个离群噪点就会把整幅图压成一片蓝 lo, hi = np.percentile(values, 2), np.percentile(values, 98) norm = np.clip((values - lo) / max(hi - lo, 1e-6), 0.0, 1.0) cmap = matplotlib.colormaps["turbo"] # 新版 matplotlib 的取法 return cmap(norm)参数说明:mode决定语义,height适合看路面起伏和物体高度,depth适合看场景纵深,intensity适合看车道线和高反光路牌。lo和hi用分位数截断,是踩过坑之后固定下来的做法——KITTI 的某些帧里存在极远的噪点,min/max归一化会让 99% 的点全挤在色带中段,画面灰得没法看。
matplotlib.colormaps["turbo"]是 matplotlib 3.9 之后的取法,老版本用cm.get_cmap("turbo")。这个 API 改过,两个都写上 try/except 也行,但没必要为此增加复杂度,装新版就行。
3.3 3D 框生成与投影:把 label 里 8 个数变成 8 个角点
KITTI 的 3D 框在 label 里只有 7 个数:高 h、宽 w、长 l、中心 x、中心 y、中心 z、绕 y 轴旋转角 ry,全部在相机坐标系下,且 (x, y, z) 是底面中心不是几何中心。这一点是新手最常搞错的地方。
# geometry/boxes.py import numpy as np def rot_y(ry: float) -> np.ndarray: c, s = np.cos(ry), np.sin(ry) return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]]) def box3d_corners_cam(h, w, l, x, y, z, ry) -> np.ndarray: """返回 3x8 角点,相机坐标系。y 轴向下,所以底面 y 偏移为 0,顶面为 -h""" xs = np.array([ l/2, l/2, -l/2, -l/2, l/2, l/2, -l/2, -l/2]) ys = np.array([ 0, 0, 0, 0, -h, -h, -h, -h]) zs = np.array([ w/2, -w/2, -w/2, w/2, w/2, -w/2, -w/2, w/2]) corners = rot_y(ry) @ np.vstack([xs, ys, zs]) # 先旋转 corners += np.array([[x], [y], [z]]) # 再平移 return corners # 3x8角点顺序决定了连线怎么画。约定是:0~3 是底面四个角(顺时针),4~7 是顶面四个角,同一序号上下对应。连线规则因此是[(0,1),(1,2),(2,3),(3,0),(4,5),(5,6),(6,7),(7,4),(0,4),(1,5),(2,6),(3,7)],一共 12 条边。
要在点云里画这个框,得把角点从相机系变回雷达系,也就是乘(R0 @ Tr)的逆:
def corners_cam_to_velo(corners_cam: np.ndarray, calib: dict) -> np.ndarray: T = calib["R0"] @ calib["Tr"] T_inv = np.linalg.inv(T) # 4x4,逆变换 corners_h = np.vstack([corners_cam, np.ones((1, 8))]) return (T_inv @ corners_h)[:3] # 3x8,雷达系如果只是想在图像上画 2D 框,直接拿 label 里的 2D 字段(第 5~8 个字段)就行,不用自己投影;但用 3D 框投影出来的 2D 框才是有价值的——两者能对上,说明你的标定链路是对的,对不上就是矩阵顺序出问题了。这个交叉验证我每次改完标定代码都要跑一遍。
3.4 BEV 俯视图:把三维问题压成二维看
点云转起来好看,但判断"这辆车到底在不在车道内",俯视图比透视视角直观得多。BEV 的做法是把 x-y 平面栅格化,每个格子标记有没有点。
# render/bev.py import numpy as np def bev_image(points, res=0.1, x_range=(-10, 70), y_range=(-35, 35)): """点云 -> BEV 灰度图,res 单位米/像素""" W = int((x_range[1] - x_range[0]) / res) H = int((y_range[1] - y_range[0]) / res) canvas = np.zeros((H, W), dtype=np.uint8) xi = ((points[:, 0] - x_range[0]) / res).astype(np.int32) yi = ((points[:, 1] - y_range[0]) / res).astype(np.int32) valid = (xi >= 0) & (xi < W) & (yi >= 0) & (yi < H) # 图像坐标 y 向下,雷达坐标 y 向左,这里翻转一次才符合俯视图直觉 canvas[H - 1 - yi[valid], xi[valid]] = 200 return canvas参数res直接决定内存和刷新速度:70 米 × 70 米的范围,res=0.1得到 700×700 的图,转成 RGB 不到 1.5MB,随便刷;res=0.02就变成 3500×3500,单帧 36MB,翻页立刻卡成幻灯片。我一般固定 0.1,需要精修时再单独导出高分辨率版本。
BEV 上画框更简单,把 3D 框的 8 个角点取 x、y 两维,投影到栅格坐标,画底面那个矩形就行——俯视图里高度信息没用。
4. 交互层:PyQt5 主窗口、索引联动与播放
渲染部分跑通之后,剩下的问题是"怎么让人用得下去"。工具的意义在于快速翻帧、快速对比,所以界面布局和刷新性能比视觉效果重要得多。
4.1 主窗口布局:左图像、右点云、下时间轴
布局用QSplitter分成左右两半,左边竖着放"相机图像 + BEV",右边放点云视口,底部一条控件栏放滑块、帧号输入框和播放按钮。
# ui/main_window.py from PyQt5.QtWidgets import (QMainWindow, QWidget, QSplitter, QVBoxLayout, QHBoxLayout, QLabel, QSlider, QPushButton) from PyQt5.QtCore import Qt class MainWindow(QMainWindow): def __init__(self, reader, view): super().__init__() self.reader, self.view = reader, view # reader 负责取数据,view 负责画 self.setWindowTitle("KITTI 点云与图像联动查看器") self.image_label = QLabel(alignment=Qt.AlignCenter) self.bev_label = QLabel(alignment=Qt.AlignCenter) left = QWidget() left_layout = QVBoxLayout(left) left_layout.addWidget(self.image_label, stretch=3) left_layout.addWidget(self.bev_label, stretch=2) splitter = QSplitter(Qt.Horizontal) splitter.addWidget(left) splitter.addWidget(view) # GLViewWidget 本身就是一个 QWidget splitter.setSizes([640, 640]) self.slider = QSlider(Qt.Horizontal) self.slider.setRange(0, reader.num_frames - 1) self.slider.valueChanged.connect(self.on_frame_changed) self.frame_label = QLabel("000000") self.play_btn = QPushButton("播放") bar = QHBoxLayout() bar.addWidget(self.play_btn) bar.addWidget(self.slider, stretch=1) bar.addWidget(self.frame_label) root = QWidget() layout = QVBoxLayout(root) layout.addWidget(splitter, stretch=1) layout.addLayout(bar) self.setCentralWidget(root) def on_frame_changed(self, idx: int): self.frame_label.setText(f"{idx:06d}") frame = self.reader.load(idx) # 返回 dict: points/colors/image/boxes self.image_label.setPixmap(frame["image"]) self.bev_label.setPixmap(frame["bev"]) self.view.update_points(frame["points"], frame["colors"])关键设计是reader.load(idx)返回一个字典,界面层只管往里塞,不关心数据从哪来。这样一来,"换数据集"这件事被彻底隔离在 reader 内部。滑块用valueChanged而不是sliderMoved,因为键盘方向键也能触发前者的同时后者不触发,翻帧体验更一致。
4.2 点云视窗嵌入:GLViewWidget 与坐标轴交换
这是 pyqtgraph 的部分,也是最容易出问题的地方:
# render/pointview.py import numpy as np import pyqtgraph.opengl as gl class PointCloudView(gl.GLViewWidget): def __init__(self): super().__init__() # 从车后方偏上往下看,方位角 -90 表示相机朝向雷达 x 正方向 self.setCameraPosition(distance=35, elevation=18, azimuth=-90) self.setBackgroundColor((18, 18, 22)) self.cloud = gl.GLScatterPlotItem(pos=np.zeros((0, 3)), size=2, pxMode=True, color=(1, 1, 1, 1)) self.addItem(self.cloud) self.boxes = gl.GLLinePlotItem(pos=np.zeros((0, 3)), width=2, antialias=True) self.addItem(self.boxes) def update_points(self, points: np.ndarray, colors: np.ndarray): # pyqtgraph 默认 y 轴向上,KITTI 雷达系是 z 轴向上,必须换轴 xyz = np.stack([points[:, 0], points[:, 2], -points[:, 1]], axis=1) self.cloud.setData(pos=xyz, color=colors) def update_lines(self, segments: np.ndarray): self.boxes.setData(pos=segments, color=(1.0, 0.4, 0.1, 1.0), mode="lines")np.stack([x, z, -y])这一行是整个视口能不能看的关键。KITTI 的雷达系是右手系、z 向上、x 向前;pyqtgraph 的GLViewWidget默认 y 向上。不换轴的结果就是点云"贴在墙上"——所有物体侧躺着,路面变成竖直的平面。换成(x, z, -y)而不是(x, z, y),是因为换轴会改变手性,补一个负号才能保持左右不镜像。
pxMode=True配合size=2表示点的大小按屏幕像素算,不管远近都是 2 个像素;如果设成pxMode=False,size 就是世界坐标单位,远处的点会缩成看不见的小点。
4.3 帧切换性能:缓存、下采样和预加载
12 万点 × 每帧重新构造GLScatterPlotItem的数据,保持流畅是可以做到的,但界面会明显有一顿。三个优化按性价比排序:
# geometry/transform.py def voxel_downsample(points: np.ndarray, voxel: float = 0.2) -> np.ndarray: """体素下采样:每 voxel 立方体内只保留一个点,返回原数组的索引子集""" keys = np.floor(points[:, :3] / voxel).astype(np.int64) _, idx = np.unique(keys, axis=0, return_index=True) return points[idx]np.unique(axis=0, return_index=True)是按行去重,第二个返回值是保留下来的原始索引,这样强度、颜色等信息全都不丢。voxel=0.2在 12 万点上通常能压到 3~4 万,视觉上几乎看不出差别——因为 0.2 米小于点自身的分布尺度。
另外两个优化:一是给reader加一级 LRU 缓存(functools.lru_cache(maxsize=8)或者自己用OrderedDict管),翻回去看上一帧时不用重新读盘;二是播放时用QTimer以 10~15 fps 推进滑块,同时在后台线程预读下一帧的 bin 和 png。KITTI 单帧 png 约 1~2MB,cv2.imread一次要几毫秒,不做预读的话播放会一顿一顿的。
注意:pyqtgraph 的
setData必须回到主线程调用,后台线程只负责读文件和算数组,算完用pyqtSignal抛回界面线程。直接在子线程里改 GL 数据,运气好是花屏,运气差是段错误。
5. 避坑与排查:五条踩过的记录
这套代码在我手里反复重写过几遍,下面五条是每次重写都会重新遇到的。按"现象 → 原因 → 解决"记,方便对照。
5.1 点云"立在墙上",路面是竖直的
现象:点云加载出来了,点数也对,但整个场景像一面墙,车和树都侧着,怎么看怎么别扭。
原因:KITTI 雷达系是 z 轴向上,pyqtgraph 的GLViewWidget默认 y 轴向上,直接把(x, y, z)塞进去,等于把"上"这个方向搞错了。
解决:update_points里统一做(x, z, -y)换轴。注意第三个分量要取负,否则场景会左右镜像——这时候你看到的是一面"镜子里"的路,很难第一时间察觉,但一旦拿图像对比就会发现车道线方向反了。
5.2 3D 框整体偏了半个车身
现象:框的形状、朝向都对,就是位置不对,经常往车顶方向偏半个身高。
原因:把 label 里的相机系角点直接当成雷达系画了。label 的 7 个数全在相机坐标系下,而点云在雷达坐标系下,中间差了(R0 @ Tr)。
解决:画在点云里的框,必须先np.linalg.inv(R0 @ Tr)变回雷达系。另一个高频原因是把 label 的y当成了几何中心——它其实是底面中心,构造角点时顶面应该是y - h,写成y - h/2就会整体上移半个身高。
5.3 图像上的框和点云对不上,偏几十像素
现象:同一辆车,点云里框得挺准,图像里框歪出去一大截,而且越远的物体偏得越多。
原因:矩阵乘法顺序错了。常见写法是Tr @ R0,正确顺序是R0 @ Tr。这两个矩阵维度不同(4×4 和 4×4 提升之后同维),乘错了不报错,只是结果悄悄地错。
解决:写一个断言做交叉验证——用 3D 框投影出的 2D 框,和 label 里现成的 2D 框字段比对,中心点偏差超过 5 像素就报错。这个断言在改标定代码时救过我好几次。另外确认用的是P2,P3/P4是另外两个相机的投影矩阵,用错了整幅图会平移一个基线距离。
5.4 翻帧卡顿,内存一路涨到几个 G
现象:刚开始翻很流畅,翻到一两百帧之后明显变慢,任务管理器里内存曲线一路向上不回头。
原因:每次切帧都新建GLScatterPlotItem并addItem,旧的没删;或者把每帧的 png、点云全存在一个 list 里当缓存,没有上限。
解决:视口里的 item 只创建一次,后面全部走setData复用;帧缓存用固定容量的OrderedDict,超过 8 帧就弹出最旧的;点云加体素下采样,把上传 GPU 的数据量砍一半以上。
5.5 画面里一个框都没有
现象:点云和图像都正常,就是没有任何标注框,或者只剩零星几个。
原因:过滤条件写太狠。label_2里除了正常目标,还有DontCare区域和截断率、遮挡等级很高的目标,如果顺手写了truncated < 0.15 and occluded == 0,再叠上DontCare过滤,剩下的框自然寥寥无几。另一个原因是按行 split 之后字段数不是 15,索引对不上,取出来的 h、w、l 是nan。
解决:默认只过滤DontCare和type == "Misc",截断率和遮挡等级做成界面上的可调阈值;解析时先断言len(fields) == 15,不满足就跳过并打日志,不要静默失败。
6. 二次开发:把自己的检测结果接进来
真正会动这套代码的人,目的大多是"把我模型输出的结果叠上去看看"。最省事的路径不是改渲染层,而是写一个适配器,把自家结果转成label_2的 15 列格式,这样整套可视化逻辑一行都不用动。
| 列号 | 字段 | 说明 |
|---|---|---|
| 1 | type | 类别名,如 Car / Pedestrian |
| 2 | truncated | 截断率 0~1,自己的结果可以填 0 |
| 3 | occluded | 遮挡等级 0/1/2/3,可以填 0 |
| 4 | alpha | 观测角,不影响本工具的绘制 |
| 5~8 | bbox | 图像 2D 框 left/top/right/bottom |
| 9~11 | dimensions | 高 h、宽 w、长 l,单位米 |
| 12~14 | location | 相机系底面中心 x/y/z |
| 15 | rotation_y | 绕 y 轴旋转角,弧度 |
适配器的核心就是把模型输出的"中心点 + 尺寸 + 朝向"对齐到这张表。如果你的模型输出的是雷达系下的框,记得先乘R0 @ Tr转到相机系,别在渲染层做这件事——渲染层只认相机系。
# tools/adapter.py def det_to_kitti_line(det: dict, calib: dict) -> str: """把一条雷达系检测结果转成 label_2 的一行文本""" x, y, z = det["center"] # 底面中心,雷达系 corners_cam = (calib["R0"] @ calib["Tr"]) @ np.array([x, y, z, 1.0]) cx, cy, cz = corners_cam[:3] uv, depth = velo_to_image(np.array([[x, y, z, 0.0]]), calib) if depth[0] <= 0: return "" # 落在相机后方,直接丢弃 return (f'{det["cls"]} 0 0 0 ' f'{uv[0][0]:.2f} {uv[0][1]:.2f} {uv[0][0]:.2f} {uv[0][1]:.2f} ' f'{det["h"]:.2f} {det["w"]:.2f} {det["l"]:.2f} ' f'{cx:.2f} {cy:.2f} {cz:.2f} {det["ry"]:.2f}')这里 2D 框退化成了一个点,够用来做快速核对;要完整框的话,把 8 个角点全投影、取包络即可,代码在geometry/boxes.py里已经有现成的。
验证方法我固定用两步:先挑 5 帧肉眼对齐,看框是不是贴在车上;再跑一个批量脚本,对全部帧统计"投影后 2D 框落在图像内的比例",正常应该在 90% 以上。这个比例突然掉下去,八成是某个坐标系的符号写反了。
导出视频的部分很简单,cv2.VideoWriter把图像帧和 BEV 拼成左右两栏,15 fps,写 1000 帧也就几十秒。真正值钱的是那个比例断言——它能在你还没打开界面之前,就告诉你矩阵错了。
从那以后我每次改标定或坐标变换相关的代码,都强制先跑一遍"3D 框投影 vs 2D 框标注"的偏差统计,哪怕只改了一个负号。人眼会被一时的"看起来差不多"骗过去,数字不会。希望帮到你。
本文还有配套的精品资源,点击获取