在停车场出口蹲了几天、手动数了一千多辆车的类型之后,我决定把这个流程自动化。那会儿手头正好要做一个车辆类型识别检测的课题,需要一套能跑通的完整方案:深度学习模型负责从画面里框出车辆并判断类型,后面还得接一个界面,让不懂算法的人也能上传视频、看结果。我选的是 YOLOv10,配合公开的 YOLO 格式数据集,最后用 Python 把推理逻辑和 UI 界面串起来。这就是这篇博文想分享的东西:一个从数据集、训练、评估到界面部署的完整链路,以及我在这个过程中遇到的、值得你绕开的几个坑。
这套东西适合谁看?如果你正在做类似的课程设计、课题项目,或者想给园区、停车场这类场景快速做一个车辆识别工具原型,那这篇内容基本可以照搬。如果你是第一次接触 YOLO 系列,也没关系,我会把环境配置、数据组织、训练命令、界面线程这些环节拆开讲,尽量让每个步骤都能复现。
1. 车辆类型识别到底解决什么问题:一个被误以为“很简单”的场景
1.1 我在什么场景下意识到需要它
车辆类型识别听起来不像人脸识别那么高深,真做起来才发现它不是“给车拍张照、告诉你是啥车”这么简单。我当时的需求来源是某个园区出入口的车辆统计:管理人员想知道进来的车有多少是轿车、多少是 SUV、多少是货车,这直接关系到停车位分配和收费策略。人工登记当然能办,但高峰期出入口车辆排队,登记速度和准确率都撑不住。
如果用摄像头自动识别,事情就变成两个子任务:先要在画面里找到“车”这个目标,再判断这辆车属于哪个类型。这就是目标检测任务,而不能只做分类。一张图里可能同时出现好几辆车,有的停在远处小小的,有的被栏杆挡住一半,还有逆光、夜间、雨天这些干扰。模型必须同时输出“车在哪里(边界框)”和“是什么类型(类别标签)”,而不是像图像分类那样只给整张图打一个标签。
1.2 车辆类型的“类内差异大、类间差异小”难题
车辆类型识别真正的难点在于:同样是轿车,三厢、两厢、跑车、老头乐,外形差异可以很大;而轿车和 SUV 在侧后方角度看,轮廓又可能非常接近。小型货车和大型 SUV 在某些角度下也容易混淆。这就是典型的类内差异大、类间差异小问题。
另外还有一个实际业务中躲不开的问题:同一个类别在不同数据集里的定义不一样。有的数据集把轿车和 SUV 合并成 car,有的把皮卡单列,有的把面包车归到 van,有的又把微面归到货车。如果直接拿别人训练好的权重来用,类别体系和你现场的需求大概率对不上。所以这个项目里,我建议你第一件想清楚的事不是“用什么模型”,而是“最终要区分哪几类车”。模型结构和训练技巧只是手段,类别体系才是业务需求的起点。
1.3 “检测”和“分类”在这里的边界
顺便说一个容易被绕进去的问题:有些方案会用“先抠图、再分类”的两阶段流程,比如先调用一个通用车辆检测器把车框出来,然后对每个框做车型分类。这样做不是不行,但流程复杂、误差也会叠加:检测器漏检,后续分类再好也没用;检测框偏了,分类输入就脏了。
YOLOv10 这类单阶段检测器把“定位 + 分类”一步完成,网络直接预测每个候选框的位置和类别概率,训练和推理都更简洁。对于车辆类型识别这种类别数量不多(常见就四五类到十几类)、对实时性又有一定要求的场景,单阶段方案是更务实的选择。
2. 数据集才是真正的分水岭:YOLO 格式数据准备与增强方案
2.1 数据从哪里来、怎么整理成 YOLO 格式
模型效果的上限由数据决定。我当时最开始用了一个看起来数量很足的公开车辆检测数据集,结果训练出来的模型在实际场景里表现很差,原因很简单:公开数据的拍摄角度是路侧视角,而我的应用场景是出入口俯视/平视视角,车辆形态差异比较大。后来我做了一次数据清洗和补充,效果立刻上了一个台阶。
YOLO 格式数据集的结构并不复杂,核心是 images 和 labels 两个目录,分别存放图片和对应的标注文本。一张图片对应一个同名 txt 文件,txt 里每一行代表一个目标:
类别id 中心点x 中心点y 宽度 高度注意,这四个坐标值全部是相对于图片宽高的归一化小数,范围在 0 到 1 之间。比如一张 1920x1080 的图里,一辆车中心在像素坐标 (960, 540),宽 480,高 360,那这一行就是:
0 0.500 0.500 0.250 0.333如果你拿到的公开数据集是 COCO 格式或者 VOC 格式的 XML 标注,需要先做一次格式转换。我当时写了一个小脚本,把 XML 里的左上角右下角坐标转成 YOLO 格式。核心逻辑是这样的:
import os import xml.etree.ElementTree as ET def convert_xml_to_txt(xml_path, out_dir, class_map): 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'): cls_name = obj.find('name').text if cls_name not in class_map: continue cls_id = class_map[cls_name] box = obj.find('bndbox') xmin = float(box.find('xmin').text) ymin = float(box.find('ymin').text) xmax = float(box.find('xmax').text) ymax = float(box.find('ymax').text) cx = (xmin + xmax) / 2 / img_w cy = (ymin + ymax) / 2 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") with open(os.path.join(out_dir, os.path.splitext(os.path.basename(xml_path))[0] + '.txt'), 'w') as f: f.write('\n'.join(lines))转换完一定要做可视化检查,最简单的方法是把标注画回图上,肉眼瞄一遍有没有坐标错位、类别标错、框超出边界。坐标转反、除错宽高、类别 id 对不上,这些错误在纯文本里根本看不出来,画出来一下子就能发现。我建议至少抽查 10% 的图片。
2.2 类别体系设计:宁可少而准,不要多而乱
类别体系的设计直接影响标注成本和模型难度。如果场景只需要区分“客车、货车、轿车、SUV”,那就不要硬塞进十几二十个细分车型。你在网上看到某些数据集有几十个车辆类别,看起来很强大,但训练难度和数据均衡问题都会相应增加。工业落地时,类别越少越稳。
我最后用的方案是 4 类:car(轿车/两厢/旅行车合并)、suv、bus(客车/中巴/大巴)、truck(货车/皮卡)。这个体系的好处是:类别边界比较清晰,标注歧义小;类别之间的外观差异大,模型容易学;对大多数园区、停车场的业务需求已经够用。如果后续需要更细的车型识别,可以在界面上保留“扩展类别”的口子,再逐步补充数据训练。
2.3 数据增强与样本均衡:把你的“底气”补足
车辆检测任务里的数据增强,除了常规的翻转、缩放、亮度变化,我更推荐加入 mosaic、mixup 这类高级增强。YOLOv10 自带的增强管线里,mosaic 会把四张图拼在一起训练,这能显著提升模型在小目标场景下的表现,因为拼图会让车辆在画面中的相对尺寸变小。
但数据增强不是越多越好,过强的增强会导致模型学到的分布与真实场景偏离。我实测下来,训练初期开启 mosaic 效果很好,训练后期最好把它关闭,让模型在接近真实分布的数据上精调。Ultralytics 的配置里可以用mosaic=0.0关掉,或者通过close_mosaic参数控制在最后若干轮关闭。
样本均衡也要注意。如果数据里轿车占了 70%、货车只有 5%,模型学出来会对货车非常不敏感。最简单的做法是:统计每个类别的图片数量,对少数类做上下采样,或者复制少数类样本并做轻微增强。我当时货车的样本明显偏少,就额外采集了一段货车较多的视频,抽帧补充标注,把类别比例拉到了大约 4:3:1.5:1.5,后面 mAP 的提升很明显。
3. YOLOv10 适配项目的几个关键点:环境、训练配置与模型输出
3.1 为什么选 YOLOv10 而不是 YOLOv8
YOLOv8 已经很好用,社区资料也多,但我在这个项目里选 YOLOv10 主要看中两点:一是它去掉了 NMS 后处理,推理时直接输出最终预测结果,少一道环节就少一个可调参数,管起来省心;二是它的模型结构在效率和精度之间做了重新平衡,轻量级的yolov10s在车辆这类不算极端的检测任务上,精度足够且速度更快。
YOLOv10 的双标签分配策略简单说就是:训练时用一对多分支提供丰富的监督信号,推理时用一对一分支直接产出无冗余的预测框,不再需要 NMS 来去重。这对 UI 集成特别友好,因为模型输出的张量直接就是目标框列表,不需要再额外写非极大值抑制代码。
当然它也有缺点,最明显的是生态和兼容性不如 YOLOv8 成熟。导出 ONNX 或 TensorRT 引擎时,不同版本之间的输出层结构可能会有变化,部署时需要注意。如果你只是做实验、跑课程设计,YOLOv10 完全没问题;如果你想快速找一个全网都有教程的模型,YOLOv8 其实也够用。两者训练数据格式完全一致,从 YOLOv8 切到 YOLOv10 的成本很低。
3.2 环境搭建:版本匹配是最大的坑
环境配置本身不难,难的是版本匹配。Ultralytics 官方仓库对 Python 版本、PyTorch 版本、CUDA 版本都有要求。我遇到过的典型问题是:装了最新版 PyTorch,但显卡驱动里的 CUDA 版本太老,导致torch.cuda.is_available()返回 False,所有训练都跑到 CPU 上去了。
我建议先确定 GPU 驱动支持的 CUDA 版本,再反过来选 PyTorch 版本,不要无脑装最新。一个稳妥的组合是 Python 3.10 + PyTorch 2.0 以上 + CUDA 11.8 或 12.1,配合ultralytics包。安装命令很简单:
pip install ultralytics它会自动带上所需的依赖。装完先跑一行:
import torch print(torch.__version__, torch.cuda.is_available())如果输出False,优先检查驱动和 PyTorch 的 CUDA 版本是否匹配。还有一个容易被忽略的问题:机器上可能装了好几个 Python 环境,pip对应的解释器和 IDE 里选的解释器不是同一个。我前前后后因为这个浪费了小半天,建议一开始就用虚拟环境,比如 venv 或 conda,把所有依赖装在同一处。
3.3 训练配置与命令:一份可以直接抄的参数表
数据准备妥当之后,需要先写一个数据集描述文件 data.yaml:
path: ./datasets/vehicle train: images/train val: images/val nc: 4 names: ['car', 'suv', 'bus', 'truck']然后就是训练命令。Ultralytics 提供了命令行和 Python API 两种方式,我在项目里用的是 Python API,方便把参数集中管理、顺便记录日志:
from ultralytics import YOLO model = YOLO('yolov10s.pt') model.train( data='vehicle_types.yaml', epochs=200, imgsz=640, batch=16, patience=30, optimizer='AdamW', lr0=0.001, device='0', workers=4, val=True, plots=True )这些参数里,我实际使用后的建议是:
imgsz=640是检测任务的通用选择。图像更大精度会更高,但显存占用和推理耗时都会涨。对车辆这种本身不算太小的目标,640 完全够用。batch大小取决于显存。12GB 显存跑yolov10s时batch=16通常没问题;显存吃紧就先降到 8,不要硬撑。patience=30是早停策略:验证集指标连续 30 轮不提升就提前结束。这个能帮你省大量时间,训练到后期损失曲线已经平了还在空转的情况很常见。- 优化器我选了 AdamW,收敛比 SGD 省心不少;但如果你想要更传统的收敛路径,SGD 也可以,只是学习率需要更仔细地调。
训练结束后,会在runs/detect/train目录下生成weights/best.pt和last.pt。best.pt是验证集上表现最好的权重,后续推理和界面集成都用它。
4. 训练与评估的实际记录:指标曲线、调参与踩坑
4.1 从损失曲线和验证指标里读出了什么
训练跑起来后,别傻等,要会看曲线。Ultralytics 默认会输出results.png,包含训练损失、验证损失、精确率、召回率和 mAP 曲线。我第一次训练的时候,box_loss 一路下降,看起来很漂亮,但 val 指标在某个节点后就不再涨了,这就是过拟合的信号。
关键指标我盯这三个:
mAP50:IoU 阈值取 0.5 时的平均精度,直观反映检测效果。mAP50-95:IoU 从 0.5 到 0.95 的平均精度,更严格,对边界框精度更敏感。recall:所有真实车辆里有多少被找出来了。车辆识别场景里漏检通常比误检更致命,recall 要优先保证。
我的训练记录大致是:yolov10s在约 120 轮后 mAP50 达到 0.93 左右,mAP50-95 在 0.64 左右。如果拿到的数据质量一般,mAP50 在 0.85 以上其实就能用了。要是 mAP50 一直卡在 0.7 以下,大概率不是模型问题,而是数据有问题:标注不一致、类别混淆、样本太少。
4.2 调参记录:我实际动过的几个参数
训练初期我不动参数,先看默认配置能不能收敛。等第一版跑完,再根据结果调整。
以下是我在这个项目里实际动过的几个参数和效果:
| 参数 | 调整方向 | 我这里的效果 |
|---|---|---|
lr0 | 损失爆炸时调小 | 从 0.001 调到 0.0005 后训练更稳 |
mosaic | 训练后期关闭 | val mAP 小幅提升 |
close_mosaic | 设 10 或 20 | 让最后若干轮在真实分布上精调 |
batch | 显存不足时调小 | 16 降到 8,loss 波动变大但可接受 |
patience | 验证指标早停 | 节省了约 30% 的训练时间 |
学习率是最值得说的。我一开始用默认学习率训练,到第 50 轮左右 loss 曲线出现明显反弹,查了一下是学习率偏大导致优化过程不稳定。把lr0调低之后,曲线重新变得平滑。如果你看到训练损失周期性震荡、不收敛,第一反应应该是降学习率,而不是加数据、换模型。
4.3 过拟合和漏检的两个典型处理思路
过拟合的外在表现是训练损失持续下降、验证指标停滞甚至下降。我当时减少过拟合的做法组合是:加数据增强的强度、加 dropout 类似的权重衰减、以及使用早停。对 YOLO 系列来说,weight_decay默认值一般是够的,重点还是增强和数据量。车辆检测的数据量门槛不算高,每个类别有 500 张以上有效标注图片,配合增强基本能压住过拟合。
漏检的情况则要区分原因。如果漏检集中在远处的小车,可以考虑提高输入分辨率到 768 甚至 960;如果漏检集中在特定角度(比如车头正对摄像头),那问题在数据缺少这个角度的样本。我在实际测试时发现白天效果很好、傍晚漏检率升高,最后补充了一批不同光照条件下的图片,漏检率明显下降。所以数据采集时要有意识地覆盖多种光照、角度、天气,而不是只挑“好看”的图。
5. UI 界面:把模型能力包装成可交付的工具
5.1 界面技术选型:权衡后的选择
模型训练好之后,它还是一个“只有程序员能看见”的东西。要让现场人员用起来,必须包一层 UI。界面技术我对比了几种:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| Streamlit | 代码量小、开发快 | 交互能力有限、打包部署重 | 快速 Demo、内部工具 |
| PySide6 | 控件丰富、可打包成 exe | 开发量大、线程管理要仔细 | 交付给现场用户 |
| Flask + Web 前端 | 跨平台访问 | 前后端联调成本高 | 服务化部署 |
我最后选了 PySide6。原因有三:一是车辆识别界面需要视频流展示、文件选择、参数调节和结果列表,PySide6 都有现成控件;二是 PySide6 打包成独立程序后,现场机器不需要装 Python 环境,双击就能跑;三是我希望界面和模型推理解耦,后续换模型或者改成 Web 服务也方便。
Streamlit 也不是不能用,我最早用 Streamlit 做过一版原型,几行代码就能显示图片和检测框。但视频流实时展示和交互控制比较别扭,最终还是迁移到了 PySide6。
5.2 界面核心逻辑:模型加载、推理与结果展示
界面设计遵循一个原则:模型加载和推理不能阻塞主界面。用 PySide6 的QThread做后台推理线程,主界面只管显示。核心结构大概是这样的:
import cv2 from PySide6.QtCore import QThread, Signal, Qt from PySide6.QtGui import QImage from ultralytics import YOLO class DetectThread(QThread): frame_ready = Signal(QImage) result_ready = Signal(list) def __init__(self, model_path, source): super().__init__() self.model = YOLO(model_path) self.source = source self.running = True def run(self): cap = cv2.VideoCapture(self.source) while self.running and cap.isOpened(): ret, frame = cap.read() if not ret: break results = self.model.predict(frame, conf=0.4, iou=0.5, verbose=False) for res in results: boxes = res.boxes # 把检测框画到画面上 annotated = res.plot() rgb = cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape qt_img = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888) self.frame_ready.emit(qt_img) cap.release()这里有两个细节很容易踩坑。第一个是 OpenCV 的cvtColor不能漏,QImage期望 RGB 顺序,而 OpenCV 默认是 BGR,直接转过去画面会红蓝颠倒。第二个是QImage构造时bytesPerLine参数要正确,也就是ch * w,否则图像显示出来是花屏或者斜的。
res.plot()是 Ultralytics 自带的画框方法,会把类别名、置信度、边界框一次性画好。如果觉得默认画风不好看,也可以手动遍历boxes.xyxy和boxes.cls自己画。我在界面里增加了置信度阈值滑块,让用户可以根据场景动态调整:白天可以调高一点,减少误报;晚上光线差,调低一点,减少漏检。
5.3 视频文件、摄像头和单张图片三类输入的细节处理
UI 需要考虑输入来源:视频文件、摄像头、单张图片。三者逻辑大同小异,但细节不同。
视频文件要处理好结束逻辑,播放完要自动停止,不能崩溃;摄像头要处理断流,比如 USB 摄像头被拔出或者 IP 摄像头掉线,要有提示而不是卡死。我当时的处理方式是给线程加一个状态信号,专门上报异常:
class DetectThread(QThread): error_occurred = Signal(str)单张图片最简单,不需要线程循环,一次推理一次显示就行。但要注意图片过大时先做缩放,否则界面可能卡顿。我统一先等比缩放到最长边不超过 1280 再送入模型,检测完成后把结果框映射回原始坐标,这样界面流畅度和检测精度可以兼顾。
界面上我还加了一个“结果统计”区域,用表格展示当前视频里各类型车辆的数量,方便业务人员直接拿去填报表。这个功能实现很简单:每帧推理完,把类别计数累加到字典里,定时刷新表格。
6. 从实验室到现场:部署优化与几个让我印象深刻的教训
6.1 换一台机器就崩了?模型路径与依赖问题是第一关
实验室里跑得好好的系统,拿到现场机器上双击却崩了。最常见的原因有三个:一是模型权重路径写成了绝对路径,代码里写的是C:/Users/xxx/runs/.../best.pt,换机器当然找不到;二是依赖库缺失,现场机器没有安装 ultralytics、torch 这些包;三是打包时没有把模型文件一起打进去。
我的解决办法是:在代码里使用相对路径,把best.pt放在项目根目录,并把路径写成一个常量方便修改;打包时用 PyInstaller 把模型文件加进资源,运行时从临时目录加载。PyInstaller 打包深度学习项目有个坑:ultralytics依赖文件较多,打包体积很大(动辄 1GB 以上),而且容易漏依赖。建议打包前先在一台干净的机器上测试完整流程,否则会得到一个“能启动但一加载模型就闪退”的半成品。
6.2 推理速度优化:从每秒几帧到实时
系统在开发机上的推理速度大约是 30ms 一帧,看起来很快。但部署到现场的老旧电脑上,直接降到 150ms 一帧,视频卡顿明显。优化方向我按性价比排序:
- 先用
model.predict(..., half=True)启用半精度推理,显存占用减半、速度提升明显。 - 把视频帧缩放后送入模型。现场需求未必需要检测 1080p 上的所有小目标,缩放后再检测,速度提升非常可观。
- 必要的时候导出 ONNX 并用 TensorRT 加速。但 YOLOv10 在导出 ONNX 时输出层需要额外处理,TensorRT 版本兼容性也比较敏感,我建议把它作为“最后手段”,而不是默认方案。
我做了一组对比测试:
| 方案 | 单帧耗时(毫秒) | 感受 |
|---|---|---|
| 原始 1080p 全尺寸 + FP32 | 约 120 | 卡顿 |
| 缩放至 640 + FP32 | 约 45 | 流畅 |
| 缩放至 640 + FP16 | 约 30 | 很流畅 |
实际操作中,现场部署我直接采用了“缩放至 768 + FP16”的组合,稳定性和速度都比较满意。
6.3 现场场景带来的“意外”:遮挡、小目标和相似类别
现场测试阶段,我发现模型对近距离大车的检测很好,但对远处车道尽头的车辆、以及被道闸杆遮挡一半的车,经常漏检。这本不是模型的锅,而是场景复杂度的正常挑战。
对遮挡问题,我调整了 NMS 和类别策略:在界面里把置信度阈值调低到 0.3,同时给模型补充了带有遮挡情况的训练图片。对小目标问题,我尝试过把推理分辨率提高到 960,效果有改善,但速度下降明显。最终我在项目里做了一个“分辨率自适应”的逻辑:检测区域较大时用 640,检测到车辆过多或者过小时自动切到更高分辨率。
还有一个印象深刻的教训:模型把停在路边的“货拉拉”辨识成了小型客车。原因是这类车既像货车又像客车,数据里标注不一致。后来我清理了标注,统一把带封闭货箱的车型归到货车类别,重新微调了几十个 epoch,这类混淆就明显减少了。数据标注的一致性,往往比模型结构对最终效果的影响更大。
6.4 遇到问题时的排查顺序,帮你少走弯路
为了让后来者少折腾,我把项目里遇到的高频问题整理成了排查顺序表:
| 现象 | 优先检查项 | 常见根因 |
|---|---|---|
| 训练时 loss 为 NaN | 学习率是否过大 | lr0 太大导致梯度爆炸 |
| GPU 显存不足 | batch 和 imgsz 是否偏大 | batch 显存占用超限 |
| 验证时一个框都没有 | 标签文本是否为空或类别越界 | 标注转换错误 |
| 界面上图像颜色不对 | 是否漏了 BGR 转 RGB | OpenCV 与 QImage 颜色空间不同 |
| 打包后运行闪退 | 是否用了绝对路径、依赖是否完整 | 模型文件未打包、依赖缺失 |
这套排查顺序我后来在一个课程设计项目里也用过,帮别人省了大把时间。很多时候,问题不是难,而是出现在预期之外的环节,比如标注文本里多了一个空格、路径里含中文导致加载失败。我后来统一规定:项目路径里不要用中文和空格,所有数据、模型、脚本全部用英文路径。这个小规范避免了一堆莫名其妙的问题。
最后再分享一点个人体会。做完这个车辆类型识别系统,我最深的感受是:整个项目里最花时间的不是模型训练,而是数据整理和工程适配。YOLOv10 把算法部分做得非常“傻瓜化”,训练命令几行就能跑起来,但数据集标得对不对、界面线程会不会卡、换台机器能不能跑,这些才是决定系统能否真正用起来的关键。如果你也正在做类似的课题或项目,建议先把数据和工程边界理清楚,再上手训练,你会比我当时少踩很多坑。