あかすくぱるふぇ

同人サークル「あかすくぱるふぇ」のブログです。

機械学習

ディープラーニングでkeyキャラっぽいイラストを自動生成してみました。
vis_21_0
すごい! keyっぽいのに、見たことないキャラが生成できた!(一部除く。後述)

利用したのは以下の記事で紹介されているDCGANというアルゴリズムです。
Chainerで顔イラストの自動生成

ソースコードは上記記事のものをそのまま用いたので、私が行ったのは学習データの作成だけです。
なので、本記事では学習データの作成過程をご紹介します。
学習データ作成の手順は以下の通りです。

1. 元画像の収集
2. 元画像からの顔画像切り出し
3. 顔画像の選別
4. Data Augmentation

では、1つずつ説明していきます。

・1. 元画像の収集
元画像の収集にはImageSpiderというフリーソフトを利用しました。
検索キーワードは"key kanon"とか"kanon あゆ"とかそんな感じです。
今回の例では、8400枚の画像を収集しました。
http://www.vector.co.jp/soft/winnt/net/se425690.html

・2. 元画像からの顔画像切り出し
顔画像切り出しには、OpenCVを利用しました。
切り出し画像が32×32[pixel]より小さい場合は無視し、それ以外の画像は96×96[pixel]にリサイズして画像ファイルに出力しました。
結局、次工程の「顔画像の選別」でボケた画像を排除したので、しきい値32×32というのはもっときつくしてもよいと思います。
OpenCVによるアニメ顔検出ならlbpcascade_animeface.xml

・3. 顔画像の選別
切り出された画像には、顔以外の画像、keyに関係ないキャラ、コスプレ画像、keyキャラだけどタッチが違いすぎるもの、ぼけた画像、などノイズが多く含まれています。
このノイズを人力で(!?)排除していきます。
……いや、大変ですよ。
完璧に排除するのは無理(同人が多く含まれるので、どのくらいのタッチの違いまで許すのかとか、タッチはkeyっぽいけどkeyキャラなのか自信が持てないとか)なので、「パッと見て、学習の邪魔になりそうなものを排除していく」くらいの気持ちでやるのがよいかと思います。
私はこの作業に5時間ほどかけました。
なお、後述しますが、男キャラや横顔が混ざっていると、自動生成結果がいまいちだったので、それらも排除しました。
今回の例では、3500枚の顔画像を選別しました。

・4. Data Augmentation
各画像を左右反転させ、7000枚に学習データを水増ししました。
学習データの一部を以下に載せます。
無題

0000088.bmpは誰でしょうね?
まあ、あまり気にしすぎず、軽い気持ちで学習データを作るのが吉です。
あまり几帳面になりすぎると、「学習データを作っているつもりが、いつの間にか自分自身がkeyキャラ識別器として働いている」というディストピアな感じになってしまうのでw。

ともかく、以上の作業で作った学習データを食わせると、最初に示した画像のように、keyキャラっぽい画像を自動生成してくれます。
ただ、問題点もいくつかあったので、最後にそれらを記します。

・学習データのキャラ数が少なすぎるせいか、元キャラの影響が強すぎる。
実は今回学習に使ったのは、kanon、AIR、CLANNADの3作品がほとんどです。
理由は私自身がその3作品が特に好きだから。
なので、学習データのキャラ数が少なくなり、自動生成画像も元キャラの影響を強く受けたものになっています。
よく見ていただくと、「ほぼ杏じゃん、ほぼ渚じゃん」という画像が含まれています。
この問題点については、リトバスやrewriteのキャラを追加学習してどうなるか試してみたいと思っています。

・オッドアイが多い。
学習データにオッドアイはほとんど含まれていないはずなんですがね。
学習データを増やすことで改善するかもです。

・目の破たんが目立つ。
生成画像を見ると、目がちゃんと描けているものは良く見えて、目がちゃんと描けていないものは他の部位がうまく描けていても悪く見えてしまいます。
これは人間がイラストを見る時に、目を中心に見ているからだと思いますが、コンピュータにとってはそんなこと知ったこっちゃありません。
目の破たんを防ぐことを重視するようなアルゴリズムにしないと実用は難しいかなと思います。

・学習データに男キャラや横顔が含まれていると、生成結果の質が低下する。
学習データに男キャラ、横顔が含まれる場合の生成結果を以下に示します。
それなりにできてはいるのですが、質はあまり良くありません。
女キャラの正面顔とは特徴が大きく異なるので、学習が難しいのだと思います。
vis_13_0


・途中から生成画像が破たんしてしまった。
最初に載せた生成画像は21世代目のものなのですが、25世代目で破たんし、そこから多少盛り返すものの、結局正常な画像は生成されなくなってしまいました。
原因はこれから調査予定です。

