まっそのせかい

やった事のメモとか

CycleGANで推しキャラを相互に変換してみた

はじめに

カードキャプターさくらクリアカード編が終わってしまい絶望したので、GANを使ってさくらちゃんの生成を試みようと思いました。
GANにはいろいろなものが提案されていますが、調査をしているとCycleGAN(論文)という面白そうなGANがある事を知りました。CycleGANでは2種類の画像A,Bを互いに変換する事ができるそうです。そこで今回はCycleGANを使って推しキャラであるさくらちゃん(カードキャプターさくら)と島村卯月(アイドルマスターシンデレラガールズ)の2名を相互に変換する事を試みます。
単にさくらちゃんを生成するだけなら他のGANでも良いと思われますが、単純にCycleGANを試してみたいというのもあり、何より推し2名を相互に変換するという罪深さ(?)に魅入られました。
(両陣営からボコボコにされそう)

今回試した結果、以下のような画像が得られました。

  • 卯月→さくら

f:id:massoumen:20180701191305p:plainf:id:massoumen:20180701191319p:plain

  • さくら→卯月

f:id:massoumen:20180701191645p:plainf:id:massoumen:20180701191648p:plain

良さげなものを載せましたが、全部ここまで綺麗という訳ではないですね…
実際は細部が崩れてたり、見るのがつらいものも多々…(

CycleGANとは

基本的なGANではGenerator(生成器)とDiscriminator(判別器)を1つずつ用意しますが、CycleGANでは2種類のデータA,Bに対しGeneratorとDiscriminatorを2つずつ用意します。 (ここではAがさくらちゃんの顔画像、Bが卯月の顔画像となります)

各GeneratorとDiscriminatorは以下の処理を行います。

  • G_{A→B}:Aを入力しBを生成
  • G_{B→A}:Bを入力しAを生成
  • D_A:本物のAとG_{B→A}で生成されたA(偽物)を分類
  • D_B:本物のBとG_{A→B}で生成されたB(偽物)を分類

CycleGANでは各{G,D}に対し基本的なGANで用いるAdversarial Loss({L_{GAN}})だけでなく、Cycle Consistency Loss({L_{cyc}})という損失を用いているのが大きな特徴になります。
L_{cyc}ではG_{A→B}にAを入力して得られた出力BをG_{B→A}に入れ、最終的に得られた出力A'が元の入力Aとどのくらい近いか(画素値の差)を求めます。Bについても同様です。
L_{cyc}を用いる事で各Generatorに元の入力に戻しやすいような変換をGeneratorに行わせる事ができます。ここではキャラの顔画像を用いるので、顔の向きなどを保った変換を期待できます。

従来提案されているような画像の変換(pix2pixなど)では変換前と変換後のペアを用意して学習する必要があったのが、L_{cyc}を導入する事によりペアである必要がなくなるというのがCycleGANの大きな利点です。今回行うようなキャラクターの変換をpix2pixなどで行う場合、さくらちゃんと卯月の画像のペアが同じ位置や向きをしている必要がありデータを用意する事が難しいです。一方CycleGANでは画像のペアを用意する必要が無く、単にさくらちゃんと卯月の画像がある程度確保できれば学習する事ができます。

L_{cyc}ではA→B→Aと変換した時に元に戻る事ができれば良いので、必ずしも同じ顔の向きで変換が行われる保証は無いと思われます。(直感的には同じ向きや表情で変換する方が戻しやすいとは思いますが…)
この辺りが少し怪しくはありますが、少なくとも論文の画像や今回試した結果を見る感じでは割と上手くいってるので良いという事にします。

今回、コードはここを参考にしました。
Generatorではストライド2の畳み込みでサイズを小さくした後Residual Block(入力を最後に足す事で残差を学習する奴)を用い、最後に転置畳み込みでサイズを元に戻し出力します。
また、Discriminatorでは通常のCNNを利用します。
(論文だとPatchGANを利用していると書いてあった気がしますが、どうなんでしょうね…)

顔画像に関しては以前卯月識別器を作った時のように、アニメをキャプチャしたものから顔部分を切り取って利用しました。
枚数は卯月、さくらちゃん共に約700枚くらいです。

おわりに

変換結果をざっと見た感じ、ある程度は変換できていますがどれも高品質とは言い難かったです。
モデルの改良とかパラメータ調整とかしたらいい感じになるんですかね…
(学習に時間がかかるのであまりパラメータ変化による出力の変化などを見れてません)
とは言うものの、機械学習での変換には普通1対1対応の画像を用意する必要があるので、その必要が無いというのはすごいなと思いました。
CycleGANを多対多にしたStarGANという変換モデルも提案されているそうなので、そのあたりも試してみたいですね。

CycleGANで推しの変換テスト

CycleGANで推し2名(さくらちゃん、島村卯月)の変換を試したのでメモ。
とりあえずサイズは64*64でテスト。
(サイズが小さいので参考コードのDとGの一部層を削除)
うまくいくと以下のような結果になった。

  • 卯月→さくら

f:id:massoumen:20180624025755p:plainf:id:massoumen:20180624025820p:plain

  • さくら→卯月

f:id:massoumen:20180624030021j:plainf:id:massoumen:20180624030024j:plain

微妙な結果のものもあるが、それでも1対1対応でないものからそれらしいものを作れるのはすごいなぁと。
パラメータの調整とかモデルの改良とかしたらさらに綺麗なものができるのだろうか。

Webアプリケーション制作

サークルの先輩がWebアプリケーション制作の資料をアップしていたので、試しにやってみました。
利用する言語はPythonで、最近機械学習とかで遊ぶのに使っていたのでちょうど良いですね。

qiita.com

一通り読んで、試しになんか作ってみようという事で、せっかくなので以前作った卯月識別器をブラウザ上で試せるようなものを作ってみました。

\デデーン/

f:id:massoumen:20180329215512p:plain

はい。見た目の調節とかほぼ何もしていないので雑さが目立ちますね。
いくつかサンプルとして画像を見る事ができるようなってます。
(無駄に)データベース使って画像のパスを管理してみたり。

f:id:massoumen:20180329215724p:plain

手持ちの画像を与えて識別させる事もできます。こっちは識別のために裏でCNNによる計算処理が行われています。

f:id:massoumen:20180329215934p:plain

GPUのせいなのか何なのか良く分かりませんがたまにPythonが死にます。
問題だらけのWebアプリですね。

f:id:massoumen:20180329220150j:plain

ブラウザ上であれこれポチポチするとちゃんと反応するの、なかなか面白いですね。
せっかくなので試しに公開とかしてみたいんですが、どうすれば公開できるんでしょう。
何も分かっていません。あとできればお金かけたくない

声質変換に挑戦しました

はじめに

機械学習を用いて以前キャラクターの識別について遊んだので、次は画像以外で何か遊んでみたいなーって思い、「そうだ、声質変換に挑戦しよう」と思い立ったので挑戦しました。
地獄を見る事になるとは思いもよらなかったのであった…

声質変換とは、ある人の声を他の人の声に変換する事を言います。
これが上手くいった場合自分の声を好きな人の声にする事ができます。 夢が広がりまくりですね。 現実は非情であった

ちょっと調べてみた感じ声質変換にはいくつか種類があり、特定の人の声から特定の人へ変換する一対一変換、任意の声から特定の人の声へ変換する多対一変換、その逆の一対多、任意の声から任意の声へ変換する多対多があるようです。今回は一番シンプルそうな一対一変換に挑戦します。

今回の目標

さて、変換する声は当然自分の声になる訳ですが、誰の声に変換するかをまずは決めます。
突然ですが私、みくにゃんが大好きなんですよ。

f:id:massoumen:20180319205921p:plain

みくにゃんというのはアイドルマスターシンデレラガールズに登場する猫系アイドルで、アニメにも登場しています。
猫系アイドルなのに魚が嫌いというすさまじい性質の持ち主だったりします。あとおっぱい

今回は機械学習を用いて自分の声をみくにゃんの声に変換するプログラムを作成するのを目標にしようと思います。

結果

先に結果を載せようと思います。まずは「アイドルマスターシンデレラガールズ」と喋ったものの変換結果です。

目標(みくにゃんの声)

変換前(僕の声)

変換結果

みくにゃんに近づいてるとは思いますが、何とも言えない微妙感ですね。機械感も強いです。
激甘評価をするならまぁまぁみくにゃんです。(自分に甘い)
とは言うものの、このデータの組は機械学習の際に用いたものなので、イイ感じに変換できて当然と言えば当然です。(イイ感じ…?)
本当に大事なのは学習データに含まれていないものがどう変換されるかという所だと思うので、その結果も載せようと思います。

1つ目は「みくはお魚が大好きだにゃ」と喋ったものの変換結果です。単語単位では学習データに存在すると思いますが、1文としては存在していません。
みくにゃんは魚が苦手です。

変換結果

すごい力の抜けた感じや機械感がしますが、一応音声としては聞き取れる…?それぞれの単語は学習データに含まれているせいか、そこまで酷い結果には感じません。
(学習データすら結果が微妙じゃないかと言えばその通りなのですが…)

次は「島村卯月、頑張ります」と喋ったものの変換結果です。先ほどのものとは異なり単語単位ですら殆ど存在しないと思います。
(みくにゃんが「島村卯月」と喋っているデータを用いた記憶は無いです)

変換結果

思ったより酷くはないし音声としては(多分…?)認識できますが、それでもやはり先ほど同様すごい力の抜けた感じや機械感はします。

さて、いくつか結果を載せたところで微妙感が漂いまくりな訳ですが、私も微妙だと思います。
いろいろ試行錯誤した結果、出力が音声だと認識できるくらいにはなりましたが、どうしても微妙感が拭えません。
正直これでも結構マシな結果で、音声とは言えないような謎の出力を多数観測したりしています。
最近ニコニコ動画に投稿された結月ゆかりの声になってみた人のものの方が遥かにクオリティが高いと思います。

www.nicovideo.jp

僕ももっとクオリティアップを図りたいところですが、だいぶ力尽きてきたので一段落と言った感じでこの記事を書いています。

手順の概要

今回は以下の手順で声質変換を行いました。

  1. 同じ内容を喋っているデータを作成
  2. メルケプストラムの抽出
  3. 機械学習を用いてメルケプストラム変換用のパラメータを推定
  4. 推定結果を用いてメルケプストラムを変換し音声を得る

残念ながらそれぞれの細かい処理について、完璧に把握してるとは言えず雰囲気でやってたりします。
Pythonと各種ライブラリに任せまくっています。

データの作成

今回の手法ではまず自分の声とみくにゃんの声データについて、同じ内容を喋っているもの(パラレルデータ)が必要になります。
みくにゃんのデータはセリフ集とかから持ってくるので大した問題ではありません。
問題は自分の声データです。みくにゃんと同じセリフを喋って録音する必要があります。

なので録音します。(迫真

これが…ほんとですね…気が狂いそうになるといいますかね、もうあれですよ。二度とやりたくないです。
オタクがひたすらみくにゃんのセリフを喋ってる光景、相当キツいです。
自分の事をみくにゃんだと勘違いしている精神異常者

声優ってすごいなって改めて思いました。

メルケプストラムの抽出

なんとか録音を終えたら音声データから特徴量としてメルケプストラムを抽出します。
メルケプストラム以外にも特徴量がいろいろあったりしますが、どれが良いんでしょうね。(正直良く分かりません
メルケプストラムを抽出するにあたり以下の記事の内容を参考にしました。

aidiary.hatenablog.com

wavファイルはあらかじめ16kHz・モノラルにしておきます。
メルケプストラムの抽出は上のサイトのものをそのまま利用させていただきました。

自分の声とみくにゃんの声データから得られたメルケプストラムの内、1次元目は以下のようになっています。

f:id:massoumen:20180320031829p:plain

同じ内容(アイドルマスターシンデレラガールズ)を喋っているのですが、かなり値が異なっています。
また、今後の処理のために時間方向のデータ数を揃えておきます。以下の記事の内容を参考にしました。

aidiary.hatenablog.com

時間方向に揃えると以下のようになりました。
割と合ってる箇所とそうでない箇所が見られます。

f:id:massoumen:20180320031840p:plain

機械学習

データを用意したらモデルを構築し学習を行います。ライブラリはKerasを利用しました。そこまで複雑でないモデルならシンプルに書けて好きです。
画像を扱った時はCNNを用いましたが、今回は時系列データという事でRNN(Bidirectional LSTM)を用いてみたいと思います。
自分のメルケプストラムの時刻tからt+aの値を入力として与え、みくにゃんのメルケプストラムの時刻tの値を出力するように学習します。
また、メルケプストラムは最初の次元にパワーの値が入り26次元のベクトルになっているそうですが、それを無視した方が良さげな結果になったので、今回の場合は入力が(a, 25)の行列、出力が25次元のベクトルになります。
また、時刻0からa、1からa+1、2からa+2、…とスライドさせながら入出力の組を作成します。入力データについて、スライドさせていくと終わりの部分が足りなくなるので、足りない部分は0で埋めるようにしています。
また、各次元ごとに平均0、分散1になるように正規化を行いました。平均を引いて標準偏差で割るアレです。
学習用のソースコードは以下のようになりました。

from keras.models import Sequential
from keras.layers import CuDNNLSTM, Dense, Bidirectional
from sklearn.model_selection import train_test_split
import numpy as np
import glob
import gc
import tensorflow as tf
from keras.backend import tensorflow_backend

config = tf.ConfigProto(gpu_options=tf.GPUOptions(allow_growth=True))
session = tf.Session(config=config)
tensorflow_backend.set_session(session)

max_t = 40
input_dim = 25 # 入力の次元
hidden_dim = 100 # 隠れ層の次元
output_dim = 25 # 出力の次元

# 1フレームずつずらしながら(data_num, max_t, input_dim)のデータを生成
def read_input(mcep_dir, mean, std):
    data = []
    mcep_list = glob.glob(mcep_dir + "/*")

    for mcep_path in mcep_list:
        print(mcep_path)

        mcep = np.loadtxt(mcep_path)[:, 1:] # パワーを無視

        mcep = (mcep - mean) / std

        mcep = np.r_[mcep, np.zeros((max_t - 1, input_dim))]

        # ずらしながらデータを追加(max_tに満たない場合は0を付け足して追加)
        if mcep.shape[0] >= max_t:
            for i in range(mcep.shape[0] - max_t + 1):
                data.append(mcep[i:i + max_t, :])
        else:
            zero = np.zeros((max_t - mcep.shape[0], mcep.shape[1]))
            mcep = np.r_[mcep, zero]

            data.append(mcep)

    return np.array(data).astype("float32")

# 時刻t,t+max_tのデータから時刻tの変換結果を推定する用のデータを生成
def read_teacher(mcep_dir, mean, std):
    data = []
    mcep_list = glob.glob(mcep_dir + "/*")

    for mcep_path in mcep_list:
        print(mcep_path)

        mcep = np.loadtxt(mcep_path)[:, 1:] # パワーを無視

        mcep = (mcep - mean) / std

        data.extend(mcep)

    return np.array(data).astype("float32")

def read_mcep(mcep_dir):
    data = []
    mcep_list = glob.glob(mcep_dir + "/*")

    for mcep_path in mcep_list:
        print(mcep_path)

        mcep = np.loadtxt(mcep_path)[:, 1:] # パワーを無視
        data.extend(mcep)

    return np.array(data)

# LSTMの構築(入力:(data_num, max_t, input_dim), 出力:(data_num, output_dim))
model = Sequential()
model.add(Bidirectional(CuDNNLSTM(hidden_dim, return_sequences=True), input_shape=(max_t, input_dim)))
model.add(Bidirectional(CuDNNLSTM(hidden_dim, return_sequences=True)))
model.add(Bidirectional(CuDNNLSTM(hidden_dim, return_sequences=True)))
model.add(Bidirectional(CuDNNLSTM(hidden_dim)))
model.add(Dense(output_dim))

model.compile(optimizer="adam", loss="mae")

# 全部のデータをまとめ、各次元ごとに平均と標準偏差を求める
x_temp = read_mcep("./aligned_mcep/me")
t_temp = read_mcep("./aligned_mcep/miku")
y = []
y.extend(x_temp)
y.extend(t_temp)
y = np.array(y)
mean = np.mean(y, axis=0)
std = np.std(y, axis=0)
del x_temp
del t_temp
gc.collect()

# 平均と標準偏差を保存
np.savetxt("lstm_mean.txt", mean, fmt="%0.6f")
np.savetxt("lstm_std.txt", std, fmt="%0.6f")

# 入力データと教師データの読み込み
x = read_input("./aligned_mcep/me", mean, std)
t = read_teacher("./aligned_mcep/miku", mean, std)

# 学習用データとテストデータに分離
x_train, x_test, t_train, t_test = train_test_split(x, t, test_size=0.3)

# 学習
history = model.fit(x_train, t_train, epochs=50, batch_size=256, validation_data=(x_test, t_test))

# モデルを保存
model_json_str = model.to_json()
open("lstm_model.json", "w").write(model_json_str)

# 重みを保存
model.save_weights("lstm.h5")

あとは実際に実行し、学習が終わるのを待ちます。

実際に変換

学習が終わったらそれを用いて実際に変換を行います。
流れとしては以下のようになります。

  1. 変換したい音声からメルケプストラムを抽出
  2. 学習したRNNを利用してメルケプストラムを変換
  3. 変換したメルケプストラムから音声合成

新たに書いたソースコードは以下の2つです。
RNNを利用してメルケプストラムを変換する部分とメルケプストラムから音声合成を行う部分です。

import os
import sys
import numpy as np
from keras.layers import Input, Dense
from keras.optimizers import Adam
from keras.models import Model, model_from_json
import tensorflow as tf
from keras.backend import tensorflow_backend

config = tf.ConfigProto(gpu_options=tf.GPUOptions(allow_growth=True))
session = tf.Session(config=config)
tensorflow_backend.set_session(session)

if len(sys.argv) != 3:
    print("usage: python convert_mcep.py <mcep_path> <converted_mcep_path>")
    exit()

mcep_path = sys.argv[1]
converted_mcep_path = sys.argv[2]

input_dim = 25 # 入力の次元
hidden_dim = 100 # 隠れ層の次元
output_dim = 25 # 出力の次元

max_t = 40
mcep = np.loadtxt(mcep_path)
power = mcep[:, 0]
mc = mcep[:, 1:]
mean = np.loadtxt("lstm_mean.txt")
std = np.loadtxt("lstm_std.txt")

mc = (mc - mean) / std # 正規化
mc = np.r_[mc, np.zeros((max_t - 1, input_dim))]

# 変換用のLSTMを読み込む
lstm = model_from_json(open("lstm_model.json").read())
lstm.load_weights("lstm.h5")

# LSTM入力用に整形
data = []

for i in range(mc.shape[0] - max_t + 1):
    data.append(mc[i:i + max_t, :])

data = np.array(data).astype("float32")

converted_mcep = lstm.predict(data) # LSTMで変換

converted_mcep = converted_mcep * std + mean # 正規化したものを戻す
converted_mcep = np.c_[power, converted_mcep]

np.savetxt(converted_mcep_path, converted_mcep, fmt="%0.6f")
import os
import sys
import subprocess
import struct
import numpy as np
from scipy.stats import multivariate_normal
from sklearn.externals import joblib

# メルケプストラム次数
# 実際はパワー項を追加して26次元ベクトルになる
m = 25

def extract_mcep(wav_file, mcep_file, ascii=False):
    cmd = "bcut +s -s 22 %s | x2x +sf | frame -l 400 -p 80 | window -l 400 -L 512 | mcep -l 512 -m %d -a 0.42 | x2x +fa%d > %s" % (wav_file, m, m + 1, mcep_file)
    subprocess.call(cmd, shell=True)

def extract_pitch(wav_file, pitch_file):
    cmd = "bcut +s -s 22 %s | x2x +sf | pitch -a 1 -s 16 -p 80 > %s" % (wav_file, pitch_file)
    subprocess.call(cmd, shell=True)

def synthesis(pitch_file, mcep_file, wav_file):
    cmd = "excite -p 80 %s | mlsadf -m %d -a 0.42 -p 80 %s | clip -y -32000 32000 | x2x +fs > temp.raw" % (pitch_file, m, mcep_file)
    print(cmd)
    subprocess.call(cmd, shell=True)

    cmd = "sox -e signed-integer -c 1 -b 16 -r 16000 temp.raw %s" % (wav_file)
    print(cmd)
    subprocess.call(cmd, shell=True)

    os.remove("temp.raw")

if len(sys.argv) != 4:
    print("usage: python convert_voice.py <wav_path> <converted_mcep_path> <converted_wav_path>")
    exit()

source_wav_file = sys.argv[1]
converted_wav_file = sys.argv[3]

print("extract pitch ...")
source_pitch_file = "source.pitch"
extract_pitch(source_wav_file, source_pitch_file)

source_mcep = np.loadtxt(sys.argv[2]) # 変換後のmcepを読み込む
source_mcep_file = "temp.mcep"
fp = open(source_mcep_file, "wb")

for t in range(len(source_mcep)):
    x_t = source_mcep[t]
    fp.write(struct.pack('f' * (m + 1), * x_t))

fp.close()

# 変換元のピッチと変換したメルケプストラムから再合成
print("synthesis ...")
synthesis(source_pitch_file, source_mcep_file, converted_wav_file)

# 一時ファイルを削除
os.remove(source_mcep_file)
os.remove(source_pitch_file)

変換前と変換後、みくにゃんのメルケプストラムの1次元目は以下のようになっています。
青色が変換前、オレンジが変換後、緑がみくにゃんのメルケプストラムです。
ある程度みくにゃんのものに近づいている感じはあります。(時間は揃えています)

f:id:massoumen:20180320035830p:plain

おわりに

以上が今回の声質変換の結果と大まかな流れです。
音声処理、超難しいですね…(決して画像処理が簡単という訳ではないと思いますが…)
クオリティを上げるためには音声処理や機械学習についてもっといろいろ学ぶ必要がありそうです。
今回、GANを用いたものにも挑戦してみたかったんですが、理解が及ばず今のところ試していません…
(ソースコードの複雑さが段違いである…無念)

今後も勉強しつつ何かネタを見つけてチャレンジしたり、これまで作ったものの改良をしたりしていきたいですね。

卯月識別器の作成に挑戦した話

はじめに

機械学習とかDeepLearningとか、そういったワードを最近よく聞いてる気がするんですよ。 気になるのでそういうの触ってみたいなーとか思う訳ですよ。
僕は一応情報系の学科に所属する大学生で、パターン認識とかも少しくらいは勉強してる訳ですが、あまりプログラムとかは書いたことないなーって。
そんな中、今更ながら(?)ゼロから作るDeepLearningを読んでみたり、アニメキャラの識別をしている記事をいくつか読んだりして、「僕もなんかやってみたい!!!」ってなりました。

さて、プログラムを書く前に「まず何を識別しようか?」と考える訳ですが、やはりここは「推しキャラの識別に挑戦する」しかないでしょう。ちなみの僕の推しキャラはデレステ島村卯月です。卯月の笑顔はとても眩しくて、尊いです。

f:id:massoumen:20180102151718p:plain

なので、顔画像に対してそれが島村卯月かそうでないかを判定するプログラムの作成にチャレンジしました。
勉強も兼ねて、Pythonで作りました。作成の流れは以下のようになりました。

  1. 卯月と卯月以外の顔画像を集める
  2. サイズの統一や明るさの調節など顔画像に前処理を行う
  3. CNNに顔画像をぶち込んで学習させる

また、学習結果を利用し卯月なら赤い枠を、そうでないなら青い枠を付けるプログラムを作成しました。完成したプログラムにいくつか画像を入れて試してみた結果、以下のようになりました。

f:id:massoumen:20180102160002j:plain

f:id:massoumen:20180102160009j:plain

f:id:massoumen:20180102160016j:plain

f:id:massoumen:20180102160027j:plain

訓練用のデータは全てアニメのスクショなので、アニメの卯月の情報からゲームの卯月を識別できていると考えれば、悪くないのかなという気はします。(もちろん、うまく識別できなかったケースもありますが…)
以下、作業の内容とかを少し書いていこうかなと思います。

顔画像の収集

機械学習で良い結果を出すにはたくさんのデータが必要らしいので、まずはアニメのスクショをたくさん撮り、そこから顔を切り取り卯月とそれ以外に分けます。ここで、大量のスクショから顔を切り取るという作業が地獄だという事に気付いたので、先人たちを参考にし、OpenCVを用いたアニメ顔識別器を利用してスクショから顔画像を切り取ってもらいました。

卯月かどうかの判定よりアニメ顔を見つける事の方が難しいのではないか。

f:id:massoumen:20180102154910p:plain f:id:massoumen:20180102154951p:plain

こんな感じに切り取られた顔画像に対し、卯月と卯月以外に分ける作業を手作業で行いました。顔だと認識されていなかったり、顔以外のものが顔だと誤認識してるものもあるので、そういうのは自分で切り取ったり削除します。

プロデューサーと美城常務、顔認識されなさすぎる問題。(悲しいね)

最初は卯月とそれ以外で200枚と500枚くらい用意したのですが、これで学習させたらあまり上手くいかなかったので、最終的に550枚と1350枚くらいまで増やしました。ラブライブ!の人は6000枚以上、ごちうさの人は12000枚以上用意したらしいので、それに比べるとかなり少ないですね…けど、手作業で分類するの、結構疲れるんですよ…

プログラムに識別させるためにまず自分で識別してるの、なんだかなぁ。

顔画像の前処理

入力画像のサイズは統一する必要があるので、得られた顔画像を100*100の大きさに調節します。また、明るさの類は統一した方が良いのかなと思い、画素値の平均や標準偏差を揃えました。(この辺の処理は何が適切なのか正直良く分かっていない…いろんなパターンを試して比較するべきだった…)

f:id:massoumen:20180102170249p:plain f:id:massoumen:20180102170334p:plain

学習

集めた顔画像を訓練用とテスト用に分け、実際に学習させます。Kerasというライブラリを用いてCNNを構築し学習させました。また、ちゃんと卯月を見分ける事ができるかどうか、ゲームの画像を何枚か用意し自分で与え、判定させて試したりします。上手くいかなかったら訓練用の顔画像を増やすべくアニメのスクショを撮りに行ったり、Kerasに用意されているデータの水増し機能を試してみたりしました。
以下、いろいろ試した過程です。

卯月識別器1号

記念すべき第1号です。データ数は卯月、卯月以外でそれぞれ200枚、500枚となっています。ゲームの画像を与えたところ、ラブレター卯月は卯月ではないと判定されました。道のりは険しそうです。

f:id:massoumen:20180102171649j:plain

卯月識別器2号

データ数をそれぞれ350枚、950枚に増やしました。ところが、全て卯月じゃないと判定されてしまいました。
訓練データの正答率が100%となってるあたり、いわゆる過学習という奴でしょうか。やはりデータが少ないようです。

f:id:massoumen:20180102171951j:plain

卯月識別器3号

OpenCVを使って顔画像を回転させ、データ数を3倍に増やしました。恒常卯月のみ卯月だと判定されました。
やはり、まだ足りないのでしょうか。頑張ってスクショ撮りましょう。(疲れるんだよなぁあの作業…)

f:id:massoumen:20180102172320j:plain

卯月識別器4号

データ数をそれぞれ550枚、1350枚に増やしました。1号に比べるとだいぶ増えましたね。(まだまだ少ない感が拭えませんが…)
回転させてデータ数を3倍にした場合もそうでない場合も、恒常卯月しか卯月と判定してくれませんでした。
訓練用に含まれていないアニメopの卯月も1枚用意してみたんですが、それも卯月じゃないと言い出す始末。

お前は何を見て育ったんだ。

f:id:massoumen:20180102172727j:plain

ループを回しすぎなのかなぁと思いループ数を減らしてみたら、天海春香島村卯月だと判定されてしまいました。

f:id:massoumen:20180102172848j:plain

ここで、最後の確認用のデータは手動で切り取っていたのですが、訓練データと同様にアニメ顔識別器で切り取った方が良いのではないか?と思いアニメ顔識別器を使って切り取って用いてみました。その結果、なんか精度が上がりました。訓練データの顔画像にはあまり髪の毛の部分が含まれていないので、そのせいで髪の毛を多く含んだラブレター卯月の顔画像は識別できていなかったのかなぁ…?(恒常佐久間まゆがうまく切り取られていなくて少し悲しかった。イーブイもダメだった。)

f:id:massoumen:20180102173223j:plain

卯月識別器5号

データ数はそのままですが、Kerasにデータを水増しする機能が含まれているらしいので使ってみる事に。(良く分かっていない顔)
学習にかかる時間が増えているっぽいので、たぶんちゃんと水増しされています。最後の確認用のデータも増やしてみましたが、今までで一番いい感じに識別できています。そろそろ疲れてきたのでこの辺で一旦完成という事にして学習結果を保存しました。

f:id:massoumen:20180102173713j:plain

卯月識別器の完成

得られた学習結果を利用し、画像が与えられたらアニメ顔認識器で顔を認識し、さらにそれが卯月であるかどうかを卯月識別器で判定するプログラムを作りました。記事の初めの方に載せた奴以外にもこんな感じの結果が得られました。顔認識がそもそもされていなかったり、されても卯月かどうかの判定に失敗していたりと、認識の難しさを感じる…

f:id:massoumen:20180102185154p:plain

f:id:massoumen:20180102185750j:plain

f:id:massoumen:20180102185800p:plain

f:id:massoumen:20180102185820p:plain

また、ゲームのイラストに関してはそこそこの精度でちゃんと卯月かどうか判定してくれるんですが、いろいろな絵師が描いた卯月については思ってた以上に精度が落ちますね…絵柄の違いが響いているのでしょうか。僕は絵柄とかによっては卯月かどうか判別しにくい場合があったりしますが、卯月識別器もそうなんですかね。いったい、何が「これは卯月である」と言わせているんですかね。

「卯月」とはいったい…(哲学)

感想

2,3日くらい卯月識別器と格闘していましたが、初めて挑戦したという事もあり、中々楽しかったですね。なんやかんやそれっぽく識別できてる感があるのもあり、いろいろな画像で試してみては「お~」って感じになってます。DeepLearningの類はデータがたくさん必要らしいですが、実際に動かしてみてそれを強く感じましたね…何はともあれ、使えるデータがたくさん必要だなと。今回はCNNの層の構成を変化させたり、画像の前処理などについてちゃんと調査したりしていないので、その辺についても調べていきたいですね。
また、今回いろいろ試してみて「まだまだ知識不足だな~」というのも強く感じたので、勉強も必要かなと。

気が向いたらソースコードどっかに上げたり改良版作ったりするかも…?

VisualStudio2017Communityの導入

VisualStudio 2017 Communityを入手してC言語(C++)でHelloWorld!!を表示するまでのメモ。

ダウンロードとインストー

まずは公式サイトからVisualStudio 2017 Communityのインストーラを入手。
すごい分かりにくいのは気のせいだろうか

f:id:massoumen:20170505113600p:plain

ダウンロードしたらインストールすべく実行します。実行するとこんな感じの画面が出てくる。

f:id:massoumen:20170505113614p:plain

今回はC言語(C++)を使いたいので、とりあえず「C++によるデスクトップ開発」にチェックを入れる。

f:id:massoumen:20170505113622p:plain

右下のインストールを実行するとインストールが始まる。

f:id:massoumen:20170505113628p:plain

完了すると再起動を要求されるので再起動。

f:id:massoumen:20170505113633p:plain

再起動後、VisualStudio 2017が入っている事を確認。

f:id:massoumen:20170505113645p:plain

実行するとサインインを要求される。
マイクロソフトのアカウントを持っているならサインイン、そうでなければアカウントを作りサインインする。

f:id:massoumen:20170505113651p:plain

サインインに成功するとVisualStudio 2017が起動しこんな感じの画面が出る。

f:id:massoumen:20170505113658p:plain

プロジェクトの作成からHelloWorld!!まで

試しにC言語(C++)を使ってコンソール上でHelloWorld!!を表示させる。
まずは「ファイル」→「新規作成」→「プロジェクト」を選択。

f:id:massoumen:20170505113711p:plain

Visual C++の欄からWin32コンソールアプリケーションを選択。
名前とファイルの保存場所はご自由に。(今回名前はTest、保存場所はデスクトップ上にした。)

f:id:massoumen:20170505113719p:plain

決定するとこんな感じの画面が出てくる。

f:id:massoumen:20170505113729p:plain

次にソースコードを記述するためのソースファイルを追加する。
ソースファイルの箇所を右クリックし「追加」→「新しい項目」を選択。

f:id:massoumen:20170505113732p:plain

C++ファイル(.cpp)を選択。ファイル名はご自由に。(今回はSource.cppのまま。)

f:id:massoumen:20170505113735p:plain

すると、ソースコードが書けるようになるので次のコードを記述。

#include <stdio.h>

int main()
{
    printf("Hello World!!\n");

    return 0;
}

f:id:massoumen:20170505113739p:plain

CtrlとF5を同時押しして実行すると「Hello World!!」が表示される(と思う)。

f:id:massoumen:20170505113742p:plain

表示されたら成功。やったぜ。

コンソールが表示されない場合

CtrlとF5を同時押ししても黒い画面が出てこない場合は以下の操作を試してみて下さい。
まず、「プロジェクト」→「(プロジェクト名)のプロパティ」を選択。

f:id:massoumen:20170505114140p:plain

左上の「構成」を「すべての構成」にした後、「構成プロパティ」→「リンカー」→「システム」→「サブシステム」の設定を「コンソール(/SUBSYSTEM:CONSOLE)」に変更。

f:id:massoumen:20170505114204p:plain

設定を終了し再びCtrlとF5を同時押しして「Hello World!!」が表示されるか確認。
表示されたら成功。ダメだったら…僕にはよく分からないわ、ごめん。

後から他の機能を追加

後からC++以外(C#など)を利用したくなった場合の機能の追加方法のメモ。

今回はC#の機能を追加してみる。まず、VisualStudio Installerを起動。

f:id:massoumen:20170505114244p:plain

「変更」を選択。

f:id:massoumen:20170505114251p:plain

最初にインストールする時と同じ画面が出てくるので、追加したいものを選んで右下の「変更」を押す。
今回はC#の機能を追加したいので「.NETデスクトップ開発」を追加。
(追加インストール後に画面をキャプチャしたのでここに載っている画像は「閉じる」になっているが…)

f:id:massoumen:20170505114257p:plain

すると追加のインストールが始まるので見守る。

f:id:massoumen:20170505114303p:plain

終わったら「起動」でVisualStudio 2017を起動する。

f:id:massoumen:20170505114311p:plain

C言語(C++)の時と同様にプロジェクトの新規作成を行うと、項目が増えている。
今回はVisual C#の中の「コンソールアプリ(.NetFramework)」を選択。

f:id:massoumen:20170505114316p:plain

C#の場合最初からソースコードが少し書かれているので、適切な位置に以下の記述を追加。

Console.WriteLine("Hello World!!");

f:id:massoumen:20170505114322p:plain

C++の時と同様にCtrlとF5を同時押しして実行。

f:id:massoumen:20170505114329p:plain

表示されたら成功。

こんな感じで後から他の機能をいろいろ追加できる。

DXライブラリ使用時に終了後もプロセスが残る問題

事の始まり

DXライブラリを用いて通信(チャットプログラム)を試していたら、突然エラーが出るようになった。

f:id:massoumen:20170422190305p:plain

exeを起動しっぱなしの状態で実行すると出てくるエラーだが、今回はちゃんと終了させてから再び実行したはず…
そこで、 タスクマネージャで調べてみると…

f:id:massoumen:20170422190524p:plain

あれ、バックグラウンドでプロセスが残ってる…?(Chat.exeが試してるプログラム)

こいつを終了してやるとエラーが消えたので、やはり原因はこいつが残っている事にある。
しかし、なぜ残っていたのだろうか…

f:id:massoumen:20170422192419p:plain

exeを直接実行しては終了を繰り返すとエライ事に

調べてみたところ、恐らくProcessMessage()やDxLib_End()を適切に実行するようなプログラムを書けていないのが原因である。

ダメなパターン

プロセスが残ってしまう今回のパターンでは次のようなコードを書いた。

#include <DxLib.h>

//省略(他のインクルードやグローバル変数)

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
    //省略(初期化やウィンドウサイズの設定など)

    //ZキーかXキーの入力があるまでループ(入力の受け付け)
    while (GetKey(KEY_INPUT_Z) == 0 && GetKey(KEY_INPUT_X) == 0) {
        KeyUpdate();
    }

    //省略(他の処理)

    DxLib_End();

    return 0;
}

大部分を省略してしまったが、GetKey関数とKeyUpdate関数は自作関数で、それぞれキーの状態とキーの入力状態の更新を行う自作の関数だ。
このような無限ループを用意してしまうと、もし入力の受け付け部分の無限ループでウインドウ右上の×を押した際に、恐らくDxLib_End関数まで処理が進まない。
もっと言えばDXライブラリではProcessMessage関数を毎フレーム実行するようにしないといけない(詳しくは知らないが、そうしないと正しく動かない可能性がある)らしいので、その点も悪いと考えられる。
恐らくDXライブラリを用いるプログラムとしては0点である。

(多分)大丈夫なパターン

要するに、DXライブラリを用いる際は

  • ループの時にProcessMessage関数を毎フレーム実行する
  • 最終的にDxLib_End()を実行する

という事に気を付ければいいんだと思う。(多分)

てなわけで早速修正。

#include <DxLib.h>

//省略(他のインクルードやグローバル変数)

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
    //省略(初期化やウィンドウサイズの設定など)

    //ZキーかXキーの入力があるまでループ(入力の受け付け)
    while (ProcessMessage() == 0) {
        if (GetKey(KEY_INPUT_Z) == 0 && GetKey(KEY_INPUT_X) == 0) {
            KeyUpdate();
        }
        else {
            break;
        }
    }

    //省略(他の処理)

    DxLib_End();

    return 0;
}

キー入力など毎フレーム受け付けるタイプのものを利用する時はProcessMessage関数を実行する。
こうやって書く事で×ボタンでウインドウが閉じられた際にProcessMessage関数が0以外を返すので、無限ループを抜けDxLib_End関数を実行して終了してくれるはず。

このように書き直したところ、終了後にプロセスが残ってなかったので多分これでいいと思う。
用いるライブラリの仕様は最低限把握しておこう。