スポンサーリンク

Pythonで東方地霊殿のゲーム画面をリアルタイムでキャプチャ、認識するツールを作りました

初学者ゆえ見当違いのこと書いてるかも。。

最近Pythonを利用した画像解析やWebスクレイピングに興味を持つ機会があり、プログラムを作りながら勉強しています。少し勉強した感じではインデントが意味を持ってるのがすごい好きです。

今回はOpenCVのテンプレートマッチングを利用して東方地霊殿のプレイ画面からスコアやグレイズ等の情報を取得するというツールを作成してみました。

作成したツールについて

以下のようなゲーム画面からスコア、プレイヤーの残機、グレイズを取得しています。
ボスが出現しているあいだ画面左上に表示されるボス名や画面上部に表示されるスペルカード名などからゲーム中の現在地なんかを計算したりもしています。
取得したデータは最後に整形してCSV出力しています。

東方地霊殿は1280×960での起動のみに対応しています。サンプルデータを用意すれば他のサイズでも使えるかもですが保留中。現状、他のサイズで起動したときはアラート表示して落ちるようになってます。
ゲームのウィンドウはpywin32というモジュールに含まれるwin32guiで起動中の東方地霊殿のハンドルIDを取得→ウィンドウ位置取得→領域の画像をImageGrabで取得という流れで撮っています。
ウィンドウの位置はぴったり取れるわけではなくウィンドウの外の数ピクセルくらい余分に取れてしまうみたいです(非クライアント領域という言うみたい?)。
調べてもぴったりウィンドウの位置を取得する情報が見つからず、何度かキャプチャを繰り返した結果、毎回同じ位置が取れることは分かったので1280×960となるよう切り抜き位置を補正しています。

やったことメモ

以下、使用した技術に関するメモなど

app.py実行時に東方地霊殿を自動起動

東方地霊殿のexeファイルを実行することで起動しています。
実行ファイルの場所は起動時にsettings.iniに設定が存在しなかったらダイアログを開いて指定もらうようにしています。
2回目以降はsettings.iniから設定を読み込んで自動で起動するとこまで処理が走ります。
設定はconfigparserというモジュールで書き出し&読み込みを行っています。

def execute_th11(config):
    # 東方地霊殿を起動してハンドルを返す。
    # 実行ファイルのパスが存在しない場合はダイアログを開き設定する
    th11_handle = win32gui.FindWindow(None, TH11_WINDOW_NAME)
    if th11_handle <= 0:

        # 設定ファイルチェック(exeファイルの設定が存在するか確認)
        th11_exe_file = ""
        if config.has_section(CONFIG_SECTION_NAME):
            th11_exe_file = config.get(CONFIG_SECTION_NAME, 'exe_path')
            if not os.path.isfile(th11_exe_file):
                th11_exe_file = ""

        # ダイアログを開いて地霊殿の実行ファイルを指定してもらう
        if th11_exe_file == "":

            print(colored("東方地霊殿の実行ファイルを指定してください。", "green", attrs=['bold']))

            # th11.exe指定
            root = tkinter.Tk()
            root.withdraw()

            file_type = [("東方地霊殿の実行ファイル", "th11.exe")]
            initial_dir = os.getcwd()
            th11_exe_file = tkinter.filedialog.askopenfilename(filetypes = file_type, initialdir = os.getcwd())

            if th11_exe_file == "" or os.path.basename(th11_exe_file) != 'th11.exe':
                print(colored("東方地霊殿の実行ファイルが見つからないため終了します。", "red", attrs=['bold']))
                time.sleep(3)
                sys.exit(1)

            # 設定ファイルにexeファイルのパス保存
            if not config.has_section(CONFIG_SECTION_NAME):
                config.add_section(CONFIG_SECTION_NAME)
            config.set(CONFIG_SECTION_NAME, 'exe_path', th11_exe_file)

            # ファイルに書き出し
            with open(CONFIG_FILE_NAME, 'w') as config_file:
                config.write(config_file)

        # 東方地霊殿を起動
        th11_exe_dir = os.path.dirname(th11_exe_file)
        th11_exe_name = os.path.basename(th11_exe_file)
        subprocess.Popen('cd /D ' + th11_exe_dir + ' && start ' + th11_exe_name, shell=True) # 移動してから起動しないと設定ファイルが読み込まれないみたい
        time.sleep(5)

        # ウィンドウ名でハンドル取得
        error_count = 0
        while(True):
            th11_handle = win32gui.FindWindow(None, TH11_WINDOW_NAME)
            if th11_handle > 0:
                break

            error_count += 1
            if (error_count > 3):
                print(colored("東方地霊殿が起動してないため終了します。", "red", attrs=['bold']))
                time.sleep(3)
                sys.exit(1)

            print(colored("東方地霊殿が起動していません。", "red", attrs=['bold']))
            time.sleep(3)

    return th11_handle
