YOLOv5+LPRNet车牌识别实战:从CCPD数据集到边缘部署
简介这套完整项目资料面向计算机视觉开发者和车辆管理相关技术人员解决从车牌检测到字符识别的端到端流程问题基于CCPD数据集训练YOLOv5定位车牌区域再交由LPRNet网络识别车牌号码。压缩包共59个文件、16.73MB涵盖Python源码22个py、模型权重pth和pt、配置文件yaml、测试图片及说明文档其中train_yolov5.py、train_lprnet.py等脚本覆盖数据转换、模型训练与测试全流程。目前已有816人学习下载。通过该资源可获得可直接运行的车牌识别工程包括CCPD数据集处理工具、YOLOv5与LPRNet训练/推理代码、预训练权重以及README使用说明适合动手复现并快速搭建自己的车牌识别系统。1. 停车场场景下最快的落地路径YOLOv5 检测 LPRNet 识别停车场出口的摄像头拍下照片系统要在一两秒内给出车牌号。YOLOv5 做检测、LPRNet 做识别、CCPD 数据集做训练三个点拼起来就是一条从图像到字符串的车牌识别流水线。这套工程把检测和识别串成了完整链路——YOLOv5 负责从整张图里框出车牌LPRNet 负责把框出来的区域识别成省份简称加字母数字数据转换、训练脚本、推理脚本、边缘部署脚本都包含在内。适合刚接车牌项目要快速出 demo 的工程师也适合想把模型从 PC 跑到树莓派、RK3568 这类设备上验证性能的开发者。在实际项目里检测和识别分开做是有好处的检测器只需要学习“车牌长什么样”识别器只需要学习“字符怎么读”两个模型互不干扰替换任何一侧都不影响另一侧。下面按数据、训练、避坑、部署的顺序把这套方案的每个关键步骤过一遍。2. CCPD 数据集解析从文件名的“暗号”里抠出标签2.1 CCPD 的命名规则和标签字段CCPDChinese City Parking Dataset是停车场场景下采集的车牌数据集样本量超过 25 万张覆盖不同角度、光照和遮挡情况。它的一个特点是标签不放在 xml 或 json 里而是直接编码在文件名中。用-分隔的每一段都有特定含义解析文件名就等于解析标签。一段典型文件名长这样025-96_315-321458_430537-441604_404504_477506_409419-88_21_30_24_21_27_29-85-74.jpg我在这套工程里按下面这个字段表解析实践验证过是可行的段位示例含义parts[0]025车牌区域面积占比等统计信息parts[1]96_315模糊度、倾斜角度相关parts[2]321458_430537车牌的左上、右下两个对角点坐标parts[3]441604_404504_477506_409419车牌的四个角点坐标parts[4]88_21_30_24_21_27_29车牌字符在字符集中的索引序列parts[5]85_74亮度、对比度相关统计需要注意不同来源的 CCPD 版本会在分段顺序上有细微差异如果你解析出来的坐标和图上实际车牌位置对不上优先去核对当前版本的 README 或开源解析脚本。四角点坐标才是我们最该信任的字段两点坐标有时候会受倾斜影响不够准。2.2 转换脚本从 CCPD 文件名生成 YOLO 标签YOLOv5 的标签格式是纯文本每行一个目标内容为class x_center y_center width height所有数值都归一化到 0~1。车牌是单类目标class 固定为 0。下面这段脚本负责把 CCPD 文件名解析成 YOLO 格式的 txt 标签同时把字符索引序列单独存一份供后面的 LPRNet 识别器使用。import os import glob from PIL import Image def parse_ccpd_filename(img_path): name os.path.basename(img_path).split(.)[0] parts name.split(-) # parts[2] 是两点坐标parts[3] 是四点坐标parts[4] 是字符索引 return parts def ccpd_to_yolo(img_path, out_label_path, out_seq_path, class_id0): parts parse_ccpd_filename(img_path) image Image.open(img_path) w, h image.size # 用四角点坐标计算外接矩形比直接用两点坐标更抗倾斜 pts parts[3].replace(, _).split(_) xs [int(pts[i]) for i in range(0, 8, 2)] ys [int(pts[i]) for i in range(1, 8, 2)] xmin, xmax min(xs) / w, max(xs) / w ymin, ymax min(ys) / h, max(ys) / h x_center (xmin xmax) / 2 y_center (ymin ymax) / 2 width xmax - xmin height ymax - ymin with open(out_label_path, w) as f: f.write(f{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}\n) # 字符索引序列用于 LPRNet 训练 label_seq parts[4].split(_) with open(out_seq_path, w) as f: f.write( .join(label_seq))这段脚本里最关键的参数是class_id车牌检测只有一种目标固定为 0。w和h必须从图像实际尺寸读取不能写死因为 CCD 图像不是统一分辨率。四角点坐标用replace(, _).split(_)统一分隔符再按奇偶位拆成 x 和 y 两个列表这是最容易写错的一步——parts[3]里的坐标顺序是x1y1_x2y2_...不能直接按顺序读。字符索引序列不要在这个阶段做 one-hot先保存成数字序列等 LPRNet 训练时再查字符表映射成对应字符。2.3 训练集和验证集怎么划分常见做法是按 8:2 或 9:1 随机打乱划分。随机划分在 CCPD 上能跑通但不够严谨同一辆车在不同帧里可能出现多次随机划分会把高度相似的样本同时分进训练集和验证集导致验证集 mAP 虚高。更保守的做法是按文件名前缀分组CCPD 的每张图都有连续编号按编号区间切分能在一定程度上避免同一场景泄漏。我一般会多走一步统计parts[4]里第一个字符省份索引的分布记录到 train_val_split 日志里。如果某个省份在训练集里一个样本都没有后面识别器的省份汉字基本就是瞎猜。CCPD 本身存在省份不平衡这个点在第 5 章会展开。划分完成后检查一下 images 和 labels 目录结构是否对齐。YOLOv5 官方代码要求图像在images/train、images/val标签在labels/train、labels/val路径前缀要完全一致否则训练时找不到标签文件。用下面的命令快速核对find labels/train -name *.txt | wc -l find images/train -name *.jpg | wc -l两个数字必须一致差一个都说明有文件解析失败或标签生成失败。这一步看似简单却是后面所有训练能跑起来的前提。3. YOLOv5 检测器网络结构选型与超参数调优3.1 选型yolov5s、m、n 和输入尺寸怎么定YOLOv5 是基于 anchor 的单阶段检测器主干是 CSPDarknet颈部是 PANet检测头输出三个不同尺度的特征图。网络结构图在网上能搜到很多版本实际使用时不需要背结构重点是把模型尺寸和输入尺寸匹配到目标场景。车牌在监控画面里通常属于中小目标占整张图的比例不大。如果你用默认的img 640输入yolov5s 就能有不错的召回率。如果检测对象是远距离停车场俯拍图车牌像素占比更小建议把输入提到 736 或 832但推理速度会明显下降。反过来如果是近景道闸抓拍车牌占画面很大img 416就够用而且树莓派上还能快不少。在算力跟得上的 PC 上我从 yolov5s 起步训完先看 mAP 和误检情况再决定是否换 m。不要一上来就上 yolov5xCCPD 是单类数据集模型复杂度主要消耗在特征提取上x 级别对指标提升有限对推理时长却是翻倍打击。3.2 训练自己的数据集命令与超参数用 CCPD 转出来的标签训练 YOLOv5先要准备一个数据配置文件ccpd.yamltrain: ./datasets/CCPD/images/train val: ./datasets/CCPD/images/val nc: 1 names: [plate]nc是类别数车牌检测只有一类写 1。names里的名称会写进检测框的显示文本随意起一个可读的名字即可。然后执行训练命令python train.py \ --data ccpd.yaml \ --weights yolov5s.pt \ --img 640 \ --batch 32 \ --epochs 100 \ --hyp hyp.ccpd.yaml--weights yolov5s.pt建议用 COCO 预训练权重做迁移学习而不是从 scratch 训车牌虽然不在 COCO 类别里但底层纹理特征迁移是有效的。--hyp hyp.ccpd.yaml指向一个自定义超参数文件我在hyp.ccpd.yaml里重点改两个参数fliplr: 0.0 mosaic: 0.8fliplr默认是 0.5对通用目标检测没问题但车牌字符是强方向性目标左右翻转之后字符顺序全部反了训练出来的模型会对镜像车牌产生错误预测。CCPD 里车牌方向基本正常直接把翻转关闭。mosaic从默认值往上提一点让每个 batch 里能看到更多组合背景对提升中小目标鲁棒性有帮助。--batch 32在单卡 12G 显存上比较稳显存小可以降到 16。--epochs 100对单类数据集通常够用如果验证集 loss 还在下降加到 150 也行。YOLOv5 训练过程中会自动做 anchor 聚类新数据集的车牌宽高比和 COCO 差异很大默认 anchor 并不适用autoanchor 会在训练前重新计算一组 anchor这也是为什么你不需要手动改 anchor 参数。3.3 推理输出与后处理参数训练完成后用best.pt做推理python detect.py \ --weights runs/train/exp/weights/best.pt \ --source ./test_images \ --conf 0.4 \ --iou 0.45--conf 0.4是置信度阈值单类检测场景建议比默认 0.25 稍微提高一点减少误检。--iou 0.45是 NMS 的 IoU 阈值车牌之间不会有重叠目标0.45 足够。如果检测结果出现同一个车牌被框两次把 iou 降到 0.3如果出现漏检先检查是不是输入分辨率太低而不是盲目降阈值。检测器训练常见的一个坑是 loss 看起来收敛了但验证集上框偏。别急着调超参数先检查标签文件里有没有异常归一化值。CCPD 的标签是从文件名解析的如果某个样本的四点坐标范围超过了图像边界生成的x_center或width会大于 1YOLOv5 训练时不会主动报错但会不断拉低边界框预测质量。跑训练前加一个数值范围检查脚本能省很多时间。4. LPRNet 识别器CTC 损失和轻量网络结构4.1 为什么选择 LPRNetLPRNetLicense Plate Recognition Network是 2018 年提出的轻量级车牌识别网络核心结构是 CNN 直接输出字符概率序列再用 CTC 损失对齐不需要预先把车牌字符分割成单字。这对实际场景很友好因为车牌字符宽度不同字体间距也有偏差做字符分割既繁琐又脆弱。LPRNet 在网络结构上没有接 RNN整个模型是纯 CNN推理路径非常短CPU 上也能跑得动。它的输入尺寸常见设计是1 x 24 x 94也就是高度 24、宽度 94 的灰度图这个比例对应标准车牌 440mm x 140mm 的宽高比。如果检测框是带角度的需要先做透视校正再送到识别器否则字符倾斜会明显降低识别率。4.2 字符集构造与标签映射车牌字符集包含三部分省份汉字、字母、数字。标准蓝牌字符顺序是省份汉字 字母 5 位字母数字新能源车牌是 8 位所以 LPRNet 不能按固定长度做分类必须用 CTC。字符集构造时字母里的I和O通常会被去掉因为它们和数字1、0在图像上极易混淆。CHARS [ 京, 津, 冀, 晋, 蒙, 辽, 吉, 黑, 沪, 苏, 浙, 皖, 闽, 赣, 鲁, 豫, 鄂, 湘, 粤, 桂, 琼, 渝, 川, 贵, 云, 藏, 陕, 甘, 青, 宁, 新, A, B, C, D, E, F, G, H, J, K, L, M, N, P, Q, R, S, T, U, V, W, X, Y, Z, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ] char2idx {ch: i for i, ch in enumerate(CHARS)} idx2char {i: ch for i, ch in enumerate(CHARS)} blank len(CHARS) # CTC 空位通常放在最后blank的位置必须和 CTC 损失里的blank参数对应我见过有人把 blank 放在字符集中间导致解码结果全部错位。这段代码同时生成char2idx和idx2char训练时用char2idx把字符串转索引推理时用idx2char把预测索引转字符。CCPD 文件名里的字符索引序列在训练前要和这里的char2idx对齐。官方 CCPD 的字符索引顺序和我这份不完全一致所以工程里保留了一份ccpd_index_map.json训练前先跑一次映射检查把超出索引范围的样本筛掉。4.3 训练循环与贪心解码LPRNet 训练的核心是 CTCLossPyTorch 里调用如下import torch from torch.nn import CTCLoss def train_one_batch(model, imgs, targets, target_lengths): # imgs: (N, 3, 24, 94) out model(imgs) # 输出形状 (N, T, C) log_probs out.log_softmax(2).permute(1, 0, 2) # 变成 (T, N, C) input_lengths torch.full(size(out.size(0),), fill_valueout.size(1), dtypetorch.long) ctc CTCLoss(blankblank, zero_infinityTrue) loss ctc(log_probs, targets, input_lengths, target_lengths) loss.backward() return loss.item()permute(1, 0, 2)这一步最容易出错CTCLoss 要求的输入必须是无 batch 维的T x N x C而 LPRNet 输出通常是N x T x C不转置会直接报 shape mismatch。input_lengths是每个样本的特征序列长度一般所有样本都一样直接填out.size(1)即可。zero_infinityTrue可以在 loss 为 inf 时回退为 0避免个别长序列样本把梯度冲掉。推理时最常用的是贪心解码def greedy_decode(output, blank): # output: (T, C) pred output.argmax(dim1).cpu().tolist() result [] prev blank for p in pred: if p ! prev and p ! blank: result.append(idx2char[p]) prev p return .join(result)贪心解码的规则是“去重 去 blank”连续相同字符会被合并成一个blank 直接丢弃。这个规则对车牌场景有个已知局限如果真实车牌里有连续重复字符比如京A·77777CTC 会把三个 7 误合并成一个。实践中遇到这种情况要么改用 beam search 解码要么在训练数据里把相邻重复字符的概率调大一点。对大多数车牌来说贪心解码已经够用而且 CPU 上跑一百张图只增加几毫秒开销。5. 避坑从 CCPD 转换到边缘部署的五个翻车现场5.1 标签坐标整体偏移检测框落在车牌旁边现象训练几十个 epoch 后验证集里检测框总是偏左或偏上置信度还不低。原因CCPD 文件名解析时把parts[2]和parts[3]用混了。parts[2]是对角两点parts[3]是四角点直接用parts[2]算外接矩形在图像有倾斜时会把框算偏另一个常见原因是replace(, _)之后没有把全替换干净坐标序列错位。解决强制统一用parts[3]的四角点算外接矩形并且每次跑完转换脚本抽 20 张图把归一化坐标还原成像素坐标画框肉眼核对再进训练。我后来把可视化核对脚本直接加进了数据转换流程不通过核对不允许训练。5.2 训练完识别全是“镜像车牌”现象模型 mAP 很高但识别结果出现B0652Z5这种明显镜像的字符串。原因YOLOv5 默认超参数里fliplr: 0.5车牌字符是强方向性目标翻转之后相当于伪造了不存在的车牌识别器学到的是镜像模式。解决在自定义hyp.ccpd.yaml里显式设置fliplr: 0.0同时检查--hyp参数是否真的传到了训练进程里可以通过训练日志里的超参表确认。5.3 省份汉字识别率远低于字母数字现象字母数字基本正确省份汉字经常错尤其冷门省份几乎全错。原因CCPD 的采集城市集中省份分布极不平衡皖、苏等几个省份样本占了绝大部分训练时汉字类别的梯度贡献被少数省份主导。解决先按字符索引统计训练集中各省份样本数对少数省份做重复采样或复制增强。注意不要只对原始图片做翻转增强汉字翻转后语义错误要做就做轻度亮度、模糊增强。如果工程允许引入外部数据少量补充几个数据集的冷门省份样本会立竿见影。5.4 识别结果凭空多一个相邻重复字符现象鲁B12345被识别成鲁B123455并且每次多出来的字符都出现在同一位置。原因CTC 的贪心解码合并连续相同字符时如果特征序列里有一个很短的 blank 间隔模型可能把两个相同字符看成两个独立字符。另一个常见原因是target_lengths没对齐训练时 loss 低但解码结果乱。解决解码后加一个规则后处理针对车牌长度固定为 7 位或 8 位的场景如果预测长度超过真实位数对连续重复字符强行合并一次。更根本的做法是重跑训练时确认target_lengths是从真实车牌字符串长度算的而不是从填充后的长度算的。5.5 树莓派上单张推理要好几秒根本没法用现象PC 上检测 识别只要几十毫秒树莓派 4B 上单张图跑了快两秒。原因直接用 yolov5m 640 输入 PyTorch 推理树莓派 CPU 完全扛不住。LPRNet 本身很轻瓶颈几乎全在检测器。解决检测模型换 yolov5n输入从 640 降到 416 或 320置信度阈值从 0.4 提到 0.5减少后处理 NMS 的目标数量。推理框架从 PyTorch 换成 ONNX Runtime 或 NCNN开启线程数优化。做完整套优化后树莓派 4B 上可以压到 300ms 以内虽然还不到实时但已经能用在道闸这类弱实时场景。具体导出步骤看第 6 章。6. 部署进阶导出 ONNX、量化到 RK3568 与树莓派优化把训练好的模型部署到端侧第一步是统一导出为 ONNX。YOLOv5 官方仓库自带导出脚本python export.py --weights best.pt --include onnx --opset 12 --dynamic--dynamic会开启动态 batch 和动态分辨率。RK3568 上跑 RKNN 时动态 shape 对部分算子支持不友好我一般会先导出固定分辨率的 ONNX再用 RKNN-Toolkit2 做 int8 量化。量化时注意给检测模型设置正确的均值方差CCPD 训练时用的是 RGB 三通道RKNN 量化配置文件里就要写mean_values[[0,0,0]]std_values[[255,255,255]]写反了输出框会全乱。LPRNet 导出更简单输入固定为1 x 3 x 24 x 94直接转 ONNX。量化后识别器在 RK3568 上单次推理可以跑到 5ms 以内整条检测 识别流水线控制在 80ms 左右这个数据对停车场道闸场景已经够用。如果目标是树莓派 4B不考虑 NPU 的话用 ONNX Runtime 的 CPU 推理加上线程设置比 PyTorch 快两到三倍import onnxruntime as ort sess ort.InferenceSession(best.onnx, providers[CPUExecutionProvider]) sess.set_intra_op_num_threads(4)set_intra_op_num_threads(4)让 4 核 CPU 都参与计算但要注意树莓派散热条件差长时间满线程跑会降频实测下来线程数设 2 到 3 反而更稳定。从那以后我每次做边缘部署都会先把模型压缩到端侧可接受的输入分辨率再用量化工具导出最后在真实设备上烤机 10 分钟看温度曲线而不是只看单张推理耗时。这些步骤看着繁琐但能避免模型“在 PC 上一切正常、一上板子就翻车”的尴尬。希望帮到你。本文还有配套的精品资源点击获取