
简介这份资源面向计算机、电子信息、数学等专业的大学生及算法初学者提供一套基于YOLOv5s与LPRNet的轻量级中文车牌检测与识别完整实现可用于课程设计、期末大作业或毕业设计参考。项目以CCPD数据集为基础YOLOv5s负责车牌定位LPRNet完成字符识别目前支持蓝牌与新能源绿牌后续可通过微调数据扩展更多车牌类型与场景。压缩包共61个文件约16.75MB包含22个Python脚本训练、测试、数据转换与推理、27张jpg与3张png示例图、3个yaml配置、1个pth与1个pt权重文件以及pptx、docx、md说明文档覆盖从数据准备到模型部署的完整流程。已有482人学习下载。读者可据此掌握检测与识别两阶段串联思路直接复用训练脚本、权重与配置快速复现车牌识别效果并在此基础上做二次开发与精度优化。1. 车牌检测加识别这条流水线为什么值得你亲手搭一遍路上跑的车越来越多停车场、收费站、小区门口都在用摄像头做车牌自动识别。很多做嵌入式和算法的朋友第一反应是买现成的一体机但真到自己要改需求、换场景、加自定义逻辑的时候黑匣子就让人抓瞎了。基于 YOLOv5 和 LPRNet 做车牌检测加识别配合 CCPD 数据集是目前复现成本最低、资料最全的一条路线。YOLOv5 负责把车牌从整张图里框出来LPRNet 负责把框出来的车牌小图读成文字两段拼起来就是一条完整的车牌识别流水线。这套方案适合想搞懂车牌识别全流程的算法工程师、要做边缘部署的嵌入式开发者以及需要在自己数据集上微调模型的同学。下面我按实际搭过的顺序把环境、数据、训练、推理和踩过的坑一条条讲清楚。2. 先把两段式流水线的账算明白YOLOv5 检测加 LPRNet 识别的分工2.1 为什么不做端到端而是拆成检测加识别两段车牌识别这件事本质上要解决两个问题车牌在哪车牌上写了什么。端到端模型想用一个网络同时输出位置和字符听起来优雅但实际训练时两个任务的损失很难平衡收敛慢调参玄学得很。拆成两段之后检测模型只管框位置识别模型只管读字符各自的数据增强、学习率、评估指标都能独立调。更关键的是检测和识别可以分别替换升级——今天 YOLOv5 换成 YOLOv8识别那头不用动明天 LPRNet 换成 CRNN检测那头也不用重训。这种解耦在工程上省下来的时间远比端到端省的那点推理开销值钱。从精度角度看两段式还有一个隐藏好处检测框可以做透视矫正和尺寸归一化把歪的、斜的车牌摆正再送进识别网络识别准确率会明显提升。端到端模型很难在中间插入这种几何变换。CCPD 数据集里的车牌本身就有大量倾斜和透视变形这个矫正步骤几乎是必须的。2.2 YOLOv5 在车牌检测上的选型理由和版本取舍YOLOv5 到现在依然是我做车牌检测的首选原因很实在代码结构清晰训练脚本开箱即用社区里踩过的坑基本都能搜到。车牌检测属于单类目标检测YOLOv5s 这个量级就够用模型小、推理快在边缘设备上也能跑。如果你追求更高精度可以上 YOLOv5m 或 YOLOv5l但车牌检测本身目标特征明显s 版本在 CCPD 上就能做到很高的召回。环境配置这块我一般用 conda 建独立环境避免和系统里的包打架。PyTorch 版本选 1.8 以上都行CUDA 按你显卡驱动来。YOLOv5 源码直接从官方仓库拉注意别用太老的 commit老版本对新版 PyTorch 兼容性差。# 创建 conda 环境python 选 3.8 比较稳 conda create -n plate python3.8 -y conda activate plate # 安装 pytorch按你的 cuda 版本去官网选对应命令 # 这里以 cuda 11.3 为例 pip install torch1.12.1cu113 torchvision0.13.1cu113 -f https://download.pytorch.org/whl/torch_stable.html # 拉取 yolov5 源码 git clone https://github.com/ultralytics/yolov5.git cd yolov5 pip install -r requirements.txt这段命令的逻辑是先隔离环境再装框架最后拉源码装依赖。参数上要注意的是 torch 版本和 cuda 版本必须匹配装错了会在训练时报CUDA error: no kernel image is available。requirements.txt 里包含了 numpy、opencv、matplotlib 这些装完可以用python detect.py --source data/images跑一张测试图验证环境是否正常。2.3 LPRNet 为什么适合车牌字符识别LPRNet 是专门为车牌识别设计的轻量网络全称 License Plate Recognition Network。它的核心思路是用 CNN 提取特征再接一个 CTC 损失做序列解码不需要像 CRNN 那样额外加 RNN 层。这让它参数量很小推理速度极快同时在中国车牌这种定长字符集上精度很稳。LPRNet 的输入是矫正后的车牌小图输出是字符序列。它支持的字符集包括省份简称、字母、数字一共 68 类左右。相比通用 OCR 方案LPRNet 针对车牌做了结构优化对模糊、光照不均的鲁棒性更好。在 CCPD 上训练时识别准确率能到 95% 以上配合检测框矫正还能再提。选 LPRNet 还有一个现实原因它的训练代码简单几百行就能跑通不像一些大 OCR 框架那样依赖一堆配置文件。对于想快速验证车牌识别效果的人来说这是最省心的选择。3. CCPD 数据集处理从原始图片到 YOLO 和 LPRNet 都能吃的格式3.1 CCPD 数据集的目录结构和标注含义CCPD 是中国科学技术大学公开的车牌数据集全称 Chinese City Parking Dataset。它包含超过 25 万张车牌图片覆盖不同光照、角度、天气条件。数据集按场景分了好几个子集比如 CCPD-Base 是基础版CCPD-Green 是新能源绿牌CCPD-DB 是模糊的CCPD-FN 是远距离的。每张图片的文件名里就编码了标注信息这是 CCPD 的一个特点也是新手容易懵的地方。文件名格式大致是这样的025-95_113-226469_448522-448522_226522_226469_448469-0-0.jpg。用横杠分段第一段是面积比第二段是倾斜度第三段是边界框坐标第四段是四个角点坐标第五段是车牌号码后面还有亮度、模糊度等。车牌号码里的字符做了映射比如 A 对应 10B 对应 11省份简称也有对应数字。解析文件名是处理 CCPD 的第一步不搞懂这个后面全乱。3.2 解析 CCPD 文件名并转成 YOLO 标注格式YOLO 需要的标注是每张图一个 txt每行格式为类别 x_center y_center width height坐标都要归一化到 0 到 1。CCPD 给的是绝对坐标需要转换。下面这段脚本把 CCPD 文件名解析出来生成 YOLO 格式的标注文件。import os import cv2 # CCPD 文件名解析函数 def parse_ccpd_filename(filename): # 去掉扩展名 name filename.split(.)[0] # 按横杠分段 parts name.split(-) # 第四段是边界框格式为 x1y1_x2y2 bbox_str parts[2] x1y1, x2y2 bbox_str.split(_) x1, y1 map(int, x1y1.split()) x2, y2 map(int, x2y2.split()) return x1, y1, x2, y2 # 转换并写入 YOLO 标注 def convert_to_yolo(img_dir, label_dir): os.makedirs(label_dir, exist_okTrue) for fname in os.listdir(img_dir): if not fname.endswith(.jpg): continue img_path os.path.join(img_dir, fname) img cv2.imread(img_path) h, w img.shape[:2] x1, y1, x2, y2 parse_ccpd_filename(fname) # 归一化中心点和宽高 cx (x1 x2) / 2.0 / w cy (y1 y2) / 2.0 / h bw (x2 - x1) / float(w) bh (y2 - y1) / float(h) # 类别 0 表示车牌 line f0 {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}\n label_name fname.replace(.jpg, .txt) with open(os.path.join(label_dir, label_name), w) as f: f.write(line) if __name__ __main__: convert_to_yolo(ccpd_base, labels)这段代码的关键在parse_ccpd_filename它把文件名第三段拆出来拿到绝对坐标。转换时用图片实际宽高做归一化这是 YOLO 的硬性要求。参数上要注意cx和cy是中心点坐标不是左上角很多人第一次转会把 x1 直接当中心点训练时框全偏了。另外 CCPD 有些图片文件名格式略有差异比如新能源车牌多一段解析时要加容错遇到解析失败的直接跳过并记录别让整个脚本崩掉。3.3 裁出车牌小图给 LPRNet 做训练集LPRNet 训练需要的是车牌小图加对应字符标签。我们可以用上一步解析出的边界框把车牌区域裁出来同时从文件名里解析出车牌号码作为标签。裁图时建议往外扩一点边避免把车牌边缘字符切掉。import os import cv2 # 字符映射表CCPD 里数字到字符的对应关系 CHARS 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ # 省份简称映射按 CCPD 实际编码调整 PROVINCES [皖, 沪, 津, 渝, 冀, 晋, 蒙, 辽, 吉, 黑, 苏, 浙, 京, 闽, 赣, 鲁, 豫, 鄂, 湘, 粤, 桂, 琼, 川, 贵, 云, 藏, 陕, 甘, 青, 宁, 新] def parse_plate_number(filename): name filename.split(.)[0] parts name.split(-) # 第五段是车牌号码用下划线分隔的编码 plate_codes parts[4].split(_) # 第一个是省份索引后面是字符索引 province PROVINCES[int(plate_codes[0])] chars .join([CHARS[int(c)] for c in plate_codes[1:]]) return province chars def crop_plates(img_dir, out_dir): os.makedirs(out_dir, exist_okTrue) for fname in os.listdir(img_dir): if not fname.endswith(.jpg): continue img cv2.imread(os.path.join(img_dir, fname)) h, w img.shape[:2] x1, y1, x2, y2 parse_ccpd_filename(fname) # 外扩 5 个像素 x1 max(0, x1 - 5) y1 max(0, y1 - 5) x2 min(w, x2 5) y2 min(h, y2 5) crop img[y1:y2, x1:x2] plate parse_plate_number(fname) # 用文件名保存标签方便后续读取 save_name f{plate}_{fname} cv2.imwrite(os.path.join(out_dir, save_name), crop) if __name__ __main__: crop_plates(ccpd_base, plate_crops)这里parse_plate_number把文件名第五段的下划线编码还原成真实车牌号。省份用索引查表字符用 CHARS 查表。裁图时外扩 5 像素是个经验值CCPD 的框有时候贴得比较紧不外扩容易切掉第一个汉字。保存时把车牌号写进文件名后面做 LPRNet 数据集时直接读文件名就行省得再维护一份标签文件。注意 CCPD 的省份编码顺序可能和你手里的版本有差异第一次跑先打印几个样本核对一下别闷头训练。4. 训练 YOLOv5 车牌检测模型配置文件、超参和训练命令4.1 准备 data.yaml 和模型配置文件YOLOv5 训练需要两个配置数据集描述文件 data.yaml 和模型结构文件。数据集描述文件告诉它训练集、验证集在哪有几类。车牌检测只有一类写起来简单。# data.yaml path: ./ccpd_yolo # 数据集根目录 train: images/train # 训练图片相对路径 val: images/val # 验证图片相对路径 nc: 1 # 类别数车牌只有一类 names: [plate] # 类别名模型结构直接用 YOLOv5s 的 yaml在models/yolov5s.yaml里把nc改成 1。如果你要改网络宽度或深度改depth_multiple和width_multiple这两个参数但车牌检测不建议动默认值就够。4.2 训练命令和关键超参数怎么设训练命令本身不复杂关键是超参。下面这条是我在单卡 3090 上跑 CCPD 的常用配置。python train.py \ --img 640 \ --batch 32 \ --epochs 100 \ --data data.yaml \ --weights yolov5s.pt \ --cfg models/yolov5s.yaml \ --hyp data/hyps/hyp.scratch-low.yaml \ --name plate_yolov5s--img 640是输入分辨率车牌在整图里占比不大640 够用想更准可以上 1280 但显存翻倍。--batch 32按显存调3090 上 32 没问题显存小就降到 16 或 8。--epochs 100对 CCPD 这种大数据集够了loss 基本在 50 轮后就平了。--weights yolov5s.pt用预训练权重能明显加快收敛别从零训。--hyp选hyp.scratch-low.yaml这是低增强配置车牌检测不需要太猛的增强过强反而让框不稳。训练过程中重点看三个指标box_loss、obj_loss和mAP0.5。box_loss 管框的位置obj_loss 管有没有目标mAP 是最终精度。如果 box_loss 一直不降检查标注格式对不对如果 obj_loss 震荡可能是学习率太大把lr0从 0.01 降到 0.005 试试。4.3 训练完怎么验证和导出模型训练结束后权重存在runs/train/plate_yolov5s/weights/best.pt。先用验证脚本跑一遍看 mAP。python val.py --weights runs/train/plate_yolov5s/weights/best.pt --data data.yaml --img 640mAP0.5 能到 0.98 以上算正常低于 0.95 就要回头查数据。验证没问题后导出 ONNX 方便部署。python export.py --weights runs/train/plate_yolov5s/weights/best.pt --include onnx --img 640导出 ONNX 时注意 opset 版本默认 12 一般够用部署到某些推理框架可能需要指定--opset 11。导出的 onnx 文件可以用 netron 打开看结构确认输入输出节点名字后面写推理代码要用到。5. 训练 LPRNet 识别模型数据加载、CTC 损失和调参5.1 构建 LPRNet 的数据集和字符集LPRNet 训练需要把车牌小图和对应字符串配对。字符集要覆盖所有可能出现的字符包括省份简称、字母、数字再加一个 CTC 的 blank 符。下面这段代码构建数据集类。import os import cv2 import torch from torch.utils.data import Dataset # 字符集第一个是 CTC blank CHARSET 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ皖沪津渝冀晋蒙辽吉黑苏浙京闽赣鲁豫鄂湘粤桂琼川贵云藏陕甘青宁新 char_to_idx {c: i for i, c in enumerate(CHARSET)} class PlateDataset(Dataset): def __init__(self, img_dir, img_size(94, 24)): self.img_dir img_dir self.img_size img_size self.samples [] for fname in os.listdir(img_dir): if not fname.endswith(.jpg): continue # 文件名格式为 车牌号_原文件名.jpg plate fname.split(_)[0] self.samples.append((fname, plate)) def __len__(self): return len(self.samples) def __getitem__(self, idx): fname, plate self.samples[idx] img cv2.imread(os.path.join(self.img_dir, fname)) img cv2.resize(img, self.img_size) img img.astype(float32) / 255.0 img img.transpose(2, 0, 1) # HWC 转 CHW # 标签转索引序列 label [char_to_idx[c] for c in plate if c in char_to_idx] return torch.tensor(img), torch.tensor(label), len(label)img_size设成 (94, 24) 是 LPRNet 论文里的推荐值宽 94 高 24这个比例适合中国车牌。字符集里把省份简称和字母数字拼在一起blank 用第一个字符占位。__getitem__返回图像、标签索引和标签长度长度是给 CTC 损失用的。注意标签里如果有字符不在 CHARSET 里会被过滤掉所以字符集一定要建全漏了字符会导致标签变短训练时对不上。5.2 LPRNet 网络结构和 CTC 损失LPRNet 的结构不复杂几个卷积加池化最后接一个全连接输出字符概率。CTC 损失负责处理不定长序列对齐。下面是一个简化版实现。import torch.nn as nn class LPRNet(nn.Module): def __init__(self, num_classes): super().__init__() self.backbone nn.Sequential( nn.Conv2d(3, 64, 3, 1, 1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(64, 128, 3, 1, 1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(128, 256, 3, 1, 1), nn.ReLU(), nn.MaxPool2d((2, 1)), nn.Conv2d(256, 256, 3, 1, 1), nn.ReLU(), nn.MaxPool2d((2, 1)), ) self.head nn.Linear(256 * 3 * 6, num_classes) def forward(self, x): x self.backbone(x) x x.view(x.size(0), -1) x self.head(x) # 输出形状调整为 (T, N, C) 给 CTC return x.view(-1, x.size(0), x.size(1))num_classes等于字符集长度。backbone 里池化用 (2,1) 是为了在宽度方向保留更多信息因为车牌字符是横向排列的。最后输出 reshape 成 (T, N, C)T 是时间步N 是 batchC 是类别数这是 PyTorch CTC 损失要求的格式。训练时用nn.CTCLoss()注意输入要取 log_softmax。5.3 训练循环和调参要点训练循环里关键是 CTC 损失的三个输入log 概率、目标序列、输入长度、目标长度。import torch from torch.utils.data import DataLoader def train_lprnet(model, dataset, epochs50, batch_size64, lr1e-3): loader DataLoader(dataset, batch_sizebatch_size, shuffleTrue, collate_fncollate) optimizer torch.optim.Adam(model.parameters(), lrlr) ctc_loss torch.nn.CTCLoss(blank0, zero_infinityTrue) model.train() for epoch in range(epochs): total_loss 0 for imgs, labels, lengths in loader: logits model(imgs) log_probs torch.log_softmax(logits, dim2) input_lengths torch.full((imgs.size(0),), logits.size(0), dtypetorch.long) loss ctc_loss(log_probs, labels, input_lengths, lengths) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch}, loss {total_loss / len(loader):.4f}) def collate(batch): imgs torch.stack([b[0] for b in batch]) labels torch.cat([b[1] for b in batch]) lengths torch.tensor([b[2] for b in batch]) return imgs, labels, lengthsblank0要和字符集里 blank 的位置对应。zero_infinityTrue防止出现 inf 损失导致训练崩掉。collate函数把 batch 里的标签拼成一维因为 CTC 要求目标序列是拼接后的格式。学习率 1e-3 起步loss 不降就降到 5e-4。batch_size 64 在 8G 显存上能跑不够就减半。训练 50 轮左右 loss 能降到 0.1 以下识别准确率就上来了。6. 检测加识别串起来跑推理流程和避坑记录6.1 完整推理流程从一张图到车牌号推理时先用 YOLOv5 检测车牌框裁出小图做矫正再送 LPRNet 识别。下面是把两段串起来的代码。import cv2 import torch import numpy as np def recognize_plate(img_path, yolo_model, lprnet, charset): img cv2.imread(img_path) # YOLOv5 推理 results yolo_model(img) boxes results.xyxy[0].cpu().numpy() plates [] for box in boxes: x1, y1, x2, y2, conf, cls box if conf 0.5: continue crop img[int(y1):int(y2), int(x1):int(x2)] # 缩放到 LPRNet 输入尺寸 crop cv2.resize(crop, (94, 24)) crop crop.astype(float32) / 255.0 crop crop.transpose(2, 0, 1) tensor torch.tensor(crop).unsqueeze(0) with torch.no_grad(): logits lprnet(tensor) preds logits.argmax(dim2).squeeze(1).cpu().numpy() # CTC 解码去重去 blank plate decode_ctc(preds, charset) plates.append((plate, conf)) return plates def decode_ctc(preds, charset): result [] prev -1 for p in preds: if p ! prev and p ! 0: result.append(charset[p]) prev p return .join(result)decode_ctc做的是 CTC 贪心解码去掉连续重复和 blank。注意charset要和训练时一致索引 0 是 blank。检测置信度阈值设 0.5低于这个的框直接丢避免误检。裁图后一定要 resize 到 LPRNet 训练时的尺寸尺寸不一致识别结果会乱。6.2 避坑记录五个让我返工的问题现象一训练 YOLOv5 时 loss 一直是 nan。原因是标注文件里有坐标超出 0 到 1 范围或者宽高为负。CCPD 有些图片的边界框解析出来 x2 小于 x1转换时没处理。解决是在转换脚本里加判断x2 必须大于 x1y2 大于 y1不满足就跳过这张图。现象二LPRNet 识别结果全是重复字符。原因是 CTC 解码时没去重或者 blank 索引设错了。检查字符集里 blank 是不是在索引 0解码函数里p ! prev这个条件有没有写。另外如果训练时blank参数和字符集不对应模型学出来的 blank 位置会错解码自然乱。现象三检测框位置对但裁出来的车牌是歪的识别率低。CCPD 里大量车牌有倾斜直接裁矩形框会带进背景。解决是用文件名里的四个角点坐标做透视变换把车牌摆正再送识别。这一步能明显提升识别率尤其是 CCPD-DB 这种模糊倾斜子集。现象四推理时 GPU 显存爆了。YOLOv5 和 LPRNet 同时加载到 GPU加上输入图分辨率高显存不够。解决是检测和识别分时复用或者把 LPRNet 放 CPU 跑。LPRNet 本身很轻CPU 推理一张车牌也就几毫秒不影响整体速度。现象五换了自己的数据集后 mAP 暴跌。大概率是标注格式或类别数没改。自己数据集的 data.yaml 里nc和names要对应标注文件里的类别索引从 0 开始。另外自己数据如果车牌尺寸和 CCPD 差异大--img参数要调整别硬套 640。6.3 推理速度优化和部署注意点整套流水线在 3090 上单张图大概 20 毫秒其中检测占大头。想提速可以把 YOLOv5 导出 TensorRT能再快一倍。部署到边缘设备时YOLOv5 用 ONNX Runtime 或 TensorRTLPRNet 用 ONNX 或直接 PyTorch Mobile。注意 ONNX 导出时动态轴要设对batch 维设成动态不然只能跑固定 batch。还有一点实际场景里摄像头出来的图往往比 CCPD 更复杂光照、雨雾、夜间红外都会影响。建议在推理前加一个简单的图像预处理比如直方图均衡化能提升暗光下的检测率。这些细节看着小但上线后就是能不能用的差别。7. 把识别率再往上推一档透视矫正和置信度过滤的实战技巧前面跑通的是基础版真要在项目里用还得在细节上抠。我自己踩下来提升最明显的两个点是透视矫正和置信度过滤策略。透视矫正这块CCPD 文件名第四段给了四个角点坐标顺序是左下、右下、右上、左上。用这四个点做透视变换把车牌映射成一个标准矩形再送 LPRNet。这一步对倾斜车牌的识别率提升能到 5 到 10 个百分点。代码不复杂用 cv2.getPerspectiveTransform 算变换矩阵再 warpPerspective 就行。注意角点顺序别搞反反了车牌会上下颠倒识别出来是乱码。def perspective_correct(img, corners): # corners 顺序左下 右下 右上 左上 src np.array(corners, dtypefloat32) # 目标矩形宽 94 高 24 dst np.array([[0, 23], [93, 23], [93, 0], [0, 0]], dtypefloat32) M cv2.getPerspectiveTransform(src, dst) return cv2.warpPerspective(img, M, (94, 24))置信度过滤策略上我一般设两道阈值检测框置信度低于 0.5 直接丢识别结果里如果字符数少于 6 或多于 8 也丢掉。中国车牌标准是 7 位新能源 8 位超出这个范围的基本是误识别。再加一道字符置信度过滤LPRNet 输出的是概率把每个字符的概率乘起来低于某个阈值的整条结果不要。这三道过滤下来误报率能降很多。验证方法上别只看整体准确率要分场景看。把测试集按 CCPD 的子集分类分别算 Base、DB、FN 上的准确率哪个子集低就针对性补数据。我习惯每训一版就存一个混淆矩阵看哪些字符容易混比如 0 和 O、1 和 I这些在车牌里常见可以在后处理里加规则修正。最后说个习惯每次改完参数或数据先跑一个小验证集看指标别直接上全量训练。我有次改了字符集忘了同步解码表训了一晚上发现识别全是错的血泪教训。模型训练这事后悔药没有只能靠小步验证。希望帮到你。本文还有配套的精品资源点击获取