
简介这份资源面向希望入门深度学习图像识别的开发者与安全方向学习者围绕字符型图片数字验证码的识别任务完整呈现从数据准备到模型训练、再到Python代码落地的实践路径。内容涉及卷积神经网络与循环神经网络结合的特征提取与序列建模思路涵盖数据归一化、one-hot编码、交叉熵损失与Adam优化器等关键环节适合具备一定Python基础、想通过真实项目理解深度学习流程的读者。资源包共1210个文件以978个png与202个jpg验证码样本图片为主体另含17个py源码脚本、2个txt说明及少量rst、zip、bmp等辅助文件压缩包约9.58MB目录结构便于按数据与代码模块检索。目前已有1543人学习下载可帮助读者对照源码复现训练过程、理解模型设计与评估方法并在此基础上尝试迁移到更复杂的验证码场景。1. 字符型验证码识别从一张扭曲图片到可训练样本的落地路径登录页上那串歪歪扭扭、带干扰线的四位字符很多人第一反应是上打码平台但真到自己要批量处理内部系统、做自动化回归测试时外部服务既不可控也不便宜。这份资源给的是另一条路用深度学习把字符型图片验证码的识别流程完整走一遍从图片预处理、字符切分或整图标注到卷积网络训练、模型导出与推理封装附完整 Python 源码。它适合两类人一是想拿一个真实小任务练手深度学习全流程的开发者二是手里有大量固定风格验证码、需要自己训一个专用识别模型的从业者。核心不是调个库跑通而是把数据怎么造、标签怎么对、模型怎么收敛这几件事讲透。2. 数据准备验证码样本生成与标签对齐的两个关键动作验证码识别和常规图像分类最大的差别在于你几乎拿不到现成的标注数据。真实业务里的验证码图片往往只有几百张而且分布单一直接训容易过拟合。所以这份资源的第一步不是建模而是造数据。常见做法是用图像库合成一批风格接近目标验证码的样本再混入少量真实截图做微调。这一步做扎实后面模型收敛会顺很多做敷衍训练 loss 降得漂亮但线上识别率一塌糊涂这种翻车我见过不止一次。2.1 用 Pillow 合成带干扰的字符样本合成脚本的核心是控制三件事字符集、字体、干扰强度。字符集要和目标验证码完全一致比如只含数字 0-9或数字加小写字母字体多准备几套干扰包括旋转、噪点、干扰线和背景渐变。下面这段是可直接改参数用的骨架from PIL import Image, ImageDraw, ImageFont, ImageFilter import random, os CHARS 0123456789 # 字符集必须与目标验证码一致 FONT_PATHS [./fonts/arial.ttf, ./fonts/consola.ttf] IMG_W, IMG_H 120, 40 LEN 4 # 验证码长度 def random_color(low0, high255): return tuple(random.randint(low, high) for _ in range(3)) def gen_one(save_path): img Image.new(RGB, (IMG_W, IMG_H), random_color(200, 255)) draw ImageDraw.Draw(img) text .join(random.choice(CHARS) for _ in range(LEN)) # 逐字符绘制允许轻微错位和旋转模拟真实扭曲 x 8 for ch in text: font ImageFont.truetype(random.choice(FONT_PATHS), random.randint(24, 30)) char_img Image.new(RGBA, (32, 40), (0, 0, 0, 0)) d ImageDraw.Draw(char_img) d.text((2, 2), ch, fontfont, fillrandom_color(0, 120)) char_img char_img.rotate(random.uniform(-18, 18), expand1) img.paste(char_img, (x, random.randint(0, 6)), char_img) x random.randint(24, 28) # 干扰线数量控制在 2~4 条太多会破坏字符结构 for _ in range(random.randint(2, 4)): draw.line([(random.randint(0, IMG_W), random.randint(0, IMG_H)), (random.randint(0, IMG_W), random.randint(0, IMG_H))], fillrandom_color(80, 180), width1) img img.filter(ImageFilter.GaussianBlur(radiusrandom.uniform(0, 0.6))) img.save(save_path) return text if __name__ __main__: os.makedirs(./dataset/synth, exist_okTrue) with open(./dataset/synth/labels.txt, w) as f: for i in range(20000): t gen_one(f./dataset/synth/{i:06d}.png) f.write(f{i:06d}.png\t{t}\n)逻辑上分三层先建背景再逐字符贴图逐字符而不是整串一次画是为了让每个字符独立旋转错位更接近真实验证码的扭曲感最后叠干扰线和模糊。参数上最需要调的是干扰线数量和模糊半径——干扰线超过 5 条、模糊半径超过 1.0 时人眼都难辨认模型更学不到稳定特征这是新手最容易踩的数据造得太狠的坑。字体路径要换成你本机实际存在的 ttf 文件Windows 下一般在C:/Windows/Fonts/Linux 下找fc-list输出的路径。2.2 标签编码定长多分类还是 CTC标签怎么编码直接决定模型输出层怎么设计。字符型验证码长度固定比如 4 位时最省事的是定长多分类把 4 个位置各当成一个独立分类任务每个位置输出字符集大小的 logits。字符集 10 类、长度 4输出就是 4×10。这种方案训练稳定、解码简单缺点是长度一变就得改网络。长度不固定时用 CTC 更合适但对新手来说 CTC 的 blank 对齐和 beam search 解码是另一个学习成本。这份资源走的是定长多分类路线因为绝大多数业务验证码长度固定。标签编码用一个映射表把字符转成索引CHARS 0123456789 char2idx {c: i for i, c in enumerate(CHARS)} def encode_label(text): # 返回长度为 LEN 的索引列表供 CrossEntropy 逐位置计算 return [char2idx[c] for c in text] def decode_pred(logits): # logits: [LEN, num_classes]每个位置取 argmax idx logits.argmax(dim-1).tolist() return .join(CHARS[i] for i in idx)这里有个容易忽略的细节如果目标验证码里出现了字符集外的字符比如偶尔混入大写字母编码时会直接 KeyError。稳妥做法是先统计真实样本的字符分布把字符集定全宁可多留几类也不要漏。另外训练集和验证集的字符集映射必须用同一份别一边用0123456789一边用1234567890顺序不同索引全乱这种低级错误排查起来能耗掉半天。3. 模型搭建与训练一个小型 CNN 为什么够用很多人一上来就想上 ResNet 或 CRNN觉得网络越深越准。字符型验证码这个任务其实很窄图像尺寸小通常 100×40 上下、字符类别少、结构规律强一个四五层的卷积网络加全连接输出就足够训练还快。资源里的模型结构走的是轻量 CNN 路线参数量控制在百万级以内单卡甚至 CPU 都能在几十分钟内跑完一轮完整训练。选型理由很实在验证码识别是工程任务不是刷榜任务推理延迟和部署成本比多一两个点的准确率更重要。3.1 网络结构与输出头设计网络主体是三个卷积块Conv-BN-ReLU-MaxPool把 120×40 的输入逐步下采样到特征图再展平接全连接。关键在输出头不是输出一个 10 维向量而是输出LEN × num_classes的矩阵每个位置独立做分类。PyTorch 里这样写import torch import torch.nn as nn class CaptchaCNN(nn.Module): def __init__(self, num_classes10, length4): super().__init__() self.length length self.features nn.Sequential( nn.Conv2d(3, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), # 120x40 - 60x20 nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), # 60x20 - 30x10 nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), # 30x10 - 15x5 ) self.head nn.Sequential( nn.Flatten(), nn.Linear(128 * 15 * 5, 256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, length * num_classes) # 每个位置一组 logits ) def forward(self, x): feat self.features(x) out self.head(feat) return out.view(-1, self.length, 10) # [B, LEN, num_classes]结构说明三个卷积块负责提取笔画和边缘特征BN 加速收敛并稳定训练Dropout 放在全连接前抑制过拟合。输出 reshape 成[B, LEN, num_classes]是为了后面按位置算交叉熵。参数上要注意128 * 15 * 5这个展平维度必须和输入尺寸严格对应——输入改成 100×40 或 160×50这个数字就得重算写死是最常见的报错来源。稳妥做法是先用一个 dummy 输入跑一遍features看输出形状再填全连接维度。3.2 训练循环与损失计算损失用逐位置交叉熵求和。PyTorch 的CrossEntropyLoss默认对最后一维做分类所以把[B, LEN, C]转成[B*LEN, C]、标签转成[B*LEN]即可import torch from torch.utils.data import DataLoader device torch.device(cuda if torch.cuda.is_available() else cpu) model CaptchaCNN(num_classes10, length4).to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(30): model.train() total_loss 0.0 for imgs, labels in train_loader: # labels: [B, LEN] 的 LongTensor imgs, labels imgs.to(device), labels.to(device) logits model(imgs) # [B, LEN, C] loss criterion(logits.reshape(-1, 10), labels.reshape(-1)) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch}, loss {total_loss / len(train_loader):.4f})逻辑上reshape(-1, 10)把每个位置的分类摊平成独立样本labels.reshape(-1)同步摊平这样一次CrossEntropyLoss就覆盖了所有位置。学习率 1e-3 配 Adam 是这类小网络的常用起点如果 loss 在前几轮震荡明显降到 3e-4 再试。训练轮数不用死守 30看验证集准确率曲线连续几轮不涨就可以停——合成数据训太久模型会把合成风格的噪声也学进去反而伤真实样本的泛化。3.3 验证指标整串准确率比单字符准确率更该盯训练时打印的 loss 只是参考真正要看的是整串准确率4 个字符全对才算对。单字符准确率 95% 听起来不错但 4 位全对的概率只有 0.95⁴≈81%差距很大。验证代码要按整串统计def eval_acc(model, loader): model.eval() correct, total 0, 0 with torch.no_grad(): for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) pred model(imgs).argmax(dim-1) # [B, LEN] correct (pred labels).all(dim1).sum().item() total imgs.size(0) return correct / total(pred labels).all(dim1)就是整串全对的判定。这个指标才是上线前该看的数字单字符准确率只用来定位是哪个位置容易错——如果某一位总是错多半是那个位置的字符在合成时被干扰线盖得太狠回去调数据生成参数比调模型更有效。4. 推理封装与部署模型导出后怎么接进业务流程训练完的模型不能只在 notebook 里跑得封装成能接收图片字节、返回字符串的函数最好还能批量处理。这一步的坑不在模型本身而在预处理一致性训练时图片经过了 resize、归一化推理时必须用完全相同的参数否则准确率断崖式下跌。我见过有人训练用 RGB、推理时 PIL 打开 PNG 带 alpha 通道转成 RGBA模型直接懵掉。4.1 预处理对齐与单图推理函数把训练时的 transform 抽成一个独立函数训练和推理共用是避免不一致的最省心做法from torchvision import transforms from PIL import Image import io infer_tf transforms.Compose([ transforms.Resize((40, 120)), # 注意 PIL 是 (H, W)别和 (W, H) 搞反 transforms.ToTensor(), transforms.Normalize([0.5]*3, [0.5]*3), ]) def predict(image_bytes): img Image.open(io.BytesIO(image_bytes)).convert(RGB) # 强制三通道 x infer_tf(img).unsqueeze(0).to(device) with torch.no_grad(): logits model(x) return decode_pred(logits[0])两个参数要盯死Resize((40, 120))的顺序是高度在前和 OpenCV 的(宽, 高)相反混用必错convert(RGB)强制三通道防止灰度图或带透明通道的图把通道数搞乱。归一化参数0.5/0.5要和训练时一致训练用了 ImageNet 均值方差推理也得跟着换。4.2 批量推理与置信度过滤业务里往往一次来一批图逐张推理浪费 GPU。批量推理时把图片堆成 batch同时输出每个位置的置信度低于阈值的直接标记为需人工复核比硬猜一个错答案更实用def predict_batch(image_list, batch_size64, conf_th0.9): results [] for i in range(0, len(image_list), batch_size): chunk image_list[i:ibatch_size] tensors torch.stack([infer_tf(Image.open(io.BytesIO(b)).convert(RGB)) for b in chunk]).to(device) with torch.no_grad(): logits model(tensors) # [B, LEN, C] probs logits.softmax(dim-1) conf, idx probs.max(dim-1) # 每个位置的最大概率 for j in range(len(chunk)): text .join(CHARS[k] for k in idx[j].tolist()) min_conf conf[j].min().item() # 取最不确定的那一位 results.append((text, min_conf, min_conf conf_th)) return results置信度取每个位置最大概率里的最小值代表整串里最没把握的那一位。阈值 0.9 是个经验起点真实业务里可以拿一批带标注的样本跑一遍看误识别样本的置信度分布再定阈值——通常误识别的置信度会明显偏低卡在 0.85~0.95 之间能过滤掉大部分硬猜。5. 避坑与排查五个让识别率腰斩的常见问题5.1 训练准确率 99%线上只有 60%现象验证集和训练集指标都很漂亮换一批真实截图就崩。原因基本是合成数据和真实数据分布差太远——字体、背景色、干扰线风格对不上。解决先拿 200~500 张真实样本做一次 zero-shot 测试看错在哪些字符、哪种背景然后把这些真实样本按比例比如 1:5混进训练集做微调或者调整合成脚本的字体和背景色分布向真实样本靠拢。别指望纯合成数据直接上线。5.2 loss 不降或变成 NaN现象训练几轮后 loss 突然 NaN或者一直卡在高位。原因常见两个学习率太大导致梯度爆炸或者输入归一化没做、像素值 0~255 直接进网络。解决先把学习率降到 1e-4 试确认输入经过ToTensor和Normalize如果还 NaN检查标签里有没有越界索引比如字符集 10 类却出现了索引 10CrossEntropyLoss遇到越界 target 会直接报错或产生异常梯度。5.3 某一位字符总是识别错现象整串准确率上不去拆开看总是第 2 位或第 3 位错得多。原因通常是那个位置的字符在合成时被干扰线或相邻字符遮挡得最严重也可能是逐字符贴图时 x 偏移量设得太小导致字符重叠。解决回看合成脚本里x random.randint(24, 28)这类步长确保字符间不重叠干扰线生成时避开字符主体区域或者降低该区域的干扰线密度。数据问题回数据层解决别硬调模型。5.4 推理速度慢单张要几百毫秒现象模型不大但推理慢。原因多半是没切model.eval()和torch.no_grad()或者每张图都重新加载模型、重复初始化。解决推理前固定model.eval()推理包在torch.no_grad()里模型只加载一次常驻内存批量推理代替逐张。另外确认是不是在 CPU 上跑——小模型 CPU 也能接受但 batch 推理在 GPU 上收益更明显。5.5 换了个验证码样式模型直接失效现象同一套系统升级后验证码字体或长度变了旧模型全错。原因定长多分类模型把长度和字符集写死在输出层样式一变分布就偏。解决这类任务本来就没有一劳永逸的模型建立样式变更→重新采样→微调的流程比追求通用模型现实。微调时保留旧数据的一部分做回放防止模型只学会新样式而忘了旧的。6. 进阶技巧用混淆矩阵定位薄弱字符并做定向补数据模型训到 90% 左右往往会卡住这时候盲目加数据量收益很低该做的是定位到底哪些字符容易混。做法是把验证集所有预测结果按位置拆开统计每个真实字符被预测成什么画一张混淆矩阵。数字验证码里最常见的混淆是 0/8、1/7、5/6、3/8这些对在低分辨率加干扰线的情况下本来就难分。定位到之后针对性地在合成脚本里提高这些字符的出现频率、降低它们的干扰强度比无脑加两万张图有效得多。import numpy as np from collections import defaultdict conf_mat np.zeros((10, 10), dtypeint) model.eval() with torch.no_grad(): for imgs, labels in val_loader: pred model(imgs.to(device)).argmax(dim-1).cpu() for p, t in zip(pred, labels): for pi, ti in zip(p.tolist(), t.tolist()): conf_mat[ti][pi] 1 # 打印每个真实字符最容易被误判成谁 for i in range(10): row conf_mat[i].copy() row[i] 0 if row.sum() 0: top row.argmax() print(f字符 {CHARS[i]} 最易误判为 {CHARS[top]}次数 {row[top]})这段代码按位置把所有样本的预测摊平统计conf_mat[真实][预测]的对角线是正确数非对角线就是混淆。跑完你会看到某几个格子特别亮那就是补数据的靶子。补的时候注意别只补错的那一类正确类也要同步加否则类别失衡又引入新问题。还有一个验证习惯值得养成每次改完数据生成参数或模型结构固定用同一批黄金样本比如 300 张带人工标注的真实截图跑一遍整串准确率和上次的数字对比。没有这个固定基准你根本分不清这次改动是真提升还是随机波动。从那以后我每次调完合成参数、换完网络结构都强制先过一遍黄金样本再决定要不要继续训省下的返工时间比训练本身还多。希望这套流程帮到你少在数据和预处理上翻车。本文还有配套的精品资源点击获取