Aidemy Tech Blog

機械学習・ディープラーニング関連技術の活用事例や実装方法をまとめる、株式会社アイデミーの技術ブログです。

超♪人♪気♪リズムゲーム『デレステ』『ミリシタ』『バンドリ』を自動化してみたよ♪♪

f:id:namahoge:20171029130717g:plain

(↑教師データ。あと、自動化したのはゲームの認識部分です)

はじめまして。なま @namahoge です。

人工知能の勉強をはじめて半年ほどのひよっこです 🐣

Aidemyの研修の一貫でブログを書くことになったので、とりあえず、最近勉強したばかりの簡単な人工知能を使ってリズムゲームの認識部分を自動化してみました。Pythonを使います。

(ね、念のために言っておきますが、自動ロボットを作って学校行ってる間もプレイしてもらおうとか、イベントでランキングTop100に入りたいだとか、そっ、そんなやましい考えは全然一切これっぽっちもありませんよっ!)



どんなシチュエーション?

ロボットが主人(?)の端末を借りて、ゲームの内容をカメラで認識し勝手にプレイする様子をイメージしています。

なので今回は、端末の内部にアクセスしたりはせず、カメラを使って外部からゲーム画面を取得します。

音符を探せばいいんでしょ?簡単じゃん?? と思われるかもしれませんが(いやごもっともではありますが)、それが地味に難しいんです。バンドリのキャプチャ画像がそれをよく示していたので↓

f:id:namahoge:20171029224815p:plain

f:id:namahoge:20171029224957p:plain

このように音符がまるで影分身しているように見えたり、エフェクトで音符が隠れたり、長押しの部分が背景に同化していたり、、、という感じです。

ごりごりの画像処理で自動化しようとすると、ノイズや背景、エフェクトに対して処理を行ったり、各色の音符について代表的な音符の画像を用意したり、様々なことをしなければなりません。

ところが、最近はやりの人工知能を使うと、教師データさえ用意すれば難しいことは考えなくても勝手に自動化できるそうではありませんか......!(茶番)

しかも人工知能による自動化プログラムは汎用的で、いろいろなリズムゲームがあっという間に自動化できる、ということらしいです(恐らく)。

ということで今回は人工知能と呼ばれるプログラムを書いて自動化にチャレンジしていくことにしました。

どんな人工知能使うの?

今回はCNNと言う人工知能(より正確には深層学習手法)を使い、学習器を作りました。

CNNを超噛み砕いて素人なりにがんばって説明してみます。。

例えば、あるポスターを渡されて、CNNがそれをリゼロのポスターだと判定する流れはこんな感じです。

与えられた画像(エミリア(銀髪)、レム(青髪)、ラム(桃髪)、スバル(黒髪)を並べた画像)

f:id:namahoge:20171030021836p:plain

  1. Convolution
    与えられた画像に対し、端から、エミリアっぽさ、レムっぽさ、ラムっぽさ、スバルっぽさをスコアリングする。この作業を Convolution という。この作業によって、画像から部分領域ごとに注目すべき特徴量を抽出できる。↓はスバルっぽさを調べているところのイメージ。 f:id:namahoge:20171030031106p:plain
    Convolution の結果データ(紫がエミリア、青がレム、赤がラム、黒がスバル)
    f:id:namahoge:20171030142339p:plain
    ○で囲ったデータが示すように、それぞれのキャラクターがいる場所にしっかり反応しています。
  2. Pooling
    データをいくつかに分割し、それぞれのブロック内で、それぞれの特徴量の最大値を抽出する。この作業を Pooling という。この作業によって、興味のないデータを捨ててデータを圧縮する。
    f:id:namahoge:20171030033851p:plain
    Pooling の結果データ
    f:id:namahoge:20171030031855p:plain

  3. Dense
    Poolingの結果残ったデータから、総合的に真偽を判断する。 今回は、上のデータから画像の中にまんべんなくリゼロのキャラクターがいることがわかるので、 与えられた画像はリゼロのポスターであると判断を下す。

このようにCNNを使うと、画像の中の特徴量をある程度の位置情報を残しながら高い情報量密度で抽出できます。

実際にCNNを使う際には、キャラクターの顔を探せばいいことや、リゼロのキャラクターはどんな顔なのか、またPooling結果からどう総合判断するか、などは最初は学習器はわかりませんが、これらは学習器が誤差逆伝播法(という数学チックな手法)などで勝手に学習してくれます。

