前阵子接了个路面监控项目,需求很明确:检测实线变道、压线行驶。客户一开始提的要求就一句话——“用YOLOv8搞定它”。听起来很简单,但真正做起来才发现,把检测框送到后台告警和真正能上路的违章取证之间,还隔着一层“空间关系”和一套“事件状态机”。这篇就把压线/越线检测的整体思路、关键代码、踩坑过程和上线经验完整梳理一遍,适合正在做交通事件检测、或者准备把目标检测模型落地到固定摄像头场景的朋友参考。
1. 方案整体拆解:检测、空间关系与事件判定三层
1.1 为什么选YOLOv8而不是更复杂的目标检测方案
YOLOv8 在工业落地里属于“最不折腾”的选择。它有官方训练/推理接口,模型从 n 到 x 分成多档,训练完全不用自己拼网络结构。交通监控场景的目标相对固定:车辆外观、视角变化有限,真正需要的是“稳定的小目标召回率”和“实时帧率”。YOLOv8s 在 640 输入下,单张 GPU 推理通常能跑到几十毫秒级,放到边缘设备上也有优化余地。
对比老版本 YOLOv5,V8 的 anchor-free 设计让检测框更贴合目标边缘,对远处车辆这种小目标更友好。对比两阶段模型,V8 在同等精度下快得多。这个项目要24小时不停跑,多路摄像头一起接入,单路推理能节省一毫秒,整体的设备数量和成本差别就很明显。所以模型选型这件事,不是看测试集上那零点几的精度提升,而是看长时间运行的稳定性和硬件占用。
1.2 压线检测的核心难点:框不等于车
很多第一次做交通事件检测的人会踩同一个坑:用“车辆检测框与车道线是否相交”来判断压线。粗看有道理,毕竟检测框就在车辆周围,但实际用起来全是误报。
原因有几个。检测框是外接矩形,它会包含车头到前保险杠的距离,车头过了线但车轮还没过,这不能算真正压线。侧面来的车如果停在导流带附近,外接框可能只是轻轻碰到线,也不代表轮胎压了线。更麻烦的是检测框会抖动,边缘一会儿在线内一会儿在线外,压线判定的状态就会反复横跳,报警刷屏。
业内更通用的做法是关注车体与地面的接触点,也就是轮胎位置。车是实际接触地面的,只有轮胎投影区越过车道线,才算压线、越线。没有轮胎关键点模型时,通常用检测框底边中心点近似代替,再结合车辆宽度做阈值校正。这样虽然不如专业关键点精准,但在工程上足够稳定。
1.3 三层架构:检测层、几何层与事件层
我在这个项目里把系统拆成三层,每层职责单一:
- 检测层:YOLOv8 负责识别画面里的车辆,输出检测框、置信度、类别,并提供跟踪ID。
- 几何层:管理车道线的空间定义,把“图像像素坐标”转成“相对于车道线的位置关系”,输出车辆底边中心点到各条参考线的距离和方向。
- 事件层:根据连续多帧的几何结果,用状态机判断是否构成压线或越线行驶,并触发抓拍、报警、存储。
拆层的好处很明显。换一个相机视角时,只需要重新标定车道线坐标,不需要重新训练模型。换一个检测模型时,只要输出格式不变,几何层和事件层完全不用动。后期客户如果要求区分虚实线,只需要在几何层的车道线定义里加一个属性字段,事件层判断时过滤掉虚线即可。
2. 数据集与模型训练细节
2.1 数据来源与标注要点
数据主要有三个来源:公开车辆检测数据集、道路监控视频截帧、合成渲染数据。监控视角的截帧是最有效的一批,因为它的视角、画质、光照和最终部署场景完全一致。公开数据集适合做预训练,合成数据则用来补足极端场景,比如夜间、雨天、大逆光。
标注时如果只做车辆检测,类别用 car/bus/truck/motorcycle 还是统一成 vehicle,取决于业务是否需要区分车型。这个项目里后续要按车型做统计,所以保留了细分类别。如果只关心“有没有车”,统一成 vehicle 会让召回更稳,因为小目标分类容易混淆。
如果要做精细的压线判罚,建议在标注车辆框的同时,在轮胎接地位置打关键点。YOLOv8-pose 可以复用这一套标注,训练出车轮关键点模型。如果没有关键点条件,退而求其次用底边中心点,但必须清楚它的误差来源。标注习惯上还有一条原则:压线场景的正样本要足够多,至少要专门收集一批车辆轧线、变道过程中的截帧,否则模型没见过“半截车身在线外”的形态,检测质量会明显打折。
2.2 模型选型与输入分辨率
YOLOv8n 适合 CPU 推理或者并发路数很多的场景;YOLOv8s 是监控项目里的默认起点,精度和速度均衡;如果相机画面很宽、远端车接近小目标,可以考虑 YOLOv8m。我个人的建议是,上来先跑 s,把整体流程跑通,再根据实际延迟切成 n 压性能。
输入分辨率不能照搬 640。道路监控画面是宽幅的,车辆在画面上下边缘的尺度差好几倍,单纯压缩到 640 会让远端密集车流变成几千像素的小方块,漏检率飙升。实际操作一般是先把视频按 ROI 区域裁剪,再缩放到 1280,或者按高度方向保持原比例。例如 1080p 画面,把路面区域裁下来缩放到 960 或 1280,模型输入尺寸设置成带宽度优先的自定义尺寸,远端车辆召回率能提升一大截。
2.3 训练配置与效果验收
训练配置上,我用过 batch size 16,SGD 和 AdamW 都试过,只要学习率配好,最终精度差距不大。数据增强保持官方默认的 mosaic、mixup 就行。交通场景最怕增强过度导致模型把真实车辆的纹理学偏,所以我一般关掉重度色彩抖动,只保留随机透视和亮度扰动,模拟白天黑夜的光照差异。
效果验收不能只看 mAP,因为违章检测是事件级任务。至少要看三类指标:车辆召回率、检测框稳定性(同一辆车连续帧的框抖动程度)、小目标召回率。前两个直接影响事件判定质量,最后一个决定远端车辆会不会漏掉。具体做法是跑一段模拟现场视频,手动标注“这辆车到底压线没有”,再和系统输出对比,统计事件层面的准确率和召回率,这比模型指标更贴近真实业务。
3. 车道线标定与越线判定算法实现
3.1 视频画面中的车道线标定
固定摄像头下最可靠的方式不是跑模型分割车道线,而是人工标定。找一个道路画面,用标注工具点选车道线的两个端点,记录像素坐标。一条车道实线可以用两个端点和类型(实线/虚线)表示。
为什么不直接用语义分割模型实时找车道线?因为分割模型在低照度、雨雪、逆光下抖动厉害,车道线识别一旦闪断,判定逻辑会被反复触发。固定机位的标定线是静态的,物理世界不变,像素坐标就不会变,稳定性好得多。如果摄像头有轻微抖动,建议做电子防抖或对关键帧做透视匹配,否则静态标定线的坐标会漂移。
还需要在画面上选择适用的 ROI 区域,把路口、停车线、渐变段排除在外,避免在非监测区域产生误报。
3.2 车辆位置特征:底边中点与轮胎投影
YOLOv8 输出的检测框是 (x1, y1, x2, y2),我们要提取的是车体与地面的接触位置。工程上可以用框底边中点 ( (x1+x2)/2, y2 ) 来近似。对这个近似要心里有数:
如果车辆正对摄像机行驶,底边中点通常落在前保险杠附近,和真实车轮接地位置偏差不大。如果车辆侧对摄像机,框底边是车侧面下沿,中点可能落在车体中间,这时需要用检测框宽高比做校正,让参考点向实际着地一侧偏移。俯仰角大的画面里,车离摄像机越远,底边中点越靠近车身中心,误差越大。这个误差可以通过透视变换或逆透视映射来纠正,但会增加计算量。折中方案是在压线判断阈值里加入一个随检测框高度变化的补偿量,让远车的容忍度相应放大。
3.3 越线事件状态机
单帧判断说明不了问题,必须看连续帧的变化。我用一个三状态机:
- 正常状态:车辆底边中点距所有车道线超过阈值 P。
- 压线状态:车辆底边中点与某条车道线的距离小于阈值 P,但没有越过线。
- 越线状态:车辆底边中点的有向距离符号发生变化,也就是从线的左侧到了右侧,或者从右侧到了左侧。
只有状态在“正常 → 压线 → 越线 → 压线 → 正常”的完整周期内出现过“越线”,并且车道线属性为实线,才生成违章记录。为了过滤抖动,每个状态必须连续保持至少 3 帧以上才切换;为了防止同一辆车在短时间内反复越线刷屏,要给每个跟踪 ID 加一个去重冷却时间,比如 30 秒内只上报一次。
阈值 P 不是固定像素,应该以车辆宽度为基准动态计算。例如检测框宽度 w,P 取 w 的 20% 到 30%。这样近处和远处的车辆都能用同一个相对尺度判断,否则近车容易误报,远车又判不出压线。
4. 核心代码与工程落地
4.1 推理与判定的Python实现
下面给出一段可运行的骨架代码,包含 YOLOv8 推理、车道线距离计算和事件判定核心逻辑。实际项目中需要把“车道”概念换成跟踪 ID 来维护多车辆状态,这里先演示基础逻辑。
import cv2 import numpy as np from ultralytics import YOLO # 车道线定义:(x1, y1, x2, y2, line_type) LANES = [ (300, 720, 700, 400, "solid"), (700, 400, 1100, 720, "solid"), ] PRESS_RATIO = 0.25 CONFIRM_FRAMES = 3 model = YOLO("yolov8s.pt") def point_line_side(px, py, lane): x1, y1, x2, y2 = lane[:4] cross = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1) return np.sign(cross) def point_line_distance(px, py, lane): x1, y1, x2, y2 = lane[:4] d = abs((y2 - y1) * px - (x2 - x1) * py + x2 * y1 - y2 * x1) d = d / np.hypot(y2 - y1, x2 - x1) return d state = {} def update_event(track_id, side, is_pressing, lane, box_w): if track_id not in state: state[track_id] = {"frames": 0, "last_side": side, "pressing": 0} s = state[track_id] if is_pressing: s["pressing"] += 1 else: s["pressing"] = 0 if side != s["last_side"]: s["frames"] += 1 else: s["frames"] = 0 s["last_side"] = side if s["frames"] >= CONFIRM_FRAMES: state.pop(track_id) return True return False主循环里,对模型输出的每个检测框取底边中点,遍历最近车道线,计算距离,再判断压线状态。代码里要注意的是:置信度阈值不要设太高,固定场景下 0.45 左右比较合适;太高会把低照度下的车漏掉。
4.2 触发上报与取证截图逻辑
事件层的输出不能只打印一行日志。交通取证对证据完整性要求很高,我一般会这样记录:
- 报警前 2 秒到报警后 1 秒的视频片段,以 H.265 编码存储。
- 报警时间段内连续三帧的 JPEG 截图,分别对应“压线开始、越线瞬间、越线结束”。
- 原始画面叠加检测框、车道线和参考点的绘制结果,方便人工复核。
- 事件 ID、相机 ID、车道线 ID、车辆跟踪 ID 等结构化字段写入本地数据库。
本地数据要通过消息队列上报到中心平台。如果网络暂时中断,设备端一定要有本地缓存和断点续传,否则现场设备离线一小时后数据全丢,对取证场景是不可接受的。这块我在项目中专门做了磁盘队列,保证事件可靠送达。
4.3 边缘设备性能优化
边缘部署时,我第一件事是转成 TensorRT FP16。YOLOv8s 在板卡上,FP16 推理通常比 PyTorch 原始推理快一倍以上。如果还嫌慢,可以先把画面 ROI 裁剪出来,只对路面区域做检测;或者把输入帧率从 25fps 采样到 10fps,事件判定仍然用跟踪结果补帧。
连续状态机要求的是“帧序正确”,不要求每帧都完整检测,所以抽样帧率控制在模型能稳定输出即可。批量处理在这种实时流场景下意义不大,因为单路视频当前只有一帧。反而要注意推理线程与 IO 线程分离:不能用阻塞式的视频读取在主线程里直接读帧,否则任何网络抖动都会拖垮推理节奏。用生产者消费者模式,读帧线程放队列,推理线程消费,事件判定再放到独立逻辑里,是稳定运行的基础。
5. 常见误报场景与排查实录
5.1 检测框抖动导致跨线闪烁
这是最典型的误报源。模型在单帧上偶发检测框尺寸抖动,底边中点跟着左右摆动,有时一帧在线内、下一帧在线外,状态机就以为越线了。解决方法是加确认帧数和去抖:连续多帧满足同一状态才切换;同时可以用跟踪结果平滑底边中点,对轨迹做卡尔曼滤波或移动平均。
我用移动平均窗口 3 到 5 帧,实测能把切换频率降一个数量级。要特别注意,平滑窗口过大会让真实越线事件发生滞后,可能导致报警截图错过越线瞬间。所以平滑窗口要根据实际采样帧率调整,10fps 下窗口 3 帧比较合适,25fps 下可以适当加大。
5.2 弯道与透视造成的距离失真
道路有弯道时,车道线在画面中是弧线,但工程上往往简化成直线段标定。直线段覆盖不到的弯道区域,车辆底边中点会被错误地归到线的一侧,产生误报。补救办法是把弧状车道线拆成多段折线,每段单独参与距离计算,取最近的一段距离。
另一个办法是做逆透视映射,把图像坐标投影到虚拟俯视图,在鸟瞰坐标下计算距离。这个方案需要相机内外参数和道路平面假设,弯道失真会明显减轻,但调试复杂度高。如果只是判定压线,折线拆分性价比更高,也更简单可控。
5.3 虚实线不分导致大批量误报
很多项目上线后误报率爆炸,原因不是模型差,而是把虚线变道当成了实线违章。视觉上虚线是断续的白短线,实线是连续长线,两者在画面中的形态差异不小,但对距离判定逻辑来说都是一条参考线。
建议在标定时直接给车道线加上 solid/dashed 属性,事件层只在目标线属性为 solid 时才报警。虚线不做违章判定,只作为“越线行为”的分析线索,这样误报量立刻能降下来。这个改动极其简单,但价值巨大,属于花十分钟省掉一个运维班。
5.4 夜间与遮挡导致的漏检
夜间是压线检测的重灾区。车身和路面灰度非常接近,小目标召回率下降明显,尤其是深色车在深色柏油路上几乎隐身。除了增加红外补光,最常见的手段是把检测置信度阈值降低,同时配合 ROI 过滤减少整幅画面的误检。模型训练时加入夜晚样本和亮度抖动增强也很关键。
车辆互相遮挡时,检测框会被部分截断,底边中点可能落在遮挡车上,而不是目标车辆身上。这种情况只能靠多角度机位覆盖或跟踪轨迹外推,指望单目相机完全解决不现实。我在项目里遇到后,选择在后方机位补了一个摄像头,重叠区域的事件用两个机位投票,遮挡导致的事件丢失率下降很多。
6. 验收指标与上线经验
6.1 用什么指标评估违章检测
事件级指标的评估方式和模型指标完全不同。我给客户报的数据一般分四层:
| 层级 | 核心指标 | 说明 |
|---|---|---|
| 检测层 | 车辆召回率 | 在全路段随机帧上统计,重点看小目标 |
| 事件层 | 越线事件准确率与召回率 | 人工复核后计算,区分漏报和误报 |
| 时效层 | 报警延迟 | 从事件发生到系统触发报警的时间,目标 2 秒内 |
| 稳定层 | 24小时运行稳定性 | 崩溃次数、内存泄漏、消息丢失率 |
这里最容易被忽视的是时效层。如果处理链路上有视频解码延迟、帧队列堆积、消息队列重试,事件发生十几秒后才出报警,客户会直接认为系统失效。所以上线前一定要做端到端的压测,确认从摄像头采集到中心收到报警的完整链路延迟在可控范围内。
6.2 上线后的运维细节
上线后维护的第一个坑是画面漂移。虽然摄像头大多数是固定的,但风吹、杆体震动、施工引起的角度变化都会让标定线偏离,导致整片区域误报。
我的做法是定期用车辆轨迹自动校验:如果某条车道线附近连续出现大量“压线”事件,而人工复核全是误报,多半是标定线偏移,需要重新标定。如果没有自动校验能力,至少要做周期检查,线上环境最怕“模型没变但画面变了”。
另一个坑是模型周期性更新。交通场景车型层出不穷,新车颜色、车身贴纸、电动车混入,都会让旧模型慢慢失效。按月度或季度用新截帧数据做一次增量训练,同时控制不回归旧样本,能保障长期可用。
最后分享一个我自己的体会:这类项目真正的工程量不在训练模型,而在把连续视频流里的空间关系稳定地转换成语义事件。项目收尾时客户愿意加钱的部分,从来不是 YOLOv8 换了哪个版本,而是你能把误报压到多低、把取证材料做得多么完整。在做任何监控类 AI 需求时,这条经验都成立。