FB-OCC 3D Occupancy预测:前反向投影融合与工程落地

发布时间:2026/10/27 13:19:28
FB-OCC 3D Occupancy预测:前反向投影融合与工程落地

去年到今年,我手上几个感知项目里被问得最多的问题,从“这个框画得准不准”悄悄换成了“这块空间到底能不能过”。这个变化把 3D Occupancy 预测从论文里的概念推到了必须落地的位置。而 FB-OCC 这套方案,是我见过的把“前向投影”和“反向投影”两条技术路线揉得最干净的一份冠军级实现——它在一个统一的体素空间里同时吃到了几何精度和语义密度,把 200×200×16 的体素场(0.4 米一格,覆盖自车周围 80 米见方、地面以上 5.4 米)跑到了纯视觉方案的第一梯队。

说白了,这套东西干的事就是:给你 6 路环视图像,它吐出 64 万个带语义标签的小方块,告诉你哪块是路面、哪块是卡车车厢、哪块是悬在半空的施工横梁、哪块是空气。它对标的是“三维占据 + 语义”这个联合任务,而不是传统的二维检测框或者平面鸟瞰图。做 BEV 感知的算法同学、想复现竞赛方案的学生、还有需要把占用预测塞进车端推理的工程同学,都能从这套方案里拿到东西:做算法的看前反向融合的设计取舍,做复现的看参数和训练配置,做部署的看推理侧怎么把算力压下来。

下面我按“为什么这么设计 → 关键参数怎么定 → 训练怎么调 → 出问题怎么查 → 怎么往工程上搬”的顺序,把这份方案里我认为真正值钱的部分拆开讲。

1. 先把问题定义清楚:占用预测到底在预测什么

1.1 从鸟瞰图平面到三维体素的那一步跨越

鸟瞰图这类表示,本质是把三维世界压扁成一张俯视图。它在“车与车之间的关系”上非常高效,但一旦遇到有高度结构的东西就开始失真。我印象最深的一次是某项目里一辆厢式货车停在路口,平面图上它的整个车身覆盖区域被预测成了障碍,这没错;但车身底盘下方那一小块真正的地面,也被一并吞掉了。还有一次是高架桥下的限高杆,平面图完全看不出来,规划模块按平面图规划出一条“可以直行”的路径,实车过去必然刮蹭。

占用预测要解决的就是这个问题。它把空间切成规则的小方块(体素),每个方块输出一个语义类别,空的地方标为空。这样得到的不再是“平面上有没有东西”,而是“这块空间被什么占着”。对下游规划来说,碰撞检测变成了一个非常朴素的操作:把未来轨迹上的采样点落到体素里查一下类别,是路面就过,是卡车就绕。这种接口的清晰程度,是检测框 + 平面分割拼出来的结果给不了的。

业内在用的体素网格基本已经形成了共识:水平方向以自车为中心取 80 米见方,也就是 X、Y 各 [-40, 40] 米;垂直方向从地面下方 1 米到上方 5.4 米,也就是 Z 取 [-1, 5.4] 米;体素边长 0.4 米。算一下就知道为什么是 64 万:200 × 200 × 16 = 640,000。语义类别通常是 16 类左右,涵盖可行驶路面、人行道、地形、人造设施、植被这几类“背景”,加上轿车、卡车、挂车、公交、工程车、自行车、摩托车、行人、锥桶、护栏、其他这几类“前景”。

1.2 评分规则反向决定了架构:为什么激光雷达只做训练期监督

很多人第一次看这类竞赛方案会疑惑:既然是纯视觉,为什么训练里到处是激光雷达点云?答案藏在评分规则里。

这类竞赛的主指标是 16 个类别的平均 IoU。注意是类平均,不是体素平均。这意味着一个在整个验证集里只占几百个体素的“锥桶”类,和占据几十万个体素的“可行驶路面”类,在最终分数里的权重是一样的。这个规则直接决定了三件事:

第一,长尾类别必须被专门照顾。你就算把路面分割做到 95 的 IoU,只要“工程车”“挂车”这类只有几十个样本的类别掉到 10 以下,总分立刻被拖垮。所以采样策略、类别权重、copy-paste 增广不是可选项,是必选项。