今回はわかりやすさのためにCNNがいきなり顔っぽいものを探す様子を書きましたが、実際に使われるCNNの多くは、初めに与えられた画像から「直線」や「角」といった低次元の特徴量を探しだし、その特徴量の分布から「輪郭」や「凹凸」といった少し高次元の特徴量を抽出、最後に「顔」を認識する、という感じでもう少し長い過程を必要とします。

今回抽出したいのは流れてくる音符(?)という比較的低次元の概念のものなので、単純な学習器で良いと考え、Convolution、Poolingはともに1回だけの簡単な学習器を作りました。

準備

まずはデレステの自動化に挑戦していきますっ!

import cv2
import numpy as np

映像データを作る

ゲームの映像の取得はウェブカメラでできます。以下のサンプルを適当に使ってください。

ウェブカメラを使う

cap = cv2.VideoCapture(0)
while(True):
    ret, frame = cap.read()
    cv2.imshow('frame', frame)
    if cv2.waitKey(1) == 27:
        break
cap.release()
cv2.destroyAllWindows()

動画を撮影

def record():

 movie = []
 cap = cv2.VideoCapture(0)

 for i in range(1000):
     ret, frame = cap.read()
     movie.append(frame)
     cv2.imshow('frame', frame)
     if cv2.waitKey(1) == 27:
         break

 cap.release()
 cv2.destroyAllWindows()
 return np.array(movie)

動画を保存

def save(movie, name):
    # nameには 'hoge.mp4' などを渡す

    result = name
    fps    = 30.0
    height = movie.shape[1]
    width  = movie.shape[2]
    fourcc = cv2.VideoWriter_fourcc('m', 'p', '4', 'v')
    out = cv2.VideoWriter(result, int(fourcc), fps, (int(width), int(height)))

    for i in range(movie.shape[0]):
        out.write(movie[i])
    out.release()

動画の読み込み

def read(name):
    # nameには 'hoge.mp4' などを渡す
    
    movie = []
    cap = cv2.VideoCapture(name)

    for i in range(1000):
        ret, frame = cap.read()
        frame = cv2.resize(frame, (160, 120))
        movie.append(frame)
        cv2.imshow('frame', frame)
        if cv2.waitKey(1) == 27:
            break

    cap.release()
    cv2.destroyAllWindows()

    return np.array(movie)

動画を numpy.ndarray 形式でそのまま保存、読み込み

np.save('hoge.npy', movie)
movie = np.load('hoge.npy')

最後に

np.save('deresute_X', movie)

教師データを作る

どんなふうに教師データを作ってもいいのですが、映像を1フレームずつ見ながらデータを入力していきたかったので、↓のコードを書きました。 デレステの教師データは、各フレームに対し、5つのボタン、その間の4箇所、どこもタップしない、の計10種類のラベルに分類したものとしました。 リズムゲームは同時に複数箇所タップしないといけなかったりするので、一つのフレームに正解ラベルがいくつもある、ということが起こりますが問題ありません。

movie = read('hoge.mp4')

num_train = 100 #何フレームに教師ラベルをつけるか
t0 = 25 #何フレーム目から教師ラベルをつけるか
labels = []

for t in range(t0, t0+num_train):
    cv2.imshow('frame', movie[t])
    cv2.waitKey(100)
    while True:
        label = np.array([int(y) for y in (input("%d > "%t)).split()])
        #配列の大きさを整えるため、各フレームに与えるラベルの数を決めておく。
        if label.shape[0] == 4: break 
    labels.append(label)
    cv2.waitKey(100)
cv2.destroyAllWindows()
labels = np.array(labels)

#0-1のデータに変換する
n = 10 #デレステの場合 n=10、ミリシタの場合 n=12、バンドリの場合 n=14
labels = [np.sum(np.eye(n)[label], axis=0) for label in labels]
labels = np.array(labels)
labels = np.where(labels > 1, 1, labels)

最後に

np.save('deresute_T_25-124.npy',labels)

データ作成の様子

f:id:namahoge:20171030124138p:plain

出来上がった教師データ

f:id:namahoge:20171030124741p:plain

学習

今回は、3フレーム分未来のタップ位置を予測する学習器を作りました。

import tensorflow as tf
from sklearn.utils import shuffle

# 乱数のシード値の設定
rng = np.random.RandomState(2525)
random_state = 39

