HSV 蒙版 + 局部 OCR——别动不动就全屏 OCR

游戏自动化里 OCR 是个又爱又恨的东西。

爱的是它”啥都能读”——按钮文字、对话内容、分数、排名,丢进去给你识别个七七八八。 恨的是它。一张 1920×1080 的全屏截图扔给 OCR 引擎,CPU 单线程上要花 200~500ms。每次决策都跑一遍,UI 卡得跟 PPT 似的。

更糟的是——全屏 OCR 召回一堆你根本不关心的文字。游戏里到处都是漂浮的提示、装饰文字、状态栏,OCR 全识别出来,你再写正则去过滤,吃力不讨好。

正经的做法是先把”目标在哪儿”框出来,再对那个小区域做 OCR。一个 30×100 像素的区域,OCR 大概 10~20ms,差一个数量级。

问题是——目标在哪儿?

这就是这篇要聊的主角:HSV 颜色蒙版

为什么是 HSV,不是 RGB

游戏 UI 里很多元素颜色高度一致——分数的橙色字、排名的蓝色背景条、警告的红色边框。这些”颜色明确”的元素是天然的”信标”。

直接在 RGB 空间挑颜色阈值很难——光照、Alpha 混合、压缩都会让 R/G/B 三个分量同时漂移。比如同样一抹”游戏里的橙色”,在不同截图里可能是 (255, 165, 0)、(248, 158, 12)、(250, 170, 5) 这种小幅波动,写 RGB 范围特别难调。

HSV 空间就友好得多:

  • H(色相):颜色本身,最稳定的维度
  • S(饱和度):颜色鲜艳程度
  • V(明度):颜色明暗

游戏里的橙色 H 大概在 [10, 25] 这个区间,S 和 V 给一个宽范围(比如 [100, 255] 和 [100, 255])就能稳稳框住。不管亮一点暗一点,H 不太会跑出去。

转换一步到位:

1
2
3
4
import cv2

hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv, (10, 100, 100), (25, 255, 255))

mask 是个二值图——目标颜色的像素是白,其他是黑。

从蒙版到 ROI

光有蒙版还不够,你要的是”位置”,不是”颜色像素”。

接下来通常这几步:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 1. 形态学清理:去噪点、连通断裂区域
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)

# 2. 找轮廓
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

# 3. 过滤掉面积太小的(多半是噪点)
contours = [c for c in contours if cv2.contourArea(c) > 100]

# 4. 拿每个轮廓的外接矩形
rois = [cv2.boundingRect(c) for c in contours]

每个 roi 就是一个 (x, y, w, h)——目标颜色在画面里的一块区域。

接下来对每个 ROI 切一小块送 OCR:

1
2
3
for (x, y, w, h) in rois:
crop = img[y:y+h, x:x+w]
text = ocr_engine.infer(crop).text

一张全屏 OCR 200ms+,换成”HSV 蒙版定位 + 几个小区域 OCR”,总耗时通常压到 50ms 以内,而且只读你关心的内容,不混进噪音

一个具体例子

举个项目里的真实场景——竞技场结算后要读排名:

画面上排名的数字是橙色背景白字,整张画面到处都是其他文字(玩家昵称、分数、奖励列表)。全屏 OCR 会把所有文字一锅端,你还得从结果里猜哪个是排名。

我们的做法(简化版):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 步骤 1:HSV 蒙版抓"排名背景的橙色"
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv, ORANGE_LOWER, ORANGE_UPPER)

# 步骤 2:形态学清理
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, KERNEL)

# 步骤 3:取连通区域,按面积排序,最大的就是排名背景
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
contours = sorted(contours, key=cv2.contourArea, reverse=True)
if not contours:
return None # 没找到,可能不在这个画面
x, y, w, h = cv2.boundingRect(contours[0])

# 步骤 4:扩一圈,给 OCR 留点 padding
pad = 5
x, y, w, h = max(0, x-pad), max(0, y-pad), w+2*pad, h+2*pad

# 步骤 5:对这小块做 OCR
crop = img[y:y+h, x:x+w]
result = ocr_engine.infer(crop)
rank = parse_rank(result.text)

