1. 从一张“糊成粥”的栅格图说起:Occupancy 后处理到底在解决什么问题
做过三维感知或者栅格建图的朋友,大概率都见过这样的画面:模型输出的 occupancy 栅格图边缘毛刺横生,地面和墙面糊成一片,远处稀疏近处冗余,同一块区域在相邻帧之间反复闪烁。你盯着这张图,明明知道网络已经尽力了,可下游的规划模块就是不敢用——因为它分不清哪里是“真的障碍”,哪里只是“模型不确定”。Occupancy 后处理,说白了就是在这张原始栅格图和下游可用结果之间,架一道“精修工序”,把粗糙的、带噪声的、时序不一致的占据栅格,收拾成干净、稳定、可被规划器信任的结构化表达。
这个工序听起来像是“锦上添花”,实际却是整个感知链路里最容易被低估、也最容易翻车的一环。原始 occupancy 输出通常是一个三维或二维的体素/栅格张量,每个格子给一个占据概率或者 logits,模型训练时用的是交叉熵或者 focal loss,它优化的是“逐格分类准确率”,而不是“这块区域作为一个整体是否可通行”。这两者之间的鸿沟,就是后处理要填的坑。举个生活化的类比:模型像是一个刚学会涂色的孩子,每个格子该涂黑还是留白他大概知道,但涂出来的边界是锯齿状的,颜色深浅也不均匀;后处理就是拿橡皮和尺子,把边界修直、把杂色擦掉、把该连的连起来。
适合读这篇内容的人,我大致分三类。第一类是刚接触 occupancy 任务、模型能跑通但结果没法看的同学,你需要一套能直接抄作业的后处理流水线;第二类是做下游规划控制、被上游栅格噪声折磨过的工程师,你想知道这些噪声到底能不能在上游被压下去;第三类是已经在做后处理、但效果时好时坏、想系统梳理参数和排查思路的人。不管你属于哪一类,核心诉求是一致的:让 occupancy 从“能看”变成“能用”。
我先把结论性的框架摆出来,后面再逐层拆。一套完整的 occupancy 后处理,通常包含五个层次:概率校准与阈值决策、空间滤波与形态学修整、连通域分析与碎片清理、时序融合与闪烁抑制、结构化输出与下游接口适配。这五层不是必须全上,但每一层解决的是不同性质的噪声,跳过任何一层,你都得在别的地方用更别扭的方式补回来。下面我按“为什么这么设计—关键细节怎么抠—完整流程怎么跑—出问题怎么查”的顺序,把这套东西讲透。
2. 整体设计与思路拆解:为什么后处理不能“一把阈值走天下”
2.1 原始 occupancy 输出的三个先天缺陷
在动手写任何后处理代码之前,得先搞清楚原始输出到底“病”在哪。我把实际项目里反复出现的缺陷归成三类,这三类对应三种完全不同的处理手段,混为一谈是新手最常见的错误。
第一类是概率语义漂移。模型输出的 0.5 并不真的等于“五五开”。由于训练数据里正负样本极度不均衡(空旷区域远多于障碍),模型往往整体偏向保守或激进,导致同一个阈值在不同场景下表现差异巨大。你在室内走廊调好的 0.4 阈值,搬到室外停车场可能满屏都是假障碍。这不是模型坏了,是概率没有经过校准。
第二类是空间高频噪声。表现为孤立的单格噪点、边缘的锯齿毛刺、以及“盐椒噪声”式的随机翻转。这类噪声的本质是模型在局部感受野内没有足够证据做决策,输出在阈值附近抖动。形态学滤波和连通域分析就是冲它来的。
第三类是时序不一致。同一堵墙,这一帧在,下一帧缺一块,再下一帧又冒出来。对规划器来说,这种闪烁比稳定的错误更致命——它会触发频繁的急刹和重规划。这类问题单帧后处理解决不了,必须引入时序维度的融合。
注意:很多人一上来就调形态学的核大小,结果把该保留的细障碍(比如细杆、栏杆)一起腐蚀没了。先分清噪声类型,再选工具,顺序反了会白干。
2.2 五层流水线的设计逻辑与取舍
为什么是这五层,而不是三层或者八层?每一层的存在都有明确的“职责边界”,层与层之间尽量解耦,这样出问题时你能快速定位是哪一层引入的副作用。
概率校准层负责把“模型打分”翻译成“可信的占据判断”,它决定了后续所有空间操作的输入质量。空间滤波层负责压制高频噪声、修整边界,它是最“暴力”的一层,也是最容易误伤的一层。连通域层负责从“一堆格子”上升到“一个个物体”,把小于物理合理尺寸的碎片判为噪声。时序融合层负责跨帧一致性,用历史信息给当前帧“投票”。结构化输出层负责把栅格转成下游能吃的格式,比如包围盒、多边形、可通行区域掩码。
这个顺序不能随便调。我见过有人把连通域分析放在形态学之前,结果因为边缘毛刺把两个本该分开的障碍连成一片,连通域直接失效。也见过有人先做时序融合再做空间滤波,导致历史帧的噪声被反复“固化”进融合结果,越融越脏。正确的顺序遵循一个原则:先修正单帧的“值”,再修正单帧的“形”,再修正单帧的“块”,最后修正跨帧的“稳”。值不对,形和块都是空中楼阁。
2.3 一个必须提前想清楚的问题:后处理的目标是“准”还是“稳”
这是设计阶段最容易被忽略、却直接决定参数取向的问题。如果你的下游是离线标注或者高精度建图,那目标是“准”,宁可保留不确定也不要把真障碍滤掉,阈值偏保守、形态学偏轻。如果你的下游是实时规划避障,那目标是“稳”,宁可把一些模糊区域判为障碍让车绕一下,也不能让真障碍时隐时现。这两个目标在参数上是打架的。
我的做法是在配置里显式区分两种 profile:precision_mode和safety_mode。前者阈值取校准后的 0.5 附近、形态学核取最小、连通域面积下限取物理最小障碍的投影面积;后者阈值下调 0.1 到 0.15、形态学只做闭运算不做开运算、连通域下限放宽。这个区分看起来简单,但它逼着你在写代码前就想清楚“这套后处理是给谁用的”,避免后期反复推倒重来。
3. 核心细节解析与实操要点:每一层到底怎么抠
3.1 概率校准:让 0.5 真的意味着五五开
概率校准这一步,很多团队直接跳过,觉得“反正后面有阈值,校准不校准差不多”。实测下来差别很大,尤其是在跨场景部署时。校准的目标是让模型输出的概率分布和真实占据频率对齐。常用的方法有两种:温度缩放(Temperature Scaling)和直方图分箱校准(Histogram Binning)。
温度缩放最简单,在验证集上拟合一个标量温度 T,对 logits 做logits / T再过 sigmoid。它只有一个参数,不容易过拟合,适合数据量中等的情况。直方图分箱更灵活,把概率空间分成若干区间,每个区间单独拟合一个映射,适合分布形状比较怪的情况,但需要足够多的验证样本,否则区间内统计不稳。
实操上我一般这样做:从验证集里按场景分层采样,保证室内、室外、近距离、远距离都有覆盖,然后分别拟合,看不同场景的最优温度是否一致。如果不一致,说明模型存在场景相关的系统性偏差,这时候要么分场景校准,要么在训练侧补数据,光靠后处理压不住。
import numpy as np def temperature_scale(logits, T): # logits: 模型原始输出,未过 sigmoid return 1.0 / (1.0 + np.exp(-logits / T)) def fit_temperature(logits_val, labels_val, T_grid=None): if T_grid is None: T_grid = np.linspace(0.5, 3.0, 51) best_T, best_nll = 1.0, float('inf') for T in T_grid: p = temperature_scale(logits_val, T) p = np.clip(p, 1e-6, 1 - 1e-6) nll = -np.mean(labels_val * np.log(p) + (1 - labels_val) * np.log(1 - p)) if nll < best_nll: best_nll, best_T = nll, T return best_T校准完之后,阈值决策就有了依据。我的经验是:校准良好的模型,安全模式阈值取 0.35 到 0.45,精度模式取 0.5 到 0.6。如果校准后发现最优阈值离 0.5 特别远(比如 0.2 或 0.8),那多半是训练数据标签有系统性问题,值得回头查一查。
3.2 空间滤波:形态学不是万能锤,核形状比核大小更重要
形态学操作(腐蚀、膨胀、开运算、闭运算)是 occupancy 后处理里用得最多、也最容易被滥用的工具。新手常犯的错是“噪声多就加大核”,结果把细结构全抹平了。这里的关键认知是:核的形状决定了你保留什么方向的结构。
举个具体例子。室内场景里墙是竖直的、地面是水平的,如果你用一个 3x3 的方形核做开运算,竖直细墙和水平地面会被同等对待,墙的厚度被削、地面的边缘被磨,两边都不讨好。更好的做法是用方向性核:对竖直结构用 1x3 的竖条核,对水平结构用 3x1 的横条核,分别处理再合并。这样能在压制噪声的同时保住主要结构的方向性。
另一个细节是先开后闭还是先闭后开。开运算(先腐蚀后膨胀)擅长去掉孤立小噪点,闭运算(先膨胀后腐蚀)擅长填补小空洞。如果你的噪声主要是“多出来的点”,先开;如果是“缺掉的小洞”,先闭。两个都做的话,顺序会影响最终形态——先开再闭倾向于“干净但略瘦”,先闭再开倾向于“饱满但可能残留”。我一般对安全模式用先闭后开(宁可饱满),对精度模式用先开后闭(宁可干净)。
import numpy as np from scipy import ndimage def directional_morphology(grid, axis='vertical', op='open', k=3): # grid: 2D 二值占据图 if axis == 'vertical': struct = np.ones((k, 1), dtype=bool) else: struct = np.ones((1, k), dtype=bool) if op == 'open': return ndimage.binary_opening(grid, structure=struct) elif op == 'close': return ndimage.binary_closing(grid, structure=struct) return grid def spatial_filter(grid, mode='safety'): if mode == 'safety': g = directional_morphology(grid, 'vertical', 'close', 3) g = directional_morphology(g, 'horizontal', 'close', 3) g = ndimage.binary_closing(g, structure=np.ones((3, 3))) else: g = directional_morphology(grid, 'vertical', 'open', 3) g = directional_morphology(g, 'horizontal', 'open', 3) g = ndimage.binary_opening(g, structure=np.ones((3, 3))) return g提示:形态学核大小和栅格分辨率强相关。如果你的栅格是 0.1 米一格,3x3 核相当于 0.3 米见方的操作范围,对细杆类障碍已经偏大了。核大小应该按“物理尺寸”而不是“格子数”来定,换分辨率时记得同步换算。
3.3 连通域分析:用物理常识给碎片“判死刑”
连通域分析的价值在于引入物理先验:一个真实障碍在栅格上的投影面积,不可能小于某个物理下限。比如一个行人,投影到地面至少也有 0.2 到 0.3 平方米;一根细杆虽然细,但高度方向是连续的。基于这个先验,我们可以把面积过小的连通块判为噪声直接剔除。
但这里有个坑:面积阈值不能一刀切。远处的小障碍在栅格上投影面积天然就小,如果你用一个固定的格子数阈值,近处的噪点可能因为膨胀而“变大”逃过一劫,远处的真障碍反而被误杀。正确做法是把面积阈值换算成物理面积,并且对距离做补偿——远处允许更小的物理面积下限,因为分辨率和遮挡导致的信息缺失是客观存在的。
from scipy import ndimage def remove_small_components(grid, min_area_m2, resolution): min_cells = max(1, int(min_area_m2 / (resolution ** 2))) labeled, num = ndimage.label(grid) if num == 0: return grid sizes = ndimage.sum(grid, labeled, range(1, num + 1)) keep = np.zeros(num + 1, dtype=bool) keep[1:] = sizes >= min_cells return keep[labeled]除了面积,连通域的形状也能用来判噪声。比如长宽比极端异常的细长条,如果它不对应已知的杆状结构,很可能是边缘毛刺连成的假连通块。我一般会额外算一下每个连通块的紧致度(面积除以凸包面积),紧致度低于某个值的块标记为“可疑”,在安全模式下保留、精度模式下剔除。这个策略比单纯看面积稳得多。
3.4 时序融合:用历史投票压住闪烁
时序融合是五层里最“重”的一层,也是收益最明显的一层。核心思想很简单:当前帧的每个格子,不只看它自己,还要看它过去若干帧的状态,用加权投票决定最终状态。但简单思想背后有几个必须抠的细节。
第一个细节是运动补偿。如果传感器在动,历史帧的栅格和当前帧不在同一个坐标系,直接融合会把静止障碍“拖影”。所以融合前必须用位姿把历史帧变换到当前帧坐标系。这一步的精度直接决定融合质量,位姿估计有偏差时,融合反而会制造重影。我的经验是:位姿平移误差超过半个格子、旋转误差超过 1 度时,宁可不融合或者降低历史权重。
第二个细节是权重衰减。历史帧不能和当前帧等权,越久远的信息越不可信。常用的做法是指数衰减:w_t = decay^(t - t0),decay 取 0.7 到 0.9。decay 太小,融合退化成单帧;太大,历史噪声固化。这个参数和帧率强相关,帧率高时可以取大一点,帧率低时取小一点。
第三个细节是占据与自由的非对称处理。障碍“出现”和“消失”的可信度是不一样的。一个格子从占据变自由,可能是障碍真的移走了,也可能是这一帧漏检;从自由变占据,可能是新障碍,也可能是噪声。安全导向下,我一般对“占据→自由”的转换更保守(需要连续多帧自由才确认),对“自由→占据”更敏感(一两帧占据就标记)。这个非对称策略能显著降低“障碍凭空消失”导致的危险。
class TemporalFusion: def __init__(self, decay=0.8, occ_thresh=0.5, free_confirm=3): self.decay = decay self.occ_thresh = occ_thresh self.free_confirm = free_confirm self.history = None self.free_count = None def update(self, current_prob, pose_delta=None): if self.history is None: self.history = current_prob.copy() self.free_count = np.zeros_like(current_prob, dtype=int) return current_prob > self.occ_thresh # pose_delta 非空时应先对 history 做坐标变换,此处省略具体变换 fused = self.decay * self.history + (1 - self.decay) * current_prob occ = fused > self.occ_thresh # 非对称:占据转自由需要连续确认 was_occ = self.history > self.occ_thresh now_free = ~occ self.free_count = np.where(was_occ & now_free, self.free_count + 1, 0) hold_occ = was_occ & (self.free_count < self.free_confirm) occ = occ | hold_occ self.history = np.where(occ, np.maximum(fused, self.occ_thresh), fused) return occ3.5 结构化输出:别让下游替你擦屁股
最后一层经常被草草了事,但它决定了后处理成果能不能被下游“无损接收”。栅格图对规划器不友好,规划器要的是几何图元:包围盒、多边形、可通行走廊。这一步做得好,下游能省一大堆解析逻辑;做得糙,前面四层的努力会在接口处打折扣。
我的做法是输出两套东西:一套是精修后的占据栅格,给需要原始栅格的模块用;一套是结构化障碍列表,每个障碍带包围盒、朝向、置信度、以及“首次观测帧”和“最后观测帧”的时间戳。时间戳特别有用,下游可以据此判断障碍是稳定的还是刚出现的,从而调整避让策略。另外,可通行区域建议单独输出一个掩码,并且把“未知区域”和“已知自由区域”分开标记——规划器对未知区域的处理策略通常和自由区域不同,混在一起会出问题。
4. 实操过程与核心环节实现:从原始张量到可用结果
4.1 环境与输入约定
先把输入输出的约定固定下来,后面所有步骤都基于这个约定。假设模型输出是一个形状为(H, W)的浮点张量,值域经过 sigmoid 后在 0 到 1 之间,代表占据概率;栅格分辨率为res米/格;同时有一份位姿序列用于时序融合。这个约定看起来琐碎,但实际项目里输入格式五花八门,先把约定写进配置,能省掉大量“这个张量到底是 logits 还是 prob”的扯皮。
from dataclasses import dataclass @dataclass class PostConfig: resolution: float = 0.1 # 米/格 temperature: float = 1.2 # 校准温度 occ_thresh_safety: float = 0.40 occ_thresh_precision: float = 0.55 min_area_m2: float = 0.15 # 最小障碍物理面积 temporal_decay: float = 0.8 free_confirm_frames: int = 3 mode: str = 'safety'4.2 完整流水线的串联实现
把前面几层串起来,形成一个可调用的函数。注意每一步之间打印中间统计量(占据格子数、连通块数、被剔除的碎片数),这些统计量是后面排查问题的第一手依据。
def occupancy_postprocess(prob_map, cfg: PostConfig, fusion: TemporalFusion = None): stats = {} # 1. 概率校准 prob = temperature_scale(np.log(prob_map / (1 - prob_map + 1e-9)), cfg.temperature) thresh = cfg.occ_thresh_safety if cfg.mode == 'safety' else cfg.occ_thresh_precision binary = prob > thresh stats['raw_occ_cells'] = int(binary.sum()) # 2. 空间滤波 filtered = spatial_filter(binary, mode=cfg.mode) stats['after_morph_cells'] = int(filtered.sum()) # 3. 连通域清理 cleaned = remove_small_components(filtered, cfg.min_area_m2, cfg.resolution) stats['after_component_cells'] = int(cleaned.sum()) # 4. 时序融合 if fusion is not None: cleaned = fusion.update(prob, pose_delta=None) stats['after_temporal_cells'] = int(cleaned.sum()) # 5. 结构化输出 labeled, num = ndimage.label(cleaned) obstacles = [] for i in range(1, num + 1): ys, xs = np.where(labeled == i) obstacles.append({ 'bbox': (xs.min(), ys.min(), xs.max(), ys.max()), 'area_m2': len(xs) * cfg.resolution ** 2, 'centroid': (xs.mean(), ys.mean()), }) stats['num_obstacles'] = len(obstacles) return cleaned, obstacles, stats这段代码可以直接跑,但有几个地方需要根据实际数据微调。temperature_scale里我用了 logit 反变换,是因为校准参数是在 logits 上拟合的;如果你的模型直接输出概率,这一步要相应调整。pose_delta在示例里传了 None,实际使用时必须接上位姿变换,否则时序融合在移动平台上会出重影。
4.3 参数标定的实操记录
参数不是拍脑袋定的,我一般按这个流程标定。先固定形态学和连通域参数为保守值(核 3、面积下限取物理最小障碍),只调阈值,在验证集上画 precision-recall 曲线,找到满足安全要求的工作点。然后固定阈值,调形态学核大小,观察细障碍(细杆、栏杆)的保留率和噪点清除率的权衡。最后调时序参数,用一段连续序列看闪烁频率的变化。
这里有个实测经验值得记下来:时序融合的收益在帧率高时非常明显,帧率低于 5Hz 时收益急剧下降。原因是低帧率下相邻帧之间场景变化大,历史信息的参考价值降低,衰减系数不得不调小,融合就退化了。如果你的系统帧率本来就低,与其硬上时序融合,不如把精力放在单帧的空间滤波和连通域上。
另一个实测记录是关于阈值和形态学的耦合。我发现当阈值调低(更保守)时,形态学的开运算可以适当加强,因为低阈值引入的噪点更多,需要更强的空间压制来平衡;反之阈值调高时,形态学要减弱,否则真障碍被双重压制。这个耦合关系没有解析公式,只能靠网格搜索加人工看效果,但知道它存在,能让你调参时少走弯路。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 典型问题速查表
| 现象 | 最可能的原因 | 排查方向 | 处理建议 |
|---|---|---|---|
| 细杆、栏杆类障碍消失 | 形态学核过大或开运算过强 | 检查核的物理尺寸是否超过细障碍宽度 | 改用方向性核,或对细结构单独保留 |
| 相邻障碍被连成一片 | 连通域在形态学之前做,或闭运算过强 | 检查处理顺序和闭运算核大小 | 调整顺序为先滤波后连通域,减小闭运算核 |
| 障碍时隐时现 | 时序融合缺失或衰减系数过小 | 检查融合是否启用、decay 取值 | 启用融合,decay 调到 0.7 以上 |
| 移动平台上出现拖影 | 时序融合未做运动补偿 | 检查位姿变换是否正确接入 | 接入位姿变换,位姿误差大时降低历史权重 |
| 远处障碍被误删 | 面积阈值未做距离补偿 | 检查面积阈值是否固定格子数 | 改为物理面积并做距离补偿 |
| 整片区域被误判为障碍 | 概率校准缺失,阈值偏低 | 检查校准温度和阈值工作点 | 先校准再定阈值,安全模式阈值不低于 0.35 |
| 融合后噪声越积越多 | 历史帧噪声被固化 | 检查融合前是否做了单帧清理 | 确保空间滤波和连通域在融合之前完成 |
5.2 三个踩过的坑与独家避坑技巧
第一个坑是校准和阈值的顺序搞反。早期我习惯先调阈值再校准,结果校准后最优阈值全变了,前面的调参白做。正确顺序一定是先校准概率、再定阈值,因为校准改变的是概率的语义,阈值是在语义确定之后才有意义的工作点。
第二个坑是连通域面积阈值用了格子数。换了一次栅格分辨率之后,同样的格子数阈值对应的物理面积翻了几倍,远处的行人全被当噪声删了,排查了大半天才反应过来。从那以后我所有和尺寸相关的参数一律用物理单位存储,代码里再换算成格子数,这个习惯救了我很多次。
第三个坑是时序融合没有区分“首次出现”和“持续存在”。早期版本里,新出现的障碍因为历史帧里没有,融合后概率被历史拉低,导致新障碍要好几帧才能被确认,反应迟钝。后来我给每个格子加了“首次观测帧”标记,新出现的格子直接跳过历史权重、以当前帧为准,确认速度立刻上来了。这个细节在安全场景里特别重要,因为突然出现的障碍恰恰是最危险的。
提示:排查 occupancy 后处理问题时,养成“逐层看中间结果”的习惯。把每一层输出的占据格子数、连通块数、可视化图都存下来,出问题时对比哪一层的统计量突变,比盯着最终结果瞎猜高效得多。
5.3 性能与实时性的权衡
后处理是实时链路的一环,性能不能不考虑。形态学和连通域在 numpy/scipy 下对中等分辨率栅格(比如 200x200)单帧耗时在毫秒级,问题不大;时序融合如果对全图做浮点运算,帧率高时可能成为瓶颈。我的优化手段是:融合只在“状态可能变化”的区域做,用当前帧和上一帧的差异掩码圈定更新范围,静止区域直接沿用历史结果。实测下来这个优化能把融合耗时压到原来的三分之一左右,而且对结果几乎没有影响,因为静止区域的融合结果本来就收敛了。
另一个性能细节是连通域分析的标签算法选择。scipy 的ndimage.label默认用四连通,如果你的障碍在斜对角相连的情况多,可能需要八连通,但八连通会把更多碎片连成大块,影响碎片清理效果。这个选择要和你的噪声特性匹配,没有绝对优劣。
6. 结构化输出与下游接口适配:让成果真正落地
6.1 包围盒、多边形与可通行掩码的取舍
结构化输出到底给什么格式,取决于下游吃什么。包围盒最通用,计算快,但对斜向障碍的贴合度差,会引入较多“虚假占据”;多边形贴合度高,但计算和传输成本大;可通行掩码最直接,规划器拿来就能用,但丢失了障碍的个体信息。我的实践是三样都给,按需取用:包围盒给需要快速粗筛的模块,多边形给需要精确避让的模块,可通行掩码给路径搜索模块。多输出一份数据的成本,远低于下游因为格式不对而自己写解析逻辑的成本。
这里有个容易被忽略的点:包围盒的朝向。轴对齐包围盒对斜向长条障碍会严重膨胀,一个 45 度摆放的窄障碍,轴对齐包围盒面积可能是真实面积的好几倍。如果下游用包围盒做碰撞检测,这种膨胀会导致过度保守。解决办法是对连通域做主成分分析,取主方向作为包围盒朝向,输出带朝向的旋转包围盒。这一步计算量不大,但对下游体验提升明显。
6.2 置信度与时间戳的传递
结构化输出里我最看重两个附加字段:置信度和时间戳。置信度不是简单的连通块平均概率,而是结合了面积、时序稳定性、以及历史观测次数的综合分。一个面积大、连续多帧稳定存在的障碍,置信度应该显著高于一个刚出现、面积小的障碍。下游可以据此做分级处理:高置信度障碍硬避让,低置信度障碍减速观察。
时间戳的价值前面提过,这里再强调一次:“最后观测帧”比“首次观测帧”更重要。一个障碍如果已经连续很多帧没被观测到,但还没达到删除条件,它的“最后观测帧”能让下游知道这个障碍可能已经消失但系统还在保守保留,从而在路径规划时留出余量而不是死等。这种细粒度的信息传递,是后处理从“能用”走向“好用”的关键。
6.3 接口稳定性与版本管理
后处理模块夹在感知和规划之间,它的输出格式一旦被下游依赖,改动成本就很高。我的经验是给输出结构加版本号,并且遵循“只增字段不删字段、不改字段语义”的原则。新增字段时给默认值,下游不关心可以忽略;确实要改语义时,新开一个字段名,旧字段保留一个过渡期。这个规矩听起来官僚,但实际项目里因为接口字段语义变更导致的联调事故,我见过不止一次,提前立规矩能省很多事。
另外,后处理的配置文件建议和模型版本绑定。不同的模型输出分布不同,校准温度和阈值往往需要跟着模型走。把后处理配置作为模型产物的附属文件一起管理,能避免“换了模型忘了换配置”这种低级但高频的错误。
7. 我个人在实际操作中的几点体会
这套 occupancy 后处理流水线,我从最早的单阈值加形态学,一路迭代到现在的五层结构,中间推翻重来了好几次。最大的体会是:后处理的上限由原始输出的质量决定,后处理能做的是把原始输出的潜力榨出来,而不是无中生有。如果原始 occupancy 在某个场景下系统性缺失某类障碍,后处理再怎么调也补不回来,这时候该回头查训练数据和模型结构,而不是在后处理里堆参数。
第二个体会是关于“够用”的判断。后处理的每一层都有成本,也都有副作用。不是层数越多越好,而是要看你的下游到底需要什么。如果下游只是个粗略的可通行判断,可能校准加连通域就够了;如果下游是精细避障,那时序融合和结构化输出才值得上。我见过为了“完整”把五层全上、结果每层都调不精的案例,不如按需裁剪、把用到的层调透。
最后分享一个我觉得最值钱的小习惯:给后处理建一个可视化回放工具。把原始栅格、每层中间结果、最终结构化输出叠在一起,支持逐帧回放和参数实时调整。调参的时候盯着回放看,比看离线指标直观得多,很多参数之间的耦合关系是靠眼睛看出来的,不是靠指标算出来的。这个工具的开发成本大概两三天,但它在整个项目周期里省下的调参时间,远远超过这个投入。