自动驾驶-M2 感知与 BEV-2.2 BEV 数据处理管线

采到的数据不能直接喂模型。这一节把磁盘上的 jpg + json, 变成模型能吃的张量批次——并且每一步都可视化验证, 确保没有静默错误。学习课程 端到端自动驾驶实战 | zhijia.dev

第一步:理解要产出什么

file

第二步:完整 Dataset 实现

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_v4")
    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 点"仍能投影到 图像上的正确位置,投影矩阵必须做反向补偿。漏了这一步,图像和真值就对不上了。

执行:

(ad) leoking@leoking-desktop:~/carla/work/courses/M2/2.2$ python bev_dataset.py --root ../dataset_v4
[train] 1 场景, 50 帧

=== 批次结构 ===
imgs      : (2, 6, 3, 450, 800)
lidar2img : (2, 6, 4, 4)
样本0: 20035 个点, 19 个框
样本1: 19902 个点, 14 个框

图像统计: 均值 0.034, 标准差 0.969
(标准化后均值应接近 0,标准差接近 1)

frames.json 数据
/home/xx/carla/work/courses/M2/dataset_v4/scene_0000/frames.json:
file

{"fps": 10, "pc_range": 51.2, "frames": [{"index": 0, "carla_frame": 399293, "timestamp": 0.0, "ego_pose": {"translation": [-2.791, -60.993, 0.002], "rotation": [-0.001, -0.038, 0.542]}, "ego_speed": 25.72, "num_lidar_points": 20303, "boxes": [{"category": "car", "center": [31.172, -7.207, 0.819], "size": [3.988, 1.851, 1.617], "yaw": 3.1325, "num_lidar_pts": 17, "distance": 31.99, "actor_id": 503}, {"category": "car", "center": [22.472, -3.624, 0.751], "size": [4.792, 2.163, 1.488], "yaw": 3.1322, "num_lidar_pts": 41, "distance": 22.76, "actor_id": 500}, {"category": "car", "center": [-38.734, 27.7, 1.887], "size": [8.468, 2.891, 3.827], "yaw": -1.5635, "num_lidar_pts": 54, "distance": 47.62, "actor_id": 499}, {"category": "car", "center": [-42.2, 20.402, 0.705], "size": [3.806, 1.97, 1.475], "yaw": -1.5709, "num_lidar_pts": 6, "distance": 46.87, "actor_id": 498}, {"category": "car", "center": [31.046, -0.2, 1.929], "size": [8.468, 2.891, 3.827], "yaw": -0.0104, "num_lidar_pts": 35, "distance": 31.05, "actor_id": 495}, {"category": "car", "center": [-38.771, 19.987, 0.734], "size": [3.633, 1.845, 1.501], "yaw": -1.5727, "num_lidar_pts": 5, "distance": 43.62, "actor_id": 494}, {"category": "car", "center": [17.445, -6.952, 2.134], "size": [10.273, 3.944, 4.253], "yaw": 3.1317, "num_lidar_pts": 397, "distance": 18.78, "actor_id": 493}, {"category": "car", "center": [6.822, -3.518, 0.754], "size": [4.902, 2.128, 1.511], "yaw": -3.1412, "num_lidar_pts": 516, "distance": 7.68, "actor_id": 492}, {"category": "car", "center": [18.29, 3.419, 0.81], "size": [3.988, 1.851, 1.617], "yaw": -0.0099, "num_lidar_pts": 59, "distance": 18.61, "actor_id": 491}, {"category": "car", "center": [14.802, -0.048, 0.95], "size": [4.605, 1.932, 1.855], "yaw": -0.0098, "num_lidar_pts": 74, "distance": 14.8, "actor_id": 490}, {"category": "car", "center": [-21.648, -0.051, 0.745], "size": [3.633, 1.845, 1.501], "yaw": 0.0009, "num_lidar_pts": 31, "distance": 21.65, "actor_id": 489}, {"category": "motorcycle", "center": [0.077, -7.056, 0.718], "size": [2.191, 0.866, 1.53], "yaw": -3.1406, "num_lidar_pts": 232, "distance": 7.06, "actor_id": 488}, {"category": "motorcycle", "center": [-21.505, 3.41, 0.775], "size": [1.817, 0.866, 1.59], "yaw": 0.001, "num_lidar_pts": 10, "distance": 21.77, "actor_id": 487}, {"category": "car", "center": [-7.924, -3.535, 0.712], "size": [4.674, 1.812, 1.442], "yaw": -3.1402, "num_lidar_pts": 334, "distance": 8.68, "actor_id": 486}, {"category": "car", "center": [-19.08, -6.99, 0.738], "size": [4.902, 2.128, 1.511], "yaw": -3.1298, "num_lidar_pts": 44, "distance": 20.32, "actor_id": 485}, {"category": "car", "center": [-17.68, -3.565, 1.197], "size": [6.366, 2.351, 2.431], "yaw": 3.1414, "num_lidar_pts": 128, "distance": 18.04, "actor_id": 484}, {"category": "car", "center": [-1.251, 3.498, 0.642], "size": [4.718, 1.895, 1.301], "yaw": 0.0001, "num_lidar_pts": 1247, "distance": 3.72, "actor_id": 483}], "rejected": [{"category": "bicycle", "center": [-49.077, 48.996, 0.846], "size": [1.843, 0.659, 1.776], "yaw": 1.561, "num_lidar_pts": 0, "distance": 69.35, "actor_id": 513}, {"category": "car", "center": [-42.121, 42.797, 0.713], "size": [4.892, 1.837, 1.49], "yaw": -1.5768, "num_lidar_pts": 0, "distance": 60.05, "actor_id": 511}, {"category": "car", "center": [-45.596, 48.626, 1.235], "size": [5.915, 1.988, 2.561], "yaw": 1.5609, "num_lidar_pts": 0, "distance": 66.66, "actor_id": 509}, {"category": "car", "center": [-38.64, 39.655, 0.69], "size": [4.674, 1.812, 1.442], "yaw": -1.5738, "num_lidar_pts": 1, "distance": 55.37, "actor_id": 507}, {"category": "bicycle", "center": [-49.234, 32.601, 0.763], "size": [1.656, 0.582, 1.62], "yaw": 1.5689, "num_lidar_pts": 0, "distance": 59.05, "actor_id": 505}, {"category": "car", "center": [-42.19, 30.363, 0.864], "size": [4.553, 2.097, 1.767], "yaw": -1.5678, "num_lidar_pts": 0, "distance": 51.98, "actor_id": 504}, {"category": "bicycle", "center": [-45.751, 30.968, 0.848], "size": [1.843, 0.659, 1.776], "yaw": 1.5573, "num_lidar_pts": 0, "distance": 55.25, "actor_id": 502}, {"category": "bicycle", "center": [-49.148, 22.854, 0.846], "size": [1.843, 0.659, 1.776], "yaw": 1.5732, "num_lidar_pts": 1, "distance": 54.2, "actor_id": 501}, {"category": "motorcycle", "center": [29.206, 3.315, 0.788], "size": [2.044, 0.797, 1.523], "yaw": -0.0099, "num_lidar_pts": 2, "distance": 29.39, "actor_id": 497}, {"category": "car", "center": [-45.673, 23.135, 0.755], "size": [5.366, 1.801, 1.575], "yaw": 1.5688, "num_lidar_pts": 0, "distance": 51.2, "actor_id": 496}, {"category": "pedestrian", "center": [-5.463, 10.815, 1.083], "size": [0.375, 0.375, 1.86], "yaw": 0.4986, "num_lidar_pts": 0, "distance": 12.12, "actor_id": 580},

debug:
file

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

写完管线不能直接开训。把处理后的数据画出来,肉眼确认对了再往下走。
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 后两图仍应对齐 → 增强联动正确")

执行:

(ad) leoking@leoking-desktop:~/carla/work/courses/M2/2.2$ python verify_pipeline.py --root ../dataset_v4 --out ./verify
[train] 1 场景, 50 帧

样本 0:
  相机视图 → ./verify/00_cameras.jpg
  BEV 视图  → ./verify/00_bev.jpg

样本 16:
  相机视图 → ./verify/01_cameras.jpg
  BEV 视图  → ./verify/01_bev.jpg

样本 32:
  相机视图 → ./verify/02_cameras.jpg
  BEV 视图  → ./verify/02_bev.jpg

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

查看输出结果:
file
file
file


参考文章:
端到端自动驾驶实战 | zhijia.dev

为者常成,行者常至