登录某系统的自动化操作时,硬生生被一张“请依次点击图中的【中】【国】【税】”给卡住了。这种中文点选验证码,跟常见的滑块、数字字母验证码不一样,它要求程序先读懂题目,再在图片里找到对应的汉字,最后还得按顺序点对位置。做过爬虫自动化或者Web自动化测试的朋友应该都有体会,这东西比纯OCR识别要恶心不少——它不是一个单纯的文字识别问题,而是“文字识别+目标定位+顺序决策”的组合问题。这篇文章我就完整复盘一下我当时做“中文点选验证码识别”这个项目的全过程,从问题拆解、技术选型、模型训练,到坐标映射、自动化点击和踩坑记录,给遇到同类需求的朋友一条可以直接参考的路线。
整个项目最终落地的方案是PaddleOCR 解析提示文字 + YOLOv8 目标检测定位汉字 + Playwright 模拟点击的组合。如果图像里的汉字带有明显颜色提示,还可以先用 HSV 颜色空间做快速过滤,把候选区域缩小。整套流程跑下来,单张验证码从拿到图片到完成点击,耗时基本在 2 秒以内,成功率在自建测试集上稳定在 96% 以上。
这个项目适合谁参考?一是做 Web 自动化测试、需要处理登录验证码的工程师;二是研究 OCR 和目标检测结合应用的 CV 方向同学;三是对验证码识别原理感兴趣、想自己动手训练一个实用模型的爱好者。下文的内容都基于已获授权的测试环境与模拟样本,真实业务场景请在法律法规和平台规则允许的范围内评估使用,本文纯粹是从技术实现角度做拆解。
1. 先从点选验证码的“题目结构”开始拆解
想搞定这类验证码,第一步不是急着训练模型,而是搞清楚它到底考什么、难点在哪。中文点选验证码的市场版本很多,但核心结构万变不离其宗:一张背景杂乱的大图 + 若干个嵌在图中的中文汉字 + 一段提示用户点选顺序的文字。它的设计思路是让“机器不好做、人容易做”,所以干扰手段基本都集中在文字和背景的融合上。
1.1 中文点选验证码的形态特点
我遇到的这个场景,验证码图片大概长这样:背景是一张生活场景照片,上面散落着七八个汉字,每个汉字有自己独立的旋转角度、缩放比例、颜色和半透明效果,有的字甚至部分被背景物体遮挡。图片下方或上方有一段提示文字,比如“请依次点击【中】【国】【税】”或者“请按顺序点击:税、国、中”。部分变体还会把提示文字做成图片格式,增加一步图像 OCR 的复杂度。
这种结构与传统的字符验证码相比,难在三个方面。
第一,文字识别从“整图切分”变成了“场景文字检测”。传统数字字母验证码,字符通常排列在一条水平线上,先用轮廓分析或投影法切分,再做单个字符分类就行。但点选验证码里的汉字是任意散落的,位置、角度、大小完全随机,根本没有稳定的切分基线,必须上目标检测,先框出每个字在哪,再判断它是什么字,检测和识别是强耦合的。
第二,干扰不是线条和噪点,而是语义信息。背景图是真实照片,里面可能本身就有类似汉字的结构、色块边缘、招牌轮廓,甚至半截汉字式的纹理。传统模板匹配在这种背景上很容易出误检,因为背景的局部信息复杂度比纯色底纹高了几个量级。
第三,输出不是“这是什么字”,而是“按什么顺序点”。系统校验的是点击顺序和点击坐标,所以程序不仅要认出图里有哪些字,还要能把提示文字里的目标序列解析出来,然后按顺序映射到坐标点,最终模拟鼠标点击。多了一个“语义解析+顺序编排”的环节,任何一步出错都会导致整次点击失败。
1.2 两条技术路线的对比与选型
针对这个形态,我在项目开始前认真对比了两条路线:传统图像处理路线和深度学习路线。
传统路线的核心思路是:先抓颜色特征或模板特征,再用形态学操作和轮廓分析定位文字块。如果验证码里的汉字颜色与提示词严格对应(比如提示“点击红色字”,图里确实只有“税”字是红色),用 HSV 颜色过滤+连通域分析确实很快,甚至不需要训练任何模型,一条 pipeline 50 毫秒内就能出来结果。我当时也写了一个 HSV 版本做实验,对于纯色字场景准确率挺高,但遇上两种情况就废了:一是提示词是“点击【品】【口】【回】”这种不绑定颜色的字形题目;二是背景里存在同色大块物体,比如红色消防栓、橙色沙发,颜色过滤会把干扰区域直接框进来。
另一种传统思路是多尺度模板匹配,预先把需要识别的汉字渲染成模板,在图上做滑动窗口匹配。这个方案的优点是脱离颜色依赖,缺点是扛不住旋转、缩放宽泛变化、透视畸变和背景遮挡,稍微加点干扰置信度就崩。实测模板匹配在自建数据集上的召回率只有 60% 出头,完全达不到可用标准。
深度学习路线选择就明确多了:目标检测负责“在哪里”,OCR 负责“提示词是什么”。目标检测选 YOLOv8,理由很简单——它在小目标、密集目标场景的表现比旧版 YOLO 和 Faster R-CNN 更稳,工程生态完善,训练部署链路短,而且能一行代码导出 ONNX 做推理加速。OCR 选 PaddleOCR,中文字典覆盖全,识别短文本的鲁棒性好,同时自带方向检测,适合解析变体较多的提示文字。这套组合的本质是用检测模型替代传统切分和模板匹配,把“场景汉字识别”变成了一个标准的检测任务,泛化能力完全不在一个量级。
选型确定之后,整个技术方案就清晰了:图像预处理模块、提示词解析模块、汉字检测模块、坐标映射模块、模拟点击模块。下文每个环节我都会讲清楚原理和实现细节。
2. 核心链路拆解:从“看题”到“点击”的四步
一套完整的中文点选验证码识别流程,可以抽象成四个环节:拿到图片、看懂题目、找到目标、按序点击。看起来简单,但每个环节都有不少隐藏细节,尤其坐标映射部分,90% 的失败案例都栽在这里。
2.1 提示文字解析:用 PaddleOCR 读懂“题目”
第一步是把验证码里的提示文字识别出来。大多数场景下提示文字是直接嵌在页面中的 HTML 文本,这时候不需要 OCR,直接抓取页面元素内容就行。但有些系统会把提示文字也渲染成图片,这种情况下就必须走 OCR。
我从页面上抓到的提示文字一般是这两种形式:
- “请依次点击【中】【国】【税】”
- “请按顺序点击:税、国、中”
PaddleOCR 的调用很标准:
from paddleocr import PaddleOCR ocr = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False) def parse_prompt_text(image_path): result = ocr.ocr(image_path, cls=True) texts = [line[1][0] for line in result[0]] full_text = "".join(texts) # 提取括号内的目标字,兼容中文和英文括号 import re targets = re.findall(r"[【\[((]([^】\]))]+)[】\]))]", full_text) if not targets: # 兼容“按顺序点击:税、国、中”这类不带括号的描述 targets = [c.strip() for c in full_text.split(":")[-1].replace("、", " ").split()] return targets这里有几个关键细节:
一是OCR 识别结果要去噪。中文 OCR 有时会把“税”识别成“祝”,把“中”识别成“中”和“申”的混合体。我的处理方式是维护一个候选词表,把所有检测类别限定在一个固定集合内,然后对 OCR 结果做编辑距离匹配。因为验证码能点击的字是有限的,通常就固定在一个汉字集合里,比如 20~50 个常用字。候选集合小,匹配准确率就能拉得很高。
CANDIDATE_CHARS = {"中", "国", "税", "品", "口", "回", "红", "橙", "蓝"} def normalize_ocr_char(char): import difflib if char in CANDIDATE_CHARS: return char # 在候选字里找最接近的 match = difflib.get_close_matches(char, list(CANDIDATE_CHARS), n=1, cutoff=0.5) return match[0] if match else None二是应对“颜色字”题目。如果题目是“请依次点击【红】【橙】【蓝】”,说明图里的字是按颜色渲染的,此时 OCR 出来的颜色名基本不会错,直接用解析结果去匹配检测框的颜色属性即可。
三是顺序的稳定性。OCR 的识别顺序并不总是从左到右、从上到下,排版一乱返回顺序就可能混。所以在解析提示词时,不要依赖 OCR 的原始顺序,而是用正则把所有目标字符提取出来作为一个列表,再遍历它确定点击顺序。
2.2 汉字定位:YOLOv8 检测器的设计与推理
定位汉字是整个项目最核心的部分。我用 YOLOv8 直接对验证码图片里的汉字做检测,类别就是每个汉字本身。这样做的好处是检测和分类一步到位,模型输出的每个框自带一个类别标签,后续匹配提示词非常方便。
数据集构造这部分我会在下一节详细讲,这里先看推理侧的关键实现。YOLOv8 的推理主流程如下:
from ultralytics import YOLO model = YOLO("best.pt") def detect_chars(image_bgr): results = model.predict(image_bgr, imgsz=640, conf=0.35, iou=0.45, verbose=False) detections = [] for r in results: for box in r.boxes: cls_id = int(box.cls[0]) conf = float(box.conf[0]) x1, y1, x2, y2 = [float(v) for v in box.xyxy[0]] cx, cy = (x1 + x2) / 2, (y1 + y2) / 2 char = model.names[cls_id] detections.append({ "char": char, "conf": conf, "cx": cx, "cy": cy, "bbox": (x1, y1, x2, y2) }) return detections推理时会自动做 letterbox 预处理,把任意长宽比的图片缩放填充到 640×640。这块后面坐标映射环节要特别注意,因为模型输出的是 letterbox 坐标系里的坐标,必须还原到原图坐标系。
检测器本身需要克服的难点是抗干扰。验证码设计者在文字周围加了大量纹理、杂线和模拟背景,YOLO 的卷积特征天然对局部纹理敏感,容易把背景里某些类似汉字形态的连通区域误检成目标。我的经验是:训练集里必须放进足够多“无字干扰样本”并标注空白,或者至少让模型见过大量不同类型背景的负样本,单纯靠调整 conf 阈值是救不了漏检的。
针对“颜色字”场景,我还做了一个 HSV 快速预筛选的分支。思路是用颜色名映射到 HSV 范围,先过滤出对应颜色的像素掩膜,再做连通域分析得到候选框,最后只对这些候选框做小图分类。这个分支的好处是不依赖 GPU,CPU 上几十毫秒就能出结果。但它的适用面窄,所以我只把它作为 YOLO 主方案的前置提速工具和兜底方案,主力还是检测模型。
2.3 坐标映射与点击顺序:坑最深的一步
模型输出的检测框坐标是相对输入图片的,要让浏览器鼠标真正点到正确位置,中间隔着好几层坐标变换。我把完整流程拆成三个子环节。
第一层,letterbox 还原。YOLOv8 predict 时会先把原图缩放填充到 640×640。假设原图宽高是w0, h0,模型输入是640×640,缩放系数是r = min(640/w0, 640/h0),那么 letterbox 后有效区域尺寸是(w0*r, h0*r),剩余部分以灰色填充,填充宽度是pad_x = (640 - w0*r) / 2,高度是pad_y = (640 - h0*r) / 2。模型输出的中心点(cx_pad, cy_pad)要先减去填充偏移,再除以缩放比例,才能得到原图坐标:
def letterbox_to_original(cx_pad, cy_pad, w0, h0, input_size=640): r = min(input_size / w0, input_size / h0) new_w, new_h = w0 * r, h0 * r pad_x = (input_size - new_w) / 2 pad_y = (input_size - new_h) / 2 cx0 = (cx_pad - pad_x) / r cy0 = (cy_pad - pad_y) / r return cx0, cy0第二层,原图坐标到页面显示坐标。验证码图片在页面上通常不是按原始尺寸展示的,而是通过 CSS 缩放到一个固定宽度,所以需要计算显示缩放比例。假设图片元素在页面上的显示宽度是display_w,原始图片像素宽度是w0,那么显示缩放系数是s = display_w / w0。点击位置相对于图片左上角偏移量就是:
rel_x = cx0 * s rel_y = cy0 * s第三层,页面相对坐标到视口绝对坐标。用 Playwright 拿到图片元素在视口中的位置(img_x, img_y),加上上面的相对偏移量,就是鼠标最终点击的视口坐标:
page.mouse.click(img_x + rel_x, img_y + rel_y)这个映射链路里最容易翻车的是第二层。曾经有一版实现直接用图片原始尺寸计算点击位置,忽略了 CSS 缩放,结果坐标全部偏到汉字右下角斜向偏移,点击成功率直接掉到 30% 以下。排查了很久才发现是缩放比例没乘回去。另外高清屏的deviceScaleFactor也会影响坐标,建议统一用 CSS 像素坐标系。
点击顺序编排上,遍历解析出的目标字符列表,每遍历一个就去检测结果里找到对应类别置信度最高的框,用它的中心点作为点击目标。如果图上同一个字出现多个实例,优先选择置信度最高的那个,或者按从左到右、从上到下排序取第一个,具体要看验证码设计逻辑。一般验证码会控制目标字在图中只出现一次,我实际遇到的场景基本是单实例,所以直接取置信度最高的框即可。
3. 数据集构建与模型训练实操
很多同学拿到这类项目第一反应是找个公开数据集,但验证码这种东西每个系统的生成风格都不同,公开数据集根本不顶用。真正可靠的做法是自己采集样本、自己标注,虽然前期工作量大,但训练出来的模型贴合度是最高的。
3.1 样本采集与标注心法
样本来源就是我自己的测试账号和一套可重复刷新的验证码接口。当时写了个脚本,每次请求一组新的验证码图片,自动下载保存原图,并同时保存页面上抓到的提示词文本。这样跑一个晚上就能攒下几千张有效图片,而且每张图片的提示词和标准答案都是已知的,直接作为弱标注数据。
不过弱标注有个问题:它只有“提示文字和目标顺序”,没有每个汉字在图片里的位置框。这就需要在已有弱标注数据上做二次标注。我用 LabelImg 工具标注了大约 1500 张图片,标注格式是 YOLO 的 txt 格式,每行一个框:类别ID、中心点x、中心点y、框宽、框高。为了提升效率,我先用了检测模型的初步预测结果做预标注,人工只做修正,速度能快一倍以上。
标注的时候要注意几个点:
- 类别集合要固定。项目里出现过的所有汉字都要纳入类别体系,不要漏掉低频字。我当时维护了一个 45 个字的表,够用。
- 负样本要单独处理。验证码页面偶尔会加载出半张图、空白图或者纯背景图,这些图一个目标都没有,我把它们单独放一个文件夹,在训练时混入数据。YOLO 训练对负样本的容忍度不算高,但适量加入能显著降低误检率。
- 确保角度多样性。整理数据时按旋转角度粗筛,保证训练集里的字既有水平也有倾斜的,不然模型很容易过拟合到特定角度。后来在迭代中测试集里混入不同来源的验证码样张,验证泛化能力。
3.2 YOLOv8 训练参数配置分析
训练配置我直接抄的 YOLOv8 默认参数,只改了几个关键项。先看完整命令:
yolo detect train \ data=dataset.yaml \ model=yolov8n.pt \ epochs=160 \ imgsz=640 \ batch=16 \ lr0=0.01 \ optimizer=AdamW \ patience=30 \ augment=True \ seed=42解释一下几个关键选择。
imgsz=640是平衡速度和精度的选择。验证码里的汉字通常是小目标,如果设置成 320,很多文字只有十几像素,特征损失严重;设置成 1024 精度还能涨一点,但推理速度翻倍,对单张验证码这种低延迟要求不划算。640 在实测中已经能把文字召回率做到 98% 以上。
optimizer=AdamW相比默认的 SGD 收敛更稳定。检测小目标对学习率波动比较敏感,AdamW 的适应性学习率机制让小批量训练时不容易震荡。用 SGD 试过一次,后期 loss 曲线一直抖,换成 AdamW 之后稳了很多。
patience=30是早停策略。验证码训练到 100 轮左右基本收敛,继续训练容易过拟合到训练集的字体风格上,早停 30 轮没提升就自动切到最佳权重,省时间也防过拟合。
训练完成后选权重不看 mAP 一个指标,重点看每个类别的召回率,尤其是出现频率低的汉字类别。如果某一类字的召回率明显低于平均线,我的做法是去检查该类别的标注框数量,如果标注太少,就专门补采一些包含该字的验证码样本重新训练,而不是盲目加 epochs。
3.3 模型导出与推理加速
模型训练完,部署环节我用 ONNX Runtime 做加速。Ultralytics 模型可以一行导出 ONNX:
yolo export model=best.pt format=onnx opset=12 simplify=True然后用 onnxruntime 做推理,速度比纯 PyTorch 快不少,特别是 CPU 环境:
import onnxruntime as ort import numpy as np import cv2 session = ort.InferenceSession("best.onnx", providers=["CPUExecutionProvider"]) def preprocess(img_bgr): # letterbox 处理 img, r, (pad_x, pad_y) = letterbox(img_bgr, 640) img = img[:, :, ::-1].transpose(2, 0, 1) # BGR -> RGB img = np.ascontiguousarray(img, dtype=np.float32) img /= 255.0 return np.expand_dims(img, axis=0), r, (pad_x, pad_y)导出后的 ONNX 模型输出是 YOLO 的原始输出头,需要自己写解码和 NMS 逻辑。如果不想折腾推理代码,也可以直接用 ultralytics 自带的YOLO("best.pt")跑 PyTorch 推理,不过 ONNX 在 CPU 上大约能有 1.5~2 倍的推理加速。单张 640×640 的图片在普通笔记本 CPU 上实测推理时间在 50~80ms 之间,加上 OCR 和点击操作,整链路 2 秒内完成没什么压力。
4. 常见故障排查与避坑记录
项目做下来,最花时间的不是训练,而是调坐标和修 bug。这里我把实际踩过的问题整理成了一份排查表,遇到类似情况的可以直接照着查。
4.1 高频问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 点击位置总是偏右下角或偏移量固定 | CSS 缩放比例未还原,或 devicePixelRatio 没处理 | 用图片元素实际显示宽度除以原图宽度得到精确缩放比 |
| 提示词解析出来的字是乱的 | OCR 输出乱序、候选字匹配失败 | 用正则提取括号内字符生成序列,不依赖 OCR 排序 |
| 检测框经常框到背景物体 | 训练集负样本不足,背景多样性不够 | 混入负样本图片,降低置信度阈值并用提示词过滤候选 |
| 同一提示词多次刷新结果不同 | 验证码更换逻辑导致图片变化 | 每次点击前重新获取最新图片和提示词,不要缓存 |
| click 事件触发失败 | 页面元素吸附、悬浮层遮挡 | 点击前先滚动到元素可见,必要时用 force 模式点击 |
| 高刷新频率下被风控 | 点击间隔过短、轨迹太机械 | 控制频次,增加随机延时,用自然的鼠标移动轨迹 |
4.2 坐标偏移的经典排查过程
坐标偏移是所有人第一次跑通全流程后遇到的头号问题。我的排查过程可以分享一下。当时所有检测都正常,提示词也对,但浏览器里点击就是不生效。我先打印了模型输出的中心点(cx, cy),再打印页面元素位置,发现点击坐标和图片里文字的实际视觉位置明显不在一个地方。
复盘后发现是三处叠加:第一,letterbox 的 padding 忘了还原——模型输出坐标是在填充后的 640×640 画布上的,直接用就是偏的;第二,页面图片元素的 CSS 宽度不是原始图片宽度,缩放系数没算上;第三,页面顶部有固定导航栏,点击时没把元素相对视口的偏移算进去。把这三层变换串到同一个函数里之后,坐标偏差从二十几个像素缩小到 1 像素以内。
4.3 误检和漏检的调优思路
检测器召回率低,先从数据下手。某次发现特定背景(深色纹理)下的汉字漏检严重,专门去采了 100 张这类图片补充标注后,漏检问题大幅改善。误检多发则是因为背景里的纹理被模型当成了文字,这类误检用提示词过滤能挡掉一波,还有一波要靠降低 conf 阈值配合类别过滤。具体做法是推理时保持conf=0.25的低阈值,把所有低置信度的候选框也返回,然后用提示词里的目标字符白名单做二次过滤,只保留类别命中白名单的框。这样既不会漏掉正确目标,又能把背景误检产生的噪声隔离在外。
颜色字的 HSV 分支也经常出问题。红色字在暗背景上可能把暗红色区域漏掉,橙色和红色在 HSV 空间边界上容易互相串。我的经验是把 HSV 范围设得宽松一些,宁可多召回候选框,再用提示词和形态学面积过滤。多次尝试下来,色相偏移 15 度之内的字体渲染都能覆盖。
4.4 模拟点击的细节补充
用 Playwright 模拟点击时,我一开始用的是裸的page.mouse.click(),坐标定位精确,但点击轨迹是一瞬间完成的,明显不自然。后来加了一段基于贝塞尔曲线的鼠标移动,让鼠标先移动到目标附近的随机偏移点,再平移到目标中心,最后点击。这样经过风控的成功率明显提升。
另外就是点击间隔。每次点击之间加了 200~600ms 的随机延时,整体节奏贴近人手操作。整套验证流程跑完后,立刻刷新验证码准备下一次操作,避免后续请求被风控盯上。安全爬虫和自动化测试双重要求下,这种温和的频率控制是必要的。
4.5 关于合规使用的几点提醒
验证码识别的技术本身是中性的,但用在哪里、怎么用,边界非常清晰。做这个项目时,我全程用的是自己申请授权的测试账号、测试接口和模拟数据,没有对任何未经授权的真实登录入口做过识别绕过。如果是接到工作任务,建议先确认目标系统是否允许自动化测试;如果是个人学习,建议在本地搭建的演示环境里跑通全流程。合规是底线,尤其涉及税务、金融、政务类系统,未经许可的任何“绕过验证码”操作都不可取,这一点务必放在心上。
5. 写在实操之后的几点体会
这个项目从立项到稳定运行,前前后后迭代了三版方案,第一版纯模板匹配失败,第二版加了 HSV 和传统图像处理仍然不够稳,最后才走上目标检测这条路。我个人最大的体会是:验证码识别项目的核心不在于某个模型的精度有多高,而在于整条链路的稳定性。其中一段出问题,整体成功率就会像多米诺骨牌一样崩掉,所以调试的顺序应该永远是先保证坐标映射零误差,再去优化检测模型的召回率。
最后再分享一个小技巧:所有验证码图片和提示词在进入模型之前,尽量统一成一张干净的图片,去掉页面截图里的按钮、边框和背景噪声。我当时在抓图时直接把验证码图片从页面里独立下载出来,绕过了页面截图的很多干扰因素,这让后续检测和识别模型的输入复杂度大幅下降。如果你的目标系统也支持直接请求图片资源,优先走这条路,省时省力。
自动化识别这类验证码本身就是一个“检测+OCR+自动化”的组合工程,按这套逻辑拆下来,就算换成其他形态的中文验证码,也只需要替换检测模型或者提示词解析规则,整体骨架是可以直接复用的。