以上です。

以下の記事で紹介している画風変換のソースコードからエッセンスを抽出してみました。
画風を変換するアルゴリズム

エッセンスを抽出したソースコードを以下に載せます。
元コードのchainer-gogh.pyに相当します。
#coding:utf-8
import os, sys
import chainer.links as L
import numpy as np
from chainer import optimizers
from PIL import Image
from models import *

# 定数
iter = 5000
width = 435
mean = 120
lr = 4.0

# 画像ファイルの入力と変換
def image_resize(img_file, width):
img = Image.open(img_file)
if img.size != (width, width):
print "Image must be square."
sys.exit()
img = np.asarray(img)[:,:,:3].transpose(2, 0, 1)[::-1].astype(np.float32)
return img.reshape(1, 3, width, width) - mean

# 画像の保存
def save_image(img, width, it):
def to_img(x):
im = np.zeros((width, width, 3))
im[:,:,0] = x[2,:,:]
im[:,:,1] = x[1,:,:]
im[:,:,2] = x[0,:,:]
def clip(a):
return 0 if a<0 else (255 if a>255 else a)
im = np.vectorize(clip)(im).astype(np.uint8)
Image.fromarray(im).save("im_%05d.png"%it)

to_img(img[0,:,:,:] + mean)

# スタイル行列の算出
def get_matrix(y):
ch = y.data.shape[1]
wd = y.data.shape[2]
gogh_y = F.reshape(y, (ch,wd**2))
gogh_matrix = F.matmul(gogh_y, gogh_y, transb=True)/np.float32(ch*wd**2)
return gogh_matrix

# モデルの読み込み
nn = NIN()

# 画像の入力
img_orig = image_resize("cat.png", width)
img_style = image_resize("style_0.png", width)

# 中間層とスタイル行列の算出
mid_orig = nn.forward(Variable(img_orig, volatile=True))
style_mats = [get_matrix(y) for y in nn.forward(Variable(img_style, volatile=True))]

# 初期合成画像を生成し、最適化対象として設定
img_gen = np.random.uniform(-20,20,(1,3,width,width)).astype(np.float32)
img_gen = L.Parameter(img_gen)
optimizer = optimizers.Adam(alpha = lr)
optimizer.setup(img_gen)

for i in range(iter):
img_gen.zerograds()
y = nn.forward(img_gen.W)
loss = Variable(np.zeros((), dtype=np.float32))
for l in range(len(y)):
# 損失関数の算出
loss1 = np.float32(0.005) * np.float32(nn.alpha[l])*F.mean_squared_error(y[l], Variable(mid_orig[l].data))
loss2 = np.float32(nn.beta[l])*F.mean_squared_error(get_matrix(y[l]), Variable(style_mats[l].data))/np.float32(len(y))
loss += loss1 + loss2

loss.backward()
optimizer.update()
tmp_shape = img_gen.W.data.shape
def clip(x):
return -120 if x<-120 else (136 if x>136 else x)
img_gen.W.data += np.vectorize(clip)(img_gen.W.data).reshape(tmp_shape) - img_gen.W.data

if i%50==0:
print i
save_image(img_gen.W.data, width, i)
たったの82行!
やはりChainerすごいですね。

ほとんど解説の必要はなさそうですが……、肝となるのは以下の部分でしょうか。
# 初期合成画像を生成し、最適化対象として設定
img_gen = np.random.uniform(-20,20,(1,3,width,width)).astype(np.float32)
img_gen = L.Parameter(img_gen)
optimizer = optimizers.Adam(alpha = lr)
optimizer.setup(img_gen)
ランダムな画像を生成して、それを最適化対象パラメータとしてoptimizerに設定しています。
ネットワークではなく、画像を更新するのですね。面白い。

Chainerでcaffemodelを読み込んで使えるらしいです。素晴らしい!
Chainerでcaffemodelを読み込んで画像を分類する

ただ、例のごとく、Python初心者には何をやっているのかよく分からなかったので解析してみました。
ソースコードは上記リンクのものを利用させていただき、Python初心者が躓きそうなところをピックアップして解説していきます。

def load(self, path):
self.func = caffe.CaffeFunction(path)

def _predict_class(self, x):
y, = self.func(inputs={'data': x}, outputs=['loss3/classifier'], disable=['loss1/ave_pool', 'loss2/ave_pool'], train=False)
return F.softmax(y)
まずは一番肝になる部分から。
CaffeFunction()は、caffemodelファイルを読みこんで、Chainerで呼び出せる関数を生成します。
func()の引数は、(入力データ, 出力層, 出力に関連しない無視する層, 学習フェーズかどうか)です。
層の名前は読み込むcaffemodelによって異なるので、調べる必要があります。
第三引数は指定しなくても結果は変わりません(無視することによって処理時間が短くなるとか?)。

