CARLA 多相机数据采集

1.2 节采的是图片,这一节要采的是能直接训练的数据集—— 6 路环视 + 激光雷达 + 3D 框真值 + 标定 + 位姿,格式对齐 nuScenes 约定。

难度 ★★★★☆ 时间 约 4 小时 前置 1.2 · 1.3 · 1.4 产出 可训练的 BEV 数据集

本节目标

  • 从 CARLA 提取 3D 边界框真值——这是 1.2 节没做、但训练必需的部分
  • 设计一套格式规范的数据集结构,让 2.2 的 Dataset 类能直接读
  • 处理可见性过滤——被遮挡、太远、在画面外的目标不该当正样本
  • 实现多场景批量采集(不同地图、天气、车流密度),保证数据多样性
▸ 和 1.2 节的区别
1.2 collect_demo2.1 本节
目的验证同步机制产出训练数据
相机6 路6 路(沿用)
激光雷达无有
3D 框真值无有(核心新增)
标定导出只存 rig 配置完整内外参矩阵
可见性过滤无有
多场景单场景批量多场景

第一步:从 CARLA 提取 3D 框真值

CARLA 里每个 actor 都有 bounding_box 属性,但它是在物体自身局部坐标系下的, 要转成我们需要的格式得做几步处理。

text
actor.bounding_box
    .location  →  框中心相对 actor 原点的偏移(局部系)
    .extent    →  半尺寸 (x=半长, y=半宽, z=半高)

actor.get_transform()
    .location  →  actor 在世界系的位置
    .rotation  →  actor 在世界系的朝向

要得到「框中心在世界系的位置」:
    world_center = actor_transform.transform(bbox.location)

要得到「框的完整尺寸」:
    length = extent.x * 2
    width  = extent.y * 2
    height = extent.z * 2
▸ 三个容易搞错的细节

① 别忘了 bbox.location 偏移。很多车的几何中心和坐标原点不重合, 直接用 actor.get_transform().location 当框中心会偏。

② extent 是半尺寸。要乘 2 才是实际长宽高。 忘了乘 2 的话,你的框会只有实际的一半大。

③ 要排除自车。遍历 actors 时自车也在里面, 必须用 actor.id != ego.id 过滤掉,否则真值里会有一个永远在原点的框。

第二步:可见性过滤(决定数据质量)

不是场景里所有车都该进真值。把完全看不见的目标当正样本,会严重干扰训练—— 模型会被迫去"猜"它根本观察不到的东西。

自车 相机视野范围 ✓ 保留 ✓ 保留 ✗ 超出 51.2m 范围 ✗ 视野外 ✗ 被前车遮挡 三条过滤规则 ① 距离 > 51.2m → 丢弃 ② 不在任何相机视野 → 丢弃 ③ 激光雷达点数太少 → 丢弃 ③ 是判断遮挡的实用方法
图 · 可见性过滤的三条规则。规则 ③ 用激光雷达点数间接判断遮挡, 比几何遮挡计算简单得多,效果也够用。
▸ 51.2 米这个数字从哪来

这是 BEV 感知的行业惯例范围——BEVFormer / BEVDet 等工作的 BEV 网格 通常覆盖 [-51.2m, 51.2m],网格 200×200, 即每格 0.512 米。

你在 LSS 第 4 节见过类似设定 (±50m、0.5m 一格)。采集范围要和训练时的 BEV 范围一致, 否则会有大量"标了但网格覆盖不到"的无效真值。

第三步:数据集格式设计

格式设计要满足两点:2.2 的 Dataset 类好读,以及人能看懂、好调试。

text
dataset/
├── scene_0000/
│   ├── CAM_FRONT/          000000.jpg  000001.jpg ...
│   ├── CAM_FRONT_LEFT/
│   ├── CAM_FRONT_RIGHT/
│   ├── CAM_BACK/
│   ├── CAM_BACK_LEFT/
│   ├── CAM_BACK_RIGHT/
│   ├── LIDAR_TOP/          000000.bin  (float32 × N × 4)
│   ├── calib.json          内参 + 各传感器外参(整场景固定)
│   └── frames.json         逐帧:位姿、时间戳、3D 框列表
├── scene_0001/
│   └── ...
└── dataset_info.json       全局元信息:场景列表、类别表、采集配置
▸ 为什么这样分文件

calib 单独存:标定在一个场景内不变,没必要每帧重复存。

