☰
基于YOLOv8和LPRNet的车牌识别系统源码解析与实战调优
2026/10/2 3:46:14 网站建设 项目流程

简介:本资源是一套基于 YOLOv8 与 LPRNet 的车牌识别系统完整项目,面向计算机、人工智能等专业的学生及开发者,可直接用于毕业设计、期末大作业或课程设计。项目将目标检测与字符识别两阶段串联,涵盖数据标注、模型训练、推理部署及前端展示等环节,代码注释详尽,新手也能快速理解整体流程。压缩包共 60 个文件,约 31.36MB,包含 13 个 Python 源码文件、3 个 pt 模型权重、1 个 pth 权重、3 个 Vue 前端文件,以及 json、yaml、Dockerfile、md 等配置与说明文档,另附 jpg 示例图片,覆盖训练、推理与界面展示所需素材。目前已有 232 人学习下载。项目经过严格调试,部署后即可运行,读者可据此掌握 YOLOv8 检测与 LPRNet 识别的联合调优思路、数据集制作方法及 Flask 后端与 Vue 前端的对接方式,具备较高的实际应用与参考价值。

1. 从一张糊到看不清的图说起:这套车牌识别源码到底能干什么

去年帮一个做停车场系统的朋友看现场,出口相机拍回来的图,车牌占画面不到八分之一,晚上还有车灯眩光,传统 OpenCV 那套边缘检测加颜色分割直接崩了,识别率掉到六成以下。后来换成检测加识别的两阶段方案,先定位车牌再单独识别字符,同样的图识别率拉回到九成五以上。这套「基于 YOLOv8 和 LPRNet 的车牌识别系统 python 源码 + 训练好的模型」,走的就是这条路子,而且把训练好的权重一起打包了,拿到手不用从零训,直接能跑推理。

它解决的核心问题就一个:在复杂光照、倾斜、远距离、多车牌并存的场景下,把车牌从整张图里抠出来,再把上面的字符逐个认对。适合谁?做计算机毕业设计的学生,需要一套结构清晰、能讲清楚原理、还能现场演示的系统;也适合做停车场、门禁、交通卡口这类项目的工程师,想快速搭个能用的基线,再往上叠业务逻辑。源码是 Python 的,YOLOv8 负责检测,LPRNet 负责字符识别,两个模型都是现成的,省掉了最耗时的标注和训练环节。

我拿到这类资源包,第一件事不是急着跑,而是先看目录结构和依赖,确认它到底是「能复现的工程」还是「跑不通的 demo」。这套东西的价值在于,它把检测和识别两个环节的接口对齐了,中间的数据流转是通的,不是两个孤立脚本拼在一起。下面按我实际拆包的顺序,把怎么用、参数怎么调、坑在哪,一条条讲清楚。

2. 拆开资源包先看什么:YOLOv8 检测与 LPRNet 识别的接口对齐

2.1 两阶段架构为什么比端到端更适合车牌场景

车牌识别本质上是个「先定位、后识别」的问题。端到端方案(比如直接用一个大模型输出车牌字符串)听起来优雅,但实际落地时有两个硬伤:一是车牌在整图里占比小,端到端模型容易被背景干扰;二是字符识别对分辨率敏感,整图缩放到统一尺寸后,车牌区域的信息损失严重。两阶段方案把这两个问题拆开解决——YOLOv8 在原始分辨率上做检测,输出车牌框坐标,再把框内的区域裁剪出来,缩放到 LPRNet 需要的尺寸单独识别。这样检测阶段不受字符分辨率影响,识别阶段不受背景干扰。

YOLOv8 在这套系统里用的是 nano 或 small 级别的骨干,常见做法是选 yolov8n,因为车牌检测的目标类别单一(就「车牌」一类),不需要大模型的特征容量。LPRNet 则是个轻量级卷积网络,专门为车牌字符识别设计,输入尺寸通常是 94x24 或 120x32,输出是字符序列。两个模型的衔接点在于:YOLOv8 输出的框要按一定比例外扩(常见 5% 到 10%),避免车牌边缘字符被裁掉,然后再送进 LPRNet。

