NoxPlayerをPythonで動かすための基礎

##背景
UWSCの配布・サポートが終わったので、以前UWSCで書いたマクロをPythonで書き直した
そこで書き直した内容のうち、NoxPlayerをマクロで動かす上で重要な内容をまとめてみようと思った

内容として、Nox操作に必要なADBコマンドの送り方、プログラムとNoxで双方向的な操作、あいまい画像認識の方法など述べる
ソースコードを乗せるが、改変しながら書いたので間違ってるかも

##環境
NoxPlayer 6.6.0.0
Python 3.6
opencv-python 4.1.2.30

##ADBコマンドを送って操作する
NoxPlayerでAndroid操作を自動化するにあたって、一々マウス操作では煩わしい
そのためバックグラウンドで操作する必要があるが、そのためにはADBコマンドを使う必要がある
→Noxにはnox_adb.exeが標準で付属しているため、これを用いる

最初に、PythonでUWSCのDOSCMDに当たる関数は以下のように書ける

```Python:コード
from subprocess import run, PIPE
def doscmd(directory, command):
    completed_process = run(command, stdout=PIPE, shell=True, cwd=directory, universal_newlines=True, timeout=10)
    return completed_process.stdout
```

このdoscmdを使ってADBコマンドを送るときは、例えばタップ操作の場合

```Python:コード
def send_cmd_to_adb(cmd):
    _dir = "D:/Program Files/Nox/bin"
    return doscmd(_dir, cmd)

def tap(x, y):
    _cmd = "nox_adb shell input touchscreen tap " + str(x) + " " + str(y)
    send_cmd_to_adb(_cmd)
```
と簡潔に記述できる
その他のADBコマンドは調べれば出てくる（後にも乗せるけど）
##logcatで挙動を見る
自動化する際、logcatでアプリやAndroidの挙動を動的に見れると捗るが、そのときは

```Python:コード
def show_log():
    _cmd = "nox_adb logcat -d"
    _pipe = send_cmd_to_adb(_cmd)
    return _pipe
```
と書くとlogcatが出力される
例えばNox標準のファイルマネージャーをADBコマンドで開いて、開いたことを確認するためには

```Python:コード
def start_app():
    _cmd = "nox_adb shell am start -n com.cyanogenmod.filemanager/.activities.NavigationActivity"
    _pipe = send_cmd_to_adb(_cmd)
    print(_pipe)


start_app()
```

```terminal:ターミナル
Starting: Intent { cmp=com.cyanogenmod.filemanager/.activities.NavigationActivity }
```
でも良いが、logcatを用いて

```Python:コード
def clear_log():
    _cmd = "nox_adb logcat -c"
    send_cmd_to_adb(_cmd)

def get_log():
    _cmd = "nox_adb logcat -v raw -d -s ActivityManager:I | find \"com.cyanogenmod.filemanager/.activities.NavigationActivity\""
    _pipe = send_cmd_to_adb(_cmd)
    print(_pipe)


clear_log()
start_app()
get_log()
```
```terminal:ターミナル
START u0 {act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] flg=0x10200000 cmp=com.cyanogenmod.filemanager/.activities.NavigationActivity bnds=[334,174][538,301] (has extras)} from pid 681
Start proc com.cyanogenmod.filemanager for activity com.cyanogenmod.filemanager/.activities.NavigationActivity: pid=14454 uid=10018 gids={50018, 1028, 1015, 1023}
Displayed com.cyanogenmod.filemanager/.activities.NavigationActivity: +691ms
START u0 {act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] flg=0x10200000 cmp=com.cyanogenmod.filemanager/.activities.NavigationActivity bnds=[334,174][538,301] (has extras)} from pid 681
Start proc com.cyanogenmod.filemanager for activity com.cyanogenmod.filemanager/.activities.NavigationActivity: pid=14562 uid=10018 gids={50018, 1028, 1015, 1023}
Displayed com.cyanogenmod.filemanager/.activities.NavigationActivity: +604ms
START u0 {flg=0x10000000 cmp=com.cyanogenmod.filemanager/.activities.NavigationActivity} from pid 14665
Start proc com.cyanogenmod.filemanager for activity com.cyanogenmod.filemanager/.activities.NavigationActivity: pid=14675 uid=10018 gids={50018, 1028, 1015, 1023}
Displayed com.cyanogenmod.filemanager/.activities.NavigationActivity: +584ms
START u0 {flg=0x10000000 cmp=com.cyanogenmod.filemanager/.activities.NavigationActivity} from pid 14771
START u0 {act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] flg=0x10200000 cmp=com.cyanogenmod.filemanager/.activities.NavigationActivity bnds=[334,174][538,301] (has extras)} from pid 681
Start proc com.cyanogenmod.filemanager for activity com.cyanogenmod.filemanager/.activities.NavigationActivity: pid=14792 uid=10018 gids={50018, 1028, 1015, 1023}
Displayed com.cyanogenmod.filemanager/.activities.NavigationActivity: +589ms
```
こんな感じで挙動が見れる

