steamで売ってるような一般のゲームを強化学習したかった

# はじめに

この記事ではこのプロジェクトのメインとなるアイデアは解説しますが，強化学習そのものの解説はしません．
よって，元コードの大半は割愛して解説しません．

この記事の結論は，「普通のゲームには加速機能なんてないので，強化学習しようとするとありえない時間がかかる」です．一日二日で収束なんてしません．

この試みは一年くらい前のものですが，もう触ることもないと思うので記事にして供養します．

コード(の残骸)はここで見れます．筆者の手元で計算した画面の座標を使用しているなど欠点は多いので，そのままでは動きません．

https://github.com/Mikka-t/AI_for_KSP

今見ると無駄というか，変な処理がある

## 何をしたかったのか

Kerbal Space Program (以下KSP) という一部界隈で有名なゲームで，ロケットの操作を学習させたかった．

ロケットは燃料への着火タイミングやロケット自体の角度を調整できるが，これを強化学習でうまいこと操作させ，惑星の周回軌道に乗せるようなタスクを設定した．

# 実装解説

KSPの画面から画像認識で情報を取り，pyautoguiなどでゲームを操作する．これをgymのフォーマットに合わせることで，gymを使った強化学習のコードに放り込めるようにした．

学習自体はchainerRLを用いて，比較的単純な学習をしています．

## 報酬関数

KSPにはロケットの周回軌道のAP(遠点)とPE(近点)が周回軌道を把握する上で重要な数値として出てきます．

PE，すなわち軌道の中で惑星に最も近い点の高度が高ければ高いほど，より遠くの大きい軌道まで飛ばすことができた，すなわちロケットの打ち上げが上手くいったと言えます．

よって，PEを報酬とします．
しかし学習初期は地面に墜落しまくる = PEが常に0となるので学習が進みません．
APを小さめにして報酬に組み込むことで，学習初期はひたすら遠くに飛ばすことを学ばせます．

``` py
def calc_reward(ap, pe):
    return pe + (ap//10)
```

## gym.Env

KSPのロケットの操作は主にWASDでの姿勢制御と，Spaceを押すことによる燃料の着火です．

reset関数では事前に取得した座標を用いて，打ち上げ前に戻るためのクリック操作やキー押下をpyautoguiにさせています．
ゲーム側の処理速度が追い付かないのでsleep()で調整．

画像認識部分 (recog) は後述

``` py    
# 画面から画像認識で取得した数字を観測結果に整形
def recog2stat(height, isFuelEmpty, isFlightOver, ap, pe):
    fuelEmpty = 1 if isFuelEmpty else 0
    ret = np.array([height, ap, pe, fuelEmpty])
    return ret

# キー押下する関数
def do_action(action):
    time = 0.75 # アクションにかける時間
    act_dic = {0:"w", 1:"s", 2:"a", 3:"d", 4:"space"}
    if action in act_dic:
        key = act_dic[action]
        pyautogui.keyDown(key)
        sleep(time)
        pyautogui.keyUp(key)
    else:
        key = "nothing"
    print("action:",key)
    sleep(time)

class KSPgym(gym.Env):
    def __init__(self):
        super(KSPgym, self).__init__()
        # エージェントが取りうる行動(キー押下)空間
        # 0: w, 1:s, 2:a, 3:d, 4:space, 5:do nothing
        self.action_space = gym.spaces.Discrete(6) 
        
        # height, AP, PE
        # エージェントが受け取りうる観測空間
        self.observation_space = gym.spaces.Box(low=np.array([-np.inf,-np.inf,-np.inf,0]), high=np.array([np.inf,np.inf,np.inf,1])) 
        # 報酬の範囲
        self.reward_range = (-np.inf, np.inf)
        
    def reset(self):
        position_of_uchiagemae = (988, 537)
        position_of_uchiagemae2= (1019,576)
        pyautogui.click(*position_of_uchiagemae) # 打ち上げ前に戻る
        pyautogui.click(*position_of_uchiagemae) # 打ち上げ前に戻る
        pyautogui.click(*position_of_uchiagemae2) # 打ち上げ前に戻る
        pyautogui.click(*position_of_uchiagemae2) # 打ち上げ前に戻る
        sleep(1)
        pyautogui.keyDown("f9")
        sleep(3)
        pyautogui.keyUp("f9")
        print("loading...")
        sleep(10)
        for i in range(5):
            print(f"starting in {5-i} seconds...")
            sleep(1)
        pyautogui.click(360,878)
        print("changed mode")
        sleep(0.2)
        pyautogui.press("t")
        print("pressed T")

        rec = recog_num.recog()
        obs = recog2stat(*rec)
        return obs

    def step(self, action):
        do_action(action) # キー押下
        ret = recog_num.recog()
        obs = recog2stat(*ret)

        height, isFuelEmpty, isFlightOver, ap, pe = ret
        done = isFlightOver
        # 近点の高度、　遠点の高度、近点と遠点の差の小ささ
        reward = calc_reward(ap,pe)

        info = {}
        return obs, reward, done, info

    def render(self):
        pass
  
    def close(self):
        pass

    def seed(self, seed=None):
        pass
```

## 画像認識

![point210_825.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609800/061694b6-48ac-08c1-dccb-62c2170de1da.jpeg)
![height733_190.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609800/8f8a53b7-b9aa-654e-7b6f-a834cf411c12.jpeg)

数字が書いてある部分をまとめて取って来たり一個ずつ取って来たりして，画像認識で数字に直す．
強化学習エージェントの目になる部分．処理速度は決して早くない．

