☰
基于YOLOv8和LPRNet的车牌识别系统实战:检测与识别分离的完整流程
2026/10/5 5:34:54 网站建设 项目流程

简介:基于YOLOv8与LPRNet的车牌识别系统,内含完整Python源码与预训练模型,面向计算机、数学、电子信息等专业学生,适合作为课程设计、期末大作业或毕业设计项目参考。系统采用前后端分离结构,后端以Flask搭建推理服务,前端使用Vue构建操作界面,并将YOLOv8目标检测、LPRNet车牌识别、数据集生成及划分等模块整合在一起。资源包共60个文件,包含13个Python脚本、22张样例图片、3个pt及1个pth模型文件,另有Vue前端页面、说明文档等,压缩包整体约36.15MB,目录层次分明,方便按需取用。目前已有1171人学习下载。借助该资源,学习者不仅可获得可直接运行的车牌识别Demo,还能从数据预处理、模型训练到服务部署完整走通一套流程;相关生成与划分脚本也有助于二次开发或算法复现。

1. 车牌识别系统别急着端到端:先分清检测和识别两件事

车牌识别系统在停车场出入口、路侧监控、高速收费这些场景里已经不算新东西了,但真正落地的方案大多不是“一个模型吃进图片吐字符”,而是两段式流水线:先用一个目标检测模型把车牌在画面里的位置框出来,再用一个序列识别模型把框里的字符读出来。基于 YOLOv8 和 LPRNet 的车牌识别系统 python 源码+模型这套组合,走的正是这条路线——YOLOv8 干检测的活,LPRNet 干识别的活,两者各管一段,模型体积和推理速度都控制得住,改起来也方便。这套方案尤其适合两类人:一类是刚接触车牌识别、想在本地把 demo 跑起来看效果的学生或转行者;另一类是已经在用传统图像处理做车牌识别、被复杂场景折磨到想换方案的工程师。你不需要从零训练一个大模型,基于开源的 YOLOv8 权重和自己整理的少量车牌数据就能把系统搭起来。下面按我实际做过的流程,从数据、训练、串联到踩坑,完整讲一遍。

2. YOLOv8 做车牌检测:数据、训练与网络结构选型

2.1 车牌检测为什么选 YOLOv8:小目标、多尺度与推理速度的权衡

车牌在监控画面里通常只占很小一块区域,而且不同卡口的摄像头安装角度、距离差异很大,同一辆车在画面里可能从近到远不断变化。传统做法用颜色特征定位蓝色车牌,或者用形态学算子提取矩形边缘,在固定角度、固定场景下能用,但一换场景就翻车。YOLOv8 作为 anchor-free 的检测器,对不同尺度的目标天然友好,尤其是它的检测头在不同特征层上分别预测小、中、大目标,车牌这种小目标落在浅层特征图上也能被召回。

选 YOLOv8 而不是更重的 YOLOv5-L 或者两阶段 Faster R-CNN,核心原因是推理速度和部署成本。车牌识别系统往往要跑在边缘设备上,比如 RK3588、Jetson 这类带 NPU 的板子。YOLOv8n 的权重只有 6MB 左右,INT8 量化后更小,在 RK3588 上跑一帧 640×640 的检测只需要几十毫秒,完全够用。常见的做法是先用 yolov8n 或 yolov8s 做基线,如果小目标召回不够,再考虑换输入分辨率或者换更大的模型 —— 而不是一上来就上大模型。

从网络结构上看,YOLOv8 把 C3 模块换成了 C2f,颈部 PAN-FPN 结构保留,检测头从 anchor-based 改成 anchor-free 的 Decoupled Head。它输出的是每个格子对目标框的四个距离回归值加类别概率,不需要像 YOLOv5 那样在损失函数里做 anchor 匹配的复杂逻辑,训练和调参都更简单。对于车牌这类类别单一(就是“车牌”一个类)、长宽比相对固定的目标,模型学的压力主要在框的回归精度上,类别区分几乎没有难度。

2.2 准备车牌检测数据集:标注格式、目录结构与数据增强

