ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

KITTI激光点云与图像叠加可视化:从标定投影到3D检测调试台

KITTI激光点云与图像叠加可视化:从标定投影到3D检测调试台

简介:这份面向自动驾驶感知与三维点云学习者的KITTI数据集可视化项目,聚焦点云、图像与标注结果的直观查看,适合具备Python基础、正在做目标检测或点云课程实验的初中级读者参考。包内提供.bin点云常规可视化与BEV俯视视图脚本,并借助kitti_object_vis整合出九类可视化操作,覆盖点云、图像、标注框及预测结果等场景,便于理解KITTI数据组织方式与感知结果呈现。压缩包共42个文件,约9.81MB,以Python脚本、Jupyter Notebook、txt/list配置说明、xml工程配置及png效果图为主,另有少量bin点云样例与pyc缓存,目录按可视化脚本、数据说明和示例图片分区,便于检索。已有2857人学习下载,读者可据此搭建点云查看与BEV展示流程,借助现成脚本和示例图对照标注内容,减少从零编写可视化代码的时间,并为检测结果分析与项目调试提供可复用的工具基础。

1. 一帧 12 万点的沉默:这个 KITTI 可视化项目解决的是什么问题

第一次打开 velodyne 目录,看到一堆 .bin 文件,多数人的第一反应是双击,然后什么也没发生。每帧十几万个 float32 四元组躺在二进制里,没有文件头、没有索引、没有颜色,必须把读取、标定、投影三件事全写完,屏幕上才会出现东西。做 KITTI 数据集可视化的这个工程,本质上就是把这条链路固定成一套可复用的代码:一帧激光点云、四张相机图、一套标定矩阵、一份 3D 标注框,在同一个窗口里对齐显示。点云可视化和图像叠加不是两个独立工具,而是同一套坐标系变换的两种输出。它适合刚拿到数据准备做 3D 检测的开发者,也适合已经跑通模型、需要一个稳定调试台的老手。

2. 工程结构与技术选型:从 bin 文件到屏幕上的联合视图

KITTI 的可视化难点不在渲染,而在“对齐”。四个相机、一个激光雷达、一份标定文件,任何一环搞错,点云就会整体飘到画面外,或者干脆镜像翻转。所以一个能长期用的可视化工程,通常会把“读数据”和“画东西”彻底切开:IO 层只负责把磁盘上的字节变成 numpy 数组,几何层只负责矩阵乘法,渲染层只负责把点和框交给 Open3D 或 OpenCV。分层的好处是,后面接自己的检测结果时,只需要替换 IO 层的一个函数,渲染代码一行都不用动。

2.1 四种文件与一条数据流

KITTI 目标检测子集里,同一帧编号会同时出现在四个目录下,文件名都是六位数字,这是能把它们串起来的唯一线索。常见做法是先按帧号索引,再按后缀区分类型,最后在内存里组装成一个 Frame 对象。

目录文件格式内容读出来是什么
velodyne.bin激光点云N×4 float32,列为 x, y, z, intensity
image_2.png左目彩色图1242×375 的三通道图
calib.txt标定参数七个键值对,含 P0~P3、R0_rect、Tr_velo_to_cam
label_2.txt3D 标注每行一个目标,共 15 列

一帧点云大约 12 万个点,bin 文件大小在 1.9MB 上下,这个量级很关键:它小到能整帧塞进内存随便算,又大到不能用 Python 的 for 循环逐点处理。后面所有的性能问题,都源于这个数量级。

数据流的走向是固定的:bin 文件读成 N×4 数组 → 取前三列补一列 1 变成齐次坐标 → 依次乘 Tr_velo_to_cam、R0_rect、P2 → 得到像素坐标和深度 → 一半送去 OpenCV 画在图上,一半送去 Open3D 做三维显示。标注框走的是另一条路,从 label_2 的 15 列还原出八个角点,再乘同一套外参搬到相机坐标系里。

