14 KiB
从手搓验证码识别到 YOLO + PP-OCRv5:一次中文点选验证码开发历程
这篇不是安装说明,而是一次开发过程复盘。
项目一开始要解决的问题很直接:GLM Coding Plan 页面出现中文点选验证码,提示里给出 3 个汉字,图里也藏着 3 个汉字。脚本要自动识别这 3 个字的位置,然后按顺序点掉。
听起来像一个小问题:找字、认字、点击。但真正做起来,它把检测、图像处理、字体差异、中文 OCR、小样本泛化、浏览器坐标和本地服务工程问题全部串在了一起。
前半段我们几乎一直在“手搓”:手搓图像处理,手搓检测数据,训练 YOLO,手搓排序模型,手搓各种相似度和增强。一路确实越来越强,但也一路撞上了小样本模型的天花板。最后真正稳定下来,反而是把一部分自研模型换成成熟 OCR,再用业务提示做强约束。
第一阶段:先别管 OCR,能不能把字抠出来?
最早的想法不是直接 OCR。
因为验证码提示已经告诉了我们要点哪 3 个字,所以当时的问题被理解成:
我不需要从全字库里认出每个字,我只需要判断图里的 3 个候选字,分别对应提示里的哪 3 个字。
这样看起来难度小很多。于是第一版路线是传统视觉:
- 从截图里裁出验证码大图。
- 用颜色阈值、连通域、形态学操作把彩色汉字从背景里抠出来。
- 本地用字体把提示字渲染出来。
- 拿渲染模板和抠出来的候选字做相似度匹配。
- 在 3 个提示字和 3 个候选框之间做全局最优匹配。
这套东西很快能跑,但准确率很惨。
传统 CV 排序大约只有 12.5%。后来加了一些合成数据,也只到约 16.2%。
问题很直观:验证码里的字不是标准字体。它有透明度,有背景粘连,有缩放,有轻微变形,有时候还是黄字贴在复杂背景上。本地渲染出来的标准字体模板,和真实验证码里的字根本不是一个分布。
这时我们还没有完全意识到“分布差异”会成为主线问题,只觉得规则还不够聪明、数据还不够多、抠字还不够干净。
第二阶段:修炼 YOLO,先把 3 个字稳定找出来
传统视觉做检测也不稳。于是我们开始给验证码字框做数据,训练 YOLO。
这个阶段做了很多脏活:
- 截图、裁图、标框。
- 整理 Pascal VOC / YOLO 数据集。
- 从少量人工样本扩展到自动采集样本。
- 训练多个检测权重,比较旧 detector、新 detector、clean 版本 detector。
YOLO 这条路最后是走通了的。它解决的是“图里 3 个字在哪里”的问题,而不是“这 3 个字分别是什么”。
中间也踩过模型路径坑。有一次后端还在用旧权重,真实样本上检测不到 3 个框。切到新的 clean detector 后,同一类样本从 0 个有效框恢复到稳定 3/3。
这一步之后,系统的前半段基本稳定了:只要截图区域和坐标没错,YOLO 能把 3 个候选字框找出来。
第三阶段:用 GLM-OCR 和 VLM 做标注交叉验证
YOLO 能找框以后,接下来最大的问题变成数据。
手工标注太慢,而排序模型又需要大量“这个框里是什么字”的监督信号。于是我们开始把大模型也拉进标注链路里。
当时有一批自动采集的验证码图,YOLO 可以先把 3 个字框切出来,然后交给 GLM-OCR 识别。这样就能从真实验证码里批量生成训练样本,而不是只靠少量手工数据。
这一步带来了第一波数据扩充:
- SorterV3 训练时已经能加载约
324条 GLM-OCR 标注。 - 加上原来的 80 条所谓 GT,训练集规模到过
404条。 - 后面 GLM 标注数据又扩到过
428条。
但自动标注不是直接可信的。
有些样本 OCR 之间会冲突,有些字看起来像形近字,有些 crop 质量本身就差。为了处理这些冲突,我们还试过引入 VLM,对约 65 条 OCR 冲突数据做复核。
这段的意义不是“VLM 最终取代了标注”,而是我们开始意识到:训练数据本身需要交叉验证。一个 OCR 给出的标签不能直接当真,最好能经过多模型投票、VLM 复核或人工抽检。
更要命的是,交叉验证过程中还挖出了一个致命数据 bug。
标注脚本和训练脚本对 3 个框的排序方式不一致:
- 自动标注时,3 个框按置信度排序生成
box_chars。 - 训练裁剪时,3 个框按 x 坐标从左到右排序。
这导致同一组三个字,在标注文件里和训练输入里不是同一个顺序。后来检查发现,428 条 GLM 数据里大量 correct_order 都是错的,甚至那份 80 条早期 GT 也有 69/80 条不等于 prompt。
最后我们把规则重新钉死:
点选验证码的正确点击顺序就是 prompt 顺序。提示里写什么,
correct_order就应该是什么。
于是批量修正为 correct_order = prompt,并废弃了不可信的旧 GT。
这一步很重要。因为它解释了一个很诡异的现象:模型训练准确率很好,但真实泛化很差。并不全是模型弱,有一段时间数据标签本身就是错的。
第四阶段:手搓排序模型,第一次看见希望
检测解决后,难点转到排序。
当时的核心思路还是不做完整 OCR,而是做一个“提示字 - 候选字”的匹配模型。
也就是:
- query:把提示字用本地字体渲染成图片。
- reference:从验证码大图里 crop 出来的真实字。
- 模型输出:query 和 reference 是不是同一个字。
这就是后来的 DualSorter / Siamese 排序模型方向。
一开始模型效果也不行。后来有几个关键变化让结果突然变好:
- 不再过度依赖错误的预处理。
- 改用更接近真实输入的
raw_crop。 - 使用真实标注数据,而不是只靠合成数据。
- 加强训练策略和 TTA voting。
这一轮之后,数字开始很好看:
raw_crop + real GT做到约91.2%。- SorterV2 在当时 80 张留出验证集上做到
80/80 = 100%。
这对当时的判断影响很大。因为系统终于从“完全不靠谱”变成了“看起来要成了”。
但现在回头看,这也是第一次被小验证集欺骗。
第五阶段:端到端打通,比模型还麻烦
模型在 notebook 或脚本里跑通,不等于真的能在浏览器里用。
接下来开始接油猴脚本、本地服务和浏览器点击链路。这里的问题完全不优雅,但每一个都能让系统失效:
- 油猴脚本如何把截图和提示发给本地服务?
- 浏览器拦不拦
localhost请求? - CORS 和 Private Network Access 怎么处理?
- Python GUI 会不会被 YOLO 推理卡死?
- 截图坐标、裁剪坐标、页面坐标、点击坐标是不是同一个坐标系?
- 后端返回的框是相对截图,还是相对页面?
后来这些问题逐个修掉:
- 油猴侧用
GM_xmlhttpRequest绕过普通页面请求限制。 - 本地服务加
Access-Control-Allow-Private-Network。 - YOLO/OCR 推理放到 subprocess worker,避免 GUI 阻塞。
- 坐标转换统一,修掉点击偏移。
- 后端服务稳定在
localhost:8888。
这一阶段第一次形成了真正端到端链路:
油猴脚本截图 -> 本地后端检测识别 -> 返回坐标 -> 浏览器自动点击。
当时系统已经能完成真实页面识别,甚至有过连续成功案例。那时我们以为主要问题已经解决,剩下就是继续增强排序模型。
第六阶段:继续堆 Sorter,结果越练越怀疑
真实样本一多,SorterV2 的问题开始暴露:泛化很差。
于是我们继续往模型上堆东西。
SorterV6 用了更复杂的结构:
- asymmetric Siamese
- CrossAttention
- Sauvola 预处理
- 更多真实数据
- oversampling
- 更复杂的训练和评估
本地评估仍然很好看:
- V6 某些评估能到
100% - benchmark 约
97.4%
但真实浏览器场景还是掉。甚至有阶段真实准确率低于 50%。
为了不再被训练集和小验证集骗,我们冻结了一批新的真实样本作为 hidden 集:
- 约 33 张图
- 99 个单字 crop
- 不参与训练,只做检验
hidden 集一上,问题就清楚了:
- V6/V8 大约只有
45.5% - 更大的 V7/V10 反而只有约
21.2%
这非常打击人,但也很有价值。它说明问题不是“模型还不够大”,也不是“排序 loss 不够精巧”。
模型在训练分布里学会了某种相似度,但没有学到足够稳定的中文字符表征。人眼觉得两个字差很多,小模型的 embedding 空间里未必分得开。
这一步基本宣告:继续手搓排序模型,投入产出比很差。
第七阶段:我们开始承认,这其实是 OCR 问题
在很长一段时间里,我们其实都在绕开 OCR。
原因很简单:完整中文 OCR 看起来太重,而验证码每次只有 3 个提示字,似乎没必要做全字库识别。
但 Sorter 的失败说明了一件事:
如果模型连“这个 crop 到底像哪个汉字”都表征不稳定,排序算法再聪明也救不了。
于是路线开始转向 OCR。
先看过 CnOCR 一类模型。结果在 hidden crop 上 margin 仍然不理想。它比我们手搓模型更像 OCR,但在这个场景下还不够强。
真正的转折是 PP-OCRv5。
我们新建了 Paddle 环境,跑 hidden 集评估,并解决了一堆 Paddle/PaddleX 的本地缓存问题。一开始静态引擎还有兼容性问题,后来切到 dynamic 路线继续评估。
结果一下子不一样了:
PP-OCRv5_server_rec单字识别:94/99 = 94.95%- 三字全对:
28/33 = 84.85% - 加提示候选约束后:
33/33 = 100%
mobile 版也不错:
PP-OCRv5_mobile_rec单字识别:91/99 = 91.92%- 三字全对:
26/33 = 78.79% - 加提示候选约束后:
32/33 = 96.97%
这组数字基本说明:之前最大的问题不是排序,而是视觉表征骨干不够强。PP-OCRv5 靠大规模预训练学到的中文识别能力,是几百张验证码样本手搓不出来的。
第八阶段:提示约束,才是这个场景的外挂
PP-OCRv5 本身已经强,但我们这个任务还有一个特殊优势:提示文字已知。
页面不是让我们识别任意汉字,而是告诉我们“请依次点击 A、B、C”。所以 OCR 不需要在全字库里自由输出,只需要在这 3 个提示字附近做选择。
这就是 constrained decode 的价值。
普通 OCR 可能把某个字识别成形近字,但如果这一轮提示只有 3 个目标字,约束解码就能把输出拉回业务范围内。
后面我们把 PP-OCR softmax logits 的提示约束解码接进 GPU worker。hidden 集完整测试变成:
33/33张图全对99/99个字符全对- 热路径延迟约
57ms级别
这时路线才真正稳定下来:
YOLO 找框,PP-OCRv5 识字,提示约束纠偏。
第九阶段:GPU 跑通后,再补 CPU 开源路径
GPU 路线能跑很快,但公开项目不能假设每个人都有可用 GPU。
所以后来又补 CPU 路径:
- CPU 跑 PaddleOCR。
- GPU 跑 PaddleOCR。
- CPU 路径启动多个 OCR worker。
- 三个候选 crop 并行识别。
- 后端支持
auto/gpu/cpu模式。 - CPU worker 数按核心数自动配置。
CPU 热路径大致测到:
server_rec约870msmobile_rec约695ms
它比 GPU 慢很多,但已经不是不能用。对于开源版本,这条路很重要:有 GPU 就自动走 GPU,没有 GPU 就走 CPU parallel。
最后形成的系统
现在这套系统的心智模型已经和最早完全不同。
最早我们想做的是:
把验证码字抠出来,然后用自己写的模型判断它像哪个提示字。
现在变成:
用 YOLO 稳定定位,用成熟 OCR 识别,用提示文字做强约束,再用工程手段保证浏览器链路稳定。
当前主线可以概括成:
- 油猴脚本截取验证码和提示文字。
- 本地后端接收请求。
- YOLO 检测 3 个候选字框。
- PP-OCRv5 识别每个 crop。
- 提示候选约束修正 OCR 输出。
- 返回点击坐标。
- 油猴脚本按顺序点击。
这条路最大的教训
这次开发最容易误判的地方,是小样本验证集。
我们曾经多次看到“100%”,但那些 100% 只能说明模型吃透了当时那批样本,不代表真实浏览器里新来的验证码也能过。
比小样本更危险的是错标签。GLM-OCR 和 VLM 交叉验证那段最大的价值,就是让我们开始审计数据来源,而不是默认“文件里写着 GT 就一定是真的”。后来 correct_order 的 bug 说明,数据管线错一次,后面再复杂的模型都会认真学习错误答案。
真正让判断变可靠的,是冻结 hidden 集,并且把错误拆开看:
- 是 YOLO 没框到?
- 是 crop 错了?
- 是坐标偏了?
- 是 OCR 认错?
- 是提示约束没处理好?
- 还是浏览器状态和截图不一致?
另一个教训是,不要把所有问题都塞给一个手搓模型。
手搓排序模型不是没价值。它帮我们理解了任务,也逼出了数据、评估和端到端链路。但最终它不该承担“学会复杂中文字符视觉表征”这个任务。这个任务更适合交给 PP-OCRv5 这种成熟 OCR 骨干。
真正有效的工程路线,是把自研部分放在我们有优势的地方:
- 采集和标注真实验证码数据。
- 训练稳定的 YOLO 检测器。
- 设计提示候选约束。
- 做好本地服务、油猴脚本和 CPU/GPU 自动切换。
而不是用几百张样本硬练一个中文 OCR。
所以这条路线从“手搓一切”走到最后,并不是放弃自研,而是分清楚了哪些值得自己做,哪些应该借助成熟模型。