训练 YOLOv8 检测车牌,数据集的标注格式是 YOLO 的 txt 格式,每行五个值:类别索引、中心点 x、中心点 y、框宽 w、框高 h,全部归一化到 0~1。如果你的原始标注是 XML(VOC 格式)或者 JSON(COCO 格式),需要先转换。最常见的做法是从公开车牌数据集 CCPD 或者网上的开源车牌图片出发,把它们整理成 YOLO 格式,再补充一部分自己场景的抓拍图。

一个可用的数据集目录结构如下:

plate_dataset/ ├── images/ │ ├── train/ │ │ ├── img_0001.jpg │ │ └── ... │ └── val/ │ ├── img_1001.jpg │ └── ... ├── labels/ │ ├── train/ │ │ ├── img_0001.txt │ │ └── ... │ └── val/ │ ├── img_1001.txt │ └── ... └── data.yaml

data.yaml 内容:

path: /path/to/plate_dataset train: images/train val: images/val names: 0: plate

这里有个细节:val 目录下的每张图片必须有对应的 txt 标注文件,哪怕某张图里没有车牌也要放一个空的 txt 文件,否则训练会报错。我在第一次训练时因为漏了几个空标注,跑了一会儿突然报 “AssertionError: Label class is greater than number of classes” 或者找不到文件,排查了半天。

数据增强方面,YOLOv8 自带 Mosaic、HSV 扰动、随机翻转、平移和缩放。对于车牌场景,我一般会额外关掉或调低水平翻转的概率,因为车牌上的字符顺序是有意义的——翻转后“京A12345”会变成镜像,检测框虽然还是框,但后续 LPRNet 识别会学错方向。在 ultralytics 的配置里,把fliplr从 0.5 调低到 0.1 比较合适。另外送高degrees旋转增强到 10 到 15 度左右,模拟停车场的各种倾斜角度。

2.3 训练 YOLOv8 检测模型:命令、损失曲线与必调参数

在本地跑通训练的最小命令是:

yolo detect train \ data=/path/to/plate_dataset/data.yaml \ model=yolov8n.pt \ epochs=100 \ imgsz=640 \ batch=16 \ patience=20 \ project=runs/plate_detect \ name=exp1 \ fliplr=0.1

逻辑说明:model=yolov8n.pt的意思是加载 COCO 预训练权重继续微调,而不是从零初始化随机权重。对于车牌这种和 COCO 类别差异很大的领域,预训练权重依然能提供边缘纹理和物体形状的基础特征,收敛速度明显更快。patience=20表示 20 个 epoch 内验证集指标没有提升就提前停止,避免盲目跑满 100 轮浪费时间。

参数说明:imgsz建议在 640 和 1280 之间取舍。输入越大,小目标检测效果越好,但显存占用和推理耗时同步上升。如果你的摄像头画面里车牌普遍很小,先试 640 看召回情况,不行再加到 1280,不要一上来就大图。batch取决于显存大小,16G 显存跑 yolov8n 可以开到 32 甚至更大,显存不够就减半。

训练结束后看runs/plate_detect/exp1/results.csv里的mAP50-95曲线。车牌是单类目标,mAP50 达到 0.95 以上才算合格,mAP50-95 能到 0.8 基本就够用。如果 mAP50 只有 0.8 不到,先检查是不是数据量太少或者标注质量有问题,而不是急着调网络结构。我见过有人拿几百张图训练后识别率不稳定,加了数据增强、换了更大的模型都不见好,最后发现是标注框把车牌边框多包了一圈,把周围的车身也包进去了,导致 LPRNet 后续裁剪进来一堆噪声。

还有一个很容易忽略的点:验证集的数据分布要和你实际部署场景一致。如果你用的是网上公开数据训练,验证集也是同一来源的随机切分,指标会虚高。我一般会单独留一个自己拍的、或者模拟目标场景的图片夹做 hold-out 验证,以这个结果为准来判断泛化能力。

3. LPRNet 识别模型:从字符序列到 CTC 损失函数

3.1 LPRNet 网络结构:为什么它能做到轻量且支持不定长识别