frames.json 存标注:一个场景一个文件, 既避免几千个小 json 拖慢文件系统,又不会大到读不动。

点云用 .bin 不用 .npy:裸 float32 格式, np.fromfile 直接读,和 nuScenes/KITTI 惯例一致,工具链兼容性好。

第四步:完整采集脚本(本节核心产出)

python collect_bev_dataset.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CARLA BEV 训练数据集采集

产出: 6 路环视图像 + 激光雷达点云 + 3D 框真值 + 标定 + 自车位姿

用法:
    # 单场景快速验证
    python collect_bev_dataset.py --scenes 1 --frames 50 --out ./dataset
    # 批量多场景采集
    python collect_bev_dataset.py --scenes 10 --frames 200 --out ./dataset
"""

import argparse
import json
import math
import os
import queue
import random

import numpy as np
import carla


# ==================== 配置 ====================
IMG_W, IMG_H = 800, 450
FPS = 10

# BEV 感知范围(与训练配置保持一致,见本节说明)
PC_RANGE = 51.2

# 6 路环视布局(对齐 nuScenes 惯例)
CAMERA_RIG = {
    "CAM_FRONT":       {"x":  1.5, "y":  0.0, "z": 2.4, "yaw":    0, "fov": 70},
    "CAM_FRONT_LEFT":  {"x":  1.3, "y": -0.5, "z": 2.4, "yaw":  -55, "fov": 70},
    "CAM_FRONT_RIGHT": {"x":  1.3, "y":  0.5, "z": 2.4, "yaw":   55, "fov": 70},
    "CAM_BACK":        {"x": -1.5, "y":  0.0, "z": 2.4, "yaw":  180, "fov": 110},
    "CAM_BACK_LEFT":   {"x": -1.0, "y": -0.5, "z": 2.4, "yaw": -110, "fov": 70},
    "CAM_BACK_RIGHT":  {"x": -1.0, "y":  0.5, "z": 2.4, "yaw":  110, "fov": 70},
}

LIDAR_CFG = {"x": 0.0, "y": 0.0, "z": 2.5}

# CARLA 蓝图前缀 → 训练类别(简化为 3 类,够用且好学)
CATEGORY_MAP = {
    "vehicle.bicycle":    "bicycle",
    "vehicle.diamondback": "bicycle",
    "vehicle.gazelle":    "bicycle",
    "vehicle.harley":     "motorcycle",
    "vehicle.kawasaki":    "motorcycle",
    "vehicle.vespa":       "motorcycle",
    "vehicle.yamaha":      "motorcycle",
    "walker":              "pedestrian",
}
DEFAULT_CATEGORY = "car"

# 多样性配置:不同场景用不同天气
WEATHER_PRESETS = [
    "ClearNoon", "CloudyNoon", "WetNoon",
    "HardRainNoon", "ClearSunset", "WetSunset",
]


def classify_actor(type_id):
    """CARLA 蓝图 id → 训练类别"""
    for prefix, cat in CATEGORY_MAP.items():
        if type_id.startswith(prefix):
            return cat
    return DEFAULT_CATEGORY


def build_intrinsic(width, height, fov_deg):
    """内参矩阵(公式见 1.4 节)"""
    f = width / (2.0 * math.tan(math.radians(fov_deg) / 2.0))
    return [[f, 0.0, width / 2.0],
            [0.0, f, height / 2.0],
            [0.0, 0.0, 1.0]]


class BEVDataCollector:

    def __init__(self, host="localhost", port=2000):
        self.client = carla.Client(host, port)
        self.client.set_timeout(20.0)
        self.actors = []
        self.sensors = {}
        self.queues = {}
        self._orig = None

    # ---------------- 场景初始化 ----------------
    def setup_scene(self, town=None, weather=None, n_npc=40):
        if town:
            print(f"  加载地图 {town} ...")
            self.world = self.client.load_world(town)
        else:
            self.world = self.client.get_world()

        # 天气(增加数据多样性)
        if weather:
            self.world.set_weather(getattr(carla.WeatherParameters, weather))

        # 同步模式(1.2 节讲过,采数据必须开)
        self._orig = self.world.get_settings()
        s = self.world.get_settings()
        s.synchronous_mode = True
        s.fixed_delta_seconds = 1.0 / FPS
        self.world.apply_settings(s)

        self.tm = self.client.get_trafficmanager(8000)
        self.tm.set_synchronous_mode(True)

        bp_lib = self.world.get_blueprint_library()

        # --- 主车 ---
        v_bp = bp_lib.filter("vehicle.tesla.model3")[0]
        v_bp.set_attribute("role_name", "ego")
        spawns = self.world.get_map().get_spawn_points()
        random.shuffle(spawns)
        self.ego = None
        for sp in spawns:
            self.ego = self.world.try_spawn_actor(v_bp, sp)
            if self.ego:
                break
        if self.ego is None:
            raise RuntimeError("无法生成主车")
        self.actors.append(self.ego)
        self.ego.set_autopilot(True, self.tm.get_port())

        # --- 相机 ---
        for name, cfg in CAMERA_RIG.items():
            bp = bp_lib.find("sensor.camera.rgb")
            bp.set_attribute("image_size_x", str(IMG_W))
            bp.set_attribute("image_size_y", str(IMG_H))
            bp.set_attribute("fov", str(cfg["fov"]))
            bp.set_attribute("sensor_tick", "0.0")
            tf = carla.Transform(
                carla.Location(x=cfg["x"], y=cfg["y"], z=cfg["z"]),
                carla.Rotation(yaw=cfg["yaw"]))
            cam = self.world.spawn_actor(bp, tf, attach_to=self.ego)
            q = queue.Queue()
            cam.listen(q.put)
            self.sensors[name] = cam
            self.queues[name] = q
            self.actors.append(cam)

        # --- 激光雷达 ---
        l_bp = bp_lib.find("sensor.lidar.ray_cast")
        l_bp.set_attribute("channels", "32")
        l_bp.set_attribute("range", str(PC_RANGE + 10))
        l_bp.set_attribute("points_per_second", "400000")
        l_bp.set_attribute("rotation_frequency", str(FPS))
        l_bp.set_attribute("upper_fov", "10")
        l_bp.set_attribute("lower_fov", "-30")
        l_bp.set_attribute("sensor_tick", "0.0")
        l_tf = carla.Transform(carla.Location(
            x=LIDAR_CFG["x"], y=LIDAR_CFG["y"], z=LIDAR_CFG["z"]))
        self.lidar = self.world.spawn_actor(l_bp, l_tf, attach_to=self.ego)
        self.lidar_queue = queue.Queue()
        self.lidar.listen(self.lidar_queue.put)
        self.actors.append(self.lidar)

        # --- NPC 车流 ---
        self._spawn_npc(n_npc)

    def _spawn_npc(self, n):
        bp_lib = self.world.get_blueprint_library()
        vehicles = bp_lib.filter("vehicle.*")
        spawns = self.world.get_map().get_spawn_points()
        random.shuffle(spawns)
        cnt = 0
        for sp in spawns:
            if cnt >= n:
                break
            npc = self.world.try_spawn_actor(random.choice(vehicles), sp)
            if npc:
                npc.set_autopilot(True, self.tm.get_port())
                self.actors.append(npc)
                cnt += 1
        print(f"  生成 {cnt} 辆 NPC")

    # ---------------- 真值提取(本节核心) ----------------
    def extract_boxes(self, lidar_points_ego):
        """
        提取当前帧所有可见目标的 3D 框(自车坐标系)
        
        返回格式(每个框):
            center : [x, y, z]   自车系下的框中心
            size   : [l, w, h]   长宽高(注意是全尺寸,不是半尺寸)
            yaw    : float       朝向(弧度,自车系)
            category, num_lidar_pts, distance
        """
        ego_tf = self.ego.get_transform()
        # 世界 → 自车 的逆变换矩阵
        world2ego = np.array(ego_tf.get_inverse_matrix())

        boxes = []
        candidates = list(self.world.get_actors().filter("vehicle.*"))
        candidates += list(self.world.get_actors().filter("walker.*"))

        for actor in candidates:
            # 排除自车(易漏,见本节说明)
            if actor.id == self.ego.id:
                continue

            bbox = actor.bounding_box
            actor_tf = actor.get_transform()

            # 框中心:局部偏移 → 世界系
            center_world = actor_tf.transform(bbox.location)
            p = np.array([center_world.x, center_world.y, center_world.z, 1.0])

            # 世界系 → 自车系
            p_ego = world2ego @ p
            x, y, z = float(p_ego[0]), float(p_ego[1]), float(p_ego[2])

            # --- 过滤规则 ①: 超出 BEV 范围 ---
            if abs(x) > PC_RANGE or abs(y) > PC_RANGE:
                continue

            # 朝向:相对自车的 yaw
            yaw = math.radians(actor_tf.rotation.yaw - ego_tf.rotation.yaw)
            # 归一化到 [-pi, pi]
            yaw = math.atan2(math.sin(yaw), math.cos(yaw))

            # --- 过滤规则 ③: 激光雷达点数(间接判断遮挡)---
            n_pts = self._count_points_in_box(
                lidar_points_ego, (x, y, z),
                (bbox.extent.x, bbox.extent.y, bbox.extent.z), yaw)
            if n_pts < 5:
                continue

            boxes.append({
                "category": classify_actor(actor.type_id),
                "center": [round(x, 3), round(y, 3), round(z, 3)],
                # extent 是半尺寸,×2 得全尺寸(易错点)
                "size": [round(bbox.extent.x * 2, 3),
                         round(bbox.extent.y * 2, 3),
                         round(bbox.extent.z * 2, 3)],
                "yaw": round(yaw, 4),
                "num_lidar_pts": int(n_pts),
                "distance": round(math.sqrt(x*x + y*y), 2),
                "actor_id": int(actor.id),   # 跨帧跟踪用
            })

        return boxes

    @staticmethod
    def _count_points_in_box(pts, center, half_size, yaw):
        """
        统计落在 3D 框内的激光点数量
        
        做法:把点云转到框的局部坐标系,再做轴对齐判断(简单可靠)
        """
        if pts is None or len(pts) == 0:
            return 0

        cx, cy, cz = center
        hx, hy, hz = half_size

        # 粗筛:先用外接球快速排除大部分点(性能优化)
        r = math.sqrt(hx*hx + hy*hy + hz*hz) + 0.5
        d = pts[:, :3] - np.array([cx, cy, cz])
        near = np.sum(d * d, axis=1) < r * r
        if not np.any(near):
            return 0
        d = d[near]

        # 反向旋转到框的局部系
        c, s = math.cos(-yaw), math.sin(-yaw)
        lx = d[:, 0] * c - d[:, 1] * s
        ly = d[:, 0] * s + d[:, 1] * c
        lz = d[:, 2]

        inside = ((np.abs(lx) <= hx) &
                  (np.abs(ly) <= hy) &
                  (np.abs(lz) <= hz))
        return int(np.sum(inside))

    # ---------------- 标定导出 ----------------
    def export_calib(self):
        """导出内参与外参(一个场景内固定)"""
        calib = {"cameras": {}, "lidar": {}}

        for name, cfg in CAMERA_RIG.items():
            calib["cameras"][name] = {
                "intrinsic": build_intrinsic(IMG_W, IMG_H, cfg["fov"]),
                # 外参:相机相对自车的位姿
                "translation": [cfg["x"], cfg["y"], cfg["z"]],
                "rotation_yaw_deg": cfg["yaw"],
                "fov": cfg["fov"],
                "image_size": [IMG_W, IMG_H],
            }

        calib["lidar"] = {
            "translation": [LIDAR_CFG["x"], LIDAR_CFG["y"], LIDAR_CFG["z"]],
            "rotation_yaw_deg": 0.0,
        }
        return calib

    # ---------------- 采集主循环 ----------------
    def collect(self, scene_dir, n_frames, warmup=20):
        os.makedirs(scene_dir, exist_ok=True)
        for name in CAMERA_RIG:
            os.makedirs(os.path.join(scene_dir, name), exist_ok=True)
        os.makedirs(os.path.join(scene_dir, "LIDAR_TOP"), exist_ok=True)

        # 标定单独存
        with open(os.path.join(scene_dir, "calib.json"), "w") as f:
            json.dump(self.export_calib(), f, indent=2)

        # 预热(丢弃,让物理和车流稳定)
        for _ in range(warmup):
            self.world.tick()
            for q in self.queues.values():
                q.get(timeout=10)
            self.lidar_queue.get(timeout=10)

        frames_meta = []
        for i in range(n_frames):
            frame_id = self.world.tick()

            # 取本帧所有传感器数据(帧号校验,见 1.2 节)
            images = {n: self._take(q, frame_id)
                      for n, q in self.queues.items()}
            pcd = self._take(self.lidar_queue, frame_id)

            # --- 存图像 ---
            for name, img in images.items():
                img.save_to_disk(os.path.join(scene_dir, name, f"{i:06d}.jpg"))

            # --- 存点云(裸 float32,np.fromfile 可读)---
            pts = np.frombuffer(pcd.raw_data, dtype=np.float32).reshape(-1, 4)
            pts.tofile(os.path.join(scene_dir, "LIDAR_TOP", f"{i:06d}.bin"))

            # --- 提取 3D 框真值 ---
            # 注意:点云在雷达系,框在自车系,做个平移对齐
            pts_ego = pts[:, :3].copy()
            pts_ego[:, 2] += LIDAR_CFG["z"]
            boxes = self.extract_boxes(pts_ego)

            # --- 自车位姿 ---
            tf = self.ego.get_transform()
            vel = self.ego.get_velocity()

            frames_meta.append({
                "index": i,
                "carla_frame": int(frame_id),
                "timestamp": round(i / FPS, 4),
                "ego_pose": {
                    "translation": [round(tf.location.x, 3),
                                    round(tf.location.y, 3),
                                    round(tf.location.z, 3)],
                    "rotation": [round(tf.rotation.roll, 3),
                                 round(tf.rotation.pitch, 3),
                                 round(tf.rotation.yaw, 3)],
                },
                "ego_speed": round(3.6 * math.sqrt(
                    vel.x**2 + vel.y**2 + vel.z**2), 2),
                "num_lidar_points": int(pts.shape[0]),
                "boxes": boxes,
            })

            if (i + 1) % 25 == 0:
                avg = np.mean([len(f["boxes"]) for f in frames_meta])
                print(f"    {i+1}/{n_frames} 帧, 平均每帧 {avg:.1f} 个框")

        with open(os.path.join(scene_dir, "frames.json"), "w") as f:
            json.dump({"fps": FPS, "pc_range": PC_RANGE,
                       "frames": frames_meta}, f)

        total_boxes = sum(len(f["boxes"]) for f in frames_meta)
        return {"n_frames": n_frames, "n_boxes": total_boxes}

    def _take(self, q, frame_id, timeout=10.0):
        while True:
            data = q.get(timeout=timeout)
            if data.frame == frame_id:
                return data
            if data.frame > frame_id:
                raise RuntimeError("传感器帧号超前,同步异常")

    # ---------------- 清理 ----------------
    def cleanup(self):
        for s in list(self.sensors.values()) + [getattr(self, "lidar", None)]:
            if s is None:
                continue
            try: s.stop()
            except Exception: pass
        if self.actors:
            self.client.apply_batch(
                [carla.command.DestroyActor(a) for a in self.actors if a])
        self.actors.clear()
        self.sensors.clear()
        self.queues.clear()
        if self._orig:
            self.world.apply_settings(self._orig)
        try: self.tm.set_synchronous_mode(False)
        except Exception: pass


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--scenes", type=int, default=3)
    ap.add_argument("--frames", type=int, default=100)
    ap.add_argument("--npc", type=int, default=40)
    ap.add_argument("--out", default="./dataset")
    ap.add_argument("--towns", nargs="+",
                    default=["Town03", "Town05", "Town10HD"])
    args = ap.parse_args()

    os.makedirs(args.out, exist_ok=True)
    scene_infos = []

    for si in range(args.scenes):
        town = args.towns[si % len(args.towns)]
        weather = WEATHER_PRESETS[si % len(WEATHER_PRESETS)]
        scene_name = f"scene_{si:04d}"
        print(f"\n[{si+1}/{args.scenes}] {scene_name} | {town} | {weather}")

        collector = BEVDataCollector()
        try:
            collector.setup_scene(town=town, weather=weather, n_npc=args.npc)
            stat = collector.collect(
                os.path.join(args.out, scene_name), args.frames)
            scene_infos.append({
                "name": scene_name, "town": town, "weather": weather,
                **stat})
            print(f"  完成: {stat['n_frames']} 帧, {stat['n_boxes']} 个框")
        except Exception as e:
            print(f"  ✗ 场景失败: {e}")
        finally:
            collector.cleanup()

    # 全局元信息
    with open(os.path.join(args.out, "dataset_info.json"), "w") as f:
        json.dump({
            "scenes": scene_infos,
            "categories": ["car", "pedestrian", "bicycle", "motorcycle"],
            "camera_rig": CAMERA_RIG,
            "lidar_cfg": LIDAR_CFG,
            "image_size": [IMG_W, IMG_H],
            "fps": FPS,
            "pc_range": PC_RANGE,
        }, f, indent=2)

    total_f = sum(s["n_frames"] for s in scene_infos)
    total_b = sum(s["n_boxes"] for s in scene_infos)
    print(f"\n采集完成: {len(scene_infos)} 个场景, "
          f"{total_f} 帧, {total_b} 个标注框")
    print(f"输出目录: {args.out}")


if __name__ == "__main__":
    main()

第五步:运行与质检

bash
# CARLA 服务端保持运行
conda activate ad

# ① 先小规模验证(1 场景 30 帧,约 2 分钟)
python collect_bev_dataset.py --scenes 1 --frames 30 --out ./dataset_test

# ② 检查产出
tree -L 2 ./dataset_test | head -20
python -c "
import json
d=json.load(open('./dataset_test/scene_0000/frames.json'))
f=d['frames'][10]
print('帧索引:', f['index'])
print('自车速度:', f['ego_speed'], 'km/h')
print('标注框数:', len(f['boxes']))
if f['boxes']:
    b=f['boxes'][0]
    print('示例框:', b['category'], '距离', b['distance'], 'm',
          '尺寸', b['size'], '点数', b['num_lidar_pts'])
"

# ③ 确认无误后正式采集(10 场景 × 200 帧,约 1 小时)
tmux new -s collect
python collect_bev_dataset.py --scenes 10 --frames 200 --out ./dataset
# Ctrl+B D 脱离

下面这个质检脚本务必跑一遍——数据问题越早发现越好, 等到训练不收敛才回头查会浪费很多时间。

pythoncheck_dataset.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""数据集质量检查 —— 训练前必跑"""