目录组织上,一个比较舒服的划分是这样:

kitti-vis/ ├── configs/ │ └── default.yaml # 数据根目录、颜色方案、窗口尺寸 ├── kitti_vis/ │ ├── io/ │ │ ├── velodyne.py # bin -> ndarray │ │ ├── calib.py # txt -> P2 / R0 / Tr │ │ ├── label.py # txt -> 目标字典列表 │ │ └── image.py # png 读取与尺寸缓存 │ ├── geometry/ │ │ ├── transform.py # 齐次变换、投影 │ │ └── boxes.py # 3D 框角点与连线 │ ├── render/ │ │ ├── pointcloud.py # Open3D 几何体构造与更新 │ │ ├── overlay.py # 图像叠加与着色 │ │ └── canvas.py # 多视图拼合 │ ├── player.py # 帧循环与交互 │ └── cli.py # 命令行入口 ├── scripts/ │ └── export_video.py ├── requirements.txt └── README.md

这不一定是唯一写法,但把 io / geometry / render 三层分开,是这类项目最省心的结构。原因很实际:调坐标变换的时候你会反复改 geometry,但只要渲染稳定了就不用再碰;换着色方案的时候只改 render,不会误伤已经验证过的投影公式。把所有东西塞进一个main.py也能跑,但改到第三天你就会想重写。

2.2 技术栈分工:numpy 管解析、Open3D 管三维、OpenCV 管叠图

三个库各司其职,边界清楚。numpy 负责二进制解析和矩阵运算,KITTI 的投影链本质上是三次矩阵乘法,用 numpy 写出来是四行代码,而且天然向量化,12 万个点一次算完。Open3D 负责三维部分的窗口、相机控制、点云几何体,它的PointCloud接受Vector3dVector,从 numpy 数组构造几乎零拷贝。OpenCV 负责读图和把投影点画上去,同时承担最后的画布拼合。

也有人用 PCL 或者 ROS 的 rviz 来做。PCL 的 C++ 版本性能更好,点云滤波、分割算法齐全,但要写 CMakeLists、编译、处理版本兼容,光环境就能耗掉半天。rviz 适合已经有 ROS 环境的场景,能直接订阅话题,但要把 KITTI 的离线文件灌进去,还得先写一个 publisher 节点,多一层不必要的转发。如果目标只是“把数据看清楚、把自己的检测结果叠上去”,Python + Open3D + OpenCV 的组合是投入产出比最高的一条路。

提示:Open3D 的窗口默认用右手系、y 轴朝上的视图习惯,而 KITTI 的相机坐标系是 y 轴朝下。不要试图去改 Open3D 的坐标系,正确做法是在喂几何体之前,把点云统一变换到相机坐标系,让“数据”去适配“显示约定”,而不是反过来。

2.3 环境准备:Python 3.9 与最小依赖清单

依赖不多,但版本要卡一下。Open3D 在 0.17 之后对 Python 版本要求变严,numpy 2.x 和部分旧版 Open3D 混用会出现 ABI 报错,这类问题排查起来很费时间,不如一开始就锁死。

# 建议用虚拟环境,别在系统 Python 里折腾 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt
# requirements.txt numpy==1.26.4 open3d==0.18.0 opencv-python==4.9.0.80 pyyaml==6.0.1 tqdm==4.66.2

numpy 锁在 1.26 是血泪经验:1.24 之前np.float还能用,1.26 彻底移除,而 2.x 又改了一批 C-API,Open3D 的预编译包在部分平台上会直接 import 失败。opencv-python 装 headless 版本也行,这个项目不需要imshow之外的 GUI 功能,服务器上跑批处理时 headless 更省事。

装完验证一下,这三行不报错就说明环境没问题:

import numpy as np, open3d as o3d, cv2 print(np.__version__, o3d.__version__, cv2.__version__)

3. 核心模块拆解:读取、标定、投影、画框四段链路