提示:外扩比例不是拍脑袋定的,跟你的检测框精度有关。如果 YOLOv8 训练时标注框贴得紧,外扩 5% 就够;如果标注有偏差,外扩 10% 更稳,但要注意别把相邻车牌框进来。

2.2 目录结构与依赖检查:先确认能跑再谈调优

拿到源码包,先别急着pip install,按这个顺序过一遍:

# 第一步:看目录结构,确认模型文件和数据样例在不在 tree -L 2 ./plate_recognition # 典型输出应该是这样: # ├── models/ # │ ├── yolov8n_plate.pt # YOLOv8 检测权重 # │ └── lprnet_final.pth # LPRNet 识别权重 # ├── utils/ # │ ├── detector.py # 检测封装 # │ ├── recognizer.py # 识别封装 # │ └── plate_utils.py # 坐标变换、字符映射 # ├── configs/ # │ └── config.yaml # 阈值、尺寸、字符集配置 # ├── demo.py # 单图/批量推理入口 # └── requirements.txt
# 第二步:检查依赖版本,重点看 torch 和 ultralytics cat requirements.txt # 常见内容: # torch>=1.8.0 # ultralytics>=8.0.0 # opencv-python>=4.5.0 # numpy # pillow
# 第三步:确认权重文件完整性,别下到一半的残包 ls -lh models/ # yolov8n_plate.pt 一般在 6MB 左右,lprnet_final.pth 在 2MB 上下 # 如果明显偏小,说明文件没下全,重新获取

逻辑说明:第一步的目录结构决定了你后面改代码时知道去哪找。第二步的依赖里,ultralytics是 YOLOv8 的官方库,版本低于 8.0 会缺 API;torch版本跟你的 CUDA 有关,CPU 跑也能用但慢。第三步是血泪经验,我见过好几次权重文件只有几百 KB,跑起来报unexpected EOF,查半天以为是代码问题,其实是下载断了。

参数说明:config.yaml里通常有几个关键项——det_conf_thres(检测置信度阈值,默认 0.25)、det_iou_thres(NMS 的 IoU 阈值,默认 0.45)、input_size(LPRNet 输入尺寸)、chars(字符集,包含省份简称和字母数字)。这些值先别改,跑通默认配置再说。

2.3 跑通第一张图:从命令行到可视化输出

# demo.py 的核心推理流程(简化版,方便理解数据流转) import cv2 import torch from ultralytics import YOLO from utils.recognizer import LPRNet from utils.plate_utils import decode_plate, expand_box # 加载两个模型 detector = YOLO('models/yolov8n_plate.pt') recognizer = LPRNet(chars=config['chars']) recognizer.load_state_dict(torch.load('models/lprnet_final.pth')) recognizer.eval() # 读图 img = cv2.imread('test.jpg') # 检测:YOLOv8 返回框和置信度 results = detector(img, conf=0.25, iou=0.45) boxes = results[0].boxes.xyxy.cpu().numpy() for box in boxes: # 外扩 8%,防止边缘字符被裁 x1, y1, x2, y2 = expand_box(box, ratio=0.08, img_shape=img.shape) plate_crop = img[y1:y2, x1:x2] # 缩放到 LPRNet 输入尺寸 plate_resized = cv2.resize(plate_crop, (94, 24)) # 识别 plate_text = recognizer.predict(plate_resized) # 画框和文字 cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, plate_text, (x1, y1-10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imwrite('result.jpg', img)

逻辑说明:这段代码把两个模型串起来了。detector(img)返回的是 YOLOv8 的标准输出,boxes.xyxy是左上右下坐标。expand_box做外扩,cv2.resize把裁剪区域统一到 LPRNet 的输入尺寸。recognizer.predict内部会做归一化和 CTC 解码,输出字符串。