第二,训练期可以随便用激光雷达,推理期一根线都不许接。点云在训练时提供两样东西:精确的深度监督信号,以及精确的三维包围框(用来做实例级辅助监督)。推理时这些全部消失,模型只能靠图像自己把几何推出来。所以整套方案的核心矛盾就变成了——怎么在只有图像的情况下,把训练期从点云学到的几何先验榨干。

第三,遮挡和“空”的处理会被放大。评分通常只在有标注的体素上算,但空体素的预测质量会通过混淆矩阵间接影响结果。如果模型把大片空气预测成“植被”,虽然那些体素不参与计分,但它会侵蚀邻近真实体素的边界,把 IoU 拉低。

任务形态输出主要监督典型痛点
三维检测少量带朝向的框三维框标注异形障碍物无法用框表达
鸟瞰图语义分割平面栅格类别点云投影栅格丢失高度,限高/悬空物失效
占用预测64 万体素语义点云体素化标签深度歧义、类别极不平衡、算力大

这张表我经常拿给刚接触这个方向的同学看,一句话概括:占用预测是“用检测的精度要求,做分割的密集输出”,两头的好处都想要,代价就是算力和深度歧义。

2. FB-OCC 的整体设计:前向投影与反向投影为什么要同时存在

2.1 前向投影的死穴:深度误差会被“焊死”在体素里

前向投影这条路线(大家熟悉的那套“按深度分布把二维特征泼到三维”的做法)逻辑很直观:每个像素预测一组沿射线的深度概率分布,把该像素的特征按概率加权,散射到三维空间对应的位置上,再做体素池化把落在同一格里的贡献加起来。

它的优点是几何来源明确,遮挡关系天然成立——因为特征只沿射线往前泼,前面的物体会先占据射线上的体素。但它的死穴也很清楚:深度是猜的,而且猜错了没法回头改。体素池化是一个加权求和,本质不可逆,一旦某个像素的深度预测偏了,它的特征就被泼到了错误的格子里,后面的网络再强也只能在这个错误的基础上做修补。

这个误差在近距离还能忍,远了就迅速失控。举个实数:假设深度相对误差 10%,在 10 米处是 1 米偏差,差不多两个半体素;到 40 米处就是 4 米偏差,跨了整整 10 个体素。而 0.4 米的网格意味着相邻体素之间的语义可能完全不同(一个是卡车,一个是卡车后面的空气)。所以纯前向方案在远处的 mIoU 通常掉得很厉害,尤其是小目标。

还有一个隐蔽问题:深度分箱是离散的。落在两个分箱之间的真实表面,特征会被劈成两半分到相邻两个深度层,表现出来就是物体在深度方向上“分层”“拉丝”。你把分箱加密能缓解,但显存和计算量是线性涨的。

2.2 反向投影的死穴:查询采样拿不到精确几何

反向投影走的是另一条路:给每个体素一个三维查询向量,把它投到各个相机平面上,在投影点附近采样二维特征,再聚合回体素。这条路不需要显式深度,语义信息直接从图像特征来,分辨率也不受深度分箱限制。

但它的问题同样明显:一个体素投到图像上就是一个点(或几个采样点),这个点落在目标表面还是落在背景上,完全靠注意力机制自己学。更要命的是遮挡没有任何先验——一个位于墙体后面的体素,投到图像上依然能采到墙上的纹理特征,模型会把墙的语义赋予墙后的体素,表现出来就是“穿透”。在密集车流场景里,前车背后的空间被错误地填成车,是这类方案的经典故障。

另外反向投影对相机内参和外参的精度非常敏感。采样点是算出来的,标定偏一点,采到的就是隔壁像素。

2.3 融合模块:把“几何可信”与“语义丰富”拼到一起

前向投影的好处是几何可信、遮挡正确,坏处是深度一错全错、远处拉丝;反向投影的好处是语义密集、分辨率不受限,坏处是遮挡穿透、几何靠猜。这两者的错误模式几乎是互补的——这正是融合的价值所在。

FB-OCC 的核心做法是在体素空间里同时算出两套特征,再做深度融合。前向分支给出一个几何主导的体素特征,反向分支给出一个语义主导的体素特征,融合模块负责逐体素地判断“这一格该信谁”。

我实测下来比较稳的一种融合是门控加权,伪代码如下:

