あかすくぱるふぇ

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

2016年12月

Deep Neural Networkによる画風転写で沙耶の唄の世界を体験できるようにしました。
 

本記事では、体験までの道のりを紹介します。

1. 全天球画像の撮影
以下のような、(部屋の)全天球画像を撮影します。
Room_02000
Ricoh Thetaなどの360度カメラで撮影しましょう。
私はパノラマ合成で作成しましたが、撮影と合成がとても大変でした。
(壁に不自然に貼られた画像が苦労を物語っている。。。)
ただ、パノラマ合成だと、真下方向も取得できるので、その点は良いですね。

2. 画風転写
全天球画像に肉塊の画風を転写して、肉塊全天球画像(しゅごい日本語)を生成します。
style_nikuim_00
画風転写は以下の記事に載っているChainerコードを用いて行いました。
画風を変換するアルゴリズム

画像サイズが大きいとメモリ不足で実行できないようなので、全天球画像を複数領域に分割してそれぞれ画風転写し、フォトショでつなぎ合わせました。


3. UnityによるHMD体験
以下の記事の通りに、UnityでSphereに肉塊全天球画像を貼り付けます。
Unity覚え書き(全天球画像を球体に貼り付ける)

あとは、UnityのHMD用プラグインで体験できるようにするだけです。
Unity+Viveで開発する


☆感想
動画だとそれっぽく見えるんですが、HMDで体験すると今一つだったりします。
そもそも、部屋の全天球画像をHMDで見ても、スケール感が合わなくて、現実感が無いのですよね。
全天球画像ではなく、部屋のメッシュデータを取り込んでそれを使ったりすれば、現実感がグンと増すかもしれません。

Pythonで、SpaceCarvingのためのボクセルデータ投影をやってみました。
Pythonだと本当にすっきりと書けていいですね。
#coding:utf-8
import numpy as np
import math
from PIL import Image

# ユーザー定義変数
voxelWidth = 50 # ボクセルの各次元の個数
spaceSize = 1000.0 # ボクセル群が埋める空間の大きさ[mm]
radius = 250.0 # 球の半径[mm]
imageShape = np.array((640, 640)) # 画像解像度
viewAngleDeg = 90.0 # 画角[deg]
cameraPos = np.array([0.0, 0.0, -500.0]) # カメラ位置[mm]

# 自動算出変数
voxelSize = spaceSize / voxelWidth # ボクセル1つの大きさ[mm]
principlePoint = imageShape / 2.0 # 主点
viewAngleRad = viewAngleDeg / 180.0 * math.pi # 画角[rad]
focalLengthPix = imageShape / 2 / math.tan(viewAngleRad / 2.0) # 焦点距離[pix]

# 中心からの距離を要素として持つボクセルデータを生成
print 'calc distance'
voxelNo = np.arange(voxelWidth**3) # ボクセル番号。座標計算に用いる。
coords3d = np.zeros((3, voxelWidth**3)) # 三次元座標。各列がボクセル、各行がX, Y, Z。
coords3d[0,:] = (voxelNo % voxelWidth - voxelWidth / 2 + 0.5) * voxelSize # X座標
coords3d[1,:] = (voxelNo / voxelWidth % voxelWidth - voxelWidth / 2 + 0.5) * voxelSize # Y座標
coords3d[2,:] = (voxelNo / voxelWidth / voxelWidth - voxelWidth / 2 + 0.5) * voxelSize # Z座標
distance = np.sqrt(np.sum((coords3d * coords3d), axis=0)) # 各ボクセルの中心からの距離

# 球のボクセルデータを生成。内部は1。外部は0
print 'make voxel data'
voxels = np.zeros(voxelWidth**3)
voxels[distance < radius] = 1
del distance

# 射影行列の生成
print 'make projection matrix'
intrinsic = np.array([[focalLengthPix[0], 0.0, principlePoint[0]],\
[0.0, focalLengthPix[1], principlePoint[1]],\
[0.0, 0.0, 1.0]])
extrinsic = np.hstack((np.eye(3), -cameraPos.reshape(1, len(cameraPos)).transpose()))
proj = np.dot(intrinsic, extrinsic)

# 投影
print 'projection'
coords3d = np.vstack((coords3d, np.ones(voxelWidth**3)))
coords2d = np.dot(proj, coords3d)
coords2d[:2,:] /= coords2d[2,:]
del coords3d

# 球内部の投影座標を抽出
print 'extract coord'
intersectCoord = np.array(coords2d[:2, (voxels == 1) *\
(coords2d[0,:] >= 0) *\
(coords2d[0,:] < imageShape[0]) *\
(coords2d[1,:] >= 0) *\
(coords2d[1,:] < imageShape[1])], dtype=np.int64)
del coords2d, voxels

# 画像生成
print 'make image'
image = np.zeros(imageShape)
for i in range(intersectCoord.shape[1]):
image[intersectCoord[0,i], intersectCoord[1,i]] = 255

# 画像表示
Image.fromarray(image).show()
以下、投影結果の画像です。
voxelWidth=400で、メモリ6GBくらい使います。
全ボクセルの三次元座標を行列として確保してるのが痛い。

・voxelWidth=100
100

・voxelWidth=200
200


・voxelWidth=300
300

・voxelWidth=400
400

ディープラーニングで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に設定しています。
ネットワークではなく、画像を更新するのですね。面白い。

↑このページのトップヘ