简介这是一份面向计算机相关专业学生与开发者的验证码识别OCR深度学习实战源码包采用ResNet与DenseNet两种经典卷积网络实现适合作为课程设计、毕业设计或大作业的参考方案也可供入门者学习图像分类与OCR识别流程。压缩包共1084个文件约8.84MB其中1071个png为验证码样本图片4个py为核心训练与推理脚本另有xml标注、ttf字体及md说明文档覆盖数据、模型与配置各环节。目前已有423人学习下载具备一定参考热度。项目代码经过测试运行成功读者可据此理解验证码数据组织、网络搭建、模型训练与识别评估的完整链路并在此基础上修改网络结构或扩充数据集实现不同字符集与样式的识别功能对掌握深度学习图像识别实践具有较好的借鉴价值。1. 验证码识别为什么用 ResNetDenseNet 双骨干从一次 92% 准确率翻车说起训练集准确率 99.2%验证集卡在 92% 上不去换了两块显卡、调了三轮学习率都没用——这是我第一次做验证码识别时踩的坑。后来把骨干从单一 ResNet 换成 ResNetDenseNet 双路融合验证集才爬到 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-z36 类为例这是最常见的组合。2.2 用 PIL 批量生成带干扰的验证码样本没有现成数据集时自己造。下面这段脚本生成 4 位验证码带随机旋转、噪点和干扰线模拟真实场景import random from PIL import Image, ImageDraw, ImageFont, ImageFilter CHARS 0123456789abcdefghijklmnopqrstuvwxyz WIDTH, HEIGHT 120, 40 def random_captcha(textNone): if text is None: text .join(random.choices(CHARS, k4)) 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, fontfont, fill(random.randint(0, 80),) * 3) char_img char_img.rotate(random.randint(-25, 25), expandFalse) 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, width1) # 噪点 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, augmentFalse): 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(degrees8, translate(0.05, 0.05)), transforms.ColorJitter(brightness0.3, contrast0.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], dtypetorch.long) return img, label逻辑说明Grayscale把三通道压成一通道验证码识别里颜色信息基本是噪声压掉能省 2/3 计算量。Normalize([0.5],[0.5])把像素映射到 [-1,1]配合后面的 BatchNorm 收敛更稳。标签是长度 4 的 long 张量对应 4 个分类头。注意增强只加在训练集验证集用augmentFalse否则评估结果不可信。3. ResNet 与 DenseNet 双骨干搭建特征融合与分类头设计3.1 为什么不是二选一两种骨干的特征差异ResNet 的核心是残差连接y F(x) x让梯度能跨层回传适合堆深。DenseNet 的核心是密集连接第 l 层的输入是前面所有层输出的拼接特征复用率极高浅层的边缘、角点信息能直接传到分类头。验证码里「字符笔画」是浅层特征「字符整体形状」是深层特征DenseNet 在浅层特征保留上更强ResNet 在深层语义抽象上更稳。实测对比同一数据集4 位 36 类骨干参数量验证集准确率单张推理耗时ResNet1811.2M94.1%3.2msDenseNet1218.0M95.3%5.8msResNet18DenseNet121 融合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_chars36, captcha_len4): super().__init__() # ResNet 分支去掉最后的 fc保留全局池化前的特征 resnet resnet18(weightsNone) resnet.conv1 nn.Conv2d(1, 64, 7, 2, 3, biasFalse) # 改单通道输入 self.resnet_feat nn.Sequential(*list(resnet.children())[:-2]) # 输出 (B,512,2,4) # DenseNet 分支 densenet densenet121(weightsNone) densenet.features.conv0 nn.Conv2d(1, 64, 7, 2, 3, biasFalse) 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(inplaceTrue), ) 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], dim1) # (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_chars36对应 0-9a-zcaptcha_len4对应 4 位。如果你的字符集包含大写字母改成 62位数变了只改captcha_len。3.3 损失函数与优化器配置model DualBackboneCaptcha().cuda() criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) # 训练循环核心 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_max50对应 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_size64): results [] for i in range(0, len(img_paths), batch_size): batch img_paths[i:ibatch_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], dim1) # (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], dim1) total labels.size(0) full_correct (preds labels).all(dim1).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 个点。原因增强太弱 模型参数量过大过拟合。ResNet18DenseNet121 有 19M 参数几千张样本根本喂不饱。解决先加增强旋转、噪声、颜色抖动再把 DenseNet 换成更小的 densenet121 但冻结前两层或者直接减半训练轮数配合早停。我的做法是增强强度翻倍 加 dropout(0.3) 在融合层后验证集涨到 96%。5.2 坑二某一位字符总是识别错现象整串准确率 95%但第 3 位错误率明显高于其他位。原因验证码生成时第 3 位位置偏移范围大或者该位置干扰线更密。解决检查数据生成脚本的位置参数把偏移范围统一或者对该位置单独加权 loss。更简单的办法是增加该位置的样本多样性重新生成一批数据。5.3 坑三推理速度慢QPS 上不去现象单张推理 7ms但批量 64 张要 800msQPS 只有 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_aug5): model.eval() probs None for _ in range(n_aug): aug transforms.RandomAffine(degrees5, 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, dim1) 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_aug5是速度和精度的平衡点再多收益递减。模型集成训练 3 个不同随机种子的模型推理时把 3 个模型的概率平均。代价是推理耗时 ×3但准确率能再提 1-2 个点。如果 QPS 要求不高100集成是性价比最高的方案。验证方法准备一个 500 张的「困难集」——专门挑扭曲严重、干扰线密集的样本。每次改动后在这个集合上测比在随机验证集上测更能反映真实提升。我一般会盯着困难集的整串准确率它涨了才算真涨。最后说个血泪教训别在验证集上反复调参。我曾经在同一个验证集上试了 20 多组参数最后验证集 98%上线后真实数据只有 85%。后来固定用「训练集 / 验证集 / 测试集 8:1:1」的划分测试集只在最后跑一次才拿到可信的数字。验证码识别这行数据分布比模型结构重要得多多花时间在数据上比换骨干划算。希望帮到你。本文还有配套的精品资源点击获取
