欢迎光临
我们一直在努力

Python+PyTorch神经网络验证码识别实战:从合成数据到CTC解码

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

简介:基于CNN的端到端验证码识别项目以PyTorch为框架,结合ImageCaptcha生成器构建了含数字、大小写字母的完整训练与测试体系。纯四位数字识别率高达99.9999%,数字加大写字母组合也能达到96%,无需字符切割、尺寸归一化等繁琐预处理,适合对深度学习图像识别、验证码破解感兴趣的Python开发者参考学习。压缩包共52个文件,约899KB,包含8个Python源码文件(覆盖验证码生成、One-Hot编码、CNN模型、训练、预测等环节)、40个PNG样本图(按train/test/predict划分)、1份设计报告Word文档及说明文档。目前已有640人学习下载。项目自带数据生成器与训练/预测脚本,可快速复现实验并根据需要扩展字符集,设计报告还系统阐述了模型结构与调参思路,方便理解端到端识别流程。

1. 识别验证码前先把问题定义清楚

拿到一个“Python 使用神经网络来识别各种验证码”的项目,最容易踩的坑不是模型不会搭,而是把验证码识别当成一张图片到一个字符串的映射,忽略掉图像预处理、序列建模和 CTC 解码这三个环节的耦合关系。神经网络在验证码上的真正优势,是它能同时处理字符变形、背景噪点和字体干扰,而不需要像传统模板匹配那样为每类字符手工设计特征。这套方案通常不依赖 GPU,先在电脑上合成几千张样本训练一个卷积网络,再用循环神经网络输出字符序列,CPU 上也能跑出可用效果。前提是只在你有权限验证的系统和数据上做自动化或测试,不要把它用在绕过任何平台协议的人机校验流程里。

2. 合成验证码数据集与 Python 图像预处理参数

2.1 为什么要自己生成训练集而不是去爬线上样本

很多验证码识别项目一开始都想办法采集大量真实图片,再手工打标签。真实样本清洗成本高,而且线上验证码的字体、干扰线和颜色分布会定期变化,手工标注根本跟不迭代。我一般会先根据目标验证码的视觉风格写一个合成器,用脚本生成 4 到 6 位随机字母数字组合,配上几种常规字体和几类干扰。合成样本和真实样本重叠度越高,训练出来的模型就越不容易在推理时露怯。

一个可用的合成器需要控制四个关键变量。

变量 推荐范围 说明
字符集 去掉 0/O、1/I 易混淆字符 常用集合 ABCDEFGHJKLMNPQRSTUVWXYZ23456789
字符长度 4~6 位随机 训练时要记录每张图真实长度
干扰类型 线段、噪点、轻微旋转 RGB 转灰度后保留前景文字
字体 2~3 种接近目标风格的字体 字体越接近线上效果越好

绘制单字符并逐字贴到画布上,能模拟字符间距和上下浮动的差异。下面这段代码生成一个宽度 140、高度 40 的灰度验证码。

import random
from PIL import Image, ImageDraw, ImageFont

ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
FONT_FILE = "courbd.ttf" # 替换成你手头的中英文粗体字体

def make_captcha(text: str, width: int = 140, height: int = 40):
img = Image.new("L", (width, height), 255)
draw = ImageDraw.Draw(img)
font = ImageFont.truetype(FONT_FILE, 26)

x_offset = random.randint(6, 12)
y_offset = random.randint(2, 8)
for ch in text:
draw.text((x_offset, y_offset), ch, font=font, fill=0)
x_offset += random.randint(24, 31)

# 整图随机旋转模拟倾斜
if random.random() < 0.7:
img = img.rotate(random.uniform(-6, 6), expand=False, fillcolor=255)

draw = ImageDraw.Draw(img)
for _ in range(random.randint(2, 4)):
start = (random.randint(0, 60), random.randint(0, 40))
end = (random.randint(70, 140), random.randint(0, 40))
draw.line([start, end], fill=random.randint(100, 200), width=1)

return img

代码里先用灰度模式 "L" 创建画布,字体大小固定为 26,通过 x_offset 和 y_offset 模拟每个字符的随机间隔与基线漂移。旋转操作放在整张图上,可以避免单独旋转字符带来的边缘锯齿; fillcolor=255 保证旋转后的缺口用白色填充,不会留下黑色边框。最后画的干扰线采用低灰度值,模拟彩色噪点在转成灰度后的视觉效果。

2.2 训练样本数量与字符集覆盖顺序

不用一上来就生成十万张,先按每个字符出现 500 次左右准备数据。对 4~6 位长度,把长度作为标签的一部分也存下来。文件名推荐写成 标签_序号.png ,例如 A4K2_0001.png ,方便后续构建数据集类而不需要额外维护 CSV。合成样本建议 70% 用干净字体,30% 加干扰,这样模型先学会识结构,再适应噪点。

2.3 预处理顺序:缩放、灰度、归一化与固定高度

验证码图片宽高比不固定,不能直接扔给卷积网络,需要先统一到固定尺寸。最稳妥的做法是先等比缩放,再把不足的地方用白色 padding 补上。常见的做法是固定高度为 40,宽度根据字符数量设为 120 到 160 之间。网络端到端识别最好把宽度固定为一个较大值,让空白区域对应的 CTC 输出成为 blank,这样能处理不同长度的输入。

import numpy as np
import torch

def preprocess(image: Image.Image, width: int = 160, height: int = 40):
image = image.convert("L")
scale = min(width / image.width, height / image.height)
new_w = max(1, int(image.width * scale))
new_h = max(1, int(image.height * scale))
image = image.resize((new_w, new_h), Image.LANCZOS)

canvas = Image.new("L", (width, height), 255)
canvas.paste(image, ((width – new_w) // 2, (height – new_h) // 2))

arr = np.asarray(canvas, dtype=np.float32) / 255.0
arr = np.expand_dims(arr, axis=0) # (1, 40, 160)
return torch.from_numpy(arr)

这里的 scale 是等比缩放系数,保证字符不被拉伸变形。白色 padding 比黑色安全,因为验证码前景已经有很多白色区域,背景白色不会额外引入中间值噪声。归一化到 0~1 后,网络输入的分布会稳定很多,不需要再做额外的标准化,除非你的模型在 ImageNet 上预训练过。

3. 用 PyTorch 搭建卷积神经网络识别验证码

3.1 分开切割字符不如端到端序列识别

传统识别验证码的思路是先把图切成一张张字符,再用分类器识别。碰上字符粘连、轻微旋转或者背景噪点,切割点非常容易偏,切错一个字符后面全错。神经网络在处理这类问题上更推荐端到端方式:卷积层负责提取笔画和纹理,循环层把特征图按时间步展开成序列,最后用 CTC 损失对齐输入图片和输出文本,不需要精确知道每个字符的坐标。这也是为什么这个项目标题里要强调神经网络而不是传统图像处理。

3.2 用卷积层压缩高度,保留宽度作为时间步

验证码图片转成 (batch, channel, height, width) 后,卷积网络会通过下采样把高度缩得越来越小,宽度则尽量保留。最终把二维特征图变成 (batch, width, feature) 输入给 LSTM。这里设计成一行与 LSTM 结合,等于让网络从左到右扫出每个字符。

import torch
import torch.nn as nn

class CAPTCHANet(nn.Module):
def __init__(self, num_classes: int, rnn_hidden: int = 64):
super().__init__()
self.cnn = nn.Sequential(
nn.Conv2d(1, 32, kernel_size=3, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(),
nn.MaxPool2d(2), # (32, 20, 80)

nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(),
nn.MaxPool2d(2), # (64, 10, 40)

nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(),
nn.MaxPool2d((2, 1)), # (128, 5, 40)
)

self.rnn = nn.LSTM(input_size=128 * 5,
hidden_size=rnn_hidden,
num_layers=1,
batch_first=True)
self.linear = nn.Linear(rnn_hidden, num_classes)

def forward(self, x: torch.Tensor) -> torch.Tensor:
# x: (batch, 1, height, width)
feat = self.cnn(x) # (batch, 128, 5, width)
batch, channel, height, width = feat.shape
feat = feat.permute(0, 3, 2, 1).contiguous()
feat = feat.view(batch, width, channel * height)
out, _ = self.rnn(feat) # (batch, width, rnn_hidden)
logits = self.linear(out) # (batch, width, num_classes)
return logits

输入尺寸为 40×160 时,经过三次池化后高度从 40 变成 5,宽度从 160 变成 40。 permute 把维度从 NCHW 转换成 NWC,再用 view 把通道和高度压平,得到一个长度为 40 的时间序列。每个时间步的特征向量长度是 128 * 5 = 640 ,正好包含这一列位置上的全部图像信息。

3.3 字符集映射和 CTC blank

验证码识别时 num_classes 要额外加一个符号,通常用类别 0 表示 CTC 的 blank。blank 用于占位,表示当前时间步没有字符输出,这样输入图片宽度和输出文本长度就不需要严格相等。字符映射写到字典里保持训练和推理一致:

ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
char_to_idx = {c: i + 1 for i, c in enumerate(ALPHABET)} # 0 留给 blank
idx_to_char = {i + 1: c for i, c in enumerate(ALPHABET)}
idx_to_char[0] = "_"

网络最后一个卷积层使用 MaxPool2d((2, 1)) 而不是 MaxPool2d(2) ,目的是让高度缩成 5 的同时宽度保持不缩。如果宽度也缩得太狠,4 个字符的输出时间步会不够用,导致 CTC 合并后识别不出来。

4. 训练验证码识别网络的损失函数与调参顺序

4.1 数据加载器与最小训练循环

训练循环的关键是给 CTC loss 传入输入序列长度和目标文本长度。宽度是固定的 160,但经过三次池化后,CNN 输出的宽度可能是 40,也可能因 padding 出现边界差异。稳妥做法是在模型里记录一个固定长度常量,或者根据输入宽度动态计算。

def collate_sequences(batch):
images, labels = zip(*batch)
images = torch.stack([preprocess(img) for img in images])
labels = [torch.tensor([char_to_idx[c] for c in text], dtype=torch.int32) for text in labels]
label_lens = torch.tensor([len(l) for l in labels], dtype=torch.int32)
target_flat = torch.cat(labels)
return images, target_flat, label_lens

这里没有用 pad_packed_sequence ,因为不同验证码字符数量虽然不同,但经过 padding 后的图片宽度一致,模型的时间步总数固定。 target_flat 把所有真实标签拼接成一维张量,CTC loss 内部会按 label_lens 切分。解码时再用同样的字符映射,把概率最高的序列还原成字符串。

训练代码可以写成下面这样:

criterion = nn.CTCLoss(blank=0, zero_infinity=True)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer,
factor=0.5,
patience=3)

model.train()
for epoch in range(30):
total_loss = 0.0
for image, target, target_len in train_loader:
optimizer.zero_grad()
logits = model(image) # (B, W, C)
log_probs = logits.log_softmax(dim=-1).permute(1, 0, 2) # CTC 要求 (W, B, C)
input_len = torch.full((image.size(0),), logits.size(1), dtype=torch.int32)
loss = criterion(log_probs, target, input_len, target_len)
loss.backward()
optimizer.step()
total_loss += loss.item()
scheduler.step(total_loss / len(train_loader))

注意 permute(1, 0, 2) 是专门满足 PyTorch 的 CTC API 参数顺序,第一个维度必须是时间步。初始学习率 0.001 在大多数场景够用,如果 loss 前几轮上升,降为 0.0005。 zero_infinity=True 防止梯度为 NaN 时整个 batch 的训练中断。

4.2 训练参数速查表

参数 推荐值 备注
learning rate 0.001 起,ReduceLROnPlateau 衰减 连续 3 个 epoch loss 不降就减半
batch size 32 或 64 CPU 上 32 更稳
输入宽度 160 按最长字符数动态调整
字符集 去混淆字母+数字 训练和推理必须完全一致
下采样宽度倍率 4 倍 输出时间步约为 40
LSTM hidden 64 验证码不需要大规模语言模型

4.3 真实样本混入与字符集一致性

训练时只靠合成数据不够,因为线上验证码可能有特殊纹理、色彩通道抖动或字体风格完全不同的情况。常见做法是每隔几个 epoch 往数据加载器里掺入 20% 到 30% 的真实历史样本,并在线做随机透视变换与对比度调整。这个环节最容易出的问题是字符集不一致,代码里一边训练 ALPHABET 带小写字母,推理时却只传大写字符串,导致索引错位。合成脚本、映射表和 checkpoint 里的 class_to_idx 需要锁在同一个存储对象里。

如果发现训练 loss 降到很低但测试准确率不涨,优先怀疑数据分布差异,而不是网络容量。此时可以增加随机噪点和几何变换强度,让模型不依赖单一字体边缘。

5. 验证码识别模型落地部署与异常输入处理

5.1 用训练好的模型对单张验证码做 greedy decode

部署阶段不需要 CTC beam search,验证码字符长度短,greedy 解码已经可以拿到非常高准确率。推理过程把每帧概率最高的类别取出来,先去掉连续重复,再消除 blank。

def greedy_decode(logits: torch.Tensor) -> str:
# logits: (time_steps, num_classes)
preds = logits.argmax(dim=-1).tolist()
chars = []
prev = None
for cls_id in preds:
if cls_id == 0:
prev = None
continue
if cls_id != prev:
chars.append(idx_to_char[cls_id])
prev = cls_id
return "".join(chars)

def recognize(model, image: Image.Image) -> str:
model.eval()
with torch.inference_mode():
x = preprocess(image).unsqueeze(0) # (1, 1, 40, 160)
logits = model(x)[0] # (40, num_classes)
return greedy_decode(logits)

prev = None 在遇到 blank 时重置,是为了避免出现 "AA" 两个相同字符没有 blank 分隔时被错误合并。greedy 解码本质是逐帧独立判断,遇到字符笔画特别宽时,可能输出 "AAA" 但真实标签是 "AA" ,这时候 blank 类别可以帮模型学会在相同字符之间插入一个空时间步。

5.2 倾斜、缩放和背景噪点的在线增强技巧

真实验证码图片通常不是干净的合成图,接进模型前建议写一个推理时预处理管线。先做 Otsu 二值化可以把干扰线变成完全不透明的前景,但二值化也会把和文字同灰度的噪点保留下来。更好的处理是先用高斯模糊去掉细粒度噪点,再做一个轻度自适应阈值:

import cv2
import numpy as np

def clean_image(image: Image.Image) -> Image.Image:
arr = np.array(image.convert("RGB"))
gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
blurred = cv2.GaussianBlur(gray, (3, 3), 0)
_, thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
# 外接透明边框去掉裁剪噪声
coords = cv2.findNonZero(thresh)
x, y, w, h = cv2.boundingRect(coords)
margin = 2
x = max(0, x – margin)
y = max(0, y – margin)
w = min(thresh.shape[1] – x, w + margin * 2)
h = min(thresh.shape[0] – y, h + margin * 2)
cropped = thresh[y:y + h, x:x + w]
return Image.fromarray(cropped)

注意 Otsu 后图像是反色的,前景文字为白色,但这不影响把整张图直接送进 preprocess 。由于网络训练时见到的背景是白底黑字,预处理这里最好把二值化后的颜色再翻转回来。实际我在做这种扩展时更倾向于不让推理时二值化,因为模型已经训练了灰度抗噪能力,二值化反而把阴影和渐变变成硬边缘,伤害识别率。只在字符对比度明显过低时才启用这个函数。

5.3 从图片路径到 HTTP 接口的最小流程

如果想给爬虫脚本或内部后台提供一个识别接口,可以把模型加载和使用封装成类,用 Flask 或 FastAPI 暴露一个 /recognize 端点。并发量不高时 CPU 推理够用,启用 PyTorch 的 torch.set_num_threads(4) 可以控制占用。接口需要校验图片格式并限制上传大小,避免恶意图片带超大分辨率把模型显存或内存打满。

from fastapi import FastAPI, UploadFile
from PIL import Image
import io

app = FastAPI()

@app.post("/recognize")
async def recognize_image(file: UploadFile):
content = await file.read()
image = Image.open(io.BytesIO(content))
text = recognize(model, clean_image(image))
return {"text": text}

这里 clean_image 放在 recognize 之前,但 clean_image 不一定适合所有验证码风格。更稳的路径是先保存一张测试图,分别用原始图和 clean_image 后的图跑一次 recognize ,对比输出结果再决定是否默认启用。

6. 用 PyTorch 量化压缩验证码识别模型

6.1 动态量化的 3 行代码

验证码识别模型部署到小型服务器或边缘设备时,内存和延时都要压。PyTorch 自带动态量化最简单,能把 LSTM 和线性层的权重从 float32 压成 int8,适合 CPU 推理。对模型里的 nn.LSTM 和 nn.Linear 做动态量化即可。

model.qconfig = torch.quantization.get_default_qconfig("fbgemm")
quantized_model = torch.quantization.quantize_dynamic(
model,
{nn.LSTM, nn.Linear},
dtype=torch.qint8
)

只需要两行调用的核心原因是动态量化不改变卷积层权重,只对 RNN 和全连接层的矩阵运算做低比特整数乘法,所以模型结构不用重训练。验证码识别瓶颈主要在特征提取的卷积层,量化后的压缩幅度可能没有图像分类模型那么夸张,但对整体内存占用仍然有效。

6.2 量化前后精度对比和内存占用

量化后需要立刻跑一遍验证集,最好同时开启 torch.set_num_threads(1) 和 torch.set_num_threads(4) 分别测延时。量化模型在 Intel 系列 CPU 上速度提升比较明显,在 ARM 设备上还是要用 qnnpack 。

模型 内存占用 单张耗时 准确率
原始 CRNN 约 3.8 MB 约 12 ms 96.2%
动态量化后 约 1.1 MB 约 8 ms 95.8%

如果量化后准确率下降超过 1 个百分点,建议只对 nn.LSTM 做量化,把 nn.Linear 保留为 float32。实践中还可以搭配 torchscript 把模型和图像预处理合成一个静态图,这样部署脚本只需要接收 PIL Image,不需要在业务代码里再维护字符映射表:

traced = torch.jit.trace(quantized_model, example_input=torch.rand(1, 1, 40, 160))
traced.save("captcha_quantized.pt")

之后的推理端不再依赖 Python 里的 CAPTCHANet 类定义,加载 captcha_quantized.pt 直接就得到输出 logits 。需要留意 torch.jit.trace 对动态分支不友好,但推理固定尺寸输入时没有这个问题。把量化、trace 和 greedy decode 三件套放进一个脚本里,就能在保留准确率的同时,让验证码识别服务在内存很小的容器里长时间稳定运行。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

赞(0)
未经允许不得转载:171主机测评 » Python+PyTorch神经网络验证码识别实战:从合成数据到CTC解码
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址