Airtest + 文字認識（OCR）

## はじめに
ソフトウェアテストの業界向けに役に立つ話、第二回目です。
前回に引き続き、 Airtest IDE という UI 自動化ソフトウェアを使用し、Android の自動化を行います。。今回は Airtest には備わっていない文字認識を実装し、組み合わせて使ってみました。

<img width="100" alt="スマホを持つ猫" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618790/5255d32a-a992-a69f-3281-3eb872aeae5f.png">


〜 前回 〜
Airtest を使ったAndroid アプリの自動操作
https://qiita.com/ids-kinoue/items/12bec45f319934afd476

## 前置き
文字認識（OCR）には、 Google Cloud の[Cloud Vision API](https://cloud.google.com/vision/docs?hl=ja)を使用します。（使用料がかかります）なお、 Google Cloud API の環境準備については長くなってしまうので、説明を省略します。

初めて使われる方は下記の記事を参考にしてください。

Google Cloud Vision API の OCR を使って Python から文字認識する方法
https://valmore.work/cloud-vision-api-ocr/

## アカウントキーの環境変数
Cloud Vision API を Python で使用する場合、環境変数**GOOGLE_APPLICATION_CREDENTIALS**に「サービス アカウント キー」を設定することが必要ですが、 Airtest IDE から実行する場合、設定した環境変数が呼び出せないようです。その為、プログラム内で環境変数を割り当てます。

例:

```python3
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "path/to/your/credentials.json"
```

## 事前準備
デフォルトの Airtest IDE では、 IDE に組み込まれた Python が使用されていますので、`pip`で追加することができません。

Options > Settings > ▼ Airtest を開き、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618790/fd879cb8-8eca-f1b9-cdde-fe5b7315f080.png)
ここにあなたの Python のパスを設定することで、普段使いの Python で動作させることができます。

`pip install google-cloud-vision`
`pip install airtest`
`pip install pillow`
`pip install numpy`
そして、今回使うパッケージをインストールします。

## 自動化する内容
今回も Airtest の GitHub にあるブラックジャックのサンプルアプリ「blackjack-release-signed.apk」を使用します。
https://github.com/AirtestProject/Airtest/tree/master/playground/blackjack_example
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618790/a2a89766-e84c-f90e-51b9-1c8d4573ce5c.png)

前回は画像をタッチしましたが、今回は文字を扱います。自動化する内容は以下です。

1. 現在の自分の所持金がいくらか取得します。
2. 所持金が【47000】以下になるまで、「1K」の画像をタッチし、お金をかけます。
3. 最後に「投注」という画像をタッチし、ゲームをはじめます。

### 所持金はどこ？
どのような方法で所持金を取得するか考えてみます。

画面上に数字は沢山ありますね。OCRで文字を取得出来ても、どの数字が自分の所持金なのか、わかりません。なので、どこに所持金があるのかを知る必要があります。大抵のゲームでは、**所持金の近くにはお金を示すアイコンがあります。**

よって、お金のアイコンの画像を探し、その近くにあるテキストを取り出すことで所持金を取得するという手が考えられます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618790/e6bc4571-1b96-0929-2014-5a19b55f1fd2.png)
このように画像を軸とすれば、所持金の位置が変わってもちゃんと取得できそうです。また、お金のテキストが左側にある場合も考えられるので、方向を選べるようにします。

## お金を取り出すクラス

