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

f:id:namahoge:20171029130717g:plain
▲教師データ(後述)。自動化したのはゲームの認識部分です。

みなさんこんにちは!今回は最近勉強したばかりの簡単な人工知能を使ってリズムゲームの認識部分を自動化してみました。Pythonを使います。

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

「ロボットが主人の端末を借りて、ゲームの内容をカメラで認識し勝手にプレイする様子」をイメージしています。そのため今回は、端末の内部にアクセスしたりはせず、カメラを使って外部からゲーム画面を取得します。「音符を探せばいいんでしょ? 簡単じゃん?」 と思われるかもしれませんが、それが地味に難しいんです。下の「バンドリ」のキャプチャ画像がそれをよく示しています。

f:id:namahoge:20171029224815p:plain f:id:namahoge:20171029224957p:plain

このように、音符がまるで“影分身”しているように見えたり、エフェクトで音符が隠れたり、長押しの部分が背景に同化していたり……という感じです。“ごりごり”の画像処理で自動化しようとすると、ノイズや背景、エフェクトに対して処理を行ったり、各色の音符について代表的な音符の画像を用意したり、様々なことをしなければなりません。

ところが、最近はやりの人工知能を使うと、教師データさえ用意すれば難しいことは考えなくても勝手に自動化できるそうではありませんか……!しかも人工知能による自動化プログラムは汎用的で、いろいろなリズムゲームがあっという間に自動化できる、ということらしいです。 ということで、今回は人工知能と呼ばれるプログラムを書いて自動化にチャレンジしていくことにしました。

どんな人工知能を使うの?

今回はCNNという人工知能(より正確には深層学習手法)を使い、学習器を作りました。 CNNについての説明はこちらの記事をお読みください。

準備

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

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 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 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種類のラベルに分類したものとしました。リズムゲームは同時に複数箇所タップしないといけないこともあるので、1つのフレームに正解ラベルがいくつもある、ということが起こりますが、問題ありません。

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秒を教師データとして使いました)

場所によって正解率が悪かったり、長押しの精度が微妙だったりはしますが、だいたいの音符に一応反応できている感じはするかな?というレベルでした。 ただ、今回はたった6秒分しか教師データを用意せずにここまでできたので、十分な長さ(3曲分くらい?)の教師データを作れば、全ての曲で“フルコンボがとれる”ようになるのではないのかなぁと思っています。

ここからが本題

さて、人工知能(今回は深層学習手法)のスゴイところはその汎用性であるという話だったので、他のリズムゲームでもやってみました。
▼「ミリシタ」の全予測

▼バンドリの全予測 

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

最後に

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

例えば「バンドリ」の利用規約には、
第6条(禁止事項) (19)(中略)BOT、チートツール、その他技術的手段を利用してサービスを不正に操作する行為 (26)その他当社が不適切だと判断する行為とあるので、ゲーム操作まで自動化してはいけません。真面目に頑張っている他のプレーヤーにとっても、あまりよくないですしね。)


github リポジトリ載せておきます!

いろいろデータを入れてあるので、jupyterでエンター連打するだけで実行できます。 深層学習や予測データの可視化までできるようにしてあります!
Github naruya/music-game

プログラミング未経験からでもAIスキルが身につくAidemy Premium Plan

PythonやAIプログラミングを学ぶなら、オンライン制スクールのAidemy Premium Planがおすすめです。
「機械学習・ディープラーニングに興味がある」
「AIをどのように活用するのだろう?」
「文系の私でもプログラミング学習を続けられるだろうか?」
少しでも気になることがございましたら、ぜひお気軽にAidemy Premium Planの【オンライン無料相談会】にご参加いただき、お悩みをお聞かせください!