LPRNet 是俄罗斯团队提出的轻量车牌识别网络,核心设计是小参数量加序列识别。它没有用 RNN 做时序建模的复杂实现,而是用 CNN 提取特征,再用一个宽卷积配合一个可选的 RNN 层做序列上下文建模,最后用 CTC(Connectionist Temporal Classification)损失函数来对齐不定长字符序列。车牌字符数量不是固定的——蓝牌是 7 位,新能源牌是 8 位,教练车、使馆车也不一样——所以不能用固定类别的分类头直接输出。CTC 恰好能处理输入序列和输出标签序列长度不一致的问题。

典型的 LPRNet 主网络结构是这样:输入一张 24×94 的灰度车牌图,经过若干卷积层逐步把空间维度缩小,同时通道数增加;在特征图宽度方向保留时序维度,每列特征代表一个时间步;把这些时序特征送进分类头,输出 T×num_classes 的概率矩阵;最后由 CTC 解码出最优字符路径。

我在 PyTorch 里实现过一个简化版 LPRNet,核心结构如下:

import torch import torch.nn as nn class SmallLPRNet(nn.Module): def __init__(self, num_classes=68): super().__init__() # CNN backbone: 输入 1x24x94 (灰度) self.conv1 = nn.Sequential( nn.Conv2d(1, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2) # 宽高减半 ) self.conv2 = nn.Sequential( nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2) ) # 时序宽卷积: 沿宽度方向聚合上下文 self.conv_wide = nn.Conv2d(128, 256, kernel_size=(1, 5), padding=(0, 2)) self.rnn = nn.LSTM(256, 64, bidirectional=True, batch_first=True) self.fc = nn.Linear(128, num_classes) def forward(self, x): # x: (B, 1, 24, 94) x = self.conv1(x) # -> (B, 64, 12, 47) x = self.conv2(x) # -> (B, 128, 6, 23) x = self.conv_wide(x) # -> (B, 256, 6, 23) # 取高度方向均值, 得到一维序列 x = x.mean(dim=2) # -> (B, 256, 23) x = x.permute(0, 2, 1) # -> (B, 23, 256) x, _ = self.rnn(x) # -> (B, 23, 128) logits = self.fc(x) # -> (B, 23, num_classes) return logits # logits 传给 CTC loss

逻辑说明:卷积部分把 24×94 的输入映射成 256 通道、23 个时间步的特征。这里的“时间步”对应车牌图像从左到右的空间位置——车牌字符是横向排列的,所以特征图宽度方向天然可以作为序列长度。LSTM 在这里不是必须的,去掉后可以用纯卷积的 CTC 模型,但加一个双向 LSTM 能明显提升相邻字符之间的上下文建模,代价是推理速度慢一点。

参数说明:num_classes是你的字符集大小加一个 CTC blank。比如字符集有 67 个字符(31 个省份汉字 + 24 个字母去除 I/O + 10 个数字 + 2 个特殊字符),那num_classes就是 68。字符集定义必须和训练数据一致,模型推理时标签映射表要存成 json 或者 py 文件,部署时和权重一起带上,否则没法把类别 ID 翻译回汉字和字母。

3.2 车牌字符集与标签编码:这是识别系统最容易被低估的环节

LPRNet 的分类头输出的是每个时间步上字符的概率分布,训练时需要把 GT 字符串编码成标签序列传给 CTCLoss。中文车牌字符集比英文复杂得多:省份简称有 31 个汉字(京、津、冀、晋、蒙、辽、吉、黑、沪、苏、浙、皖、闽、赣、鲁、豫、鄂、湘、粤、桂、琼、渝、川、贵、云、藏、陕、甘、青、宁、新),字母去掉 I 和 O(避免和数字 1 和 0 混淆),数字 0~9。如果你还要支持使馆牌、警牌、军牌,字符集会更多。

标签编码的做法是:把字符串按字符逐一映射成索引序列,比如“京A12345”映射成[23, 10, 1, 2, 3, 4, 5](索引值取决于你定义的映射表)。CTCLoss 接受的 target 是一个二维张量(batch, target_length),每个样本的 label 是一个整数序列。空车牌或者识别不出字符的情况在训练时不要混入,因为 CTC 只负责对齐,不负责教你“没车牌也要有输出”。

字符集的编码代码特别简单:

plate_chars = "京津冀晋蒙辽吉黑沪苏浙皖闽赣鲁豫鄂湘粤桂琼渝川贵云藏陕甘青宁新" \ "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" char_to_idx = {c: i for i, c in enumerate(plate_chars)} idx_to_char = {i: c for i, c in enumerate(plate_chars)}

逻辑说明:char_to_idx用于训练时把车牌字符串转成标签序列,idx_to_char用于推理时把模型输出转回可读的字符串。这里有个细节:char_to_idx 里没有留 blank 的位置,CTCLoss 内部约定blank=0或blank=num_classes-1(取决于你调用时的参数),所以要么从索引 1 开始编号,要么在 Pytorch 的 CTCLoss 里把blank显式设成你的类数减一。最常见的错误就是字符索引从 0 开始,结果 CTC 把真实的字符当成了 blank,导致训练起来 loss 一直降不下去。

3.3 训练 LPRNet 的关键配置:输入尺寸、batch size 与学习率

LPRNet 训练数据的来源是检测模块的产物:把原始图片里车牌区域裁剪出来,统一缩放到 24×94 的灰度图。公共车牌数据集 CCPD 的裁剪已经很规范,你也可以从检测模型的训练集里顺带把车牌框裁出来,配上标注字符串,形成识别训练集。数据量不需要像检测那么夸张,几千张高质量车牌图经过数据增强就能训练出一个可用的模型。

训练的最小流程分三步:加载图片和标签、做在线增强、跑 CTC loss 反向传播。下面是一个简化的训练循环:

import torch import torch.nn.functional as F def train_one_epoch(model, dataloader, optimizer, device): model.train() total_loss = 0 for imgs, labels, label_lens in dataloader: imgs = imgs.to(device) # (B, 1, 24, 94) optimizer.zero_grad() logits = model(imgs) # (B, T, num_classes) # 转置成 (T, B, num_classes) 供 CTCLoss 使用 log_probs = F.log_softmax(logits.permute(1, 0, 2), dim=-1) # 每个样本的输入序列长度均为 T input_lens = torch.full((logits.size(0),), logits.size(1), dtype=torch.long) loss = F.ctc_loss(log_probs, labels, input_lens, label_lens, blank=0) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)

逻辑说明:PyTorch 的F.ctc_loss要求输入 log_probs 的形状是(T, B, num_classes),T 是序列长度,B 是 batch,num_classes 是字符类别数加 blank。labels是所有样本的标签拼接成的一维序列,label_lens是每个样本的标签长度。这个拼接方式刚上手容易写错——如果你按 batch 维度压成 (B, L) 再展平,顺序对了没问题,但 label_lens 必须和拼接顺序严格对应。

参数说明:输入尺寸 24×94 是 LPRNet 原论文的数值,也是大多数开源实现的默认值。不要随意改成其他尺寸,因为网络的池化层和时序长度是按这个尺寸设计的,改了输入尺寸后 RNN 的时间步和卷积的感受野都要重新算。学习率一般从 0.001 开始,用 Adam 优化器,配合每 10 个 epoch 衰减一次。车牌字符识别对学习率比较敏感,我用 0.001 训练时 loss 稳定下降,调到 0.01 后 loss 直接震荡到不收敛。

4. 把 YOLOv8 和 LPRNet 串成完整系统:推理管线与后处理

4.1 整体流程与模块划分:不要让两个模型互相污染输入

单张图片的推理流程分四步:检测、裁剪、预处理、识别。检测模块吃全图,输出车牌框;识别模块只吃裁剪出来的车牌区域。这个流程看起来简单,但模块边界要划清楚——如果识别模块的输入里混入大量背景噪声,LPRNet 的时序特征会被干扰,从而读错字符。

一个完整的推理脚本骨架如下:

