搞智能驾驶视觉感知的人,对这样的场景应该不陌生:模型明明框到了前方的车,但框在连续几帧里左右跳,类别一会儿是轿车一会儿是货车,框的中心位置漂移得离谱。你要是敢把这个结果直接丢给AEB或者ACC去用,车在高速上能给你来一脚莫名其妙的刹车。模型输出的是“框”,是单帧的、带噪声的、彼此独立的“检测假设”,而下游功能要的是“目标”——是连续、稳定、带着运动状态和置信度评价的对象。这中间缺的那一大块,就是视觉后处理。
这篇是视觉障碍物后处理系列的第五篇,聊的主题是视觉后处理与辅助驾驶功能。前面几篇分别讲了检测输入、前后处理链路、以及障碍物基本信息处理,这篇更侧重一个层面:后处理好的障碍物目标,到底怎么跟FCW、AEB、ACC这些辅助驾驶功能衔接,后处理结果的质量会怎么影响功能表现,以及实操过程中那些文档里不会告诉你的坑。适合做感知后处理、融合、功能算法或者系统集成的人参考,也适合想搞清楚“感知和功能之间到底发生了什么”的入门者。
1. 先弄清楚后处理在整条链路里到底要干什么
1.1 从“框”到“目标”:后处理解决的不是检测,而是信任问题
很多刚接触这个领域的人觉得,后处理不就是把检测结果的置信度过滤一下、把重叠框去掉吗?如果只做这两件事,那叫“检测结果清理”,不叫后处理。真正的视觉后处理,任务是把单帧的检测假设,变成时间上连续、物理上可信、语义上稳定的目标级输出。换句话说,它解决的是“信任”问题——让下游功能愿意相信这个目标存在、位置可信、状态可预测。
你看检测模型的输出格式就知道了:每帧给出一堆结构化数据,包含类别标签、框坐标、置信度分数。这些数据之间没有任何关联,前一帧的框和后一帧的框谁是谁、哪里去了,模型不关心。而后处理恰恰要解决这个问题:帧与帧之间的目标如何关联?目标被遮挡了怎么办?目标的速度和加速度怎么估算?目标类别闪变了怎么平滑?这些如果不处理,ACC跟车时前方车辆速度稍微变一下,感知速度就跳一下,跟车就会一冲一冲的,坐在车里的人分分钟晕车。
所以整体链路是这样的:检测模型(感知)→ 障碍物后处理(目标化)→ 决策规划(功能决策)→ 执行器(控制)。后处理是感知的“收尾者”,也是功能的“接口者”。它的质量好不好,用户体感最直观的两个指标就是误报率和漏报率。误报率高,AEB天天制造惊险瞬间;漏报率高,FCW该响不响,功能就成了摆设。
1.2 为什么模型输出不能直接进功能:一个例子看清楚
我举个特别典型的例子。某次我调一个前向的FCW功能,最初直接把YOLO系检测器的输出拿来计算TTC(碰撞时间)。正常跟车时,前方目标相对速度大约20km/h,相对距离30米,TTC应该稳定在5秒左右。但由于检测框的宽度抖动比较厉害,目标在图像上的尺寸一会儿大一会儿小,换算出的相对距离就是33米、28米、35米这样来回波动,TTC也跟着从4秒跳到7秒,FCW的触发逻辑被搞得疲于奔命,该报警的时候没报,不该报警的时候响个不停。
检测框抖动是必然的,神经网络对目标边缘的回归有噪声,光照变化、目标部分遮挡、车辆颠簸都会放大这种噪声。而后处理的一个重要工作,就是对这些有噪声的输入做时间和空间上的滤波,让输出的目标位置、尺寸、速度有“惯性感”。这不是简单做一下平均,而是需要建立目标状态估计模型,用历史信息和当前观测融合,还得考虑目标的运动模型是匀速还是匀加速、传感器的观测噪声有多大。这些都是后处理的核心内容。
2. 视觉后处理的核心细节:从框到轨迹的关键技术
2.1 目标关联:怎么判断这两帧的框是同一个人
后处理的第一步,也是最基础的一步,是帧间目标关联。上一帧检测到前方第1辆车、第2辆车,这一帧又检测到一辆车,你得知道这一帧的这辆车是不是上一帧的1号车,这个问题解决不好,后面全是空中楼阁。
常用的关联方法有两大类:一类是基于位置重叠的,比如IoU(交并比);一类是基于特征相似度的,比如外观特征、尺寸特征。但在纯视觉后处理里,最主流、性价比最高的做法是“预测框与检测框的IoU匹配”。你先用上一帧目标的位置和速度,预测出这一帧它大概在哪,然后用预测框和一帧检测框算IoU,如果IoU大于某个阈值,就可以认为是同一个目标。
实际操作中我用的是匈牙利算法来做全局最优匹配。先构建一个代价矩阵,矩阵元素是预测框和检测框的IoU(或距离的负值),然后通过匈牙利算法找到整体代价最小的匹配方案。核心经验是IoU阈值的选择——我一般设0.3~0.5之间。阈值太低,比如0.2,容易出现两个不同目标错配在一起;阈值太高,比如0.7,目标稍微移动快一点就断开连接、重新分配ID。低速城区场景可以设高一些,高速场景建议设低一些,因为高速下相同时间间隔内目标的位移更大,帧间重叠率天然偏小。
2.2 轨迹生命周期管理:目标不是一出现就是“真”的
目标发出来一个ID,不是马上就推给下游使用的。因为检测模型偶尔会对背景误检,比如路牌、树影、桥上横梁,都可能被误检为障碍物。如果误检也被当成稳定目标,FCW会对天降横梁疯狂报警。
因此我会给每条轨迹定义几个生命周期状态:未确认(候选)、已确认、丢失中、已删除。一个新目标进入系统时,先挂一个候选轨迹,连续几帧都能匹配到检测框,才升级为已确认轨迹并输出给下游功能。反过来,已确认目标连续多帧匹配不上(比如被前车遮挡),轨迹进入丢失状态,在丢失状态下目标位置继续用运动模型推测,但置信度会随时间衰减。连续丢失时间超过阈值,轨迹直接删除,ID不再复用。
参数怎么定?我自己的经验是:确认帧数设3~5帧,丢失帧数设30~50帧。确认帧数设太短,误检容易混进来;设太长,真实目标刚出现在视野边缘时,功能层迟迟拿不到确认结果,等都确认完,目标已经离很近了,留给系统的反应时间变短。丢失帧数的设置要考虑目标遮挡场景。高速上如果前车被旁边大车挡住,通常只会遮挡一两秒,30~50帧(假设25~30fps)足够撑过遮挡期,目标ID不会丢;但遮挡时间再长,轨迹预测的置信度已经很低,继续跟下去反而可能导致目标位置漂移很远的假轨迹,这时候删除轨迹反而干净。
2.3 目标运动状态估计:卡尔曼滤波和它的工程落地
辅助驾驶功能里,目标的速度、加速度是核心输入。ACC要知道前车是加速还是减速,AEB要知道碰撞时间还有多少,FCW要判断危险等级。那么目标速度怎么来?最朴素的方法是相邻两帧位置差除以时间差,但这样算出来的速度噪声非常大,你根本没法用。
所以工程上普遍用卡尔曼滤波做运动状态估计。这里分享一个基础的匀速模型(CV模型)实现思路。目标状态定义为 x = [pos_x, pos_y, vel_x, vel_y],观测是检测输出的目标中心位置 [pos_x, pos_y]。每帧做两步操作:先通过运动模型预测目标状态(预测步),再用当前帧检测结果对预测进行修正(更新步)。核心要调的两个参数是过程噪声协方差Q和观测噪声协方差R。Q大,表示更信任观测、响应快但噪声大;Q小,表示更平滑但滞后明显。我调车时的经验是:初始先给Q设一个相对偏小的值,让速度曲线平滑,再根据实际跟车体感逐步调整。如果发现目标速度响应太慢,前车刹车了感知速度还在慢慢降,就适当增大Q;如果速度曲线毛刺多,就增大R。
卡尔曼滤波在工程上的一个复杂点是:目标初始化和终止条件要做处理。第一帧出现的目标没有历史速度,需要用连续两帧的位置差来初始化速度,但这两帧检测噪声会让初始速度偏差很大。我的做法是限制初始速度的范围,比如相机感知的目标速度不能超过物理极限(比如200km/h),超过就置为边界值,再用后续帧的观测逐步修正。
2.4 类别与置信度的时序平滑:让车不再“变性”
检测模型对目标类别的判断,在单帧上会存在抖动。同一个目标,这一帧输出是Car,下一帧输出是Truck,再下一帧又是Car。如果你直接把类别变化传给下游,功能层会非常困惑——Truck和Car的动力学模型、刹车能力、危险程度完全不一样。
我做类别平滑的方法很简单:给每条轨迹维护一个类别投票器。每帧匹配上检测框时,把检测类别的得分加进去,最后输出得分最高的类别,且只有得分超过一定比例才允许类别切换。一个比较保守的策略是:新类别连续N帧(比如5帧)都占优势,才切换类别;否则保持原类别输出。这能有效过滤掉单帧的闪烁。代价是真实类别变化时(比如拖挂车切换)会有几个帧的延迟,但这对功能层面通常是可接受的。
置信度的处理也类似。模型输出的置信度分数,在不做处理时抖动很大,尤其夜间、逆光、雨天场景。我习惯对置信度做时间维度的加权平均:当前帧置信度占60%~70%,历史置信度占30%~40%。这样单个低置信度的突变帧不会导致目标被直接删除,也不会让一个高置信度的偶然误检瞬间升级成稳定目标。置信度阈值的选择则需要分场景:城区场景我通常把初始阈值设0.4左右,郊区高速设0.5,因为高速上误检代价更高,宁可多漏一些也不能乱报。
3. 实操:后处理管线实现与辅助驾驶功能对接
3.1 一个可复现的视觉后处理管线架构
理清核心点之后,我直接给一套最常见的视觉后处理管线架构。输入是检测模型输出的检测框列表,输出是带有ID、类别、位置、速度、置信度等属性的目标列表。管线内部通常分四个模块:预处理与坐标转换、目标关联与匹配、轨迹管理与状态估计、输出与接口适配。
预处理模块负责把检测框从像素坐标换到相机坐标系或车身坐标系。这里特别提醒一点:像素坐标框不能直接用于速度估算。如果不做坐标转换,用像素位移近似物理位移,目标距离越远越不准。通常我们是利用相机内参、外参和地面平面假设,把检测框底边中心点投影到地面上,得到目标的世界坐标(x, y)。这一步的精度直接决定后续距离和速度的精度,标定参数要定期检查和校验。
输出接口是给下游功能用的object list结构。我常用的结构体包含以下关键字段:
target_id // 全局唯一ID class_type // 障碍物类别:轿车/卡车/行人/骑行者/未知 bbox // 原始图像检测框,调试用 position_x // 车身坐标系下的纵向位置(米) position_y // 车身坐标系下的横向位置(米) velocity_x // 纵向速度(米/秒) velocity_y // 横向速度(米/秒) confidence // 目标置信度得分 existence_score // 目标存在概率 track_age // 轨迹已维持帧数 lost_age // 轨迹丢失帧数 valid_flag // 是否可作为可信目标参与功能决策valid_flag这个字段在设计时一定要留。它表示该目标是否满足功能安全的最低要求(比如是否经过足够帧数确认、置信度是否超过阈值、时间戳是否新鲜)。功能层拿到目标列表后,优先只看valid_flag为真的目标。这样即使感知端有异常、目标处于丢失状态,只要valid_flag置为false,功能层就不会用它做决策,从源头避免误触发。
3.2 关键模块伪代码:目标关联、生命周期、状态更新
我写一个简化但可运行的伪代码流程,方便大家对照自己的工程实现思路。
# 伪代码:视觉后处理主循环 for frame_idx in range(total_frames): detections = detector.infer(image) # 当前帧检测结果 # 1. 坐标转换:检测框到车身坐标系 dets_world = world_coord_transform(detections) # 2. 预测:用已有轨迹的运动模型预测当前帧状态 predicted_tracks = [track.predict(dt) for track in tracks] # 3. 关联:预测轨迹与当前帧检测做匹配 cost_matrix = build_iou_cost_matrix(predicted_tracks, dets_world) matched_pairs, unmatched_dets, unmatched_tracks = hungarian_match(cost_matrix) # 4. 更新:匹配上的检测用于修正轨迹状态 for track_idx, det_idx in matched_pairs: tracks[track_idx].update(dets_world[det_idx]) tracks[track_idx].confirm() # 累加确认计数 # 5. 处理未匹配的检测:新轨迹候选 for det_idx in unmatched_dets: new_track = Tracker.initialize(dets_world[det_idx]) tracks.append(new_track) # 6. 处理未匹配的轨迹:进入丢失状态 for track_idx in unmatched_tracks: tracks[track_idx].mark_lost() # 7. 生命周期管理:确认/删除 for track in tracks: track.update_state_machine() # 8. 产出:过滤无效目标,输出object list output_targets = [track.to_object() for track in tracks if track.is_confirmed()] publish(message_frame_id, output_targets)这里每一步都有细节讲究。第三步的代价矩阵,如果纯用IoU,遇到快速接近的大货车和远处的小轿车时容易匹配混乱。我建议代价矩阵不要只用IoU,可以加入位置距离和高宽比约束:IoU低于0.2的配对直接设为无穷大排除;高宽比差异超过一定倍数也排除。这样即使两个目标空间上接近,但形状差异大,也不会错配。另外一个实操技巧是,如果检测帧率稳定,每次预测的dt要严格用时间戳计算,不要默认等于1/帧率。相机帧率在颠簸路面上可能偶发掉帧,统一用时间戳计算dt可以让速度估算更稳。
3.3 时间戳对齐:最容易翻车的细节
时间戳对齐是我见过最容易被忽略、也是最容易翻车的问题。视觉后处理输出的目标列表,带上时间戳是不够的,关键是你这个时间戳是“算法完成时间”还是“图像曝光时间”。这两个时间差了可能几十到上百毫秒,高速场景下,100毫秒意味着车辆向前移动了好几米。如果你的目标位置是100毫秒前的,而下游功能按“当前时间”做决策,那AEB的反应距离凭空缩短好几米。
我的做法是:感知输出里的每个目标都带两个时间戳,一个是图像曝光时间,一个是后处理完成时间。下游决策用曝光时间作为目标状态的有效时刻,用完成时间判断数据的时效性。如果完成时间和当前规控时刻差超过一个阈值(比如150ms),这帧感知数据就不能参与实时安全决策,功能层要降级处理。另外,后处理内部在计算速度时,必须用相邻两帧曝光时间戳的差值作为dt,而不是用算法执行间隔,否则速度里会混入处理延迟的误差。
3.4 后处理如何支撑FCW、AEB、ACC这些具体功能
后处理做完,最终要落到功能上。这里讲三个最典型的对接实例。
先看FCW(前向碰撞预警)。FCW的核心参数是TTC,计算方式是相对距离除以相对速度。后处理给的是自车的目标纵向位置和纵向速度,经过差值得出相对速度和相对距离,再算出TTC。FCW的报警阈值一般分两级:预警级TTC在2.6~3.0秒,介入级在1.2~1.5秒。但单纯靠TTC触发工程上会误报很大,因为目标可能正在切入或者本身在制动过程中。所以功能层通常还要求后处理额外提供目标的生命周期稳定度和横向运动趋势。如果目标刚确认不久、横向速度明显,即使TTC很小,也不会触发FCW。我在调这个的时候,发现后处理输出的目标横向速度估算尤其重要,横向速度估算不准,功能会把旁边车道正常行驶的车误判为切入车辆。
然后是AEB(自动紧急制动)。AEB对感知置信度的要求比FCW高得多,因为它要主动制动。功能层一般需要后处理提供一个“危险目标等级”的评价,除了TTC,还会结合目标类别(行人、骑行者、车辆权重不同)、目标是否被遮挡(遮挡状态)、置信度分数。AEB触发的目标,后处理必须给出valid_flag为真,且已经确认跟踪超过一定帧数,并且目标位置必须经过滤波稳定。工程上这类目标通常还要过一遍“安全逻辑”——如果后处理内部检出目标状态异常或者时间戳超时,宁可延迟一帧制动,也不能在目标不明确时误制动。
ACC(自适应巡航)是另一个典型场景。ACC的核心是选目标:从所有有效目标里,选出同车道内距离最近的目标作为跟随目标。后处理在这里要做的,是保证每个目标的速度估计平滑,尤其是目标刚进入ACC跟随范围时,速度不能突变。否则ACC控制端发现目标速度从0突然跳到20m/s,会先急减速再慢慢跟上,那种驾驶体验约等于新手司机猛踩刹车。我的经验是ACC用到的目标,速度至少经过5帧以上的滤波再进入控制,且速度变化率(也就是加速度)也需要平滑输出,让控制端有预测性。
3.5 输出质量的评价闭环:你说了不算,回归数据说了算
后处理做得好不好,不能靠主观感觉,需要一套评价闭环。我自己的习惯是,除了常规的检测指标(Precision/Recall),对后处理效果重点盯三个指标:ID Switch次数(ID切换率)、轨迹中断次数、以及跟车速度波动幅度。
ID Switch是行业里衡量追踪稳定性的核心指标,一帧之内目标ID发生切换的次数。高速场景下,一次ID Switch就会让ACC把目标当成新目标处理,导致一顿一挫。轨迹中断次数统计的是已确认轨迹因为遮挡或丢失而结束的次数,如果一条轨迹在中远距离频繁中断,下游功能就会频繁重新选取目标。速度波动幅度则是直接在功能层看跟车目标速度序列的方差,正常情况波动应该很小。
怎么测?我的做法是录一批包含城区、高速、夜晚、雨天、隧道进出口等场景的测试数据,跑完后处理算法,用标注工具(比如分析追踪结果的脚本脚本)自动统计上述指标。每次改动关联阈值、卡尔曼参数或者生命周期参数,都要跑一遍回归,对比指标变化。我调参时最常遇到的情况是:某个场景ID Switch变好了,另一个场景误检变多了。这种时候就需要定义场景权重,不是简单求平均,而是按场景安全性分配权重后综合评分。
4. 踩坑实录:视觉后处理常见问题与排查经验
4.1 目标ID频繁切换:先别改算法,先看数据
ID Switch算是后处理问题里最让人抓狂的一个。目标ID频繁切换会导致功能层对目标状态感知不连续,AEB可能把同一个目标当成新目标重新评估,ACC会重新选目标,体感就是车在高速上“犹豫不决”。
遇到ID Switch,我的排查路径是固定的。第一步,把前后几帧图像数据抽出来,看目标在图像上的重叠度。如果两个相邻目标在图像上几乎完全重叠,那是当前匹配逻辑无法区分的正常现象,需要考虑加入特征匹配(比如目标外观特征)或者用多传感器融合辅助分离。第二步,检查检测框的稳定性。如果检测框宽度在连续帧内剧烈变化,导致IoU不稳定,优先解决检测框抖动,比如对检测框做宽高比平滑。第三步,调整匈牙利算法的代价矩阵。这个我在前面提过,IoU阈值太严格会导致同一目标短暂失配然后重新分配ID,太宽松又会让两个目标交叉时错配。我最终的做法是,把IoU阈值作为场景参数缓存,在不同场景(高速、城市、隧道)波动,而不是全局固定一个值。
4.2 高速上的目标速度突然跳到离谱值
这是我自己踩过比较深的一个坑。某次测试中,前方目标速度在200多公里每小时和正常值之间来回跳。后来排查发现,问题不在卡尔曼增益,而在坐标转换。车辆在弯道上行驶时,如果是做纯像素到地面的投影,目标框底边中心点在地面上的投影位置会有很大的系统偏差,因为相机外参中的俯仰角在车辆加减速时会变化(车辆抬头点头),导致目标底边投影位置在前后方向上剧烈漂移,速度自然就跳变了。
解决思路有两个方向。一是做俯仰角补偿,利用车道线消失点、或者IMU数据来实时估计相机俯仰角变化。二是目标位置滤波时增加观测噪声的自适应调整,当检测到目标在图像上处于远距离、且地面投影不稳定的边缘区域时,增大观测噪声R,让滤波器更信任预测而不是观测。注意,这是过滤问题,不是根治问题,根治还是需要前端做稳定的相机姿态补偿。但后处理端的这个自适应R,能极大缓解功能层面的体感问题。
4.3 低置信度目标造成误检:置信度阈值不是一拍脑袋定的
很多人一开始面试时问“置信度阈值设多少”,脱口而出0.5。但0.5根本不具备普适性。检测模型在不同天气、不同距离上的置信度分布完全不一样。远距离的行人置信度普遍在0.3~0.5之间,你要是全局设0.5,行人目标全被滤掉,AEB对行人功能就失效了。夜间大灯照射下,目标的置信度整体偏低,但目标往往是真的。
所以阈值设定要考虑场景和距离维度。我的做法是:把图像划分成近、中、远三个区域,对应不同阈值;同时结合时间信息,连续低置信度但稳定的轨迹可以作为候选目标,不立即丢弃。还有一个技巧是,利用“消失一致性”——如果一个低置信度的目标轨迹在连续几帧内保持了稳定的运动趋势,它比一个突然出现的高置信度目标更值得保留。功能层更怕的是那种“突然冒出来、又突然消失”的不稳定目标。
4.4 雨夜、逆光、隧道口的经典翻车场景
这几个场景几乎每个做后处理的人都会遇到,我就直接给结论和经验。
白天进隧道那一瞬间,相机自动曝光来不及调整,画面要么过曝要么全黑,检测结果基本没法用。后处理这时候要做两件事:一是对感知结果的时间戳做“无效标记”,告诉下游这一帧数据不可信;二是利用轨迹预测维持目标输出,但不能让目标速度产生假变化。如果功能层因为进隧道瞬间感知失效而退出ACC,体验很糟糕;但如果后处理强行输出预测位置,又可能因为预测误差积累导致目标位置偏差很大。折中方案是:输出预测轨迹但降低置信度,同时告诉功能层数据处于“感知降级”状态,ACC可以继续保持但限制加减速幅度。
雨夜场景则是另一个问题。地面反光、雨滴在镜头前形成光晕,检测模型容易把水渍、灯光反射误检成障碍物。后处理层面能做的是加大确认帧数,让这类误检因为时空不一致而被过滤掉。逆光场景,目标往往只检测到上半部分或者出现“局部检测”,框高度偏矮,这会导致目标实际位置投影偏差。后处理可以加一个针对性的箱体过滤:目标高度低于物理合理高度(比如0.5米以下)且ID又是首次出现,就提高确认门槛。
5. 一些个人经验与后续扩展方向
说到这,再补充一个我实际调试中摸索出来的经验。后处理里面,参数“跟着场景走”这件事,不要只靠感觉去调,最好做成一张场景参数表。高速、城市、隧道、雨天、夜晚,分别配一组不同的确认帧数、IoU阈值、置信度阈值、卡尔曼Q/R参数。运行的时候根据当前的场景识别结果(光照、车速、雨刮状态、车道场景)自动切换参数组。这比用一个全局参数跑所有场景要稳得多。但也要注意,参数不能切得太频繁,否则场景识别抖动会造成参数反复切换,目标状态也跟着抖。我一般对场景标签做5秒以上的“粘滞”处理,标签变了至少保持几秒再切换参数。
另一个值得扩展的方向是和雷达、超声波的融合问题。纯视觉后处理做到最后,你会发现一些结构性问题靠视觉本身很难解决,比如单目视觉对目标绝对距离估计天生不精确,尤其是远距离目标。这时候就需要多传感器融合来做互补。视觉后处理的输出结果可以作为融合的输入之一,而融合后的目标质量反过来也可以校验视觉后处理的好坏。这个我后续准备单独写一篇,这里先提一个核心观点:视觉后处理做到后半段,目标已经不是“检不出来”的问题,而是“信不信得过”的问题,而融合恰好是解决“信得过”的另一条关键路径。
最后再分享一个小技巧:调试后处理算法时,记得做一个可视化的调试工具,在图像上同时绘制检测框和目标轨迹、目标ID、速度矢量,能极大提升排查问题的效率。很多复杂问题,比如ID切换、速度跳变、轨迹漂移,你光看数字是看不出来的,但在图上放慢播放,一眼就能定位。好的可视化调试工具是后处理算法迭代效率的分水岭——这个工具值得你花时间建好,越早越好。