☰
KITTI点云可视化:Open3D渲染、标定投影与标注检查
2026/10/29 4:45:19 网站建设 项目流程

简介:面向自动驾驶环境感知、3D 目标检测与点云算法调试的 KITTI 可视化工程,适合具备 Python 基础、需要快速查看和理解 KITTI 数据的高校学生、科研人员及算法工程师。资源包共 42 个文件、约 9.81MB,以 6 个 Python 脚本、7 张效果示意图、6 个 txt/xml 标注与配置、3 个 bin 点云样例、3 个 Jupyter Notebook 及说明文档为主,兼顾代码、样例数据与可视化结果。已有 2857 人学习下载。包内围绕原始点云查看、BEV 鸟瞰视图生成和基于 kitti_object_vis 的九类可视化操作展开,配有示例图片、工具模块与 notebook 演示,并可对照示例图检查运行效果,可帮助读者直观理解 KITTI 数据目录、标定与标注文件、点云与图像投影关系、2D/3D 框展示及检测结果对比,适合用于课程实验、论文配图、算法调试前的快速验证和可视化流程复现,也能作为后续二次开发、功能扩展与教学演示的基础工程。

1. 从一帧 .bin 说起:这个 KITTI 可视化项目到底解决了什么

第一次拿到 KITTI 的 velodyne 目录,很多人的反应是一样的:上万个.bin文件,双击打不开,拖进文本编辑器全是乱码。点云可视化在论文里只是一张配图,落到工程上其实要串起三件事——把二进制读成 N×4 的数组、把数组送进能转能缩能平移的窗口、再按标定矩阵把三维点投到相机图像上对齐。这个 KITTI 数据集可视化项目做的就是这条链路:velodyne 点云、image_2 图像、calib 标定、label_2 标注四路数据读进来,在同一个坐标系里对齐显示,顺带把三维标注框同时画到点云和图像上。

适合谁:刚接手激光雷达感知任务、需要先“看得见”数据长什么样的同学;要核对标注质量、怀疑某一帧的框画歪了的同学;手里有自采数据、想把这套读取和投影逻辑迁过去的人。整套东西不依赖重型框架,一台能开图形界面的机器、装上三四个常用库就能跑。

2. 环境与数据准备:把 KITTI 的四个目录摆对位置

2.1 依赖安装:三个库撑起整条渲染链路

这类项目我一般不引入什么大框架,最小依赖就三个:numpy负责二进制解析和矩阵运算,opencv-python负责图像读写和画线画点,open3d负责点云的交互式渲染。选 Open3D 而不是 PyQtGraph 或 Mayavi,理由很实在——它内置了转、缩、平移的鼠标操作,PointCloud和Vector3dVector之间的转换是零拷贝的,改颜色只要换一个 (N,3) 的数组,写起来最省事。

# 单独建虚拟环境,科学计算类的轮子和系统自带的包容易互相干扰 python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate # open3d 只提供 wheel,Python 版本别选太新的,选 3.8~3.11 之间最省心 pip install numpy opencv-python open3d

这三行命令背后的坑在于 Open3D 的 wheel 分发——它是编译好的二进制,不提供源码包,所以换系统或者 Python 版本太超前时,pip会直接告诉你找不到匹配的发行版,而不是走源码编译。碰到这种情况别急着折腾编译,退一个 Python 小版本通常就解决了。另外opencv-python装的是带 GUI 支持的版本,别装opencv-python-headless,那个版本没有imshow,你会得到一堆cv2.error。

我一般会在项目根目录放一个requirements.txt把这三个锁死,理由不是洁癖:Open3D 不同版本之间Visualizer的接口确实改过,特别是渲染选项的几个字段名,跨版本跑别人的代码很容易在get_render_option()那一步翻车。

2.2 目录结构:KITTI 的命名规律先摸清