真正决定这个项目好不好用的,是四个函数:读点云、读标定、做投影、还原框。它们的输入输出一旦定下来,整个工程就是在这四个函数上面搭积木。下面按调用顺序拆开讲,每段都能单独抄出来验证。

3.1 点云读取:把 bin 还原成 N×4 的 float32 数组

KITTI 的 velodyne 文件没有魔数、没有 header、没有点数标记,它就是一串连续的 little-endian float32,每四个一组。所以读取只有一行,但这一行必须带上形状校验,否则路径写错读到别的文件时,reshape 会抛一个完全看不懂的错误。

import numpy as np def load_velodyne(bin_path): """ 读取 KITTI velodyne 的 bin 文件。 每个点 4 个 float32:x, y, z, intensity(反射强度 0~1) 返回 (N, 4) 的 float32 数组 """ raw = np.fromfile(bin_path, dtype=np.float32) if raw.size % 4 != 0: raise ValueError(f'{bin_path} 不是合法的 velodyne 文件,长度 {raw.size} 不是 4 的倍数') points = raw.reshape(-1, 4) if points.shape[0] < 1000: raise ValueError(f'{bin_path} 只有 {points.shape[0]} 个点,疑似读错文件') return points

np.fromfile直接按 dtype 解释字节,比struct.unpack快一到两个数量级,12 万点大概 2ms 读完。reshape(-1, 4)里的 -1 让 numpy 自己推点数,比硬编码安全。第二个校验(点数小于 1000 报错)是我后来加的:有一次把 image_2 的 png 路径写进了点云路径,png 的头几个字节被当成 float32 解释,reshape 居然通过了,只是点数只有 3 个,后面投影出来一片空白,查了半小时才发现是路径串了。

点云的坐标系是雷达系:x 向前、y 向左、z 向上,原点在雷达安装位置。这是后面所有变换的起点,记错这个方向,投影出来的图会左右镜像。

3.2 标定解析与投影:三个矩阵决定点落在哪个像素

calib 文件是纯文本的键值对,每行形如P2: 12 个数字。要用的就三个:P2 是左目相机的 3×4 投影矩阵,R0_rect 是 3×3 的整流旋转,Tr_velo_to_cam 是 3×4 的雷达到相机外参。注意后两个在文件里是扁平的 9 个和 12 个数字,要 reshape 成矩阵,还要补成 4×4 齐次形式才能连乘。

import numpy as np def load_calib(calib_path): """ 解析 KITTI calib txt,返回三个 4x4 矩阵: P2 (3x4 补成 4x4)、R0 (4x4)、Tr (4x4) """ data = {} with open(calib_path, 'r') as f: for line in f: if ':' not in line: continue key, value = line.split(':', 1) data[key.strip()] = np.array([float(x) for x in value.split()]) P2 = np.eye(4) P2[:3, :4] = data['P2'].reshape(3, 4) R0 = np.eye(4) R0[:3, :3] = data['R0_rect'].reshape(3, 3) # 9 个数 -> 3x3 Tr = np.eye(4) Tr[:3, :4] = data['Tr_velo_to_cam'].reshape(3, 4) # 12 个数 -> 3x4 return P2, R0, Tr

补成 4×4 是为了能连乘,最后一步取前三行丢掉齐次行。这里有个容易忽略的点:R0_rect 在公式里的位置是夹在 P2 和 Tr 中间,顺序是P2 @ R0 @ Tr @ [x, y, z, 1],不能写成P2 @ Tr @ R0。乘法不满足交换律,写反了图不会报错,只是点会整体偏出画面,属于最典型的“看起来能跑但结果是错的”。

