「画像認識ウマ娘イベント選択肢チェッカー」をPythonで作るチュートリアル

# はじめに
ウマ娘、流行ってますね ~~（ひと波超えた感はありますが）~~。
特に流行りがピークだった4月頃にこんなツールを見かけて、「自分もいつかこんなツールが作れるようになりたい…！」と思った方も多いのではないでしょうか。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">自動でDMM版ウマ娘のウィンドウから、イベント選択肢の効果を表示するツールを作った<br><br>ダウンロードはこちらから<a href="https://t.co/V8DRfoBaDg">https://t.co/V8DRfoBaDg</a><br>readmeはこっち<a href="https://t.co/yQJiDMbhza">https://t.co/yQJiDMbhza</a> <a href="https://t.co/DYnCVUiNCw">pic.twitter.com/DYnCVUiNCw</a></p>&mdash; amate (@amatetest) <a href="https://twitter.com/amatetest/status/1383179067361431553?ref_src=twsrc%5Etfw">April 16, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

このツールはC++で書かれていますが、根幹部分である、画像認識、OCR、イベント選択肢の表示の機能は、実はPythonを使えば結構簡単に実現することができます。

というわけで今回は、上記の3機能に焦点を当てて、順を追って作っていきたいと思います。

# 今回のゴール
![yq4iv-gm1jx.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/18f7e63e-3cf3-8f54-0ec2-81ba69945204.gif)

# １. 機能の整理
プログラムを書く前にまずは、

・どんな機能を付けるか
　- 最低限実現したい機能は何か

・それはどうすれば実装可能か
　- 使えそうなライブラリはあるか
　- 情報は自分で入力するか、どこかから拾ってきて整形するか

などを整理します。


今回であれば、

・どんな機能を付けるか
　- GUIは省略、コマンドラインに直出力
　- ウィンドウ位置を特定する
　- イベント名をOCRで抽出する
　- イベント選択肢の効果を出力する

・それはどうすれば実装可能か
　- `PyAutoGUI`の画像認識（`locateOnScreen`）を使えばウィンドウの位置が判りそう！
　- `PyOCR`+`Tesseract`で画像から日本語を含む文字列を抽出できそう！
　- GameWithの[イベント選択肢チェッカー（逆引き検索ツール）](https://gamewith.jp/uma-musume/article/show/259587)の情報が使えそう！

といった具合ですね。

# ２. アタリを付けた構想から具体的な機能を作ってみる
せっかくPythonを使っているなら、こういった開発を行う場合は`Jupyter Notebook（Lab）`の使用を強くおすすめします。
## ・ウィンドウ位置の特定
`PyAutoGUI`の`locateOnScreen`は、画面内から入力した画像に一致する部分を探し出し、一致する部分があればその場所を返してくれる非常に便利な関数です。

ウマ娘のアプリウィンドウで画面変遷に依る変化のない部分はあるでしょうか？
![umaratio.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/330e25a7-b3e1-79e6-ff30-0e15838bf4e8.jpeg)
この部分は変わらなそうですね。
![umaraatio.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/65b21b63-43b4-5e80-d34b-57b69164c021.jpeg)
スクリーンショットからこの部分を切り出して`titlebar.png`として実行ディレクトリに保存してみます。
すると画面からタイトルバーを見つけ出す関数は以下のようになります。

```python 
import pyautogui

titlebar_pos = pyautogui.locateOnScreen('titlebar.png', confidence=0.8) # confidenceは正確さ（小さくすると多少の違いを吸収できる）
print(titlebar_pos)
```

驚異的な簡単さですね。
このような高度な機能を、2, 3行で享受できるのがPythonの最大の強みだと個人的には思っています。

さて、これでウィンドウ位置が特定できました。と言いたいところですが、ウマ娘のアプリはウィンドウサイズを変更することができます。
このままでウィンドウサイズの変更に対応できるでしょうか。
試しにウィンドウを小さくしてみましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/7bddffa6-b666-434a-fb83-b5bcdffafed9.png)
先程と比較するとタイトルバーの空白部分が詰まっていますね。
`locateOnScreen`関数はこのような変化を吸収できないので、もう一捻り工夫が必要そうです。

ウィンドウサイズの変更の前後でよく観察してみると、タイトル部分と「X」などのアイコンサイズは変化がなさそうです。
ここで、`locateOnScreen`関数の戻り値を見てみると、

```:out
Box(left=1730, top=398, width=172, height=45)
```

`（左上の座標（左）, 左上の座標（上）, 幅, 高さ）`となっていることが分かります。
つまり、一致した部分の上下左右のカドの座標は足し算で求められるので、
左上のこの部分の左上の座標と、
![umatitle.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/52a25b18-5834-bc08-308e-8d154fb46338.png)
右上のこの部分の右上の座標を
![x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/9c59cadc-d997-30e9-b813-dea2a90b4a4e.png)
知ることで、「ウィンドウの位置」と「ウィンドウサイズ」の両方を得ることができそうです。
上の2画像をそれぞれ`umatitle.png`, `x.png`と名付けたとすると、

