1. Cartelet

    Posted

    Cartelet
Changes in title
+「画像認識ウマ娘イベント選択肢チェッカー」をPythonで作るチュートリアル
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,350 @@
+# はじめに
+ウマ娘、流行ってますね ~~(ひと波超えた感はありますが)~~。
+特に流行りがピークだった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)
+
+# 1. 機能の整理
+プログラムを書く前にまずは、
+
+・どんな機能を付けるか
+ - 最低限実現したい機能は何か
+
+・それはどうすれば実装可能か
+ - 使えそうなライブラリはあるか
+ - 情報は自分で入力するか、どこかから拾ってきて整形するか
+
+などを整理します。
+
+
+今回であれば、
+
+・どんな機能を付けるか
+ - GUIは省略、コマンドラインに直出力
+ - ウィンドウ位置を特定する
+ - イベント名をOCRで抽出する
+ - イベント選択肢の効果を出力する
+
+・それはどうすれば実装可能か
+ - `PyAutoGUI`の画像認識(`locateOnScreen`)を使えばウィンドウの位置が判りそう!
+ - `PyOCR`+`Tesseract`で画像から日本語を含む文字列を抽出できそう!
+ - GameWithの[イベント選択肢チェッカー(逆引き検索ツール)](https://gamewith.jp/uma-musume/article/show/259587)の情報が使えそう!
+
+といった具合ですね。
+
+# 2. アタリを付けた構想から具体的な機能を作ってみる
+せっかく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
+トレーナー心得「指導は常に改良せよ』
+```
+
+ほぼ完璧に抽出できました。
+
+ここまで来ればほとんど終わったようなもので、あとはイベント名と効果のセットを用意して出力すれば完了です。
+ということで、次はデータの収集を行います。
+
+## イベント選択肢の効果を出力する
+出力の前に、イベント名と効果がセットにっているデータがないと始まりませんので、データの収集方法を模索します。
+### - データ収取
+
+今の時代ゲームの攻略サイトは多いので、それらのいずれかから拝借できないものかと考えながら色々見て回っていると、GameWithさんに良さそうなサービスがありました。
+[イベント選択肢チェッカー(逆引き検索ツール)](https://gamewith.jp/uma-musume/article/show/259587)
+![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/557c36cd-93c0-d06c-3de3-fadc5ce74da7.png)
+
+Chromeの検証機能を使って通信を監視してみると、入力欄に文字を入力しても一切通信を行っていないことが分かり、これによってまとまった辞書をローカルにダウンロードしていることがほぼ確定しました。
+もう少し探索してみると、目的の辞書が定義されたjavascriptファイルを発見、今回はこれを使ってみようと思います。
+(あまりウェブサイトの内部ファイルをピンポイントで書き出すのもいかがなものかと思うので、ここでは省略)
+これを`{イベント名: 選択肢・効果}`の形に整形し、保存しておきます。
+
+### - 表示するイベントのピックアップ
+
+OCRでは高い確率で文字列を完璧には抽出できないので、何かしらの方法で近い名前のイベントをピックアップする必要があります。
+ここでは標準ライブラリの`difflib`から`SequenceMatcher`を使って文字列同士の類似度を計算します。
+[Pythonで文字列部分一致度合いを調べる](https://qiita.com/aizakku_nidaa/items/20abcd8aa32152786687)
+最も近い文字列のスコアが、ある一定の値より大きくなった場合にイベントと判断し、出力するという方針で書いてみたいと思います。
+
+```python
+# 変数名が適当で申し訳ない
+from difflib import SequenceMatcher
+
+eventDatas = {イベント名: 選択肢・効果} # イベントの情報が入った変数
+t = ''
+r = 0
+for i in eventDatas:
+ rr = SequenceMatcher(None, i, text).ratio()
+ if r < rr:
+ r = rr
+ t = i
+if r >= .625:
+ print(f'【{t}】')
+ for i in eventDatas[t]:
+ print(i['n'], ' ' + '\n '.join(i['t'].split('[br]')), sep='\n')
+```
+
+```:out
+【トレーナー心得『指導は常に改良せよ』】
+あまり無理はなさらずに
+ 体力+10~14
+ スキルPt+15~18
+ 桐生院トレーナーの絆ゲージ+5
+ ※サポート効果により数値が変動
+『トレーナー白書』見てみたいです
+ スピード+5~6
+ 賢さ+5~6
+ 桐生院トレーナーの絆ゲージ+5
+```
+
+これで、ウィンドウ認識からイベント選択肢の効果の出力までを実装できました。
+
+
+
+最後に、一つのクラス・プログラムにまとめたものを掲載したいと思います。
+
+## 3. 最終的に1つのプログラムとしてまとめる
+<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.db_init()
+ self.get_win_pos()
+ self.tool = pyocr.get_available_tools()[0]
+ self.scene = ""
+
+ def db_init(self):
+ male = (
+ get(
+ "https://gamewith-tool.s3-ap-northeast-1.amazonaws.com/uma-musume/male_event_datas.js"
+ )
+ .text.replace(";", "")
+ .replace("window.eventDatas['男'] = ", "")
+ )
+ """female = (
+ get(
+ "https://gamewith-tool.s3-ap-northeast-1.amazonaws.com/uma-musume/female_event_datas.js"
+ )
+ .text.replace(";", "")
+ .replace("window.eventDatas['女'] = ", "")
+ )"""
+ eventDatas = eval(male)
+ # eventDatas = eval(female)
+ self.eventDatas = {i["e"]: i["choices"] for i in eventDatas}
+
+ 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.5, 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, 195, 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.eventDatas:
+ 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.eventDatas[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>
+
+#さいごに
+Pythonでは豊富なライブラリのおかげで、一見するととても高レベルな内容も短いコードで簡単に実現することができます。
+もっと色んな人に気軽に使えるツールだよって伝えたい…