def project_velo_to_image(points, P2, R0, Tr, img_w=1242, img_h=375): """ points: (N, 4) 雷达点云 返回 uv (M, 2) 像素坐标、depth (M,) 相机系深度、mask (N,) 有效掩码 """ N = points.shape[0] xyz1 = np.hstack([points[:, :3], np.ones((N, 1), dtype=np.float32)]) # (N, 4) cam = (R0 @ Tr @ xyz1.T) # (4, N) 雷达 -> 相机 uv1 = P2 @ cam # (4, N) 相机 -> 像素齐次 u = uv1[0] / uv1[2] v = uv1[1] / uv1[2] depth = uv1[2] # 只保留落在图像内、且在相机前方的点 mask = (depth > 1.0) & (u >= 0) & (u < img_w) & (v >= 0) & (v < img_h) return np.stack([u[mask], v[mask]], axis=1), depth[mask], mask

depth > 1.0而不是> 0,是因为雷达有极小一部分点落在相机后方,除以一个接近 0 的 z 会产生巨大的坐标,虽然被后面的边界检查挡住了,但中间可能出现 inf 参与运算。给个 1 米的下限,既排除了后方点,又排除了贴在镜头上的异常值,实测更稳。

向量化的收益在这里体现得最明显:12 万点一次矩阵乘法约 8ms,如果写成 Python 循环逐个点算,得跑到 3 秒以上,播放就完全没法看了。

3.3 标注框还原:label_2 的 15 列怎么变成八个角点

label_2 每行 15 列,空格分隔。前四列是类别、截断率、遮挡程度、观察角,接着四列是 2D 框的左上右下,再往后是 3D 框的高宽长、底面中心 xyz、绕 y 轴的偏航角。跳过 DontCare 是必须的,这类目标没有可靠的 3D 信息。

def load_labels(label_path): """解析 label_2,返回目标字典列表;DontCare 直接跳过""" objs = [] with open(label_path, 'r') as f: for line in f: p = line.strip().split() if len(p) < 15 or p[0] == 'DontCare': continue objs.append({ 'type': p[0], 'h': float(p[8]), 'w': float(p[9]), 'l': float(p[10]), 'x': float(p[11]), 'y': float(p[12]), 'z': float(p[13]), 'ry': float(p[14]), }) return objs

角点计算是最容易翻车的地方。KITTI 的 (x, y, z) 是底面中心,不是几何中心;相机坐标系 y 轴朝下,所以车顶方向的偏移是 -h 而不是 +h。把这两点搞反,画出来的框会沉到地底下,或者上下颠倒。

import numpy as np # 八个角点的连线顺序,用于 cv2.line 或 LineSet BOX_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)] def box3d_corners(h, w, l, x, y, z, ry): """ h/w/l: 高、宽、长(米) x/y/z: 底面中心在相机坐标系下的位置 ry: 绕 y 轴的偏航角(弧度) 返回 (8, 3) 角点 """ xc = np.array([ l/2, l/2, -l/2, -l/2, l/2, l/2, -l/2, -l/2]) yc = np.array([ 0, 0, 0, 0, -h, -h, -h, -h]) # 顶面是 -h zc = np.array([ w/2, -w/2, -w/2, w/2, w/2, -w/2, -w/2, w/2]) corners = np.stack([xc, yc, zc]) # (3, 8) c, s = np.cos(ry), np.sin(ry) R = np.array([[ c, 0, s], [ 0, 1, 0], [-s, 0, c]]) # 绕 y 轴的旋转矩阵 corners = R @ corners + np.array([[x], [y], [z]]) return corners.T

验证方法很土但很有效:拿一帧有 Car 标注的数据,把框画到图像上,看框的底边是不是贴着车轮、顶边是不是盖住车顶。如果框整体悬空半米,大概率是把 y 当成了几何中心又减了一次 h/2。

3.4 渲染与播放:Open3D 窗口与 OpenCV 画布的拼合

渲染层要同时满足两个需求:三维里能自由旋转看结构,二维里能核对投影对不对。常见做法是开两个窗口,或者把 OpenCV 画好的图转成 Open3D 的纹理贴在一个平面上,做左右分屏。更省事的方案是只开 Open3D 窗口显示点云,图像叠加结果单独用cv2.imwrite存盘,调的时候看文件,不占屏幕。

