简介:基于深度学习实现的验证码识别(OCR)Python源码项目,融合ResNet与DenseNet算法,面向计算机相关专业学生、算法初学者以及需要完成课程设计、大作业或毕设演示的开发者。压缩包内共1084个文件,以1071张验证码样本图片为主,另含4个Python训练/推理脚本、5个XML配置、字体文件及README说明文档,整体仅8.84MB,轻量易用,适合本地快速实验。目前已有423人学习浏览,代码经测试可正常运行,拿来即可上手。借助该项目,读者不仅能理解ResNet与DenseNet在图像分类任务中的实现差异,还能掌握验证码图像预处理、模型训练、评估与OCR识别的完整流程;项目结构清晰,可在现有基础上扩展新算法或适配不同难度验证码,兼具学习借鉴与二次开发价值,也适合作为课程设计或项目立项的初始方案。
1. 验证码识别这块硬骨头,为什么非得搬出 ResNet 和 DenseNet
只要写过爬虫,或者维护过任何带登录态的系统,就一定绕不过验证码这道坎。早期那种纯数字、纯字母的验证码,用 tesseract 加图像预处理勉强能啃下来,但现在的验证码早就不是当年那个老实样子了:扭曲、粘连、旋转、干扰线、点状噪声,甚至字符之间互相重叠。tesseract 这种面向文档识别的 OCR 引擎,碰到这种场景基本就是靠运气。也就是在这个节骨眼上,深度学习方案成了唯一靠谱的出路。
标题里这个 zip 包,做的事情其实很明确:把验证码识别建模成一个图像分类或序列识别问题,用 ResNet 和 DenseNet 这两个经典的卷积网络作为特征提取器,在 Python 环境下完成从训练到推理的完整流程。它解决的痛点是"通用 OCR 识别不了、传统图像处理调参调到崩溃"的这类验证码,也就是字符被刻意扭曲重叠的场景。
这个 zip 里通常不会只有孤零零一个模型文件,而是包含数据生成或预处理脚本、网络定义、训练主程序和推理 demo。对刚接触深度学习的 Python 开发者来说,它的价值在于提供了一个端到端的参照系:从图片输入到字符串输出,每一条数据流都走通了。对已经写过 CNN 分类任务的工程师来说,它的看点在两个地方,一是 ResNet 和 DenseNet 怎么在同一个网络里协作,二是验证码这种"短序列、强噪声"任务在训练时踩的坑。下面这几章,我会顺着一条可复现的路径把这些事全部讲透。
2. ResNet 与 DenseNet 在验证码识别里的结构特性和选型逻辑
2.1 残差连接与密集连接在特征提取上的本质差异
要理解为什么验证码识别项目里会同时出现 ResNet 和 DenseNet,先得搞清楚这两个网络各自在解决什么问题。ResNet 的核心是残差块,它让网络每一层的输出不只是这一层卷积的结果,而是加上输入本身,用公式表达就是 output = F(x) + x 。这个"捷径连接"让梯度可以无损地回传到浅层,所以网络可以堆到 50 层、101 层甚至更深。对验证码识别来说,深度意味着网络能组合出更抽象的视觉模式,比如把"几条扭曲的弧线"识别成字符的局部笔画。
DenseNet 走的是另一条路。它把每一层之前所有层的特征图都拼接起来作为输入,自己的输出又会被后续所有层接收,形成一个密集连接块,即 x_l = H_l([x_0, x_1, ..., x_{l-1}]) 。这种设计带来一个非常实际的优势:特征被反复利用。对验证码这种字符密集、纹理丰富的小图,浅层提取到的边缘信息、纹理信息到了深层依然保留着,不会被深层卷积"稀释"掉。DenseNet 的参数量也比同深度的 ResNet 少,因为它不需要每个残差块都学一个完整的残差映射,很多特征直接拿前面的来用就行。
2.1.1 梯度路径长度对验证码训练的直接影响
验证码数据集大多存在一个特性:训练样本数量有限,除非你特意做大规模合成数据。在数据量不充裕的时候,梯度能不能稳定回传直接决定了网络能不能收敛。ResNet 的捷径把梯度路径缩短到"每个块内部",DenseNet 则通过密集连接让每一层都能直接获得来自损失函数的梯度信号,这两个机制都能缓解梯度消失。在实际项目中,如果只用一个 10 层左右的普通 CNN,训练到后期特征图会出现明显的梯度衰减,靠 BN 层也救不回来。换上 ResNet 骨干后,这个问题基本就消失了。
2.2 常见的 ResNet+DenseNet 混合拼法
zip 包里的"ResNet+DenseNet"更可能指两种形态:一种是双分支并联结构,图像分别经过一个 ResNet 分支和一个 DenseNet 分支,最后把特征拼在一起过分类头;另一种是串联结构,前几层用 DenseNet 的密集块提取细粒度特征,后面接 ResNet 的残差块做高层语义抽象。这两种形态我在项目里都试过,并联结构效果通常更稳,因为两个分支独立提取特征再融合,相当于两个视角的投票;串联结构参数量更小,但对学习率的敏感度更高。
从工程角度给出一个可参考的对比:
| 对比项 | ResNet18 | ResNet34 | DenseNet121 |
|---|---|---|---|
| 参数量 | 约 11M | 约 21M | 约 7M |
| 特征复用能力 | 低,各层独立学习残差 | 低,同上 | 高,密集拼接 |
| 对过拟合的耐受度 | 一般 | 较弱 | 较好 |
| 推理速度 | 快 | 中等 | 中等 |
| 适合验证码的体感 | 字符数多时表现稳 | 样本量大时更优 | 小样本、字符粘连时优 |
对验证码这个任务,我的选择基准是:如果样本量在一万张以内,优先 DenseNet 或并联结构;如果样本量超过五万,ResNet 系列的稳定性更值得信赖。
2.3 为什么不直接用 Transformer 或 CRNN
验证码长度通常是 4 到 6 个字符,属于极短序列。CRNN 这类序列模型需要把图像按时间步切分,对字符宽度一致性的要求比较高;验证码字符宽度天然不一致,切分容易把字符从中砍断。Transformer 虽然能建模长距离依赖,但验证码图片里的字符之间没有明显的语义依赖,注意力机制的优势发挥不出来,反而增加了训练数据需求量。ResNet 和 DenseNet 属于纯卷积模型,平移不变性使得字符无论出现在图片哪个位置都能被同样对待,恰好匹配验证码的结构特性。这也是这个 zip 选型逻辑上站得住脚的根因。
3. 基于 Python 与 PyTorch 复现验证码识别流程的完整实现
3.1 构造验证码数据集:合成生成与真实样本的取舍
没有数据就谈不上训练。验证码识别项目里最常见的做法是先合成一批数据,因为验证码生成逻辑相对固定,字体文件、扭曲方式、干扰元素都可以直接通过代码控制。我自己一般用 PIL 的 ImageFont 和 ImageDraw 来合成,OpenCV 做扭曲和后处理。
import numpy as np
import cv2
from PIL import Image, ImageDraw, ImageFont, ImageFilter
class CaptchaGenerator:
def __init__(self, font_path, char_set, width=160, height=60):
self.font = ImageFont.truetype(font_path, size=32)
self.char_set = char_set # 例如 string.digits + string.ascii_uppercase
self.width = width
self.height = height
def generate(self, length=4):
# 随机选取 length 个字符组成验证码文本
text = ''.join(np.random.choice(list(self.char_set), size=length))
image = Image.new('RGB', (self.width, self.height), (255, 255, 255))
draw = ImageDraw.Draw(image)
char_imgs = []
# 逐字符绘制,并加入随机旋转
for i, ch in enumerate(text):
char_img = Image.new('RGBA', (40, 50), (255, 255, 255, 0))
char_draw = ImageDraw.Draw(char_img)
char_draw.text((5, 5), ch, font=self.font, fill=(0, 0, 0, 255))
angle = np.random.uniform(-25, 25)
char_img = char_img.rotate(angle, expand=True)
char_imgs.append(char_img)
# 横向拼接字符并添加字符间距随机偏移
x_offset = 10
for char_img in char_imgs:
image.paste(char_img, (x_offset, 5), char_img)
x_offset += char_img.width + np.random.randint(2, 6)
# 转换为 OpenCV 图像加干扰线和噪点
cv_image = np.array(image)
for _ in range(np.random.randint(3, 6)):
pt1 = (np.random.randint(0, self.width), np.random.randint(0, self.height))
pt2 = (np.random.randint(0, self.width), np.random.randint(0, self.height))
cv2.line(cv_image, pt1, pt2, (128, 128, 128), 1)
return text, cv_image
这段代码做三件事:逐字符渲染并随机旋转模拟扭曲、横向拼接制造字符间距不均、绘制干扰线增加识别难度。参数上有两个调整空间: font_size 决定字符在图片中的占比,太大字符容易溢出,太小特征不明显,通常是图片高度的二分之一到三分之二;旋转角度 angle 不要超过 30 度,否则字符拓扑结构破坏得太彻底,训练阶段的难度曲线会失控。
真实样本的价值在于它包含合成数据无法覆盖的噪声分布,比如扫描伪影、反色、渐变背景。常见的做法是用合成数据预训练,再用少量真实样本微调。这个策略能平衡数据量和真实分布之间的缺口。
3.2 编码方案选型:定长多标签分类与 CTC
验证码识别有两种建模方式。第一种是定长多标签分类,把图片理解成"四个独立字符拼在一起",输出层是 4 个分支,每个分支做一个字符集的分类。第二种是 CTC 序列识别,网络输出一个概率序列,通过 CTC 解码对齐到最终字符串。定长方式的网络结构简单、loss 直接用交叉熵就好;缺点是字符数必须固定,且字符间距变化时边界定位容易出错。CTC 方式不需要显式切分字符,能处理变长输出,但训练收敛更慢,对后端 LSTM 或注意力模块的依赖更强。
这个标题下的项目多半采用定长方案,因为验证码长度固定是它的一个固有特性,没必要牺牲训练稳定性去上 CTC。我建议从定长入手,把主干跑通后再考虑升级到 CTC。
3.3 ResNet+DenseNet 混合骨干的 PyTorch 落地
基于 PyTorch 实现混合骨干,需要先定义两类基础块:DenseBlock 和 BasicBlock。PyTorch 官方 torchvision 里自带 ResNet 实现,DenseNet 的 DenseBlock 也可以直接 import,但既然要混合拼装,不如自己写一份更灵活的。
import torch
import torch.nn as nn
from torchvision.models import resnet18, densenet121
class HybridBackbone(nn.Module):
def __init__(self, num_classes, num_chars=4):
super().__init__()
# 加载预训练权重但不保留最后的全连接层
self.resnet = resnet18(weights=None)
# 去掉最后的池化和全连接
self.resnet = nn.Sequential(*list(self.resnet.children())[:-2])
self.densenet = densenet121(weights=None)
# DenseNet121 最后的特征层是 1024 通道
# 取出从 input 到最后一个 DenseBlock 的输出,去掉 BN 层
self.densenet = nn.Sequential(
self.densenet.features[:-1]
)
self.resnet_fc = nn.Conv2d(512, 256, kernel_size=1)
self.densenet_fc = nn.Conv2d(1024, 256, kernel_size=1)
# 全局平均池化后接全连接,输出 N*num_chars 个类别
self.classifier = nn.Linear(256 * 2, num_chars * num_classes)
def forward(self, x):
# x: (batch, 3, height, width)
feat_res = self.resnet(x) # (batch, 512, h/32, w/32)
feat_den = self.densenet(x) # (batch, 1024, h/32, w/32)
# 用 1x1 卷积统一通道数,便于特征拼接
feat_res = torch.relu(self.resnet_fc(feat_res))
feat_den = torch.relu(self.densenet_fc(feat_den))
# 在通道维度上拼接
feat_cat = torch.cat([feat_res, feat_den], dim=1) # (batch, 512, h/32, w/32)
feat_cat = torch.mean(feat_cat, dim=[2, 3]) # 全局平均池化
logits = self.classifier(feat_cat)
# reshape 成 (batch, num_chars, num_classes)
return logits.view(-1, 4, num_classes)
resnet18 和 densenet121 的 children() 结构差异比较大,从 torchvision 里拆出来再拼接时最容易出问题的就是 DenseNet 的 features 模块末尾带了 BN 和池化层,直接整段取出来会导致后续 1x1 卷积的输入尺寸对不上。所以代码里取的是 features[:-1] 。 resnet18 的最后一个卷积 block 输出 512 通道, densenet121 是 1024 通道,1x1 卷积把两边统一到 256 通道,拼接后就是 512 维特征向量,两边信息没有哪一方占主导地位。
不用 nn.Linear 直接接 nn.AdaptiveAvgPool2d 的理由是验证码图片分辨率偏低,全局平均池化能最大化保留空间位置特征,对字符处于不同横向位置的场景更有容忍度。
3.4 训练主循环与损失函数配置
训练过程中有两个关键点:标签编码和多标签损失。标签编码需要把字符串映射成整数序列,例如 "AB3C" 映射为 [10, 11, 3, 12] ;损失函数用交叉熵,但维度要调整成 (batch * num_chars, num_classes) 。
def train_one_epoch(model, dataloader, optimizer, criterion, device):
model.train()
total_loss = 0.0
correct_chars = 0
total_chars = 0
for images, labels in dataloader:
# labels: (batch, 4) 每个元素是字符在字符表中的索引
images = images.to(device)
labels = labels.to(device)
logits = model(images) # (batch, 4, num_classes)
# 将 (batch, 4, num_classes) 与 (batch, 4) 对齐
loss = criterion(logits.reshape(-1, logits.size(-1)), labels.reshape(-1))
optimizer.zero_grad()
loss.backward()
# 梯度裁剪防止极端样本把参数推飞
nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step()
total_loss += loss.item() * images.size(0)
# 计算字符级准确率
preds = logits.argmax(dim=-1)
correct_chars += (preds == labels).sum().item()
total_chars += labels.numel()
return total_loss / len(dataloader.dataset), correct_chars / total_chars
交叉熵默认接受 (N, C) 的预测维度和 (N,) 的目标维度,所以先做 reshape(-1, num_classes) 把 4 个字符的预测摊平成 N*4 行。 clip_grad_norm_ 在验证码任务里值得加,原因在于干扰线和字符粘连会让极少量样本产生异常大的梯度,不裁剪的话一个 batch 就可能把整个学习率调度周期毁掉。
优化器方面,我一般用 SGD 加 momentum 0.9,初始学习率 0.01;Adam 收敛快但最终精度通常略低,而且对 weight decay 更敏感。学习率调度用 CosineAnnealingLR 或 StepLR(milestone=每 15 个 epoch 衰减 0.1) 都可以,前者在样本量小时更平滑,后者更易解释。
3.5 推理阶段:从概率矩阵到字符串
训练完成后,推理阶段要把模型的概率输出翻译回字符串,核心逻辑是逐字符取概率最大类。
def decode_predictions(logits, idx_to_char):
# logits: (4, num_classes)
pred_indices = logits.argmax(dim=-1).cpu().numpy()
text = ''.join([idx_to_char[i] for i in pred_indices])
return text
这个解码直接依赖训练时的字符表顺序,所以训练脚本和推理脚本里维护同一份 char_set 并保持顺序一致很重要。字符表一旦变了,模型类别索引就全错位。推理时常见的工程手段是重复解码三次,对三个结果做字符级投票,能有效减少单次预测的随机误差。
4. 训练参数配置与高频故障排查清单
4.1 必调超参数:学习率、批大小、图片尺寸的组合策略
验证码图片输入尺寸直接影响网络 receptive field 与字符特征的平衡。图片太小,字符笔画会糊成一片;太大则计算量上升。经验值是高度 60、宽度 160,如果要覆盖 6 位字符则宽度换成 200。这两组尺寸都要保证能被 32 整除,因为 ResNet 和 DenseNet 都有 5 次下采样,最终特征图尺寸是输入的 1/32。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 160x60 或 200x60 | 宽度按最大字符数调整,高度按字体大小微调 |
| batch size | 64 ~ 128 | 显存不够优先降 batch,不降输入尺寸 |
| 初始学习率 | 0.01(SGD) / 0.001(Adam) | 使用预训练权重时调低一个数量级 |
| weight decay | 1e-4 到 5e-4 | 验证码样本量小,过拟合风险高,不宜低于 1e-4 |
| 字符集大小 | 36 或 62 | 是否区分大小写看业务需求,类别数影响收敛速度 |
| Epoch | 30 ~ 50 | 合成数据下 30 epoch 基本能到 95% 以上字符准确率 |
batch size 与学习率需要联动。如果 batch 从 128 降到 32,学习率也对应降到原来的四分之一,否则梯度噪声变大容易震荡。用预训练权重时,因为浅层特征已经被 ImageNet 训练得很好了,学习率开太大反而会破坏已有特征,踩过这个坑的都知道有多难受。
4.2 训练中后期如何判断模型状态:损失曲线与 EMA
很多人在训练验证码模型时犯的错误是只看总 loss 下降趋势,忽略字符级准确率曲线的抖动。验证码训练过程里,loss 曲线通常在前 5 个 epoch 快速下降,之后进入平台期;这时应该关注的是字符级准确率是否突破 95%。如果 loss 下降但准确率停滞,大概率是陷入局部最优,常见解是提高学习率并配上 warmup restart,或者直接换优化器重跑。
EMA(Exponential Moving Average)在验证码识别上是见效很快的技巧。它让模型参数取历史权重的滑动平均,能有效抑制单 batch 噪声带来的参数抖动。
class EMAModel:
def __init__(self, model, decay=0.999):
self.model = model
self.decay = decay
self.shadow = {}
self.backup = {}
for name, param in model.named_parameters():
if param.requires_grad:
self.shadow[name] = param.data.clone()
def update(self):
for name, param in self.model.named_parameters():
if param.requires_grad:
self.shadow[name].mul_(self.decay).add_(param.data, alpha=1 - self.decay)
def apply_shadow(self):
for name, param in self.model.named_parameters():
if param.requires_grad:
self.backup[name] = param.data.clone()
param.data.copy_(self.shadow[name])
def restore(self):
for name, param in self.model.named_parameters():
if param.requires_grad:
param.data.copy_(self.backup[name])
在验证集上评测前先调用 apply_shadow() ,评测完调 restore() 回到原始参数,这是 EMA 的标准用法。 decay=0.999 在 batch size 较小的场景下效果优于 0.99,因为它保留了更长历史的平均信息。EMA 对最终模型准确率的提升通常在 0.5% 到 1.5% 之间,这在验证码批量识别的场景里已经是质变级收益。
4.3 高频故障定位:不收敛、重复预测同一字符、字符错位
三个高频问题分别对应不同的处理路径:
第一,loss 从一开始就不降。先检查数据归一化是不是直接把像素除以 255,而输入网络前又做了一遍标准化;再检查标签和输出的 shape 是否匹配, reshape(-1, num_classes) 时如果标签维度不是 N*4 就会直接显式报错或静默对齐错误。
第二,模型老是预测成同一个字符。这种问题多半出在字符集样本不均衡上。合成生成器如果随机选择字符均匀分布,理论上不会出现;但真实样本中"0"和"o"、"1"和"l"这类混淆字符对会让模型偏好其中一个。解决办法是在 loss 里给高频混淆字符分配略低的权重,或对特定字符对做数据增强补样本。
第三,整图识别的字符顺序错位。常见原因是图片宽度不一致导致 resize 时字符横向比例被压缩,字符串结果整体偏移一位。排查方法是打印模型对每个字符位置的置信度矩阵,看第 2 个字符位置是不是总在高置信度地预测第 3 个字符。如果是,大概率是宽度缩放过狠了,把 height / width 比例固定下来即可。
提示:遇到这类字符错位问题时,先检查数据 transform 里是否有
RandomResizedCrop这类会改变纵横比的增强操作。验证码字符的横向分布信息是序列解码的关键,纵横比一变,序列语义就毁了。
5. 批量识别场景下的精度提升与部署技巧
模型训练出来后,离"能用"还差一步:在批量识别场景里稳定运行。验证码识别通常是高并发、低延迟的调用需求,比如批量处理历史库里的存量验证码,这时候单张推理的毫秒级性能反而不是瓶颈,批量吞吐、异常过滤、模型导出才是真正的考卷。
先把模型转换成 ONNX 或 TorchScript 格式。ONNX 导出时有一个需要手动解决的坑:dense 连接和残差连接在 PyTorch 的动态图里能正常跑,但 ONNX 静态图对部分 reshape 操作支持不友好。导出前把输入尺寸固定成 (1, 3, 60, 160) ,用 torch.onnx.export 的 fixed_batch_size 参数锁定 batch 维度,能避免后续推理时出现显式的 shape 不匹配。我一般在导出前跑一遍模型在 ONNX Runtime 下的推理,跟 PyTorch 的原始输出逐元素比对,误差超过 1e-5 就排查算子兼容性。
批量推理的另一个收益点是用上置信度阈值过滤。单次识别结果携带的置信度信息不是直接对字符取 argmax 后那一下的下标,而是对每个字符位置的 softmax 概率值做乘积。连续的 4 个字符概率乘积低于某个阈值时,说明这张图大概率是难样本,应当做人工标注回流训练集,而不是把低置信度的错误结果直接写进业务库。这个过滤策略在大多数验证码纬度上能把整体准确率从 90% 拉到 97%,而且不可见成本几乎为零。
最后一个实用技巧是简单 TTA(Test-Time Augmentation)。对验证码这种小图来说,全图的轻度缩放(0.9 到 1.1 倍)加水平翻转,三次推理结果做字符级投票,能抵消单张图中偶然的噪声干扰。效果在干扰线非常重的样本上尤其明显。这个技巧不增加训练成本,只牺牲两次推理时间,在批量离线场景里非常值。
验证码识别做到这个程度,就已经从"能不能识别"进阶到了"能不能稳定识别"。把置信度过滤和 TTA 这两个技巧叠加进线上流程,配合每一批新样本的少量人工回流,就能形成一条可持续进化的数据闭环。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_29046611/article/details/165272928



