孪生神经网络与VGG16结合的点选验证码识别实践

发布时间:2026/9/28 21:27:51
孪生神经网络与VGG16结合的点选验证码识别实践 简介面向Python与深度学习初、中级学习者的孪生神经网络点选识别项目以图片对相似度判断为核心实现点选验证码破解思路适合用作毕设、课程设计或工程实训基线。压缩包共13个文件约67.23MB包含Python训练/预测脚本、VGG16与YOLOv3-tiny配置文件、模型结构示意图、配对样本数据集及环境依赖清单整体结构便于直接对照学习。已有149人学习。项目在4090上训练100轮后测试集准确率可达98.6%以上数据集采用id_序号_1/2成对命名方式组织样本README与config.py提供了环境安装和GPU配置指引。除完整训练、预测与数据预处理代码外包内数据集与模型截图可辅助理解孪生网络训练流程但资源定位为参考资料需具备基础编程能力后自行调试与扩展。1. 为什么用孪生神经网络做点选识别一个反直觉的结论做点选验证码识别的人第一反应通常是分类网络——把每个字当成一个类别训练一个多分类模型。但这个项目一开始就走了一条不同的路用孪生神经网络Siamese Network做相似度度量判断“图里的字”和“点选目标”是不是同一个字而不是直接分类。这个选择的直接收益是测试集准确率能稳定到 98.6% 以上而且不需要维护庞大的类别清单。为什么这个方案更适合点选场景因为点选验证码的字是随机渲染的字体、颜色、背景干扰、旋转角度每次都变。如果做分类模型只能学到训练集里出现过的字换一批生僻字就翻车。孪生网络学的是两个输入的相似度本质是在做“判断两张图是否同源”的任务对新字天然具备泛化能力。这一点会贯穿整个项目后续的数据格式、训练逻辑、预测流程全部围绕它展开。适用人群很明确正在做验证码识别相关的毕设、课程设计、工程实训的人或者想学习度量学习、双分支网络结构的初学者。你得有一定的 Python 和深度学习基础能看懂训练脚本、会调参、能处理报错。这份资源不是开箱即用的产品而是能让你完整跑通“数据准备 → 模型训练 → 预测部署”全流程的参考实现。2. 孪生网络的核心逻辑从 VGG16 骨干到相似度度量2.1 孪生网络在点选场景里到底学的是什么孪生网络的结构特点在于两个分支共享权重。输入一对图片比如目标字图和被检测字图分别经过同一个特征提取网络得到各自的向量表示然后计算这两个向量的距离或相似度。如果距离小说明是同源距离大说明不是同一个字。这个思路和人脸验证里的 FaceNet 本质上是同一套东西只是把任务从“判断两张人脸是否同一个人”换成了“判断两个图片里的字是否相同”。项目里用到的骨干网络是 VGG16输入图片经过预训练的 VGG16 提取特征后把高层特征拉平成一个特征向量再计算相似度。选择 VGG16 而不是更轻量的 MobileNet 或更重的 ResNet是权衡了训练稳定性和精度。VGG16 结构简单、预训练权重容易获取、在中等规模数据集上表现稳定。训练 100 轮达到 98.6% 的准确率这个数字是在 4090 上跑出来的说明模型容量和数据规模匹配得不错。核心代码在 siamese.py 里class SiameseNetwork(nn.Module): def __init__(self): super(SiameseNetwork, self).__init__() # 加载预训练 VGG16 特征提取层不包含全连接层 vgg16 models.vgg16(pretrainedTrue) self.features vgg16.features # 冻结前几层防止小数据量下底层特征被破坏 for param in list(self.features.parameters())[:10]: param.requires_grad False # 自定义分类头把 VGG 输出的 feature map 映射到特征向量 self.fc nn.Sequential( nn.Linear(512 * 7 * 7, 1024), # 7*7 是 VGG16 输出 feature map 尺寸 nn.ReLU(inplaceTrue), nn.Linear(1024, 512), nn.ReLU(inplaceTrue), nn.Linear(512, 128) # 最终输出 128 维特征向量 ) self._init_weights() def forward_one(self, x): x self.features(x) x x.view(x.size(0), -1) x self.fc(x) return x def forward(self, x1, x2): out1 self.forward_one(x1) out2 self.forward_one(x2) return out1, out2代码逻辑说明pretrainedTrue会从 torchvision 下载 VGG16 在 ImageNet 上的预训练权重这是训练快速收敛的关键。不要改成随机初始化小数据集跑随机初始化的 VGG16 很容易过拟合。冻结前 10 层参数防止训练初期梯度回传破坏底层边缘、纹理特征。如果你发现 loss 下降特别慢可以减少冻结层数反过来如果验证集精度上不去增加冻结层数。全连接层最终输出 128 维特征向量这个维度是经验值。维度太低特征区分度不够维度太高容易过拟合且推理变慢。2.2 损失函数和度量方式的选择孪生网络有两个主流训练方式一是用 Contrastive Loss对比损失二是用 Triplet Loss三元组损失。这个项目用的是对比损失因为数据组织方式比较简单——成对输入一对是相同字一对是不同字不需要构造三元组。对比损失的目标是相同对的欧氏距离尽可能小不同对的欧氏距离尽可能大并且留出一个 margin 间隔。公式在损失函数里体现为class ContrastiveLoss(nn.Module): def __init__(self, margin2.0): super(ContrastiveLoss, self).__init__() self.margin margin def forward(self, out1, out2, label): # 计算两分支输出向量的欧氏距离 euclidean_distance F.pairwise_distance(out1, out2, p2) # label1 表示相同0 表示不同 loss_positive label.float() * torch.pow(euclidean_distance, 2) loss_negative (1 - label.float()) * torch.pow(torch.clamp(self.margin - euclidean_distance, min0.0), 2) loss torch.mean(loss_positive loss_negative) return loss参数说明margin2.0表示不同对的期望距离下限。如果距离小于 2就会产生损失迫使模型继续拉大距离。这个值不是固定的你可以先试 2.0如果训练后期正负样本距离区分不明显可以调大到 3.0 或 4.0。F.pairwise_distance计算的是 L2 距离欧氏距离。也有人用余弦相似度但欧氏距离在对比损失里更直观阈值也好设置。这里的label不是类别标签而是“是否相同”的二元标签。这正体现了孪生网络把多分类问题转化成了二分类相似度问题。预测阶段不看损失函数只看距离阈值。predict.py里会计算待检测字图和目标字图的特征向量距离然后和预设的阈值比较小于阈值判定为同一个字。2.3 为什么这个方案比普通分类网络抗干扰直接做分类的点选识别通常遇到两类问题一是字库覆盖不全模型没见过某个字就完全无法识别二是渲染风格漂移训练集里是宋体黑底白字线上换成楷体渐变背景识别率立刻崩塌。孪生网络解决这两个问题的机制在于它不学“这个字长什么样属于类别 A”而是学“这个字的笔画结构和目标图的笔画结构是否一致”。VGG16 提取的是底层纹理、边缘、形状特征对比损失的训练让模型学会把这些特征映射到一个距离空间。相同结构的字在这个空间里聚集不同结构的字互相远离。当然孪生网络不是万能的。如果两个字外形高度相似比如“日”和“目”模型依然会犯错因为它们的笔画结构太接近。另一个弱点是训练数据必须是成对的数据量要求比分类网络高。这个项目带了整理好的数据集省去了自己构造正负样本对的麻烦。3. 环境搭建与数据准备从 conda 到 uuid 命名的数据格式3.1 环境安装的完整步骤与版本约束项目在 Python 3.8 环境下开发推荐用 conda 管理环境避免污染系统 Python。我一般会在装依赖前先确认两件事CUDA 版本和 PyTorch 版本是否匹配。4090 需要 CUDA 11.8 以上如果 CUDA 装低了PyTorch 会自动 fallback 到 CPU训练速度会慢到无法接受。# 创建虚拟环境指定 Python 3.8 conda create -n geetest python3.8 # 激活环境 conda activate geetest # 安装依赖 pip install -r requirement.txtrequirement.txt 里核心依赖大概是 torch、torchvision、opencv-python、numpy、Pillow 这几项。如果你用 conda 安装 PyTorch建议用官方渠道单独装不要依赖 pip 的默认源默认源常出现 torch 和 torchvision 版本不匹配的坑。我的习惯是先跑一句python -c import torch; print(torch.__version__, torch.cuda.is_available())确认 GPU 可用再往下走。一个小提醒Python 3.8 版本跨度很大PyTorch 1.13 和 2.x 在 API 上有差异。siamese.py 里的models.vgg16(pretrainedTrue)写法是 1.x 时代的风格如果你装了 PyTorch 2.x这一步会提示你改用weightsmodels.VGG16_Weights.DEFAULT。老代码在 2.x 下大概率能跑但会有警告不致命。3.2 数据集的目录组织与命名规则这个项目的数据格式是一个很容易被忽略但极影响训练效果的环节。数据存放在data目录下命名格式为id_序号.jpg|png同一个字的两张图共享同一个 id序号分别为 1 和 2。data/ ├── a6f2c1e0-4f3a-11ec-9e3a-00163e1c0b2f_1.jpg ├── a6f2c1e0-4f3a-11ec-9e3a-00163e1c0b2f_2.jpg ├── 8b3d7a90-4f3a-11ec-9e3a-00163e1c0b2f_1.png ├── 8b3d7a90-4f3a-11ec-9e3a-00163e1c0b2f_2.png └── ...为什么 id 用 uuid两点考虑一是避免用中文或语义化命名防止文件系统编码问题二是 uuid 全局唯一多个人并行标注数据时不会冲突。序号固定 1 和 2不能出现 3 张或更多同 id 的图片因为训练逻辑里是按照“每两张为一对”来读取的。数据组织和模型设计是耦合的。孪生网络需要成对输入所以数据加载器必须从文件系统里识别出同 id 的两张图并在训练时随机组合生成正负样本对。pre.py和utils.py就负责这个工作。import os import uuid import random def prepare_data(data_dir): 扫描数据目录生成同 id 图片对索引 pairs {} # {id: [path_1, path_2]} for filename in os.listdir(data_dir): if filename.endswith((.jpg, .png)): # 文件名格式uuid_序号.jpg按 _ 切分 id_part, seq filename.rsplit(_, 1) if id_part not in pairs: pairs[id_part] [None, None] seq_num int(seq.split(.)[0]) pairs[id_part][seq_num - 1] os.path.join(data_dir, filename) # 过滤不完整的样本对 valid_pairs {k: v for k, v in pairs.items() if v[0] is not None and v[1] is not None} return valid_pairs def make_batch(pairs, batch_size32): 从有效样本对中构造正负样本 batch ids list(pairs.keys()) batch_x1, batch_x2, batch_label [], [], [] # 正样本同一个 id 的两张图 for _ in range(batch_size // 2): idx random.choice(ids) batch_x1.append(pairs[idx][0]) batch_x2.append(pairs[idx][1]) batch_label.append(1) # 负样本不同 id 的两张图 for _ in range(batch_size // 2): idx1, idx2 random.sample(ids, 2) batch_x1.append(pairs[idx1][0]) batch_x2.append(pairs[idx2][0]) batch_label.append(0) return batch_x1, batch_x2, batch_label逻辑说明rsplit(_, 1)是从右边分割避免 uuid 里本来就带下划线uuid 本身不会带但保险起见这样做更稳。负样本对是随机拼接的不用专门构造难负样本。项目里能跑到 98.6%说明随机负样本对已经足够。如果你追求更高精度可以引入难负样本挖掘这里先不展开。batch_size默认 32正负样本各一半。这个比例别改如果负样本占比太高模型会倾向于把所有对都判成不同距离阈值就不好设置了。训练时数据增强也很重要。点选验证码图片通常有旋转、缩放、平移我建议在数据加载时做轻度随机的旋转±10 度和亮度扰动增加模型的鲁棒性。这个在utils.py里可以加但要注意别做太重的增强比如随机裁剪容易把字的笔画切掉反而让模型学错特征。4. 训练配置与完整流程从 config.py 到 98.6% 的达成路径4.1 config.py 里该调什么参数config.py是项目的总控文件训练前需要关注的核心参数如下# config.py import torch class Config: # 训练设备配置 GPU_ID 0 # 使用的 GPU 编号多卡机器注意 BATCH_SIZE 32 # 训练 batch 大小 EPOCHS 100 # 训练轮数 LEARNING_RATE 1e-4 # 初始学习率 MARGIN 2.0 # 对比损失 margin 参数 TRAIN_DATA_DIR ./data # 数据目录 MODEL_SAVE_PATH ./checkpoints # 模型保存路径 INPUT_SIZE (224, 224) # VGG16 标准输入尺寸 def set_gpu(self): if torch.cuda.is_available(): torch.cuda.set_device(self.GPU_ID)参数选择逻辑LEARNING_RATE 1e-4是迁移学习场景下比较稳妥的起点。如果直接用默认的 1e-3冻结层较少时容易震荡。我的习惯是先用 1e-4 跑 20 轮看 loss 曲线如果下降很平稳后续可以不衰减直接跑完如果 loss 后期下降缓慢可以在 60 轮时降到 5e-5。EPOCHS 100在 4090 上大概需要几小时取决于数据量。项目的 98.6% 是在 100 轮这个设置下达到的。如果你的数据量减半100 轮可能不够要延长到 150~200 轮。INPUT_SIZE要保持 (224, 224)这是 VGG16 全连接层输入维度的硬约束。改动这个尺寸意味着要重写全连接层非必要不动。4.2 一条完整的训练命令与训练过程解读训练入口是train.py核心逻辑是把数据加载器、模型、损失函数、优化器串起来。训练循环里有两件事需要重点关注一是每个 epoch 结束后要保存模型二是要记录正样本对的距离分布和负样本对的距离分布这比单看 loss 数值更有意义。# 单卡训练默认使用 config.py 里的 GPU_ID python train.py # 如果有多张卡显式指定 CUDA_VISIBLE_DEVICES1 python train.py训练过程中的关键输出解读loss 下降曲线只是表面真正要盯的是验证集上正负样本的距离分布是否分离。如果两者分布重叠严重说明模型还没收敛或者 margin 设得太大。如果完全不重叠说明训练已经饱和继续跑只会过拟合。我一般会改train.py里验证输出部分让它每个 epoch 打印一次正样本平均距离和负样本平均距离。形态大致是Epoch 50/100, Loss: 0.0527 Positive Distance: 0.412 Negative Distance: 1.873 (margin2.0)当正样本平均距离降到 0.5 以下、负样本平均距离稳定在 1.8 以上时模型基本就收敛了。此时测试集准确率超过 98% 不意外。训练完成后模型权重会保存到checkpoints目录。保存时注意不要只存 state_dict最好连 config 一起序列化方便后续预测时读取结构。5. 避坑指南数据、训练、预测三个环节的常见问题与排查5.1 数据集格式错误导致训练时报“空样本对”现象启动train.py后报错提示某个 id 对应的图片对不完整或者直接提示数据加载器返回空 batch。原因命名格式不符合id_序号.jpg的约定。最常见的翻车姿势是第一张图命名成xx_1.jpg第二张图命名成xx_2.png后缀不一致程序扫描时按.jpg和.png分别过滤结果两张图没有进同一个列表或者两张图同为.jpg但是第二张图的序号写成了 3。另一个坑是 Windows 系统上文件名的下划线被吞掉abc_1.jpg变成abc1.jpg。解决写一个简单的检查脚本扫描整个数据目录对每个 id 确认_1和_2的后缀、格式都一致。python -c import os for f in os.listdir(data): base, ext os.path.splitext(f) parts base.split(_) if len(parts) ! 2 or parts[1] not in (1, 2): print(f非法文件名: {f}) 5.2 训练 loss 下降缓慢且验证集准确率始终在 50% 附近徘徊现象训练了 30 个 epochloss 从 5 降到 4.5验证集准确率始终在 50%~55% 之间跟随机猜测一样。原因特征提取部分的学习率偏低或者冻结层数太多导致底层特征没有被充分适应。另一个高概率原因是数据增强过度特别是随机裁剪把文字主体切掉了模型学到的是一堆无关边缘。解决分步排查。先检查数据加载器的输出打印几张预处理后的图片看文字是否完整。然后调整冻结层数从冻结前 10 层改成只冻结前 5 层让更多底层参数参与训练。最后把学习率从1e-4提到3e-4观察 10 个 epoch 内 loss 是否有明显下降。如果这些都不奏效检查数据集中正负样本数量是否均衡如果负样本远多于正样本模型会倾向把所有对都判为不同。5.3 验证集准确率很高但实际预测一塌糊涂现象测试集准确率 96% 以上但用predict.py去预测新的点选验证码准确率下跌到 70% 甚至更低。原因测试集和真实场景的分布不一致。测试集里的负样本是随机拼接的任意两个字都有可能是负样本但真实点选验证码里的候选字往往长得比较像比如“人、入、八”这种高频相似字组。如果训练数据里没有针对性覆盖模型在这些近似字形上会大面积犯错。解决让负样本对的构造更有针对性。在训练后期修改数据加载器优先采样笔画结构接近的负样本对让模型见过足够多的易混淆组合。这等同于难负样本挖掘的轻量版——不需要完整实现只要能提高混淆字的出现频率就行。我通常会在数据目录里增加一个hard_pairs.txt文件手动列出易混淆组合训练时额外加载这些组合。5.4 predict.py 里距离阈值怎么调都不对现象阈值设小很多目标字判不出来阈值设大非目标字被误判成目标字滑条找不到合适的平衡点。原因训练时 margin 设置为 2.0但预测脚本里默认阈值可能是 1.0两者没有对齐。更常见的原因是 VGG16 输入数据的归一化方式在训练和预测两个阶段不一致导致特征向量分布漂移。训练时如果做了 ImageNet 归一化预测时也要一样。解决在训练过程中记录正负样本距离的分布取两者的中界作为初始阈值。比如正样本平均距离 0.4负样本平均距离 1.8那阈值可以取 1.0~1.2 之间。然后跑一批真实预测样本微调阈值到最能区分的位置。# predict.py 中距离阈值微调示例 if distance 1.1: label 0 # 不同 else: label 1 # 相同5.5 4090 上训练显存溢出现象程序启动后两三秒就报 CUDA out of memory。原因BATCH_SIZE 太大或者数据加载时图片没有缩放到INPUT_SIZE原始图片直接进入网络。点选验证码图片通常不大但如果有超大截图混入数据集VGG16 的中间 feature map 会爆炸。解决第一步确认图片在数据加载时做了resize((224, 224))第二步把 BATCH_SIZE 从 32 降到 16。如果还想提高吞吐量可以把torch.backends.cudnn.benchmark True打开在train.py最前面加一行让 cuDNN 自动选择最优卷积算法显存占用通常能省 20% 左右。6. 预测链路与进阶技巧从距离阈值到 YOLOv3 定位的完整衔接点选识别不是跑完孪生网络就完了完整的链路是先定位候选字位置再对每个候选字做相似度判断。这个项目里带了yolov3-tiny.cfg说明开发者的完整方案是 YOLOv3-tiny 负责目标检测孪生网络负责匹配。模型文件model.png应该是网络结构可视化图方便你理解两个任务的衔接逻辑。在预测流程里检测模块先圈出图中所有候选字的位置并裁剪出小图然后孪生网络依次计算每个裁剪图和目标字图的距离。关键技巧是不要只取距离最小的那个字而是设置一个阈值区间距离小于阈值上限的候选字都视为匹配。如果匹配数量大于点选数量取距离最小的 N 个小于则放宽阈值重试。# predict.py 中检测 匹配的衔接伪代码 import cv2 import torch import numpy as np from utils import preprocess_img def predict(screenshot_path, target_word_path, model, cfg): # 1. YOLOv3-tiny 检测候选字区域 boxes detect_words(screenshot_path, cfg) # 返回 xyxy 坐标列表 # 2. 加载目标字图片提取特征 target_img preprocess_img(target_word_path) with torch.no_grad(): target_feat model.forward_one(target_img.unsqueeze(0)) # 3. 遍历候选框裁剪并计算距离 candidates [] for box in boxes: x1, y1, x2, y2 [int(v) for v in box] crop screenshot[y1:y2, x1:x2] crop_img preprocess_img(crop) with torch.no_grad(): crop_feat model.forward_one(crop_img.unsqueeze(0)) distance torch.pairwise_distance(target_feat, crop_feat).item() candidates.append((distance, box)) # 4. 根据距离阈值筛选最终点选位置 candidates.sort(keylambda x: x[0]) selected [c for c in candidates if c[0] 1.0][:4] return selected这段代码的几个关键参数作用detect_words是封装好的 YOLOv3-tiny 推理函数返回的坐标一定要确保裁剪后图片比例正常别把字切开threshold1.0是距离阈值需要结合第 5.4 节的微调方法确定 4是限制点选数量具体值取决于验证码要求点几个字。进阶调优可以从三个方向入手。第一VGG16 特征提取层做剪枝把后几层替换成全局平均池化能明显提速且精度损失很小——这是我自己改过比较满意的做法。第二在推理阶段对每个候选图做两次轻微偏移的预测取平均距离能降低检测框偏移带来的误判。第三如果遇到训练数据量不够的问题可以去收集不同风格字体渲染的公开字库图片做预训练再在自己标注数据上微调。另外一个容易被忽略的细节是部署时的批量推理。验证码通常有 4~6 个字用 batch 一次性输入所有候选图比逐个前向传播快得多。把forward_one改写为接受多张图并行计算with torch.no_grad(): crops torch.stack([preprocess_img(c) for c in crop_list]) feats model.forward_one(crops) # 一次性输出 [N, 128] 特征最后说一个我自己的教训。以前我总觉得测试集准确率 98% 就万事大吉直到一次交付时发现线上截图带有边框阴影干扰检测框把阴影也算进去了裁剪出来的图混进了大量背景噪声孪生网络的匹配准确率暴跌。从那以后我每次预测前都会先加一步图像预处理检查统计裁剪图的文字像素占比占比低于阈值的直接扔掉。实际效果立竿见影误判率降了一半以上。这个习惯我也建议你保留特别是在处理真实截图时。希望帮到你。本文还有配套的精品资源点击获取