# 从手搓验证码识别到 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。 所以这条路线从“手搓一切”走到最后,并不是放弃自研,而是分清楚了哪些值得自己做,哪些应该借助成熟模型。