X_data = np.load('deresute/deresute_X.npy')
Y_data = np.load('deresute/deresute_T_25-124.npy')
print(X_data.shape, Y_data.shape) # -> (1000, 120, 160, 3) (100, 10)

train_X = ((X_data[22:122] -X_data.mean())/X_data.std())
train_Y = Y_data # <- 120:220 # 3フレーム分未来の予測をする

train_X には、規格化(平均を引いて標準偏差で割る)を施していますが、そうすることで学習速度が向上します。

まずはCNNに使われるいくつかの作業を記述したクラスを宣言します。

これらはどんなCNNプログラムにも使えるので一度他で使ったことのあるものを使いまわしています。

Conv

class Conv:
    def __init__(self, filter_shape, function=lambda x: x, strides=[1,1,1,1], padding='VALID'):
        # Xavier Initialization
        fan_in = np.prod(filter_shape[:3])
        fan_out = np.prod(filter_shape[:2]) * filter_shape[3]
        self.W = tf.Variable(rng.uniform(
                        low=-np.sqrt(6/(fan_in + fan_out)),
                        high=np.sqrt(6/(fan_in + fan_out)),
                        size=filter_shape
                    ).astype('float32'), name='W')
        self.b = tf.Variable(np.zeros((filter_shape[3]), dtype='float32'), name='b') # バイアスはフィルタごとなので, 出力フィルタ数と同じ次元数
        self.function = function
        self.strides = strides
        self.padding = padding

    def f_prop(self, x):
        u = tf.nn.conv2d(x, self.W, strides=self.strides, padding=self.padding) + self.b
        return self.function(u)

Pool

class Pooling:
    
    def __init__(self, ksize=[1,2,2,1], strides=[1,2,2,1], padding='VALID'):
        self.ksize = ksize
        self.strides = strides
        self.padding = padding
    
    def f_prop(self, x):
        return tf.nn.max_pool(x, ksize=self.ksize, strides=self.strides, padding=self.padding)

Flatten

class Flatten:
    def f_prop(self, x):
        return tf.reshape(x, (-1, np.prod(x.get_shape().as_list()[1:])))

Dense

class Dense:
    def __init__(self, in_dim, out_dim, function=lambda x: x):
        # Xavier Initialization
        self.W = tf.Variable(rng.uniform(
                        low=-np.sqrt(6/(in_dim + out_dim)),
                        high=np.sqrt(6/(in_dim + out_dim)),
                        size=(in_dim, out_dim)
                    ).astype('float32'), name='W')
        self.b = tf.Variable(np.zeros([out_dim]).astype('float32'))
        self.function = function

    def f_prop(self, x):
        return self.function(tf.matmul(x, self.W) + self.b)

こっそり Flatten なるものを追加しましたが、これは Pooling で得られたデータを一次元配列に変換するだけの作業です。

ネットワークの構築

layers = [
    Conv(filter_shape=[28,28,3,5], function=tf.nn.relu, strides=[1,4,4,1]), # 120x160x3 -> 34x24x5
    Pooling(ksize=[1,2,2,1], strides=[1,2,2,1]), # 34x24x5 -> 17x12x5
    Flatten(),
    Dense(17*12*5, Y_data.shape[1])
]

x = tf.placeholder(tf.float32, [None, 120, 160, 3])
t = tf.placeholder(tf.float32, [None, Y_data.shape[1]])

def f_props(layers, x):
    for layer in layers:
        x = layer.f_prop(x)
    return x

y = f_props(layers, x)

cost = tf.reduce_mean(tf.nn.sigmoid_cross_entropy_with_logits(logits=y, labels=t))
train = tf.train.GradientDescentOptimizer(0.02).minimize(cost)

valid = tf.cast(tf.rint(tf.sigmoid(y)),dtype=tf.int32)

学習

n_epochs = 100
batch_size = 10
n_batches = train_X.shape[0]//batch_size

