简介这是一套面向计算机、电子信息等专业学生与算法初学者的车牌识别完整项目源码基于 YOLOv8 做车牌检测、LPRNet 做字符识别可直接运行适合作为课程设计、期末大作业或毕业设计的参考方案。压缩包共 60 个文件约 36.15MB包含 13 个 Python 脚本、3 个 pt 与 1 个 pth 模型权重、3 个 Vue 前端文件及 json、yaml、md 等配置与说明文档覆盖数据生成、标注转换、数据集划分、模型训练与 Flask 推理服务等环节并附有图片素材与前端页面。目前已有 1171 人学习下载。读者可借此理解检测与识别两阶段串联的工程结构参考数据预处理与训练脚本的写法并基于现有权重快速复现识别效果再按需替换数据集或调整网络完成二次开发。1. 车牌识别系统为什么总在“最后一公里”翻车做车牌识别这个方向的人大多经历过一个尴尬时刻检测框画得漂漂亮亮字符却认成了“京A·8B8B8”或者干脆把“0”和“D”、“8”和“B”搞混。基于 YOLOv8 和 LPRNet 的车牌识别系统 python 源码加模型本质上就是把两个成熟模块串成一条流水线——YOLOv8 负责在整张图里把车牌抠出来LPRNet 负责把抠出来的小图读成字符串。这套组合之所以在最近两年被反复提起是因为它把“检测”和“识别”解耦了你可以单独换检测模型、单独调识别字典不用像端到端方案那样一改全改。它适合谁适合手头有几百到几千张卡口图、想快速跑通一套可复现车牌识别流水线的工程师也适合拿它当 YOLOv8 训练自己数据集和 LPRNet 序列识别入门练手的人。不适合谁不适合指望一套通用模型直接上生产、不标数据不调参的人。车牌识别系统 python 代码满天飞但真正能跑通的往往卡在数据格式对齐和 CTC 解码这两个点上。下面我按“先立住原理、再动手复现、最后讲坑”的顺序把这条流水线拆开讲清楚。2. 拆开流水线YOLOv8 检测与 LPRNet 识别的分工2.1 为什么不用一个模型端到端搞定车牌识别系统最直觉的做法是训练一个模型输入整图直接输出车牌字符串。但实际做下来端到端方案有两个硬伤一是车牌在整图里占比太小字符级特征在 backbone 下采样后几乎被抹平二是标注成本高你得同时标框和字符错一个字符整条样本就废了。YOLOv8 加 LPRNet 的分工方案把问题拆成两步检测阶段只关心“车牌在哪”识别阶段只关心“车牌上写了什么”。这样检测可以用通用目标检测的标注方式矩形框识别可以用序列标注的思路字符序列加 CTC两边各自的数据集都能复用现成工具链。从计算量看YOLOv8n 在 640 输入下大约 8.7 GFLOPsLPRNet 在 94x24 的灰度小图上只有零点几 GFLOPs整条流水线在 CPU 上也能跑到几帧GPU 上更是轻松实时。这也是为什么很多边缘部署场景比如 RK3588、hi3516cv610 这类板子愿意选这套组合——两个模型都小量化友好。2.2 YOLOv8 检测头的输出怎么接给 LPRNetYOLOv8 默认输出的是 xywh 加类别置信度你需要做的是把检测框按置信度过滤、NMS 去重然后按框坐标从原图裁剪出车牌区域。这里有个容易被忽略的点裁剪时不要贴边裁要往外扩 5% 到 10% 的边距。原因是 YOLOv8 的框回归有微小抖动贴边裁容易把车牌第一个或最后一个字符切掉半个LPRNet 对字符完整性很敏感。裁剪出来的图不能直接喂给 LPRNet因为 LPRNet 的输入是固定高度 24、宽度可变的灰度图。常见做法是先把裁剪图缩放到高度 24宽度按比例缩放再转灰度最后归一化到 [-1, 1] 或 [0, 1]。宽度方向如果超过 LPRNet 的最大支持宽度常见实现是 94 或 128就等比压缩不足则补零或复制边缘。这一步的插值方式建议用双线性最近邻会在字符边缘产生锯齿CTC 解码时容易把“1”和“7”混淆。2.3 LPRNet 的 CTC 解码与字符字典LPRNet 的核心是 backbone 加一个序列输出头输出形状是 (T, num_classes)T 是时间步num_classes 包含字符集加一个 blank。训练时用 CTC loss推理时用 CTC greedy decode 或 beam search decode。字符字典通常包含省份简称京沪粤等、字母 A-Z去掉 I 和 O 避免和 1、0 混淆、数字 0-9再加一个 blank。字典顺序一旦定下就不能改训练和推理必须用同一份。CTC 解码的坑在于 blank 的处理。greedy decode 的做法是每个时间步取 argmax然后合并连续相同字符、去掉 blank。如果两个相同字符中间没有 blank会被合并成一个比如“AA”需要“A blank A”才能解出两个 A。车牌里“京A·A8888”这种重复字符不少所以训练时 blank 的占比要够否则解码会丢字符。3. 用 YOLOv8 训练车牌检测器数据集与参数3.1 车牌检测数据集的标注与格式转换YOLOv8 训练需要 YOLO 格式的标注每张图一个 txt每行是class_id x_center y_center width height坐标都归一化到 0-1。如果你手头是 Labelme 标注的 json或者 VOC 的 xml需要先转。Labelme 转 YOLO 的脚本网上很多但要注意 Labelme 的矩形框是左上右下两点转 YOLO 要先算中心点和宽高再除以图像尺寸。下面是一个我常用的转换脚本处理 Labelme json 到 YOLO txtimport json import os from pathlib import Path # 输入Labelme json 目录输出YOLO labels 目录 json_dir Path(labelme_jsons) out_dir Path(yolo_labels) out_dir.mkdir(exist_okTrue) # 类别映射车牌检测通常只有一类 class_map {plate: 0} for jf in json_dir.glob(*.json): data json.loads(jf.read_text(encodingutf-8)) img_w data[imageWidth] img_h data[imageHeight] lines [] for shape in data[shapes]: label shape[label] if label not in class_map: continue # Labelme 矩形是 [[x1,y1],[x2,y2]] (x1, y1), (x2, y2) shape[points] xc (x1 x2) / 2.0 / img_w yc (y1 y2) / 2.0 / img_h w abs(x2 - x1) / img_w h abs(y2 - y1) / img_h lines.append(f{class_map[label]} {xc:.6f} {yc:.6f} {w:.6f} {h:.6f}) (out_dir / (jf.stem .txt)).write_text(\n.join(lines), encodingutf-8)这段脚本的关键参数是class_map车牌检测一般只设一类如果你要区分蓝牌、绿牌、黄牌可以在这里扩展成多类但类别数要和训练 yaml 里的nc一致。坐标归一化用.6f保留六位小数足够 YOLOv8 训练用。转换完记得抽查几张确认框的位置和原图对得上Labelme 的 points 顺序偶尔会反导致宽高为负。3.2 YOLOv8 训练命令与关键参数含义数据准备好后目录结构按 YOLOv8 的要求放images/train、images/val、labels/train、labels/val然后写一个 data.yamlpath: /data/plate_det train: images/train val: images/val nc: 1 names: [plate]训练命令用 ultralytics 的 CLI 或 python API 都行我一般用 CLI 方便记录yolo detect train \ data/data/plate_det/data.yaml \ modelyolov8n.pt \ epochs100 \ imgsz640 \ batch16 \ lr00.01 \ patience20 \ device0 \ projectruns/plate \ nameyolov8n_plate参数里最需要盯的是imgsz、batch和lr0。车牌在卡口图里通常不大640 输入下小目标召回一般够用如果车牌特别小比如远距离监控可以提到 960 或 1280但显存和速度会明显下降。batch根据显存调GTX1660Ti 6G 跑 640 大概能到 16再大就 OOM。lr0初始学习率 0.01 是 YOLOv8 的默认值小数据集几百张建议降到 0.001 并开cos_lr否则容易过拟合。patience20表示 20 轮验证指标不升就早停省时间。训练完看runs/plate/yolov8n_plate/weights/best.pt用yolo detect val验证 mAP50 和 mAP50-95。车牌检测一般 mAP50 能到 0.95 以上才算可用如果只有 0.8 左右先检查标注有没有漏框或错框再考虑加数据。3.3 检测结果裁剪与预处理代码训练好检测器后推理阶段要把框裁出来送给 LPRNet。下面这段是裁剪加预处理的函数import cv2 import numpy as np def crop_plate(img, box, expand0.08, target_h24, max_w128): # box: [x1, y1, x2, y2] x1, y1, x2, y2 box w x2 - x1 h 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] # 缩放到高度 target_h宽度按比例 scale target_h / crop.shape[0] new_w int(crop.shape[1] * scale) new_w min(new_w, max_w) crop cv2.resize(crop, (new_w, target_h), interpolationcv2.INTER_LINEAR) # 转灰度 gray cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) # 归一化到 [-1, 1] gray gray.astype(np.float32) / 127.5 - 1.0 # 宽度不足补零 if new_w max_w: pad np.zeros((target_h, max_w - new_w), dtypenp.float32) gray np.concatenate([gray, pad], axis1) return gray # shape: (24, max_w)expand0.08是外扩比例我一般设 0.05 到 0.1太小容易切字符太大引入背景干扰。target_h24是 LPRNet 常见输入高度max_w128是最大宽度这两个值必须和 LPRNet 训练时一致否则识别率会掉。归一化用[-1, 1]还是[0, 1]取决于 LPRNet 训练时的设置源码里一般会写改之前先确认。4. LPRNet 识别训练、字典与推理4.1 LPRNet 网络结构与输入输出约定LPRNet 的结构不复杂几个卷积层做特征提取接一个全连接或 1x1 卷积把通道数映射到字符类别数然后 reshape 成 (T, num_classes)。T 是时间步等于特征图宽度。输入是 (1, 24, W) 的灰度图输出是每个时间步的类别概率。训练时用 CTC loss需要输入长度和标签长度。字符字典我一般这样定义CHARS 京沪津渝冀晋蒙辽吉黑苏浙皖闽赣鲁豫鄂湘粤桂琼川贵云藏陕甘青宁新 LETTERS ABCDEFGHJKLMNPQRSTUVWXYZ # 去掉 I 和 O DIGITS 0123456789 DICT CHARS LETTERS DIGITS # blank 放在最后索引为 len(DICT) num_classes len(DICT) 1字典顺序一旦确定训练和推理必须完全一致。省份简称 31 个字母 24 个去掉 I、O数字 10 个加 blank 共 66 类。如果你的场景不涉及省份比如只识别车牌后五位可以裁掉省份部分字典相应缩小识别率会更高。4.2 LPRNet 训练数据准备与 CTC loss 对接LPRNet 的训练数据是裁剪好的车牌小图加对应字符串标签。标签要转成索引序列比如“京A88888”转成[0, 24, 34, 34, 34, 34, 34]具体索引看字典顺序。训练时每个 batch 的标签长度不一需要 pad 到同一长度并记录原始长度给 CTC。下面是一个简化的 Dataset 和 collate 示例import torch from torch.utils.data import Dataset class PlateDataset(Dataset): def __init__(self, img_paths, labels, dict_str): self.img_paths img_paths self.labels labels self.char2idx {c: i for i, c in enumerate(dict_str)} def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img cv2.imread(self.img_paths[idx], cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (128, 24)) img img.astype(np.float32) / 127.5 - 1.0 img torch.from_numpy(img).unsqueeze(0) # (1, 24, 128) label [self.char2idx[c] for c in self.labels[idx]] return img, torch.tensor(label, dtypetorch.long) def collate_fn(batch): imgs, labels zip(*batch) imgs torch.stack(imgs, 0) lengths torch.tensor([len(l) for l in labels], dtypetorch.long) # pad 到最大长度 max_len max(len(l) for l in labels) padded torch.zeros(len(labels), max_len, dtypetorch.long) for i, l in enumerate(labels): padded[i, :len(l)] l return imgs, padded, lengthsCTC loss 调用时传log_probs、targets、input_lengths、target_lengths。input_lengths是 LPRNet 输出的时间步 Ttarget_lengths是每个标签的真实长度。注意 T 必须大于等于 target_length否则 CTC 会报错。LPRNet 在 128 宽度下 T 一般是 32 左右车牌 7 到 8 个字符余量够。4.3 推理阶段 CTC greedy decode 实现推理时把 LPRNet 输出做 softmax取每个时间步 argmax然后合并重复去 blankdef ctc_greedy_decode(logits, dict_str): # logits: (T, num_classes) indices torch.argmax(logits, dim1).cpu().numpy() blank len(dict_str) result [] prev -1 for idx in indices: if idx ! blank and idx ! prev: result.append(dict_str[idx]) prev idx return .join(result)这段逻辑里prev记录上一个时间步的索引只有当前索引不等于 blank 且不等于上一个索引时才输出字符。这样“A blank A”会输出两个 A“A A”会输出一个 A。如果你的车牌里有连续相同字符训练时 blank 要学得够好否则会丢字。beam search 能缓解这个问题但实现复杂一般 greedy 够用。5. 避坑与排查车牌识别流水线最常见的 5 个翻车点5.1 检测框偏移导致字符被切现象识别结果总是少第一个或最后一个字符比如“京A88888”识别成“A88888”。原因YOLOv8 框回归有抖动裁剪时贴边裁把边缘字符切掉了一部分。解决裁剪时外扩 5% 到 10% 边距并在预处理时检查裁剪图宽度是否足够容纳字符。如果外扩后仍然切检查检测标注是否把车牌框标小了。5.2 字典顺序不一致导致识别乱码现象训练时 loss 正常下降推理时输出全是无意义字符。原因训练用的字典和推理用的字典顺序不一致比如训练时省份简称在前推理时字母在前。解决把字典定义写在一个公共文件里训练和推理都 import 同一份。改字典后必须重新训练不能只改推理端。5.3 CTC 解码丢重复字符现象“京A·A8888”识别成“京A·8888”少了一个 A。原因CTC greedy decode 把连续相同字符合并了而训练时 blank 占比不够模型没学会在重复字符间插入 blank。解决检查训练数据里重复字符样本的比例适当增加这类样本或者改用 beam search 解码beam width 设 5 到 10。5.4 输入尺寸不匹配导致识别率骤降现象检测框裁得没问题但识别率只有五六成。原因LPRNet 训练时输入是 94x24推理时用了 128x24宽度不一致导致特征图时间步对不上。解决确认 LPRNet 训练时的输入宽度推理时保持一致。如果训练用 94推理也 resize 到 94不足补零。宽度改大改小都要重新训练。5.5 低显存环境下 batch 过大导致训练崩溃现象训练到一半 OOM或者 loss 变成 nan。原因batch 设太大显存不够或者学习率太高导致梯度爆炸。解决GTX1660Ti 6G 跑 YOLOv8n 640 输入batch 设 8 到 16LPRNet 输入小batch 可以到 64 甚至 128。如果 OOM先降 batch再考虑降 imgsz。loss 变 nan 时把 lr0 降到 0.001 并加梯度裁剪。6. 把两个模型串成可复现的推理脚本6.1 端到端推理流程与代码骨架把检测和识别串起来核心是一个循环读图、YOLOv8 推理、NMS、裁剪、LPRNet 推理、CTC 解码、画结果。下面是一个可复现的骨架from ultralytics import YOLO import torch import cv2 det_model YOLO(runs/plate/yolov8n_plate/weights/best.pt) rec_model LPRNet(num_classes66) rec_model.load_state_dict(torch.load(lprnet_best.pth, map_locationcpu)) rec_model.eval() def recognize(img_path): img cv2.imread(img_path) results det_model(img, conf0.4, iou0.5)[0] plates [] for box in results.boxes: x1, y1, x2, y2 box.xyxy[0].cpu().numpy().astype(int) crop crop_plate(img, [x1, y1, x2, y2]) inp torch.from_numpy(crop).unsqueeze(0).unsqueeze(0) # (1,1,24,128) with torch.no_grad(): logits rec_model(inp)[0] # (T, num_classes) text ctc_greedy_decode(logits, DICT) plates.append((text, (x1, y1, x2, y2))) return platesconf0.4是检测置信度阈值车牌检测一般设 0.3 到 0.5太低会引入误检太高会漏检。iou0.5是 NMS 的 IoU 阈值车牌之间重叠少0.5 够用。识别模型加载后要eval()否则 BatchNorm 会用训练时的统计量推理结果不稳定。6.2 用验证集量化整条流水线的准确率光看单张图不够要算整条流水线的端到端准确率。做法是拿验证集每张图跑一遍对比识别结果和真实标签。指标分两级检测 mAP 和识别整牌准确率。整牌准确率要求每个字符都对才算对比字符准确率严格得多。我一般要求整牌准确率到 90% 以上才考虑上线低于 80% 就要回去查数据。验证时注意把检测和识别的错误分开统计检测漏框、检测误框、识别错字、识别丢字。分开看才知道该调哪一端。如果检测 mAP 高但整牌准确率低问题在识别端如果检测 mAP 本身就低先回去补检测数据。6.3 一个容易被忽略的技巧识别结果做规则后处理车牌有固定格式省份简称加字母加五位数字字母组合。识别结果可以用正则做后处理比如第一位必须是省份简称第二位必须是字母后面五位是数字字母。如果识别结果不符合格式可以尝试用编辑距离找最接近的合法车牌或者把置信度低的字符用字典约束重新解码。这个技巧在识别率卡在 85% 上不去时特别有用往往能拉几个点。我自己的习惯是每次改完字典或输入尺寸先跑一遍验证集看整牌准确率再抽查 20 张错例看是检测问题还是识别问题。这套流水线不复杂但细节多数据、字典、尺寸、解码四个环节任何一个不一致都会翻车。希望帮到你。本文还有配套的精品资源点击获取