图像叠加的高效写法是 numpy 批量索引,而不是逐点cv2.circle:

import cv2 import numpy as np def draw_points_on_image(img, uv, depth, max_depth=60.0): """按深度着色叠加到图像上:近处偏红、远处偏蓝。img 是 BGR""" d = np.clip(depth / max_depth, 0.0, 1.0) # 注意 OpenCV 是 BGR 顺序,第 0 通道是蓝 colors = np.stack([ (255 * d).astype(np.uint8), # B:越远越蓝 (60 * (1 - np.abs(d - 0.5) * 2)).astype(np.uint8), # G:中间亮 (255 * (1 - d)).astype(np.uint8), # R:越近越红 ], axis=1) u = uv[:, 0].astype(np.int32) v = uv[:, 1].astype(np.int32) img[v, u] = colors # 同一像素被多个点命中时,后写的覆盖先写的 return img

img[v, u] = colors是 numpy 的花式索引,一次写入十万个像素,耗时不到 2ms。换成for循环加cv2.circle,同样的点要 300ms 以上,连续帧播放立刻掉到 3 帧。如果想让点看起来粗一点,正确做法是先上采样图像再写像素,而不是给每个点画半径 2 的圆。

Open3D 那边,播放循环要复用同一个几何体对象,而不是每帧新建:

import open3d as o3d import numpy as np def play_sequence(frames, fps=10): """frames 是 [(N,4) 点云, ...],循环播放,复用同一个 PointCloud""" vis = o3d.visualization.Visualizer() vis.create_window(window_name='kitti', width=1280, height=720) pcd = o3d.geometry.PointCloud() vis.add_geometry(pcd) first = True for pts in frames: pcd.points = o3d.utility.Vector3dVector(pts[:, :3]) if first: vis.reset_view_point(True) # 只在第一帧重置视角 first = False vis.update_geometry(pcd) vis.poll_events() vis.update_renderer() # 这里按 fps 做简单节流,实际项目里用 time.sleep vis.destroy_window()

reset_view_point只调一次很关键。每帧都重置视角的话,窗口里的相机永远回到初始位置,你想换个角度看细节,一帧之后就被拽回去了,交互体验直接归零。

4. 跑通全流程:从数据落盘到连续帧播放

代码看懂了不等于跑得起来。KITTI 的目录层级有两三套不同的组织方式,官方压缩包解压出来是training/velodyne,有些镜像站会给成data_object_velodyne/training/velodyne,还有些人自己整理成扁平的velodyne/。工程的加载器如果只认一种,换台机器就得改代码。这一章按“放数据 → 单帧验证 → 批量跑”的顺序走一遍。

4.1 数据落盘:三种目录布局与一份配置

我一般会在配置文件里写一个根目录加上一个可选的layout字段,加载器根据 layout 拼路径,而不是在代码里硬编码。这样换数据集只改 yaml,不动 Python。

# configs/default.yaml data: root: /data/kitti/object layout: nested # nested | flat | object split: training render: point_size: 2.0 color_by: depth # depth | height | intensity max_depth: 60.0 window: width: 1280 height: 720 fps: 10

三种 layout 对应的路径拼法:nested是{root}/{split}/velodyne,flat是{root}/velodyne,object是{root}/data_object_velodyne/{split}/velodyne。写一个resolve(root, split, kind, layout)函数统一处理,返回一个Path,后面所有 IO 都从这里取。

from pathlib import Path LAYOUTS = { 'nested': '{root}/{split}/{kind}', 'flat': '{root}/{kind}', 'object': '{root}/data_object_{kind}/{split}', } def resolve(root, split, kind, layout='nested'): """kind: velodyne / image_2 / calib / label_2""" pattern = LAYOUTS[layout] path = Path(pattern.format(root=root, split=split, kind=kind)) if not path.exists(): raise FileNotFoundError(f'目录不存在: {path},检查 layout 配置') return path

