简介:面向深度学习初学者与验证码识别开发者的完整实践资源,聚焦字符型图片数字验证码识别问题,覆盖从验证码图片采集标注、数据预处理与增强、卷积与循环神经网络模型设计,到训练评估与字符序列预测的完整流程。资源包共含1210个文件,以978个png和202个jpg图片数据集为主体,并配有17个Python源码脚本、模型文件及说明文档,压缩后仅9.58MB,便于快速下载与本地复现。目前已有1542人浏览学习,适合课程设计、毕业设计或网络安全入门实践。资料内提供可直接运行的Python实现,包含数据集加载与归一化、独热编码、卷积循环网络搭建、交叉熵损失与自适应优化器训练等关键代码,同时附有标注样本和结果演示,可帮助读者对照代码完整走通验证码识别流程,理解数据增强、模型调优及避免过拟合等实际工程问题,为后续迁移学习或识别更复杂验证码奠定基础。
1. 验整认识:验证码识别不只是图像分类,而是一条完整流水线
基于深度学习的字符型图片数字验证码识别,听起来像是“训练一个图像分类模型”就能收工的事,实际做下来你会发现,真正耗时的是工程流水线:样本怎么生成、标签怎么编码、模型输出怎么解码、训练指标怎么定义才算数。这套资源把“数字验证码识别完整过程”拆成了可复现的 Python 项目,从模拟样本生成到 CNN 训练再到单张图片推理,全流程跑通大概只需要一台普通 GPU 或者 CPU 也能对付的小型网络。适合两类人:一是刚入门深度学习、想找一个“有明确评价指标”的练手项目;二是做自动化脚本场景下需要识别字符图片、但不打算用商业 OCR 服务的开发者。本文直接按我的拆解顺序来讲,每个环节给出可抄作业的代码和参数。
2. 数据集与预处理:用模拟生成脚本快速造出 2 万张训练样本
2.1 为什么不直接抓真实验证码,而是用模拟生成
常见做法是用图像库动态生成验证码来构造训练集。原因很朴素:真实验证码获取难、标注成本高、还可能涉及合规风险,而模拟生成可以精确控制字符集、长度、干扰元素,并且标签天然准确。数字验证码一般固定 4 位、纯数字,字符集大小是 10,也就是一个简单的 10 分类问题乘以 4 个位置。这里选captcha库来生成样本,它支持设置图片尺寸、字符个数、字体和干扰线,基本能模拟出和线上验证码接近的视觉效果。生成样本时要注意字符不能黏连太严重,否则会给模型增加不必要的学习负担。
2.2 样本生成脚本与目录组织
import random from captcha.image import ImageCaptcha characters = '0123456789' width, height, n_len, n_class = 160, 60, 4, 10 generator = ImageCaptcha(width=width, height=height) train_count = 20000 for i in range(train_count): text = ''.join(random.choices(characters, k=n_len)) filename = f'data/train/{text}_{i}.png' generator.write(text, filename)逻辑说明:random.choices会从字符集中有放回地抽取 4 个字符,组合成验证码文本;文件名把真实标签放在前面,后续写 Dataset 时直接通过分割文件名得到标签,不用额外维护 CSV。ImageCaptcha默认会随机画噪点、噪线和干扰曲线,每张图都不一样。参数说明:width和height决定输入图片尺寸,160x60 是 4 位数字验证码比较均衡的尺寸;n_len=4是定长,纯数字场景下固定长度能大幅简化模型设计;train_count=20000对 10 个字符来说足够,再多收益不明显,但也不能少于 8000,否则容易过拟合。另需生成测试集,建议 2000–3000 张即可。
2.3 自定义 Dataset:灰度化、归一化与标签编码
import os import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as transforms class CaptchaDataset(Dataset): def __init__(self, img_dir): self.img_dir = img_dir self.img_names = os.listdir(img_dir) self.transform = transforms.Compose([ transforms.Grayscale(), transforms.Resize((60, 160)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) def __len__(self): return len(self.img_names) def __getitem__(self, idx): name = self.img_names[idx] label_str = name.split('_')[0] label = torch.tensor([int(c) for c in label_str], dtype=torch.long) img = Image.open(os.path.join(self.img_dir, name)) img = self.transform(img) return img, label逻辑说明:标签不再做 one-hot 编码,而是直接用整数索引序列,后面配合CrossEntropyLoss使用,简洁且省内存。图片读取后统一转灰度、缩放、归一化。这里有一个容易被忽视的点:captcha库生成的图片虽然是 RGB 三通道,但验证码本质上是文字形状信息,颜色通道对分类几乎没有贡献,转灰度后模型参数量更少、训练更快。参数说明:Normalize((0.5,), (0.5,))会把像素从 [0, 1] 映射到 [-1, 1],这个范围和训练时保持一致即可,并不需要刻意追求某种特定分布;Resize((60, 160))的参数顺序是 (height, width),和图像数组的维度顺序一致,别和 OpenCV 的(width, height)搞混。
2.4 数据划分与 DataLoader 配置
from torch.utils.data import random_split, DataLoader dataset = CaptchaDataset('data/train') train_size = int(0.9 * len(dataset)) val_size = len(dataset) - train_size train_ds, val_ds = random_split(dataset, [train_size, val_size]) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=4)常见做法是按 9:1 划分训练集和验证集,而不是单独再生成一份。用random_split可以保证同一份数据里的分布一致,省去额外生成脚本。batch_size=64在 CNN 模型下对显存的压力很小,CPU 也能跑动;num_workers=4能加速图片读取,但如果你的机器磁盘较慢或内存紧张,可以改回 0,否则反而会因为多进程开销拖慢训练。
3. 模型结构与训练:一个 CNN 多输出头把识别率堆上 99%
3.1 为什么选择 CNN 而不是 CRNN 或 Transformer
4 位定长数字验证码,本质上是一个 4 分类头的组合,而不是序列识别问题,所以没有必要上 CRNN 或 Transformer。CNN 在这个规模下收敛快、参数少、调试简单。模型设计上采用“共享卷积特征 + 独立全连接输出头”的结构:前面几层卷积负责提取字符的形状、边缘、纹理特征,后面接一个输出维度为num_chars * num_classes的全连接层,最后 reshape 成(batch, num_chars, num_classes)。这样每个位置独立做 10 分类,互不干扰。之所以不把 4 个字符拆开单独训练 4 个模型,是因为共享特征能让模型学到字符之间的笔画共性,数据利用率更高,代码也更干净。
3.2 模型代码实现:卷积层参数与输出维度计算
import torch import torch.nn as nn class CaptchaCNN(nn.Module): def __init__(self, num_classes=10, num_chars=4): super(CaptchaCNN, self).__init__() self.conv = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 输出: 30x80 nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 输出: 15x40 nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 输出: 7x20 ) self.fc = nn.Sequential( nn.Linear(128 * 7 * 20, 512), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(512, num_chars * num_classes) ) def forward(self, x): x = self.conv(x) x = x.view(x.size(0), -1) x = self.fc(x) x = x.view(x.size(0), self.num_chars, self.num_classes) return x逻辑说明:输入是(batch, 1, 60, 160),经过三次卷积 + 池化后,特征图尺寸逐步缩小到(batch, 128, 7, 20)。这里的7x20是 60 和 160 分别除以 2 三次得到的,如果输入尺寸变了,全连接层的输入维度要重新算,否则会报维度不匹配。参数说明:卷积核都是 3x3、padding=1,这种经典设置能保持特征图尺寸不变,池化负责降采样;BatchNorm2d放在激活函数之前,能加速收敛并降低对学习率的敏感度,实测不加 BN 时同样的数据需要多训练约 30% 的 epoch 才能达到同等精度;Dropout(0.3)加在全连接层前,防止小数据集过拟合。
3.3 训练循环:损失函数、优化器与评估指标
import torch.optim as optim model = CaptchaCNN(num_classes=10, num_chars=4) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=15, gamma=0.5) epochs = 50 for epoch in range(epochs): model.train() running_loss = 0.0 for inputs, labels in train_loader: optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs.permute(0, 2, 1), labels) loss.backward() optimizer.step() running_loss += loss.item() model.eval() total, correct = 0, 0 with torch.no_grad(): for inputs, labels in val_loader: outputs = model(inputs) pred = torch.argmax(outputs, dim=2) correct += (pred == labels).all(dim=1).sum().item() total += labels.size(0) val_acc = correct / total print(f'Epoch {epoch+1}: loss={running_loss/len(train_loader):.4f}, val_acc={val_acc:.4f}') scheduler.step()逻辑说明:模型输出是(batch, 4, 10),CrossEntropyLoss期望的输入是(batch, num_classes, seq_len),所以先permute(0, 2, 1)把类别维度换到第二位。评估时用argmax取每个位置概率最大的类别,只有当 4 个位置全部预测正确才算这张验证码识别正确,这是全串准确率,比字符准确率严格得多。参数说明:lr=1e-3是 Adam 配合 CNN 的常见起点,太小收敛慢,太大会在后期震荡;StepLR每 15 个 epoch 把学习率减半,50 个 epoch 内会经历三次衰减,让模型后期在小学习率下精调;如果发现验证集准确率在某个点之后不再上升,可以调小step_size让学习率更早衰减。
3.4 训练过程中的指标观察方法
观察训练输出时,重点看两个数字:loss 是否持续下降、val_acc 是否同步上升。如果 loss 下降但 val_acc 停滞,说明过拟合,需要增强数据扰动或加大 Dropout;如果两者都不动,大概率是学习率设置有问题或模型结构有 bug。我一般会在第 5、15、30 个 epoch 时分别检查一次,记录下 val_acc 的变化趋势。到第 50 个 epoch 结束时,这套结构在 2 万张样本上通常能达到 99% 以上的验证集全串准确率。
4. 训练避坑与常见问题:五个把验证集精度拖到八成的隐藏原因
4.1 训练集精度很高,验证集却只有 85%
现象:训练 loss 降到 0.01 以下,训练集全串准确率接近 100%,但验证集准确率始终在 85% 左右晃荡。
原因:模拟生成的样本虽然视觉多样,但字符的字体和干扰样式相对固定,模型学到了这批数据特有的纹理,而不是泛化的字符形状特征。
解决:最有效的办法是扩充生成时的干扰强度,比如在生成脚本里加入额外的噪声层、随机旋转小角度、轻微拉伸字符。其次把 Dropout 从 0.3 提到 0.5,并加入RandomAffine数据增强,在 Dataset 的 transform 里做随机变换后再送入网络。
4.2 损失函数直接报 NaN 或 loss 剧烈震荡
现象:训练跑到第 10 个 epoch 左右,loss 突然变成nan,或者 loss 曲线像锯齿一样上下跳动。
原因:最常见的是学习率偏大,Adam 虽然自适应,但对 1e-2 以上的初始学习率依然敏感;另一个隐蔽问题是标签里有脏数据,比如文件名解析出错,导致标签值等于 11 或负数,超出类别范围。
解决:先把学习率降到 3e-4 重跑一次,如果还出现nan,检查 Dataset 的__getitem__里的标签解析逻辑,确保标签范围在[0, 9]内。可以在训练循环中插入assert labels.max() < 10快速定位。
4.3 预测结果总是第一位字符错,后面三位全对
现象:测试时发现错误集中在验证码的第一个字符,后面几位几乎不出错。
原因:验证码生成时字符位置并不是完全居中的,第一位字符经常贴边或被干扰线遮挡,卷积层在边缘位置提取到的特征不够充分。
解决:在生成脚本里让第一个字符的绘制位置往右偏移,或者干脆把所有字符向中心聚拢一点;也可以把图片宽度从 160 加到 180,让边缘余量更多。这种“按位分析错误率”的习惯很重要,单看整体准确率发现不了这类问题。
4.4 模型在模拟样本上效果好,换成另一批样本就崩
现象:换一个字体风格或加了背景噪点的验证码生成器,同样结构的模型全串准确率直接掉到 70% 以下。
原因:模拟生成的数据分布和真实目标分布不一致,尤其是字体、笔画粗细、噪点类型这三个因素,CNN 会过度依赖训练时的纹理特征。
解决:没有捷径,只能在目标数据来源上做针对性采样和标注。常见做法是写一个半自动标注脚本,先用当前模型预测,人工只修正置信度低的样本,把修正后的数据加进训练集重训,两轮之后准确率就能明显回升。
4.5 多卡训练时 batch size 翻倍,验证集准确率反而下降
现象:从单卡切到多卡,batch size 从 64 变成 256,训练速度上去了,但验证集准确率掉了 2-3 个百分点。
原因:batch size 变大后,BN 层的统计量变化、优化器更新步数变少,原学习率不再适配。
解决:如果把 batch size 放大 k 倍,学习率一般要相应调整,常见做法是线性放缩学习率基数,但幅度不宜过大,也可以直接采用学习率预热策略,前 5 个 epoch 从 1e-4 线性升到目标值。对这个小项目来说,单卡 64 的 batch size 已经足够,多卡收益有限,建议量力而行。
5. 推理与评估:把训练好的权重封装成单张图片识别函数
5.1 模型保存与加载、推理函数封装
import torch from PIL import Image import torchvision.transforms as transforms from model import CaptchaCNN model = CaptchaCNN(num_classes=10, num_chars=4) model.load_state_dict(torch.load('captcha_cnn.pth', map_location='cpu')) model.eval() transform = transforms.Compose([ transforms.Grayscale(), transforms.Resize((60, 160)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) def predict_image(img_path): img = Image.open(img_path) img_tensor = transform(img).unsqueeze(0) with torch.no_grad(): outputs = model(img_tensor) pred = torch.argmax(outputs, dim=2) result = ''.join(str(x.item()) for x in pred[0]) return result逻辑说明:推理时不需要计算梯度,包在torch.no_grad()里既省内存又更快。unsqueeze(0)是给图片加一个 batch 维度,因为模型期望输入是四维张量。加载权重时用map_location='cpu'可以把原本在 GPU 上训练的权重搬到 CPU 上运行,兼容性更好。参数说明:这里的transform必须和训练时完全一致,尤其是Normalize的均值和标准差,任何不一致都会导致推理效果明显变差,这是新手最容易踩的坑之一。建议把 transform 逻辑单独抽成一个公共函数,训练和推理共用同一份代码。
5.2 批量测试、混淆矩阵与误差分析
import os import numpy as np def evaluate_folder(img_dir): results = {} conf_matrix = np.zeros((10, 10), dtype=int) total, correct = 0, 0 for name in os.listdir(img_dir): true_label = name.split('_')[0] pred_label = predict_image(os.path.join(img_dir, name)) if true_label == pred_label: correct += 1 total += 1 for i, (t, p) in enumerate(zip(true_label, pred_label)): conf_matrix[int(t)][int(p)] += 1 print(f'Accuracy: {correct / total:.4f}') return conf_matrix说明:混淆矩阵能直观反映模型在哪个数字上混淆严重,比如“3”经常被预测成“8”,说明这两个字符在笔画结构上相似,需要补充更多该字符的样本或者在生成时增大这两个字符的变形差异。逐位准确率和整串准确率也要分开统计,前者衡量字符识别能力,后者才是真实任务指标。整串准确率通常是逐位准确率的 4 次方量级,所以说“单字符准确率 99%”和“验证码全对率 99%”是两个差别很大的概念。
5.3 推理速度优化与批处理识别
def predict_batch(img_paths, batch_size=32): imgs = [Image.open(p) for p in img_paths] tensors = torch.stack([transform(img) for img in imgs]) with torch.no_grad(): outputs = model(tensors) preds = torch.argmax(outputs, dim=2) return [''.join(str(x) for x in p) for p in preds]遇到大量图片需要识别时,逐张跑推理会造成重复的前向开销,把图片堆成一个 batch 统一推理可以减少 GPU 显存分配次数。torch.stack会把多个(1, 60, 160)张量合成(N, 1, 60, 160),模型一次前向就能出全部结果。CPU 环境下 batch size 设 16–32 比较合适,再大反而会因为内存拷贝变慢。
6. 进阶与扩展:从定长模型切换到 CRNN+CTC 的迁移姿势
固定 4 位数字模型的逻辑很简单,但真实业务里验证码可能变成 4 到 6 位不等的字母数字混合,这时候定长多输出头就不够灵活了。常见做法是把模型换成 CRNN 结构,卷积层负责提特征,双向 LSTM 建模字符序列,最后用 CTC Loss 对齐不定长输出。训练时只需要把标签改成字符串序列,不需要提前切分字符。CTC 的原理一句话概括:让模型在“每帧输出一个概率分布”的基础上去穷举所有可能的对齐路径,最终取总概率最大的那条路径作为识别结果。
数据增强方面值得投入。除了常见的RandomAffine,还可以对灰度图做局部腐蚀或膨胀,模拟低清晰度扫描效果;也可以随机加入高斯噪声,增强模型对干扰线的鲁棒性。这些操作在torchvision.transforms里都有现成实现,不需要自己造轮子。
模型导出推理可以走 ONNX 路线。torch.onnx.export可以跳过 PyTorch 直接部署到轻量推理框架,对 CPU 环境尤其友好。导出的网络输入输出维度都是定长的,对部署工程师更直观。以前我第一版模型在推理阶段直接复用训练脚本,结果线上换图后准确率惨不忍睹,后来发现是归一化参数不一致,从那以后我每次跑验证码识别项目,都会把训练和推理共用的 transform 抽成一个独立模块,模型导出前再强制跑一遍批量对比测试,确保推理结果和训练时完全一致。这条习惯帮我避开了大量返工,希望帮到你。
本文还有配套的精品资源,点击获取