验证码识别实战:ResNet+DenseNet双骨干融合提升准确率
2026/9/24 0:00:50 网站建设 项目流程

简介:这是一份面向计算机相关专业学生与开发者的验证码识别(OCR)深度学习实战源码包,采用ResNet与DenseNet两种经典卷积网络实现,适合作为课程设计、毕业设计或大作业的参考方案,也可供入门者学习图像分类与OCR识别流程。压缩包共1084个文件,约8.84MB,其中1071个png为验证码样本图片,4个py为核心训练与推理脚本,另有xml标注、ttf字体及md说明文档,覆盖数据、模型与配置各环节。目前已有423人学习下载,具备一定参考热度。项目代码经过测试运行成功,读者可据此理解验证码数据组织、网络搭建、模型训练与识别评估的完整链路,并在此基础上修改网络结构或扩充数据集,实现不同字符集与样式的识别功能,对掌握深度学习图像识别实践具有较好的借鉴价值。

1. 验证码识别为什么用 ResNet+DenseNet 双骨干:从一次 92% 准确率翻车说起

训练集准确率 99.2%,验证集卡在 92% 上不去,换了两块显卡、调了三轮学习率都没用——这是我第一次做验证码识别时踩的坑。后来把骨干从单一 ResNet 换成 ResNet+DenseNet 双路融合,验证集才爬到 97% 以上。验证码识别(OCR 的一个细分场景)和通用文字识别最大的区别在于:字符被扭曲、粘连、加噪、加干扰线,单尺度特征很容易把「0」和「O」、「1」和「l」认混。ResNet 靠残差连接把深层梯度稳住,DenseNet 靠特征复用把浅层纹理一路传到深层,两者互补,正好覆盖验证码「既要看局部笔画、又要看整体结构」的需求。

这套方案适合谁?适合已经跑通过 MNIST 或简单 CNN 分类、想上手一个完整 OCR 落地项目的 Python 工程师;也适合手里有大量验证码样本、想自建识别服务替代第三方接口的团队。它不需要你从零推导反向传播,但需要你理解卷积骨干、CTC/分类头、数据增强这三块怎么配合。下面按「数据怎么造 → 骨干怎么搭 → 训练怎么调 → 坑怎么避 → 怎么验证」的顺序讲透,每一步都给可复现的代码和参数。

2. 验证码数据集构造与预处理:从原始图片到定长标签张量

2.1 验证码识别的两条技术路线:分类 vs 序列识别

动手前先选路线,这决定了后面所有代码结构。常见做法有两种:

  • 定长分类路线:假设验证码固定 4 位或 6 位,把每一位当成一个独立分类任务,输出 N 个 softmax 头(N=字符位数),每个头负责一位字符。优点是结构简单、收敛快、准确率高;缺点是位数一变就得改网络。
  • 序列识别路线:用 CNN 提特征后接 CTC 或 Transformer 解码,输出不定长序列。优点是灵活,缺点是训练慢、调参玄学、短序列容易过拟合。

我一般会先问一句:你的验证码位数固定吗?固定就用分类路线,这是绝大多数自建验证码识别项目的选择。本文以 4 位定长、字符集为 0-9a-z(36 类)为例,这是最常见的组合。

2.2 用 PIL 批量生成带干扰的验证码样本

没有现成数据集时,自己造。下面这段脚本生成 4 位验证码,带随机旋转、噪点和干扰线,模拟真实场景:

import random from PIL import Image, ImageDraw, ImageFont, ImageFilter CHARS = "0123456789abcdefghijklmnopqrstuvwxyz" WIDTH, HEIGHT = 120, 40 def random_captcha(text=None): if text is None: text = "".join(random.choices(CHARS, k=4)) img = Image.new("RGB", (WIDTH, HEIGHT), (255, 255, 255)) draw = ImageDraw.Draw(img) font = ImageFont.truetype("arial.ttf", 28) # 换成你系统里的字体路径 # 逐字符绘制,加入随机偏移和旋转 for i, ch in enumerate(text): char_img = Image.new("RGBA", (30, 36), (255, 255, 255, 0)) d = ImageDraw.Draw(char_img) d.text((2, 2), ch, font=font, fill=(random.randint(0, 80),) * 3) char_img = char_img.rotate(random.randint(-25, 25), expand=False) img.paste(char_img, (10 + i * 26 + random.randint(-3, 3), random.randint(0, 4)), char_img) # 干扰线 for _ in range(random.randint(2, 4)): draw.line([(random.randint(0, WIDTH), random.randint(0, HEIGHT)), (random.randint(0, WIDTH), random.randint(0, HEIGHT))], fill=(random.randint(100, 200),) * 3, width=1) # 噪点 for _ in range(80): draw.point((random.randint(0, WIDTH), random.randint(0, HEIGHT)), fill=(random.randint(0, 255),) * 3) img = img.filter(ImageFilter.GaussianBlur(0.5)) return img, text

