你以为 OCR 慢?实测 81.3 s 里全是故障代价:如何把「定格态」消掉,让耗时从 6.5 s 到 13 倍差距显出原形

5 阅读8分钟

你如果盯着一次只读探针的耗时发愁,可能会以为该优化 OCR:同一套代码、同一个引擎、同一台机器,一次 81.3 s,另一次 6.5 s。那一刻我卡在"到底该优化什么"上:你以为是识别慢,结果诊断下来是窗口已经不吃输入,每个闸门都在把超时全额烧掉。

差距摆在这:6.5 s 与 81.3 s

2026-10-15 单机实测,i5-10210U,DPI 100%,Python 3.13.12,OCR 用的是 RapidOCR 3.x 最快档(det SMALL + rec TINY)。同一张图、同一段代码路径,两种状态下的全程耗时:

  • 健康态:连跑 3 轮 --dry,7.22 s / 6.31 s / 6.48 s,中位 6.48 s,每轮 1 候选、0 跳帧。
  • 故障态(定格):81.31 s,0 候选、跳帧 3 次。单轮 42.2 s 和 38.3 s,两轮叠加。

约 13 倍差距。历史成功投递最佳是 6.03 s——也就是说 81 s 不是"这台机器就这么慢",而是同一条路径被拖进了故障态。

先别急着优化:定格态把成本放大在哪里

定格时窗口不吃输入,poll_render 的闸门预言永不为真,于是每一轮轮询都要付一次完整 OCR,并且把 timeout 全额烧掉,而不是提前退出。源文件给的一次失败账单:约 21 次 OCR / 尝试。其中搜索框过闸一条就占 18–27 s(3 次粘贴重试 × 每次约 3.0 s),结果过闸 5–6 s,再加退化候选和屏幕抓取兜底各约 1.3 s。

换成健康态,全套流程的 OCR 调用总数只剩 6 次,且每轮还能靠"跳帧"省掉若干轮。**耗时等于"单个环节有多慢"乘上"失败路径把单次成本放大了几倍"。**前者你优化不动多少,后者才是那 13 倍。

报错坑:它不会告诉你"抢不到前台"

第一次修的时候,最浪费时间的是报错完全对不上真实原因。raise_main() 有一条必经崩的 bug:suppress_foreign_occluders(hwnd) 返回的是 list,代码却写成 print("... %d ..." % n),触发 TypeError 直接把脚本崩掉。真实原因其实是「抢不到前台」,但报错信息和它毫不相干。只要"抢前台失败 → 升级最小化遮挡窗"这条分支被走到,整次投递就结束了,而你盯着 TypeError 去查格式化字符串。

修法很直白:% n 改成 len(n)。教训是——看到崩溃信息,先确认它和业务的原因链是不是同一个。

终于是那个超时的改动:2.5 → 7.0

标题核验用 poll_render(..., timeout=2.5),但点击后第一次标题条 OCR 本身就要 2.0–3.2 s。跑完一轮即超时,等于只给一次识别机会。点击后标题条恰好在重绘帧被读到 ''(时序问题,不是语义问题),直接判死,然后换词重搜,白烧约 12 s(实发 34.97 s 对干跑 11.26 s)。

改动就一行,下面是把"超时预算要覆盖至少两次识别机会、命中即早退"固化下来的写法(无绝对路径,可直接贴):

import time

TITLE_OCR_LOW, TITLE_OCR_HIGH = 2.0, 3.2   # 实测单次标题条 OCR 区间
TIMEOUT = 7.0                              # 原来 2.5,跑完一轮就超时
POLL_GAP = 0.3

