BEV 数据处理管线

采到的数据不能直接喂模型。这一节把磁盘上的 jpg + json, 变成模型能吃的张量批次——并且每一步都可视化验证, 确保没有静默错误。

难度 ★★★☆☆ 时间 约 3 小时 前置 2.1 已采好数据 产出 可用的 DataLoader
▸ 这一节看起来平淡,但它决定成败

数据管线是最容易出静默 bug 的地方——不报错,但训练怎么都不收敛。 典型的:归一化用错均值、坐标系转换漏了一步、真值和预测的角度定义差 90°。

本节的核心方法论是:每写一步,就可视化验证一步。 最后给的验证脚本会把处理后的数据画出来——肉眼确认对了再往下走, 这比事后调试省几十倍时间。

本节目标

  • 写出标准的 PyTorch Dataset,能索引到任意帧的 6 图 + 点云 + 真值
  • 处理变长真值问题(每帧框数不同,默认 collate 会崩)
  • 实现图像归一化、尺寸调整,并同步更新内参矩阵(易漏!)
  • 做 BEV 数据增强(旋转/缩放/翻转),图像、点云、真值必须一起变
  • 用可视化脚本验证整条管线正确

第一步:理解要产出什么

磁盘(2.1 的产出) 6 × jpg (800×450) lidar .bin frames.json 真值 calib.json 标定 Dataset.__getitem__ ① 读 6 图 → resize ② 归一化 + 同步改内参 ③ 读点云 → 裁剪范围 ④ 真值 → 张量 ⑤ 数据增强(联动) collate_fn 图像 → stack 真值 → 保持 list (每帧框数不同,不能 stack) 模型输入 imgs [B,6,3,H,W] lidar2img [B,6,4,4] gt_boxes list of [Ni,7] gt_labels list of [Ni] 最关键的输出:lidar2img 变换矩阵 它把「自车系 3D 点」直接映射到「某路相机的像素」,是 BEVFormer 空间交叉注意力的输入 = 内参 K · 轴重排 · 相机外参逆 —— 1.4 节那条变换链,打包成一个 4×4 矩阵
图 · 数据管线的输入输出。注意 lidar2img——它把 1.4 节的整条变换链 预先算好打包,模型直接用。
▸ lidar2img 是什么,为什么重要

回想 BEVFormer 第 4 节: BEV 网格的每个格子拿着自己的 3D 坐标,投影到 6 路相机上去采样特征。

这个投影每次前向都要算成千上万次。与其在模型里反复算, 不如在数据管线里预先算好一个 4×4 矩阵——模型只需一次矩阵乘法。

所以 lidar2img 就是把「自车系 → 相机系 → 像素」 这整条链压缩成一个矩阵。这是 BEV 类模型数据管线的标准做法。

第二步:完整 Dataset 实现

python bev_dataset.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
BEV 训练数据集 —— 读取 2.1 采集的数据,产出模型可用的张量

关键设计:
  1. lidar2img 矩阵预计算(自车系 3D → 像素,BEVFormer 直接用)
  2. 变长真值用自定义 collate_fn(每帧框数不同,不能 stack)
  3. 数据增强时图像/点云/真值必须联动变换
