☰
基于深度学习的字符型数字验证码识别:CNN多标签分类Python实现
2026/11/6 2:04:31 网站建设 项目流程

简介:这份资源围绕字符型图片数字验证码识别,提供从数据准备、图像预处理到CNN与RNN模型构建、训练评估及Python实现的完整学习材料,面向具备一定Python基础、希望进入计算机视觉与深度学习实践的开发者及网络安全学习者。压缩包共1210个文件,以978个png、202个jpg验证码样本为主,配有17个py源码文件及少量txt说明文档,整体约9.58MB,便于按数据、脚本与说明分层查阅。已有1543人学习,说明其在验证码识别入门与复现实验场景中具有一定参考价值。读者可据此了解one-hot编码、数据增强、卷积特征提取、LSTM/GRU序列建模、Adam优化与模型保存等关键环节,并结合源码复现训练流程、观察字符序列预测效果,为课程设计、项目实践和算法排错提供可操作的参考。

1. 别把它当练手玩具:字符型验证码识别其实是深度学习落地的最小闭环

很多人搜「基于深度学习的字符型图片数字验证码识别」,心里想的是找个 demo 跑跑看。真跑起来才发现,一张 4 位数字的字符图片,把数据清洗、标签编码、CNN 特征提取、多位置解码、置信度阈值这几件事全串了一遍,任何一环掉链子,准确率就卡在 60% 上下晃。这套 Python 实现的价值不在算法多新,而在于它是一条能完整走通的流水线:图片进目录,模型吐字符串,中间每一步都能单独拎出来改。它适合两类人,一类是想用一个小任务把深度学习训练流程吃透的新手,另一类是手上有一批自建系统测试环境的图片、需要一套能改字符集能改长度的识别模板的开发者。

2. 方案选型与工程骨架:三种识别路线的取舍和目录落地

动手之前先把路线定死,不然写到一半想换方案,数据集、标签格式、解码逻辑全得推倒重来。这个项目最终选的是「CNN 主干 + 定长多标签分类」,原因很朴素:大多数业务场景里的数字验证码长度是固定的 4 位或 6 位,用 CTC 属于杀鸡用牛刀,训练慢、调参多,还容易在 blank 对齐上翻车。

2.1 模板匹配、多标签分类、CRNN+CTC 到底选哪个

三条路线在工程上的差别比想象中大。模板匹配靠字符切分加相似度比对,字符粘连或者有干扰线就切不准;多标签分类把「第几位是什么字符」当成若干个独立的分类任务,长度固定,但每个位置只做一次 softmax,训练极稳;CRNN + CTC 把整张图当成一个序列来读,长度自适应,代价是要处理 blank 和对齐。

方案适用场景训练成本主要短板
模板匹配字体固定、无干扰线、字符不粘连无需训练换个字体或加条线就崩
多标签分类定长验证码,4 位或 6 位单卡几十分钟长度必须固定
CRNN + CTC变长、字母数字混合、字符粘连数小时起步调参多,blank 对齐容易出问题

选多标签分类还有一个隐性好处:每个位置的输出是独立的概率分布,推理时可以直接拿到「第 3 位置信度 0.42」这种信息,方便做人工复核兜底。CTC 输出的是整条路径的概率,想定位到某一位的置信度要额外算,工程上反而麻烦。

2.2 目录结构与依赖:把训练和推理拆成两条入口

工程骨架按「配置、数据、模型、训练、推理」五块切,训练和推理共用同一份模型定义和字符集常量,这是避免线上翻车的关键。

captcha-ocr/ ├── configs/ │ └── base.yaml # 字符集、图片尺寸、batch size、学习率 ├── data/ │ ├── raw/ # 原始图片,文件名即标签,如 3841.png │ ├── train/ # 划分后的训练集 │ └── val/ # 划分后的验证集 ├── src/ │ ├── charset.py # 字符集与编解码,全项目唯一来源 │ ├── dataset.py # Dataset 与预处理 │ ├── model.py # CNN 主干 + 多标签头 │ ├── train.py # 训练循环 │ ├── infer.py # 批量推理与置信度输出 │ └── synth.py # 合成样本脚本 ├── checkpoints/ └── requirements.txt

