简介:基于YOLOv5和LPRNet构建的车牌检测识别项目,面向计算机、电子信息、数学等专业学生的课程设计、期末大作业或毕业设计,以YOLOv5s完成车牌检测、LPRNet完成车牌识别,支持蓝牌与新能源绿牌等中国车牌,并提供可继续微调的数据与训练脚本。压缩包共61个文件,含27张jpg图片、22个Python脚本、3个yaml配置、1个pt与1个pth权重,整体约16.75MB,目录区分训练、测试、数据转换与工具模块,便于二次开发;内容涵盖CCPD数据集拆分、模型训练与测试、demo演示结果、模型权重及说明文档,可帮助理解检测识别全流程。目前已有483人学习下载,资源由资深算法工程师整理,代码结构清晰,适合直接运行或对照学习。
1. 用 YOLOv5s + LPRNet 做轻量车牌识别:先搞清楚这套代码的边界
车牌检测和识别经常被当成两个独立问题,实际上坑都在衔接处。这套基于 CCPD 数据集的项目,检测侧用 YOLOv5s 出车牌包围框,识别侧用 LPRNet 出字符序列,目标是把「检测框 → 裁剪 → 识别」串成一条能跑的端到端链路。目前支持蓝牌和绿牌(新能源车牌)等中国大陆车牌,如果你想做课程设计、算法岗机试或者把 OCR 实践补全,它是很合适的参考。适合的人群是已经有 YOLOv5 基础、想补车牌识别那半段的人;如果你完全没有深度学习经验,建议先把 YOLOv5 官方 train.py 流程跑通再进这个项目。
2. CCPD 转 YOLO 格式:文件名解析与标注生成脚本
2.1 CCPD 的文件名里其实藏着全部标注
CCPD(Chinese City Parking Dataset)最大的特点是标注信息全部编码在文件名里,而不是独立的 json 或 xml。比如025-95_113-154&383_386&473-386&473_177&454_154&383_363&402-0_0_22_27_27_33_16-37-63.jpg,用-切分成 6 段,按顺序是:车牌区域倾斜信息、检测框左上角和右下角坐标、车牌四个角点坐标、字符标签(下划线分隔的数字索引)、亮度和模糊度。第 2 段直接对应 YOLO 需要的 bbox,第 3 段对应透视矫正需要的角点,第 4 段对应 LPRNet 需要的字符标签。这个结构是后面所有脚本的源头,很多坑也踩在这里。
我一般会先写一个 parse 函数把 bbox 和标签段拆出来:
from pathlib import Path def parse_ccpd_filename(fname): base = Path(fname).stem parts = base.split("-") if len(parts) < 5: return None, None # parts[1] 形如 154&383_386&473 lt, rb = parts[1].split("_") lt_x, lt_y = map(int, lt.split("&")) rb_x, rb_y = map(int, rb.split("&")) bbox = [lt_x, lt_y, rb_x, rb_y] # parts[3] 形如 0_0_22_27_27_33_16,蓝牌 7 个索引,绿牌 8 个 label_ids = parts[3].split("_") return bbox, label_ids解析的关键是split("-")后固定取第 1 段和第 3 段。注意绿牌的标签段是 8 位,别按 7 位硬切,否则后面 LPRNet 的序列对齐会全错。bbox 是像素坐标,后面转 YOLO 格式时除以图片宽高归一化即可。
2.2 把 bbox 写成本项目需要的 txt 标注
YOLOv5 训练的标注格式是class x_center y_center width height,全部归一化。这个项目只有一个类(车牌),所以 class 固定填 0。CCPD 原图尺寸统一是 1160×720,但写转换脚本时不要写死,用cv2.imread读实际尺寸更安全。转换脚本ccpd2yolov5.py做的事就是遍历 CCPD 目录、解析文件名、生成每张图对应的 txt:
import cv2 from pathlib import Path def convert_one(src_img, txt_out, bbox): img = cv2.imread(str(src_img)) h, w = img.shape[:2] x1, y1, x2, y2 = bbox # 归一化到 [0,1] x_c = ((x1 + x2) / 2) / w y_c = ((y1 + y2) / 2) / h box_w = (x2 - x1) / w box_h = (y2 - y1) / h with open(txt_out, "w") as f: f.write(f"0 {x_c:.6f} {y_c:.6f} {box_w:.6f} {box_h:.6f}\n") for img_path in Path(CCPD_DIR).rglob("*.jpg"): bbox, _ = parse_ccpd_filename(img_path) if bbox is None: continue txt_path = OUTPUT_DIR / "labels" / f"{img_path.stem}.txt" convert_one(img_path, txt_path, bbox) # 图片用软链接或复制进 images 目录,保持文件名一致中心点坐标和宽高都必须做归一化,否则 YOLOv5 训练时会警告坐标越界,严重时 loss 直接变成 nan。转换完抽查一个 txt,人工对照原图画框验证一遍,这个步骤我每次都做,能挡掉一半的标注问题。另外建议把 bbox 往里收缩 2 个像素,因为 CCPD 的 bbox 偶尔会沾到车身边缘,收缩后 LPRNet 识别的输入更干净。
3. YOLOv5s 训练细节:数据划分、超参数和权重产出
3.1 按 9:1 划分训练集和验证集
CCPD 数据量很大(官方有 30 万张),课程设计不需要全量训练。常见做法是每个子目录里随机抽一部分,比如 ccpd_weather、ccpd_blur、ccpd_db 都留一点在验证集里。项目里split_dataset.py做的事就是把转换好的数据划成 train 和 val 两份:
import random from pathlib import Path random.seed(42) images = list(Path("datasets/ccpd_yolo/images").rglob("*.jpg")) random.shuffle(images) train_split = images[: int(len(images) * 0.9)] val_split = images[int(len(images) * 0.9):] for split, files in [("train", train_split), ("val", val_split)]: out_dir = Path("datasets/ccpd_yolo") / split / "images" out_dir.mkdir(parents=True, exist_ok=True) for f in files: # 拷贝或软链图片,同时把同名的 txt 放到 labels 目录 (out_dir / f.name).symlink_to(f) lbl_src = Path(str(f).replace("/images/", "/labels/")).with_suffix(".txt") lbl_dst = Path(str(out_dir).replace("/images/", "/labels/")) / lbl_src.name lbl_dst.parent.mkdir(parents=True, exist_ok=True) lbl_dst.symlink_to(lbl_src)为什么要固定随机种子?车牌训练对数据分布敏感,不固定种子会让两次实验对比失去意义。划分后data/ccpd.yaml指向这两个目录,类别数写 1,names 写['plate']。我建议每个子场景都留一点在 val 里,尤其是 blur 和 weather,不然 val 上 mAP 好看、换到真实场景立刻露馅。
3.2 训练超参数怎么设:hyp.scratch.yaml 里真正要动的三个值
YOLOv5 的train_yolov5.py可以理解成官方 train.py 的精简版,训练命令基本一致:
python train_yolov5.py --data data/ccpd.yaml --cfg models/yolov5s.yaml \ --weights yolov5s.pt --img 640 --batch 16 --epochs 200 \ --hyp data/hyp.scratch.yaml对车牌这个任务,hyp.scratch.yaml里默认的 mosaic=1.0 要小心。车牌是小目标,mosaic 把四张图拼在一起后单张车牌面积更小,容易在随机裁剪时变成残片。我一般把 mosaic 降到 0.5,同时把 degrees 保持 0 或最多 2,因为 CCPD 的车牌本来就有倾斜,训练时再转角度容易让边框语义变差。perspective 不建议开,车牌近似平面刚体,透视增强会造成检测框和车牌实际轮廓错位。
mosaic: 0.5 degrees: 2.0 perspective: 0.0 hsv_h: 0.015 hsv_s: 0.4 hsv_v: 0.2batch 大小按显存来选,16G 显存可以到 32,8G 就 16。epochs 给到 200 基本收敛,再用--patience 30做早停。训练结束后看weights/best.pt即可,这个项目里已经预训练了一份yolov5_best.pt,说明作者用的就是这个流程产出的权重。如果你只是想跑通 demo,直接加载这份权重就行,不一定要重新训练。
3.3 验证检测效果:test_yolov5.py 和 conf_thres 的取舍
项目里test_yolov5.py做的就是对单张图跑检测。车牌检测的置信度阈值我一般设 0.5,宁缺毋滥,因为识别阶段会被错误检测框直接拖下水。如果发现漏检多,先把 conf_thres 放到 0.35 看召回。
注意 YOLOv5 的推理输出要过 letterbox,图被等比缩放后检测框坐标要映射回原图坐标,这个映射如果不做,离线测试和线上推理的结果会不一致。我习惯把坐标还原写进工具函数,单独测一张带多个车牌的图,逐一确认框的位置没有偏移,再进下一步训练。
4. LPRNet 识别链路:透视矫正、字符集与 CTC 解码
4.1 ccpd2lpr.py 的两种数据生成模式
LPRNet 输入的是车牌的近正视角图像,所以ccpd2lpr.py要利用文件名第 3 段的角点坐标做透视变换,把任意倾斜的车牌拉成水平矩形。这是检测和识别在数据分布上最重要的一环。最常见的输出尺寸是 24 高、94 宽,对应蓝牌 7 位字符的宽高比。绿牌 8 位字符会略拥挤,部分实现会用更宽的尺寸,具体看项目里 data 目录下是否已有转换好的样本,保持一致即可。
import cv2 import numpy as np from pathlib import Path PLATE_W, PLATE_H = 94, 24 def perspective_crop(img, corners): src = np.array(corners, dtype=np.float32) dst = np.array( [[0, 0], [PLATE_W - 1, 0], [PLATE_W - 1, PLATE_H - 1], [0, PLATE_H - 1]], dtype=np.float32, ) M = cv2.getPerspectiveTransform(src, dst) return cv2.warpPerspective(img, M, (PLATE_W, PLATE_H)) # corners 来自文件名第三段: p0&p1_p2&p3_p4&p5_p6&p7 corner_pts = [tuple(map(int, p.split("&"))) for p in parts[2].split("_")] plate = perspective_crop(img, corner_pts)注意角点顺序必须是左上、右上、右下、左下,如果从文件名里拆出来的顺序不对,透视结果会是镜像或者翻转的,识别率直接崩。我会在脚本里加一个最小面积检查,角点围成的四边形面积小于 3000 像素的直接丢弃,这些大多是伪样本。
如果只用角点矫正后的图训练 LPRNet,推理时 YOLO 给的又是普通矩形检测框,两者分布对不上,识别率会掉得很明显。项目里更稳妥的做法是训练时也生成一份「检测框裁剪」版本:直接用第 2 段 bbox 裁剪、外扩 10% 再缩放到 24×94,让 LPRNet 见过带背景的车牌图。两者混合训练是我在实盘中验证过的方案,通常矫正图占七成、检测框裁剪图占三成,识别准确率能回到可接受范围。
4.2 字符集构建和标签编码
中国车牌字符由省份汉字、发牌机关字母和后面的字母数字组成。因为 0 和 O、1 和 I 在车牌字体下几乎不可分,常规做法是把字母 I 和 O 从字符集里去掉。字符集的顺序就是类别索引,训练标签里存的是索引序列,不是 ASCII 码:
PROVINCES = "京津冀晋蒙辽吉黑沪苏浙皖闽赣鲁豫鄂湘粤桂琼渝川贵云藏陕甘青宁新" ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" CHAR_DICT = {c: i for i, c in enumerate(PROVINCES + ALPHABET)} def encode_label(label_str): return [CHAR_DICT[c] for c in label_str] def decode_out(indices): return "".join([list(CHAR_DICT.keys())[i] for i in indices])如果项目自带的权重是用它的字符集训练的,别随便改字符集顺序,否则加载lprnet_best.pth后解码结果全错。重新训练时也要保持训练集和推理时用同一个CHAR_DICT,这是最容易犯的隐蔽错误。我在工程里会把字符集单独存成 json,训练和推理脚本都从同一个文件读取,避免两处硬编码不一致。
4.3 LPRNet 的序列输出与 CTC 贪心解码
LPRNet 本质是 CNN 提特征 + 宽度方向等间隔输出的序列预测,用 CTC Loss 做对齐。模型输入 24×94 的单通道或三通道图,输出形状可以理解成(序列长度, 字符类别数),序列长度由宽度方向下采样次数决定。训练时每个车牌给一个标签序列,由 CTC 决定每个位置对应哪个字符。推理时最常用的是贪心解码,逐位置取概率最大的类别,再合并重复并去掉 blank:
def ctc_greedy_decode(log_probs): # log_probs: (T, C),T 是序列长度,C 是字符类别数 + 1 个 blank blank_id = log_probs.shape[1] - 1 last_blank = True result = [] for t in range(log_probs.shape[0]): cls_id = int(log_probs[t].argmax()) if cls_id == blank_id: last_blank = True continue if last_blank or (len(result) and cls_id != result[-1]): result.append(cls_id) last_blank = False return result训练参数上,train_lprnet.py里我建议学习率用 5e-4 起步,配合 cosine 退火,batch 给 128 左右,大约 60 个 epoch 能收敛。CCPD 绿牌样本比蓝牌少,损失函数里不用额外加权,但 val 拆分时注意按蓝绿牌分层,否则 val 准确率会虚高。CTC 对序列长度和标签长度的匹配有要求,生成 batch 时要把同 batch 内样本的序列长度对齐,一般 pad 到最大长度,然后把真实长度一并传给损失函数。
5. 实战避坑:检测与识别对接中最容易翻车的四个问题
5.1 现象:识别率训练时有 95%,端到端只剩 70%
原因:LPRNet 训练数据用的是角点透视矫正后的干净车牌图,推理时送进去的是 YOLO 检测框直接裁剪的带背景图,图像分布不一致,模型在推理时面对的是没见过的输入形态。
解决:把ccpd2lpr.py增加一个「检测框裁剪」模式,用 bbox 外扩 10% 后缩放到 24×94,和透视矫正图按 7:3 混合训练。推理时对 YOLO 输出框同样外扩再识别,两边就对上了。我在离线测试前一定会检查 LPRNet 的 val 集里是不是混了矫正图,这是数据泄漏的一种隐蔽形式,val 数字好看不代表真实场景能用。
5.2 现象:训练第一轮 loss 就出 NaN,检查数据后发现标注是脏的
原因:CCPD 文件名解析出错,比如部分样本的parts[3]不是纯数字索引,或者图片文件存在但对应的 txt 没生成,导致数据加载器读到空标注。
解决:转换脚本里加白名单校验,标签字符串里每个字符必须在0-9和分隔符_的集合内;同时校验 txt 内容解析后的 box 宽高都大于 0 且不超过图片尺寸。我习惯在转换完输出一行统计数字:总样本数、有效样本数、丢弃数,三个数对不上就知道问题出在哪一段。
5.3 现象:绿牌识别成 7 位,最后一位字符被截掉
原因:标签编码时按蓝牌 7 位来定最大长度,绿牌 8 位字符时序列长度不够,CTC 在解码时被迫在最末端截断,把第 8 个字符丢掉了。
解决:数据加载器里读取标签长度时直接用真实长度,损失函数和解码都按 batch 内的最大长度走,不要写死 7。推理侧如果不打算区分蓝绿牌,可以把序列长度直接放宽到 9,让 CTC 自己输出 blank 来收尾;如果项目目标是精确实用,再往前端加一个蓝绿分类会更稳。
5.4 现象:加载 lprnet_best.pth 提示 key 不匹配
原因:项目用models/LPRNet.py里的类定义,你重新声明网络时构造函数参数不同,比如输入通道数或者类别数对不上,或者strict=True加载时 state dict 键名不一致。
解决:先打印torch.load("weights/lprnet_best.pth", map_location="cpu")的键名结构,再对应当前模型类的属性命名。常见情况是多了或少了module.前缀,写一个小脚本做 key 映射处理即可。加载后一定跑一张已知车牌图验证解码结果,别只盯着 loss 曲线看。
6. 端到端验证与部署前检查:三个指标和一个习惯
main.py就是把两段串起来的总入口:读入图片 → YOLOv5s 检测出车牌框 → 对每个框外扩裁剪 → LPRNet 识别 → 输出车牌号和置信度。端到端验证我从来只看三个数字:检测 mAP@0.5、识别整牌准确率、端到端整牌准确率。其中第三个数字才是真实水平,因为两个模型各自的误差会叠加。拿到权重后先跑项目提供的 demo 目录确认能出结果,再用自己的几十张真实图片做压力测试,记录每一张图的失败原因——是没检测到、框歪了,还是识别错了字符。
如果要做轻量化部署,我一般先把 YOLOv5s 的 pt 导出 ONNX,用 onnxruntime 固定输入尺寸推理,LPRNet 本来就很轻,直接走 PyTorch 即可。两个模型串成一个服务时,中间环节越少越好,图片解码、缩放、颜色通道顺序都要统一 BGR 还是 RGB,这是最容易在整链路验证时翻车的点。从那以后我每次跑完车牌项目,都会先执行一次「单图跑通 → 批量离线跑 → 失败样本聚类」三步检查,再谈上线。希望帮到你。
本文还有配套的精品资源,点击获取