简介:一份基于YOLOv5的深度学习车牌识别系统毕业设计论文,面向计算机视觉、人工智能方向的毕业生和研究者,系统阐述如何利用YOLOv5实时目标检测模型完成车牌定位、字符分割与识别全流程。论文内容覆盖图像采集与预处理、模型训练与调优、车牌精确定位、字符识别等关键技术环节,同时详细展示了Python及OpenCV等工具在工程实现中的应用。资源文件为单个docx文档,约1.14MB,内含中英文摘要、目录、绪论及完整章节框架,可直接作为毕业设计撰写和系统开发的参考蓝本。目前已有1006人学习下载,适合需要快速搭建车牌识别系统或完成相关毕业设计的读者参考学习。
1. 车牌识别没你想的那么难:yolov5 + 深度学习系统拆开看
停车场入口经常出现这种画面:屏幕显示“未识别”,栏杆纹丝不动,后面喇叭催促。基于yolov5的深度学习车牌识别系统,解决的正是这类实际场景——先让yolov5检测模型在画面里把车牌框出来,再做矫正与字符识别,最终输出一串完整车牌号。相比传统图像处理方案,它对光照变化、车牌倾斜、运动模糊的容忍度明显更高,这也是深度学习车牌识别成为毕设和工程落地主流方向的原因。这套方案适合正在做毕设、准备快速搭一套可用demo、或者想从数据到部署完整跑通识别链路的开发者。下面从环境配置开始,一步步把这套系统搭起来。
2. 先别急着训练:yolov5环境配置与车牌数据集的三个准备步骤
2.1 深度学习环境配置:从零装到能跑detect.py
环境配置在深度学习项目里是第一道坎,很多教程喜欢把步骤写得很玄学,其实yolov5官方依赖已经相当克制。常见做法是先用conda创建独立环境,避免把系统自带的Python搞乱。Python版本选3.8最稳,PyTorch选1.8以上都行,我的建议是直接装最新稳定版。GPU版和CPU版二选一,没有独立显卡就老老实实装CPU版,或者后面选择租用服务器跑深度学习。
# 创建独立环境,避免污染系统Python conda create -n yolov5 -y python=3.8 conda activate yolov5 # 安装GPU版PyTorch(先确认显卡驱动版本,再选对应的cuda版本) conda install pytorch torchvision torchaudio pytorch-cuda=11.8 -c pytorch -c nvidia # 克隆yolov5源码并安装依赖 git clone https://github.com/ultralytics/yolov5.git cd yolov5 pip install -r requirements.txt这里有几个参数值得说明:conda create -n yolov5里的yolov5只是环境名,和源码目录名没有任何绑定关系,两者同名纯属方便记忆;pytorch-cuda=11.8要对照你的显卡驱动版本来选,驱动太低装了也调用不了GPU。装完之后先验证一下CUDA是否真的打通,这一步能筛掉一半的环境问题:
python -c "import torch; print(torch.version.cuda, torch.cuda.is_available())"输出True说明GPU可用,输出False说明PyTorch装成了CPU版或者驱动和CUDA版本不匹配。这时候别急着往后走,训练一个识别模型要迭代几十轮,CPU能把你熬到怀疑人生。我一般直接用nvidia-smi看驱动支持的CUDA版本,再回头重新装对应版本的PyTorch,比去网上一个个查兼容表格省时间。
2.2 车牌数据集从哪里来:公开数据集与自采数据的检查脚本
环境跑通之后,数据准备往往决定最终效果的80%。车牌识别任务的数据分布和通用目标检测不一样:车牌在画面里占比小、宽高比固定、字符密集,所以数据集质量比数量更重要。常用做法是取公开的中国城市车牌数据集CCPD做底子,再往里面混入一部分自己拍摄的实际场景图。注意CCPD的标注是写在文件名里的,不是标准的yolo格式,直接拿过来训练前要先解析转换,这个细节放在后面的避坑章里说。
不管是公开数据还是自采数据,喂给yolov5之前都要做一次体检。下面这个脚本我每次都会跑一遍,它能一次性筛出三类问题:缺少标注文件的图片、标注格式错误的行、标注框明显不合理的样本。
import os import cv2 img_dir = "datasets/license/images" label_dir = "datasets/license/labels" min_size = 20 # 车牌宽度低于20像素的框,大概率是标注错误 for fname in os.listdir(img_dir): if not fname.endswith(".jpg"): continue label_path = os.path.join(label_dir, fname.replace(".jpg", ".txt")) if not os.path.exists(label_path): print(f"[无标注] {fname}") continue img = cv2.imread(os.path.join(img_dir, fname)) h, w = img.shape[:2] with open(label_path, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split() if len(parts) != 5: print(f"[格式异常] {fname}: {line}") continue cls, xc, yc, bw, bh = parts bw, bh = float(bw) * w, float(bh) * h if bw < min_size or bh < min_size or bw / bh > 8: print(f"[疑似错误标注] {fname} cls={cls} w={bw:.1f} h={bh:.1f}")脚本逻辑不复杂,但几个参数很关键:min_size=20是经验阈值,正常车牌在1080p画面里宽度至少有几十像素,小于20的基本是把远处的小车框进来了;bw / bh > 8是排查把整辆车框进来的情况,标准车牌宽高比在3到4之间,超过8就能认定标注有问题。yolo标注格式里存的是归一化后的中心点坐标和宽高,所以读取时要乘回图片的实际宽高,直接用原始值做判断会得出完全错误的结论。
数据体检完,还要按8:1:1分成训练、验证、测试三份。这里有一个容易漏掉的细节:数据里至少要混入5%左右完全不包含车牌的背景图。原因是yolov5在训练时会在所有输入图上计算损失,如果数据里全是带车牌的图,模型会默认“画面里一定有个车牌”,到了真实场景就会把类似纹理的区域误检出来。
3. 跑通yolov5训练自己的数据集:数据文件、训练命令与必调超参数
3.1 数据配置与训练启动命令
yolov5训练自己的数据集,入口文件是data.yaml。这个文件告诉训练脚本数据在哪、有几类、类名是什么。车牌识别往往只检测“车牌”这一个目标,所以配置文件非常简单:
path: datasets/license # 数据集根目录,下面按train/val/test分 train: images/train # 训练图片目录 val: images/val # 验证图片目录 nc: 1 # 类别数:只检测车牌一类 names: ['license_plate'] # 类别名称训练命令的写法相对固定,核心参数就那几个:
python train.py \ --data datasets/license.yaml \ --weights yolov5s.pt \ --epochs 100 \ --batch-size 16 \ --imgsz 640 \ --device 0参数含义与选择逻辑:--weights yolov5s.pt用的是官方预训练权重,s版本参数量适中、在单卡上训练时间可控,工程验证阶段没必要上来就开yolov5x;--imgsz 640是速度和精度的默认平衡点,车牌属于小目标,显存够用的话开到1280会带来明显的mAP提升,但推理延迟也会翻倍,部署时反而得不偿失;--batch-size 16在12GB显存下很宽裕,显存不够就降到8或4,不要硬撑。
训练过程中的监控点有两条:前20个epoch里train/loss是否在稳定下降;每轮验证的mAP@0.5是否在上升。如果loss在降但mAP一直趴着不动,问题基本都在数据侧——要么标注错乱,要么类别定义不一致,这时候继续加大epochs只是浪费电。我可太多次在数据没检查好的情况下盲目调参,最后全是白忙。
3.2 yolov5超参数怎么调:真正值得动的只有几个
训练跑起来之后,很多人喜欢去翻官方hyp.scratch.yaml里的超参数,看到几十项就发怵。实际做yolov5超参数调优时,真正值得动的只有几个。下面这张表给出我常用的参考区间:
| 超参数 | 默认值 | 建议范围 | 作用与调整逻辑 |
|---|---|---|---|
lr0 | 0.01 | 0.001 ~ 0.01 | 初始学习率,小数据集调到0.001防止早期震荡 |
mosaic | 1.0 | 0.5 ~ 1.0 | 是否启用马赛克增强,数据量少时关掉防过拟合 |
warmup_epochs | 3.0 | 3 ~ 5 | 预热轮数,让训练起点平稳 |
weight_decay | 0.0005 | 0.0001 ~ 0.001 | 权值衰减过大拖慢收敛,小数据集取下限 |
调超参数有一个血泪原则:一次只动一个。很多人习惯把学习率、mosaic、anchor参数一排全改,结果模型崩了完全不知道是哪个造成的。我一般先跑一个默认参数版本作为基准,确认收敛后只改lr0,再对比mAP变化;批量改参不仅难以定位问题,写出来的结论也没有说服力。
另外两个容易翻车的参数值得单独说。第一个是mosaic,官方默认开启,它能把四张图拼成一张训练图,增强模型对小目标的感知。但如果数据集本身只有几千张车牌图,马赛克增强反而会把车牌切得支离破碎,最后十轮还容易过拟合。第二个是adam和SGD的选择,yolov5默认用SGD,初学者换成Adam后往往发现前期收敛快了、后期精度上不去,就把这口黑锅扣在模型头上——其实留默认优化器就行。
如果本地显卡实在跑不动,常见方案是租一台云GPU服务器,按小时计费那种,把数据集传上去训练完再下载权重。2000张左右的训练集用一张v100级别的卡,一百轮大概一小时出头,成本可控,效果和本地基本没有差别。
4. 从检测框到车牌字符串:yolov5后处理与字符识别实现
4.1 车牌区域提取与透视矫正
yolov5检测模型输出的结果是一个个外接矩形框,以及每个框的置信度。detect.py里的--conf-thres和--iou-thres两个参数直接决定输出质量,前者控制置信度阈值,默认0.25;后者控制NMS去重的交并比阈值,默认0.45。实际做车牌识别时conf-thres我会调到0.35以上,因为车牌区域特征显著、检测难度低,阈值低只会引入更多误检框,增加后续识别环节的负担。
检测框拿到之后,直接裁剪出来的图像往往带透视变形——车停在坡道上、摄像头安装角度偏、车牌本身弯折,都会让字符面目全非。这时候需要做透视矫正。常见做法是先取车牌区域内最大的边缘轮廓,然后用approxPolyDP逼近成四边形,最后做透视变换。
import cv2 import numpy as np def rectify_plate(plate_bgr: np.ndarray) -> np.ndarray: """对检测裁剪出的车牌图做透视矫正,返回尽量端正的车牌图。""" gray = cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) edges = cv2.Canny(gray, 50, 150) contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return plate_bgr # 取面积最大的轮廓,通常是车牌边框 contour = sorted(contours, key=cv2.contourArea, reverse=True)[0] epsilon = 0.02 * cv2.arcLength(contour, True) quad = cv2.approxPolyDP(contour, epsilon, True) if len(quad) != 4: return plate_bgr # 将四边形的四个角点按左上、右上、右下、左下排序 pts = quad.reshape(4, 2).astype(np.float32) s = pts.sum(axis=1) diff = np.diff(pts, axis=1).ravel() tl = pts[np.argmin(s)] br = pts[np.argmax(s)] tr = pts[np.argmin(diff)] bl = pts[np.argmax(diff)] width = int(max(np.linalg.norm(tr - tl), np.linalg.norm(br - bl))) height = int(max(np.linalg.norm(bl - tl), np.linalg.norm(br - tr))) dst = np.array([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtype=np.float32) M = cv2.getPerspectiveTransform( np.array([tl, tr, br, bl], dtype=np.float32), dst) rectified = cv2.warpPerspective(plate_bgr, M, (width, height)) return rectified参数说明集中在两处。epsilon = 0.02 * cv2.arcLength是轮廓逼近的精度系数,太小会把边缘噪声拟合成更多顶点,太大则四边形变形明显,0.02是多次试出来的稳妥值。角点排序的逻辑利用了矩形的几何特性:左上角的x+y最小、右下角最大、右上角的x-y最小,这套判别在车牌轻微倾斜时仍然稳定。
这个方案的前提是检测框里车牌区域占比较高且边框清晰。如果检测框留白太多,轮廓提取会把无关区域包进去,矫正结果反而更差——此时先按框裁剪再放大两倍做矫正,效果立刻改善。要是车牌本身已经严重倾斜,四边形逼近频繁失败,就别硬扛了,改用下一节的端到端识别方案更省事。
4.2 字符分割与识别:投影法实现与参数讨论
矫正后的车牌图,传统路线是分割出每一个字符再逐个识别。投影法是其中最经典、最好复现的做法:先把图像二值化,再做水平投影找出字符行的上下边界,接着做垂直投影把每个字符的左右边界切出来。下面这套代码在干净车牌上的分割成功率很高,代码量不大,适合作为基线方案。
import cv2 import numpy as np def segment_chars(plate_bgr: np.ndarray) -> list: """分割车牌字符,返回每个字符的裁剪图列表。""" gray = cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) # OTSU自动选阈值,蓝底车牌会得到蓝底白字的效果 # 我们要的是字符为白色,做一次反色 _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) binary = 255 - binary # 闭运算把字符笔画之间的断裂补上,核形状用横向长条 binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((3, 5), np.uint8)) h, w = binary.shape # 水平投影:统计每一行的白点数量,找出字符行区域 row_sum = binary.sum(axis=1) / 255 row_idx = np.where(row_sum > w * 0.1)[0] if len(row_idx) == 0: return [] top, bottom = row_idx.min(), row_idx.max() # 垂直投影:在行区域内统计每列白点数量 col_sum = binary[top:bottom + 1, :].sum(axis=0) / 255 col_idx = np.where(col_sum > (bottom - top) * 0.1)[0] # 连续列聚合为字符块,列间隔超过3个像素视为新字符 chars = [] start = col_idx[0] for i in range(1, len(col_idx)): if col_idx[i] - col_idx[i - 1] > 3: chars.append((start, col_idx[i - 1])) start = col_idx[i] chars.append((start, col_idx[-1])) # 按“车牌字符等宽”这一先验过滤铆钉和边框残留 char_w = [c[1] - c[0] for c in chars] if not char_w: return [] median_w = np.median(char_w) filtered = [c for c in chars if median_w * 0.7 < (c[1] - c[0]) < median_w * 1.3] return [plate_bgr[top:bottom + 1, c[0]:c[1] + 1] for c in filtered]几个关键处必须解释清楚。第一,为什么要先反色:OTSU自动阈值只能区分前景和背景,蓝底车牌的二值化结果是蓝底变白、白色字符变黑,反色之后字符才是白色,后续投影统计才有意义。第二,MORPH_CLOSE的核为什么是(3, 5)而不是方形:车牌字符笔画细密,横向长条的核能把断裂补上、又不会把相邻两个字符粘在一起,核再大就分不开了。第三,垂直投影的阈值取(bottom - top) * 0.1,行高十分之一的设定是为了滤掉细小的噪点列,同时也避免把字符中笔画稀疏的列误判成空白。
字符分割完成后,每个字符裁剪图可以直接套一个简单的分类CNN,类别就是省份简称、字母和数字的合集。这一步训练数据可以用开源字符集,也可以从分割正确的样本里自动收集。
投影法的局限在于对粘连字符束手无策——“苏”、“浙”这类笔画密集的汉字经常内部断开或被误切,模糊图像里字符边缘又会粘连。更稳的做法是下一节的端到端识别,直接把矫正后的车牌图映射成字符串。
4.3 端到端识别:LPRNet方案为什么更适合实际场景
字符分割加分类的路线,胜在每一步都可控、出了错好排查,适合跑通基线。但实际做深度学习车牌识别系统,更常见的方案是检测模型裁剪车牌后,直接接一个轻量CNN做序列识别——主流的开源实现是LPRNet。它用CTC损失函数让模型自己学习字符之间的对齐关系,不需要单独切割每个字符,对倾斜、模糊、字符粘连的鲁棒性比投影法高一个档次。
LPRNet的输入就是4.1矫正后的车牌图,输出直接是车牌字符串。训练数据可以用检测阶段积累的真实车牌图,再加一部分合成车牌——用程序把真实字符粘贴到纯色或带噪声的背景上,可以几万张地生成,把各种字体、倾斜角度、亮度变化都覆盖到。CTC训练不需要逐字符标注位置,只需要整张车牌对应的字符串标注,数据准备成本低很多,这也是我向做毕设的人推荐这个方案的核心原因。
5. yolov5车牌识别常见问题排查:五条必踩的坑与解法
环境、数据、训练、后处理整套流程走下来,会遇到的问题其实高度集中。这些坑我基本都踩过一遍,按“现象→原因→解决”的格式整理成五条,对照排查能省下大量瞎试的时间。
训练loss不下降,mAP始终在零附近。这个现象最容易让人怀疑是模型代码坏了,其实绝大多数情况是标签和数据集配置对不上。yolo训练要求每张图片对应的txt文件名与图片名完全一致,且路径写在data.yaml里要正确。有一次我把train目录写成了val,模型两个epoch就跑完一轮“训练”,loss曲线也正常,mAP却一直为零——因为val图片全部作为训练集的一部分参与了训练。解决方法是训练前跑一遍2.2节的数据体检脚本,并逐个确认data.yaml路径和实际目录结构一致。这一条值得写进任何深度学习项目的checklist里。
模型把车灯、车轮误检成车牌。现象很直观:conf值还很高,都在0.5以上,说明模型确实学到了某种纹理特征。原因分两种,一是训练集里车牌图片背景高度相似,模型学到了背景而不是车牌本身;二是负样本不足,模型没见过“长得像车牌但不是车牌”的东西。解决的常用做法是收集误检图,存成背景类负样本加入训练集,再微调几个epoch。另一种快速缓解手段是调高conf-thres,牺牲召回率换准确率,但治标不治本。数据层面最彻底的做法是在训练集里混入大量没有车牌的车辆局部图、路面图、店铺招牌图,类别数保持nc=1,不要新建负样本类——yolo把背景直接当背景处理。
字符分割把铆钉、边框残留当成字符。这个坑出在投影法后处理里,现象是分割结果多出一两个极窄的“字符”。原因是二值化图像里铆钉和边框在垂直投影上也有明显的白点堆积,宽度接近单个字符的四分之一,参与排序后干扰了字符宽度的统计。前面4.2节代码里我加了字符等宽的过滤条件——字符宽度落在中位数0.7到1.3倍区间内才保留,这一道过滤在实际数据上能滤掉大半误分割。另一种有效手段是分割前用固定尺寸的腐蚀操作先把边框线去掉。注意腐蚀核大小要小于字符笔画宽度,否则会把字符内部掏空,这一步要针对你的图像分辨率试,不要照搬参数。
换了一个摄像头场景之后识别率断崖式下跌。训练时用的是公开数据集,测试时用自己的手机拍了几张,效果直接崩。原因很明确:训练集和测试集的分布差异太大——摄像头角度、车牌光照、图像压缩程度全都不一样。这个问题的根源在数据侧,解法也不复杂:采集实际部署场景的几百张图加入训练集,哪怕只是微调几十轮效果都非常明显。yolov5迁移学习的另一个技巧是固定前几层权重只微调后面几层,用--freeze参数指定,能在数据量有限时稳住已有特征提取能力。记住一个原则:模型是数据的镜子,场景差多少,识别率就差多少。
复制了公开超参数配置,训练时报NaN或loss爆炸。常见做法是拿网上别人分享的hyp.yaml直接替换,也不仔细看自己的数据量级,结果没跑几轮loss就飞了。最典型的是lr0=0.01这个通用参数在几千张的小数据集上过大——数据量小,每个batch的梯度方向波动大,大学习率直接让loss冲上NaN。解决方式很简单:小数据集把lr0降到0.001,或打开--cos-lr让学习率按余弦曲线衰减,能显著平缓前期震荡。遇到NaN还有一个容易被忽视的原因:数据里存在纯黑或纯白图片,归一化后输入方差为零,优先排查训练目录里有没有损坏或异常图像,这比调参数更快。
6. 把实验结论做扎实:用固定随机种子控制训练与数据划分
训练跑通、识别正确之后,紧接着要面对的问题是:这个系统到底比别的方案好多少?如果把结论写进论文或者汇报材料,就需要一个能经得起推敲的实验方法。这里有一个非常朴素但极其关键的技巧:固定随机种子,让每一次实验都从同一起跑线出发。
import random import numpy as np import torch def set_seed(seed: int = 42): """固定Python、NumPy、PyTorch的随机种子。 必须在导入数据之前、任何随机操作之前调用, 否则数据打乱顺序、模型权重初始化都会变化。 """ random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)random.seed控制Python内置随机数,numpy.random.seed影响数据增强和数据集打乱的随机流程,torch.manual_seed管模型权重的初始化。三个都固定,两次训练的启动状态才能完全一致。调用时机也重要,必须在数据加载器构建之前执行,否则DataLoader里的shuffle顺序已经变了,固定种子就失去了意义。
这个技巧的实际价值体现在对比实验上。之前我做过一次模型结构的对比,方案A比方案B高了2个百分点,当时还兴奋了一下。后来发现只是数据划分的随机性在起作用——换了另一种数据划分方式,B反超了A。自从固定种子并让两个模型使用同一份划分好的训练集和验证集,结论才真正稳定下来。这个细节虽然简单,却决定了实验结论的可信度,也是很多深度学习项目做对比时容易忽略的一环。
另外两个小习惯也很实用:一个是训练日志的保存,yolov5每次训练会自动生成runs/train/exp目录,把每个版本的权重、超参数、曲线图都留在那里,写总结时直接拿出来对比;另一个是推理脚本里固定--iou-thres和--conf-thres,否则同一套模型在不同阈值下测出的mAP会差好几个点,这种低级失误在验收答辩时最容易被问住。把种子、阈值、数据划分都定死,剩下的变量才真正属于模型本身。希望这篇笔记能帮你把车牌识别系统从头到尾搭起来,少走几段我走过的弯路。
本文还有配套的精品资源,点击获取