アプリ操作の自動化の際は、Activity遷移やGC動作（メモリ解放）のタイミングを見ることで色々できる
<font color="red">clear_log→具体的な動作→get_log→判別、みたいに書くと画像認識させなくても画面状態が判別できたりする</font>
##画面上の指定した画像をタップさせる
アプリを自動化させる際に、~~ゲーム等では~~動的にオブジェクトが移動するため、プログラム上でオブジェクトを認識させなければならない
そのためそのオブジェクトを画像認識させることで、プログラム上で処理させることができる
具体的なやり方として

1.ADBコマンドで画面内のスクリーンショットを取る
2.1のスクリーンショットを元に、該当する画像（テンプレート）がないかOpenCVで画像認識させる
3.画像認識をもとに動作を決定する

といった方法が最も簡潔だった

利点としては
・<font color="red">ウィンドウが最小化されててもバックグラウンド動作する</font>
・またウィンドウサイズに関わらず画像認識が機能し、さらに座標の取り方が楽

###手順1
ADBコマンドで画像内のスクリーンショットをとり、PC内部に保存する場合は

```Python:コード
_DIR_ANDROID_CAPTURE = "/sdcard/_capture.png"
_NAME_INTERNAL_CAPTURE_FOLDER = "pics"

def capture_screen(dir_android, folder_name):
    _cmd = "nox_adb shell screencap -p " + dir_android
    _pipe = send_cmd_to_adb(_cmd)
    
    _cmd = "nox_adb pull " + dir_android+ " " + folder_name
    send_cmd_to_adb(_cmd)


capture_screen(_DIR_ANDROID_CAPTURE, _NAME_INTERNAL_CAPTURE_FOLDER)
```

で、D:/Program Files/Nox/bin/pics/_capture.pngが生成される
またテンプレートを作成する際は、この画像をもとに作ると、Noxのウィンドウサイズに関係なく作成することができる

###手順2
具体的には以下のように、画像内でテンプレートに一致する部分の中心座標を返すといった方法が考えられる
main.pyと同じディレクトリにimg/temp.pngのテンプレートがあるときは

```Python:コード
import cv2
import numpy as np

_DIR_INTERNAL_CAPTURE = "D:/Program Files/Nox/bin/pics/_capture.png"
_DIR_TEMP = "img/temp.png"
_THRESHOLD = 0.9 #類似度

def get_center_position_from_tmp(dir_input, dir_tmp):
    _input = cv2.imread(dir_input)
    _temp = cv2.imread(dir_tmp)

    gray = cv2.cvtColor(_input, cv2.COLOR_RGB2GRAY)
    temp = cv2.cvtColor(_temp, cv2.COLOR_RGB2GRAY)

    _h, _w = _temp.shape

    _match = cv2.matchTemplate(_input, _temp, cv2.TM_CCOEFF_NORMED)
    _loc = np.where(_match >= _THRESHOLD)
    try:
        _x = _loc[1][0]
        _y = _loc[0][0]
        return _x + _w / 2, _y + _h / 2
    except IndexError as e:
        return -1, -1


get_center_position_from_tmp(_DIR_INTERNAL_CAPTURE , _DIR_TEMP)
```
と書ける

###手順3
手順2で取得した座標をタップさせたい場合は、先程のtapメソッドとget_center_position_from_tmpメソッドより

```Python:コード
_DIR_INTERNAL_CAPTURE = "D:/Program Files/Nox/bin/pics/_capture.png"
_DIR_TEMP = "img/temp.png"

x,y = get_center_position_from_tmp(_DIR_INTERNAL_CAPTURE, _DIR_TEMP)
tap(x, y)
```
と記述できる

具体例として、以下のような画面を
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202086/03db79a4-7782-b4eb-fc5f-48d0140e2fbd.png" width=50%>
テンプレート
![temp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202086/73d6cfa0-b35c-cb41-27d1-56ab6284f9cf.png)
でタップしたいときは、<font color="red">スクショ取って画像を、GIMPとかで該当部分を切り抜いて、img/tmp.pngに置く</font>
<font color="red">その後プログラム上でcapture_screenメソッド→get_center_position_from_tmpメソッド→tapメソッドの順番で実行</font>させればいい
つまり今までのを、まとめると

```Python:コード
from subprocess import run, PIPE
import cv2
import numpy as np

_DIR_NOX = "D:/Program Files/Nox/bin"
_DIR_ANDROID_CAPTURE = "/sdcard/_capture.png"
_NAME_INTERNAL_CAPTURE_FOLDER = "pics"
_DIR_INTERNAL_CAPTURE = "D:/Program Files/Nox/bin/pics/_capture.png"
_DIR_TEMP = "img/temp.png" #ここにブックマークの画像を入れた
_THRESHOLD = 0.9 #類似度

def main():
   capture_screen(_DIR_ANDROID_CAPTURE, _NAME_INTERNAL_CAPTURE_FOLDER)
   x, y = get_center_position_from_tmp(_DIR_INTERNAL_CAPTURE, _DIR_TEMP)
   tap(x, y)

def capture_screen(dir_android, folder_name):
    _cmd = "nox_adb shell screencap -p " + dir_android
    _pipe = send_cmd_to_adb(_cmd)

    _cmd = "nox_adb pull " + dir_android+ " " + folder_name
    send_cmd_to_adb(_cmd)

get_center_position_from_tmp(_DIR_INTERNAL_CAPTURE , _DIR_TEMP)

def get_center_position_from_tmp(dir_input, dir_tmp):
    _input = cv2.imread(dir_input)
    _temp = cv2.imread(dir_tmp)

    gray = cv2.cvtColor(_input, cv2.COLOR_RGB2GRAY)
    temp = cv2.cvtColor(_temp, cv2.COLOR_RGB2GRAY)

    _h, _w = _temp.shape

    _match = cv2.matchTemplate(_input, _temp, cv2.TM_CCOEFF_NORMED)
    _loc = np.where(_match >= _THRESHOLD)
    try:
        _x = _loc[1][0]
        _y = _loc[0][0]
        return _x + _w / 2, _y + _h / 2
    except IndexError as e:
        return -1, -1

def doscmd(directory, command):
    completed_process = run(command, stdout=PIPE, shell=True, cwd=directory, universal_newlines=True, timeout=10)
    return completed_process.stdout

def send_cmd_to_adb(cmd):
    return doscmd(_DIR_NOX, cmd)

def tap(x, y):
    _cmd = "nox_adb shell input touchscreen tap " + str(x) + " " + str(y)
    send_cmd_to_adb(_cmd)


if __name__ == '__main__':
   main()
```
となり、実際の挙動は
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202086/32e0c474-ef5f-c84f-05fc-cf6f0540c0e0.gif" width=50%>
となるはずである

##おまけ
###ログが消えないようにする
実行後にすぐログが消えるとprintした内容がわからなくなるので、ログが残るようにする必要がある
そのためには、マクロ終了時にa = input()を記述する

```Python:コード
def main():
    print("Start")

    #処理部
    
    print("Finish")
    _a = input()

if __name__ == "__main__":
    main()
```

###ADBコマンドで使うやつ

```Python:コード
##タップ
def tap(x, y):
    _cmd = "nox_adb shell input touchscreen tap " + str(x) + " " + str(y)
    send_cmd_to_adb(_cmd)

##スワイプ
def swipe(x1, y1, x2, y2, seconds):
    _millis = seconds * 1000
    _cmd = "nox_adb shell input touchscreen swipe " + str(x1) + " " + str(y1) + " " \
           + str(x2) + " " + str(y2) + " " + str(_millis)
    send_cmd_to_adb(_cmd)

##ロングタップ
def long_tap(x, y, seconds):
    swipe(x, y, x, y, seconds)

##アプリ（アクティビティ）スタート
def start_app():
    _cmd = "nox_adb shell am start -n com.hoge.fuga/.MyActivity"
    send_cmd_to_adb(_cmd)

##アプリストップ
def start_app():
    _cmd = "nox_adb shell am force-stop com.hoge.fuga"
    send_cmd_to_adb(_cmd)

##ホームに戻る
def return_home():
    _cmd = "nox_adb shell input keyevent KEYCODE_HOME"
    send_cmd_to_adb(_cmd)

##端末の日付を変える（この方法だけ情報がまとまってなかったので一応記載）
import datetime

def set_date(delta_days):
    _ANDROID_DATE_FORMAT = "%Y%m%d.%H%M%S"
    _day = datetime.datetime.today() - datetime.timedelta(days=delta_days)
    _days_ago = _day.strftime(_ANDROID_DATE_FORMAT)
    _cmd = "nox_adb shell date -s " + date_2days_ago
    send_cmd_to_adb(_cmd)
```

###exeファイル化する
Pyinstallerを使ってexe化すると捗る

以下のサイトを参考にするといい（書くの面倒くさい）
・[PyInstallerでexeファイル化 - Qiita](https://qiita.com/takanorimutoh/items/53bf44d6d5b37190e7d1)

Pycharmで使う場合は以下も参照、aerobiomatのExternalToolsを使う方法が正解
・[Configuring Pycharm to run Pyinstaller](https://stackoverflow.com/questions/33906539/configuring-pycharm-to-run-pyinstaller)

###設定ファイルを作る
環境によってNox本体のディレクトリが変わってしまう場合があるが、exeファイル化してしまうとプログラムを変更することができなくなってしまう
したがって予めconfig.iniファイルを作る必要がある
今回の例では、exeファイルとconfig.iniが同じディレクトリにあることを想定して、

```Python:main.py
import configparser

def main():
    config_ini = configparser.ConfigParser()
    config_ini.read('config.ini', encoding='utf-8')

    _ADB_DIR = config_ini['DEFAULT']['NoxDirectory']

    print("start macro")

    #処理部

    print("finish macro")
    _a = input()

if __name__ == "__main__":
    main()
```

```ini:config.ini
[DEFAULT]
NoxDirectory = D:/Program Files/Nox/bin
```

と書くことができる