import cv2 import torch from ultralytics import YOLO class PlateRecognizer: def __init__(self, det_weights, rec_weights, char_map): self.detector = YOLO(det_weights) self.recognizer = load_lprnet(rec_weights) # 加载 PyTorch 权重 self.char_map = char_map self.rec_imgsz = (24, 94) def recognize_plate(self, img): # 1. YOLOv8 检测 results = self.detector(img, conf=0.5, imgsz=640, verbose=False) plates = [] for r in results: boxes = r.boxes.xyxy.cpu().numpy() for box in boxes: x1, y1, x2, y2 = [int(v) for v in box] # 2. 裁剪车牌区域,加一点边缘余量 h, w = y2 - y1, x2 - x1 pad_x, pad_y = int(w * 0.08), int(h * 0.08) x1 = max(0, x1 - pad_x) y1 = max(0, y1 - pad_y) x2 = min(img.shape[1], x2 + pad_x) y2 = min(img.shape[0], y2 + pad_y) crop = img[y1:y2, x1:x2] # 3. 灰度化 + 归一化 + resize crop_gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) crop_resized = cv2.resize(crop_gray, self.rec_imgsz) crop_tensor = torch.from_numpy(crop_resized).float() / 255.0 crop_tensor = crop_tensor.unsqueeze(0).unsqueeze(0) # (1,1,24,94) # 4. LPRNet 识别 with torch.no_grad(): logits = self.recognizer(crop_tensor) # (1, T, num_classes) text = self.decode_ctc(logits) plates.append((box, text)) return plates def decode_ctc(self, logits): probs = logits.squeeze(0) # (T, num_classes) preds = probs.argmax(dim=-1).cpu().numpy() # 去除重复字符和 blank prev = -1 result = [] for p in preds: if p != prev and p != 0: # 0 是 blank result.append(int(p)) prev = p return ''.join(self.char_map[i] for i in result)

逻辑说明:检测框往外扩 8% 的边距是很有必要的。YOLOv8 输出的框通常紧贴车牌边缘,但 LPRNet 的输入需要把车牌边框外侧的一点区域包进来,否则边框字符会被裁剪掉一半,识别率明显下降。扩边大小要适中——扩太多会把周围车身颜色带进来,扩太少又剪掉字符。8% 是我在多个数据集上试过的一刀流参数,基本不用再调。

参数说明:conf=0.5是检测置信度阈值。在卡口场景,如果漏检比误检更严重,把它降到 0.3;如果误检太多(比如把车标、广告牌当车牌),就把它升到 0.7。实际部署时这个阈值要单独调,没有万能值。

4.2 车牌倾斜与图像几何:什么时候必须做透视矫正

YOLOv8 输出的是松散的矩形框,但车牌在画面里经常有透视变形——从侧面看过去车牌是一个梯形。LPRNet 对轻度倾斜有一定的容忍度,但在角度超过 10~15 度时识别率会明显下降。常见做法是加一个透视矫正步骤:用车牌的四个角点做变换,把梯形拉伸成矩形。

OpenCV 的透视矫正实现并不复杂:

def four_point_transform(gray, points): # points: 四个角点坐标, 顶点顺序: 左上、右上、右下、左下 tl, tr, br, bl = points width_top = int(np.linalg.norm(np.array(tr) - np.array(tl))) width_bottom = int(np.linalg.norm(np.array(br) - np.array(bl))) width = max(width_top, width_bottom) height_left = int(np.linalg.norm(np.array(bl) - np.array(tl))) height_right = int(np.linalg.norm(np.array(br) - np.array(tr))) height = max(height_left, height_right) dst = np.array([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtype=np.float32) src = np.array(points, dtype=np.float32) M = cv2.getPerspectiveTransform(src, dst) warped = cv2.warpPerspective(gray, M, (width, height)) return warped

逻辑说明:cv2.getPerspectiveTransform把源图像中四个点映射到目标矩形的对应位置。角点坐标的来源可以是检测框的四个角,也可以通过边缘检测找车牌轮廓的最小外接矩形再取角点。前者简单但不精确——检测框是水平矩形,没有车牌的旋转信息;后者精度更高但会增加一次轮廓查找的计算开销。

参数说明:width_top和width_bottom分别是透视梯形上边和下边的像素长度,理论上这两个值越接近说明透视变形越小。如果检测框本身就带角度(比如用旋转目标检测模型输出带角度的框),可以直接把旋转框的四个顶点传进来,省去轮廓查找。做矫正前记得先灰度化,warpPerspective直接作用于彩色图也可以,但后续 LPRNet 反正要转灰度,提前转可以少算一遍。

4.3 集成推理与性能分析:定位瓶颈在检测还是识别

