实时占用栅格预测这件事,难在30毫秒的单帧预算里把一整片三维空间的占用状态稳定填对,把mIoU刷上去反而没那么难。FastOcc这个名字第一次出现在我视野里时,我以为是又一个常规加速方案,把网络砍两刀、精度掉几个点,也能交差;直到自己按同样的思路重做了一遍链路,才发现它真正值得学的是它对“哪一步该省、哪一步绝不能省”的判断。这篇东西写给正在做车端感知部署、被延迟与显存同时卡脖子的同行,也写给刚接触三维占用任务、还没被量产指标毒打过的新人。往下我会把FastOcc当成一个可复现的工程方案来拆,讲清楚它为什么这么设计、每一步的代价在哪里、以及我在复现过程中踩到的那些文档里不会写的问题。
1. “快”在占用预测里是个结构问题,不是调参问题
很多人拿到一个新模型,第一反应是砍通道、减层数、降输入分辨率,把FLOPs压下去,然后发现端侧实测延迟并没有跟着降。这在占用预测任务上尤其明显,因为这个任务的耗时分布跟常见的二维检测完全不是一个形状。二维检测的耗时大头集中在主干网络,你把主干压一压,整体就线性下降;占用预测则不同,它的耗时被切成三段:图像主干、视角变换、三维解码头,而视角变换这一段在小模型上反而会成为最大的一块,因为它跟通道数不成正比,跟采样点数量、体素网格规模成正比。
1.1 单帧预算到底怎么算出来的
先把预算算清楚,否则后面全是拍脑袋。车端常见的目标是30FPS,也就是每帧33.3毫秒;但感知链路里你不可能独占这33毫秒,预处理、后处理、结果发布、多任务并行都要分。我的经验是给占用预测模块留12到18毫秒的推理时间比较现实,剩下的留给图像解码、几何校正和下游规划接口。如果只跑10FPS的泊车场景,预算可以放宽到40毫秒左右。
这个预算决定了体素网格的上限。我做过一个粗略的估算表,帮助自己在方案初期就框定范围:
| 体素范围(长×宽×高) | 分辨率 | 体素总数 | 单帧占用特征显存(FP16,C=32) | 典型适用场景 |
|---|---|---|---|---|
| 80m×80m×6.4m | 0.8m | 100×100×8 | 约5MB | 高速巡航,远距离粗粒度 |
| 80m×80m×6.4m | 0.4m | 200×200×16 | 约41MB | 通用城区,主流选择 |
| 60m×60m×4.8m | 0.3m | 200×200×16 | 约41MB | 泊车,近距离高精度 |
| 100m×100m×8m | 0.4m | 250×250×20 | 约80MB | 大车/高速,显存压力大 |
注意这里的显存只是中间特征,还没算主干的多尺度输出和梯度。训练时把batch开到4,同一份配置的显存占用会直接翻十倍以上,这是新手最容易被劝退的地方。所以FastOcc这类方案的第一条设计原则就是:体素网格在满足任务最小可辨识尺寸的前提下尽量收紧,绝不用“反正显存够”这种理由放大网格。
1.2 三个看起来有效、实际没用的加速误区
第一个误区是把输入图像分辨率砍一半。这招在二维检测上很灵,在占用预测上会直接破坏远处的体素填充:一个远处行人在1024宽度的图像里可能只占6个像素,砍到512就剩3个,深度估计和语义分类同时崩。我实测过,输入从1280×720降到960×540,延迟只降了8%,而远处小目标的占用召回掉了将近11个点,这笔买卖完全不划算。
第二个误区是无脑上三维稀疏卷积。稀疏卷积确实能省掉大量空体素的计算,但它对“稠密程度”极其敏感。城区场景里地面、建筑、植被的占用率经常超过30%,这时候稀疏化的收益就被索引构建和 gather/scatter 的开销吃掉了。更麻烦的是稀疏卷积在不同推理后端上的支持程度差异很大,导出之后常常退化成一堆零散算子,延迟比稠密卷积还高。
第三个误区是把深度估计的类别数从几十档降到几档。Lift这类方案里深度分布是用来做“软投影”的,档位太少会让相邻体素抢同一个像素,形成条带状的伪影。我在一个项目里把深度从88档降到32档,延迟只省了不到1毫秒,但地面和墙面的边界出现了明显的锯齿,后处理怎么补都补不干净。
1.3 FastOcc的取舍逻辑:砍冗余,不砍表达能力
理解FastOcc的关键是看懂它的取舍顺序。它不是从“我要多快”倒推,而是先问“这个任务的精度天花板在哪、哪部分计算是完全冗余的”。占用预测的冗余主要来自三处:一是多相机之间有大量重叠视野,同一块三维区域被投影了多次;二是高度维上的信息密度极不均匀,地面附近信息密集,头顶几层几乎全空;三是输出的语义类别分布极度长尾,地面占了七成以上的体素。
FastOcc针对这三点分别做了对应处理:重叠区域在特征聚合阶段做权重归一,而不是简单相加;高度维在后段用通道折叠的方式降维,把三维卷积换成二维卷积;类别上对高频类做下采样监督、低频类做重加权。这三条加起来才是它能把延迟压下去的核心,如果你只抄了“换成二维卷积”这一条,那大概率会得到一个又快又差的模型。
2. 从六路图像到体素特征:三个耗时环节的真实占比
把一个占用预测网络拆开看,实际上是三个串行阶段。第一阶段是图像主干,把每路图像编码成多尺度特征;第二阶段是视角变换,把图像特征按相机内外参投影到三维空间;第三阶段是三维解码头,在体素空间里做特征融合和语义分类。我手上的一个中等规模配置(六路1280×720输入、0.4米分辨率、200×200×16网格)在单卡上的耗时分布大致是:主干42%、视角变换31%、解码头27%。这个比例非常关键,因为很多人优化了半天主干,最后发现省下来的时间还不如把视角变换的采样点减少两成。
2.1 主干网络选型的实际考量
主干不是越深越好,也不是越新越好。占用预测的主干有个特殊要求:它输出的特征要被投影到三维空间,所以特征图的空间分辨率必须够高,否则投影之后的体素会非常稀疏。这意味着你在选主干时要优先保证 stride 8 这一层的通道数和分辨率,而不是一味追求最后一层的感受野。
我通常会把主干设计成四层,输出 stride 4、8、16、32 四个尺度,投影阶段主要用 stride 8 和 stride 16 这两层。stride 4 分辨率太高,投影的采样点数量会爆炸;stride 32 太粗,远处小目标直接消失。这里有个经验值:投影用的特征层,其空间分辨率最好不要低于输入图像的1/16,也不要高于1/8,超出这个区间的配置我都试过,要么太慢要么太糊。
主干的宽度也要克制。我见过不少人把通道数堆到256起步,结果投影阶段的显存直接爆掉。因为投影之后的体素特征通道数通常跟主干通道数对齐,主干宽一倍,体素特征就宽一倍,中间那41MB会变成82MB,再加上采样索引,整体就撑不住了。实践中128到192的基准通道数已经能覆盖大部分场景。
2.2 视角变换为什么是最容易被低估的一段
视角变换的耗时跟体素数量、采样点数量、相机数量三者相乘。以200×200×16的网格为例,总共64万个体素,每个体素如果从两路相机各取4个采样点,就是512万次采样。这些采样在实现上表现为大量的索引计算和双线性插值,属于典型的访存密集型操作,GPU的算力根本用不满,瓶颈全在带宽上。
这也是为什么单纯看FLOPs评估延迟会失准。视角变换的FLOPs可能只占全网的15%,但实际耗时占了30%。我在做性能剖析时有个习惯:不看算子数量,看每个算子前后的同步点和内存读写量。视角变换这一段往往是一堆小算子串起来,中间没有融合机会,每个算子都要读写一遍中间张量,累积起来就是灾难。
FastOcc在这一段的处理思路是把“预计算”和“运行时”彻底分开。每个相机的外参内参在一段时间内是固定的,那么采样点的三维坐标、落在哪个体素、落在特征图的哪个位置,这些全都可以离线算好,存成一张索引表。运行时只需要按索引表做 gather 和加权求和。这一招听起来简单,但能把这部分的耗时砍掉四成以上,代价是索引表要占几十MB的存储,切换相机标定时需要重建。
2.3 解码头:二维卷积怎么吃掉高度维
三维解码头是第二个可以大做文章的地方。传统的做法是堆几个3D卷积,感受野在三个维度上同时扩张,参数和计算量都是立方级增长。FastOcc的做法是把高度维折叠到通道维,也就是把形状为 (B, C, H, W, D) 的张量 reshape 成 (B, C×D, H, W),然后跑标准的二维卷积。
这个操作的合理性来自一个观察:占用预测里高度维的相关性远弱于平面维。地面是连续的一片,建筑立面是竖直的一片,它们在平面方向上有强连续性,在高度方向上则经常是突变的。你用一个二维卷积在平面上做聚合,已经能抓到绝大部分结构信息,高度维的信息完全可以通过通道混合来隐式表达。
当然这个折叠不是无损的。代价是参数量会随高度层数线性增长,如果高度是16层、通道是32,折叠后就是512通道,二维卷积的开销并不小。所以FastOcc通常会在折叠前先把通道压到一个较小的值(比如16或24),折叠后再用1×1卷积压回来。这个“先压后折”的顺序很重要,顺序反了参数量会翻好几倍。
2.4 输出头与后处理的耦合
很多人忽略了一点:占用预测的输出头设计跟后处理是强耦合的。如果你的下游只需要“哪里有障碍物”这一个二值结果,那输出通道可以是1;如果需要区分车辆、行人、可行驶区域,那就是十几个通道。通道数直接决定了最后几层卷积的开销,也决定了argmax或者阈值化后处理的时间。
我的建议是把语义粒度跟安全等级挂钩,而不是跟标注数据的丰富度挂钩。标注数据里可能有二十类,但真正影响行车决策的可能只有六类,剩下的合并成“其他障碍物”完全够用。少掉的这十几类,在输出头能省下接近一半的计算,在后处理上也能省掉一层查表。
3. 体素网格与投影实现:参数定错,后面全白干
这一节讲的是我认为最容易翻车、也最值得花时间的地方。体素网格的定义、坐标系的约定、投影的采样策略,这三件事只要有一件没对齐,你训练出来的模型看着loss在降,可视化一看却是整体偏移或者镜像的。而且这类错误往往不会报错,只会让精度莫名其妙地低几个点,非常难查。
3.1 坐标系的三个约定必须先写死
在动手写任何代码之前,先把三件事写进文档并且全组统一:自车坐标系的原点在哪、体素索引的(H, W, D)分别对应哪个物理轴、图像像素坐标的左上角原点跟相机光心怎么对应。我见过最离谱的一次是两个人分别写数据加载和模型,一个人认为体素索引的0是最近处、另一个认为是正前方,最后训出来的模型把整个场景旋转了90度,还以为是数据标注有问题,查了三天。
我习惯的约定是这样的:自车坐标系x向前、y向左、z向上;体素网格以自车为中心,x方向覆盖[-40m, 40m],y方向覆盖[-40m, 40m],z方向覆盖[-2m, 4.4m];体素索引为(i, j, k),i对应y方向(从左到右),j对应x方向(从后到前),k对应z方向(从下到上)。这套约定不用照抄,但一定要在项目里固化下来,并且写一个单元测试去验证:给一个已知的三维点,走一遍从物理坐标到体素索引再到图像像素的转换,结果跟手算对上才算数。
3.2 深度分布与采样点数量的平衡
Lift这一步的核心是给每个像素预测一个深度分布,然后把特征按概率摊到不同的深度上去。深度分布的档位数量和采样点的数量是两个独立参数,很多人会混淆。档位数量决定了深度方向的离散精度,采样点数量决定了每个体素从多少个像素上聚合特征。
我实测下来的经验区间是:深度范围设成1米到60米,采用对数间隔比线性间隔更好,因为近处的深度误差对投影结果的影响远大于远处。档位数量64到88之间比较稳妥,低于48就会出现明显的层状伪影。采样点数量方面,每个体素从每个相机取2到4个点足够,再多收益递减得非常快。
下面是一段我常用的体素采样索引构建代码,把离线预计算的部分抽出来,运行时直接查表:
import numpy as np def build_voxel_sample_index(cam_intrinsics, cam_extrinsics, voxel_range, voxel_size, feat_stride): """ cam_intrinsics: (N, 3, 3) cam_extrinsics: (N, 4, 4) 相机到自车 voxel_range: [(xmin, xmax), (ymin, ymax), (zmin, zmax)] voxel_size: (sx, sy, sz) 单位米 feat_stride: 特征图相对于原图的下采样倍数 """ grids = [np.arange(lo, hi, s) for (lo, hi), s in zip(voxel_range, voxel_size)] gx, gy, gz = np.meshgrid(*grids, indexing='ij') # 体素中心点,形状 (M, 3),M 为体素总数 centers = np.stack([gx.ravel(), gy.ravel(), gz.ravel()], axis=-1) centers = centers + np.array(voxel_size) * 0.5 # 取格心 homo = np.concatenate([centers, np.ones_like(centers[:, :1])], axis=-1) all_idx, all_w, all_cam, all_vox = [], [], [], [] for n in range(cam_intrinsics.shape[0]): # 自车坐标 -> 相机坐标 pts_cam = (np.linalg.inv(cam_extrinsics[n]) @ homo.T).T[:, :3] depth = pts_cam[:, 2] valid = depth > 0.1 # 相机坐标 -> 像素坐标 uv = (cam_intrinsics[n] @ pts_cam.T).T u = uv[:, 0] / np.maximum(uv[:, 1] * 0 + uv[:, 2], 1e-6) v = uv[:, 1] / np.maximum(uv[:, 2], 1e-6) u_f = u / feat_stride v_f = v / feat_stride # 落在特征图范围内才算有效,注意这里要留出双线性插值的边 valid &= (u_f >= 1) & (v_f >= 1) valid &= (u_f < FEAT_W - 2) & (v_f < FEAT_H - 2) vox_ids = np.nonzero(valid)[0] all_vox.append(vox_ids.astype(np.int32)) all_idx.append(np.stack([v_f[valid], u_f[valid]], axis=-1).astype(np.float32)) all_cam.append(np.full(vox_ids.shape, n, dtype=np.int32)) # 拼接后再按体素做归一化权重(解决多相机重叠重复计数) vox = np.concatenate(all_vox) idx = np.concatenate(all_idx) cam = np.concatenate(all_cam) order = np.argsort(vox, kind='stable') vox, idx, cam = vox[order], idx[order], cam[order] counts = np.bincount(vox, minlength=centers.shape[0]).astype(np.float32) w = 1.0 / np.maximum(counts[vox], 1.0) return vox, idx, cam, w这段代码里有几个容易忽略的点。第一,特征图边界要留出一格余量,否则双线性插值会越界,越界访存在GPU上不一定报错,可能悄悄返回0,表现为边缘体素特征变空。第二,深度的有效性判断必须在相机坐标系下做,用世界坐标或者自车坐标判断会得到完全错误的结果。第三,归一化权重要按体素统计,而不是按相机统计,这两者的差别在多相机重叠区非常明显。
3.3 重叠区域的重复计数:一个被低估的精度杀手
六路环视相机的视野重叠率通常在30%到50%之间,正前方区域甚至能被三路相机同时看到。如果不做处理,直接把所有相机的特征加起来,重叠区域的体素特征幅值会是边缘区域的两到三倍,这会让网络对这些区域产生过强的响应,表现为车身正前方的占用预测过度膨胀。
解决方案有三种,各有取舍。第一种是硬归一化,也就是上面代码里做的按体素除以相机数量,简单有效,但会让边缘区域的单相机体素权重过高,训练不稳定。第二种是软归一化,用一个可学习的小网络给每个相机的贡献打分,再做加权求和,效果好但增加了延迟。第三种是在相机之间做注意力,FastOcc这类追求速度的方案一般不会选。
我的建议是训练初期用软归一化,收敛之后再蒸馏到硬归一化。这个流程听起来麻烦,但实际能挽回一到两个点的mIoU,而且部署时只跑硬归一化,不增加任何推理开销。这个技巧我在两个项目里都用过,收益稳定。
3.4 采样点数量对延迟的实测影响
为了让“砍采样点”这件事有据可依,我做过一次系统的对比测试。固定网格200×200×16、六路相机、特征stride 8,只改每个体素从每路相机取的点数:
| 每体素每相机采样点数 | 视角变换耗时(ms) | mIoU | 远处小目标召回 |
|---|---|---|---|
| 1 | 2.1 | 0.281 | 0.41 |
| 2 | 3.0 | 0.302 | 0.47 |
| 4 | 4.8 | 0.311 | 0.51 |
| 6 | 6.6 | 0.313 | 0.52 |
| 8 | 8.4 | 0.314 | 0.52 |
从2个点加到4个点,mIoU涨了近1个点,耗时多了1.8毫秒,值得;从4加到6,收益已经很小,耗时又多1.8毫秒,不值得。所以4个点是我在这个配置下的选择。但这个结论不能照搬,如果你的相机基线更宽、或者场景以远距离为主,最优值可能会往上走。
4. 轻量三维头的三个取舍与损失设计
解码头是FastOcc里最能体现工程品味的部分,因为它要在“保留三维结构”和“控制计算量”之间做一系列不那么直观的取舍。这一节我按三个维度展开:通道怎么折、类别怎么分、上采样怎么做。
4.1 通道折叠的具体配方
前面提到把高度维折进通道维,实际操作里有一堆细节。首先是折叠前的通道压缩,我推荐用1×1卷积把通道压到16,不要压到8以下,否则高度维的结构信息在压缩时就丢了。其次是折叠之后的卷积核大小,用3×3就够了,5×5的收益不明显但参数量翻倍。最后是折叠和解折叠的次数,我一般做两轮折叠-卷积-解折叠,再多就没有边际收益了。
有个细节很多人会错:折叠之后的卷积必须是带padding的same卷积,否则平面尺寸会逐层缩小,解折叠时对不上。我见过有人在这里用了valid卷积,结果每次解折叠都要做一次插值,白白增加了开销还引入模糊。
另一件事是折叠之后要不要加归一化。我个人倾向于加,但要注意高度维折叠后通道数很大,归一化层的统计量在大通道数下容易不稳定。解决办法是用较小的动量,或者在最后几层不加。这个细节对最终精度的影响大概在0.5个点左右,但会影响训练收敛的速度。
4.2 类别粒度:细粒度语义的真实代价
前面说过类别数直接影响输出头开销,这里补充一下它更隐蔽的影响:类别数会影响训练的难度和收敛速度。当类别从6类扩到15类,且新增的都是长尾类别时,你不仅要多花输出通道的计算,还要面对严重得多的类别不均衡。一个常见的表现是训练到中后期,地面类的准确率已经到0.95以上,而一些稀有类的召回还停留在0.2附近,整体mIoU被拖得很低。
FastOcc的处理方式是把类别按频率分档,不同档位用不同的损失权重。这里有个实操技巧:权重要按“每类有效体素数”的平方根倒数来设,而不是直接按倒数。直接用倒数的权重会大得离谱,稀有类的梯度会把模型带偏,表现为地面出现大面积的空洞。用平方根倒数能兼顾两者,这个经验我从二维分割里带过来的,在三维场景同样成立。
4.3 上采样与边界锐化
体素输出的分辨率往往比输入特征粗,需要上采样。常见的做法是三线性插值,简单但会让边界变模糊。对于占用预测,边界模糊意味着可行驶区域和障碍物的分界不清,下游规划会非常敏感。我的做法是在最后一次上采样之后加一个轻量的边缘增强模块:用平面方向的Sobel算子提取梯度,跟特征做逐通道相乘,再残差相加。这个模块的参数量几乎为零,耗时增加不到0.3毫秒,但边界清晰度的提升在可视化上肉眼可见。
这里要注意别把边缘增强放在最后一步输出之后,那样只会让logits的边缘变尖锐,不改变语义判断。放在倒数第二层,让它影响后续的语义分类,效果才明显。
4.4 损失函数组合的实际情况
损失这块没什么玄学,focal loss加dice loss的组合是最稳的。但有几个参数需要根据体素规模调整。focal loss的gamma在体素任务里我一般设1.5到2.0,比二维检测常用的2.0略低,原因是三维空间的样本更稀疏,gamma太高会让模型只关注极难样本,忽略中间地带。dice loss的权重设0.3左右,太高会让训练前期震荡得很厉害。
还有一个容易被忽略的点:损失要在有效体素上算,而不是全网格。如果你的网格有一部分在实际视野之外(比如相机看不到的区域),这部分体素的标签是“未知”,不能当负样本。如果不做mask,模型会学成“看不见的地方都是空的”,这个错误在部署时会直接变成碰撞风险。这一点必须写进数据管线的检查清单。
5. 训练阶段踩过的四个坑与完整排查链路
前面讲的是设计,这一节讲的是我在复现过程中真实撞上的问题。我按“现象-排查-根因-修复”的顺序写,你可以直接拿去对照自己的现象。
5.1 标签生成的坐标系错位
现象是训练loss正常下降,验证集mIoU也还行,但是可视化的时候发现障碍物的占用体素整体往自车后方偏移了大约两个体素。一开始我怀疑是投影公式里的平移项写错了,查了半天没找到问题。
排查过程是这样走的:我先手工构造了一个最简单的用例,只有一个点云点位于自车正前方10米、地面上方1米,走一遍标签生成流程,看输出的体素索引对不对。结果是对的,说明标签生成本身没问题。然后我用同样的点走一遍模型侧的投影流程,发现索引差了两格。问题出在标签生成用的是“点所在体素的左下角索引”,而投影用的是“点所在体素的中心”,两者差半个体素。当分辨率是0.4米时,半个体素是0.2米,但视觉上看起来差了两格,是因为我在地面附近观察,透视放大了这个误差。
根因是两处代码不知道彼此的存在。修复方式很土:写一个共享的坐标转换模块,标签生成、数据增强、模型投影三处全部调用同一个函数,谁都不许自己写一遍。这件事之后我养成了一个习惯,任何涉及坐标变换的代码,全项目只能有一份实现。
5.2 数据增强破坏了几何一致性
现象是加了随机缩放增强之后,验证精度掉了将近4个点,而且掉得很均匀,不是某一类的问题。这个问题比较隐蔽,因为它不报错。
排查思路是逐项关闭增强看影响。先关颜色抖动,精度没变;再关随机裁剪,恢复了1个点;最后关随机缩放,精度基本回来了。锁定缩放之后,我看了一下标签的处理方式,发现图像做了0.9到1.1的缩放,但点云标签没有跟着改内参。也就是说,网络看到的是一张被缩放的图,但标签是按原始内参投出来的,两者的几何关系对不上。
修复的办法是缩放图像之后,同步修改相机的内参矩阵,把焦距和光心都按比例缩放。这个操作在数学上很简单,但在代码里极容易被漏掉,因为增强模块和标签模块是两个独立的分支。我现在的做法是把增强参数和对应的内参变换绑成一个结构体一起传递,从接口层面杜绝漏改的可能。
5.3 小batch下的归一化不稳定
现象是训练前几十个迭代loss剧烈震荡,偶尔出现NaN。查了学习率、梯度裁剪,都不是。后来打印了每层的统计量,发现主干浅层的特征方差在batch=2的时候波动极大。
根因是归一化层在batch尺寸很小的时候,统计量估计不准。这在车端训练里太常见了,因为体素任务的显存开销大,batch开到4就算奢侈。解决方案有几个:一是换成对batch不敏感的归一化方式,代价是收敛稍慢;二是用梯度累积模拟大batch,但要注意归一化的统计量仍然是小batch算的,只能缓解不能根治;三是冻结浅层的归一化统计量,用预训练权重。
我最终选的是梯度累积加分组归一化的组合,batch实际是2,累积4次后等效batch 8。这个组合的代价是训练时间长了大概30%,但换来的是稳定收敛。顺便说一句,梯度累积时要记得同步调整学习率,否则等效batch变了但学习率没变,收敛会明显变慢。
5.4 精度掉点之后我用的排查顺序
经过上面几次之后,我总结出一套固定的排查顺序,遇到“精度不对”的情况按这个流程走,能把大部分问题在半天内定位:
| 步骤 | 检查项 | 典型症状 | 耗时 |
|---|---|---|---|
| 1 | 单样本过拟合 | 一个样本训到loss接近0,但可视化不对,说明前向链路有问题 | 30分钟 |
| 2 | 标签可视化叠加图像 | 标签跟图像里的物体对不上,说明坐标或内参有问题 | 20分钟 |
| 3 | 关闭全部数据增强 | 关掉增强精度回升,说明增强和标签不一致 | 15分钟 |
| 4 | 检查有效体素mask | mask全1或者全0,说明视野范围配置错了 | 10分钟 |
| 5 | 逐层打印特征统计量 | 某层方差爆炸或为0,说明归一化或初始化有问题 | 1小时 |
| 6 | 对比训练/验证的数据分布 | 类别比例差异大,说明划分有偏 | 1小时 |
第1步最关键,因为它能把“链路问题”和“训练问题”彻底分开。一个健康的模型,给它一个样本训上几百次,loss应该能压到接近0,可视化的结果应该跟标签几乎一致。如果连这一步都过不了,说明网络结构或者前向逻辑本身有bug,后面所有调参都是白费。
6. 部署侧的实测与那些导出之后才出现的问题
训练好只是第一步,真正让我掉头发的是导出到推理后端之后。这一节讲三个问题,都是我在实测中遇到的,而且都不太容易在文档里找到答案。
6.1 固定shape带来的连锁反应
大部分推理后端对动态shape的支持都不好,尤其是涉及大量索引操作的投影层。我的做法是全部固定shape:输入图像固定分辨率、体素网格固定、采样点数量固定。听起来简单,但连锁反应不少。
第一个反应是图像预处理的适配。车端相机的原始分辨率跟模型输入往往不一致,需要做缩放和裁剪。如果缩放策略在训练和部署时不一致,精度会直接崩。我的做法是把缩放和裁剪的参数写进模型配置,训练和部署共用同一份配置,避免两边各写一套。
第二个反应是相机数量固定。六路就是六路,少一路或者多一路都要重新导出模型。这在开发阶段很烦,因为你要为不同的车型准备多个模型文件。缓解办法是把“缺少相机”的情况用全零输入填充,配合一个相机有效性掩码,让模型学会忽略这些输入。这个做法能让一个模型覆盖多种相机配置,代价是多了一点点计算。
6.2 算子融合与半精度的实际收益
半精度和算子融合是部署侧性价比最高的两个优化,但收益取决于网络结构。我的实测数据(同一个中等规模配置)是:全精度基线18.6毫秒,切半精度后11.2毫秒,加上算子融合后9.4毫秒,再把投影层的索引查表改成预分配后8.1毫秒。
这几个数字里,半精度的收益最大,接近40%,但要注意它会带来精度损失。占用预测对半精度还是比较宽容的,我实测logits的偏差在1e-3量级,argmax的结果基本不变。不过我建议做半精度转换后一定要跑一遍全量验证集,因为个别样本可能在决策边界上翻转,整体mIoU看不出问题但局部会出错误。
算子融合的收益主要来自投影层和解码头的连续卷积。这两处的特点是“小算子密集”,融合之后能减少中间张量的读写。这里有个坑:有些融合会自动把归一化层折进卷积,但如果你的归一化是在通道维折叠之后才做的,融合可能会算错。我的做法是融合之后逐层比对输出,误差超过1e-4就关掉这一层的融合。
6.3 量化之后的argmax抖动
INT8量化是我最谨慎的一步。占用预测的输出是逐体素的logits,量化误差会直接体现在分类边界上。我遇到的现象是:整体mIoU只掉了0.6个点,看起来能接受,但把两帧结果做差分后发现,大约有3%的体素在相邻区域反复翻转,形成闪烁。这种闪烁在视频流里非常明显,下游如果做时序滤波,会引入额外的延迟。
根因是分类边界的体素本身logits就很接近,量化误差一扰动就翻过去了。解决办法有三条:一是对输出层做逐通道量化,而不是逐张量;二是对最终输出的logits做温度平滑,让决策边界更宽;三是保留最后一层卷积为浮点。我最后用的是第二条加第三条的组合,成本可控,闪烁降到0.8%以下。
顺便说一个反直觉的结论:量化对mIoU的影响跟体素分辨率强相关。分辨率越细,单个体素的误判对整体指标影响越小,但对可视化的影响越明显。所以在细分辨率配置下,指标好看不代表能上线,一定要看时序一致性。
6.4 一份可以直接对照的部署检查清单
把上面这些整理成清单,按顺序走能省下很多来回:
- 输入预处理参数是否与训练完全一致(包括归一化的均值方差、裁剪的位置)
- 相机数量、内外参是否在导出时固化,切换配置是否需要重新导出
- 投影层的索引表是否预分配,运行时有无动态分配
- 半精度转换后是否跑过全量验证集,逐层误差是否在容限内
- 量化后是否检查过相邻帧的时序一致性,而不只是单帧mIoU
- 峰值显存是否留了20%余量,车端芯片的显存往往是共享的
- 是否有降级策略:当延迟超标时能否跳过某些相机或降低网格分辨率
最后一条经常被忽略但非常重要。实车运行时的负载是动态的,如果感知模块没有降级路径,一旦系统整体压力上来,就会雪崩式地掉帧。我的做法是准备两套配置:全量配置和降级配置,降级配置砍掉两路侧后方相机、网格分辨率从0.4米降到0.6米,延迟能降一半,精度掉大概4个点,作为兜底完全够用。
7. 这套方案适合谁、边界在哪、之后还能往上加什么
FastOcc这类方案的本质是“用结构先验换计算量”:它假设高度维的相关性弱、假设多相机视野重叠可以归一化、假设语义粒度可以按需裁剪。这些假设在城区中低速场景成立得很好,但在一些特定场景里会失效,用之前最好心里有数。
第一个边界是远距离。0.4米的分辨率在40米外已经很难区分行人和电线杆,如果你的应用场景需要百米级的感知,这套网格配置撑不住,要么放大幅度要么改用非均匀网格。非均匀网格是个值得做的方向,远处粗、近处细,实现上相当于在高度和平面两个方向都做分级,但会显著增加索引构建的复杂度。
第二个边界是快速运动的场景。预计算的采样索引假设相机标定在短时间内不变,但车辆高速过坎时,相机会有瞬时的姿态变化,索引对不上就会产生投影偏移。缓解办法是在标定里加入在线补偿,用悬挂或者惯导的数据做微调,这个补偿的计算量很小但需要跟底盘打通。
第三个边界是极端天气。雨雾条件下相机的有效视野大幅缩短,深度估计失效,占用预测的结果会变得极不可靠。这种情况下更稳妥的做法是降低对视觉占用的置信度,转而依赖其他传感器。这不是FastOcc本身的问题,是纯视觉方案的共性局限,做方案选型时要想清楚。
如果往后要继续加东西,我个人最看好的两个方向:一是把时序信息用起来,用前一帧的体素特征做先验,能在不增加太多计算的前提下明显提升稳定性,尤其是远处区域;二是把占用的输出跟可行驶区域、地面高度联合建模,让网络共享几何理解,比单独训两个头要省得多。这两个方向我都在试,时序那块的收益已经比较明确了,联合建模还在调。
最后分享一个小技巧:调试占用预测模型时,别一上来就看整体的mIoU,先看地面高度估计的误差。地面是体素量最大、最容易收敛的部分,如果连地面的高度都错了半个体素,那整个投影链路一定有系统性的偏移,后面所有的精度问题都是它的派生结果。这个指标我一般放在监控面板最显眼的位置,比任何loss曲线都直观。