基于YOLOv8与LPRNet的车牌识别实战:从90%到98%的调优指南
简介本资源为基于 YOLOv8 与 LPRNet 的车牌识别系统完整项目包面向计算机、人工智能、电子信息等专业的学生及企业开发者可用于毕业设计、课程设计、大作业或初期项目立项演示也适合作为深度学习实战练习的参考案例。压缩包共 60 个文件约 36.15MB包含 13 个 Python 脚本、3 个 PyTorch 模型权重文件、22 张示例图片以及 Vue 前端页面、JSON 配置、Markdown 说明文档等覆盖数据生成、标注转换、数据集划分、模型训练与 Flask 推理服务等环节。项目代码均经过测试运行功能正常目录中提供 YOLOv8 训练脚本、LPRNet 车牌识别模块与 Web 端交互界面便于读者理解检测与识别两阶段流程的衔接方式。目前已有 1148 人学习下载适合希望快速搭建车牌识别原型、借鉴工程结构或进行二次开发的读者参考使用。1. 车牌识别为什么总在“最后一公里”翻车做过车牌识别的人大多有过这种体验白天正着拍、字迹清晰的车牌模型识别率能到 98% 以上可一旦换成地库出入口的逆光、夜间补光灯过曝、或者车速稍快带来的运动模糊准确率立刻掉到七八成甚至把“京A”认成“京B”。问题往往不在检测环节而在识别环节——检测框稍微偏一点、字符被切掉一两个像素整块车牌就废了。基于 YOLOv8 和 LPRNet 的车牌识别系统解决的正是这条“最后一公里”。YOLOv8 负责在整张图里把车牌框出来LPRNet 负责把框里的字符序列读出来。前者是通用目标检测里目前工程化最成熟的方案之一后者是专门为车牌这种短序列、定长字符集设计的轻量识别网络不需要字符分割端到端出结果。这套组合适合谁适合要做停车场出入口、园区道闸、交通卡口这类场景的开发者尤其是想在本地跑通、自己换数据集、自己调参的团队。它不依赖云端 API模型小、推理快一张消费级显卡就能同时跑多路视频流。2. YOLOv8 与 LPRNet 的分工为什么不是“一个模型全干完”2.1 检测与识别的边界在哪里很多人第一反应是能不能用一个模型直接从图里读出车牌号理论上可以但工程上不划算。车牌在整张图里占比通常不到 5%直接做序列识别网络要花大量容量去“找”车牌而不是“读”字符。YOLOv8 把定位这件事做到极致输出的是车牌的四个角点或矩形框LPRNet 只在这个小框里工作输入尺寸固定字符集固定专注度完全不同。这个分工还有一个隐性好处检测模型和识别模型可以独立迭代。今天换了相机、换了角度只需要重新标注检测数据微调 YOLOv8字符集变了比如新增新能源车牌只需要动 LPRNet 的输出层。如果是一个端到端模型任何一点变化都要整体重训成本高得多。2.2 两个模型的输入输出对齐YOLOv8 的输出是边界框坐标LPRNet 的输入是固定尺寸的图像块。中间这一步“裁剪缩放”看似简单却是最容易出问题的地方。常见做法是YOLOv8 检测到框后按框坐标从原图裁剪再缩放到 LPRNet 要求的尺寸通常是 94×24 或 96×32。这里有两个参数必须对齐一是裁剪时是否留边距二是缩放时是否保持宽高比。我一般会在裁剪时向外扩 5% 到 10% 的边距防止检测框偏紧导致字符被切。缩放时如果直接拉伸字符会变形LPRNet 的识别率会明显下降。更稳的做法是按宽高比缩放后居中填充填充值用 0 或 128 都行但训练和推理必须一致。import cv2 import numpy as np def crop_plate(image, box, expand_ratio0.08, target_size(94, 24)): image: 原始 BGR 图像 box: [x1, y1, x2, y2] 检测框 expand_ratio: 向外扩展比例防止字符被切 target_size: LPRNet 输入尺寸 (宽, 高) h, w image.shape[:2] x1, y1, x2, y2 box bw, bh x2 - x1, y2 - y1 # 向外扩展 x1 max(0, int(x1 - bw * expand_ratio)) y1 max(0, int(y1 - bh * expand_ratio)) x2 min(w, int(x2 bw * expand_ratio)) y2 min(h, int(y2 bh * expand_ratio)) crop image[y1:y2, x1:x2] # 按宽高比缩放后填充 tw, th target_size ch, cw crop.shape[:2] scale min(tw / cw, th / ch) nw, nh int(cw * scale), int(ch * scale) resized cv2.resize(crop, (nw, nh)) canvas np.zeros((th, tw, 3), dtypenp.uint8) canvas[:] 128 ox, oy (tw - nw) // 2, (th - nh) // 2 canvas[oy:oy nh, ox:ox nw] resized return canvas这段代码里expand_ratio控制外扩比例太小会切字太大会引入背景干扰target_size必须和 LPRNet 训练时一致改了就重新训。填充值 128 是中性灰比 0 更不容易让网络把填充区当成字符。2.3 字符集与输出解码LPRNet 的输出是一个序列每个时间步对应字符集里的一个索引。国内车牌常见字符集包括省份简称31 个、字母去掉 I 和 O、数字、以及新能源车牌的特殊位。常见做法是维护一个chars列表索引 0 留给 CTC blank最后一位留给序列结束或填充。解码时用 CTC 的 greedy 解码就够了车牌长度固定7 位或 8 位不需要 beam search。但要注意如果训练时用了 CTC loss推理时也必须按 CTC 规则去重和去 blank否则会出现重复字符。def decode_ctc(logits, chars): logits: LPRNet 输出形状 [T, num_classes] chars: 字符集列表索引 0 为 blank indices logits.argmax(axis1) result [] prev -1 for idx in indices: if idx ! 0 and idx ! prev: result.append(chars[idx]) prev idx return .join(result)logits是每个时间步的类别分数argmax取最大索引。去重逻辑是 CTC 的核心连续相同字符只保留一个blank 直接跳过。如果发现输出有重复字符先检查这里。3. 从零跑通数据准备、训练与推理的最小闭环3.1 数据集标注与格式转换YOLOv8 需要 YOLO 格式的标注每张图一个 txt每行class x_center y_center width height坐标归一化到 0 到 1。LPRNet 需要的是裁剪后的车牌图加对应文本标签。常见做法是先用标注工具把整图里的车牌框出来再写脚本按框裁剪生成 LPRNet 的训练集。# 目录结构建议 dataset/ images/ # 原始整图 labels/ # YOLO 格式标注 plates/ # 裁剪后的车牌图 plate_labels.txt # 每行: 文件名 车牌号转换脚本的核心是按标注框裁剪并保存同时把车牌号写入标签文件。注意裁剪时用的外扩比例要和推理时一致否则训练和推理的分布不匹配。import os import cv2 def build_lpr_dataset(image_dir, label_dir, out_dir, label_file, expand_ratio0.08): os.makedirs(out_dir, exist_okTrue) fout open(label_file, w, encodingutf-8) for name in os.listdir(image_dir): if not name.lower().endswith((.jpg, .png)): continue img_path os.path.join(image_dir, name) lbl_path os.path.join(label_dir, os.path.splitext(name)[0] .txt) if not os.path.exists(lbl_path): continue img cv2.imread(img_path) h, w img.shape[:2] with open(lbl_path, encodingutf-8) as f: for i, line in enumerate(f): parts line.strip().split() if len(parts) 5: continue _, xc, yc, bw, bh map(float, parts[:5]) # 转回像素坐标 x1 int((xc - bw / 2) * w) y1 int((yc - bh / 2) * h) x2 int((xc bw / 2) * w) y2 int((yc bh / 2) * h) # 外扩 ew, eh int((x2 - x1) * expand_ratio), int((y2 - y1) * expand_ratio) x1, y1 max(0, x1 - ew), max(0, y1 - eh) x2, y2 min(w, x2 ew), min(h, y2 eh) crop img[y1:y2, x1:x2] out_name f{os.path.splitext(name)[0]}_{i}.jpg cv2.imwrite(os.path.join(out_dir, out_name), crop) # 车牌号需要另外维护映射这里假设已有 # fout.write(f{out_name} {plate_text}\n) fout.close()这段脚本只负责裁剪车牌号文本需要你从标注里另外提取。实际项目中车牌号通常和检测框一起标注格式可以是class x y w h plate_text读取时多取一个字段即可。3.2 YOLOv8 训练的关键参数YOLOv8 的训练命令很简洁但几个参数直接决定检测框的质量。我一般会这样起步yolo detect train \ dataplate.yaml \ modelyolov8n.pt \ epochs100 \ imgsz640 \ batch16 \ lr00.01 \ patience20 \ augmentTrue \ mosaic1.0 \ degrees10.0 \ translate0.1 \ scale0.5modelyolov8n.pt是最小的预训练权重车牌检测任务通常够用如果误检多再换yolov8s。imgsz640是输入尺寸车牌在图中占比小的话可以提到 960但推理速度会下降。mosaic1.0是 YOLOv8 默认的增强对小目标检测帮助很大但训练后期建议关掉否则框的定位会偏。degrees10.0控制旋转增强车牌倾斜场景多的话可以加到 15。训练完成后验证集上的mAP50至少要到 0.95 以上否则检测框会拖累识别。如果 mAP 上不去先看标注有没有漏框、框有没有贴太紧。3.3 LPRNet 训练与损失函数LPRNet 的核心是 CTC loss输出层类别数等于字符集大小加 1blank。训练时输入是裁剪好的车牌图标签是车牌字符串。常见做法是用 Adam 优化器学习率 1e-3batch 32训练 50 到 100 轮。import torch import torch.nn as nn class LPRNet(nn.Module): def __init__(self, num_classes): super().__init__() # 简化版结构实际可用 backbone 序列头 self.backbone nn.Sequential( nn.Conv2d(3, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(128, 256, 3, padding1), nn.ReLU(), ) self.head nn.Linear(256, num_classes) def forward(self, x): feat self.backbone(x) # [B, 256, H/4, W/4] b, c, h, w feat.shape feat feat.mean(dim2) # 沿高度池化 feat feat.permute(0, 2, 1) # [B, W/4, 256] return self.head(feat) # [B, T, num_classes] # 训练循环关键部分 criterion nn.CTCLoss(blank0, zero_infinityTrue) optimizer torch.optim.Adam(model.parameters(), lr1e-3) for images, labels, label_lens in dataloader: logits model(images) # [B, T, C] log_probs logits.log_softmax(dim2).permute(1, 0, 2) # [T, B, C] input_lens torch.full((images.size(0),), logits.size(1), dtypetorch.long) loss criterion(log_probs, labels, input_lens, label_lens) loss.backward() optimizer.step()CTCLoss的blank0表示索引 0 是空白符zero_infinityTrue防止极端情况下 loss 变成无穷。log_probs的形状必须是[T, B, C]这是 PyTorch CTC 的要求很多人在这里翻车。input_lens是每个样本的时间步数如果 batch 内长度不一致需要按实际长度传。3.4 推理串联与后处理推理时先跑 YOLOv8 得到框再逐个裁剪送 LPRNet。这里有一个性能优化点如果一张图里有多个车牌可以 batch 起来一起送 LPRNet比逐个跑快很多。def recognize(image, det_model, lpr_model, chars, devicecuda): results det_model(image, imgsz640, conf0.4, iou0.5)[0] boxes results.boxes.xyxy.cpu().numpy() plates [] for box in boxes: crop crop_plate(image, box) tensor torch.from_numpy(crop).permute(2, 0, 1).float().unsqueeze(0) / 255.0 tensor tensor.to(device) with torch.no_grad(): logits lpr_model(tensor)[0].cpu().numpy() text decode_ctc(logits, chars) plates.append((box, text)) return platesconf0.4是检测置信度阈值太低会引入误检太高会漏掉模糊车牌。iou0.5控制 NMS 的合并程度。识别结果建议加一个简单的规则校验车牌长度是否符合、省份简称是否在合法集合里不符合的可以标记为低置信度交给人工复核。4. 避坑与排查那些让识别率腰斩的细节4.1 检测框偏紧导致字符被切现象LPRNet 输出总是少一两个字符尤其是车牌边缘的省份简称。原因YOLOv8 训练时标注框贴得太紧推理时框也紧裁剪后字符被切掉。解决训练标注时框向外扩 5% 到 10%推理裁剪时同样外扩保持训练和推理一致。如果已经训完了可以在推理时加大expand_ratio但效果不如重新标注。4.2 训练和推理的预处理不一致现象训练时 loss 降得很好推理时准确率却很低。原因训练时用了某种归一化或填充方式推理时忘了。比如训练时图像除以 255推理时忘了除或者训练时填充 128推理时填充 0。解决把预处理写成一个函数训练和推理都调用同一个不要各写各的。4.3 CTC 解码重复字符现象输出车牌号里有连续重复字符比如“京A12345”变成“京A112345”。原因解码时没有按 CTC 规则去重或者 blank 索引设错了。解决确认blank0解码时连续相同索引只保留一个blank 直接跳过。如果还有问题检查训练标签里有没有把 blank 当成普通字符。4.4 新能源车牌识别率低现象普通蓝牌识别正常绿牌新能源识别率明显偏低。原因新能源车牌是 8 位字符集和普通车牌不同训练数据里绿牌样本太少。解决单独收集绿牌数据至少占训练集的 20% 以上输出层类别数要覆盖新能源字符集如果绿牌和蓝牌混在一个模型里可以在检测阶段加一个类别区分识别阶段用不同的字符集。4.5 夜间过曝导致字符粘连现象夜间补光灯直射车牌反光字符糊成一片。原因图像动态范围不够字符和背景对比度丢失。解决在预处理阶段加一个自适应直方图均衡化CLAHE对过曝区域有一定恢复效果更根本的是调整补光灯角度避免直射车牌。如果硬件改不了可以在训练数据里加入过曝样本让模型学会适应。5. 把识别率从 90% 推到 98% 的几个进阶技巧第一个技巧是检测框的二次回归。YOLOv8 输出的框有时候会偏尤其是车牌倾斜时。可以在检测和识别之间加一个轻量的角点回归网络把车牌的四个角点找出来再做透视变换校正。校正后的车牌是正矩形LPRNet 的识别率会明显提升。这个回归网络不需要很大几个卷积层就够训练数据用检测框的角点标注。第二个技巧是识别结果的置信度过滤。LPRNet 的 CTC 输出可以算出一个序列概率把概率低于阈值的识别结果标记为可疑。可疑结果不要直接丢弃可以送一个更重的模型复核或者触发二次抓拍。实际部署时这个策略能把误识率降低一个数量级。第三个技巧是数据增强的针对性设计。车牌识别的难点集中在模糊、倾斜、过曝、遮挡这四类。训练时不要只用通用的随机裁剪和翻转要针对性地加运动模糊、透视变换、亮度扰动、随机遮挡。这些增强在 YOLOv8 和 LPRNet 的训练里都要加但强度要控制过强的增强会让模型学不到真实分布。第四个技巧是模型量化与加速。如果部署在边缘设备上可以把 YOLOv8 和 LPRNet 都转成 ONNX 或 TensorRT用 FP16 甚至 INT8 推理。量化后精度通常掉 1% 到 2%但速度能翻倍。量化时要注意校准集的选取用真实场景的图不要用训练集里的图否则量化误差会偏。最后一个习惯每次调整参数或换模型都固定一组验证集跑一遍记录检测 mAP、识别准确率、单帧耗时三个指标。不要凭感觉说“好像好了点”数据不会骗人。我自己的教训是有一次换了个更大的检测模型mAP 涨了 2%但识别率反而掉了因为大模型的框更紧字符被切了。后来把外扩比例调大才恢复。这个坑让我明白检测和识别是两个耦合的系统不能单独看某一个指标。希望帮到你。本文还有配套的精品资源点击获取