你如果盯着一次只读探针的耗时发愁,可能会以为该优化 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)。
改动就一行,下面是把"超时预算要覆盖至少两次识别机会、命中即早退"固化下来的写法(无绝对路径,可直接贴):
python
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_NULL0.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)