系统搭好后,最常遇到的性能问题是识别率不达标。这时候不要凭感觉乱调,先把问题拆开:是检测框不对,还是识别模型读错字符。一个可操作的办法是把识别模块的输入图批量保存下来,按“检测召回率”和“字符准确率”两个指标分别统计。

检测召回率 = 正确框出的车牌数 / 实际车牌总数。如果这个指标低,说明 YOLOv8 没检测到牌照,把检测阈值下调并且检查输入图像分辨率是否够大。字符准确率 = 识别完全正确的车牌数 / 检测到并送入识别的车牌数。如果检测召回率很高但字符准确率低,问题出在 LPRNet 这边——有可能是字符集不匹配、训练数据太少,也可能是裁剪预处理不对。

实际排障时我会打印出每一层的时间:

import time t0 = time.time() results = self.detector(img, conf=0.5, imgsz=640, verbose=False) t1 = time.time() # ... 裁剪和预处理 ... logits = self.recognizer(crop_tensor) t2 = time.time() print(f"detect: {(t1-t0)*1000:.1f}ms, recognize: {(t2-t1)*1000:.1f}ms")

这个打印输出的价值在于:如果识别的时间占总耗时一半以上,而你的部署环境是嵌入式设备,LPRNet 的 LSTM 层往往是瓶颈——LSTM 在 CPU 和轻量 NPU 上的加速效果不如卷积好。此时可以考虑把双向 LSTM 换成一层宽卷积(1×7)加两个全连接层,字符准确率会掉 1~2 个百分点,但推理时间能缩短一半多。

5. 车牌识别系统避坑指南:五个高频翻车现场与解决方案

5.1 检测框一直抖动,视频里识别结果闪烁不定

现象:在视频流中部署后,同一辆车静止在画面里时,检测框的位置和大小每帧都在轻微变化,识别结果有时对有时错,屏幕上显示的车牌号来回跳。 原因:YOLOv8 是单帧推理模型,没有帧间关联。车牌的边缘特征和曝光在帧与帧之间有随机噪声,导致检测框回归值在小范围内波动。如果识别模块对这个波动很敏感,字符就容易被读错。 解决:最简单的做法是在绘制结果时对检测框做指数平滑——用上一帧的框和当前帧的框做加权平均。更稳妥的做法是引入 ByteTrack 这类轻量跟踪算法,为每辆车分配 ID,车牌识别结果按置信度投票,同一个 ID 连续几帧都识别出同一结果才显示,否则保留上一帧的结果。

5.2 汉字老是识别错,字母数字却基本没问题

现象:识别“京A12345”变成了“津A12345”,其他省份汉字也常有混淆,但字母和数字的准确率在 98% 以上。 原因:汉字笔画复杂、结构相似(京和津、沪和津),LPRNet 的空间特征提取对这些细微差异不够敏感。更关键的是训练数据里各省份车牌数量严重不均衡——如果训练集里“京”字样本有 5000 张,“藏”只有 200 张,样本少的省份自然更容易错。 解决:首先保证训练集各省份汉字数量均衡,最少的一类也不低于 1000 张。如果数据不好找,可以对含特定汉字的样本做复制加轻微扰动(对比度、亮度、旋转)来增强。其次在模型层面,可以把汉字单独拆一个分类头,在特征图高度方向截取上半部分单独做分类;这个办法会改网络结构,但效果明显。

5.3 夜间场景识别率从 95% 掉到 50% 以下

现象:白天测试一切正常,到了晚上识别率断崖式下跌。检查发现 YOLOv8 偶尔漏检,LPRNet 即使拿到裁剪图也读不准。 原因:夜间车牌区域亮度低、噪点多,摄像头自动增益还会让车牌反光过曝。训练时如果以白天数据为主,模型在低照度分布上的特征能力很弱。 解决:分两步走。第一步是图像预处理层加自适应直方图均衡化(CLAHE),在灰度化之后、resize 之前做,能明显提升暗部车牌对比度。第二步是训练数据里混入夜间图片,用合成方式模拟——白天数据降亮度、加高斯噪声、模拟强光遮蔽。如果条件允许,专门收集一段时间夜间抓拍数据做二次微调。

5.4 导出 ONNX 后推理结果与 PyTorch 不一致

