拓冰建站拓冰建站
首页 / 资讯中心 / 正文

PyTorch点选验证码识别实战:目标检测与文字分类全流程

简介基于PyTorch实现的文字点选、选字、选择、点触验证码识别项目面向人工智能、计算机、自动化、电子信息等专业学生与开发者适用于毕业设计、课程设计、项目初研以及验证码识别方向的小白进阶。整个压缩包共60个文件大小约69.49MB文件类型以Python脚本为主30个py同时包含10张png与4张jpg图片样本、2个gif演示动画、2个bin权重、pyd动态库、接口文档、map配置及requirements依赖清单便于从数据、模型、接口到展示全链路对照学习。目前已有49人学习/下载工程代码经测试通过曾获导师指导认可并取得95分答辩成绩。除源码外还配有详细文档目录包括src核心模块、utils工具、app接口层、model预训练模型、swagger docs接口说明、service_demo与bilbil等演示脚本可直观看到点选文字定位、识别和前端交互的完整实现配套README和依赖清单可快速搭建运行环境。拿到手可快速复现验证码识别流程也可替换数据集、调整网络结构或扩展新验证码类型支撑课程设计、毕业论文、项目演示与二次开发。1. 点选验证码识别要同时解决的两个问题点选验证码也叫文字点选、选字或点触验证码和传统字符验证码最大的区别在于它不是让你把一串字符输进输入框而是给出一张包含汉字或字母的背景图再给一个目标文字可能是一个字也可能是按顺序点两个字要求你把图上对应位置点出来。换句话说模型要输出的不是字符串而是“图上哪个坐标对应哪个字”。这是一个典型的两阶段任务先在背景图上把每个文字的位置框出来再把框里的文字识别出来。如果目标文字有两个甚至三个还需要按题面给出的顺序对坐标排序。下面按这个任务的 PyTorch 实战路径来讲数据怎么做、模型怎么搭、损失函数怎么配、推理时坐标怎么输出。适合已经会用 PyTorch 做图像分类、但没接触过目标检测的读者也适合想把手写识别、OCR 能力迁移到点击式验证码场景的工程师。2. 数据准备合成样本与标注格式设计2.1 点选验证码数据要包含哪些标注信息点选验证码的神经网络输出通常包含两样东西文字的位置框和文字的内容类别。因此训练数据的标注格式和普通目标检测数据集一致每一项样本至少包含图像路径、若干个外接矩形、每个矩形对应的字符标签。和通用检测不同的是验证码场景里“类别”不是物体类别而是汉字或字母本身字符集大小直接影响网络分类头的参数规模500 个常用汉字和 26 个字母分类头的输出维度差近 20 倍。有一个细节容易被忽略boxes的归一化方式。YOLO 系列习惯用(cx, cy, w, h)并除以图像宽高而不少检测框架用(x1, y1, x2, y2)绝对像素。如果直接在 PyTorch 里自建数据加载推荐统一成归一化坐标因为点选验证码的背景图往往来自不同设备的截图宽高不固定归一化可以让同一套代码适配不同分辨率也会省去后续处理时反复换算的麻烦。字段含义示例image背景图RGB 三通道captcha.pngboxes每个文字的外接矩形[[0.31, 0.42, 0.08, 0.12]]labels每个框对应的字符索引[7]2.2 用脚本合成训练样本真实点选验证码需要注册账号、过滑块、才能拿到正样本成本高且样本量有限。常见做法是先用合成数据把模型训起来再用少量真实样本做微调。合成的思路是选一批字体和背景纹理把文字随机摆到图上自动记录坐标和内容。以下脚本生成单张合成图并返回真值框和标签。import random from PIL import Image, ImageDraw, ImageFont def synth_sample(bg_size(320, 200), chars甲乙丙丁戊己庚辛壬癸): bg Image.new(RGB, bg_size, (245, 245, 245)) draw ImageDraw.Draw(bg) font ImageFont.truetype(simsun.ttc, 28) boxes, labels [], [] for i in range(random.randint(3, 6)): x random.randint(10, bg_size[0] - 50) y random.randint(10, bg_size[1] - 50) ch random.choice(chars) draw.text((x, y), ch, fontfont, fill(random.randint(0, 100), random.randint(0, 100), random.randint(0, 100))) boxes.append([x, y, x 32, y 32]) labels.append(ord(ch)) return boxes, labelsdraw.text的起始坐标是文字左上角所以代码里用x32、y32近似表示包围盒32 是字号加粗后的一个保守估计。更精确的做法是用draw.textbbox拿到实际占用的像素范围否则带描边的字体会让右边框多出一截。字号、字体、文字数量这三项必须随机化模型的检测头会从不同尺寸的候选里学到“文字区域”的共性而不是记住某个固定字号下的纹理。合成数据不能只有白底黑字真实验证码的背景通常有噪点、渐变或抽象图案。至少要加入两类背景一类是纯色渐变一类是叠加细小噪声的纹理图。每张图上的文字数量控制在 3~6 个和目标场景保持一致文字之间不能重叠否则训练时 NMS 会倾向把重叠区域吞掉。2.3 用 albumentations 做针对性增强点选验证码的难处在“文字嵌在背景里”所以增强的目标是让模型对背景不敏感。我一般会叠加这几类增强随机亮度对比度、高斯噪声、模糊、透视变换以及把文字颜色往背景色方向拉近。这些增强可以直接用 albumentations 实现它和 PyTorch 的数据加载配合比较顺同时会自动同步更新 boxes 和 labels。import albumentations as A from albumentations.pytorch import ToTensorV2 transform A.Compose([ A.RandomBrightnessContrast(p0.5), A.GaussianBlur(blur_limit(3, 5), p0.3), A.ShiftScaleRotate(shift_limit0.05, scale_limit0.1, rotate_limit15, p0.5), A.Normalize(mean[0.5, 0.5, 0.5], std[0.5, 0.5, 0.5]), ToTensorV2(), ], bbox_paramsA.BboxParams(formatpascal_voc, label_fields[labels]))这里的bbox_params声明包围盒格式是(xmin, ymin, xmax, ymax)transform 执行 ShiftScaleRotate 时boxes 里的坐标会跟着图像同时旋转不需要手动改坐标。需要注意的是旋转角度超过 15 度后汉字笔画会被切掉一部分分类分支的准确率反而下降所以rotate_limit不要给太大。Normalize的 mean/std 用的是 ImageNet 的默认值因为 backbone 如果是预训练的 ResNet数值分布对齐迁移效果才稳定。3. 用 PyTorch 搭轻量检测识别网络3.1 为什么选一阶段检测而不是两阶段点选验证码识别的输入图像通常只有 300×200 左右文字数量少一般 36 个目标小但密度低。两阶段检测器如 Faster R-CNN精度高但对这种小图上的小目标反而收益有限且推理速度慢。一阶段检测器在速度和精度之间更均衡尤其当锚框比例覆盖文字这种近似方形目标时训练调参也简单。这个实现不直接套 YOLOv5 或 SSD而是用 PyTorch 自己搭一个简化版检测头原因是验证码场景比较专一目标只有文字占比小不需要通用检测里复杂的特征金字塔和多尺度训练。自己写 200 行代码就能完成前向和损失计算遇到定位不准时也更容易定位问题。骨干网络参数量320×200 输入 CPU 单帧耗时参考ResNet-18约 11M40~60msMobileNetV3-Small约 2.5M20~35msShuffleNetV2 1.0x约 2.3M15~25ms选择 ResNet-18 最主要的原因是回传梯度稳定虽然参数最多但在合成数据这种噪声较大的场景下不容易发散。如果后续要把模型放到线上推理再换成 MobileNetV3 也不迟两个网络的输出头完全一样只换 backbone 部分即可。3.2 自定义 Dataset 与数据加载import torch from torch.utils.data import Dataset import cv2 class ClickCaptchaDataset(Dataset): def __init__(self, samples, transformNone): self.samples samples # list of (img_path, boxes, labels) self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, boxes, labels self.samples[idx] img cv2.imread(path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w img.shape[:2] # 归一化坐标yolo 格式 (cx, cy, w, h)除以图像宽高 norm_boxes [] for x1, y1, x2, y2 in boxes: cx, cy (x1 x2) / 2 / w, (y1 y2) / 2 / h bw, bh (x2 - x1) / w, (y2 - y1) / h norm_boxes.append([cx, cy, bw, bh]) norm_boxes torch.tensor(norm_boxes, dtypetorch.float32) labels torch.tensor(labels, dtypetorch.long) if self.transform: img, norm_boxes, labels self.transform( img, norm_boxes, labels) return img, norm_boxes, labels在这个Dataset里坐标被转成归一化的中心点和宽高这是为了后面计算损失时不受输入尺寸影响。cv2.imread默认读成 BGR所以先转成 RGBtransform 里的ToTensorV2会把 numpy 数组转成torch.Tensor并把通道维放到最前。这里有个坑albumentations的BboxParams默认要求 boxes 是二维[N, 4]的数组如果某张图里没有文字需要传[[0, 0, 1, 1]]这种可见框否则会被 transform 过滤掉样本数就少了。3.3 骨干网络和检测头设计import torch.nn as nn from torchvision.models import resnet18 class ClickDetector(nn.Module): def __init__(self, num_chars10, num_anchors4): super().__init__() backbone resnet18(weightsNone) self.features nn.Sequential(*list(backbone.children())[:-2]) self.loc_head nn.Conv2d(512, num_anchors * 4, kernel_size3, padding1) self.cls_head nn.Conv2d(512, num_anchors * (num_chars 1), kernel_size3, padding1) def forward(self, x): feat self.features(x) # (B, 512, H/16, W/16) loc self.loc_head(feat) # (B, A*4, H/16, W/16) cls self.cls_head(feat) # (B, A*(C1), H/16, W/16) B, _, H, W loc.shape # 重排为 (B, H*W*A, 4) 和 (B, H*W*A, C1) loc loc.permute(0, 2, 3, 1).reshape(B, -1, 4) cls cls.permute(0, 2, 3, 1).reshape(B, -1, num_chars 1) return loc, clsnum_chars是字符类别数分类头输出num_chars1多出来的第 0 类表示背景。loc_head输出 4 个值分别是预测框相对于锚框的中心偏移和宽高缩放。两个头部都是 3×3 卷积共享特征图参数实际就是全卷积网络。特征图每个位置预测num_anchors个候选框320×200 输入最终得到约 960 个候选比通用检测器的数万个候选少一个数量级这也是能在 CPU 上跑实时推理的基础。3.4 锚框生成与匹配回归损失需要知道每个预测框对应哪个真值框。以锚框为中心匹配规则是当某个锚框与某个真值框的 IoU 大于 0.5 时该锚框负责预测这个真值框。生成锚框的代码def generate_anchors(feat_h, feat_w, stride16, scales[0.8, 1.2, 1.8]): anchors [] for i in range(feat_h): for j in range(feat_w): cx, cy (j 0.5) * stride, (i 0.5) * stride for s in scales: size stride * s anchors.append([cx - size/2, cy - size/2, cx size/2, cy size/2]) return torch.tensor(anchors, dtypetorch.float32)stride是特征图相对输入图的下采样倍数这里固定为 16scales决定锚框边长。文字在点选验证码里通常是 2436 像素stride 为 16 时scale 取 1.2 对应的锚框是 19×191.8 对应 29×29能覆盖文字的主要尺寸。实际项目中可以把 scales 改成按文字高度统计出来的比例匹配度更高。锚框匹配这段逻辑建议放在训练循环里做而不是放在 Dataset 里因为不同图的真值框数量不同离线做反而慢还要额外存一版中间结果。4. 训练流程损失函数、学习率与调参要点4.1 多任务损失如何组合检测网络的损失由框回归的 Smooth L1 损失和分类交叉熵损失组成前者定位后者同时区分背景和具体字符。两者量纲不同回归损失通常在 0.1~1 之间分类损失是交叉熵的 0~5直接相加会让分类主导训练常见的做法是给分类损失加权重并先对回归目标做归一化。def detect_loss(loc_pred, cls_pred, anchors, gt_boxes, gt_labels, cls_weight2.0): ious box_iou(anchors, gt_boxes) # (N_anchor, N_gt) max_iou, gt_idx ious.max(dim1) pos_mask max_iou 0.5 # 正样本 neg_mask max_iou 0.3 # 负样本 delta encode_delta(anchors[pos_mask], gt_boxes[gt_idx[pos_mask]]) loc_loss smooth_l1(loc_pred[pos_mask], delta, beta1.0) cls_target torch.full((len(anchors),), -1, dtypetorch.long) cls_target[pos_mask] gt_labels[gt_idx[pos_mask]] 1 cls_target[neg_mask] 0 cls_loss cross_entropy(cls_pred, cls_target, ignore_index-1) return loc_loss cls_loss * cls_weight这里的 IoU 计算要向量化在 PyTorch 里可以用torch.cdist或者把锚框和真值框都转成 (x1, y1, x2, y2) 之后广播计算交集面积避免两层 for 循环。pos_mask选中的锚框同时参与回归和分类neg_mask只参与背景分类IoU 介于 0.3~0.5 的模糊锚框用ignore_index-1直接排除。合成数据里背景锚框占了九成如果不控制正负样本比例模型学到的基本全是“预测为背景”导致正样本召回率上不去。4.2 优化器、学习率与批次大小点选验证码数据集通常不大几千张合成样本就能达到可用的准确率。优化器首选用 AdamW初始学习率 1e-3权重衰减 5e-4。批次大小受显存限制这里的模型很小batch size 64 在 8GB 显卡上完全跑得动。参数推荐值说明optimizerAdamW比 Adam 收敛更稳对权重衰减更可预测base_lr1e-3输入尺寸小数据量充足时不需要预热weight_decay5e-4抑制过拟合合成数据上尤其有效batch_size64过小会导致锚框匹配不稳定max_epochs3050用验证集匹配率判断早停合成数据里的前景和背景像素比例悬殊超过 90% 的锚框是背景。除了损失函数中的cls_weight另一个有效手段是控制批次内正负样本比例。常见做法是每个 batch 里正负锚框按 1:3 采样而不是把所有锚框都扔进损失计算这样可以避免负样本梯度把正样本的信号盖掉。4.3 训练脚本骨架model ClickDetector(num_chars10).cuda() optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max40) for epoch in range(40): model.train() total_loss 0.0 for img, boxes, labels in train_loader: img img.cuda() loc_pred, cls_pred model(img) anchors generate_anchors(20, 12).cuda() loss detect_loss(loc_pred, cls_pred, anchors, boxes, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() scheduler.step() if epoch % 5 0: torch.save(model.state_dict(), click_detector_%d.pth % epoch)CosineAnnealingLR配合 AdamW 是这套任务里比较稳妥的组合前 5 个 epoch 学习率较大模型能快速把文字区域从背景里分离出来后期余弦退火让回归头精细修正。保存模型时不要只存最后一代点选验证码模型在合成数据上往往第 25 代左右匹配率最高之后再训练只是过拟合背景纹理。用验证集上的匹配率来挑权重比看训练损失更可靠。4.4 过拟合与样本多样性合成数据的文字类别如果只有十几个模型很容易把字形和背景纹理绑定。缓解办法是每轮训练更换字体和背景样式让同一个字在不同背景中出现。还可以在训练最后 5 个 epoch 把合成数据替换成真实验证码的截图让模型从“看字体”转成“看图上的字”。这个微调阶段学习率降到 1e-4只跑几个 epoch否则真实数据量太小模型会把真实样本的噪声也记进去。5. 推理管线从模型输出到点击坐标5.1 解码预测框与文字类别推理时模型输出的还是锚框偏移量和类别分数要还原成图像上的坐标。先取每个位置分类分数最高的类别用 softmax 阈值过滤掉背景然后对预测框做解码把中心点偏移量乘上锚框宽高再加上锚框中心坐标。def decode_predictions(loc_pred, cls_pred, anchors, img_w, img_h, conf_thresh0.6): probs torch.softmax(cls_pred, dim-1) # (N, C1) scores, labels probs[:, 1:].max(dim-1) labels labels 1 # 对应真实字符索引 keep scores conf_thresh anchors anchors[keep] loc loc_pred[keep] labels labels[keep] scores scores[keep] cx anchors[:, :2] loc[:, :2] * anchors[:, 2:] wh anchors[:, 2:] * torch.exp(loc[:, 2:].clamp(max4)) x1 (cx[:, 0] - wh[:, 0] / 2) * img_w y1 (cx[:, 1] - wh[:, 1] / 2) * img_h x2 (cx[:, 0] wh[:, 0] / 2) * img_w y2 (cx[:, 1] wh[:, 1] / 2) * img_h return torch.stack([x1, y1, x2, y2, labels.float(), scores], dim1)解码时两个细节值得注意宽度和高度用exp而不是直接回归绝对值保证输出恒为正坐标乘回img_w/img_h是因为模型内部始终在归一化空间做回归。conf_thresh设 0.6 在验证码场景偏保守如果文字与背景对比弱可以降到 0.4代价是误检增多后处理里用 NMS 消掉重叠框。5.2 NMS 与结果排序点选验证码对每个目标文字只需要一个点击点所以 NMS 按类别分组的 IoU 阈值建议宽松一点0.5 比较合适。NMS 结束后按题面文字顺序输出坐标先看预测框里有哪些字符再按题面列表逐个取对应字符且置信度最高的框。def pick_targets(dets, target_chars, img_w, img_h): picked [] for ch in target_chars: cand [d for d in dets if int(d[4]) ch] if not cand: continue best max(cand, keylambda d: d[5]) cx (best[0] best[2]) / 2 / img_w cy (best[1] best[3]) / 2 / img_h picked.append((cx, cy, float(best[5]))) return picked这里返回的是归一化坐标因为调用方网页自动化脚本或 appium 这类工具往往要按屏幕尺寸换算。target_chars是接口返回的题面例如[我, 爱]如果某个字符模型没检出来这个函数会跳过它上层的重试逻辑会重新截一张图。顺序体现在target_chars的排列跟模型输出无关。5.3 CPU 部署与推理优化点选验证码场景通常不允许用 GPU模型要能在普通 CPU 上 100 毫秒内出结果。上面的轻量检测网络已经满足要求额外可以做三步1把输入 resize 到 320×200 而不是保持原尺寸避免动态形状2用torch.set_grad_enabled(False)加model.eval()同时转成 torch.jit 格式3把 NMS 的 IoU 计算向量化不要用 Python 双重循环。torch.set_grad_enabled(False) model.eval() traced torch.jit.trace(model, torch.randn(1, 3, 200, 320)) torch.jit.save(traced, click_detector_jit.pt)torch.jit.trace对输入尺寸敏感trace 时用的 320×200 在部署时也必须一致否则输出形状会错。如果文字区域有大有小建议固定输入宽高为 320×200靠锚框尺寸去覆盖不同大小的文字这样既满足 trace 约束也能保证回归分支的稳定性。ONNX 导出同理需要固定 dynamic_axes 或者直接把输入尺寸写死。优化项做法典型收益固定输入尺寸统一 resize 到 320×200CPU 耗时更稳定规避动态 shape 开销向量化 NMS用张量运算替代 Python 循环端到端耗时减少约 30%模型导出torch.jit.trace 后加载Python 层函数调用减少耗时下降约 10%6. 准确率再往上提的实战细节6.1 目标字符集与识别分支拆分点选验证码的字符集往往不是全部汉字而是某个常用字表比如 300 个高频字。模型分类头的大小直接依赖字符集字符集越大正负样本越不均衡。一个实用做法是把分类头改成“检测 裁剪识别”两段检测模型只输出少量候选字符裁剪出文字区域后接一个轻量识别网络。如果一直用一个网络做端到端字符集控制在 500 以内训练压力可控超过 500 建议拆开。6.2 预测坐标的偏移校准实际点击验证码时平台校验的是点击点是否落在文字区域内对精度要求不算苛刻但有一个系统性偏移模型框的中心点往往偏向文字左下角因为汉字包围盒的视觉重心偏右上方。可以在验证集上统计中心点偏移量推理时统一修正offset_x, offset_y 2.5, -1.5 # 从验证集统计得到 cx (best[0] best[2]) / 2 offset_x cy (best[1] best[3]) / 2 offset_y这个偏移不是模型错误而是合成数据里draw.text的坐标与视觉重心不一致导致的微调阶段换成真实数据后偏移会变小。每个平台的字号和描边风格不同换平台后重新统计一次通常几十张验证图就能算出稳定值。6.3 难例挖掘与干扰线预处理合成数据训练出的模型在真实验证码上最常见的错误是“背景纹理被当成文字”。把这些错误样本收集回来不是直接塞进训练集而是先做一次聚类把误检的框缩成小图用特征相似度分组每组挑 20 张做增强后加入训练。这种难例挖掘比随机加数据有效得多尤其是防误检方向因为误检的根源往往是某类特定纹理重复出现。另一个容易被忽略的环节是验证码图片的预处理。多数验证码图自带干扰线如果干扰线与文字颜色接近常规增强反而会加剧混淆。可以在输入模型前用快速连通域分析把面积小于阈值且不包含文字的色块抹掉这个操作放在Dataset里做不影响训练速度。抹掉干扰线后置信度阈值可以从 0.6 降到 0.45召回率提升明显。6.4 验证指标设计训练时用损失判断收敛但上线前要看三个指标目标字符召回率题面里每个字都被检出的比例、点击命中率点击点是否落在真值框内阈值 IoU 0.5、以及顺序准确率按题面顺序点击后整体是否通过平台校验。前两个指标可以自动统计顺序准确率只能靠真实平台测试。顺序准确率通常比单独看每个字的准确率低 5% 左右因为一个字符错位会导致后面的点击全部错位。如果继续往下做建议多记录真实平台上的失败截图按失败原因分桶字没检出来、检出来了但位置偏、字符识别错。三个桶分别对应检测头、回归头和分类头的改进方向比盲目调训练参数更有效。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门