做前向碰撞预警(FCW),大部分人第一反应是毫米波雷达,觉得视觉方案不可靠。但真正把一套基于深度学习和单目摄像头测距的源码从零跑通之后,我反而觉得这条路线被低估了。成本低、部署简单,在结构化的城市道路和高速场景里完全能承担起前向碰撞预警的核心职责。这篇文章不是论文复现,是我在某智能驾驶项目里搭FCW预警链路时的完整记录,重点会落在单目测距的工程实现、源码模块怎么拆、预警决策怎么写,以及实测中那些只有在路上跑起来才会暴露的问题。适合正在评估单目FCW方案、准备做预警Demo,或者想从源码层面理解FCW内部逻辑的工程师参考。
1. 先别急着写代码:FCW的预警逻辑要解决哪三个物理问题
很多人以为FCW就是“检测到前方有车就报警”,真到源码实现阶段就会发现完全不是这么回事。如果检测到车就报警,城市快速路上前方两百米内的所有车都会触发提示,这一路根本没法开。预警系统必须回答三个物理问题:前方的车距离我多远、我们之间的相对速度是多少、以当前的速度和距离,碰撞风险到底有多高。
1.1 从“检测到车”到“判断危险”,中间隔着相对运动模型
FCW里最关键的一个物理量叫TTC(Time to Collision),即按当前相对速度推算,两车还有多少秒相撞。最朴素的公式是:
TTC = 相对距离 / 相对接近速度当相对距离是30米,相对速度是10米/秒时,TTC就是3秒。3秒听起来很长,但这是理想匀速情况,如果前车突然急刹,或者自车制动性能一般,实际可用时间会急剧缩短。所以我在源码里并不会单纯拿TTC一个值做判断,而是把相对速度、自车绝对速度、反应时间和制动减速度都拉进同一个决策函数:
def calc_ttc(distance, v_rel): if v_rel <= 0.2: return float("inf") return distance / v_rel def judge(distance, v_rel, v_ego, brake_max=8.0, t_react=1.0): ttc = calc_ttc(distance, v_rel) if ttc < 1.5 and v_ego > 10: return "warning" if distance < v_ego * t_react + (v_ego * v_ego) / (2 * brake_max): return "caution" return "safe"这里的brake_max代表假设的自车最大舒适制动减速度,t_react代表驾驶员或系统开始反应的时间。为什么要有这两个系数?因为预警不能等到最后一刻才响起。报警的目的是给驾驶员留出反应窗口,而不是告诉他“现在已经撞了”。我在项目里把这套判断单独封装成一个模块,输入是跟踪模块给出的距离和相对速度,输出是safe、caution、warning三个等级,方便后续接状态机和报警策略。
1.2 为什么纯距离阈值不够,TTC也有自己的坑
早期的FCW实现里很多人直接用恒定距离阈值,比如“距离小于30米就报警”。这套逻辑在低速城区会疯狂误报,在高速上又显得太迟钝。原因很简单:30米在城区时速30公里下能跑约3.6秒,在高速时速120公里下只有0.9秒,两个场景的反应需求完全不同。
改用TTC之后,场景适应性会好很多,但不能只依赖TTC。有一个很容易踩的坑:如果相对速度很小,比如前车也是走走停停,两车距离在缓慢缩短,TTC算出来可能是几十秒甚至无穷大,危险度反而被低估。另一个典型坑是旁边车道的静止车辆:它在前方侧面的像素位置也会进入检测框,如果只看纵向距离,很容易把邻车道停着的车误判成自车车道内的碰撞目标。
所以FCW源码里一定不能只有测距和TTC,还要有横向位置校验。我在检测框进来之后会计算目标的横向偏移量,结合车辆当前所在车道宽度,判断目标到底在自车车道内还是相邻车道。只有落在自车车道投影范围内,同时纵向距离和TTC满足条件时,才会进入预警流程。这个横向校验看起来不起眼,却是把误报率从每公里两三次压到百公里两三次的关键。
2. 单目测距的三种工程路径:检测框先验、地面假设和端到端深度
单目摄像头只有一个镜头,每一帧图像都是三维世界到二维平面的投影,深度信息在投影过程中天然丢失了。同样宽度的像素区域,在画面里可能是一辆近处的小轿车,也可能是一辆远处的大货车。单目测距的核心思路,就是用各种外部先验把这个丢失的深度“补回来”。
2.1 单目为什么难,难在哪一步
多目视觉靠左右图像的视差恢复深度,毫米波雷达靠电磁波飞行时间测距,单目摄像头没有任何主动测距的物理量,像素坐标里只有角度信息,没有距离信息。所以单目测距不是在“测量”,而是在“估计”。既然是估计,就一定有假设,有误差。
这也是为什么很多做视觉的人一开始很排斥单目测距。但工程上的价值点在于:只要把误差控制在一定范围内,并且整个系统知道误差何时会变大,单目方案在FCW场景里是完全可用的。我见过很多Demo用单目测距误差在20%以上,但那往往是因为标定没做好、假设条件选错,而不是单目这条路本身不行。
2.2 路径A:检测框宽度、物理尺寸先验、针孔模型
第一种实现路径,也是我这个项目最终采用的主路径:先通过深度学习目标检测拿到车辆的2D检测框,然后假设车辆的真实物理宽度,再利用针孔相机模型把像素宽度映射成距离。
针孔相机模型的横向投影关系可以简化成:
像素宽度 = 焦距 * 真实物理宽度 / 距离反过来:
距离 = 真实物理宽度 * 焦距 / 像素宽度写成伪代码:
class DistanceEstimator: def __init__(self, fx, width_assumption): self.fx = fx self.width_assumption = width_assumption self.min_pixel_width = 4 def estimate(self, pixel_width): if pixel_width < self.min_pixel_width: return None return (self.width_assumption * self.fx) / pixel_width这里的fx是焦距的像素单位,不是镜头上标注的毫米数。标定时得到的内参矩阵里,fx就是水平方向的等效像素焦距。只要标定准确,用小轿车1.8米、SUV 1.9米、卡车2.5米这类物理宽度先验,就能把距离计算出来。
这条路径最大的优点是简单、可解释、运行速度快。检测框的宽度从模型出来就有,测距模块只做一个除法。最大的缺点是误差直接受“先验宽度”和“检测框宽度”影响:实际车辆宽度和假设不一致时,测距误差会线性放大;检测框少了一个像素,距离也会跳一下。
2.3 路径B:地面平面假设与接地点投影
第二条路径是几何测距思路:假设路面是一个平面,摄像头外参已知,那么目标车辆与地面的接触点(通常是后轮与地面交界处)在图像上的位置,能通过相机位姿反投影算出地面距离。
这个方法不依赖车辆的真实宽度,而是依赖摄像头距离地面的高度和俯仰角。对安装刚性要求很高,车身上下颠簸、坡道起伏、载重变化导致车头仰角变化时,距离误差会非常明显。我记得有次在颠簸路面上跑测试,同一辆前车在10米和25米之间来回跳动,后来排查发现就是车身俯仰角在波动,地面假设被打破。这套方法可以作为路径A的交叉验证,不建议单独扛FCW主逻辑。
2.4 路径C:端到端单目深度估计
第三条路径是用深度神经网络直接从单目图像回归深度图。训练数据足够好、场景覆盖足够广的情况下,它可以输出整个画面的稠密深度,不需要知道目标检测框,也不需要对车辆宽度做假设。
但端到端深度估计的工程成本不低。训练需要大量带深度真值的数据,推理模型也比较重,在嵌入式平台上很难做到实时。而且深度网络是黑盒,一旦距离跳变,根本说不清是图像里的哪块区域让网络产生了误判。在FCW这种安全相关场景里,可解释性本身就很重要,所以我最终没有把它作为主路径,最多在研究阶段拿来和路径A的测距结果做交叉对比。
2.5 标定是单目测距的命根子
先不管选哪条路径,标定都是绕不开的。检测框宽度和地面假设这两条路径,一个需要准确的内参fx,一个需要准确的外参高度和俯仰角。任何一项偏差都会直接变成测距误差。
比如一辆真实宽度1.8米的车在10米外成像宽度是100像素,如果标定的fx偏大了1%,算出来的距离就会偏大1%,10米误差10厘米。到了30米外,同样的标定误差会放大到30厘米。俯仰角偏差就更明显,地面假设路径下,一米外的高度误差可能引起几米的距离偏差。我个人的习惯是装好摄像头之后,先用棋盘格做一次完整标定,然后在平地上验证一条3到50米范围内的测距曲线,标定结果不直观可信,坚决不进入算法联调。
3. FCW源码的模块化组装:检测、测距、跟踪、决策的数据流设计
预警系统不是单个模型,而是一条完整的数据流。图像进来之后要先检测,再测距,然后跟踪平滑,最后才能交给决策模块。每个模块各干各的活,接口清晰,才好独立调优。我搭的这套FCW源码按五个模块拆:图像采集、目标检测、距离估计、目标跟踪、预警决策。
3.1 单帧处理流程先跑通
第一版代码不要考虑线程池、并发、复杂滤波,先把单帧流程完整跑通。每一帧图像经过检测,得到目标列表;测距模块把每个目标框换算成距离;一个非常简化的Tracker负责把同一辆车在前后帧关联起来;决策模块再根据关联后的状态输出是否报警。整个主循环的伪代码大概长这样:
class FCWSystem: def process_frame(self, image, timestamp): detections = self.detector.run(image) for det in detections: if det.cls not in self.vehicle_classes: continue if not self.is_in_current_lane(det.box): continue distance = self.distance_estimator.estimate(det.box.width) if distance is None: continue self.tracker.update(det.id, distance, timestamp) for track in self.tracker.active_tracks: risk = self.decision.evaluate(track) if risk.level == "warning": self.alerter.trigger(risk)这段代码看起来简单,但已经把关键决策都定下来了:检测目标需要满足类别要求,目标要落在自车车道内,距离要能被测距模块正常算出,最后才交给决策模块。很多FCW误报都是因为前面某个校验条件缺失,比如没有车道内判断,导致左侧车道飞驰而过的车持续触发预警。
3.2 检测框到距离的换算细节
测距模块内部还有很多细节要处理。目标检测网络输出的是带角度的矩形框或者水平矩形框,大车和小车的框形态差别很大。如果直接用检测框的整体宽度,框里包含了大量背景像素,尤其是在货车被截断、SUV被路牌遮挡时,框宽会剧烈变化。
我的处理思路是:优先使用检测框底边附近的有效宽度,因为车辆在路面上的投影宽度最稳定。同时给最小像素宽度设了一个阈值,比如4像素以下的框直接放弃测距。图像里一辆距离上百米的车,可能只有几个像素宽,这时候强行测距,误差大到没意义,不如不报。测距模块返回None,让跟踪模块直接跳过这个目标,反而比报一个错误距离更安全。
3.3 卡尔曼滤波:不是只能滤噪声,还能估相对速度
单帧测距结果一定会抖。检测框本身的抖动、车辆外形在视角变化下的宽窄变化、摄像头微小震动,都会让距离序列毛刺很多。我加了卡尔曼滤波做距离平滑,状态量是距离和相对速度。标准卡尔曼更新公式比较繁琐,用在距离跟踪上可以简写成这样的迭代过程:
def update_track(track, z_meas, dt): z_pred = track.distance + track.v_rel * dt v_pred = track.v_rel residual = z_meas - z_pred K = 0.3 track.distance = z_pred + K * residual track.v_rel = v_pred + 0.02 * residual if track.distance < 0: track.distance = 0K相当于对观测值的信任程度。K越大,响应越快但越容易抖;K越小,曲线越平滑但会延迟。相对速度的更新用的是残差的一小部分,因为距离残差里混着噪声,不能全信。这个滤波有两个作用:一是让距离曲线更稳,二是更关键地,它给了决策模块一个相对速度的估计值。没有相对速度,后面的TTC就无从谈起。
但这个滤波也有副作用:对急刹车场景响应变慢。前车突然急刹时,距离观测值开始快速变小,滤波器如果平滑系数太强,相对速度的更新被延迟,报警就会晚上半拍。所以滤波参数必须在“稳定”和“灵敏”之间找平衡,我最后把K定在0.3左右,既保留了大部分真实变化,又能压住检测框抖动带来的毛刺。
3.4 预警状态机:连续N帧确认,别让单帧误报炸掉用户
预警模块如果只在单帧里满足条件就报警,系统会非常神经质。检测网络偶发误检、测距偶发跳变、前方车辆突然驶入检测范围边缘,都会制造一个短暂的“伪危险”。我的解法是把报警升级成一个状态机:连续多帧满足危险条件才进入预警,退出时也要持续多帧不满足才解除。
class WarningStateMachine: def __init__(self, confirm_frames=3, release_frames=8): self.confirm_frames = confirm_frames self.release_frames = release_frames self.warn_count = 0 self.release_count = 0 self.warning = False def update(self, is_danger): if is_danger: self.warn_count += 1 self.release_count = 0 else: self.warn_count = max(0, self.warn_count - 1) self.release_count += 1 if not self.warning and self.warn_count >= self.confirm_frames: self.warning = True if self.warning and self.release_count >= self.release_frames: self.warning = False这套状态机逻辑很老套,但特别有效。危险状态需要连续3帧确认,可以过滤掉单帧检测抖动;解除预警需要连续8帧安全,避免系统在临界状态来回报警。实际体验下来,这个“迟滞”设计是FCW让人愿意用的核心,否则报警声音响一下停一下,驾驶员很快就会关掉整个系统。
4. 实测中最容易翻车的四个场景:数据不会骗人
源码写完只是开始,真正让FCW“能用”的,是实测阶段。我把这套系统装到测试车上跑过好几轮,覆盖了日间、夜间、雨天、隧道、逆光等场景。测试序列录了很多段,每段都人工标注真实距离,用来评估测距误差和预警准确率。数据看起来很直观,也很扎心。
4.1 不同场景下的真实差距
测试时我记录了几个典型场景的表现。检测召回率、测距误差、有效预警率这三项,最能说明问题。
| 场景 | 检测召回率 | 测距误差 | 主要问题 |
|---|---|---|---|
| 日间城市道路 | 约95% | 8%-12% | 无明显问题,偶尔遮挡和截断导致框变宽 |
| 夜间城市道路 | 约80% | 12%-18% | 低对比度、车灯过曝、行人混淆 |
| 雨天 | 约70% | 15%-25% | 雨滴遮挡、前车尾灯反光、检测框抖动 |
| 隧道入口 | 约60% | 20%以上 | 曝光突变导致画面白墙化 |
| 逆光 | 约75% | 15%-20% | 动态范围不足,车尾轮廓不清晰 |
日间场景下,测距误差能稳定在10%左右,对FCW来说已经是可以接受的精度。但一进入隧道入口,情况立刻恶化。摄像头在从明亮环境进入隧道时,自动曝光来不及调整,画面会先整个白掉,检测模型找不到前车,测距和预警自然全废。这个场景不解决,单目FCW在城市快速路的可用性就会打折扣。
4.2 一次“距离在15米和35米之间跳变”的完整排查
我印象最深的一次问题是:静止的前车明明停在前面,测距结果却在15米和35米之间来回跳。当时第一反应是检测模型把目标框宽度搞抖了,但打开每一帧的检测框可视化之后发现,框的宽度变化其实只有不到10%。10%的框宽偏差,按30米远的基数算,顶多跳3米,为什么距离能跳到两倍多?
排查过程是这样的。首先检查是否标定问题:我重新用棋盘格标定了一次,发现fx和之前相比没有明显变化,内参先排除了。然后检查测距模块用的像素宽度信号源:发现我在代码里取的是检测框整体宽度,而前车旁边有一根路灯杆,当路灯杆和车尾在图像里粘连时,检测框会突然往旁边撑开,框宽就立刻变大。距离瞬间从35米被算到15米。问题不在检测模型本身,而在检测框粘连干扰。
搞清楚根因后,处理方式就很直接了:测距时不再直接用检测框的整体宽度,而是取检测框下半部分的中部宽度,同时给单帧距离变化率设置一个上限,一帧距离突变超过40%时,先以滤波预测结果为主,等连续几帧稳定后再相信观测值。这两个改动叠加之后,距离跳变问题基本消失。
这次排查给我的经验是:单目FCW的测距错误,很多不是“测距算法”本身错了,而是检测框输入质量差。检测框是上游,测距是下游,下游永远会被上游的脏数据坑。排查故障时一定要先把“框本身稳不稳”这个问题单独验证,否则会在测距公式里白白浪费时间。
4.3 夜间和雨天,不能只靠图像增强
夜间场景的最大问题是车灯。前车尾灯非常亮,周边环境又暗,摄像头容易把所有像素往高光那边拉曝光,车尾轮廓被压进黑暗里。后来我在预处理里加了局部的伽马校正,并让曝光策略偏向保留暗部细节,召回率才从70%左右提回到80%。
雨天则不太建议走“图像去雨”这条路。去雨网络本身有延迟,推理还贵,实时性不好。我最后的处理是把雨天场景的触发策略调保守:降低最大可巡航速度的假设,同时把TTC阈值从1.5秒放宽到1.8秒。看起来很简单的策略调整,比任何图像增强都可靠。
4.4 隧道口的曝光突变,靠“曝光锁定”缓解
隧道入口的白墙效应改起来要动相机控制。我当时引入了一个非常简单的策略:当画面平均亮度出现快速下降时,提前锁定曝光参数,不等到画面全部变白再去调。这个策略在大多数隧道口能抢出500毫秒到1秒的有效检测窗口。虽然隧道里如果灯光很暗,最终还是会失效,但至少不会在进入隧道的一瞬间完全失明。
5. 如果要从零复现,我会这样配置最小系统
这个部分写给想自己复现一版单目FCW源码的工程师。先说明一点,不需要一上来就搭特别完整的硬件系统,一个USB相机、一台普通计算机、一个开源目标检测模型,就能把核心链路跑通。真正难的不是模型精度,而是把测距、跟踪、决策这几个模块连起来。
5.1 最小硬件与安装建议
摄像头选水平视场角60度到90度之间的普通USB相机就行,分辨率1080p足够,关键是帧率尽量稳定在30FPS附近。安装位置尽量靠挡风玻璃中央偏上,保证车辆正前方视野没有被遮挡。支架一定要固定牢,测试时如果摄像头在震动,后面调滤波参数会调到怀疑人生。安装好之后先测量摄像头距离地面的高度,记录这个值,后面做地面假设路径时会用到。
标定是第一步,也是最容易跳过的。用棋盘格拍二十张不同角度的照片,解出内参和畸变系数,然后在图像的检测框测距验证里用到fx。没有标定就直接跑,测距结果完全不可信。
5.2 检测模型、数据与评测方法
检测模型直接选一个轻量级的通用目标检测网络,类别里包含car、truck、bus这类车辆类别即可。训练数据用公开的自动驾驶道路数据集就能起步,但要想减少误报,最好补充一些自采的道路视频,把特殊场景单独抽帧出来做微调。
评测不能只看目标检测的mAP。FCW真正关心的,是最终报警的准确率和召回率。我的评测方法是对测试序列逐帧做三类标注:安全、接近但不危险、危险。然后把系统的输出结果和标注对齐,统计“危险帧有没有报警”“安全帧有没有误报”,最后计算预警准确率和漏报率。如果只在检测框层面评测,很多误报逻辑根本暴露不出来。
5.3 什么情况下,别硬上单目FCW
说了这么多单目的好处,还是要讲清楚边界。单目视觉方案的测距误差通常不会低于5%,在遮挡、强光、夜间环境下可能到20%以上。如果项目目标是L2级辅助驾驶,单目FCW可以作为主要预警信息来源;如果目标是更高阶的主动安全控制,比如AEB自动紧急制动,直接让单目视觉独立输出制动触发信号,我是不建议的。
这种情况下,最稳妥的做法是融合。用单目视觉做目标识别和车道内判断,用毫米波雷达做精确距离和相对速度测量,两者交叉验证。视觉负责回答“前面的东西是什么、在不在我的车道”,雷达负责回答“它到底有多远、接近速度多大”,各干各的擅长的事。
我个人的体会是,做单目FCW源码探索,别急着堆高精度模型。先把相机标定、测距公式、预警状态机这三样吃透,比换一个更强的骨干网络有用十倍。我现在的版本里,模型还是那个轻量级检测网络,反而是测距的细节处理和状态机的迟滞逻辑救回了大量误报问题。这套极简闭环能稳定跑起来之后,再逐步加入滤波、多目标跟踪、传感器融合,每一步都会有清晰的可验证结果。单目方案的边界一直都在,但把这个边界摸清楚,本身就是做这个项目最大的收获。