这套流程跑完十几毫秒,准确率比全屏 OCR 高得多——因为输入就只有”排名”那一块。

对应项目里的代码可以看 src/utils/contest_overlay_tools.py,思路一致,多了几层鲁棒性兜底。

几个常见坑

坑一:HSV 颜色范围调不准

老老实实拿真实截图调。OpenCV 自带个交互工具不算好用,最方便的还是写个小脚本——加滑动条,实时看蒙版效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
def nothing(x): pass

cv2.namedWindow("tuner")
cv2.createTrackbar("H_min", "tuner", 0, 179, nothing)
cv2.createTrackbar("H_max", "tuner", 179, 179, nothing)
# S/V 同理

while True:
h_min = cv2.getTrackbarPos("H_min", "tuner")
# ... 拿到所有阈值
mask = cv2.inRange(hsv, (h_min, s_min, v_min), (h_max, s_max, v_max))
cv2.imshow("mask", mask)
if cv2.waitKey(1) == 27: break

拖几下滑动条,立马看到颜色范围对不对。比闷头猜数字快十倍。

项目里有个 devtools/hsv_tools.py 就是这个用途,专门用来快速调阈值。

坑二:颜色范围要带样本验证

调出来一组阈值,绝对不能只在一张图上验证就上线

游戏里不同场景的同种颜色可能略有差异——白天关卡和夜晚关卡的”橙色按钮”,HSV 值能差出去十几度。同一种 UI 元素至少要在 10~20 张不同场景截图上验证,确保都能稳定召回。

我们的规矩是:新加一个 HSV 蒙版规则,必须附带至少 10 张样本测试,跑过才能合进主线。

坑三:颜色冲突

游戏里有时候多个不同 UI 元素用了相近的颜色。HSV 蒙版会同时召回所有——你以为找到了 A,结果是 B。

解决办法不止一个:

  • 加形状约束:A 是横长条,B 是方块——用宽高比过滤
  • 加位置约束:A 总在屏幕上半部分——直接对 y 范围加限制
  • 加面积约束:A 比 B 大很多——按面积区间过滤
  • 多颜色融合:A 的标志性边缘有蓝色,B 没有——两个蒙版做与运算

实战里通常至少两个约束叠加才稳,单一颜色蒙版很容易误识别。

坑四:截图压缩带来的颜色漂移

如果截图来源经过 JPG 压缩(比如某些投屏方案),颜色会有可见的偏移和锯齿。HSV 阈值得放宽,宁可多召回一些再过滤。

更稳的做法是在管线源头就避免 JPG——能拿 PNG / 原始 numpy 数组就别走 JPG。但有时候是底层方案限制,没得选,那就阈值放宽 + 后续约束严格。

什么时候还是该用全屏 OCR

不是所有场景都能 HSV 蒙版。有几类情况老老实实全屏 OCR:

  • 目标颜色没有显著特征——比如普通对话框的黑字白底,颜色不独特
  • 目标位置完全不可预测——比如某个事件提示可能出现在屏幕任意位置
  • 第一次见的新页面,还没分析过 UI 结构——先全屏 OCR 拿到所有文本,分析完再写局部规则

但这些场景应该是少数。主流程的每一步都该有”先框定区域再 OCR”的优化,留全屏 OCR 给真正必要的地方。

收个尾

写自动化项目时间长了你会发现,“性能优化”这个话题往往不是优化代码本身,而是减少不必要的工作量

OCR 跑全屏,单次 200ms;HSV 蒙版定位后跑局部,10ms。差 20 倍。

模型本身没变,参数也没调,就是”先用便宜的方法收窄范围”这一个小改动。

这种思路在视觉自动化里到处适用——能用 YOLO 框定的就别让 CLIP 全图比对;能用形状判断的就别让 OCR 读字;能用本地缓存的就别每次去查服务端。让昂贵操作只对必要数据生效,是这类项目最重要的工程嗅觉。

下次写到 ocr_engine.infer(full_screen_img) 之前,先问一下自己——能不能用 HSV / YOLO / 颜色统计先收一收?多半能。

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