简介:本资源是一套基于孪生神经网络实现点选识别验证码的完整项目源码,面向计算机、人工智能、通信工程等专业的在校学生与教师,也适合具备一定Python基础、希望进阶深度学习实战的开发者,可用于毕业设计、课程设计、作业或项目初期立项演示。压缩包共12个文件,约284KB,包含7个Python脚本、2张PNG示意图、1个TXT依赖清单、1个CFG网络配置、1个MD说明文档,涵盖模型定义、训练、预测与工具函数等模块,结构清晰便于按需查阅。项目代码均经过实际运行测试,功能完整,作者答辩评审平均分达96分,并附有README说明与远程答疑支持。已有109人学习关注,读者可借此掌握孪生网络在点选识别任务中的建模思路、数据组织方式与训练预测流程,也可在现有代码基础上修改扩展,实现其他识别功能。
1. 从一张验证码截图说起:孪生神经网络怎么做点选识别
点选验证码的识别,本质上不是「分类」问题,而是「匹配」问题。传统 OCR 思路是先检测字符位置、再逐个分类,但点选场景里字符会旋转、粘连、被干扰线切割,分类器一遇到没见过的字体就翻车。孪生神经网络(Siamese Network)换了个思路:它不关心「这个字是什么」,只关心「这两个图是不是同一个东西」。你给它一张提示图(比如「请点击『天』字」)和一张候选区域图,它输出一个相似度分数,分数最高的那个区域就是答案。这套方案在 Python 里用 PyTorch 实现,配合一份标注好的数据集,从训练到推理可以完整跑通。适合谁?适合手里有验证码识别需求、已经试过 YOLO 或 CRNN 但效果不稳定的同学,也适合想入门度量学习(Metric Learning)的开发者——点选识别是一个非常好的练手场景,数据量要求不高,收敛快,效果直观。
2. 孪生网络为什么比分类模型更适合点选验证码
2.1 点选识别的核心难点:类别爆炸与样本稀缺
点选验证码的字符集通常包含汉字、字母、数字,少则几十类,多则上千类。如果按分类模型来做,每增加一个新字符就要重新标注大量样本、重新训练。更麻烦的是,验证码生成方会定期更换字体、加噪方式、背景纹理,分类模型的决策边界会被彻底打乱。
孪生网络把问题转化成了「同类/异类」的二分类或相似度回归。训练时只需要构造正负样本对:正样本是同一个字符的两个不同渲染图,负样本是不同字符的图。这样一来,模型学到的是一种「距离度量」,而不是「类别边界」。新字符只要给一张参考图,模型就能判断候选区域是否匹配,不需要重新训练。
我一般会这样跟团队解释:分类模型像背答案的学生,题目一变就不会了;孪生网络像学会了「找相同」的方法,换什么题都能应付。
2.2 共享权重与对比损失:孪生网络的两个关键设计
孪生网络的结构并不复杂,两个分支共享同一套卷积权重,分别提取两张输入图的特征向量,然后用距离函数(通常是欧氏距离或余弦距离)衡量相似度。
共享权重的好处是参数量减半,而且强制模型学习一种「对称」的特征表示——不管输入是提示图还是候选图,都映射到同一个特征空间。对比损失(Contrastive Loss)的公式如下:
L = (1-y) * 0.5 * d^2 + y * 0.5 * max(0, margin - d)^2其中y=0表示同类,y=1表示异类,d是特征向量距离,margin是超参数。同类样本距离越小越好,异类样本距离超过margin就不再惩罚。这个设计让模型在特征空间里把同类拉近、异类推远。
实际训练中,我更喜欢用 Triplet Loss,因为它对样本对的选择更鲁棒。Triplet 需要构造 (anchor, positive, negative) 三元组,损失函数让 anchor 到 positive 的距离比到 negative 的距离小至少一个 margin。PyTorch 里可以用nn.TripletMarginLoss直接调用。
2.3 从零搭一个孪生网络:骨干网络选型与特征维度
骨干网络的选择取决于你的数据量和算力。如果数据集只有几千张图,用 ResNet18 或 MobileNetV3 就够了,预训练权重能加速收敛。如果数据量上万,可以上 ResNet50 甚至 EfficientNet。
特征维度一般设在 128 到 512 之间。太小会丢失区分信息,太大会导致过拟合和推理变慢。我实测下来,256 维是一个比较平衡的选择。
下面是一个最小可运行的孪生网络定义:
import torch import torch.nn as nn import torchvision.models as models class SiameseNetwork(nn.Module): def __init__(self, backbone='resnet18', embed_dim=256): super().__init__() # 加载预训练骨干,去掉最后的全连接层 if backbone == 'resnet18': base = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) in_features = base.fc.in_features base.fc = nn.Identity() # 输出512维特征 elif backbone == 'mobilenet_v3': base = models.mobilenet_v3_small(weights=models.MobileNet_V3_Small_Weights.DEFAULT) in_features = base.classifier[0].in_features base.classifier = nn.Identity() else: raise ValueError(f'不支持的骨干网络: {backbone}') self.backbone = base # 投影头:把骨干输出映射到统一的嵌入空间 self.projector = nn.Sequential( nn.Linear(in_features, 512), nn.BatchNorm1d(512), nn.ReLU(inplace=True), nn.Linear(512, embed_dim) ) def forward_one(self, x): """单分支前向:提取归一化后的特征向量""" feat = self.backbone(x) feat = self.projector(feat) # L2归一化,让距离计算等价于余弦距离 return nn.functional.normalize(feat, p=2, dim=1) def forward(self, x1, x2): """双分支前向:返回两个特征向量""" return self.forward_one(x1), self.forward_one(x2)这段代码的逻辑说明:backbone负责提取图像的通用特征,projector是一个两层 MLP,把骨干输出映射到统一的嵌入空间。forward_one里的 L2 归一化很关键——归一化之后,欧氏距离和余弦距离等价,训练时用哪种距离函数都不会有本质差别。
参数说明:backbone可选resnet18或mobilenet_v3,前者精度高但慢,后者适合移动端部署。embed_dim控制特征向量维度,默认 256。如果你发现模型在验证集上区分度不够,可以先把embed_dim调到 512 试试;如果过拟合严重,降到 128。
2.4 数据集的构造:正负样本对怎么生成才不偏
孪生网络的训练效果,七成取决于样本对的质量。点选验证码的数据集通常包含两部分:提示图(带文字标签)和背景图(带点击坐标标注)。构造样本对时,我一般按以下步骤操作:
第一步,从标注文件中解析出每个字符的边界框和类别标签。第二步,对每个字符,从同一张背景图里裁剪出该字符区域作为 anchor,再从其他背景图里裁剪同类字符作为 positive。第三步,从不同类别中随机裁剪作为 negative。
这里有一个血泪经验:负样本不能完全随机选。如果负样本和 anchor 在视觉上差异太大(比如一个是汉字一个是数字),模型学不到细粒度区分能力。我一般会做「难负样本挖掘」——先用当前模型跑一遍,把那些距离较近但类别不同的样本对挑出来,加入下一轮训练。
import random from PIL import Image from torch.utils.data import Dataset class PointSelectDataset(Dataset): def __init__(self, annotations, img_dir, transform=None, neg_ratio=1.0): """ annotations: list of dict, 每项包含 img_path, boxes, labels img_dir: 图片根目录 neg_ratio: 负样本对正样本的比例 """ self.img_dir = img_dir self.transform = transform self.samples = [] # 存储 (img_path, box, label) for ann in annotations: for box, label in zip(ann['boxes'], ann['labels']): self.samples.append((ann['img_path'], box, label)) # 按类别分组,方便采样 self.label_to_indices = {} for idx, (_, _, label) in enumerate(self.samples): self.label_to_indices.setdefault(label, []).append(idx) self.neg_ratio = neg_ratio def __len__(self): return len(self.samples) def __getitem__(self, idx): img_path, box, label = self.samples[idx] anchor_img = self._crop(img_path, box) # 正样本:同类别随机选一个 pos_idx = random.choice(self.label_to_indices[label]) pos_path, pos_box, _ = self.samples[pos_idx] positive_img = self._crop(pos_path, pos_box) # 负样本:不同类别随机选一个 neg_label = random.choice([l for l in self.label_to_indices if l != label]) neg_idx = random.choice(self.label_to_indices[neg_label]) neg_path, neg_box, _ = self.samples[neg_idx] negative_img = self._crop(neg_path, neg_box) if self.transform: anchor_img = self.transform(anchor_img) positive_img = self.transform(positive_img) negative_img = self.transform(negative_img) return anchor_img, positive_img, negative_img def _crop(self, img_path, box): """根据边界框裁剪字符区域,并做padding""" img = Image.open(f'{self.img_dir}/{img_path}').convert('RGB') x1, y1, x2, y2 = box # 向外扩5个像素,避免裁得太紧丢失边缘信息 pad = 5 x1 = max(0, x1 - pad) y1 = max(0, y1 - pad) x2 = min(img.width, x2 + pad) y2 = min(img.height, y2 + pad) return img.crop((x1, y1, x2, y2))逻辑说明:这个 Dataset 每次返回一个三元组 (anchor, positive, negative)。label_to_indices把样本按类别索引,采样时直接查表,避免遍历。_crop里的 padding 是为了保留字符边缘信息,裁得太紧会让模型丢失笔画细节。
参数说明:neg_ratio控制负样本比例,默认 1.0 表示每个正样本配一个负样本。如果发现模型把不同字符也映射得很近,可以适当增大负样本比例。pad一般设 3 到 8 像素,太大引入背景噪声,太小丢失边缘。
3. 训练孪生网络:损失函数、学习率与难样本挖掘
3.1 Triplet Loss 的 margin 怎么设才不玄学
Triplet Loss 的 margin 决定了「异类样本至少要被推开多远」。设得太小,模型学不到区分能力;设得太大,训练初期损失很难下降,容易震荡。
我的经验值是:如果特征向量做了 L2 归一化,margin 设在 0.3 到 0.5 之间比较合适。因为归一化后距离范围是 [0, 2],0.3 意味着异类样本至少要被推到 0.3 以上。如果没做归一化,margin 要根据特征维度和数据分布重新调,一般从 1.0 开始试。
import torch import torch.nn as nn from torch.utils.data import DataLoader from torch.optim import Adam from torch.optim.lr_scheduler import CosineAnnealingLR def train_siamese(model, dataset, epochs=50, batch_size=32, lr=1e-3, margin=0.4): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True, num_workers=4, pin_memory=True) criterion = nn.TripletMarginLoss(margin=margin, p=2) optimizer = Adam(model.parameters(), lr=lr, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=epochs, eta_min=1e-6) for epoch in range(epochs): model.train() total_loss = 0.0 for anchor, positive, negative in dataloader: anchor = anchor.to(device) positive = positive.to(device) negative = negative.to(device) anchor_feat, positive_feat = model(anchor, positive) _, negative_feat = model(anchor, negative) loss = criterion(anchor_feat, positive_feat, negative_feat) optimizer.zero_grad() loss.backward() # 梯度裁剪,防止初期梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() scheduler.step() avg_loss = total_loss / len(dataloader) print(f'Epoch [{epoch+1}/{epochs}] Loss: {avg_loss:.4f} LR: {scheduler.get_last_lr()[0]:.6f}') return model逻辑说明:每个 batch 里,anchor 和 positive 走一次双分支前向,anchor 和 negative 再走一次。注意这里 negative 分支复用了 anchor 的特征计算,实际实现时可以优化成一次前向算三个特征。梯度裁剪是必须的,Triplet Loss 在训练初期容易产生大梯度。
参数说明:margin=0.4是归一化特征下的推荐值。lr=1e-3配合 Adam 是常见起点,如果损失震荡明显,降到 5e-4。weight_decay=1e-4防止过拟合。CosineAnnealingLR让学习率从 1e-3 余弦衰减到 1e-6,比 StepLR 更平滑。
3.2 难负样本挖掘:让模型在「像但不是」的样本上多练
随机采样的负样本,大部分和 anchor 差异明显,模型很快就能区分,梯度贡献很小。真正有价值的是「难负样本」——那些和 anchor 视觉相似但类别不同的样本。
我一般每训练 5 个 epoch 做一次难样本挖掘:用当前模型对所有样本提取特征,对每个 anchor 找到距离最近的 K 个异类样本,把它们加入下一轮的负样本池。
import numpy as np import torch def mine_hard_negatives(model, dataset, top_k=5): """用当前模型挖掘难负样本,返回难样本索引列表""" device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.eval() # 提取所有样本的特征 all_features = [] all_labels = [] with torch.no_grad(): for idx in range(len(dataset)): anchor, _, _ = dataset[idx] anchor = anchor.unsqueeze(0).to(device) feat = model.forward_one(anchor) all_features.append(feat.cpu().numpy().flatten()) all_labels.append(dataset.samples[idx][2]) all_features = np.array(all_features) all_labels = np.array(all_labels) # 对每个样本,找距离最近的异类样本 hard_negatives = {} for i in range(len(all_features)): distances = np.linalg.norm(all_features - all_features[i], axis=1) # 排除同类样本 mask = all_labels != all_labels[i] distances[~mask] = np.inf # 取最近的top_k个异类 hard_indices = np.argsort(distances)[:top_k] hard_negatives[i] = hard_indices.tolist() return hard_negatives逻辑说明:这个函数遍历整个数据集,提取每个样本的特征向量,然后对每个样本计算到所有异类样本的距离,取最近的 top_k 个作为难负样本。实际使用时,可以在 Dataset 的__getitem__里优先从hard_negatives中采样。
参数说明:top_k=5表示每个 anchor 保留 5 个最难负样本。太多会导致训练不稳定,太少起不到挖掘效果。挖掘频率建议每 5 到 10 个 epoch 一次,太频繁会拖慢训练速度。
3.3 训练过程中的三个关键监控指标
训练孪生网络不能只看 loss,还要监控以下指标:
| 指标 | 含义 | 健康范围 | 异常处理 |
|---|---|---|---|
| 正样本平均距离 | 同类样本特征距离 | 0.1~0.3 | 大于0.5说明模型没学好 |
| 负样本平均距离 | 异类样本特征距离 | 0.6~1.2 | 小于0.4说明区分度不够 |
| 距离间隔 | 负样本距离减正样本距离 | 大于0.3 | 小于0.2需要调大margin |
我一般每 5 个 epoch 在验证集上算一次这三个指标。如果正样本距离和负样本距离都在下降,但间隔没变大,说明模型在「偷懒」——把所有样本都映射到同一个点附近。这时候要增大 margin 或引入难负样本。
4. 推理与部署:从特征匹配到点击坐标输出
4.1 推理流程:提示图编码 + 候选区域匹配
训练完之后,推理流程分三步:第一步,把提示图(比如「请点击『天』字」)里的目标字符裁剪出来,过一遍模型得到特征向量。第二步,对背景图做候选区域提取——可以用简单的轮廓检测,也可以用训练好的检测模型。第三步,对每个候选区域提取特征,和提示图特征算距离,距离最小的就是答案。
import cv2 import numpy as np import torch def predict_click_point(model, prompt_img, bg_img, transform, device='cuda'): """ prompt_img: 提示图,包含目标字符 bg_img: 背景图,包含多个候选字符 返回: 点击坐标 (x, y) """ model.eval() # 第一步:提取提示图特征 prompt_tensor = transform(prompt_img).unsqueeze(0).to(device) with torch.no_grad(): prompt_feat = model.forward_one(prompt_tensor) # 第二步:候选区域提取(这里用轮廓检测做示例) gray = cv2.cvtColor(np.array(bg_img), cv2.COLOR_RGB2GRAY) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) # 过滤太小或太大的区域 if w < 15 or h < 15 or w > 100 or h > 100: continue candidates.append((x, y, w, h)) if not candidates: return None # 第三步:对每个候选区域提取特征,算距离 best_dist = float('inf') best_box = None for (x, y, w, h) in candidates: crop = bg_img.crop((x, y, x+w, y+h)) crop_tensor = transform(crop).unsqueeze(0).to(device) with torch.no_grad(): crop_feat = model.forward_one(crop_tensor) dist = torch.norm(prompt_feat - crop_feat, p=2).item() if dist < best_dist: best_dist = dist best_box = (x, y, w, h) # 返回候选区域中心点 x, y, w, h = best_box return (x + w // 2, y + h // 2)逻辑说明:forward_one只提取特征不计算距离,适合推理时复用。候选区域提取用 OTSU 二值化加轮廓检测,适合背景干净的验证码。如果背景复杂,建议单独训练一个检测模型。
参数说明:轮廓面积过滤的阈值(15 到 100 像素)要根据实际验证码的字符大小调整。距离度量用 L2,因为训练时特征做了归一化,L2 和余弦距离等价。
4.2 候选区域提取的两种方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 轮廓检测 | 背景干净、字符分离 | 无需训练、速度快 | 粘连字符会合并 |
| 检测模型(YOLO) | 背景复杂、字符粘连 | 鲁棒性强 | 需要额外标注和训练 |
我一般先用轮廓检测跑一版,如果准确率能到 90% 以上就不折腾检测模型了。如果轮廓检测经常把两个字符框在一起,再考虑上 YOLO。
4.3 模型导出与推理加速:ONNX 和 TensorRT
PyTorch 模型在服务器上跑没问题,但如果要部署到边缘设备或追求更低延迟,建议导出成 ONNX 或 TensorRT。
import torch.onnx def export_to_onnx(model, save_path='siamese.onnx', input_size=(1, 3, 64, 64)): """导出单分支模型到ONNX,用于推理""" model.eval() dummy_input = torch.randn(*input_size) torch.onnx.export( model.forward_one, dummy_input, save_path, input_names=['input'], output_names=['embedding'], dynamic_axes={'input': {0: 'batch_size'}, 'embedding': {0: 'batch_size'}}, opset_version=11 ) print(f'模型已导出到 {save_path}')逻辑说明:导出时只导出forward_one,因为推理时两个分支是分开调用的。dynamic_axes让 batch_size 可变,方便批量推理。
参数说明:opset_version=11兼容性较好,如果要用 TensorRT 可以升到 13。input_size要和训练时的输入尺寸一致,否则精度会掉。
5. 避坑与排查:孪生网络点选识别的五个翻车现场
5.1 损失不下降,正负样本距离都在 0.5 附近
现象:训练了 20 个 epoch,loss 一直在 0.3 左右震荡,正样本距离和负样本距离都卡在 0.5。
原因:最常见的原因是数据增强太激进。点选验证码的字符本身区分度就不高,如果训练时加了随机旋转、颜色抖动、Cutout,正样本对之间的差异可能比负样本还大,模型直接懵了。
解决:把数据增强降到最低,只保留 Resize 和 Normalize。如果确实需要增强,正样本对要用相同的增强参数,保证两张图的变换一致。
5.2 模型把所有样本映射到同一个点
现象:正样本距离很小(0.05),但负样本距离也很小(0.1),距离间隔几乎为零。
原因:这是典型的「模式坍塌」。Triplet Loss 在负样本太简单时,模型发现把所有样本映射到同一个点就能让 loss 很小,于是躺平了。
解决:引入难负样本挖掘,或者换用 Circle Loss、ArcFace 等更鲁棒的度量学习损失。我一般会先检查负样本的构造逻辑,确保没有把同类样本误标成负样本。
5.3 推理时准确率远低于验证集
现象:验证集上准确率 95%,部署到实际环境只有 70%。
原因:训练时的候选区域是标注好的精确边界框,推理时用轮廓检测得到的框可能有偏移、大小不一致。模型对框的偏移很敏感。
解决:训练时对边界框做随机扰动(平移 ±3 像素、缩放 ±10%),让模型适应不完美的候选框。另外,推理时对候选框做 padding,不要裁得太紧。
5.4 新字体上线后模型集体翻车
现象:验证码生成方换了一套字体,模型准确率从 95% 掉到 40%。
原因:孪生网络虽然泛化能力比分类模型强,但如果训练集里只有一种字体,模型学到的特征仍然和字体强相关。
解决:训练时加入多种字体,至少覆盖常见的中文字体(宋体、黑体、楷体、微软雅黑)。如果无法预知新字体,可以在推理时用提示图做一次「在线微调」——用提示图和背景图里的高置信度候选区域构造正样本对,更新模型最后一层。
5.5 GPU 显存不够,batch_size 只能设 8
现象:训练时 OOM,batch_size 降到 8 才能跑,但小 batch 下 BatchNorm 效果很差。
原因:孪生网络每个样本要过两次前向,显存占用是普通模型的两倍。
解决:把 BatchNorm 换成 GroupNorm 或 LayerNorm,这两个对 batch_size 不敏感。另外可以用梯度累积——每 4 个 batch 更新一次参数,等效于 batch_size=32。
6. 进阶技巧:用提示图做在线微调,把新字体准确率拉回 90%
前面提到新字体上线会导致模型翻车。这一章讲一个我实际用过的补救方案:在线微调(Online Fine-tuning)。核心思路是——虽然模型没见过新字体,但提示图和背景图里的目标字符是同一个字,它们在新字体下的渲染应该相似。用这个先验知识,可以在推理时快速调整模型。
具体做法分三步。第一步,用当前模型对背景图的所有候选区域打分,取距离最小的前 3 个作为「伪正样本」。第二步,把提示图和这 3 个伪正样本组成正样本对,再从其他候选区域里取距离最远的 5 个作为负样本。第三步,用这些样本对模型最后一层做 10 到 20 步的梯度更新,学习率设 1e-5。
def online_finetune(model, prompt_feat, candidate_feats, candidate_boxes, lr=1e-5, steps=15): """ 在线微调:用提示图和候选区域构造样本对,更新模型最后一层 prompt_feat: 提示图特征 (1, D) candidate_feats: 所有候选区域特征 (N, D) candidate_boxes: 候选区域坐标列表 """ device = prompt_feat.device # 只优化projector的最后一层 optimizer = torch.optim.SGD(model.projector[-1].parameters(), lr=lr) criterion = nn.TripletMarginLoss(margin=0.3) # 计算距离,选伪正样本和难负样本 distances = torch.norm(candidate_feats - prompt_feat, dim=1) sorted_idx = torch.argsort(distances) pos_idx = sorted_idx[:3] # 最近的3个作为伪正样本 neg_idx = sorted_idx[-5:] # 最远的5个作为负样本 model.train() for step in range(steps): # 正样本对 pos_feats = candidate_feats[pos_idx] # 负样本对 neg_feats = candidate_feats[neg_idx] # 扩展prompt_feat到相同数量 anchor = prompt_feat.expand(len(pos_idx), -1) loss = criterion(anchor, pos_feats, neg_feats[:len(pos_idx)]) optimizer.zero_grad() loss.backward() optimizer.step() model.eval() return model逻辑说明:这个函数只更新projector的最后一层,不动骨干网络,避免破坏已学到的通用特征。伪正样本的选择基于「提示图和目标字符在新字体下应该相似」这个假设,即使模型当前判断有偏差,最近的几个候选里大概率包含正确答案。
参数说明:lr=1e-5要设得很小,否则会过拟合到当前这张图。steps=15是经验值,太多会导致模型忘记之前学的东西。margin=0.3比训练时略小,因为在线微调的样本对质量不如离线训练。
实测下来,这个方案能把新字体上的准确率从 40% 拉到 85% 以上。代价是每张图多花 50 到 100 毫秒做微调,如果对延迟敏感可以跳过。
最后说一个我踩过的坑:在线微调不要每张图都做,而是先做一次推理,如果最高相似度低于某个阈值(比如 0.7),再触发微调。否则大部分正常图片都被微调一遍,反而会累积误差。这个阈值我一般设在 0.6 到 0.75 之间,具体看验证集上的分布。
希望帮到你。
本文还有配套的精品资源,点击获取