简介面向深度学习初学者与验证码安全研究者这份资料完整呈现了字符型图片数字验证码识别的全过程涵盖验证码样本采集与标注、图像归一化、数据增强、CNN特征提取、RNN序列建模、模型训练与评估等环节并配有可运行的Python实现。资源包共1210个文件以978张png训练/测试图片和202张jpg样本为主另有17个py源码脚本、模型文件、说明文档及少量示例图片整体压缩包约9.58MB目录结构清晰便于按步骤查阅。目前已有1542人学习下载适合希望系统掌握图像验证码识别原理并动手实践的读者。通过源码可复现数据增强、one-hot编码、LSTM序列建模等关键步骤同时附带预处理后的标注数据与训练好的模型文件能帮助快速验证算法效果节省自行采集和标注数据的时间。1. 基于深度学习的字符型验证码识别从数据集到部署的完整闭环字符型图片验证码识别是深度学习中少有的、能在一个周末内跑完「数据标注→模型训练→效果验证→部署接口」全流程的项目。它不像目标检测那样需要大量工程化铺垫也不像自然语言处理那样依赖预训练模型调参一个中等显存的显卡加上 PyTorch就能把准确率做到 90% 以上。这个资源包的核心价值在于它把验证码识别的完整过程拆成了可复现的步骤从生成训练集到字符分割再到模型推理全部给出了可运行的 Python 实现。适合两类人一是刚学完卷积神经网络基础、想找一个真实项目练手的初学者二是需要批量处理某种固定格式验证码的开发者。文中提到的所有方案和坑都是基于字符型4-6 位数字或字母组合验证码场景展开的。2. 验证码识别的技术选型为什么是 CNN CTC而不是目标检测2.1 两种主流路线的边界条件验证码识别目前主要有两条技术路线一种是「检测 分类」的两阶段方案先用目标检测框出每个字符再对单个字符做分类另一种是「序列识别」方案把整张验证码图片直接映射为字符串典型代表是 CNN RNN CTC或者更现代的 CRNN 架构。资源包采用的是后者核心原因是字符型验证码的字符排列通常比较规整不需要复杂的空间定位直接序列建模反而更简单。选择序列识别方案的另一个理由是抗干扰能力强。很多验证码会加干扰线、噪点甚至字符粘连两阶段方案在字符分割这一步就容易翻车粘连字符的边界框很难定准一旦分割错误后面的分类再准也没用。而 CTC 损失函数天然支持变长序列对齐不需要显式分割字符模型自己学会「看哪里是一个字符」的能力这在字符有轻微粘连时优势非常明显。资源包里没有使用目标检测路线还有一个实际考量标注成本。检测方案需要对每个字符画框4 位验证码一张图要标 4 个框而序列识别方案只需要标注整张图的文本内容。对于动辄几万张的训练集标注效率差好几倍。如果你后续要处理更复杂的验证码比如字符旋转、扭曲或者背景有复杂纹理的检测路线的上限更高但当前这个资源包的场景下序列识别是性价比最高的选择。2.2 图像预处理灰度、二值化与归一化的具体参数拿到验证码图片后第一步不是直接扔进网络而是做预处理。资源包里的预处理脚本主要做了三件事灰度化、二值化可选、归一化。直方图均衡化在验证码场景里用处不大因为验证码的前景和背景对比度通常已经刻意做得比较清晰。import cv2 import numpy as np def preprocess(image_path, target_size(160, 48), binaryFalse): # 读取图片并转为灰度图 img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) # 如果原图是彩色的这一步会丢失颜色信息。对于字符型验证码 # 颜色信息大多是背景干扰丢掉反而有利于模型收敛。 if img is None: raise ValueError(f无法读取图片: {image_path}) # 可选自适应二值化。某些验证码的字符和背景灰度值接近 # 直接归一化会导致特征不清晰此时尝试二值化。 if binary: img cv2.adaptiveThreshold( img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, blockSize31, C10 ) # 缩放并保持宽高比周围补灰边 h, w img.shape scale target_size[1] / h new_w int(w * scale) new_w min(new_w, target_size[0]) resized cv2.resize(img, (new_w, target_size[1]), interpolationcv2.INTER_AREA) # 创建目标尺寸的画布用 128 灰度填充中性值 canvas np.full((target_size[1], target_size[0]), 128, dtypenp.uint8) offset (target_size[0] - new_w) // 2 canvas[:, offset:offset new_w] resized # 归一化到 [-1, 1] 区间这个区间比 [0, 1] 更适合 BN 层 canvas (canvas.astype(np.float32) - 127.5) / 127.5 # 增加通道维度CHW 格式适配 PyTorch tensor np.expand_dims(canvas, axis0) return tensor这段代码里有几个参数值得细说。target_size 选 (160, 48)即宽度 160、高度 48这是经验值大多数 4-6 位验证码在这种尺寸下字符不会严重变形且参数量可控。如果验证码字符特别多或者特别密需要适当增加宽度。自适应二值化的 blockSize 选 31、C 选 10这两个参数只对背景复杂的情况有效。如果你处理的验证码背景是纯色或简单渐变建议跳过二值化直接走归一化因为二值化本质上是信息压缩可能会把字符的细节纹理也抹掉。灰度填充值选 128 而不是 0 或 255是因为中性灰度不会让模型产生「看到黑边或白边」的偏置。如果你用 0 填充模型可能会把左右的黑边误判为字符的一部分。2.3 数据集生成用 captcha 库合成训练样本真实验证码标注成本高而且爬取真实验证码涉及合规问题。资源包采用的是「合成数据为主、真实数据微调」的策略。合成数据用 Python 的 captcha 库批量生成可以精确控制字符集、干扰线、噪点等级。from captcha.image import ImageCaptcha import random import string # 字符集这里用数字小写字母排除易混淆的 0/O、1/l/I CHAR_SET 0123456789abcdefghijkmnpqrstuvwxyz CAPTCHA_LENGTH 4 TOTAL_COUNT 50000 def generate_dataset(count, output_dir): generator ImageCaptcha(width160, height60, font_sizes(42, 48, 52)) for i in range(count): # 随机生成验证码文本 text .join(random.choices(CHAR_SET, kCAPTCHA_LENGTH)) # 生成图片并保存 image generator.generate_image(text) filename f{output_dir}/{text}_{i}.png image.save(filename) if (i 1) % 5000 0: print(f已生成 {i 1} 张)这段代码里有两处关键设计。字符集去掉了 0/O、1/l/I 这类易混淆字符这是工程上的务实选择——如果你的业务场景不允许排除这些字符可以在后续用更多数据量去硬啃但训练时间和误识别率都会上升。filename 里带上了文本标签这是最简单也最不容易出错的标注方式。不要单独维护一个 CSV 或 JSON 映射文件文件多了之后同步容易出错直接从文件名解析标签是最稳妥的做法。做真实数据微调时也沿用「文件名即标签」的约定。合成的图片尺寸是 160×60但前面预处理会缩放到 160×48这里故意生成略高的图片是为了让字符上下留白更明显模拟真实验证码的排版。如果直接生成 160×48字符会占满整个高度预处理时缩放反而会引入畸变。3. 模型搭建与训练CRNN 结构的参数解析和训练策略3.1 网络结构卷积层提取特征双向 LSTM 建模序列资源包里的模型不是手写的 VGG LSTM而是精简版的 CRNN。整体结构是卷积层组类似 VGG 的堆叠结构→ 双向 LSTM → 全连接层 → CTC 解码。这里的关键是把图像特征图转换为序列特征让 CNN 的输出能喂给 LSTM。import torch import torch.nn as nn class CRNN(nn.Module): def __init__(self, num_classes, hidden_size256, num_layers2): super(CRNN, self).__init__() # CNN 特征提取部分3 层卷积 2 层最大池化 self.cnn nn.Sequential( nn.Conv2d(1, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 160x48 - 80x24 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride1), # 80x24 - 79x23奇数尺寸处理 nn.Conv2d(128, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Conv2d(256, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size(2, 1), stride(2, 1)) # 79x23 - 39x23 ) # 将 H 维度压缩到 1方便 LSTM 按序列处理 self.squeeze nn.Sequential( nn.Conv2d(256, 256, kernel_size(23, 1)), nn.ReLU(inplaceTrue) ) # 双向 LSTM 序列建模 self.lstm nn.LSTM( input_size256, hidden_sizehidden_size, num_layersnum_layers, bidirectionalTrue, dropout0.3 if num_layers 1 else 0 ) # 分类层输出每个时间步的字符概率分布 self.linear nn.Linear(hidden_size * 2, num_classes) def forward(self, x): # x: [batch, 1, 48, 160] feat self.cnn(x) # [batch, 256, 23, 39] # 用 1x1 卷积压缩高度 feat self.squeeze(feat) # [batch, 256, 1, 39] feat feat.squeeze(2) # [batch, 256, 39] # 转置LSTM 期望 [seq_len, batch, input_size] feat feat.permute(2, 0, 1) # [39, batch, 256] lstm_out, _ self.lstm(feat) # [39, batch, 512] # 全连接层输出log_softmax 便于 CTC 计算 output self.linear(lstm_out) # [39, batch, num_classes] return output这个网络设计有几个关键点。卷积层的通道数从 64 → 128 → 256 递增这是特征图空间尺寸逐层缩小、通道数加深的标准模式。第二个最大池化的 stride 改成 1是为了避免宽高被均匀压缩后序列长度过短导致 LSTM 建模能力不足。每次池化后特征图尺寸变化输入 160×48第一层池化后变 80×24第二层因为 stride1 变成 79×23第三层的高度池化用 (2,1) 只压缩高度不压缩宽度最终特征图是 39×23。压缩高度到 1 用的是 23×1 的卷积核这个「瓶颈层」的作用是把空间高度信息融合到通道维度中避免信息丢失。双向 LSTM 的 hidden_size 取 256num_layers 取 2。这个配置对 4-6 位验证码是够用的。如果验证码位数增加到 8 位以上建议把 hidden_size 提到 384 或 512否则长距离依赖建模会吃力。dropout 设为 0.3 只在层数大于 1 时生效防止过拟合。3.2 CTC 损失函数训练脚本与学习率策略CTC 的核心思想是引入一个「空白符」允许输出序列中出现重复字符和空白然后对所有可能的对齐方式求和。这就是它不需要字符级标注的原因。训练时用的 PyTorch 内置 CTCLoss但有几个参数必须注意。import torch.optim as optim from torch.nn import CTCLoss # 字符映射表索引 0 保留给 CTC 空白符 # 索引 1-36 映射到字符集37 作为额外空白位可选项 CHAR2IDX {c: i 1 for i, c in enumerate(CHAR_SET)} IDX2CHAR {i 1: c for i, c in enumerate(CHAR_SET)} NUM_CLASSES len(CHAR_SET) 1 # 1 给 CTC 空白符 def train_one_epoch(model, dataloader, optimizer, device, grad_clip5.0): model.train() total_loss 0 for images, texts in dataloader: images images.to(device) # 文本标签转为索引序列列表的列表因为每个 batch 内长度可不同 targets [] target_lengths [] for text in texts: indices [CHAR2IDX[c] for c in text] targets.extend(indices) target_lengths.append(len(indices)) targets torch.tensor(targets, dtypetorch.long).to(device) target_lengths torch.tensor(target_lengths, dtypetorch.long) # 模型前向输出为 [seq_len, batch, num_classes] output model(images) # seq_len 相同但不同图片的有效字符数不同 # 这里用 log_probs 形式shape 要求是 [seq_len, batch, num_classes] log_probs output.log_softmax(2) input_lengths torch.full( size(images.size(0),), fill_valueoutput.size(0), dtypetorch.long ) # CTC Loss 计算 loss CTCLoss(blank0, zero_infinityTrue)( log_probs, targets, input_lengths, target_lengths ) optimizer.zero_grad() loss.backward() # 梯度裁剪LSTM 容易梯度爆炸尤其 batch 内序列长度差异大时 torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) optimizer.step() total_loss loss.item() return total_loss / len(dataloader)这里最容易踩坑的是 input_lengths 和 target_lengths 的构造。input_lengths 在 CN 中通常等于输出序列长度即 39卷积后的宽度特征数。target_lengths 是每个验证码文本的实际字符数比如 4 位验证码就是 4。CTC 要求 input_lengths 必须大于等于 target_lengths如果验证码长度接近或超过序列长度训练会直接崩溃。blank0 表示索引 0 是 CTC 空白符所以字符映射必须从 1 开始这正是 CHAR2IDX 里 i1 的含义。zero_infinityTrue 的作用是防止 loss 为负无穷导致梯度 NaN在训练初期模型输出非常不确定时这个问题经常出现。学习率策略用的是阶梯下降初始 lr 设为 0.001每 10 个 epoch 乘以 0.5用 ReduceLROnPlateau 也可以。Adam 优化器配 weight_decay 1e-4 防止过拟合因为合成数据集虽然量够大但模式相对单一模型很容易在合成数据分布上过拟合。训练到第 15 个 epoch 左右通常能看到准确率开始收敛如果到第 30 个 epoch 还不收敛先检查数据预处理和标签对齐再考虑网络结构。3.3 训练工程化数据集类、批处理与验证逻辑完整的训练不只是模型代码还有 DataLoader 的合理设计。图片读取和预处理是训练瓶颈之一尤其数据量到几万张后磁盘 IO 和预处理耗时可能超过 GPU 计算时间。from torch.utils.data import Dataset, DataLoader from PIL import Image import os class CaptchaDataset(Dataset): def __init__(self, data_dir, transformNone): self.data_dir data_dir self.filenames [f for f in os.listdir(data_dir) if f.endswith(.png)] self.transform transform def __len__(self): return len(self.filenames) def __getitem__(self, idx): filename self.filenames[idx] # 从文件名解析标签格式 text_index.png text filename.split(_)[0] # 读取图片转灰度 image Image.open(os.path.join(self.data_dir, filename)).convert(L) if self.transform: image self.transform(image) return image, text # DataLoader 配合 collate_fn 动态 pad def collate_fn(batch): images, texts zip(*batch) images torch.stack(images, dim0) return images, list(texts)关键性能优化点是 DataLoader 的 num_workers 参数。合成数据集的图片比较小单图读取大约 10 毫秒如果 num_workers0GPU 会大量时间空转。设成 4-8 个 worker 可以把数据加载时间压到 GPU 计算时间内。但如果你的机器内存不够worker 太多会触发频繁的页交换反而更慢需要根据实际机器配置微调。验证逻辑强烈建议用「整串正确率」而不是「字符正确率」。字符正确率 99% 但整串正确率可能只有 96%因为 4 个字符都对的概率是每个字符正确率的乘积。资源包里的验证脚本会同时输出这两个指标训练收敛看整串准确率更实际。4. 训练避坑手册数据、环境、解码三类高频翻车现场4.1 解码结果全是重复字符或乱码现象训练出的模型在验证集上 loss 很低但解码出来的字符串明显不对比如「aaaa」或「1111」或者带有连续空白符。原因这种问题九成出在 CTC 解码逻辑上。用了贪心解码每步取最大概率但没有处理重复字符的合并规则。CTC 解码时连续相同字符出现在非空白隔断时需要合并成一个字符。比如输出序列是「a _ a」解码后应该是「aa」但如果是「a a」中间无空白则合并为「a」。很多初版代码直接取了 argmax 然后去重这就导致原本是两个字符的「aa」被错误合并成一个。解决用 torch 的 CTC 解码函数或手动实现合并逻辑。保证合并时先移除空白符再合并连续重复字符。另外检查字符映射表如果索引 0 被误用为某个真实字符等于是让空白符参与解码也会输出乱码。4.2 训练 loss 降到 0.05 以下但验证整串准确率停滞在 80%现象loss 持续下降字符级准确率 99%但整串准确率卡在 80% 出头上不去。原因这是典型的「数据分布单一 模型钻牛角尖」问题。合成数据里干扰线和字符字体模式固定模型学会了某个「取巧」的特征但对真实验证码的泛化能力不足。更隐蔽的原因是字符集里存在形近字符模型对这两个字符的内部特征表示几乎一致。解决两个方向。第一增加合成数据的扰动参数字体大小随机范围拉大、干扰线数量从固定值改成随机、添加高斯噪声。第二针对形近字符做数据增强——在生成数据时把字符集里容易混淆的几对比如 g 和 q、m 和 n在图片生成时做随机轻微旋转或畸变人为拉大它们在特征空间的距离。真实数据微调时留出 20% 的真实样本做验证集不要全量训进去。4.3 换机器后训练结果不可复现现象同一份代码、同一个数据集在一台机器上训练收敛到 95%换了一台机器或换了个 CUDA 版本后训练结果始终到不了 90%。原因环境差异的累积效应。BN 层的 running_mean 和 running_var 在 float32 下对舍入误差敏感V100 和 A100 的卷积算子在不同 CUDA 版本下的计算顺序可能不同。此外 RandomFlip 等数据增强操作在不同 PyTorch 版本的随机数生成算法可能改变导致训练数据分布产生细微偏移。解决固定随机种子是第一步但不是全部。torch.backends.cudnn.deterministic True 和 torch.backends.cudnn.benchmark False 能保证卷积计算的确定性。数据增强操作里涉及随机数的显式指定 generator 而不是用全局随机状态。强烈建议在训练脚本开头就用一段固定的 seed 函数包含 Python 的 random、numpy 的 random、torch 的 manual_seed 和 CUDA 的 seed_all。5. 部署与验证把模型封装成可用的验证码识别接口5.1 推理优化动态尺寸输入与批处理训练完成后模型部署时的输入尺寸不一定要和训练时完全一致。验证码图片的宽度可能从 100 到 180 不等而 160×48 只是训练时的标准尺寸。推理时支持动态宽度可以让识别更灵活但注意保持高度固定为 48。def postprocess(output, idx2char): # output: [seq_len, num_classes]去掉 batch 维度 # 贪心解码取每个时间步概率最大的索引 pred_indices output.argmax(dim1).cpu().numpy() # CTC 解码去除空白符索引 0和连续的重复字符 results [] prev -1 for idx in pred_indices: if idx ! prev and idx ! 0: results.append(idx) prev idx # 映射回字符 text .join([idx2char[idx] for idx in results]) return text这个后处理函数看起来简单但有个隐藏的边界条件当模型输出「空白 - 字符 - 字符」时两个相同字符之间必须有空白符隔开才能被识别为两个字符。如果模型没有学会在两个相同字符间插入空白符解码就会错误合并。这就是 CTC 训练中「blank」标记的意义所在也是为什么不能用简单的去重操作。推理时的代码路径读取图片 → preprocess 函数 → 模型前向 → postprocess 解码 → 返回文本。整个流程可以封装成一个类方便 Web 框架调用。5.2 置信度阈值与防御策略生产环境不能只看识别结果还要看置信度。模型输出的概率分布分散还是集中能反映识别的可靠程度。如果一个验证码图片的识别概率分布非常均匀熵高说明模型很「犹豫」此时宁可拒绝识别也不要给出可能错误的答案。import torch def predict_with_confidence(model, tensor, idx2char, threshold0.8): model.eval() with torch.no_grad(): output model(tensor.unsqueeze(0)) # 加 batch 维度 # 对每个时间步的概率做 softmax计算最大概率的平均值 probs torch.softmax(output, dim-1) max_probs, _ probs.max(dim-1) avg_confidence max_probs.mean().item() text postprocess(output.squeeze(0), idx2char) if avg_confidence threshold: return None, avg_confidence return text, avg_confidence阈值一般取 0.8 到 0.9 之间。调阈值时在验证集上画出准确率 - 召回率曲线选一个业务上可接受的平衡点。如果业务允许验证码识别失败后重新刷新验证码阈值可以放宽到 0.7让更多请求进到人工确认环节。如果识别错误代价较高比如登录接口阈值设到 0.9 以上。5.3 半监督自举用「高置信度预测 人工抽检」扩充数据集资源包的合成数据在真实场景中通常会有域偏移而收集真实数据又需要标注。这里有一个半监督的兜底方案先部署模型把置信度高于 0.95 的预测结果直接当作伪标签加入训练集。每收集 2000 张高置信度样本人工抽检 100 张如果错误率低于 1%这 2000 张就可以放心并入训练数据。我自己的习惯是把这套流程写成一个小脚本每天定时扫描当天的高置信度预测结果自动归档到训练集目录。跑两周之后真实数据在训练集中的占比会逐渐提高模型对真实分布的拟合会明显改善。要注意的是伪标签会放大模型的系统性误差——如果模型对某个字符有固定偏误伪标签也会把这种偏误强化。所以人工抽检的 100 张一定要覆盖所有字符不要只看随机子集。从那以后我对「验证码识别」这类项目的迭代习惯就固定成了合成数据打底、真实数据校准、高置信度结果回流三步循环跑起来。希望这篇拆解能帮你把这个项目完整落地省掉我当初踩过的那几个小时无谓的调试时间。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?