nuScenes 数据集

CARLA 造的是"你想要的数据",nuScenes 提供的是"真实世界长什么样"。 本节把它的数据结构彻底拆开——看懂了这个结构,你才知道自己该采什么。

难度 ★★★☆☆ 时间 约 2.5 小时 前置 1.1 产出 可读取的数据集 + 理解坐标系

本节目标

  • 下载并正确组织 nuScenes(先用 mini 版,10GB 即可跑通全流程)
  • 吃透它的关系型数据结构——这是理解 BEV 数据管线的前提
  • 彻底搞清 4 层坐标系(传感器→车身→全局→BEV),这是整个课程最容易出错的地方
  • 写出能把 3D 框投影到图像上的验证脚本——肉眼确认标定和坐标变换是对的
▸ 为什么 CARLA 之外还要 nuScenes

两者分工不同,都要有:

CARLAnuScenes
数据来源仿真生成真实道路采集
能否闭环能(车真的开)不能(离线录像)
场景可控完全可控只能用现成的
视觉真实度有仿真痕迹真实
主要用途闭环评测、造长尾数据训练与论文对标

你的模型如果只在 CARLA 上训,简历说服力会打折—— 面试官会问"在真实数据上效果如何"。nuScenes 是行业公认基准,指标可以直接和论文比。

第一步:下载与组织

nuScenes 完整版约 360GB,mini 版只有 约 4GB。 强烈建议先用 mini 跑通全流程,确认代码没问题再下完整版。

bash
# ---------- 1. 注册账号 ----------
# 到 https://www.nuscenes.org/ 注册(免费),下载需要登录

# ---------- 2. 建目录 ----------
mkdir -p ~/data/nuscenes && cd ~/data/nuscenes

# ---------- 3. 下载 mini 版(约 4GB)----------
# 从官网 Downloads 页面获取链接,或用 wget(链接有时效,需从网页复制)
# 文件名: v1.0-mini.tgz

tar -xzf v1.0-mini.tgz

# ---------- 4. 确认目录结构 ----------
tree -L 1 ~/data/nuscenes
# ~/data/nuscenes/
# ├── maps/          地图栅格图
# ├── samples/       关键帧传感器数据(有标注)
# ├── sweeps/        中间帧(无标注,用于时序)
# └── v1.0-mini/     标注 json 文件

# ---------- 5. 安装 devkit ----------
conda activate ad
pip install nuscenes-devkit

# 注意:devkit 依赖 matplotlib/opencv,1.1 节已装
# 若报 numpy 版本冲突,确认 numpy==1.26.4
▸ samples 与 sweeps 的区别(很多人搞混)

samples/ 是 2Hz 的关键帧,有完整 3D 标注——训练检测模型用这个。

sweeps/ 是 关键帧之间的中间帧(相机约 12Hz、激光雷达 20Hz), 没有标注。它的用途是时序融合——BEVFormer 的时序自注意力就需要历史帧。

所以:训练标签来自 samples,历史上下文来自 sweeps。

第二步:理解数据结构(本节重点)

nuScenes 不是"一个文件夹装图片",而是一套关系型数据库,用 token 互相引用。 这个设计一开始很绕,但理解后你会发现它极其严谨。

scene 一段 20 秒的驾驶片段 first_sample_token sample 一个关键帧时刻 (2Hz) data[] sample_data 一个传感器的一次采样 filename · timestamp is_key_frame · prev/next calibrated_sensor 内参 K + 外参(车身系) ego_pose 车身在全局系的位姿 anns[] sample_annotation 一个 3D 标注框 instance 同一物体的跨帧身份 ← 跟踪任务的真值来源 最关键的一条设计 标注框存的是「全局坐标系」下的位置, 不是相机系、也不是车身系。 要投影到图像,必须依次经过 ego_pose 和 calibrated_sensor 两次坐标变换。 每个表用 token(唯一字符串 ID)互相引用,本质是关系型数据库 devkit 的 nusc.get('表名', token) 就是在做「查表」 理解这一点,后面写数据管线就不会迷路
图 · nuScenes 的核心表关系。注意标注框存在全局坐标系——这是坐标变换的起点。

