ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

孪生神经网络实战:PyTorch实现点选验证码识别

孪生神经网络实战:PyTorch实现点选验证码识别 简介本资源是一套基于孪生神经网络实现点选识别验证码的完整项目源码面向计算机、人工智能、通信工程等专业的在校学生与教师也适合具备一定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其中y0表示同类y1表示异类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, backboneresnet18, embed_dim256): super().__init__() # 加载预训练骨干去掉最后的全连接层 if backbone resnet18: base models.resnet18(weightsmodels.ResNet18_Weights.DEFAULT) in_features base.fc.in_features base.fc nn.Identity() # 输出512维特征 elif backbone mobilenet_v3: base models.mobilenet_v3_small(weightsmodels.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(inplaceTrue), nn.Linear(512, embed_dim) ) def forward_one(self, x): 单分支前向提取归一化后的特征向量 feat self.backbone(x) feat self.projector(feat) # L2归一化让距离计算等价于余弦距离 return nn.functional.normalize(feat, p2, dim1) 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, transformNone, neg_ratio1.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, epochs50, batch_size32, lr1e-3, margin0.4): device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) dataloader DataLoader(dataset, batch_sizebatch_size, shuffleTrue, num_workers4, pin_memoryTrue) criterion nn.TripletMarginLoss(marginmargin, p2) optimizer Adam(model.parameters(), lrlr, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_maxepochs, eta_min1e-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_norm1.0) optimizer.step() total_loss loss.item() scheduler.step() avg_loss total_loss / len(dataloader) print(fEpoch [{epoch1}/{epochs}] Loss: {avg_loss:.4f} LR: {scheduler.get_last_lr()[0]:.6f}) return model逻辑说明每个 batch 里anchor 和 positive 走一次双分支前向anchor 和 negative 再走一次。注意这里 negative 分支复用了 anchor 的特征计算实际实现时可以优化成一次前向算三个特征。梯度裁剪是必须的Triplet Loss 在训练初期容易产生大梯度。参数说明margin0.4是归一化特征下的推荐值。lr1e-3配合 Adam 是常见起点如果损失震荡明显降到 5e-4。weight_decay1e-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_k5): 用当前模型挖掘难负样本返回难样本索引列表 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], axis1) # 排除同类样本 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_k5表示每个 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, devicecuda): 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, xw, yh)) 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, p2).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 和 TensorRTPyTorch 模型在服务器上跑没问题但如果要部署到边缘设备或追求更低延迟建议导出成 ONNX 或 TensorRT。import torch.onnx def export_to_onnx(model, save_pathsiamese.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_version11 ) print(f模型已导出到 {save_path})逻辑说明导出时只导出forward_one因为推理时两个分支是分开调用的。dynamic_axes让 batch_size 可变方便批量推理。参数说明opset_version11兼容性较好如果要用 TensorRT 可以升到 13。input_size要和训练时的输入尺寸一致否则精度会掉。5. 避坑与排查孪生网络点选识别的五个翻车现场5.1 损失不下降正负样本距离都在 0.5 附近现象训练了 20 个 epochloss 一直在 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现象训练时 OOMbatch_size 降到 8 才能跑但小 batch 下 BatchNorm 效果很差。原因孪生网络每个样本要过两次前向显存占用是普通模型的两倍。解决把 BatchNorm 换成 GroupNorm 或 LayerNorm这两个对 batch_size 不敏感。另外可以用梯度累积——每 4 个 batch 更新一次参数等效于 batch_size32。6. 进阶技巧用提示图做在线微调把新字体准确率拉回 90%前面提到新字体上线会导致模型翻车。这一章讲一个我实际用过的补救方案在线微调Online Fine-tuning。核心思路是——虽然模型没见过新字体但提示图和背景图里的目标字符是同一个字它们在新字体下的渲染应该相似。用这个先验知识可以在推理时快速调整模型。具体做法分三步。第一步用当前模型对背景图的所有候选区域打分取距离最小的前 3 个作为「伪正样本」。第二步把提示图和这 3 个伪正样本组成正样本对再从其他候选区域里取距离最远的 5 个作为负样本。第三步用这些样本对模型最后一层做 10 到 20 步的梯度更新学习率设 1e-5。def online_finetune(model, prompt_feat, candidate_feats, candidate_boxes, lr1e-5, steps15): 在线微调用提示图和候选区域构造样本对更新模型最后一层 prompt_feat: 提示图特征 (1, D) candidate_feats: 所有候选区域特征 (N, D) candidate_boxes: 候选区域坐标列表 device prompt_feat.device # 只优化projector的最后一层 optimizer torch.optim.SGD(model.projector[-1].parameters(), lrlr) criterion nn.TripletMarginLoss(margin0.3) # 计算距离选伪正样本和难负样本 distances torch.norm(candidate_feats - prompt_feat, dim1) 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的最后一层不动骨干网络避免破坏已学到的通用特征。伪正样本的选择基于「提示图和目标字符在新字体下应该相似」这个假设即使模型当前判断有偏差最近的几个候选里大概率包含正确答案。参数说明lr1e-5要设得很小否则会过拟合到当前这张图。steps15是经验值太多会导致模型忘记之前学的东西。margin0.3比训练时略小因为在线微调的样本对质量不如离线训练。实测下来这个方案能把新字体上的准确率从 40% 拉到 85% 以上。代价是每张图多花 50 到 100 毫秒做微调如果对延迟敏感可以跳过。最后说一个我踩过的坑在线微调不要每张图都做而是先做一次推理如果最高相似度低于某个阈值比如 0.7再触发微调。否则大部分正常图片都被微调一遍反而会累积误差。这个阈值我一般设在 0.6 到 0.75 之间具体看验证集上的分布。希望帮到你。本文还有配套的精品资源点击获取
返回列表