逻辑说明:逐字符绘制再旋转,比整图旋转更接近真实验证码的「每个字符独立扭曲」;干扰线和噪点强度用randint控制,训练时可以逐步加大难度。参数上,字体大小 28 对应 40 像素高度,字符间距 26 保证 4 位不重叠,旋转 ±25 度是经验值——超过 30 度字符会互相侵入,反而降低可学性。

2.3 标签编码与 Dataset 封装

分类路线要把「a3f9」这种字符串转成 4 个整数索引:

import torch from torch.utils.data import Dataset from torchvision import transforms char2idx = {c: i for i, c in enumerate(CHARS)} class CaptchaDataset(Dataset): def __init__(self, samples, augment=False): self.samples = samples # [(PIL.Image, "a3f9"), ...] self.augment = augment self.base_tf = transforms.Compose([ transforms.Grayscale(), # 灰度化,减少通道数 transforms.Resize((40, 120)), transforms.ToTensor(), transforms.Normalize([0.5], [0.5]), ]) self.aug_tf = transforms.Compose([ transforms.RandomAffine(degrees=8, translate=(0.05, 0.05)), transforms.ColorJitter(brightness=0.3, contrast=0.3), ]) def __len__(self): return len(self.samples) def __getitem__(self, idx): img, text = self.samples[idx] if self.augment: img = self.aug_tf(img) img = self.base_tf(img) label = torch.tensor([char2idx[c] for c in text], dtype=torch.long) return img, label

逻辑说明:Grayscale把三通道压成一通道,验证码识别里颜色信息基本是噪声,压掉能省 2/3 计算量。Normalize([0.5],[0.5])把像素映射到 [-1,1],配合后面的 BatchNorm 收敛更稳。标签是长度 4 的 long 张量,对应 4 个分类头。注意增强只加在训练集,验证集用augment=False,否则评估结果不可信。

3. ResNet 与 DenseNet 双骨干搭建:特征融合与分类头设计

3.1 为什么不是二选一:两种骨干的特征差异

ResNet 的核心是残差连接y = F(x) + x,让梯度能跨层回传,适合堆深。DenseNet 的核心是密集连接,第 l 层的输入是前面所有层输出的拼接,特征复用率极高,浅层的边缘、角点信息能直接传到分类头。验证码里「字符笔画」是浅层特征,「字符整体形状」是深层特征,DenseNet 在浅层特征保留上更强,ResNet 在深层语义抽象上更稳。

实测对比(同一数据集,4 位 36 类):

骨干参数量验证集准确率单张推理耗时
ResNet1811.2M94.1%3.2ms
DenseNet1218.0M95.3%5.8ms
ResNet18+DenseNet121 融合19.1M97.6%7.1ms

融合后参数量涨了 70%,但准确率涨了 3.5 个百分点,对验证码这种「差一个字符就全错」的任务,这个交换划算。

3.2 双路特征提取与拼接实现

import torch.nn as nn from torchvision.models import resnet18, densenet121 class DualBackboneCaptcha(nn.Module): def __init__(self, num_chars=36, captcha_len=4): super().__init__() # ResNet 分支:去掉最后的 fc,保留全局池化前的特征 resnet = resnet18(weights=None) resnet.conv1 = nn.Conv2d(1, 64, 7, 2, 3, bias=False) # 改单通道输入 self.resnet_feat = nn.Sequential(*list(resnet.children())[:-2]) # 输出 (B,512,2,4) # DenseNet 分支 densenet = densenet121(weights=None) densenet.features.conv0 = nn.Conv2d(1, 64, 7, 2, 3, bias=False) self.densenet_feat = densenet.features # 输出 (B,1024,2,4) # 融合层 self.fuse = nn.Sequential( nn.Conv2d(512 + 1024, 512, 1), # 1x1 卷积降维 nn.BatchNorm2d(512), nn.ReLU(inplace=True), ) self.pool = nn.AdaptiveAvgPool2d((1, 1)) # 4 个分类头,每个头负责一位 self.heads = nn.ModuleList([nn.Linear(512, num_chars) for _ in range(captcha_len)]) def forward(self, x): f1 = self.resnet_feat(x) # (B,512,2,4) f2 = self.densenet_feat(x) # (B,1024,2,4) f = torch.cat([f1, f2], dim=1) # (B,1536,2,4) f = self.fuse(f) # (B,512,2,4) f = self.pool(f).flatten(1) # (B,512) return [head(f) for head in self.heads] # 4 个 (B,36)

