OCR 和 CLIP 怎么搭配——让识别器自己长记忆

游戏自动化要识别卡片,单用 OCR 不行,单用 CLIP 也不行。

OCR 准是准,慢;而且它的命根子是”卡面有清晰文字”——很多技能卡花体字带遮挡,识别率能给你跌成过山车。CLIP 倒是快,但前提是”见过”——头一次遇到的卡,记忆库里压根没特征,找个鬼。

后来灵光一闪,这俩其实可以串起来嘛:日常识别让 CLIP 顶着,识别不出来的,让 OCR 上去救场一次。OCR 拿到结果之后回头把这张图喂给 CLIP “学一下”——下次再遇到同样的卡,CLIP 直接就认出来了。

整个流程长这样:

1
2
3
4
5
6
7
8
9
截图 → YOLO 检测 → CLIP 尝试识别 → 命中? → 直接使用
↓ 未命中
OCR 识别文字

数据库匹配

让 CLIP 学习这张图

持久化到记忆库

CLIP 记忆库本身的工程细节我另写了一篇,这里专门讲两个东西的协作。

学习路径

完整的”学一张新卡”长这样:先让 CLIP 试一手,命中就跳过,没命中才轮到 OCR:

1
2
3
4
5
6
7
8
9
10
11
12
def learn_card(self, app, card_frame, card_list):
for card in card_list:
# 先试 CLIP
existing_id = self._try_clip_identify(app, card.frame)
if existing_id:
continue

# CLIP 不认识,OCR 上
learned_id = self._learn_via_ocr(app, card.frame)
if learned_id:
# OCR 找到了,让 CLIP 把这张图记下来
self.clip_manager.add_to_memory(card.frame, learned_id)

CLIP 识别那一段套了个 try/except——因为 CLIP 失败属于”正常情况”,库为空、相似度不够都会返回 None,没必要往外抛异常吓人:

1
2
3
4
5
6
7
8
def _try_clip_identify(self, app, card_frame):
try:
result = self.clip_manager.retrieve(card_frame)
if result is not None:
return result.payload.id
except Exception as e:
logger.debug(f"CLIP identify failed: {e}")
return None

OCR 这边稍微讲究点——不是把识别到的字直接当卡名(那也太天真了),而是拿去数据库里搜:

1
2
3
4
5
6
7
8
9
10
11
def _learn_via_ocr(self, app, card_image):
ocr_result = ocr_service.ocr(card_image)
if not ocr_result or not ocr_result.results:
return None

for item in ocr_result.results:
if len(item.text) >= 3:
status, db_result = database.search(item.text)
if status and db_result:
return db_result.id
return None

len(item.text) >= 3 这个限制不能少。OCR 看到图标、装饰元素也会硬识别,给你来一堆”●““◇”“+”这种单字符——要是不卡长度,数据库搜索分分钟被这些噪声淹没。三个字以上,基本上误识别就能挡掉大头。

运行时识别

学习路径主要发生在首次扫描或者版本更新那会儿。日常跑的时候走的是另一条道——CLIP 优先,没命中才回头去学一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def identify_element(self, screenshot, element_frame):
# 置信度够高就直接用
result = self.clip_manager.retrieve(element_frame)
if result is not None and result.similarity > 0.96:
return result.payload

# 没命中,那就触发一次学习
self.learn_element(screenshot, element_frame)

# 再试一次
result = self.clip_manager.retrieve(element_frame)
if result is not None:
return result.payload

return None

可能你会问:学完了为啥还要 retrieve 一次?

因为 learn_element 内部会判断 OCR 是不是成功——成功了才往 CLIP 里灌,失败就啥都不做。所以学完到底有没有可用特征,得靠下一次 retrieve 兜底确认。

几个权衡

阈值定在 0.96,意思就是模棱两可的边缘情况,统统按”未命中”处理。

这是故意的。错认比漏认代价高太多了——漏认顶多就是触发一次 OCR 兜底,慢个几百毫秒;错认就麻烦了,后面整个自动化流程都得跟着跑错路径,那是真的会出事。

OCR 的开销其实不小,单次几百毫秒打底。所以 CLIP 的命中率,直接决定了用户体验的快慢。新版本卡刚出那阵子,命中率会暴跌,体感就是”咋这么慢”。等用户跑过两三次自动化把记忆库灌满,速度自己就回来了。

还有个不太显眼的好处:因为 OCR 只在 CLIP 失手时才上场,可以放心用那些又慢又准的 OCR 引擎(比如 macOS 的 Vision),不用在”速度”和”识别质量”之间做痛苦取舍。

欢迎关注我的其它发布渠道