"""

import json
import math
import os

import numpy as np
import cv2
import torch
from torch.utils.data import Dataset


# ImageNet 统计量(骨干网络预训练时用的,必须一致)
IMG_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
IMG_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)

# CARLA(UE4) → 标准相机 的轴重排(1.4 节推导过)
UE4_TO_CAM = np.array([
    [0,  1,  0],
    [0,  0, -1],
    [1,  0,  0],
], dtype=np.float64)

CATEGORIES = ["car", "pedestrian", "bicycle", "motorcycle"]
CAT2ID = {c: i for i, c in enumerate(CATEGORIES)}


def build_lidar2img(K, cam_translation, cam_yaw_deg):
    """
    构造「自车系 3D 点 → 图像像素」的 4×4 变换矩阵
    
    变换链(1.4 节讲过):
        自车系 --相机外参逆--> CARLA相机系 --轴重排--> 标准相机系 --K--> 像素
    
    返回 4×4 矩阵 M,用法:
        p_img = M @ [x, y, z, 1]
        u, v = p_img[0]/p_img[2], p_img[1]/p_img[2]
        depth = p_img[2]
    """
    # --- 相机在自车系的位姿(外参)---
    yaw = math.radians(cam_yaw_deg)
    c, s = math.cos(yaw), math.sin(yaw)
    R_cam2ego = np.array([[c, -s, 0],
                          [s,  c, 0],
                          [0,  0, 1]], dtype=np.float64)
    t_cam2ego = np.array(cam_translation, dtype=np.float64)

    # --- 求逆:自车系 → 相机系 ---
    #   旋转矩阵正交,逆 = 转置
    R_ego2cam = R_cam2ego.T
    t_ego2cam = -R_ego2cam @ t_cam2ego

    # --- 组合:轴重排 · 外参逆 ---
    R_total = UE4_TO_CAM @ R_ego2cam
    t_total = UE4_TO_CAM @ t_ego2cam

    # --- 左乘内参 ---
    K = np.array(K, dtype=np.float64)
    M = np.eye(4, dtype=np.float64)
    M[:3, :3] = K @ R_total
    M[:3, 3] = K @ t_total
    return M


def scale_intrinsic(K, scale_x, scale_y):
    """
    图像缩放后,内参必须同步缩放 —— 这是最容易漏的一步
    
    忘了这步的后果:投影全部错位,且不报错。
    """
    K = np.array(K, dtype=np.float64).copy()
    K[0, :] *= scale_x     # fx, cx
    K[1, :] *= scale_y     # fy, cy
    return K


class BEVDataset(Dataset):

    def __init__(self, root, split="train", img_size=(800, 450),
                 pc_range=51.2, augment=False, val_ratio=0.2):
        """
        root      : 2.1 产出的数据集根目录
        img_size  : (W, H) 送入模型的尺寸
        augment   : 训练开启,验证关闭
        """
        self.root = root
        self.img_w, self.img_h = img_size
        self.pc_range = pc_range
        self.augment = augment

        info = json.load(open(os.path.join(root, "dataset_info.json")))
        self.cam_names = list(info["camera_rig"].keys())
        self.src_w, self.src_h = info["image_size"]

        # --- 建立样本索引 ---
        # 按场景切分 train/val,避免同场景相邻帧泄漏到验证集
        scenes = [s["name"] for s in info["scenes"]]
        n_val = max(1, int(len(scenes) * val_ratio))
        if split == "train":
            use_scenes = scenes[:-n_val] if len(scenes) > n_val else scenes
        else:
            use_scenes = scenes[-n_val:]

        self.samples = []
        self.scene_calib = {}
        for sc in use_scenes:
            sdir = os.path.join(root, sc)
            fpath = os.path.join(sdir, "frames.json")
            if not os.path.exists(fpath):
                continue
            data = json.load(open(fpath))
            self.scene_calib[sc] = json.load(
                open(os.path.join(sdir, "calib.json")))
            for fr in data["frames"]:
                self.samples.append((sc, fr))

        # --- 预计算 lidar2img(标定场景内固定,不必每次算)---
        self.l2i_cache = {}
        for sc, calib in self.scene_calib.items():
            mats = []
            sx = self.img_w / self.src_w
            sy = self.img_h / self.src_h
            for name in self.cam_names:
                c = calib["cameras"][name]
                K = scale_intrinsic(c["intrinsic"], sx, sy)
                mats.append(build_lidar2img(
                    K, c["translation"], c["rotation_yaw_deg"]))
            self.l2i_cache[sc] = np.stack(mats)      # (6, 4, 4)

        print(f"[{split}] {len(use_scenes)} 场景, {len(self.samples)} 帧")

    def __len__(self):
        return len(self.samples)

    # ---------------- 核心 ----------------
    def __getitem__(self, idx):
        scene, frame = self.samples[idx]
        sdir = os.path.join(self.root, scene)
        fid = frame["index"]

        # ---- 1) 读 6 路图像 ----
        imgs = []
        for name in self.cam_names:
            path = os.path.join(sdir, name, f"{fid:06d}.jpg")
            img = cv2.imread(path)
            if img is None:
                raise FileNotFoundError(path)
            if (img.shape[1], img.shape[0]) != (self.img_w, self.img_h):
                img = cv2.resize(img, (self.img_w, self.img_h))
            # BGR → RGB,归一化到 [0,1] 再标准化
            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
            img = (img - IMG_MEAN) / IMG_STD
            imgs.append(img.transpose(2, 0, 1))       # HWC → CHW
        imgs = np.stack(imgs)                          # (6, 3, H, W)

        # ---- 2) 读点云并裁剪到 BEV 范围 ----
        lpath = os.path.join(sdir, "LIDAR_TOP", f"{fid:06d}.bin")
        pts = np.fromfile(lpath, dtype=np.float32).reshape(-1, 4)
        keep = ((np.abs(pts[:, 0]) < self.pc_range) &
                (np.abs(pts[:, 1]) < self.pc_range))
        pts = pts[keep]

        # ---- 3) 真值 → 数组 ----
        # 格式: [x, y, z, l, w, h, yaw]  共 7 维
        boxes, labels = [], []
        for b in frame["boxes"]:
            if b["category"] not in CAT2ID:
                continue
            boxes.append(b["center"] + b["size"] + [b["yaw"]])
            labels.append(CAT2ID[b["category"]])
        boxes = (np.array(boxes, dtype=np.float32)
                 if boxes else np.zeros((0, 7), dtype=np.float32))
        labels = (np.array(labels, dtype=np.int64)
                  if len(labels) else np.zeros((0,), dtype=np.int64))

        lidar2img = self.l2i_cache[scene].copy()       # (6, 4, 4)

        # ---- 4) 数据增强(图像/点云/真值/矩阵 必须联动)----
        if self.augment:
            boxes, pts, lidar2img = self._augment(boxes, pts, lidar2img)

        return {
            "imgs": torch.from_numpy(imgs),
            "lidar2img": torch.from_numpy(lidar2img.astype(np.float32)),
            "points": torch.from_numpy(pts),
            "gt_boxes": torch.from_numpy(boxes),
            "gt_labels": torch.from_numpy(labels),
            "meta": {"scene": scene, "frame": fid,
                     "ego_speed": frame.get("ego_speed", 0.0)},
        }

    # ---------------- 数据增强 ----------------
    def _augment(self, boxes, pts, lidar2img):
        """
        BEV 空间增强 —— 关键是「所有东西一起变」
        
        只变真值不变 lidar2img,会导致图像和真值对不上,训练必然失败。
        这是本节最容易踩的坑。
        """
        # --- 随机绕 z 轴旋转 ---
        angle = np.random.uniform(-np.pi / 12, np.pi / 12)   # ±15°
        c, s = np.cos(angle), np.sin(angle)
        R = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]], dtype=np.float64)

        # --- 随机缩放 ---
        scale = np.random.uniform(0.95, 1.05)

        # 组合成 4×4 变换(作用于自车系)
        T = np.eye(4, dtype=np.float64)
        T[:3, :3] = R * scale

        # 1) 变换点云
        if len(pts):
            pts = pts.copy()
            pts[:, :3] = (T[:3, :3] @ pts[:, :3].T).T

        # 2) 变换真值框
        if len(boxes):
            boxes = boxes.copy()
            boxes[:, :3] = (T[:3, :3] @ boxes[:, :3].T).T
            boxes[:, 3:6] *= scale             # 尺寸也要缩放
            boxes[:, 6] += angle                 # 朝向加上旋转量
            # 归一化 yaw 到 [-pi, pi]
            boxes[:, 6] = np.arctan2(np.sin(boxes[:, 6]),
                                       np.cos(boxes[:, 6]))

        # 3) 关键:lidar2img 要右乘 T 的逆
        #    因为 3D 点被 T 变换了,投影矩阵要反向补偿才能对应同一张图像
        T_inv = np.linalg.inv(T)
        lidar2img = lidar2img @ T_inv

        return boxes, pts, lidar2img


# ---------------- 变长真值的 collate ----------------
def bev_collate_fn(batch):
    """
    自定义批处理
    
    为什么需要:每帧的框数不同(有的 3 个,有的 20 个),
    默认 collate 会尝试 stack 成同形状张量而报错。
    解法:定长的 stack,变长的保持 list。
    """
    return {
        # 定长 → stack
        "imgs": torch.stack([b["imgs"] for b in batch]),
        "lidar2img": torch.stack([b["lidar2img"] for b in batch]),
        # 变长 → list
        "points": [b["points"] for b in batch],
        "gt_boxes": [b["gt_boxes"] for b in batch],
        "gt_labels": [b["gt_labels"] for b in batch],
        "meta": [b["meta"] for b in batch],
    }


# ---------------- 快速自测 ----------------
if __name__ == "__main__":
    import argparse
    from torch.utils.data import DataLoader

    ap = argparse.ArgumentParser()
    ap.add_argument("--root", default="./dataset")
    args = ap.parse_args()

    ds = BEVDataset(args.root, split="train", augment=True)
    dl = DataLoader(ds, batch_size=2, shuffle=True,
                    collate_fn=bev_collate_fn, num_workers=2)

    batch = next(iter(dl))
    print("\n=== 批次结构 ===")
    print(f"imgs      : {tuple(batch['imgs'].shape)}")
    print(f"lidar2img : {tuple(batch['lidar2img'].shape)}")
    for i, (p, b) in enumerate(zip(batch["points"], batch["gt_boxes"])):
        print(f"样本{i}: {p.shape[0]} 个点, {b.shape[0]} 个框")

    # 数值健康检查
    im = batch["imgs"]
    print(f"\n图像统计: 均值 {im.mean():.3f}, 标准差 {im.std():.3f}")
    print("(标准化后均值应接近 0,标准差接近 1)")
▸ 两个必须理解的设计

① 为什么按场景切 train/val,不按帧随机切?
因为同一场景相邻帧高度相似。随机切会让验证集里出现训练集的"近邻帧", 验证指标虚高,你以为模型很好,实际泛化很差。这是很隐蔽但很致命的数据泄漏。

② 增强时为什么 lidar2img 要右乘 T 的逆?
你把 3D 点旋转了 15°,但图像没有变。为了让"旋转后的 3D 点"仍能投影到 图像上的正确位置,投影矩阵必须做反向补偿。漏了这一步,图像和真值就对不上了。

第三步:可视化验证(本节最重要的一步)

写完管线不能直接开训。把处理后的数据画出来,肉眼确认对了再往下走。

python verify_pipeline.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
数据管线验证 —— 把 Dataset 输出画出来,确认没有静默错误

产出两张图:
  1. 六路图像 + 投影的 3D 框  → 验证 lidar2img 正确
  2. BEV 俯视图(点云 + 框)  → 验证真值坐标系正确
"""

