我最早复现Complex-YOLO的时候,就在KITTI数据上栽了个大跟头。网上下了一套KITTI,解压完才发现里面只有image和velodyne_points,翻了半天没找到label_2和calib目录。后来才搞清楚,那是SLAM教程里最常用的odometry版本,拿来做视觉里程计没问题,但放到3D目标检测训练里就是缺胳膊少腿。更折磨人的是,就算换到object detection数据包,点云用的是雷达坐标系,标签却写在相机坐标系里,不把这两个坐标系对齐,画出来的3D框全飘在空中。这篇博文就是整理我做完KITTI预处理全流程之后的东西,覆盖下载哪些压缩包、目录怎么组织、怎么读bin点云、怎么做三通道BEV编码、怎么把3D框从相机坐标变换到雷达坐标,以及最后怎么验证数据没做错。如果你正在复现Complex-YOLO,或者想把3D点云目标检测的数据链路彻底搞清楚,这篇能帮你少踩不少坑。
1. 训练Complex-YOLO前,先分清KITTI里那几套“长得像”的数据
1.1 为什么我拿SLAM那套KITTI数据来训练,直接失败
很多人是从SLAM相关的书和视频里第一次听说KITTI数据集的,比如SLAM十四讲里提到KITTI下载,跟着教程拿到的是odometry里程计数据集。这个系列目录下确实有图像、有velodyne点云、有位姿真值,看起来“什么都有”,但3D目标检测训练需要的东西恰恰不在里面。
目标检测数据集需要的是每帧点云对应的3D边界框标签,也就是每个物体类别、截断程度、遮挡程度、2D框、3D尺寸、3D中心位置、绕y轴旋转角。odometry数据集提供的是相机位姿,不是物体级别的标注。你拿odometry数据去跑Complex-YOLO,训练代码解析label文件时会直接报错,或者更糟糕的是你不会立刻发现,因为你可能会自己去写一套“无监督”逻辑,但那已经偏离原论文的复现路线了。
所以下单之前先看清楚文件名:object detection相关的压缩包统一带data_object_前缀,odometry相关的则带data_odometry_前缀。这个区别在KITTI官网首页一眼就能看到,千万别顺手点错。
1.2 雷达坐标系、相机坐标系与BEV视图的关系
KITTI里涉及三套坐标,很多人栽在这里。
Velodyne激光雷达坐标系定义是:x轴指向车头正前方,y轴指向车身左侧,z轴垂直向上。相机坐标系则完全不同,x轴指向右侧,y轴指向下方,z轴指向相机前方。也就是说,在雷达坐标里“前方”是x轴方向,在相机坐标里“前方”是z轴方向,两个坐标系之间隔着一个旋转平移矩阵。
BEV(Bird's Eye View)就是把点云从空中往下看,把z轴方向压平,留下x-y平面。Complex-YOLO不走常规的3D卷积路线,而是先把点云编码成一张BEV图像,再用类似YOLO的2D检测思想去预测目标。由于雷达坐标系天然是x向前、y向左、z向上,所以BEV图上通常让x方向作为图像的行方向,y方向作为列方向,这样车头方向在图像里是“向上”的。
这个过程可以类比成两个人面对面坐着看同一张地图,一个习惯于“前方是门”,另一个习惯于“右侧是窗”,中间必须有一套统一的坐标换算规则。KITTI提供的calib标定文件,就是这套换算规则的“官方字典”。
1.3 Complex-YOLO对数据格式的隐性要求
如果你去翻Complex-YOLO论文,会发现它对输入有一个明确的预处理约定:点云限制在x方向0到70.4米、y方向-40米到40米、z方向-3米到1米,然后以0.1米分辨率投影成一张704乘800的BEV三通道图。
这三个范围不是随便拍脑袋定的。前方70.4米覆盖了KITTI里绝大多数有效目标,左右各40米能包住多车道高速和城区道路场景,z方向上的-3到1米则把地面以下噪点和头顶树枝、立交桥等无关反射剔除掉。分辨率0.1米意味着每10个像素对应1米,这个粒度在远处目标上还能保留足够的点云轮廓,再粗就分不清行人和骑行者了。
这几个参数在预处理脚本里是全局常量,后面很多代码都依赖它们。后面你看到的BEV尺寸、栅格索引、标签编码,全都围绕这个范围展开。我的建议是先把这三个取值范围和0.1米分辨率写死,等整个流程跑通之后,再考虑要不要调。
2. 下载解压与目录整理:从压缩包到标准数据集的完整动作
2.1 该下哪几个压缩包:一份能直接照着做的清单
Complex-YOLO训练只需要KITTI Object Detection数据集里的三样东西:点云、3D标签、校准文件。如果你要做可视化检查,建议再把左相机图像也下了。下面是明确清单。
| 压缩包 | 用途 | 训练是否必须 | 说明 |
|---|---|---|---|
| data_object_velodyne.zip | 激光雷达点云 | 必须 | 约29GB,training和testing都包含在其中 |
| data_object_label_2.zip | 3D目标检测标签 | 必须 | 很小,只有几MB |
| data_object_calib.zip | 相机/雷达标定文件 | 必须 | 很小 |
| data_object_image_2.zip | 左相机图像 | 强烈建议 | 约12GB,用于投影验证GT框 |
| data_object_planes.zip | 地面平面参数 | 可选 | 某些方法需要,Complex-YOLO基本用不上 |
有人会问testing部分要不要保留。训练时只用training目录,testing主要是官方评测时用的,你本地复现可以完全不管它。但下载还是整包下载,解压时如果嫌占空间,可以只解压training子目录。
2.2 解压后的目录结构长什么样
三个压缩包分别解压后,最终应该整理成这样的结构:
KITTI/ ├── training/ │ ├── calib/ │ │ ├── 000000.txt │ │ ├── 000001.txt │ │ └── ... │ ├── image_2/ │ │ ├── 000000.png │ │ ├── 000001.png │ │ └── ... │ ├── label_2/ │ │ ├── 000000.txt │ │ ├── 000001.txt │ │ └── ... │ └── velodyne/ │ ├── 000000.bin │ ├── 000001.bin │ └── ... └── testing/ ├── calib/ ├── image_2/ └── velodyne/这里有个坑:三个压缩包解压后各自会带一层目录,比如data_object_velodyne/training/velodyne、data_object_label_2/training/label_2,它们不是天然的合并结构。训练代码通常按KITTI/training/{velodyne,label_2,calib,image_2}这种目录去找文件,所以解压完最好手动把子目录移动到同一个根目录下。
可以先把压缩包放在不同临时目录里解压,然后用cp -r或rsync合并。目录名必须完全对齐:velodyne、label_2、calib、image_2,大小写和单复数都不能错,否则后续代码路径会拼接失败。
2.3 下载中断、文件损坏与一致性校验
KITTI压缩包体积不小,尤其是点云包,下载过程中断是常态。我不推荐用浏览器默认下载,因为断点续传能力差。用命令行工具加-c参数,可以在中断后继续下载:
wget -c https://s3.eu-central-1.amazonaws.com/avg-kitti/data_object_velodyne.zip下完后不要急着解压。先做两件事:第一,比较压缩包大小是不是和官网标注一致;第二,解压时检查有没有CRC错误。如果解压报错,有效做法是删掉重下,而不是强行解压出一部分文件来用。
数据是否完整,还有更细的检查方式。每一帧velodyne点云文件都是float32数组,每个点由x、y、z、reflectance四个float组成,因此文件字节数必须能被16整除。写一个脚本扫一遍所有bin文件,顺手统计点数范围,能帮你发现下载损坏或截断的文件。
import os import numpy as np def check_velodyne_dir(velodyne_dir): for name in sorted(os.listdir(velodyne_dir)): path = os.path.join(velodyne_dir, name) size = os.path.getsize(path) if size % 16 != 0: print(f"{name}: size {size} not divisible by 16") continue points = np.fromfile(path, dtype=np.float32).reshape(-1, 4) if len(points) < 1000: print(f"{name}: only {len(points)} points, suspicious") print("done")3. 点云读取与BEV编码:把Velodyne bin变成网络能看的图
3.1 一个bin点云文件的内部结构
KITTI的velodyne点云不是常见的有头PCD或者LAS格式,而是最简单纯粹的二进制裸数组。每一帧000000.bin里连续存放若干个float32,每4个float组成一行,依次是x、y、z、反射强度。
读取代码非常短:
import numpy as np def load_kitti_bin(bin_path): data = np.fromfile(bin_path, dtype=np.float32).reshape(-1, 4) points = data[:, :3] # x, y, z reflectance = data[:, 3] # intensity return points, reflectance一帧64线Velodyne点云大概有10万到12万个点。读文件时不要一次性np.load之类,直接用np.fromfile就好,速度已经很快。
3.2 裁剪范围和分辨率怎么定
原始点云覆盖整个360度范围,但训练时模型只需要车辆前方的点。Complex-YOLO按论文约定,裁剪到这样一块区域:x方向0到70.4米,y方向-40米到40米,z方向-3米到1米。
为什么x方向从0开始?因为雷达坐标里x轴指向车头前方,x小于0的点在车身后方,而KITTI目标检测的主要标注对象都在前方,车身后方的点对训练目标帮助有限。y方向左右对称,是因为道路场景中目标会出现在左右两侧。z方向控制在-3到1米,既能保留地面附近的有效反射,又能去掉大量的高空噪点。
分辨率0.1米是论文作者选的。我们把这个值换成代码:
DISCRETIZATION = 0.1 X_MIN, X_MAX = 0.0, 70.4 Y_MIN, Y_MAX = -40.0, 40.0 Z_MIN, Z_MAX = -3.0, 1.0 BEV_WIDTH = int((X_MAX - X_MIN) / DISCRETIZATION) # 704 BEV_HEIGHT = int((Y_MAX - Y_MIN) / DISCRETIZATION) # 8003.3 三通道BEV编码的具体实现与归一化
点云投影到BEV之后,不能只保留一个二值占用图,那样点云高度、反射强度、点密度这些信息全丢了。Complex-YOLO使用三通道编码,每个通道对应一种点云统计特征。
- height通道:每个栅格内点的最大z值,再除以z范围做归一化。它保留了物体的立体轮廓。
- intensity通道:每个栅格内点的最大反射强度。路面车道线、汽车金属表面的反射率和行人差异明显,对分类很有用。
- density通道:每个栅格内点的数量,做对数压缩,避免近处点云数量淹没远处目标。
先给出最直观的循环版本帮助理解,但实际训练时速度太慢,只建议用于复现逻辑:
def create_bev_naive(points, reflectance): bev = np.zeros((BEV_HEIGHT, BEV_WIDTH, 3), dtype=np.float32) for i in range(len(points)): x, y, z = points[i] if not (X_MIN <= x <= X_MAX and Y_MIN <= y <= Y_MAX and Z_MIN <= z <= Z_MAX): continue col = int((x - X_MIN) / DISCRETIZATION) row = int((y - Y_MIN) / DISCRETIZATION) if 0 <= row < BEV_HEIGHT and 0 <= col < BEV_WIDTH: if z > bev[row, col, 0]: bev[row, col, 0] = z if reflectance[i] > bev[row, col, 1]: bev[row, col, 1] = reflectance[i] bev[row, col, 2] += 1.0 bev[:, :, 0] = (bev[:, :, 0] - Z_MIN) / (Z_MAX - Z_MIN) bev[:, :, 2] = np.minimum(1.0, np.log(bev[:, :, 2] + 1.0) / np.log(64.0)) return bev真正用于生成训练集时,用向量化方法替代逐点循环:
def create_bev_vectorized(points, reflectance): mask = ( (points[:, 0] >= X_MIN) & (points[:, 0] <= X_MAX) & (points[:, 1] >= Y_MIN) & (points[:, 1] <= Y_MAX) & (points[:, 2] >= Z_MIN) & (points[:, 2] <= Z_MAX) ) points = points[mask] reflectance = reflectance[mask] col = ((points[:, 0] - X_MIN) / DISCRETIZATION).astype(np.int32) row = ((points[:, 1] - Y_MIN) / DISCRETIZATION).astype(np.int32) height_map = np.zeros((BEV_HEIGHT, BEV_WIDTH), dtype=np.float32) intensity_map = np.zeros((BEV_HEIGHT, BEV_WIDTH), dtype=np.float32) density_map = np.zeros((BEV_HEIGHT, BEV_WIDTH), dtype=np.float32) np.maximum.at(height_map, (row, col), points[:, 2]) np.maximum.at(intensity_map, (row, col), reflectance) np.add.at(density_map, (row, col), 1.0) bev = np.stack([height_map, intensity_map, density_map], axis=-1) bev[:, :, 0] = (bev[:, :, 0] - Z_MIN) / (Z_MAX - Z_MIN) bev[:, :, 2] = np.minimum(1.0, np.log(bev[:, :, 2] + 1.0) / np.log(64.0)) return bev注意行和列的对应关系:row对应y方向,col对应x方向,最终BEV形状是(800, 704, 3),其中800是y方向网格数,704是x方向网格数。如果你把这两者反过来,后面标签的中心点映射也会跟着全错。
4. 标签转换:从KITTI标定文件到Complex-YOLO训练目标
4.1 label_2文件每一列的含义
KITTI的3D标签文件是文本格式,每行一个目标。打开一个典型文件,你会看到类似这样的内容:
Car 0.00 0 1.58 599.41 156.40 629.75 189.25 1.67 1.87 3.69 -7.06 1.60 24.56 -1.58每一列依次是:
| 列位 | 字段 | 含义 |
|---|---|---|
| 1 | type | 目标类别,如Car、Pedestrian、Cyclist、Van、Truck等 |
| 2 | truncated | 截断程度,0到1,目标在图像边缘被截断的比例 |
| 3 | occluded | 遮挡级别,0到3 |
| 4 | alpha | 观察角,弧度,表示物体中心相对相机光轴的方向 |
| 5-8 | bbox | 2D框,x1, y1, x2, y2,在左相机图像上 |
| 9-11 | dimensions | 3D尺寸,高、宽、长,单位米,定义在相机坐标系 |
| 12-14 | location | 3D中心坐标,tx, ty, tz,单位米,定义在相机坐标系 |
| 15 | rotation_y | 绕相机y轴的旋转角,弧度 |
注意dimensions顺序是height、width、length,不是我们习惯的长宽高。height对应相机坐标系的y轴方向,width对应x轴方向,length对应z轴方向。这与后面计算3D框角点时矩阵的行顺序直接相关。
4.2 calib标定文件的读取与矩阵组合
calib目录下每个txt文件对应一帧的标定参数。内容大致如下:
P0: 7.070912e+02 ... P1: 7.070912e+02 ... P2: 7.070912e+02 ... P3: 7.070912e+02 ... R0_rect: 9.999239e-01 ... Tr_velo_to_cam: 6.927964e-03 ... Tr_imu_to_velo: 9.999976e-01 ...其中:
- P2是左相机的3x4投影矩阵,把相机坐标投影到图像像素坐标。
- R0_rect是3x3旋转矩阵,用来校正在相机坐标系。
- Tr_velo_to_cam是3x4变换矩阵,把Velodyne雷达坐标变换到相机坐标。
对于Complex-YOLO训练来说,标签中心在相机坐标,点云在雷达坐标,我们需要的是从相机坐标变换到雷达坐标的矩阵。也就是说,先组合一个“雷达到相机”的4x4矩阵,再求逆。
import numpy as np def read_calib(calib_path): calib = {} with open(calib_path, 'r') as f: for line in f.readlines(): key, val = line.split(':') values = [float(v) for v in val.split()] if key in ['P0', 'P1', 'P2', 'P3']: calib[key] = np.array(values).reshape(3, 4) elif key == 'R0_rect': calib[key] = np.array(values).reshape(3, 3) elif key in ['Tr_velo_to_cam', 'Tr_imu_to_velo']: calib[key] = np.array(values).reshape(3, 4) return calib def build_cam_to_velo(calib): R_rect = np.eye(4) R_rect[:3, :3] = calib['R0_rect'] Tr_velo_cam = np.eye(4) Tr_velo_cam[:3, :] = calib['Tr_velo_to_cam'] T_velo_to_cam = R_rect @ Tr_velo_cam T_cam_to_velo = np.linalg.inv(T_velo_to_cam) return T_cam_to_velo一定要记得把R0_rect扩展成4x4单位矩阵形式,再把Tr_velo_to_cam也扩展成4x4,否则矩阵乘法的维数对不上。这里也是一个经典报错点。
4.3 从相机坐标到雷达坐标的3D框角点变换
KITTI标注里的3D框由中心位置、尺寸和rotation_y定义。rotation_y是绕相机y轴的旋转。我们需要先在这个“相机坐标系语义”下把8个角点算出来,再整体变换到雷达坐标。
def compute_corners3d_cam(center, dims, rotation_y): h, w, l = dims x, y, z = center R = np.array([ [np.cos(rotation_y), 0, np.sin(rotation_y)], [0, 1, 0], [-np.sin(rotation_y), 0, np.cos(rotation_y)] ]) corners = np.array([ [l / 2, l / 2, -l / 2, -l / 2, l / 2, l / 2, -l / 2, -l / 2], [0, 0, 0, 0, -h, -h, -h, -h], [w / 2, -w / 2, -w / 2, w / 2, w / 2, -w / 2, -w / 2, w / 2] ]) corners = R @ corners corners[0, :] += x corners[1, :] += y corners[2, :] += z return corners.T # 8x3,相机坐标然后变换到雷达坐标系:
def corners_cam_to_velo(corners_cam, T_cam_to_velo): pts = np.hstack([corners_cam, np.ones((len(corners_cam), 1))]) pts_velo = pts @ T_cam_to_velo.T return pts_velo[:, :3]为什么不用“只把中心点变换过去,再手动转换yaw角”的简化方案?因为相机y轴方向朝下,雷达z轴方向朝上,两者的旋转轴并不一致。中心坐标用刚体变换很简单,但角度变换很容易在弧度转换、正负号、以及“哪个轴对齐哪个轴”上出错。角点法把整个框的几何结构都变换过去,再从角点里反推朝向,即使你中间的符号写错了,可视化阶段也会很快暴露问题。
4.4 输出目标编码:中心、尺寸与复数角度
对每个目标,我们需要从变换后的雷达坐标系框里提取出BEV视角下的关键信息:中心的x和y、框的宽度和长度、绕垂直轴的偏航角。
在BEV图上,目标是一个类矩形区域。中心坐标可以直接用雷达坐标变换后的中心点,然后投影到栅格。宽度w在雷达坐标系下对应y方向跨度,长度l对应x方向跨度。这一点和相机坐标系里的“width、length”语义不同,必须重新换算。
计算雷达BEV朝向角的方法,可以通过框的左右前角点位置来求:
def compute_yaw_from_corners(corners_velo): # corners_velo是8x3 center = corners_velo.mean(axis=0) front_x = center[0] + corners_velo[:, 0].max() - center[0] # 用朝向的中点来算,更稳的是取前侧两个角点的连线的中点 front_center = (corners_velo[0] + corners_velo[1]) / 2.0 if corners_velo.shape[0] == 8 else None yaw = np.arctan2(front_center[1] - center[1], front_center[0] - center[0]) return yaw但这个函数容易混淆角点顺序。更稳妥的通用做法是:在雷达坐标下生成一个朝向向量,目标朝向就是长轴方向。把这样一个向量定义为从中心指向某个角点的方向,然后取arctan2。
Complex-YOLO最特别的设计,是角度不用单一弧度值,而是用复数的实部和虚部来编码,也就是cos(θ)和sin(θ)。原因是角度回归如果用弧度值,在-π和π的边界会出现很大的数值跳变,明明两个朝向几乎一样,loss却突然翻倍。用复数编码后,角度被映射到单位圆上的两个连续值,回归起来平滑得多。
所以最终落到训练target里的每个目标大致长这样:
def build_target_entry(center_velo, dims_bev, yaw_velo, class_id): x_cell = (center_velo[0] - X_MIN) / DISCRETIZATION y_cell = (center_velo[1] - Y_MIN) / DISCRETIZATION w_cell = dims_bev[0] / DISCRETIZATION l_cell = dims_bev[1] / DISCRETIZATION return { 'x': x_cell, 'y': y_cell, 'w': w_cell, 'l': l_cell, 'sin': np.sin(yaw_velo), 'cos': np.cos(yaw_velo), 'class_id': class_id }真正训练时,这些值还要进一步归一化到输出特征图的网格上,比如把整张BEV图下采样到特征图尺寸,再让网络预测相对当前grid cell的偏移。这部分在训练篇(二)展开,但预处理这一步已经把所有关键信息都提取好了。
5. 不急着开训:可视化验证与训练集/验证集划分
5.1 验证集划分不是随机了一下就算完
整个object detection training数据集一共7481帧,不能全拿去训练。你需要留出一部分做验证,否则训练loss下降曲线没有任何可信度。
KITTI官方提供了多种划分方式。最省事的做法是直接用官方devkit里的train.txt和val.txt。如果你没有下载devkit,也可以用一个被很多复现项目采用的划分:前3712帧做训练,后3769帧做验证。注意这两个数字加起来正好是7481。
更严谨一点,由于KITTI里Car类样本远多于Pedestrian和Cyclist,如果按顺序硬切,可能某些小类只在一边出现。建议统计每一帧里存在的类别集合,做带类别约束的随机划分,保证Car、Pedestrian、Cyclist三类在训练集和验证集里都有足够数量。
5.2 用BEV视图叠加GT框做第一道检查
预处理脚本写完后,最该做的第一件事不是立刻训练,而是可视化。打开一张BEV图,把转换后的GT框画上去,人眼一秒钟就能看出坐标有没有错位。
from PIL import Image, ImageDraw import numpy as np def visualize_bev_with_boxes(bev, boxes): # bev: (800, 704, 3),值在0~1 img = (bev * 255).astype(np.uint8) img = Image.fromarray(img) draw = ImageDraw.Draw(img) for box in boxes: x_cell, y_cell, w_cell, l_cell, yaw, cls = box # 简化绘制:先画一个框中心点,再画一个粗略矩形 cx = x_cell cy = y_cell draw.ellipse([cx - 2, cy - 2, cx + 2, cy + 2], fill=(255, 0, 0)) # 实际建议画旋转矩形 return img如果你的GT框中心在BEV图上落在点云轮廓中间,方向也和车头一致,那很大概率是正确的。如果框整体偏移、旋转方向反了、或者中心落在完全没有点云的空白区域,马上回去查矩阵组合和角点计算。
5.3 三个容易静默出错的点
第一,栅格方向反了。BEV图里row对应y方向、col对应x方向,如果标签中心映射时也写成row对应x,那么框会绕着原点转90度。这种错误从数字上很难发现,但可视化时一眼就能看到所有GT框和点云轮廓成垂直关系。
第二,yaw符号错了。在相机坐标里rotation_y是绕朝下的y轴旋转,转换到雷达坐标后,正负号可能被颠倒了。检查方法:把3D框的8个角点从雷达坐标再投回图像像素坐标,和label_2里给的2D bbox对比。如果3D框投影出的轮廓和2D bbox贴合,说明旋转矩阵方向没问题。
def project_3d_box_to_image(corners_velo, calib): R_rect = np.eye(4) R_rect[:3, :3] = calib['R0_rect'] Tr_velo_cam = np.eye(4) Tr_velo_cam[:3, :] = calib['Tr_velo_to_cam'] pts = np.hstack([corners_velo, np.ones((len(corners_velo), 1))]) pts_cam = Tr_velo_cam @ pts.T pts_img = calib['P2'] @ R_rect @ pts_cam pts_img = pts_img[:2] / pts_img[2] return pts_img.T第三,GT中心落在裁剪范围外被静默丢弃。某些目标虽然中心在x 0到70.4米范围内,但它的局部点云可能超出范围,或者某些目标中心因为遮挡等原因在裁剪边界附近被过滤掉了。写一个统计函数,输出“每帧有多少个GT被丢弃”,如果丢弃率超过几个百分点,要回到范围定义去检查,而不是直接忽略。
6. 预处理代码提速与复现性:不只是能跑,还要能快跑
6.1 用np.maximum.at和np.add.at代替逐点循环
第一版做BEV编码时,如果直接用纯Python循环遍历10万个点再写进800乘704的矩阵,一帧大概要3到5秒。7481帧全量预处理,就是好几个小时的无意义等待。
向量化版本用np.maximum.at和np.add.at,一帧可以压到几十毫秒。虽然np.maximum.at在部分numpy版本里不算特别快,但已经比纯循环快一个量级。如果还想再快,可以用np.histogram2d算density,height和intensity用np.maximum.at,整体瓶颈已经不在CPU上。
6.2 多进程并行处理7481帧数据
预处理天然适合并行。每一帧的bin、label、calib相互独立,不存在顺序依赖。直接用multiprocessing.Pool就能把整个数据集压到几分钟内处理完。
from multiprocessing import Pool def process_frame(frame_id): bin_path = f"KITTI/training/velodyne/{frame_id}.bin" calib_path = f"KITTI/training/calib/{frame_id}.txt" label_path = f"KITTI/training/label_2/{frame_id}.txt" # 返回bev图或目标列表 return bev, targets if __name__ == '__main__': frame_ids = [f"{i:06d}" for i in range(7481)] with Pool(8) as p: results = p.map(process_frame, frame_ids)注意一点:如果每个子进程都要读取calib文件或构建同一个矩阵,建议把它们写成一个全局只读对象,或者直接在子进程内读取,避免用大对象跨进程传递,否则序列化开销会抵消并行收益。
6.3 把中间产物保存为npy还是逐帧缓存
我建议把每一帧预处理后的BEV图保存为npy文件,同时把标签打包成单独的pkl或npy。这样训练脚本读取时不需要重新处理原始bin,能显著减少IO压力,也方便在训练时做DataLoader打乱。
磁盘占用可以粗略估算:一张BEV图是800乘704乘3,每个float32占4字节,大约6.7MB。整套7481帧下来差不多50GB。如果你觉得占用太大,可以把三通道转成float16,或者只在内存里做缓存、不落盘。
真正训练时,我一般保留原始bin文件和预处理后的npy两种形式。npy负责快速读取和验证预处理管线,原始bin文件留着,以防后续需要调整预处理参数重新生成。
最后再分享一个我每次做完预处理都会执行的检查:随机抽50帧,把所有标注的3D框投影到左相机图像上,再与label_2里的2D bbox对比。如果投影结果和2D框重叠率低于80%,几乎可以断定是calib矩阵组合顺序错了或者维数不对。如果图像上没问题但BEV上框和点云轮廓对不上,那就要回到x/y方向的栅格映射去排查。这个小检查只花几分钟,但能挡下后面可能白跑几天的训练。数据预处理这一步,慢就是快。