简介:这份资源围绕深度学习车牌识别展开,面向具备一定PyTorch基础、希望动手复现完整LPRNet+MTCNN方案的开发者与学习者,可用于智能交通、安防监控等场景的入门实践。包内共870个文件,以566个ckpt训练检查点、36个py源码脚本、12个pth权重、133张jpg与50张png样本图片为主,另含少量txt说明、xml标注、csv数据及ui界面文件,压缩包约543.9MB,覆盖从车牌检测到字符识别的完整流程。资源将MTCNN用于车牌区域定位,LPRNet负责端到端字符识别,并附带多轮迭代保存的模型权重,便于读者直接加载推理或继续微调训练。目前已有409人学习下载,适合对照代码理解预处理、检测、裁剪、识别与结果整合各环节,并在此基础上尝试数据增强、模型轻量化与部署优化,逐步提升识别准确率与鲁棒性。
1. 车牌识别 LPRNet+MTCNN:为什么这对组合至今仍是性价比首选
如果你手头有一段卡口视频或一批车辆抓拍图,想在不堆显卡的前提下把车牌号抠出来,LPRNet+MTCNN 这套组合大概率是你绕不开的方案。MTCNN 负责在整张图里把车牌区域框出来,LPRNet 负责把框里的字符序列直接读出来,一个管“找”,一个管“认”,分工干净。它不像两阶段检测那样需要先检字符再拼序列,也不像大模型方案那样动辄要几十 GB 显存。我见过不少团队在车牌识别上反复折腾,最后回到这套组合,原因很实在:MTCNN 对倾斜、遮挡、小目标车牌的召回够用,LPRNet 的轻量结构在 CPU 上也能跑到实时,整套链路部署成本低。适合谁?做停车场出入口、园区卡口、车载终端、边缘盒子的开发者,尤其是那些算力预算有限、又不想牺牲识别率的场景。这一章先把“是什么、能解决什么”讲清楚,后面几章直接上代码和参数。
2. MTCNN 车牌检测:从整图到车牌框的完整链路
MTCNN 原本是人脸检测的经典网络,但它的三级级联结构——P-Net、R-Net、O-Net——对车牌这种具有固定长宽比、边缘清晰的矩形目标同样有效。核心思路是:P-Net 在全图滑动生成大量候选框,R-Net 做第一次筛选和边框回归,O-Net 做最终精修并输出五个关键点。用在车牌上,关键点可以退化成四个角点,方便后续做透视变换。这一章把数据准备、网络输入尺寸、级联阈值、后处理 NMS 全部拆开讲,目标是让你能用自己的车牌数据跑通检测。
2.1 车牌数据标注与 MTCNN 输入尺寸的匹配
MTCNN 训练需要三类样本:正样本(IoU>0.65)、负样本(IoU<0.3)、部分样本(0.4<IoU<0.65)。车牌的长宽比通常在 2:1 到 3.5:1 之间,如果你直接拿人脸那套 12×12、24×24、48×48 的输入尺寸,车牌在 P-Net 阶段会被压扁得太厉害,小字符直接糊掉。我的做法是把 P-Net 输入改成 16×32,R-Net 改成 32×64,O-Net 改成 64×128,保持长宽比接近 1:2。标注格式用 WIDER FACE 那种 txt 就行,每行是path x1 y1 x2 y2,但要注意车牌框要贴紧字符区域,不要把车牌底板边缘留太多,否则 LPRNet 会学到无用的边框纹理。
# 生成 MTCNN 训练用的正负样本,针对车牌长宽比调整 import numpy as np import cv2 def generate_samples(img, boxes, img_size=(16, 32)): """ img: 原始 BGR 图像 boxes: 车牌框列表,每个元素 [x1, y1, x2, y2] img_size: P-Net 输入尺寸,宽高比约 1:2 """ samples = [] h, w = img.shape[:2] for box in boxes: x1, y1, x2, y2 = box # 计算 IoU 用于分类正负样本 # 这里简化处理:直接裁剪并缩放到目标尺寸 crop = img[y1:y2, x1:x2] if crop.size == 0: continue # 保持长宽比缩放,短边补零 target_w, target_h = img_size scale = min(target_w / crop.shape[1], target_h / crop.shape[0]) new_w = int(crop.shape[1] * scale) new_h = int(crop.shape[0] * scale) resized = cv2.resize(crop, (new_w, new_h)) canvas = np.zeros((target_h, target_w, 3), dtype=np.uint8) # 居中放置 x_offset = (target_w - new_w) // 2 y_offset = (target_h - new_h) // 2 canvas[y_offset:y_offset+new_h, x_offset:x_offset+new_w] = resized samples.append(canvas) return samples这段代码的关键在scale的计算和居中补零。车牌被缩放到 16×32 的画布上,短边补零而不是拉伸,这样字符的宽高比不会失真。参数img_size要根据你的实际车牌像素分布来调:如果卡口相机拍出来的车牌宽度普遍在 120 像素以上,P-Net 用 16×32 足够;如果车牌只有 40 像素宽,建议把 P-Net 提到 24×48,否则 P-Net 的卷积核感受野覆盖不了整个字符。
2.2 三级级联的阈值设置与 NMS 后处理
MTCNN 的三级阈值是检测召回和误检的平衡杆。P-Net 的阈值我一般设 0.6,R-Net 设 0.7,O-Net 设 0.9。为什么 O-Net 要这么高?因为到了 O-Net 阶段,候选框已经很少了,这时候宁可错杀不可放过,把置信度低的框直接扔掉,避免把广告牌、车身贴纸误检成车牌。NMS 的 IoU 阈值设 0.3 到 0.4 之间,车牌之间通常不会重叠,所以 NMS 可以激进一点。
def nms(boxes, scores, iou_threshold=0.35): """ boxes: [[x1, y1, x2, y2], ...] scores: 对应的置信度 """ if len(boxes) == 0: return [] boxes = np.array(boxes) scores = np.array(scores) x1 = boxes[:, 0] y1 = boxes[:, 1] x2 = boxes[:, 2] y2 = boxes[:, 2] # 注意:这里应该是 boxes[:, 3],示例中故意留错提醒检查 areas = (x2 - x1 + 1) * (y2 - y1 + 1) order = scores.argsort()[::-1] keep = [] while order.size > 0: i = order[0] keep.append(i) xx1 = np.maximum(x1[i], x1[order[1:]]) yy1 = np.maximum(y1[i], y1[order[1:]]) xx2 = np.minimum(x2[i], x2[order[1:]]) yy2 = np.minimum(y2[i], y2[order[1:]]) w = np.maximum(0.0, xx2 - xx1 + 1) h = np.maximum(0.0, yy2 - yy1 + 1) inter = w * h iou = inter / (areas[i] + areas[order[1:]] - inter) inds = np.where(iou <= iou_threshold)[0] order = order[inds + 1] return boxes[keep].tolist()上面代码里y2 = boxes[:, 2]是一个典型的复制粘贴错误,正确应该是boxes[:, 3]。这种错误在调试时不会报错,但会导致 NMS 结果完全乱掉,框的高度计算错误,最终要么全保留要么全删掉。血泪经验:每次改完 NMS 代码,拿一张有 3 到 5 个车牌的图跑一遍,看输出的框数量对不对。参数方面,iou_threshold设 0.35 是我在多个卡口数据集上试出来的,低于 0.3 会漏掉相邻车牌的框,高于 0.5 会保留太多重复框。
2.3 从 MTCNN 输出到车牌矫正:透视变换的四个角点
MTCNN 的 O-Net 输出的是矩形框,但实际卡口场景里车牌往往有倾斜。如果你直接拿矩形框去裁剪,LPRNet 读出来的字符会带透视畸变,识别率掉得厉害。我的做法是在 O-Net 阶段回归四个角点,然后用cv2.getPerspectiveTransform做矫正。四个角点的顺序要固定:左上、右上、右下、左下。训练时把标注的矩形框四个角作为回归目标,损失函数用 Smooth L1。
def perspective_correct(img, corners): """ corners: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] 顺序为左上、右上、右下、左下 """ src = np.float32(corners) # 目标尺寸按车牌标准比例 440x140 dst = np.float32([[0, 0], [440, 0], [440, 140], [0, 140]]) M = cv2.getPerspectiveTransform(src, dst) warped = cv2.warpPerspective(img, M, (440, 140)) return warped目标尺寸 440×140 是常见车牌的标准比例,但如果你做的是新能源车牌(长宽比略有不同),可以改成 480×140。矫正后的图直接送进 LPRNet,省掉了 LPRNet 自己学透视不变性的负担。注意corners的顺序必须和训练时一致,否则矫正出来的图是镜像或旋转的,LPRNet 会直接翻车。
3. LPRNet 字符识别:从矫正图到车牌号的端到端推理
LPRNet 的核心卖点是不用字符分割,直接输出字符序列。它用 CTC 损失函数处理不定长序列,骨干网络是轻量级的 CNN,没有全连接层,参数量很小。这一章讲清楚 LPRNet 的输入预处理、CTC 解码、以及如何和 MTCNN 的输出对接。如果你之前用过 CRNN,会发现 LPRNet 更轻、更快,但在中文车牌上需要调整字符集和输出维度。
3.1 LPRNet 的输入预处理与字符集定义
LPRNet 的输入是矫正后的车牌图,尺寸通常缩放到 94×24 或 128×32。我一般用 94×24,因为它在 CPU 上推理更快,而且对蓝牌、黄牌的识别率足够。字符集要覆盖你的业务场景:蓝牌是 省份简称 + 字母 + 数字,新能源牌多一位。字符集定义成一个列表,索引 0 留给 CTC 的 blank。
# 字符集定义,按实际业务增删 CHARS = [ 'blank', '京', '沪', '粤', '苏', '浙', '鲁', '川', '渝', '冀', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' ] # 注意:没有 I 和 O,避免和 1、0 混淆 def preprocess_plate(img, size=(94, 24)): """ img: 矫正后的 BGR 车牌图 """ img = cv2.resize(img, size) img = img.astype(np.float32) # 归一化到 [-1, 1] img = (img / 255.0 - 0.5) / 0.5 # 转成 CHW 格式 img = img.transpose(2, 0, 1) return np.expand_dims(img, axis=0)字符集里去掉I和O是行业惯例,因为车牌上不会出现这两个字母,它们和数字1、0太像。如果你的业务里出现了特殊车牌,比如使领馆车牌,需要单独加字符。预处理里的归一化用(x/255 - 0.5)/0.5而不是简单的x/255,是因为 LPRNet 原文用的就是这种归一化,换掉之后模型收敛会变慢。
3.2 CTC 解码:从 LPRNet 输出到最终车牌字符串
LPRNet 的输出是一个T×C的矩阵,T 是时间步,C 是字符集大小。CTC 解码有两种:贪心解码和束搜索解码。贪心解码就是每个时间步取最大概率的字符,然后合并重复字符并去掉 blank。束搜索解码更准但更慢。我在实际项目里用贪心解码,因为车牌字符之间的区分度很高,贪心足够。
def ctc_greedy_decode(logits, chars): """ logits: LPRNet 输出,形状 [T, C] chars: 字符集列表 """ # 取每个时间步的最大概率索引 indices = np.argmax(logits, axis=1) result = [] prev = -1 for idx in indices: if idx != prev and idx != 0: # 0 是 blank result.append(chars[idx]) prev = idx return ''.join(result)这段代码的逻辑是:先取 argmax,然后遍历,如果当前索引和上一个索引不同且不是 blank,就加入结果。注意prev初始化为 -1,避免第一个字符被误判为重复。参数方面,logits的形状取决于 LPRNet 的下采样倍数,如果你的输入是 94×24,输出 T 通常是 23 左右,足够覆盖 7 到 8 个字符。
3.3 MTCNN 与 LPRNet 的对接:从检测框到识别结果的完整推理脚本
把前面几块拼起来,就是一个完整的推理流程:读图 → MTCNN 检测 → 透视矫正 → LPRNet 识别 → 输出车牌号。下面是一个可运行的脚本框架,依赖 PyTorch 和 OpenCV。
import cv2 import numpy as np import torch def full_pipeline(image_path, mtcnn_model, lprnet_model, chars): """ image_path: 输入图片路径 mtcnn_model: 已加载的 MTCNN 模型 lprnet_model: 已加载的 LPRNet 模型 chars: 字符集 """ img = cv2.imread(image_path) if img is None: raise ValueError("图片读取失败") # 第一步:MTCNN 检测车牌框和角点 boxes, corners = mtcnn_model.detect(img) results = [] for box, corner in zip(boxes, corners): # 第二步:透视矫正 warped = perspective_correct(img, corner) # 第三步:LPRNet 预处理 input_tensor = preprocess_plate(warped) input_tensor = torch.from_numpy(input_tensor).float() # 第四步:推理 with torch.no_grad(): logits = lprnet_model(input_tensor) logits = logits.squeeze(0).cpu().numpy() # 第五步:CTC 解码 plate_str = ctc_greedy_decode(logits, chars) results.append({ 'box': box, 'plate': plate_str, 'confidence': float(np.max(logits)) }) return results这个脚本里mtcnn_model.detect和lprnet_model需要你自己根据前面的章节实现或加载。confidence取的是 logits 的最大值,只能粗略反映置信度,更严谨的做法是对 CTC 路径的概率求和。实际部署时,我会把 MTCNN 和 LPRNet 都转成 ONNX,用 ONNXRuntime 推理,CPU 上单帧耗时能压到 30 毫秒以内。
4. 训练与调参:MTCNN 和 LPRNet 各自的关键参数
这一章讲训练。MTCNN 和 LPRNet 是分开训练的,MTCNN 先训好,冻结之后生成矫正图,再训 LPRNet。很多人把两个网络一起端到端训,结果要么 MTCNN 不收敛,要么 LPRNet 学不到东西。我的建议是分阶段训,MTCNN 用自己的人脸预训练权重初始化,LPRNet 从头训。下面把两个网络的关键参数和训练技巧拆开讲。
4.1 MTCNN 训练:学习率、批次和难例挖掘
MTCNN 的三级网络可以一起训,也可以分开训。我一般分开训,P-Net 先训 10 个 epoch,R-Net 和 O-Net 各训 15 个 epoch。学习率用 0.001,批次大小 P-Net 用 256,R-Net 和 O-Net 用 128。难例挖掘是 MTCNN 训练的关键:每个 epoch 结束后,用当前模型跑一遍训练集,把误检的负样本和漏检的正样本加进下一轮训练。没有难例挖掘,MTCNN 在车牌上的误检率会高得离谱,尤其是把车身反光条误检成车牌。
# MTCNN 难例挖掘的简化逻辑 def hard_example_mining(model, dataloader, threshold=0.5): """ 收集误检和漏检样本 """ hard_negatives = [] hard_positives = [] for imgs, labels, boxes in dataloader: preds = model(imgs) for i, pred in enumerate(preds): if labels[i] == 0 and pred > threshold: # 负样本被误检为正 hard_negatives.append(imgs[i]) elif labels[i] == 1 and pred < threshold: # 正样本被漏检 hard_positives.append(imgs[i]) return hard_negatives, hard_positives这段代码只是逻辑示意,实际用的时候要把难例样本和原始数据混合,比例控制在 1:3 左右,否则模型会过拟合到难例上。参数threshold设 0.5 是中间值,如果你发现误检特别多,可以降到 0.3,让更多负样本被挖出来。
4.2 LPRNet 训练:CTC 损失、数据增强和收敛判断
LPRNet 的损失函数是 CTC Loss,优化器用 Adam,学习率 0.001,批次大小 128。数据增强对 LPRNet 特别重要,因为车牌识别要应对光照变化、模糊、遮挡。我常用的增强有:随机亮度对比度调整、高斯模糊、随机裁剪(裁剪比例不超过 5%)、轻微旋转(±3 度)。注意不要用水平翻转,车牌翻转后字符顺序反了,CTC 会学乱。
import torch.nn as nn # CTC Loss 的使用 ctc_loss = nn.CTCLoss(blank=0, reduction='mean') # log_probs 形状 [T, N, C],targets 是字符索引序列 # input_lengths 和 target_lengths 是序列长度 loss = ctc_loss(log_probs, targets, input_lengths, target_lengths)blank=0要和字符集里 blank 的索引一致。reduction='mean'会对批次求平均,如果批次里车牌长度差异大,可以改成'sum'再自己除以批次大小。收敛判断看两个指标:CTC Loss 降到 0.1 以下,以及验证集上的整牌准确率不再提升。整牌准确率比字符准确率更重要,因为车牌识别错一个字符整个结果就废了。
4.3 联合调优:MTCNN 召回率与 LPRNet 识别率的平衡
MTCNN 和 LPRNet 虽然分开训,但联合调优时要一起看指标。MTCNN 的召回率提高,会送更多框给 LPRNet,但误检框也会增多;LPRNet 的识别率提高,对模糊车牌的容忍度更好,但可能对 MTCNN 的定位精度要求降低。我的做法是:先固定 MTCNN,调 LPRNet 到整牌准确率 95% 以上;然后固定 LPRNet,调 MTCNN 的 O-Net 阈值,看端到端准确率的变化。端到端准确率 = MTCNN 召回率 × LPRNet 在召回框上的识别率。这个乘积才是你真正要优化的目标。
5. 避坑与排查:车牌识别落地时最容易翻车的五个点
这一章全是踩坑记录。车牌识别看起来链路清晰,但实际部署时各种玄学问题层出不穷。下面五条是我和同行交流时出现频率最高的,每条按现象、原因、解决来写。
5.1 现象:夜间红外图上车牌全白,MTCNN 一个框都检不出来
原因:MTCNN 的训练数据以可见光为主,红外图的车牌对比度和纹理分布完全不同,P-Net 的浅层卷积核学到的边缘特征在红外图上失效。解决:在训练数据里混入至少 20% 的红外车牌图,或者在 MTCNN 前面加一个简单的图像增强,把红外图映射到可见光风格。我一般用 CLAHE 做自适应直方图均衡,对红外图的提升很明显。
5.2 现象:LPRNet 把“京A12345”识别成“京A1234S”
原因:字符集里5和S的视觉特征太接近,CTC 在时间步上的概率分布容易混淆。解决:在训练数据里增加5和S的对比样本,或者在 LPRNet 的输出层加一个小的分类头,专门区分易混字符。更简单的做法是后处理加规则:如果识别结果里出现S但位置应该是数字,就强制替换成5。
5.3 现象:MTCNN 在 CPU 上推理一帧要 200 毫秒,完全达不到实时
原因:P-Net 在全图上的滑动窗口太多,输入分辨率越高,候选框数量指数增长。解决:先把输入图缩放到长边 640 像素,再做 MTCNN 检测,检测完把框映射回原图。另外把 P-Net 的输入尺寸从 16×32 降到 12×24,速度能提升一倍,召回率只掉 2% 左右。
5.4 现象:LPRNet 训练 Loss 降到 0.05,但验证集整牌准确率只有 60%
原因:过拟合。LPRNet 参数量小,但如果训练集只有几千张,模型会记住训练样本的噪声。解决:加数据增强,加 Dropout,或者用早停。我一般把训练集和验证集按 8:2 分,验证集准确率连续 5 个 epoch 不提升就停。
5.5 现象:透视矫正后的车牌图上下颠倒,LPRNet 输出乱码
原因:MTCNN 回归的四个角点顺序不固定,有时候左上角点跑到了右下角。解决:在透视变换前加一个角点排序逻辑,按坐标的 x+y 和 x-y 排序,确保顺序是左上、右上、右下、左下。这个坑我踩过两次,每次都是因为换了训练数据后角点回归的初始化变了。
6. 进阶技巧:用 ONNX 量化把 LPRNet+MTCNN 塞进边缘设备
如果你要把这套方案部署到边缘盒子或车载终端,PyTorch 原生推理太重了。我的做法是:MTCNN 和 LPRNet 分别导出 ONNX,然后用 ONNXRuntime 的量化工具做 INT8 量化。量化后模型体积缩小到原来的四分之一,CPU 推理速度提升 2 到 3 倍,精度掉不到 1%。下面是一个导出和量化的示例。
import torch import onnx from onnxruntime.quantization import quantize_dynamic, QuantType # 导出 LPRNet 到 ONNX dummy_input = torch.randn(1, 3, 24, 94) torch.onnx.export( lprnet_model, dummy_input, "lprnet.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, opset_version=11 ) # 动态量化 quantize_dynamic( "lprnet.onnx", "lprnet_quant.onnx", weight_type=QuantType.QUInt8 )opset_version=11是兼容性比较好的选择,dynamic_axes把批次维度设为动态,方便你一次推理多张车牌。量化后的模型用 ONNXRuntime 加载,推理代码和 PyTorch 版本几乎一样,只是把torch.no_grad()换成session.run。注意量化后的模型对输入尺度敏感,预处理里的归一化参数要和量化时保持一致,否则精度会崩。
验证量化模型的方法很简单:拿 100 张验证集图片,分别用 PyTorch 模型和量化模型跑一遍,对比整牌准确率。如果掉超过 2%,就检查量化时的校准数据是否覆盖了各种光照和角度。我一般用 500 张校准图,从训练集里随机抽,确保分布一致。
最后说一个我自己的习惯:每次改完 MTCNN 或 LPRNet 的任何参数,先跑一个 50 张图的小测试集,看端到端准确率和耗时,确认没有回归之后再跑全量。这个习惯帮我省了很多后悔药,因为车牌识别链路上任何一个环节的改动都可能引发连锁反应。希望帮到你。
本文还有配套的精品资源,点击获取