那个exists()检查看着多余,实际上能省掉大量排查时间。路径拼错的时候,报错信息会直接告诉你拼出来的是什么,而不是等到np.fromfile抛一个FileNotFoundError: [Errno 2]让你去猜是哪一层少了。

4.2 单帧模式:先让第 0 帧对得上

不要一上来就跑全量。先挑一帧有明确目标的(比如 000000,画面里有车有行人),把点云、图像、标注三样东西对齐了,再谈别的。命令行入口建议做成这样:

# 只显示图像叠加结果,存盘查看 python -m kitti_vis.cli \ --config configs/default.yaml \ --frame 0 \ --mode overlay \ --save out/frame_000000.png # 打开 Open3D 窗口看三维点云 + 3D 框 python -m kitti_vis.cli \ --config configs/default.yaml \ --frame 0 \ --mode 3d

--mode支持overlay、3d、both三个值。both会开 Open3D 窗口,同时在旁边用 OpenCV 窗口显示叠加图。参数里--frame接受单个数字,也接受0-50这种区间写法,区间交给range解析。

单帧跑通之后,重点看三件事:点云投影到图像上,车身的轮廓是不是能被点勾出来;3D 框的底边是不是贴着地面;点云的左右方向是不是和图像一致(车在图的左边,点云里也应该在左边)。这三条都对了,说明标定链和坐标系没搞反。

注意:如果你的显示器是高分屏,Open3D 窗口里的点可能看起来特别小。这是 DPI 缩放的问题,不是点云的问题。调render.point_size到 4 或 6 再试,别去改数据。

4.3 连续帧与批量导出:把调试过程留下来

连续帧的价值在于看动态。单独一帧看不出目标运动是否合理,连续 50 帧一放,跟踪有没有跳变、框有没有抖,一眼就看出来了。批量导出则是为了写报告或者对照模型输出。

import cv2 from pathlib import Path def export_range(root, split, start, end, out_dir, layout='nested'): """批量渲染并导出叠加图,用于离线比对""" out = Path(out_dir) out.mkdir(parents=True, exist_ok=True) velo_dir = resolve(root, split, 'velodyne', layout) calib_dir = resolve(root, split, 'calib', layout) img_dir = resolve(root, split, 'image_2', layout) for idx in range(start, end): name = f'{idx:06d}' velo = load_velodyne(velo_dir / f'{name}.bin') P2, R0, Tr = load_calib(calib_dir / f'{name}.txt') img = cv2.imread(str(img_dir / f'{name}.png')) uv, depth, _ = project_velo_to_image(velo, P2, R0, Tr, img_w=img.shape[1], img_h=img.shape[0]) canvas = draw_points_on_image(img, uv, depth) cv2.imwrite(str(out / f'{name}.png'), canvas) print(f'[{idx - start + 1}/{end - start}] {name} 完成')

注意img_w/img_h是从实际读到的图像尺寸取的,而不是写死 1242×375。KITTI 里绝大多数图是这个尺寸,但裁剪过或者缩放过的版本也流通过,写死会导致投影点全部落到边界外面。

50 帧的导出实测在 12 秒左右,瓶颈在读 png(每张 1242×375 的图解码约 8ms)。如果只需要点云不要图像,可以跳过imread,速度快三倍。导出结果建议按帧号命名,和原始数据一一对应,后面拿模型预测结果做对比时能直接按文件名配对。

5. 避坑与排查:KITTI 可视化最容易翻车的几处细节

这套代码逻辑上只有四步,但每一步都有能让结果“看起来对、实际错”的坑。下面这些是我和别人实际踩过的,按现象分类整理,遇到问题时可以先对号入座。

5.1 数据与路径类:读得到文件但显示不对