把字符集单独抽成charset.py而不是散落在各处,是我踩过坑之后的习惯。之前有个版本训练脚本里写的是"0123456789",推理脚本里手滑写成了"1234567890",模型照样输出结果,只是每一位都错得很有规律,排查了两个小时才定位到这一行。

依赖清单不大,torch和torchvision版本对齐就行,pillow负责读写图片,numpy做数组操作,tqdm看进度条,pyyaml读配置。如果要做合成数据,再补一个opencv-python画干扰线比较方便。

python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install torch torchvision pillow numpy tqdm pyyaml opencv-python

提示:先确认torch.cuda.is_available()返回 True 再往下走,CPU 训练 4 位验证码也能跑,只是每轮要等十几分钟,调参体验很差。

2.3 一个样本走完全程:张量形状从 (H,W,3) 到 (B,4,C)

理解形状变化,比读十遍模型代码都管用。一张原始图片读进来是PIL.Image,转成数组是(64, 160, 3)的 RGB,转灰度并归一化后是(1, 64, 160),加上 batch 维度变成(B, 1, 64, 160)。经过主干网络后特征图被压到(B, 256, 4, 10),展平后是(B, 10240),过一层全连接到(B, 512),最后分类头输出(B, 4×C),reshape 成(B, 4, C),C 是字符集大小加不加 blank 的区别所在。

这里的高度 4 不是随便定的。输入高 64,经过四次步长为 2 的最大池化,正好压到 4,宽度 160 压到 10。特征图在高度方向保留 4 个位置,宽度方向保留 10 个位置,对 4 位数字来说位置信息足够,再往下压到 1×1 做全局平均池化,位置信息就丢了,模型会退化成「只认字符不认顺序」。

如果换成 6 位验证码,宽度要相应拉长到 200 到 240,让特征图在宽度方向保留足够的格子;或者减少一次池化,把特征图维持在(B, 256, 8, 25)。这个换算关系在改长度的时候必须重新算一遍,不能只改label_len参数。

3. 数据管线:合成样本、真实采样与 Dataset 的写法

模型结构再漂亮,数据不对就是白搭。字符型验证码的数据有两个来源,一是拿脚本合成,二是从自有系统的测试环境里批量保存真实图片。前者量大管饱,后者分布真实,两者混着用效果最好。

3.1 数据从哪来:合成脚本与真实抓取的比例

合成脚本干的事情很简单:随机挑 4 个数字,随机挑一个字体,画到画布上,随机加旋转、加噪点、加干扰线,最后存成3841.png这种文件名。文件名即标签,这是最省事的弱标注方式,不需要额外维护一份 CSV。

import random from PIL import Image, ImageDraw, ImageFont, ImageFilter def make_sample(charset, label_len, fonts, size=(160, 64)): text = "".join(random.choices(charset, k=label_len)) img = Image.new("RGB", size, (255, 255, 255)) draw = ImageDraw.Draw(img) x = 10 for ch in text: font = ImageFont.truetype(random.choice(fonts), random.randint(34, 44)) # 每个字符单独做小角度旋转,模拟真实渲染时的抖动 layer = Image.new("RGBA", (56, 56), (255, 255, 255, 0)) ImageDraw.Draw(layer).text((4, 4), ch, font=font, fill=(0, 0, 0)) layer = layer.rotate(random.uniform(-8, 8), resample=Image.BICUBIC) img.paste(layer, (x, 4), layer) x += 36 if random.random() < 0.5: # 干扰线只画 1 到 2 条,画多了会把字符连成一片 for _ in range(random.randint(1, 2)): draw.line([(random.randint(0, size[0]), random.randint(0, size[1])), (random.randint(0, size[0]), random.randint(0, size[1]))], fill=(120, 120, 120), width=1) return img.filter(ImageFilter.GaussianBlur(radius=random.uniform(0, 0.6))), text