参数说明:conf=0.25是检测阈值,调高会漏检、调低会误检;iou=0.45控制 NMS 合并重叠框的力度;ratio=0.08是外扩比例,前面说过跟标注精度有关;(94, 24)是 LPRNet 的输入尺寸,必须跟训练时一致,改了会识别乱码。

跑通这张图之后,你会得到一张画了框和识别结果的图。如果框位置对但文字错,问题在识别模型或字符集;如果框都没出来,问题在检测阈值或权重加载。这一步是分水岭,过了才能往下调。

3. 把模型跑出稳定结果:阈值、尺寸与批处理的参数怎么设

3.1 检测阈值与 NMS 的联动调法

YOLOv8 的conf和iou两个参数不是独立的,调的时候要一起看。conf决定哪些框进入候选,iou决定候选框怎么合并。常见误区是只调conf不调iou,结果要么一堆重叠框,要么相邻车牌被合并成一个。

我一般按这个顺序调:先把iou固定在 0.45,conf从 0.5 往下试到 0.15,看漏检和误检的平衡点。车牌场景下,如果图里车牌清晰,conf=0.4就够;如果远距离小目标多,降到 0.2 甚至 0.15。然后固定conf,iou从 0.3 试到 0.6,如果发现两个车牌挨得近被合并,把iou降到 0.3 到 0.35;如果同一个车牌出多个框,把iou升到 0.5 以上。

# 批量测试不同阈值组合,找最优 import itertools import cv2 from ultralytics import YOLO detector = YOLO('models/yolov8n_plate.pt') img = cv2.imread('test_multi.jpg') for conf, iou in itertools.product([0.15, 0.25, 0.35, 0.45], [0.3, 0.4, 0.5]): results = detector(img, conf=conf, iou=iou, verbose=False) n_boxes = len(results[0].boxes) print(f"conf={conf}, iou={iou}, boxes={n_boxes}")

逻辑说明:这段脚本遍历阈值组合,打印每种组合检出的框数。框数突然跳变的地方就是阈值敏感区,选跳变前的稳定值。

参数说明:verbose=False关掉 YOLO 的日志输出,批量测试时清爽。实际部署时,conf和iou应该写进配置文件,不要硬编码在代码里,方便现场调。

3.2 LPRNet 输入尺寸与字符集的对应关系

LPRNet 的输入尺寸和字符集是绑定的。常见的中文车牌字符集是 68 个字符(省份简称 31 个 + 字母 24 个 + 数字 10 个 + 特殊字符),加上 CTC 的 blank 标签,输出维度是 69。如果你拿到的权重是在 94x24 上训的,推理时改成 120x32,识别结果会全乱,因为卷积层的感受野和位置编码对不上。

# 检查字符集和输入尺寸是否匹配 import yaml with open('configs/config.yaml', 'r', encoding='utf-8') as f: config = yaml.safe_load(f) print(f"输入尺寸: {config['input_size']}") # 应该是 [94, 24] 或 [120, 32] print(f"字符集长度: {len(config['chars'])}") # 中文车牌一般是 68 print(f"字符集前几个: {config['chars'][:5]}") # 看是不是省份简称开头

逻辑说明:这段代码读配置,确认输入尺寸和字符集。如果字符集长度对不上权重文件的输出维度,加载权重时会报size mismatch。

参数说明:input_size是列表[width, height],注意顺序,OpenCV 的 resize 是(width, height),别搞反。chars是字符串或列表,顺序必须跟训练时一致,否则解码出来的字符会错位。

3.3 批处理与 GPU 显存:什么时候该分批

如果一次要处理几百张图,逐张推理太慢。YOLOv8 支持批处理,LPRNet 也可以。但批处理大小受显存限制,常见做法是检测阶段用 batch=8 或 16,识别阶段因为裁剪区域尺寸小,可以 batch=32 甚至 64。