[config]
exe_path = C:/Program Files (x86)/上海アリス幻楽団/東方地霊殿/th11.exe
キャプチャした画面の画像について

画像に関する扱いはPillowはRGB、OpenCVはBGRっていうのを覚えたら何とかなりました。
以下のプログラムはcapture_areaで指定した領域をImageGrabで切り抜いているプログラムです。
capture_areaはX1、Y1、X2、Y2のピクセル値を持つタプルです。

def get_original_frame(capture_area, current_time, development):
    # 画面のクリッピング処理
    img = ImageGrab.grab(bbox=capture_area)
    if development:
        img.save(OUTPUT_DIR + current_time + '.png')

    return numpy.array(img)

OpenCV側で処理するにあたって画像のバイナリはnumpy.arrayでndarrayという多次元配列に変換しています。
また、OpenCV側で処理するにあたってRGB→BGRの変換を行う必要がありますが、これはOpenCVのcvtColorという関数で簡単に変換できました。

ImageGrab.grabで切り抜いた画像を保存する際はsave関数を呼び出すだけですが、例えばこの画像をOpenCV側で保存する場合は以下のようになると思います。

import cv2

original_frame = get_original_frame(capture_area, current_time, development)
cv2_frame = cv2.cvtColor(original_frame, cv2.COLOR_RGB2BGR)
cv2.imwrite('hogehoge.png', cv2_frame)

OpenCVでの色の変換は第2引数に[変換元色]2[変換先色]のような定義済みのコードを指定する。コードの一覧は以下
参考:OpenCV: Color Space Conversions
その他、以下のような感じでBGRの並びのタプルを2つ指定することで特定の色から色の間を白、それ以外を黒といった二値化なんかも行えました。

# 赤(R:255、G:0、B:0)~濃い赤(R:136、G:0、B:0)の範囲で二値化したサンプルデータとのマッチング
clopped_frame = original_frame[STAGE_CLEAR_ROI[1]:STAGE_CLEAR_ROI[3], STAGE_CLEAR_ROI[0]:STAGE_CLEAR_ROI[2]]
clopped_frame = cv2.cvtColor(clopped_frame, cv2.COLOR_RGB2BGR)
clopped_frame = cv2.inRange(clopped_frame, (0, 0, 136), (0, 0, 255))

numpyは正直よくわかってなくて「そういうもの」という理解しかできてないです。。

テンプレートマッチングについて

サンプルデータとのテンプレートマッチングを進めるにあたって、マッチングに使用した画像を保存できる仕組みを作っておくと良いと思います。ROIの座標がずれてないかとか二値化が意図した通りに行えてるかとか確認します。

Pillowでは以下のような感じで画像を保存できました。

img = ImageGrab.grab(bbox=capture_area)
img.save(OUTPUT_DIR + 'hogehoge.png')

OpenCVでは一つ前の項目で書いた通りで以下のような感じ。OpenCVで開いてOpenCVで保存するのであれば変換は不要です。

clopped_frame = cv2.cvtColor(clopped_frame, cv2.COLOR_RGB2BGR)
cv2.imwrite(OUTPUT_DIR + 'fugafuga.png', clopped_frame)