import torch import torch.nn as nn class GatedFusion(nn.Module): def __init__(self, c): super().__init__() self.gate = nn.Sequential( nn.Conv3d(2 * c, c, 1, bias=False), nn.BatchNorm3d(c), nn.ReLU(inplace=True), nn.Conv3d(c, c, 1), # 输出逐体素权重 ) self.proj = nn.Conv3d(2 * c, c, 1, bias=False) def forward(self, f_fwd, f_bwd): # f_fwd: 前向投影体素特征 (B, C, Z, Y, X) # f_bwd: 反向投影体素特征 (B, C, Z, Y, X) w = torch.sigmoid(self.gate(torch.cat([f_fwd, f_bwd], dim=1))) fused = w * f_fwd + (1.0 - w) * f_bwd return self.proj(torch.cat([fused, f_fwd * f_bwd], dim=1)) + fused

门控这个设计的关键不在结构,而在于初始化。如果一开始权重随机,融合层的初始输出接近 0.5:0.5 的均匀混合,等于把两路的缺点各继承一半,训练前期 loss 会明显比单路方案难降。我的处理是给门控最后一层卷积的偏置初始化成正值(比如 2.0),让 sigmoid 输出初始接近 0.88,也就是前期以几何可信的前向特征为主,随着训练推进再让网络自己往语义侧偏移。这一手在三个不同数据集上都带来了训练前期 20% 左右的收敛加速。

还有个小细节:融合之后一定要留一条残差。前向特征本身已经是可用的,融合模块如果训崩了,残差至少能保证不劣于单路方案。我在调结构的时候踩过这个坑,去掉残差后有一次训练到第 8 个 epoch 突然 mIoU 断崖式下跌,查了很久才发现是融合层的 BN 统计量被一批异常样本带偏了。

2.4 头部与多任务监督的整体编排

融合后的体素特征进三个头:语义头(三维卷积或稀疏卷积,输出 16+1 类 logits)、实例头(中心热力图 + 掩码分支,用训练期点云导出的三维框生成监督)、深度头(只在前向分支上挂,输出深度分布)。

多任务编排上我的建议是分阶段而不是一锅炖。第一阶段只开语义损失,让骨干和融合模块先学出稳定的特征;第二阶段加入深度损失,把几何约束灌进去;第三阶段再挂实例辅助监督和 IoU 损失。一次性把四个损失全打开,最容易出现的情况是深度分支梯度太大,把语义特征带偏,验证集表现反而不如只开语义。

损失项作用对象典型权重什么时候开
Focal Loss逐体素语义分类1.0全程
Lovasz / CE逐体素语义分类0.5第二阶段起
场景级 IoU 损失类集合交并比2.0第三阶段起
深度 SILog前向分支深度分布0.2 → 1.0第 2 个 epoch 起线性升温
实例中心 + 掩码实例头1.0第三阶段起

3. 关键环节拆解与实操要点

3.1 体素网格、分辨率与距离分箱:三个必须先算清楚的数

动手之前有三个数必须先定下来,它们互相牵制,改一个另外两个就得跟着动。

第一,网格尺寸。前面算过,80 米见方、0.4 米一格是 200×200,高度 6.4 米是 16 层。这不是拍脑袋定的。往细了走,比如 0.2 米一格,体素数直接变成 512 万,前向投影里平均每个体素收到的像素贡献从 3 个掉到 0.4 个都不到,几何证据严重不足,性能反而下降;往粗了走,0.8 米一格,行人、锥桶这种小目标基本被吞掉。0.4 米是当前这个任务上性价比的拐点。

第二,二维特征分辨率。图像输入通常缩到 256×704 或 512×1408 这一档,骨干输出取 1/8 下采样的特征图。以 256×704 为例,1/8 后是 32×88,每张图 2816 个像素位置。六路相机一共 16896 个位置。

第三,深度分箱数。取 112 个分箱,范围 1 到 60 米,采用线性加对数的混合分箱——近处密、远处疏,因为近处的深度分辨率对几何影响更大,而远处本身误差就大,分太密没有意义。我对比过 88、96、112、128 四档,96 和 112 之间的总分差距在 0.3 个点以内,128 只比 112 高 0.1 但显存多出近三成。所以除非显存特别宽裕,112 是默认选择。

把这三个数放一起算一下前向投影的散射点数量:16896 × 112 ≈ 189 万个点,对应 64 万个目标体素,平均每个体素收到约 3 个点的贡献。这个数字很关键——它解释了为什么前向分支在远处会拉丝:3 个点的证据本身就稀疏,稍微偏一点就落到隔壁格子里去了。

# 前向投影的体素池化:用排序+cumsum 替代 scatter_add, # 原因是 scatter_add 在反向传播时有非确定性,多卡训练时 # 同一个实验两次跑出来的 loss 会有微小差异,排查问题很痛苦 def voxel_pool(feat, depth_prob, geom, voxel_cfg): # feat: (B, C, H, W) # depth_prob: (B, D, H, W) # geom: (B, D*H*W, 3) 每个像素每个深度分箱对应的三维坐标 B, C = feat.shape[0], feat.shape[1] vals = (feat.unsqueeze(2) * depth_prob.unsqueeze(1)) # (B, C, D, H, W) vals = vals.reshape(B, C, -1).permute(0, 2, 1).contiguous() idx, mask = points_to_voxel_index(geom, voxel_cfg) # (B, N), (B, N) idx = idx * mask - (1 - mask) # 无效点推到末尾 order = idx.argsort(dim=1) vals = vals.gather(1, order.unsqueeze(-1).expand(-1, -1, C)) idx_sorted = idx.gather(1, order) # 同一体素内累加 cum = vals.cumsum(dim=1) # 只保留每个体素最后一个位置的值,再做差分得到段内和 return segment_sum(cum, idx_sorted, voxel_cfg.num_voxels)

3.2 深度监督:伪点云怎么造、损失怎么设

前向分支的效果几乎完全取决于深度质量,所以这一步值得花时间。

伪点云生成的做法:把该帧的激光点云按外参投到六路相机上,只保留落在图像范围内的点,得到每个像素位置上的稀疏深度。然后处理两个问题——稀疏和噪声。稀疏的处理我推荐“只在有值像素上算损失 + 掩码”,而不是先做插值再算损失,因为插值会引入假深度,模型会去拟合这些假值。如果一定要插值(比如为了增加监督密度),用 3×3 的形态学膨胀把深度图往外扩一圈,扩出来的像素权重打 0.5。

当同一个像素被多个激光点投中(近距离扫描线密),取最近的那个点,并且做一个最小距离过滤,把打在车窗玻璃、湿地面上的异常点剔掉——这些点会产生“穿透”的假深度,投到地面以下的体素里。

损失函数我推荐在视差空间算,也就是对 1/z 做回归:

def silog_loss(pred_log_depth, gt_depth, mask, lam=0.85): """pred_log_depth 是网络输出的对数深度,gt_depth 是伪点云深度""" pred = pred_log_depth[mask] gt = gt_depth[mask].clamp(min=1e-3).log() d = pred - gt return (d.pow(2).mean() - lam * d.mean().pow(2)).clamp(min=0).sqrt()

为什么在视差空间?因为深度回归的绝对误差在远处天然更大,直接回归对数深度会让远处样本的梯度被压得很小,而远处的几何恰恰是最需要约束的。SILog 的尺度不变性也能抵消一部分标定和地面高度估计的偏差。

注意:深度损失权重千万别一上来就给 1.0。我的做法是前 2000 步只给 0.2,然后线性升到 1.0。直接给满权重的后果是前向分支的梯度淹没了语义分支,训练曲线看着很漂亮(深度 loss 降得飞快),但验证集语义 mIoU 卡在一个很低的值上不动。

3.3 语义损失与 IoU 损失的分工

交叉熵和 focal 优化的是逐体素的分类准确率,但评价指标是类平均 IoU,这两者的梯度方向并不完全一致。一个直观的例子:某类目标在图像边缘只有几十个体素,逐体素准确率视角下它根本不重要,但 mIoU 视角下它和被几十万个体素的路面一样重。

所以除了 focal(γ=2,α=0.25,配反频率类别权重),还得加一个直接把 IoU 写进损失里的项,也就是场景级 IoU 损失。它通常拆成两块:一块是语义层面的类 IoU(把所有体素按类别聚合成一个混淆矩阵,用 soft 版本算每类的交并比),另一块是几何层面的场景 IoU(前景整体 vs 背景整体,以及每个类别内部的空间一致性)。这两块的权重通常是 1:1,整体权重给到 2.0。

实测下来 IoU 损失带来的提升集中在长尾类,常见类(路面、轿车、植被)提升很有限。所以如果算力紧张,可以只在训练的后期 1/3 阶段开启,收益能拿到七成。

还有一个容易被忽略的点:空体素要不要参与损失计算。我的经验是让空体素参与分类损失(权重压低,比如 0.1),但绝对不要让它参与 IoU 损失。原因是空体素的数量是前景体素的几十倍,如果让它进混淆矩阵,IoU 损失的梯度会被“预测空气”这件事完全主导,模型倾向于把所有不确定的地方都判成空。这个问题在方案刚复现完的时候特别常见,表现是验证集里能看到物体轮廓,但内部有大片空洞。

3.4 时序融合与自车运动补偿

单帧图像对遮挡区域是无解的——被前车挡住的那部分空间,图像里根本没有信息。所以必须引入时序。

做法是维护一个历史体素特征缓存,用自车位姿把上一帧的特征刚性变换到当前帧坐标系,再和当前帧做融合。这里有一个精度门槛值得算一下:假设外参标定有 0.1 度的角度误差,在 40 米外产生的横向位移是 40 × tan(0.1°) ≈ 0.07 米。这个量级还在一个体素(0.4 米)之内,可以接受。但如果误差放大到 0.5 度,40 米外就是 0.35 米,几乎顶到体素边界了,融合的特征会在物体边缘出现明显的“台阶”和毛刺。

所以时序融合之前,建议先做一次标定自检:挑几帧静止场景,把点云投到图像上叠加看边缘是否重合,偏超过两个像素就得重新标。

另一个坑是动态物体。位姿变换只补偿了自车运动,场景里跑动的车靠刚性变换是补不了的,直接融合会拖影。常规做法是用实例掩码或者前向光流把动态区域的融合权重压到接近零,宁可不融合也不要融错。我在一个车流很密的场景上测过,不加动态掩码的版本在运动物体边缘的 IoU 掉了将近 8 个点。

注意:时间戳对齐比想象中更重要。相机曝光时刻和位姿查询时刻如果差了一帧(大约 50 毫秒),在 60 公里时速下自车已经移动了 0.83 米,正好两个体素。这个偏差在低速场景几乎看不出来,上了高速就是系统性拖影。

4. 训练配置与调优实操

4.1 数据增广与长尾采样

三维增广里有几个必做的,也有几个我建议谨慎的。

必做的是图像光度增广(亮度、对比度、饱和度抖动,加上轻微的高斯噪声),这一项对纯视觉方案几乎零成本却有稳定收益,因为它让骨干不至于记住某个特定光照下的纹理。

三维空间的增广要小心。全局旋转我通常只做 ±10 度,因为体素网格是轴对齐的,旋转之后要么重新对齐网格(引入插值模糊),要么把旋转后出界的部分裁掉。有人做 ±22.5 度的旋转,涨点也确实有,但那是在网格边界留了余量的前提下。缩放做 0.95 到 1.05,平移做 ±0.5 米,这两项主要防止模型对目标绝对尺寸过拟合。

水平翻转是个大坑。图像翻转之后,相机内参里的主点 cx 必须从 W-1-cx 重新算,三维空间的 y 坐标符号也要跟着变。我见过至少三个复现项目在这里出错,现象是训练 loss 正常下降,但验证集 mIoU 只有正常水平的一半,查了两天才发现翻转后的投影全错位了。

长尾采样用按类别分组采样的思路:统计每个场景里出现的稀有类别(工程车、挂车、锥桶这些),给含稀有类的场景更高的采样概率,让一个 batch 里的类别分布尽量均匀。这一项单独就能带来 2 到 3 个点的总分提升,是所有技巧里性价比最高的。

4.2 显存与吞吐的参数账

把显存账算清楚,比盲目调 batch size 有用得多。

体素特征:640000 × 256 通道 × 2 字节(半精度)= 327 MB。这个数字不大,但注意它只是一层。融合模块、三维卷积的中间激活都是这个量级,堆五六层就接近 2 GB。

反向投影的采样坐标:64 万体素 × 6 路相机 × 3 个坐标值 × 4 字节 = 46 MB,这部分还好,但如果开了可变形注意力的多采样点(每相机 4 个点),直接乘 4。

梯度的显存占用通常是前向激活的 2 到 3 倍。所以单卡跑 batch=1 是常态,想要等效大 batch 就靠梯度累积。我的配置是单卡 batch=1、8 卡、累积 2 步,等效 batch 16,学习率按线性缩放定在 2e-4(骨干部分 2e-5,因为预训练权重不需要大幅调整),权重衰减 0.01,余弦退火,warmup 500 步。混合精度用 bf16,比 fp16 稳,省下 30% 到 40% 显存而且不用配 loss scale,代价是老的卡可能不支持。

训练时长方面,24 个 epoch 在 8 卡 80G 级别显存的环境下大约两天半。用 EMA(衰减 0.9998)收尾,验证时用 EMA 权重,这一项通常白送 0.5 个点。

一个不太被提及但很实用的技巧:训练最后 2 个 epoch 关掉所有三维增广,只保留光度增广。这相当于给模型一个“适应真实分布”的收尾期,我在三个实验里都观察到了 0.3 到 0.6 个点的提升。

4.3 推理侧加速与测试时增强

榜单方案的推理耗时通常是不能直接上车的,但加速的思路值得记下来。

第一,反向投影只对有效体素做。视锥裁剪之后,真正落在至少一路相机视野内的体素大概只占 35% 到 45%,把无效体素提前剔掉,反向投影这块的计算量直接砍一半以上。实现上就是预先算一个全局的有效体素掩码,第一帧算一次就够,因为相机内外参是固定的。

第二,深度分箱剪枝。前向投影时,把概率和小于 0.05 的尾部深度层丢掉,实际参与散射的分箱数通常能降到 60 到 70 个。这一项对精度影响很小(我测下来 0.1 个点以内),但散射点的数量少了近四成。

第三,语义头换成稀疏卷积。稠密三维卷积在 64 万体素上做的绝大部分计算是在处理空气,而空气占了 90% 以上。稀疏卷积只在实际被占据的体素上算,速度能提 2 倍以上,代价是实现复杂度上升,而且要小心稀疏模式在 batch 之间不一致带来的额外开销。

测试时增强的部分,flip 加三尺度(0.9、1.0、1.1)是标配,权重我给 0.5、1.0、0.8。这里有个必须检查的点:flip 增强之后,预测出的体素场要沿 y 轴翻回来,翻的时候所有带方向属性的输出(如果头里有朝向回归)都要取反。忘了这一步,融合出来的结果会比单模型还差,而且症状很隐蔽——总分只掉一点点,但小目标位置系统性偏移。

模型集成就是不同主干、不同随机种子的 logits 直接平均,注意类别顺序一定要对齐。两到三个模型集成通常能带来 1 到 1.5 个点,但耗时线性翻倍,只适合打榜。

5. 常见问题排查实录

5.1 故障速查表

这张表是我从若干次复现和调参里攒下来的,按“现象 → 大概率原因 → 先查什么”组织,遇到问题可以直接对着找。

现象大概率原因先查什么
训练 loss 正常降,验证 mIoU 只有正常值一半图像翻转后内参未同步修改拿一帧做翻转前后投影叠图比对
远处目标糊成一片,近处正常深度分箱过稀或深度损失权重过低打印深度预测的分布直方图,看是否集中在少数几层
物体内部出现大片空洞空体素参与了 IoU 损失检查混淆矩阵里空类的计数是否异常膨胀
前车背后的空间被填成车反向投影遮挡穿透,融合门控偏向语义侧可视化门控权重的空间分布,看背景区域权重是否过高
运动物体边缘拖影时序融合未做动态掩码关掉时序看拖影是否消失
长尾类 IoU 长期低于 5采样不均或类别权重不足统计每个 batch 的类别出现频次
多卡训练两次结果不一致scatter_add 反向非确定性换成排序 + cumsum 实现
显存 OOM 但参数账算下来够反向投影的多采样点展开把每相机采样点数从 4 降到 2 试一次
推理耗时是预期的两倍反向投影跑了全部体素而非有效体素打印有效体素占比,正常应在 35% 到 45%

5.2 几条文档里不会写的经验

先做投影正确性验证,再谈模型效果。我在项目里立了个规矩:任何涉及相机投影的新代码,第一件事是把点云投到六路图像上叠图,肉眼确认边缘重合;第二件事是挑一个已知坐标的体素,把它反投到图像上高亮出来,看落点对不对。这两步花十分钟,能省掉后面两天的瞎猜。

用八场景过拟合实验验证整条流水线。取 8 个场景、训 200 步,如果一切正常,训练 loss 应该能压到非常低的水平,mIoU 冲到 80 以上。如果过拟合都过不了,说明数据加载、投影、损失计算里有结构性 bug,这时候去调超参纯属浪费时间。这个习惯帮我抓到过标签对齐错位一个体素的隐蔽问题。

分类别看 mIoU,别只看总平均。总分掉 1 个点,可能是 15 个类都没动、1 个类掉了 15 个点。每次验证我都导出一张分类别表格和历史对比,长尾类的波动一眼就能看出来。这个做法听起来笨,但它让我在两次“莫名其妙掉点”里很快定位到了采样器的随机种子问题。

验证集和榜单之间一定会有 gap,别把验证集调得太舒服。后处理阈值、TTA 权重这些,如果在验证集上反复调到最优,实际上是在过拟合验证集。我的做法是留一个小的 holdout 集合完全不动,只在最后确认时跑一次。

关于空体素的监督,值得单独设计。很多人直接把空当成第 17 类扔进去训,效果一般。更好的做法是给空体素一个很低的损失权重(0.1 左右),同时在前向投影的深度监督里隐含地约束“射线上第一个命中点之前都是空的”。这个隐式约束比显式分类更有效,因为它是几何层面的。

6. 从榜单方案到车端落地还要动哪些刀

榜单方案和能上车的方案,中间的差距主要在三个地方:算力、时序稳定性、和下游的接口。

算力这块,竞赛方案通常在 80G 级别显存上跑到 2 到 3 FPS,车端的预算往往只有它的十分之一。可行的路径是把体素网格从 200×200×16 降到 128×128×16(0.625 米一格),远处用两级网格(近处细、远处粗),配合稀疏卷积,能在损失 2 到 3 个点 mIoU 的前提下把耗时压到可接受范围。另一个思路是把前向分支的深度分箱剪到 48 个,反向分支只保留两路正前方的相机做精细采样,两侧相机只用前向分支——这个非对称设计在实际测试里性价比很高。

时序稳定性是车端特有的问题。榜单评测是逐帧独立的,车端要求帧间连续。我在实车数据上见过体素场在相邻帧之间整片闪烁的情况,原因通常是时序缓存的融合权重在边界场景抖动。解决办法是给融合权重加一个时间维度的平滑(当前帧权重不低于上一帧的 0.7),代价是响应速度略慢,但视觉上稳定得多。

和下游的接口设计也值得提前想。直接把 64 万体素的语义图丢给规划是不现实的,通常要做一层抽象:把占用体素按“可通行 / 不可通行 / 未知”三值化,再叠加上一层基于距离场的膨胀(按自车刹车距离算膨胀半径),最后输出一张代价地图。这个抽象层的位置很关键——放在感知侧会限制规划的表达能力,放在规划侧又会重复计算,我的经验是放在两者之间的独立模块里,用明确的接口约定。

最后说个我自己的习惯:每次改完这套方案的一个模块,我都会固定跑三个场景的可视化——一个密集车流的城市路口、一个有大车遮挡的高速匝道、一个植被茂密且有悬空树枝的乡道。这三个场景基本能覆盖深度歧义、遮挡穿透、高度结构这三类典型失效模式。看可视化比看数字有用得多,数字告诉你掉点了,可视化告诉你为什么掉。

这套方案我在实际复现和改动里最大的体会是:前向和反向两路的价值不在于“融合”这个动作本身,而在于它们的错误模式互补。真正决定效果的是融合模块能不能逐体素地判断该信谁,以及深度监督的质量够不够撑住前向分支的可信度。把这两件事做扎实,剩下的调参都是锦上添花。