# 批处理推理示例 import glob import cv2 import torch from ultralytics import YOLO detector = YOLO('models/yolov8n_plate.pt') img_paths = glob.glob('test_imgs/*.jpg') # 检测阶段批处理 results = detector(img_paths, conf=0.25, iou=0.45, batch=8) # 收集所有裁剪区域 all_crops = [] for i, r in enumerate(results): img = cv2.imread(img_paths[i]) for box in r.boxes.xyxy.cpu().numpy(): x1, y1, x2, y2 = expand_box(box, 0.08, img.shape) crop = cv2.resize(img[y1:y2, x1:x2], (94, 24)) all_crops.append(crop) # 识别阶段批处理 crops_tensor = torch.stack([torch.from_numpy(c).permute(2,0,1).float()/255 for c in all_crops]) with torch.no_grad(): for i in range(0, len(crops_tensor), 32): batch = crops_tensor[i:i+32] texts = recognizer.predict_batch(batch)

逻辑说明:检测阶段把图片路径列表传给 YOLO,它内部会分批。识别阶段先把所有裁剪区域收集起来,再按 32 一批送进 LPRNet。这样比逐张快三到五倍。

参数说明:batch=8是检测的批大小,显存不够就降到 4 或 2。识别阶段的 32 可以根据显存调,CPU 跑的话建议降到 8。torch.no_grad()关掉梯度计算,省显存。

注意:批处理时图片尺寸不一致会导致 YOLO 内部做 padding,padding 太多会影响检测精度。常见做法是先把图片缩放到相近尺寸再批处理,或者干脆逐张检测、批量识别。

4. 避坑与排查:这套源码跑不起来时先查这五处

4.1 现象:报错No module named 'ultralytics'或版本不兼容

原因:ultralytics没装,或者装了旧版(7.x),API 跟 YOLOv8 不兼容。YOLOv8 的YOLO类和model.predict接口在 8.0 之后才稳定。

解决:先卸载旧版再装新版。

pip uninstall ultralytics -y pip install ultralytics>=8.0.0 # 如果还报错,检查 torch 版本 python -c "import torch; print(torch.__version__)" # torch 低于 1.8 的话,ultralytics 8.x 跑不了,升级 torch

4.2 现象:权重加载报unexpected key或size mismatch

原因:权重文件跟模型定义对不上。常见情况是 LPRNet 的字符集改了,输出层维度变了,但权重还是旧的;或者 YOLOv8 权重是分割模型(-seg)而不是检测模型。

解决:先确认权重类型和字符集。

# 检查 YOLOv8 权重类型 from ultralytics import YOLO model = YOLO('models/yolov8n_plate.pt') print(model.task) # 应该是 'detect',如果是 'segment' 就错了
# 检查 LPRNet 权重输出维度 import torch state_dict = torch.load('models/lprnet_final.pth', map_location='cpu') # 找最后一层全连接或卷积的输出维度 for k, v in state_dict.items(): if 'output' in k or 'fc' in k: print(k, v.shape)

逻辑说明:model.task告诉你权重是检测还是分割。LPRNet 的输出维度要跟字符集长度加一(CTC blank)对上。

4.3 现象:识别结果全是重复字符或空白

原因:CTC 解码出问题。常见是输入图片没做归一化,或者解码时没去重、没去 blank。

解决:检查预处理和解码逻辑。

# 正确的预处理:归一化到 [0, 1] 并转 tensor crop = cv2.resize(plate_crop, (94, 24)) crop = crop.astype('float32') / 255.0 crop = torch.from_numpy(crop).permute(2, 0, 1).unsqueeze(0) # 正确的 CTC 解码:去重、去 blank def ctc_decode(logits, chars, blank=0): indices = torch.argmax(logits, dim=2).squeeze().cpu().numpy() result = [] prev = -1 for idx in indices: if idx != blank and idx != prev: result.append(chars[idx - 1]) # 减 1 是因为 blank 占了 0 prev = idx return ''.join(result)

