Skip to content
📔 阅读量:

图像识别

图像识别在 UI 自动化中是不可缺少的,市面上甚至有完全基于图像识别的自动化测试框架,比如 AirtestSikuli 等,在游戏等特定领域也有不错的效果,这些工具实际上也是用的 OpenCV 进行了封装,YouQu 框架基于 OpenCV 开发了自己的图像识别功能,它可以方便的用于界面元素的定位和断言;

YouQu 的图像识别功能几乎满足了你的所有要求,我们在长时间的思考和摸索中,针对常规场景及一些特殊场景探索出了一些实用且有效的方案,且听我慢慢道来。

常规识别

【背景】

常规识别很好理解,一句话讲就是,要获取到目标元素在屏幕中的位置。

【原理实现】

在测试过程中需要获取的坐标是相对于整个屏幕的坐标,我们可以截取到整个屏幕的图片(screen);

在元素识别的过程中,我们需要截取某个元素的小图进行识别,比如截取播放按钮:

那么实际上,元素定位的问题就转换为,将截图的小图(play_btn)拿到整个屏幕的大图(screen)中去做匹配,如果匹配成功,返回小图在大图中的坐标( x, y )即可。

为了方便描述,以下我将整个屏幕的截图称为:大图,某个元素图片的截图称为:小图。

基于 OpenCV 的模板匹配 cv.matchTemplate() 功能,我们实现了图像定位的功能,框架提供了一个图像识别的底层接口(一般不对上层提供调用):

python
def _match_image_by_opencv(
    image_path: str, 
    rate: float = None, 
    multiple: bool = False, 
    picture_abspath: str = None, 
    screen_bbox: List[int] = None
):
    """
     图像识别,匹配小图在屏幕中的坐标 x, y
    :param image_path: 图像识别目标文件的存放路径
    :param rate: 匹配度
    :param multiple: 是否返回匹配到的多个目标
    :param picture_abspath: 大图,默认大图是截取屏幕,否则使用传入的图片;
    :param screen_bbox: 截取屏幕上指定区域图片(仅支持X11下使用);
        [x, y, w, h]
        x: 左上角横坐标;y: 左上角纵坐标;w: 宽度;h: 高度;根据匹配度返回坐标
    """
    # 详细代码太长不贴了,感兴趣请查看源码

【参数介绍】

(1)image_path

image_path 是小图的绝对路径;

  • 通常在 AT 工程里面,我们约定将用于元素定位的图片资源放到 widget/pic_res 目录下,图片的名称以实际的元素名称命名,如:play_btn.png

  • 用于用例断言的图片资源放到 case/assert_res 目录下,图片的名称以用例的名称命名,如:music_001.png

这样是为了方便管理和维护。

(2)rate

图像识别的的匹配度或者说相似度,框架默认的配置为 0.9,也就是说小图在大图中存在一个相似度 90% 的图标即返回其在大图中的坐标;

如果你在用例中需要调整识别度,你可以在调用函数的时候,传入不同的识别度的值。

(3)multiple

默认情况下 multiple=False,表示只返回识别到的第一个,如果 multiple=True 返回匹配到的多个目标,因为大图中可能存在多个相同的小图,在某些场景下你可能需要全部获取到所有匹配到的坐标。

(4)picture_abspath

默认情况下 picture_abspath=None 表示大图为截取的屏幕截图,如果你不希望大图是屏幕的截图,而是你自定义传入的某个图片,你只需要将你的图片路径传递给这个参数就行,比如: picture_abspath="~/Desktop/big.png"

(5)screen_bbox

大图默认情况下是截取整个屏幕,screen_bbox = [x, y, w, h] 可以指定截取屏幕中的固定区域,某些场景下,可以排除部分区域对识别结果的影响。

【隐式等待】

用例执行过程中进行图像识别时,有时候页面跳转有延时,有可能存在识别的那一刻页面也没有跳转出来,或者或者识别的那一刻;

因此我们需要一种等待机制,即在一定的时间内,如果识别不到,重复去识别:

python
def find_image(
    	cls,
        *widget, rate: [float, int] = None,
        multiple: bool = False,
        match_number: int = None,
        picture_abspath: str = None,
        screen_bbox: List[int] = None
):
    """
     在屏幕中区寻找小图,返回坐标,
     如果找不到,根据配置重试次数,每次间隔1秒
    :param widget: 模板图片路径
    :param rate: 相似度
    :param multiple: 是否返回匹配到的多个目标
    :param match_number: 图像识别重试次数
    :return: 坐标元组
    """
    if rate is None:
        rate = float(GlobalConfig.IMAGE_RATE)
    try:
        for element in widget:
            for _ in range((match_number or int(GlobalConfig.IMAGE_MATCH_NUMBER)) + 1):
                locate = cls._match_image_by_opencv(
                    element,
                    rate,
                    multiple=multiple,
                    picture_abspath=picture_abspath,
                    screen_bbox=screen_bbox
                )
                if not locate:
                    sleep(int(GlobalConfig.IMAGE_MATCH_WAIT_TIME))
                else:
                    return locate
        raise TemplateElementNotFound(*widget)
    except Exception as e:
        raise e

