一句话总结

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 表示换取更好的可控性和泛化性。这个方向是正确的,原因有三:

  1. Occupancy 已是驾驶感知的标准接口:很多量产车的感知栈都在往 3D Occupancy 方向走,合成数据用同样的表示能直接服务下游任务,标注对齐成本低
  2. 无参考帧生成是真正的突破点:这允许完全凭空构建场景,而不需要”真实视频种子”,极大扩展了数据生产的自由度
  3. 离实际部署仍有距离:自动驾驶公司需要百万级别的合成场景,按当前速度(分钟/片段量级)需要数百 GPU 天。下一步关键是引入一致性模型(Consistency Model)或流匹配(Flow Matching)加速采样

值得关注的开放问题:

  • 物理一致性验证:生成的场景动力学是否合理(车辆不穿墙、行人运动符合物理)?
  • 传感器级别真实性:除 RGB 视频,能否同时生成对应的 LiDAR 点云?
  • 闭环评估:合成数据提升的感知指标,在真实道路上的迁移效率究竟几何?

自动驾驶合成数据的终极目标是”仿真到现实零差距”。AnyScene 在几何可控性上迈出了扎实的一步,但纹理真实性、传感器物理建模、动态物体运动合理性——这些仍然是巨大的开放挑战。