init = tf.global_variables_initializer()
sess = tf.Session()
sess.run(init)
for epoch in range(n_epochs):
    train_X, train_Y = shuffle(train_X, train_Y, random_state=random_state)
    for i in range(n_batches):
        start = i * batch_size
        end = start + batch_size
        sess.run(train, feed_dict={x: train_X[start:end], t: train_Y[start:end]})
    pred_Y, valid_cost = sess.run([valid, cost], feed_dict={x: train_X, t: train_Y})
    _W = sess.run(layers[0].W)
    _W = ((_W - _W.min()) * 255 / _W.max()).astype(np.uint8)
    cv2.imshow('w1', _W[:,:,:,0])
    cv2.waitKey(50)
    if epoch%10 == 0:
        score = np.sum(np.abs(pred_Y - train_Y.astype(np.int32)))
        print(score, end=' ')
        print('EPOCH:: %i, Validation cost: %.3f' % (epoch + 1, valid_cost))

学習の様子

f:id:namahoge:20171030131724p:plain

結果(まぁまぁうまくいったよっ)

教師データがない部分も含めて予測を行います。

一度に大きいtestデータを作るとパソコンのメモリがパンクするので、いくつかに分割します。

for i in range(10):
    test_X = ((X_data[100*i:100*(i+1)] - X_data.mean())/X_data.std())
    pred = sess.run(valid, feed_dict={x: test_X})
    np.save('deresute_pred' + str(i) + '.npy', pred)

pred = np.zeros([0,Y_data.shape[1]], dtype=np.int32)
for i in range(10):
    pred = np.append(pred, np.load('deresute_pred' + str(i) + '.npy'), axis=0)
print(pred.shape) # -> (1000, 10)

学習結果の視覚化

def overlay_(x, y, points):
    img = x.copy()
    for k in range(1,y.shape[0]):
        if y[k] == 1:
            cv2.circle(img, (points[k-1,1], points[k-1,0]), 20, (255,0,0), 3)
    return img

def overlay(X, Y, points):
    imgs = []
    for t in range(X.shape[0]):
        img = overlay_(X[t], Y[t], points)
        imgs.append(img)
    imgs = np.array(imgs)
    return imgs

bandori_points = np.zeros((13,2)).astype(np.uint16)
bandori_points[:,0] = 210
bandori_points[:,1] = np.arange(22, 297, 320/14)
deresute_points = np.zeros((9,2)).astype(np.uint16)
deresute_points[:,0] = 184
deresute_points[:,1] = np.arange(42, 281, 238/8)
mirisita_points = np.zeros((11,2)).astype(np.uint16)
mirisita_points[:,0] = 184
mirisita_points[:,1] = np.arange(42, 281, 238/10)
mov1 = overlay(movie[3:1000],pred[0:997], mirisita_points)

こんな感じになりました↓

 (冒頭の約6秒を教師データとして使いました。)

youtu.be

場所によって正解率が悪かったり長押しの精度が微妙だったりはしますが、だいたいの音符に一応反応できている感じはするかなというレベルでした。

ただ、今回はたった6秒分しか教師データを用意していなくてここまでできたので、十分な長さ(3曲分とか?)の教師データを作れば、全ての曲でフルコンボがとれるようになるのではないのかなぁと思っています。。

ここからが本題

さて、人工知能(今回は深層学習手法)のスゴイところはその汎用性という話だったので、他のリズムゲームでもやってみましたっ

ミリシタの教師データ

youtu.be

ミリシタの全予測

youtu.be

バンドリの教師データ

youtu.be

バンドリの全予測

youtu.be

ちょっと微妙な感じではありますが、学習したんだなというのは伝わってきます(よね)。十分な数の教師データがあればどのゲームでも自動化できそうです。

最後に

CNNを使えば簡単に自動化ができそうだということがわかってもらえたと思います、が、一応注意書きしておくとゲームの完全な自動化は恐らく全てのゲームで利用規約に反してしまいます。。。

例えばバンドリの利用規約には

第6条(禁止事項)
(19)(中略)BOT、チートツール、その他技術的手段を利用してサービスを不正に操作する行為
(26)その他当社が不適切だと判断する行為

とあるので、ゲーム操作まで自動化してはいけませんよ〜(“真面目に”頑張ってる他のプレーヤーにとってもあまりよくないですしね、たぶん(?)。(自分に言い聞かせてる))


github リポジトリ載せときます!

いろいろデータを入れてあるので、jupyterでエンター連打するだけで実行できます♪

深層学習や予測データの可視化までできるようにしてありますっっ

github.com


最後まで読んでくださってありがとうございましたっっ!!

(精一杯小声で…面白かったらいいねくださいっ♪)

参考1:リゼロの画像
https://pbs.twimg.com/media/Cik8sVYUgAIgO00.jpg