参数 match_number 用于控制重复识别的次数,默认不传参,取全局配置 setting/globalconfig.ini 里面的 IMAGE_MATCH_NUMBER 配置项的值,默认IMAGE_MATCH_NUMBER = 1,即重试 1 次;

find_image 是框架提供的常规图像识别函数接口,这个函数提供了隐式等待的功能,且包含上面介绍的 _match_image_by_opencv 函数的所有功能。

气泡识别

【背景】

气泡识别指的是,某些场景下要定位的元素是一些会消失的小弹窗,这类场景在用例执行过程中进行图像识别时就可能存在不稳定性,有可能图像识别的时候气泡已经消失了,也有可能气泡出现的时间太短了,不容易捕捉到,就像气泡一样,出现一下就消失,因此我们形象的称之为 “气泡识别”;

【原理实现】

为了能稳定的识别气泡类场景,我们采用的方案是:

在一段时间内(包含气泡从出现到消失),不停的截取这段时间内的大图,以此确保在截取的一堆图片中,肯定有至少一张图片能捕捉到气泡,最后再对这一堆图片逐个进行图像识别;

代码示例:

python
def get_during(
        cls,
        image_path: str,
        screen_time: [float, int],
        rate: float = None,
        pause: [int, float] = None,
        max_range: int = 10000
):
    """
    在一段时间内截图多张图片进行识别,其中有一张图片识别成功即返回结果;
    适用于气泡类的断言,比如气泡在1秒内消失,如果用常规的图像识别则有可能无法识别到;
    :param image_path: 要识别的模板图片;
    :param screen_time: 截取屏幕图片的时间,单位秒;
    :param rate: 识别率;
    :param pause: 截取屏幕图片的间隔时间,默认不间隔;
    :param max_range: 截图的最大次数,这是一个预设值,一般情况下不涉及修改;
    """

【参数介绍】

(1)screen_time

截取屏幕图片的时间,在此时间内会不断的进行截图操作,就像录制视频一样;

(2)pause

每次截取图片的间隔时间,默认情况下是一刻不停的截图,如果你想每次截图存在一些间隔时间传入对应的时间间隔即可,单位是秒,比如:pause = 0.03,表示 30 ms,相当于帧率为 30 帧;

不依赖 OpenCV 的图像识别方案

1. 自研图像识别技术

【原理】

为了实现识别图像的目的,我们可以通过将图片的每个像素的RGB值,与整个屏幕中的RGB进行对比,如果小图上的RGB值与对应大图位置的RGB都相等,则匹配成功,即可返回小图在大图中的中心坐标点。

读取小图和大图的RGB值

(1)小图的RGB值

shell
small_data = small_pic.load() 
# load()会将图片的RGB值获取到,数据格式为一个二维列表,赋值给一个变量small_data。

(2)大图的RGB值

shell
big_data = big_pic.load()

将小图与大图的RGB值进行匹配

(1)匹配从大图的坐标(0,0)开始匹配,匹配小图里面所有的坐标点(0,0)—(small_pic.width,small_pic.height);

(2)如果在大图的(0,0)对应的所有小图的RGB值不相等,则移动到下一个坐标点(1,0),同样匹配小图里面所有的坐标点(0,0)—(small_pic.width,small_pic.height);

(3)按照这样的规律将这一行每移动一个坐标点,都将小图所有的RGB与对应大图的值进行匹配;

(4)如果在大图的其中一个坐标点上匹配到了小图的所有RGB值,则此时返回小图在大图中的坐标点;

(5)如果匹配了大图所有的坐标点,都没有匹配到,则说明大图中不存在小图,匹配失败;

【代码实现】

python
class ImageRgb:

    @staticmethod
    def _check_match(_x, _y, small, bdata, sdata, rate):
        """
        Matching degree of small graph and large graph matching
        """

    @staticmethod
    def _pre_random_point(small):
        """
        Pre matching, take 10-20 points at random each time,
        and take coordinates randomly in the small graph
        """

    @staticmethod
    def _pre_random_match(_x, _y, point_list, bdata, sdata, rate):
        """
        In the small graph, several points are randomly
        selected for matching, and the matching degree is
        also set for the random points
        """

    @classmethod
    def match_image_by_rgb(cls, image_name=None, image_path=None, rate=0.9):
        """
        By comparing the RGB values of the small image with the large
        image on the screen, the coordinates of the small image on
        the screen are returned.
        """

通过 match_image_by_rgb() 这个函数,传入目标小图的文件名称,即可返回在当前屏幕中的中心坐标。

有同学要问了,有 OpenCV 干嘛不用,有必要自己实现一个图像识别的功能吗,你们是不是闲的啊?