def wait_title(read_fn, is_hit, timeout=TIMEOUT, gap=POLL_GAP):
    """轮询读标题;命中立即返回,超时才罢休。
    保证 >=2 次机会:timeout 至少覆盖 2 * TITLE_OCR_HIGH 加轮询间隔。"""
    assert timeout >= 2 * TITLE_OCR_HIGH + gap, "超时预算不够两次机会"
    deadline = time.monotonic() + timeout
    attempts = 0
    while True:
        attempts += 1
        text = read_fn()                   # 返回 None 表示"没读清",继续轮询
        if text is not None and is_hit(text):
            return text, attempts          # 命中即早退
        if time.monotonic() >= deadline:
            return None, attempts          # 只失败路径付费
        time.sleep(gap)

这段的关键不是"加大超时更保险",而是把 None(没读清)和"确认不是目标"分开:前者继续等,后者立即失败。否则加长超时只会让每次失败更贵。

可照做的判据

  • 先量出故障态的耗时,再决定优化目标。 81.31 s 和 6.48 s 都是同机同时段的 --dry 记录,先拿到这两个数,才谈得上优化哪里。
  • 单次成本超时预算至少值两倍。 单次 OCR 实测 2.0–3.2 s,timeout=2.5 就是"只给一次机会",改成 7.0 之后连 3 轮实测中位 6.48 s。
  • 报错和真实原因对不上时,先往上追一层调用栈。 TypeError 来自 print 的格式化,真实原因是 raise_main() 抢不到前台,改 len(n) 就结束。
  • 超时改长不等于变快。 本轮改动本身对耗时几乎无影响(删掉的 esc() 只在 attempt>1 重试轮跑,单次最多省 0.65 s 的 sleep)。6.3–6.5 s 是"窗口不再被自己打坏"之后自然收敛回基准。

结论

**13 倍差距(81.3 s → 6.5 s)不是靠优化代码换来的,而是"故障态被消除"。**代码层面剩下的只有超时预算修正和崩掉的 print,这两项加起来还到不了那 13 倍。

回头再看这条排查路径,它说明的东西比一个具体数字更耐用:当同一段代码在两个时间点跑出 13 倍差,先怀疑环境状态,再怀疑算法。定格态的特征是"Win32 全部正常、但屏幕上的内容不再刷新、输入也不落地"——源文件里量到的是取图、点击、注入各通道都 0.000%,而 IsWindowEnabled/Visible=True、WM_NULL 0.1–0.2 ms 秒回,CPU 24 s 内只增加 0.109 s。窗口看起来活着,呈现链却已经断了。判别真定格和假阴性也有据可依:真定格是连悬停都严格 0.000%,只要悬停分大于 0 就说明在出帧,不能只凭"打字 0.000%"判死——重启后右侧没开会话时输入框不存在,打字 0.000% 只是假阴性。

另有一条同源纪律:像素对比前必须校验最上层窗口归属,否则会在别人的窗口上拍到 90%+ 的虚假变化。这条和"13 倍"放在一起看,指向同一件事——测到的数先要确认是在测你以为的那个东西。

以上均为 2026-10-15 单机实测,i5-10210U,DPI 100% 的结果,不代表其他机器或其他 OCR 配置的速度。

数字出处