import argparse
import os

import numpy as np
import cv2

from bev_dataset import BEVDataset, IMG_MEAN, IMG_STD, CATEGORIES


BOX_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)]

COLORS = [(80,220,120), (100,160,255),
          (255,200,80), (255,120,200)]


def box_to_corners(box):
    """[x,y,z,l,w,h,yaw] → (8,3) 角点(自车系)"""
    x, y, z, l, w, h, yaw = box
    # 局部系下 8 个角点
    xs = l / 2 * np.array([1,1,1,1,-1,-1,-1,-1])
    ys = w / 2 * np.array([1,-1,-1,1,1,-1,-1,1])
    zs = h / 2 * np.array([1,1,-1,-1,1,1,-1,-1])
    corners = np.stack([xs, ys, zs])              # (3,8)

    c, s = np.cos(yaw), np.sin(yaw)
    R = np.array([[c,-s,0],[s,c,0],[0,0,1]])
    corners = R @ corners
    corners += np.array([[x],[y],[z]])
    return corners.T                              # (8,3)


def denormalize(img_chw):
    """标准化的张量 → 可显示的 BGR 图"""
    img = img_chw.transpose(1, 2, 0)
    img = img * IMG_STD + IMG_MEAN
    img = np.clip(img * 255, 0, 255).astype(np.uint8)
    return cv2.cvtColor(img, cv2.COLOR_RGB2BGR)


