KITTI 点云与图像可视化:从标定到 3D 框的完整实现
简介这是一份面向自动驾驶感知学习者的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.215377e02 0.000000e00 6.095593e02 0.000000e00 ... P1: 7.215377e02 0.000000e00 6.095593e02 -3.875744e02 ... P2: 7.215377e02 0.000000e00 6.095593e02 4.485728e01 ... P3: 7.215377e02 0.000000e00 6.095593e02 3.395284e02 ... R0_rect: 1.000000e00 0.000000e00 0.000000e00 ... Tr_velo_to_cam: 0.000000e00 -1.000000e00 0.000000e00 ... Tr_imu_to_velo: 9.999999e-01 0.000000e00 0.000000e00 ...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, dtypenp.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_d0, max_d80): 深度转伪彩色返回 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], axis1)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_pathNone): 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、llocation是相机坐标系下框的中心点。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 向下框的底部在 y0顶部在 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用 ly_corners用 hz_corners用 w。5.4 标注框和图像里的物体对不上现象2D 框画上去框的位置和图像里的车偏移了几十个像素。原因KITTI 的 2D 框坐标是相对于原始图像 1242×375 的如果图像被缩放或裁剪过框坐标没有同步变换。解决可视化时不要缩放图像直接用原始尺寸。如果必须缩放把 2D 框坐标按同样比例缩放。5.5 点云和图像读取的帧号不一致现象点云投影上去和图像内容完全不搭像是两帧数据。原因文件名排序时用了字符串排序000123和00099的顺序会错。KITTI 文件名是 6 位数字字符串排序和数值排序结果不同。解决读取文件列表时按文件名中的数字排序用sorted(files, keylambda 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], axis1) 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键盘回调里同步切换帧号。几个实际调参经验点云点大小默认是 164 线雷达点很密调到 2 或 3 更清楚背景色设成白色或浅灰比黑色更容易看清远处点3D 框线宽用line_set.line_width设成 2 到 3太细看不清。验证可视化是否正确有一个简单办法找一帧有明确参照物的数据比如路边停着一排车把点云投影到图像上看车的轮廓是否和点云重合。如果重合说明标定和投影链路没问题如果偏移回到第 5 章排查。我自己做这个项目最大的教训是不要一上来就写完整可视化工具先用一帧数据把投影链路跑通确认点云和图像对齐再往上加 3D 框、加交互、加批量处理。很多翻车都是因为跳过验证步骤直接写大脚本结果错了不知道哪一层出的问题。希望帮到你。本文还有配套的精品资源点击获取