简介:这是一套面向Python与深度学习入门者的端到端验证码识别实战资源,基于CNN卷积神经网络构建,无需图片字符切割、尺寸归一化或特征提取等繁琐预处理,直接输入验证码图片即可输出识别结果。资源包共52个文件,以40张png验证码样本、8个py脚本为主,另含docx设计报告、md说明与license等,压缩包约899KB,涵盖验证码生成、训练、预测与模型定义等完整模块。项目采用pytorch框架配合ImageCaptcha生成训练、测试与预测数据集,纯四位数字识别率可达99.99%以上,数字加大小写字母混合识别率约96%,适合想掌握CNN图像分类与端到端识别流程的开发者参考。目前已有642人学习下载,读者可获取完整源码、数据集与设计报告,理解从数据生成到模型训练再到预测的完整链路,并据此复现或改造自己的验证码识别方案。
1. 验证码识别为什么让神经网络成了刚需
做爬虫、做自动化测试、做数据采集的人,迟早会撞上验证码这堵墙。字符扭曲、粘连、干扰线、背景噪点,传统 OCR 一上就废,规则写得越多越像在给自己挖坑。Python 加神经网络这套组合,是目前识别各类图形验证码最稳的路线:卷积神经网络负责从像素里抽特征,循环网络或 CTC 负责处理不定长字符序列,训练完之后推理速度可以压到毫秒级。这篇笔记面向的是已经会写 Python、想把这套东西真正跑起来的工程师,从数据生成、模型搭建、训练调参到部署推理,每一步都给可复现的命令和代码。不聊虚的,直接讲怎么让模型在你的验证码上跑通。
2. 验证码数据从哪来:生成、标注与预处理
2.1 没有数据集就自己造:用 captcha 库批量生成
真实场景里你不可能手动标几万张验证码,常见做法是用captcha库按目标风格生成训练集。先装依赖:
pip install captcha pillow numpy opencv-python torch torchvision生成脚本如下,关键参数是字符集、长度和干扰强度,要尽量贴近你要识别的真实验证码:
from captcha.image import ImageCaptcha import os, random # 字符集要和目标验证码一致,别多也别少 CHARS = '0123456789abcdefghijklmnopqrstuvwxyz' LENGTH = 4 SAVE_DIR = 'dataset/train' os.makedirs(SAVE_DIR, exist_ok=True) image = ImageCaptcha(width=160, height=60, font_sizes=(28, 32, 36)) for i in range(50000): text = ''.join(random.choices(CHARS, k=LENGTH)) # 每次生成都带随机干扰线和噪点,模拟真实场景 img = image.generate_image(text) img.save(f'{SAVE_DIR}/{text}_{i}.png')逻辑说明:文件名里直接带标签,省掉单独维护标注文件的麻烦。font_sizes给多个值是为了让模型见过不同字号的字符,避免只认一种字体。生成 5 万张大概占 1.5GB 磁盘,训练前不用全加载进内存。
参数说明:width/height决定输入尺寸,后面网络的第一层要对应改;LENGTH是验证码位数,如果目标有 4 位也有 6 位,建议分开训两个模型,混在一起 CTC 会很难收敛。
2.2 图像预处理:灰度化、归一化和尺寸统一
生成出来的图是 RGB 三通道,但验证码识别里颜色信息基本是噪声,转灰度能砍掉三分之二的输入维度:
import cv2 import numpy as np def preprocess(img_path, target_size=(160, 60)): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) # 统一尺寸,双线性插值比最近邻更平滑 img = cv2.resize(img, target_size, interpolation=cv2.INTER_LINEAR) # 归一化到 [0,1],加速收敛 img = img.astype(np.float32) / 255.0 # 增加通道维度,变成 (1, H, W) img = np.expand_dims(img, axis=0) return img逻辑说明:灰度化之后每个像素只剩一个值,网络参数量直接降下来。归一化是必须的,不归一化的话学习率要设得很小,训练慢得让人怀疑人生。
参数说明:target_size要和生成时的尺寸一致,如果真实验证码尺寸不固定,建议先统计一下宽高分布,取出现最多的那个尺寸,其余靠 resize 补齐。
提示:如果你的验证码有彩色干扰线,灰度化之后干扰线可能和字符混在一起,这时候可以加一步自适应二值化,但别过度处理,过度处理会把字符笔画也削掉。
3. 模型选型:CNN、CRNN 还是 CTC,到底用哪个
3.1 定长验证码:CNN 多分类头最省事
如果验证码固定 4 位、字符集 36 个,那本质上是 4 个独立的 36 分类问题。用 CNN 提特征,接 4 个全连接头,每个头输出 36 维,交叉熵求和。这种结构简单、收敛快、推理也快,是定长场景的首选。
import torch import torch.nn as nn class CaptchaCNN(nn.Module): def __init__(self, num_chars=36, length=4): super().__init__() self.length = length self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.fc = nn.Linear(128 * 20 * 7, 512) # 每个字符位置一个分类头 self.heads = nn.ModuleList([nn.Linear(512, num_chars) for _ in range(length)]) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) x = torch.relu(self.fc(x)) return [head(x) for head in self.heads]逻辑说明:三个卷积块逐步把空间维度压小、通道数拉大,最后展平接全连接。heads是独立的,每个位置单独算 loss,这样某个位置学得快不会拖累其他位置。
参数说明:128 * 20 * 7这个数要按你的输入尺寸算,输入 160x60 经过三次池化变成 20x7,通道 128,所以是 128207。输入尺寸变了这里必须改,否则 forward 会报维度错误。
3.2 不定长验证码:CRNN + CTC 是标准答案
验证码位数不固定,或者字符之间有重叠、粘连,CNN 多分类头就不够用了。这时候上 CRNN:CNN 提特征,BiLSTM 建模序列依赖,CTC 解决对齐问题。这是 OCR 领域的经典结构,验证码识别直接拿来用就行。
class CRNN(nn.Module): def __init__(self, num_chars=37): # 36个字符 + 1个blank super().__init__() self.cnn = nn.Sequential( nn.Conv2d(1, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(128, 256, 3, padding=1), nn.ReLU(), nn.MaxPool2d((2, 1)), # 高度池化,宽度保留 ) self.rnn = nn.LSTM(256, 128, bidirectional=True, batch_first=True) self.fc = nn.Linear(256, num_chars) def forward(self, x): x = self.cnn(x) # (B, 256, H', W') x = x.mean(dim=2) # 高度维平均,变成 (B, W', 256) x, _ = self.rnn(x) return self.fc(x) # (B, T, num_chars)逻辑说明:MaxPool2d((2, 1))只在高度方向池化,宽度方向保留,因为宽度对应字符序列的时间步。mean(dim=2)把高度维压掉,得到序列特征。CTC 的 blank 类别是必须的,用来分隔相邻重复字符。
参数说明:num_chars要加 1,多出来的就是 blank。LSTM 的 hidden 设 128 是经验值,验证码序列短,再大容易过拟合。
3.3 损失函数和优化器怎么配
定长场景用CrossEntropyLoss,每个头单独算再求和。CTC 场景用CTCLoss,注意blank的索引要设对:
# 定长 criterion = nn.CrossEntropyLoss() # CTC ctc_loss = nn.CTCLoss(blank=36, zero_infinity=True) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)逻辑说明:zero_infinity=True是 CTC 的后悔药,不加的话遇到某些样本 loss 会变成 inf,训练直接崩。Adam 对验证码这种小任务够用,学习率 1e-3 起步,10 个 epoch 降一半。
参数说明:blank=36对应字符集大小,如果你的字符集是 36 个,blank 就是索引 36。这个数写错 loss 会一直不降,排查起来很费时间。
4. 训练、验证与推理:把模型真正跑起来
4.1 训练循环里必须盯住的三个指标
训练代码本身不复杂,但有几个地方不注意就会白跑:
for epoch in range(50): model.train() total_loss = 0 for imgs, labels in train_loader: optimizer.zero_grad() outputs = model(imgs) # 定长场景:每个头单独算loss loss = sum(criterion(out, labels[:, i]) for i, out in enumerate(outputs)) loss.backward() # 梯度裁剪,防止LSTM梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5) optimizer.step() total_loss += loss.item() scheduler.step() print(f'epoch {epoch}, loss {total_loss / len(train_loader):.4f}')逻辑说明:梯度裁剪对 CRNN 尤其重要,LSTM 容易梯度爆炸,不加裁剪 loss 会突然变成 nan。每个 epoch 打印平均 loss,正常情况前 5 个 epoch 应该明显下降,如果不动,先检查学习率和数据标签对不对。
参数说明:max_norm=5是常用值,太小会限制学习,太大等于没裁。batch_size建议 64 或 128,太小训练不稳定,太大显存吃不消。
4.2 验证集上算准确率:字符级和整图级要分开看
loss 降了不代表识别对了,必须算准确率。字符级准确率看每个位置对不对,整图准确率看整张图全对才算对:
def evaluate(model, val_loader): model.eval() char_correct, char_total = 0, 0 img_correct, img_total = 0, 0 with torch.no_grad(): for imgs, labels in val_loader: outputs = model(imgs) preds = torch.stack([out.argmax(1) for out in outputs], dim=1) char_correct += (preds == labels).sum().item() char_total += labels.numel() img_correct += (preds == labels).all(dim=1).sum().item() img_total += labels.size(0) return char_correct / char_total, img_correct / img_total逻辑说明:字符级准确率到 99% 的时候,整图准确率可能只有 96%,因为 4 位里错一位整图就算错。实际部署要看整图准确率,字符级只是参考。
参数说明:验证集要和训练集同分布,别拿生成时用了不同字体的数据来验证,那样指标没意义。
4.3 推理部署:单张图和批量怎么调
训练完保存模型,推理时注意model.eval()和torch.no_grad()都要加:
model.load_state_dict(torch.load('captcha_cnn.pth', map_location='cpu')) model.eval() def predict(img_path): img = preprocess(img_path) tensor = torch.from_numpy(img).unsqueeze(0) with torch.no_grad(): outputs = model(tensor) preds = [out.argmax(1).item() for out in outputs] return ''.join(CHARS[p] for p in preds)逻辑说明:unsqueeze(0)是加 batch 维度,模型 forward 默认按 batch 处理。CPU 推理单张大概 5-10ms,GPU 上更快,但验证码场景 CPU 通常够用。
参数说明:map_location='cpu'是防止在 GPU 上训练、CPU 上推理时报错。如果要做服务,建议用 ONNX 导出再推理,速度还能再提一截。
5. 避坑指南:验证码识别里最容易翻车的五个地方
5.1 训练集和真实场景分布不一致
现象:训练集准确率 99%,一上真实验证码就掉到 60%。
原因:生成数据太干净,真实验证码有压缩噪点、有抗锯齿、有背景纹理,模型没见过。
解决:生成数据时加随机噪声、随机模糊、随机亮度变化,或者直接拿几百张真实验证码做微调。数据增强的代码:
import random from PIL import ImageFilter def augment(img): if random.random() < 0.5: img = img.filter(ImageFilter.GaussianBlur(radius=random.uniform(0.5, 1.5))) if random.random() < 0.3: img = img.point(lambda x: x * random.uniform(0.8, 1.2)) return img5.2 CTC loss 一直不降
现象:CRNN 训练几个 epoch,loss 卡在 8 左右不动。
原因:最常见的是 blank 索引设错,或者标签里包含了 blank 对应的字符。
解决:检查CTCLoss(blank=?)里的数字是不是字符集大小,标签编码时字符到索引的映射有没有把 blank 占掉。另外输入序列长度要大于标签长度,太短的输入 CTC 直接算不出有效路径。
5.3 模型把干扰线当成了字符
现象:识别结果里多出一些不存在的字符。
原因:干扰线和字符颜色接近,CNN 没区分开。
解决:训练时加更多干扰线样本,或者在预处理阶段做颜色过滤。如果干扰线是固定颜色,直接按颜色阈值剔除:
# 假设干扰线是红色,字符是黑色 b, g, r = cv2.split(cv2.imread(img_path)) mask = (r > 150) & (g < 100) & (b < 100) img[mask] = 255 # 干扰线位置涂白5.4 学习率设太大导致 loss 震荡
现象:loss 一会儿 0.5 一会儿 3.0,来回跳。
原因:学习率太大,优化器在最优解附近反复横跳。
解决:把学习率降到 1e-4 再试,或者加 warmup。Adam 默认 1e-3 对验证码任务偏大,1e-4 到 5e-4 之间比较稳。
5.5 推理时忘了切 eval 模式
现象:同一张图推理两次结果不一样。
原因:模型还在 train 模式,Dropout 和 BatchNorm 在起作用。
解决:推理前必须model.eval(),并且用torch.no_grad()包住,不然每次结果都带随机性。
6. 进阶技巧:用迁移学习和模型蒸馏把准确率再拉一截
训练数据不够的时候,别从零训。拿一个在 ImageNet 上预训练过的 CNN backbone,换成灰度输入,只训后面的分类头,收敛快很多。具体做法是把torchvision.models.resnet18的第一层卷积改成单通道,然后冻结前面几层:
import torchvision.models as models backbone = models.resnet18(pretrained=True) # 改第一层为单通道 backbone.conv1 = nn.Conv2d(1, 64, 7, stride=2, padding=3, bias=False) # 冻结前两个stage for name, param in backbone.named_parameters(): if 'layer1' in name or 'layer2' in name: param.requires_grad = False逻辑说明:预训练权重里已经包含边缘、纹理这些底层特征,验证码识别也用得上。冻结浅层可以防止小数据集上过拟合,只训深层和分类头。
另一个技巧是模型蒸馏:先用大模型(比如 CRNN + 更宽的 LSTM)训一个高准确率的教师模型,再用它去教一个小 CNN。小模型推理快,适合高并发场景。蒸馏的 loss 是硬标签 loss 加软标签 KL 散度:
def distill_loss(student_out, teacher_out, labels, T=4, alpha=0.7): hard = nn.functional.cross_entropy(student_out, labels) soft = nn.functional.kl_div( nn.functional.log_softmax(student_out / T, dim=1), nn.functional.softmax(teacher_out / T, dim=1), reduction='batchmean' ) * T * T return alpha * hard + (1 - alpha) * soft参数说明:T=4是温度,越大软标签越平滑,通常 3 到 5 之间。alpha=0.7表示硬标签占七成,软标签占三成,这个比例可以根据教师模型准确率调。
验证蒸馏有没有效果,别只看 loss,要在独立测试集上比整图准确率。我一般会留 2000 张真实验证码做最终测试,教师模型和小模型都在上面跑一遍,小模型掉点不超过 1% 就算成功。
最后说个血泪经验:验证码识别没有一劳永逸的模型,对方换个字体、加条干扰线,你的准确率就可能腰斩。所以训练脚本、数据生成脚本、评估脚本要能一键重跑,模型文件按日期存档,出问题能快速回滚。这套流程跑顺了,后面换验证码风格就是改几个参数重新训一版的事。希望帮到你。
本文还有配套的精品资源,点击获取