def load_label(self, path):
self.categories = np.loadtxt(path, str, delimiter="\n")
カテゴリ名を記載したファイルの読み込みです。
この例では、categoriesは1000要素の配列で、各要素はカテゴリ名となります。

def predict(self, image_path, rank=10):
x = chainer.Variable(self.load_image(image_path), volatile=True)
y = self._predict_class(x)
result = zip(y.data.reshape((y.data.size,)), self.categories)
return sorted(result, reverse=True)
result = zip(a, b)は(a, b)というセットを要素として持つ配列resultを生成します。
そして、sorted(result)は、resultを(zip(a, b)のaについて)ソートする関数です。
この処理によって、カテゴリを確率の高いものから順に並べた結果を得ることができます。

def print_prediction(self, image_path, rank=10):
prediction = self.predict(image_path, rank)
for i, (score, label) in enumerate(prediction[:rank]):
print '{:>3d} {:>6.2f}% {}'.format(i + 1, score * 100, label)
enumerate()はインデックスと要素を同時に得るための関数です。
この例では、iにインデックス、(score, label)にprediction[i]が入ります。
また、{:>3d}や{:>6.2f}はオプション付きの書式であり、>は右詰めのオプションです。

以上です。

本記事では、Python初心者のためにCIFAR-10データのファイル入力について解説します。
CIFAR-10というのは、一般物体認識のベンチマーク用画像データセットです。
http://www.cs.toronto.edu/~kriz/cifar.html

本記事を書くきっかけとなったのは、以下の記事を参考にChainerでCNNを試したことです。
実行はうまくいったのですが、Python初心者なので、CIFAR-10のファイル入力の理解に手間取りました。
ChainerによるCIFAR-10の一般物体認識 (1)

まずは、ソースコードを載せます。
このソースコードは上記サイトと以下のサイトのコードを合体して、改変したものです。
http://aidiary.hatenablog.com/entry/20151014/1444827123
#coding: utf-8
import numpy as np
import matplotlib.pyplot as plt

def unpickle(f):
import cPickle
fo = open(f, 'rb')
d = cPickle.load(fo)
fo.close()
return d

def load_cifar10(datadir):
train_data = []
train_target = []

# 訓練データをロード
for i in range(1, 6):
d = unpickle("%s/data_batch_%d" % (datadir, i))
train_data.extend(d["data"])
train_target.extend(d["labels"])

# テストデータをロード
d = unpickle("%s/test_batch" % (datadir))
test_data = d["data"]
test_target = d["labels"]

# データはfloat32、ラベルはint32のndarrayに変換
train_data = np.array(train_data, dtype=np.float32)
train_target = np.array(train_target, dtype=np.int32)
test_data = np.array(test_data, dtype=np.float32)
test_target = np.array(test_target, dtype=np.int32)

# 画像のピクセル値を0-1に正規化
train_data /= 255.0
test_data /= 255.0

return train_data, test_data, train_target, test_target

# CIFAR-10データをロード
print "load CIFAR-10 dataset"
X_train, X_test, y_train, y_test = load_cifar10("data")

# 画像を (nsample, channel, height, width) の4次元テンソルに変換
X_train = X_train.reshape((len(X_train), 3, 32, 32))
X_test = X_test.reshape((len(X_test), 3, 32, 32))

# 画像を表示
plt.imshow(X_train[0].reshape(3, 32, 32).transpose(1, 2, 0))
plt.show()
では、解説していきます。
def unpickle(f):
import cPickle
fo = open(f, 'rb')
d = cPickle.load(fo)
fo.close()
return d
ここでは、PythonのcPickleを利用しています。
cPickleはPythonオブジェクトとバイト列との相互変換を行うライブラリです。
この例では、load()によってファイル入力を行い、Pythonのdict型オブジェクトであるdを生成しています。
dict型オブジェクトは、以下のサイトに記されているように、キーと値が紐づけられたオブジェクトです。
http://www.pythonweb.jp/tutorial/dictionary/index8.html

# 訓練データをロード
for i in range(1, 6):
d = unpickle("%s/data_batch_%d" % (datadir, i))
train_data.extend(d["data"])
train_target.extend(d["labels"])
6個のファイルに分かれた訓練データをロードします。
"%s/data_batch_%d" % (datadir, i)は、%sにdatadir、%dにiを代入して、ファイル名文字列となります。
このファイル名のファイルからデータをロードし、train_dataとtrain_targetに追加していきます。
d["key"]はdict型オブジェクトdから"key"という名前のキーに紐づけられた値を参照します。
"data"キーは画像データ、"labels"キーは正解ラベルの値と紐づけられています。

