车辆类型检测这个方向,放在几年前还得靠传统图像处理加手工特征,又慢又脆,换个角度或者光线差点就崩。现在用YOLOv10这代检测器,配合一套整理干净的车辆数据集,再包一层UI界面,基本可以在一两天内把一套能用的识别系统搭起来,跑通图片、视频甚至摄像头实时检测。这篇文章就是基于我自己实际开发“基于深度学习的车辆类型检测系统(YOLOv10+YOLO数据集+UI界面+Python项目源码+模型)”这个项目的过程总结,从数据集处理、模型训练、界面封装到部署调优,把核心细节和踩过的坑都摊开讲。
这套系统最终能实现对轿车、卡车、公交车、摩托车等常见车辆类型的实时检测与分类,适合做交通监控辅助、停车场管理、车流统计这类场景,也适合正在做毕业设计或者想入门YOLO实战的开发者参考。整套代码基于Python,检测部分直接用Ultralytics YOLOv10,界面用PySide6实现,模型和数据集的准备过程全部可复现。
1. 项目整体设计与技术选型
1.1 核心需求拆解
项目标题里的关键词拆开看,其实就是三件事:检测模型、数据集、交互界面。但实际动手做的时候,你会发现这三件事互相牵制。
模型方面,要能识别车辆类型,不能只输出一个“车”的框,还得区分是轿车还是卡车,这就决定了数据集必须有类别标注,而且类别体系要提前定好。界面方面,用户不想每次改参数都去命令行敲代码,所以需要一个能选文件、显示结果、调置信度阈值的图形界面。Python源码和模型则是整个项目的交付物,源码结构要清晰,模型要能脱离训练环境独立推理。
我在开始之前把整个系统的角色拆成了三个模块:训练模块、推理模块、UI模块。训练模块只管数据加载和模型训练,推理模块把训练好的权重封装成可供调用的检测函数,UI模块负责接受用户输入并展示结果。模块之间不直接互相引用,UI调用推理接口,推理接口读取模型权重,这样后续换模型或者换界面框架都不会把代码搅成一团。
这种拆分逻辑,和写业务后端差不多:接口层、逻辑层、展示层分离。项目虽小,但按这个思路搭,后面加功能或者换技术方案都会轻松很多。
1.2 为什么选YOLOv10而不是其他检测模型
YOLO系列到目前已经迭代了很多版本,单就“车辆类型检测”这个任务,YOLOv10有几个点让我觉得特别合适。
第一,它去掉了NMS(非极大值抑制)环节。传统目标检测在预测完一堆候选框之后还要用NMS把重复框合并掉,YOLOv10通过双标签分配策略(训练时一个GT框同时分配给一对多分支和一对一分支,推理时只走一对一分支)把NMS直接省了。这意味着推理管线更短,TensorRT导出之类的部署步骤也少一个坎,对边缘设备部署更友好。
第二,它的模型结构继续沿用了C2f模块,并引入了PSA(部分自注意力)和SCDown(空间通道下采样),在大目标和小目标上的平衡做得比前代更好。车辆检测场景里,摄像头画面中远处的车往往只有几十个像素,这种小目标能不能稳住,直接决定了项目能不能用。
第三,也是我个人最看重的,Ultralytics的YOLOv10完全兼容之前的训练和导出习惯。YAML配置、命令行训练、pt/ONNX/engine导出这些流程和YOLOv8几乎一样,迁移成本几乎为零。网上关于YOLOv8的教程也一大堆,遇到问题很好查。
不过YOLOv10也不是没有缺点。它在一些极端小目标场景下,个别类别的召回率不如专门优化的模型,这对于车辆检测来说影响不大,因为车在画面里一般不会小到只有十来个像素。如果要做无人机视角的超远距离识别,那可能需要再用SAHI切片推理做辅助,这个后面会提。
1.3 技术栈与项目结构规划
项目技术栈如下:
- Python 3.10
- PyTorch 2.x + CUDA 11.8
- Ultralytics YOLOv10(源码安装或者pip安装均可)
- PySide6(UI界面)
- OpenCV(图像读取与摄像头采集)
- LabelImg(标注工具,主要用于补充自定义数据)
目录结构规划成下面这样:
text复制vehicle_detection_system/
├── app/
│ ├── main.py # UI入口
│ ├── detect.py # 推理封装
│ └── utils.py # 绘图、解析等辅助函数
├── models/
│ └── vehicle_yolov10.pt # 训练好的权重
├── datasets/
│ ├── images/
│ └── labels/
├── runs/
│ └── train/ # 训练日志与结果
├── config.yaml # 训练配置
└── requirements.txt
UI入口只负责界面事件,detect.py对上层暴露一个detect_vehicle(image_path)函数,返回检测框、类别和置信度。这样不管是做Web端还是做API服务,后续都只要改UI层就行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 车辆数据集准备与标注处理
2.1 数据集选型:公开数据集与自建数据互补
车辆检测有一个好处是公开数据集不少。我最初用的是一套公开的车辆检测数据集,包含轿车、卡车、公交车、摩托车几个常见类别,图片主要来自城市道路和高速公路监控视角,总共一万多张,标注格式是VOC XML。但实际跑起来发现两个问题:一是类别分布非常不均匀,轿车占了将近六成,公交车只有不到一千张,导致公交车训练时损失下降特别慢;二是傍晚和夜间图片占比很低,白天效果还行,一换到傍晚场景误检明显增多。
所以我在第二轮训练时做了两件事:
- 从公开数据集中按类别分层抽样,保证每类样本数量差距不要超过3倍。
- 自己补充标注了一批特定场景图片,比如小区地下车库出入口、工地周边、雨天路面,大概补了800多张,用LabelImg手动框选。
数据集中类别体系最终定为:car、bus、truck、motorcycle、bicycle、person。为什么不加van(面包车)?因为面包车和SUV在侧视角度下区分度很差,标准不一致会导致模型训练时梯度互相打架,我宁可把它归到car里,保证类内一致性比追求类别丰富更重要。
2.2 标注规范与格式转换
如果你用的是LabelImg,默认输出是Pascal VOC的XML格式,但YOLOv10训练需要的是txt格式的标注文件,每张图对应一个同名txt,每行是这样:
text复制class_id x_center y_center width height
注意这里x_center、y_center、width、height都是相对于图片宽高的归一化值。所以转换时不能直接把像素坐标填进去,要先除以图片宽高。
我的转换脚本核心逻辑如下:
python复制import os
import xml.etree.ElementTree as ET
def voc_to_yolo(xml_path, out_dir, class_names):
tree = ET.parse(xml_path)
root = tree.getroot()
img_w = int(root.find('size/width').text)
img_h = int(root.find('size/height').text)
lines = []
for obj in root.findall('object'):
name = obj.find('name').text
if name not in class_names:
continue
cls_id = class_names.index(name)
bbox = obj.find('bndbox')
x1 = float(bbox.find('xmin').text)
y1 = float(bbox.find('ymin').text)
x2 = float(bbox.find('xmax').text)
y2 = float(bbox.find('ymax').text)
x_center = (x1 + x2) / 2 / img_w
y_center = (y1 + y2) / 2 / img_h
w = (x2 - x1) / img_w
h = (y2 - y1) / img_h
lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}")
with open(os.path.join(out_dir, os.path.basename(xml_path).replace('.xml', '.txt')), 'w') as f:
f.write('\n'.join(lines))
转换完之后一定要做一次可视化检查,直接把标注框和类别画回原图,逐张抽查。一图胜千言,很多坐标转换的错误在数字上是看不出来的,画出来一眼就会发现框跑到树上了。
2.3 数据划分与增强策略
训练集、验证集、测试集的划分我用的是8:1:1,并且确保同一场景下的相似帧尽量进同一个集合,不然验证集里都是训练集附近的画面,评估指标会虚高。
数据增强方面,YOLOv10自带增强参数已经很强,我在训练时只额外开了Mosaic(默认开启)和MixUp(设为0.1)。对于车辆检测,重点说一下翻转:水平翻转可以放心开,车辆左右对称,语义不变;但垂直翻转千万别开,路面上的车倒过来就是底朝天了,模型会学到错误的空间关系。
光照变化是车辆检测数据增强里最值得做的一项。我额外加了HSV通道的随机扰动,色调扰动控制在0.015左右,饱和度和亮度扰动可以到0.3左右,这样可以模拟不同天气、不同时间段的光线,夜间的检测效果能明显提上来。
3. YOLOv10模型训练与调优全流程
3.1 环境配置与依赖版本
训练环境我分成了两种情况准备:本地单卡和服务器多卡。本地主要用来跑小迭代和Debug,服务器用来跑正式的长训练。
本地安装依赖时强烈建议用虚拟环境,避免装出“依赖地狱”:
bash复制conda create -n yolov10 python=3.10
conda activate yolov10
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118
pip install ultralytics
pip install pyside6 opencv-python
这里有个坑:Ultralytics的YOLOv10包名是ultralytics,模型名是yolov10n.pt、yolov10s.pt这类,如果你在旧教程里看到yolov8n.yaml之类的配置,不要混用。YOLOv10的模型定义文件和YOLOv8是分开的,虽然代码接口兼容,但配置内部不一样。
GPU版本一定要和PyTorch的CUDA版本对齐。我一开始图省事装了CPU版来调代码,结果数据集一加载就慢得没法忍,后面换成CUDA 11.8版本才正常。如果你只有CPU机器,也可以跑,就是训练时间会翻好几倍。
3.2 训练参数选择与含义
训练用的模型我是先跑一遍yolov10s作为基线,再视情况换yolov10m。车辆类型检测不是特别复杂的细粒度分类任务,s版本在精度和速度之间最平衡。
关键的训练参数如下表:
| 参数 | 取值 | 说明 |
|---|---|---|
| imgsz | 640 | 输入分辨率,一般场景够用,太低小目标容易丢 |
| batch | 16 | 取决于显存,8GB显存建议8,16GB可用16 |
| epochs | 120 | 配合早停,没必要死等 |
| patience | 20 | 验证集指标连续20轮不涨就停 |
| optimizer | AdamW | 比SGD收敛更快,调参省心 |
| lr0 | 0.001 | 初始学习率,迁移学习场景不建议太高 |
| weight_decay | 0.0005 | 防过拟合 |
| mixup | 0.1 | 轻微混合增强 |
| cache | True | 允许的话把图片缓存进显存/内存,训练快很多 |
无数新手在这一步会犯同一个错误:盲目追求大的imgsz。imgsz从640提到1280,训练时间至少翻四倍,而车辆检测本身是强纹理目标,中近距离的识别率并不会因为分辨率翻倍而获得等量提升,反而会让小模型学不过来。先640跑通一版,再看实际效果决定要不要提分辨率。
训练命令如下:
bash复制yolo train data=vehicle.yaml model=yolov10s.pt epochs=120 imgsz=640 batch=16 device=0
vehicle.yaml内容很简单,指定数据集路径和类别名列表就行了。
3.3 训练过程监控与指标解读
训练开始后,我习惯每隔10轮去瞄一眼runs/detect/train/目录下的results.png和验证集可视化图片。重点看三张图:val/box_loss、metrics/mAP50(B)、metrics/mAP50-95(B)。
box_loss下降说明模型在定位框的位置和大小上越来越准;mAP50和mAP50-95的区别是IoU阈值不同,前者只要求预测框和真实框重叠50%就算检出,后者要求更严格。车辆检测这种任务,mAP50达到90%以上、mAP50-95达到75%以上基本可用了。
如果发现训练中期mAP50一直不涨,但train_loss还在降,多半是过拟合了。我遇到过一次,检查原因发现验证集里夜间场景太少,训练集的夜间图像被模型直接背了下来。后面我把夜间图做了色调扰动扩充,又调整了验证集分布,指标才恢复正常。
训练完不要只看best.pt,也要看看last.pt。best.pt是按验证集指标选的,last.pt是最后一个epoch的,两者相差在1%以内说明训练收尾稳定。如果best和last差很多,说明早停阈值设置得太松,验证集指标已经过拟合了。
3.4 模型导出与部署格式选择
训练结束后,模型还在PyTorch的checkpoint格式里,直接拿到推理环境用没问题,但体积大、速度一般。我做部署时导出了两个格式:
bash复制yolo export model=runs/detect/train/weights/best.pt format=onnx imgsz=640
导出ONNX之后,基于C++生态或者OpenCV DNN都能直接跑推理,不再依赖PyTorch环境。如果你最终要端侧部署,比如放到NVIDIA Jetson或者类似的边缘盒子,再导出engine格式(TensorRT),推理速度还能再快一截。
踩过的坑:导出ONNX的时候,opset版本太旧会把PSA模块里的一些算子拆散,导致推理结果数值对不上。导出时手动指定opset=12以上的版本就能解决。
4. UI界面开发与推理部署
4.1 界面功能设计与交互流程
这套系统的UI我用PySide6来写,选它不是因为功能有多花哨,而是因为QSS样式表能比较方便地把界面做得像样,用户看着不是“学生作业”风格。
界面规划为四个区域:
- 左侧是功能面板,有“选择图片”“选择视频”“打开摄像头”三个入口按钮,和一个置信度阈值滑块。
- 中间主区域是结果显示画布,负责显示原图和叠加检测框后的结果。
- 右侧是检测信息列表,展示每一条检测结果:目标类型、坐标、置信度。
- 底部状态栏,显示当前使用的模型文件、推理耗时、FPS。
交互流程是:用户点击按钮加载文件,程序调用推理模块得到结果,在画布上绘制,同时把结果列表更新出来。摄像头模式下要支持连续帧检测,并且能通过“停止”按钮随时中断。
之所以把“置信度阈值滑块”放在这么显眼的位置,是因为不同场景下最佳阈值差别很大。白天光线充足,阈值拉到0.45都没问题;夜间画面噪点多,0.3能避免漏检关键目标。直接在界面上调,不用改代码重启,这是用户最在意的体验。
4.2 推理逻辑与多线程处理
推理逻辑封装在detect.py中,核心函数是:
python复制from ultralytics import YOLO
class VehicleDetector:
def __init__(self, model_path, conf_thres=0.35):
self.model = YOLO(model_path)
self.conf_thres = conf_thres
def detect_image(self, image_path):
results = self.model.predict(
source=image_path,
conf=self.conf_thres,
verbose=False
)
return self._parse_results(results)
def detect_frame(self, frame):
results = self.model.predict(
source=frame,
conf=self.conf_thres,
verbose=False
)
return self._parse_results(results)
def _parse_results(self, results):
detections = []
for r in results:
boxes = r.boxes
for box in boxes:
x1, y1, x2, y2 = box.xyxy[0].tolist()
conf = float(box.conf[0])
cls_id = int(box.cls[0])
name = self.model.names[cls_id]
detections.append({
'bbox': [int(x1), int(y1), int(x2), int(y2)],
'conf': round(conf, 3),
'label': name
})
return detections
UI线程和推理线程一定要分开。刚开始写的时候我直接在按钮回调函数里跑检测,结果检测一张大图要几百毫秒,界面直接卡死,窗口变白,标题栏显示“未响应”。后来用QThread把检测放到子线程,主线程只负责接收结果并刷新界面,问题就解决了。
摄像头取流同理,cv2.VideoCapture的read()方法要放在独立线程里缓冲,这样界面刷新和检测计算各干各的,不会互相阻塞。
4.3 性能优化与实时性保障
车辆检测的实时性,说到底是帧率问题。算力充足的情况下,YOLOv10s在GPU上能跑出80到100帧,CPU上可能只有3到5帧,差距非常悬殊。
我的优化路线是按顺序试的:
第一步,降低输入分辨率。界面里加了一个“检测分辨率”下拉框,默认640,可选的也有480和320。在夜间场景,480比640掉大概1%到2%的mAP,但推理速度快了将近一半,对监控场景完全够用。
第二步,模型换小。从s换到n,精度下降有限,但模型体积降到5MB级别,加载速度、推理速度都更快。对摄像头实时检测来说,这个交换是值的。
第三步,如果是NVIDIA显卡,导出TensorRT engine格式,推理速度比PyTorch直接跑有非常明显的提升。我在一个只有4GB显存的嵌入式设备上,用TensorRT跑YOLOv10n,640分辨率,帧率稳定在40帧以上。
还有一个容易被忽视的点:OpenCV的cv2.resize默认插值算法是双线性,检测前把帧缩小到640,画框的时候要按缩放比例把坐标映射回原图尺寸。不然小窗口显示没问题,全屏一看框全部偏移,这个问题我刚开始也踩过。
5. 实测效果、踩坑经验与调优思路
5.1 检测效果实测与边界情况
整套系统在我本地的测试集(不是训练验证集)上,轿车、公交车、卡车三个主要类别的mAP50都在90%以上,摩托车因为样本少且和电动车外观相似,大约84%左右,这个成绩对项目交付完全够用。
但实测中也发现几个边界情况特别有意思。
第一个是公交车和小型货车的混淆。城市里的中型货车和短头公交车在侧视角下,轮廓极其相似。模型有时候会把卡车顶上的货箱部位误判成公交车车厢,导致类别跳变。解决办法是在后处理里加了“类别平滑”:如果连续三帧同一位置的检测框类别在truck和bus之间跳变,就取置信度更高的帧作为确定结果。
第二个是夜间目标。暗光环境下,模型仍然能框住车,但置信度普遍下降0.1到0.2。我在界面默认阈值不写在代码里,而是从配置文件读取,用户端根据自己的摄像头安装位置调一次就好。
第三个是雨刷和水渍。下雨天挡风玻璃上的雨刷区域会被模型误检成person,因为雨刷摆动起来确实有一个类人形的移动范围。减少误检的办法是把person的置信度阈值调高0.05,或者干脆在交通监控场景下把person类别从输出列表里隐藏掉。
5.2 高频报错与解决方案速查表
从零开始复现这个项目的过程中,我整理了一份高频问题速查表,写在下面:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| CUDA out of memory | batch过大或输入分辨率过高 | 降低batch到8或4;开启cache=True时改为cache=False |
| 训练时loss变成NaN | 学习率过高或数据里有异常label | 降低lr0到0.0001;检查标注txt里是否有超出0~1范围的坐标 |
| 导出ONNX后推理结果偏差大 | opset版本过低 | 导出命令加上opset=12 |
| 摄像头界面卡死 | 推理阻塞了UI线程 | 将检测放到QThread子线程 |
| CPU推理非常慢 | 模型尺寸大 | 改用yolov10n;降低检测分辨率 |
| 检测框偏小,车轮被截掉 | 标注时框得太紧 | 标注时四周多留3~5像素,尤其在目标边缘位置 |
第二行那个“异常label”在自建数据集中出现的频率很高。我检查过一次,发现一张图片的标注坐标是1.8,明显超出归一化范围,原因是标注完图片被裁切过,但标注没跟着更新。这种数据会让训练梯度直接爆炸,最好的办法就是写一个数据校验脚本,训练前检查所有label是否在0到1之间。
5.3 从车辆检测到更多场景的扩展
车辆类型检测属于通用目标检测里比较典型的任务,做完这套系统之后,往别的方向迁移的成本其实很低。换个数据集就能做施工安全帽检测、道路缺陷检测、农作物计数等等。
我这里提两个我自己评估过的扩展方向。
第一个是把单帧检测升级成跟踪计数。当前这套系统每帧输出的是检测框,帧与帧之间没有目标关联。引入一个简单的IOU匹配或者用DeepSORT,就能统计某条车道在某个时间段内通过的车辆数量,这是交通管理场景里非常实际的需求。
第二个是引入SAHI切片推理做远距离小目标检测。监控摄像头画面中远处的车往往只有几个像素高,直接640分辨率推理很容易漏检。SAHI先把大图切成小块,每块独立推理再合并结果,小目标召回率会改善很多,代价是推理时间增加。这个方案适合离线处理高清监控录像。
这两个方向我都做了原型验证,跟踪计数在普通路口场景下准确率能到达可用水平,SAHI在1080p画面上小目标召回率提升明显。后续如果有人需要,我也可以把这些扩展单独写成一篇完整的实践笔记。
实际开发完这套系统,我最想分享的体会是:项目本身的门槛不在“跑通YOLO”,而在“把数据、模型、界面这三个环节衔接得不掉链子”。数据不好,再新的模型也白搭;界面太糙,再准的模型也没人愿意用。如果你正在做类似的项目,我的建议是先别急着把模型换大,认真检查一遍数据分布和标注质量,再做一轮可视化结果巡检,产出会稳得多。最后再补充一句,模型文件的备份要做好,我训练完best.pt之后还顺手导出了一份ONNX放到了不同目录,后来有一次不小心把pt覆盖了,ONNX版本直接救了场。
