☰
YOLOv5车牌识别实战:从数据标注到端侧部署全链路
2026/10/1 6:11:10 网站建设 项目流程

简介:这份资源面向深度学习初学者、目标检测实践者及毕业设计选题学生,提供基于YOLOv5的完整车牌识别项目。压缩包共77个文件,约25.4MB,以29个Python脚本、17个YAML配置、21张示例图片为主,另含2个预训练权重文件、6个Shell脚本及Dockerfile,覆盖模型定义、训练、推理与部署全流程。项目包含YOLOv5各规格网络配置、数据转换脚本与车牌检测权重,读者可据此完成数据预处理、模型训练、参数调优、验证测试到实时部署的完整链路,理解卷积神经网络如何提取车牌特征并输出边界框。已有2591人学习下载,适合作为课程实践、课题研究或智能交通场景入门参考,帮助快速搭建可运行的车牌检测环境并掌握调参排错思路。

1. 从一张模糊的卡口图说起:YOLOv5 做车牌识别到底难在哪

深夜卡口拍回来的图,车牌区域往往只占整幅画面的百分之几,还伴随运动模糊、车灯眩光、角度倾斜。很多人第一次用 YOLOv5 跑车牌识别,训练 loss 降得很漂亮,一上真实图就翻车:要么框不住,要么把「京」认成「津」。问题不在 YOLOv5 本身,而在于车牌识别是一个「检测 + 识别」的复合任务,而 YOLOv5 原生只解决检测这一半。

这篇笔记讲的就是怎么用 YOLOv5 把车牌识别这条链路真正跑通:从数据标注、环境配置、训练调参,到检测框后处理、字符识别衔接,再到部署时的性能取舍。适合已经会一点 Python、装过 PyTorch,但还没把检测模型落到具体业务上的工程师;也适合做过车牌识别但用的是传统 OpenCV 方案、想换成深度学习路线的朋友。整条链路我会按我自己落地的顺序讲,参数给具体值,坑给具体现象,不绕弯子。

2. YOLOv5 车牌检测的选型与数据准备:为什么不用 SSD 和 Faster R-CNN

2.1 车牌检测为什么 YOLOv5 比两阶段方案更合适

车牌检测的本质是「小目标 + 高召回」。一张 1920×1080 的卡口图,车牌框可能只有 120×40 像素,占全图面积不到 0.3%。Faster R-CNN 这类两阶段检测器精度高,但 RPN 生成候选框再逐个分类回归,推理速度在同等硬件下通常是 YOLOv5s 的三到五倍慢,而车牌场景往往要求单帧 30ms 以内出结果,否则多路视频流根本扛不住。

YOLOv5 是单阶段检测器,一次前向就出框和类别,工程上更好压榨。它有几个对车牌特别友好的设计:一是 PANet 结构里的自底向上路径,能把浅层高分辨率特征传到检测头,小目标召回明显好于早期 YOLO;二是 Mosaic 数据增强,四张图拼一张,等效于让模型在一张图里见到更多不同尺度的车牌,对小目标训练很关键;三是 anchor 可以按你自己的数据集聚类重设,车牌这种宽高比接近 3:1 到 4:1 的目标,用默认 COCO anchor 是浪费。

选版本上,我一般用 YOLOv5s 或 YOLOv5m。s 版本参数量约 7M,在 1080Ti 上单帧 1080p 推理能到 10ms 出头,m 版本精度高两三个点但慢一倍。车牌检测类别只有一类(或者加个「双层车牌」两类),不需要大模型容量,s 足够。如果要做端侧部署,比如树莓派 5 上跑自己训练的 YOLOv5 模型,那更得用 s 甚至 nano,还得配合 ONNX 或 NCNN 转换。

2.2 数据标注:车牌框怎么画才不坑自己

数据是这条链路里最容易被低估的部分。我见过太多人拿几千张图随便标标就开训,结果模型学到的其实是「车头位置」而不是「车牌位置」——因为标注时框画大了,把保险杠也框进去了。

标注工具用 labelImg 或 X-AnyLabeling 都行,导出 YOLO 格式。关键规则有三条:框必须紧贴车牌四边,留白不超过 2 像素;倾斜车牌用水平外接矩形,不要用旋转框(YOLOv5 原生不支持旋转框,除非你换 OBB 分支);模糊到人眼都认不出字符的图直接删,别留着让模型学噪声。