这段代码里几个参数值得说清楚。label_len是验证码位数,决定了标签张量的形状;fonts是字体路径列表,字体种类越接近真实场景越好,只用一个字体训练出来的模型换个字体准确率能掉二十个点;旋转角度控制在 ±8 度以内,超过这个范围字符会碰到相邻位置的边界,标签就不成立了;模糊半径上限 0.6,再糊下去人眼都认不出。

比例上,我的经验是合成数据占七成、真实数据占三成。纯合成数据训出来的模型在真实图片上会有明显的域偏移,纯真实数据又太少不够训。混合的时候不要简单拼接,按 7:3 的比例在每个 batch 里采样,让模型每轮都能见到两种分布。

3.2 Dataset 与 DataLoader:标签编码必须和字符集绑死

Dataset 的职责只有三件:读图、转张量、编码标签。看起来很平淡,但标签编码这一块是最容易出错的地方。

import os import torch from PIL import Image from torch.utils.data import Dataset from torchvision import transforms from src.charset import CHARSET, CHAR2IDX class CaptchaDataset(Dataset): def __init__(self, root, label_len=4, img_size=(64, 160), train=True): self.root = root self.label_len = label_len self.samples = [f for f in os.listdir(root) if f.endswith(".png")] self.train = train self.tf = transforms.Compose([ transforms.Grayscale(num_output_channels=1), # 统一转单通道 transforms.Resize(img_size), # 强制到固定尺寸 transforms.ToTensor(), # 归一到 [0,1] transforms.Normalize((0.5,), (0.5,)), # 归一到 [-1,1] ]) def __len__(self): return len(self.samples) def __getitem__(self, idx): name = self.samples[idx] label = os.path.splitext(name)[0] # 文件名去掉扩展名就是标签 if len(label) != self.label_len: raise ValueError(f"标签长度不符: {name}") img = Image.open(os.path.join(self.root, name)).convert("RGB") ids = torch.tensor([CHAR2IDX[c] for c in label], dtype=torch.long) return self.tf(img), ids

__getitem__里那个raise ValueError是有意加的。数据集里混进一张 3 位或者 5 位的图,如果不报错,torch.tensor会构造出长度不一致的标签,后面DataLoader拼 batch 的时候才会炸,报错信息还指着 collate 函数,根本看不出是哪张图的问题。在源头拦住,省下的时间不止一点。

CHAR2IDX的定义方式也要注意,多标签分类不需要 blank,所以直接从 0 开始编号:

CHARSET = "0123456789" CHAR2IDX = {c: i for i, c in enumerate(CHARSET)} IDX2CHAR = {i: c for c, i in CHAR2IDX.items()}

如果后面要切到 CTC,这里必须改成i + 1,把 0 留给 blank。这个差异我在第 5 章会再提一次,因为它是最容易在换方案时忘掉的一处。

DataLoader 的配置没太多花样,batch_size开 64 到 128,num_workers开到 CPU 核数的一半,shuffle=True只在训练集开。验证集一定要shuffle=False,不然每次评估的样本顺序都变,做错误分析的时候对不上号。

3.3 预处理与增强的边界:哪些能加,哪些一加就废

图像增强在自然图像分类里是万金油,在验证码识别里却要克制。有些增强加上去,训练集准确率照样涨,测试集准确率反而掉,因为增强改变了标签的语义。

能加的有三类:小角度旋转,±8 度以内;高斯噪声,sigma 不超过 0.05;亮度对比度微调,幅度控制在 ±15%。这三类在真实渲染环境里都会出现,属于合理扰动。

不能加的有两类,一类是水平翻转和垂直翻转,数字 6 翻过来是 9,3 翻过来什么都不是,标签直接失效;另一类是随机裁剪和 RandomResizedCrop,验证码的字符是横向铺开的,裁掉一块等于把某一位切没了。

train_tf = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((64, 160)), transforms.RandomAffine(degrees=8, translate=(0.02, 0.02)), # 只允许微小平移旋转 transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)), transforms.Lambda(lambda x: x + torch.randn_like(x) * 0.02), # 轻微噪声 ])

注意:增强只作用于训练集,验证集走的是纯 Resize + Normalize,两条 pipeline 必须严格分开写。见过太多把增强写进全局 transforms、结果验证指标一路虚高的例子。

还有一个细节是归一化的均值方差。训练用(0.5, 0.5),推理时必须也是(0.5, 0.5),不能因为「推理时想省点计算」就用ToTensor()直接送进去。这个不一致带来的后果不是报错,而是准确率莫名其妙掉十几个点,属于典型的黑匣子问题。

4. 模型与训练:一个 CNN 主干加四个分类头的完整实现

到这一章才真正碰模型。整体思路是能小则小,4 位数字验证码这个任务的复杂度撑不起 ResNet50,一个四层卷积加一层全连接就足够了,训练快、推理快、部署到边缘设备也没压力。

4.1 主干网络逐层拆解与特征图尺寸推演

主干用了四个卷积块,每个块是 Conv + BatchNorm + ReLU + MaxPool 的组合,通道数按 32、64、128、256 递增。输入(B, 1, 64, 160),逐层推一下尺寸:

层操作输出形状
conv1Conv3x3(1→32) + Pool2(B, 32, 32, 80)
conv2Conv3x3(32→64) + Pool2(B, 64, 16, 40)
conv3Conv3x3(64→128) + Pool2(B, 128, 8, 20)
conv4Conv3x3(128→256) + Pool2(B, 256, 4, 10)
flatten展平(B, 10240)
fcLinear(10240→512)(B, 512)
headLinear(512→4×C)(B, 40)
import torch import torch.nn as nn class CaptchaNet(nn.Module): def __init__(self, num_classes, label_len=4): super().__init__() self.label_len = label_len self.num_classes = num_classes self.backbone = nn.Sequential( self._block(1, 32), self._block(32, 64), self._block(64, 128), self._block(128, 256), ) # 4x10 的特征图展平后是 10240 维,先降到 512 再分类 self.fc = nn.Sequential( nn.Flatten(), nn.Linear(256 * 4 * 10, 512), nn.ReLU(inplace=True), nn.Dropout(0.3), ) self.head = nn.Linear(512, num_classes * label_len) @staticmethod def _block(cin, cout): return nn.Sequential( nn.Conv2d(cin, cout, 3, padding=1, bias=False), nn.BatchNorm2d(cout), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) def forward(self, x): feat = self.backbone(x) # (B, 256, 4, 10) feat = self.fc(feat) # (B, 512) logits = self.head(feat) # (B, label_len * num_classes) return logits.view(-1, self.label_len, self.num_classes)

forward最后那个view是关键。训练时把(B, 4, C)当成 4 个独立的分类问题,推理时又能按位置取 argmax 还原成字符串,一份输出两种用法。bias=False配 BatchNorm 是标准做法,BN 的 beta 参数已经承担了偏置的作用,再留一个 bias 是冗余参数。

Dropout(0.3)放在全连接之后、分类头之前。这个位置比放在卷积块里更合适,因为卷积层的参数量本来就少,dropout 加进去收益不大,反而拖慢收敛。0.3 这个值在数据量一万张以下时比较稳,数据量上去了可以降到 0.1 或者直接关掉。

4.2 多标签输出、损失函数与标签展平

损失函数用交叉熵,但喂进去之前必须把两个张量都展平,这是最容易出维度错位的地方。

criterion = nn.CrossEntropyLoss() logits = model(imgs) # (B, 4, C) loss = criterion( logits.reshape(-1, model.num_classes), # (B*4, C) labels.reshape(-1), # (B*4,) )

reshape(-1, C)把 batch 和位置两个维度合并,reshape(-1)对标签做同样的合并,两个张量的元素顺序是一一对应的:第 i 个样本的第 j 位,对应展平后的第i*4 + j个元素。顺序错了不会报错,只会训练出一个「字符认识但位置全乱」的模型,表现是训练 loss 能降到 0.3 左右就下不去了,验证集整串准确率永远接近零。

有一次我在改 batch size 的时候顺手把labels的 shape 从(B, 4)改成了(4, B),想着「按位置组织更直观」,结果 loss 曲线看着挺正常,实际整串准确率死活上不去。后来把logits[0].argmax(-1)打出来才发现,模型学到的是「所有样本的第 0 位都是同一个字符」。这种血泪经验说明一件事:维度对齐这件事,宁可写一行 assert,也别靠脑补。

assert logits.shape[:2] == labels.shape, f"{logits.shape} vs {labels.shape}"

这行断言加在 loss 计算之前,出问题第一时间就能看到真实形状,比看 loss 曲线猜要快得多。

4.3 训练循环、学习率调度与断点续训

训练循环本身没多少新东西,值得展开的是学习率调度和 checkpoint 的存法。

import torch from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = CaptchaNet(num_classes=len(CHARSET), label_len=4).to(device) optimizer = AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=epochs, eta_min=1e-5) for epoch in range(epochs): model.train() for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) logits = model(imgs) loss = criterion(logits.reshape(-1, model.num_classes), labels.reshape(-1)) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) # 防梯度爆炸 optimizer.step() scheduler.step() acc = evaluate(model, val_loader, device) # 整串全对才算对 torch.save({ "state_dict": model.state_dict(), "charset": CHARSET, # 字符集必须一起存 "label_len": model.label_len, "img_size": (64, 160), "epoch": epoch, "val_acc": acc, }, f"checkpoints/epoch_{epoch:02d}_{acc:.4f}.pt")