```python 
import pyautogui

pos = pyautogui.locateOnScreen('umatitle.png', confidence=0.8)
x_pos = pyautogui.locateOnScreen('x.png', confidence=0.8)
print(pos[0]+pos[2]) # タイトルバーの下のアプリ描画部分の左上の座標
print(x_pos[0]+x_pos[2]-pos[0]) # ウィンドウ幅
```

これで、ウィンドウ位置、ウィンドウサイズを特定することができました。

## ・イベント名をOCRで抽出する
OCRをかける前段階として、
・文字部分の切り取り
・2値化など、認識精度を上げるための前処理
を行う必要があります。

まずは、文字部分の切り取りから行っていきましょう。

### - 文字部分の切り取り
どのように目的部分を切り取るか戦略を立てるべく、今回の目的であるイベント選択画面を観察してみましょう。
![名称未設定 1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/f940ab97-c109-e1cc-d4e7-c0fea5cf83b3.jpeg)
プレイしている方ならすぐに気付くと思いますが、イベントタイトルは決まった位置に表示されています。
先程までで、ウィンドウの位置、サイズは取得できていますので相対座標で切り取れそうですね。
ウィンドウサイズ（幅）を乗算すれば常に狙った位置を切り取れるように、ウィンドウ幅を基準に規格化しておきましょう。
![図1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/173a06cf-8440-e1a3-51dc-e111d51d05ab.png)

`PyAutoGUI`には指定した長方形領域のスクリーンショットを撮る関数も、そのまま`screenshot`という名前で用意されていて、引数として`region=(左上の座標（左）, 左上の座標（上）, 幅, 高さ)`を渡すことで領域を指定できます。

```python 
pos = pyautogui.locateOnScreen('umatitle.png', confidence=0.8)
x_pos = pyautogui.locateOnScreen('x.png', confidence=0.8)
width = x_pos[0] + x_pos[2] - pos[0]
text_pos = (np.array([0.15, 0.33, 0.6, 0.065]) * width).astype(int)
pyautogui.screenshot(region=(pos[0] + text_pos[0], pos[1] + pos[3] + text_pos[1], *text_pos[2:]))
```
↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/ec0e3acb-1b0b-9448-603e-4b13738c464e.png)
目的の部分は切り出せましたので、次は認識精度を上げるための前処理を行っていきます。

### - OCRの認識精度を上げるための前処理
今回の場合、文字の背景は軽いグラデーションが掛かっている程度ですので、一番簡単な「閾値による2値化」を採用したいと思います。
「閾値による2値化」を行うというのはものすごく単純な話で、画像をモノクロにしたとき、ある値より暗ければ真っ黒、明るければ真っ白にするという処理のことです。

今回は`cv2`というライブラリを使って2値化を行ってみたいと思います。
先程のスクリーンショットの結果が`pic`に格納されているとして、

```python 
import cv2
import numpy as np

gray = cv2.cvtColor(np.array(pic), cv2.COLOR_BGR2GRAY)
_, th = cv2.threshold(gray, 210, 255, cv2.THRESH_BINARY)
th
```

↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/868e6d0f-0f05-a984-d15b-66c658a2f2b7.png)
さらに、白黒を反転して、

```python 
255 - th
```

↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/81022ad2-10e8-fe3d-4eb6-45c12f6cc791.png)

きれいに加工することができましたので、いよいよOCRをかけてみたいと思います。