今回の記事の一番のメインコンテンツとなるテンプレートマッチングの処理自体についてはうまくまとめられる気がしないので省略(^q^
以下のような感じだと思ってます。

  1. あらかじめ数字やボス名などの切り抜いた画像を用意
  2. リアルタイムで取得した画像の指定位置と1の画像を比較
  3. 画像の類似度を取得し閾値を超えていたら一致と見なす
    1. 「5」という数字が2で取得した画面内があったとして、「5」のサンプル画像との類似度が高かったら5を検出したと判定するということ
複数のサンプルデータをnpzという1つのファイルにまとめる

npzはnumpyでndarray化したデータをまとめたファイルと理解しました。合ってないかも。
プログラムを実行するたびに先頭でpng形式のサンプルデータを大量に読み込む無駄をなくすことが出来ました。
npzを生成するためのプログラムは以下のような感じ。サンプルデータに変更が入ったら実行しておいてnpzをGitにプッシュするような流れです。
ちなみに一応書いておくと、今回作成したツールではテンプレートマッチングで使用するサンプルデータについてpng形式でプログラムの実行の度に画像ファイルから読み込んでも正直そんなに時間は変わりませんでした。プログラムの規模がもっと大きくなったときに効果出るのかなと思いました。

import numpy
import os
import time
import cv2
from datetime import datetime
from PIL import Image, ImageGrab
from builtins import enumerate
import glob


# 変数(定数扱いする変数)
OUTPUT_DIR = os.path.abspath(os.path.dirname(__file__)) + os.sep + 'output' + os.sep
SAMPLE_DIR = os.path.abspath(os.path.dirname(__file__)) + os.sep + 'sample_data' + os.sep
SAMPLE_NUMBERS_DIR = SAMPLE_DIR + 'number' + os.sep
SAMPLE_REMAINS_DIR = SAMPLE_DIR + 'remain' + os.sep
SAMPLE_DIFFICULTIES_DIR = SAMPLE_DIR + 'difficulty' + os.sep
SAMPLE_BOSS_NAMES_DIR = SAMPLE_DIR + 'boss_name' + os.sep
SAMPLE_BOSS_REMAINS_DIR = SAMPLE_DIR + 'boss_remain' + os.sep
SAMPLE_SPELL_CARDS_DIR = SAMPLE_DIR + 'spell_card' + os.sep
SAMPLE_STAGE_CLEARS_DIR = SAMPLE_DIR + 'stage_clear' + os.sep
SAMPLE_ENEMY_ICONS_DIR = SAMPLE_DIR + 'enemy_icon' + os.sep
SAMPLE_TIME_REMAINS_DIR = SAMPLE_DIR + 'time_remain' + os.sep
NPZ_FILE =  'resources' + os.sep + 'bundle.npz'
DIFFICULTY_EASY = 0
DIFFICULTY_NORMAL = 1
DIFFICULTY_HARD = 2
DIFFICULTY_LUNATIC = 3
DIFFICULTY_EXTRA = 4
DIFFICULTY_HASHMAP = {
                      None: '',
                      DIFFICULTY_EASY: 'Easy',
                      DIFFICULTY_NORMAL: 'Normal',
                      DIFFICULTY_HARD: 'Hard',
                      DIFFICULTY_LUNATIC: 'Lunatic',
                      DIFFICULTY_EXTRA: 'Extra'
}


# スコアのサンプルデータ(0~9)
BINARY_NUMBERS = []
number_len = len(glob.glob(SAMPLE_NUMBERS_DIR + '*.png'))
for index in range(number_len):
    img = cv2.imread(SAMPLE_NUMBERS_DIR + str(index) + '.png', cv2.IMREAD_GRAYSCALE) #グレースケールで読み込み
    BINARY_NUMBERS.append(img)


# 残機のサンプルデータ(1、1/5、2/5、3/5、4/5)
BINARY_REMAINS = []
remain_len = len(glob.glob(SAMPLE_REMAINS_DIR + '*.png'))
for index in range(remain_len):
    img = cv2.imread(SAMPLE_REMAINS_DIR + str(index) + '.png', cv2.IMREAD_GRAYSCALE) #グレースケールで読み込み
    BINARY_REMAINS.append(img)


~~~省略~~~


# npz保存
numpy.savez_compressed(NPZ_FILE,
            number = BINARY_NUMBERS,
            remain = BINARY_REMAINS,
            difficulty = BINARY_DIFFICULTIES,
            boss_name = BINARY_BOSS_NAMES,
            boss_remain = BINARY_BOSS_REMAIN,
            spell_card = BINARY_SPELL_CARDS,
            stage_clear = BINARY_STAGE_CLEARS,
            enemy_icon = BINARY_ENEMY_ICONS,
            time_remain = BINARY_TIME_REMAINS
            )

numpy.savez_compressedで1つのファイルにまとめています。
可変長引数としてkey=ndarrayの配列みたいな指定をすることで、読み込みの際に以下のように読み込めました。

# generate_npz_data.pyによって生成されたデータを読み込み
NPZ_DATA = numpy.load(NPZ_FILE, allow_pickle = True)

# スコアのサンプルデータ(0~9)
BINARY_NUMBERS = NPZ_DATA['number']

# 残機のサンプルデータ(1、1/5、2/5、3/5、4/5)
BINARY_REMAINS = NPZ_DATA['remain']

# 難易度のサンプルデータ(Easy~Extra)
BINARY_DIFFICULTIES = NPZ_DATA['difficulty']

# ボス名のサンプルデータ(キスメ~古明地こいし)
BINARY_BOSS_NAMES = NPZ_DATA['boss_name']

# ボス残機のサンプルデータ(緑色の星画像で固定)
BINARY_BOSS_REMAIN = NPZ_DATA['boss_remain']

# スペルカードのサンプルデータ(難易度を元に動的に切り替え)
BINARY_SPELL_CARDS = []

# ステージクリアのサンプルデータ(1~6面およびリプレイ再生時のALLクリア用の2種類)
BINARY_STAGE_CLEARS = NPZ_DATA['stage_clear']

# エネミーアイコンのサンプルデータ
BINARY_ENEMY_ICONS = NPZ_DATA['enemy_icon']

# 残り時間のサンプルデータ
BINARY_TIME_REMAINS = NPZ_DATA['time_remain']
1回あたりの処理時間について

作成したプログラムでは画像を取得してテンプレートマッチングを行う処理をwhile(True)で無限ループさせています。
CPUなどのリソースについてどの程度使うものなのかわかってないため1ループごとにsleepを行う処理を入れています。sleepの時間自体はコマンドラインの引数で設定可能としており、sleepを挟まずにテンプレートマッチングを行い続ける起動は以下のコマンドになります。

app.py --capture-period 0 --print-exec-time

私のPCでは以下のような結果となりました。

0.1秒以下には収まりました。作り始めたころはもうちょっと早かった気がするのですがいつのまにか遅くなってしまった。。
とはいえ、ゲームの画面をリアルタイムで取得して処理するのには十分な時間かと思います。

次にCPUの使用率について。
上はcapture-periodオプションが1、下は0のときの結果になります。

キャプチャ間隔が1秒のとき27%、0秒のとき46%という結果となりました。
他のプロセスも動いてる状況での計測なので、ざっくりとしてますがキャプチャ間隔が0秒のときは東方地霊殿自体のFPSが少しちらついたりする感じでそれなりに負荷が上がってるなーという感じはしました。

メモリはプログラムを実行したタイミングで目に見えて増えるということはありませんでした。上の画像の8.8GBの大半はEclipseとブラウザが使ってる感じかと思います。

仮想環境(venv)を導入する

複数のpythonのプロジェクトを仮想環境を作らずに動かした結果、片方のプロジェクトでは不要なモジュールがrequirements.txtに含まれてしまうという事態に陥りました。
解決のため途中からプロジェクトごとにvenvを使った仮想環境を作ることにしました。
導入自体はPython3.3以降は最初から使用可能になっているとのことなので特にpip等でインストールする必要はありませんでした。

プロジェクトの直下で以下のコマンドで仮想環境を作成します。

python -m venv venv

上のコマンドの一つ目のvenvは-mオプションで指定するvenvモジュールを指しています。
2つ目はvenvという名前の仮想環境を構築するという意味。実行するとvenvというディレクトリが出来上がり、その中に仮想環境の起動スクリプトや仮想環境内でインストールしたpipモジュールなどが含まれるようになる。2つ目はvenvを使ってるということがわかれば何でもOKということ。

Windows環境における仮想環境の起動と終了は以下の通り。

# venv起動
.\venv\Scripts\activate.bat

# venv終了
deactivate

venvを起動すると以後、起動中のセッションではpipコマンドの参照が起動したvenv以下を参照するようになる。
pipで必要なモジュールをインストールし、仮想環境内にインストールされたモジュールの一覧をrequirements.txtに出力、requirements.txtの差分をGitにプッシュという流れでよさそう。

pip install numpy
pip freeze > requirements.txt

その他、gitを使っている場合はvenv内のファイルを管理対象から外すため、以下のようなignore設定を行うことが推奨されていました。
リンク:gitignore/VirtualEnv.gitignore at master · github/gitignore

# Virtualenv
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
.Python
[Bb]in
[Ii]nclude
[Ll]ib
[Ll]ib64
[Ll]ocal
[Ss]cripts
pyvenv.cfg
.venv
pip-selfcheck.json
実行ファイル(exe)化する

pyinstallerというモジュールを使うことでPythonで書いたプログラムを実行ファイル化できることがわかりました。
実行ファイル化できる=PythonがインストールされていないPCでも作ったツールを動かせるということ。
ざっと説明を読んだ感じではexeにPythonやpipモジュールを同梱するためファイルサイズは大きくなりがち。
venvを導入して最小構成で作成するのがよさそうです。

以下は最初にpyinstallerによるビルドを行ったときのコマンド。
実行するとビルドで使用した?ファイルが置かれるbuildディレクトリ、生成した実行ファイルを置くdistディレクトリ、カレントに実行した.pyファイルと同じ名前の.specファイルなどが生成されます。

pyinstaller app.py --onefile

今回作成したツールでは、テンプレートマッチングで使用する画像ファイルをまとめたnpzファイルが存在するため以下のようにリソースを実行ファイルに含める設定を行いました。

pyz = PYZ(a.pure, a.zipped_data,
             cipher=block_cipher)
exe = EXE(pyz,
+          Tree('resources',prefix='resources'),
          a.scripts,
          a.binaries,
          a.zipfiles,

2回目以降のビルドは上記の修正したspecファイルを元に行いました。

pyinstaller app.spec

上の画像からわかる通り仮想環境外で実行ファイルから作成したツールが起動できていることがわかります。
また、実行ファイルを起動する際、clickというモジュールで定義したコマンドライン引数が同じように取れるようになっていました。すごい助かる。

ちなみに実行ファイル化した際の1回あたりのテンプレートマッチングについても計測してみました。前述の「1回あたりの処理時間について」の項目に書いた結果と同じくらいとなりました。

作成したツールの配布について

せっかくなので実行ファイルを公開してみました。
ファイルサイズは約49MBとなりました。

※注意
Windows10 (64bit)でないと正しく動作しないと思います。
キャプチャする東方地霊殿は1280×960で起動する必要があります。
マルチディスプレイの場合は東方地霊殿をメインディスプレイ側に配置する必要があります。
自己責任での使用をお願いいたします。上手く動かない、発生した不具合について一切の責任を負いかねます。

ダウンロードは以下よりお願いします。

ソースコードについて

ソースは以下になります。
GitHub:imo-tikuwa/th11-score-capture

最初から思い付きでコード書いて消してを繰り返し続けてるのでめちゃくちゃ汚いコードになってると思います。
commons.pyの辞書データとかメンテナンスしたくない感じのコードになってます。

その他①

5年くらい前にも東方紺珠伝で似たようなツールを作ってました
こっちは当時、職場で使用していたJavaのSeasar2というフレームワークを使ったWebアプリでした。
たしかTesseractOCRがうまく動かなくて、最終的にスコアの数字の白いピクセルの数を数えて数字を判定するっていう頭良くない感じのことしてたと思います(^^;

その他②

今回の記事に書いたリポジトリの他に以下の2つのPythonのプロジェクトを作りました。どちらもvenv導入済み。

GitHub:imo-tikuwa/python-webscraping-practice
seleniumやbeautifulsoup4を使ったWebスクレイピングの勉強用リポジトリです。
ヤフー天気を現在日~8日分取得したり、スクレイピングの練習として使用することが許可されているこちらのサイトにてログイン、一覧ページ、画像の取得などのプログラムを作成しています。

GitHub:imo-tikuwa/prkn-boss-hp-remain
DMM版プリンセスコネクト!Re:DiveのダンジョンEX3のボス「ラースドラゴン」について、 画面のHPバーをリアルタイムでキャプチャしておおよその残りHPを算出するプログラムです。
東方地霊殿のツールの前に作ってたのでサンプルデータのnpz化とかは行っていません。

参考サイト

今回の記事に関係ないものもいくつか含みます。。

テンプレートマッチング &#8212; OpenCV-Python Tutorials 1 documentation
Python, OpenCV, NumPyでカラー画像を白黒(グレースケール)に変換 | note.nkmk.me
venv: Python 仮想環境管理 – Qiita
Pyinstaller でリソースを含めたexeを作成する – Qiita
Python でcsv出力したら一行空く件 – Qiita
Pythonでprint関数のターミナル出力を上書きで1行表示する方法|dot blog
Seleniumで待機処理するお話 – Qiita

タイトルとURLをコピーしました