我在一个车流统计的测试现场遇到过很有意思的情况:模型跑出来的mAP50有0.87,检测框画得也确实准,但路口统计到的车流量和人工数出来的差了将近两成。问题不在“识别”上,而在“计数”上。这也是我想写这篇文章的原因——基于YOLO系列算法做车型识别与计数,真正难的不是把检测模型调到多高精度,而是怎么把“看得见”变成“数得清”。
这篇文章我会把整套项目的沉淀做一个完整复现,从需求拆分、YOLO版本选型、数据准备、模型训练,到跟踪计数以及最终部署优化,全部基于我实际跑通过的技术方案。适合刚接触目标检测的学生,也适合想把车辆检测落地成统计系统的工程师参考。
1. 需求拆解与技术路线:为什么最终落在YOLO系列
1.1 系统到底要解决什么问题
这个项目最开始的诉求其实很简单:在某个路口场景下,把经过的车辆按类型区分出来,并且统计双向通行的车流量。车辆类型大概分成轿车、SUV、卡车、公交车、面包车、摩托车、三轮车七类。听上去不复杂,但仔细拆一下就有几个硬性要求。
第一是实时性。虽然不需要做到像自动驾驶那么极端的毫秒级延迟,但既然要统计路口车流,处理速度至少要跟上视频帧率,否则漏帧就会直接影响计数结果。第二是精度要稳,尤其是白天强光、傍晚逆光、夜间路灯三种光照条件下都不能明显掉链子。第三是部署环境受限,我手头只有一张中等性能的独立显卡,目标是在1280x720分辨率下跑出20帧以上的处理速度,同时还要预留CPU资源给计数逻辑和界面显示。
从这个角度反推,模型方案基本上就是单阶段检测器。两阶段检测器虽然在小目标上精度有优势,但速度满足不了路侧摄像头的实时处理需求。而在单阶段方案里,YOLO系列是工程生态最成熟、迭代最活跃的一条线,训练工具链完整,导出部署格式顺手,社区踩坑资料也足够多,这些都对开发效率和后期维护非常关键。
1.2 YOLO版本怎么选
现在提到YOLO系列,YOLOv5和YOLOv8是两条最主流的技术线,另外还有更新的版本。我的建议是别盲目追新,先看自己的任务类型和团队熟悉度。
YOLOv5的最大优势是稳定。训练脚本、模型导出、部署示例都极其成熟,很多工业项目都是拿它做基座的,遇到稀奇古怪的问题时基本都能查到解决方案。缺点是底层结构相对老一些,在部分小目标场景下需要更大输入分辨率才能拉回精度。
YOLOv8在结构上做了明显升级,C2f模块加上Anchor-Free检测头,让模型的收敛速度和精度上限都要好一些。更让我看重的是它内置了旋转框、姿态估计、分割等额外能力,虽然本项目用不到,但同一个工程框架内可以做更多扩展。部署方面,YOLOv8的导出和量化支持也比较完整,可以直接转成ONNX、TensorRT等格式。
如果要给两者做个粗略对比,可以参考下表,这是我基于实际训练结果整理的,包含一个中等复杂度的车辆检测数据集的实测量级:
| 对比项 | YOLOv5s | YOLOv8s |
|---|---|---|
| 训练收敛速度 | 相对慢一些 | 更快,大约省10%-20%轮次 |
| mAP50-95(同等条件下) | 较低约1-2个点 | 较高 |
| 小目标表现 | 需要更高输入分辨率补偿 | 对小型目标更友好 |
| 部署生态 | 非常成熟 | 成熟,对TensorRT支持好 |
| 扩展功能 | 需要额外改结构 | 自带多任务支持 |
最终我选择了YOLOv8s作为主力模型。原因很直接:在差不多的推理速度下,YOLOv8s的精度确实比YOLOv5s更高,而且后续只需改配置就能扩展成分割或姿态识别,对长期迭代更划算。
1.3 锚定硬件约束的选型决策
选型不能只看模型精度,还要看硬件能不能顶住。把YOLOv8的几个子版本放在一起比,n版本最快但掉精度明显,s版本是精度和速度都比较均衡的切入点,m版本精度更好但推理时间直接翻倍。
我当时的计算逻辑很简单:目标20帧以上,YOLOv8s在TensorRT FP16下处理1280x720输入大约需要25-35毫秒。如果换成m版本,耗时一般会跳到50-70毫秒,留给后续跟踪、计数逻辑的时间就很紧张了。所以我直接锁定s模型,把省下来的推理开销用于提高输入分辨率,这样对小目标的改善比单纯换大模型更划算。
多说一句,如果部署环境是CPU,那s模型也撑不住高分辨率实时推理,只能换n版本或者降低帧率要求。不要试图在纯CPU环境里跑一个完整的多车型计数系统,那会把大量精力浪费在优化上,挡住真正的业务目标。
2. 数据工作占了整个项目一半的力气
2.1 数据集这样配才够用
很多初学者拿到模型第一件事就是跑训练,但我的经验是,这个项目的成败在数据阶段就已经决定了。车辆检测公开数据集其实不少,包括面向自动驾驶场景的公开数据集和专门的交通监控数据集,但直接用它们训练出来的模型放到路侧监控画面上,效果往往不够好。原因是自动驾驶数据集多是车内视角,车辆大、遮挡少、光线相对统一,而路侧监控是高位视角,目标小、姿态复杂、光照变化大。
我的做法是把开源数据和自采数据按比例混用。自采数据从真实路口视频里抽帧,涵盖了清晨、正午、傍晚、夜间、雨天、雾天几种典型条件,再人工筛选出有效帧。混用比例大概在开源数据占70%、自采样数据占30%左右,这样既保证了类别和场景的多样性,又让模型对目标视角变化有足够适应力。
另外一个关键点是验证集要对时段做分层。不要简单随机切分,否则可能出现验证集里全是白天样本,夜间的指标反而被掩盖。我按来源视频分组切分,确保白天、夜间、雨天样本都同时出现在训练集和验证集中,这样评估出来的mAP才有参考意义。
2.2 标注规范与难例处理
标注听着是纯体力活,但规范直接决定模型上限。车辆类别的边界相对清晰,但有三类容易混淆:SUV和面包车、卡车和公交车、摩托车和三轮车。如果标注标准不统一,模型训练时就会收到互相矛盾的信号,后期调起来极其痛苦。
我针对容易混淆的类别定了一套规则:看车身结构而非尺寸,SUV是封闭式乘用车厢,面包车通常是单厢或短头设计;卡车和公交车区分看货箱和车窗布局,公交车车窗连续且面积大;摩托车和三轮车看轮子数量和车体宽度。规则定下来之后让标注团队先跑一轮,再由我抽检复核,出现分歧的样本放进“待讨论列表”逐张确认。
难例处理同样重要。车辆目标在路侧场景里有大量遮挡、截断、夜间模糊的情况。我最初只标了完整清晰的车,结果模型在真实路口的漏检率一直降不下来。后来专门补了一批遮挡和截断样本,即使车只露出一半也照常标注。标注框的默契是贴近可见部分,而不是凭经验脑补完整的车,这点在COCO数据集里也是通行做法。补齐难例之后,漏检率下降非常明显。
2.3 增强策略和样本平衡
车辆检测的类别天然不平衡,轿车和SUV样本量最大,三轮车、摩托车相对少很多。如果不处理,模型对尾部的几类车基本处于“选择性失明”状态。我这边除了简单统计类别数量做重采样之外,还把少样本类别的图像做了倍增式的离线增强,包括水平翻转、小角度旋转、亮度调整、饱和度扰动,每类至少保证总量不低。
训练时YOLOv8自带的Mosaic增强对这类场景非常有用,把多张图拼成大图再训练,等于让模型看到更丰富的上下文。我在前三分之二的训练轮次开启Mosaic,后三分之一关闭,因为一直开着Mosaic会让模型在小目标定位上不够稳定,后期关掉能帮助模型收敛到更精细的边界。
需要强调一点:水平翻转对车辆类型识别是安全增强,因为车的左右视角不会改变类别属性。但如果是那种要识别路牌文字、或者左右方向敏感的任务,翻转就要谨慎了。这个小细节曾经帮我避开一个很大的坑,在后文计数部分还会继续展开。
3. 训练过程的参数与调优,能省的时间都在这里
3.1 环境依赖:先让版本矩阵对齐
训练环境看起来简单,但版本不对会浪费大量时间。YOLOv8直接依赖的深度学习框架是PyTorch,而PyTorch和CUDA版本之间有一个兼容矩阵约束。我的建议是先确定显卡驱动支持的CUDA版本,再反推PyTorch版本,最后安装配套的检测框架和推理工具。
我在本项目中最终使用的组合是:PyTorch 2.1稳定版,CUDA 11.8,TensorRT 8.5。这个组合的优点是兼容性验证充分,社区资料多,导出ONNX和TensorRT后端时踩坑少。如果盲目装最新版的PyTorch,虽然能用,但某些第三方库可能还没跟上,部署时反而多出一堆兼容性问题。
安装依赖后还要做一次小验证,用一个几十张图片的子数据集跑通训练流程,确认数据加载、标注解析、增强管线都正常,再进入正式训练。别一上来就全量数据集跑,我见过不少因为标注文件格式错误,跑到一半才发现loss异常的案例,那才叫浪费时间。
3.2 参数是这么一组一组试出来的
训练参数里最关键的是输入尺寸、批量大小、周期数、优化器和学习率。输入尺寸我选用1280x1280作为训练尺寸,因为路口视频里大量车辆都属于中小目标,尺寸太小直接输在起跑线上。推理时的输入尺寸可以单独设置,我最终使用的是1280x720附近,在部署时会通过保持宽高比的letterbox方式预处理。
批量大小受显存限制,可以按这个思路估算:单张1080p三通道图像在FP16混合精度下约占3到5GB显存,如果把输入调整为1280x720并做归一化,一张图约占2到3GB,那么一块便宜卡上的16GB显存可以支撑较大批量。显存不够时优先减批量而不要减输入尺寸,因为分辨率对车辆小目标检测的影响远大于批次大小。
优化器我用了SGD加动量,配合warmup机制。很多教程默认用AdamW,但在目标检测这种高维稀疏监督任务上,SGD配好学习率之后的泛化能力通常更好。我的训练参数是初始学习率0.01,warmup持续3个epoch,权重衰减设置在比较保守的量级。使用EMA对权重做滑动平均,能让最终模型的指标小幅度提升而且更稳定。
3.3 训练曲线要看哪些指标
训练过程中我不太看训练集上的loss直接下降,因为那只能说明模型在“死记硬背”。真正有效的是看验证集上的mAP50和mAP50-95变化趋势。mAP50代表粗略定位能力,mAP50-95更严格,要求预测框和真实框重叠度更高,两者同步上升说明模型在正确学习。
如果发现训练loss持续下降但验证mAP停滞甚至回落,那就是过拟合信号。这时候我不会盲目延长训练,而是回头检查数据增强强度、增加正则或提前截断。YOLOv8支持早停机制,它会在验证指标连续若干周期不再改善时自动结束。我在项目里把早停耐心设置为30个epoch,既保证训练充分,也避免无效空跑。
还有一个小习惯:把中间权重定期保存下来,后期如果发现部署时对某种特殊光线效果不好,可以回头比较不同阶段权重,而不是一切从头再来。这个习惯在几次返工里救了我很多时间。
4. 从检测框到车流数字:跟踪与计数才是真正的核心
4.1 单帧检测为什么不能直接计数
模型训练完后,很多同学会以为把每一帧的检测框数相加就得到车流量。这个想法在测试视频里也许能看个大概,一旦放到真实路口就崩了。原因很简单:连续帧里同一辆车会被重复检测,你根本分不清这是刚才那辆车还是新来的车;再加上车辆走走停停,单帧统计会把同一辆车数成四五辆。
所以计数的前提是搞清楚同一辆车跨帧的对应关系,也就是多目标跟踪。只有给每个目标分配稳定ID之后,才能设计出去重和计数逻辑。车流量统计的正确做法是:检测每一帧中的车辆,然后对相邻帧的检测结果做关联,维护每个车辆的唯一标识,再根据标识穿越指定区域的情况来计数。
4.2 用ByteTrack做车辆级跟踪
多目标跟踪方案里,DeepSORT是经典选择,它依赖外观重识别特征来区分不同目标,但需要额外训练一个ReID模型,复杂度高。在本项目里我选用了ByteTrack,原因是它对车辆这种刚性物体非常合适,而且不需要额外特征提取网络,逻辑也容易理解。
ByteTrack的核心思路并不复杂:先做一些高置信度检测框之间的关联,再把低置信度框也当作关联候补,这样对于受到遮挡、短暂模糊的车辆,仍然有机会通过IoU和运动信息延续同一个轨迹ID。这一点对路口场景特别重要,因为车辆被电线杆或前车短暂遮挡,几乎每隔几秒就会出现一次。
我按官方开源实现做适配,把YOLOv8输出的检测框输入ByteTrack,设置最大丢失帧数在一秒左右,超过这个帧数没有重新匹配才判定轨迹结束。实测下来,白天场景的ID切换次数比传统IOU匹配降低了一大截,夜间低置信度框也能被正确吸收,计数稳定性明显改善。
4.3 虚拟计数线的设计与方向判定
有了轨迹ID之后,计数逻辑通常采用虚拟计数线的方式。在画面中画一条线,当车辆中心点跨越这条线时,记录一条通行记录。为了判断是进还是出,还需要知道车辆中心点在这条线两侧的先后位置。
实现上我维护了一个“跨线状态”字典,以车辆ID为键。当某ID首次出现时记录它相对计数线的方位;当该ID的轨迹点陆续移动,一旦发现中心点越过计数线,就记为一次有效通行,同时更新状态防止重复计数。这种方法比单纯检测框与线是否相交更稳定,因为框的边界波动很大,而中心点位置相对可靠。
方向判定在双车道或多车道场景可以进一步增强,例如在车道中央布置两条并行的虚拟线,根据两条线穿越的先后来区分左右方向。这个方案我在模拟项目X中验证过,比单向单线计数更精确,也能应对车辆在计数线附近变道的极端情况。
4.4 误检漏检的兜底方案
检测模型不可能100%准确,误检和漏检在计数系统里会被放大。我在项目里做了三层兜底,效果比较可靠。
第一层是ROI区域过滤。路侧摄像头的画面里,车道外的行人、广告牌、路牌常常被误检成车辆。我会在界面里手动画一个多边形区域,只统计检测框中心落在区域内的目标,区域外一律忽略。这个操作简单但作用极大,误检数量直接下降一个量级。
第二层是置信度阈值策略。YOLOv8的默认置信度阈值在0.25左右,但计数场景我建议把阈值适当提高,比如0.35到0.4,同时配合ROI过滤和跟踪约束。因为跟踪器本身有多帧确认机制,单帧偶发的低置信度框会被标记为不稳定轨迹,不会进入计数统计。
第三层是夜晚专项。夜间车辆开灯造成光晕,暗色车几乎和背景融为一体。我在数据阶段专门收集了夜晚样本,训练时也保留了夜间的增强策略。部署时如果检测到画面亮度均值低于某个阈值,会额外压低一些置信度要求,但会同时提高ROI外过滤的强度。这套组合拳跑下来,夜间计数准确率从不足六成提升到了接近八成五。
5. 部署加速与实测数据:模型跑起来只是另一段开始
5.1 从PyTorch到TensorRT的推理加速
训练时代的模型直接用于推理,速度通常是不够的。我先把YOLOv8权重导出为ONNX格式,再用TensorRT做FP16优化,推理耗时能压缩到原来的一半甚至更低。
导出过程里最值得注意的一个细节是动态分辨率。YOLOv8默认导出的是固定尺寸batch,如果我们想支持不同输入分辨率,就必须导出动态维度。但动态维度在TensorRT里需要额外配置优化Profile,如果设置不当,部署时会有很大性能损失或直接报错。我的经验是,计数场景的输入分辨率基本固定,尽量走固定尺寸,这样最稳也最快。
给一段最小的推理示例作为参考,这段代码只负责加载模型、跑检测、返回结果,实际的跟踪和计数逻辑在外部维护。
from ultralytics import YOLO import cv2 model = YOLO("best_fp16.engine") # 已转成TensorRT引擎 cap = cv2.VideoCapture("路口视频.mp4") while True: ret, frame = cap.read() if not ret: break results = model(frame, conf=0.35, iou=0.45, imgsz=1280, device=0) boxes = results[0].boxes for box in boxes: x1, y1, x2, y2 = box.xyxy[0].tolist() cls_id = int(box.cls[0]) score = float(box.conf[0]) # 这里把检测结果交给跟踪器和计数逻辑 cap.release()5.2 实测结果:精度与帧率
整套系统在模拟项目X的真实路口视频上做了基准测试,总时长大概一个多小时,包含白天、傍晚、夜间三段。最终指标我整理如下:
| 指标 | 白天 | 傍晚 | 夜间 |
|---|---|---|---|
| 车辆类型识别mAP50 | 0.91左右 | 0.89左右 | 0.82左右 |
| 计数准确率 | 约93% | 约88% | 约83% |
| 平均推理耗时 | 约28ms | 约30ms | 约33ms |
| 处理帧率 | 30帧以上 | 30帧左右 | 27帧左右 |
需要注意表格里的计数准确率指的是“模型系统统计数 / 人工离线复核数”。夜间准确率略低主要是因为暗色车和背景对比度太低,偶尔出现漏检。但如果调整置信度阈值后再叠加ROI过滤,能明显改善。
从维度上看,模型类型识别准确率和计数准确率是两回事。类型识别准了,不代表计数准;反过来也一样。所以在验收项目时,我会把两者分开来测,分别出报告,这在和需求方对齐时非常有用。
5.3 部署环节最常踩的几个坑
部署阶段的坑大多是版本和环境不一致引起的。第一个坑是TensorRT和PyTorch的算子支持不同步,某些模型层在导出ONNX时能成功,但在转TensorRT时却报“不支持的插件”。最好的应对办法是先用小模型试通一条转换流程,再切换到正式模型。
第二个坑是推理线程安全。计数系统往往要同时处理多路视频流,每路视频一个线程。如果所有线程共用同一个推理引擎,TensorRT上下文默认不是线程安全的,容易崩溃。我最后的做法是给每路视频分配独立的推理上下文,或者用批处理推理把多路帧合并成一个大batch,只做一次前向,再从结果里按索引拆分回各路。这样做效率高又稳。
第三个坑是letterbox预处理。YOLO训练时保留了原始宽高比,用灰边填充到正方形输入。部署时如果忘记做同样处理,模型精度会莫名其妙掉很多。这类问题很难排查,通常表现为模型在验证集上跑得好,真实视频里却各种漏检。我自己就因为这个原因浪费了整整一天,后来逐帧比对才发现是预处理不一致。
最后提一个经验收尾:整个项目走下来,我对“识别”和“计数”的关系体会特别深。很多人把精力全花在模型精度上,但真正让系统在路口产生业务价值的,是后面那条从检测框到轨迹、从轨迹到统计数字的链路。下次如果再做一个类似的流量统计项目,我会从第一天就把跟踪和计数逻辑设计进去,而不是等模型训练完之后才补。这套思路同样适用于人员流量、动物监测等场景,识别模型可以换,但计数链路的设计方法基本是通用的。