画像認識周りは [ゲーム画面の画像認識 → 文字認識 → 文字起こし の流れをリアルタイムに行う](https://qiita.com/onaka_yurusugi/items/7fe2bacb7ede88eadd1b) を参考にさせて頂きました．

### 座標取得

`pyautogui.position()`でカーソルの位置を取得できるので，これを使って取得したい範囲の左上の座標と，右下までの相対座標を取得・計算する．

``` py
x, y = pyautogui.position()
```

### メイン

ScreenShotは単純に指定した座標をスクショして保存する関数．

captureはPILを用いて，画像から文字を認識する関数．
文字認識かつあまり性能が良いとは言えないので，誤認識の処理などをする関数prefixを用意しています．

recogの上半分は画像を取ってきて数字にして格納する部分．
recogの下半分は汚いですが，認識した数字を状態として整形する部分．

なお，toolやlangはpyocrの変数．pyocrの使い方はここでは解説しません．

``` py
import pyocr
import pyocr.builders
import pyautogui
import cv2
from PIL import Image

def ScreenShot(x1, y1, x2, y2, misc):
    sc = pyautogui.screenshot(region=(x1, y1, x2, y2))
    sc.save("images/"+misc+str(x1)+"_"+str(y1)+'.jpg')
    img = cv2.imread("images/"+misc+str(x1)+"_"+str(y1)+'.jpg')
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    tmp = cv2.resize(gray, (gray.shape[1]*2, gray.shape[0]*2), interpolation=cv2.INTER_LINEAR)
    cv2.imwrite("images/"+misc+str(x1)+"_"+str(y1)+'.jpg', tmp)
    
def capture(misc, pos_str):
    txt = tool.image_to_string(
        Image.open("images/"+misc+pos_str),
        lang="eng",
        builder=pyocr.builders.TextBuilder(tesseract_layout=TESSERACT_LAYOUT)
    )
    print(","+misc+" "+txt+" ", end="")
    return prefix(txt, misc)
    
def recog():
    positions = [[733,190,20,25,"height"],[755,190,20,25,"height"],[777,190,20,25,"height"],
                    [202,688,45,12,"fuel"],[210,825,80,17,"point"],[295,825,80,17,"point"]]
    dat = [0]*len(positions)

    for i in range(len(positions)):
        x1, y1, x2, y2 = positions[i][0], positions[i][1], positions[i][2], positions[i][3]
        misc = positions[i][4]

        flag = 1
        while flag == 1:
            ScreenShot(x1, y1, x2, y2, misc)
            flag, num = capture(misc, str(x1)+"_"+str(y1)+'.jpg')
            dat[i] = num
        
    height = dat[0]*100 + dat[1]*10 + dat[2]
    isFuelEmpty = dat[3]==0
    isFlightOver = dat[3]==123456
    if dat[3]==-1 and height==0:
        isFlightOver = True # 初期状態の燃料は読み取り可能という前提で、墜落時True
    ap = dat[4] # 遠点
    pe = dat[5] # 近点
    return height, isFuelEmpty, isFlightOver, ap, pe
    
```

### 誤認識の処理

このプロジェクトで一番汚く，一番人に見せられないコードです．

画像 → 数字における誤認識はある程度パターンがあるので，その全てを記録して無理やり直しています．
数字の1が英語のIになるとか．

長いので，元コードのうち高度を処理する部分だけ抜粋しています．

他にもkmとmを区別して，kmが小数表記ならmに直す処理などもしています

``` py
def prefix(txt, misc):
    isMinus = False
    if misc == "height":
        dic4height = {"6":"0", "0":"0", "l":"1", "I":"1", "1":"1", "の":"2", "2":"2", "3":"3", "4":"4", "ら":"5", "5":"5",
                       "ム":"6", "6":"6", "7":"7", "8":"8", "9":"9"}
        try:
            txt = dic4height[txt]
        except:
            return 1, 0

    if txt.isdigit():
        ret = -int(txt) if isMinus else int(txt)
        return 0, ret
    else:
        return 1, 0
```

# 結果

コードの半分以上を割愛し，学習を回す部分などは紹介しませんでしたが，メインとなるアイデアは共有できたかと思います．

そのまま3~4日程度学習させた結果，高度はある程度高い所まで飛べるようになりましたが，周回軌道に乗るまでは行きませんでした．

# 失敗の原因

### 学習効率

機械学習に対応していない市販ゲームでは学習中も通常の速度でプレイすることになるので，当然ながら学習時間がありえないほど長くなります．

動画や人間のプレイを教師データとして事前学習に用いるなどは考えましたが，技術が新しすぎてリファレンスがあまり存在せず，断念……

やはり強化学習のために作られたのではないゲームで強化学習をするなら，UnityでML-Agentsを使う程度が現実的なラインなのかもしれません．

### スクショ・画像認識の精度

KSPは数字をプログラム用に出力するAPIなんて当然備えていないので，画像認識をする必要はありました．

しかし，KSPの高度計のフォントの特殊さもあり，誤認識が多発．

![height733_190.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609800/8f8a53b7-b9aa-654e-7b6f-a834cf411c12.jpeg)
( 0 が 8 と誤認識される)

誤った数字がエージェントに渡されてノイズになっている可能性は大いにあるので，数字の認識はもっと慎重に行うべきでした．

手間がかかるのでやりませんでしたが，KSPに使われるフォントの数字の画像を0~9まで用意して，画像の類似度で判定する方が精度は良くなったと思います．