首页 / 资讯中心 / 文章详情

基于ResNet与DenseNet双骨干的验证码识别实战:从数据增强到模型集成

基于ResNet与DenseNet双骨干的验证码识别实战:从数据增强到模型集成 ★ FEATURED ARTICLE
简介本资源是一套基于深度学习实现的验证码识别OCRPython 源码项目采用 ResNet 与 DenseNet 两种经典卷积网络算法面向计算机、人工智能、信息安全等专业的学生与教师可用于课程设计、毕业设计、大作业或入门级项目立项演示。压缩包共 1084 个文件以 1071 张 png 验证码样本图片为主另含 5 个 xml 标注文件、4 个 py 源码脚本、1 个 ttf 字体及 iml、name、md 等工程配置与说明文件整体约 8.84MB结构清晰、便于直接运行与二次开发。项目代码经过测试运行成功读者可借此理解验证码数据生成、标注解析、模型搭建、训练与推理的完整流程并在此基础上修改网络结构或扩充数据集实现不同字符集与长度的识别任务。目前已有 423 人学习关注适合希望快速上手深度学习 OCR 实战的读者参考借鉴。1. 验证码识别为什么还在用 ResNet DenseNet 这套组合打开任何一个需要登录的站点验证码这一关总绕不过去。字符扭曲、粘连、背景噪点、干扰线这些设计初衷是拦住脚本但也让传统 OCR 方案频繁翻车。基于深度学习实现的验证码识别模型本质上就是把「看图认字」拆成两个可训练的子任务先定位字符再分类字符。ResNet 负责在深层网络中稳住梯度DenseNet 负责把浅层纹理和深层语义反复拼接两者组合在验证码这种小尺寸、高噪声图像上往往比单一骨干网络更稳。这套方案适合谁如果你手头有一批标注好的验证码图片想跑通一个端到端的 OCR 流程或者正在对比 ResNet、DenseNet 在短字符序列上的实际表现那这篇笔记就是按这个目标写的。它不解决通用文档 OCR 的版面分析问题也不处理韩文、PDF 合同字段抽取这类场景只聚焦一件事固定长度、字符集有限的验证码怎么用 Python 和深度学习模型把它认出来。下面从数据准备、模型搭建、训练调参到避坑一步步拆开讲。2. 验证码数据集怎么造标注、切分与增强的落地细节2.1 验证码字符集与标签编码的确定动手之前先定两件事字符集和验证码长度。常见验证码只包含数字 0-9或者数字加小写字母长度固定为 4 位或 6 位。这个信息决定了模型输出层的维度。假设字符集是0123456789长度 4那么输出就是 4 个位置每个位置 10 类。标签不能直接存字符串要转成索引序列。# 字符集与编码映射 CHARS 0123456789 CHAR_TO_IDX {c: i for i, c in enumerate(CHARS)} IDX_TO_CHAR {i: c for c, i in CHAR_TO_IDX.items()} CAPTCHA_LEN 4 def encode_label(text): 把验证码文本转成定长索引列表 return [CHAR_TO_IDX[c] for c in text] def decode_label(indices): 把模型输出索引还原成文本 return .join(IDX_TO_CHAR[i] for i in indices) # 示例 label 3a7b # 如果字符集含字母需提前加入 CHARS逻辑说明CHAR_TO_IDX和IDX_TO_CHAR是一对互逆映射训练时用encode_label把字符串变成整数列表推理时用decode_label还原。参数说明CAPTCHA_LEN必须和实际验证码长度一致如果训练集里混入了不同长度的样本要么统一裁剪要么在损失函数里加掩码。常见做法是先把字符集固定下来再检查所有标注文件把不在字符集里的字符直接剔除或替换。2.2 数据增强别把验证码增强成另一个字符验证码本身已经包含旋转、扭曲、噪声如果再叠加随机旋转和弹性形变很容易把6变成9把0变成O。我一般只做三类增强小角度旋转±5 度、亮度对比度微调、随机高斯噪声。不做水平翻转因为翻转后的字符可能不在字符集里。import cv2 import numpy as np import random def augment_image(img): 轻量增强旋转、亮度、噪声 h, w img.shape[:2] # 小角度旋转 angle random.uniform(-5, 5) M cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) img cv2.warpAffine(img, M, (w, h), borderValue(255, 255, 255)) # 亮度对比度 alpha random.uniform(0.9, 1.1) beta random.uniform(-10, 10) img cv2.convertScaleAbs(img, alphaalpha, betabeta) # 高斯噪声 noise np.random.normal(0, 5, img.shape).astype(np.uint8) img cv2.add(img, noise) return img逻辑说明cv2.warpAffine的borderValue设为白色是因为多数验证码背景是浅色旋转后边缘填充白色比黑色更接近真实分布。参数说明旋转角度范围控制在 ±5 度亮度系数alpha在 0.9 到 1.1 之间噪声标准差设为 5。如果验证码背景是深色borderValue要改成对应背景色。增强后的图片建议保存一份可视化样本人工抽查有没有把字符改得不可辨认。2.3 训练集、验证集、测试集的切分比例验证码识别容易过拟合因为字符组合有限模型可能记住训练集里的特定噪声模式。切分比例建议 8:1:1并且按字符组合分层抽样保证每个字符在三个集合里出现次数大致均衡。如果某类字符样本太少比如字母l和数字1容易混淆可以单独过采样。from sklearn.model_selection import train_test_split import os # 假设所有图片路径和标签已读入 paths [...] # 图片路径列表 labels [...] # 对应文本标签 # 先分训练验证 和 测试 X_train_val, X_test, y_train_val, y_test train_test_split( paths, labels, test_size0.1, random_state42, stratifylabels ) # 再分训练 和 验证 X_train, X_val, y_train, y_val train_test_split( X_train_val, y_train_val, test_size0.111, random_state42, stratifyy_train_val )逻辑说明stratifylabels保证切分后各字符类别比例一致。参数说明test_size0.1表示测试集占 10%第二次test_size0.111是从剩余 90% 里再切出约 10% 作为验证集最终比例接近 8:1:1。如果某些标签只有一两个样本stratify会报错这时要先合并稀有类别或直接剔除。3. ResNet 与 DenseNet 双骨干怎么搭从输入尺寸到输出头的参数配置3.1 输入层验证码图片的尺寸归一化与通道处理验证码图片原始尺寸可能五花八门但模型输入必须固定。常见做法是缩放到(128, 32)或(160, 40)灰度图转三通道或者直接单通道。如果字符高度只有 20 像素左右缩放到 32 高度足够保留细节宽度按比例缩放后 padding 到固定值避免字符被压扁。import torch from torchvision import transforms transform transforms.Compose([ transforms.Grayscale(num_output_channels3), # 灰度转三通道适配预训练权重 transforms.Resize((32, 128)), # 高度32宽度128 transforms.ToTensor(), transforms.Normalize(mean[0.5, 0.5, 0.5], std[0.5, 0.5, 0.5]) ])逻辑说明Grayscale(num_output_channels3)把单通道灰度图复制成三通道这样可以直接加载 ImageNet 预训练的 ResNet 权重。参数说明Resize((32, 128))是高度在前、宽度在后如果验证码是竖排的要改成(128, 32)。Normalize的均值和标准差设为 0.5把像素值映射到[-1, 1]这是训练稳定性的常见做法。3.2 ResNet 分支用预训练权重加速收敛ResNet 的残差连接能缓解深层网络的梯度消失在验证码这种小图上一般用 ResNet18 或 ResNet34 就够了。直接加载预训练权重把第一层卷积改成适配单通道或保持三通道最后一层全连接去掉只取特征图。import torch.nn as nn from torchvision import models class ResNetBranch(nn.Module): def __init__(self, pretrainedTrue): super().__init__() self.backbone models.resnet18(pretrainedpretrained) # 去掉最后的全连接和池化 self.backbone nn.Sequential(*list(self.backbone.children())[:-2]) def forward(self, x): return self.backbone(x) # 输出形状 [B, 512, H/32, W/32]逻辑说明list(self.backbone.children())[:-2]去掉自适应池化和全连接保留卷积特征。参数说明pretrainedTrue会下载 ImageNet 预训练权重如果网络环境不允许可以设为False从头训练但收敛会慢很多。输出特征图的空间尺寸是输入的高宽各除以 32对于(32, 128)输入输出是(1, 4)也就是 4 个水平位置正好对应 4 位验证码。3.3 DenseNet 分支特征复用与拼接策略DenseNet 的核心是密集连接每一层都接收前面所有层的特征。在验证码识别里这种结构能让浅层的边缘纹理直接传到深层对细笔画字符更友好。同样去掉最后的分类层取全局池化前的特征图。class DenseNetBranch(nn.Module): def __init__(self, pretrainedTrue): super().__init__() self.backbone models.densenet121(pretrainedpretrained) self.backbone nn.Sequential(*list(self.backbone.children())[:-1]) def forward(self, x): return self.backbone(x) # 输出形状 [B, 1024, H/32, W/32]逻辑说明DenseNet121 的输出通道是 1024比 ResNet18 的 512 多一倍。参数说明pretrainedTrue同样加载 ImageNet 权重。两个分支的输出在通道维度拼接后是 1536 通道再经过一个卷积层压缩到合适维度最后接序列输出头。3.4 融合与输出头把特征图变成字符序列两个分支的输出在通道维拼接然后经过几层卷积和池化把高度压到 1宽度保留为验证码长度。最后用 1x1 卷积把通道数映射到字符集大小得到每个位置的分类 logits。class CaptchaModel(nn.Module): def __init__(self, num_chars, captcha_len): super().__init__() self.resnet ResNetBranch() self.densenet DenseNetBranch() self.fusion nn.Sequential( nn.Conv2d(512 1024, 512, 3, padding1), nn.BatchNorm2d(512), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool2d((1, None)), # 高度压到1宽度保留 ) self.classifier nn.Conv1d(512, num_chars, 1) self.captcha_len captcha_len def forward(self, x): f1 self.resnet(x) f2 self.densenet(x) f torch.cat([f1, f2], dim1) f self.fusion(f) # [B, 512, 1, W] f f.squeeze(2) # [B, 512, W] logits self.classifier(f) # [B, num_chars, W] return logits逻辑说明AdaptiveAvgPool2d((1, None))把高度压成 1宽度保持不变这样每个水平位置对应一个字符。参数说明num_chars是字符集大小captcha_len是验证码长度但实际输出宽度由输入宽度和网络下采样决定如果宽度不等于captcha_len需要在损失函数里做对齐或调整输入尺寸。常见做法是确保输入宽度经过下采样后正好等于验证码长度。4. 训练与调参损失函数、优化器和学习率怎么设4.1 CTC 损失还是交叉熵验证码场景的选择依据验证码长度固定、字符间无重叠时直接用交叉熵逐位置分类更简单。如果字符有粘连、长度不固定才考虑 CTC。对于固定 4 位数字验证码交叉熵配合位置对齐就够了。import torch.nn.functional as F def compute_loss(logits, targets): logits: [B, num_chars, W] targets: [B, captcha_len] # 如果 W 和 captcha_len 不一致用自适应池化对齐 if logits.size(2) ! targets.size(1): logits F.adaptive_avg_pool1d(logits, targets.size(1)) loss F.cross_entropy(logits, targets) return loss逻辑说明adaptive_avg_pool1d把输出宽度对齐到标签长度避免因下采样导致的位置错位。参数说明cross_entropy默认对类别维计算所以logits的形状是[B, num_chars, W]targets是[B, captcha_len]。如果字符集里有空白符或填充符要设置ignore_index。4.2 优化器与学习率Adam 还是 SGD验证码识别任务上Adam 收敛更快适合快速验证想法SGD 加动量在精细调参后可能泛化更好。我一般先用 Adam 跑通学习率设 1e-3再根据验证集准确率决定是否换 SGD。import torch.optim as optim model CaptchaModel(num_chars10, captcha_len4) optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.5)逻辑说明weight_decay1e-4是 L2 正则防止过拟合。参数说明StepLR每 10 个 epoch 把学习率乘以 0.5。如果验证集准确率波动大可以把step_size调小到 5或者改用ReduceLROnPlateau根据损失自动降学习率。4.3 批次大小与显存占用小图也要算清楚输入(32, 128)的验证码ResNet18 加 DenseNet121 双骨干显存占用比单骨干高不少。批次大小 64 在 8GB 显存上可能吃紧建议从 32 开始试。from torch.utils.data import DataLoader train_loader DataLoader( train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue )逻辑说明num_workers4启用多进程加载pin_memoryTrue加速 GPU 传输。参数说明如果显存不足先把batch_size降到 16或者把 DenseNet 换成更小的densenet121已经是较小版本再小就要换自定义网络。训练时用torch.cuda.max_memory_allocated()监控显存峰值。4.4 训练过程监控准确率、损失和早停每个 epoch 结束后在验证集上算准确率同时保存验证损失最低的模型。如果连续 5 个 epoch 验证损失不降就早停。best_acc 0.0 patience 5 wait 0 for epoch in range(50): model.train() for imgs, labels in train_loader: imgs, labels imgs.cuda(), labels.cuda() optimizer.zero_grad() logits model(imgs) loss compute_loss(logits, labels) loss.backward() optimizer.step() model.eval() correct, total 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels imgs.cuda(), labels.cuda() logits model(imgs) preds logits.argmax(dim1) correct (preds labels).all(dim1).sum().item() total labels.size(0) acc correct / total if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pth) wait 0 else: wait 1 if wait patience: break scheduler.step()逻辑说明(preds labels).all(dim1)表示整个验证码 4 位全对才算正确这是验证码识别的严格指标。参数说明patience5是早停耐心值scheduler.step()每个 epoch 调一次学习率。如果显存允许可以在验证时也开torch.no_grad()节省内存。5. 避坑与排查验证码识别训练中常见的 5 个翻车现场5.1 损失降不下去准确率卡在 10% 左右现象训练几个 epoch 后损失几乎不变准确率接近随机猜。原因标签编码和模型输出类别顺序不一致或者字符集映射写反了。解决打印一个批次的labels和logits.argmax(dim1)逐位对比。检查CHAR_TO_IDX是否和生成标签时用的映射一致。如果用了预训练权重确认输入归一化的均值和标准差是否匹配。5.2 验证集准确率远低于训练集现象训练集准确率 95%验证集只有 60%。原因过拟合模型记住了训练集的噪声模式。解决增加数据增强的多样性但不要过度加 Dropout 或权重衰减减少模型参数量比如把 DenseNet121 换成更小的自定义网络。另外检查训练集和验证集是否来自同一批验证码生成器如果验证集是另一种字体或干扰线风格分布差异会导致准确率骤降。5.3 某些字符总是认错比如 0 和 O、1 和 l现象整体准确率不错但特定字符混淆严重。原因字符集里同时包含形近字符模型难以区分。解决如果业务允许从字符集里去掉容易混淆的字符或者在损失函数里给这些类别更高权重。也可以单独收集这些字符的样本做针对性增强。常见做法是先把字符集缩小到数字跑通后再逐步加字母。5.4 推理时单张图片预测结果不稳定现象同一张图片多次预测结果偶尔不同。原因模型里有 Dropout 或 BatchNorm 在推理时没切到 eval 模式。解决推理前调用model.eval()并用torch.no_grad()包住前向传播。如果用了Dropout确认推理时关闭。BatchNorm 在eval模式下用运行统计量不再依赖当前批次。5.5 训练速度慢GPU 利用率低现象每个 epoch 耗时很长nvidia-smi显示 GPU 利用率只有 30%。原因数据加载是瓶颈num_workers设得太小或者图片解码在 CPU 上耗时。解决把num_workers调到 CPU 核心数的一半左右开启pin_memoryTrue。如果图片是 JPEG可以提前转成二进制格式或使用 LMDB 存储减少解码开销。另外确认batch_size不要太小否则 GPU 等数据。6. 进阶技巧用测试时增强和模型集成再榨几个点训练到瓶颈后如果还想提升准确率可以试测试时增强TTA和模型集成。TTA 是对同一张测试图做多种轻微变换分别预测后投票。模型集成是训练多个不同初始化的模型推理时平均 logits。这两个方法在验证码识别上通常能再涨 1 到 3 个百分点但推理成本成倍增加。def tta_predict(model, img, transforms_list): 测试时增强多种变换投票 model.eval() all_logits [] with torch.no_grad(): for t in transforms_list: x t(img).unsqueeze(0).cuda() logits model(x) all_logits.append(logits) # 平均 logits avg_logits torch.mean(torch.stack(all_logits), dim0) return avg_logits.argmax(dim1) # 定义多种变换 tta_transforms [ transforms.Compose([transforms.Resize((32, 128)), transforms.ToTensor()]), transforms.Compose([transforms.Resize((34, 130)), transforms.CenterCrop((32, 128)), transforms.ToTensor()]), transforms.Compose([transforms.Resize((32, 128)), transforms.RandomRotation(3), transforms.ToTensor()]), ]逻辑说明tta_predict对每种变换分别前向再对 logits 求平均。参数说明transforms_list里包含不同缩放和轻微旋转注意不要引入会改变字符语义的变换。集成时可以把多个模型的 logits 拼接后平均或者用投票法。我一般会保留验证集上最好的三个模型推理时取平均。还有一个容易被忽略的点验证码识别模型的输出后处理。如果字符集里没有空白符但模型在某些位置输出了低置信度可以设一个置信度阈值低于阈值时拒绝识别或触发重试。这个阈值在验证集上按准确率和覆盖率权衡确定。我自己的习惯是每次上线新模型前先跑一遍历史测试集把混淆矩阵打出来看看哪些字符对最容易错再决定要不要补数据。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站