# 画像を (nsample, channel, height, width) の4次元テンソルに変換
X_train = X_train.reshape((len(X_train), 3, 32, 32))
X_test = X_test.reshape((len(X_test), 3, 32, 32))
データをChainerのデータ形式(画像数, チャンネル数, 高さ, 幅)に変換します。

以上です。

Chainerをインストールし、mnistの実行ができた!
……が、mnistには色々な要素が詰まっていて、なかなか解読できません(以下の記事に続いて3度目。。)
Pythonと機械学習の初心者がChainerで最初に解読したコード
get_mnist()で取得したデータをchainer1.11以前のmnistに入力する

そこで本記事では、mnistのサンプルコードからエッセンスのみを抽出してみました。
元にしたのは上記2つ目の記事に載せたコードです。
エッセンスを抽出したコードを以下に載せます。
import numpy as np
from chainer import cuda, Variable, FunctionSet, optimizers, datasets
import chainer.functions as F

n_epoch = 100
n_units = 1000 # 中間層
pixel_size = 28

# MNISTの画像データDL
train, test = datasets.get_mnist()

# データの詰め替え
N = np.array(train).size / 2
x_train, y_train, x_test, y_test = [], [], [], []
for i in range(0, N):
x_train.append([train[i][0].astype(np.float32)])
y_train.append(train[i][1].astype(np.int32))
N_test = np.array(test).size / 2
for i in range(0, N_test):
x_test.append([test[i][0].astype(np.float32)])
y_test.append(test[i][1].astype(np.int32))
x_train = np.array(x_train)
y_train = np.array(y_train)
x_test = np.array(x_test)
y_test = np.array(y_test)

# MLP モデル
# 入力784, 出力10
model = FunctionSet(l1 = F.Linear(784, n_units),
l2 = F.Linear(n_units, n_units),
l3 = F.Linear(n_units, 10))

# NNの構造
def forward(x_data, y_data, train = True):
x, t = Variable(x_data), Variable(y_data)
#h1 = F.dropout(F.relu(model.l1(x)), train = train)
#h2 = F.dropout(F.relu(model.l2(h1)), train = train)
h1 = F.relu(model.l1(x))
h2 = F.relu(model.l2(h1))
y = model.l3(h2)

# 多クラス分類なのでsoftmax関数の交差エントロピー関数を誤差関数とする
return F.softmax_cross_entropy(y, t)

# optimizerの設定
optimizer = optimizers.Adam()
optimizer.setup(model)

# Learning loop
for epoch in range(1, n_epoch + 1):
print("epoch", epoch)

model.zerograds()
loss.backward()
optimizer.update()

# 訓練データの誤差と正解精度を表示
loss = forward(x_train, y_train)
print("train mean loss = {0}".format(loss.data))

# evaluation
# テストデータで誤差と正解精度を算出し汎化性能を確認
loss = forward(x_test, y_test)

# テストデータの誤差と正解精度を表示
print("test mean loss = {0}".format(loss.data))
これなら理解できそう!
というわけで、解説していきます。
model = FunctionSet(l1 = F.Linear(784, n_units),
l2 = F.Linear(n_units, n_units),
l3 = F.Linear(n_units, 10))
ネットワーク構造。
784個の入力(28×28の画像)から10個の出力(0~9のクラス分類)を生成します。
def forward(x_data, y_data, train = True):
x, t = Variable(x_data), Variable(y_data)
#h1 = F.dropout(F.relu(model.l1(x)), train = train)
#h2 = F.dropout(F.relu(model.l2(h1)), train = train)
h1 = F.relu(model.l1(x))
h2 = F.relu(model.l2(h1))
y = model.l3(h2)

# 多クラス分類なのでsoftmax関数の交差エントロピー関数を誤差関数とする
return F.softmax_cross_entropy(y, t)
順伝播関数。
コメントにも書いていますが、「多クラス分類なのでsoftmax関数の交差エントロピー関数を誤差関数」とします。
ドロップアウトは過適合を防ぐ仕組みであり、無くても動きます。
model.zerograds()
loss.backward()
optimizer.update()
元のコードではbatchsizeごとに処理をしていますが、これは「ミニバッチ」という効率化のためのテクニックであり、必須ではありません。
ただし、元のコードではbatchsize×n_epoch回重みを更新しますが、上記コードではn_epoch回しか更新しないため、その分n_epochを大きい値に設定する必要があります。

以上です。
やっとmnistを理解することができました。
早くCNNとかを使えるようになって、実践的な例を試してみたいです。

↑このページのトップヘ