逻辑说明:归一化是必须的,LPRNet 训练时输入就是 [0,1]。CTC 解码的核心是「合并重复、去掉 blank」,漏了任何一步都会输出乱码。

4.4 现象:CPU 上跑得极慢,一张图要好几秒

原因:默认用了 CPU 推理,或者 torch 没装对应 CUDA 版本。YOLOv8 在 CPU 上跑 nano 模型大概 200 到 500 毫秒一张,LPRNet 更慢。

解决:确认 GPU 可用并指定设备。

import torch print(torch.cuda.is_available()) # True 才能用 GPU # 推理时指定 device results = detector(img, device='cuda:0' if torch.cuda.is_available() else 'cpu') recognizer = recognizer.to('cuda' if torch.cuda.is_available() else 'cpu')

参数说明:device='cuda:0'指定第一块 GPU。如果有多块,可以选cuda:1。CPU 跑的话,把 YOLO 的imgsz降到 320 能提速,但小目标检测会变差。

4.5 现象:中文车牌识别出字母数字,省份简称丢了

原因:字符集里没有中文省份简称,或者训练数据里中文样本太少。LPRNet 的字符集如果只包含字母数字,中文自然识别不出来。

解决:检查字符集是否包含省份简称。

# 常见的中文车牌字符集应该包含这些 provinces = '京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼' letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' digits = '0123456789' full_chars = provinces + letters + digits print(f"完整字符集长度: {len(full_chars)}") # 应该是 68

逻辑说明:如果配置里的chars长度明显小于 68,说明省份简称缺失。这种情况要么换权重,要么自己补训练数据重新训 LPRNet。

5. 从能跑到好用:置信度过滤、多帧投票与结果后处理

5.1 用置信度做二次过滤,压掉误检

YOLOv8 输出的每个框都带置信度,但默认的conf阈值是全局的。实际场景里,有些误检框的置信度在 0.3 到 0.4 之间,跟真车牌混在一起。我一般会在检测之后再加一层过滤:对每个框,把裁剪区域送进 LPRNet,如果识别结果的字符数不在合理范围(比如中文车牌 7 到 8 位),或者识别置信度低于某个值,就丢掉这个框。

# 二次过滤:结合检测置信度和识别置信度 def filter_plates(results, recognizer, img, det_thres=0.3, rec_thres=0.8): valid_plates = [] for box in results[0].boxes: det_conf = box.conf.item() if det_conf < det_thres: continue x1, y1, x2, y2 = box.xyxy.cpu().numpy()[0] crop = cv2.resize(img[int(y1):int(y2), int(x1):int(x2)], (94, 24)) text, rec_conf = recognizer.predict_with_conf(crop) # 字符数合理且识别置信度够高 if 6 <= len(text) <= 9 and rec_conf >= rec_thres: valid_plates.append((text, det_conf * rec_conf)) return valid_plates

逻辑说明:det_conf * rec_conf作为综合得分,比单看检测置信度更稳。字符数范围 6 到 9 覆盖了普通车牌和新能源车牌。

参数说明:det_thres=0.3比默认的 0.25 稍高,压误检;rec_thres=0.8是识别置信度门槛,调高会漏掉模糊车牌,调低会引入错误结果。这两个值要根据你的场景试。

5.2 多帧投票:视频流里把抖动压下去

如果是视频流,同一辆车连续多帧都会被检测到。单帧识别可能因为运动模糊出错,但多帧投票能纠正。常见做法是维护一个滑动窗口,对同一个车牌位置(用 IoU 匹配)的识别结果做多数投票。