现象:PyTorch 里识别得好好的模型,导出 ONNX 后用 onnxruntime 推理,输出的字符概率分布和 PyTorch 差了不少,甚至解码出来的字符串错误。 原因:LPRNet 里有几个容易踩的算子兼容性雷区:LSTM 的动态 shape、mean(dim=2)的降维操作、以及 OpenCV 预处理和 PyTorch 预处理的微小差异(比如 BGR/RGB 顺序、归一化系数)。 解决:导出 ONNX 时把输入尺寸固定为(1, 1, 24, 94),不要用动态 batch 和动态尺寸。LSTM 导出时要保证序列长度是固定的,onnxruntime 对动态 LSTM 的支持不如静态 shape 稳定。另外要检查 ONNX 模型里是否带BatchNorm的 training 状态——推理模型必须切换到 eval 模式,否则 BN 层的统计量会导致输出漂移。

5.5 车牌区域被雨刷、遮挡物盖住,系统完全傻眼

现象:雨天雨刷扫过车牌,或者车辆加装了遮挡物(防虫网、保险杠装饰条),检测框面积变小或者完全检测不到。 原因:目标检测模型对“不完整目标”的辨识能力有限——训练数据里基本没有遮挡样本。而且遮挡会导致车型特征和车牌特征重叠,模型对车牌的置信度大幅下降。 解决:短期方案是降低检测置信度阈值到 0.25 左右,让概率低一点的框也能跑完整个流程,然后靠识别模块的分数做二次过滤——如果识别结果置信度也低,就丢弃不显示。长期方案是在训练数据里加入遮挡样本,用随机矩形遮挡或粘贴模拟物生成增强数据。这类问题不要指望换个模型就能解决,数据层面的补充才是根本。

6. 用少量数据验证系统达标率:一个练手的全套验证办法

前面几章把检测模型和识别模型都搭好了,但真正要交付一个车牌识别系统,最后一步不能省——对着一个带标注的测试集做端到端评估,量化“系统准确率”而不是单模型指标。这个评估结果就是你和别人汇报的核心数据。

具体做法是准备一个测试视频,人工逐帧标注车牌的 GT 文本(比如京A12345),写一个脚本跑完整条推理管线,把每帧的输出与 GT 对比。评估指标用三个:检测召回率(GT 车牌中被检测框命中的比例)、字符准确率(检测框内字符全部正确的比例)、整体识别率(检测和识别都正确的端到端比例)。如果整体识别率低于 80%,按第 4.3 节的定位方法拆分:先单独跑检测模块,把漏检的帧挑出来;再单独跑识别模块,把识别错的裁剪图批量导出肉眼过一遍——这两种图特征完全不同,很快能找到瓶颈模块。

一个我亲测有用的验证技巧是:把同一辆车从远到近驶过的视频段取关键帧,观察整体识别率随车牌像素宽度(检测框宽)的变化曲线。通常车牌在画面里的宽度小于 60 像素时识别率急剧下降。这个数据直接决定摄像头安装高度和补光方案:如果车牌的像素宽度长期小于 80,识别率天花板就摆在那里,再怎么调模型也白搭。碰到这种场景我会直接建议把摄像头架低一点或者换长焦镜头,而不是继续优化模型。

最后,关于部署到 RK3588 这类边缘设备的优化思路:检测模型用 ultralytics 官方导出 ONNX 再转 RKNN,INT8 量化后推理时间能降到 20ms 以内;LPRNet 导出 ONNX 后先检查 LSTM 算子能否被 RKNN 编译器支持——如果不支持,把 LSTM 替换成宽卷积加全连接处理,重新训练几轮,识别率损失不大但部署省心得多。每次换部署平台,都要重新做一遍第 4.3 节的耗时拆分,不要想当然照搬参数。

这套方案我做过不止一遍,最大的教训是:不要把精力全花在刷模型指标上——数据质量、模块边界和后处理细节对最终系统表现的影响,往往比换一个更强的骨干网络来得大。先跑通最小闭环,再用验证办法定位瓶颈,按数据说话,这套流程能帮你避开车牌识别系统里绝大多数隐藏的坑。希望帮到你。

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

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

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

立即咨询