AdamW配weight_decay=1e-4在小模型上是稳妥的组合,比 SGD 收敛快,也不需要仔细调动量。CosineAnnealingLR让学习率从 1e-3 平滑降到 1e-5,比阶梯下降少了两个需要调的参数。clip_grad_norm_的max_norm=5.0是保守值,主要还是防合成数据里偶尔出现的异常样本把梯度拉飞。

checkpoint 里存了charset和img_size,这不是多余的。模型权重本身只是一堆数字,脱离字符集就没法解读,脱离图片尺寸就没法重建输入。少了这两个字段,三个月后回来做微调,得靠猜才知道当时用的是哪套配置。

整串准确率的评估函数写法很简单,但一定要区分「整串准确率」和「单字符准确率」两个指标:

@torch.no_grad() def evaluate(model, loader, device): model.eval() correct, total = 0, 0 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) pred = model(imgs).argmax(-1) # (B, 4) correct += (pred == labels).all(dim=1).sum().item() total += imgs.size(0) return correct / total

单字符准确率 98% 听起来很好,但 4 位全对的概率是 0.98 的四次方,只有 92% 左右。真正的业务指标是整串准确率,训练过程中盯着这个数字才有意义。

4.4 推理与解码:置信度阈值怎么定

推理阶段除了 argmax,还要把每个位置的置信度拿出来,低于阈值的结果标记为「不确定」,交给人工或者重试逻辑处理。

@torch.no_grad() def predict(model, img_tensor, idx2char, threshold=0.9): model.eval() probs = model(img_tensor).softmax(-1) # (1, 4, C) conf, ids = probs.max(-1) # 各 (1, 4) text = "".join(idx2char[i.item()] for i in ids[0]) min_conf = conf.min().item() # 取四位里最没把握的那个 return text, min_conf, min_conf >= threshold

阈值取 0.9 是个起点,实际值要拿验证集跑一遍置信度分布再定。做法是把所有验证样本的min_conf收集起来,看正确样本和错误样本的分布有没有明显分界。如果有,阈值取在分界处;如果重叠严重,说明模型对这几位本来就分不清,得回去查数据而不是调阈值。

argmax之前先softmax是为了拿概率,如果只要字符串,直接argmax就行,省一次运算。但对工程落地来说,置信度这个信息比字符串本身更值钱,它决定了整条链路要不要做人机协同。

如果后面换成 CRNN + CTC,解码就不能用 argmax 了。CTC 的输出是(T, B, C+1),要按时间步取 argmax 之后做去重和去 blank:

def ctc_greedy_decode(logits, idx2char): # logits: (T, B, C+1),0 是 blank ids = logits.argmax(-1) # (T, B) out = [] for b in range(ids.size(1)): seq, prev = [], -1 for t in range(ids.size(0)): cur = ids[t, b].item() if cur != prev and cur != 0: # 跳过重复和 blank seq.append(idx2char[cur]) prev = cur out.append("".join(seq)) return out

去重的逻辑是「相邻相同只保留一个」,这是 CTC 的贪心解码规则,写错这一条会让输出里出现大量重复字符,而且错误模式很规整,容易误判成模型没训好。

5. 避坑与排查:五个让准确率卡住的真实原因

训练跑通只是开始,把准确率从 60% 推到 98% 的过程里,遇到的问题基本都集中在下面这几类。每一条我都按「现象、原因、解决」的顺序写,方便对照排查。

5.1 训练集准确率 99% 测试集 60%:字符集顺序对不上

现象是训练 loss 一路降到 0.05,验证集整串准确率在 60% 上下反复横跳,看起来像过拟合,但加了 dropout、加了数据也没用。

原因通常是训练和推理用了两份不同的字符集映射,或者字符集的顺序在两次运行之间变了。"0123456789"和"1234567890"在集合上完全一样,但映射关系整体错了一位,模型预测出的 index 一到解码就全变。这种错误不会报异常,只会让结果「错得很有规律」,比如所有 1 都被识别成 2。

解决办法是把字符集定义收敛到唯一来源,训练脚本、推理脚本、checkpoint 全部引用同一个常量,并且在 checkpoint 里把字符集字符串一起存下来,加载时做一致性校验:

ckpt = torch.load(path, map_location="cpu") assert ckpt["charset"] == CHARSET, f"字符集不一致: {ckpt['charset']} vs {CHARSET}"

5.2 loss 一直不降:标签没展平,维度悄悄错位

现象是 loss 从第一个 epoch 就卡在 2.3 附近不动,偶尔抖一下又回去,学习率调大调小都没反应。

原因多半是logits和labels的形状没对齐。(B, 4, C)的 logits 直接喂给CrossEntropyLoss,PyTorch 会按最后一维当类别维度处理,前面的维度自动合并,看起来能跑,但如果标签是(B, 4)而 logits 是(B, C, 4)(比如某次不小心做了转置),合并出来的对应关系就全乱了。

解决方式是在 loss 计算前加断言,并且养成先 reshape 再算 loss 的习惯:

assert logits.dim() == 3 and logits.size(1) == label_len loss = criterion(logits.reshape(-1, num_classes), labels.reshape(-1))

排查这类问题时,把logits[0].argmax(-1)和labels[0]直接打印出来是最快的,看模型到底学到了什么。

5.3 某些数字永远识别错:样本分布不均

现象是整体准确率 95%,但混淆矩阵里某几个数字错得特别多,比如 1 和 7 互认,0 和 8 互认。

原因有两层。一层是合成脚本里的字体分布不均,某些字体下 1 和 7 的笔画差异很小;另一层是真实数据里某些数字出现频率天然偏低,比如验证码生成算法里 0 和 1 被排除掉(避免和字母 O、I 混淆),导致模型没见过足够的 0 和 1。

解决方式是先统计每个字符的出现次数,把明显偏少的字符在合成阶段加权补齐,同时在每个 batch 里做分层采样,保证每个 batch 里 10 个数字至少各出现一次。混淆矩阵要定期看,不能只看总准确率。

5.4 推理结果忽好忽坏:预处理和训练不一致

现象是同一批图片,训练时评估 97%,导出模型单独推理只有 80%,而且错的样本没有明显规律。

原因是推理脚本的预处理和训练时不一致。最常见的是忘了做归一化,ToTensor()出来是[0,1],训练时又减了 0.5 除了 0.5,推理时没做这一步,等于把输入整体偏移了。其次是 Resize 的插值方式不一致,训练用默认的 bilinear,推理用 nearest,像素值分布会有细微差异。

解决办法是把预处理 pipeline 抽成一个函数,训练和推理都调用它,绝不各写一份。另外可以做一个自检:拿 20 张训练集里的图走推理流程,如果这 20 张不是全对,说明预处理肯定有问题,先别怀疑模型。

5.5 换机器就崩:checkpoint 只存了权重

现象是在训练机器上加载模型正常,拷到另一台机器上load_state_dict报 key 不匹配,或者加载成功了但输出全是乱码。

原因是 checkpoint 里只存了state_dict,没有存结构参数。num_classes、label_len、输入尺寸这些信息决定了模型的形状,只存权重的话,加载方得先猜到这些参数才能建出结构一致的模型,猜错一个维度就报 key mismatch。

解决方式就是前面训练循环里写的,把charset、label_len、img_size一起打包存进去,加载时先读这些字段再建模型:

def load_model(path): ckpt = torch.load(path, map_location="cpu") model = CaptchaNet(num_classes=len(ckpt["charset"]), label_len=ckpt["label_len"]) model.load_state_dict(ckpt["state_dict"]) model.eval() return model, ckpt["charset"], ckpt["img_size"]

这种做法多占几百字节,换来的是「任何一台机器上都能一键加载」,非常值。

6. 二次开发:改字符集、改长度、换 CRNN 的三条路径

拿到这套代码之后,绝大多数人的第一件事是改字符集,从纯数字扩到数字加小写字母,类别数从 10 变成 36。改动点看着只有charset.py一行,但分类头的维度变了,旧权重没法直接加载。我的做法是保留主干、重初始化分类头,然后拿全部数据重新训,而不是在旧模型上微调——字符集变了意味着输出空间变了,旧 head 的权重对新类别没有任何意义,硬微调只会拖慢收敛。

def load_for_finetune(ckpt_path, new_charset, label_len=4): ckpt = torch.load(ckpt_path, map_location="cpu") model = CaptchaNet(num_classes=len(new_charset), label_len=label_len) state = ckpt["state_dict"] if ckpt["charset"] == new_charset and ckpt["label_len"] == label_len: model.load_state_dict(state) else: # 字符集或长度变了,只复用主干,分类头重新初始化 backbone_state = {k: v for k, v in state.items() if not k.startswith("head")} model.load_state_dict(backbone_state, strict=False) return model

第二条路径是改长度,从 4 位改到 6 位。这条路上有三个点必须一起改:label_len参数、数据集的标签过滤、特征图的宽度。前两个是逻辑改动,第三个是形状改动。6 位字符横向占的空间更大,输入宽度从 160 拉到 240,经过四次池化后特征图是(B, 256, 4, 15),展平维度变成 15360,head前面的全连接层输入维度要跟着改。只改label_len不改宽度,模型会被迫在 10 个格子里塞 6 个字符,准确率上不去。

第三条路径是换 CRNN + CTC 做变长识别。改动集中在三处:Dataset返回变长标签、collate 函数做 padding、模型输出改成(T, B, C+1)并用nn.CTCLoss。字符集编号要从 1 开始,0 留给 blank。这条路适合验证码里混了字母、长度不固定的场景,代价是训练时间翻倍,调参也更容易玄学。

改造点涉及文件风险
扩字符集charset.py、model.py分类头维度变化,必须重训 head
改位数dataset.py、model.py、configs输入宽度和全连接维度要同步改
换 CTCcharset.py、dataset.py、model.py、train.pyblank 编号、标签 padding、解码逻辑

每次改完,我都会固定拿 200 张验证集图片跑一遍,输出整串准确率、单字符准确率和混淆矩阵三项。只看整串准确率容易掩盖单字符的退化,比如整体从 96% 涨到 97%,但某个数字的错误率其实翻了三倍,这种问题不上混淆矩阵根本看不出来。这套流程跑顺之后,改任何一处都能在十分钟内知道是变好了还是翻车了,比凭感觉调参靠谱得多。从那以后我每次动模型结构,都强制先跑一遍这 200 张的基线对比,确认没有回退再往下走。希望帮到你。

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

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

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

立即咨询