KITTI 的三维检测部分给的是平铺目录,training下每个子目录里全是六位补零的文件名,靠序号一一对应。理解这一点比看懂任何代码都重要——点云、图像、标定、标注之间的关联完全是靠文件名对上的,没有任何索引文件。

目录文件格式内容谁在用
velodyne/000000.bin每帧约 12 万个点,float32 连续存储点云渲染
image_2/000000.png左侧彩色相机,投影的目标平面图像叠加
calib/000000.txtP0~P3、R0_rect、Tr_velo_to_cam 等矩阵投影变换
label_2/000000.txt每行一个目标的类型、尺寸、位置、朝向画三维框
poses/、times/txt里程计部分的位姿与时间戳多帧拼接(可选)

项目里我习惯用一个字典把这些路径收口,而不是在每个函数里拼字符串:

from pathlib import Path ROOT = Path("/data/kitti/object/training") # 换成你自己的路径 PATHS = { "velodyne": ROOT / "velodyne", # 点云,每帧一个 .bin "image": ROOT / "image_2", # 左侧彩色相机,投影目标就是这一路 "label": ROOT / "label_2", # 三维标注 "calib": ROOT / "calib", # 内参外参 } def frame_id(idx: int) -> str: # 固定 6 位补零,千万别用 str(idx) 直接拼路径,000123 会变成 123 return f"{idx:06d}" def frame_paths(idx: int) -> dict: fid = frame_id(idx) return { "velodyne": PATHS["velodyne"] / f"{fid}.bin", "image": PATHS["image"] / f"{fid}.png", "label": PATHS["label"] / f"{fid}.txt", "calib": PATHS["calib"] / f"{fid}.txt", }

frame_id这个函数看着不起眼,但它是最容易踩的一个坑。KITTI 的序号是字符串层面补零的,一旦你在中间某处把它转成了int再拼回去,000123就变成123.bin,然后你会在FileNotFoundError里找半天,以为数据集下载不全。把它做成唯一的入口函数,所有地方都调它,就不会有这个问题。

另外注意testing目录下没有label_2,如果你的代码无条件读标注文件,在测试集上会直接崩。取标注之前先if path.exists(),或者干脆把标注做成可选参数。

2.3 跑通第一帧:从一条命令到可交互窗口

入口我一般写成命令行工具,参数用argparse,这样换数据集、换帧号都不用改代码:

import argparse from pathlib import Path def parse_args(): p = argparse.ArgumentParser(description="KITTI 单帧点云可视化") p.add_argument("--root", type=Path, required=True, help="training 或 testing 目录的绝对路径") p.add_argument("--frame", type=int, default=0, help="帧号,对应 000000.bin 里的序号") p.add_argument("--point-size", type=float, default=1.5, help="点的大小,屏幕分辨率高的时候要调大") p.add_argument("--color", default="height", choices=["height", "depth", "intensity"], help="着色策略,height 按高度、depth 按距离、intensity 按反射强度") return p.parse_args() if __name__ == "__main__": args = parse_args() paths = frame_paths(args.frame) # 上面那个函数,root 改成从 args.root 推 pts = load_velo(paths["velodyne"]) print(f"帧 {args.frame} 共 {pts.shape[0]} 个点") print(f"x 范围 {pts[:,0].min():.1f} ~ {pts[:,0].max():.1f}") print(f"z 范围 {pts[:,2].min():.1f} ~ {pts[:,2].max():.1f}") show_cloud(pts, point_size=args.point_size, mode=args.color)

运行起来是这样:

python main.py --root /data/kitti/object/training --frame 0 --color height

终端里的第一行输出就是最好的自检。正常的 KITTI 帧打印出来应该是一万多到十几万个点不等,x 范围大致在 ±80 米以内,z 范围在 -3 到 2 米之间(地面附近)。如果点数是 0,说明文件路径或者字节序有问题;如果 x 范围是几百万,说明你把 float32 当成了别的类型在读。这一步先确认数字对,再去开窗口,能省掉一大半“窗口里啥也没有”的排查时间。

窗口开出来之后,用鼠标左键拖动是旋转,滚轮是缩放,中间键按住拖是平移。视角建议先摆成俯视,你能看到一条自车为中心的道路弧线,两侧是稀疏的树干和车辆轮廓,远处点会明显变稀——这是机械式激光雷达的角分辨率造成的,不是渲染 bug。

提示:第一次跑先别急着开标注和图像叠加,就用单帧点云把窗口调舒服,点大小、背景色、初始视角都定下来,后面加图层会轻松很多。

3. 点云渲染主链路:从 velodyne 二进制到可交互的 Open3D 窗口

3.1 读 bin:一次 reshape 解决 99% 的解析问题

KITTI 的点云文件没有任何文件头,就是纯粹的 float32 平铺数据,每个点固定 4 个分量:x、y、z 和反射强度 reflectance。理解了这一点,读取代码就只剩两行:

import numpy as np def load_velo(path) -> np.ndarray: """读一帧 velodyne 点云,返回 (N, 4) 的 float32 数组""" scan = np.fromfile(str(path), dtype=np.float32) # 长度不是 4 的倍数说明文件被截断或下错了,直接断言比默默 reshape 成空数组强 assert scan.size % 4 == 0, f"{path} 长度异常: {scan.size}" # -1 表示行数自动推断,列固定 4;这样比用固定点数更安全,不同帧点数不一样 return scan.reshape(-1, 4)