```python3
import io
import os
import numpy as np
from PIL import Image
from google.cloud import vision
from airtest.core.cv import Template
from airtest.core.helper import G
from airtest.aircv.utils import cv2_2_pil


class AirOCR:
    def __init__(self, key: str):
        self.key = key
        self._set_env()
        self.client = vision.ImageAnnotatorClient()

    def _set_env(self) -> None:
        """Google Cloudの「サービス アカウント キー」を環境変数に設定します。"""

        if not os.path.isfile(self.key):
            raise Exception("Googleの認証キーが見つかりませんでした。")
        else:
            os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = self.key

    def get_money(self, image: Template, position: str = "right", image_scale: int = 1.5) -> str:
        """所持金のテキストを取得します。"""

        money_image_coordinate = self._get_image_coordinate(image)
        content = self._get_window_capture()

        bounds, texts = self._get_text(content=content)
        txt_pos = self._get_best_text_coordinate(bounds, position)

        img_width, img_height = self._get_size(money_image_coordinate)

        add_height = (img_height * image_scale) - img_height
        add_width = (img_width * image_scale) - img_width

        img_center_x = self._get_center(money_image_coordinate["x"])
        img_center_y = self._get_center(money_image_coordinate["y"])

        hit_index = []

        LEFT_TOP = 0
        LEFT_BOTTOM = 1
        RIGHT_BOTTOM = 2
        RIGHT_TOP = 3

        for idx, (x, y) in enumerate(txt_pos):
            if position == "left" and \
                    x <= img_center_x and \
                    y >= money_image_coordinate["y"][LEFT_TOP] - add_height and \
                    y <= money_image_coordinate["y"][LEFT_BOTTOM] + add_height:

                hit_index.append(idx)

            if position == "right" and \
                    x >= img_center_x and \
                    y >= money_image_coordinate["y"][RIGHT_TOP] - add_height and \
                    y <= money_image_coordinate["y"][RIGHT_BOTTOM] + add_height:

                hit_index.append(idx)

            if position == "top" and \
                    x >= money_image_coordinate["x"][LEFT_TOP] - add_width and \
                    x <= money_image_coordinate["x"][RIGHT_TOP] + add_width and \
                    y <= img_center_y:

                hit_index.append(idx)

            if position == "bottom" and \
                    x >= money_image_coordinate["x"][LEFT_TOP] - add_width and \
                    x <= money_image_coordinate["x"][RIGHT_BOTTOM] + add_width and \
                    y >= img_center_y:

                hit_index.append(idx)

        hit_txt_pos = [(x, y) for i, (x, y) in enumerate(txt_pos) if i in hit_index]
        img_center = [(self._get_center(money_image_coordinate["x"]), self._get_center(money_image_coordinate["y"]))]

        best_idx = self._find_nearest_index(origin=img_center, target=hit_txt_pos)
        nearest_idx = hit_index[best_idx]

        if not texts[nearest_idx]:
            raise Exception("対象画像の近くにテキストが見つかりませんでした。")
        else:
            return texts[nearest_idx]

    def _get_text(self, content: bytes) -> (list, list):
        """デバイス画面をVision APIに与え、画面上の全てのテキストを取得します"""

        image = vision.Image(content=content)
        response = self.client.text_detection(image=image)
        document = response.full_text_annotation

        bounds = []
        text = []
        texts = []

        for page in document.pages:
            for block in page.blocks:
                for paragraph in block.paragraphs:
                    for word in paragraph.words:
                        for symbol in word.symbols:
                            text.append(symbol.text)

                bounds.append(block.bounding_box)
                texts.append(''.join(text))
                text = []

        return bounds, texts

    def _get_image_coordinate(self, image: Template) -> dict:
        """デバイス画面から画像を探して、4点座標を辞書で返します。"""

        screen = G.DEVICE.snapshot(filename=None)
        result = image.match_all_in(screen)

        image_coordinate = {}

        if result:
            image_coordinate["x"] = [c[0] for c in result[0]["rectangle"]]
            image_coordinate["y"] = [c[1] for c in result[0]["rectangle"]]
        else:
            raise Exception("所持金のイメージが見つかりませんでした。")

        return image_coordinate

    def _get_best_text_coordinate(self, bounds: list, position: str) -> list:
        """認識したテキストの内、最適なテキストを選択します。"""

        LEFT_TOP = 0
        RIGHT_TOP = 1
        RIGHT_BOTTOM = 2
        LEFT_BOTTOM = 3

        coordinate = []

        for bound in bounds:
            x_list = [
                bound.vertices[LEFT_TOP].x,
                bound.vertices[RIGHT_TOP].x,
                bound.vertices[RIGHT_BOTTOM].x,
                bound.vertices[LEFT_BOTTOM].x
            ]
            y_list = [
                bound.vertices[LEFT_TOP].y,
                bound.vertices[RIGHT_TOP].y,
                bound.vertices[RIGHT_BOTTOM].y,
                bound.vertices[LEFT_BOTTOM].y
            ]

            if position == "right":
                x = min(x_list)
                y = self._get_center(y_list)
                coordinate.append((x, y))

            elif position == "left":
                x = max(x_list)
                y = self._get_center(y_list)
                coordinate.append((x, y))

            elif position == "top":
                x = self._get_center(x_list)
                y = min(y_list)
                coordinate.append((x, y))

            elif position == "bottom":
                x = self._get_center(x_list)
                y = max(y_list)
                coordinate.append((x, y))

        return coordinate

    def _get_center(self, coordinate: list) -> int:
        """座標の中心を返します。"""

        return sum(coordinate[0:4]) / 4

    def _get_size(self, coordinate: dict) -> (int, int):
        """横幅と高さを返します。"""

        width = max(coordinate["x"]) - min(coordinate["x"])
        hight = max(coordinate["y"]) - min(coordinate["y"])

        return width, hight

    def _get_window_capture(self) -> bytes:
        """接続されているデバイスのイメージを取得します。"""

        cv2_image = G.DEVICE.snapshot(filename=None)
        pil_image = cv2_2_pil(cv2_image)
        bytes_image = self._pil_2_bytes(pil_image)

        return bytes_image

    def _pil_2_bytes(self, pil_image: Image) -> bytes:
        """PIL.Imageをbytesに変換します。"""

        bytes_image = io.BytesIO()
        pil_image.save(bytes_image, format='PNG')
        bytes_image = bytes_image.getvalue()

        return bytes_image

    def _find_nearest_index(self, origin: tuple, target: list) -> int:
        """ヒットしたテキストの中からもっとも距離が近い対象のindexを返します。"""

        target_np = np.array(target)
        origin_np = np.array(origin)

        line_segment = [np.linalg.norm(t - origin_np) for t in target_np]
        idx = np.where(min(line_segment) == line_segment)
        best_index = int(idx[0])
        return best_index

```