from collections import deque, Counter class PlateVoter: def __init__(self, window=5): self.window = window self.history = deque(maxlen=window) def add(self, plate_text): self.history.append(plate_text) def get_stable(self): if len(self.history) < 3: return None # 多数投票 counter = Counter(self.history) text, count = counter.most_common(1)[0] # 至少 60% 的帧认同才输出 if count / len(self.history) >= 0.6: return text return None

逻辑说明:deque(maxlen=5)维护最近 5 帧的结果,Counter统计出现次数,超过 60% 才认为稳定。这样单帧的偶发错误会被压掉。

参数说明:window=5是窗口大小,视频帧率高可以调到 7 或 9,帧率低就降到 3。0.6是投票阈值,调高更稳但响应慢,调低响应快但可能输出错误结果。

5.3 结果后处理:省份简称纠错与格式校验

LPRNet 的输出偶尔会把相似的字符认错,比如「京」和「津」、「0」和「O」。常见做法是加一层规则校验:第一位必须是省份简称,第二位必须是字母,后面是字母数字混合。如果不符合,尝试用编辑距离找最接近的合法车牌。

# 简单的格式校验和纠错 PROVINCES = set('京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼') LETTERS = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ') DIGITS = set('0123456789') def validate_plate(text): if len(text) < 7: return None # 第一位必须是省份简称 if text[0] not in PROVINCES: return None # 第二位必须是字母 if text[1] not in LETTERS: return None # 后面是字母数字 for ch in text[2:]: if ch not in LETTERS and ch not in DIGITS: return None return text

逻辑说明:这个校验函数把明显不合法的结果直接过滤掉。如果识别出「京A12345」,通过;如果识别出「京A12O45」(字母 O 混进数字位),可以加一步把 O 替换成 0 再校验。

参数说明:PROVINCES集合可以根据你的实际场景增减,比如只做某个省的业务,就只保留那个省的简称。校验规则越严,误识率越低,但漏识率会上升,要平衡。

5.4 一个完整的推理脚本骨架

把前面的东西串起来,一个能直接用的推理脚本大概长这样:

import cv2 import torch import yaml from ultralytics import YOLO from utils.recognizer import LPRNet from utils.plate_utils import expand_box, ctc_decode # 加载配置 with open('configs/config.yaml', 'r', encoding='utf-8') as f: config = yaml.safe_load(f) # 初始化模型 device = 'cuda' if torch.cuda.is_available() else 'cpu' detector = YOLO('models/yolov8n_plate.pt').to(device) recognizer = LPRNet(chars=config['chars']).to(device) recognizer.load_state_dict(torch.load('models/lprnet_final.pth', map_location=device)) recognizer.eval() def recognize_image(img_path): img = cv2.imread(img_path) results = detector(img, conf=0.25, iou=0.45, device=device) plates = [] for box in results[0].boxes: x1, y1, x2, y2 = expand_box(box.xyxy.cpu().numpy()[0], 0.08, img.shape) crop = cv2.resize(img[y1:y2, x1:x2], tuple(config['input_size'])) crop = crop.astype('float32') / 255.0 crop = torch.from_numpy(crop).permute(2,0,1).unsqueeze(0).to(device) with torch.no_grad(): logits = recognizer(crop) text = ctc_decode(logits, config['chars']) if validate_plate(text): plates.append(text) return plates if __name__ == '__main__': print(recognize_image('test.jpg'))

逻辑说明:这个骨架把配置加载、模型初始化、推理、后处理都串起来了。expand_box和ctc_decode从 utils 里导入,保持代码整洁。

参数说明:map_location=device确保权重加载到正确的设备。tuple(config['input_size'])把列表转成元组,因为cv2.resize要元组。validate_plate是前面定义的校验函数。

从那以后我每次拿到新的识别模型,都强制先跑一遍阈值扫描和格式校验,确认误检和漏检在可接受范围再往下做业务。这套源码的价值不在于它多完美,而在于它把两阶段车牌识别的完整链路摊开了,你能看到每个环节的输入输出,改起来有抓手。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询