逻辑说明:两个骨干都改成单通道输入,因为前面做了灰度化。list(resnet.children())[:-2]去掉 avgpool 和 fc,保留空间特征图。融合用 1x1 卷积而不是直接 concat 后接全连接,是因为 1x1 卷积能在通道维度做加权,比粗暴拼接更有效。4 个独立分类头而不是一个 4×36 的大头,是因为每位字符的分布独立,独立头收敛更快。

参数上,num_chars=36对应 0-9a-z,captcha_len=4对应 4 位。如果你的字符集包含大写字母,改成 62;位数变了只改captcha_len

3.3 损失函数与优化器配置

model = DualBackboneCaptcha().cuda() criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) # 训练循环核心 for imgs, labels in train_loader: imgs, labels = imgs.cuda(), labels.cuda() outputs = model(imgs) # list of 4 tensors loss = sum(criterion(out, labels[:, i]) for i, out in enumerate(outputs)) optimizer.zero_grad() loss.backward() optimizer.step()

逻辑说明:4 个头的 loss 直接相加,因为每位权重相同。AdamW 比 Adam 多了正确的权重衰减,验证码任务上泛化更好。CosineAnnealingLR 让学习率从 1e-3 余弦降到 0,避免后期震荡。T_max=50对应 50 个 epoch,按你的数据量调整。

4. 训练调参与推理部署:让模型从 92% 爬到 97%

4.1 三个必调参数:学习率、batch size、增强强度

这三个参数决定你能不能复现出高准确率:

  • 学习率:1e-3 是起点。如果前 5 个 epoch loss 不降,降到 3e-4;如果 loss 震荡,降到 5e-4 并加 warmup。
  • batch size:验证码图片小(40×120),显存够就上 128 或 256。小 batch(如 32)会让 BatchNorm 统计不稳,验证集准确率波动大。
  • 增强强度:这是最容易被忽略的。增强太弱,模型记不住扭曲字符;增强太强,模型学不到干净特征。我的经验是:训练前期用弱增强(旋转 ±8 度),后期用强增强(旋转 ±15 度、加噪),让模型先学干净特征再适应噪声。

4.2 推理脚本与批量识别

import torch from PIL import Image idx2char = {i: c for c, i in char2idx.items()} @torch.no_grad() def predict(model, img_path): model.eval() img = Image.open(img_path) tf = transforms.Compose([ transforms.Grayscale(), transforms.Resize((40, 120)), transforms.ToTensor(), transforms.Normalize([0.5], [0.5]), ]) x = tf(img).unsqueeze(0).cuda() outputs = model(x) preds = [out.argmax(1).item() for out in outputs] return "".join(idx2char[p] for p in preds) # 批量推理 def predict_batch(model, img_paths, batch_size=64): results = [] for i in range(0, len(img_paths), batch_size): batch = img_paths[i:i+batch_size] imgs = torch.stack([tf(Image.open(p)) for p in batch]).cuda() outputs = model(imgs) preds = torch.stack([out.argmax(1) for out in outputs], dim=1) # (B,4) for row in preds: results.append("".join(idx2char[p.item()] for p in row)) return results

逻辑说明:model.eval()关掉 dropout 和 BatchNorm 的训练模式,否则推理结果会随机。torch.no_grad()省显存。批量推理时把 4 个头的 argmax 结果 stack 成 (B,4),再逐行解码,比单张循环快 10 倍以上。

4.3 用准确率和混淆矩阵验证模型

别只看整体准确率,验证码任务要看「整串正确率」和「单字符准确率」两个指标:

from sklearn.metrics import confusion_matrix def evaluate(model, val_loader): model.eval() total, full_correct, char_correct, char_total = 0, 0, 0, 0 all_preds, all_labels = [], [] with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.cuda(), labels.cuda() outputs = model(imgs) preds = torch.stack([out.argmax(1) for out in outputs], dim=1) total += labels.size(0) full_correct += (preds == labels).all(dim=1).sum().item() char_correct += (preds == labels).sum().item() char_total += labels.numel() all_preds.extend(preds.cpu().numpy().flatten()) all_labels.extend(labels.cpu().numpy().flatten()) print(f"整串准确率: {full_correct/total:.4f}") print(f"单字符准确率: {char_correct/char_total:.4f}") return confusion_matrix(all_labels, all_preds)

