简介:一套基于Python孪生神经网络实现的点选验证码识别方案,面向希望入门深度学习或完成毕设、课程设计、工程实训的初中级学习者。项目在4090显卡上训练100轮后测试准确率可达98.6%,模型结构与训练流程完整,可帮助读者掌握孪生网络在图像相似度对比与点选坐标预测中的核心思路。资源共计13个文件、67.23MB,主要包含7个Python脚本(模型定义、训练、预测及工具函数)、2张示例图片、1个数据集压缩包、配置文件、依赖清单与说明文档;配置文件与依赖清单可降低环境搭建门槛,说明文档对数据格式和训练配置给出指引。已有150人学习,适合作为复现实验与二次开发的起点。压缩包内提供数据集、配置项与运行说明,能够串联从环境搭建、数据准备、模型训练到结果验证的完整链路;同时代码仅作参考资料,需要具备一定基础,能够自行调试、解决报错并扩展功能。
1. 孪生神经网络做点选识别:为什么判断两张图是否同类比单张分类更实用
做点选识别项目,我最早也以为核心难点是检测,把图里的小目标框出来就完事了。实际跑下来才发现,检测只是第一步,真正难的是判断候选小图里哪些和题干目标是同一类,这在思路上完全是个度量问题,而不是分类问题。基于 Python 的孪生神经网络方案,把两两图片输入共享权重的双分支网络,用特征向量之间的距离决定“是不是同类”。这份资源带了完整数据集,摘要里给的结果是 4090 上训练 100 轮、测试集准确率 98.6% 以上,是一套能跑通的完整闭环,适合做毕设、课程设计、工程实训的从业者拿来做样板工程。需要提醒的是,资源定位是参考资料,代码价值在于能看懂、能改、能排错,而不是无脑复制。
2. Python 3.8 环境与数据集准备:用成对样本把训练链路先跑起来
动手训练之前,先把环境和数据准备好。这一步最容易被新手跳过,但几乎所有 loss 不降、报错不断的问题,最后都能追溯到环境版本不一致或者数据没按规范整理。这一章落地三件事:conda 环境、数据目录规则、预处理逻辑。
2.1 conda 环境与依赖清单
项目指定的是 Python 3.8。为什么强调版本?因为 torch、torchvision、opencv 这几个核心库在 Python 3.8 下有大量现成的编译好轮子,踩坑最少。我的习惯是先建一个独立的 conda 环境,再在环境里 pip 安装依赖,不建议直接塞进 base 环境,后面换项目版本冲突时非常难受。
conda create -n geetest python=3.8 conda activate geetest pip3 install -r requirement.txt第一行创建名为 geetest 的环境并指定 Python 3.8;第二行激活它;第三行按 requirement.txt 安装依赖。requirement.txt 里一般包含 torch、torchvision、opencv-python、numpy、pillow 这几类。如果机器有 NVIDIA GPU,建议先单独装和 CUDA 版本匹配的 torch,再执行 requirement.txt,否则很可能装成 CPU 版本,训练速度差出几十倍。装完先运行 python -c "import torch;print(torch.cuda.is_available())" 确认能识别 GPU,别等到训练跑了一小时才发现在用 CPU。
2.2 数据集的目录规则与成对样本设计
data.zip 解压后就是 data 目录,里面是裁剪好的小图。命名规范是 id_序号.jpg 或 png,id 可以用 uuid 生成,同一个对象的两张图共用同一个 id,序号分别是 1 和 2。举例:xxxxx_1.jpg 和 xxxxx_2.jpg 就是同一对象的一对样本。这个命名规则直接决定了之后样本对怎么生成,也是整个训练的根基。
| 文件 | id | 序号 | 含义 |
|---|---|---|---|
| a3f2_1.jpg | a3f2 | 1 | 对象 A 的第一张截图 |
| a3f2_2.jpg | a3f2 | 2 | 对象 A 的第二张截图 |
| 9c1e_1.jpg | 9c1e | 1 | 对象 B 的第一张截图 |
训练时,同一 id 的两张图组成正样本对,随机挑两个不同 id 组成负样本对。这个设计决定孪生网络学到的不是“这是什么对象”,而是“这两张图是不是同一个对象”。如果数据命名错乱、拼接出错、或者同一对象只裁了一张图,那你训练出来的模型基本是废的。我第一次整理数据就栽在这里:偷懒用 glob 顺序生成 id,同一对象的第二张图被分配了新 id,整个训练集没有一对正样本,loss 怎么训都降不下去。
如果你手里的原始图没按这个规范命名,可以像我一样先按对象分组,再统一分配 id:
import uuid, shutil, os # 按对象分组,同一对象的图片放在同一个列表里 groups = { "objA": ["raw/A1.jpg", "raw/A2.jpg"], "objB": ["raw/B1.jpg", "raw/B2.jpg"], } os.makedirs("data", exist_ok=True) for gid, paths in groups.items(): fid = uuid.uuid4().hex[:8] # 同一个对象共用一个id for idx, p in enumerate(paths, 1): # 序号从1开始 dst = f"data/{fid}_{idx}.jpg" shutil.copy(p, dst)这段脚本先把原始图片按对象分组,每组分配同一个 uuid 片段作为 id,同组内按 1、2 顺序编号。关键点在于 fid 在组内只生成一次,而不是每张图生成一个。如果你要扩到三个对象,就往 groups 字典里继续加 key 就行。整理完务必检查每个 id 下的图片数量,正常情况下恰好两张,出现一张或三张说明分组有遗漏。
2.3 数据预处理:统一尺寸、通道与归一化
模型训练前要把图片统一成相同尺寸。VGG16 系列一般用 224×224,网络结构决定了输入 shape 必须固定,不能有的图 128、有的图 512 直接往里塞。另外还要统一通道:灰度图转三通道,png 的 alpha 通道要处理掉。常见做法是先 resize 再归一化,归一化参数沿用 ImageNet 的均值 mean=[0.485, 0.456, 0.406] 和标准差 std=[0.229, 0.224, 0.225]。pre.py 里基本都是把这套逻辑封装成一个函数,训练和推理共用同一份。
import cv2 def load_image(path, size=(224, 224)): img = cv2.imread(path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 统一转RGB img = cv2.resize(img, size) # 统一尺寸 img = img.astype("float32") / 255.0 mean = [0.485, 0.456, 0.406] std = [0.229, 0.224, 0.225] for c in range(3): img[..., c] = (img[..., c] - mean[c]) / std[c] return img这段代码做了三件事:转 RGB、resize 到固定尺寸、按通道做标准化。灰度图会在 cvtColor 这一步被转成三通道,RGBA 图也会被丢弃 alpha 通道,保证所有进网络的图片都是稳定的 H×W×3。这里要特别强调:训练和推理必须调用同一个 load_image。我见过太多项目训练时做归一化、测试时直接读原始图,准确率直接从 90% 掉到 60%,这种翻车根本不是模型的问题,是预处理不一致。
3. 孪生网络与 VGG16 拆解:siamese.py、vgg16.py、utils.py 各自干了什么
模型部分是整个项目的核心。很多人一上来就 python train.py,把孪生网络当黑匣子用,遇到问题就完全不知道从哪下手。其实这个项目的代码结构非常清晰:vgg16.py 负责从单张图提取特征,siamese.py 把两个 VGG16 封装成共享权重的双分支网络,utils.py 负责生成样本对并打包成 batch。这一章按文件逐个拆。
3.1 孪生网络为什么用共享权重
核心思想一句话:让网络学会比较,而不是学会分类。孪生网络有两个输入 x1、x2,分别进入两个分支,但这两个分支的权重完全共享。共享的意思是“同一个网络加载了同一份权重、跑两遍”,而不是两个独立网络各学各的。
为什么必须共享?如果两个分支独立训练,模型完全可以把 x1 映射到空间 A、x2 映射到空间 B,再靠后面的融合层硬生生学出“配对正确”,这样学到的特征根本不具备可比性,换一张没见过的图就失效。共享权重强制模型用同一种映射方式处理两张图,特征向量之间的距离才真正反映图片内容本身的相似度。而且共享权重直接把参数量减半,在小数据集上不容易过拟合。训练时喂进去的是一个个图片对,正样本对目标是让特征距离小,负样本对目标是拉开距离,这就是度量学习的标准思路。
3.2 VGG16 作为特征提取骨干
vgg16.py 做的事,是把 VGG16 最后的全连接分类头去掉,只保留卷积部分作为特征提取器。输入 224×224 图片,输出一个固定维度的特征向量。VGG16 的卷积层在 ImageNet 上预训练过,直接拿来提取通用视觉特征是性价比很高的选择,这也是这个项目选 VGG16 而不是自己从头写 CNN 的原因——你不需要几百万张图去训一个基础特征提取器,预训练权重已经帮你解决了这个问题。
拿到卷积特征后,常见做法是全局池化或展平,再接一个全连接层把维度压到比如 128 维。这个低维向量就是 embedding,是后续相似度计算的依据。vgg16.py 里做的无非就是“加载预训练权重 + 削掉分类头 + 接上目标维度全连接”。
3.3 siamese.py 的核心结构
看 siamese.py 时,重点看 forward 方法是怎么组织双分支的。典型结构如下:
import torch.nn as nn class SiameseNetwork(nn.Module): def __init__(self, backbone, embed_dim=128): super().__init__() self.backbone = backbone self.fc = nn.Linear(backbone.out_dim, embed_dim) def forward_one(self, x): feat = self.backbone(x) # 卷积特征 [B, 512] return self.fc(feat) # 128维embedding def forward(self, x1, x2): f1 = self.forward_one(x1) # 第一张图的embedding f2 = self.forward_one(x2) # 第二张图的embedding dist = nn.functional.pairwise_distance(f1, f2) return dist核心就三行:forward_one 把单张图变成 128 维向量;forward 里对两张图分别调用同一个 forward_one,然后算两个向量的欧氏距离。pairwise_distance 计算每个 batch 内对应样本对的距离,距离越小说明两张图越可能是同一对象。实际仓库里的代码可能把 backbone 换成实例化的 VGG16,或者加一层 L2 归一化,但整体结构就是上面这个骨架。
损失函数常见有两种。一种是对比损失 contrastive loss,直接约束同类距离小、异类距离大;另一种是把相似度再过一层 sigmoid 当二分类,用交叉熵训练。区别在于前者学的是度量空间,后者学的是概率输出。度量空间在业务侧更好用,你可以根据自己的容忍度去调阈值;二分类则更直观,输出直接是同类概率。这个项目用的是哪种,读 siamese.py 里 forward 返回值和 loss 计算的部分就能确定。
3.4 utils.py 的样本对生成逻辑
utils.py 负责把 data 目录读进来,按相同 id 生成正样本对,随机组合不同 id 生成负样本对,再转成 tensor 打包。负样本怎么生成非常影响训练质量,常见做法是每个 batch 里一半正样本对、一半负样本对,比例失衡模型会倾向于把所有输入都判成同类或异类。
另一个值得注意的点:同一对象的两张图如果太像,比如几乎是同一帧截图,模型学到的是“像素完全一致就是同类”,推理时遇到同对象但角度、光照变化的情况就翻车。所以在生成训练对之后加一点随机裁剪、旋转或亮度抖动,能明显提升鲁棒性。这一步可以在 utils.py 里做,也可以在数据加载时做。如果仓库里没带数据增强,我建议你自己加上,对最终准确率的影响比调学习率大得多。
4. 训练实操与常见问题排查:从 config.py 到 98.6% 准确率的完整过程
训练是大多数人真正发怵的地方。这一章先按 config.py 把参数配置好、把训练跑起来,然后重点列出我在实际运行中遇到的高频翻车点,每条按现象、原因、解决三个维度来讲,最后给出测试集准确率的验证方式。
4.1 config.py 参数解析与最小配置
config.py 是训练入口的公共配置。摘要里说“一般来说只需要配置 gpu 就行”,说明仓库的默认参数在常见环境下已经能跑,但你需要知道每个参数是干什么的,才能在显存不够或者 loss 不降时做出调整:
| 参数 | 常见取值 | 说明 |
|---|---|---|
| GPU | "0" | 指定使用的 GPU 编号 |
| EPOCH | 100 | 训练轮数 |
| BATCH_SIZE | 32 | 每次迭代喂入的样本对数量 |
| IMG_SIZE | 224 | 输入图片边长 |
| LR | 1e-4 | 初始学习率 |
| DATA_ROOT | "data" | 数据集目录 |
GPU 参数要和机器实际编号对上,跑 nvidia-smi 看一眼再填。显存只够跑 16 或 8 的时候,优先把 BATCH_SIZE 调小,这比强行开 32 然后被 OOM 打断要实际得多。损失剧烈震荡可以把 LR 降到 3e-5 试一下。启动训练的命令很简单:
python train.py训练脚本读 config.py,初始化网络和数据加载器,然后在训练循环里不断喂样本对、计算损失、反向传播。我第一次跑这个项目时,直接把 EPOCH 改成 10 验证链路,确认数据加载、前向传播、反向传播、模型保存都没问题之后再改回 100 跑全量。如果 10 轮都报错,问题通常不在训练本身,而在数据或环境,先回头查第 2 章的内容。
4.2 常见问题排查清单:五个高频翻车点
翻车点一:CUDA out of memory。 现象:训练开始没几步就报显存不足,进程被杀。原因:BATCH_SIZE 和 IMG_SIZE 太大,超出显卡显存。解决:先把 BATCH_SIZE 降到 16 或 8;还不行就把 IMG_SIZE 降到 192 或 160。这个项目的默认参数是按 4090 这种大显存卡调的,普通 8G 显存卡要按这个方式降配,跑起来一样能收敛,只是每个 epoch 的时间会长一些。
翻车点二:loss 基本不降,或者卡在 0.69 附近。 现象:训练了几十轮,loss 几乎不动,或者长期贴着一个固定值。原因:最常见的是正样本对全部配错了,也就是数据命名错乱;另一个常见原因是样本对没有 shuffle,模型一直在看同一批数据。解决:先打印一个 batch,人工确认每一对图片的标签是否符合预期;再确认数据加载器里 shuffle=True;最后确认 loss 计算的是两张图的特征距离,而不是单张图的输出。
翻车点三:训练集准确率接近 100%,测试集掉到 70%。 现象:训练时指标非常漂亮,一测试就打回原形。原因大多不是过拟合,而是训练和测试的预处理不一致:训练时做了归一化和统一 resize,测试时直接读了原始图。解决:把第 2 章的 load_image 抽成公共函数,train.py、predict.py、验证脚本三处引用同一份代码。另外检查测试集图片尺寸是否和训练一致,不一致的图要先 resize 再进网络。
翻车点四:数据命名错乱,正样本对变成不同对象的两张图。 现象:loss 在下降,但模型判断结果完全没意义。原因:数据整理时,同一对象的第二张图被分配了新 id,导致训练集里找不到任何一对正样本。解决:整理数据时按对象分组再分配 uuid,整理完统计每个 id 下的图片数量,正常情况下恰好两张,出现一张或三张的就是整理错了。
翻车点五:灰度图和彩色图混用导致 shape 错误。 现象:训练到一半报维度不匹配,比如期望 3 通道但输入是 1 通道。原因:数据里有灰度图,或者带了透明通道的 png,它们和 RGB 图的通道数不一致。解决:在 load_image 里统一做 cvtColor,灰度图转 BGR、RGBA 去 alpha,保证所有进网络的图片都是稳定的三通道。
这五个问题里,前两个几乎每个跑这个项目的人都会撞见。遇到报错不要急着改网络结构,按数据、预处理、超参数、代码 Bug 这个顺序排查,效率高得多。改网络结构通常是最后一步,大多数情况下根本不需要动。
4.3 测试集准确率验证
摘要里写“4090 训练 100 轮,测试集准确率在 98.6% 以上”,这个指标是通过计算测试集上样本对判断正确的比例得到的。写一个快速验证脚本:
from siamese import SiameseNetwork from utils import load_test_pairs import torch model = SiameseNetwork(backbone).eval() model.load_state_dict(torch.load("checkpoints/best.pth")) pairs, labels = load_test_pairs("data") correct = 0 for (x1, x2), y in zip(pairs, labels): with torch.no_grad(): d = model(x1.unsqueeze(0), x2.unsqueeze(0)).item() pred = 1 if d < threshold else 0 correct += (pred == int(y)) print("acc:", correct / len(pairs))验证脚本不再计算梯度、统一走 model.eval(),然后按相似度阈值判定样本对是不是同类,最后统计预测正确的比例。threshold 通常取训练完成后在验证集上扫出来的最优值,不是随便拍脑袋定的。准确率超过 98% 说明模型已经能稳定区分同类和异类,至于放到具体业务场景好不好用,还得看检测环节把候选图裁得干不干净。如果候选框裁得歪七扭八,后面相似度判断再准也白搭。
5. 推理管线与进阶技巧:YOLOv3-tiny 候选框与孪生判同怎么配合落地
predict.py 做的事情不只是加载模型权重跑一次 forward。完整链路是:先用 yolov3-tiny.cfg 对应的检测网络把大图中的候选小图抠出来,再把候选小图与题干模板图两两组成样本对,输入孪生网络得到相似度,最后按阈值筛选出匹配项,输出坐标和序号。仓库里带 yolov3-tiny.cfg 就是为了这一步候选框提取,它和孪生网络是前后串联的关系,不是二选一。
阅读 predict.py 时,重点看两个地方。一是模板图从哪来:有的版本是用户指定一张目标图,有的版本是直接从检测结果里挑置信度最高的框当模板。二是阈值的处理方式:判断是不是同类,最终都落在“距离小于阈值就算匹配”这个逻辑上。可以加一个小技巧:把特征向量做 L2 归一化后用余弦相似度代替欧氏距离,点选场景里候选图的光照和缩放变化比较大,余弦相似度对向量模长不敏感,鲁棒性更好。
阈值最好不要自己拍脑袋定。训练完可以在验证集上扫一遍:
best_t, best_acc = 0.0, 0.0 for t in np.arange(0.3, 1.0, 0.05): acc = evaluate(model, val_pairs, t) if acc > best_acc: best_t, best_acc = t, acc print(best_t, best_acc)这段代码用不同阈值在验证集上做评估,挑出准确率最高的 threshold。距离在阈值附近的样本对宁可不放行,也不要误判打乱点选顺序,这类场景漏判比错判更容易接受。我之前翻过车:训练阶段的 load_image 里做了归一化,predict.py 里直接读了原始图,推理结果一塌糊涂。从那以后我每次换数据集、换机器,都强制先跑一遍最小规模的 predict,确认预处理一致再开始正式训练。这套资源带数据集、带检测配置、带训练和推理脚本,麻雀虽小五脏俱全,适合当样板工程研究,希望帮到你。
本文还有配套的精品资源,点击获取