文字点选验证码识别实战:从数据生成到PyTorch模型服务化

发布时间:2026/9/15 1:57:05
文字点选验证码识别实战:从数据生成到PyTorch模型服务化 简介基于PyTorch训练实现文字点选、选字、选择、点触验证码识别的完整项目工程面向毕业设计、课程设计、工程实训与学科竞赛等场景适合需要快速获得可复现代码、借鉴设计思路的学习者。资源共57个文件压缩包约66.34MB以Python源码为主28个py同时包含PyTorch模型权重bin、前端页面文件css/js、演示图片png/jpg/gif及说明文档覆盖数据准备、模型训练、推理服务与接口调用等完整链路。工程内含可执行的训练与演示脚本、API服务模块以及依赖清单代码经过测试运行功能正常可直接复现结果也可基于现有模块扩展二次开发或参考其设计报告撰写。目前已有81人学习下载适用于AI视觉识别方向的项目参考与课程实践。1. 文字点选验证码的难点不在模型在数据生成做 Web 自动化测试和 UI 自动化的人应该都撞过这种验证码底图上散落着几个中文字系统给出“点击山、云、火”你必须按顺序点完才能通过。第一次上手时很多人会先去堆模型但其实这类点选、点触验证码有个反直觉的结论——模型层面一个 ResNet 加检测头就能拿到挺高的准确率真正耗时间的反而是数据生成、坐标标注和推理时的坐标映射这三段。我拿一个 PyTorch 训练工程来拆工程里包含合成数据集生成器、两个训练好的权重文件best_v2.bin、pre_model_v3.bin、训练脚本和带 Swagger 文档的 API 服务适合毕业设计、课设、工程实训进去改一版也适合想给自动化框架加验证码识别能力的人参考。下面按“数据怎么造、模型怎么训、服务怎么出、指标怎么验”的顺序写每一步都能直接抄参数和代码。2. 数据集生成与坐标标注先解决训练吃什么文字点选验证码的训练数据不是去抓真实截图而是自己生成。原因很简单真实的验证码截图拿不到像素级坐标标注手工标一张图要好几秒而且不同站点字体、背景干扰差异非常大找几百张真实图也不足以覆盖特征。工程里src/drawing.py和src/captcha.py就是干这件事的一个管字符绘制一个管整图合成。这种拆分比较常见画字和排版分开后面换字体风格时不用动另一份代码。2.1 一张训练样本长什么样模型要学的内容是“给定一张图告诉我图里每个字符在哪、是什么”。所以生成器返回的不只是一张图而是一个完整三元组图像、图内全部字符的类别与中心坐标、以及题目。字段类型说明imagePIL.Image / np.ndarray合成好的底图通常 320x180charslist[dict]图上所有字符含 char字符和 (x, y) 中心坐标questionlist[str]需要点击的字符序列顺序不能乱有些工程把question直接编码进模型输入我更推荐先把全图字符都检测出来推理时再用 question 过滤。好处是模型不绑死题目长度真实场景里一个词少则 2 个字、多则 5 个字输出长度固定反而不方便扩展。2.1.1 坐标为什么记中心点而不是框点选验证码最终动作是点击文字中心所以存字符外接框有点浪费中心点坐标配合半径就能完成训练和评估。如果以后要扩展到更复杂的点触场景再把中心点扩展成 (x1, y1, x2, y2) 也不难损失函数换一下就行。工程里method目录大概率同时放了两套评估逻辑核心区别只在标注字段的解析。2.2 生成器的合成逻辑生成器要随机挑一组不重复文字放进底图再加干扰。放字的时候必须先做碰撞检测否则两个字叠在一起坐标标出来了模型也学不到有效特征。下面的代码是生成器核心逻辑的简化版真实工程里会额外加入字体随机、颜色随机和背景纹理。import random import numpy as np from PIL import Image, ImageDraw, ImageFont def generate_one(font_path, image_size(320, 180), font_size24): # 候选字符集模拟验证码里常见的那批汉字 candidates list(山水云火天地风雨龙虎马牛羊) chars random.sample(candidates, 6) # 图上放 6 个字 question random.sample(chars, 3) # 题目抽 3 个字 img Image.new(RGB, image_size, (255, 255, 255)) draw ImageDraw.Draw(img) font ImageFont.truetype(font_path, font_size) centers [] for ch in chars: while True: x random.randint(0, image_size[0] - font_size) y random.randint(0, image_size[1] - font_size) # 简单碰撞检测新字中心与已有字中心距离必须大于 1.5 倍字号 cx, cy x font_size // 2, y font_size // 2 if all((cx - pcx) ** 2 (cy - pcy) ** 2 (font_size * 1.5) ** 2 for _, pcx, pcy in centers): break draw.text((x, y), ch, fontfont, fill(0, 0, 0)) centers.append((ch, cx, cy)) # 按 question 顺序整理坐标保证训练时类别与坐标一一对应 target [(cx, cy) for ch, cx, cy in centers if ch in question] return np.array(img), question, target逻辑说明generate_one返回图像数组、题目字符列表、按题目顺序排列的中心坐标列表。碰撞检测用中心距离判断距离阈值font_size * 1.5可以根据实际字号调太小字会重叠太大会让字分布太散模型学不到密集场景。这里question的顺序很关键。虽然前面说模型不强依赖 question但生成数据集时必须保存题目顺序后面做“按题点击”评估和数据微调都靠它。如果只存无序坐标集合之后想模拟“先点山再点云”的站点行为就做不了。2.2.1 干扰线和噪点怎么加纯白底黑字的合成数据到了真实场景几乎必挂真实验证码会有干扰线、噪点、半透明色块。生成器一般在captcha.py里统一加干扰。常见做法是用 PIL 直接画线、用 numpy 随机撒点颜色贴近文字颜色防止模型靠颜色分离前景背景。for _ in range(4): x1, y1 random.randint(0, 320), random.randint(0, 180) x2, y2 random.randint(0, 320), random.randint(0, 180) draw.line((x1, y1, x2, y2), fill(random.randint(80, 160),) * 3, widthrandom.randint(1, 2))说明干扰线颜色取值 80~160 的灰度和黑色文字相近模型没法简单用颜色阈值过滤。线宽 1~2 像素太粗会把字盖住。噪点可以用ImageDraw.point随机画几十个效果类似。2.3 数据增强时坐标必须同步变换带坐标的数据做增强比纯分类麻烦翻转、旋转、缩放都会改变像素位置必须保证坐标跟着图一起变。我一般直接用 Albumentations它自带关键点增强如果用 torchvision 的 RandomAffine要自己算仿射矩阵容易漏。下面是一个最小配置import albumentations as A transform A.Compose([ A.RandomBrightnessContrast(p0.5), A.Rotate(limit8, border_mode0, p0.6), ], keypoint_paramsA.KeypointParams(formatxy, label_fields[labels])) # keypoints 和 labels 长度必须一致增强后两者顺序保持不变 aug transform(imageimg_np, keypointstarget, labelsquestion) img_aug aug[image] target_aug aug[keypoints]逻辑说明keypoint_params里formatxy指明坐标格式labels传 question 是为了让增强器记住每个点对应的字符。旋转角度上限设在 8°因为真实验证码里的文字歪斜有限超过 15° 后字符互相叠压热力图训练也不稳定。补一点亮度对比度增强在这种任务里作用不大真正的干扰来自背景纹理和字符扭曲。如果机器性能允许建议在captcha.py里加轻微透视变换而不是训练时硬靠增强补。3. 模型结构与 PyTorch 训练循环从热力图回归到权重保存数据就绪后就要设计模型。文字点选验证码的模型输入是一张 320x180 的图输出建议别做成“直接回归 question 的三个坐标”因为 question 是输入条件模型如果只输出 3 个点训练时必须固定 question 数量和顺序实际换一组字就崩。更稳的结构是 CenterNet 式的全字表检测ResNet 主干提取特征两个卷积头分别输出热力图和中心点偏移热力图通道数等于字符集大小。推理时根据 question 里的字符索引对应通道从峰值取坐标。3.1 为什么选 ResNet 主干而不是更深的网络点选验证码里的目标文字数量少、尺寸大通常 20x20 以上网络的瓶颈不在感受野而在特征稳定性。ResNet18 在这个任务里和 ResNet50 的差距可以忽略但推理速度差一倍。工程里的 pre_model_v3.bin 如果作为预训练权重加载时要注意它的输入通道可能是第一版留下的三通道拷贝如果结构不完全一致后面给的strictFalse加载方式能帮你跳过缺失层。模型头部的输出尺寸原图 320x180 经过 4 次下采样变成 20x12 的特征图热力图大小也就是 20x12。每个通道对应一个字符类别图上该字符中心位置形成高斯峰。这种设计天然支持不定数量 question因为推理时只需要查对应通道跟题目长度无关。3.2 损失函数热力图用加权 BCE先跑通再上 Focal Loss热力图是典型的正负样本不平衡场景一张热力图 20x12240 个像素里只有一两个正点其余都是背景。直接用 MSE 会让网络学成“全输出 0”所以常见做法是 Focal Loss 或者带正类加权的 BCE。下面代码是生成高斯热力图的过程也是损失函数的前置步骤。import numpy as np def make_heatmap(labels, num_classes100, height12, width20, sigma2): heatmap np.zeros((num_classes, height, width), dtypenp.float32) for cls_idx, cx, cy in labels: # label 的坐标是相对于原图的映射到热力图按宽高比例缩放 x int(round(cx * width / 320)) y int(round(cy * height / 180)) if x 0 or x width or y 0 or y height: continue # 高斯核sigma 控制峰的范围 xx, yy np.meshgrid(np.arange(width), np.arange(height)) dist2 (xx - x) ** 2 (yy - y) ** 2 heatmap[cls_idx] np.maximum( heatmap[cls_idx], np.exp(-dist2 / (2 * sigma ** 2))) return heatmap逻辑说明labels里存的是原图坐标这里按宽高比例映射到热力图。sigma2给出大约 4~5 个像素的激活半径如果生成数据里两个字挨得很近可以把 sigma 降到 1.5减少峰重叠。Focal Loss 的 PyTorch 实现只比 BCE 多几行但为了在下面直接演示训练循环先用带正类权重的 BCE 也能收敛只是背景处容易出低置信度假峰。调通流程后把损失换成 Focal Loss阈值就不用卡得太死。3.3 训练超参配置下表是这套结构下比较稳的参数显存 6G 以下也能跑如果 batch_size 降到 8学习率也要相应降到 5e-5。参数推荐值说明input_size320 x 180和生成器保持一致backboneresnet18预训练权重 pre_model_v3.bin输出通道数100字符集大小按实际候选字调整optimizerAdamWlr1e-4, weight_decay5e-4schedulerCosineAnnealingLRT_max40batch_size32显存小就往下调epochs40前 10 轮冻结 backbonelossWeighted BCE SmoothL1热力图 中心点偏移3.3.1 为什么前 10 轮要冻结 backbone验证码图像和 ImageNet 自然图像差距很大如果一上来就微调全部参数预训练权重的低层特征会被快速打乱训练集损失降得很快但换一个字体就崩。常见做法是前 10 个 epoch 让 backbone 参数requires_gradFalse只训练头部第 10 轮后解冻用 1e-5 的学习率微调。这样做对生成数据的拟合稍微慢一点但真实场景泛化明显更好。3.4 训练循环代码下面这段训练循环是简化版去掉了验证和 EMA只保留主干逻辑方便在这里解释每一步。import torch import torch.nn.functional as F from torch.utils.data import DataLoader def train_one_epoch(model, loader, optimizer, device): model.train() total_loss 0 for imgs, labels in loader: imgs imgs.to(device) # labels: list of list[(cls_idx, cx, cy), ...] heatmaps torch.stack( [torch.tensor(make_heatmap(l), dtypetorch.float32) for l in labels] ).to(device) pred model(imgs) # (B, 100, 12, 20) pos_weight torch.tensor([10.0], devicedevice) loss F.binary_cross_entropy_with_logits( pred, heatmaps, pos_weightpos_weight) # 如果模型带 offset 分支再加 SmoothL1Loss optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) return total_loss / len(loader.dataset)逻辑说明每个 batch 先调用make_heatmap把标注转成与模型输出同样形状的标签再计算加权 BCE。pos_weight10表示正样本权重是负样本的 10 倍用来拉高峰值响应。模型输出的pred要保证和热力图标签尺寸一致不一致时在前面接一个Conv2d头做通道投影。训练完后保存权重torch.save(model.state_dict(), best_v2.bin) # 加载时如果模型结构不完全一致用 strictFalse 忽略缺失键 model.load_state_dict(torch.load(best_v2.bin, map_locationcpu), strictFalse)注意torch.save默认只存权重字典。如果要同时存超参和字符集建议用torch.save({model: state, config: config}, best_v2.bin)。pre_model_v3.bin 是另一个结构的预训练权重加载时大概率会有 shape mismatch这时先打印两个字典的 key 和 shape再决定哪些层要跳过。4. 模型服务化把 best_v2.bin 变成可调用的识别接口训练完模型只是第一步工程最终要交付的是能被人调用的服务。项目里有app/main.py、api、swagger、service.py和service_demo.py说明部署层已经拆好了service.py负责加载模型和推理app负责 HTTP 接口service_demo.py是一个本地 demo 入口。下面先讲推理链路再讲接口封装。4.1 推理时的预处理和后处理模型训练时输入是 320x180真实截图往往是几倍大的高分辨率图也有小到 200x100 的。推理时先按比例缩放到 320x180再归一化。后处理的关键是从热力图中找峰值这一步做不好模型再准也会输出重复坐标。峰值提取用“3x3 max pool 等于原值”的技巧比直接遍历二维数组快得多而且兼容 torch 的 Tensor 操作。代码如下import torch import torch.nn.functional as F def extract_peaks(heatmap, scale_w16, scale_h15, threshold0.35): # heatmap: (1, num_classes, H, W)这里 H12, W20 hm heatmap.squeeze(0) # (num_classes, H, W) pooled F.max_pool2d(hm.unsqueeze(0), 3, stride1, padding1).squeeze(0) is_peak (hm pooled) (hm threshold) # 局部最大值且超过阈值 result {} for cls_idx in range(hm.size(0)): pos is_peak[cls_idx].nonzero() if pos.size(0) 0: continue y, x pos[0].tolist() # 原图坐标 热力图坐标 * 缩放倍数 result[cls_idx] (x * scale_w, y * scale_h, hm[cls_idx, y, x].item()) return result逻辑说明is_peak取所有“是 3x3 邻域最大值且大于阈值”的点每个通道只取第一个峰值。这里的scale_w16是因为 320/2016scale_h15是因为 180/1215如果你改动输入尺寸这两个值要同步调整。阈值 0.35 是比较通用的起点生成数据干扰线多时提到 0.5 左右能压掉假峰。4.2 接口层使用 FastAPI 封装工程里 Swagger 目录说明接口文档已经配好用 FastAPI 的话/docs就能直接调试。我一般把 service 层拆成两个类一个Predictor负责加载权重和 forward一个 HTTP Handler 负责解析请求。下面是最小可用的 Predictorclass Predictor: def __init__(self, weight_path, deviceNone): self.device device or (cuda if torch.cuda.is_available() else cpu) self.model build_model(num_classes100).to(self.device) self.model.load_state_dict( torch.load(weight_path, map_locationself.device)) self.model.eval() def infer(self, image: np.ndarray) - dict: # image: 任意尺寸的 RGB 图 x preprocess(image) # resize normalize with torch.no_grad(): heatmap, offset self.model(x.to(self.device)) peaks extract_peaks(heatmap) # offset 分支用于亚像素修正基础版可直接忽略 return {cls_idx: (cx, cy) for cls_idx, (cx, cy, score) in peaks.items()}说明模型只初始化一次进程内复用。接口收到图片后先转 RGB 再preprocess防止用户传 RGBA 图导致通道数不对。然后定义一个 FastAPI 接口from fastapi import FastAPI, UploadFile, File, Form import json app FastAPI() predictor Predictor(best_v2.bin) app.post(/predict) async def predict(file: UploadFile File(...), question: str Form(...)): image np.array(Image.open(file.file).convert(RGB)) peaks predictor.infer(image) # question 是 JSON 字符串例如 [山,云,火] q_chars json.loads(question) coords [] for ch in q_chars: if ch in char_to_idx and char_to_idx[ch] in peaks: coords.append(peaks[char_to_idx[ch]]) return {coords: coords, question: q_chars}代码里的char_to_idx是训练时定义的字符到通道索引映射必须和生成器一致否则模型输出的类别通道和 question 对不上。/docs页面可以直接上传文件、填入 question 字符串调试不用另写前端。4.3 用 curl 和 service_demo.py 验证启动服务后命令行用 curl 测一次curl -X POST http://127.0.0.1:8000/predict \ -F filesample.png \ -F question[山,云,火]返回的 coords 是原图坐标系下的点击位置单位是像素顺序和 question 一致。service_demo.py做的事情本质上就是这段 curl读一张本地图、调接口、把返回坐标画到图上保存。我第一次拿到工程时就是先跑这个 demo 确认模型权重没有损坏。4.4 服务部署的几个注意点模型服务化有几个容易出问题的地方排查时优先看这几个问题处理方式torch.load 报 CUDA 错误加载时加map_locationcpu或map_locationself.device返回坐标落在 20x12 区域检查extract_peaks里的scale_w / scale_h是否乘回每次请求都卡顿明显Predictor 做成全局单例不要在接口函数里重复加载CPU 推理偏慢网络结构简单可优先用 ONNX 导出再交给 onnxruntime并发场景下FastAPI 的异步接口里Predictor.infer是同步阻塞的如果不放心可以让接口函数去掉async交给线程池处理。这个项目规模下更重要的不是 QPS而是别把模型加载放到请求路径上。5. 调优技巧多尺度推理与真实样本微调最后说怎么把识别率从“训练集 99 分”拉到“能过业务”。这里不展开调参只讲三个我验证过有效的点。5.1 多尺度推理热力图对尺度的敏感度比分类模型高。同一张图上如果目标文字偏小3x3 max pool 提取峰值时峰被背景干扰压下去的概率会变大。常见做法是推理时对原图做 0.8、1.0、1.2 三个尺度缩放分别出热力图和峰值再把坐标映射回原图取中位数。代码片段如下def multi_scale_infer(predictor, image, scales(0.8, 1.0, 1.2)): all_peaks [] for s in scales: resized cv2.resize(image, (int(image.shape[1] * s), int(image.shape[0] * s))) peaks predictor.infer(resized) # 按缩放比例映射回原图 scaled {cls: (x / s, y / s) for cls, (x, y) in peaks.items()} all_peaks.append(scaled) merged {} for cls in set().union(*all_peaks): xs [p[cls][0] for p in all_peaks if cls in p] ys [p[cls][1] for p in all_peaks if cls in p] if len(xs) 2: merged[cls] (int(np.median(xs)), int(np.median(ys))) return merged逻辑说明每个尺度的峰值坐标除以 scale 后不同尺度下会小范围抖动。用中位数而不是平均可以避开某个尺度漏检或误检的离群值。要求至少两个尺度都有该字符才合并能有效去掉单尺度假峰。5.2 真实截图微调合成数据训练出的模型换到真实字体后最容易翻车的点不是字形而是截图里的半透明背景、字重异常和字符倾斜。我的做法是先用生成数据训 40 轮得到 best_v2.bin再去人工标 30 到 50 张真实截图用 1e-5 的学习率微调 5 到 10 轮。微调时不要重新随机背景直接用真实图做输入标签仍然画成高斯热力图流程和前面训练循环完全一样。合成数据负责主干真实数据负责适配字体。5.3 判定“点对”的指标点选验证码的评价不能只看字符命中还要看点击位置对不对。业务上通常以坐标与真值中心距离在 15 像素内算命中。给 service_demo.py 加一个这样的评估函数def hit_rate(preds, targets, radius15): hits 0 for p, t in zip(preds, targets): if abs(p[0] - t[0]) radius and abs(p[1] - t[1]) radius: hits 1 return hits / len(targets)这个 15 像素是我根据 320x180 输入定的如果实际站点验证码点击区域更大可以放宽到 20。你在 service_demo.py 里用同一批固定图反复跑hit_rate 稳定在 0.9 以上再考虑接业务。本文还有配套的精品资源点击获取