数据量上,单场景(比如固定卡口)2000 到 5000 张就能出可用模型,多场景(不同城市、不同光照)建议 1 万张起步。类别就一个license_plate。标注完检查一下每类框的数量分布,如果某些光照条件下样本特别少,后面训练时那类场景必然拉胯。

2.3 用脚本把标注转成 YOLOv5 训练格式

YOLOv5 要求的数据结构是 images 和 labels 分开,labels 里每个 txt 对应一张图,每行是class x_center y_center width height,全部归一化到 0 到 1。下面这个脚本把 labelImg 导出的 VOC 格式转成 YOLO 格式,并划分训练验证集。

import os import random import xml.etree.ElementTree as ET from pathlib import Path # 输入:VOC 格式的 xml 标注目录和图片目录 # 输出:YOLOv5 要求的 images/labels 结构 + train/val 划分 def voc_to_yolo(xml_dir, img_dir, out_dir, val_ratio=0.1): xml_dir, img_dir, out_dir = Path(xml_dir), Path(img_dir), Path(out_dir) for split in ['train', 'val']: (out_dir / 'images' / split).mkdir(parents=True, exist_ok=True) (out_dir / 'labels' / split).mkdir(parents=True, exist_ok=True) xml_files = list(xml_dir.glob('*.xml')) random.shuffle(xml_files) val_count = int(len(xml_files) * val_ratio) for idx, xml_file in enumerate(xml_files): split = 'val' if idx < val_count else 'train' tree = ET.parse(xml_file) root = tree.getroot() size = root.find('size') w, h = int(size.find('width').text), int(size.find('height').text) lines = [] for obj in root.iter('object'): cls_name = obj.find('name').text if cls_name != 'license_plate': continue 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) # 归一化并转成中心点+宽高格式 xc = (x1 + x2) / 2.0 / w yc = (y1 + y2) / 2.0 / h bw = (x2 - x1) / w bh = (y2 - y1) / h lines.append(f"0 {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}") if not lines: continue img_name = xml_file.stem + '.jpg' src_img = img_dir / img_name if not src_img.exists(): continue # 复制图片并写 label import shutil shutil.copy(src_img, out_dir / 'images' / split / img_name) with open(out_dir / 'labels' / split / (xml_file.stem + '.txt'), 'w') as f: f.write('\n'.join(lines)) if __name__ == '__main__': voc_to_yolo('./annotations', './images', './plate_dataset', val_ratio=0.1)

逻辑说明:脚本先打乱所有 xml,按比例切分 train/val,然后逐个解析 xml 里的 bbox,做归一化转换。参数val_ratio控制验证集比例,单场景数据 0.1 够用,多场景建议 0.15 到 0.2,保证验证集覆盖各种光照。注意cls_name过滤那行,如果你标了多种车牌类型,这里要改成映射字典,否则类别索引会错位。

2.4 用 K-means 重设 anchor 让框更贴合车牌

YOLOv5 默认 anchor 是 COCO 上聚类的,宽高比偏方正,车牌是扁长的,直接用会导致正样本匹配质量差。跑一遍 K-means 聚类自己的标注框,把结果填进模型配置。

# YOLOv5 仓库自带聚类脚本,先克隆官方仓库 git clone https://github.com/ultralytics/yolov5 cd yolov5 pip install -r requirements.txt # 对车牌数据集做 anchor 聚类,9 个 anchor,输入尺寸 640 python utils/autanchor.py --data plate_dataset/data.yaml --img-size 640 --n 9

跑完会输出类似[[10,13, 16,30, ...]]的数组,把它替换到models/yolov5s.yaml的anchors字段。车牌场景聚类出来的 anchor 通常宽高比在 2.5 到 4 之间,比如[12,36]、[18,52]这种。这一步做完,小目标召回一般能涨两到三个点,属于低成本高收益的操作。

3. 训练 YOLOv5 车牌检测模型:超参数怎么设、训练怎么盯

3.1 环境配置与 data.yaml 的写法

环境上,PyTorch 1.8 到 2.x 都能跑 YOLOv5,CUDA 版本跟显卡驱动对齐就行。conda 建环境是最省事的:

conda create -n yolov5_plate python=3.9 conda activate yolov5_plate pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 cd yolov5 pip install -r requirements.txt

data.yaml是训练入口,写清楚路径和类别:

path: ./plate_dataset train: images/train val: images/val nc: 1 names: ['license_plate']

nc是类别数,车牌检测就写 1。如果加了双层车牌或新能源绿牌作为独立类,改成对应数量,names顺序必须和标注时的类别索引一致,否则训练出来的模型会把类别搞反。

3.2 训练命令与关键超参数

我常用的训练命令长这样:

python train.py \ --img 640 \ --batch 16 \ --epochs 150 \ --data plate_dataset/data.yaml \ --weights yolov5s.pt \ --cfg models/yolov5s.yaml \ --hyp data/hyps/hyp.scratch-low.yaml \ --name plate_yolov5s \ --cache

逐个说参数。--img 640是输入分辨率,车牌小目标多的话可以提到 960 甚至 1280,但显存和速度代价明显,640 是精度和速度的平衡点。--batch 16看显存,12G 显存跑 640 大概能到 16 到 24。--epochs 150是上限,实际看验证集 mAP 什么时候平了,通常 100 到 200 之间。--weights yolov5s.pt用官方预训练权重做迁移学习,比从头训收敛快得多,小数据集尤其重要。--hyp用 low 增强配置,如果数据量少(低于 3000 张),可以换hyp.scratch-low.yaml里把mosaic关掉最后 20 个 epoch,避免过拟合。--cache把图片缓存到内存,训练速度能快 20% 以上,数据集大就加--cache disk。

3.3 训练过程盯什么指标

训练日志里重点看三个:box_loss、obj_loss、mAP@0.5。box_loss 是框回归损失,正常应该稳步下降;obj_loss 是目标置信度损失,如果它震荡不降,多半是 anchor 不匹配或者正负样本失衡。mAP@0.5 是主指标,车牌检测单类场景,好的模型能到 0.95 以上,如果卡在 0.8 上不去,先查标注质量再看数据量。

验证集 mAP 涨、训练集 loss 还在降但验证 loss 开始涨,就是过拟合信号,这时候要么加数据,要么开强增强,要么早停。YOLOv5 自带--patience参数,默认 100 个 epoch 没提升就停,小数据集可以调到 30。

3.4 用训练好的模型跑推理看效果

训练完权重在runs/train/plate_yolov5s/weights/best.pt。推理命令:

python detect.py \ --weights runs/train/plate_yolov5s/weights/best.pt \ --source ./test_images \ --img 640 \ --conf-thres 0.4 \ --iou-thres 0.45 \ --save-txt

--conf-thres 0.4是置信度阈值,车牌场景我一般设 0.3 到 0.5,太低会出大量误检,太高会漏掉模糊车牌。--iou-thres 0.45是 NMS 的 IoU 阈值,车牌之间一般不重叠,0.45 够用。--save-txt把检测框坐标存下来,方便后面接字符识别。跑完看runs/detect/exp里的可视化结果,重点看漏检和误检分别出现在什么场景,反推是数据问题还是阈值问题。

4. 从检测框到车牌字符串:后处理与字符识别衔接

4.1 检测框裁剪与透视校正

YOLOv5 输出的是水平外接矩形,但实际车牌往往有倾斜,直接裁出来送识别,字符会变形。常见做法是先裁框,再做一次透视校正。如果标注时用的是四点标注(有些工具支持),可以用四点做透视变换;如果只有水平框,可以用简单的仿射校正或者直接送识别,让识别模型自己扛。

import cv2 import numpy as np def crop_and_correct(img, box, expand=0.05): # box: [x1, y1, x2, y2] x1, y1, x2, y2 = map(int, box) h, w = img.shape[:2] # 适当外扩,避免裁掉车牌边缘字符 ew = int((x2 - x1) * expand) eh = int((y2 - y1) * expand) x1, y1 = max(0, x1 - ew), max(0, y1 - eh) x2, y2 = min(w, x2 + ew), min(h, y2 + eh) crop = img[y1:y2, x1:x2] # 统一缩放到识别模型输入尺寸,比如 94x24 或 168x48 crop = cv2.resize(crop, (168, 48)) return crop

