AnyScene:从 BEV 布局到可控驾驶场景生成的完整框架
一句话总结
AnyScene 以语义占用(Semantic Occupancy)为中间表示,将任意 BEV 布局转化为时序一致的多视角驾驶视频,让自动驾驶长尾场景的合成数据生产成为可能。
为什么这个问题重要?
自动驾驶的软肋从来不是”常见场景”——高速公路巡航已经相当稳定了。真正的挑战是长尾场景:逆光下突然横穿的行人、暴雪中失控的货车、复杂立交桥的几何关系……这些场景在真实数据里极其稀少,但一旦出错就是事故。
现有的合成数据方案有几个痛点:
- 浅层条件控制:很多方法只能接受粗糙的语义图作为条件,无法精细控制每辆车的位置和朝向
- 参考帧依赖:生成长视频时需要真实的参考帧”打底”,限制了泛化能力
- 相机配置固定:训练时用几个相机,推理时就只能用几个相机,换个传感器套件就完了
AnyScene 的核心洞察是:3D Occupancy 是连接”场景描述”和”传感器观测”的天然桥梁。先生成 3D 结构,再从任意视角渲染,这个分解让控制性和泛化性同时得到提升。
背景知识
驾驶场景的 3D 表示对比
| 表示方式 | 存储 | 几何精度 | 典型用途 |
|---|---|---|---|
| 点云 | 稀疏 | 高 | LiDAR 原始数据 |
| BEV 特征图 | 中等 | 中 | 驾驶感知 |
| 语义占用体素 | 密集 | 高 | 场景理解、生成 |
| NeRF 隐式场 | 紧凑 | 高 | 新视角合成 |
| 3D Gaussian | 显式 | 高 | 实时渲染 |
语义占用(Semantic Occupancy) 将空间划分为体素网格,每个体素存储语义类别(道路/车辆/行人/天空等)和占用概率。它是一个密集的 3D 结构描述,足以从中投影出任意相机视角的图像。
BEV 与 Occupancy 的关系
BEV(Bird’s Eye View)是从正上方俯视的 2D 语义图,可以理解为占用体的俯视投影:
\[\text{BEV}(x, y) = \max_{z} \text{Occ}(x, y, z)\]反过来,从 BEV 布局恢复 3D Occupancy 才是难点——需要补全高度维度上的语义分布。这正是 AnyScene 第一阶段要解决的问题。
核心方法
直觉解释
整个 Pipeline 分两阶段:
用户定义 BEV 布局(语义俯视图)
↓ 阶段一:STOccDiT
语义占用序列(T 帧的 3D 结构)
↓ 阶段二:GGVEx
多视角驾驶视频(时序一致、任意相机配置)
阶段一 解决”从鸟瞰图到 3D 结构”的问题。
阶段二 解决”从 3D 结构到相机图像”的问题。
两阶段的解耦让各自的问题都更好处理,也让推理时相机配置可以灵活切换。
阶段一:Spatial-Temporal Occupancy Diffusion Transformer(STOccDiT)
STOccDiT 的关键设计是自回归的联合 tokenization:将 BEV 特征和 Occupancy 体素拉平后共同输入 Transformer,让模型在同一注意力空间中学习它们的对应关系。
训练目标是标准的扩散去噪目标:
\[\mathcal{L}_{\text{diff}} = \mathbb{E}_{t, \mathbf{x}_0, \boldsymbol{\epsilon}} \left[ \left\| \boldsymbol{\epsilon} - \boldsymbol{\epsilon}_\theta(\mathbf{x}_t, t, \mathbf{c}_{\text{BEV}}) \right\|^2 \right]\]其中 $\mathbf{c}{\text{BEV}}$ 是 BEV 条件嵌入,$\mathbf{x}_t$ 是加噪后的占用序列,$\boldsymbol{\epsilon}\theta$ 是 Transformer 预测的噪声。时序一致性通过自回归生成保证:以前几帧的占用作为条件,逐帧生成后续帧。
阶段二:Geometry-Grounded View Expansion(GGVEx)
GGVEx 的核心思想是:已知占用体的语义,利用相机内外参将体素投影到图像平面,给视频生成器提供明确的几何”锚点”。
对于相机 $k$,体素 $(x, y, z)$ 的图像坐标为:
\[\begin{pmatrix} u \\ v \\ 1 \end{pmatrix} \sim K_k \cdot [R_k \mid t_k] \cdot \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix}\]投影得到的语义深度图作为额外条件,引导视频生成器产生几何一致的多视角图像。这个过程不依赖参考帧,所以可以自由切换相机配置。
实现
核心数据结构:BEV 与 Occupancy 表示
import torch
import numpy as np
class OccupancyGrid:
"""语义占用网格:驾驶场景的3D表示"""
def __init__(self, voxel_size=0.2, pc_range=(-40, -40, -1, 40, 40, 5.4)):
# pc_range: [x_min, y_min, z_min, x_max, y_max, z_max]
self.voxel_size = voxel_size
self.pc_range = pc_range
self.grid_size = [
int((pc_range[i+3] - pc_range[i]) / voxel_size)
for i in range(3)
] # [400, 400, 32]
def project_to_camera(self, occ_grid, K, R, t, H=256, W=512):
"""
将占用体素投影到相机图像平面
occ_grid: [X, Y, Z] 语义标签 (numpy)
K: [3,3] 内参, R: [3,3] 旋转, t: [3] 平移
返回: [H, W] 语义投影图
"""
X, Y, Z = occ_grid.shape
xs = np.arange(X) * self.voxel_size + self.pc_range[0]
ys = np.arange(Y) * self.voxel_size + self.pc_range[1]
zs = np.arange(Z) * self.voxel_size + self.pc_range[2]
# 生成所有非空体素的 3D 坐标 [N, 3]
gx, gy, gz = np.meshgrid(xs, ys, zs, indexing='ij')
mask = occ_grid > 0
pts = np.stack([gx[mask], gy[mask], gz[mask]], axis=-1)
labels = occ_grid[mask]
# 变换到相机坐标系
pts_cam = (pts @ R.T) + t
valid = pts_cam[:, 2] > 0.1 # 过滤相机后方的点
pts_cam, labels = pts_cam[valid], labels[valid]
# 投影到图像平面
uvw = pts_cam @ K.T
u = (uvw[:, 0] / uvw[:, 2]).astype(int)
v = (uvw[:, 1] / uvw[:, 2]).astype(int)
proj = np.zeros((H, W), dtype=int)
valid_uv = (u >= 0) & (u < W) & (v >= 0) & (v < H)
# 深度排序:远处先写,近处覆盖
depth_order = np.argsort(-pts_cam[valid_uv, 2])
u_v, v_v, lab_v = u[valid_uv], v[valid_uv], labels[valid_uv]
proj[v_v[depth_order], u_v[depth_order]] = lab_v[depth_order]
return proj
STOccDiT 简化实现
import torch.nn as nn
class OccupancyDiffusionTransformer(nn.Module):
"""简化版 STOccDiT:BEV 条件下的占用扩散生成"""
def __init__(self, num_classes=18, bev_ch=64, d_model=512, n_heads=8, n_layers=6):
super().__init__()
self.bev_encoder = nn.Sequential(
nn.Conv2d(num_classes, d_model, 1), nn.GELU(),
nn.Conv2d(d_model, d_model, 1)
)
# 压缩 Z 维度:[B, C, X, Y, Z] → [B, D, X, Y, Z//8]
self.occ_tokenizer = nn.Sequential(
nn.Conv3d(num_classes, d_model // 4, kernel_size=(1,1,4), stride=(1,1,4)),
nn.GELU(),
nn.Conv3d(d_model // 4, d_model, kernel_size=(1,1,2), stride=(1,1,2))
)
self.time_mlp = nn.Sequential(
nn.Linear(d_model, d_model * 4), nn.SiLU(),
nn.Linear(d_model * 4, d_model)
)
layer = nn.TransformerEncoderLayer(d_model=d_model, nhead=n_heads, batch_first=True)
self.transformer = nn.TransformerEncoder(layer, num_layers=n_layers)
self.noise_head = nn.Linear(d_model, num_classes * 4) # 对应 Z//8
def forward(self, noisy_occ, timestep, bev_cond):
"""
noisy_occ: [B, C, X, Y, Z] 加噪占用
timestep: [B] 扩散步
bev_cond: [B, C, X, Y] BEV 语义条件
"""
B, _, X, Y, _ = noisy_occ.shape
bev_tok = self.bev_encoder(bev_cond).flatten(2).permute(0,2,1) # [B, X*Y, D]
occ_tok = self.occ_tokenizer(noisy_occ).flatten(2).permute(0,2,1) # [B, X*Y*Zr, D]
tokens = torch.cat([bev_tok, occ_tok], dim=1)
t_emb = self.time_mlp(sinusoidal_emb(timestep, tokens.shape[-1]))
tokens = tokens + t_emb.unsqueeze(1)
out = self.transformer(tokens)
# 取 occ 部分,预测噪声
occ_out = out[:, X*Y:, :] # [B, X*Y*Zr, D]
return self.noise_head(occ_out) # [B, X*Y*Zr, C*4]
def sinusoidal_emb(t, dim, max_period=10000):
half = dim // 2
freqs = torch.exp(-np.log(max_period) * torch.arange(half, device=t.device) / half)
args = t[:, None].float() * freqs[None]
return torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
端到端可视化验证
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
def visualize_pipeline(occ_grid, K, R, t, H=256, W=512):
"""三图并排:BEV 投影 / 3D 体素 / 相机视角"""
fig = plt.figure(figsize=(15, 5))
# 1. BEV(Z 轴最大投影)
ax1 = fig.add_subplot(131)
ax1.imshow(occ_grid.max(2), cmap='tab20', origin='lower')
ax1.set_title('BEV Layout Input')
# 2. 3D 占用体(随机抽样5000点)
ax2 = fig.add_subplot(132, projection='3d')
pts = np.stack(np.where(occ_grid > 0), axis=1)
idx = np.random.choice(len(pts), min(5000, len(pts)), replace=False)
pts = pts[idx]
c = occ_grid[pts[:,0], pts[:,1], pts[:,2]]
ax2.scatter(pts[:,0], pts[:,1], pts[:,2], c=c, cmap='tab20', s=0.5, alpha=0.5)
ax2.set_title('3D Semantic Occupancy')
# 3. 相机视角投影
grid = OccupancyGrid()
proj = grid.project_to_camera(occ_grid, K, R, t, H, W)
ax3 = fig.add_subplot(133)
ax3.imshow(proj, cmap='tab20')
ax3.set_title('Camera View Projection')
plt.tight_layout()
plt.savefig('anyscene_pipeline.png', dpi=150)
# 预期输出:左图显示道路/车辆的俯视语义图,中图为3D点云分布,
# 右图为将3D结构投影到前向相机后的语义深度图
实验
数据集说明
| 数据集 | 场景数 | 相机数 | 关键特点 |
|---|---|---|---|
| nuScenes | 1000 clips | 6 | 标准多相机基准,有完整 Occ 标注 |
| Waymo Open | 1000 segments | 5 | 高质量,多样场景 |
| 用户自定义 BEV | 无限制 | 任意 | AnyScene 的独特能力 |
nuScenes 对学术用途免费,Waymo 需要签署协议。两者都提供完整的相机内外参标注,这是 GGVEx 几何投影所必需的。
定量评估
占用生成质量(nuScenes 验证集):
| 方法 | mIoU ↑ | FID ↓ | 时序一致性 |
|---|---|---|---|
| OccGen | 32.1 | 48.3 | 中等 |
| DriveX | 35.8 | 41.7 | 中等 |
| AnyScene | 41.2 | 35.1 | 强 |
视频生成质量(多视角一致性):
| 方法 | FVD ↓ | SSIM ↑ | 参考帧依赖 |
|---|---|---|---|
| MagicDrive | 612 | 0.61 | 是 |
| DriveDreamer | 587 | 0.64 | 是 |
| AnyScene | 521 | 0.69 | 否 |
失败案例诚实评估
- 极端遮挡:大型货车完全挡住视野时,后方场景生成会出现模糊
- 超出训练分布的几何:复杂立交桥、螺旋坡道等在训练数据中稀少
- 夜间场景:光照条件差异大时,GGVEx 视频质量明显下降
工程实践
实际部署考虑
计算需求(参考量级):
- STOccDiT 推理:A100 单卡,生成 10 帧占用序列约 15-30 秒
- GGVEx 视频生成:叠加视频扩散模型,总计可达数分钟/片段
- 占用体
[400, 400, 32]以 float16 存储约 100 MB,单次前向需 24GB+ 显存
适合离线批量生产,不适合实时场景。
坑 1:坐标系不一致
# 错误:直接用 LiDAR 坐标投影,忽略坐标系差异
pts_cam = R @ pts_world.T + t[:, None]
# 正确:先用传感器标定文件对齐坐标系
# nuScenes: x 向前, y 向左, z 向上(LiDAR)
# 相机坐标: x 向右, y 向下, z 向前
T_l2c = np.linalg.inv(cam_info['sensor2lidar_rotation']) # 从标注读取
pts_cam = (T_l2c[:3, :3] @ pts_world.T + T_l2c[:3, 3:]).T
坑 2:扩散采样步数过多
# 生产合成数据时,DDIM 25步已经足够(比1000步快40倍,质量损失<10%)
from diffusers import DDIMScheduler
scheduler = DDIMScheduler(num_train_timesteps=1000)
scheduler.set_timesteps(num_inference_steps=25) # 推荐 25-50 步
坑 3:生成后缺乏一致性校验
def check_reprojection_error(depth_cam1, K1, RT1, K2, RT2, threshold=5.0):
"""
用重投影误差过滤几何不一致的生成样本
误差 > threshold 像素的样本应丢弃
"""
# 将 cam1 深度图反投影到 3D,再投影到 cam2
pts_3d = unproject(depth_cam1, K1, RT1) # 伪代码
u2, v2 = project(pts_3d, K2, RT2)
error = np.mean(np.sqrt((u2 - u2_gt)**2 + (v2 - v2_gt)**2))
return error < threshold
什么时候用 / 不用?
| 适用场景 | 不适用场景 |
|---|---|
| 离线批量生产合成训练数据 | 需要实时生成(>10 FPS) |
| 补充稀有/危险场景数据 | 动态物体的高频运动细节 |
| 测试不同 BEV 布局的感知模型 | 极精细纹理(路面标线、文字) |
| 跨相机套件的数据增强 | 训练分布之外的极端天气 |
| 下游稀疏重建的伪标注生成 | 消费级单卡(<24GB 显存) |
与其他方法对比
| 方法 | 控制粒度 | 时序一致 | 参考帧依赖 | 相机灵活性 | 3D 结构 |
|---|---|---|---|---|---|
| MagicDrive | BEV 框 | 中等 | 是 | 固定 | 无 |
| DriveDreamer | 文本+BEV | 一般 | 是 | 固定 | 无 |
| OccGen | Occupancy | 强 | 否 | 固定 | 有 |
| AnyScene | BEV+Occ | 强 | 否 | 任意 | 核心 |
本质区别:AnyScene 是两阶段的显式 3D 方法,MagicDrive/DriveDreamer 是端到端的条件视频生成。前者更可控、更通用,但计算更重;后者生成速度更快,但几何一致性较差。
我的观点
AnyScene 代表了自动驾驶合成数据方向一个重要的设计选择:用显式 3D 表示换取更好的可控性和泛化性。这个方向是正确的,原因有三:
- Occupancy 已是驾驶感知的标准接口:很多量产车的感知栈都在往 3D Occupancy 方向走,合成数据用同样的表示能直接服务下游任务,标注对齐成本低
- 无参考帧生成是真正的突破点:这允许完全凭空构建场景,而不需要”真实视频种子”,极大扩展了数据生产的自由度
- 离实际部署仍有距离:自动驾驶公司需要百万级别的合成场景,按当前速度(分钟/片段量级)需要数百 GPU 天。下一步关键是引入一致性模型(Consistency Model)或流匹配(Flow Matching)加速采样
值得关注的开放问题:
- 物理一致性验证:生成的场景动力学是否合理(车辆不穿墙、行人运动符合物理)?
- 传感器级别真实性:除 RGB 视频,能否同时生成对应的 LiDAR 点云?
- 闭环评估:合成数据提升的感知指标,在真实道路上的迁移效率究竟几何?
自动驾驶合成数据的终极目标是”仿真到现实零差距”。AnyScene 在几何可控性上迈出了扎实的一步,但纹理真实性、传感器物理建模、动态物体运动合理性——这些仍然是巨大的开放挑战。
Comments