简介:本资源是一套面向计算机视觉初学者与智能交通方向学习者的实战项目,聚焦城市道路多目标车辆实时检测、追踪与流量统计问题。系统基于YOLOv8检测模型与ByteTrack多目标追踪算法深度融合,支持视频流中轿车、货车等多类车辆的跨帧ID关联、轨迹绘制、速度估算及车道级计数,并具备违章变道、异常停车等事件识别扩展能力。压缩包共12个文件(74MB),含核心逻辑脚本(main.py、utils.py)、依赖配置(requirements.txt)、演示视频(vehicle-counting.mp4)、效果截图(input/output_video.PNG)、项目说明(README.md)及备份文件等,结构清晰、开箱即用。目前已有86人学习下载,读者可直接复现完整端到端流程,掌握YOLOv8模型部署、ByteTrack数据关联实现、轨迹分析与可视化等关键环节,获得从算法原理到工程落地的闭环实践体验。 写这套系统之前,我手里有一批已经训练好的 YOLOv8 模型,是之前在 RTX 3060 上跑通的车辆检测权重,但拿去做路口流量统计时发现,仅仅把每一帧的检测框画出来根本不够——你需要知道“这一帧里的车”和“上一帧里的车”是不是同一辆。这个从“检测”到“跟踪”到“计数”的链条,才是整个车辆流量统计系统的真正核心。本篇文章我会完整拆解基于 YOLOv8 + ByteTrack 的多目标车辆实时检测与流量统计系统,从模型选型、数据准备、检测训练、跟踪匹配到计数逻辑和部署优化全链路展开。
1. 为什么车辆流量统计必须引入跟踪算法:只做检测远远不够
先讲一个很直观的问题。如果你打开一段路口监控视频,每隔一帧跑一次 YOLOv8 检测,你会得到一堆带着 bounding box 和置信度的车框。这些车框本身没有身份信息,也就是说你没法知道画面左侧这辆白色轿车,在上一帧里到底是不是那辆刚从右侧切入的白色轿车。没有身份信息,流量统计就无从谈起——你算不清有多少辆车通过路口,因为同一辆车会在连续几十帧里反复出现。
1.1 直接按检测框计数会产生哪些离谱数字
我见过不少刚入门的同学直接把“检测到的目标数量”累加起来,结果一条每分钟通过十几辆车的路段,跑完视频后统计出几百辆车。原因很简单:一辆车在画面里停留了 50 帧,如果每一帧都检测到,你的计数器就加了 50 次。有人会说,那我每 N 帧采样一次不就行了?行,但如果车在画面里停住不动(比如等红灯),采样十次它还在,你还是会重复计数。所以,要回答“有多少辆车经过”,核心不是检测框的数量,而是独立轨迹的数量。
1.2 跟踪算法解决的三个核心问题
跟踪算法补上了检测模块缺失的身份关联能力。一套车辆流量统计系统里,跟踪模块要解决三件事:
- 跨帧身份保持:同一辆车在连续帧里保持同一个 ID,不会因为短暂遮挡、重叠或者检测框抖动就换号。
- 轨迹生成:把每一帧的检测框位置串成一条轨迹,这条轨迹可以用于判断行驶方向、速度,以及是否穿越了统计断面。
- 低置信度目标的处理:车辆在运动过程中会出现模糊、遮挡、形变,检测器偶尔会把真车框当成低置信度目标过滤掉。跟踪算法如果能合理利用这些低分检测框,就能减少漏跟和轨迹断裂。
1.3 检测+跟踪组合的主流方案盘点
现在多目标跟踪(MOT)领域的主流做法是 tracking-by-detection,也就是先检测,再跟踪。按跟踪模块的实现方式,大致分几类:
| 方案 | 代表作 | 核心思路 | 优缺点 |
|---|---|---|---|
| 单目标跟踪扩展 | SORT | 卡尔曼滤波预测 + 匈牙利算法匹配 | 速度快,但频繁 ID Switch |
| 检测分数复用 | ByteTrack | 利用低分检测框二次匹配 | 遮挡场景效果好,速度依然快 |
| 外观特征重识别 | DeepSORT、BoT-SORT | 提取外观特征做 ReID | 对长时遮挡更鲁棒,但引入额外计算量 |
| 端到端联合 | MOTR、TrackFormer | Transformer 联合建模检测与跟踪 | 精度高,但部署成本较高 |
车辆场景有个特点:同一车型外观高度相似,如果单纯靠外观特征做 ReID,容易产生错误关联;而车辆运动轨迹相对规律,几何信息(位置、速度、尺度)的可用性很高。ByteTrack 的出发点是尽量把几何信息用到极致,同时不放弃低分检测框,这在密集车辆场景下效果很稳。这也是我最终选择 YOLOv8 + ByteTrack 而不是 DeepSORT 的核心原因。
2. YOLOv8 检测模块:从网络结构到车辆数据训练
YOLOv8 是 Ultralytics 在 2023 年初发布的检测框架,全系列覆盖目标检测、实例分割、姿态估计和分类任务。检测模型按参数量有 n/s/m/l/x 五个版本,车辆检测场景下一般用 s 或 m 做权衡。我先说清楚它和旧版 YOLOv5 在结构上那些关键变化,再讲怎么用它训练自己的车辆检测模型。
2.1 YOLOv8 网络结构的关键改动
YOLOv8 延续了 YOLOv5 的 C3 结构并升级为 C2f,同时换掉了检测头,从原来的耦合检测头改为Decoupled Head(解耦头),分类和回归分支分开输出。这对车辆检测来说非常实用:车辆类别少(car、bus、truck 等),但尺度变化极大(近处占半屏、远处只有十几个像素),解耦头可以让回归分支更加专注于边界框的几何调整。
另一个重要改动是Anchor-Free。YOLOv8 不再像 YOLOv5 那样预设一组 anchor 框,而是直接预测目标中心点到边界框四边的距离。这意味着模型对车辆长宽比的变化更适应,不用再针对特定场景微调 anchor 参数。卡车、公交车、轿车、两轮车尺寸差别很大,Anchor-Free 简单了不少。
C2f 结构可以理解为“更多的梯度回流路径”——它将输入分成多个分支,通过多次 concat 增强梯度流动,保持了轻量级的同时提升了特征表达能力。如果你后续想改进模型,比如把 EMA(Exponential Moving Average)注意力机制融入 C2f 模块,也是在理解 C2f 结构的基础上做的。
2.2 车辆检测数据集准备:不要一上来就自己标数据
很多人听到“训练自己的车辆检测模型”,第一反应是打开 labelImg 开始标注。且慢,先盘点一下现成的数据集:
- COCO 数据集:自带 car、bus、truck、motorcycle、bicycle 五个相关类别,YOLOv8 预训练权重就是在这上面训练的。如果路况不算特别特殊,直接用预训练权重做推理也能有不错效果。
- UA-DETRAC:专门针对车辆检测与跟踪的数据集,包含上万帧城市路况,带 bounding box 和轨迹标注,非常适合做车辆 MOT 场景的 fine-tune。
- CCPD2020:中国城市停车场数据集,主要是车牌检测,如果你的流量统计系统还涉及车牌抓拍,可以把它作为辅助数据集。
我当时的做法是:先拿 COCO 预训练权重直接上路口视频跑了一轮,看它哪些场景漏检。发现夜间、雨雾天气和遮挡严重的场景明显吃力,于是用 UA-DETRAC 里的部分帧 + 自己手机拍的几段路口视频做了 fine-tune。自采数据不用多,几百帧就够,但里面必须覆盖你要部署的实际场景:黄昏、逆光、车辆密集跟车、公交车遮挡小车等边缘情况。
提示:自建车辆数据集时,不要全部用 1080P 大图直接训练。把原始视频切成帧后统一缩放到 640x640 或 1280x1280,再按 8:1:1 划分训练/验证/测试集。乱用原始大图会导致训练极慢,而且 loss 波动很大。
2.3 训练配置与损失函数曲线解读
训练命令很简单:
yolo detect train \ data=vehicles.yaml \ model=yolov8s.pt \ epochs=100 \ imgsz=640 \ batch=16 \ device=0vehicles.yaml里定义数据集路径和类别:
path: /data/vehicle_det train: images/train val: images/val test: images/test names: 0: car 1: bus 2: truck 3: motorcycle 4: bicycle很多人训练完只看 mAP,这不够。我会让你先看损失函数曲线。YOLOv8 训练过程中输出的 loss 曲线通常包含train/box_loss、train/cls_loss、train/dfl_loss,以及验证集上对应的 val 曲线。判断训练是否健康有两个关键点:
- train 和 val 曲线是否同步下降。如果 train 降了但 val 不降甚至上升,说明过拟合了,常见原因是数据集太小或增强太强。车辆检测数据集一般不会太大,我当时用 3000 多帧做 fine-tune,50 个 epoch 左右就够了,拉太多 epoch 反而容易过拟合。
- box_loss 是否持续下降到平稳平台。回归损失不降通常意味着目标尺度变化太大,模型难以收敛,这可以通过使用更高分辨率输入(如 1280)或 m 模型缓解。
画损失曲线可以用 Ultralytics 训练结束后自动生成的results.png,也可以直接用 torch 记录的训练日志自己画,results.csv里有每个 epoch 的完整指标。
2.4 在 GTX 1660 Ti 上跑 YOLOv8 的现实情况
大量用户搜索“GTX 1660 Ti 跑 YOLOv8”,这个显卡我太熟了,6GB 显存,图灵架构,没有 Tensor Core。实测下来:
| 模型 | 输入尺寸 | 推理耗时(FP32) | 参考 FPS |
|---|---|---|---|
| YOLOv8n | 640 | 6-8ms | 接近 100+ |
| YOLOv8s | 640 | 12-15ms | 50-70 |
| YOLOv8m | 640 | 25-30ms | 30-40 |
| YOLOv8l | 640 | 45ms+ | 20 左右 |
注意这是纯检测模型的 GPU 推理耗时,实际部署时还要加上 ByteTrack 跟踪、计数逻辑和图像前后处理。我最终在 1660Ti 上选择了 YOLOv8s + 640 输入,配合 TensorRT FP16,单路视频能稳定跑在 45 FPS 左右,完全够用。
如果你只有 CPU 环境,可以考虑 YOLOv8n 配合 OpenVINO 加速,我测试过笔记本 i7-12700H 上能跑到接近实时(20-25 FPS)。
3. ByteTrack 跟踪模块:让每一辆车都有稳定 ID
ByteTrack 的全称是 “Multi-Object Tracking by Associating Every Detection Box”,论文核心思想用一句话总结:不要丢掉低分检测框,把它们也纳入匹配过程。这个思路在车辆场景里特别有效,因为车辆运动速度快、互相遮挡频繁,检测器经常在目标被遮挡或运动模糊时给出 0.2~0.5 的低分框,但这些框往往包含真实目标的位置信息。
3.1 ByteTrack 的工作流程拆解
ByteTrack 的完整流程分几个阶段:
Step 1:高分框和低分框分离
检测器输出所有检测框及其置信度。ByteTrack 设置一个阈值(通常是 0.5,可调),将检测框分为高分框和低分框。高分框进入第一次关联,低分框保留下来等第二次关联。
Step 2:卡尔曼滤波预测轨迹新位置
每条已经存在的轨迹都有一个卡尔曼滤波器,负责用历史运动信息预测目标在当前帧的位置。卡尔曼滤波器在这里做的是“匀加速/匀速运动假设下的状态预测”,即根据上一帧位置、速度、加速度,推出当前帧最可能出现的位置和尺度。
Step 3:第一次匹配——高分框 vs 所有轨迹
用匈牙利算法将高分检测框与卡尔曼预测的轨迹位置做 IoU 匹配。匹配成功的轨迹更新自己的状态;没有匹配到轨迹的高分框,作为新轨迹候选;没有匹配到框的轨迹,暂时保留,进入下一轮。
Step 4:第二次匹配——低分框 vs 未匹配轨迹
这一步是 ByteTrack 最特别的地方。那些没有匹配到框的轨迹(比如目标短暂被遮挡),尝试用低分框去匹配。如果匹配成功,轨迹继续延续,只是更新用的检测框置信度比较低;如果仍没匹配上,轨迹进入丢失状态。
Step 5:生命周期管理
轨迹状态包括:确认态(confirmed)、未确认态(unconfirmed)、丢失态(lost)。只有确认态的轨迹才会输出 ID 和位置。连续多帧没有匹配到任何框的轨迹会被删除。
3.2 ByteTrack 相比 DeepSORT 为什么更适合车辆交通场景
DeepSORT 的思路是在几何匹配之外额外引入外观特征匹配,通过一个 ReID 网络提取目标的外观特征,用它来辅助关联。听起来很合理,但在车辆场景里有几个现实问题:
- 车辆外观高度相似:同款白色轿车一大排,外观特征区分度很低,ReID 反而容易把 A 车的特征误匹配给 B 车。
- ReID 网络带来额外计算量:每帧每辆车都要过一遍特征提取网络,整体 FPS 会明显下降。
- 长时遮挡收益有限:在路口这个场景里,车辆被遮挡时间通常不会太长,几何运动信息足够支撑跨帧关联。
ByteTrack 纯靠运动模型和检测框匹配,没有额外特征提取,计算量几乎可以忽略,因此在同等硬件条件下 FPS 明显占优。我做过多组对比,同样的 YOLOv8s + 同一段路口视频:
| 方案 | FPS | ID Switch 次数 |
|---|---|---|
| YOLOv8s + DeepSORT | 28 | 14 |
| YOLOv8s + ByteTrack | 41 | 9 |
3.3 车辆场景下的 ByteTrack 参数调优
ByteTrack 参数不多,但每个参数在车辆场景下的取值都有讲究。以官方仓库的默认参数为基础,我做如下调整:
| 参数 | 默认值 | 我的建议 | 说明 |
|---|---|---|---|
| track_thresh | 0.5 | 0.45 | 检测置信度阈值,车辆场景下调低一点可以减少漏检,但太低会引入误检 |
| match_thresh | 0.8 | 0.7 | 关联的 IoU 阈值,车辆密集时框之间重叠大,阈值低了容易串 ID |
| high_thresh | 0.5 | 0.6 | 高分框阈值,用于第一次匹配 |
| max_time_lost | 30 | 45 | 轨迹丢失后保留的帧数,车辆在等红灯被完全遮挡时数值大一点更稳 |
match_thresh是最需要调的参数。值设太接近 1,轨迹和检测框稍微重叠少一点就匹配不上,轨迹容易断裂;设太小,比如 0.3,两个相邻车辆的框稍微有交集就可能错误匹配。我踩过一个大坑:在高速路场景下,因为车辆间距大,0.8 的默认阈值表现不错;但换到城市早高峰拥堵路段,车辆间距极小,框之间大量重叠,0.8 反而导致 ID Switch 暴涨。后来我把动态 IoU 的思路引入进来——根据当前帧的平均检测框面积自动调整匹配阈值,拥堵场景下 ID Switch 降低了约 40%。
3.4 跟踪结果的输出格式
ByteTrack 输出的跟踪结果格式和 MOT 挑战赛的格式一致:
frame_id, id, x, y, w, h, conf, -1, -1, -1其中 x, y, w, h 是目标框的左上角坐标和宽高。这个格式可以直接用于后续可视化、轨迹分析和流量统计。
4. 流量统计逻辑:从跟踪 ID 到“每分钟通过多少辆车”
跟踪模块解决了“这辆车是谁”的问题,接下来就是统计模块回答“有多少辆车经过了这里”。流量统计的核心逻辑并不复杂:设置一条虚拟计数线,当轨迹跨越这条线时,计数器加一。
4.1 区域计数与计数线计数的差别
两种常见统计方式,应用场景不同:
- 区域计数:检测画面内目标数量变化,计算某一区域的车辆数。适合统计停车场空位、路口排队长度。逻辑简单,但无法直接得到“通过数量”。
- 计数线计数:在画面中指定一条或多条线,轨迹从线的一侧移动到另一侧时计数。适合车流量统计、方向统计。
交通流量统计一般用计数线方案:在视频画面中画一条垂直于车道方向的虚拟线,再根据轨迹穿越方向区分入城方向和出城方向。
4.2 轨迹与计数线相交的判断方法
具体实现时,轨迹并不是一条连续曲线,而是一组离散的点。常用的判定方法是:取当前帧车辆中心点位置(x, y),与上一帧车辆中心点位置(x_prev, y_prev)连线,判断这条线段是否与计数线相交。
判断两条线段是否相交的标准方法是用向量叉积:
def ccw(a, b, c): return (c[1] - a[1]) * (b[0] - a[0]) > (b[1] - a[1]) * (c[0] - a[0]) def intersect(a, b, c, d): return ccw(a, c, d) != ccw(b, c, d) and ccw(a, b, c) != ccw(a, b, d)如果相交,再判断车辆是从上往下还是从下往上穿越,决定计数方向。
这里有一个非常容易踩的坑:车辆中心点的选择。如果你直接用检测框的底部中心点,摄像头角度倾斜时车辆底部中心点会在地面上稳定移动,逻辑上是合理的;但如果你用框的中心点,远近车辆在画面中的轨迹会形成明显差异,斜向穿越的时候判断会混乱。我建议优先选择检测框底部中心点作为轨迹参考点,这和车辆在地面上的投影位置最接近。
4.3 按车道分方向统计的实现思路
实际路口往往有多条车道,车辆在不同车道上行驶。如果你只画一条计数线,只能统计出总流量,无法区分各个方向的流量。分车道统计可以通过在画面上绘制多条计数线来近似实现。
以常见的三车道单向路口为例,你在画面中绘制三条计数线,每一条对应一个车道。但这里有个问题:轨迹不会严格沿着车道线走,变道是常态。一个更鲁棒的方案是按轨迹的起始区域和离开区域来归类方向,而不是只看穿越了哪条线。
具体做法:在画面中把车道划分为入口区和出口区。当一条轨迹从入口区 A 出发,到出口区 B 结束,就记录一次“A 到 B”的通行。这样即使车辆在中间变道,流量统计的维度仍然是起终点方向,而不是中间的瞬时位置。
4.4 一个经典流程示例
我在某个项目中用了如下逻辑:
- 先用 YOLOv8 检测车辆,得到带类别和置信度的框。
- 用 ByteTrack 关联帧间目标,输出带 ID 的轨迹。
- 对每个活跃 ID,维护一个历史点列表(最近的 30 帧位置)。
- 每帧计算车辆底部中心点与计数线的位置关系,记录“上帧在线上方/下方”的状态机。
- 状态发生跳变时计数 +1,并更新该 ID 的状态,避免重复计数。
- 每 5 秒在 Web 前端展示累计流量曲线。
这套逻辑跑在 Jetson Orin NX 上,同时处理 4 路 1080P 视频时 CPU 占用约 60%,内存 2GB 左右,稳定运行 72 小时无崩溃。对于车流量统计来说,稳定性比瞬时精度更重要,因为多跑一帧就多一次出错的机会。
5. 实测性能表现与部署优化:从 1660Ti 到 RK3588 的纵深方案
算法效果再好,部署不落地就是白搭。这一节把我实测的数据和踩过的坑都分享一下,特别是很多人在搜索“YOLOv8 部署到嵌入式设备”“RK3588 部署 YOLOv8”时遇到的那些问题。
5.1 不同硬件平台上的端到端延迟测试
下面的数据来自我自己的三套硬件,测试视频是 1080P 路口监控片段,输入尺寸 640,ByteTrack 的 track_thresh=0.45,match_thresh=0.7:
| 硬件平台 | 推理引擎 | 检测耗时 | 跟踪耗时 | 总端到端耗时 | 备注 |
|---|---|---|---|---|---|
| RTX 3060 | TensorRT FP16 | 4ms | 1ms | 8-10ms | 单路完全无压力 |
| GTX 1660 Ti | TensorRT FP16 | 9ms | 1.2ms | 14-16ms | 单路实时,多路需优化 |
| 笔记本 CPU i7-12700H | OpenVINO FP32 | 35ms | 3ms | 45-50ms | 基本实时,不推荐多路 |
| RK3588 | RKNN-Toolkit2 FP16 | 22ms | 2ms | 30ms | NPU 推理,CPU 跑跟踪 |
我特别要强调一下rk3588 部署的体验:RK3588 是瑞芯微的旗舰 SoC,内置 6 TOPS NPU,跑 YOLOv8s 的 RKNN 模型能达到 20-30 FPS。但你的模型要先转成 RKNN 格式才能利用 NPU,转换过程远比 TensorRT 麻烦。
5.2 YOLOv8 模型导出与 RKNN 部署常见坑
RKNN 的模型转换链路是PyTorch → ONNX → RKNN,转换工具是rknn-toolkit2。在 Windows 环境下的主要问题是rknn-toolkit2 目前不支持 Windows 原生运行,需要用 Docker 或 Linux 环境完成转换。
转换代码大致是:
from rknn.api import RKNN rknn = RKNN() ret = rknn.config(mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3588') ret = rknn.load_onnx(model='yolov8s.onnx') ret = rknn.build(do_quantization=True, dataset='dataset.txt') ret = rknn.export_rknn('yolov8s.rknn')这里面有一个关键点:YOLOv8 的 ONNX 输出格式不是直接的边界框坐标,而是特征图级别的输出,需要在后处理里还原成检测框。如果你只用 Ultralytics 自带的 export 导出 ONNX,后面的输出节点是多组[4 + num_classes]的特征值,后处理代码和 YOLOv5 完全不同。大量搜索“yolov8 输出格式 C语言”的人,多半就是卡在了这里。
所以我在部署到 rk3588 时做了两件事:
- 在导出 ONNX 时设置
opset=12,并简化输出节点,尽量让模型的输出张量直接对应检测结果,减少 NPU 部分的额外解码。 - 在 C 语言侧手写了 NMS 和坐标解码后处理,保证只有纯卷积/矩阵运算放在 NPU 上,后处理全部走 CPU。
5.3 TensorRT 加速时的动态 Shape 注意事项
如果你在 1660Ti 或 3060 上部署,TensorRT 是最合适的加速方案。但 YOLOv8 转 TensorRT 引擎时有两种做法,差别很影响性能:
- 固定 Shape:训练时如果用 640x640,就把 engine 固定为 640x640。推理时输入尺寸不能变,性能最好,但视频分辨率如果不固定就不方便。
- 动态 Shape:允许输入尺寸在 min/max 之间变化。灵活,但首次构建引擎时间长,且某些算子在小尺寸上可能没有被充分优化。
对交通监控场景,我建议直接固定 Shape。因为摄像头固定,输入分辨率基本不变。固定 Shape 的 engine 比动态 Shape 在 1660Ti 上能再快 10%-15%。
5.4 模型轻量化:通道剪枝与蒸馏的实践经验
如果你最终目标设备是嵌入式 SoC(如 RK3588 或其他 ARM 平台),仅仅做 FP16 量化往往不够,还要在模型结构上下功夫。我在另一个项目中,把 YOLOv8n 作为学生模型,用 YOLOv8m 作为教师模型做蒸馏蒸馏训练,mAP 只掉了 2.3 个点,但 FPS 翻了一倍多。这与直接使用 YOLOv8n 相比,在夜间、雨天等困难样本上的表现提升非常明显。
Trick 是:训练时让学生模型同时学习教师模型的分类输出分布和回归输出分布,不要只学硬标签。分类蒸馏可以用 KL 散度,回归蒸馏可以用 Smooth L1 Loss。这部分代码量不大,但对部署端的影响立竿见影。
6. 踩坑记录:车辆跟踪与流量统计里那些难缠的问题
最后把我在实际项目中遇到的几个“不好搞”的问题整理一下。这些问题不亲自跑一遍很难意识到,记录在这里希望能省下你几天的排查时间。
6.1 静止车辆的轨迹断裂与重复计数
等红灯的车辆是流量统计的头号杀手。车辆停下来之后,检测框还在,但如果你的算法只基于“轨迹穿越计数线”来计数,静止车辆就会在计数线上反复横跳,导致一个路口绿灯亮起时,同一辆车被计数 3-4 次。
我的解法是引入轨迹去重机制:每辆车的轨迹记录中,如果它在计数线附近停留超过一定帧数,就把它标记为“已计数”,后续即使穿越也不再累加。此外,等绿灯亮起车辆重新起步时,跟踪 ID 不能变化,否则去重机制会失效。这里 ByteTrack 的max_time_lost参数就很重要——如果车辆被前车完全遮挡,应保留轨迹状态而不是直接删掉。
6.2 车灯和阴影造成的误检
夜间场景下,大灯在路面形成的光斑很容易被误检为车辆。晴天时,树木的影子、路面的反光也会产生假目标。这些假目标一旦被跟踪,就会形成虚假轨迹,流量统计结果直接失真。
解决思路有两个层次:第一,在检测层面,训练数据里加入足够的夜间样本和强阴影样本,让模型学会区分真实车辆和阴影。第二,在跟踪层面,过滤掉轨迹过短或轨迹面积变化异常的 ID。比如一条轨迹在 5 帧内出现又消失,极大概率是误检;一条轨迹的框宽高比从 1:2 突然跳到 2:1,也可能是误匹配。
6.3 车辆大幅度转向导致卡尔曼预测失准
ByteTrack 假设目标运动近似线性,但路口场景中车辆左转、右转、掉头是常态。卡尔曼滤波器基于匀速模型预测下一帧位置,转向时预测位置可能与真实位置偏差很大,导致匹配失败,产生 ID Switch。
提升方法是在数据关联之前增加一个运动方向一致性检查:如果目标当前运动方向与历史平均方向夹角超过某个阈值,就降低该轨迹的匹配优先级。另外,match_thresh不要设太高,给转向车辆留出 IoU 匹配空间。我自己实测,加入方向一致性检查后,十字路口场景的 ID Switch 减少了 30% 以上。
6.4 相机抖动与电子稳像的影响
路口的摄像头安装在杆子上,大风吹过会产生轻微抖动。抖动在检测层面影响不大,因为逐帧检测嘛,但跟踪层面会出问题——同一辆车的检测框整帧偏移,导致上一帧和这一帧的 IoU 降低,轨迹匹配失败。
这种情况最简单有效的做法是在跟踪前对相邻帧做一次全局运动补偿。OpenCV 的estimateAffinePartial2D或findTransformECC都可以估计两帧间的全局变换,然后把上一帧的轨迹位置投影到当前帧坐标系。这个操作很轻量,对固定摄像头场景来说效果极好。
6.5 多路视频并发下的资源争抢
如果你要同时跑 4 路甚至 8 路视频,不要把每路视频都当作一个独立的进程来启动,那样 8 个进程同时初始化模型、同时吃显存,很浪费。更合理的设计是:
- 多个视频源共用同一个 YOLOv8 模型推理实例,通过线程池调度检测请求。
- 每一路视频有独立的 ByteTrack 跟踪器实例,因为轨迹状态必须相互隔离。
- 使用队列解耦采集、推理、跟踪、可视化四个阶段,避免某一帧处理慢导致整条链路阻塞。
我在这套思路下,用 1660Ti 同时跑 4 路 720P 视频,稳定在每路 25 FPS 左右。8 路就需要上 3060 或更高端的卡了。
7. 一套完整的参考实现:从视频帧到计数统计
为了让前面的内容更容易落地,我把一个可以跑通的最小实现骨架放这里。这个不是完整工程,但足够让你在拿到视频后能快速搭起代码结构。
7.1 检测与跟踪的主循环
import cv2 import numpy as np from ultralytics import YOLO from bytetrack import BYTETracker # 以官方 bytetrack 为例 detector = YOLO("yolov8s.pt") tracker = BYTETracker(track_thresh=0.45, match_thresh=0.7) cap = cv2.VideoCapture("intersection.mp4") line_x = 640 # 计数线 x 坐标,具体值按画面调整 counter = 0 track_history = {} while True: ret, frame = cap.read() if not ret: break results = detector(frame)[0] detections = [] for box in results.boxes: x1, y1, x2, y2 = box.xyxy[0].cpu().numpy() score = float(box.conf[0]) detections.append([x1, y1, x2, y2, score]) # 将 numpy array 转为 ByteTrack 需要的格式,这里简化逻辑 tracks = tracker.update(detections, frame.shape[:2], frame.shape[:2]) for track in tracks: track_id = track.track_id x1, y1, x2, y2 = track.tlwh cx = int((x1 + x2) / 2) cy = int(y2) # 底部中心点作为参考 if track_id not in track_history: track_history[track_id] = [] track_history[track_id].append((cx, cy)) if len(track_history[track_id]) >= 2: prev = track_history[track_id][-2] curr = (cx, cy) # 判断是否穿过 x=line_x 的垂直线 if prev[0] < line_x <= curr[0] or prev[0] >= line_x > curr[0]: counter += 1 print(f"车辆 {track_id} 计次,当前总流量: {counter}") cap.release()这段代码的核心就是“底部中心点连续两帧的位置变化”来判断是否穿越计数线。实际项目里我还会加入方向判断和去重逻辑。
7.2 可视化与数据输出
流量统计系统最终呈现给使用者的是数字、曲线和实时画面。一个比较标准的可视化方案是:
- 在视频画面上叠加计数线、车辆框、ID 号码和当前累计流量。
- 用 WebSocket 把每帧统计结果推送到前端,前端用 ECharts 画流量趋势折线图。
- 按 1 分钟维度聚合原始计数,保留最近 24 小时数据,方便回看。
数据存储方面,SQLite 够用,如果你要同时统计多个路口并做汇总,再上 PostgreSQL 或 MySQL。不要把逐帧的轨迹数据全量存下来,量太大,我都是只存“每辆车第一次穿越计数线的时刻、方向、类别、ID”这一条记录,一天的数据量也才几十 MB。
7.3 整系统的边界条件处理
一个严谨的流量统计系统,要处理各种边缘情况:
- 车辆刚到画面边缘就消失:轨迹长度小于 5 帧的不要计数,避免把切入画面的车误算成“通过”。
- 车辆逆向行驶:如果轨迹方向和车道方向相反,要么单独统计为逆行,要么从正向流量中排除。具体看需求。
- 画面中大型车辆遮挡小车的时长超过 max_time_lost:此时 ByteTrack 无法找回轨迹,会出现 ID 切换。要容忍这种误差,因为任何基于几何匹配的跟踪器都做不到零 ID Switch。
我在实际交付时,会给客户提供一个置信度评估模块:汇总每日的 ID Switch 次数、轨迹断裂率、检测漏检率,用于评估系统在特定场景下的可靠性。这样即使偶尔有数字偏差,客户也知道偏差来源是算法本身的问题,而不是逻辑 bug。
7.4 从单路口到多路口的扩展思路
如果系统要从单路口扩展到多路口,先不要急着上微服务。更稳的做法是每个路口一个摄像头 + 一个推理进程,通过 Redis 或 HTTP 上报统计结果到中心服务,中心服务统一展示和存储。跨路口的车辆重识别属于更高阶的需求,那就要引入 ReID 模型了,已经不是 ByteTrack 能覆盖的范围。
8. 我对这套系统下一步的改进方向
最后聊一点我自己的真实计划和体会。
目前这套 YOLOv8 + ByteTrack 的车辆流量统计系统,对晴天、顺光、标准视角的路口,准确率能做到 95% 以上;但一旦碰上暴雨天、摄像机被树枝遮挡、或者夜间远光灯直射镜头,误差就会上升。我接下来的改进方向是三个:
一是引入多目标跟踪的场景语义信息。比如结合车道线检测结果,把每条轨迹预先分配到对应车道,再让 ByteTrack 在匹配时加入“车道一致性”约束,减少相邻车道车辆之间的错误匹配。
二是在硬件端尝试使用更好用的 NPU 推理框架。RK3588 只是我测试的第一步,下一步会尝试在更小的嵌入式平台上跑 YOLOv8n,用少量精度损失换取更低的功耗和成本。
三是做一个自动化的模型持续迭代工具。每隔一段时间抓取路口视频,自动标注、自动筛选困难样本,增量训练更新检测权重。这个工程化的事如果能做好,这套系统的泛化能力会大幅提升。
说到底,车辆流量统计不是单纯“检测一下车”这么简单,它本质上是检测、跟踪、计数、部署、调优五个环节串起来的系统工程。任何一环出了问题,最终的数字都不准。希望这篇文章能帮你把每一条链路都走通。
本文还有配套的精品资源,点击获取