expand参数控制外扩比例,0.05 到 0.1 之间,太小会切掉边缘字符,太大引入背景干扰。缩放尺寸要和后面识别模型的输入对齐,常见的是 94×24(CRNN 常用)或 168×48。

4.2 字符识别方案选型:CRNN 还是轻量 CNN

检测框裁出来后,识别这一环有三条路:一是用 CRNN + CTC,序列识别,对不定长车牌友好,是主流方案;二是用轻量 CNN 做定长分类,把车牌当成 7 个字符位置分别分类,简单但要求车牌位置对齐好;三是直接调 OCR 库,比如 PaddleOCR 的车牌识别模型,省事但定制性差。

我一般用 CRNN,因为车牌字符数不固定(蓝牌 7 位、新能源 8 位、黄牌可能 7 位),CTC 能处理变长序列。训练数据就是检测框裁出来的车牌图,标签是车牌字符串。识别模型和检测模型分开训,推理时串起来。

4.3 检测与识别的串联推理

把两个模型串起来的推理脚本大致长这样:

import torch import cv2 from models.experimental import attempt_load from utils.general import non_max_suppression # 加载检测模型 det_model = attempt_load('runs/train/plate_yolov5s/weights/best.pt', map_location='cuda') det_model.eval() # 加载识别模型(假设已训练好,这里用占位) # rec_model = ... def detect_plates(img_path): img0 = cv2.imread(img_path) img = cv2.resize(img0, (640, 640)) img = img[:, :, ::-1].transpose(2, 0, 1) # BGR to RGB, HWC to CHW img = torch.from_numpy(img).float().cuda() / 255.0 img = img.unsqueeze(0) with torch.no_grad(): pred = det_model(img)[0] # NMS 后处理,conf 0.4,iou 0.45 pred = non_max_suppression(pred, 0.4, 0.45) results = [] for det in pred: if det is not None and len(det): for *xyxy, conf, cls in det: # 坐标还原到原图尺寸 scale = img0.shape[1] / 640 box = [int(x * scale) for x in xyxy] crop = crop_and_correct(img0, box) # 送识别模型,得到字符串 # plate_str = rec_model(crop) results.append((box, conf.item())) return results

逻辑上,检测模型输出归一化坐标,要按原图尺寸还原。non_max_suppression的阈值和 detect.py 里保持一致。识别模型接在裁剪图后面,输出字符串。实际部署时,两个模型可以合并成一个 ONNX 图,减少 IO 开销。

4.4 后处理里的字符纠错

识别出来的字符串常有混淆,比如0和O、1和I、8和B。车牌有固定规则:第一位是汉字省份简称,第二位是字母,后面是字母数字组合。可以写个规则纠错:

# 车牌字符纠错规则 PROVINCES = set('京津冀晋蒙辽吉黑沪苏浙皖闽赣鲁豫鄂湘粤桂琼渝川贵云藏陕甘青宁新') LETTERS = set('ABCDEFGHJKLMNPQRSTUVWXYZ') # 车牌不含 I 和 O def correct_plate(s): if not s: return s chars = list(s) # 第一位必须是省份汉字 if chars[0] not in PROVINCES: # 尝试从常见混淆里恢复,这里简化处理 pass # 后续位把 O 换成 0,I 换成 1 for i in range(1, len(chars)): if chars[i] == 'O': chars[i] = '0' elif chars[i] == 'I': chars[i] = '1' return ''.join(chars)

这个纠错规则能救回不少识别错误,尤其是数字和字母混淆的场景。规则要根据你的实际车牌类型调整,新能源车牌位数和规则都不一样。

5. 车牌识别落地避坑:五个我踩过的坑

5.1 坑一:训练 mAP 很高,实际部署漏检严重

现象:验证集 mAP@0.5 到 0.97,但拿现场视频流跑,漏检率超过 20%。

原因:验证集和训练集同分布,都是清晰卡口图,但现场有逆光、雨雾、夜间红外。模型没见过的域,泛化直接崩。

解决:训练集里必须混入目标场景的图,哪怕只有几百张。另外推理时把--conf-thres从 0.4 降到 0.25,配合后处理规则过滤误检,召回优先。如果现场光照变化大,加一个简单的图像预处理,比如 CLAHE 做自适应直方图均衡。

5.2 坑二:anchor 没重聚类,小目标召回上不去