参数说明:dtype=np.float32是硬性的,KITTI 用的是 32 位浮点而不是 64 位,写成np.float64会让每个点占 8 字节,reshape 出来的结果会是点数翻倍、坐标全乱。reshape(-1, 4)的-1让 numpy 自己算行数,这一步比很多人写的reshape(scan.size // 4, 4)更不容易出错。

这里有个我踩过的坑:np.fromfile读的是本机字节序。绝大多数机器是小端,KITTI 也是小端,直接读没问题;但在某些大端架构的机器上你会读到一堆NaN或者极端小的数。真要跨平台,写成np.fromfile(path, dtype="<f4")显式声明小端更保险。

如果帧数很多、内存吃紧,还可以用np.memmap做内存映射,读的时候不真正加载进内存,只在你切片访问的时候才从磁盘取。对单帧可视化的场景意义不大,但后面要做批量统计(比如统计整个数据集的点数分布)时很有用。

3.2 渲染窗口:Visualizer 的五个必调参数

o3d.visualization.draw_geometries那个一行版本只适合看个大概,正经项目里我都是用Visualizer对象,因为要控制窗口大小、背景色、点大小和初始视角:

import numpy as np import open3d as o3d def show_cloud(pts: np.ndarray, point_size=1.5, mode="height"): pcd = o3d.geometry.PointCloud() # Vector3dVector 要求 float64,传 float32 会被静默转换,数据量大时这一步挺耗时 pcd.points = o3d.utility.Vector3dVector(pts[:, :3].astype(np.float64)) pcd.colors = o3d.utility.Vector3dVector(colorize(pts, mode)) vis = o3d.visualization.Visualizer() vis.create_window(window_name=f"KITTI velodyne ({pts.shape[0]} pts)", width=1280, height=720) # 先加坐标系,再加点云,这样原点位置一眼能看出来 vis.add_geometry(o3d.geometry.TriangleMesh.create_coordinate_frame(size=3.0)) vis.add_geometry(pcd) opt = vis.get_render_option() opt.background_color = np.array([0.05, 0.05, 0.05]) # 深灰背景,比纯黑更容易看清暗点 opt.point_size = point_size # 默认可能是 0,那样什么都看不见 # 初始视角:从斜上方看下去,自车在中心 ctr = vis.get_view_control() ctr.set_front([0.0, -1.0, 0.6]) ctr.set_up([0.0, 0.0, 1.0]) ctr.set_zoom(0.35) vis.run() vis.destroy_window()

关键的就三个参数:background_color、point_size、set_zoom。point_size尤其要注意,某些 Open3D 版本的默认值是 0 或者极小,结果就是窗口正常弹出来、坐标系也在、但点云完全看不见,你会以为数据没读到。我现在的习惯是无论什么情况都显式写一次点大小,1.5 到 3 之间按屏幕分辨率调。

set_front和set_up这两个向量决定了初始朝向。点云是 z 轴朝上的(KITTI 的 velodyne 坐标系),所以set_up给[0,0,1];把set_front给一个带负 y 和正 z 的向量,视角就是从车后方斜上方往下看,这个角度最符合人的直觉,能同时看到路面、两侧和远处的物体。

3.3 着色策略:按高度、按距离、按反射强度

单色点云看着就是一坨,看不出结构。着色是最便宜的可读性提升手段,三种策略我一般全实现,用命令行参数切:

def colorize(pts: np.ndarray, mode="height") -> np.ndarray: """返回 (N,3) 的 RGB,取值范围 0~1,Open3D 要求归一化""" xyz, intensity = pts[:, :3], pts[:, 3] if mode == "height": val = xyz[:, 2] # 高度,地面附近低、树和楼高 elif mode == "depth": val = np.linalg.norm(xyz[:, :2], axis=1) # 水平距离,只算 xy 平面 else: val = intensity # 反射强度,交通标志牌会异常突出 # 用 2% 和 98% 分位数做归一化,比 min/max 稳,能滤掉个别离群点把色阶拉爆 lo, hi = np.percentile(val, 2), np.percentile(val, 98) norm = np.clip((val - lo) / (hi - lo + 1e-6), 0.0, 1.0) # 手写一条蓝-青-黄-红的分段色带,不引 matplotlib,省掉一个依赖 r = np.clip(1.5 - np.abs(4 * norm - 3), 0, 1) g = np.clip(1.5 - np.abs(4 * norm - 2), 0, 1) b = np.clip(1.5 - np.abs(4 * norm - 1), 0, 1) return np.stack([r, g, b], axis=1)

这里最值得说的是那个percentile。一开始我用val.min()和val.max()归一化,结果下雨天或者有反光板的帧里,总有一两个反射强度爆表的点,导致其余所有点全被压到色带最左端,整片点云变成一种颜色。换成 2% 到 98% 的分位数之后,极端值被截掉,正常点就能铺满整个色阶。这个改动很小,但对“能不能一眼看出路面和障碍物”的影响非常大。

三种策略各有用途:按高度看能立刻分出地面、路沿和立体障碍物,是最适合入门的一档;按距离看能直观感受点云密度随距离衰减,做检测范围规划的时候常用;按反射强度看最适合验证标牌、车道线这类高反目标的可见性,做数据筛选的时候很有用。

注意:pcd.colors的数值必须在 0 到 1 之间。传 0 到 255 的整数不会报错,但 Open3D 会把它当超亮处理,整片点云变成纯白,看起来像是渲染坏了。

4. 标定与投影:把点云贴到相机图像上的完整推导

4.1 calib 文件解析:三个矩阵各自的活

calib目录下的每个 txt 都是若干行键: 一堆数字。真正参与“velodyne 到 image_2”这条链路的只有三个:P2、R0_rect、Tr_velo_to_cam。剩下的P0、P1、P3是另外三个相机,Tr_imu_to_velo是惯导到激光的变换,做纯视觉投影的时候用不上。

import numpy as np def read_calib(path) -> dict: """把 calib txt 解析成 键 -> ndarray,形状按数字个数还原""" data = {} with open(path, "r") as f: for line in f: if ":" not in line: continue key, val = line.split(":", 1) arr = np.array([float(x) for x in val.split()], dtype=np.float64) # 12 个数是 3x4(P2、Tr_*),9 个数是 3x3(R0_rect),别一律 reshape 成 3x4 if arr.size == 12: data[key.strip()] = arr.reshape(3, 4) elif arr.size == 9: data[key.strip()] = arr.reshape(3, 3) else: data[key.strip()] = arr return data

三个矩阵的语义分别是:Tr_velo_to_cam是 3×4 的刚体变换(旋转加平移),把 velodyne 坐标系下的点转到相机 0 的坐标系;R0_rect是 3×3 的旋转,把未校正的相机系转到校正后的像平面坐标系(双目校正的产物);P2是 3×4 的投影矩阵,把校正坐标系下的三维点投影到 image_2 的像素坐标,里面同时包含焦距、主点和基线。

新手最容易在这里犯的错是把R0_rect也 reshape 成 3×4,那会得到一个形状不符的矩阵然后报一个和实际问题毫无关系的matmul错误。所以上面这段代码一定要按数字个数分支处理。

4.2 点云投影:把四个矩阵压成一个

投影的公式链是p_img = P2 @ R0_rect @ Tr_velo_to_cam @ p_velo。三个矩阵都是固定的 3×4 或 3×3,完全可以预先乘成一个 3×4,每帧只做一次矩阵乘法,比在循环里连乘快得多:

def build_velo_to_image(calib: dict) -> np.ndarray: """合成一个 3x4,velodyne 齐次坐标左乘它直接得到像素坐标""" P2 = calib["P2"] # 3x4,相机 2 的内参 + 基线 R0 = calib["R0_rect"] # 3x3,校正旋转 Tr = calib["Tr_velo_to_cam"] # 3x4,激光到相机 0 的刚体变换 # 顺序必须是 P2 @ R0 @ Tr,写成 P2 @ Tr @ R0 会直接报形状错误, # 写成 Tr @ R0 @ P2 则能乘但结果是错的,表现就是整幅图偏移 return P2 @ R0 @ Tr def project_velo_to_image(pts_velo, M, img_w, img_h, min_depth=1.0): """返回落在图像范围内的像素坐标和对应深度""" xyz = pts_velo[:, :3].astype(np.float64) homo = np.hstack([xyz, np.ones((xyz.shape[0], 1))]) uv = homo @ M.T # (N,3),第三列是相机系下的深度 z depth = uv[:, 2] # 深度 <= 0 的点在相机背后,直接除会翻到图像里,必须提前踢掉 valid = depth > min_depth u = uv[valid, 0] / depth[valid] v = uv[valid, 1] / depth[valid] inside = (u >= 0) & (u < img_w) & (v >= 0) & (v < img_h) return (u[inside].astype(np.int32), v[inside].astype(np.int32), depth[valid][inside])

min_depth这个参数是有讲究的。理论上只要深度大于 0 就能投影,但深度接近 0 的点除出来的像素坐标会到几万、几十万,不仅落在图像外浪费计算,还会在图像边缘糊出一片噪点。设成 1.0 米(也就是把激光雷达正下方那一小圈丢掉)就干净了,因为那部分本来也不可能出现在前视相机里。

把点画到图像上的时候,我建议用向量化赋值而不是逐个cv2.circle:

import cv2 def draw_points_on_image(img, u, v, depth, max_depth=50.0): """向量化上色,比 for 循环画圆快一个数量级""" norm = np.clip(depth / max_depth, 0, 1) # OpenCV 是 BGR 顺序,近处偏红、远处偏蓝 colors = np.stack([(255 * norm).astype(np.uint8), np.full_like(u, 60, dtype=np.uint8), (255 * (1 - norm)).astype(np.uint8)], axis=1) img[v, u] = colors # numpy 花式索引,一次搞定 return img

img[v, u] = colors这一行依赖 numpy 的花式索引,注意顺序是先 v 后 u,也就是先行后列——写反了不会报错,但画出来的点和图像内容完全对不上,这是属于那种“看起来只是有几像素偏移”的玄学 bug,能排一晚上。

4.3 三维标注框投影:8 个角点和 12 条边

label_2每行的格式是:类型、截断程度、遮挡程度、alpha、二维框四个数、三维尺寸h w l、三维位置x y z、绕 y 轴朝向rotation_y。这里的location已经在相机 0 坐标系下,所以投影它的时候只需要P2 @ R0_rect,不用再乘Tr_velo_to_cam——这一点如果搞混,框会整体跑到点云的另一侧。

def box3d_corners(obj: dict) -> np.ndarray: """按 KITTI 约定算 8 个角点,返回 (8,3),相机坐标系""" h, w, l = obj["dimensions"] # 顺序是高、宽、长,别按 xyz 的直觉记 x, y, z = obj["location"] # 相机系下的底面中心 ry = obj["rotation_y"] # 绕 y 轴旋转,相机系 y 轴朝下 # 物体自身坐标系里:长沿 x、宽沿 z、高沿 -y(y 朝下所以往上要减) 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, -h, -h, -h, -h]) Z = np.array([ w/2, -w/2, -w/2, w/2, w/2, -w/2, -w/2, w/2]) c, s = np.cos(ry), np.sin(ry) R = np.array([[ c, 0, s], [ 0, 1, 0], [-s, 0, c]]) corners = R @ np.stack([X, Y, Z]) + np.array([[x], [y], [z]]) return corners.T # 转成 (8,3),每行一个角点 # 12 条棱:底面 4 条、顶面 4 条、竖直 4 条 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 draw_box3d(img, corners_cam, M_cam, color=(0, 255, 0)): """M_cam = P2 @ R0_rect,注意不含 Tr""" homo = np.hstack([corners_cam, np.ones((8, 1))]) uv = homo @ M_cam.T # 只要有角点落在相机背后就整框不画,否则会画出一团穿过整幅图的乱线 if np.any(uv[:, 2] <= 0.1): return img pts = (uv[:, :2] / uv[:, 2:3]).astype(int) for i, j in EDGES: cv2.line(img, tuple(pts[i]), tuple(pts[j]), color, 2) return img

X、Y、Z这三个数组的顺序是最容易翻车的地方。KITTI 的尺寸顺序是h w l,位置y是底面中心而不是几何中心,朝向是绕竖直轴的 yaw。我见过不只一个人把w和l换着填,结果就是框子“能画出来、位置也对、但车的长宽反了”,画轿车像是画了个方盒子,肉眼很难第一眼看出来。

那个“有角点在背后就整框不画”的判断也很关键。不做这个判断的话,当车在相机正后方或者侧后方时,有一两个角点的深度是负的,除完会翻到图像另一侧,画出来的绿色线段横穿整幅图,看着像标注数据坏了,其实是投影没做剔除。

5. 常见问题排查:点云全黑、投影错位、帧率掉到个位数

5.1 窗口弹出来了,但里面什么都没有

现象:vis.run()之后窗口正常出现,坐标系那三个箭头也在,但点云区域一片空,怎么转都看不到东西。

原因有两个层面。表层是point_size太小甚至是 0,点确实画了,只是小到看不见一个像素。深层是点云坐标压根不在视野附近——比如你误读了testing目录里另一套坐标约定不同的数据,或者把pts[:, 1]当成高度轴去着色,导致点全被压缩到一个平面上。

解决:先在终端打印pts.shape、pts[:, :3].min(axis=0)、pts[:, :3].max(axis=0),确认三个轴的数值范围都在几十米量级;再强制把point_size设到 3.0、背景改成纯白、点云改成纯黑,这样只要有一个点存在就绝对能看见。这两个动作做完还没显示,基本就能断定是数据路径读错了文件。

5.2 点云投到图像上整体偏右或偏下

现象:点云投影叠加到 image_2 上之后,能看出车和路的形状是对的,但整体朝右下方偏了一截,越远的物体偏得越少。

原因:矩阵乘的顺序错了。常见的错误写法是P2 @ Tr(漏掉R0_rect),或者用了P3、P4却仍然叠加在image_2上。R0_rect是双目校正引入的旋转,漏掉它的话投影结果会有几个像素到几十个像素的系统性偏移,而且偏移量随距离变化——这个特征很好认。

解决:把合成矩阵打印出来,和用P2 @ R0_rect @ Tr_velo_to_cam手算的结果逐元素比一下。验证方法是找一根远处的电线杆或者路牌,看它的点云是不是正好落在图像上的同一根柱子边缘;对齐了就是对的,偏一截就是矩阵顺序的问题。

5.3 顺序翻帧时内存一路涨,最后卡死

现象:写了个循环连续播几十帧,跑着跑着内存占用从几百兆涨到几个 G,帧率从几十帧掉到个位数,最后窗口无响应。

原因:每一帧都新建了一个Visualizer,或者每帧都add_geometry而从不remove_geometry。Open3D 的显存和几何体是挂在 Visualizer 上的,destroy_window没走到的话资源不会释放。另一种情况是每帧重新计算颜色数组时用了 Python 的for循环遍历十几万个点,CPU 直接跑满。

解决:整个程序只创建一次 Visualizer,每帧复用同一个PointCloud对象,改完points和colors之后调vis.update_geometry(pcd)再vis.poll_events()+vis.update_renderer()。颜色计算全部向量化,实在还慢就上pcd.voxel_down_sample(0.1)下采样,十几万点降到两万点,视觉上几乎没差别,帧率能翻几倍。

5.4 三维框位置对,但尺寸和朝向明显不对

现象:绿色框确实套在车上了,但框比车高出一大截,或者长宽比明显是反的,有的车框还转了个奇怪的角度。

原因:三处高频错误。一是dimensions的h w l顺序填错,把l当成了w;二是角点的Y数组符号写反,忘了相机系 y 轴朝下,于是框往上长到了车顶上方;三是rotation_y的正负号弄反,框绕着车转 180 度,位置还对但朝向反了。

解决:先拿一帧里距离最近、面积最大的那辆车做样本,把dimensions和location打印出来,用卷尺思维核对——轿车的高度大概 1.5 米、宽度 1.6 米、长度 4 米,数字对不上就是顺序错了。然后把rotation_y加上负号再画一次,两个结果里有一个是对的,用肉眼就能判断。

5.5 换到自己采的数据就全乱

现象:同一套代码跑 KITTI 没问题,换成自采数据后,点云能从 bin 里读出来,但投影完全对不上,或者点云的朝向整个反了。

原因:自采数据的标定格式、坐标系朝向、甚至文件头都可能和 KITTI 不一样。有的采集设备输出的点云是 x 朝前、y 朝左的右手系,和 KITTI 的 velodyne 系差了一个旋转;有的设备 bin 文件前面加了自定义头,fromfile会从头读进去导致整体错位。

解决:先单独验证点云本身——把点云和一张同位置的俯视地图对一下,确认“车头朝哪”。确认之后,把这个朝向差异写成一个 4×4 的修正矩阵,乘在Tr_velo_to_cam前面,而不是去改后面的投影代码。这样 KITTI 的逻辑保持原样,只是多了一层适配,将来换设备只改这一个矩阵。

6. 二次开发改造:从单帧脚本到可回放的标注检查工具

单帧脚本跑通之后,真正的价值在改造。我一般会把它拆成三层:io.py只负责读文件和解析标定,render.py只负责把点云、图像、框画出来,app.py负责交互循环和状态。这样拆的好处是,后面要做导出、要做批量统计、要接自己的数据格式,都只动一层。

第一个能立刻见效的改造是键盘切帧。VisualizerWithKeyCallback支持注册按键回调,用左右方向键翻帧,就能变成一个小型的标注检查器:

import numpy as np import open3d as o3d class FrameViewer: def __init__(self, root, start=0): self.root, self.idx = root, start self.vis = o3d.visualization.VisualizerWithKeyCallback() self.vis.create_window(window_name="KITTI replay", width=1280, height=720) self.pcd = o3d.geometry.PointCloud() self.vis.add_geometry(self.pcd) # 262/263 是 GLFW 的右/左方向键码,回调里只改索引,别在这里做重计算 self.vis.register_key_callback(262, lambda v: self._step(1)) self.vis.register_key_callback(263, lambda v: self._step(-1)) def _step(self, delta): self.idx = max(0, self.idx + delta) self._refresh() return False # 返回 False 表示不关闭窗口,返回 True 会直接退出 def _refresh(self): pts = load_velo(self.root / "velodyne" / f"{self.idx:06d}.bin") self.pcd.points = o3d.utility.Vector3dVector(pts[:, :3].astype(np.float64)) self.pcd.colors = o3d.utility.Vector3dVector(colorize(pts, "height")) # 复用同一个 PointCloud,只更新数据,不要 add 新的几何体 self.vis.update_geometry(self.pcd) def run(self): self._refresh() while self.vis.poll_events(): self.vis.update_renderer() self.vis.destroy_window()

这里_step直接返回False而不是在主循环里轮询按键,是实践下来最省心的做法。唯一的坑是回调执行在渲染线程里,如果你在回调里做磁盘读取加十几万点的矩阵运算,界面会明显卡顿。稳妥点的话,把“加载下一帧”做成后台线程加一个待刷新的标记,主循环检测到标记再更新几何体。

第二个改造是缓存。翻帧的时候反复读同一个 calib 文件很浪费,加一个functools.lru_cache装饰在read_calib上就够了,因为标定在同一个 sequence 里基本不变。点云本身不太适合缓存,太占内存,但可以缓存下采样后的版本。

第三个改造是导出。检查标注的时候,看到有问题的帧总得留证据,write_image保存叠加图、write_point_cloud保存 ply 都是两行的事。我一般再加一个“按 q 把当前帧的序号写进一个 txt”,最后一次性把所有可疑帧号捞出来。

改造点大致工作量实际收益
键盘切帧 + 复用几何体半天检查效率翻倍,卡顿消失
calib 缓存十分钟翻帧时省掉重复文件 IO
二维框 + 类别文字叠加两小时一眼看出投影和标注是否一致
导出可疑帧号半小时标注复核有据可查
换自采数据适配层一天一套工具吃两种数据源

最后一个技巧,也是我觉得最值钱的:把点云投影到图像后的像素坐标,和标注里的二维框bbox做一次交集比对。如果投影框和标注框重叠率低于某个阈值,就把这一帧标红。这个检查能自动筛出标注错漏和标定漂移,比自己一帧一帧翻高效得多。

验证改造效果的方法我一般用两个:一是拿一帧里有明确尺寸参照物的(比如一辆轿车旁边站了个人),量一下投影框底部和车轮着地点的重合度,差几个像素以内就算对;二是随机抽二十帧,人工过一遍框和点云是否贴合,把这二十帧的结果存下来,之后每次改完代码都重跑这二十帧对比,避免“改好了 A 帧、悄悄弄坏了 B 帧”。

说个自己的教训:早期我图省事,把矩阵合成的顺序写在一个临时脚本里,没写注释也没做单测,后来想加一个相机 3 的视角,照着记忆重写了一版,结果顺序和原版不一样,两个脚本画出来的图一左一右。我在那上面耗了整整一个晚上,最后是逐元素打印矩阵才发现的。从那以后我每次碰标定这块,都强制走一遍“打印合成矩阵 + 抽三帧肉眼核对 + 记录到项目笔记”的流程,再也不敢凭记忆写多矩阵相乘。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询