第三步:四层坐标系(最容易出错的地方)

这一节请务必读懂。课程后面每次投影出错,十有八九是这里没理清。

坐标系原点在哪谁提供变换典型用途
① 全局 global 地图原点(固定不动) — 标注框存在这里;轨迹记录
② 车身 ego 车辆后轴中心(跟车走) ego_pose BEV 网格建在这个系上
③ 传感器 sensor 相机/雷达光心 calibrated_sensor 相机成像计算
④ 图像 pixel 图像左上角 内参矩阵 K 最终的像素坐标
▸ 变换链必须按顺序,一步都不能少

把一个 3D 标注框画到相机图像上,完整链条是:

全局 ──ego_pose⁻¹──▸ 车身 ──calib⁻¹──▸ 传感器 ──K──▸ 像素

注意前两步都是逆变换——因为 ego_pose 描述的是 "车身在全局系的位姿",我们要反过来把全局点转到车身系。这个方向搞反是最常见的 bug, 现象是框全部跑到画面外或位置诡异。

▸ 这里直接连着 BEVFormer

你在 BEVFormer 拆解 第 4 节学的"空间交叉注意力"—— BEV 格子拿自己的 3D 坐标投影到 6 路相机——用的正是同一套变换链,只是方向相反:

· 本节:标注框(全局)→ 图像,用于验证
· BEVFormer:BEV 格子(车身系)→ 图像,用于采样特征

写通了本节的投影脚本,你就已经实现了 BEVFormer 里最核心的那个几何运算。

第四步:读取与探索

pythonexplore_nuscenes.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""nuScenes 数据结构探索 —— 把关系型结构走一遍"""

import argparse
from nuscenes.nuscenes import NuScenes


def explore(dataroot, version="v1.0-mini"):
    # verbose=True 会打印加载统计,第一次跑建议开着看看
    nusc = NuScenes(version=version, dataroot=dataroot, verbose=True)

    print("\n" + "="*60)
    print("  数据集概览")
    print("="*60)
    print(f"场景数        : {len(nusc.scene)}")
    print(f"关键帧数      : {len(nusc.sample)}")
    print(f"传感器采样数  : {len(nusc.sample_data)}")
    print(f"标注框数      : {len(nusc.sample_annotation)}")
    print(f"物体实例数    : {len(nusc.instance)}")

    # ---------- 走一个 scene ----------
    scene = nusc.scene[0]
    print("\n" + "="*60)
    print("  第一个场景")
    print("="*60)
    print(f"名称   : {scene['name']}")
    print(f"描述   : {scene['description']}")
    print(f"帧数   : {scene['nbr_samples']}")

    # ---------- 走一个 sample ----------
    sample = nusc.get("sample", scene["first_sample_token"])
    print("\n" + "="*60)
    print("  第一个关键帧的传感器")
    print("="*60)
    for sensor_name, sd_token in sample["data"].items():
        sd = nusc.get("sample_data", sd_token)
        print(f"  {sensor_name:20} {sd['fileformat']:5} "
              f"{sd['width']}x{sd['height']}" if sd['width']
              else f"  {sensor_name:20} {sd['fileformat']}")

    # ---------- 看一路相机的标定 ----------
    cam_token = sample["data"]["CAM_FRONT"]
    cam_data = nusc.get("sample_data", cam_token)
    calib = nusc.get("calibrated_sensor", cam_data["calibrated_sensor_token"])
    pose = nusc.get("ego_pose", cam_data["ego_pose_token"])

    print("\n" + "="*60)
    print("  CAM_FRONT 标定参数")
    print("="*60)
    print(f"图像文件 : {cam_data['filename']}")
    print(f"外参平移 (相机在车身系) : {calib['translation']}")
    print(f"外参旋转 (四元数 wxyz)  : {calib['rotation']}")
    print(f"内参矩阵 K:")
    for row in calib["camera_intrinsic"]:
        print(f"    [{row[0]:9.3f} {row[1]:9.3f} {row[2]:9.3f}]")
    print(f"\n车身在全局系的位置 : {pose['translation']}")

    # ---------- 看标注 ----------
    print("\n" + "="*60)
    print(f"  本帧标注框(共 {len(sample['anns'])} 个,显示前 5 个)")
    print("="*60)
    for ann_token in sample["anns"][:5]:
        ann = nusc.get("sample_annotation", ann_token)
        print(f"  类别 : {ann['category_name']}")
        print(f"    全局位置 : [{ann['translation'][0]:.1f}, "
              f"{ann['translation'][1]:.1f}, {ann['translation'][2]:.1f}]")
        print(f"    尺寸 wlh : {ann['size']}")
        print(f"    可见度   : {ann['visibility_token']}")

    # ---------- 类别分布 ----------
    from collections import Counter
    cats = Counter(a["category_name"] for a in nusc.sample_annotation)
    print("\n" + "="*60)
    print("  类别分布(前 10)")
    print("="*60)
    for name, cnt in cats.most_common(10):
        print(f"  {name:42} {cnt:6d}")
    print("\n提示: 注意长尾分布 —— 少数类别占了绝大多数样本")
    print("      这正是后面 M4/M5 要处理的困难样本问题\n")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--dataroot", default="~/data/nuscenes")
    ap.add_argument("--version", default="v1.0-mini")
    args = ap.parse_args()

    import os
    explore(os.path.expanduser(args.dataroot), args.version)

第五步:投影验证(本节核心产出)

这个脚本把 3D 标注框投影到相机图像上。如果框能准确套住物体,说明你的坐标变换理解正确—— 这是后面所有 BEV 工作的地基。

pythonproject_boxes.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把 nuScenes 的 3D 标注框投影到相机图像 —— 验证坐标变换是否正确

完整变换链(本节最重要的知识点):
    全局 --ego_pose⁻¹--> 车身 --calib⁻¹--> 相机 --K--> 像素

注意前两步都是「逆变换」,因为:
    ego_pose 描述「车身在全局系的位姿」,我们要把全局点转到车身系 → 取逆
    calib    描述「相机在车身系的位姿」,我们要把车身点转到相机系 → 取逆
"""