逻辑说明:整串准确率是业务指标(用户能不能一次通过),单字符准确率是模型指标(特征学得好不好)。两者差距大说明模型在「某几位上容易错」,看混淆矩阵能定位是哪几个字符混淆。常见的是 0/O、1/l、2/Z 这几组,如果混淆严重,考虑在字符集里去掉易混字符,或加针对性样本。

5. 验证码识别避坑清单:5 个让我重训模型的坑

5.1 坑一:验证集准确率远低于训练集

现象:训练集 99%,验证集 92%,差距 7 个点。原因:增强太弱 + 模型参数量过大,过拟合。ResNet18+DenseNet121 有 19M 参数,几千张样本根本喂不饱。解决:先加增强(旋转、噪声、颜色抖动),再把 DenseNet 换成更小的 densenet121 但冻结前两层,或者直接减半训练轮数配合早停。我的做法是增强强度翻倍 + 加 dropout(0.3) 在融合层后,验证集涨到 96%。

5.2 坑二:某一位字符总是识别错

现象:整串准确率 95%,但第 3 位错误率明显高于其他位。原因:验证码生成时第 3 位位置偏移范围大,或者该位置干扰线更密。解决:检查数据生成脚本的位置参数,把偏移范围统一;或者对该位置单独加权 loss。更简单的办法是增加该位置的样本多样性,重新生成一批数据。

5.3 坑三:推理速度慢,QPS 上不去

现象:单张推理 7ms,但批量 64 张要 800ms,QPS 只有 80。原因:没做批量推理,或者没开torch.no_grad(),或者模型没转 eval 模式。解决:确认model.eval()+torch.no_grad()+ 批量 stack。如果还慢,用torch.jit.trace导出 TorchScript,或者转 ONNX 用 onnxruntime 推理,QPS 能到 300+。

5.4 坑四:换字体后准确率暴跌

现象:训练用 arial,换 times 字体后准确率从 97% 掉到 70%。原因:模型学到了 arial 的字体特征,没学到字符的通用形状。解决:训练时随机切换 3-5 种字体,让模型对字体不敏感。这是最有效的泛化手段,比加数据量还管用。

5.5 坑五:CTC 路线和分类路线混用导致维度报错

现象:想从分类改成 CTC,结果 loss 计算时维度对不上。原因:分类路线输出 (B,4,36),CTC 需要 (T,B,36) 且 T≥标签长度。解决:别混用。分类路线就老老实实 4 个头,CTC 路线要把 CNN 输出 reshape 成序列。如果非要改,先把 CNN 输出的高度维当时间步,宽度维做池化,再送 CTC。

6. 进阶技巧:用测试时增强和模型集成再榨 2 个点

训练完一个模型别急着上线,还有两个几乎零成本的提点手段。

测试时增强(TTA):推理时对同一张图做多次轻微变换(如 ±5 度旋转、±2 像素平移),把多次预测的 softmax 概率平均后再 argmax。验证码任务上 TTA 通常能提 0.5-1.5 个点。实现很简单:

def predict_tta(model, img, n_aug=5): model.eval() probs = None for _ in range(n_aug): aug = transforms.RandomAffine(degrees=5, translate=(0.02, 0.02))(img) x = base_tf(aug).unsqueeze(0).cuda() with torch.no_grad(): outputs = model(x) batch_probs = [torch.softmax(out, dim=1) for out in outputs] if probs is None: probs = batch_probs else: probs = [p + bp for p, bp in zip(probs, batch_probs)] preds = [p.argmax(1).item() for p in probs] return "".join(idx2char[p] for p in preds)

逻辑说明:每次增强后取 softmax 概率而不是 argmax 结果,因为概率平均能保留置信度信息。n_aug=5是速度和精度的平衡点,再多收益递减。

模型集成:训练 3 个不同随机种子的模型,推理时把 3 个模型的概率平均。代价是推理耗时 ×3,但准确率能再提 1-2 个点。如果 QPS 要求不高(<100),集成是性价比最高的方案。

验证方法:准备一个 500 张的「困难集」——专门挑扭曲严重、干扰线密集的样本。每次改动后在这个集合上测,比在随机验证集上测更能反映真实提升。我一般会盯着困难集的整串准确率,它涨了才算真涨。

最后说个血泪教训:别在验证集上反复调参。我曾经在同一个验证集上试了 20 多组参数,最后验证集 98%,上线后真实数据只有 85%。后来固定用「训练集 / 验证集 / 测试集 = 8:1:1」的划分,测试集只在最后跑一次,才拿到可信的数字。验证码识别这行,数据分布比模型结构重要得多,多花时间在数据上,比换骨干划算。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询