简介:这是一份面向自动驾驶感知学习者的KITTI数据集可视化工具包,聚焦点云与标注结果的可视化查看,适合具备一定Python基础、正在做3D目标检测或点云处理实践的开发者使用。包内共42个文件,以py脚本、ipynb笔记本、png效果图、txt与xml标注文件为主,另有少量bin点云样本和pyc缓存,压缩包约9.81MB。其中脚本模块分别提供.bin格式点云的直接可视化、点云BEV俯视图查看,以及基于kitti_object_vis的九种数据集可视化操作,配套的notebook与示例图片便于快速验证效果。已有2849人学习下载,读者可借此搭建起从原始点云到可视化结果的完整链路,理解KITTI数据组织方式,并在此基础上调试自己的检测或分割模型输出,减少重复造轮子的时间成本。
1. KITTI 可视化不是“画个图”:从点云到标注的完整链路
很多人第一次拿到 KITTI 数据集,解压完看到image_2、velodyne、label_2、calib四个文件夹,第一反应是写个脚本把图片读出来看一眼。结果图片能看,点云一打开全是黑屏,标注框画上去位置全歪。问题不在代码,在于 KITTI 的坐标系、标定文件和传感器布局没有对齐。
KITTI 可视化项目的核心目标,是把激光雷达点云、RGB 图像、3D 标注框、标定参数这四类数据在同一空间里对齐展示。它解决的是感知算法开发中最基础也最容易被跳过的一步:你得先看见数据长什么样,才能判断模型输出是否合理。适合做这个项目的人包括:刚接触自动驾驶感知的算法工程师、需要做数据质检的标注团队、以及想验证自己检测模型输出是否正确的开发者。
这个项目不需要 GPU,不需要深度学习框架,一台普通笔记本就能跑。但要做好,必须理解 KITTI 的传感器配置和坐标变换关系。下面从数据组织讲起,一步步把可视化链路搭起来。
2. KITTI 数据组织与坐标系:先把“谁在哪儿”搞清楚
2.1 四个文件夹各自装了什么
KITTI 目标检测数据集的标准目录结构如下:
kitti/ ├── training/ │ ├── image_2/ # 左侧 RGB 图像,PNG 格式 │ ├── velodyne/ # 激光雷达点云,BIN 格式 │ ├── label_2/ # 3D 标注框,TXT 格式 │ └── calib/ # 标定参数,TXT 格式 └── testing/ ├── image_2/ ├── velodyne/ └── calib/image_2是左侧彩色相机拍的图,分辨率 1242×375。velodyne是 Velodyne HDL-64E 激光雷达采集的点云,每帧约 12 万个点,每个点包含 x、y、z、反射强度四个值,以 float32 二进制存储。label_2是标注文件,每行一个目标,包含类别、截断程度、遮挡程度、观察角度、2D 框、3D 框尺寸和位置、旋转角。calib是标定文件,包含相机内参、相机间外参、雷达到相机的外参。
这四个文件夹的文件名按帧号一一对应,比如000123.png、000123.bin、000123.txt是同一帧的数据。
2.2 坐标系变换:从雷达到图像的关键矩阵
KITTI 涉及四个坐标系:激光雷达坐标系、左侧相机坐标系、右侧相机坐标系、图像像素坐标系。可视化时最常用的是把雷达点投影到图像上,这需要三个变换:
- 雷达到相机:用
calib文件里的Tr_velo_to_cam矩阵,把雷达坐标系的点转到相机坐标系。 - 相机到图像:用
P2投影矩阵,把相机坐标系的点投影到像素坐标。 - 图像到标注:标注文件里的 3D 框是在相机坐标系下定义的,2D 框是在像素坐标系下定义的。
calib文件每行格式如下:
P0: 7.215377e+02 0.000000e+00 6.095593e+02 0.000000e+00 ... P1: 7.215377e+02 0.000000e+00 6.095593e+02 -3.875744e+02 ... P2: 7.215377e+02 0.000000e+00 6.095593e+02 4.485728e+01 ... P3: 7.215377e+02 0.000000e+00 6.095593e+02 3.395284e+02 ... R0_rect: 1.000000e+00 0.000000e+00 0.000000e+00 ... Tr_velo_to_cam: 0.000000e+00 -1.000000e+00 0.000000e+00 ... Tr_imu_to_velo: 9.999999e-01 0.000000e+00 0.000000e+00 ...P2是 3×4 矩阵,R0_rect是 3×3 修正矩阵,Tr_velo_to_cam是 3×4 矩阵。投影一个雷达点p_velo = [x, y, z, 1]到图像的完整公式是:
p_cam = R0_rect @ Tr_velo_to_cam @ p_velo p_img = P2 @ p_cam u = p_img[0] / p_img[2] v = p_img[1] / p_img[2]注意R0_rect需要扩展成 4×4 再乘,因为Tr_velo_to_cam是 3×4,实际计算时通常把R0_rect补成 4×4 单位阵形式。
提示:KITTI 的相机坐标系是 x 向右、y 向下、z 向前,雷达坐标系是 x 向前、y 向左、z 向上。直接拿雷达点当相机点用,投影结果会完全错位。
3. 用 Python 把点云投到图像:最小可复现代码
3.1 读取标定文件和点云
先写两个工具函数,一个读标定,一个读点云。标定文件里每行按冒号分割,点云文件用 numpy 直接读二进制。
import numpy as np def read_calib(calib_path): """读取 KITTI 标定文件,返回字典""" calib = {} with open(calib_path, 'r') as f: for line in f: if ':' not in line: continue key, value = line.split(':', 1) # 每行 12 或 9 个数,按需 reshape nums = np.array([float(x) for x in value.strip().split()]) if key.startswith('P'): calib[key] = nums.reshape(3, 4) elif key == 'R0_rect': calib[key] = nums.reshape(3, 3) elif key.startswith('Tr_'): calib[key] = nums.reshape(3, 4) return calib def read_velodyne(bin_path): """读取 KITTI 点云,返回 N×4 数组""" points = np.fromfile(bin_path, dtype=np.float32) return points.reshape(-1, 4)read_calib里对P开头的键做 3×4 reshape,对R0_rect做 3×3,对Tr_开头的做 3×4。read_velodyne直接按 float32 读,每四个数一个点。
3.2 投影计算与颜色映射
投影时只保留相机前方的点,即p_cam[2] > 0,否则会投到图像背面。颜色按深度或反射强度映射,深度越远颜色越冷。
def project_velo_to_image(points, calib): """把雷达点投影到图像,返回像素坐标和深度""" # 补齐齐次坐标 pts_velo = np.hstack([points[:, :3], np.ones((points.shape[0], 1))]) # 雷达到相机 Tr = np.vstack([calib['Tr_velo_to_cam'], [0, 0, 0, 1]]) R0 = np.eye(4) R0[:3, :3] = calib['R0_rect'] pts_cam = (R0 @ Tr @ pts_velo.T).T # 只保留相机前方 mask = pts_cam[:, 2] > 0 pts_cam = pts_cam[mask] # 投影到图像 pts_img = (calib['P2'] @ pts_cam.T).T u = pts_img[:, 0] / pts_img[:, 2] v = pts_img[:, 1] / pts_img[:, 2] depth = pts_cam[:, 2] return u, v, depth, mask def color_map(depth, min_d=0, max_d=80): """深度转伪彩色,返回 0-1 的 RGB""" norm = np.clip((depth - min_d) / (max_d - min_d), 0, 1) # 简单蓝到红映射 r = norm g = 1 - np.abs(norm - 0.5) * 2 b = 1 - norm return np.stack([r, g, b], axis=1)project_velo_to_image返回的mask是原始点云中哪些点被保留,后面画图时要用它对齐颜色。color_map把深度归一化后映射到蓝-绿-红渐变,近处偏红,远处偏蓝。
3.3 叠加显示与保存
用 OpenCV 把图像读进来,在投影位置画点,再画 2D 标注框。
import cv2 def visualize_frame(img_path, bin_path, calib_path, label_path, save_path=None): img = cv2.imread(img_path) points = read_velodyne(bin_path) calib = read_calib(calib_path) u, v, depth, mask = project_velo_to_image(points, calib) colors = color_map(depth) # 画点云 for i in range(len(u)): ui, vi = int(u[i]), int(v[i]) if 0 <= ui < img.shape[1] and 0 <= vi < img.shape[0]: bgr = (int(colors[i][2]*255), int(colors[i][1]*255), int(colors[i][0]*255)) cv2.circle(img, (ui, vi), 1, bgr, -1) # 画 2D 标注框 with open(label_path, 'r') as f: for line in f: parts = line.strip().split() if len(parts) < 15: continue x1, y1, x2, y2 = map(int, map(float, parts[4:8])) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, parts[0], (x1, y1-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) if save_path: cv2.imwrite(save_path, img) return imgvisualize_frame把点云按深度着色画到图像上,再叠加 2D 框和类别标签。cv2.circle半径设为 1,避免点太密糊成一片。标注文件每行第 5 到第 8 个数是 2D 框的左上角和右下角坐标。
注意:KITTI 图像是 BGR 还是 RGB 取决于读取方式,OpenCV 默认 BGR,如果后面用 matplotlib 显示需要转换,否则颜色会偏。
4. 3D 框绘制与点云着色:让标注“立起来”
4.1 从标注文件解析 3D 框
KITTI 标注每行 15 个字段,3D 框相关的是第 9 到第 15 个:高度、宽度、长度、相机坐标系下的 x、y、z、绕 y 轴旋转角。注意这里的尺寸顺序是 h、w、l,不是 l、w、h。
def parse_label(label_path): """解析 KITTI 标注,返回目标列表""" objects = [] with open(label_path, 'r') as f: for line in f: parts = line.strip().split() if len(parts) < 15: continue obj = { 'type': parts[0], 'truncated': float(parts[1]), 'occluded': int(parts[2]), 'alpha': float(parts[3]), 'bbox2d': list(map(float, parts[4:8])), 'dimensions': list(map(float, parts[8:11])), # h, w, l 'location': list(map(float, parts[11:14])), # x, y, z 'rotation_y': float(parts[14]) } objects.append(obj) return objectsdimensions是 h、w、l,location是相机坐标系下框的中心点。rotation_y是绕相机坐标系 y 轴的旋转角,单位弧度。
4.2 计算 3D 框的 8 个顶点
3D 框在相机坐标系下是一个长方体,先算局部坐标的 8 个角点,再旋转平移。
def compute_3d_box_corners(obj): """计算 3D 框 8 个顶点在相机坐标系下的坐标""" h, w, l = obj['dimensions'] x, y, z = obj['location'] ry = obj['rotation_y'] # 局部坐标,中心在原点 x_corners = [l/2, l/2, -l/2, -l/2, l/2, l/2, -l/2, -l/2] y_corners = [0, 0, 0, 0, -h, -h, -h, -h] z_corners = [w/2, -w/2, -w/2, w/2, w/2, -w/2, -w/2, w/2] # 旋转矩阵 R = np.array([[np.cos(ry), 0, np.sin(ry)], [0, 1, 0], [-np.sin(ry), 0, np.cos(ry)]]) corners = np.dot(R, np.vstack([x_corners, y_corners, z_corners])) corners[0] += x corners[1] += y corners[2] += z return corners.T # 8×3y_corners从 0 到 -h 是因为相机坐标系 y 向下,框的底部在 y=0,顶部在 y=-h。旋转矩阵绕 y 轴,和rotation_y定义一致。
4.3 把 3D 框画到图像和点云上
画到图像上需要把 8 个顶点投影到像素坐标,然后连 12 条边。画到点云上则直接在 3D 空间连线。
def draw_3d_box_on_image(img, corners, calib, color=(0, 255, 255)): """把 3D 框投影到图像并画线""" # 补齐齐次坐标 pts = np.hstack([corners, np.ones((8, 1))]) pts_img = (calib['P2'] @ pts.T).T pts_2d = pts_img[:, :2] / pts_img[:, 2:3] # 12 条边的顶点索引 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)] for i, j in edges: p1 = tuple(map(int, pts_2d[i])) p2 = tuple(map(int, pts_2d[j])) cv2.line(img, p1, p2, color, 2) return imgedges定义了长方体 12 条边的连接关系。投影后直接取前两维除以第三维得到像素坐标。画到点云上时,用 Open3D 或 matplotlib 的 3D 绘图,把corners按同样边连接即可。
提示:如果 3D 框画出来位置对但方向反了,检查
rotation_y的符号。KITTI 的rotation_y是绕 y 轴逆时针,但相机 y 轴向下,实际视觉上是顺时针。
5. 避坑与排查:KITTI 可视化最常见的 5 个翻车点
5.1 点云投影后全部偏到图像一侧
现象:点云投影到图像上,所有点都挤在左边或右边,和图像内容对不上。
原因:Tr_velo_to_cam矩阵读取时 reshape 错了,或者R0_rect没有正确扩展成 4×4。KITTI 的Tr_velo_to_cam是 3×4,直接和 4×N 的点乘会维度不匹配,必须补成 4×4。
解决:打印calib['Tr_velo_to_cam']的形状,确认是 (3,4),然后用np.vstack([Tr, [0,0,0,1]])补成 (4,4)。R0_rect同理,用np.eye(4)填充前 3×3。
5.2 点云颜色全黑或全白
现象:投影后的点云颜色没有层次,要么全黑要么全白。
原因:深度归一化的范围设错了。KITTI 点云深度范围大约 0 到 80 米,如果max_d设成 255 或 1000,所有点归一化后都接近 0,颜色全偏一个方向。
解决:先统计当前帧深度的最小值和最大值,动态设置min_d和max_d。或者固定用 0 到 80,这是 KITTI 的典型有效距离。
5.3 3D 框画出来比实际物体大一圈
现象:3D 框的尺寸看起来比图像里的车大很多,或者小很多。
原因:dimensions的顺序是 h、w、l,不是 l、w、h。如果按 l、w、h 解析,长度和高度会互换,框的形状完全错。
解决:确认解析时dimensions = [h, w, l],计算角点时x_corners用 l,y_corners用 h,z_corners用 w。
5.4 标注框和图像里的物体对不上
现象:2D 框画上去,框的位置和图像里的车偏移了几十个像素。
原因:KITTI 的 2D 框坐标是相对于原始图像 1242×375 的,如果图像被缩放或裁剪过,框坐标没有同步变换。
解决:可视化时不要缩放图像,直接用原始尺寸。如果必须缩放,把 2D 框坐标按同样比例缩放。
5.5 点云和图像读取的帧号不一致
现象:点云投影上去,和图像内容完全不搭,像是两帧数据。
原因:文件名排序时用了字符串排序,000123和00099的顺序会错。KITTI 文件名是 6 位数字,字符串排序和数值排序结果不同。
解决:读取文件列表时按文件名中的数字排序,用sorted(files, key=lambda x: int(x.split('.')[0]))。
6. 进阶技巧:用 Open3D 做交互式点云与标注联动
前面用 OpenCV 画的是静态图,调试时够用,但想旋转视角、单独看某个目标、对比多帧,静态图就不方便了。我一般会再搭一个 Open3D 的交互窗口,把点云、3D 框、甚至图像缩略图放在一起,用键盘切换帧。
import open3d as o3d def visualize_with_open3d(bin_path, label_path, calib_path): """用 Open3D 交互式显示点云和 3D 框""" points = read_velodyne(bin_path) pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(points[:, :3]) # 按深度着色 depth = np.linalg.norm(points[:, :3], axis=1) colors = color_map(depth, 0, 80) pcd.colors = o3d.utility.Vector3dVector(colors) # 画 3D 框 objects = parse_label(label_path) geometries = [pcd] for obj in objects: corners = compute_3d_box_corners(obj) # 用线段集画框 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)] lines = [[i, j] for i, j in edges] line_set = o3d.geometry.LineSet() line_set.points = o3d.utility.Vector3dVector(corners) line_set.lines = o3d.utility.Vector2iVector(lines) line_set.colors = o3d.utility.Vector3dVector([[1, 0, 0] for _ in lines]) geometries.append(line_set) o3d.visualization.draw_geometries(geometries)visualize_with_open3d把点云按深度着色,每个目标的 3D 框用红色线段表示。Open3D 窗口里可以鼠标拖拽旋转、滚轮缩放,按+和-调点大小。如果想加图像联动,可以在窗口旁边用 OpenCV 开一个imshow,键盘回调里同步切换帧号。
几个实际调参经验:点云点大小默认是 1,64 线雷达点很密,调到 2 或 3 更清楚;背景色设成白色或浅灰,比黑色更容易看清远处点;3D 框线宽用line_set.line_width设成 2 到 3,太细看不清。
验证可视化是否正确,有一个简单办法:找一帧有明确参照物的数据,比如路边停着一排车,把点云投影到图像上,看车的轮廓是否和点云重合。如果重合,说明标定和投影链路没问题;如果偏移,回到第 5 章排查。
我自己做这个项目最大的教训是:不要一上来就写完整可视化工具,先用一帧数据把投影链路跑通,确认点云和图像对齐,再往上加 3D 框、加交互、加批量处理。很多翻车都是因为跳过验证步骤,直接写大脚本,结果错了不知道哪一层出的问题。希望帮到你。
本文还有配套的精品资源,点击获取