现象一:点云投影出来只有左半边有,右半边空空如也。原因基本可以锁定在投影矩阵选错了。calib 文件里有 P0 到 P3 四个矩阵,分别对应四个相机,只有 P2 对应 image_2。用 P0 或者 P3 去投 image_2 的图,基线不同,画面会整体平移,通常表现为点云挤在一侧、另一半没有点。解决办法是把load_calib里取的键固定成P2,并且把这个名字写进配置,不要用“取第一个 P 开头的键”这种偷懒写法。

现象二:reshape 报cannot reshape array of size N into shape (4)。文件读错了。最常见的是把 label_2 的 txt 或者 image 的 png 传进了点云加载函数,其次是下载了 velodyne 的降采样版本(有些第三方整理版会把点云抽稀)。前面load_velodyne里的两个校验就是为这个准备的:长度不是 4 的倍数直接报错并打印路径,点数小于 1000 也报错。有了这两条,排查时间能从半小时压到十秒。

现象三:换一台机器跑,所有路径都不对。十有八九是用了相对路径,而工作目录变了。统一用配置文件里的绝对路径,或者在入口处Path(root).resolve()一次,后面全部基于这个绝对路径拼。别在多个模块里各自os.path.join,迟早会出现一层目录名不一致。

5.2 坐标与渲染类:点云飘了、框歪了

现象四:点云整体左右镜像,图像里车在左,点云里车在右。典型的 y 轴方向搞反。雷达系 y 朝左,相机系 y 朝下,如果你手动改过Tr_velo_to_cam或者自己写了一个“简化版”外参,很容易在符号上出错。最省事的验证方式是只用一个点:取雷达系里 (10, 0, 0) 这个正前方的点,投影后 u 应该接近图像中心 621。如果 u 差了一倍宽度,说明 y 的符号错了。不要凭感觉调参数,用单个点定位。

现象五:3D 框沉到地面以下,或者整个框倒过来扣在车上。KITTI 的 3D 位置是底面中心,且相机系 y 向下。写角点时如果按“几何中心”处理,把 yc 写成[-h/2, -h/2, ...]和[h/2, ...],框就会下沉 h/2。另一种情况是把yc写成[0, ..., h, ...],顶面跑到下面去了,看起来像框倒扣着。正确写法就是 3.3 节里的[0, 0, 0, 0, -h, -h, -h, -h],底面的四个点 y 偏移为 0,顶面为 -h。

现象六:点云全是黑的,什么都看不见。如果着色用的是 intensity,而这一帧的 intensity 恰好全 0 或者分布极窄,整片点都是同一个颜色。KITTI 的 intensity 是反射率,在晴天和阴天拍的数据里范围差别不小,直接intensity * 255就很容易糊成一片。稳妥做法是用深度或者高度着色,或者对 intensity 做一次百分位归一化:取 2% 和 98% 分位数做 min-max,把极端值掐掉。

5.3 性能与交互类:点多了就卡

现象七:单帧很流畅,连续播放掉到 3 帧,风扇狂转。两个原因叠加。一是每帧新建了o3d.geometry.PointCloud()并且add_geometry,Open3D 每次都要重新分配 GPU 缓冲;二是图像叠加用了逐点cv2.circle。前者改成复用同一个对象、只更新points,后者改成 numpy 批量索引,两项加起来能把单帧耗时从 400ms 压到 20ms 以内。

现象八:想看得细一点,一放大就卡得转不动。12 万点在桌面级显卡上其实不算多,卡顿往往是因为同时开了太多几何体:点云 + 八个角点的 LineSet + 每个目标的坐标系,几百个几何体一起渲染,批次开销比点数本身更致命。解决办法是把所有框合并成一个LineSet(lines和points拼起来),一次add_geometry,渲染批次从几百降到一。

import open3d as o3d import numpy as np def build_boxes_lineset(all_corners, color=(1, 0, 0)): """把一帧里所有 3D 框合并成一个 LineSet,避免每框一个几何体""" points, lines = [], [] for i, corners in enumerate(all_corners): base = i * 8 points.append(corners) lines.extend([(base + a, base + b) for a, b in BOX_EDGES]) ls = o3d.geometry.LineSet() ls.points = o3d.utility.Vector3dVector(np.vstack(points)) ls.lines = o3d.utility.Vector2iVector(np.array(lines)) ls.colors = o3d.utility.Vector3dVector([color] * len(lines)) return ls

如果连合并之后还是卡,再加一层体素降采样pcd.voxel_down_sample(voxel_size=0.15)。0.15 米的体素对看整体结构完全够用,点数能降到三万左右,视觉上几乎看不出差别。但注意降采样只用来看,做精度评估的时候必须用原始点。

现象九:窗口关掉之后进程不退出,终端卡住。Open3D 的Visualizer在某些平台上destroy_window()之后还有后台线程没回收干净。加一句vis.close()再del vis,或者在try/finally里做清理,能解决大部分情况。如果是批量脚本,干脆不要在循环里开窗口,用离屏渲染OffscreenRenderer输出图片。

6. 二次开发:把它改成你自己的检测结果调试台

可视化工程真正的价值,不在于能看 KITTI 的原始数据,而在于它是一个现成的“结果核对台”。模型训练完,预测出来的框对不对、偏了多少、有没有漏检,最终都要落到这套坐标系里才能看明白。改造的切入点其实只有一个:把load_labels换成load_predictions,输入从 label_2 的 txt 变成你自己的输出格式,后面的角点计算和渲染一行都不用动。

我一般会在工程里预留两个钩子:一个是predict_loader,接受(frame_id) -> List[dict],字典字段和 label 保持一致(type, h, w, l, x, y, z, ry, score);另一个是color_map,按类别或者按 score 上色,预测框用半透明绿、真值框用红色,同一画面里叠着看,偏多少一目了然。扩展点大致是这几处:

扩展点改哪个文件典型用途
预测结果接入io/label.py接自己的检测器输出
着色策略render/overlay.py按类别、按置信度、按误差着色
几何体类型render/pointcloud.py加点云语义分割的类别颜色
输出格式scripts/export_video.py导出对比视频给他人复核

接预测结果的时候,最容易忽略的是坐标系。很多检测器的输出是雷达系下的框,而 KITTI 的 label 是相机系下的框,中间差一个Tr_velo_to_cam加R0_rect。验证方法很简单:先把真值框走一遍同样的变换,如果变换后的框和原始 label 画出来的位置一致,说明变换写对了;再把预测框塞进去,位置自然就对了。这一步千万别跳,我见过直接拿雷达系坐标往相机系上画的,框全在天上飞,还以为是模型崩了。

def velo_box_to_cam(box, R0, Tr): """把雷达系下的 3D 框搬到相机系:位置做齐次变换,偏航角加 90 度""" p = np.array([box['x'], box['y'], box['z'], 1.0]) p_cam = (R0 @ Tr @ p)[:3] return {**box, 'x': p_cam[0], 'y': p_cam[1], 'z': p_cam[2], 'ry': box['ry'] - np.pi / 2} # 雷达系 x 向前,相机系 z 向前,差 90 度

那个-pi/2是最容易被忘掉的。位置变换对了、朝向差 90 度,框会横着穿在车上,看起来像检测算法把车当成了横向停放。我第一次遇到时盯着看了二十分钟,还去翻了模型的 NMS 实现,最后发现是角度没转。

从那以后我每次接新模型的输出,都强制走一遍“真值回环”验证:拿标注文件当预测结果喂进去,渲染出来的画面必须和直接读 label 完全重合,差一个像素都要查清楚。这个习惯帮我省掉了无数次“到底是模型不行还是可视化写错了”的自我怀疑。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表