import argparse
import os

import numpy as np
import cv2
from nuscenes.nuscenes import NuScenes
from pyquaternion import Quaternion


# 3D 框的 12 条边(顶点索引对)
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 = {
    "vehicle":   (80, 220, 120),
    "human":     (100, 160, 255),
    "movable":   (80, 200, 255),
    "default":   (200, 200, 200),
}


def get_color(category):
    for key, c in COLORS.items():
        if category.startswith(key):
            return c
    return COLORS["default"]


def box_corners(center, size, rotation):
    """
    计算 3D 框的 8 个角点(全局坐标系)
    
    参数:
        center   : [x, y, z] 框中心
        size     : [w, l, h] 宽、长、高(注意 nuScenes 的顺序)
        rotation : 四元数 [w, x, y, z]
    返回:
        (3, 8) 数组
    """
    w, l, h = size

    # 在物体自身坐标系下的 8 个角点
    # x 沿车长方向, y 沿车宽, z 向上
    x_c = l / 2 * np.array([1, 1, 1, 1, -1, -1, -1, -1])
    y_c = w / 2 * np.array([1, -1, -1, 1, 1, -1, -1, 1])
    z_c = h / 2 * np.array([1, 1, -1, -1, 1, 1, -1, -1])
    corners = np.vstack((x_c, y_c, z_c))          # (3, 8)

    # 旋转 + 平移到全局系
    q = Quaternion(rotation)
    corners = np.dot(q.rotation_matrix, corners)
    corners = corners + np.array(center).reshape(3, 1)
    return corners


