简介:一套基于 PyTorch 框架实现的文字点选、选字与选择文字验证码识别工程,面向深度学习入门者与中文 OCR 方向开发者,旨在解决图像中文字区域的定位、检测与字符识别等关键问题。压缩包共包含 33 个文件,整体体积约 146.78MB,其中以 20 个 Python 脚本为核心,并配有 3 个 ONNX 推理模型,以及用于效果展示的图片与说明文档等。这些脚本覆盖了从数据预处理、模型搭建、训练评估到服务化部署的完整流程;ONNX 模型可直接加载推理,降低环境配置门槛。目前已有 2492 人学习或下载,适合作为课程设计、毕业设计或竞赛项目的实战参考。此外,资料中还提供了中文字符集、依赖清单与 API 封装示例,方便开发者深入理解 CTC 损失函数、序列标注及注意力机制在变长中文验证码识别中的具体应用,并能够基于已有代码快速开展二次开发。
1. 文字点选验证码识别:为什么检测和识别必须拆成两个模型
一张点选验证码图片上散落着七八个中文文字,提示区写着“请依次点击:下、心、云”。人眼瞬间完成定位和认字两件事,但机器不行——它必须先找到每个字在画面中的坐标,再判断这个坐标上的字到底是什么。这就是标题里“文字点选、选字、选择文字验证码识别”的技术本质:一个 PyTorch 项目同时承载文字检测和中文字识别两个子任务,通常分别训练检测模型与识别模型,再串成一条完整推理链路。
这套方案最常见的落地场景是:安全团队评估自家验证码的抗机器破解能力、自动化测试中模拟真实用户点击、以及合规授权下的验证码安全性研究。适合的读者是已经跑通过 PyTorch 基础框架、想动手实现完整 OCR 流水线的开发者。把任务拆成检测与识别两段,而不是训练一个端到端模型,是因为点选验证码的文字数量少且位置离散,两段式能分别优化、分别换模型,工程上最稳。
2. 数据准备:点选验证码的标注格式与中文合成增强策略
2.1 标注什么:文字框坐标 + 文字内容,VOC 还是旋转框
点选验证码的数据标注和普通目标检测不一样,每个标注对象有两个属性:框的位置和框里的汉字内容。框的位置交给检测模型训练,汉字内容交给识别模型训练。理论上位置和内容可以分开标注,但实际操作中我一律建议一次性标全,因为后续识别模型做裁剪训练时,直接复用检测标注的框即可,不必二次返工。
标注工具上,常见做法是先用 labelimg 标水平框导出 VOC 格式。点选验证码里的汉字大多是水平排布或轻微旋转(正负 20 度以内),水平框加一点外扩余量就能兜住;只有当文字旋转超过 30 度、水平框会框入大量背景时,才考虑用 roLabelImg 标旋转框。旋转框训练要回归角度参数,YOLOv8 的 OBB 模式虽然支持,但部署时后处理复杂,角度误差会直接影响点击坐标精度。我的建议是:第一版先做水平框,把识别模型的旋转增强加上,90% 的场景能扛住。
2.2 把 VOC 转成 YOLO 格式:转换脚本与关键参数
检测模型我习惯用 YOLO 格式训练,所以拿到 labelimg 的 VOC xml 后要转一次。转换脚本网上很多,但有几个参数点容易写错:VOC 的坐标是左上角和右下角,YOLO 需要中心点加宽高;坐标必须除以图像宽高归一化到 0~1;类别编号从 0 开始,点选验证码统一当成一个“文字”类即可。
import xml.etree.ElementTree as ET def voc_to_yolo(xml_path, img_width, img_height, output_txt): """把 labelimg 标出的 VOC xml 转成 YOLO txt 点选验证码只标一个类 text,编号固定为 0 """ tree = ET.parse(xml_path) root = tree.getroot() lines = [] for obj in root.iter('object'): name = obj.findtext('name') # 理论上都是 text bndbox = obj.find('bndbox') xmin = float(bndbox.findtext('xmin')) ymin = float(bndbox.findtext('ymin')) xmax = float(bndbox.findtext('xmax')) ymax = float(bndbox.findtext('ymax')) # 转成 YOLO 的 cx, cy, w, h 并归一化 cx = (xmin + xmax) / 2 / img_width cy = (ymin + ymax) / 2 / img_height w = (xmax - xmin) / img_width h = (ymax - ymin) / img_height lines.append(f"0 {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") with open(output_txt, 'w', encoding='utf-8') as f: f.write('\n'.join(lines))这段脚本逻辑很简单,但要注意两个坑:一是 xml 里<bndbox>的标签名在不同标注版本里可能是xmin/ymin/xmax/ymax也可能是Xmin等大小写变体,解析前先打印一条看看;二是如果检测框是旋转框导出的,xml 里会有robndbox节点,上面的脚本读不到,得单独处理角度字段。转换完成后,记得抽几张图把 txt 的框画回去肉眼检查,别信自动转换的“成功”提示。
2.3 合成数据:用字体渲染脚本补齐中文长尾
真实点选验证码样本拿不到太多,尤其冷门汉字更是稀缺。我一般用合成渲染补数据:把目标汉字按随机位置、随机旋转、随机字体画到带干扰的背景上。合成数据的好处是检测框的坐标完全可控,连标注都省了,还能精确控制每个字的出现频次。
import numpy as np from PIL import Image, ImageDraw, ImageFont def render_captcha(chars, font_path, out_size=(300, 200)): """按给定汉字序列渲染一张带干扰的点选验证码 chars: 要绘制的汉字列表,例如 ['下','心','云'] 返回 numpy 图像和每个字的中心坐标、内容 """ img = Image.new('RGB', out_size, (245, 243, 238)) draw = ImageDraw.Draw(img) rng = np.random.default_rng() # 背景干扰线 for _ in range(120): x1 = rng.integers(0, out_size[0]) y1 = rng.integers(0, out_size[1]) x2 = x1 + rng.integers(-8, 8) y2 = y1 + rng.integers(-8, 8) draw.line([(x1, y1), (x2, y2)], fill=(int(rng.integers(180, 230)),) * 3, width=1) boxes = [] font_size = int(rng.integers(28, 40)) font = ImageFont.truetype(font_path, font_size) for ch in chars: x = int(rng.integers(10, out_size[0] - 60)) y = int(rng.integers(10, out_size[1] - 60)) tmp = Image.new('RGBA', (90, 90), (0, 0, 0, 0)) d = ImageDraw.Draw(tmp) d.text((20, 20), ch, font=font, fill=(int(rng.integers(0, 80)), int(rng.integers(0, 80)), int(rng.integers(0, 80)), 255)) tmp = tmp.rotate(float(rng.integers(-25, 25)), expand=True) img.paste(tmp, (x, y), tmp) boxes.append({'char': ch, 'center': (x + 45, y + 45)}) return np.array(img), boxes这段脚本里的关键参数有三个:旋转角度范围、字体大小、干扰线密度。旋转范围我先设正负 25 度,和真实验证码接近;字体大小 28 到 40 像素对应实际场景里的中号文字;干扰线密度用来模拟背景噪声,避免检测模型在干净背景上过拟合。字形方向上,至少准备三套字体:黑体、宋体、楷体,有条件再加一种手写体,否则识别模型会对字体纹理过拟合。合成时还要单独加大形近字的渲染比例,“未/末”“已/己”“日/曰”“土/士”这类字对识别模型是天然陷阱,数据里不专门加量,后面推理时必翻车。
3. 文字检测模型:用 YOLOv8n 做文字定位的 3 个关键配置
3.1 为什么选 YOLOv8n 而不是 DBNet
点选验证码的文字检测,很多人第一反应是 DBNet 这类场景文字检测模型,但实际做下来我更推荐 YOLOv8n。原因有三:一是点选验证码里文字数量少,通常只有 5 到 12 个,属于稀疏小目标检测,YOLO 的密集预测机制绰绰有余;二是 DBNet 的优势在整行文本的检测分割,点选验证码是散落的单字,用分割检测是杀鸡用牛刀,后处理还更重;三是 YOLOv8n 模型小、推理快,PyTorch 生态成熟,转 ONNX 部署几乎没有坑。
YOLOv8 的 OBB 旋转框模式适合文字旋转明显的场景,但第一版建议用普通水平框。水平框对点选验证码的意义在于:点击坐标就是框的中心点,只要框的中心点稳定,略微框进一点背景不影响点击准确度。如果文字旋转超过 30 度导致识别效果差,优先给识别模型加旋转增强,而不是急着上 OBB。
3.2 训练配置:输入分辨率、anchor 与小目标处理
点选验证码里的单个汉字在原始图像上可能只有 30×30 像素,属于典型小目标。YOLOv8 是 anchor-free 结构,不需要手动调 anchor,但输入分辨率直接影响小目标召回率。我实测 imgsz=640 时 30 像素高的文字经常漏检,调到 768 后漏检率明显下降。显存允许的话可以直接上 832,但推理速度会变慢,建议先用 768 作为基准。
数据增强上有一个容易忽略的参数:mosaic。YOLOv8 默认 mosaic=1.0,但点选验证码背景干净、文字分布规律,过度的 mosaic 会让训练图变成四张拼贴,破坏文字与背景的天然分布。我会把 mosaic 降到 0.5,同时开启 copy-paste 增强,把训练图里的文字实例随机复制到其他位置,把小目标的样本量翻几倍。
3.3 训练命令与效果验收指标
PyTorch 环境搭建好之后,YOLOv8 可以直接用命令行工具训练,不需要手动写训练循环。下面是我常用的训练配置,注意数据集 yaml 里的路径要写绝对路径或相对统一路径,否则训练时会报数据集不存在的错。
# point_click_text.yaml path: ./data/point_click train: images/train val: images/val nc: 1 names: ['text']yolo detect train \ data=point_click_text.yaml \ model=yolov8n.pt \ epochs=200 \ imgsz=768 \ batch=32 \ lr0=0.01 \ patience=30 \ mosaic=0.5 \ project=./runs/point_click \ name=exp_text这里几个参数值得说明:model=yolov8n.pt表示加载 COCO 预训练权重,不是从零训练,迁移学习能让收敛速度和最终精度都好看很多;patience=30是早停轮数,验证集 mAP 连续 30 轮不涨就停,省时间;mosaic=0.5是我前面提到的降增强策略。batch 大小看显存,24G 显存可以开到 32,16G 就降到 16。
验收指标别只盯着 mAP@0.5。对于点选验证码,我更关心两点:一是 0.5 IoU 下的召回率,漏检一个文字坐标,整张验证码就废了;二是预测框的中心点抖动幅度,因为最终要输出点击坐标,框中心偏 5 个像素,点击就可能点偏。验证时用脚本把预测框画回图上,肉眼扫一遍漏检和偏框,比只看 mAP 数字靠谱。
4. 文字识别模型:CRNN+CTC 完成中文识别的 PyTorch 实现
4.1 CRNN 结构拆解:CNN 压缩高度、LSTM 串序列
检测模型把文字框裁出来后,下一步是识别框里的汉字。中文识别我用 CRNN+CTC 路线:CNN 负责提取视觉特征,BiLSTM 负责串起字符序列,CTC 负责把可变长度序列对齐到标签。点选验证码的单字识别可以裁剪成单字图直接分类,但实际业务里检测框很难裁得正好一个字,经常带半个相邻字,所以用序列识别模型更稳。
网络结构上,关键是 CNN 部分要把特征图高度压到 1,这样后续 LSTM 才能按宽度方向展开成序列。我的输入高度固定为 48 像素,宽度可变,经过多层池化后高度缩到 1、宽度缩到原来的四分之一左右。宽度不固定意味着 batch 内的图要填充到同一宽度,训练时用 padding 补齐,推理时可以单张走。
4.2 字典设计:常用汉字表与 CTC blank
中文 OCR 的字典设计比英文麻烦得多。英文 26 个字母加符号就搞定,中文常用字就三千多。点选验证码用字范围其实不大,通常就是常用汉字表的子集,但为了泛化,我直接把 GB2312 的一级汉字表(3755 字)加上常用标点作为字典。字典第一项固定为 CTC blank,CTC loss 里的blank=0必须和字典索引对齐,这是最容易踩的坑之一。
字典文件就是一个普通的 txt,一行一个字,程序加载时按行号生成字符到索引的映射。标签编码时把“下心云”逐字查表转换成[idx_下, idx_心, idx_云],训练时把所有样本的标签拼接成一维张量,再单独记录每条样本的标签长度。
4.3 训练代码:CTC Loss 的计算细节与参数
模型定义和训练 loss 是整个 PyTorch 实现的核心部分。CNN 部分我采用类似 VGG 的小卷积堆叠,每层卷积后接 BatchNorm 和 ReLU,池化层控制高度压缩节奏;BiLSTM 两层、隐藏单元 256;最后接一个全连接层输出字典大小的 logits。
import torch import torch.nn as nn class CRNN(nn.Module): """CNN + BiLSTM + CTC 的中文单行文字识别模型 num_classes = 字典大小 + 1(CTC blank),blank 索引为 0 """ def __init__(self, num_classes, input_h=48): super().__init__() self.cnn = nn.Sequential( nn.Conv2d(3, 64, 3, 1, 1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2, 2), # 48 -> 24 nn.Conv2d(64, 128, 3, 1, 1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2, 2), # 24 -> 12 nn.Conv2d(128, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(), nn.Conv2d(256, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(), nn.MaxPool2d(2, (2, 1)), # 12 -> 6, 宽只缩一半 nn.Conv2d(256, 512, 3, 1, 1), nn.BatchNorm2d(512), nn.ReLU(), nn.Conv2d(512, 512, 3, 1, 1), nn.BatchNorm2d(512), nn.ReLU(), nn.MaxPool2d(2, (2, 1)), # 6 -> 3, 宽再缩一半 nn.Conv2d(512, 512, 2, 1), nn.ReLU() ) self.lstm = nn.LSTM(512, 256, bidirectional=True, num_layers=2, batch_first=True) self.fc = nn.Linear(512, num_classes) def forward(self, x): # x: (B, 3, 48, W) feat = self.cnn(x) # (B, 512, 1, W') feat = feat.squeeze(2).permute(0, 2, 1) # (B, W', 512) out, _ = self.lstm(feat) # (B, W', 512) logits = self.fc(out) # (B, W', num_classes) return logits注意最后两层池化用的是(2, 1)这种不对称核,意思是高度缩一半、宽度不动。这样做的目的是尽可能保留宽度方向的序列信息,因为文字识别的关键在宽度方向的分辨率。如果宽度缩太狠,CTC 的路径长度就不够长,识别率会明显下降。
训练 loss 用 PyTorch 自带的反向 CTC:
import torch.nn.functional as F def ctc_train_step(model, images, labels, label_lengths): """images: (B, 3, 48, W) labels: 所有样本标签拼接后的一维索引张量 label_lengths: 每条样本的真实标签长度 """ logits = model(images) # (B, T, num_classes) # CTC 要求 log_probs 形状为 (T, B, num_classes) log_probs = F.log_softmax(logits, dim=-1).permute(1, 0, 2) input_lengths = torch.full( (images.size(0),), logits.size(1), dtype=torch.long ) loss = F.ctc_loss( log_probs, labels, input_lengths, label_lengths, blank=0 ) return loss这里最容易出错的是张量形状。PyTorch 的ctc_loss第一个参数要求(T, B, C),其中 T 是序列长度,来自模型输出的宽度维度;第二个参数标签要展平成一维;input_lengths是模型输出的序列长度,因为 batch 内宽度做了 padding,所以每条样本的 input_length 其实不一样,但代码里先简化为都等于当前 batch 的 T,实际严谨写法是记录每条图 padding 前的宽度并换算成 T。训练时用学习率 0.0003、batch 64,宽度随机在 64 到 320 之间采样,相当于做了宽度增强。形近字多的时候,把 yaml 里的字形混淆样本单独抽出来多训几个 epoch。
5. 点选坐标推理的完整流程与 5 个常见排查点
5.1 推理流程:检测→裁剪→识别→匹配→输出坐标
训练完检测和识别两个模型后,推理阶段把它们串起来。流程固定为五步:检测模型框出所有文字位置,按框裁剪图片,识别模型逐个读字,拿识别结果和目标提示词匹配,最后按提示词顺序输出点击坐标。下面是核心推理代码,注意匹配环节是整条链路里最容易出逻辑错误的地方。
def infer_click_points(img_bgr, det_model, rec_model, target_chars, device): """输入验证码图和目标文字列表,返回按顺序点击的坐标 target_chars: 例如 ['下','心','云'] """ # 1. 检测所有文字框 results = det_model(img_bgr, conf=0.35, imgsz=768)[0] boxes = results.boxes.xyxy.cpu().numpy() # 2. 逐框裁剪并识别 cands = [] for x1, y1, x2, y2 in boxes: crop = img_bgr[int(y1):int(y2), int(x1):int(x2)] char = rec_model(crop) # 返回字符 cx = (x1 + x2) / 2 cy = (y1 + y2) / 2 cands.append({'char': char, 'center': (cx, cy)}) # 3. 按目标顺序贪心匹配 coords = [] for target in target_chars: best = min(cands, key=lambda c: char_distance(c['char'], target)) coords.append(best['center']) cands.remove(best) # 每个框只能用一次 return coordsconf=0.35是检测置信度阈值,调太高会漏检,调太低会把干扰线框进来。char_distance是字符相似度函数,最简单的实现是相等返回 0,不等返回 1;进阶点用编辑距离或拼音距离。匹配之后还有一个细节:点选验证码经常出现重复字,比如提示词要求点两个“云”,代码里cands.remove保证每个框只匹配一次,避免两个目标字匹配到同一个框上。
5.2 排查点 1:漏检小字导致坐标缺失
现象:验证码图上明明有那个字,但检测模型没框出来,导致后续匹配直接崩。原因就两个:输入分辨率不够,或者训练数据里小尺寸文字样本太少。解决第一步把imgsz提到 768 以上;第二步做 copy-paste 增强,把训练集中的小字实例多复制几次,让小目标在训练时的采样权重提上来。
5.3 排查点 2:形近字识别错误的处理
现象:“未”被识别成“末”,“土”被识别成“士”,点击坐标全错。这几乎是中文点选验证码必踩的坑。原因不是模型结构有问题,而是形近字在特征空间里距离太近。解决的直接办法是训练数据里把形近字对单独渲染一批,让模型见过足够多的差异样本;推理时还可以用候选集约束,只从目标字的形近范围内做选择,效果会好很多。
5.4 排查点 3:背景干扰让检测框偏移
现象:检测框把文字和旁边一条干扰线一起框了进去,识别模型读出来的字完全是乱的。原因是干扰线和文字纹理在浅层特征上有相似性,模型被背景噪声带偏。解决分两步:训练时合成数据里加入随机干扰线,让模型学会忽略;推理时过滤掉面积过小的框和置信度过低的框,因为干扰线产生的误检通常置信度在 0.3 以下。
5.5 排查点 4:旋转字符裁剪后识别率低
现象:文字旋转 20 度,检测框是水平的,裁剪图里字是歪的,识别模型读错。直接给识别模型加旋转增强是最省事的路子,训练时把裁剪图随机旋转正负 25 度。要是还不行,就在推理时对裁剪图做小角度旋转校正,常见的做法是用图像投影法估算文字倾斜角,把图转正后再送识别模型,代价是每个框多花几毫秒。
5.6 排查点 5:目标字与候选字匹配的顺序优化
现象:候选字都识别对了,但输出的点击顺序反了。点选验证码的提示词顺序是固定的,比如“下心云”要求先点“下”再点“心”最后点“云”,但图片上文字位置没有任何顺序约定。我的解决方法是:识别完成后,先对候选字按从上到下、从左到右排序,然后按提示词顺序做贪心匹配。最难的环节是识别置信度不高时,贪心匹配会配错,这时要把识别模型改成输出 top5 候选,用候选集去做匹配,而不是只拿一个最优结果。
6. 把两个模型压成可用的推理服务:ONNX 导出与 CPU 部署技巧
6.1 ONNX 导出与动态 batch 配置
PyTorch 模型训练完,部署时第一件事是转 ONNX。检测模型和识别模型都要转,且都需要开动态轴,因为验证码输入图像的宽高是不固定的。下面以识别模型为例:
import torch.onnx def export_crnn_onnx(model, export_path, input_h=48): model.eval() dummy = torch.randn(1, 3, input_h, 160) torch.onnx.export( model, dummy, export_path, input_names=['input'], output_names=['logits'], dynamic_axes={ 'input': {0: 'batch', 3: 'width'}, 'logits': {0: 'batch', 1: 'seq_len'} }, opset_version=14 )动态轴配置里,input第 3 维是宽度,图片输入时宽高都可以变,但识别模型的高度固定是 48,所以只把宽度设成动态。检测模型转 ONNX 时用 ultralytics 自带的导出命令即可,它会自动处理动态输入。转完后用onnxruntime加载,输入前要确认图像预处理和 PyTorch 训练时完全一致,否则精度会莫名掉一截。
6.2 预处理对齐:颜色通道与归一化的坑
推理精度下降最常见的原因是预处理不一致。YOLO 系列训练时图像归一化到 0~1,通道顺序是 RGB;而 OpenCV 读出来是 BGR,不转换直接喂给模型,色偏就会导致检测漂移。CRNN 模型的预处理则是 ImageNet 标准化,每个通道减均值除方差,这个均值和方差必须和训练脚本里完全一致。我吃过一次亏:训练时用(0.5, 0.5, 0.5)做标准化,推理脚本里用了(0.485, 0.456, 0.406),识别率直接掉了 15 个点,查了半天才发现是这里不一致。
6.3 一个提升稳定性的技巧:多候选输出与拼音距离
最后分享一个实战里很有用的技巧。识别模型推理时不要只取argmax,而是取概率最高的 top5 字符,然后结合目标提示词做过滤。比如目标字是“云”,识别候选输出可能是“云”“芸”“运”,拼音全是 yun,单看字形无法区分,但加上字形结构信息就能筛掉一部分。具体做法是给每个汉字建立拼音索引,匹配时先按拼音粗筛,再用字符相似度细筛。这个技巧特别适合形近字多的中文点选验证码,能把成功率从 80% 拉到 95% 以上。
另外一个习惯是:每次部署完,跑一遍完整的端到端验证,把检测框、识别结果、最终坐标画到图上人工抽查,不要只看单模型的准确率。我做过一个项目,检测模型 mAP 高达 0.98,识别模型准确率也超过 99%,但端到端点击成功率只有 75%,最后定位到是预处理对齐问题。从那以后,我再也不信单模型指标,只信端到端成功率。这套方案从数据合成到 ONNX 部署流程并不复杂,但每个环节都有隐藏的细节,希望这些踩坑记录能帮你少走弯路。
本文还有配套的精品资源,点击获取