import argparse
import json
import os
from collections import Counter

import numpy as np


def check(root):
    info_path = os.path.join(root, "dataset_info.json")
    if not os.path.exists(info_path):
        print("✗ 缺少 dataset_info.json")
        return
    info = json.load(open(info_path))
    cams = list(info["camera_rig"].keys())

    print("="*58)
    print("  数据集质检")
    print("="*58)

    total_frames, total_boxes = 0, 0
    cat_counter = Counter()
    dist_all, size_all, pts_all = [], [], []
    problems = []

    for scene in info["scenes"]:
        sdir = os.path.join(root, scene["name"])
        fpath = os.path.join(sdir, "frames.json")
        if not os.path.exists(fpath):
            problems.append(f"{scene['name']}: 缺少 frames.json")
            continue
        data = json.load(open(fpath))
        frames = data["frames"]
        total_frames += len(frames)

        # --- 检查 1: 各相机文件数一致 ---
        counts = {}
        for cam in cams:
            d = os.path.join(sdir, cam)
            counts[cam] = len(os.listdir(d)) if os.path.isdir(d) else 0
        lidar_dir = os.path.join(sdir, "LIDAR_TOP")
        counts["LIDAR_TOP"] = (len(os.listdir(lidar_dir))
                                if os.path.isdir(lidar_dir) else 0)

        if len(set(counts.values())) != 1:
            problems.append(f"{scene['name']}: 各传感器帧数不一致 {counts}")
        if list(counts.values())[0] != len(frames):
            problems.append(
                f"{scene['name']}: 文件数 {list(counts.values())[0]} "
                f"≠ 标注帧数 {len(frames)}")

        # --- 检查 2: 标注统计 ---
        for fr in frames:
            for b in fr["boxes"]:
                total_boxes += 1
                cat_counter[b["category"]] += 1
                dist_all.append(b["distance"])
                size_all.append(b["size"])
                pts_all.append(b["num_lidar_pts"])

                # 异常值检查
                l, w, h = b["size"]
                if not (0.3 < l < 20 and 0.3 < w < 5 and 0.3 < h < 5):
                    problems.append(
                        f"{scene['name']} 帧{fr['index']}: 尺寸异常 {b['size']}")
                if abs(b["yaw"]) > 3.15:
                    problems.append(
                        f"{scene['name']} 帧{fr['index']}: yaw 超范围 {b['yaw']}")

    # --- 汇总 ---
    print(f"场景数   : {len(info['scenes'])}")
    print(f"总帧数   : {total_frames}")
    print(f"总框数   : {total_boxes}")
    if total_frames:
        print(f"平均每帧 : {total_boxes/total_frames:.1f} 个框")

    print("\n类别分布:")
    for c, n in cat_counter.most_common():
        pct = 100 * n / max(total_boxes, 1)
        print(f"  {c:14} {n:7d}  ({pct:5.1f}%)")

    if dist_all:
        d = np.array(dist_all)
        print(f"\n距离分布: 均值 {d.mean():.1f}m, "
              f"中位数 {np.median(d):.1f}m, 最大 {d.max():.1f}m")
        for lo, hi in [(0, 10), (10, 20), (20, 30), (30, 52)]:
            n = np.sum((d >= lo) & (d < hi))
            print(f"  {lo:2d}-{hi:2d}m : {n:6d} ({100*n/len(d):5.1f}%)")

    if pts_all:
        p = np.array(pts_all)
        print(f"\n每框激光点数: 中位数 {np.median(p):.0f}, "
              f"最少 {p.min()}, 最多 {p.max()}")

    # --- 问题汇报 ---
    print("\n" + "="*58)
    if problems:
        print(f"发现 {len(problems)} 个问题(显示前 10 条):")
        for p in problems[:10]:
            print(f"  ⚠ {p}")
    else:
        print("✓ 未发现结构性问题")

    # --- 数据健康度建议 ---
    print("\n健康度评估:")
    if total_frames < 500:
        print("  ⚠ 帧数偏少,建议至少 1000 帧再训练")
    else:
        print("  ✓ 帧数足够启动训练")

    if total_frames and total_boxes / total_frames < 3:
        print("  ⚠ 每帧目标太少,建议增加 --npc")
    else:
        print("  ✓ 目标密度合理")

    if len(cat_counter) < 2:
        print("  ⚠ 类别单一,建议加入行人(--npc 时生成 walker)")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--root", default="./dataset")
    check(ap.parse_args().root)
▸ 质检要看的三个关键数字

① 各传感器帧数必须完全一致——不一致说明同步出了问题(1.2 节的坑)。

② 平均每帧框数在 5-20 之间比较健康。 太少说明车流不够(加 --npc),太多可能过滤规则太宽松。

③ 距离分布应有远有近。 如果全是 0-10m 的近处目标,模型学不到远距离感知能力。

常见报错与排查

现象原因解决
每帧框数是 0 点数过滤太严,或点云坐标系没对齐 把 n_pts < 5 临时改成 < 1 看是否有框;检查 pts_ego[:,2] += LIDAR_CFG["z"]
框尺寸只有实际一半 忘了 extent × 2 extent 是半尺寸,必须乘 2
真值里有个永远在原点的框 没排除自车 检查 actor.id == self.ego.id 的过滤
load_world 超时 切地图很慢,默认超时不够 已设 set_timeout(20.0);仍失败就加大或改用固定地图
采几个场景后 CARLA 崩溃 反复 load_world 内存泄漏 分批采集,每 3-5 个场景重启一次服务端
磁盘爆了 图像 6 路 × 帧数,很占空间 降低 IMG_W/IMG_H;先小规模验证
采集很慢 GPU 被别人占(你的常在 88%) QUALITY=Low;减少 points_per_second;夜间采

验收清单

  • 理解 bbox.location 是局部偏移、extent 是半尺寸
  • 知道要排除自车,否则真值里会多一个原点框
  • 能说清三条可见性过滤规则,以及为什么用激光点数判断遮挡
  • 小规模采集跑通(1 场景 30 帧),目录结构正确
  • check_dataset.py 无结构性问题,各传感器帧数一致
  • 平均每帧框数在合理区间(5-20),距离分布有远有近
  • 完成正式采集(建议 ≥ 1000 帧),为 2.2/2.3 备好数据

本节小结

  • 3D 框真值提取有三个易错点:忘记 bbox.location 偏移、extent 没乘 2、没排除自车
  • 可见性过滤决定数据质量——用激光点数判断遮挡是简单有效的实用方法
  • 采集范围要与训练 BEV 范围一致(本课统一用 51.2m),否则产生无效标注
  • 格式设计:标定与标注分离,点云用裸 .bin 兼容主流工具链
  • 多地图 + 多天气是保证数据多样性的低成本手段
  • 训练前必跑质检——数据问题在这一步发现,比训练不收敛时再回头查省太多时间