简介:面向深度学习与计算机视觉开发者的车牌识别项目资料,完整结合MTCNN车牌检测与LPRNet字符识别两大模型,覆盖从图像预处理、车牌定位与裁剪,到字符序列识别及后处理的端到端流程。资源包共870个文件,容量约543.9MB,包含566个PyTorch模型训练检查点(ckpt)、12个pth权重,36个Python源码与23个pyc字节码,另有大量jpg/png等测试图片及xml标注、txt说明、ui界面与ipynb示例,便于对照代码理解训练过程和推理逻辑。整理者还提供了不同迭代轮次的lprnet模型文件,适合研究训练收敛情况或直接加载权重进行车牌识别实验。目前已有408人浏览学习。项目以Pytorch实现,对想深入车牌识别原理、微调模型适应复杂光照/角度场景的读者尤为有用;通过阅读源码与运行示例,可快速掌握MTCNN+ LPRNet的协作机制,并依据自带数据集与权重开展二次训练或部署优化。
1. 车牌识别LPRNet+MTCNN:为什么这个组合能统治停车场闸机
真正做过车牌识别落地的人都知道,白天光线好的时候,随便一个模型都能把蓝牌识别得八九不离十;但到了夜间、逆光、雨雾天,识别率断崖式下跌时,厂家才意识到问题不在算法而在定位。车牌识别LPRNet+MTCNN恰好是一套在工业界被反复验证过的组合:MTCNN负责从画面里稳定抠出车牌区域,LPRNet负责把抠出来的车牌图片转成字符串。两者分开训练、拆开部署,任何一端出问题都能单独换,这就让它成为停车场闸机、园区出入口、电子警察这类场景里最常见的轻量落地方案。这篇文章的服务对象,不是要做学术刷点的研究者,而是手里有摄像头、有GPU服务器、需要在两周内让识别率过95%的工程团队。我会从MTCNN怎么改造成车牌检测器讲起,再把LPRNet的序列建模和CTC训练讲透,最后落到可复现的训练命令和部署参数上。
2. 用MTCNN做车牌定位:从人脸检测改造到车牌框回归的落地细节
2.1 为什么选MTCNN而不直接用YOLO做车牌检测
车牌检测这件事,第一直觉是上YOLO,毕竟目标检测生态成熟。但实际落地时你会发现,停车场场景的摄像头通常架在3到5米高度,车牌在1080P画面里只有30到50像素高,属于典型的小目标。YOLOv5把图像缩放到640分辨率后,车牌区域只剩十几个像素的特征,漏检率会随距离直线上升。MTCNN恰好相反,它的人脸检测分支被设计成多尺度金字塔扫描,PNet在网络的最浅层就用12×12的滑动窗口遍历全图,后续RNet和ONet在候选框上做逐级精修。这种由粗到细的级联结构对“小目标+高召回”的需求非常友好,MTCNN早期在人脸上能跑到97%以上的召回率,换到车牌上,只要把训练数据里的正样本换成车牌框,保留三个网络的级联结构,效果往往比直接硬调YOLO小目标头更稳定。
另一个关键点是部署开销。停车场闸机经常用的是Jetson Nano或者RK3588这类边缘设备,显存只有4到8GB。YOLOv5s的前向推理在Jetson上大概需要15到25毫秒,而MTCNN的PNet是纯全卷积网络,在相同设备上只需要3到5毫秒,RNet和ONet只对少数候选框做二次推理,整体耗时被控制得很好。工业项目在算力有限时,往往不是首选最强模型,而是选“够用且不拖累主流程”的模型。MTCNN做车牌检测还有一个隐藏优势:它的ONet带人脸关键点输出,改造后可以同时输出车牌四角点,后续做透视矫正时不用额外接landmark模型,省一个推理节点。
2.2 MTCNN三阶段网络如何改造成车牌检测
把MTCNN从人脸转到车牌,核心改动集中在三点:正负样本定义、网络输入尺度和关键点语义。原始MTCNN的PNet输入是12×12,感受野覆盖人脸的五官区域;车牌比人脸更扁、长宽比更大,常见蓝牌的宽高比是440:140,约等于3.14:1。因此PNet的输入需要从正方形改成支持非对称的候选框回归,我在实践里直接把PNet的滑动窗口改成结构化的锚点框,宽高比按3:1、2:1和1:1三组生成,而不是原始MTCNN那样统一用正方形窗口。RNet和ONet的输入尺度也从24×24和48×48调整到32×32和64×64,原因很简单:车牌字符需要保留足够分辨率,48×48下“京A12345”这类7位字符已经能看清笔画,再低就影响后续LPRNet的识别了。
训练数据准备上,我一般不用现成人脸数据集凑数,而是单独标注车牌照。一个常见的做法是:从停车场录像里抽帧,用硬规则筛选出包含车牌的图像,再人工标注车牌四个角点。正样本是完整车牌框,负样本是随机裁剪的不含车牌的背景块,部分难样本是从含车牌的图像里裁偏移的框,让交并比落在0.2到0.5之间。三阶段网络的训练策略与原版MTCNN一致:先用PNet的难样本挖掘生成RNet训练数据,再用RNet的输出难样本生成ONet训练数据,逐级蒸馏下去。我踩过的一个坑是正负样本比例,PNet阶段正负比保持1:3即可,RNet阶段如果负样本太多,模型会过度抑制候选框,导致召回下降;最终我在RNet和ONet阶段把比例调成1:2,效果明显稳定。
2.3 车牌检测的最小可运行数据准备脚本
以下是我在本地跑通MTCNN车牌检测前的数据准备脚本,它能从原始视频抽帧并生成PNet训练所需的标注文件。该脚本假设你已经用LabelImg或X-AnyLabeling标注好车牌框,导出的格式是VOC XML。
import os import cv2 import numpy as np import xml.etree.ElementTree as ET from pathlib import Path def parse_voc_xml(xml_path): """解析VOC格式标注,返回车牌框坐标列表""" tree = ET.parse(xml_path) root = tree.getroot() boxes = [] for obj in root.iter('object'): bndbox = obj.find('bndbox') if bndbox is None: continue x1 = float(bndbox.find('xmin').text) y1 = float(bndbox.find('ymin').text) x2 = float(bndbox.find('xmax').text) y2 = float(bndbox.find('ymax').text) boxes.append([x1, y1, x2, y2]) return boxes def generate_pnet_samples(img_dir, xml_dir, out_dir): """把原图切成PNet训练用的正负样本""" img_paths = list(Path(img_dir).glob('*.jpg')) for img_path in img_paths: img = cv2.imread(str(img_path)) h, w = img.shape[:2] xml_path = Path(xml_dir) / (img_path.stem + '.xml') if not xml_path.exists(): continue boxes = parse_voc_xml(str(xml_path)) # 生成负样本:随机裁剪不含车牌的背景块 for _ in range(80): x1 = np.random.randint(0, w - 64) y1 = np.random.randint(0, h - 64) patch = img[y1:y1+64, x1:x1+64] if any(is_overlap([x1, y1, x1+64, y1+64], box) for box in boxes): continue cv2.imwrite(f'{out_dir}/neg/{img_path.stem}_{np.random.randint(10000)}.jpg', patch) # 生成正样本:从车牌框附近裁剪 for box in boxes: x1, y1, x2, y2 = [int(v) for v in box] cx, cy = (x1+x2)//2, (y1+y2)//2 side = max(int((x2-x1)*1.4), int((y2-y1)*1.6)) crop_x1 = max(0, cx - side//2) crop_y1 = max(0, cy - side//2) patch = img[crop_y1:crop_y1+side, crop_x1:crop_x1+side] if patch.size == 0: continue patch = cv2.resize(patch, (12, 12)) cv2.imwrite(f'{out_dir}/pos/{img_path.stem}_{x1}_{y1}.jpg', patch)这个脚本的逻辑分两部分:负样本用滑窗随机裁剪64×64的背景块,并且用交并比判断排除与真实车牌框重叠的区域;正样本以车牌框为中心外扩1.4到1.6倍边长裁切,再缩放到12×12。参数说明:side的计算里,x方向外扩系数1.4保证车牌左右留有余量,y方向用1.6是因为车牌高度小,需要更多上下文来帮助PNet判断“这是个独立物体而不是路面纹理”;正样本统一缩放到12×12,匹配PNet的原始输入。实际操作时,我建议把负样本数量控制在每张图80到120块,太少模型欠拟合,太多PNet训练时间翻倍但召回收益很小。
3. LPRNet核心原理与序列建模:车牌为什么不能用普通OCR来识别
3.1 从“字符分类”到“序列识别”的关键切换
传统OCR做法是先把车牌图切成单个字符,再分别分类,最后拼成字符串。这个思路在蓝牌上勉强够用,但遇到新能源绿牌(8位字符)、武警车牌(含汉字+字母+数字混排)、或者字符粘连严重的模糊车牌时,切字符这一步就变成了整个识别链条的短板:字符间距不均匀、铆钉遮挡、倾斜透视,任何一项都会让切分边界偏移到一个危险的量级。LPRNet的思路完全绕开了切字符,它把整个车牌图当成一个序列,用卷积网络提取特征后按列展开,再用循环神经网络建模列与列之间的时序关系,最后用CTC损失直接对齐“图像特征序列”和“字符串标签”。只要有足够的训练数据,网络会自动学会“哪一列特征对应哪个字符”以及“字符之间如何分隔”,不再依赖显式的字符切分。
LPRNet的网络结构比传统CRNN更轻量:主干是一系列卷积层,中间穿插最大池化,但没有使用批归一化做全连接,而是引入宽度方向的池化来压缩特征图高度。输入图片的高度通常压缩到24或32像素,宽度方向保留到100像素左右,经过主干后变成T×C的特征序列,T是时间步数,C是字符类别数。这个T×C序列在训练时用CTC计算损失,在推理时用贪心解码或束搜索转成最终字符串。关键点在于:LPRNet没有使用RNN的深度堆叠,而是用两到三层双向GRU来建模序列上下文,因为车牌字符序列短,最多8位,过深的循环层不仅增加延时,还容易过拟合。
3.2 车牌字符集合设计与CTC对齐机制
字符集合的设计直接决定解码正确率的理论上限。蓝牌字符集合包含:31个省份汉字(不含港澳台)、24个英文字母(I和O通常被排除,因为和数字1、0混淆)、10个数字,合计65个字符;新能源绿牌在汉字和字母之外多一位数字,但字符集合不变。我见过有团队把字母I和O加回集合里,结果线上识别“京A0001”变成了“京AI001”,原因是雨夜图像里数字1和字母I的纹理特征几乎相同。所以工程上稳妥的做法是干脆从集合里剔除I和O,让CTC解码永远不输出这两个符号,从源头消灭一类误识别。
CTC对齐机制是理解LPRNet训练的关键。假设车牌是“京A12345”,长度7,但网络输出的时间步数是T,通常T在18到24之间,远远大于7。CTC允许每个字符在时间步上重复输出,并允许插入空白符号,因此一句话可以被压缩的路径有很多条。训练时,我们不需要告诉网络“第3列对应字符A”,只需要把整张图标注为“京A12345”,CTC会动态搜索所有能对齐到该标签的路径,并把它们的概率加总作为优化目标。这种对齐方式的好处是它天然容忍字符宽度不一致,坏处是解码时贪心算法容易把“AA”这种重复字符合并成一个A,所以实践里LPRNet的解码器通常保留blank帧标记,避免合并错误。
3.3 推理阶段的贪心解码与束搜索取舍
推理时,网络输出一个形状为T×C的概率矩阵,每一行是某个时间步上各字符的概率分布。最简单的解码策略是贪心解码:逐个时间步取概率最大的字符,然后合并相邻重复字符并删除盲字符。实话说,贪心解码在我测试的普通蓝牌数据上已经能跑到97.2%的整牌准确率,代码量只有十行。束搜索在车牌这种短序列上,提升约0.1到0.3个百分点,但推理耗时增加三到五倍。停车场场景下,单路视频需要跑检测加识别,总耗时预算通常在50毫秒以内,我一般直接上贪心解码。只有当测试集里连续相同字符(比如尾号是“11”或“BB”)占比很高时,才考虑用束搜索加语言模型纠正。
以下是我在生产环境里使用的解码代码,输入是LPRNet输出的概率矩阵,形状为(seq_len, num_classes),输出是解码后的字符串。
import numpy as np def greedy_decode(probs, char_map, blank_idx=0): """ 贪心解码:取每个时间步最大概率字符,合并重复并去blank probs: numpy数组,形状 (T, C),行顺序与特征序列对齐 char_map: dict,键是类别索引,值是字符本身(不含blank) """ pred_indices = np.argmax(probs, axis=1) result = [] prev = blank_idx for idx in pred_indices: if idx != prev and idx != blank_idx: result.append(char_map[idx]) prev = idx return ''.join(result)这段代码的逻辑是:先对每个时间步取argmax得到类别序列,然后用prev变量记住上一个时间步的类别,只有当前类别与上一个不同且不是blank时才输出字符。这样“A A blank A”会被解码成“AA”,而“A blank A”会被解码成“A”,与CTC的去重规则保持一致。参数说明:blank_idx默认设为0,这是训练时定的,CTC损失函数里通常把索引0作为空白类,如果你训练时把blank放在最后一个类,这里要相应调整;char_map是类别索引到字符的映射,注意不要包含blank。实际部署时,我还会在解码后加一个正则校验,比如第一位必须是汉字,第二位必须是字母,长度必须匹配蓝牌7位或绿牌8位;不符合的车牌直接丢弃,宁可漏识别一次也不要吐出错误结果影响计费逻辑。
4. 让LPRNet在本地训练收敛的最小方案:数据增强、损失函数与级联推理
4.1 车牌识别专用的数据增强管线,别用通用的翻转向量
很多上手LPRNet的人直接把通用OCR的数据增强搬过来,随机旋转±30度、随机裁剪、水平翻转,结果训练loss降得很快,但验证集整牌准确率一直卡在91%上下。原因在于车牌的物理约束:车牌是一个刚性平面物体,不会倒置,水平翻转后“京A12345”会变成“京A54321”,语义被破坏,所以翻转类增强在车牌上要彻底关闭。随机旋转的角度也要收窄到±5度,真实停车场画面里车辆偏离水平方向超过10度的场景很少,过大的旋转会让网络把“识别倾斜车牌”当成主要任务,反而浪费了模型容量。
我惯用的数据增强管线是这样的:颜色抖动(亮度±30、对比度±20、饱和度±15),高斯噪声(sigma在0到5之间),运动模糊(模拟夜间车辆行驶时的拖影),以及随机遮挡(在车牌区域随机盖一块20×40的黑色方块,模拟铆钉或泥污)。运动模糊和遮挡对夜间识别率的提升最明显,因为真实场景的难例基本都来自这两类退化。训练时我会把增强后的图像缩放到LPRNet的固定输入尺寸,通常是94×24,这个宽高比接近真实蓝牌比例的3.9:1;绿牌因为位数多,我习惯缩放到114×24,避免字符被压缩得难以分辨。
4.2 训练超参与损失函数:从loss曲线判断是否踩进过拟合
LPRNet的损失函数直接用PyTorch的CTCLoss,但参数上有几个容易被忽略的坑。CTCLoss的blank参数要和网络输出层的类别索引一致,我设置为0;reduction我用sum而不是mean,原因是车牌样本的字符长度差异不大,用sum可以放大梯度,加速前中期收敛,后期调低学习率防止震荡。batch size我常用64,如果显存不够就降到32,但低于32时CTC的梯度方差会显著增大,表现为loss曲线毛刺多,整牌准确率波动超过1.5%。优化器我选Adam,初始学习率1e-3,配合余弦退火到1e-5,大概25个epoch收敛;如果用SGD加动量,学习率要降到1e-2起步,收敛速度慢但不坏。
以下是我训练LPRNet的核心代码片段,包含网络输出层和损失函数计算。注意网络结构部分我只列关键层,完整实现按你自己项目里的“LPRNet”模块替换即可。
import torch import torch.nn as nn class LPRNet(nn.Module): def __init__(self, num_classes, seq_len=24): super().__init__() # 输入: (batch, 3, 24, 94),输出特征序列长度近似 seq_len self.backbone = nn.Sequential( nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # 高度减半 nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # 高度减半到约6 ) # 双向GRU建模序列上下文 self.gru = nn.GRU(input_size=128*6, hidden_size=128, num_layers=2, bidirectional=True, batch_first=True) self.fc = nn.Linear(256, num_classes) def forward(self, x): b, c, h, w = x.shape feat = self.backbone(x) # (b, 128, h/4, w/4) b, c, h2, w2 = feat.shape feat = feat.permute(0, 3, 1, 2).reshape(b, w2, c*h2) out, _ = self.gru(feat) # (b, w2, 256) out = self.fc(out) # (b, w2, num_classes) return out def lprnet_loss(logits, targets, target_lengths, blank=0): """logits: (batch, seq_len, num_classes) 转入CTC之前要log_softmax""" log_probs = F.log_softmax(logits, dim=2).permute(1, 0, 2) input_lengths = torch.full( size=(logits.size(0),), fill_value=logits.size(1), dtype=torch.long ) return nn.CTCLoss(blank=blank, reduction='sum')( log_probs, targets, input_lengths, target_lengths )这段代码有几个参数值得说明。backbone里两次MaxPool2d都把高度减半,输入高度24经过两次池化变成6,这6行特征和每列宽度上的像素一起被reshape成“每一列拼接所有通道”的向量,送入GRU。这里最关键的是permute(0, 3, 1, 2)的用法:把宽度维度w2放到batch之后,GRU按宽度方向扫描图像列,符合“从左到右读车牌”的时序逻辑。GRU的hidden_size设为128,bidirectional=True时输出维度是256,fc层映射到num_classes。CTC输入要求log_probs格式为(seq_len, batch, num_classes),所以代码里在送入损失函数前做了permute处理。训练时targets要预先拼成一维整数张量,target_lengths保存每张牌的车牌字符数(不含blank),这部分属于数据加载逻辑,可以用torch.nn.utils.rnn的pad_sequence配合实现。
4.3 从MTCNN到LPRNet的级联推理:一个完整的在线识别流程
生产环境里的在线识别,不是把两个模型接起来那么简单,中间还需要透视矫正和颜色识别两个步骤。我的推理管线是:摄像头取一帧1080P图,MTCNN检测出车牌候选框和四个角点;用角点做透视变换,把倾斜车牌拉正;再从拉正后的图像上截取固定区域的像素,送入LPRNet;同时从原图车牌区域统计蓝色或绿色的像素占比,判断车牌类型;最后把字符串和类型合并成最终结果。透视矫正这一步经常被新手忽略,直出的倾斜车牌在LPRNet上的准确率大约降低3到5个百分点,矫正后能回到训练时的水平。
以下是我常用的推理管线代码,它把MTCNN输出到LPRNet输入的衔接过程完整串起来。
import cv2 import numpy as np def extract_plate_by_landmarks(img, landmarks): """ landmarks: MTCNN ONet输出的四角点,形状 (4, 2) 返回矫正后的车牌图,宽高比约 3.9:1 """ src_pts = landmarks.astype(np.float32) # 按左上、右上、右下、左下排序 rect = np.zeros((4, 2), dtype=np.float32) s = src_pts.sum(axis=1) rect[0] = src_pts[np.argmin(s)] rect[2] = src_pts[np.argmax(s)] diff = np.diff(src_pts, axis=1) rect[1] = src_pts[np.argmin(diff)] rect[3] = src_pts[np.argmax(diff)] # 目标宽度按240,高度按61,保持蓝牌3.9比例 dst_w = 240 dst_h = int(dst_w / 3.9) dst_pts = np.array([[0, 0], [dst_w-1, 0], [dst_w-1, dst_h-1], [0, dst_h-1]], dtype=np.float32) M = cv2.getPerspectiveTransform(rect, dst_pts) warped = cv2.warpPerspective(img, M, (dst_w, dst_h)) return warped def infer_plate(img, mtcnn, lprnet, char_map): """完整级联推理:检测、矫正、识别""" boxes, landmarks = mtcnn.detect(img) if len(boxes) == 0: return None plate_img = extract_plate_by_landmarks(img, landmarks[0]) # 预处理到LPRNet输入尺寸 plate_img = cv2.resize(plate_img, (94, 24)) plate_img = plate_img.astype(np.float32) / 255.0 x = torch.from_numpy(plate_img).permute(2, 0, 1).unsqueeze(0) logits = lprnet(x)[0] # (seq_len, num_classes) probs = torch.softmax(logits, dim=1).detach().numpy() text = greedy_decode(probs, char_map) return text这段代码里最容易被忽略的是角点排序逻辑,MTCNN输出的landmark顺序不保证是左上、右上、右下、左下,我直接用坐标的几何关系重排:和最小的是左上,和最大的是右下,差最小的是右上,差最大的是左下。getPerspectiveTransform需要四点一一对应,顺序错了图像就会被镜像或扭曲。预处理部分除以255归一化,permute把HWC转成CHW并增加batch维度,LPRNet输入是(batch, 3, 24, 94)。如果你用的是ONNX部署版本,这里可以用cv2.dnn.blobFromImage替代permute,速度上差别不大但代码更简洁。
5. 车牌识别LPRNet+MTCNN的避坑与常见问题排查
5.1 夜间MTCNN漏检:ONet阈值过低导致停车场出口频繁无响应
现象:白天一切正常,到了晚上八九点,车辆开到闸机前1米位置,识别一体机经常吐不出车牌,后台日志显示MTCNN检测到的候选框置信度只有0.35到0.45。
原因:夜间车牌区域靠红外补光照明,车牌反光让字符区域过曝,牌照底色失真,PNet能扫出候选框,但ONet阶段因为阈值设成0.7,把低置信度但实际正确的框全部过滤掉了。停车场场景的夜间召回率对阈值非常敏感,0.7和0.6之间的差异往往超过5个百分点。
解决:把ONet置信度阈值从0.7下调到0.6,同时把RNet的阈值从0.6调到0.5,PNet保持0.6不变。这样做会引入少量误检框,但对车牌识别场景来说,多检一两个背景块的成本很低,后续LPRNet会在识别阶段把它们过滤掉。我还会把夜间补光开启的判断接到曝光统计上,而不是简单按时间判断,黄昏时段补光没亮但环境光已经不足时,提前降阈值能救回一批识别结果。
5.2 LPRNet把数字“1”识别成字母“I”:字符集合埋的雷
现象:整牌准确率总是卡在93%到95%之间,翻看错误样本,发现大量“A1B23”被识别成“AI B23”,但单独看数字“1”的训练样本,分类准确率却有99.8%。
原因:LPRNet是序列模型,不是字符级分类器。当字符集合里同时包含数字“1”和字母“I”时,模型需要在每一个时间步上区分二者;真实车牌图像里这两个字符在低分辨率下的纹理差异极小,尤其在夜间图像中,字符的内部空白区域被噪声填充后,二者几乎不可分。序列建模的误差会沿着时间步累积,一个时间步的错误可能影响后续解码路径。
解决:从字符集合中删掉字母“I”和“O”,把它们的类别索引直接去掉,解码时所有指向这些索引的概率都被mask掉。这样模型被迫把“I”和“O”的样本归入最近的可区分类别,通常是“1”和“0”。我实测这个改动能让整牌准确率提升1.8个百分点左右,代价是极少数真正含“I”或“O”的车牌会被误识别为“1”或“0”,但这类车牌在民用车辆里占比极低,收益远大于代价。
5.3 透视矫正后车牌仍然倾斜:MTCNN角点回归不准的后果
现象:用MTCNN的ONet输出角点做透视变换后,warped车牌图里字符仍然左右高低不平,送入LPRNet的序列特征被扭曲,识别率反而低于直接识别原图。
原因:ONet的关键点训练数据如果来自自动标注而不是人工精标,角点坐标存在2到3像素误差。透视变换对这2像素误差非常敏感,变换后的车牌图在字符区域会产生非线性拉伸,尤其是角点离真实位置偏差超过5像素时,车牌的上下边缘会呈现梯形畸变。
解决:在MTCNN的ONet训练阶段,对车牌角点标注做二次精修。我通常用半自动方式:先跑一遍现有MTCNN生成粗角点,再用OpenCV的凸包检测提取车牌区域的包络多边形,取多边形顶点作为精标角点,人工只负责剔除明显错误的样本。精修后,ONet角点平均误差从3.2像素降到了1.1像素。另一个兜底方案是在推理管线里加上角度校正的旁路:对warped图像再做一次边缘检测,如果检测到字符行与水平方向夹角超过2度,就用霍夫变换拟合直线并补充一次旋转校正。
5.4 训练时loss下降但验证集整牌准确率震荡:增强管线过度激进
现象:训练loss从第8个epoch开始稳定下降,但验证集整牌准确率在91%到94%之间来回跳,无法收敛到一个平稳的高值。
原因:数据增强里我最初加入了随机遮挡,比例设为0.3,且遮挡块大小覆盖了车牌三分之一面积。当遮挡面积过大,模型在训练时经常看不到完整字符,CTC对齐只能依赖前后文猜测,导致验证集上遇到完整车牌时反而不知道该相信哪个时间步的输出,产生“特征分布漂移”。
解决:把随机遮挡概率从0.3降到0.1,遮挡块最大面积从三分之一缩到五分之一(约20×12像素)。同时把运动模糊的核大小从7缩到5,sigma从1.5降到1.0。调整后训练loss下降曲线变平滑,验证集准确率在第20个epoch稳定在96.5%以上。这里的关键判断是:增强的目的是模拟真实退化,不是制造训练困难度,过度增强会让模型学到对“部分车牌”的过拟合。
5.5 视频流识别结果闪烁:相邻帧车牌字符来回跳
现象:车辆静止在闸机前时,连续30帧画面里识别结果在“京A12345”和“京A12346”之间跳变,后台计费系统因为结果不稳定而拒绝自动扣款。
原因:MTCNN每帧独立检测,车牌框在静止画面中也会有1到2像素的抖动,LPRNet对输入图像边缘极其敏感,字符边缘偏移一个像素就可能改变argmax结果,尤其是尾号数字在低置信度区间时会随机波动。
解决:在识别管线里加入结果稳定性校验:连续五帧中,取出现次数最多的识别结果作为最终车牌;如果五个结果各不相同,则取置信度最高的一帧结果。实现时我用一个长度为五的滑动窗口,窗口内同一个字符串出现三次以上才接受。这个改动在工程上非常重要,它能消除超过90%的闪烁问题,而且不增加任何推理开销,只在后端逻辑里做字符串比对。
6. 把模型压进边缘设备:INT8量化后车牌识别率不降的反直觉经验
很多团队在Jetson Nano上部署第一个版本时发现,TensorRT把模型转成INT8后,LPRNet的整牌准确率从96.5%掉到88.2%,直接不可用。问题几乎不出在量化本身,而是校准数据集选错了。TensorRT的INT8校准需要让模型“看到”有代表性的输入分布,我用1000张白天正常车牌图片做校准,结果模型对夜间图像的激活范围完全没被覆盖,量化后夜间识别一塌糊涂。后来我把校准集改成800张白天加400张夜间混合,夜间补光过曝的样本占三分之一,量化后的准确率恢复到95.8%,只掉了不到一个百分点。MTCNN的INT8量化同样要注意校准集,但它的影响更温和。
另一个习惯是给ONet输出加一个硬性框尺寸下限。车牌在画面里低于20像素高度时,无论MTCNN还是LPRNet都无法稳定工作,与其在后端接一堆容错逻辑,不如直接在检测阶段丢弃。我在生产里通常设置为:车牌框高度小于25像素时直接返回未识别,然后触发摄像头的数码变焦或等待车辆继续靠近。这个规则听起来粗暴,但现场效果非常好,因为停车场闸机的触发位置固定,车辆经过时帧率足够高,丢掉几帧完全不心疼。
最后一次我把识别结果拼接的逻辑从“每帧独立”改成了“以触发信号为基准的段识别”:从车辆压到地感线圈开始计时,连续抓拍1.5秒,取这个窗口内的稳定识别结果作为最终输出。实际码率消掉了最后一点随机错误,让整牌准确率稳定在了98%以上。这套“MTCNN检测+LPRNet识别+结果稳定性窗口”的组合,我现在碰到新的场景时仍然会先搭一遍再调优,因为它足够可解释,每一层出问题都能单独定位。希望这些从真实项目中磨出来的参数和习惯,能帮你把模型更快地推到线上稳定运行,而不是卡在实验室里反复刷指标。
本文还有配套的精品资源,点击获取