简介:本资源为基于 YOLOv8 与 LPRNet 的车牌识别系统完整工程包,面向计算机、人工智能、电子信息等专业的学生及企业开发者,可用于毕业设计、课程设计、大作业或初期项目立项演示。项目将目标检测与字符识别两阶段串联:YOLOv8 负责定位车牌区域,LPRNet 完成车牌字符序列识别,并配套 Flask 后端与 Vue 前端界面,形成可运行、可演示的端到端系统。压缩包共 60 个文件,约 36.15MB,包含 13 个 Python 源码文件、3 个 pt 权重与 1 个 pth 模型文件、3 个 Vue 组件、若干 json/yaml 配置及 jpg 示例图片,覆盖数据处理、模型训练、推理部署与前端展示各环节。资源内代码均经测试运行成功,功能正常。已有 1148 人学习下载,适合希望快速理解车牌识别全流程、借鉴检测与识别模型衔接方式、并在此基础上二次开发的读者参考使用。
1. 车牌识别为什么总在“检测”和“识别”之间翻车
很多做车牌识别的开发者都有过这种经历:YOLOv8 检测框画得漂漂亮亮,置信度 0.9 以上,可一到字符识别环节,输出就是“京A·12345”变成“京A·l234S”,或者干脆整串乱码。问题不在模型本身,而在于把检测和识别当成两个独立任务硬拼在一起——检测框稍微偏一点,LPRNet 的输入就完全变了分布,识别结果自然雪崩。
这套“YOLOv8 + LPRNet”的组合,本质上是把车牌识别拆成两阶段:YOLOv8 负责在整张图里定位车牌区域,LPRNet 负责把裁出来的车牌小图转成字符序列。它适合谁?适合已经跑通过 YOLO 检测、想补上字符识别这一环的工程师;也适合手头有几千张车牌图、想快速搭一套能用的端到端 pipeline 的团队。但要注意,两阶段方案的上限由检测框质量决定,LPRNet 再强也救不回一个歪到离谱的 crop。
2. 把两阶段串起来:YOLOv8 检测与 LPRNet 识别的接口设计
2.1 为什么选 YOLOv8 做检测而不是端到端方案
端到端车牌识别(比如用 Transformer 直接输出字符序列)听起来优雅,但实际落地时有两个硬伤:一是训练数据需求量大,没有几万张精标图根本收敛不好;二是调试困难,识别错了你很难判断是定位问题还是字符分类问题。YOLOv8 做检测的好处是成熟、快、可解释——检测框偏了你能立刻从可视化结果看出来,而不是面对一个黑匣子干瞪眼。
YOLOv8 本身支持多种尺寸(n/s/m/l/x),车牌检测这种单类别任务,n 或 s 就够用。输入尺寸我一般设 640,因为车牌在整图中占比通常不大,再小会丢细节。训练时把车牌当作唯一类别,标注格式用 YOLO 的 txt(class x_center y_center width height,归一化到 0-1)。
2.2 LPRNet 的输入预处理:裁图、对齐、归一化
LPRNet 原论文是针对中文车牌设计的,输入尺寸通常是 94×24(宽×高)。从 YOLOv8 拿到检测框后,不能直接 resize,因为车牌有倾斜角度。常见做法是先做透视变换把车牌摆正,再 resize 到 94×24。
透视变换需要车牌四角坐标。如果标注只有水平框,可以用简单的仿射变换近似;如果标注了四角点,直接 cv2.getPerspectiveTransform。我一般会在训练 YOLOv8 时额外回归四个角点,或者用分割模型出 mask 再拟合四边形。下面是一个从水平框做透视校正的最小示例:
import cv2 import numpy as np def crop_plate(img, box): """ img: 原始 BGR 图像 box: [x1, y1, x2, y2] 水平框 返回: 校正后的车牌图,尺寸 94x24 """ x1, y1, x2, y2 = map(int, box) plate = img[y1:y2, x1:x2] h, w = plate.shape[:2] # 假设车牌只有轻微倾斜,用四点仿射近似 src_pts = np.float32([[0, 0], [w, 0], [w, h], [0, h]]) dst_pts = np.float32([[0, 0], [93, 0], [93, 23], [0, 23]]) M = cv2.getPerspectiveTransform(src_pts, dst_pts) warped = cv2.warpPerspective(plate, M, (94, 24)) return warped这段代码的逻辑是:把检测框内的区域当作一个矩形,直接映射到 94×24 的目标矩形。参数 94 和 24 是 LPRNet 的标准输入,不要随意改,改了就要重新训练。如果车牌倾斜严重,这个近似会失效,需要改用四角点做透视变换。
2.3 字符集定义与 CTC 解码的对应关系
LPRNet 用 CTC loss 训练,输出是每个时间步的字符概率。中文车牌字符集包括:省份简称(京、沪、粤等)、字母(A-Z,去掉 I 和 O)、数字(0-9)、以及分隔符“·”。总共约 65 个类别,加上 CTC 的 blank,输出维度是 66。
定义字符集时要注意顺序必须固定,训练和推理用同一份映射表。我一般把字符集存成 txt,每行一个字符,blank 放在最后。解码时用 greedy decode:每个时间步取 argmax,去掉连续重复和 blank,剩下的就是结果。
def ctc_greedy_decode(logits, charset): """ logits: (T, num_classes) numpy array charset: list of chars, blank 在最后 返回: 解码字符串 """ indices = np.argmax(logits, axis=1) result = [] prev = -1 blank_idx = len(charset) - 1 for idx in indices: if idx != prev and idx != blank_idx: result.append(charset[idx]) prev = idx return ''.join(result)这里的关键参数是 blank_idx,必须和训练时一致。如果训练时 blank 放在第 0 位,推理时也要放第 0 位,否则解码全乱。这个坑我踩过,当时训练脚本和推理脚本用了不同的字符集顺序,结果识别出来全是乱码,排查了一下午才发现是映射表对不上。
3. 从零跑通训练:数据准备、配置与两个模型的联合调试
3.1 车牌检测数据集的标注格式转换
假设你手头有标注好的数据,常见格式是 VOC 的 xml 或 LabelImg 的 txt。YOLOv8 需要每张图对应一个 txt,每行是class x_center y_center width height,全部归一化。转换脚本如下:
import os import xml.etree.ElementTree as ET def voc_to_yolo(xml_path, out_path, img_w, img_h): tree = ET.parse(xml_path) root = tree.getroot() lines = [] for obj in root.findall('object'): name = obj.find('name').text if name != 'plate': continue bbox = obj.find('bndbox') x1 = float(bbox.find('xmin').text) y1 = float(bbox.find('ymin').text) x2 = float(bbox.find('xmax').text) y2 = float(bbox.find('ymax').text) xc = (x1 + x2) / 2 / img_w yc = (y1 + y2) / 2 / img_h w = (x2 - x1) / img_w h = (y2 - y1) / img_h lines.append(f"0 {xc:.6f} {yc:.6f} {w:.6f} {h:.6f}") with open(out_path, 'w') as f: f.write('\n'.join(lines))注意 img_w 和 img_h 必须和实际图片尺寸一致,不能想当然用 640。如果图片尺寸不一,最好先统一 resize 再标注,或者转换时动态读取图片尺寸。
3.2 YOLOv8 训练参数:epochs、imgsz、batch 怎么设
YOLOv8 的训练命令很简洁:
yolo detect train data=plate.yaml model=yolov8s.pt epochs=100 imgsz=640 batch=16参数说明:epochs 设 100 起步,车牌检测任务通常 50-80 轮就收敛了,但多跑没坏处;imgsz 设 640,如果车牌特别小可以设 1280,但显存翻倍;batch 根据显存调,16 是 8G 显存的保守值。plate.yaml 里写清楚 train/val 路径和类别数(nc=1,names=['plate'])。
训练完成后用yolo detect val看 mAP50,一般能到 0.95 以上。如果低于 0.9,检查标注是否有漏标或框不准。
3.3 LPRNet 训练:损失曲线不下降时先查这三个地方
LPRNet 的训练相对轻量,但容易遇到 loss 不降的情况。先查三处:一是学习率,初始 1e-3 配 Adam 通常没问题,如果 loss 震荡就降到 1e-4;二是字符集映射,确认训练和验证用的是一份;三是输入尺寸,必须和预处理一致,94×24 就一直是 94×24。
训练脚本的核心逻辑是:读一张车牌图,过 LPRNet 得到 (T, 66) 的 logits,用 CTC loss 和真实标签算损失。真实标签是字符索引序列,不需要对齐。batch 里不同长度的标签用 padding,CTC 会自动处理。
import torch import torch.nn as nn ctc_loss = nn.CTCLoss(blank=65, zero_infinity=True) # logits: (T, N, 66), targets: (N, S), input_lengths: (N,), target_lengths: (N,) loss = ctc_loss(logits.log_softmax(2), targets, input_lengths, target_lengths)zero_infinity=True 很重要,否则遇到某些样本会出 inf 导致训练崩溃。blank=65 对应字符集最后一位。
4. 推理部署:把两个模型串成一条流水线
4.1 检测框后处理:NMS 阈值与最小框过滤
YOLOv8 推理输出一堆框,需要 NMS 去重。conf 阈值我一般设 0.25,iou 设 0.45。另外要加一个最小框过滤:宽高小于 20 像素的框直接丢掉,因为这种框裁出来 LPRNet 也认不出。
from ultralytics import YOLO model = YOLO('best.pt') results = model(img, conf=0.25, iou=0.45) boxes = results[0].boxes.xyxy.cpu().numpy() # 过滤小框 valid = [] for box in boxes: w = box[2] - box[0] h = box[3] - box[1] if w > 20 and h > 10: valid.append(box)4.2 批量推理时的对齐与 padding 陷阱
如果一次处理多张车牌,LPRNet 的输入需要统一尺寸。但不同车牌的宽高比不同,直接 resize 会变形。常见做法是保持宽高比 resize 到高度 24,宽度不足 94 的补零,超过的截断。补零的位置要在右侧,并且记录实际宽度,CTC 解码时按实际宽度截断 logits。
这个细节很容易忽略:如果补零后不截断,解码结果末尾会多出一些无意义的字符。我一般会在推理时维护一个 width_list,解码时只取前 width//4 个时间步(LPRNet 的下采样倍率通常是 4)。
4.3 端到端脚本:从一张图到车牌字符串
把上面几步串起来:
def recognize_plate(img, det_model, rec_model, charset): results = det_model(img, conf=0.25, iou=0.45) boxes = results[0].boxes.xyxy.cpu().numpy() plates = [] for box in boxes: plate_img = crop_plate(img, box) tensor = preprocess(plate_img) # 归一化、转 tensor logits = rec_model(tensor) text = ctc_greedy_decode(logits, charset) plates.append(text) return platespreprocess 里要做的是:BGR 转 RGB、减均值除方差、HWC 转 CHW、加 batch 维度。均值和方差用 ImageNet 的即可,LPRNet 对这点不敏感。
5. 避坑指南:车牌识别落地时最容易翻车的五个地方
现象一:检测框很准,但识别结果少一位字符。原因通常是 CTC 解码时把相邻相同字符合并了,比如“AA”被解成“A”。解决方法是检查字符集里是否有重复字符,或者改用 beam search 解码。如果车牌本身有重复字符(如“京A·A1234”),greedy decode 确实会出错,这时候需要在两个相同字符之间插入 blank,但 CTC 训练时如果没学过这种模式,推理时也解不出来。实际做法是训练数据里要有足够多的重复字符样本。
现象二:夜间或逆光图片识别率骤降。原因是训练集里缺乏低照度样本,LPRNet 对对比度变化敏感。解决方法是做数据增强时加入随机亮度、对比度扰动,或者用 CLAHE 做预处理。我一般会在训练时加RandomBrightnessContrast,推理时对暗图先做一次直方图均衡。
现象三:新能源车牌(绿牌)识别错位。新能源车牌比蓝牌长一位,字符集里如果没有专门处理,LPRNet 会把最后一位截掉。解决方法是训练数据里包含足够多的新能源车牌,并且字符集要覆盖所有可能出现的字符。另外,新能源车牌的底色不同,检测模型如果只见过蓝牌,绿牌可能检不到,需要在标注时把绿牌也标上。
现象四:模型在验证集上表现好,上线后一塌糊涂。典型的数据分布偏移。验证集和训练集同分布,但实际场景的摄像头角度、光照、车牌磨损程度都不同。解决方法是尽量用真实场景数据训练,或者在验证集里加入不同场景的样本。我一般会留一个“困难集”,专门放模糊、倾斜、遮挡的图,用来评估模型的真实鲁棒性。
现象五:推理速度慢,达不到实时。YOLOv8s 在 GPU 上单帧约 10ms,LPRNet 约 5ms,加起来 15ms 理论上够 60fps。但如果预处理用了 Python 循环、或者每张车牌单独跑一次 LPRNet,速度会掉到 10fps 以下。解决方法是把车牌 crop 攒成 batch 一起送 LPRNet,并且预处理用 OpenCV 的 GPU 版本或者 TensorRT 加速。
6. 进阶技巧:用置信度过滤和投票机制把准确率再拉一截
前面讲的都是单帧识别,实际视频流里同一辆车会出现几十帧,完全可以利用时序信息提升准确率。我的做法是:对同一检测框在连续帧里的识别结果做投票,取出现次数最多的字符串。如果某帧的 LPRNet 输出置信度低于阈值(比如 0.8),直接丢弃该帧结果。
置信度怎么算?CTC 解码时,把每个时间步的 max 概率累乘,再除以字符数,得到平均置信度。低于阈值的直接不要。
def decode_with_confidence(logits, charset, threshold=0.8): probs = torch.softmax(logits, dim=-1) max_probs, indices = torch.max(probs, dim=-1) text = ctc_greedy_decode(logits, charset) if len(text) == 0: return text, 0.0 conf = max_probs.mean().item() return text, conf投票时维护一个字典,key 是字符串,value 是出现次数。每来一帧,如果置信度达标,对应字符串计数加一。当某字符串计数超过 3 且领先第二名 2 票以上,就输出为最终结果。这个策略在视频流里能把准确率从 92% 拉到 97% 以上,代价是延迟增加几帧。
另一个技巧是检测框的时序平滑。YOLOv8 每帧的框会有轻微抖动,如果直接用当前帧的框裁图,LPRNet 的输入会有微小变化。可以用卡尔曼滤波或者简单的滑动平均来稳定框的位置,这样裁出来的车牌更一致,识别结果也更稳定。
最后说一个我自己的习惯:每次训练完新模型,不要只看 mAP 和 loss,一定要拿几十张真实场景的图跑一遍端到端,肉眼检查识别结果。模型指标好看但实际翻车的例子太多了,只有亲眼看过输出,才能放心上线。希望帮到你。
本文还有配套的精品资源,点击获取