现象:训练 loss 正常降,但小尺寸车牌(远距离、低分辨率)召回率明显低于大车牌。

原因:默认 COCO anchor 的宽高比和车牌不匹配,小 anchor 尺寸也不对,正样本匹配时小目标很难被分配到合适的 anchor。

解决:跑utils/autanchor.py聚类自己的数据,把结果填进模型配置。这一步做完,小目标召回通常能涨 3 到 5 个点。聚类时--img-size要和训练时一致。

5.3 坑三:数据增强开太猛,车牌字符被裁掉

现象:训练时 loss 震荡,验证集精度上不去,可视化发现有些训练样本车牌被裁了一半。

原因:YOLOv5 默认的 Mosaic 和随机裁剪增强,对普通目标没问题,但车牌字符密集,裁掉一部分字符后标签还是整个车牌框,模型学到的是不完整特征。

解决:在hyp.scratch-low.yaml里把mosaic的概率调低,或者最后 20 个 epoch 关掉 Mosaic。随机缩放的范围也收窄,scale从默认的 0.5 调到 0.3。数据增强是为了泛化,但不能破坏标签语义。

5.4 坑四:检测框裁太紧,识别模型丢字符

现象:检测框可视化看着很准,但识别出来的车牌字符串总是少一两个字符,尤其是边缘的汉字。

原因:检测框紧贴车牌,但识别模型训练时的裁剪图通常带一点边距,推理时裁太紧,字符被切掉边缘笔画。

解决:裁剪时做外扩,expand参数设 0.05 到 0.1。另外检测框本身可以稍微放宽,训练标注时留 1 到 2 像素边距,让模型学到的框略大于车牌。

5.5 坑五:部署时 FP16 量化导致小目标检测崩掉

现象:PyTorch 模型推理正常,转 TensorRT FP16 后小目标检测精度明显下降。

原因:FP16 精度对小目标的特征响应不友好,尤其是车牌这种小面积目标,量化误差放大。

解决:小目标场景优先用 FP32 或 INT8 校准,如果必须 FP16,做量化感知训练,或者在 TensorRT 里对检测头部分保留 FP32。部署前一定要用同一批测试图对比量化前后的 mAP,别只看速度。

6. 把 YOLOv5 车牌识别压到端侧:树莓派 5 上的部署取舍

端侧部署是很多人关心的方向,尤其是树莓派 5 这类设备。我实际跑过一轮,说几个关键取舍。

首先是模型选择,YOLOv5s 在树莓派 5 上直接跑 PyTorch 大概 2 到 3 FPS,没法用。必须转格式,常见路线是 PyTorch → ONNX → NCNN 或 ONNX Runtime。NCNN 在 ARM 上优化好,YOLOv5s 转 NCNN 后能到 8 到 12 FPS,勉强够单路视频。如果还要接识别模型,帧率会再降,所以端侧一般只做检测,识别放后端,或者用极轻量的识别模型。

转换命令大致是:

# PyTorch 转 ONNX python export.py --weights best.pt --include onnx --img 640 --batch 1 # ONNX 转 NCNN(需要 onnx2ncnn 工具) onnx2ncnn best.onnx best.param best.bin

转完注意输入输出层的名字,NCNN 推理时按名字取。输入尺寸可以降到 416 甚至 320,速度能再提一截,但小目标召回会掉,需要重新评估。

另一个取舍是输入分辨率。树莓派 5 上 640 输入跑 NCNN 大概 10 FPS,降到 416 能到 18 FPS 左右,但远距离车牌就检测不到了。我的做法是双分辨率策略:先用 416 快速筛一遍,有候选框的区域再裁出来用 640 精检,兼顾速度和召回。

最后是内存和散热。树莓派 5 跑持续推理,不加散热片会降频,帧率掉一半。加个风扇,把 CPU governor 设成 performance 模式,能稳住。模型权重放内存盘,减少 SD 卡 IO。

这套方案值不值得做,取决于你的场景。如果是固定点位、光照可控、车牌距离近,端侧完全可行;如果是多场景、远距离、高召回要求,还是老老实实上服务器 GPU,端侧只做粗筛。我自己踩过的最大教训是:别在端侧追求和服务器一样的精度,先把速度跑通,再按场景调阈值,精度不够的地方用后端补。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询