def draw_cameras(sample, cam_names, out_path):
    """六路图像 + 投影 3D 框,验证 lidar2img 是否正确"""
    imgs = sample["imgs"].numpy()
    l2i = sample["lidar2img"].numpy()
    boxes = sample["gt_boxes"].numpy()
    labels = sample["gt_labels"].numpy()

    panels = []
    for ci in range(len(cam_names)):
        img = denormalize(imgs[ci])
        h, w = img.shape[:2]
        M = l2i[ci]
        n_drawn = 0

        for bi, box in enumerate(boxes):
            corners = box_to_corners(box)               # (8,3)
            pts = np.hstack([corners, np.ones((8,1))]).T  # (4,8)
            proj = M @ pts                              # (4,8)
            depth = proj[2]

            # 全部角点必须在相机前方
            if np.any(depth < 0.5):
                continue
            uv = proj[:2] / depth

            # 至少部分在画面内
            if (uv[0].max() < 0 or uv[0].min() > w or
                uv[1].max() < 0 or uv[1].min() > h):
                continue

            color = COLORS[int(labels[bi]) % len(COLORS)]
            for i, j in BOX_EDGES:
                p1 = (int(uv[0,i]), int(uv[1,i]))
                p2 = (int(uv[0,j]), int(uv[1,j]))
                cv2.line(img, p1, p2, color, 2, cv2.LINE_AA)
            n_drawn += 1

        cv2.rectangle(img, (0,0), (250,26), (0,0,0), -1)
        cv2.putText(img, f"{cam_names[ci]} ({n_drawn})", (6,18),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255,255,255), 1)
        panels.append(img)

    # 拼成 2×3 网格
    while len(panels) < 6:
        panels.append(np.zeros_like(panels[0]))
    top = np.hstack(panels[:3])
    bot = np.hstack(panels[3:6])
    cv2.imwrite(out_path, np.vstack([top, bot]))
    print(f"  相机视图 → {out_path}")


def draw_bev(sample, out_path, pc_range=51.2, size=800):
    """BEV 俯视图,验证真值坐标系是否正确"""
    canvas = np.full((size, size, 3), 18, dtype=np.uint8)
    scale = size / (2 * pc_range)

    def to_px(x, y):
        # 自车系 x 向前 → 画面向上;y 向右 → 画面向右
        u = int(size / 2 + y * scale)
        v = int(size / 2 - x * scale)
        return u, v

    # 距离环
    for r in [10, 20, 30, 40, 50]:
        cv2.circle(canvas, (size//2, size//2),
                   int(r*scale), (45,52,63), 1)
        cv2.putText(canvas, f"{r}m",
                    (size//2+4, size//2-int(r*scale)+14),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.35, (80,90,105), 1)

    # 点云
    pts = sample["points"].numpy()
    for p in pts[::3]:                    # 抽稀加速
        u, v = to_px(p[0], p[1])
        if 0 <= u < size and 0 <= v < size:
            canvas[v, u] = (70, 80, 95)

    # 真值框(俯视 → 只画底面 4 角)
    boxes = sample["gt_boxes"].numpy()
    labels = sample["gt_labels"].numpy()
    for bi, box in enumerate(boxes):
        corners = box_to_corners(box)[:4]      # 顶面 4 角
        poly = np.array([to_px(c[0], c[1]) for c in corners], np.int32)
        color = COLORS[int(labels[bi]) % len(COLORS)]
        cv2.polylines(canvas, [poly], True, color, 2, cv2.LINE_AA)
        # 朝向指示线(从中心指向车头)
        cx, cy = to_px(box[0], box[1])
        hx = box[0] + np.cos(box[6]) * box[3] / 2
        hy = box[1] + np.sin(box[6]) * box[3] / 2
        cv2.line(canvas, (cx,cy), to_px(hx,hy), color, 1, cv2.LINE_AA)

    # 自车
    cv2.rectangle(canvas,
                  (size//2-7, size//2-14),
                  (size//2+7, size//2+14), (255,190,60), -1)

    # 信息
    m = sample["meta"]
    cv2.putText(canvas, f"{m['scene']} frame {m['frame']} | "
                f"{len(boxes)} boxes | {len(pts)} pts",
                (10, 22), cv2.FONT_HERSHEY_SIMPLEX,
                0.5, (220,230,240), 1)
    cv2.putText(canvas, "UP = vehicle forward (+x)",
                (10, size-14), cv2.FONT_HERSHEY_SIMPLEX,
                0.42, (110,120,135), 1)

    cv2.imwrite(out_path, canvas)
    print(f"  BEV 视图  → {out_path}")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--root", default="./dataset")
    ap.add_argument("--out", default="./verify")
    ap.add_argument("--n", type=int, default=3)
    ap.add_argument("--augment", action="store_true")
    args = ap.parse_args()

    os.makedirs(args.out, exist_ok=True)
    ds = BEVDataset(args.root, split="train", augment=args.augment)

    for i in range(min(args.n, len(ds))):
        idx = i * max(1, len(ds) // max(args.n, 1))
        print(f"\n样本 {idx}:")
        sample = ds[idx]
        draw_cameras(sample, ds.cam_names,
                     os.path.join(args.out, f"{i:02d}_cameras.jpg"))
        draw_bev(sample, os.path.join(args.out, f"{i:02d}_bev.jpg"))

    print("\n检查要点:")
    print("  ① 相机图上的框应精确套住车辆 → lidar2img 正确")
    print("  ② BEV 图上框应贴合点云簇     → 真值坐标系正确")
    print("  ③ 朝向线应指向车头方向       → yaw 定义正确")
    print("  ④ 开 --augment 后两图仍应对齐 → 增强联动正确")
bash
conda activate ad

# ① Dataset 自测
python bev_dataset.py --root ./dataset

# ② 可视化验证(不带增强)
python verify_pipeline.py --root ./dataset --out ./verify

# ③ 验证增强联动是否正确 —— 这一步很关键
python verify_pipeline.py --root ./dataset --out ./verify_aug --augment

# ④ 拉回本地看图
# scp -r l20:~/work/verify* ./
▸ 四条验证标准(逐条确认)

① 相机图上框套住车 → lidar2img 矩阵正确
② BEV 图上框贴合点云簇 → 真值坐标系正确
③ 朝向线指向车头 → yaw 定义正确
④ 开增强后前三条仍成立 → 增强联动正确

第 ④ 条最容易出问题。如果开了增强后框就偏了, 说明 lidar2img 的补偿变换写错了——这个 bug 不修,训练必然失败。

常见报错与排查

现象原因解决
RuntimeError: stack expects each tensor to be equal size 没用自定义 collate_fn DataLoader 传 collate_fn=bev_collate_fn
相机图上框全部偏移 图像 resize 了但内参没同步缩放 检查 scale_intrinsic 是否调用
相机图上一个框都画不出 深度全为负——轴重排漏了 确认 UE4_TO_CAM 在 build_lidar2img 里生效
BEV 图上框和点云错位 点云在雷达系、框在自车系,z 没对齐 2.1 采集时已做 pts[:,2] += lidar_z,检查是否遗漏
开增强后框偏了 lidar2img 没做反向补偿 确认 lidar2img @ T_inv
图像统计均值不接近 0 归一化顺序错(应先 /255 再标准化) 检查 (img/255 - MEAN)/STD 的顺序
DataLoader 卡住不动 num_workers 过多导致内存不足 先设 num_workers=0 调试,正常后再加

验收清单

  • 理解 lidar2img 是什么、为什么要预计算
  • 知道图像 resize 后必须同步缩放内参
  • 理解为什么按场景切 train/val 而非按帧随机切(数据泄漏)
  • 理解变长真值为什么需要自定义 collate_fn
  • Dataset 自测通过,图像标准化后均值接近 0、标准差接近 1
  • 可视化验证四条标准全部通过(特别是开增强后仍对齐)
  • DataLoader 能稳定迭代,不报错不卡死

本节小结

  • lidar2img 把 1.4 节的变换链压缩成一个 4×4 矩阵,是 BEV 模型数据管线的标准做法
  • 图像缩放必须同步缩放内参——漏了不报错,但投影全错
  • 按场景切分数据集,按帧随机切会造成隐蔽的数据泄漏
  • 变长真值用自定义 collate_fn:定长 stack,变长保持 list
  • 数据增强时图像、点云、真值、投影矩阵必须联动, 其中 lidar2img 要右乘变换的逆做补偿
  • 方法论:每写一步就可视化验证一步——这是本节最值钱的习惯