はじめに
「GANで画像を生成してみた」系の記事はたくさんありますが、動画の生成はなかなかないんじゃないかと思います。
なぜなら、従来のGANによる動画生成1は人間の影らしきものを動かすのがやっとで、画像生成と比べて技術が遅れていたのです。しかし本日ご紹介するMoCoGAN2は、7月にarxivで論文が発表されたばかりの論文で、既存研究に比べて圧倒的に綺麗な動画を生成することができます。
この記事では、MoCoGANのArchitecture及び、Pytorchによる実装のポイントを解説していきます。
github上の実装はこちらになります。https://github.com/DLHacks/mocogan
また筆者による実装はまだ公開されていないようなので、これがおそらく初の公開実装となるかと思います。(※17/9/30時点)
スターをいただけると励みになります……!!!
モデル
新規性
- Generatorの潜在空間をContent SubspaceとMotion Subspaceにわけた。
- 上記によって潜在空間のもつれをほどき、精度向上。
- 可変長のビデオを生成できる。
- content(人物など)を固定してmotionを変えたり(歩かせる、手を振らせるなど)、逆にmotionを固定してcontentを変えるなど、柔軟な生成が可能になる。
Architecture
MoCoGANは4つのモデルの組み合わせでできています。
- 画像Discriminator: 生成されたビデオのうち1フレームを受け取り、真の画像か偽の画像か判断する2DのCNN。
- 動画Discriminator: 生成された(Tフレームからなる)ビデオを受け取り、真の動画か偽の動画か判断する3DのCNN。
- 画像Generator: 1枚の画像を生成する。input noiseの\(z\)は、contentに対応する\(z_C\)とmotionに対応する\(z_M\)の結合からなる\((z = [z_C, z_M]^{\top})\)、2Dの逆CNN。
- GRU: Gaussian noiseの\(\epsilon\)から、motion noiseの\([z_M^{(1)}, z_M^{(2)}, ……, z_M^{(K)}]\)を生成する。
(画像は論文中より)
Discriminators詳細
realまたはfakeの動画から、1枚の画像とTフレームの動画をサンプリングして(図中の\(S_1, S_T\))それぞれDiscriminatorに渡します。生成される動画は可変長ですが、動画Discriminatorは3Dの畳み込みを使用するので固定長しか受け取れない点に注意。
先行研究(VGAN, temporal-GAN) では動画Discriminatorしか使っていなかったのに対して、画像Discriminatorも使っています。これによって、contentの本物っぽさを画像Discriminatorに任せることができるようになり、動画Discriminatorがmotionの本物っぽさの学習に注力できる、というメリットがあります。
Generators詳細
今回登場するGeneratorはあくまで一つの潜在変数zに対して1枚の画像を生成します。そこで、K個の潜在変数\([z^{(1)}, z^{(2)}, ……, z^{(K)}]\)を入力することで、K枚のフレームからなる動画を生成させることにします。(先行研究のVGANが、Generatorに三次元の逆畳み込みで動画を直接生成させている点との違い)
このK枚のフレームの中で、写っているcontent(例えば人物)はいつも同じであると仮定しましょう。すると、\(z_C^{(t)}\)はある動画内のフレームですべて同じであると考えられます。(すべてのtに対して、\(z_C^{(t)} = z_C\))
これに対してmotion noiseの\(z_M^{(t)}\)は、ある動画内での動き(motion)を吸収するために、フレームを通じて変化し続けなければなりません。そこで、GRUによって系列的に生成することにします。(\([z_M^{(1)}, …, z_M^{(K)}] = GRU([\epsilon^{(1)}, …, \epsilon^{(K)}])\))
論文からの変更点
本実装では論文上のモデルに1点大きな変更を加えているので、その点について先に解説します。
論文では、\([\epsilon^{(1)}, ……,\epsilon^{(K)}]\)がi.i.dでガウス分布に従うと書かれています。しかし、ランダムな系列\(\epsilon\)をGRUに与えて、意味のある系列\([z_M^{(1)}, …, z_M^{(K)}]\)を生成することはできないように思えます。もし意味のある系列が生成されるならば、それはinputである\([\epsilon^{(1)}, ……,\epsilon^{(K)}]\)を無視して、隠れ層の初期値\(h_0\)のみに依存するようにモデルが学習されているのではないでしょうか。
実際、(\(h_0\)が固定という条件のもとで)\([\epsilon^{(1)}, ……,\epsilon^{(K)}] \)をi.i.dにサンプリングしてモデルを訓練したところ、\([\epsilon^{(1)}, ……,\epsilon^{(K)}] \)を変化させても、毎回同じmotionが生成されてしまいました。
そこで本実装では、\(\epsilon^{(1)}\)のみをランダムサンプリングし、\(\epsilon^{(t)} = GRU(\epsilon^{(t-1)})\)のように、GRUの出力自身を次の期の入力としました。これにより、\(\epsilon^{(1)}\)を変化させることで、毎回違うmotionが生成できるようになりました。
(元の論文がこの問題をどうクリアしているのかわからないけど、\(\epsilon^{(t)}\)を全部同じ値にしているか、\(h_0\)の初期化でmotionのvariationを作っているかしている可能性があります。)
このあたり、私の理解が間違っていましたら突っ込みをお願いしますσ(^_^;)
拡張
action labelで条件付けることができます(笑わせる、怒らせる等)。こちらは未実装です。
実装
モデルの部分のみご紹介します。trainの詳細はGithubのコードをご覧下さい。
画像Discriminator、画像Generator
pytorch公式のexampleを参考にしました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
# see: _netD in https://github.com/pytorch/examples/blob/master/dcgan/main.py class Discriminator_I(nn.Module): def __init__(self, nc=3, ndf=64, ngpu=1): super(Discriminator_I, self).__init__() self.ngpu = ngpu self.main = nn.Sequential( # input is (nc) x 96 x 96 nn.Conv2d(nc, ndf, 4, 2, 1, bias=False), nn.LeakyReLU(0.2, inplace=True), # state size. (ndf) x 48 x 48 nn.Conv2d(ndf, ndf * 2, 4, 2, 1, bias=False), nn.BatchNorm2d(ndf * 2), nn.LeakyReLU(0.2, inplace=True), # state size. (ndf*2) x 24 x 24 nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, bias=False), nn.BatchNorm2d(ndf * 4), nn.LeakyReLU(0.2, inplace=True), # state size. (ndf*4) x 12 x 12 nn.Conv2d(ndf * 4, ndf * 8, 4, 2, 1, bias=False), nn.BatchNorm2d(ndf * 8), nn.LeakyReLU(0.2, inplace=True), # state size. (ndf*8) x 6 x 6 nn.Conv2d(ndf * 8, 1, 6, 1, 0, bias=False), nn.Sigmoid() ) def forward(self, input): if isinstance(input.data, torch.cuda.FloatTensor) and self.ngpu > 1: output = nn.parallel.data_parallel(self.main, input, range(self.ngpu)) else: output = self.main(input) return output.view(-1, 1).squeeze(1) # see: _netG in https://github.com/pytorch/examples/blob/master/dcgan/main.py class Generator_I(nn.Module): def __init__(self, nc=3, ngf=64, nz=60, ngpu=1): super(Generator_I, self).__init__() self.ngpu = ngpu self.main = nn.Sequential( # input is Z, going into a convolution nn.ConvTranspose2d( nz, ngf * 8, 6, 1, 0, bias=False), nn.BatchNorm2d(ngf * 8), nn.ReLU(True), # state size. (ngf*8) x 6 x 6 nn.ConvTranspose2d(ngf * 8, ngf * 4, 4, 2, 1, bias=False), nn.BatchNorm2d(ngf * 4), nn.ReLU(True), # state size. (ngf*4) x 12 x 12 nn.ConvTranspose2d(ngf * 4, ngf * 2, 4, 2, 1, bias=False), nn.BatchNorm2d(ngf * 2), nn.ReLU(True), # state size. (ngf*2) x 24 x 24 nn.ConvTranspose2d(ngf * 2, ngf, 4, 2, 1, bias=False), nn.BatchNorm2d(ngf), nn.ReLU(True), # state size. (ngf) x 48 x 48 nn.ConvTranspose2d( ngf, nc, 4, 2, 1, bias=False), nn.Tanh() # state size. (nc) x 96 x 96 ) def forward(self, input): if isinstance(input.data, torch.cuda.FloatTensor) and self.ngpu > 1: output = nn.parallel.data_parallel(self.main, input, range(self.ngpu)) else: output = self.main(input) return output |
動画Discriminator
基本的に画像Discriminatorの2次元のConvを3次元のConvに変更しただけです。途中で挟むFlattenを自分で定義する必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
class Discriminator_V(nn.Module): def __init__(self, nc=3, ndf=64, T=16, ngpu=1): super(Discriminator_V, self).__init__() self.ngpu = ngpu self.main = nn.Sequential( # input is (nc) x T x 96 x 96 nn.Conv3d(nc, ndf, 4, 2, 1, bias=False), nn.LeakyReLU(0.2, inplace=True), # state size. (ndf) x T/2 x 48 x 48 nn.Conv3d(ndf, ndf * 2, 4, 2, 1, bias=False), nn.BatchNorm3d(ndf * 2), nn.LeakyReLU(0.2, inplace=True), # state size. (ndf*2) x T/4 x 24 x 24 nn.Conv3d(ndf * 2, ndf * 4, 4, 2, 1, bias=False), nn.BatchNorm3d(ndf * 4), nn.LeakyReLU(0.2, inplace=True), # state size. (ndf*4) x T/8 x 12 x 12 nn.Conv3d(ndf * 4, ndf * 8, 4, 2, 1, bias=False), nn.BatchNorm3d(ndf * 8), nn.LeakyReLU(0.2, inplace=True), # state size. (ndf*8) x T/16 x 6 x 6 Flatten(), nn.Linear(int((ndf*8)*(T/16)*6*6), 1), nn.Sigmoid() ) def forward(self, input): if isinstance(input.data, torch.cuda.FloatTensor) and self.ngpu > 1: output = nn.parallel.data_parallel(self.main, input, range(self.ngpu)) else: output = self.main(input) return output.view(-1, 1).squeeze(1) class Flatten(nn.Module): def forward(self, input): return input.view(input.size(0), -1) |
GRU
- 前述のように\(\epsilon^{(t)} = GRU(\epsilon^{(t-1)})\)としています。
- 出力の前にBatchNormを噛ませています\(( z_M^{(t)} = BN( GRU(\epsilon^{(t)}) ) )\) 。
- \(z^{(t)} = [z_C, z_M^{(t)}]^{\top}\)を考えるに、\(z_M\)の平均分散を\(z_C\)(ガウス分布からサンプリング)と揃えたほうがいいんじゃないかと思ってこの処理を入れてみました。本当に効いているかは未検証ですすみません。
- parameterの初期化では、forget gateに入るbias項が大きくなるようにしています。
- これによって過去の情報を忘れやすくなり、勾配消失を防ぐ効果があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
class GRU(nn.Module): def __init__(self, input_size, hidden_size, dropout=0, gpu=True): super(GRU, self).__init__() output_size = input_size self._gpu = gpu self.hidden_size = hidden_size # define layers self.gru = nn.GRUCell(input_size, hidden_size) self.drop = nn.Dropout(p=dropout) self.linear = nn.Linear(hidden_size, output_size) self.bn = nn.BatchNorm1d(output_size, affine=False) def forward(self, inputs, n_frames): ''' input.shape() => (batch_size, input_size) gru_out.shape() => (seq_len, batch_size, hidden_size) outputs.shape() => (seq_len, batch_size, output_size) ''' outputs = [] for i in range(n_frames): self.hidden = self.gru(inputs, self.hidden) inputs = self.linear(self.hidden) outputs.append(inputs) outputs = [ self.bn(elm) for elm in outputs ] outputs = torch.stack(outputs) return outputs def initWeight(self, init_forget_bias=1): # See details in https://github.com/pytorch/pytorch/blob/master/torch/nn/modules/rnn.py for name, params in self.named_parameters(): if 'weight' in name: init.xavier_uniform(params) # initialize forget gate bias elif 'gru.bias_ih_l' in name: b_ir, b_iz, b_in = params.chunk(3, 0) init.constant(b_iz, init_forget_bias) elif 'gru.bias_hh_l' in name: b_hr, b_hz, b_hn = params.chunk(3, 0) init.constant(b_hz, init_forget_bias) else: init.constant(params, 0) def initHidden(self, batch_size): self.hidden = Variable(torch.zeros(batch_size, self.hidden_size)) if self._gpu == True: self.hidden = self.hidden.cuda() |
実験
人間が走る、手をふるなどのactionを行うビデオが93個あります。1つのビデオあたり約3秒で、全体で約244秒(画像にして6108フレーム)と、比較的小さなサンプルサイズです。
1batchあたり16サンプルで12万iteration回した結果がこちら。
ジャンプのような激しい運動だとぼやけますが、手を振るくらいの簡単な動作ならちゃんとしたヒューマンが生成できました!!
感想
GANは学習が不安定だとよく聞きますが、MoCoGANは学習のテクニックをほぼ使わず(one-sided label smoothingのみ)、またサンプルも少なめで生成がうまくいったので驚きです。それだけ「MotionとContentの分離で潜在空間のもつれをほどく」というアイデアが機能しているのでしょうか。
ここまでお付き合いいただきありがとうございました。
- C. Vondrick, H. Pirsiavash, and A. Torralba. Generating videos with scene dynamics. In Advances In Neural Information Processing Systems, 2016. ↩
- Sergey Tulyakov. Ming-Yu Liu. Xiaodong Yang. Jan Kautz. MoCoGAN: Decomposing Motion and Content for Video Generation, arXiv preprint arXiv:1707.04993, 2017. ↩
- L.Gorelick,M.Blank,E.Shechtman,M.Irani,andR.Basri. Actions as space-time shapes. PAMI, 29(12):2247–2253, 2007. http://www.wisdom.weizmann.ac.il/%7Evision/SpaceTimeActions.html ↩