
简介这份资源面向计算机视觉入门与进阶开发者提供一套基于YOLOv5与LPRNet的车牌检测与识别完整实现解决从图像中定位车牌并识别字符的工程问题适用于交通监控、停车管理等场景的学习与二次开发。压缩包共59个文件约16.73MB包含22个Python脚本训练、测试、数据转换与推理入口、27张jpg示例图、3个yaml配置、3张png结果图以及lprnet_best.pth与yolov5_best.pt两个预训练权重另附README与依赖清单结构清晰便于直接运行。已有816人学习下载。资源覆盖CCPD数据集到YOLOv5、LPRNet的格式转换脚本YOLOv5与LPRNet的独立训练和联合推理代码并给出检测与识别结果示例图读者可据此复现车牌检测加字符识别的完整流程理解两阶段模型衔接、数据预处理与权重加载等关键环节快速搭建自己的车牌识别基线。1. YOLOv5LPRNet车牌识别从CCPD数据集到端到端推理的完整路径停车场闸机抬杆慢半拍、高速卡口抓拍漏字、地库暗光下把“京A”认成“京R”——这些现场问题十有八九不是摄像头不行而是检测和识别两个环节没接好。YOLOv5负责把车牌框出来LPRNet负责把框里的字符读出来这套组合在CCPD数据集上训练后能跑出一套可复现的端到端车牌检测识别流程。它适合谁手里有几百张以上车牌图、想自己训一个能部署到边缘设备上的模型的工程师也适合已经跑通过YOLOv5目标检测、但卡在“检测完怎么接识别”这一步的熟手。下面按数据准备、检测训练、识别训练、联合推理、踩坑排查的顺序把这条链路拆开讲清楚。2. CCPD数据集处理与YOLOv5检测器训练从标注格式到mAP达标2.1 CCPD的目录结构与标注解析逻辑CCPD数据集常见做法是按文件夹分场景比如ccpd_base、ccpd_blur、ccpd_night等每个文件夹里是单张车牌图文件名本身就是标注信息。以025-95_113-226469_448489-452496_232498_228469_448489-0_0_22_27_27_33_16-66-88.jpg为例用-切分后第3段是车牌框坐标第5段是四个角点第7段是车牌号编码。这个编码不是直接可读字符而是省份、字母、数字的索引序列需要配合字符集映射表还原。我一般先写一个解析脚本把文件名拆成(image_path, x1, y1, x2, y2, plate_text)五元组再转成YOLO需要的归一化格式。注意CCPD里有些图是倾斜或模糊的直接拿框坐标训练检测没问题但识别阶段要额外做透视变换这个后面讲。import os import cv2 # CCPD字符集省份简称字母数字顺序按官方映射表 provinces [皖, 沪, 津, 渝, 冀, 晋, 蒙, 辽, 吉, 黑, 苏, 浙, 京, 闽, 赣, 鲁, 豫, 鄂, 湘, 粤, 桂, 琼, 川, 贵, 云, 藏, 陕, 甘, 青, 宁, 新] letters list(ABCDEFGHIJKLMNOPQRSTUVWXYZ) digits list(0123456789) chars provinces letters digits [-] # 最后一位可能是-占位 def parse_ccpd_filename(fname): parts fname.split(-) # 第3段x1y1_x2y2 box parts[2].split(_) x1, y1 map(int, box[0].split()) x2, y2 map(int, box[1].split()) # 第5段车牌字符索引 idxs list(map(int, parts[4].split(_))) plate .join(chars[i] for i in idxs) return x1, y1, x2, y2, plate def convert_to_yolo(img_dir, out_label_dir, out_img_dir): os.makedirs(out_label_dir, exist_okTrue) os.makedirs(out_img_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, plate parse_ccpd_filename(fname) # YOLO格式class cx cy bw bh归一化 cx (x1 x2) / 2.0 / w cy (y1 y2) / 2.0 / h bw (x2 - x1) / w bh (y2 - y1) / h label_path os.path.join(out_label_dir, fname.replace(.jpg, .txt)) with open(label_path, w) as f: f.write(f0 {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}\n) # 复制图片到统一目录方便YOLOv5读取 cv2.imwrite(os.path.join(out_img_dir, fname), img)这段代码的关键在parse_ccpd_filenameCCPD文件名用-分隔第3段是检测框第5段是字符索引。转换时注意归一化用的是原图宽高不是裁剪后的车牌区域。chars列表的顺序必须和训练LPRNet时一致否则识别结果会错位。常见坑是省份简称顺序搞反比如把“皖”和“沪”调换训练loss能降但推理全错。2.2 YOLOv5训练配置超参数与数据增强的取舍YOLOv5训练自己的数据集核心是改data.yaml和选对预训练权重。CCPD车牌检测属于单类目标检测nc: 1names: [plate]。我一般用yolov5s.pt起步显存够就上yolov5m.pt。输入尺寸设640CCPD原图普遍在720p以上缩放到640后车牌框大概占30~80像素对小目标检测够用。# data/ccpd_plate.yaml path: /data/ccpd_yolo train: images/train val: images/val nc: 1 names: [plate]训练命令python train.py \ --img 640 \ --batch 16 \ --epochs 100 \ --data data/ccpd_plate.yaml \ --weights yolov5s.pt \ --hyp data/hyps/hyp.scratch-low.yaml \ --project runs/train \ --name ccpd_plate参数说明--img 640是输入分辨率CCPD里小目标多不建议低于512--batch 16在8G显存上跑yolov5s刚好爆显存就降到8--hyp选hyp.scratch-low.yaml是因为车牌检测场景单一不需要太强的颜色抖动否则暗光样本容易被增强坏。训练完看mAP0.5CCPD_base上一般能到0.98以上但ccpd_night和ccpd_blur子集会低3~5个点这是正常的后面识别阶段要针对性补数据。2.3 检测结果后处理从YOLO输出到车牌裁剪图YOLOv5推理输出的是(x1, y1, x2, y2, conf, cls)直接按框裁剪会带背景LPRNet对背景敏感。我一般把框往外扩10%再裁同时做一次透视变换把倾斜车牌拉正。CCPD标注里有四个角点但推理时没有所以用cv2.minAreaRect对框内区域做矫正。import cv2 import numpy as np def crop_plate(img, box, expand0.1): x1, y1, x2, y2 box w, h x2 - x1, y2 - y1 # 外扩 x1 max(0, int(x1 - w * expand)) y1 max(0, int(y1 - h * expand)) x2 min(img.shape[1], int(x2 w * expand)) y2 min(img.shape[0], int(y2 h * expand)) crop img[y1:y2, x1:x2] # 灰度自适应直方图均衡提升暗光对比度 gray cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) gray clahe.apply(gray) # 缩放到LPRNet输入尺寸94x24 resized cv2.resize(gray, (94, 24)) return resizedexpand0.1是经验值扩太多会把相邻车牌带进来扩太少字符边缘会被切掉。CLAHE的clipLimit2.0在夜间样本上效果明显但白天过曝图会引入噪声可以按亮度动态调。缩放目标94x24是LPRNet的标准输入宽高比接近CCPD车牌的真实比例不要改成正方形否则字符会变形。3. LPRNet识别网络训练字符集映射与CTC损失调参3.1 LPRNet结构适配CCPD输入尺寸与字符集定义LPRNet原论文是为国外车牌设计的输入94x24输出字符集包含数字和字母。用到CCPD上字符集要扩到provinces letters digits blank共68类31省26字母10数字1空白。网络结构不用大改但最后一层全连接输出维度要改成68。CTC损失负责处理不定长序列CCPD车牌固定7位但CTC仍然适用因为训练时不需要对齐字符位置。import torch import torch.nn as nn class LPRNet(nn.Module): def __init__(self, num_classes68): super().__init__() self.backbone nn.Sequential( nn.Conv2d(1, 64, 3, 1, 1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(64, 128, 3, 1, 1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(128, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(), nn.MaxPool2d((2, 1), (2, 1)), nn.Conv2d(256, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(), nn.Dropout(0.3) ) self.head nn.Linear(256 * 3 * 6, num_classes) # 按94x24输入算出的特征图尺寸 def forward(self, x): x self.backbone(x) x x.view(x.size(0), -1) return self.head(x)num_classes68要和前面chars列表长度一致。Dropout(0.3)是防止过拟合CCPD_base有20万张图但如果你只用子集训练这个值可以提到0.5。特征图尺寸3x6是按94x24输入、经过三次下采样算出来的换输入尺寸要重新算否则Linear层会报维度错误。3.2 CTC损失训练blank标签与学习率调度CTC损失的核心是引入blank标签处理重复字符和不定长对齐。CCPD车牌没有重复字符但CTC仍然能简化训练流程。我一般用torch.nn.CTCLossblank67最后一个索引reductionmean。优化器选Adam初始学习率1e-3在第30和第60个epoch各降10倍。from torch.nn import CTCLoss from torch.utils.data import DataLoader ctc_loss CTCLoss(blank67, reductionmean) optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size30, gamma0.1) for epoch in range(100): model.train() for imgs, labels, label_lens in train_loader: imgs imgs.unsqueeze(1).float() # (B, 1, 24, 94) logits model(imgs) # (B, T, C) log_probs logits.log_softmax(2).permute(1, 0, 2) # CTC要求(T, B, C) input_lens torch.full((imgs.size(0),), log_probs.size(0), dtypetorch.long) loss ctc_loss(log_probs, labels, input_lens, label_lens) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()log_probs的维度顺序必须是(T, B, C)这是CTC最容易翻车的地方写成(B, T, C)会报维度不匹配。input_lens是时间步长度这里固定为特征图宽度如果用了变长输入要按实际算。label_lens是每个车牌的真实字符数CCPD固定7但如果有占位符要减去。训练到50个epoch左右验证集准确率能到95%以上夜间样本会低一些需要额外做亮度增强。3.3 识别准确率验证逐字符比对与混淆矩阵训练完不能只看整体准确率要逐字符看混淆。我一般写一个验证脚本把预测序列和真实标签按位比对统计每个字符的召回率。CCPD里容易混的是“0”和“O”、“1”和“I”、“8”和“B”尤其是模糊样本。def decode_ctc(log_probs, chars): # log_probs: (T, C) indices torch.argmax(log_probs, dim1) result [] prev -1 for idx in indices: idx idx.item() if idx ! prev and idx ! 67: # 67是blank result.append(chars[idx]) prev idx return .join(result) def evaluate(model, val_loader, chars): model.eval() correct, total 0, 0 char_correct {c: 0 for c in chars} char_total {c: 0 for c in chars} with torch.no_grad(): for imgs, labels, label_lens in val_loader: imgs imgs.unsqueeze(1).float() logits model(imgs) log_probs logits.log_softmax(2).permute(1, 0, 2) for i in range(imgs.size(0)): pred decode_ctc(log_probs[:, i, :], chars) gt .join(chars[j] for j in labels[i][:label_lens[i]]) if pred gt: correct 1 total 1 for p, g in zip(pred, gt): char_total[g] char_total.get(g, 0) 1 if p g: char_correct[g] char_correct.get(g, 0) 1 print(f整体准确率: {correct / total:.4f}) for c in chars: if char_total.get(c, 0) 0: print(f{c}: {char_correct[c] / char_total[c]:.4f})decode_ctc里的prev变量用来合并连续相同字符这是CTC解码的标准做法。逐字符统计能看出哪些字符拖后腿比如“京”和“津”在模糊图里容易混可以针对性补充这两类的训练样本。整体准确率到97%以上基本可用但实际部署时还要看端到端耗时。4. 检测识别联合推理把两个模型串成一条流水线4.1 模型加载与推理顺序先检测后识别的必要性联合推理的顺序不能反。先检测后识别检测框决定了识别输入的裁剪区域如果先识别再检测识别网络没有目标位置信息只能对整图做滑动窗口耗时翻几十倍。我一般把YOLOv5和LPRNet都加载到同一个进程里YOLOv5用torch.hub.load或直接加载best.ptLPRNet加载自己训的权重。import torch from models.experimental import attempt_load from utils.general import non_max_suppression # 加载YOLOv5 yolo attempt_load(runs/train/ccpd_plate/weights/best.pt, map_locationcpu) yolo.eval() # 加载LPRNet lpr LPRNet(num_classes68) lpr.load_state_dict(torch.load(lprnet_ccpd.pth, map_locationcpu)) lpr.eval() def inference(img_path): img cv2.imread(img_path) # YOLOv5预处理 img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img_tensor torch.from_numpy(img_rgb).permute(2, 0, 1).float() / 255.0 img_tensor img_tensor.unsqueeze(0) # 检测 with torch.no_grad(): pred yolo(img_tensor)[0] pred non_max_suppression(pred, conf_thres0.5, iou_thres0.45) results [] for det in pred: if det is not None and len(det): for *box, conf, cls in det: x1, y1, x2, y2 map(int, box) plate_img crop_plate(img, (x1, y1, x2, y2)) plate_tensor torch.from_numpy(plate_img).float().unsqueeze(0).unsqueeze(0) / 255.0 with torch.no_grad(): logits lpr(plate_tensor) log_probs logits.log_softmax(2).permute(1, 0, 2) text decode_ctc(log_probs[:, 0, :], chars) results.append((text, conf.item(), (x1, y1, x2, y2))) return resultsconf_thres0.5是检测置信度阈值低于这个值的框直接丢避免把背景裁给识别网络。iou_thres0.45是NMS的IoU阈值车牌一般不会重叠这个值可以设低一点。crop_plate里做了灰度化和CLAHE所以LPRNet输入是单通道unsqueeze(0).unsqueeze(0)变成(1, 1, 24, 94)。4.2 端到端耗时优化从CPU到GPU的批处理策略单张推理在CPU上大概200~300ms其中YOLOv5占70%LPRNet占30%。如果要做视频流必须上GPU并做批处理。我一般把检测和识别分成两个线程检测线程负责出框识别线程攒够一批车牌图再统一过LPRNet。from queue import Queue from threading import Thread det_queue Queue(maxsize32) rec_queue Queue(maxsize32) def detect_worker(): while True: img det_queue.get() # YOLOv5推理拿到框 boxes yolo_infer(img) for box in boxes: plate crop_plate(img, box) rec_queue.put(plate) def rec_worker(): batch [] while True: plate rec_queue.get() batch.append(plate) if len(batch) 16: batch_tensor torch.stack([torch.from_numpy(p).float() for p in batch]) batch_tensor batch_tensor.unsqueeze(1) / 255.0 with torch.no_grad(): logits lpr(batch_tensor) # 批量解码 batch []批大小16在1080Ti上能把LPRNet的耗时摊到每张5ms以内。注意crop_plate输出的尺寸必须一致否则torch.stack会报错。如果车牌框大小差异大可以先统一resize到94x24再入队。4.3 结果可视化与置信度过滤把识别结果画回原图推理完要把车牌号和框画回原图方便人工核对。我一般用cv2.putText写车牌号框用绿色置信度低于0.8的用黄色标出来提醒可能误检。def draw_results(img, results): for text, conf, (x1, y1, x2, y2) in results: color (0, 255, 0) if conf 0.8 else (0, 255, 255) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, text, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, color, 2) return img置信度阈值0.8是经验值CCPD_base上大部分样本能过夜间样本会掉到0.6左右。如果业务场景对误识零容忍可以把阈值提到0.9但召回会降。实际部署时建议把低置信度的结果单独存下来人工复核后加入训练集形成闭环。5. 避坑与排查车牌检测识别链路里最容易翻车的5个点5.1 检测框偏移导致识别全错现象YOLOv5的mAP很高但端到端识别准确率只有60%多。原因检测框虽然IoU达标但边缘切掉了字符的偏旁部首LPRNet对字符完整性敏感。解决在crop_plate里把外扩系数从0.1提到0.15同时用cv2.minAreaRect对框做一次旋转矫正确保车牌水平。5.2 字符集顺序不一致导致解码乱码现象训练loss正常下降但推理输出的车牌号全是乱序字符。原因训练LPRNet时的chars列表和推理时的chars列表顺序不一致比如训练时省份按拼音排推理时按笔画排。解决把chars列表写成一个独立的chars.py文件训练和推理都import同一个不要手写两遍。5.3 CTC blank标签设置错误导致loss不收敛现象CTC loss在第一个epoch就降到0.1以下但解码结果全是blank。原因CTCLoss的blank参数设成了0而字符集里索引0是“皖”导致网络把所有输出都预测成blank。解决blank必须设成字符集最后一个索引即len(chars)-1并且chars列表里不要包含blank字符本身。5.4 夜间样本过曝或欠曝导致识别率骤降现象白天识别率98%夜间掉到70%。原因CCPD_night里有些图车灯过曝车牌区域一片白有些图欠曝字符和背景融为一体。解决在crop_plate里加自适应gamma校正按图像均值动态调亮度同时把夜间样本复制一份做亮度增强后加入训练集。5.5 推理时忘记切换eval模式导致BN层异常现象训练时验证准确率97%推理时只有80%。原因LPRNet里的BatchNorm2d在train模式下用当前batch的均值和方差单张推理时batch size为1统计量偏差大。解决加载权重后必须调model.eval()同时用torch.no_grad()包住推理过程避免梯度计算和BN更新。6. 把CCPD训练好的模型塞进边缘设备量化与TensorRT加速的取舍边缘设备上跑YOLOv5LPRNet绕不开模型压缩。我一般先试ONNX导出再用TensorRT做FP16量化。YOLOv5官方有export.pyLPRNet需要自己写导出脚本。注意LPRNet里的view操作在ONNX里会变成Reshape如果特征图尺寸算错导出会失败。# LPRNet导出ONNX dummy torch.randn(1, 1, 24, 94) torch.onnx.export(lpr, dummy, lprnet.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, opset_version11)dynamic_axes让batch维度可变方便后面做批处理。opset_version11兼容性最好TensorRT 8以上都支持。导出后用onnxsim简化一下去掉多余的Transpose和Reshape。TensorRT加速时YOLOv5和LPRNet分别建engine。YOLOv5的NMS可以放到TensorRT里做也可以拿出来用CPU做前者快但配置复杂后者简单但多一次拷贝。我一般把NMS放CPU因为车牌检测的框不多CPU做NMS耗时可以忽略。量化到FP16后YOLOv5在Jetson Nano上单张推理从300ms降到80msLPRNet从50ms降到15ms。INT8量化更快但需要校准集CCPD的验证集拿500张出来做校准就行。注意INT8对LPRNet的字符分类影响比YOLOv5大因为字符特征更细量化误差容易把“0”和“O”混在一起。如果INT8后准确率掉超过2个点就退回FP16。最后说一个我踩过的坑TensorRT engine和显卡架构绑定在1080Ti上建的engine不能直接拿到Jetson上跑必须在目标设备上重新建。我一般把ONNX文件作为中间产物部署时在目标设备上跑一次trtexec生成engine这样最稳。这套方案从CCPD数据解析到边缘部署链路不算短但每一步都有明确的验证指标。检测看mAP识别看逐字符准确率端到端看耗时和置信度分布。我习惯在每次改动后跑一遍完整的验证集把结果记在表格里避免“改了这里坏了那里”的玄学问题。希望帮到你。本文还有配套的精品资源点击获取