孪生网络实战:点选验证码识别从训练到部署
简介本资源是一套基于孪生神经网络实现点选识别验证码的完整项目面向计算机、人工智能、通信工程等专业的在校学生、教师及企业员工尤其适合作为毕业设计、课程设计或项目初期立项演示也适合具备一定基础的小白进阶学习。压缩包共12个文件约284KB包含7个Python源码文件、2张png示意图、1个txt依赖说明、1个cfg模型配置、1个md说明文档覆盖模型定义、训练、预测与工具函数等模块结构清晰便于按需查阅。项目代码均经过测试运行成功答辩评审平均分达96分已有110人学习下载。读者可从中获得孪生网络与目标检测结合的完整实现思路、可复现的训练与预测脚本、配置文件及数据集使用说明并能在现有代码基础上修改扩展用于其他识别任务或二次开发具备较高的学习与参考价值。1. 点选验证码识别为什么让传统 OCR 集体翻车做过爬虫或者自动化测试的人大概率都遇到过这种场景页面上弹出一张图上面散落着「春」「风」「得」「意」几个字旁边一行小字写着「请依次点击风、春、意、得」。你拿 Tesseract 或者 PaddleOCR 直接怼上去识别出来的文字顺序完全是乱的——因为传统 OCR 的思维是「从左到右、从上到下」逐行读取而点选验证码要的是「按语义顺序点击坐标」。这两个任务的目标根本不在一个维度上。这就是点选识别Click Captcha Recognition要解决的核心问题它不只是「认字」而是「认字 定位 排序」三合一。孪生神经网络Siamese Network之所以在这个任务上表现突出是因为它的本质是学习「两个输入是否相似」的度量而不是直接做分类。放到点选场景里我们可以把「提示文字」和「图中每个字符区域」分别编码然后比对相似度相似度最高的那个区域就是目标位置。这个思路天然适配「给定一个 query在候选集里找最匹配项」的需求比强行套检测分类的流水线要干净得多。这篇笔记面向的是有 Python 基础、想自己跑通一套点选识别方案的工程师。我会从数据集的准备讲起把孪生网络的结构设计、训练细节、推理部署串成一条可复现的路径中间该踩的坑一个不落。整套方案不依赖任何闭源接口纯本地训练和推理代码结构清晰适合作为自己项目的起点。2. 孪生网络做点选识别从任务拆解到数据准备2.1 点选识别到底在预测什么先把任务定义清楚。一张点选验证码图片通常包含以下元素若干随机排列的字符中文、数字或字母以及一行提示文字告诉你「请依次点击X、Y、Z」。模型的输出应该是一个有序的坐标列表对应提示中每个字符在图片中的位置。形式化地讲给定图片 I 和提示序列 T [t1, t2, ..., tn]我们需要输出坐标序列 P [(x1,y1), (x2,y2), ..., (xn,yn)]使得每个 (xi, yi) 指向图中字符 ti 的位置。传统做法分两步先做字符检测把图中所有字符框出来并分类再拿提示文字去匹配分类结果。这个方案的问题在于检测模型对字符的分类精度直接决定了最终效果而点选验证码里的字符往往有旋转、扭曲、粘连、干扰线分类器很容易崩。孪生网络的思路绕开了「分类」这个环节。我们不关心图中那个字到底是什么只关心「它和提示里的某个字是不是同一个字」。具体来说把提示中的每个字符切出来作为 query 图像把图中每个候选区域切出来作为 candidate 图像两者送入同一个编码器得到特征向量然后计算余弦相似度或欧氏距离。相似度最高的候选区域就是目标位置。这样做的好处是编码器只需要学好「相同字符的特征要接近不同字符的特征要远离」这个度量关系不需要对每个字符类别都有足够的训练样本。对于字符种类多、长尾分布严重的场景这个优势非常明显。2.2 数据集从哪来、怎么造标题里提到了数据集但点选验证码的公开数据集并不多。常见做法是自己合成。合成的好处是你可以控制字符集、干扰强度、字体样式、背景复杂度而且标注是天然准确的——你知道每个字符贴在了哪个位置。合成流程大致如下准备一个字符集比如 500 个常用汉字准备若干背景图纯色、纹理、自然场景图都可以准备若干字体文件。每次生成时随机选 n 个字符随机选位置贴上去记录每个字符的 bounding box 和对应的文字内容。同时生成提示文字提示中的字符顺序和贴图顺序可以不一致这样模型才能学会「按提示顺序」而不是「按贴图顺序」输出。import random from PIL import Image, ImageDraw, ImageFont def generate_click_captcha(chars, fonts, bg_images, num_chars4, img_size(300, 200)): 合成一张点选验证码图片 chars: 候选字符列表 fonts: 字体文件路径列表 bg_images: 背景图路径列表 num_chars: 图中需要点击的字符数量 bg Image.open(random.choice(bg_images)).resize(img_size) draw ImageDraw.Draw(bg) # 随机选 num_chars 个不重复的字符 selected random.sample(chars, num_chars) # 打乱提示顺序让提示顺序和贴图顺序不同 prompt_order selected.copy() random.shuffle(prompt_order) annotations [] for ch in selected: font ImageFont.truetype(random.choice(fonts), random.randint(28, 42)) # 随机位置留出边距 x random.randint(10, img_size[0] - 60) y random.randint(10, img_size[1] - 60) # 随机颜色和背景有对比 color (random.randint(0, 100), random.randint(0, 100), random.randint(0, 100)) draw.text((x, y), ch, fontfont, fillcolor) annotations.append({char: ch, bbox: (x, y, x 50, y 50)}) prompt 请依次点击 、.join(prompt_order) return bg, annotations, prompt这段代码的关键点在于prompt_order和selected是分开打乱的这样提示顺序和图中字符的物理排列顺序无关模型必须真正理解「提示中的每个字对应图中哪个区域」而不是靠位置先验去猜。bbox这里给了一个固定大小的框实际使用时可以根据字体的getbbox方法精确计算减少背景噪声的引入。参数方面num_chars一般设 3 到 5太多会导致图片拥挤、字符重叠字体大小 28 到 42 是比较安全的范围太小了编码器学不到判别性特征太大了字符之间容易粘连。背景图建议至少准备 200 张以上否则模型容易过拟合到特定背景。2.3 孪生网络的输入构造正负样本对怎么配孪生网络训练时需要成对的样本。对于点选任务正样本对是「提示中的某个字符」和「图中对应位置的字符图像」负样本对是「提示中的某个字符」和「图中其他位置的字符图像」。具体构造时从一张合成图中我们可以生成 num_chars 个正样本对和 num_chars × (num_chars - 1) 个负样本对。如果每张图有 4 个字符那就是 4 个正对和 12 个负对。正负比例 1:3 是比较合理的既不会让模型偏向某一类也不会让训练集过度膨胀。这里有一个容易翻车的地方负样本对不能随便选。如果负样本中的字符和正样本中的字符恰好是同一个字比如提示里有两个「春」那这个负样本对其实是正样本强行标成负会污染训练。合成数据时可以强制每张图的字符不重复从源头避免这个问题。真实场景中如果遇到重复字符需要在匹配阶段做特殊处理这个后面会讲。3. 编码器选型与网络结构从 CNN 到度量学习3.1 编码器 backbone 怎么选孪生网络的性能上限很大程度上由编码器决定。编码器的作用是把一张字符小图映射成一个固定维度的特征向量使得相同字符的特征向量距离近不同字符的距离远。常见的选择有几种。轻量级的可以用一个 4 层卷积加全局平均池化的结构参数量在 100K 左右推理速度快适合部署到边缘设备。中等规模可以用 ResNet-18 或 MobileNetV3 的前几层参数量在 1M 到 3M 之间精度和速度比较平衡。如果对精度要求极高可以用 ResNet-50 甚至更大的 backbone但推理延迟会明显上升。我一般会先用一个自定义的小型 CNN 跑通全流程确认数据管线和训练逻辑没问题再换更大的 backbone 做精度调优。小型 CNN 的结构大概是Conv(3→32, 3×3) → BN → ReLU → MaxPool → Conv(32→64, 3×3) → BN → ReLU → MaxPool → Conv(64→128, 3×3) → BN → ReLU → AdaptiveAvgPool → FC(128→embedding_dim)。embedding_dim 通常设 128 或 256太小了表达能力不够太大了容易过拟合且推理变慢。import torch import torch.nn as nn import torch.nn.functional as F class CharEncoder(nn.Module): def __init__(self, embedding_dim128): super().__init__() self.conv1 nn.Conv2d(3, 32, 3, padding1) self.bn1 nn.BatchNorm2d(32) self.conv2 nn.Conv2d(32, 64, 3, padding1) self.bn2 nn.BatchNorm2d(64) self.conv3 nn.Conv2d(64, 128, 3, padding1) self.bn3 nn.BatchNorm2d(128) self.pool nn.AdaptiveAvgPool2d(1) self.fc nn.Linear(128, embedding_dim) def forward(self, x): # x: (B, 3, H, W) x F.relu(self.bn1(self.conv1(x))) x F.max_pool2d(x, 2) x F.relu(self.bn2(self.conv2(x))) x F.max_pool2d(x, 2) x F.relu(self.bn3(self.conv3(x))) x self.pool(x).flatten(1) # (B, 128) x self.fc(x) # L2 归一化后续用余弦相似度 return F.normalize(x, p2, dim1)这个编码器的输入是 32×32 或 48×48 的字符小图输出是 128 维的单位向量。L2 归一化这一步很关键它把特征向量约束在单位球面上后续计算余弦相似度时只需要做点积数值稳定性也更好。3.2 损失函数对比损失还是三元组损失孪生网络的训练目标由损失函数定义。最经典的是对比损失Contrastive Loss它的形式是对于正样本对拉近两者距离对于负样本对如果距离小于 margin就推开否则不产生梯度。对比损失的问题是它只考虑了「对」之间的关系没有考虑「对与对」之间的相对关系。三元组损失Triplet Loss引入了锚点anchor、正样本、负样本的三元组要求锚点到正样本的距离比到负样本的距离小至少一个 margin。这个约束更强学到的特征判别性通常更好。在点选任务里我一般用三元组损失因为「提示字符」天然就是锚点「图中对应字符」是正样本「图中其他字符」是负样本三元组的构造非常自然。margin 设 0.3 到 0.5 之间比较合适太小了约束不够太大了训练容易震荡。def triplet_loss(anchor, positive, negative, margin0.4): anchor: 提示字符的特征 (B, D) positive: 图中对应字符的特征 (B, D) negative: 图中其他字符的特征 (B, D) pos_dist F.pairwise_distance(anchor, positive, p2) neg_dist F.pairwise_distance(anchor, negative, p2) loss F.relu(pos_dist - neg_dist margin) return loss.mean()这里用的是欧氏距离因为特征已经 L2 归一化了欧氏距离和余弦距离是等价的。F.pairwise_distance计算的是每个样本对的距离返回的是 (B,) 的向量最后取 mean 得到标量损失。训练时还有一个技巧难负样本挖掘Hard Negative Mining。随机选的负样本可能和锚点差异很大模型很容易就分开了梯度信号很弱。如果专门挑那些和锚点相似度高但又不是同一个字的负样本训练效率会高很多。实现上可以在每个 batch 内计算所有负样本对的距离选距离最小的那个作为难负样本。3.3 训练流程与关键超参训练流程大致是每个 epoch 遍历所有合成图对每张图构造三元组前向传播得到特征计算损失反向传播更新参数。优化器用 Adam学习率初始设 1e-3每 10 个 epoch 衰减到原来的 0.5 倍。Batch size 设 64 或 128太小了梯度噪声大太大了显存吃不消。import torch.optim as optim from torch.utils.data import DataLoader def train_one_epoch(model, dataloader, optimizer, margin0.4): model.train() total_loss 0 for anchor_imgs, pos_imgs, neg_imgs in dataloader: anchor_imgs anchor_imgs.cuda() pos_imgs pos_imgs.cuda() neg_imgs neg_imgs.cuda() anchor_feat model(anchor_imgs) pos_feat model(pos_imgs) neg_feat model(neg_imgs) loss triplet_loss(anchor_feat, pos_feat, neg_feat, margin) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(dataloader)这里有一个工程上的细节anchor、positive、negative 三张图都要过同一个编码器如果分三次 forward计算量是 3 倍。可以把三张图拼成一个 batch 一次性 forward然后再拆开这样 GPU 利用率更高。具体做法是把 (B, 3, H, W) 的三组张量 concat 成 (3B, 3, H, W)forward 后再 split。训练过程中要监控验证集上的准确率。验证集的构造和训练集类似但要用不同的随机种子和不同的背景图确保模型没有过拟合到训练背景。如果训练 loss 持续下降但验证准确率停滞甚至下降说明过拟合了需要加数据增强随机旋转、颜色抖动、高斯噪声或者减小模型容量。4. 推理阶段从特征匹配到坐标输出4.1 候选区域的提取策略推理时我们拿到一张完整的点选验证码图片和提示文字。第一步是把提示中的每个字符切出来作为 query 图像。这一步相对简单因为提示文字通常是规整的印刷体用简单的投影法或者连通域分析就能切分。第二步是把图中所有可能的字符区域提取出来作为 candidate。这一步比较麻烦因为图中的字符可能有旋转、扭曲、粘连而且还有干扰线和背景噪声。常见做法有两种一种是用一个轻量的目标检测模型比如 YOLO 的极简版先把所有字符框出来另一种是用滑动窗口或者选择性搜索Selective Search生成候选区域。我一般会用一个简化版的检测头在编码器的特征图上做一次 1×1 卷积输出每个位置的「是否有字符」得分和边界框回归。这个检测头可以和孪生编码器共享 backbone训练时联合优化。如果不想引入检测也可以用 OpenCV 的 MSERMaximally Stable Extremal Regions算法提取候选区域然后过滤掉面积过小或过大的区域。import cv2 import numpy as np def extract_candidates(image_path, min_area200, max_area5000): 用 MSER 提取候选字符区域 返回候选区域的裁剪图像列表和对应的 bbox img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) mser cv2.MSER_create() regions, _ mser.detectRegions(gray) candidates [] bboxes [] for region in regions: x, y, w, h cv2.boundingRect(region) area w * h if min_area area max_area and 0.3 w / h 3.0: crop img[y:yh, x:xw] crop cv2.resize(crop, (48, 48)) candidates.append(crop) bboxes.append((x, y, w, h)) # 去重IoU 大于 0.5 的只保留一个 keep [] for i in range(len(bboxes)): if i in keep: continue for j in range(i 1, len(bboxes)): if iou(bboxes[i], bboxes[j]) 0.5: break else: keep.append(i) return [candidates[i] for i in keep], [bboxes[i] for i in keep]MSER 的参数需要根据实际图片调整。min_area和max_area控制候选区域的大小范围太小了会把噪声当成字符太大了会把多个字符框在一起。宽高比过滤可以去掉细长的干扰线。IoU 去重是为了避免同一个字符被多次检测。4.2 相似度匹配与顺序输出拿到 query 特征和所有 candidate 特征后对每个 query 字符计算它和所有 candidate 的余弦相似度取相似度最高的那个作为匹配结果。如果提示中有多个字符每个字符独立匹配最后按提示顺序输出坐标。def match_and_output(model, query_imgs, candidate_imgs, candidate_bboxes): query_imgs: 提示字符图像列表 candidate_imgs: 候选区域图像列表 candidate_bboxes: 候选区域坐标列表 返回按提示顺序排列的坐标列表 model.eval() with torch.no_grad(): # 批量编码 query_tensor torch.stack([preprocess(img) for img in query_imgs]).cuda() cand_tensor torch.stack([preprocess(img) for img in candidate_imgs]).cuda() query_feat model(query_tensor) # (Q, D) cand_feat model(cand_tensor) # (C, D) # 余弦相似度矩阵 sim_matrix query_feat cand_feat.T # (Q, C) # 每个 query 取相似度最高的 candidate best_indices sim_matrix.argmax(dim1) # (Q,) result [] for idx in best_indices: result.append(candidate_bboxes[idx.item()]) return result这里有一个潜在问题如果两个 query 字符匹配到了同一个 candidate说明匹配出错了。解决办法是在匹配时加一个约束已经匹配过的 candidate 不再参与后续匹配。实现上可以用贪心策略先匹配相似度最高的 query然后把它选中的 candidate 从候选集中移除再匹配下一个。另一个问题是阈值。如果所有 candidate 的相似度都低于某个阈值比如 0.5说明图中可能根本没有这个字符或者候选区域提取失败了。这时候应该返回一个「未找到」的标志而不是强行选一个最高分。4.3 后处理坐标映射与可视化模型输出的坐标是在候选区域裁剪图上的坐标需要映射回原图。如果候选区域是从原图上裁剪的那 bbox 本身就是原图坐标直接用即可。如果做了缩放需要按比例还原。可视化是一个很实用的调试手段。把匹配结果画在原图上用不同颜色标注不同 query 的匹配位置可以直观地看出模型哪里匹配对了、哪里匹配错了。def visualize_result(image_path, bboxes, prompt_chars, output_pathresult.jpg): img cv2.imread(image_path) colors [(0, 0, 255), (0, 255, 0), (255, 0, 0), (0, 255, 255), (255, 0, 255)] for i, (bbox, ch) in enumerate(zip(bboxes, prompt_chars)): x, y, w, h bbox color colors[i % len(colors)] cv2.rectangle(img, (x, y), (x w, y h), color, 2) cv2.putText(img, f{i1}:{ch}, (x, y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) cv2.imwrite(output_path, img)这个可视化函数会把每个匹配结果按顺序标号并写上对应的提示字符。如果发现某个框明显偏了或者两个框重叠了就说明匹配环节有问题需要回头检查候选区域提取或者相似度计算。5. 避坑指南点选识别落地时最容易翻车的五个地方5.1 合成数据太干净真实场景直接崩现象在合成数据集上准确率 99%一上真实网站准确率掉到 30%。原因合成数据的背景太单一、字符太清晰、干扰太少。真实点选验证码往往有复杂的背景纹理、半透明干扰线、字符扭曲和颜色渐变。模型在合成数据上学到的特征和真实数据分布差距太大。解决合成时加入更强的数据增强。背景图要用自然场景图而不是纯色图字符要加随机旋转±15 度、弹性形变、颜色抖动还要叠加随机干扰线和噪点。如果条件允许收集几百张真实图片做微调哪怕标注少一点也能显著提升泛化能力。5.2 候选区域提取漏掉了目标字符现象匹配结果总是差一个某个提示字符找不到对应的框。原因MSER 或者检测头对某些字符不敏感比如颜色和背景接近的字符、被干扰线穿过的字符、面积特别小的字符。解决降低候选区域的过滤阈值宁可多提一些候选也不要漏掉。多出来的候选会被相似度匹配自然过滤掉但漏掉的候选是永远找不回来的。另外可以多尺度提取候选在不同分辨率下各跑一次 MSER然后合并结果。5.3 提示字符切分错误导致 query 本身就是错的现象模型匹配结果看起来「有道理」但和预期完全不符。原因提示文字的切分出了问题。比如「请依次点击春、风、得、意」这行字如果切分时把「春」和「、」切在了一起query 图像里就混入了标点编码出来的特征自然不对。解决切分提示文字时先用颜色阈值或者形态学操作去掉标点符号只保留汉字或字母数字。切分后做一次尺寸归一化确保每个 query 图像的大小和训练时一致。如果提示文字有旋转或倾斜先做倾斜校正再切分。5.4 相似度阈值设死换一批数据就失效现象在 A 网站上调好的阈值 0.6换到 B 网站后要么全部匹配失败要么全部匹配到错误位置。原因不同网站的字符字体、大小、干扰程度不同模型输出的相似度分布也不同。固定阈值无法适应这种分布变化。解决不要用固定阈值用相对阈值。比如取最高相似度和次高相似度的比值如果比值大于 1.2 才认为匹配可信。或者用 batch 内的相似度均值和标准差做归一化动态判断。更稳妥的做法是保留一个小的验证集每次换场景时重新校准阈值。5.5 GPU 显存不够导致 batch size 被迫调小训练不稳定现象训练 loss 震荡严重准确率上不去。原因显存不够batch size 只能设 8 或 16梯度噪声太大BatchNorm 的统计量也不准确。解决用梯度累积Gradient Accumulation。每次 forward 一个小 batch累积多次梯度后再更新参数等效于大 batch。另外可以把编码器的输入分辨率从 48×48 降到 32×32显存占用能减少一半以上。如果还是不够用混合精度训练AMP显存再省 30% 左右。scaler torch.cuda.amp.GradScaler() for i, (anchor, pos, neg) in enumerate(dataloader): with torch.cuda.amp.autocast(): anchor_feat model(anchor.cuda()) pos_feat model(pos.cuda()) neg_feat model(neg.cuda()) loss triplet_loss(anchor_feat, pos_feat, neg_feat) / accum_steps scaler.scale(loss).backward() if (i 1) % accum_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()这段代码把 loss 除以累积步数保证梯度尺度不变。scaler自动处理浮点精度避免梯度下溢。累积步数一般设 4 或 8等效 batch size 就是实际 batch size 乘以累积步数。6. 进阶技巧用难负样本挖掘和在线评估把准确率再拉一截训练到后期随机负样本已经很难提供有效的梯度信号了。这时候需要上难负样本挖掘。具体做法是每个 epoch 结束后用当前模型对所有训练样本做一次推理找出那些「模型认为相似但实际不是同一个字」的负样本对把它们加入下一个 epoch 的训练集并赋予更高的采样权重。这个策略的本质是让模型专注于它目前还分不清的样本。实现上不需要改网络结构只需要在 DataLoader 的采样器上做文章。我一般会维护一个难负样本池每个 epoch 从池中采样 30% 的负样本剩下 70% 从随机负样本中采。池子每两个 epoch 更新一次避免模型过拟合到特定的难样本。另一个实用技巧是在线评估。每训练 N 个 batch抽一小批验证数据跑一次匹配准确率如果连续几次准确率没有提升就降低学习率或者提前停止。这个逻辑用回调函数实现即可不需要额外的框架。class HardNegativeMiner: def __init__(self, model, dataloader, top_k100): self.model model self.dataloader dataloader self.top_k top_k self.hard_pool [] def update(self): self.model.eval() all_pairs [] with torch.no_grad(): for anchor, pos, neg in self.dataloader: anchor_feat self.model(anchor.cuda()) neg_feat self.model(neg.cuda()) # 计算锚点和负样本的相似度 sim (anchor_feat * neg_feat).sum(dim1) for i in range(len(sim)): all_pairs.append((sim[i].item(), anchor[i], neg[i])) # 按相似度降序排列取 top_k 作为难负样本 all_pairs.sort(keylambda x: x[0], reverseTrue) self.hard_pool all_pairs[:self.top_k] self.model.train()这个 miner 每次更新时遍历一遍训练集计算所有锚点-负样本对的相似度取相似度最高的 top_k 作为难负样本。这些样本在下一个 epoch 中会被优先采样。top_k一般设 100 到 500太少了多样性不够太多了和随机采样差别不大。最后说一个我自己的习惯每次调整模型结构或者超参后不要只看最终的准确率数字一定要把匹配错误的样本可视化出来看。很多时候准确率只掉了 1%但错误样本的分布完全变了——可能之前错的是相似字现在错的是被遮挡的字。看懂错误模式比盲目调参有效得多。希望帮到你。本文还有配套的精品资源点击获取