在前篇文章中,我们介绍了最简单的一种自动化方法——记录坐标,按顺序点击。但是问题也显而易见:如果,坐标会变动呢?又如果,某对象的坐标虽然不会变动,但我们不确定它什么时候才会出现呢?本篇我们要介绍的是一种利用OpenCV的模板匹配方法,实现对固定模板图片匹配,从而确认屏幕上是否存在和模板图片相同的部分,并得到其所在的坐标。模板匹配方法相较于深度学习方案,一个显著的优点是轻量、易上手、无需训练,我们只要明白其原理,拿来即用(你甚至完全可以让AI封装好,无需了解原理直接使用)
matchTemplate方法简介
要在Python中使用OpenCV,我们先安装OpenCV:
pip install opencv-python
之后在程序中导入就可以:
import cv2
在PyCharm中,使用鼠标中键点击该函数,我们可以快速索引到库文件中函数的定义:
def matchTemplate(image: UMat, templ: UMat, method: int, result: UMat | None = ..., mask: UMat | None = ...) -> UMat: ...
- 📝注:虽然函数签名里有
result
和mask
,但在 Python 中使用时我们只需传入前 3 个参数,后两个一般由 OpenCV 内部处理,忽略即可。mask参数可以实现更高级的掩码功能,由于暂时没有用到,故不做介绍。
matchTemplate方法需要传入三个参数,第一个参数image为原图,即你要在上面进行模板匹配的源图像;第二个参数templ为模板图;第三个参数method是匹配方式,OpenCV提供了如下几种方法:
方法名 | 全称 | 匹配值意义 | 推荐指数 | 说明 |
---|---|---|---|---|
cv2.TM_SQDIFF | 平方差匹配 | 越小越相似 | ⭐ | 对亮度敏感,容易误判 |
cv2.TM_SQDIFF_NORMED | 归一化平方差 | 越小越相似 | ⭐ | 同上但归一化 |
cv2.TM_CCORR | 相关匹配 | 越大越相似 | ⭐⭐ | 简单粗暴,效果一般 |
cv2.TM_CCORR_NORMED | 归一化相关匹配 | 越大越相似 | ⭐⭐ | 比上一个更稳定 |
cv2.TM_CCOEFF | 相关系数匹配 | 越大越相似 | ⭐⭐⭐ | |
cv2.TM_CCOEFF_NORMED | 归一化相关系数 | 越大越相似 | ⭐⭐⭐⭐⭐ | 最常用、鲁棒性最好,亮度变化也能忍 |
推荐初学者默认使用 cv2.TM_CCOEFF_NORMED
,在多数场景下稳定且效果良好。
有兴趣的读者可以进一步了解这些方法背后的公式。例如我们要使用的TM_CCOEFF_NORMED
,它的计算公式为
当然,在实践中,我们更关心的是如何使用。接下来从三个参数讲起。
如何使用matchTemplate?
image参数
你要在这张图上搜索模板图的位置。有以下几点要求:
-
必须是 单通道灰度图(
np.ndarray
类型,dtype =uint8
) -
可以是彩色图,但你得手动转换为灰度图再传进去。例如:
img = cv2.imread("screenshot.png") img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
templ参数
模板图像,要求如下:
-
同样必须是 灰度图(不能是彩色图)
-
类型必须和
image
相同的数据类型,通常都是np.uint
-
要使用有足够多特征的图像,同时不要有多余的背景和噪声影响到模板图像的纯净度。这一点我们在后续举例中会讲解。
method参数
我们就使用 cv2.TM_CCOEFF_NORMED。
返回值:result
匹配结果矩阵
cv2.matchTemplate()
会返回一个二维的 numpy.ndarray
,我们可以叫它 result
或 res
,它的每个元素代表模板图左上角放在原图该位置时的匹配分数。
你可以把它想象成是:模板在原图左上角顶点开始,从左往右,再从上往下“滑动”过去的过程中,每一个位置都算一个“相似度打分”,所有分数就拼成了这个二维矩阵。
result
的每个点,对应原图上一个可能的模板左上角位置。
综上,我们可以例如这样调用matchTemplate方法:
result = cv2.matchTemplate(img_gray, template, cv2.TM_CCOEFF_NORMED)
举个栗子,我们还是要匹配部落战的图标,这次是有进攻机会的图标,它会有点点不一样。首先看全屏截图:
……
……
……
唔,刚好手上没有正好有部落战进攻机会的账号,没法截图,不过没关系啦!都是小事,把上次的图偷过来,再放我之前截好的模板图像好啦。
需要锚定的是图中这个位置的图标。
我们截图。在1920*1080分辨率下,截出来的模板图像应该是这样:
请注意看,这里我只截了这一小部分,甚至右上角都没截全,但是这个特征已经足够区别于没有进攻机会的按钮(在后续魔改matchTemplate方法的img_match函数中,你只需要把阈值调高一些就是啦)。没有多截的另一个原因是,由于躲开了背景的无关像素,模板图像就能确保准确性,从而确保模板匹配的成功率。模板图像截取的重点和原则是:
- 宁可少截,也不要截取到无关像素点;同时要确保截取的图像的特征能确保它在屏幕上是唯一的(除非,你真的遇到了有多个相同图标的场景,不过在这个游戏里我目前还没遇到过)。
当然,注意力惊人的读者应该注意到了我特意说明该模板图像是在1080p下截取的。也就是说,假设一个模板图像在1920*1080分辨率下是100*100像素,那在3840*2160分辨率下它应该是200*200像素(横竖分辨率都翻倍)!这可如何是好?
resize方法:针对不同分辨率的标准化
如果我们只想截一次图,但是能在不同分辨率下都能跑,那我们需要的就是cv2.resize方法(我们在最早技术总览的序篇中也有所提及,没有读过的读者可以自行补上)。OpenCV 提供了 resize
函数来改变图像大小,我们照例先看函数签名:
def resize(src: UMat, dsize: cv2.typing.Size | None, dst: UMat | None = ..., fx: float = ..., fy: float = ..., interpolation: int = ...) -> UMat: ...
其中:
-
src
:原图像 -
dsize
:目标尺寸,格式为 (width, height) -
interpolation(可选参数)
:插值算法,决定了缩放的“画质”,默认使用cv2.INTER_LINEAR
就够用了,平衡速度与效果,也可以不指明。
resize方法实际上就是帮助我们把源图像插值为指定分辨率的图像,因此我们可以使用它解决分辨率不同带来的问题。还是如上文的例子,一个截图大图,一个模板小图,我们要把哪个拿来resize?
我选用的是将屏幕截图resize成1920*1080,因为我们知道模板图像是在某个分辨率下的截图(我都是在1080p下截取的),而我们不知道模板图像在某个分辨率下实际大小是多少,如果用截图分辨率除以实际分辨率,再乘上模板图像大小得到实际大小,那就太麻烦了!而且也不能保证正确。
有了resize以后,我们就能确保在不同分辨率下,程序大概率也能运行正常了。
截图的实现
最后我们再简要介绍一下在Python中实现屏幕截图的方法。
- pyautogui实现
- pillow库实现
可以直接使用pyautogui.screenshot函数实现截图操作,代码如下:
import pyautogui
screenshot = pyautogui.screenshot()
screenshot.save("screenshot.png")
或者pillow库(即PIL),可以用它内置的 ImageGrab
模块:
from PIL import ImageGrab
img = ImageGrab.grab()
img.save("screenshot.png")
由于这里是全屏的截图,因此不传入任何参数,两者也是完全等价的。但在后续需要截取指定区域时,两者的参数格式会稍有不同,需要留意。不过这里还没用到,就先不介绍了。别问我怎么知道的,因为我就踩过坑(哭)。
可用的模板匹配函数:把所有功能组合起来!
我们只需要把上述所有功能组合起来,就能搭建出一个可用的模板匹配函数了——实际上你可以看出,matchTemplate的返回值并不是该模板图像出现的坐标或范围。
# img_match.py
import cv2
import numpy as np
import pyautogui
import time
def img_match(template_path, threshold=0.75, mode='point'):
"""
截图,尝试匹配模板图像,失败return False,成功return坐标
默认模式 'point': 返回模板图像的中心点坐标
'range' 模式: 返回模板图像的左上角坐标和右下角坐标
"""
start_time = time.perf_counter()
# 截图
screenshot = pyautogui.screenshot()
# 读取模板图像,并转换为灰度
template = cv2.imread(template_path, 0)
# 将PIL图像转换为OpenCV格式
screenshot = np.array(screenshot)
screenshot = cv2.cvtColor(screenshot, cv2.COLOR_RGB2BGR)
# 获取屏幕截图的尺寸
screenshot_height, screenshot_width = screenshot.shape[:2]
# 将屏幕截图调整为1920x1080分辨率
target_width = 1920
target_height = 1080
resized_screenshot = cv2.resize(screenshot, (target_width, target_height))
# 将调整后的截图转换为灰度
gray_screenshot = cv2.cvtColor(resized_screenshot, cv2.COLOR_BGR2GRAY)
# 调用matchTemplate函数
result = cv2.matchTemplate(gray_screenshot, template, cv2.TM_CCOEFF_NORMED)
# 获取最佳匹配位置
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
# 检查匹配结果是否足够好
if max_val < threshold:
print(f'\033[31m失败 {template_path}, 系数{max_val}, 用时 {time.perf_counter() - start_time}\033[0m')
return False # 如果匹配结果不够好,返回False
if mode == 'point':
# 计算匹配区域的中心点坐标
center_x = max_loc[0] + template.shape[1] // 2
center_y = max_loc[1] + template.shape[0] // 2
# 将坐标转换回原始截图的分辨率
original_center_x = int((center_x / target_width) * screenshot_width)
original_center_y = int((center_y / target_height) * screenshot_height)
# 返回中心点坐标
print(f'成功 {template_path}, 系数{max_val}, 用时 {time.perf_counter() - start_time}')
return original_center_x, original_center_y
elif mode == 'range':
# 计算匹配区域的坐标
start_x = max_loc[0]
start_y = max_loc[1]
end_x = max_loc[0] + template.shape[1]
end_y = max_loc[1] + template.shape[0]
# 将坐标转换回原始截图的分辨率
original_start_x = int((start_x / target_width) * screenshot_width)
original_start_y = int((start_y / target_height) * screenshot_height)
original_end_x = int((end_x / target_width) * screenshot_width)
original_end_y = int((end_y / target_height) * screenshot_height)
# 返回图标所在区域的坐标(左上角和右下角)
return (original_start_x, original_start_y), (original_end_x, original_end_y)
所有模板图像都要在指定分辨率下截取,这段代码里我给的是1080p。建议把截取好的模板图像分门别类地存放在特定文件夹中,便于查找。在使用时,只需调用img_match并传入模板图像路径,就可以了。
比如我们要在屏幕上匹配有进攻机会的部落战打开按钮(就是前面的示例),可以这样写(首先你要有截好的模板图像!):
from img_match import img_match
# "img/clan_war/open_clan_war.png"是我存储该模板图像的相对路径
clan_war_button = img_match("img/clan_war/open_clan_war.png", threshold=0.95)
if clan_war_button:
pyautogui.click(clan_war_button)
这样就能在屏幕上匹配并点击该按钮了。
在这一节中,我们完整地从模板匹配函数开始,到实现一个可以直接调用的匹配函数的完整流程。后续的章节中,我们会介绍一些游戏内具体行为的实现(比如进攻中的“一字划”,或者进攻中的资源识别,还有流派的搭配),并在我基于PaddleOCR开发完成好自动升级的模块后,也会逐一介绍原理和实现。教程不定期更新,点个关注不迷路,谢谢(୨୧• ᴗ •͈)◞︎ᶫᵒᵛᵉ ♡