简介:这是一份面向深度学习入门与计算机视觉实战的字符型数字验证码识别项目资料,围绕卷积神经网络与循环神经网络结合的识别流程展开,适合希望掌握图像分类、序列建模与端到端训练的开发者及在校学生。压缩包共1210个文件,约9.58MB,主体为978张png和202张jpg验证码样本图片,另有17个py脚本、2个txt说明、2个rst文档及少量bmp、jpeg、zip与模型文件,覆盖数据读取、预处理、模型构建、训练评估和预测等环节。已有1542人学习下载,可作为课程设计、练手项目或算法复现的参考。源码将字符序列编码、CNN特征提取、LSTM/GRU序列建模、Adam优化与交叉熵损失等关键点串成可运行示例,并保留预训练模型、SVM模型文件及网页辅助材料,便于读者对照修改网络结构、调整数据增强策略并复盘训练日志,搭建自己的验证码识别基线。
1. 字符型图片验证码识别:从一张 120×40 的图说起
很多人第一次拿到字符型图片验证码识别任务,都会低估它:不就是四个数字或字母,用深度学习一跑不就完了?真正上手才发现,120×40 的图上叠着干扰线、噪点、扭曲和粘连,直接丢进通用 OCR 接口,识别率经常卡在六七成,换个字体、加点旋转就崩。这份资源解决的正是这个痛点——它把「数字、字符验证码」从数据生成、预处理、模型搭建、训练到推理的完整链路拆开,附源码,可以自己改参数复现。适合两类人:一是想拿一个可控小项目把深度学习从理论走到落地的开发者,二是需要在自己系统里做验证码环节自动化测试的工程师。核心词「基于深度学习」在这里不是口号,而是指卷积网络直接从像素学特征,替代掉早期靠字符切分+模板匹配那套手工规则。
2. 数据从哪来:字符验证码的生成与预处理链路
验证码识别项目的成败,八成在数据。真实业务里的验证码你拿不到标注,所以这类项目通常自己生成训练集:用图像库画字符、加干扰,同时天然拿到标签。这一步看似取巧,其实是把问题定义清楚了——字符集大小、长度、字体、干扰强度,全都可控可配。
2.1 字符集与图像参数怎么定
先定死几个维度,后面所有代码都依赖它们:字符集(比如0123456789,或再加大小写字母共 62 类)、验证码长度(4 位最常见)、图像尺寸(宽×高)、干扰类型(噪点、干扰线、字符旋转)。
我的习惯是把这些写成一个配置区,不要散落在各处。字符集一旦定了就不要中途改,改了标签映射全乱,模型权重直接作废。尺寸上给个参考:4 位数字、宽 120、高 40,字符高度 28 左右,通道用单通道灰度就够,彩色通道对这个任务几乎没有增益,反而多两倍计算量。
| 参数 | 典型取值 | 说明 |
|---|---|---|
| 字符集 | 0123456789 | 10 类,最稳;加字母变 36/62 类 |
| 验证码长度 | 4 | 定长处理,分类头按位拆 |
| 图像尺寸 | 120×40×1 | 宽高比接近 3:1,适配 CNN |
| 干扰类型 | 噪点 + 1~2 条曲线 | 强度别超过字符笔画 |
| 训练/验证比例 | 9:1 | 至少留 5000 张做验证 |
字符集变大不是线性增加难度,而是明显变难:36 类时相似字形(0/O、1/l/I)会让准确率掉几个点。所以第一版建议只用数字,跑通链路再扩字符集,这是踩过坑之后总结的顺序。
2.2 生成脚本与标签编码
下面是一段可以直接改字体路径跑起来的生成代码,用 PIL 绘图,标签同步保存。
import os, random from PIL import Image, ImageDraw, ImageFont, ImageFilter CHARS = "0123456789" # 字符集,改这里等于改任务难度 LEN = 4 # 验证码长度 W, H = 120, 40 # 图像宽高 FONT_PATH = "./fonts/arial.ttf" # 换成你本机存在的字体文件 SAVE_DIR = "./dataset/train" NUM = 20000 os.makedirs(SAVE_DIR, exist_ok=True) font = ImageFont.truetype(FONT_PATH, 28) # 字号略小于高度,留出扰动空间 for i in range(NUM): img = Image.new("L", (W, H), color=255) # 白底灰度图 draw = ImageDraw.Draw(img) text = "".join(random.choice(CHARS) for _ in range(LEN)) # 逐字符绘制,加入随机纵向偏移模拟位置抖动 for idx, ch in enumerate(text): x = 8 + idx * 28 + random.randint(-2, 2) y = random.randint(0, 6) draw.text((x, y), ch, font=font, fill=random.randint(0, 60)) # 干扰线:1~2 条,颜色不要太深,避免盖死笔画 for _ in range(random.randint(1, 2)): x1, y1 = random.randint(0, W), random.randint(0, H) x2, y2 = random.randint(0, W), random.randint(0, H) draw.line([(x1, y1), (x2, y2)], fill=random.randint(80, 160), width=1) img = img.filter(ImageFilter.GaussianBlur(radius=random.uniform(0, 0.8))) img.save(os.path.join(SAVE_DIR, f"{text}_{i}.png")) # 文件名即标签这段代码有几处值得说清。字符绘制时x的步长 28 配合随机偏移,是为了制造字符间距不均;y的随机抖动模拟垂直错位,这两点是让模型别死记固定位置的常用做法。干扰线颜色取 80~160,比字符颜色(0~60)浅,这样干扰线存在但不至于把笔画截断——如果你把线的颜色也开到 0,模型会被迫学更鲁棒的特征,但收敛会明显变慢,第一版不建议。文件名直接拼标签,省掉单独的标注文件,读取时按_切分即可,这是最省事也最不容易出错的方案。
2.3 读取与归一化:别小看这一步
很多人训练不收敛,问题出在数据管道而不是模型。常见做法是把图像统一缩放到固定尺寸、转成张量、像素除以 255。为什么用灰度单通道:字符验证码的信息几乎全在亮度上,颜色通道会引入无关维度。归一化到[0,1]还是标准化到均值 0.5、方差 0.5,取决于你用的模型和优化器,但同一套训练/推理流程必须保持一致,否则线上准确率会莫名下降。
from torch.utils.data import Dataset import torchvision.transforms as T from PIL import Image class CaptchaDataset(Dataset): def __init__(self, root, chars): self.files = [f for f in os.listdir(root) if f.endswith(".png")] self.root = root self.char2idx = {c: i for i, c in enumerate(chars)} # 统一尺寸 + 转张量 + 归一化,推理时也必须走同一套 self.tf = T.Compose([ T.Resize((40, 120)), T.ToTensor(), # 自动缩放到 [0,1] T.Normalize((0.5,), (0.5,)), # 单通道均值方差 ]) def __len__(self): return len(self.files) def __getitem__(self, i): name = self.files[i] label_str = name.split("_")[0] img = Image.open(os.path.join(self.root, name)).convert("L") label = [self.char2idx[c] for c in label_str] return self.tf(img), labelResize((40, 120))里高在前宽在后,写反了图像会被拉成另一比例,字符变形后准确率掉得很明显,这是新手高频翻车点。标签转成索引列表,4 位就是长度 4 的整数列表,后面按位做交叉熵。注意文件名里的标签必须和字符集严格对应,出现一个字符集外的字符,char2idx直接 KeyError,所以生成脚本和读取脚本的CHARS要从同一个配置里来,别两处各写一份。
3. 模型怎么搭:CNN 主干加四位分类头
验证码识别不是单一分类任务,而是「定长多标签分类」:一张图输出 4 个字符,每位独立分类。理解这一点,模型结构就没有悬念了——共享的卷积主干提特征,后面接 4 个(或一个展平成 4×类别数)的分类头。
3.1 为什么不用通用 OCR,而是自己搭小 CNN
通用 OCR 面向的是自然场景文字,模型大、依赖强、对扭曲字符的鲁棒性反而不一定好;而这个任务的字符集固定、长度固定、背景干净,一个几层卷积的小网络就能做到很高准确率,训练几分钟到几十分钟,单卡甚至 CPU 都能跑。选型逻辑是:问题越窄,模型越该小。上大模型不但慢,还容易在 2 万张的规模上过拟合。
结构上我一般这么配:3 组「卷积 + ReLU + 池化」,通道 32→64→128,然后展平接全连接,输出维度为长度 × 类别数,再 reshape 成(batch, 4, 10)按位算损失。
import torch.nn as nn class CaptchaCNN(nn.Module): def __init__(self, num_chars=10, length=4): super().__init__() self.length = length self.num_chars = num_chars self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 40x120 -> 20x60 nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # -> 10x30 nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # -> 5x15 ) self.head = nn.Sequential( nn.Flatten(), nn.Linear(128 * 5 * 15, 256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, length * num_chars), # 一次输出 4x10 ) def forward(self, x): x = self.features(x) x = self.head(x) return x.view(-1, self.length, self.num_chars) # (B,4,10)参数说明:padding=1保持尺寸、MaxPool2d(2)每次减半,三层后特征图 5×15,所以全连接输入是128*5*15。这个数字必须和实际尺寸对上,改了输入宽高或池化层数,这里就得跟着算,写死是常见翻车来源。Dropout(0.3)在小数据集上有用,数据量到十万级可以降到 0.1 或去掉。输出用view拆成按位形式,配合下面的损失函数。
3.2 按位交叉熵与训练循环
损失用CrossEntropyLoss,把(B,4,10)和(B,4)的标签分别展平后计算,等价于四位损失求和。优化器 Adam,学习率 1e-3 起步,batch 64 或 128。
import torch from torch.utils.data import DataLoader device = torch.device("cuda" if torch.cuda.is_available() else "cpu") train_ds = CaptchaDataset("./dataset/train", CHARS) train_dl = DataLoader(train_ds, batch_size=128, shuffle=True, num_workers=2) model = CaptchaCNN(len(CHARS), LEN).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(20): model.train() total, correct_num = 0, 0 for imgs, labels in train_dl: imgs = imgs.to(device) labels = torch.tensor(labels).to(device) # (B,4) out = model(imgs) # (B,4,10) # 展平后算损失:4 位各自独立分类 loss = criterion(out.reshape(-1, len(CHARS)), labels.reshape(-1)) optimizer.zero_grad(); loss.backward(); optimizer.step() # 整图四位全对才算对,这个指标才反映真实可用性 pred = out.argmax(dim=2) correct_num += (pred == labels).all(dim=1).sum().item() total += imgs.size(0) print(f"epoch {epoch} acc={correct_num/total:.4f}")这里最关键的是评估口径:用「四位全对」算准确率,而不是按位准确率。按位 95% 看起来漂亮,四位全对只有0.95^4≈81%,差距很大,线上能不能用看的是后者。训练日志里如果按位准确率涨到 99% 但整图准确率不动,基本是某一位(比如相似字形)拖后腿,去看混淆矩阵定位。学习率 1e-3 配合 Adam 在 128 batch 上比较稳,出现 loss 震荡就往 5e-4 降。
4. 训练完不算完:验证、调参与避坑清单
模型能跑通训练循环只是及格线,真正决定这份资源你能不能用在业务里的,是验证方法和排错能力。这一章讲验证集怎么切、指标怎么看,以及几类高频翻车现场。
4.1 验证集切分与指标解读
生成时就按 9:1 切好,别训练完再随机分,否则同一批生成参数下的样本分布会有轻微泄漏感。验证集至少 2000 张,字符长度和字符集要与训练一致。
| 指标 | 含义 | 参考阈值 |
|---|---|---|
| 整图准确率 | 4 位全对比例 | 数字集 ≥97% 可用 |
| 按位准确率 | 单位字符正确率 | 应 ≥99% |
| 单词错误率 | 1 - 整图准确率 | 越低越好 |
| 单张推理耗时 | batch=1 端到端 | 40ms 内可接受 |
解读时注意:验证集准确率高但线上低,八成是分布不一致——线上验证码的字体、干扰强度和你生成的不一样。这种时候不要急着调模型,先抓几百张线上图人工标注,混进训练集再训一轮,通常比改网络结构有效得多。这是血泪经验,结构调半天不如补数据。
4.2 推理脚本与阈值策略
推理要复用训练时的预处理,逐字符解码拼回字符串。实践中可以加一个置信度门槛:四位里最低的那位概率低于阈值就判为「不确定」,返回需要人工复核而不是硬猜。
import torch.nn.functional as F def predict(model, img_path, chars, threshold=0.5): model.eval() img = Image.open(img_path).convert("L") tf = T.Compose([T.Resize((40, 120)), T.ToTensor(), T.Normalize((0.5,), (0.5,))]) x = tf(img).unsqueeze(0).to(device) with torch.no_grad(): probs = F.softmax(model(x), dim=2) # (1,4,10) conf, idx = probs.max(dim=2) text = "".join(chars[i] for i in idx[0].tolist()) # 任一位置信度低于阈值就标记为可疑,交给下游复核 if conf.min().item() < threshold: return text, False return text, Trueunsqueeze(0)补 batch 维,忘了就报维度错。threshold=0.5是个起点,数字集通常可以提到 0.8,宁可多复核也别错放行。这个返回值二元的写法方便接入自动化脚本做分支处理。
4.3 避坑与排查:五条真实踩坑记录
现象一:loss 一直不降,准确率躺平在 10% 左右。原因最常见的是标签错位——Resize写成了(120, 40),图像被强行拉成竖图,字符全变形。解决:打印一张 batch 的 shape 和图像尺寸核对,预处理里高宽顺序统一为(H, W)。
现象二:训练准确率 99%,验证只有 70%。原因是训练集和验证集用了不同的生成参数,或者验证集里混进了训练样本文件名重复的图。解决:生成脚本一次性切分,验证集单独目录,训练前抽查 20 张确认没有重复标签序列。
现象三:改了字符集后加载旧权重直接报维度错。原因是分类头输出维度是长度×类别数,字符集从 10 类扩到 36 类,最后一层形状对不上。解决:换字符集等于换任务,从头训或只加载卷积主干、重建分类头。
现象四:显存够但训练极慢。原因是num_workers=0且没用 pin_memory,数据加载成了瓶颈。解决:num_workers设成 CPU 核数的一半左右,加pin_memory=True,单卡训练速度通常能明显提升。
现象五:线上准确率随干扰线变多迅速下滑。原因是训练数据的干扰强度分布太窄,模型没见过更粗的线。解决:生成时对干扰线宽度、颜色、曲线做随机化,让训练分布覆盖线上最坏情况,这比事后调阈值管用。
5. 进阶:把单模型做成可复用的识别服务
跑通单张图片识别之后,下一步通常是接进业务。这里讲两个我实际会做的改造:把推理封装成批量接口,以及用简单的一致性校验兜住误识别。
5.1 批量推理与模型导出
批量推理的关键是凑 batch 时保持预处理一致,避免逐张调用带来的开销。同时可以把模型导出为 TorchScript,脱离训练代码独立部署,减少依赖冲突。
import torch # 导出 TorchScript,部署侧只需 torch,不必带训练代码 model.eval() example = torch.randn(1, 1, 40, 120).to(device) traced = torch.jit.trace(model, example) traced.save("captcha_model.pt") # 批量推理:一次送入多张图 def batch_predict(model, paths, chars, batch_size=64): results = [] tf = T.Compose([T.Resize((40, 120)), T.ToTensor(), T.Normalize((0.5,), (0.5,))]) for i in range(0, len(paths), batch_size): chunk = paths[i:i + batch_size] batch = torch.stack([tf(Image.open(p).convert("L")) for p in chunk]).to(device) with torch.no_grad(): idx = model(batch).argmax(dim=2) for row in idx: results.append("".join(chars[j] for j in row.tolist())) return resultstorch.jit.trace要求输入 shape 固定,所以部署时尺寸必须和 trace 时一致,动态尺寸得走torch.jit.script或保持固定输入。导出的模型在 CPU 上跑单张大约几十毫秒,足够大多数自动化场景。
5.2 效果验证与失败兜底
上线前我会做一件很土但很有效的事:拿 500 张线上真实图,人工标好,跑一遍批量推理,统计「整图准确率」和「错在哪些位」。如果错误集中在某一位,多半是字符间距或某类字形的问题,针对性补数据即可。
兜底策略上,我一般用两条规则:置信度低于阈值直接走人工复核;同一批任务里如果某张图识别结果长度不符或含字符集外字符(理论上不会,但线上图片可能损坏),直接判失败重试。这些都是小逻辑,但能挡住大量低级事故。
从那以后我每次做这类验证码项目,都会先固定字符集和图像尺寸、跑通生成与读取的闭环、再动模型结构,最后一定用线上真实样本做一次分布核对。顺序反了,后面全是返工。这套生成、预处理、CNN 训练、批量推理的源码链路我都整理在资源包里,按上面的章节顺序改参数就能复现,希望帮到你。
本文还有配套的精品资源,点击获取