4. 数据加载器实现:自定义Dataset类、BEV数据增强策略、多模态数据融合加载

数据加载器,说白了就是整个部署流程的「粮草官」。

我见过不少团队,模型结构设计得漂漂亮亮,结果一到训练就卡在数据读取上。要么内存爆炸,要么增强策略跟BEV空间不兼容,跑出来的结果一塌糊涂。嗯,今天咱们就把这块硬骨头啃下来。

4.1 自定义Dataset类:从原始数据到模型输入的最后一公里

写一个Dataset类,其实没那么玄乎。你只需要搞清楚三件事:数据从哪来、怎么读、怎么给。

核心原则:Dataset类只负责「单帧」数据的加载与预处理。多线程、批处理这些活儿,交给DataLoader去干。

我个人习惯把BEV的Dataset拆成三个模块:

  • 数据索引模块:扫描数据集目录,建立样本列表
  • 数据加载模块:读取图像、点云、标定参数
  • 数据预处理模块:去畸变、坐标变换、裁剪填充

来看一个简化版的代码骨架:

class BEVDataset(Dataset):
    def __init__(self, data_root, split='train'):
        self.samples = self._build_index(data_root, split)
        # 我习惯在这里预加载标定参数,避免每次__getitem__都读文件
        self.calibs = self._load_calibrations(data_root)
    
    def __len__(self):
        return len(self.samples)
    
    def __getitem__(self, idx):
        sample = self.samples[idx]
        # 多模态数据
        imgs = self._load_images(sample['img_paths'])
        points = self._load_lidar(sample['lidar_path'])
        calib = self.calibs[sample['scene_id']]
        
        # 坐标变换:从传感器坐标系到BEV网格
        # 这里要注意,不同传感器的时间戳要对齐
        return {
            'images': imgs,          # [N_cam, C, H, W]
            'points': points,        # [N_pts, 4]  (x, y, z, intensity)
            'calib_matrix': calib,   # 投影矩阵
            'gt_boxes': sample['gt'] # 标注框
        }

避坑指南:我曾经在nuscenes数据集上踩过一个坑——不同相机的曝光时间不一样,导致同一时刻的图像亮度差异巨大。后来我在Dataset里加了一个自适应直方图均衡化,效果好了不少。

4.2 BEV数据增强策略:别把空间关系搞乱了

图像上的数据增强,比如随机裁剪、颜色抖动,直接搬到BEV上会出问题。为什么?因为BEV空间是物理世界的映射,你随便裁剪一下,物体的真实位置就变了。

我总结了一套「BEV安全增强三板斧」:

4.2.1 翻转(Flip)

水平翻转是最安全的。你想想看,一辆车在路左边,翻转后到了右边,这在现实中完全合理。

  • 图像域:左右翻转所有相机图像
  • 点云域:对x轴取反(假设y轴朝前)
  • 标注域:框的中心x坐标取反,朝向角加π

4.2.2 旋转(Rotation)

旋转要小心。BEV网格是离散的,旋转后物体边缘会出现锯齿。我一般限制在±5°以内。

注意:旋转操作必须同时作用于所有模态。我曾经只旋转了点云没旋转图像,结果图像上的车道线和点云上的路沿差了半个车身位,模型直接学废了。

4.2.3 缩放(Scaling)

缩放模拟的是相机高度变化。比如公交车和轿车的视野高度不同。

  • 缩放因子建议在[0.9, 1.1]之间
  • 缩放后要重新计算BEV网格的分辨率
  • 标注框的尺寸也要等比缩放

来看一个增强策略的代码片段:

def bev_augmentation(imgs, points, gt_boxes, config):
    # 随机翻转
    if random.random() < 0.5:
        imgs = [cv2.flip(img, 1) for img in imgs]
        points[:, 0] = -points[:, 0]
        gt_boxes[:, 0] = -gt_boxes[:, 0]
        gt_boxes[:, 6] = np.pi - gt_boxes[:, 6]  # 朝向角调整
    
    # 随机旋转(小角度)
    angle = random.uniform(-5, 5) * np.pi / 180
    rot_mat = np.array([[np.cos(angle), -np.sin(angle)],
                        [np.sin(angle),  np.cos(angle)]])
    points[:, :2] = points[:, :2] @ rot_mat.T
    # 别忘了旋转图像!这里用仿射变换
    M = cv2.getRotationMatrix2D((W/2, H/2), angle*180/np.pi, 1.0)
    imgs = [cv2.warpAffine(img, M, (W, H)) for img in imgs]
    
    return imgs, points, gt_boxes

4.3 多模态数据融合加载:时间同步与空间对齐

多模态融合,说白了就是让摄像头和激光雷达「说同一种语言」。

我遇到过最头疼的问题就是时间戳对齐。摄像头是30fps,激光雷达是10fps,你拿到的数据天然就是错位的。

我的做法:以激光雷达的时间戳为基准,找到最近邻的相机帧。如果时间差超过50ms,就丢弃这帧数据。别心疼,错位的数据比没数据更可怕。

4.3.1 空间对齐:投影矩阵

把点云投影到图像上,需要三步:

  1. 点云从激光雷达坐标系转到相机坐标系(外参)
  2. 从相机坐标系转到图像坐标系(内参)
  3. 去畸变(径向畸变+切向畸变)

代码实现:

def project_lidar_to_image(points, calib):
    # points: [N, 3] 在激光雷达坐标系下
    # 1. 转到相机坐标系
    pts_cam = points @ calib['lidar2cam_rot'].T + calib['lidar2cam_trans']
    # 2. 投影到图像平面
    pts_img = pts_cam @ calib['intrinsic'].T
    pts_img[:, :2] /= pts_img[:, 2:3]  # 归一化
    # 3. 去畸变(这里用OpenCV的接口)
    pts_undist = cv2.undistortPoints(pts_img[:, :2], 
                                     calib['K'], calib['dist'])
    return pts_undist

4.3.2 数据融合策略

我常用的融合方式有三种:

融合方式 优点 缺点 适用场景
前融合(Early Fusion) 信息损失少 计算量大,内存爆炸 小规模场景
后融合(Late Fusion) 模块解耦,易调试 丢失了底层交互信息 工业部署
特征融合(Feature Fusion) 平衡了性能和效果 实现复杂 大多数BEV模型

个人经验:在部署阶段,我倾向于用后融合。为什么?因为摄像头和激光雷达的故障模式不同,后融合可以单独降级。摄像头坏了,激光雷达还能撑一会儿。

4.4 本章知识体系

下面这张图,是我自己梳理的数据加载器整体流程。你照着这个结构去写代码,基本不会跑偏。

BEV数据加载器核心流程 原始数据 自定义Dataset 图像加载 + 去畸变 点云加载 + 体素化 标定参数 + 时间同步 BEV数据增强 翻转 / 旋转 / 缩放 模型输入张量

嗯,数据加载器这块,说白了就是「把原始数据变成模型能吃的格式」。你只要把时间同步、空间对齐、增强策略这三个点拿捏住,剩下的就是体力活了。

可可精品资料库