ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ResNet+DenseNet双骨干验证码识别实战:从数据增强到模型部署

ResNet+DenseNet双骨干验证码识别实战:从数据增强到模型部署 简介本资源是一套基于深度学习实现的验证码识别OCRPython 源码项目采用 ResNet 与 DenseNet 两种经典卷积网络算法面向计算机、人工智能、信息安全等专业的学生与教师可用于课程设计、毕业设计、大作业或入门级项目立项演示。压缩包共 1084 个文件以 1071 张 png 验证码样本图片为主另含 5 个 xml 标注文件、4 个 py 源码脚本、1 个 ttf 字体及 README 说明文档整体约 8.84MB结构清晰便于直接训练与二次开发。项目代码均经过实际运行测试功能正常读者可借此掌握验证码数据生成、标注解析、模型搭建、训练调参与识别推理的完整流程并理解 ResNet 残差连接与 DenseNet 密集连接在 OCR 任务中的差异。目前已有 423 人学习关注适合希望快速上手深度学习图像识别实战的读者参考借鉴。1. 验证码识别这件事为什么 ResNetDenseNet 双骨干比单模型更值得试验证码识别是 OCR 里最容易被低估的一个细分方向。通用 OCR 面对的是印刷体、规整排版、清晰背景而验证码天生就是来对抗自动化的字符粘连、随机旋转、干扰线、背景噪点、非固定字体这些手段叠加在一起让传统 OCR 引擎的识别率断崖式下跌。我最早用 Tesseract 直接怼验证码准确率大概在 30% 上下浮动基本不可用。后来转向深度学习方案用 CNN 做端到端分类才真正把识别率拉到生产可用的水平。这个标题里的 ResNetDenseNet 组合核心思路是用两个不同拓扑的骨干网络分别提取特征再融合输出。ResNet 的残差连接擅长捕捉全局形状和字符间的相对位置关系DenseNet 的特征复用机制对细粒度纹理更敏感比如被干扰线切割后的笔画残段。两者互补在验证码这种「全局结构局部细节」都需要兼顾的场景里比单一骨干更稳。适合谁有 Python 和 PyTorch 基础、想做一个完整深度学习实战项目的人或者手头有验证码识别需求、想自己训练一个定制模型的工程师。2. 验证码识别的数据准备与增强策略从原始图片到可训练张量2.1 验证码数据集的采集与标注格式验证码识别和通用 OCR 最大的区别在于你几乎不可能找到现成的公开数据集覆盖你的目标场景。常见做法是自己写一个采集脚本从目标页面批量拉取验证码图片同时记录对应的正确标签。如果目标系统不返回标签那就只能人工标注通常几百到几千张就能起步。标注格式我一般用最简单的「文件名即标签」方案每张图片命名为a7b3k.png标签就是a7b3k。这样写 Dataset 类的时候直接解析文件名即可不需要额外的标注文件。字符集根据验证码实际使用的字符确定常见的是数字小写字母共 36 类。如果验证码区分大小写就是 62 类。import os from PIL import Image from torch.utils.data import Dataset class CaptchaDataset(Dataset): def __init__(self, root_dir, transformNone, char_setNone): self.root_dir root_dir self.transform transform self.char_set char_set # 例如 0123456789abcdefghijklmnopqrstuvwxyz self.char2idx {c: i for i, c in enumerate(char_set)} self.samples [] for fname in os.listdir(root_dir): if fname.endswith(.png): label os.path.splitext(fname)[0] # 文件名去掉扩展名就是标签 self.samples.append((os.path.join(root_dir, fname), label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_path, label self.samples[idx] img Image.open(img_path).convert(RGB) if self.transform: img self.transform(img) # 将标签转为索引序列长度固定为验证码字符数 target [self.char2idx[c] for c in label] return img, target这段代码的关键点char_set决定了分类头的输出维度必须和实际验证码字符集完全一致否则训练时会出现索引越界。target返回的是索引列表而不是 one-hot 向量后续用 CTC Loss 或 CrossEntropy 逐位计算损失时更灵活。如果验证码长度不固定需要额外处理对齐问题但大多数场景下长度是固定的直接按位分类即可。2.2 针对验证码的增强手段与参数设置验证码的增强不能照搬通用图像分类的那一套。RandomResizedCrop 会把字符裁掉ColorJitter 过度调色会让字符和背景的对比度消失。我一般只用以下几种增强并且控制强度随机旋转±15 度以内模拟验证码的轻微倾斜随机仿射变换小幅平移和缩放模拟字符位置偏移高斯模糊kernel size 3sigma 0.5~1.0模拟传输压缩带来的模糊随机擦除小面积遮挡模拟干扰线覆盖from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((64, 160)), # 统一尺寸宽高比根据验证码调整 transforms.RandomAffine(degrees15, translate(0.05, 0.05), scale(0.95, 1.05)), transforms.GaussianBlur(kernel_size3, sigma(0.5, 1.0)), transforms.ToTensor(), transforms.Normalize(mean[0.5, 0.5, 0.5], std[0.5, 0.5, 0.5]), ])Resize的目标尺寸需要根据验证码原始宽高比来定。如果验证码是 4 字符宽条形64×160 比较合适如果是 6 字符可能要 64×192。RandomAffine的 translate 参数我一般不超过 0.05再大就容易把边缘字符移出画面。GaussianBlur的 sigma 上限控制在 1.0否则字符笔画会糊成一团模型学不到有效特征。归一化用 0.5 均值和标准差是常规操作如果验证码背景偏白或偏黑可以统计一下训练集的均值和方差再调整。注意增强只用在训练集上验证集和测试集只用 Resize ToTensor Normalize否则评估结果会失真。3. ResNet 与 DenseNet 双骨干的模型搭建与融合方式3.1 两个骨干网络的选型理由与输出特征对齐ResNet 和 DenseNet 都是 ImageNet 上验证过的经典结构但它们在验证码任务上的表现差异是有原因的。ResNet 的残差块让梯度能直接跳过非线性层深层网络也能稳定训练它学到的特征偏向全局轮廓和字符间的空间关系。DenseNet 每一层都接收前面所有层的特征图这种密集连接让浅层的边缘、纹理信息能直接传到深层对验证码里被干扰线切断的笔画特别有用。我一般用 ResNet34 和 DenseNet121 这两个量级的骨干再大就过拟合了验证码数据集通常没到百万级。两个骨干都去掉最后的全连接层取全局平均池化后的特征向量。ResNet34 输出 512 维DenseNet121 输出 1024 维需要各接一个线性层投影到同一维度比如都降到 512 维再拼接。import torch import torch.nn as nn from torchvision.models import resnet34, densenet121 class DualBackboneCaptcha(nn.Module): def __init__(self, num_classes, max_len4, proj_dim512): super().__init__() # ResNet 骨干去掉 fc 层 resnet resnet34(pretrainedTrue) self.resnet_features nn.Sequential(*list(resnet.children())[:-2]) # DenseNet 骨干去掉 classifier densenet densenet121(pretrainedTrue) self.densenet_features densenet.features # 投影层把两个骨干的输出对齐到同一维度 self.res_proj nn.Linear(512, proj_dim) self.dense_proj nn.Linear(1024, proj_dim) # 融合后的分类头每个字符位置一个分类器 self.max_len max_len self.classifiers nn.ModuleList([ nn.Linear(proj_dim * 2, num_classes) for _ in range(max_len) ]) def forward(self, x): # ResNet 分支 r self.resnet_features(x) r nn.functional.adaptive_avg_pool2d(r, 1).flatten(1) r self.res_proj(r) # DenseNet 分支 d self.densenet_features(x) d nn.functional.relu(d) d nn.functional.adaptive_avg_pool2d(d, 1).flatten(1) d self.dense_proj(d) # 拼接融合 fused torch.cat([r, d], dim1) # (batch, proj_dim*2) # 逐位分类 outputs [] for i in range(self.max_len): outputs.append(self.classifiers[i](fused)) return torch.stack(outputs, dim1) # (batch, max_len, num_classes)pretrainedTrue加载 ImageNet 预训练权重这在验证码数据量不大时非常关键能显著加快收敛。adaptive_avg_pool2d把任意空间尺寸的特征图压成 1×1避免因输入尺寸变化导致全连接层维度不匹配。proj_dim设 512 是经验值太小会丢失信息太大参数量上去了容易过拟合。分类头用ModuleList逐位独立分类每个位置有自己的线性层这样模型可以学到「第 1 个字符偏数字、第 3 个字符偏字母」这类位置相关的分布规律。3.2 训练循环、损失函数与学习率调度验证码识别本质上是多标签分类问题每个字符位置独立计算交叉熵损失再求和或平均。我一般用平均这样 loss 数值不会随字符数增长而膨胀学习率好调。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR device torch.device(cuda if torch.cuda.is_available() else cpu) model DualBackboneCaptcha(num_classes36, max_len4).to(device) criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max50, eta_min1e-5) for epoch in range(50): model.train() total_loss 0 for imgs, targets in train_loader: imgs imgs.to(device) targets torch.stack(targets, dim1).to(device) # (batch, max_len) optimizer.zero_grad() logits model(imgs) # (batch, max_len, num_classes) loss 0 for i in range(model.max_len): loss criterion(logits[:, i, :], targets[:, i]) loss loss / model.max_len loss.backward() optimizer.step() total_loss loss.item() scheduler.step() print(fEpoch {epoch1}, Loss: {total_loss/len(train_loader):.4f})AdamW比 Adam 多了正确的权重衰减实现对 Transformer 和 CNN 都更友好。weight_decay1e-4是常规起点如果发现训练 loss 降得很快但验证 loss 反弹可以加到 1e-3。CosineAnnealingLR让学习率从 1e-3 平滑降到 1e-5避免后期震荡。T_max设成总 epoch 数让余弦周期覆盖整个训练过程。提示如果显存不够把 batch size 降到 16 或 8同时把学习率按比例调小。不要用梯度累积来硬撑大 batch验证码任务对 batch 内的样本多样性敏感小 batch 反而有正则化效果。4. 验证码识别模型训练中的避坑与排查清单4.1 损失不下降或震荡从数据到学习率逐层排查现象训练 10 个 epoch 后 loss 还在 3.5 以上或者来回跳变没有下降趋势。原因最常见的是标签对齐错误。比如文件名里有大写字母但char_set里只有小写char2idx会抛异常或者验证码长度和max_len不一致导致部分位置的标签是填充值模型学不到有效信号。其次是学习率过大AdamW 的 1e-3 对某些初始化不好的模型确实偏高。解决先写一个check_dataset函数遍历前 100 个样本打印图片尺寸、标签、索引序列确认没有异常值。然后把学习率降到 1e-4 试 5 个 epoch如果 loss 开始稳定下降说明是学习率问题。如果降了又升检查weight_decay是否过大。4.2 验证集准确率远低于训练集过拟合的三种抑制手段现象训练集准确率到 95% 以上验证集卡在 60% 不动。原因验证码数据集通常只有几千张ResNet34DenseNet121 的参数量对这个小数据集来说偏大。另外增强策略如果太弱模型会记住训练集的特定噪声模式。解决第一把pretrainedTrue改成冻结骨干前几层只训练后面的层和分类头训练几个 epoch 后再解冻全部微调。第二增强里加入RandomErasing概率 0.3面积比 0.02~0.1。第三在融合层后加Dropout(0.3)分类头前也加一层。4.3 推理时单张图片识别结果不稳定BatchNorm 与预处理一致性现象同一张验证码图片单独推理和放在 batch 里推理结果不一样。原因模型里的 BatchNorm 层在eval()模式下用训练时统计的 running mean 和 running var如果推理时的预处理和训练时不一致比如归一化参数不同、Resize 的插值方式不同特征分布就会偏移。解决推理前必须调用model.eval()并且用和验证集完全相同的 transform。把推理脚本里的 transform 定义成和验证集共用一个对象不要重新写一遍。另外torch.no_grad()也要加上否则 BatchNorm 的 running 统计会被推理数据更新。4.4 字符集顺序错乱导致预测标签映射错误现象模型输出的索引看起来合理但映射回字符后全是乱码。原因训练时char_set是0123456789abcdefghijklmnopqrstuvwxyz推理时写成了abcdefghijklmnopqrstuvwxyz0123456789索引和字符的对应关系完全变了。解决把char_set定义在一个公共模块里训练和推理都从同一个地方导入。不要在两处分别写字符串字面量。如果已经训练完了才发现顺序错了不用重新训练只需要在推理时用正确的char_set做映射即可模型输出的索引本身是对的。4.5 GPU 显存溢出从 batch size 到混合精度的调整顺序现象训练开始几秒后报CUDA out of memory。原因ResNet34 和 DenseNet121 同时加载中间特征图占用的显存是单骨干的两倍左右。如果输入尺寸是 64×192 且 batch size 设了 64很容易爆。解决先把 batch size 降到 16如果还爆把输入尺寸降到 64×128。仍然不够就启用混合精度训练用torch.cuda.amp把前向和反向的浮点运算降到 FP16显存占用能减少 30%~40%。最后再考虑把 DenseNet121 换成 DenseNet169 以下的更小变体。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for imgs, targets in train_loader: imgs imgs.to(device) targets torch.stack(targets, dim1).to(device) optimizer.zero_grad() with autocast(): logits model(imgs) loss 0 for i in range(model.max_len): loss criterion(logits[:, i, :], targets[:, i]) loss loss / model.max_len scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()autocast自动把矩阵乘法和卷积降到 FP16但 loss 计算和 softmax 仍然用 FP32 保证数值稳定。GradScaler对 loss 做缩放防止 FP16 下梯度下溢。这套组合在 2080Ti 上能把 batch size 从 16 提到 32 左右。5. 从训练到部署验证码识别模型的推理优化与效果验证5.1 单张图片推理脚本与批量测试方法训练完之后最直接的需求是给一张验证码图片输出识别结果。推理脚本要保证和训练时的预处理完全一致同时把模型输出从索引映射回字符。import torch from PIL import Image from torchvision import transforms # 和验证集完全一致的预处理 infer_transform transforms.Compose([ transforms.Resize((64, 160)), transforms.ToTensor(), transforms.Normalize(mean[0.5, 0.5, 0.5], std[0.5, 0.5, 0.5]), ]) def predict(image_path, model, char_set, device): model.eval() img Image.open(image_path).convert(RGB) img_tensor infer_transform(img).unsqueeze(0).to(device) # 增加 batch 维度 with torch.no_grad(): logits model(img_tensor) # (1, max_len, num_classes) preds logits.argmax(dim2).squeeze(0) # (max_len,) result .join([char_set[i] for i in preds]) return resultunsqueeze(0)把单张图片变成 batch size 为 1 的张量这是模型 forward 要求的输入格式。argmax(dim2)在类别维度取最大索引squeeze(0)去掉 batch 维度。char_set必须和训练时完全一致建议从训练脚本里 import 过来不要重新定义。批量测试的时候写一个循环遍历测试集统计逐位准确率和整串准确率。逐位准确率反映模型对每个位置字符的识别能力整串准确率才是实际可用的指标。我一般要求整串准确率至少到 85% 才考虑上线低于这个值说明模型还有优化空间。5.2 模型导出与推理速度优化如果要把模型部署到生产环境PyTorch 的默认推理速度可能不够。两个优化方向导出 ONNX 用 ONNX Runtime 推理或者用 TorchScript 做图优化。# 导出 ONNX dummy_input torch.randn(1, 3, 64, 160).to(device) torch.onnx.export( model, dummy_input, captcha_model.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}, output: {0: batch_size}}, opset_version11 )dynamic_axes让导出的模型支持变长 batch部署时可以根据并发量灵活调整。opset_version11兼容性比较好主流推理引擎都支持。导出后可以用onnxruntime加载在 CPU 上推理速度通常比原生 PyTorch 快 2~3 倍GPU 上差距小一些但显存占用更低。注意导出 ONNX 前一定要把模型设为eval()模式并且用torch.no_grad()包住导出过程否则 BatchNorm 和 Dropout 的行为会不对。5.3 一个验证模型是否真正学会的检查技巧我习惯在训练结束后做一件事从验证集里挑 20 张模型预测正确的图片和 20 张预测错误的图片分别拼成两张网格图肉眼过一遍。预测正确的里面如果有一些人眼都很难辨认的说明模型确实学到了有效特征预测错误的里面如果有一些人眼一眼就能看出来的说明模型在某些模式上存在盲区可能需要补充这类样本重新训练。这个习惯帮我省了很多次「指标看起来还行但上线就翻车」的麻烦。验证码识别这个方向指标只是参考最终还是要看模型在真实流量上的表现。我一般会留一个线上灰度通道用真实请求做 A/B 测试新模型先跑 10% 流量观察一周再决定是否全量。希望帮到你。本文还有配套的精品资源点击获取
返回列表