这么问的话,小了,格局小了;我们自己实现主要有几方面原因:

  • 减少环境依赖,不用安装 OpenCV 我们也能实现其功能,环境依赖这块后面会单独详细讲,减少环境依赖对于任何软件工程都非常重要;
  • OpenCV 在其他国产 CPU 架构上安装并不能保证100%成功,甚至有没有可能在一些架构上压根儿就不能安装使用 OpenCV
  • 有没有可能有一天国内无法使用 OpenCV ?就像有没有可能有一天国内无法使用 Windows 呢?这些问题值得思考。

当然,我们承认这套方案,虽然识别准确率没问题,但在识别效率上还没有达到 OpenCV 模板匹配的效果,我们的方案每次识别在 1.5s 左右,而 OpenCV 1s 左右;

整体识别效果来讲,我认为还是可以接受的,也希望有志之士能一起优化此方案,一起技术报国。

2. 基于 RPC 服务实现图像识别

在远程服务器上部署 OpenCV 的环境,并将其部署为 RPC 服务,测试机上不用安装 OpenCV 依赖,而是通过请求 RPC 服务的方式进行图像识别;

【原理】

测试机截取当前屏幕图片以及模板图片,发送给 RPC 服务端,服务端拿到两张图片进行图像识别,最后将识别结果返回给测试机;

要特殊说明的是: RPC 是一种协议,许多语言都是支持的,比如说服务端也可以用 C++ 来实现,客户端使用 Python 也是可以调用的。

【代码实现】

服务端代码示意(Service):

python
from socketserver import ThreadingMixIn
from xmlrpc.server import SimpleXMLRPCServer

import cv2 as cv
import numpy as np

class ThreadXMLRPCServer(ThreadingMixIn, SimpleXMLRPCServer):
    pass

CURRENT_DIR = dirname(abspath(__file__))

def image_put(data):
    """上传图片"""

def _match_image_by_opencv(
    image_path: str, 
    rate: float = None, 
    multiple: bool = False, 
    picture_abspath: str = None, 
    screen_bbox: List[int] = None
):
    """
     图像识别,匹配小图在屏幕中的坐标 x, y
    :param image_path: 图像识别目标文件的存放路径
    :param rate: 匹配度
    :param multiple: 是否返回匹配到的多个目标
    :param picture_abspath: 大图,默认大图是截取屏幕,否则使用传入的图片;
    :param screen_bbox: 截取屏幕上指定区域图片(仅支持X11下使用);
        [x, y, w, h]
        x: 左上角横坐标;y: 左上角纵坐标;w: 宽度;h: 高度;根据匹配度返回坐标
    """
    
if __name__ == "__main__":
    server = ThreadXMLRPCServer(("x.x.x.x", 8889), allow_none=True)
    server.register_function(image_put, "image_put")
    server.register_function(match_image_by_opencv, "match_image_by_opencv")
    server.serve_forever()

这样,我们基于 Python 标准库 xmlrpc 搭建了一个 RPC 服务器,注册了 image_putmatch_image_by_opencv 两个功能接口,在测试机上可以通过 IP 和端口进行 RPC 请求;

客户端代码示意(Client):

python
from xmlrpc.client import Binary
from xmlrpc.client import ServerProxy

server = ServerProxy(GlobalConfig.OPENCV_SERVER_HOST, allow_none=True)
screen_rb = open(screen, "rb")
template_rb = open(template_path, "rb")
try:
    screen_path = server.image_put(Binary(screen_rb.read()))
    screen_rb.close()
    tpl_path = server.image_put(Binary(template_rb.read()))
    template_rb.close()
    return server.match_image_by_opencv(
        tpl_path, screen_path, rate, multiple
    )
except OSError as exc:
    raise EnvironmentError(
        f"RPC服务器链接失败. {GlobalConfig.OPENCV_SERVER_HOST}"
    ) from exc

通过返回 server.match_image_by_opencv 就获取了在服务端图像识别的结果。

动态图像识别

【背景】

在桌面壁纸切换,或看图、相册切换图片类的测试场景,由于你的测试资源是不固定的(不同版本的系统壁纸不同、壁纸顺序不同,看图相册在图片资源不一定固定),那么在测试切换壁纸或者切换图片的场景时就会存在一个问题,就是你不知道预期是啥,用例操作动态的,也是极不稳定。

【原理】

在切换图片之前截图保存并返回图片的路径,切换图片之后再次识别这张图片,如果不存在,说明图片已经切换了;

示意图:

这样,我们截取了当前图片中比较有代表性的位置(一只鸟),在切换图片之后再用这张小图在当前屏幕中进行图像识别:

我们再拿着这张小图在当前屏幕中进行图像识别,这样在当前图片中,就不能找到这只鸟了,图像识别的结果是 False,那么也就可以判断图片切换是成功的。

代码示意:

python
def save_temporary_picture(_x: int, _y: int, width: int, height: int):
    """
     截取屏幕上指定区域图片,保存临时图片,并返回图片路径
    :param x: 左上角横坐标
    :param y: 左上角纵坐标
    :param width: 宽度
    :param height: 高度
    :return: 图片路径
    """

此函数用于在操作之前截图一张临时图片,返回图片路径,最后在断言的时候再将图片路径作为参数传入断言语句即可;

代码示例: