KITTI点云可视化与标定投影校验实战
简介:面向自动驾驶感知学习与点云可视化实践者,这套KITTI数据集可视化项目聚焦.bin点云查看、鸟瞰图展示以及多类标注结果呈现,适合已具备Python基础、希望快速理解KITTI数据组织结构与可视化流程的读者。压缩包共42个文件,约9.81MB,以py脚本、ipynb示例、png预览图、txt/xml标注与配置文件为主,覆盖点云读取、BEV视图、目标框叠加和多视图渲染等环节,便于按模块查阅与复现。已有2857人学习下载。借助其中的脚本与示例,读者可对照KITTI标注理解点云、图像和标签之间的对应关系,掌握基于开源可视化工具的9类展示操作,并据此搭建自己的调试环境,或扩展至检测、跟踪任务的可视化验证流程。
1. 从一堆 .bin 到能转的 3D 场景:KITTI 数据集可视化到底在解决什么
很多人拿到 KITTI 的第一反应是直接跑检测模型,训了两天 loss 不降,回头排查才发现是标定矩阵拼接少了一行,或者点云读进来被 reshape 成了 4×N。这类问题在 loss 曲线上看不出来,但在一个能鼠标拖着转的点云可视化窗口里,第一帧就能暴露。这份可视化项目的定位就是把数据侧的"黑匣子"打开:给定一帧编号,把 image_2 的彩色图、velodyne 的 .bin 点云、calib 的七行标定、label_2 的 3D 框摆在同一套坐标系里,能叠、能转、能对照。做 3D 检测预研的、做多传感器融合标定的、以及要人工复核标注质量的团队,都能直接拿它当第一道 sanity check。它不训练任何模型,只负责回答一个问题:我读进来的这帧数据,到底对不对。
2. 目录与坐标系:先把四类文件的对应关系钉死
可视化做不出来,十次里有七次不是代码错,是文件配错或者坐标系搞混。所以动手写渲染之前,得先把 KITTI 的一帧记录拆干净,弄清楚哪些数字属于哪个坐标系,变换链路上有几段。
2.1 一条帧记录由四个文件拼成
KITTI 的 3D 检测子集按帧号组织,同一个000123在四个目录里各有一份文件,文件名主干完全一致。训练集七千多帧,每帧的这四份文件必须能对上,缺一份可视化就不完整。常见的目录结构是这样的(解压后一般会多一层training/):
data/kitti/ ├── training/ │ ├── image_2/000123.png # 左目彩色图,1242x375 │ ├── velodyne/000123.bin # 激光点云,float32,N x 4 │ ├── calib/000123.txt # 7 行标定参数 │ └── label_2/000123.txt # 每行一个目标,15 列 └── testing/ # 无 label_2四个文件的角色分工如下。这张表建议直接贴在项目 README 里,后面 Debug 时对着查比翻官方文档快。
| 文件 | 格式 | 关键维度 | 在可视化里的作用 |
|---|---|---|---|
image_2/*.png | PNG,8bit RGB | 1242 × 375 | 底图,投影落点都画在它上面 |
velodyne/*.bin | 纯二进制,float32 | N × 4(x, y, z, intensity) | 3D 场景主体,也是投影的输入 |
calib/*.txt | 文本,7 行键值 | P0~P3 各 12 数,R0_rect 9 数,Tr_* 各 12 数 | 把雷达点搬到像素上的唯一依据 |
label_2/*.txt | 文本,逗号分隔 | 15 列 | 3D 框、2D 框、类别、遮挡等级 |
有一点容易忽略:image_2是左目彩色,image_3是右目彩色,灰度图在image_0/image_1。做投影只用image_2,配套的投影矩阵是P2。用P0去配image_2会整体偏移几十个像素,肉眼看着"差一点",很容易被当成标定精度问题查上半天。
2.2 三套坐标系与两条变换
KITTI 一帧数据里同时存在三套坐标系,混淆它们是可视化翻车的第一大来源。
激光雷达坐标系(velodyne):原点在雷达自身,x 向前、y 向左、z 向上,单位米。.bin里的数字就是这套坐标。
相机坐标系(cam):原点在左目相机光心,x 向右、y 向下、z 向前。注意 y 轴朝下,这是和雷达系最大的差别,也是"点云上下颠倒"的根因。
图像坐标系(pixel):左上角为原点,u 向右、v 向下。
从雷达到像素要走两段变换,顺序不能颠倒:
Tr_velo_to_cam:3×4,把雷达坐标系的点转到相机坐标系。这一步同时完成了轴的重排和y轴翻转。R0_rect:3×3,相机去畸变后的校正旋转。单目图像已经是校正过的,所以从雷达过来的点也必须跟着转一次,否则会有几度的系统性倾斜。P2:3×4,把相机坐标系下的 3D 点投影成像素齐次坐标,最后除以 z 得到 u、v。
顺序固定为P2 · R0_rect · Tr_velo_to_cam · p_velo。中间任何一个漏掉,结果都会"看起来差不多但就是不对",这是最耗时间的一类问题。我一般会在读标定的函数里就把这两个 3×4 / 3×3 补成 4×4 齐次矩阵,后面所有计算统一用矩阵乘法,少写分支就少出错。
注意:
R0_rect在文件里是 9 个数,Tr_velo_to_cam是 12 个数,补 4×4 时右下角必须置 1、最后一行前三个必须置 0,忘记补这一行会让平移量被放大或直接丢失。
2.3 先写一个文件完整性检查,再谈渲染
在动手画图之前,先花二十行代码把整批数据的配对关系扫一遍。这一步能提前拦掉编号补零不一致、训练/测试目录混用、某几帧缺 calib 之类的批量问题。
import os from pathlib import Path def check_split(root, split="training"): """扫描一个 split,返回四类文件的配对缺失报告""" base = Path(root) / split # 用集合求差,比逐个 os.path.exists 快一个量级 stems = { "image_2": {p.stem for p in (base / "image_2").glob("*.png")}, "velodyne": {p.stem for p in (base / "velodyne").glob("*.bin")}, "calib": {p.stem for p in (base / "calib").glob("*.txt")}, "label_2": {p.stem for p in (base / "label_2").glob("*.txt")}, } full = set.intersection(*stems.values()) report = {"common": len(full)} for name, s in stems.items(): missing = sorted(full.symmetric_difference(s))[:10] report[name] = {"total": len(s), "diff_sample": missing} return report if __name__ == "__main__": for k, v in check_split("./data/kitti").items(): print(k, v)这段代码的逻辑很简单但很值:Path.glob拿到每一类文件的 stem 集合,四个集合求交集就是可以安全可视化的帧号;再对每一类做对称差,就能列出"别人有我没有"的样本,最多打 10 个出来够定位问题了。
参数上只有一个root需要改,指向解压后的上级目录即可。如果发现某类的total明显小于其他类,基本可以确定是解压中断或下载不完整,别急着调代码。编号补零的问题也会在这一步暴露——000123.png和123.bin的 stem 不一样,交集直接为空。
3. 解析 .bin 与 calib:把二进制读对,后面的可视化才有意义
数据配平之后,进入真正的读取环节。KITTI 的 .bin 没有文件头、没有魔数,就是裸的 float32 数组,读错的方式有一百种,而每一种都能画出一张"看着像点云"的图。
3.1 velodyne .bin 的读取与降采样
.bin的结构是 N 行 4 列,每行依次是 x、y、z、intensity,全部小端 float32,行与行之间没有任何分隔。正确读法是先np.fromfile成一维,再按 4 列 reshape。
import numpy as np def load_velo(path, roi=None, max_points=None, seed=0): """ path: .bin 文件路径 roi: (xmin, ymin, zmin, xmax, ymax, zmax),None 表示不过滤 max_points: 随机降采样上限,None 表示全量 """ scan = np.fromfile(str(path), dtype=np.float32) if scan.size % 4 != 0: raise ValueError(f"文件长度 {scan.size} 不是 4 的倍数,可能已损坏") scan = scan.reshape(-1, 4) pts = scan[:, :3] intensity = scan[:, 3] if roi is not None: xmin, ymin, zmin, xmax, ymax, zmax = roi mask = ( (pts[:, 0] > xmin) & (pts[:, 0] < xmax) & (pts[:, 1] > ymin) & (pts[:, 1] < ymax) & (pts[:, 2] > zmin) & (pts[:, 2] < zmax) ) pts, intensity = pts[mask], intensity[mask] if max_points is not None and len(pts) > max_points: idx = np.random.default_rng(seed).choice(len(pts), max_points, replace=False) pts, intensity = pts[idx], intensity[idx] return pts, intensity逻辑说明:先做长度校验,能拦住截断文件;reshape 用-1让 numpy 自己推 N,比手写len/4稳;ROI 过滤放在降采样之前,避免把远处的有效点先丢掉再筛。
参数上,roi建议给(0, -40, -3, 70, 40, 2)这样一套典型值——KITTI 是车载前视采集,雷达后方和极远处的点对可视化没帮助,去掉之后点数能从十万级降到三五万,交互帧率立刻从卡顿变成顺滑。max_points设 30000 到 50000 之间比较合适,再高对肉眼没有增益。seed固定住是为了让两次运行的画面一致,方便对照调参。
注意:
scan[:, 3]是反射强度,不是颜色也不是类别。直接拿它当 RGB 的某一通道会让画面呈现一种奇怪的偏色,误以为点云"质量不行"。
3.2 calib 七行文本的解析与 4×4 补齐
calib 文件是KEY: v1 v2 ... vn的格式,七个键:P0 P1 P2 P3 R0_rect Tr_velo_to_cam Tr_imu_to_velo。解析要点是拿冒号切分,别按空格切,因为键和值之间可能有空格也可能没有。
import numpy as np def read_calib(path): """返回 (P2[3x4], R0[4x4], Tr[4x4]),全部 float64""" raw = {} with open(path, "r") as f: for line in f: if ":" not in line: continue key, _, rest = line.partition(":") raw[key.strip()] = np.array( [float(x) for x in rest.split()], dtype=np.float64 ) P2 = raw["P2"].reshape(3, 4) # 3x3 -> 4x4,右下角补 1 R0 = np.eye(4, dtype=np.float64) R0[:3, :3] = raw["R0_rect"].reshape(3, 3) # 3x4 -> 4x4,最后一行补 [0,0,0,1] Tr = np.eye(4, dtype=np.float64) Tr[:3, :] = raw["Tr_velo_to_cam"].reshape(3, 4) return P2, R0, Tr三个返回值的含义要分清楚:P2保持 3×4 不补,因为它做的是齐次除法前的投影,补成 4×4 反而多一层无用运算;R0和Tr补成 4×4 是为了能串成一条矩阵链。用float64而不是float32,原因是 P2 里的焦距量级在千位、平移量级在百位,float32 在连续相乘后会有肉眼可见的像素级漂移——这属于典型的"改一个 dtype 就解决的问题,却查了一晚上"。
如果解析时报reshape失败,八成是某个键读到的数字个数不对。P0到P3每组 12 个数,R0_rect9 个,Tr_*12 个,写个断言提前炸出来比后面默默画歪强。
3.3 用 Open3D 起一个最小可交互窗口
点云读进来之后,第一件事是确认它在 3D 空间里长得像一条街,而不是一团球或者一个平面。Open3D 起窗口只需要几行,配置好窗口背景和点大小,观感差别很大。
import open3d as o3d import numpy as np def show_cloud(pts, intensity, point_size=1.5): pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(pts) # 用强度映射到灰度,避免全白看不出远近 t = (intensity - intensity.min()) / (intensity.ptp() + 1e-6) colors = np.stack([t, t, t], axis=1) * 0.8 + 0.2 pcd.colors = o3d.utility.Vector3dVector(colors) vis = o3d.visualization.Visualizer() vis.create_window(width=1280, height=720) vis.add_geometry(pcd) opt = vis.get_render_option() opt.background_color = np.array([0.05, 0.05, 0.08]) opt.point_size = point_size # 首次渲染后把视角切到俯视偏前方,比默认视角好判读 vis.poll_events() vis.update_renderer() ctr = vis.get_view_control() ctr.set_front([0, -1, 0.35]) # 从车头斜上方看 ctr.set_up([0, 0, 1]) # 让 z 轴朝上 ctr.set_zoom(0.12) vis.run() vis.destroy_window() if __name__ == "__main__": pts, inten = load_velo("./data/kitti/training/velodyne/000123.bin") show_cloud(pts, inten)几个关键参数值得调:set_up([0,0,1])必须显式设,否则 Open3D 会用自己的默认 up 向量,画面里街道是歪的,你会怀疑数据有问题;set_zoom的取值和场景尺度相关,KITTI 一帧点云跨度七十米左右,0.1 到 0.15 之间比较合适;point_size在 1080p 屏上 1.5 到 2.0 之间,太小看不清路面结构,太大远处糊成一片。
如果窗口里看到的是一条沿 x 方向延伸的带状点云,路面在 z≈-1.7 的平面上,说明坐标系和单位都对了。如果看到的是一个球、一个圆锥或者所有点挤在原点附近,先回 3.1 检查 reshape 顺序,再回 3.2 检查 4×4 补齐——这两个地方占了这类异常的大头。
4. 投影链路:把 3D 点云和 3D 框画回 2D 图像
3D 窗口看着对,只说明点云本身读对了,不代表标定用对了。真正的交叉验证是把点云投回图像:路沿该压在路沿上,车顶该压在车顶上,在图像上偏移几个像素都能看出来。
4.1 从雷达到像素的四步矩阵乘法
投影的完整链路是pixel ~ P2 · R0_rect · Tr_velo_to_cam · p_velo,其中最后一步要除以相机坐标系的 z 做归一化。整个函数不超过二十行,但每一步都得留中间变量方便排查。
import numpy as np def project_velo_to_image(pts, P2, R0, Tr): """ pts: (N, 3) 雷达坐标系点 返回: uv (N, 2) 像素坐标, depth (N,) 相机系 z 值 """ N = pts.shape[0] homo = np.hstack([pts, np.ones((N, 1), dtype=np.float64)]) # (N,4) # 1) 雷达到相机:轴重排 + 翻转 cam = (Tr @ homo.T).T # (N,4) # 2) 去畸变校正旋转 cam = (R0 @ cam.T).T # (N,4) # 3) 相机系 z 即深度,用于后续过滤 depth = cam[:, 2].copy() # 4) 投影到像素 img = (P2 @ cam.T).T # (N,3) uv = img[:, :2] / img[:, 2:3] return uv, depth逻辑上有两处必须强调。第一,第 1、2 步的矩阵乘法都作用在齐次坐标上,所以homo的最后一列必须是 1,不能省。第二,第 4 步的除法用的是img[:, 2],也就是相机系 z 经过 P2 后的第三分量,它与depth在数值上相等但不建议混用,分开存方便后面做遮挡判断。
参数层面,P2保持 3×4 直接乘,R0和Tr用 3.2 里补好的 4×4。返回值里带上depth是有意为之:后面按深度着色、过滤雷达背后的点、判断遮挡,都要用它。
拿到 uv 之后,先做一次有效性过滤再画:depth > 0排除相机后方的点,0 <= u < 1242且0 <= v < 375排除视场外的点。不过滤直接 scatter,会在图像边缘拉出一堆乱七八糟的散点,看起来像标定炸了,其实只是画了不该画的点。
def filter_uv(uv, depth, w=1242, h=375): valid = ( (depth > 0.5) & (uv[:, 0] >= 0) & (uv[:, 0] < w) & (uv[:, 1] >= 0) & (uv[:, 1] < h) ) return uv[valid], depth[valid], validdepth > 0.5这个下界不是随手写的:KITTI 的雷达装在车顶,最近的有效回波也在 1 米开外,把 0.5 米以内的点排掉能滤掉大量数值噪声。如果你的场景里有近距离目标,这个阈值可以调到 0.2。
4.2 3D 框的八个角点与朝向
把 label_2 里的 3D 框画出来,需要从 15 列里抽出中心、尺寸、朝向三组数,算出八个角点,再逐条连线。这一步最容易被"尺寸顺序"和"中心到底在底面还是几何中心"两个问题坑住。
label_2 每行 15 列,顺序是:类别、截断率、遮挡等级、alpha、2D 框四边、高 h、宽 w、长 l、相机系 x、y、z、绕 y 轴转角 rotation_y。注意尺寸给的是 h、w、l,不是常见的 l、w、h。
import numpy as np def parse_label(path): objs = [] with open(path, "r") as f: for line in f: parts = line.strip().split() if len(parts) < 15: continue objs.append({ "type": parts[0], "bbox2d": np.array([float(x) for x in parts[4:8]]), "h": float(parts[8]), "w": float(parts[9]), "l": float(parts[10]), "loc": np.array([float(x) for x in parts[11:14]]), # 相机系 x,y,z "ry": float(parts[14]), }) return objs def box3d_corners_cam(obj): """在相机坐标系下算 8 个角点,loc 视为底面中心""" h, w, l, ry = obj["h"], obj["w"], obj["l"], obj["ry"] # 局部坐标:x 沿车宽方向,y 向下,z 沿车长方向 x = np.array([ l/2, l/2, -l/2, -l/2, l/2, l/2, -l/2, -l/2]) y = np.array([ 0.0, 0.0, 0.0, 0.0, -h, -h, -h, -h ]) z = np.array([ w/2, -w/2, -w/2, w/2, w/2, -w/2, -w/2, w/2 ]) corners = np.stack([x, y, z], axis=1) c, s = np.cos(ry), np.sin(ry) # 绕相机 y 轴旋转 Ry = np.array([[ c, 0, s], [ 0, 1, 0], [-s, 0, c]]) return corners @ Ry.T + obj["loc"] def corners_to_pixels(corners_cam, P2): homo = np.hstack([corners_cam, np.ones((8, 1))]) img = (P2 @ homo.T).T return img[:, :2] / img[:, 2:3], img[:, 2]这里有两个必须交代的约定。其一,loc在社区实现里普遍按"底面中心"处理,所以 y 从 0 往下到 -h;如果你的标注工具输出的是几何中心,y 序列要整体加-h/2的偏移,否则框会整体悬空半米。其二,rotation_y是绕相机 y 轴(朝下那根)的转角,不是绕雷达 z 轴,用错轴画出来的框朝向会整整差 90 度。
连线关系固定为:底面 0-1-2-3-0,顶面 4-5-6-7-4,立柱 0-4、1-5、2-6、3-7。用 Open3D 画的时候组装成LineSet,颜色按类别区分,行人、车辆、骑行者各一色,比全用同一种颜色好判断。
注意:投影前先检查
img[:, 2]是否全为正。如果八个角点里有 z 小于 0 的,说明这个目标在相机后方,画出来会是镜像的错误框,应当整帧跳过。
4.3 鸟瞰图:投影之外的第二双眼睛
图像投影对高度方向不敏感,两个前后相邻的车在图上可能完全重叠。这时候补一张 BEV 鸟瞰图,把点云压到 x-y 平面按俯视渲染,前后关系立刻清楚。KITTI 的 BEV 一般以车辆为中心,前向 70 米、左右各 40 米,分辨率取 0.1 米/格。
import numpy as np def make_bev(pts, intensity, res=0.1, fwd=70.0, side=40.0): """返回 (H, W) 的灰度图,x 向前映射到行,y 向左映射到列""" W = int(2 * side / res) H = int(fwd / res) bev = np.zeros((H, W), dtype=np.float32) x, y, z = pts[:, 0], pts[:, 1], pts[:, 2] keep = (x >= 0) & (x < fwd) & (np.abs(y) < side) col = ((y[keep] + side) / res).astype(np.int32).clip(0, W - 1) row = ((fwd - x[keep]) / res).astype(np.int32).clip(0, H - 1) # 取同一格内的最大强度,比累加更能突出目标轮廓 np.maximum.at(bev, (row, col), intensity[keep]) return bev[::-1] # 翻转行,让车头朝上np.maximum.at这个用法值得留意:普通赋值bev[row, col] = intensity在多点落同一格时结果取决于顺序,画面会闪;用maximum.at取格内最大强度,同一帧反复渲染结果稳定,目标轮廓也更清晰。res=0.1对应 800×700 的输出尺寸,够看清车道线和车身;想要更快可以降到 0.2,代价是自行车这类细长目标会糊掉。
画 BEV 之前把点云按 z 做一次过滤(保留 -2.0 到 1.5 米),只留路面附近的点,能大幅减少树冠和建筑物立面造成的干扰。这一条是我在对比图像和 BEV 时反复验证过的:不过滤的话,路边行道树在俯视图上会盖住整条车道。
5. 避坑与排查:五个把结果画歪的典型现场
下面五条都是我在不同项目里真实撞过的,现象描述尽量具体,方便对号入座。
5.1 点云糊成一个平面或者只剩一个球
现象:Open3D 窗口里所有点沿某一轴压缩,看起来像一张纸或者一个球壳,拖动视角也展不开。
原因:.bin读进来之后没有 reshape,或者 reshape 成了(4, -1)。前者是把 N×4 当成了一维数组直接塞给Vector3dVector,后者是行列颠倒,xyz 三列的分组完全错位。另一种可能是把 intensity 当成 z 用了。
解决:在读函数里加一句断言assert pts.shape[1] == 3,并且打印pts.min(axis=0)和pts.max(axis=0)。正常一帧的范围大致是 x 在 0 到 80、y 在 -40 到 40、z 在 -3 到 2,任何一维的数量级差出十倍都说明读错了。
5.2 投影整体偏移几十个像素,但形状是对的
现象:点云投到图像上,轮廓形状完全正确,路沿还是路沿、车顶还是车顶,但整体平移了一段,或者有轻微倾斜。
原因:漏掉了R0_rect。只做P2 · Tr_velo_to_cam时,缺失了相机去畸变的校正旋转,表现为系统性倾斜;另一种是把Tr_velo_to_cam的 3×4 补成 4×4 时最后一行写成了[0,0,0,0],平移量直接消失。
解决:把中间变量打出来。先只看单点:取雷达原点[0,0,0],经过Tr后应该落在相机系(0, -1.7, 0)附近(雷达装在车顶,比相机高约 1.7 米)。这个值对不上,问题一定在Tr;对上了但图像偏移,问题在R0。
5.3 图像和点云差一个"镜像"
现象:投影结果左右反了,或者和图像内容整体错开一个固定距离。
原因:用了P0去配image_2。P0对应的是灰度左目image_0,虽然分辨率相同,但内外参不完全一致。另一种情况是把image_3(右目)和P2配在一起。
解决:把投影矩阵和图像文件名的对应关系写成常量表放在代码顶部,image_2 ↔ P2、image_0 ↔ P0,不要在多处重复手写。这类错误用肉眼看单帧很容易忽略,但一旦批量生成可视化结果再拿去标注复核,会污染整批数据。
5.4 3D 框朝向差了 90 度或者悬在半空
现象:框的位置大致对,但朝向和车辆实际朝向垂直;或者框整体离地半米,悬浮在车顶上方。
原因:两个独立问题。朝向问题是用错了旋转轴——rotation_y是绕相机 y 轴(朝下)转,用成绕 z 轴就会差 90 度。悬空问题是loc的语义搞错了,KITTI 的 y 是底面中心,角点计算时 y 从 0 到 -h;如果当成几何中心,整个框会上移 h/2。
解决:先用一辆正对镜头的车做单目标验证,把它的 3D 框投到图像上,看框底边是否压在地面接地点。压住了说明语义对,悬空就是 h/2 的偏移没处理。朝向则看框的长边是否和车身长度方向一致,不用绝对值判断,用肉眼对齐最快。
5.5 切帧卡顿,一帧要等两三秒
现象:写了个滑动条切换帧号,每换一帧窗口就卡住,或者内存一路涨到爆。
原因:每次切换都新建一个Visualizer,旧窗口的几何体没有释放;或者一次性把整个 velodyne 目录的点云都np.fromfile到列表里,七千多帧乘十万点,内存直接吃满。
解决:Visualizer只创建一次,切帧时用remove_geometry+add_geometry替换点云对象,或者直接改pcd.points后调update_geometry。读取端做成按需加载,不要预加载全量;如果一定要缓存,缓存降采样后的版本,五万点一帧,一百帧也才 80MB 左右。
6. 进阶:用三个量化指标把"看着对"变成"验过对"
肉眼看着对,和真的对,中间隔着一层可复现的验证。我现在的习惯是每换一批数据、每改一次标定解析代码,都跑一遍下面这套量化检查,三个数出来,心里就有底了。
| 指标 | 计算方式 | 健康区间 | 异常指向 |
|---|---|---|---|
| 图像内点占比 | 落在 1242×375 内的点数 / 深度大于 0 的点数 | 25% ~ 45% | 低于 10% 基本是标定链路错 |
| 3D 框投影 IoU | 8 角点投影的外接框与 label 2D 框的 IoU | 中位数 > 0.75 | 低于 0.5 查尺寸顺序与朝向 |
| 单帧点数 | pts.shape[0] | 8 万 ~ 13 万 | 明显偏低查文件完整性 |
第一个指标最灵敏。KITTI 是前视采集,雷达 360 度扫描但相机只覆盖前方约 90 度,所以正常范围就在三成上下。如果算出来只有百分之几,说明有一大半点被投到了图像外面,几乎可以确定是矩阵链或补齐出了问题。写成函数只要十几行:
def sanity_check(frame_id, root="./data/kitti/training"): """返回三个指标,任一越界直接打印告警""" pts, inten = load_velo(f"{root}/velodyne/{frame_id}.bin") P2, R0, Tr = read_calib(f"{root}/calib/{frame_id}.txt") uv, depth = project_velo_to_image(pts, P2, R0, Tr) front = depth > 0.5 uv, depth, _ = filter_uv(uv, depth) ratio = len(uv) / max(front.sum(), 1) objs = parse_label(f"{root}/label_2/{frame_id}.txt") ious = [] for o in objs: if o["type"] == "DontCare": continue corners = box3d_corners_cam(o) px, z = corners_to_pixels(corners, P2) if (z <= 0).any(): continue x0, y0 = px.min(axis=0) x1, y1 = px.max(axis=0) # 与标注 2D 框求交并比 ix0, iy0 = max(x0, o["bbox2d"][0]), max(y0, o["bbox2d"][1]) ix1, iy1 = min(x1, o["bbox2d"][2]), min(y1, o["bbox2d"][3]) inter = max(0, ix1 - ix0) * max(0, iy1 - iy0) union = (x1 - x0) * (y1 - y0) + \ (o["bbox2d"][2] - o["bbox2d"][0]) * (o["bbox2d"][3] - o["bbox2d"][1]) - inter if union > 0: ious.append(inter / union) med_iou = float(np.median(ious)) if ious else 0.0 print(f"frame={frame_id} points={len(pts)} in_image={ratio:.1%} iou_med={med_iou:.3f}") if ratio < 0.10: print(" [告警] 图像内点占比过低,优先查 R0_rect 与 Tr 的补齐") if med_iou and med_iou < 0.50: print(" [告警] 3D 框投影与 2D 框偏差大,查尺寸顺序与 loc 语义") return ratio, med_iou, len(pts)用它批量扫二十帧,取三个指标的均值,比逐帧盯图效率高得多。我一般还会把ratio按帧号画成折线,如果某一段突然掉下去,那一段数据大概率有问题——要么 calib 文件串了,要么文件下载不完整。
场景句还有一个经验值得记:DontCare类别的 2D 框经常是-1 -1 -1 -1,参与 IoU 计算会直接把中位数拉低,算之前必须跳过。这类小坑不写在代码里,换个人接手就会重踩一遍。从那以后我每次改完标定相关的解析逻辑,都会强制先跑这二十帧的检查再去看图——图会骗人,三个数字不会。希望帮到你。
本文还有配套的精品资源,点击获取