<details><summary>docstring あり版（長いので畳みました。）</summary><div>

```python3
import io
import os
import numpy as np
from PIL import Image
from google.cloud import vision
from airtest.core.cv import Template
from airtest.core.helper import G
from airtest.aircv.utils import cv2_2_pil


class AirOCR:
    def __init__(self, key: str):
        """
        Parameters
        ----------
            key : str
                Google Cloud Platformの「サービス アカウント キー」のファイルパス
                キーのタイプ: JSON
                例: "path/to/your/credentials.json"
        """
        self.key = key
        self._set_env()
        self.client = vision.ImageAnnotatorClient()

    def _set_env(self) -> None:
        """
        Google Cloudの「サービス アカウント キー」を環境変数に設定します。
        """
        if not os.path.isfile(self.key):
            raise Exception("Googleの認証キーが見つかりませんでした。")
        else:
            os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = self.key

    def get_money(self, image: Template, position: str = "right", image_scale: int = 1.5) -> str:
        """
        所持金のテキストを取得します。

        Parameters
        ----------
            image : Template
                所持金のイメージが入った Airtest の Template クラス
            position : str
                所持金のイメージの中心点に対して、テキストがある位置
                    (left, right, top, bottom)
            image_scale : int
                所持金のイメージが小さすぎる場合に判定範囲を広げるために使用する。
                テキストが左右にある場合、所持金のイメージの高さが調整されます。
                テキストが上下にある場合、所持金のイメージの横幅が調整されます。

        Returns
        ------
            str
                Vision APIで認識したテキストの内、条件に適合したテキスト。

        Raises
        ------
            Exception
                所持金のイメージの近くにテキストが見つからなかった場合。
        """

        money_image_coordinate = self._get_image_coordinate(image)
        content = self._get_window_capture()

        bounds, texts = self._get_text(content=content)
        txt_pos = self._get_best_text_coordinate(bounds, position)

        img_width, img_height = self._get_size(money_image_coordinate)

        add_height = (img_height * image_scale) - img_height
        add_width = (img_width * image_scale) - img_width

        img_center_x = self._get_center(money_image_coordinate["x"])
        img_center_y = self._get_center(money_image_coordinate["y"])

        hit_index = []

        LEFT_TOP = 0
        LEFT_BOTTOM = 1
        RIGHT_BOTTOM = 2
        RIGHT_TOP = 3

        for idx, (x, y) in enumerate(txt_pos):
            if position == "left" and \
                    x <= img_center_x and \
                    y >= money_image_coordinate["y"][LEFT_TOP] - add_height and \
                    y <= money_image_coordinate["y"][LEFT_BOTTOM] + add_height:

                hit_index.append(idx)

            if position == "right" and \
                    x >= img_center_x and \
                    y >= money_image_coordinate["y"][RIGHT_TOP] - add_height and \
                    y <= money_image_coordinate["y"][RIGHT_BOTTOM] + add_height:

                hit_index.append(idx)

            if position == "top" and \
                    x >= money_image_coordinate["x"][LEFT_TOP] - add_width and \
                    x <= money_image_coordinate["x"][RIGHT_TOP] + add_width and \
                    y <= img_center_y:

                hit_index.append(idx)

            if position == "bottom" and \
                    x >= money_image_coordinate["x"][LEFT_TOP] - add_width and \
                    x <= money_image_coordinate["x"][RIGHT_BOTTOM] + add_width and \
                    y >= img_center_y:

                hit_index.append(idx)

        hit_txt_pos = [(x, y) for i, (x, y) in enumerate(txt_pos) if i in hit_index]
        img_center = [(self._get_center(money_image_coordinate["x"]), self._get_center(money_image_coordinate["y"]))]

        best_idx = self._find_nearest_index(origin=img_center, target=hit_txt_pos)
        nearest_idx = hit_index[best_idx]

        if not texts[nearest_idx]:
            raise Exception("対象画像の近くにテキストが見つかりませんでした。")
        else:
            return texts[nearest_idx]

    def _get_text(self, content: bytes) -> (list, list):
        """
        デバイス画面をVision APIに与え、画面上の全てのテキストを取得します

        text_detectionの詳しい説明は以下のfullTextAnnotationの説明を参照してください。
        https://cloud.google.com/vision/docs/fulltext-annotations?hl=ja

        Parameters
        ----------
        content : bytes
            Vision APIに渡すイメージ

        Returns
        -------
            bounds : list
                [
                    <class 'google.cloud.vision_v1.types.geometry.BoundingPoly'>,
                    <class 'google.cloud.vision_v1.types.geometry.BoundingPoly'>,
                    ...
                ]
                BoundingPolyクラスはテキストの矩形の四隅の座標点を示します。
                vertices { x: 1 y: 2} vertices { x: 1 y: 2} vertices { x: 1 y: 2} vertices { x: 1 y: 2},

                ↓ それぞれリストに入っています。
                vertices[0], vertices[1], vertices[2], vertices[3]

                ↓
                vertices[0].x, vertices[0].y

            texts : list
                ['テキスト1', 'テキスト2', ...]
        """
        image = vision.Image(content=content)
        response = self.client.text_detection(image=image)
        document = response.full_text_annotation

        bounds = []
        text = []
        texts = []

        for page in document.pages:
            for block in page.blocks:
                for paragraph in block.paragraphs:
                    for word in paragraph.words:
                        for symbol in word.symbols:
                            text.append(symbol.text)

                bounds.append(block.bounding_box)
                texts.append(''.join(text))
                text = []

        return bounds, texts

    def _get_image_coordinate(self, image: Template) -> dict:
        """
        デバイス画面から画像を探して、4点座標を辞書で返します。

        Parameters
        ----------
            image : Template
                所持金のイメージが入った Airtest の Template クラス

        Raises
        ------
            Exception
                所持金のイメージが見つからなかった場合。

        Returns
        ------
            image_coordinate : dict
        """
        screen = G.DEVICE.snapshot(filename=None)
        result = image.match_all_in(screen)

        image_coordinate = {}

        if result:
            image_coordinate["x"] = [c[0] for c in result[0]["rectangle"]]
            image_coordinate["y"] = [c[1] for c in result[0]["rectangle"]]
        else:
            raise Exception("所持金のイメージが見つかりませんでした。")

        return image_coordinate

    def _get_best_text_coordinate(self, bounds: list, position: str) -> list:
        """
        認識したテキストの内、最適なテキストを選択します。

        Parameters
        ----------
            bounds : list
            position : str

        Returns
        ------
            coordinate : list
        """
        LEFT_TOP = 0
        RIGHT_TOP = 1
        RIGHT_BOTTOM = 2
        LEFT_BOTTOM = 3

        coordinate = []

        for bound in bounds:
            x_list = [
                bound.vertices[LEFT_TOP].x,
                bound.vertices[RIGHT_TOP].x,
                bound.vertices[RIGHT_BOTTOM].x,
                bound.vertices[LEFT_BOTTOM].x
            ]
            y_list = [
                bound.vertices[LEFT_TOP].y,
                bound.vertices[RIGHT_TOP].y,
                bound.vertices[RIGHT_BOTTOM].y,
                bound.vertices[LEFT_BOTTOM].y
            ]

            if position == "right":
                x = min(x_list)
                y = self._get_center(y_list)
                coordinate.append((x, y))

            elif position == "left":
                x = max(x_list)
                y = self._get_center(y_list)
                coordinate.append((x, y))

            elif position == "top":
                x = self._get_center(x_list)
                y = min(y_list)
                coordinate.append((x, y))

            elif position == "bottom":
                x = self._get_center(x_list)
                y = max(y_list)
                coordinate.append((x, y))

        return coordinate

    def _get_center(self, coordinate: list) -> int:
        """
        座標の中心を返します。

        Parameters
        ----------
            coordinate : list

        Returns
        ------
            int
        """
        return sum(coordinate[0:4]) / 4

    def _get_size(self, coordinate: dict) -> (int, int):
        """
        横幅と高さを返します。

        Parameters
        ----------
            coordinate : dict

        Returns
        ------
            width : int
            hight : int
        """
        width = max(coordinate["x"]) - min(coordinate["x"])
        hight = max(coordinate["y"]) - min(coordinate["y"])

        return width, hight

    def _get_window_capture(self) -> bytes:
        """
        接続されているデバイスのイメージを取得します。

        Returns
        -------
            bytes_image : bytes

        Notes
        -----
        cv2では、RGBからBGRに変換されるので、RGBに戻しています。
            PIL: RGB
            cv2: BGR
        """
        cv2_image = G.DEVICE.snapshot(filename=None)
        pil_image = cv2_2_pil(cv2_image)
        bytes_image = self._pil_2_bytes(pil_image)

        return bytes_image

    def _pil_2_bytes(self, pil_image: Image) -> bytes:
        """
        PIL.Imageをbytesに変換します。

        Parameters
        ----------
            pil_image : Image (PIL.Image)

        Returns
        -------
            bytes_image : bytes
        """
        bytes_image = io.BytesIO()
        pil_image.save(bytes_image, format='PNG')
        bytes_image = bytes_image.getvalue()

        return bytes_image

    def _find_nearest_index(self, origin: tuple, target: list) -> int:
        """
        ヒットしたテキストの中からもっとも距離が近い対象のindexを返します。

        ----------
        Parameters
            origin : tuple
            target : list

        Returns
        -------
            best_index : int
        """
        target_np = np.array(target)
        origin_np = np.array(origin)

        line_segment = [np.linalg.norm(t - origin_np) for t in target_np]
        idx = np.where(min(line_segment) == line_segment)
        best_index = int(idx[0])
        return best_index

```
</div></details>