def global_to_camera(points, ego_pose, calib):
    """
    全局坐标 → 相机坐标(本函数是整节课的核心)
    
    points : (3, N)
    """
    # ---- 第 1 步: 全局 → 车身 ----
    # ego_pose 给的是「车身在全局的位姿」,所以要取逆
    points = points - np.array(ego_pose["translation"]).reshape(3, 1)
    points = np.dot(
        Quaternion(ego_pose["rotation"]).rotation_matrix.T,   # .T 即取逆(旋转阵正交)
        points)

    # ---- 第 2 步: 车身 → 相机 ----
    # calib 给的是「相机在车身的位姿」,同样取逆
    points = points - np.array(calib["translation"]).reshape(3, 1)
    points = np.dot(
        Quaternion(calib["rotation"]).rotation_matrix.T,
        points)

    return points


def camera_to_pixel(points, K):
    """
    相机坐标 → 像素坐标(针孔投影)
    
    points : (3, N),相机系下,z 为深度
    返回   : (2, N) 像素坐标, (N,) 深度
    """
    depths = points[2, :].copy()

    # 齐次投影: p = K @ P, 然后除以 z
    projected = np.dot(np.array(K), points)
    # 防止除零
    safe_z = np.where(np.abs(projected[2, :]) < 1e-6, 1e-6, projected[2, :])
    projected = projected[:2, :] / safe_z

    return projected, depths


def render_sample(nusc, sample_token, cam_name, out_path,
                  min_depth=1.0, max_depth=60.0):
    sample = nusc.get("sample", sample_token)
    cam_token = sample["data"][cam_name]
    cam_data = nusc.get("sample_data", cam_token)

    # 读图
    img_path = os.path.join(nusc.dataroot, cam_data["filename"])
    img = cv2.imread(img_path)
    if img is None:
        raise FileNotFoundError(f"读不到图像: {img_path}")

    # 取标定与位姿
    calib = nusc.get("calibrated_sensor", cam_data["calibrated_sensor_token"])
    pose = nusc.get("ego_pose", cam_data["ego_pose_token"])
    K = calib["camera_intrinsic"]

    n_drawn = 0
    for ann_token in sample["anns"]:
        ann = nusc.get("sample_annotation", ann_token)

        # 1) 算 8 个角点(全局系)
        corners = box_corners(ann["translation"], ann["size"], ann["rotation"])

        # 2) 全局 → 相机
        corners_cam = global_to_camera(corners, pose, calib)

        # 3) 过滤:所有角点都在相机后方 or 太远 → 跳过
        if np.all(corners_cam[2, :] < min_depth):
            continue
        if np.min(corners_cam[2, :]) > max_depth:
            continue
        # 有角点在后方的框投影会畸变,简单起见要求全部在前方
        if np.any(corners_cam[2, :] < min_depth):
            continue

        # 4) 相机 → 像素
        pts, depths = camera_to_pixel(corners_cam, K)

        # 5) 过滤画面外的
        h, w = img.shape[:2]
        if np.all(pts[0, :] < 0) or np.all(pts[0, :] > w):
            continue
        if np.all(pts[1, :] < 0) or np.all(pts[1, :] > h):
            continue

        # 6) 画 12 条边
        color = get_color(ann["category_name"])
        for i, j in BOX_EDGES:
            p1 = (int(pts[0, i]), int(pts[1, i]))
            p2 = (int(pts[0, j]), int(pts[1, j]))
            cv2.line(img, p1, p2, color, 2, cv2.LINE_AA)

        # 7) 标注类别与距离
        label = ann["category_name"].split(".")[-1]
        dist = np.mean(depths)
        text = f"{label} {dist:.0f}m"
        tx, ty = int(np.min(pts[0, :])), int(np.min(pts[1, :])) - 6
        cv2.putText(img, text, (tx, max(ty, 14)),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1, cv2.LINE_AA)
        n_drawn += 1

    # 左上角信息条
    cv2.rectangle(img, (0, 0), (330, 30), (0, 0, 0), -1)
    cv2.putText(img, f"{cam_name}  |  drawn {n_drawn} boxes", (8, 20),
                cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1, cv2.LINE_AA)

    os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
    cv2.imwrite(out_path, img)
    print(f"[{cam_name}] 画了 {n_drawn} 个框 → {out_path}")
    return n_drawn


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dataroot", default="~/data/nuscenes")
    ap.add_argument("--version", default="v1.0-mini")
    ap.add_argument("--out", default="./out_proj")
    args = ap.parse_args()

    nusc = NuScenes(version=args.version,
                    dataroot=os.path.expanduser(args.dataroot),
                    verbose=False)

    # 取第一个场景的第一帧,6 路相机全画一遍
    scene = nusc.scene[0]
    sample_token = scene["first_sample_token"]

    cams = ["CAM_FRONT", "CAM_FRONT_LEFT", "CAM_FRONT_RIGHT",
            "CAM_BACK", "CAM_BACK_LEFT", "CAM_BACK_RIGHT"]

    total = 0
    for cam in cams:
        out = os.path.join(args.out, f"{cam}.jpg")
        total += render_sample(nusc, sample_token, cam, out)

    print(f"\n共画出 {total} 个框(同一物体可能出现在多路相机中)")
    print(f"请打开 {args.out}/ 目录肉眼检查:")
    print("  ✓ 框能准确套住车辆行人 → 坐标变换正确")
    print("  ✗ 框错位/跑飞      → 检查是否漏了逆变换")


