起因
原本一行代码的事情,但是在Mac上总能出现意外,如下
box = pyautogui.locateOnScreen('obsidian.png')
print(box)
pyautogui.moveTo(box[0],box[1])
上面的代码用来定位图片在屏幕中的位置,然后移动鼠标到定位到的屏幕位置坐标。
意外的结果如下:
排除的情况:
- 图片相对路径是一定没错的(左边目录结构同级)
- 截图真实存在屏幕中
看了很多人(window用户)定位图片简简单单一行代码搞定,为什么到我这里就无限报错呢,所以模版匹配(定位图片)到底是怎么回事?
理解pyautogui定位原理
locateOnScreen源码
Note:(本质只有两件事情)
screenshot(region=None)
截屏locate
携带截屏数据和目标图片数据以及多值字典定位图片
截屏源码
因为pillow版本大于6并且没有指定region所以pyautogui截屏本质是调用底层库ImageGrab得到的。
# pyautogui 截屏
screenshotIm = screenshot(region=None)# 本质等于
im = ImageGrab.grab()
定位源码
Note:
- 默认就是底层调用opencv进行定位
- 如果没有指定grayscale,默认就是灰度图
- 核心定位方法为cv2.matchTemplate即opencv的模版匹配
所以pyautogui定位图片的调用本质是opencv的模版匹配,那么opencv的模版匹配原理是什么呢?
理解opencv模版匹配
理解计算机中的图片
我们知道颜色是由三原色RGB(红色Red,绿色Green,蓝色Blue)组成,一张图片就是由给定的像素点组成,通常我们说图片大小为100*100
就是横着存在100个像素点,竖着存在100个像素点,而每个像素点记录一个RGB值,当像素越密集,我们看到的图片就越清晰。
因此对于彩色图而言,如果图为100*100
意味着存在矩阵R[],矩阵G[],矩阵B[]大小为100行100列,分别记录每个像素点的二进制表示,也就是0~255。
由于二进制可以进行十六进制转换,因此一个像素点通常也可以表示为两种模式:
- RGB:比如RGB(255, 255, 255)
- 十六进制:比如0XFFFFFF
如果图片是灰度模式,则不需要三个通道RGB记录,只需要亮度即可,也就是说在灰度图中,记录一张图片的信息只需要每个像素点的亮度值表示即可!即一个二维数组
总结:在计算机中图片由很多的像素点组成,其中每个像素点在计算机中以二进制形式进行存储,可以是彩色模式,也可以是灰度模式。
理解模版匹配
给定一个图片,如何在屏幕中(源图)中定位给定图片的位置呢?这就是模版匹配。
我们先看一下屏幕的表示
0,0 X increases -->
+---------------------------+
| | Y increases
| | |
| 1920 x 1080 screen | |
| | V
| |
| |
+---------------------------+ 1919, 1079
在屏幕中我们以左上角为坐标轴原点(0, 0),分辨率(1920*1080
)表示在横轴X上存在1920个像素点(pixel),在纵轴Y上存在1080个像素点。
那我要查找某个图片,图片肯定涵盖多个像素点,比如我们截屏(100*100
),我们称这个为盒子(box),匹配一张图片最起码需要两张大小相同的图进行对比,那我们就需要在屏幕上截取大小相同的图然后进行像素点的差值比较,最后计算整个像素的差异,就可以得到匹配的相关性数据了。
比如原图大小(100*100
)即10000个像素点,需要查找的图(10*10
)即100个像素点,那么就需要从坐标原点(0,0)截取长宽为10的一个盒子和原图进行比较,怎么比较呢?就是每个像素点依次做差值,这样就可以判断像素颜色是否相近,最后得到100个像素点的差值,最后进行计算得到一个相关性系数,范围(0~1)。
如果原图大小为(W, H)需要查找的目标图大小为(w, h),那么在X轴方向就需要移动(W-w+1)次,也就是这么多像素点,同理Y轴方向为(H-h+1)次,所以针对上面的情况,进行匹配将会得到结果大小为91*91
的数组结果,其中每个元素代表区块的匹配程度,显然我们只需要最接近的那个区块,也就是结果中R[0,0,…,0,1]中最后的一个数字。
代码验证
Note:
- 图片对比需要保证两张图片大小相同,即像素点一样才具有可比性。
- 因此原图中查找的基本思路就是从原点开始逐个像素点移动得到长宽一样的图进行比较。
- 匹配的思路是每个像素点的差值大小记为相关性,在每个位置都能得到一个相关系数,因此匹配的结果大小一定是
(W-w+1)*(H-h+1)
为什么100*100
的原图查找10*10
X轴到90就可以了?*
因为再移动就出原图边界了,这样得到的原图块大小和目标图不一致,无法比较! 因为我们得到这些数据后,只需要记录左上角坐标(90, 90)即可。
opencv模版匹配方法的使用
文档链接
基本定义
cv.matchTemplate(image, templ, method[, result[, mask]]) ->result
其中参数:
- image:表示原图数据,如果彩色则是三维数组,如果灰度图则为二维数组
- templ:需要查找的图数据,同上,图片需要数组表示
- method:模版匹配的算法,就是做差值时候怎么得到相关系数的算法
- cv.TM_SQDIFF
- cv.TM_SQDIFF_NORMED
- cv.TM_CCORR
- cv.TM_CCORR_NORMED
- cv.TM_CCOEFF
- cv.TM_CCOEFF_NORMED (pyautogui底层默认的算法)
使用
# 屏幕截屏, 默认是RGBA模式,也可以直接用pyautogui.screenshot()底层就是下面代码
screen = ImageGrab.grab()
# 加载图片为数组数据,指定灰度图,也可通过 Image.open('obsidian.png') 加载图片,但是同上是图片对象,非数组RGBA模式
target_img = cv2.imread('obsidian.png', cv2.IMREAD_GRAYSCALE) # 将屏幕图片转为数组,并且模式为灰度图
screen_img = cv2.cvtColor(np.array(screen), cv2.COLOR_RGBA2GRAY) result = cv2.matchTemplate(screen_img, target_img, cv2.TM_CCOEFF_NORMED)
# 从所有的相关系数结果中找到最大最小值,以及坐在的坐标位置
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result) print(min_val, max_val, min_loc, max_loc) # 结果
# -0.6216521859169006 0.637797474861145 (37, 10) (22, 102)
可以看到最大相关系数只有0.63,这个图片是我的图标默认存在的,这里是存在问题的
分析问题
分辨率对查找的影响
要知道我们使用pyautogui的目的是定位图片,获取图片在屏幕中所在的坐标位置,这里涉及三个东西:
- 目标图
- 原图(屏幕截图)
- 屏幕坐标位置
已知我的屏幕大小为1440*900
,那么给定原图为2880*1800
,那么我查找目标图时候怎么得到在屏幕中的坐标位置?比如目标刚好在右下角,难道得到坐标(2800,1720)?显然这么走鼠标都要点击到屏幕外边去了。
# 从所有的相关系数结果中找到最大最小值,以及坐在的坐标位置
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
这行代码返回的max_loc对应上图就是(20,12)显然这是超出屏幕大小的,因此使用这个坐标点击屏幕肯定不行,一种思路是等比例计算在屏幕中的位置,即上图的坐标(10,6)
再看下面的问题
按照理论,查找的图片应该位于屏幕之外的位置,可是这里得到的结果却是完全错的,并且最大相关系数也才0.63,这是因为,原图比例等比例放大了一倍,但是查找的目标图并没有,这意味着,在计算机中存储的数值完全是不相关的!
我们可以通过放大目标图去匹配如下:
target_img = cv2.resize(target_img, (width*2, height*2))
结果如下:
可以看到相关系数和坐标都看似正常了,通过pyautogui移动鼠标位置基本正确。
解决问题
上面我们已经知道,截屏的尺寸问题会影响对图片的查找,这里的本质其实是:我的目标图要在原图的基础上截取! 如果我不使用Image.grab()
截屏而是直接手动截图存储然后查找就可以了。
方式一:手动截屏
# 加载目标图为数组数据
target_img = cv2.imread('obsidian.png', cv2.IMREAD_GRAYSCALE)
# 加载原图为数组数据
screen_img = cv2.imread('screenshot.png', cv2.IMREAD_GRAYSCALE) result = cv2.matchTemplate(target_img, screen_img, cv2.TM_CCOEFF_NORMED)
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
print(min_val, max_val, min_loc, max_loc)
结果图
可以看到最大相关系数为0.89,坐标也基本正确。
方式二:调整截屏大小
# 加载目标图为数组数据
target_img = cv2.imread('obsidian.png', cv2.IMREAD_GRAYSCALE)
# 截屏默认为RGBA
screen = ImageGrab.grab()
# numpy将图片转为数组数据
screen_tmp = np.array(screen)
# 将RGBA通道转为灰度图
screen_img = cv2.cvtColor(screen_tmp, cv2.COLOR_RGBA2GRAY)
print("截屏大小:" + str(screen_img.shape))
# 调整截屏大小为屏幕分辨率大小
width, height = pyautogui.size()
screen_img = cv2.resize(screen_img, (width, height))
print("调整后截屏大小:" + str(screen_img.shape)) result = cv2.matchTemplate(screen_img, target_img, cv2.TM_CCOEFF_NORMED)
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
print(min_val, max_val, min_loc, max_loc)
结果图
Note:
- 一定要确保通道转换正确,RGBA转GRAY
- 查看截图的图片模式可以通过Image.grab().mode查看
方式三:调整目标图大小
# 加载目标图为数组数据
target_img = cv2.imread('obsidian.png', cv2.IMREAD_GRAYSCALE) # 截屏默认为RGBA
screen = ImageGrab.grab()
# numpy将图片转为数组数据
screen_tmp = np.array(screen)
# 将RGBA通道转为灰度图
screen_img = cv2.cvtColor(screen_tmp, cv2.COLOR_RGBA2GRAY) # 等比例调整目标图大小
width_screen, height_screen = pyautogui.size()
height_original, width_original = screen_img.shape[:2]
# 计算调整比例
rate_width = width_original // width_screen
rate_height = height_original // height_screen
print(rate_width, rate_height) height, width = target_img.shape[:2]
target_img = cv2.resize(target_img, (width * rate_width, height * rate_height)) result = cv2.matchTemplate(screen_img, target_img, cv2.TM_CCOEFF_NORMED)
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result) # 坐标等比例换算
min_loc = min_loc[0] // rate_width, min_loc[1] // rate_width
max_loc = max_loc[0] // rate_height, max_loc[1] // rate_height
print(min_val, max_val, min_loc, max_loc)
结果图
总结: 出现问题的根因在于底层库截屏得到的屏幕图片分辨率和屏幕不一致导致的,解决办法也就是根据这个思路进行,推荐resize调整截屏大小,然后再进行定位图片就可以了。
关于为什么出现Mac截图分辨率放大一倍的解释,可以参考这条issue