自动驾驶感知后处理里的 Occupancy 环节,是这两年被讨论得越来越多的一块。前几年大家聊感知,焦点基本都在检测框和分割掩码上,直到 Occupancy Network 这类范式出来,把"场景里每个体素被占没被占"变成一等公民,后处理才真正被推到台前。我接触这块是从一个模拟项目开始的,当时模型输出的 occupancy 栅格噪声大得离谱,直接拿去做规划根本没法用,于是硬着头皮把后处理这一整套流程从头捋了一遍。这篇就聊聊 Occupancy 后处理到底在做什么、为什么难、怎么落地,适合刚接触占用栅格感知的算法同学,也适合做下游规划、想搞清楚上游输出质量边界的朋友。核心关键词会围绕占用栅格、体素、后处理、连通域、栅格滤波这些展开,尽量把每个环节的"为什么"讲透,而不是只丢一堆参数。
1. 先搞清楚 Occupancy 后处理到底在解决什么问题
1.1 从模型原始输出说起:它为什么不能直接用
Occupancy Network 这类模型的典型输出,是一个三维体素栅格,每个体素带一个占用概率或者占用 logits,有的还会附带语义类别、流场、特征向量。听起来很规整,但真正拿到手你会发现它离"能用"差着十万八千里。最直接的问题是概率图是稠密且带噪的:模型在物体边缘、远处稀疏区域、遮挡边界附近会给出大量模棱两可的中间概率,比如 0.4、0.55 这种,你按 0.5 一刀切,切出来的表面坑坑洼洼,像被虫蛀过一样。
第二个问题是孤立噪点。体素空间里经常冒出一些和主体不相连的零星占用体素,可能是模型对反光、雨雾、稀疏点云的误判。这些点如果直接喂给规划,规划器会把它当成真实障碍物,轻则绕路,重则急刹。第三个问题是空洞和断裂:真实物体内部本该是实心的,模型却可能因为观测不足在中间留个洞;或者一个连续墙面被切成好几段,连通性被破坏。
所以后处理的第一层使命很朴素:把一张"毛坯概率图"打磨成"结构干净、连通合理、语义一致"的占用栅格。它不是锦上添花,而是决定这套感知能不能进下游的关键闸门。我见过不少团队模型指标刷得很漂亮,但后处理没做扎实,实车一跑全是幽灵障碍,最后返工的还是后处理。
1.2 后处理在整个链路里的位置与边界
得先把边界划清楚。Occupancy 后处理处在"模型推理输出"和"下游建图/规划/碰撞检测"之间,它的输入是体素级的概率或 logits,输出应该是干净的占用体素集合,通常还会带上语义标签和置信度。它不负责提升模型的原始召回,也不该去凭空补出模型完全没看到的结构——那是模型和时序融合的活。后处理能做的是:去噪、补洞、连通、平滑、裁剪感兴趣区域、做时序一致性维护。
这个边界很重要,因为很多新手一上来就想用后处理"救"模型的漏检,结果越补越错,把真实空隙填成障碍,规划直接卡死。我的经验是:后处理只做"基于已有证据的合理整理",凡是需要"无中生有"的操作,都要极度克制,宁可留空也不要乱填。下面这张表是我梳理的职责划分,实际项目里拿它对齐上下游预期特别管用。
| 环节 | 该做的事 | 不该做的事 |
|---|---|---|
| 模型推理 | 输出体素概率、语义、特征 | 保证结构干净 |
| 后处理 | 去噪、连通、补洞、平滑、裁剪、时序维护 | 凭空补全未观测区域 |
| 下游规划 | 基于占用做碰撞检测与路径搜索 | 承担去噪职责 |
1.3 适合谁来读、需要什么前置知识
这篇面向的是已经大致知道体素、栅格、占用概率这些概念的同学,最好动手跑过一点三维数据处理。如果你完全没接触过,也不影响理解整体思路,但涉及连通域、形态学这些操作时,建议先补一下三维图像处理的基础。代码层面我会用 Python 加 NumPy 为主,偶尔提一下 SciPy 的 ndimage,因为这套组合上手快、调试直观,做原型足够;真要上量产再考虑换成更高效的实现。整篇的基调是"能抄作业",每个环节我都会给出可运行的思路和参数选择的理由,而不是停留在概念层面。
2. 核心思路拆解:为什么是这套处理顺序
2.1 处理流水线的整体设计逻辑
后处理不是一堆操作的随意堆叠,顺序错了效果会差很多。我最终稳定下来的流水线大致是:阈值化 → 感兴趣区域裁剪 → 体素级去噪 → 连通域分析 → 补洞 → 表面平滑 → 时序融合 → 输出结构化结果。这个顺序背后有明确的因果链,我逐个说理由。
先阈值化再裁剪,是因为裁剪本身要先知道哪些体素是"候选占用",在概率图上裁剪没有意义;而且先裁剪能大幅减少后续操作的体素数量,三维形态学在稠密栅格上是很吃算力的,先砍掉无关区域能省一大截时间。去噪放在连通域之前,是因为孤立噪点会干扰连通域统计——你统计出来的连通域数量里混着一堆单个体素的噪声块,阈值就不好定。连通域之后再补洞,是因为补洞需要知道"洞"是被谁包围的,只有先确定了主体连通块,才能判断某个空腔是不是被同一个物体包住的内部空洞。平滑放在补洞之后,避免平滑把刚补上的结构又抹掉。时序融合放最后,是因为它需要当前帧已经整理干净的占用结果作为输入。
提示:这个顺序不是唯一解,但每一步的依赖关系是硬的。你可以合并某些步骤,但不要颠倒"先确定主体、再修补细节"这个大原则。
2.2 阈值策略:固定阈值为什么经常翻车
最直觉的做法是概率大于 0.5 就算占用。实测下来这个做法在近处表现还行,远处和边缘就崩。原因是模型输出的概率分布并不是校准良好的,不同距离、不同语义类别的置信度分布差异很大。远处体素观测稀疏,概率普遍偏低,固定 0.5 会把远处真实障碍整片漏掉;而近处反光地面又容易给出虚高概率。
我试过几种改进。第一种是分类别阈值:地面、墙面、车辆、行人各自用不同阈值,因为它们的观测特性和误判模式不一样。第二种是基于距离的自适应阈值:距离越远阈值适当放低,补偿稀疏观测带来的置信度衰减。第三种是双阈值滞回:高阈值确定"铁定占用",低阈值确定"候选占用",再用连通性把候选里和铁定占用相连的拉进来。滞回这招在抑制边缘抖动上效果明显,代价是要多存一份候选掩码。
参数上,我一般把高阈值设在 0.6 到 0.7,低阈值设在 0.3 到 0.4,中间地带交给连通性裁决。这个区间不是拍脑袋,是我在几个模拟场景里扫参数扫出来的经验带:高阈值再往上,召回掉得厉害;低阈值再往下,噪声涌进来。具体数值一定要在你的数据上重新扫,因为模型校准程度不同,最优带会平移。
2.3 为什么用连通域而不是简单的中值滤波
有人会问,去噪用三维中值滤波不就行了,何必搞连通域。中值滤波确实能压掉椒盐噪声,但它对"成块的孤立噪声"无能为力——一个 3x3x3 的噪声团,中值滤波照样保留。而连通域分析能直接从"结构是否与主体相连"这个维度判断,把整块孤立噪声一锅端,这是中值滤波做不到的。
连通域的核心参数是最小连通体素数。小于这个数的连通块直接丢弃。这个阈值怎么定?取决于你关心的最小真实物体。比如你希望至少能保留一个行人在远处的占用,那就要估算行人在最远关注距离处大概占多少体素。假设体素分辨率 0.2 米,一个行人粗略 0.6x0.6x1.8 米,那大约占 3x3x9 个体素,考虑远处观测不全,打个对折也有几十个。所以最小连通体素数设在 20 到 50 之间比较合理,设太小噪声去不干净,设太大远处小目标被误删。这个计算过程我建议每个人都自己走一遍,因为体素分辨率一变,结论就变。
2.4 补洞与平滑的取舍:别把证据抹掉
补洞的诱惑很大,看到空洞就想填。但我踩过的坑是:把真实空隙填成障碍。比如两辆车之间的缝隙、栏杆之间的空隙,如果补洞算法太激进,会把它们连成一片实心墙,规划器直接判定不可通行。所以补洞必须限定在"被单一连通块完全包围的内部空腔",而且空腔体积要小于某个上限。实现上可以用连通域对"空"体素做标记,只有不连通到栅格边界的空腔才算内部洞,这个技巧叫背景连通标记,非常实用。
平滑同理。三维形态学的开运算能去掉毛刺,闭运算能填小缝,但开运算会削掉细小真实结构(比如细杆、天线),闭运算会粘连邻近物体。我的做法是平滑只作用在表面一层体素上,且用较小的结构元素(3x3x3),并且平滑前后做一次体积变化检查,体积变化超过阈值就回退。这个"改完要验"的习惯,帮我避免了好几次把场景改坏的事故。
3. 实操落地:从概率图到干净占用栅格的完整流程
3.1 环境与数据准备
原型阶段我用 Python 3.10 加 NumPy、SciPy,可视化用 Open3D 或者直接 matplotlib 切片看。数据方面,你需要一份体素概率张量,形状通常是(D, H, W)或者(X, Y, Z),外加可选的语义标签张量。如果没有真实模型输出,可以自己造一个带噪声的模拟栅格来验证流程,我一开始就是这么干的——造一个球体加几个立方体,再撒上随机噪点和随机空洞,专门用来调试后处理参数。
import numpy as np from scipy import ndimage # 模拟:一个带噪声和空洞的占用概率体素栅格 rng = np.random.default_rng(42) shape = (64, 64, 64) prob = np.zeros(shape, dtype=np.float32) # 造一个球体作为主体 xx, yy, zz = np.mgrid[0:64, 0:64, 0:64] sphere = ((xx-32)**2 + (yy-32)**2 + (zz-32)**2) < 12**2 prob[sphere] = 0.9 # 撒噪点 noise_mask = rng.random(shape) > 0.995 prob[noise_mask] = rng.random(noise_mask.sum()) * 0.8 # 挖几个空洞模拟观测缺失 for _ in range(5): cx, cy, cz = rng.integers(20, 44, 3) hole = ((xx-cx)**2 + (yy-cy)**2 + (zz-cz)**2) < 3**2 prob[hole] = 0.1这段造数据的代码看着简单,但它能覆盖后处理最常遇到的几类问题:边缘模糊、孤立噪点、内部空洞。拿它当回归测试集,每次改参数都跑一遍看输出,比在真实数据上盲调高效得多。
3.2 阈值化与感兴趣区域裁剪的实现
阈值化我推荐用双阈值加滞回。先算高阈值掩码和低阈值掩码,然后对低阈值掩码做连通域,凡是连通块里含有高阈值体素的,整块保留。这样既保住了弱证据连成的结构,又过滤了纯噪声。
def hysteresis_threshold(prob, high=0.65, low=0.35): high_mask = prob >= high low_mask = prob >= low # 对低阈值掩码做连通标记 structure = np.ones((3, 3, 3), dtype=bool) labeled, num = ndimage.label(low_mask, structure=structure) keep = np.zeros(num + 1, dtype=bool) # 找出包含高阈值体素的连通块 hit_labels = np.unique(labeled[high_mask]) keep[hit_labels] = True keep[0] = False return keep[labeled]感兴趣区域裁剪这块,实际项目里通常只关心自车周围一定范围,比如前后各 40 米、左右各 20 米、高度 0 到 4 米。裁剪不只是省算力,更重要的是把远处那些观测极稀疏、误判率极高的区域排除掉,避免它们污染连通域统计。裁剪边界建议留一点余量,别卡得死死的,否则物体刚好在边界上会被切断,反而制造出假边缘。
3.3 体素级去噪与连通域过滤
去噪我分两步走。第一步用三维中值滤波压椒盐噪声,核大小 3;第二步用连通域按体积过滤。中值滤波放在连通域之前,是因为它能把一些即将形成孤立小块的噪点先打散,减少连通域的数量,让后面的体积阈值更好定。
def denoise_and_filter(mask, min_voxels=30): # 中值滤波去椒盐 filtered = ndimage.median_filter(mask.astype(np.uint8), size=3).astype(bool) # 连通域体积过滤 structure = np.ones((3, 3, 3), dtype=bool) labeled, num = ndimage.label(filtered, structure=structure) sizes = ndimage.sum(filtered, labeled, range(1, num + 1)) keep_labels = np.where(sizes >= min_voxels)[0] + 1 clean = np.isin(labeled, keep_labels) return clean这里有个细节值得说:structure用全 1 的 3x3x3 意味着 26 邻接,连通性判定更宽松,适合物体表面有细微断裂的场景;如果你发现不同物体被错误地连成一块,可以改成 6 邻接(只认面相邻),连通更严格。这个选择直接影响连通域数量和后续补洞行为,值得单独调。
3.4 内部空洞填补与表面平滑
补洞用背景连通标记法。思路是把"空"体素做连通域,凡是连通到栅格边界的空腔是"外部背景",不连通的才是"内部洞",只填内部洞,且限制洞的体积上限。
def fill_internal_holes(mask, max_hole_voxels=200): empty = ~mask structure = np.ones((3, 3, 3), dtype=bool) labeled, num = ndimage.label(empty, structure=structure) # 找出接触边界的标签(外部背景) border_labels = set() for axis in range(3): border_labels.update(np.unique(labeled.take(0, axis=axis))) border_labels.update(np.unique(labeled.take(-1, axis=axis))) filled = mask.copy() for lbl in range(1, num + 1): if lbl in border_labels: continue hole = labeled == lbl if hole.sum() <= max_hole_voxels: filled[hole] = True return filled平滑我只在表面做,用 3x3x3 的开运算去毛刺,然后做体积变化检查。体积变化超过 5% 就回退到平滑前,这个保险丝很关键,我靠它拦下过好几次过度平滑。
3.5 时序融合与输出结构化
单帧再干净,帧间抖动也会让下游难受。时序融合最简单的做法是维护一个衰减的占用置信度体素,当前帧结果按权重融合进去,再重新阈值化。权重一般当前帧给 0.6 到 0.7,历史给剩下的,衰减系数控制历史记忆长度。要注意自车运动带来的坐标系变换,融合前必须把历史栅格对齐到当前自车坐标系,否则融合出来全是拖影。
输出结构化时,我建议除了占用掩码,再输出每个连通块的包围盒、体素数、平均置信度、语义众数。下游规划拿包围盒做粗筛,拿体素做精检,比只给一张掩码好用得多。这一步经常被忽略,但它是后处理和下游之间的"接口契约",定好了能省大量沟通成本。
4. 常见问题与排查技巧实录
4.1 幽灵障碍反复出现怎么查
幽灵障碍是最高频的问题。排查顺序我总结成:先看是单帧就有还是时序融合后才出现。单帧就有,多半是阈值太低或去噪不够,重点查低阈值区域和最小连通体素数;时序融合后才出现,八成是坐标系对齐有误或者历史权重给太高,导致旧帧的误判被"记住"了。我遇到过一次,融合后总在固定位置出现障碍,最后发现是历史栅格对齐时漏了一个平移分量,物体被复制到了错误位置。这类问题用可视化逐帧对比最快,别硬看数字。
4.2 远处小目标被误删的处理
远处小目标被连通域体积阈值误删,是另一个典型矛盾。我的处理是分区域用不同阈值:近处用大一点的体积阈值,远处用小阈值,因为远处体素本来就少。同时配合距离自适应阈值,远处降低占用阈值保住召回。如果还不行,可以考虑对远处单独做一次不做体积过滤的"保底通道",把高置信度的小连通块强行保留。这个保底通道要谨慎,容易放噪声进来,建议只在远距离区域开。
4.3 补洞把真实缝隙填死怎么办
这就是前面强调的"补洞要克制"。除了限制只填内部洞、限制洞体积,还可以加一条:洞的长宽比检查。真实缝隙往往细长,内部空洞往往接近球形。算一下洞的包围盒长宽比,超过阈值(比如 3:1)就判定为缝隙不填。这招对栏杆、车缝特别有效。另外补洞后一定要做一次"可通行性抽查",挑几个已知该通的缝隙看有没有被填,形成回归用例。
4.4 性能扛不住时的优化方向
三维形态学和连通域在稠密栅格上很吃资源。优化优先级我建议:先做感兴趣区域裁剪,这是收益最大的;再把体素分辨率在远处降采样,近处保持精细;然后考虑把连通域换成基于并查集的增量实现;最后才是上多线程或 GPU。别一上来就想着并行化,先把算法层面的冗余砍掉,往往能省一半以上时间。下面这张速查表是我平时排查用的,直接对着查能省不少时间。
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 幽灵障碍 | 阈值低/去噪弱/时序对齐错 | 分单帧与时序对比 |
| 远处漏检 | 体积阈值大/固定阈值 | 分区域阈值+保底通道 |
| 缝隙被填 | 补洞过激 | 加长宽比检查 |
| 帧间抖动 | 无时序融合/权重不当 | 调融合权重与衰减 |
| 运行慢 | 未裁剪/分辨率过高 | 先裁剪再降采样 |
4.5 参数调试的实操心得
最后分享几个调参上的实在经验。第一,永远先固定其他参数,只动一个,三维后处理参数之间耦合很强,一起动你根本不知道是谁的功劳。第二,建立小规模回归集,十几帧覆盖典型场景(近处、远处、缝隙、空洞、噪声),每次改参数全跑一遍看指标,比凭感觉靠谱。第三,把中间结果都存下来,阈值后、去噪后、补洞后各存一份,出问题能快速定位是哪一步引入的。第四,别迷信一套参数打天下,不同场景(地下车库、开阔道路、雨雾天)最优参数带不一样,条件允许就做场景自适应。我个人在实际操作中的体会是,后处理这活儿七分靠对数据的理解、三分靠算法,把数据看透了,参数自然就有方向了。