nuScenes 数据集
CARLA 造的是"你想要的数据",nuScenes 提供的是"真实世界长什么样"。 本节把它的数据结构彻底拆开——看懂了这个结构,你才知道自己该采什么。
本节目标
- 下载并正确组织 nuScenes(先用 mini 版,10GB 即可跑通全流程)
- 吃透它的关系型数据结构——这是理解 BEV 数据管线的前提
- 彻底搞清 4 层坐标系(传感器→车身→全局→BEV),这是整个课程最容易出错的地方
- 写出能把 3D 框投影到图像上的验证脚本——肉眼确认标定和坐标变换是对的
两者分工不同,都要有:
| CARLA | nuScenes | |
|---|---|---|
| 数据来源 | 仿真生成 | 真实道路采集 |
| 能否闭环 | 能(车真的开) | 不能(离线录像) |
| 场景可控 | 完全可控 | 只能用现成的 |
| 视觉真实度 | 有仿真痕迹 | 真实 |
| 主要用途 | 闭环评测、造长尾数据 | 训练与论文对标 |
你的模型如果只在 CARLA 上训,简历说服力会打折—— 面试官会问"在真实数据上效果如何"。nuScenes 是行业公认基准,指标可以直接和论文比。
第一步:下载与组织
nuScenes 完整版约 360GB,mini 版只有 约 4GB。 强烈建议先用 mini 跑通全流程,确认代码没问题再下完整版。
# ---------- 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/ 是 2Hz 的关键帧,有完整 3D 标注——训练检测模型用这个。
sweeps/ 是 关键帧之间的中间帧(相机约 12Hz、激光雷达 20Hz),
没有标注。它的用途是时序融合——BEVFormer 的时序自注意力就需要历史帧。
所以:训练标签来自 samples,历史上下文来自 sweeps。
第二步:理解数据结构(本节重点)
nuScenes 不是"一个文件夹装图片",而是一套关系型数据库,用 token 互相引用。 这个设计一开始很绕,但理解后你会发现它极其严谨。
第三步:四层坐标系(最容易出错的地方)
这一节请务必读懂。课程后面每次投影出错,十有八九是这里没理清。
| 坐标系 | 原点在哪 | 谁提供变换 | 典型用途 |
|---|---|---|---|
| ① 全局 global | 地图原点(固定不动) | — | 标注框存在这里;轨迹记录 |
| ② 车身 ego | 车辆后轴中心(跟车走) | ego_pose |
BEV 网格建在这个系上 |
| ③ 传感器 sensor | 相机/雷达光心 | calibrated_sensor |
相机成像计算 |
| ④ 图像 pixel | 图像左上角 | 内参矩阵 K |
最终的像素坐标 |
把一个 3D 标注框画到相机图像上,完整链条是:
全局 ──ego_pose⁻¹──▸ 车身 ──calib⁻¹──▸ 传感器 ──K──▸ 像素
注意前两步都是逆变换——因为 ego_pose 描述的是
"车身在全局系的位姿",我们要反过来把全局点转到车身系。这个方向搞反是最常见的 bug,
现象是框全部跑到画面外或位置诡异。
你在 BEVFormer 拆解 第 4 节学的"空间交叉注意力"—— BEV 格子拿自己的 3D 坐标投影到 6 路相机——用的正是同一套变换链,只是方向相反:
· 本节:标注框(全局)→ 图像,用于验证
· BEVFormer:BEV 格子(车身系)→ 图像,用于采样特征
写通了本节的投影脚本,你就已经实现了 BEVFormer 里最核心的那个几何运算。
第四步:读取与探索
#!/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 工作的地基。
#!/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()
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 数据引擎都是为解决它