ARTICLE DETAIL

资讯详情

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

孪生网络实战:点选验证码识别从数据集到部署

孪生网络实战:点选验证码识别从数据集到部署 简介本资源是一套基于孪生神经网络实现点选识别验证码的完整项目源码面向计算机、人工智能、通信工程等专业的在校学生与教师也适合具备一定Python基础、希望进阶深度学习实战的开发者可用于毕业设计、课程设计、作业或项目初期立项演示。压缩包共12个文件约284KB包含7个Python脚本、2张png示意图、1个cfg配置文件、1个txt依赖清单及1个md说明文档覆盖模型定义、训练、预测与工具函数等模块结构清晰便于按需查阅。项目代码均经过实际运行测试功能完整答辩评审平均分达96分已有109人学习下载。读者可从中获得孪生网络与VGG16骨干结合的具体实现、点选识别任务的训练与推理流程、依赖配置与运行排错思路并可在现有代码基础上修改扩展实现其他识别功能。下载后建议先阅读README.md仅供学习参考切勿用于商业用途。1. 点选验证码为什么让传统 OCR 集体翻车做过爬虫或者自动化测试的人大概率都遇到过这种场景目标网站登录页弹出一张图上面散落着七八个汉字或图标旁边一行小字写着「请依次点击 春、风、得、意」。你拿 Tesseract 或者 PaddleOCR 直接怼上去识别出来的文字位置全是乱的顺序更是对不上。这不是 OCR 引擎不行而是任务本身变了——它要的不是「图里有什么字」而是「哪个字在哪个坐标按什么顺序点」。这就是点选识别Click Captcha Recognition要解决的问题。传统 OCR 做的是文本检测加文本识别输出是一串字符和对应的矩形框但点选验证码要求的是给定提示字符序列在图中找到每个字符的精确中心坐标并按提示顺序输出点击点位。字符之间可能旋转、缩放、重叠、加干扰线甚至用不同字体渲染同一个字。孪生神经网络Siamese Network在这里的价值就体现出来了它不依赖对每个字符做分类而是学习「两张图是不是同一个东西」的度量把模板字符和图中候选区域做匹配天然适合小样本、多字体、多干扰的场景。这套方案适合谁如果你在做自动化测试、数据采集、UI 自动化或者单纯想搞明白孪生网络怎么落地到一个具体视觉任务上这篇文章就是写给你的。我会从数据集的构造讲到模型训练、推理部署再到实际跑起来会踩的坑代码和参数都给到能直接抄的程度。python 环境下从零跑通整个流程不需要 GPU 集群一张消费级显卡甚至 CPU 都能验证。2. 孪生网络做点选识别从数据集构造到模型选型2.1 为什么不用分类模型而用孪生结构点选验证码的字符集是不固定的。今天这个网站用「春夏秋冬」明天那个用「甲乙丙丁」你不可能为每个网站训练一个分类器。而且同一个字在不同网站可能用完全不同的字体、颜色、背景分类模型需要大量标注数据才能覆盖。孪生网络换了个思路它不关心「这个字是什么」只关心「这两个输入是不是同一个字」。具体来说孪生网络由两个共享权重的分支组成每个分支是一个特征提取器通常是 CNN。训练时输入一对图片网络输出两个特征向量通过计算余弦相似度或欧氏距离来判断这对图片是否属于同一类。推理时你把提示字符的模板图和图中每个候选区域分别送入网络得到相似度分数取最高分对应的位置就是目标坐标。这种结构的优势在于字符集变化时不需要重新训练分类头只需要提供新的模板图即可。对于点选验证码这种「字符集动态变化、标注成本高」的场景孪生网络是比分类模型更务实的选择。2.2 数据集怎么造模板图与候选区域的配对策略原始数据集通常包含两类内容一是验证码原图二是每个字符的标注信息字符内容 中心坐标。但孪生网络需要的是「正样本对」和「负样本对」所以第一步是把原始标注转换成配对样本。假设一张验证码图里有 4 个目标字符标注格式是char, x, y。我的做法是以每个标注坐标为中心裁剪一个固定大小的区域比如 32x32作为该字符的「图中实例」。然后从系统字体库中渲染同一个字生成「模板图」。正样本对就是模板图图中实例负样本对就是模板图图中其他字符实例以及模板图随机背景区域。import cv2 import numpy as np import os from PIL import Image, ImageDraw, ImageFont def crop_char_region(image, cx, cy, size32): 以(cx, cy)为中心裁剪字符区域超出边界则填充 h, w image.shape[:2] half size // 2 x1, y1 cx - half, cy - half x2, y2 cx half, cy half # 计算实际可裁剪区域 crop_x1, crop_y1 max(0, x1), max(0, y1) crop_x2, crop_y2 min(w, x2), min(h, y2) region image[crop_y1:crop_y2, crop_x1:crop_x2] # 如果裁剪区域不足size用黑色填充 if region.shape[0] ! size or region.shape[1] ! size: canvas np.zeros((size, size, 3), dtypenp.uint8) canvas[:region.shape[0], :region.shape[1]] region region canvas return region def render_template(char, font_path, size32): 用指定字体渲染单个字符作为模板图 img Image.new(RGB, (size, size), (0, 0, 0)) draw ImageDraw.Draw(img) font ImageFont.truetype(font_path, sizeint(size * 0.8)) # 居中绘制 bbox draw.textbbox((0, 0), char, fontfont) tw, th bbox[2] - bbox[0], bbox[3] - bbox[1] draw.text(((size - tw) / 2 - bbox[0], (size - th) / 2 - bbox[1]), char, fill(255, 255, 255), fontfont) return np.array(img)这段代码做了两件事crop_char_region从验证码原图中按标注坐标裁剪字符区域render_template用系统字体渲染模板图。参数size控制输入网络的图片尺寸一般设 32 或 64太小会丢失笔画细节太大增加计算量。font_path建议准备多套字体因为验证码可能用不同字体渲染模板图字体越丰富模型泛化越好。配对样本的生成逻辑是对每个标注字符生成一个正样本对模板图 vs 图中实例再随机采样 3-5 个负样本对模板图 vs 其他字符实例或背景区域。正负样本比例控制在 1:3 到 1:5 之间太少了模型学不到区分边界太多了训练不稳定。2.3 特征提取网络的设计与训练参数特征提取器不需要太深。点选验证码的字符区域通常只有 32x32 到 64x64用 ResNet-18 或者一个 4 层卷积网络就够了。我一般用后者参数量小、推理快在 CPU 上也能跑到可接受的速度。import torch import torch.nn as nn import torch.nn.functional as F class SiameseFeatureExtractor(nn.Module): def __init__(self, embedding_dim128): super().__init__() self.conv nn.Sequential( nn.Conv2d(3, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), # 32x32 - 16x16 nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), # 16x16 - 8x8 nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), # 8x8 - 4x4 nn.Conv2d(128, 256, 3, padding1), nn.BatchNorm2d(256), nn.ReLU(), nn.AdaptiveAvgPool2d(1) # 全局平均池化 ) self.fc nn.Linear(256, embedding_dim) def forward(self, x): x self.conv(x) x x.view(x.size(0), -1) x self.fc(x) return F.normalize(x, p2, dim1) # L2归一化方便算余弦相似度 class SiameseNetwork(nn.Module): def __init__(self, embedding_dim128): super().__init__() self.backbone SiameseFeatureExtractor(embedding_dim) def forward(self, x1, x2): z1 self.backbone(x1) z2 self.backbone(x2) # 余弦相似度映射到[0,1] similarity (F.cosine_similarity(z1, z2) 1) / 2 return similarity网络结构的关键点最后用F.normalize做 L2 归一化这样余弦相似度就等价于内积计算更高效。embedding_dim设 128 是经验值太小区分度不够太大容易过拟合。损失函数用对比损失Contrastive Loss或者二元交叉熵都行我倾向后者因为输出已经映射到 [0,1]直接当概率用。训练参数方面batch size 设 64学习率 1e-3 配合余弦退火训练 30-50 个 epoch。正样本对的标签是 1负样本对是 0。如果正负样本不均衡可以在损失函数里加权重。验证集用准确率就够了——判断相似度大于 0.5 为正小于 0.5 为负看分类正确率。3. 推理流程拆解从验证码原图到点击坐标序列3.1 候选区域提取滑动窗口还是目标检测推理阶段的核心问题是怎么从验证码原图中找出所有可能的字符位置两种思路。一是用目标检测模型比如 YOLO先检测出所有字符框再对每个框做孪生匹配。二是用滑动窗口或者连通域分析直接生成候选区域。我一般用连通域分析因为点选验证码的字符通常是独立渲染的颜色和背景有差异二值化后连通域比较干净。但如果字符有重叠或者干扰线严重连通域会碎掉这时候滑动窗口更稳。滑动窗口的步长设 4-8 像素窗口大小和训练时的size一致。def generate_candidates(image, window_size32, stride8): 滑动窗口生成候选区域 h, w image.shape[:2] candidates [] positions [] for y in range(0, h - window_size 1, stride): for x in range(0, w - window_size 1, stride): region image[y:ywindow_size, x:xwindow_size] candidates.append(region) positions.append((x window_size // 2, y window_size // 2)) return np.array(candidates), positions def nms_candidates(scores, positions, iou_threshold0.3): 非极大值抑制去掉重叠的候选框 order np.argsort(scores)[::-1] keep [] while len(order) 0: i order[0] keep.append(i) if len(order) 1: break # 计算当前框与剩余框的IoU xx1 np.maximum(positions[i][0] - 16, [positions[j][0] - 16 for j in order[1:]]) yy1 np.maximum(positions[i][1] - 16, [positions[j][1] - 16 for j in order[1:]]) xx2 np.minimum(positions[i][0] 16, [positions[j][0] 16 for j in order[1:]]) yy2 np.minimum(positions[i][1] 16, [positions[j][1] 16 for j in order[1:]]) w np.maximum(0, xx2 - xx1) h np.maximum(0, yy2 - yy1) iou (w * h) / (32 * 32 * 2 - w * h) order order[1:][iou iou_threshold] return keepgenerate_candidates生成所有可能的窗口nms_candidates做非极大值抑制去掉重叠框。参数stride越小候选越多精度越高但速度越慢一般设窗口大小的 1/4 到 1/2。iou_threshold设 0.3 左右太大会保留太多重叠框太小会漏掉相邻字符。3.2 模板匹配与坐标排序怎么保证点击顺序正确拿到候选区域后对每个提示字符把它渲染成模板图然后和所有候选区域计算相似度。取相似度最高的候选位置作为该字符的点击坐标。但这里有个坑如果两个字符长得像比如「未」和「末」或者图中存在多个相似区域可能会选错。我的做法是加一个阈值过滤相似度低于 0.6 的候选直接丢弃如果某个提示字符找不到超过阈值的候选就报错或者重新采样。另外对于多个提示字符要确保它们对应的候选位置不重复——如果两个字符选了同一个位置说明匹配出了问题需要调整阈值或者重新推理。def match_and_sort(model, image, prompt_chars, font_path, devicecpu): 对提示字符序列依次匹配并返回点击坐标 model.eval() candidates, positions generate_candidates(image) # 批量推理提高速度 candidate_tensors torch.tensor(candidates).permute(0, 3, 1, 2).float() / 255.0 candidate_tensors candidate_tensors.to(device) results [] used_positions set() for char in prompt_chars: template render_template(char, font_path) template_tensor torch.tensor(template).permute(2, 0, 1).float().unsqueeze(0) / 255.0 template_tensor template_tensor.to(device) # 扩展模板以匹配候选数量 template_expanded template_tensor.expand(len(candidate_tensors), -1, -1, -1) with torch.no_grad(): similarities model(template_expanded, candidate_tensors).cpu().numpy() # 按相似度排序取最高且未使用的位置 sorted_idx np.argsort(similarities)[::-1] for idx in sorted_idx: if similarities[idx] 0.6: break pos positions[idx] if pos not in used_positions: results.append((char, pos[0], pos[1], similarities[idx])) used_positions.add(pos) break return results这段代码的逻辑是对每个提示字符渲染模板图和所有候选区域批量计算相似度按分数从高到低找第一个未被使用的位置。0.6这个阈值是经验值实际用的时候要根据验证码的干扰程度调整——干扰强的可以降到 0.5干净的可以提到 0.7。used_positions防止同一个位置被多个字符选中。3.3 后处理坐标偏移修正与置信度过滤滑动窗口生成的坐标是窗口中心但字符的实际中心可能偏离窗口中心几个像素。如果对精度要求高可以在匹配到的位置附近做局部搜索以匹配点为中心在 ±8 像素范围内滑动找相似度最高的精确位置。def refine_position(model, image, template_tensor, init_pos, search_radius8, devicecpu): 在初始位置附近精细搜索修正坐标偏移 best_pos init_pos best_score 0 cx, cy init_pos for dy in range(-search_radius, search_radius 1, 2): for dx in range(-search_radius, search_radius 1, 2): x, y cx dx, cy dy region crop_char_region(image, x, y) region_tensor torch.tensor(region).permute(2, 0, 1).float().unsqueeze(0) / 255.0 region_tensor region_tensor.to(device) with torch.no_grad(): score model(template_tensor, region_tensor).item() if score best_score: best_score score best_pos (x, y) return best_pos, best_scoresearch_radius设 8 像素通常够用步长设 2 是为了平衡精度和速度。如果验证码字符特别大可以适当加大搜索范围。这个后处理步骤能把坐标误差从 ±4 像素降到 ±1 像素以内对于点击操作来说已经足够了。4. 训练与推理中那些让你白跑一整天的坑4.1 正负样本比例失衡导致模型输出恒定值现象训练 loss 很快降到接近 0但验证集准确率始终在 50% 左右模型对所有输入都输出 0.5 附近的相似度。原因负样本对数量远多于正样本对模型学到「全部预测为负」就能拿到很低的 loss。这是典型的类别不平衡问题。解决控制正负样本比例在 1:2 到 1:4 之间。可以在 DataLoader 里用WeightedRandomSampler给正样本更高权重或者在损失函数里给正样本更大的权重系数。我一般用后者简单直接loss - (pos_weight * y * log(p) (1-y) * log(1-p))pos_weight设 2-3。4.2 模板图字体与验证码字体不匹配导致相似度普遍偏低现象推理时所有候选区域的相似度都低于 0.5找不到有效匹配。原因训练时用的模板图字体和验证码实际渲染字体差异太大网络学到的特征空间不兼容。解决训练时准备多套字体至少 5-10 种常见中文字体每个字符随机选字体渲染模板图。推理时如果知道目标网站用的字体优先用对应字体不知道的话用训练时表现最好的那套字体。另外可以在模板图上加随机噪声、模糊、旋转增强泛化能力。4.3 候选区域包含大量背景导致误匹配现象某些背景区域比如干扰线交叉处的相似度意外地高导致点击位置偏移到空白处。原因背景区域的纹理和字符笔画有相似的高频特征网络没有学到足够的区分度。解决在负样本里加入纯背景区域让网络学会把背景判为负。另外可以在推理时加一个「字符存在性」判断如果最高相似度低于某个阈值比如 0.55认为该字符不在图中直接跳过。这个阈值需要根据实际验证码的干扰程度调。4.4 滑动窗口步长过大导致漏掉小字符现象验证码里有些字符比较小滑动窗口步长设 8 时窗口中心没有对准字符中心相似度上不去。原因步长太大窗口覆盖不到字符的最佳位置。解决步长设窗口大小的 1/4比如窗口 32 像素步长设 8。如果字符特别小可以把窗口缩小到 24 或 16同时增加候选数量。速度换精度看你的场景能不能接受。4.5 推理时 batch 过大导致显存溢出现象候选区域数量多的时候比如滑动窗口生成几千个一次性送入网络导致 OOM。原因候选区域没有分批处理全部堆在显存里。解决推理时把候选区域分成小 batch比如每批 256 个循环处理。代码里加一个batch_size参数控制。CPU 推理的话这个问题不明显但 GPU 上很容易踩。5. 把点选识别集成到自动化流程里的几个进阶技巧5.1 用 ONNX 导出模型推理速度提升 2-3 倍训练好的 PyTorch 模型直接推理有框架开销导出成 ONNX 后用 ONNX Runtime 跑速度提升明显而且部署到没有 PyTorch 的环境更方便。import torch.onnx def export_to_onnx(model, save_pathsiamese.onnx, input_size32): model.eval() dummy_input1 torch.randn(1, 3, input_size, input_size) dummy_input2 torch.randn(1, 3, input_size, input_size) torch.onnx.export( model, (dummy_input1, dummy_input2), save_path, input_names[template, candidate], output_names[similarity], dynamic_axes{ template: {0: batch}, candidate: {0: batch}, similarity: {0: batch} }, opset_version11 )导出时dynamic_axes把 batch 维度设为动态这样推理时可以灵活调整 batch size。opset_version用 11 兼容性最好。导出后用onnxruntime加载import onnxruntime as ort session ort.InferenceSession(siamese.onnx) template np.random.randn(1, 3, 32, 32).astype(np.float32) candidate np.random.randn(1, 3, 32, 32).astype(np.float32) result session.run([similarity], {template: template, candidate: candidate})实际测下来ONNX Runtime 在 CPU 上的推理速度比 PyTorch 快 2-3 倍GPU 上差距小一些但显存占用更低。5.2 用模板缓存减少重复渲染开销每次推理都重新渲染模板图是浪费。如果提示字符集合固定比如只有几十个常用字可以在初始化时把所有模板图渲染好存成 numpy 数组或者 tensor推理时直接查表。class TemplateCache: def __init__(self, charset, font_paths, size32): self.cache {} for char in charset: templates [] for font_path in font_paths: template render_template(char, font_path, size) templates.append(template) # 取多字体的平均作为最终模板 self.cache[char] np.mean(templates, axis0).astype(np.uint8) def get(self, char): return self.cache.get(char)多字体平均是个小技巧如果验证码字体不确定用多套字体渲染同一个字然后取平均得到的模板图对字体变化更鲁棒。代价是模板图会稍微模糊但实测影响不大。5.3 置信度阈值动态调整策略固定阈值在不同验证码上表现差异很大。我的做法是先跑一批样本统计正确匹配和错误匹配的相似度分布找一个能分开两者的阈值。如果分布重叠严重说明模型区分度不够需要回去补训练数据。def adaptive_threshold(similarities, top_k3): 根据相似度分布动态调整阈值 sorted_sims np.sort(similarities)[::-1] if len(sorted_sims) top_k: return 0.5 # 如果第一名和第二名差距大说明匹配明确可以降低阈值 gap sorted_sims[0] - sorted_sims[1] if gap 0.2: return 0.5 # 差距小说明有歧义提高阈值避免误匹配 return 0.7这个策略的核心思想是如果最高分和第二名差距明显说明匹配结果可信阈值可以放宽如果差距小说明存在歧义宁可漏掉也不要错点。实际用的时候可以结合业务容忍度调整——自动化测试场景可以严格一些数据采集场景可以宽松一些。5.4 一个我踩过的坑验证码刷新导致坐标失效最后说一个血泪教训。点选验证码通常有有效期如果你在匹配过程中验证码刷新了之前算出来的坐标全部作废。我一开始没注意这个问题脚本跑着跑着就点空了排查了半天才发现是验证码过期。解决办法很简单在获取验证码图片的同时记录一个时间戳或者 session id点击之前先检查验证码是否还有效。如果无效重新获取图片并重新匹配。另外匹配和点击之间的间隔要尽量短别在中间做耗时操作。这套方案我从数据集构造到推理部署完整跑过一遍python 环境下用一张 1060 就能训练推理在 CPU 上也能跑到可接受的速度。孪生网络做点选识别的核心优势是字符集无关换一套验证码只需要换模板图不用重新训练。如果你正在做自动化相关的事情这个方向值得投入时间搞明白。希望帮到你。本文还有配套的精品资源点击获取
返回列表