以下数字逐个对应 reports/OCR与坐标耗时归因_2026-10-15.md 的原句。

  • 81.3 s / 6.5 s / 13 倍:「结论:13 倍差距(81.3 s → 6.5 s)不是靠优化代码换来的,而是"故障态被消除"。」(§7.7)
  • 81.31 s:「故障期(定格态) | 81.31 s | 0 候选 / 跳帧 3 次」(§7.7 对照表);「[t= 81.31s] search#2: 输入+结果OCR 完成 (0 候选, 跳帧3次)」「搜索/定位联系人失败」「← 总计 81.3 s」(§2.3 日志)
  • 6.31–7.22 s,中位 6.48 s:「第 1 轮 … 7.22 s」「第 2 轮 … 6.31 s」「第 3 轮 … 6.48 s」「中位 … 6.48 s」(§7.7 表);「修复后(健康态) | 6.31–7.22 s(中位 6.48 s)」(§7.7 对照表)
  • 单轮 42.2 s / 38.3 s:「← 单轮 42.2 s」「← 单轮 38.3 s」(§2.3)
  • 6.03 s(历史成功投递最佳):「历史成功投递最佳 | 6.03 s」(§7.7 对照表);「历史记录的成功投递 TOTAL = 6.03 s。」(§2.3)
  • ≈ 21 次 OCR / 尝试:「≈ 21 次 OCR / 尝试。poll_render 的「像素没变就跳过 OCR」优化在搜索下拉的动画面前完全失效」(§2.3);「全额烧 timeout(≈21 次 OCR/轮)」(§7.7)
  • 搜索框过闸 18–27 s,每次约 3.0 s,3 次粘贴重试:「poll_render(shot_box, box_text, timeout=4.0) ×3 次粘贴重试 | ~6–9 | ~3.0 s | 18–27 s」(§2.3 表)
  • 结果过闸 5–6 s、退化候选与屏幕兜底各 1.3 s:「结果过闸 … | ~4–5 | ~1.3 s | 5–6 s」「退化最佳候选 … | 1 | ~1.3 s | 1.3 s」「屏幕抓取兜底 … | 1 | ~1.3 s | 1.3 s」(§2.3 表)
  • OCR 调用总数 6 次:「OCR 调用总数=6(跳帧已省若干轮)」(§7.8);「6 次(故障期同一动作 ≈21 次/轮 × 多轮)」(§7.8 表)
  • 超时 2.5 → 7.0:「已修:timeout 2.5 → 7.0(保证 ≥2 次机会;命中即早退,只失败路径付费)。」(§6.3)
  • 单次标题条 OCR 2.0–3.2 s:「poll_render(..., timeout=2.5) 中,点击后第一次标题条 OCR 就要 2.0–3.2 s」(§6.3);「实测:360×76 的标题条要 2.0–3.2 s」(§2.2)
  • 白烧约 12 s、34.97 s 对 11.26 s:「⇒ 白烧 ~12 s(实发 34.97 s vs 干跑 11.26 s)。」(§6.3)
  • TypeError / len(n):「print("... %d ..." % n) ⇒ TypeError 直接把脚本崩掉,报错与真实原因(抢不到前台)毫不相干。」「已修:len(n)。」(§四.1)
  • 单次最多省 0.65 s 的 sleep:「删掉的 esc() 只在 attempt>1 的重试轮才跑,单次最多省 0.65 s 的 sleep」(§7.7)
  • i5-10210U / DPI 100% / Python 3.13.12:「机器:i5-10210U / DPI 100% / 托管 Python 3.13.12」(文件头);「2026-10-15 21:18–21:45」(文件头取证时间)
  • RapidOCR 3.x 最快档(det SMALL + rec TINY):「OCR 引擎:RapidOCR 3.x + PP-OCRv6,det SMALL + rec TINY(即当前 RapidOCR 最快档)」(文件头)
  • 窗口活着但不出帧 / 假阴性判据 / 虚假变化:「点击 / Unicode 直注 / Ctrl+V / PostMessage(WM_CHAR、WM_LBUTTONDOWN、WM_PAINT、SC_MINIMIZE) | 全 0.000%」「IsWindowEnabled/Visible=True … WM_NULL 0.1–0.2 ms 秒回」「CPU | 24 s 内 user+kernel 仅 +0.109 s」(§7.2 表);「真定格是悬停也严格 0.000%(整窗完全不动);只要悬停 > 0 就说明在出帧」「重启后右侧没有打开任何会话时,输入框不存在,「点输入框 + 打 q」必然 0.000% —— 这是假阴性」(§7.6);「_diag_resize.py 曾在 (254,197) 拍到 WorkBuddy,产出 90%+ 的虚假变化。」(§7.5)
  • 闸门全额烧 timeout:「闸门预言永不为真时,timeout 被全额烧掉而不是提前退出。」(§2.3);「定格时窗口不吃输入 ⇒ 每一轮 poll_render 的闸门预言永不为真 ⇒ 全额烧 timeout」(§7.7)