思ったより、長くなってしまいましたが、前述のお金を取り出すOCR クラスを実装してみました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618790/21ff3c1b-6947-d2af-a92b-9f34cb24b38b.png)

お金のイメージの高さの1.5倍の範囲に入っている最も近いテキストを取り出すように書き、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618790/d3bbfd85-10e3-fb14-f733-2c22bc65896a.png)
方向は引数で指定できるようにしました。

## 書いたモジュールの呼び出し
```python3
from airtest.core.api import using, touch
from airtest.core.cv import Template
using("./ocr_test")
from ocr import AirOCR
```
Airtest IDE では別ファイルにある自分で書いた Python ファイルをそのまま呼び出せないようです。`airtest.core.api`に用意されている[using関数](https://airtest.readthedocs.io/en/latest/README_MORE.html#import-from-other-air)を使用して呼び出します。

今回は「ocr_test」というディレクトリを作成し、その中に先ほど作ったクラスを`ocr.py`という名前で保存しました。

## 不要な文字を消す

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618790/92c8d97f-ca66-d0df-5bd6-87c7b915da72.png)

```
50000+
```
所持金を取得すると、横にある「+」ボタンまで文字として取り出されてしまいました。

```python3
if "+" in money:
    money = money.replace("+", "")

money = int(money)
```
これはいらないので、`replace`で消し、int に変換します。

## コード全文
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618790/bca45098-da71-b29a-0603-3ec4f495b602.png)

## 結果
![gamen_test1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618790/bd166bad-e8cb-7611-d06e-20bf61e78973.gif)
OCR の部分の処理が遅いですが、ちゃんと所持金を扱った自動化ができました。