简介:面向深度学习入门与计算机视觉实战的字符型数字验证码识别资料包,适合希望掌握图像分类与序列识别完整流程的学生、算法初学者及Python开发者,用于理解验证码防御机制与模型落地之间的对应关系。压缩包共1210个文件、约9.58MB,主体为978张png和202张jpg验证码样本,辅以17个Python实现脚本、少量txt说明、bmp/jpeg图像及zip、html等辅助文件,可支撑数据读取、预处理、训练与评估等环节。已有1542人学习下载,具备一定参考热度。内容围绕卷积神经网络与循环神经网络结合的识别方案展开,涵盖样本标注、尺寸归一化、数据增强、字符one-hot编码、VGG/ResNet特征提取、LSTM/GRU序列建模、Softmax输出、交叉熵损失与Adam优化等关键知识点,并给出可保存和复用的模型代码。读者可据此复现从数据准备到预测输出的完整链路,理解过拟合监控、验证集评估与准确率优化思路,并借助源码拆解各模块职责,形成可迁移到OCR与字符识别任务的工程经验。
1. 一张糊掉的数字图,和它背后的四段流水线
很多人第一次做字符型图片的数字验证码识别,都会在预处理上先花两天:灰度、二值化、去噪、字符细化,一套组合拳打完,训练集准确率刷到 99%,换一批新图立刻掉到六成。这个反直觉的坑说明了一件事——决定上限的不是那几个图像处理算子,而是模型有没有见过足够多样的干扰。这套基于深度学习的 Python 实现走的是另一条路:原始彩色图直接进网络,把干扰交给数据增强去喂,从样本采集、字符级标注、CNN 训练一路做到批量预测和服务化封装,定长的纯数字串和不定长的数字字母混合串都覆盖。它适合两类人:想入门深度学习但不想再从 MNIST 抄一遍的开发者,以及要给内部系统做自动化回归、需要稳定产出验证码文本的工程同学。
2. 项目骨架与技术选型:六段流水线怎么切、目录怎么分
一条验证码识别链路拆开看是六段:样本采集、清洗与标注、预处理与增强、训练、评估、推理服务。这六段之间最容易出问题的地方不在模型,而在边界——采集脚本把图存成 JPEG 有损压缩,增强阶段又做一次重采样,两次插值叠在一起,字符笔画直接糊成一片,模型再怎么调都是玄学。所以这套代码的第一原则是:原始图只存 PNG,任何会损失信息的变化都留到训练时的 transform 里做,随时可回退。
2.1 为什么不直接调通用 OCR,而要走「检测 + 识别」两段式
通用 OCR 在你本地跑文档识别很准,扔到验证码上往往直接歇菜。原因有三层。第一层是语言模型先验:那类引擎在解码时会用词典和上下文做纠错,而验证码本身就是随机串,词典反而成了负资产,0和O、1和l会被硬掰成"更像词"的组合。第二层是版式假设:它们默认文本行是水平、等距、有基线对齐的,而验证码的字符常常被旋转 ±10 度、上下错位、被干扰线切断。第三层是尺度:验证码通常只有几十像素宽,检测网络在这么小的图上给不出稳定框。
所以工程上有两种切法。一种是"检测 + 单字符分类":先用投影法或轻量检测模型把整图切成 N 个字符块,每块送进一个 36 类的小分类器。这种做法的好处是可解释,坏处是对粘连极度敏感——一旦两个字符被干扰线连在一起,切分位置就会错,后面全崩。另一种是端到端的"整图多标签分类":网络直接输出 N 个位置的分类结果,不关心字符在哪,只关心第几位是什么。这套实现的主线用的是第二种,字符粘连、轻微重叠都不影响,代价是必须提前知道验证码的位数;位数不定时,走第 4 章讲的 CTC 分支。
选哪条不是拍脑袋,看你的数据形态。如果目标站点固定输出 4 位数字,多标签头又简单又准,一个下午就能跑通;如果是 4 到 6 位混合、长度随机,那检测切分那条路会把你拖进无底洞,直接用 CTC 更省命。我一般会先抓 200 张图肉眼过一遍,看字符有没有粘连、位数是否固定,再决定架构,而不是先写代码。
2.2 技术栈与目录结构
技术栈基本是 PyTorch 系的最小组合,不引入额外框架,方便二次开发时直接改。
| 层次 | 选型 | 说明 |
|---|---|---|
| 深度学习框架 | PyTorch 2.x | torch.compile可选,CPU 上也能跑推理 |
| 图像处理 | Pillow + torchvision | 只做 Resize、颜色抖动、仿射,不引入 OpenCV 依赖 |
| 数据加载 | 原生Dataset+DataLoader | 文件名即标签,不需要额外的标注文件 |
| 训练加速 | 单卡 CUDA / Apple MPS / CPU | 代码里用torch.cuda.is_available()自动降级 |
| 服务化 | Flask | 单文件app.py,够用就行,量大了再换 |
目录按"配置、数据、源码、服务"四块切开,配置文件单独放,这样换字符集、改图像尺寸不用翻代码:
captcha_digit/ ├── configs/ │ └── default.yaml # 字符集、img_size、max_len、超参全部集中在这里 ├── data/ │ ├── raw/ # 原始抓取图,只存 PNG,永久不覆盖 │ ├── train/ # 训练集,文件名即标签,如 3a7f.png │ └── val/ # 验证集,必须来自不同批次/不同时间的抓取 ├── src/ │ ├── charset.py # 字符表 + encode/decode,全项目唯一真源 │ ├── dataset.py # Dataset 与 train/val 两套 transform │ ├── model.py # MultiHeadNet 与 CTCModel 两条分支 │ ├── train.py # 训练循环 + 整串级评估 │ ├── predict.py # 单图/批量推理 │ └── export_onnx.py # 导出,供纯 CPU 环境部署 ├── service/ │ └── app.py # HTTP 推理接口 └── requirements.txt搭环境这一步别偷懒,把 CUDA 版本和 torch wheel 对齐,否则后面报CUDA error: no kernel image is available会查到你怀疑人生:
# 建议在独立虚拟环境里做,避免和系统里的 torch 打架 python -m venv .venv && source .venv/bin/activate # 只写下限约束,不锁小版本;有 GPU 的先按官方命令装对应 CUDA 的 torch pip install "torch>=2.0" "torchvision>=0.15" pillow flask pyyaml onnx # 跑一遍 smoke test,确认能建模型、能前向 python -c "from src.model import MultiHeadNet; import torch; \ print(MultiHeadNet()(torch.randn(2,3,32,128))[0].shape)"最后一行是这套流程里我最推荐的一步:模型还没训,先确认输入输出形状对得上。torch.randn(2,3,32,128)是「批大小 2、3 通道、高 32、宽 128」,输出应该是[2, 36],也就是 2 张图、每张 36 类的 logits。形状不对就别往下走了,训练十有八九是白跑。
2.3 三个入口脚本的职责边界
train.py、predict.py、export_onnx.py三个入口不许互相 import 业务逻辑,公共部分全放src/下。这条规矩不是洁癖——我见过太多项目把推理逻辑抄进训练脚本的评估函数里,后来改了预处理顺序只改一处,训练评估 95%、线上 60%,查了两天才发现两边 Resize 的插值方式不一样,这就是典型的黑匣子式翻车。
train.py只负责「读数据、迭代、存 checkpoint」,predict.py只负责「加载 checkpoint、单图前向、解码」,export_onnx.py只负责「把同一个模型导成静态图」。三者共享的只有charset.py、dataset.py里的val_tf和model.py,预处理顺序天然一致。
checkpoint 的命名也要固定下来,带上 epoch 和验证集整串准确率,例如ckpt_e12_acc0.9412.pt,别用best.pt。血泪经验是:best.pt被覆盖之后,你永远说不清线上那个模型到底是第几轮的,出了问题连重训都复现不了。
3. 数据工程实操:标签编码、预处理取舍与增强的边界
这一章是整条链路里最脏也最值钱的部分。同样的网络结构,数据做对了能到 95%,做错了卡在 40% 不动。所谓「做对」,核心就三件事:字符表唯一、标签编码可逆、增强幅度匹配真实干扰分布。
3.1 字符表与标签编码:全项目唯一真源
字符表必须只有一个定义,训练的类别数、推理的解码、导出的输出维度全靠它算出来。散落在三个文件里的字符表,是后期最典型的对不上的 bug 来源。
# src/charset.py # 字符表:出现在验证码里的所有字符,顺序一旦定下就不要再改 # 改了顺序 = 所有权重作废,必须从头训 CHARSET = "0123456789abcdefghijklmnopqrstuvwxyz" NUM_CLASSES = len(CHARSET) # 36,分类头输出维度 char2idx = {c: i for i, c in enumerate(CHARSET)} idx2char = {i: c for c, i in char2idx.items()} def encode(label: str, max_len: int = 4): """把 'a3f9' 编成 [10, 3, 15, 9] 长度不足时用 -100 补齐,CrossEntropyLoss 默认忽略 -100 这个标签 """ ids = [char2idx[c] for c in label.lower()][:max_len] return ids + [-100] * (max_len - len(ids)) def decode(indices): """把模型输出的索引数组还原成字符串,-100 和未知索引直接跳过""" return "".join(idx2char[i] for i in indices if i in idx2char)encode里的-100是 PyTorch 交叉熵的硬约定,不是随便挑的数:ignore_index默认值就是 -100,凡是这个标签的位置不参与 loss 计算。这意味着你可以用同一套代码处理「位数不足需要补齐」的场景,只要在DataLoader里 collate 时保持-100不变即可。decode里的if i in idx2char是给推理阶段兜底的——模型不太可能输出越界索引,但万一你在导出时改了输出维度,这一句能让它安静地跳过而不是抛异常。
数据集的组织约定是「文件名即标签」,3a7f.png对应的标签就是3a7f,不需要额外的 CSV。这个约定在样本量几千以内非常好用,省掉一个标注文件和一套解析逻辑;样本上万之后建议换成按目录分片,因为单目录下几十万个文件在部分文件系统上listdir会很慢。
划分验证集的时候有个细节要盯住:验证集必须来自「另一批」抓取,不能从同一个批次的样本里随机切 10%。同一个批次里的图往往来自同一次会话,字体、干扰样式高度相似,随机切出来的验证集准确率会虚高十到二十个点,这就是后面第 5 章要专门讲的那个坑。
3.2 预处理:为什么我最后放弃了二值化
二值化在传统方案里是标配,在这套方案里我建议直接砍掉。原因是二值化把「颜色」这个信息维度整个丢掉了,而验证码的干扰往往正是靠颜色区分的——比如字符是深蓝色、干扰线是浅灰色,二值化之后两者都变成黑,模型再也没法把它们分开。
更稳的做法是保留三通道 RGB,只做 Resize 和归一化,让网络自己去学颜色通道的差异。代价是模型要多学一点东西,但样本量上千之后完全能学出来,而且泛化性明显更好。真正需要二值化的只有一种情况:目标站点的图本身就是单色位图,没有任何颜色信息,那保留三通道等于重复三份,纯浪费算力。
# src/dataset.py(节选) from PIL import Image import torch from torch.utils.data import Dataset from torchvision import transforms from src.charset import encode IMG_SIZE = (128, 32) # 宽 128、高 32;宽高比 4:1,贴近常见验证码形态 val_tf = transforms.Compose([ # 只做 Resize + 归一化,不做二值化、不做锐化 transforms.Resize(IMG_SIZE), transforms.ToTensor(), # 0~255 -> 0~1,且变成 CxHxW transforms.Normalize(mean=[0.5] * 3, std=[0.5] * 3), # -> -1~1 ]) class CaptchaDataset(Dataset): def __init__(self, root, max_len=4, tf=None): self.root = root self.max_len = max_len self.tf = tf or val_tf # 约定:文件名去掉扩展名就是标签,例如 3a7f.png -> "3a7f" self.items = [f for f in os.listdir(root) if f.lower().endswith(".png")] def __len__(self): return len(self.items) def __getitem__(self, idx): name = self.items[idx] label = name.rsplit(".", 1)[0].lower() # 统一转 RGB:抓来的图可能混着灰度、RGBA、调色板模式,不转会在 collate 时炸 img = Image.open(f"{self.root}/{name}").convert("RGB") x = self.tf(img) y = torch.tensor(encode(label, self.max_len), dtype=torch.long) return x, yIMG_SIZE是宽在前、高在后,这一点和 PyTorch 的(H, W)相反,torchvision 的Resize接收的是(W, H),写成(32, 128)就会把图压成竖条,训练 loss 一直卡在 3.5 附近不动。归一化用0.5/0.5而不是 ImageNet 的均值方差,是因为这是从零训练而不是加载预训练权重,用-1~1的对称分布能让训练初期的梯度更稳一点。
3.3 增强的边界:增强过头比不增强更糟
增强的目的是让训练分布覆盖推理分布,不是把图变得更花。这条线一旦越过,模型学到的是「如何从一堆噪声里猜字符」,而不是「如何认字符」。
# src/dataset.py(续) train_tf = transforms.Compose([ # 轻微仿射:模拟同一站点不同采样偏移,角度控制在 5 度以内 transforms.RandomAffine(degrees=5, translate=(0.06, 0.06), shear=3), # 颜色抖动:对抗不同背景色、不同干扰线颜色 transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2), transforms.Resize(IMG_SIZE), transforms.ToTensor(), transforms.Normalize(mean=[0.5] * 3, std=[0.5] * 3), ])参数怎么定,用一句经验话概括:增强幅度要小于真实干扰的方差。真实样本里字符最多歪 5 度,你就别开 15 度;真实背景最多浅灰到浅黄,就别把饱和度调到 1.0。判断方法很土但有效——把train_tf处理后的图存 20 张出来肉眼过一遍,如果人已经认不出字符了,模型也认不出,这不是在增强,是在投毒。
另外两个别踩的点:不要加高斯模糊(验证码本来分辨率就低,模糊一次字符就没了),不要加随机遮挡(字符只有四五个,遮掉一笔整个标签就废了)。真要做抗遮挡,正确姿势是在采集阶段拿到带遮挡的真实样本,而不是靠增强合成。
4. 模型与训练:共享主干加多标签头,再备一条 CTC 路线
网络结构没什么花活,一个四层卷积主干,两种头。选这个结构不是为了刷分,是为了让它在 CPU 上也能跑到几十毫秒一张——很多验证码识别的实际场景是内部回归测试,跑在 CI 机器上,根本用不上 GPU。
4.1 定长验证码:共享主干 + N 个字符分类头
把整图压成一个特征向量,接四个独立的线性层,每个层负责一个位置的 36 分类。四个头不共享参数,目的是避免位置混淆——如果共享一个头,模型很容易把第 1 位的特征和第 3 位的特征混起来。
# src/model.py import torch import torch.nn as nn class Backbone(nn.Module): """四层卷积主干,每层 stride=2 把特征图缩小一半 输入 3x32x128 -> 输出 256x2x8,宽度方向剩 8 个时间步 """ def __init__(self): super().__init__() def block(cin, cout): return nn.Sequential( nn.Conv2d(cin, cout, 3, padding=1), nn.BatchNorm2d(cout), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), ) self.features = nn.Sequential( block(3, 32), # 32 x 16 x 64 block(32, 64), # 64 x 8 x 32 block(64, 128), # 128 x 4 x 16 block(128, 256), # 256 x 2 x 8 ) def forward(self, x): return self.features(x) class MultiHeadNet(nn.Module): """定长验证码:共享主干 + max_len 个独立的字符分类头""" def __init__(self, num_classes=36, max_len=4): super().__init__() self.backbone = Backbone() self.pool = nn.AdaptiveAvgPool2d(1) # 任意输入尺寸都能压成 1x1 self.drop = nn.Dropout(0.2) self.heads = nn.ModuleList( [nn.Linear(256, num_classes) for _ in range(max_len)] ) def forward(self, x): feat = self.pool(self.backbone(x)).flatten(1) # B x 256 feat = self.drop(feat) # 返回 list,第 i 个元素是第 i 个字符的 logits return [head(feat) for head in self.heads]AdaptiveAvgPool2d(1)是关键的一行,它让模型对输入尺寸不敏感,后面换IMG_SIZE不用改网络。Dropout(0.2)加在全连接之前,因为这一层参数量占了全模型的一大半,不加很容易在几千张样本上过拟合。num_classes千万别写死成 36,从charset.py的NUM_CLASSES取,否则你加一个大写字母表就全线崩。
4.2 不定长字符:CNN + CTC 的备选分支
位数不固定的时候,多标签头的max_len就没法定了。这时候走 CTC:把主干输出的宽度方向当作时间步,每个时间步预测一个字符(外加一个 blank),解码时合并重复、去掉 blank。
class CTCModel(nn.Module): """不定长验证码:主干按宽度展平成时间序列,接 CTC 解码""" def __init__(self, num_classes=37): # 36 个字符 + 1 个 blank super().__init__() self.backbone = Backbone() self.rnn = nn.LSTM(256, 128, num_layers=2, bidirectional=True, batch_first=True) self.fc = nn.Linear(256, num_classes) # 双向 -> 256 def forward(self, x): f = self.backbone(x) # B x 256 x 2 x 8 # 高度方向压掉,宽度当时间维度:B x T x C,这里 T = 8 f = f.mean(dim=2).permute(0, 2, 1) out, _ = self.rnn(f) return self.fc(out) # B x T x num_classes时间步 T 是这条路线里最容易翻车的参数。输入 128 宽、四次池化之后 T=8,而 CTC 解码要求T >= 标签长度 + 相邻重复字符数。四位验证码勉强够,一旦出现11ab这种相邻重复,T=8 就不够用了——CTC 需要在这两个1之间插入一个 blank 才能区分,一个 blank 就吃掉一个时间步。稳妥的做法是把 T 做到2L+1以上,也就是四位验证码至少 T=9,把输入宽度加到 160 或者去掉最后一次池化。这一点第 5 章还会展开。
4.3 训练循环与整串级评估
训练循环没什么特别的,重点在评估口径:必须按「整串全对」算准确率,而不是逐字符平均。逐字符准确率 97% 听起来不错,但四位串全对的概率是 0.97⁴ ≈ 88.5%,差了将近十个百分点,这个数字骗自己没意义。
# src/train.py import torch import torch.nn as nn from torch.utils.data import DataLoader from src.dataset import CaptchaDataset, train_tf, val_tf from src.model import MultiHeadNet MAX_LEN = 4 def train(): device = "cuda" if torch.cuda.is_available() else "cpu" train_ds = CaptchaDataset("data/train", MAX_LEN, train_tf) val_ds = CaptchaDataset("data/val", MAX_LEN, val_tf) train_dl = DataLoader(train_ds, batch_size=128, shuffle=True, num_workers=4, drop_last=True) val_dl = DataLoader(val_ds, batch_size=256, shuffle=False, num_workers=4) model = MultiHeadNet(num_classes=36, max_len=MAX_LEN).to(device) # 四个位置各算一次交叉熵再平均;-100 的补齐位置自动忽略 criterion = nn.CrossEntropyLoss(ignore_index=-100) opt = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=1e-4) sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=30) for epoch in range(30): model.train() for x, y in train_dl: x, y = x.to(device), y.to(device) logits = model(x) loss = sum(criterion(lg, y[:, i]) for i, lg in enumerate(logits)) / len(logits) opt.zero_grad() loss.backward() opt.step() sched.step() acc = evaluate(model, val_dl, device) print(f"epoch={epoch} loss={loss.item():.4f} val_acc={acc:.4f}") torch.save(model.state_dict(), f"ckpt_e{epoch}_acc{acc:.4f}.pt") @torch.no_grad() def evaluate(model, dl, device): """整串全对才算对,比逐字符准确率更贴近线上口径""" model.eval() right = total = 0 for x, y in dl: x, y = x.to(device), y.to(device) preds = torch.stack([lg.argmax(1) for lg in model(x)], dim=1) right += (preds == y).all(dim=1).sum().item() total += x.size(0) return right / max(total, 1)学习率3e-3配AdamW是从零训练小 CNN 的常用组合,比1e-3收敛快,又不至于发散。CosineAnnealingLR的T_max=30要和总 epoch 数一致,否则余弦曲线会在训练中途走完,后半程学习率一直贴着 0,看起来像「训练不动了」。drop_last=True是为了避免最后一个不满批次的 BN 统计量抖动,样本量本来就不大的时候这个抖动很要命。
loss 的算法是四个位置交叉熵的平均,不是求和。求和会让 loss 数值变成四倍,你按 0.1 的阈值判断收敛就会误判。如果你用 CTC,对应换成nn.CTCLoss(blank=36, zero_infinity=True),输入形状是T x B x C,记得先log_softmax再传进去,这俩顺序搞反 loss 会一直是inf。
5. 推理与排查:单图预测、HTTP 封装和四类高频翻车现场
推理侧代码很短,坑却不少。核心原则只有一条:推理的预处理必须和验证集用的是同一个函数对象,不是「长得一样的另一份代码」。
5.1 单图与批量推理
# src/predict.py import torch from PIL import Image from src.dataset import val_tf from src.model import MultiHeadNet from src.charset import decode @torch.no_grad() def predict(model, img_path, device="cpu"): """单图推理:返回识别出的字符串,失败返回空串""" img = Image.open(img_path).convert("RGB") x = val_tf(img).unsqueeze(0).to(device) # 加 batch 维 -> 1x3x32x128 preds = [lg.argmax(1).item() for lg in model(x)] return decode(preds)unsqueeze(0)是最常漏的一步,模型永远接收带 batch 维的输入,忘了加会报Expected 4D input。@torch.no_grad()省的是显存和一点时间,在批量跑几万张的时候差别明显。批量推理时把unsqueeze换成torch.stack拼批,别写 for 循环一张张跑,GPU 利用率会低到想哭。
5.2 封装成 HTTP 服务
# service/app.py import io import torch from flask import Flask, request, jsonify from PIL import Image from src.dataset import val_tf from src.model import MultiHeadNet from src.charset import decode app = Flask(__name__) model = MultiHeadNet().eval() model.load_state_dict(torch.load("ckpt_e12_acc0.9412.pt", map_location="cpu")) @app.post("/ocr") def ocr(): f = request.files.get("file") if f is None: return jsonify(code=400, msg="missing file"), 400 img = Image.open(io.BytesIO(f.read())).convert("RGB") x = val_tf(img).unsqueeze(0) with torch.no_grad(): text = decode([lg.argmax(1).item() for lg in model(x)]) return jsonify(code=0, text=text)model.eval()一定要在load_state_dict之前调,否则 Dropout 和 BN 还是训练态,同一张图两次请求可能返回两个结果。Flask 自带服务器是单线程的,并发上来会排队,量大的话套 gunicorn 起多 worker,但注意每个 worker 都会加载一份模型,内存按 worker 数翻倍。
5.3 排查一:loss 从头到尾不降,卡在 3.58 附近
现象是 loss 停在ln(36) ≈ 3.58一动不动,准确率贴着随机猜的水平。
原因基本是两个:标签和输出对不上,或者梯度根本没流。前者最常见的是encode出来的索引和CHARSET的顺序不一致——比如数据是用0-9a-z排的,模型输出按0-9A-Z解码;后者常见于charset.py里的char2idx被重新定义过一次,训练时用的是新版,解码时用的是旧版。
解决方法是训练前先跑一次「过拟合 10 张图」的 sanity check:取 10 张训练图,关掉增强,训 200 步,如果 loss 降不到 0.01 以下,说明链路本身有问题,别急着调超参。这一步能省掉大量无效训练时间。
5.4 排查二:验证集 94%,线上 58%
这是最经典的坑,没有之一。现象是本地验证集漂亮,换一批真实图直接崩盘。
原因多半是验证集泄漏:验证集是从同一批抓取里随机切的,字体、颜色、干扰样式和训练集高度重合,模型学到的是「这批图的特征」而不是「字符的形状」。第二个原因是增强幅度盖过了真实分布,训练时的干扰强度是线上干扰的两倍,模型习惯了重噪声,遇到干净图反而愣住。
解决方法是按抓取批次划分验证集,同一个批次只能出现在训练或验证的一侧。再加一个「跨批次验证集」,专门放不同时间、不同来源的样本,这个数字才是你真正能对外报的准确率。我一般会把跨批次准确率写在 checkpoint 文件名里,一眼能看出哪个模型是真的能上线。
5.5 排查三:CTC 训练 loss 一路降,推理全是空串
现象是 CTC loss 收敛得挺好,但解码出来全是空字符串,或者只有一两个字符。
原因是时间步不够。CTC 解码要求T >= L + 相邻重复字符数,四位验证码配 T=8,遇到1122这类标签就直接解码失败。模型在这种情况下会倾向于全输出 blank,因为这是 loss 最低的「躺平」解。
解决方法是把 T 做到2L+1以上,四位至少 9,稳妥用 12。做法是把输入宽度从 128 加到 160,或者把主干的最后一次池化去掉。改完之后 loss 会先上升一下再降,这是正常的,因为可用的对齐路径变多了。
5.6 排查四:训练正常,推理时报 CUDA out of memory
现象是训练阶段 batch 128 跑得好好的,predict.py一跑就 OOM。
原因是@torch.no_grad()漏了,或者批量推理时一次把几万张图堆进内存。前者会让 PyTorch 保存计算图,一张图就吃掉几百 MB;后者是你torch.stack的时候没分批,把整个数据集拼成了一个大 tensor。
解决办法是单图推理加@torch.no_grad(),批量推理按 256 一批切,用DataLoader而不是手写循环。还有一个隐蔽的点:PIL 打开大图后如果不close(),几万张跑下来文件句柄会耗尽,报Too many open files,用with Image.open(...) as img:包一下。
6. 二次开发:换字符集、抗干扰改造与 ONNX 导出的验证方法
拿到这套代码之后,最常见的三种改造是换字符集、加干扰、换部署环境。三种改造的验证顺序不一样,搞反了会白折腾。
换字符集(比如加上大写字母)要动的地方只有一个:charset.py里的CHARSET字符串和NUM_CLASSES。但注意,改了之后旧的 checkpoint 全部作废,因为输出层的维度变了,load_state_dict会报 shape mismatch。同步要改的还有model.py里MultiHeadNet和CTCModel的num_classes默认值,最省事的做法是让它们从charset.pyimport,别写死。
加干扰改造属于「数据端解决」,不是「模型端解决」。很多人第一反应是加深网络、加注意力模块,实际上把真实干扰样本补进训练集,效果比换模型大得多。我的做法是先抓 500 张失败样本,按干扰类型分三类(干扰线、背景噪点、字符扭曲),每类补 200 张进训练集,重训一轮看跨批次准确率涨了多少。如果涨了 5 个点以上,说明是数据问题;如果没涨,再考虑改结构。
导出 ONNX 是为了在没有 PyTorch 的环境里跑推理,导出之后必须做一致性验证,不能导完就用:
# src/export_onnx.py import torch import onnxruntime as ort from src.model import MultiHeadNet model = MultiHeadNet(num_classes=36, max_len=4).eval() model.load_state_dict(torch.load("ckpt_e12_acc0.9412.pt", map_location="cpu")) dummy = torch.randn(1, 3, 32, 128) torch.onnx.export( model, dummy, "captcha.onnx", input_names=["input"], output_names=["p0", "p1", "p2", "p3"], # 多输出模型必须显式声明 dynamic_axes,否则 batch 维被写死成 1 dynamic_axes={"input": {0: "batch"}, **{f"p{i}": {0: "batch"} for i in range(4)}}, opset_version=12, ) # 一致性校验:PyTorch 和 ONNX Runtime 的输出必须逐元素接近 with torch.no_grad(): ref = [t.numpy() for t in model(dummy)] sess = ort.InferenceSession("captcha.onnx") got = sess.run(None, {"input": dummy.numpy()}) for i, (a, b) in enumerate(zip(ref, got)): diff = abs(a - b).max() print(f"head{i} max_diff={diff:.6f}") # 正常应小于 1e-4opset_version=12是兼容性比较好的一个档位,太低不支持某些算子,太高部分推理引擎还没跟上。dynamic_axes那段是给批量推理留的口子,不写的话导出的图只能一次喂一张。最后的一致性校验别省,模型里只要有一个算子导出时被近似实现,diff 就会飙到 0.1 以上,而你肉眼看输出字符串可能还是一样的,等到线上出问题才发现。
从那以后我每次改完字符集或者增强策略,都会强制走一遍「过拟合 10 张图 → 跨批次验证 → ONNX 一致性」这三步,一步不落。这套流程看着笨,但它把「模型到底有没有学到东西」这件事从玄学变成了可观测的数字。希望帮到你。
本文还有配套的精品资源,点击获取