### - OCRによる文字列抽出
今回は`PyOCR`+`Tesseract`を採用して文字を抽出してみます。
それぞれ下記のページなどを参考にインストールを行ってください。使い方に関しても参考になると思います。
[Tesseract+PyOCRで簡易OCRを試してみる](https://qiita.com/henjiganai/items/7a5e871f652b32b41a18)
[PythonとTesseract OCRで文字認識](https://qiita.com/nabechi6011/items/3a367ca94dbd208efcc7)

```python 
import pyocr

tool = pyocr.get_available_tools()[0]
text = tool.image_to_string(Image.fromarray(255 - th),
                            lang='jpn',
                            builder=pyocr.builders.TextBuilder())
print(text)
```
 ```out
トレーナー心得「指導は常に改良せよ』
```

ほぼ完璧に抽出できました。

ここまで来ればほとんど終わったようなもので、あとはイベント名と効果のセットを用意して出力すれば完了です。
ということで、次はデータの収集を行います。

## イベント選択肢の効果を出力する
出力の前に、イベント名と効果がセットにっているデータがないと始まりませんので、データの収集方法を模索します。
### - データ収集

今の時代ゲームの攻略サイトは多いので、何かしらの方法でイベント効果の情報を集めて、`{イベント名: 選択肢・効果}`の形に整形し、保存しておきます。

### - 表示するイベントのピックアップ

OCRでは高い確率で文字列を完璧には抽出できないので、何かしらの方法で近い名前のイベントをピックアップする必要があります。
ここでは標準ライブラリの`difflib`から`SequenceMatcher`を使って文字列同士の類似度を計算します。
[Pythonで文字列部分一致度合いを調べる](https://qiita.com/aizakku_nidaa/items/20abcd8aa32152786687)
最も近い文字列のスコアが、ある一定の値より大きくなった場合にイベントと判断し、出力するという方針で書いてみたいと思います。

```python
# 変数名が適当で申し訳ない
from difflib import SequenceMatcher

eventData = {イベント名: 選択肢・効果} # イベントの情報が入った変数
t = ''
r = 0
for i in eventData:
    rr = SequenceMatcher(None, i, text).ratio()
    if r < rr:
        r = rr
        t = i
if r >= .625:
    print(f'【{t}】')
    for i in eventData[t]:
        print(i['n'], '    ' + '\n    '.join(i['t'].split('[br]')), sep='\n')
```

```:out
【トレーナー心得『指導は常に改良せよ』】
あまり無理はなさらずに
    体力+10~14
    スキルPt+15~18
    桐生院トレーナーの絆ゲージ+5
    ※サポート効果により数値が変動
『トレーナー白書』見てみたいです
    スピード+5~6
    賢さ+5~6
    桐生院トレーナーの絆ゲージ+5
```

これで、ウィンドウ認識からイベント選択肢の効果の出力までを実装できました。



最後に、一つのクラス・プログラムにまとめたものを掲載したいと思います。

# ３. 最終的に１つのプログラムとしてまとめる
<details><summary>umamusume_options_checker.py</summary><div>

```python 
import pyautogui
import numpy as np
import time
import pyocr
from PIL import Image
from requests import get
from difflib import SequenceMatcher
import cv2


class uma_option_checker:
    def __init__(self, title_ref, x_ref):
        self.pos_ref = title_ref
        self.x_ref = x_ref
        self.eventData = {} # イベント情報をまとめた辞書
        self.get_win_pos()
        self.tool = pyocr.get_available_tools()[0]
        self.scene = ""

    def get_win_pos(self):
        pos = pyautogui.locateOnScreen(self.pos_ref, confidence=0.9)
        x_pos = pyautogui.locateOnScreen(self.x_ref, confidence=0.9)
        if pos is None or x_pos is None:
            self.pos = 0
            return False
        else:
            self.pos = pos
            self.ratio = x_pos[0] + x_pos[2] - pos[0]
            self.text_pos = (np.array([0.15, 0.33, 0.6, 0.065]) * self.ratio).astype(
                int
            )
            return True

    def get_scene(self):
        self.p = pyautogui.screenshot(
            region=(
                self.pos[0] + self.text_pos[0],
                self.pos[1] + self.pos[3] + self.text_pos[1],
                *self.text_pos[2:],
            )
        )
        gray = cv2.cvtColor(np.asarray(self.p), cv2.COLOR_BGR2GRAY)
        _, th = cv2.threshold(gray, 210, 255, cv2.THRESH_BINARY)
        text = self.tool.image_to_string(
            Image.fromarray(255 - th), lang="jpn", builder=pyocr.builders.TextBuilder()
        )
        self.text = text
        if text:
            return True
        else:
            return False

    def get_likely(self):
        t = ""
        r = 0
        for i in self.eventData:
            rr = SequenceMatcher(None, i, self.text).ratio()
            if r < rr:
                r = rr
                t = i
        return t if r >= 0.625 else self.scene

    def show(self):
        print(f"【{self.scene}】")
        for i in self.eventData[self.scene]:
            print(i["n"], "    " + "\n    ".join(i["t"].split("[br]")), sep="\n")
        print()

    def update(self):
        if self.get_win_pos():
            if self.ratio > 0:
                if self.get_scene():
                    t = self.get_likely()
                    if t != self.scene:
                        self.scene = t
                        self.show()


if __name__ == "__main__":
    uop = uma_option_checker("umatitle.png", "x.png")

    while 1:
        time.sleep(0.5)
        uop.update()

```

</div></details>

# 改善点の候補
・GUIの開発
・現状では育成ウマ娘の情報を考慮していない為、イベント名が共通のイベントは異なるウマ娘の選択肢が表示される点の解決
・OCR精度の向上
興味のある方は是非挑戦してみてください。

#さいごに
Pythonでは豊富なライブラリのおかげで、一見するととても高レベルな内容も短いコードで簡単に実現することができます。
もっと色んな人に気軽に使えるツールだよって伝えたい…