ARTICLE DETAIL

资讯详情

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

基于PyTorch的中文字点选验证码识别:检测、识别与匹配实战

基于PyTorch的中文字点选验证码识别:检测、识别与匹配实战 简介本资源是一套基于PyTorch实现的中文文字点选、选字及选择式验证码识别完整方案面向深度学习初学者与OCR应用开发者聚焦中文场景下的文字检测与端到端识别难题适用于登录安全加固、自动化测试、验证码平台对接等实际业务需求。压缩包共33个文件涵盖20个核心Python脚本含模型定义、数据加载、服务封装与演示逻辑、3个ONNX推理模型YOLO文字定位、CRNN与CNN字符识别、3张效果示意图及GIF动图、2份字符集与依赖说明文本以及README和部署配置文件结构清晰模块解耦明确。目前已有2485人学习下载。读者可直接复用训练流程、调用预转换ONNX模型进行轻量部署、参考captcha.py与drawing.py理解中文验证码生成与可视化标注逻辑并通过service.py快速构建API服务具备从数据预处理、模型训练到工程落地的全链路实践价值。 做自动化测试和技术研究的朋友一定没少跟验证码打交道。尤其是那种“请点击下图中”的“花”字图片里散落着几个汉字背景还带干扰线、噪点甚至扭曲变形的点选式文字验证码乍一看不难真要让程序识别还得点中位置其实是个相当完整的CV小项目。这次分享的就是我用PyTorch从零搭出来的一套中文字点选验证码识别方案先用中文字检测模型定位每个字的坐标再对检测区域做中文识别最后结合题目的语义匹配出目标字输出可点击的中心点。整套代码在自建测试集上跑到了90%以上的端到端点选成功率对PyTorch、OCR和检测任务感兴趣的开发者都有参考价值。这套方案我拆成了检测、识别、匹配三段式来做避免端到端模型在大规模中文词表下的训练成本。文章里会讲清楚每段为什么这么选、数据怎么造、代码怎么写、训练怎么调以及那些文档里不会写的坑。1. 点选验证码识别的难点拆解与总体方案设计1.1 这类验证码到底难在哪点选式文字验证码看起来就是“图里有字按题目点字”但真正落地时难点比想象中多。首先是背景干扰大多数点选验证码不会给你干净的白色底图背景通常是风景图、插画、渐变纹理甚至故意加上高密度噪点和干扰线。文字嵌在这种背景下直接用传统图像处理手段比如轮廓查找很难稳定分割出每个汉字。其次是文字形变和字体差异。同一张图里的汉字可能是楷体、宋体、黑体混排有的字被旋转了十几度有的被拉伸或缩放。中文汉字结构复杂笔画密集一个“赢”字包含五个部分在低分辨率下检测和识别的难度都会陡增。最后还有语义匹配的问题。题目可能写“请点击‘燕’”图片里确实有“燕”但也可能有“雁”或“莺”形状相近。程序需要具备一定程度的语义理解或字形区分能力而不是单纯判断“图里有没有这个字”。而且真正的难点在于验证码是程序化生成的字体、位置、背景都是随机组合模型要具备泛化能力不能靠死记硬背。1.2 三段式方案检测 识别 匹配为什么这么拆面对这些难点我没有选择端到端的“图片题目→坐标”方案而是拆成了三个独立模块文字检测从整张图片中找出所有汉字的位置输出若干候选框。文字识别对每个候选框区域做字符识别输出对应的中文文本。语义匹配将识别出的文本与题目关键字做匹配选出正确目标并计算点击坐标。这样拆的核心原因有三个。第一是训练解耦检测和识别是两个相对通用的任务独立训练时可以复用大量开源资源和预训练模型不需要针对验证码这种特殊场景从头设计复杂的联合损失。第二是错误定位清晰如果点选失败能明确知道是检测漏了字、识别认错了字还是匹配没选对排查效率远高于端到端黑盒。第三是对抗小样本验证码数据集规模通常不会特别大拆开后每个子任务都可以用更小的数据集训到可用水平整体对数据的依赖比端到端低一个量级。之所以不用传统OCR的检测方式比如MSER、EAST纯传统特征是因为点选验证码的背景干扰太强传统方法在复杂背景下误检率非常高。用基于深度学习的检测模型能更好地学习“什么是文字区域”的高层语义特征。2. 数据准备合成数据为主真实数据为辅2.1 合成数据为什么训练集必须自己造点选验证码的数据集几乎没有公开的因为每个平台的验证码风格差异很大直接拿别人生成的数据去训练换一个平台效果就崩。最可靠的方式就是用代码自己合成数据这样可以完全控制字体、背景、干扰、旋转角度、字符位置等变量。我的合成脚本基于Pillow实现核心逻辑是import random from PIL import Image, ImageDraw, ImageFont, ImageFilter def generate_captcha(text, font_path, bg_imageNone, width300, height150): # 1. 随机背景可用纯色渐变 噪点也可直接贴合真实背景图 if bg_image is None: img Image.new(RGB, (width, height), (random.randint(100, 200), random.randint(100, 200), random.randint(100, 200))) draw ImageDraw.Draw(img) # 添加干扰线 for _ in range(random.randint(3, 8)): x1, y1 random.randint(0, width), random.randint(0, height) x2, y2 random.randint(0, width), random.randint(0, height) draw.line([(x1, y1), (x2, y2)], fill(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)), widthrandom.randint(1, 3)) else: img bg_image.copy() draw ImageDraw.Draw(img) font_size random.randint(28, 42) font ImageFont.truetype(font_path, font_size) positions [] # 2. 逐个字符绘制随机旋转和平移 for i, char in enumerate(text): char_img Image.new(RGBA, (font_size * 2, font_size * 2), (0, 0, 0, 0)) char_draw ImageDraw.Draw(char_img) char_draw.text((font_size // 2, font_size // 2), char, fontfont, fill(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255), 255)) angle random.randint(-20, 20) char_img char_img.rotate(angle, expandTrue) x random.randint(10, width - 50) y random.randint(10, height - 50) img.paste(char_img, (x, y), char_img) positions.append((x font_size // 2, y font_size // 2)) # 3. 添加噪点、模糊 img img.filter(ImageFilter.GaussianBlur(radiusrandom.choice([0, 0.5, 1]))) return img, positions合成时需要注意几点。一是字体尽量多我在脚本里准备了20多种中文字体覆盖宋体、黑体、楷体、仿宋以及一些艺术字体确保模型看到的字形足够多样。二是背景不能单一我用开源风景图数据集做底图同时混合渐变、纯色、纹理几种模式让模型学会“在复杂背景下找文字”而不是“在哪类图上找文字”。三是干扰要逼真验证码平台常用的干扰有噪点、干扰线、局部遮挡这些都要在合成阶段模拟出来否则训练和推理的数据分布差异会很大。2.2 真实数据采集与标注的注意事项合成数据能让模型从零跑到可用水平但要进一步提升在特定平台上的表现还是需要少量真实数据校准。采集时我不会在这里展开具体绕过某平台的方法重点只说原则必须在你拥有合法授权的系统、自己的测试环境或公开数据集中进行。我在实际项目中采集的是测试站点授权范围内的验证码图片。标注工具推荐用X-AnyLabeling或labelImg格式上建议统一成YOLO的txt格式每行是“类别id 中心x 中心y 宽度 高度”坐标归一化。需要注意每张图除了标注文字框还要记录题目关键字。我的做法是每张图配一个同名txt文件第一行是题目目标字后面每一行是检测框标注这样训练检测模型和评估匹配逻辑时都能直接读取。真实数据量不需要太多我最终用了约500张真实图片和合成数据按1:10混合效果提升很明显。关键是要把真实图片中出现频率高的背景类型、字体风格混进合成数据生成逻辑里这样模型才能更好地适配目标平台。2.3 标注格式与词表设计检测模型和识别模型的词表不同。检测模型不关心具体是什么字只关心“这里是不是文字区域”所以类别只有一类。识别模型需要定义字符集合常见做法是把常用汉字表比如《现代汉语常用字表》的2500字加上数字和字母作为识别词表。我在实际项目里把词表扩大到了约5000个字符覆盖常用字和常见生僻字识别模型的输出维度就是这个词表大小加一个CTC空白符。词表太大容易导致训练收敛慢太小又会出现大量“未登录字”所以需要根据实际验证码平台的出字范围来权衡。我一开始拍脑袋用3000字词表结果某些场景频繁出现词表外的字识别准确率掉了10个百分点。后来统计了合成源和真实数据中所有出现过的字再补上常用字表才稳定下来。3. PyTorch核心模块实现细节3.1 环境搭建要点先别急着写代码PyTorch环境是老生常谈但真有不少人在这里卡住。如果是NVIDIA显卡安装前先用nvidia-smi看驱动支持的CUDA版本再根据这个版本选择对应的PyTorch安装命令。比如驱动支持CUDA 12.1就可以用官方命令装cu121版本的PyTorch。若是ARM设备比如Jetson系列就不要用pip装官方预编译包需要根据JetPack版本从对应源编译或使用预编译wheel。CPU版本只在模型极小的场景够用检测和识别模型在CPU上推理一个样本可能耗时1秒以上体验很差。一个稳妥的安装命令模板conda create -n ocr python3.10 -y conda activate ocr # GPU版本示例具体以官方命令为准 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 pip install opencv-python pillow numpy pyyaml tqdm安装完务必跑一遍CUDA可用性验证python -c import torch; print(torch.cuda.is_available())输出True再继续。我第一次搭环境时就在这步偷懒没验证结果训练时用的是CPU一个epoch跑了几小时才发现。3.2 文字检测模块用轻量检测模型而不是重型分割网络检测模块我选的是YOLOv8n的检测头配合CSPDarknet主干没有用DBNet这类分割型网络。原因很简单点选验证码里的汉字大多横向排布、旋转角度不大普通目标检测的矩形框足够覆盖而DBNet虽然对任意方向文字更友好但训练和部署成本更高。轻量检测模型在300x150的图片上单次推理只需要20毫秒左右GPU已经能满足实际流程要求。如果用YOLO官方仓库训练数据组织非常简单yolo detect train datadata.yaml modelyolov8n.pt epochs100 imgsz320 batch32 device0其中data.yaml指向标注文件夹格式如下path: ./captcha_dataset train: images/train val: images/val nc: 1 names: [text]训练完成后导出onnx方便部署yolo export modelruns/detect/train/weights/best.pt formatonnx但直接用官方YOLO做服务有一个不方便的点输入输出格式固定集成到自定义推理管道时要多一层转换。所以我更推荐直接用PyTorch写一个精简的检测器或者用YOLO在训练阶段的预训练权重做backbone然后接自己的检测头。我实际项目里是直接基于ultralytics的API做的二次封装这样既省了重复造轮子的时间又能在推理阶段自由控制NMS和坐标后处理。3.3 中文识别模块CRNN CTC的实现要点识别模块采用经典的CRNN结构CNN提取特征 → LSTM序列建模 → CTC解码。对验证码这种短文本识别场景CRNN比Transformer类模型更轻量、更容易训练识别速度也更快。模型核心定义大致如下import torch import torch.nn as nn class CRNN(nn.Module): def __init__(self, num_classes, nh128): super().__init__() # CNN backbone self.cnn nn.Sequential( nn.Conv2d(3, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), nn.Conv2d(128, 256, 3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Conv2d(256, 256, 3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d((2, 1), (2, 1)), nn.Conv2d(256, 512, 3, padding1), nn.BatchNorm2d(512), nn.ReLU(inplaceTrue), nn.Conv2d(512, 512, 3, padding1), nn.BatchNorm2d(512), nn.ReLU(inplaceTrue), nn.MaxPool2d((2, 1), (2, 1)), ) # 高度压缩到1接LSTM self.rnn nn.Sequential( nn.LSTM(512, nh, bidirectionalTrue, num_layers2, batch_firstTrue), ) self.fc nn.Linear(nh * 2, num_classes) def forward(self, x): x self.cnn(x) # [B, C, H, W] - [B, 512, 1, W] x x.squeeze(2) # [B, 512, W] x x.permute(0, 2, 1) # [B, W, 512] x, _ self.rnn(x) # [B, W, 2*nh] x self.fc(x) # [B, W, num_classes] return xCTC解码是这部分的重点。训练时用torch.nn.CTCLoss要求输入序列长度大于目标字符串长度。推理时不能直接取每个时间步的argmax要做“去重合并”并过滤掉空白符def ctc_decode(probs, class_to_char, blank_id0): pred_ids torch.argmax(probs, dim-1).cpu().numpy() results [] prev -1 for pred in pred_ids: if pred ! prev and pred ! blank_id: results.append(class_to_char[pred]) prev pred return .join(results)CTC的“去重合并”逻辑本质上是把连续重复的字符视为一次输出。但要注意中文里本身就存在连续相同字的文本如“谢谢”如果两个相同字中间没有空白符间隔CTC会合并错误。在验证码场景里极少出现连续重复字我暂时没有这层担心如果真遇到可以在两个相同字间插入一个空白符作为分隔技巧。3.4 语义匹配模块直接用文本相等还不够识别完图片里的每个字后需要与题目关键字符匹配。最简单的做法是字符串相等判断但实际中识别器会有一定的错误率比如把“未”识别成“末”把“日”识别成“曰”。这些错误在单独的字上特别容易发生因为字符面积小、笔画差异细微。我在匹配阶段加了两层兜底。第一层是拼音相似度先把题目字和候选字转成拼音如果拼音完全相同而字形稍有差异依然认为是候选匹配项第二层是字形偏旁拆解比如“燕”和“雁”虽然都有“雁”的部件但写法差异较大通过字形相似度做加权排序。from pypinyin import lazy_pinyin def match_text(target, candidates): # 1. 精确匹配优先 if target in candidates: return candidates.index(target) # 2. 拼音兜底 target_py lazy_pinyin(target)[0] for i, cand in enumerate(candidates): if lazy_pinyin(cand)[0] target_py: return i # 3. 简单字形相似度使用difflib的SequenceMatcher import difflib best_idx, best_score -1, 0 for i, cand in enumerate(candidates): score difflib.SequenceMatcher(None, target, cand).ratio() if score best_score: best_idx, best_score i, score return best_idx注意这层匹配逻辑只是兜底核心准确率还是要靠识别模块的精度。匹配段代码跑得再快识别错了也没有意义不要反过来在兜底逻辑上花太多精力。3.5 推理流水线整合从图片到点击坐标检测、识别、匹配三个模块单独都能跑还不够最终要合成一个端到端的推理管道。我的实现流程如下import cv2 import torch def infer_captcha(image, detect_model, recog_model, target_text, device): # 1. 检测所有文字框 boxes detect_model(image, conf_thres0.25, iou_thres0.45) if len(boxes) 0: return None, no text detected # 2. 裁剪每个文字区域并识别 candidates [] cropped_regions [] for box in boxes: x1, y1, x2, y2 box[:4].int().tolist() # 四周扩展一点避免字体被截断 x1 max(0, x1 - 3); y1 max(0, y1 - 3) x2 min(image.shape[1], x2 3); y2 min(image.shape[0], y2 3) crop image[y1:y2, x1:x2] # 识别前resize到统一高度32像素 crop_resized cv2.resize(crop, (int(crop.shape[1] * 32 / crop.shape[0]), 32)) crop_tensor torch.from_numpy(crop_resized).permute(2, 0, 1).float().div(255).unsqueeze(0).to(device) probs recog_model(crop_tensor) char ctc_decode(probs[0], class_to_char) candidates.append(char) cropped_regions.append((x1, y1, x2, y2)) # 3. 语义匹配 match_index match_text(target_text, candidates) if match_index -1: return None, no match found # 4. 返回目标框中心点 x1, y1, x2, y2 cropped_regions[match_index] center_x (x1 x2) // 2 center_y (y1 y2) // 2 return (center_x, center_y), candidates这里有个细节很重要检测框裁剪后统一resize到高度32再送入识别模型。CRNN对输入宽度不敏感但高度必须是固定值一般32或64否则CNN特征图尺寸会乱。resize时宽度按比例缩放始终保持文本的宽高比避免字形被压扁。4. 训练策略与效果调优4.1 训练流程三个模型分开训别一口吃成胖子三个模块的训练难度差异很大。检测模型最简单用预训练权重微调100个epoch基本收敛识别模型稍微麻烦需要保证输入图片的宽高比合理词表覆盖完整匹配逻辑没有单独的训练过程是纯规则的。检测模型的关键是不要用太大的输入尺寸。点选验证码图片本身分辨率不高通常300x150左右如果强行resize到640x640再训练反而会因为文字被放大失真导致检测框偏移。我建议训练尺寸固定在320x320既保留足够细节又不会过度拉伸。识别模型训练时一个常见的坑是CTC Loss下降很慢。如果发现loss在几个epoch后几乎不动先检查词表里是否包含了所有训练样本的字符再检查输入图片高度是否统一。我遇到过一个情况某个font渲染出的字符宽高比异常导致resize后字形严重变形识别准确率只有20%。排查后过滤掉该字体训练效果立刻正常。推荐训练超参模块优化器初始学习率批量大小Epoch数据增强检测SGD / AdamW0.0132100Mosaic、随机翻转、HSV抖动识别Adam0.0016450随机旋转、噪声、模糊匹配无训练----4.2 效果评估端到端点选成功率才是最终指标单独看检测mAP或识别准确率都不够因为点选验证码的最终要求是“把一次点击落在正确文字的中心”。我评估时用三个层面检测召回率所有目标文字框能否被检测出来IoU阈值0.5。识别准确率检测出的文字区域中识别结果与真实字符一致的比例。端到端点选成功率整张图输入后输出的点击坐标是否落在目标文字中心附近一般要求落在该文字框内部。实际测试中检测模块mAP约0.98识别模块单独准确率约0.96但端到端点选成功率只有0.90左右。差距主要来自检测和识别的级联误差一旦检测框偏移几个像素识别就容易出错一旦识别出错后面的匹配和点击就全错。所以优化时优先盯端到端指标而不是单个模块指标。有一个典型的优化点当识别置信度低时不要直接取argmax结果而是返回Top-3候选让匹配阶段做模糊匹配。这能让端到端成功率提升2到3个百分点。4.3 数据增强的取舍数据增强直接决定模型的泛化能力。我在检测和识别任务中用了完全不同的增强策略。检测模型使用Mosaic增强很有帮助。Mosaic把4张图片拼接成一张训练能大幅提升模型对目标尺寸变化的适应能力尤其适合验证码中文字大小不一的情况。但Mosaic增强生成的拼接图背景分布和真实推理场景差异较大训练最后几个epoch最好关闭用纯真实分布微调。识别模型不要用太多几何增强。旋转超过30度、透视变换这类增强在自然场景OCR中有效但验证码文字本身不会出现大幅透视过度增强反而会让模型学到不存在的形态。我最终只保留了±15度的旋转、饱和度抖动和轻度模糊。5. 常见问题与排查技巧实录5.1 检测漏检小字、低对比度字一坨就没了这是最常遇到的问题。验证码里有些文字很小或者颜色和背景非常接近检测器常常漏掉。排查方向是先看数据合成数据中是否覆盖了小字号和低对比度场景。如果没有就去合成脚本里把字号范围从28-42改成22-48把文字颜色的RGB取值与背景的差值区间调小。如果数据没问题就把检测置信度阈值从0.25降到0.15虽然会增加一些误检但配合后面的识别和匹配阶段能过滤掉。经验是宁可多检几个框也不能漏掉目标框因为多的框可以在匹配阶段排除漏掉的框无论如何都点不中。5.2 识别混淆形近字几乎无解只能用匹配兜底“未”和“末”、“日”和“曰”、“人”和“入”这类形近字在低分辨率下人眼都容易看错模型更难。我尝试过用更高分辨率输入、更大模型但提升有限。最有效的方案是在数据合成时把容易混淆的字组合专门采样让模型见过足够多的正反例。另一个技巧是识别模型输出后如果置信度低于0.7就把字符送到字形相似度匹配阶段尝试与目标字的偏旁部首、笔画数做比对而不是直接放弃。虽然会增加误匹配率但整体点选成功率是上升的。5.3 检测框偏移裁歪了导致识别看不清检测框只是矩形验证码文字如果旋转角度较大矩形框就会包含大量背景甚至把旁边文字的一部分切进来。处理方式有两种一是把检测框向外扩3-5像素确保文字完整二是做一个轻量的角度分类把检测框按角度旋转后再裁切。验证码场景中旋转角度一般不会超过30度所以第一种方式通常就够用。如果追求更稳的框可以在检测模块中输出旋转框。但YOLOv8的默认检测头不支持旋转框需要换成OBB检测模型训练成本会明显上升。我实测下来在点选验证码这种场景里旋转框带来的收益并不大矩形框加外扩已经能满足后续识别需求。5.4 验证码常见问题速查表现象可能原因排查思路检测漏检小字合成数据缺少小字号样本扩大字号范围加入小字样本检测框偏移旋转文字导致矩形框包含背景外扩边界3-5像素识别准确率低词表不全导致未登录字统计真实数据并扩充词表识别把形近字搞混样本中形近字对不够构造混淆字对专项数据匹配失败识别置信度低且匹配逻辑过简单返回Top-3候选加入拼音兜底推理速度慢CPU推理或模型过大换轻量模型导出onnxGPU推理5.5 部署时容易忽略的性能细节推理性能在验证码实时识别场景中很重要。整套流程如果检测25ms 识别每个字约10ms乘以5个字共50ms左右单次推理总耗时约75msGPU这个速度基本能满足需求。但如果部署在CPU上建议做两个优化先把检测模型从YOLOv8n降到YOLOv8nano或者用TensorRT加速再把识别模型的输入宽度限制在最大128像素减少序列长度。另一个常被忽略的点是图像预处理方式。Pillow读取的图片是RGB顺序OpenCV读取的是BGR顺序直接混用会导致识别准确率下降。我在实践中统一用OpenCV读取和预处理只在最终合成验证码结果展示时用Pillow做画框避免颜色通道错乱。6. 踩坑实录那些文档里不会写的细节合成数据看着简单但第一个版本我完全用白底加干扰线生成训练出的模型在真实平台上检测框偏移严重。后来对齐了真实平台截图才发现真实验证码的背景不是纯色而是带纹理的插画。这一问题靠改进合成背景解决不能指望模型自己“学会”。这算是我在这个项目里踩得最深的一个坑。同理字体选择也影响巨大。早期合成数据只用了一种黑体字模型在真实数据上遇到宋体字时识别准确率掉了15%以上。后来我在合成脚本中加入了不同字体的权重随机并让每个文字样本使用随机字体渲染问题才得到缓解。还有一个经验不要迷信复杂模型。我试过用ResNet50做backbone的检测器和更深层LSTM的识别器效果提升不到1个百分点但推理速度慢了3倍。点选验证码识别场景对精度要求并没有那么极致对速度和稳定性的要求更高轻量模型是更明智的选择。在最终发布的代码里我建议保留一份纯合成的预训练模型权重和一份混合真实数据的微调权重。两者的适用场景不同前者泛化性更好适合快速适配新平台后者在特定平台上准确率更高但如果换平台可能过拟合。两套权重都用PyTorch的torch.save(model.state_dict(), path)保存加载时用model.load_state_dict(torch.load(path))即可非常方便。另外合规性必须多说一句。验证码识别技术本身是图像理解和OCR研究的一部分应用场景包括自动化测试、无障碍访问辅助、安全研究等。代码请只用于你有合法授权的系统或者你自己的测试环境切勿用于绕过他人安全机制的非法用途。技术本身是中性的怎么用取决于人。这套方案从数据生成到推理部署全部基于PyTorch生态是我个人实测下来准入门槛较低且效果稳定的一条技术路线。如果你也在做类似项目建议按“合成数据优先→检测识别分开训→端到端联调”的顺序推进先跑通最小闭环再逐步优化比一开始就追求大模型稳妥得多。本文还有配套的精品资源点击获取
返回列表