if __name__ == "__main__":
    main()
bash
conda activate ad
pip install pyquaternion   # devkit 依赖,若没装

# 探索数据结构
python explore_nuscenes.py --dataroot ~/data/nuscenes

# 投影验证
python project_boxes.py --dataroot ~/data/nuscenes --out ./out_proj

# 服务器上没法直接看图,拉到本地:
# (在你的本地机器执行)
scp -r l20:~/work/out_proj ./
▸ 怎么判断你做对了

打开生成的图片,3D 框应该严丝合缝地套在车辆和行人上。

如果框整体偏移——多半是漏了某一步平移;
如果框方向拧了——旋转矩阵转置(求逆)的位置错了;
如果一个框都没有——深度全为负,说明坐标系方向反了。

常见报错与排查

报错 / 现象原因解决
AssertionError: Database version not found dataroot 路径不对,或版本名写错 确认 ~/data/nuscenes/v1.0-mini/ 存在;mini 版 version 必须是 v1.0-mini
ModuleNotFoundError: pyquaternion 缺依赖 pip install pyquaternion
加载极慢 / 内存爆 用了完整版而非 mini 先用 mini 验证;完整版加载需 16GB+ 内存
一个框都画不出来 深度过滤把全部滤掉了 打印 corners_cam[2,:] 看深度符号;负值说明变换方向反了
框位置明显错位 漏了 ego_pose 或 calib 其中一步 两步变换缺一不可,且都要「先平移后旋转取逆」
numpy 版本冲突 devkit 未适配 numpy 2.x pip install numpy==1.26.4

验收清单

  • nuScenes mini 已下载,目录结构正确(maps/samples/sweeps/v1.0-mini)
  • explore_nuscenes.py 跑通,能看到场景数、标注数、类别分布
  • 能说清 samples 与 sweeps 的区别及各自用途
  • 能默写四层坐标系及变换链:全局→车身→相机→像素
  • 理解为什么前两步要用逆变换
  • project_boxes.py 生成的图上,3D 框准确套住物体
  • 注意到了类别的长尾分布(为 M4/M5 埋下伏笔)

本节小结

  • nuScenes 是关系型数据库,各表用 token 互引;nusc.get() 本质是查表
  • samples 有标注(训练用),sweeps 无标注(时序上下文用)
  • 标注框存在全局坐标系,投影到图像必须经过 ego_pose 和 calib 两次逆变换
  • 写通投影脚本 = 已经实现了 BEVFormer 最核心的几何运算, 只是方向相反(那边是 BEV 格子去查图像)
  • 类别长尾分布是真实存在的问题,M4 困难样本挖掘、M5 数据引擎都是为解决它