Files
glm-coding-helper/docs/captcha_model_journey.md
T

14 KiB

从手搓验证码识别到 YOLO + PP-OCRv5:一次中文点选验证码开发历程

这篇不是安装说明,而是一次开发过程复盘。

项目一开始要解决的问题很直接:GLM Coding Plan 页面出现中文点选验证码,提示里给出 3 个汉字,图里也藏着 3 个汉字。脚本要自动识别这 3 个字的位置,然后按顺序点掉。

听起来像一个小问题:找字、认字、点击。但真正做起来,它把检测、图像处理、字体差异、中文 OCR、小样本泛化、浏览器坐标和本地服务工程问题全部串在了一起。

前半段我们几乎一直在“手搓”:手搓图像处理,手搓检测数据,训练 YOLO,手搓排序模型,手搓各种相似度和增强。一路确实越来越强,但也一路撞上了小样本模型的天花板。最后真正稳定下来,反而是把一部分自研模型换成成熟 OCR,再用业务提示做强约束。

第一阶段:先别管 OCR,能不能把字抠出来?

最早的想法不是直接 OCR。

因为验证码提示已经告诉了我们要点哪 3 个字,所以当时的问题被理解成:

我不需要从全字库里认出每个字,我只需要判断图里的 3 个候选字,分别对应提示里的哪 3 个字。

这样看起来难度小很多。于是第一版路线是传统视觉:

  1. 从截图里裁出验证码大图。
  2. 用颜色阈值、连通域、形态学操作把彩色汉字从背景里抠出来。
  3. 本地用字体把提示字渲染出来。
  4. 拿渲染模板和抠出来的候选字做相似度匹配。
  5. 在 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 约 870ms
  • mobile_rec 约 695ms

它比 GPU 慢很多,但已经不是不能用。对于开源版本,这条路很重要:有 GPU 就自动走 GPU,没有 GPU 就走 CPU parallel。

最后形成的系统

现在这套系统的心智模型已经和最早完全不同。

最早我们想做的是:

把验证码字抠出来,然后用自己写的模型判断它像哪个提示字。

现在变成:

用 YOLO 稳定定位,用成熟 OCR 识别,用提示文字做强约束,再用工程手段保证浏览器链路稳定。

当前主线可以概括成:

  1. 油猴脚本截取验证码和提示文字。
  2. 本地后端接收请求。
  3. YOLO 检测 3 个候选字框。
  4. PP-OCRv5 识别每个 crop。
  5. 提示候选约束修正 OCR 输出。
  6. 返回点击坐标。
  7. 油猴脚本按顺序点击。

这条路最大的教训

这次开发最容易误判的地方,是小样本验证集。

我们曾经多次看到“100%”,但那些 100% 只能说明模型吃透了当时那批样本,不代表真实浏览器里新来的验证码也能过。

比小样本更危险的是错标签。GLM-OCR 和 VLM 交叉验证那段最大的价值,就是让我们开始审计数据来源,而不是默认“文件里写着 GT 就一定是真的”。后来 correct_order 的 bug 说明,数据管线错一次,后面再复杂的模型都会认真学习错误答案。

真正让判断变可靠的,是冻结 hidden 集,并且把错误拆开看:

  • 是 YOLO 没框到?
  • 是 crop 错了?
  • 是坐标偏了?
  • 是 OCR 认错?
  • 是提示约束没处理好?
  • 还是浏览器状态和截图不一致?

另一个教训是,不要把所有问题都塞给一个手搓模型。

手搓排序模型不是没价值。它帮我们理解了任务,也逼出了数据、评估和端到端链路。但最终它不该承担“学会复杂中文字符视觉表征”这个任务。这个任务更适合交给 PP-OCRv5 这种成熟 OCR 骨干。

真正有效的工程路线,是把自研部分放在我们有优势的地方:

  • 采集和标注真实验证码数据。
  • 训练稳定的 YOLO 检测器。
  • 设计提示候选约束。
  • 做好本地服务、油猴脚本和 CPU/GPU 自动切换。

而不是用几百张样本硬练一个中文 OCR。

所以这条路线从“手搓一切”走到最后,并不是放弃自研,而是分清楚了哪些值得自己做,哪些应该借助成熟模型。