すぎゃーんメモ Twitter

2016-05-16

TensorFlowによるDCGANでアイドルの顔画像生成

アイドル顔識別のためのデータ収集 をコツコツ続けて それなりに集まってきたし、これを使って別のことも…ということでDCGANを使ったDeep Learningによるアイドルの顔画像の「生成」をやってみた。

f:id:sugyan:20160516092946p:image

まだだいぶ歪んでいたりで あまりキレイじゃないけど…。顔画像を多く収集できているアイドル90人の顔画像からそれぞれ120件を抽出した合計10800件をもとに学習させて生成させたもの。


分類タスクとは逆方向の変換、複数のモデル定義などがあってなかなか理解が難しい部分もあったけど、作ってみるとそこまで難しくはなく、出来上がっていく過程を見るのが楽しいし とても面白い。


DCGANとは

"Deep Convolutional Generative Adversarial Networks"、略してDCGAN。こちらの論文で有名になった、のかな?

あとは応用事例として日本語の記事では以下のものがとても詳しいのでそれを読めば十分かな、と。

一応あらためて書いておくと。

顔識別のような分類タスクは

といった分類器を作って学習させるだけだが、DCGANではそういった分類器を"Discriminator"として使い、それと別に"Generator"というモデルを構築し使用する。Generatorの役割は

というものであり、この出力が最終的な「機械学習による画像生成」の成果物となる。

原理としては、

  • Discriminatorに「Generatorによって乱数ベクトルから生成された画像」と「学習用データ画像(生成させたい画像のお手本となるもの)」の両方を食わせ、それぞれの画像が「Generatorによって生成されたものであるか否か」の判定をさせる
  • Discriminatorは正しく判定できるよう学習させ、GeneratorはDiscriminatorを欺いて誤判定させる画像を生成するよう学習する

これを繰り返してお互いに精度を上げることで、ランダム入力から学習データそっくりの画像を生成できるようになる、というもの。

f:id:sugyan:20160514234103p:image

(http://qiita.com/mattya/items/e5bfe5e04b9d2f0bbd47 より引用)

言葉にしてみるとまぁなるほど、とは思うけどそんな上手く双方を学習できるのか、という感じではある。そのへんをBatch Normalizationを入れたりLeaky ReLUを使ったりして上手くいくようになったよ、というのが上記の論文のお話のようだ。


TensorFlowでの実装

先行のDCGAN実装例は既に結構ある。

TensorFlowによる実装も既にあったので、それを参考にしつつも自分で書いてみた。


Generator

乱数ベクトルから画像を生成するモデルは下図のようになる。

f:id:sugyan:20160515012346p:image

(arXiv:1511.06434より引用)

分類器などで使っている畳み込みの逆方向の操作で、最初は小さな多数のfeature mapにreshapeして、これを徐々に小数の大きなものにしていく。"deconvolution"と呼んだり呼ばなかったり、なのかな。TensorFlowではこの操作tf.nn.conv2d_transposeという関数で実現するようだ。

各層間の変換でW(weights)を掛けてB(biases)を加え、このWとBの学習により最終的な出力画像を変化させていくことになる。あと論文にある通りReLUにかける前にBatch Normalizationという処理をする。これはTensorFlow 0.8.0からtf.nn.batch_normalizationが登場しているのかな?ここにtf.nn.momentsで得るmeanとvarianceを渡してあげれば良さそう。

ということでこんな感じのコードで作った。

def model():
    depths = [1024, 512, 256, 128, 3]
    i_depth = depths[0:4]
    o_depth = depths[1:5]
    with tf.variable_scope('g'):
        inputs = tf.random_uniform([self.batch_size, self.z_dim], minval=-1.0, maxval=1.0)
        # reshape from inputs
        with tf.variable_scope('reshape'):
            w0 = tf.get_variable('weights', [self.z_dim, i_depth[0] * self.f_size * self.f_size], tf.float32, tf.truncated_normal_initializer(stddev=0.02))
            b0 = tf.get_variable('biases', [i_depth[0]], tf.float32, tf.zeros_initializer)
            dc0 = tf.nn.bias_add(tf.reshape(tf.matmul(inputs, w0), [-1, self.f_size, self.f_size, i_depth[0]]), b0)
            mean0, variance0 = tf.nn.moments(dc0, [0, 1, 2])
            bn0 = tf.nn.batch_normalization(dc0, mean0, variance0, None, None, 1e-5)
            out = tf.nn.relu(bn0)
        # deconvolution layers
        for i in range(4):
            with tf.variable_scope('conv%d' % (i + 1)):
                w = tf.get_variable('weights', [5, 5, o_depth[i], i_depth[i]], tf.float32, tf.truncated_normal_initializer(stddev=0.02))
                b = tf.get_variable('biases', [o_depth[i]], tf.float32, tf.zeros_initializer)
                dc = tf.nn.conv2d_transpose(out, w, [self.batch_size, self.f_size * 2 ** (i + 1), self.f_size * 2 ** (i + 1), o_depth[i]], [1, 2, 2, 1])
                out = tf.nn.bias_add(dc, b)
                if i < 3:
                    mean, variance = tf.nn.moments(out, [0, 1, 2])
                    out = tf.nn.relu(tf.nn.batch_normalization(out, mean, variance, None, None, 1e-5))
    return tf.nn.tanh(out)

入力は乱数なのでtf.random_uniformを使えば毎回ランダムな入力から作ってくれる。逆畳み込みはchannel数が変わるだけなのでfor loopで繰り返すだけで定義できる。最後の出力にはBatch Normalizationをかけずにtf.nn.tanhで -1.0〜1.0 の範囲の出力にする。


Discriminator

こちらは以前までやっていた分類器とほぼ同じで、画像入力から畳み込みを繰り返して小さなfeature mapに落とし込んでいく。最後は全結合するけど、隠れ層は要らないらしい。出力は、既存のTensorFlow実装などでは1次元にしてsigmoidの出力を使うことで「0に近いか 1に近いか」を判定にしていたようだけど、自分はsigmoidを通さな2次元の出力にして、「0番目が大きな出力になるか 1番目が大きくなるか」で分類するようにした(誤差関数については後述)。

また各層の出力にはLeaky ReLUを使うとのことで、これに該当する関数TensorFlowには無いようだったけど、tf.maximum(alpha * x, x)がそれに該当するということで それを使った。

また、Discriminatorは「学習用データ」と「Generatorによって生成されたもの」の2つの入力を通すことになるのでフローが2回繰り返されることになる。けどこれは同じモデルに対して入出力を行う、つまり同じ変数を使い回す必要がある。こういうときはtf.variable_scopereuse=Trueを指定すると2回目以降で同じ変数が重複定義されないようになるらしい。いちおう、初回の呼び出しか否かを使う側が意識する必要がないようPython3のnonlocalを使ってクロージャ的な感じで書いてみた。

ということでこんなコード。

def __discriminator(self, depth1=64, depth2=128, depth3=256, depth4=512):
    reuse = False
    def model(inputs):
        nonlocal reuse
        depths = [3, depth1, depth2, depth3, depth4]
        i_depth = depths[0:4]
        o_depth = depths[1:5]
        with tf.variable_scope('d', reuse=reuse):
            outputs = inputs
            # convolution layer
            for i in range(4):
                with tf.variable_scope('conv%d' % i):
                    w = tf.get_variable('weights', [5, 5, i_depth[i], o_depth[i]], tf.float32, tf.truncated_normal_initializer(stddev=0.02))
                    b = tf.get_variable('biases', [o_depth[i]], tf.float32, tf.zeros_initializer)
                    c = tf.nn.bias_add(tf.nn.conv2d(outputs, w, [1, 2, 2, 1], padding='SAME'), b)
                    mean, variance = tf.nn.moments(c, [0, 1, 2])
                    bn = tf.nn.batch_normalization(c, mean, variance, None, None, 1e-5)
                    outputs = tf.maximum(0.2 * bn, bn)
            # reshepe and fully connect to 2 classes
            with tf.variable_scope('classify'):
                dim = 1
                for d in outputs.get_shape()[1:].as_list():
                    dim *= d
                w = tf.get_variable('weights', [dim, 2], tf.float32, tf.truncated_normal_initializer(stddev=0.02))
                b = tf.get_variable('biases', [2], tf.float32, tf.zeros_initializer)
        reuse = True
        return tf.nn.bias_add(tf.matmul(tf.reshape(outputs, [-1, dim]), w), b)
    return model

Input Images

Discriminatorには[batch size, height, width, channel]の入力を与える前提で作っていて、学習用の画像データはその形のmini batchが作れれば良い。以前から 顔画像データはTFRecordsのファイル形式で作っていて それを読み取ってBatchにする処理は書いているので、それをほぼそのまま利用できる。

def inputs(batch_size, f_size):
    files = [os.path.join(FLAGS.data_dir, f) for f in os.listdir(FLAGS.data_dir) if f.endswith('.tfrecords')]
    fqueue = tf.train.string_input_producer(files)
    reader = tf.TFRecordReader()
    _, value = reader.read(fqueue)
    features = tf.parse_single_example(value, features={'image_raw': tf.FixedLenFeature([], tf.string)})
    image = tf.cast(tf.image.decode_jpeg(features['image_raw'], channels=3), tf.float32)
    image.set_shape([INPUT_IMAGE_SIZE, INPUT_IMAGE_SIZE, 3])
    image = tf.image.random_flip_left_right(image)

    min_queue_examples = FLAGS.num_examples_per_epoch_for_train
    images = tf.train.shuffle_batch(
        [image],
        batch_size=batch_size,
        capacity=min_queue_examples + 3 * batch_size,
        min_after_dequeue=min_queue_examples)
    return tf.sub(tf.div(tf.image.resize_images(images, f_size * 2 ** 4, f_size * 2 ** 4), 127.5), 1.0)

元々は分類タスクの教師データなのでlabel_idとセットになっているデータセットだけど、ここではJPEGバイナリ部分だけ取り出して使うことになる。distort系の処理はとりあえずほぼ無しで、random_flip_left_right(ランダム左右反転)だけ入れている。あと分類タスクでは最後にtf.image.per_image_whiteningを入れていたけど、これをやると元の画像に戻せなくなってしまうと思ったので 単純に 0〜255 の値を -1.0〜1.0 の値になるよう割って引くだけにしている。


Training

で、GeneratorとDiscriminatorが出来たらあとは学習の手続き。それぞれに対して最小化すべき誤差(loss)を定義して、Optimizerに渡す。前述した「Discriminatorは正しく判定できるよう学習させ、GeneratorはDiscriminatorを欺いて誤判定させる画像を生成するよう学習する」というのをコードに落とし込む。

Discriminatorによる分類を「0なら画像はGeneratorによるもの、1なら学習データのもの」と判定する関数D(x)と定義し、Generatorから生成した画像をG()、学習データの画像をIとすると

  • Generatorは、D(G())がすべて1になるのが理想
  • Discriminatorは、D(G())をすべて0にし D(I)をすべて1にするのが理想

なので、そのギャップをlossとして定義することになる。Discriminatorのような排他的な唯一の分類クラスを決める場合の誤差にはtf.nn.sparse_softmax_cross_entropy_with_logitsを使うのが良いらしい。

ということでこんな感じのコード。

def train(self, input_images):
    logits_from_g = self.d(self.g())
    logits_from_i = self.d(input_images)
    tf.add_to_collection('g_losses', tf.reduce_mean(tf.nn.sparse_softmax_cross_entropy_with_logits(logits_from_g, tf.ones([self.batch_size], dtype=tf.int64))))
    tf.add_to_collection('d_losses', tf.reduce_mean(tf.nn.sparse_softmax_cross_entropy_with_logits(logits_from_i, tf.ones([self.batch_size], dtype=tf.int64))))
    tf.add_to_collection('d_losses', tf.reduce_mean(tf.nn.sparse_softmax_cross_entropy_with_logits(logits_from_g, tf.zeros([self.batch_size], dtype=tf.int64))))
    g_loss = tf.add_n(tf.get_collection('g_losses'), name='total_g_loss')
    d_loss = tf.add_n(tf.get_collection('d_losses'), name='total_d_loss')
    g_vars = [v for v in tf.trainable_variables() if v.name.startswith('g')]
    d_vars = [v for v in tf.trainable_variables() if v.name.startswith('d')]
    g_optimizer = tf.train.AdamOptimizer(learning_rate=0.0001, beta1=0.5).minimize(g_loss, var_list=g_vars)
    d_optimizer = tf.train.AdamOptimizer(learning_rate=0.0001, beta1=0.5).minimize(d_loss, var_list=d_vars)
    with tf.control_dependencies([g_optimizer, d_optimizer]):
        train_op = tf.no_op(name='train')
    return train_op, g_loss, d_loss

論文によるとAdamOptimizerのパラメータはデフォルト値ではなくlearning_rate0.0002beta10.5を使うとのことだったけれど、Qiitaでの先行事例ではlearning_rateはさらに半分の0.0001としていて 実際大きすぎると最初の段階で失敗してしまうことがあったので0.0001にしておいた。

あと効くかどうか分からないけど一応すべてのweights分類タスク で使っていたWeight Decayを入れておいた。


Generating Images

こうして学習のopsも定義できたらあとはそれを実行して繰り返していれば少しずつ「ランダムな出力」から「顔らしい画像」になっていく。はず。

で、その成果物を確かめたいのでやっぱり画像ファイルとして書き出したいわけで。Generatorからの出力を取得して変換かけて、scipypylabなどを使って画像として出力できるみたいだけど、そのあたりも実はTensorFlowだけで出来るんですね。

Generatorからの出力は[batch size, height, width, channel]の、 -1.0〜1.0 の値をとるTensorなので、まずはそれらをすべて 0〜255 の整数値に変換する。

で、それをbatch sizeにsplitしてやると、それぞれ[height, width, channel]な画像データになるわけで。これらはtf.image.encode_pngとかにかければPNGバイナリが得られる。

せっかくなので複数出力された画像をタイル状に並べて1つの画像として出力させたいじゃん、って思ったらtf.concatで縦に繋げたり横に繋げたりを事前に入れておくことでそれも実現できる。

def generate_images(self, row=8, col=8):
    images = tf.cast(tf.mul(tf.add(self.g(), 1.0), 127.5), tf.uint8)
    images = [tf.squeeze(image, [0]) for image in tf.split(0, self.batch_size, images)]
    rows = []
    for i in range(row):
        rows.append(tf.concat(1, images[col * i + 0:col * i + col]))
    image = tf.concat(0, rows)
    return tf.image.encode_png(image)

これで、得られたopsをevalして得たバイナリファイルに書き出すだけで1つのbatchで生成された複数の画像出力を並べたものを一発で得ることができる。便利〜。

f:id:sugyan:20160516093024p:image


計算量を減らす(?)

今回は64x64でなく96x96の画像を生成させようとしていて(学習データが112x112で収集しているし 折角ならそれなりに大きく作りたい!)、元の論文では各層のchannel数が 1024, 512, 256, 128 になっていて(Qiitaの記事ではすべて半分にしていた)、そのパラメータ数で手元のCPUマシンで計算させる(僕はケチなのでGPUマシンとか持ってない…)と、 1step に 50sec とかとんでもなく時間がかかってしまい ちょっと絶望的だわ…と思い 少しでも計算量が減るよう 250, 150, 90, 54 という数字に変えた(50sec -> 18sec)。そしてbatch sizeも 128 から、半分の 64 だと流石に無理そうだったので 96 に(18sec -> 13sec)。一応このパラメータ数で200stepほど回してみたところ ちゃんとランダム出力から顔っぽいものに変わっていっているのが観測できたので これでやってみた。

f:id:sugyan:20160516092949g:image

少しずつ顔っぽいものが現れてきて、それぞれが個性ある顔に 鮮明に写るようになっていく変化がみてとれるかと。

こうして丸2日以上かけて、7000stepくらい回した結果 得たのが冒頭の画像になります。まだちゃんとした顔にならなかったりするのは、単に学習回数が足りていないのか パラメータが足りなすぎてこれ以上キレイにならないのか、はもうちょっと続けてみないと分からないけど 多分まだ回数が足りていないだけなんじゃないかな… もうちょっと続けてみます。

今回は「顔」っていうとても限定的な領域での生成だし、これくらいで大丈夫だろう、と かなり勘でパラメータ数を決めてしまっているので 本当はもっと理論的に適切な数を導き出したいところだけど…。

前回までの分類器だとsparsityというのを計測していたのでそれを元に削れるだろうな、と思っているのだけど、今回の場合すべてにBatch Normalizationが入っているのでそれも意味なくて、なんとも難しい気がする。それこそGPUで何度もぶん回して探っていくしかないのかなぁ。


任意の画像の生成…

DCGANによる生成ができると、今度は入力の乱数ベクトル操作することで任意の特徴をもつ画像をある程度狙って生成できるようになる、とのことなのでそれも試してみたいと思ったけど まだ出来ていないのと 長くなってしまったので続きは次回。

2016-04-29

画像内から検出した顔領域をImageMagickで固定サイズに切り出す

f:id:sugyan:20160428205401p:image

TensorFlowでのDeep Learningによるアイドルの顔識別 のためのデータ作成 - すぎゃーんメモ の記事で書いているけれど、学習用データとして使うために収集した画像から「顔の領域」だけを切り出して「固定サイズ」(112x112など)に切り出す必要があって。

以前にも書いたけど、自撮り画像はけっこう顔が傾いた状態で写っているものが多いので、それも検出できるようにしたりしている。

で、せっかく傾きの角度も含めて検出できるならそのぶんを補正して回転加工して切り出すようにしていて。

…というのを RMagick のRVGを使ってcanvasっぽい感じでどやこや書いていたのだけど、どうも使っているImageMagickバージョンなどの影響もあるのかもしれないけど

  • #destroy!とか明示的に呼んでるはずなのにメモリ使用量がどんどん増え続けてしまう
  • 特定の画像を読み込ませて加工しようとすると必ずSegmention faultになってclockworkプロセスごと死んでしまう

といった問題が起きていて、ちょっと原因追うのも面倒 というかわざわざこれくらいの加工にRMagickで頑張りすぎることもないんじゃないか、と思って捨てることにした。

要はImageMagickCLIを使いこなせればそれくらいのことが出来るはず、ということで調べたら

  • 指定倍率で拡大縮小させて
  • 中心を指定して回転させて
  • 任意の場所に並行移動する

というのにピッタリな、「Scale-Rotate-Translate (SRT) Distortion」というのがあることを知った。

Angle -> centered rotate
ScaleAngle -> centered scale and rotate
X,Y Angle -> rotate about given coordinate
X,YScaleAngle -> scale and rotate about coordinate
X,YScaleX,ScaleYAngle -> ditto
X,YScaleAngleNewX,NewY-> scale, rotate and translate coord
X,YScaleX,ScaleYAngleNewX,NewY-> ditto

という具合に、引数で「回転角」「倍率」「回転中心座標」「中心の移動先座標」をそれぞれ指定することで一発で変換ができるらしい。

ImageMagickCLI wrapper的な MiniMagick を使ってそれぞれ実験してみる。

顔の検出は Google Cloud Vision API でのFACE_DETECTIONのレスポンスを使うとする。


1. まずは回転角度だけを指定する場合

MiniMagick.logger.level = Logger::DEBUG
detected['responses'].first['faceAnnotations'].each do |annotation|
  srt = [
    - annotation['rollAngle']
  ].join(' ')
  MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert|
    convert.distort(:SRT, srt)
  end
end
DEBUG -- : [0.39s] mogrify -distort SRT -47.189156 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90415-1tuqqvg.jpg
DEBUG -- : [0.36s] mogrify -distort SRT -38.082096 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90415-kfub15.jpg

f:id:sugyan:20160428205356j:image:w640


2. ちょっと背景を…

回転によって空く領域はvirtual-pixelで指定できるらしい。デフォルトは白のようなので黒にする。

detected['responses'].first['faceAnnotations'].each do |annotation|
  srt = [
    - annotation['rollAngle']
  ].join(' ')
  MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert|
    convert.background('black')
    convert.virtual_pixel('background')
    convert.distort(:SRT, srt)
  end
end
DEBUG -- : [0.22s] mogrify -background black -virtual-pixel background -distort SRT -47.189156 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90304-1e0od53.jpg
DEBUG -- : [0.22s] mogrify -background black -virtual-pixel background -distort SRT -38.082096 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90304-1yp6bof.jpg

f:id:sugyan:20160428205357j:image:w640

それぞれの顔が真っ直になるよう微妙に回転角が調整されているのが確認できる


3. 回転中心座標を指定

何も指定していないと画像中央を中心として回転していたけど、顔の中心座標を指定すると

detected['responses'].first['faceAnnotations'].each do |annotation|
  xs = annotation['fdBoundingPoly']['vertices'].map { |v| v['x'] }
  ys = annotation['fdBoundingPoly']['vertices'].map { |v| v['y'] }
  srt = [
    "#{(xs.min + xs.max) * 0.5},#{(ys.min + ys.max) * 0.5}",
    - annotation['rollAngle']
  ].join(' ')
  MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert|
    convert.background('black')
    convert.virtual_pixel('background')
    convert.distort(:SRT, srt)
  end
end
DEBUG -- : [0.22s] mogrify -background black -virtual-pixel background -distort SRT 457.0,402.0 -47.189156 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91029-ctowe2.jpg
DEBUG -- : [0.20s] mogrify -background black -virtual-pixel background -distort SRT 210.5,290.5 -38.082096 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91029-irskuw.jpg

f:id:sugyan:20160429232040j:image:w640

顔の中心位置は動かずにそこを中心に回転した形、になる


4. スケールを指定する

顔のサイズと、切り出したいサイズの比 から倍率を求めて指定

size = 96
detected['responses'].first['faceAnnotations'].each do |annotation|
  xs = annotation['fdBoundingPoly']['vertices'].map { |v| v['x'] }
  ys = annotation['fdBoundingPoly']['vertices'].map { |v| v['y'] }
  srt = [
    "#{(xs.min + xs.max) * 0.5},#{(ys.min + ys.max) * 0.5}",
    size.to_f / [xs.max - xs.min, ys.max - ys.min].max,
    - annotation['rollAngle']
  ].join(' ')
  MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert|
    convert.background('black')
    convert.virtual_pixel('background')
    convert.distort(:SRT, srt)
  end
end
DEBUG -- : [0.31s] mogrify -background black -virtual-pixel background -distort SRT 457.0,402.0 0.43636363636363634 -47.189156 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90917-l2v5ta.jpg
DEBUG -- : [0.29s] mogrify -background black -virtual-pixel background -distort SRT 210.5,290.5 0.4085106382978723 -38.082096 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90917-1gf4b9q.jpg

f:id:sugyan:20160428205359j:image:w640

倍率の差はあまり分からないけど、それぞれ回転したものが縮小されているのは間違いない


5. 移動先座標を指定

最終的に左上から指定サイズでcropするために、変換後のものを左上に寄せる。顔の中心を既に指定しているので、これが指定サイズ領域の中心になるようになれば良い。

detected['responses'].first['faceAnnotations'].each do |annotation|
  xs = annotation['fdBoundingPoly']['vertices'].map { |v| v['x'] }
  ys = annotation['fdBoundingPoly']['vertices'].map { |v| v['y'] }
  srt = [
    "#{(xs.min + xs.max) * 0.5},#{(ys.min + ys.max) * 0.5}",
    size / [xs.max - xs.min, ys.max - ys.min].max,
    - annotation['rollAngle'],
    "#{size * 0.5},#{size * 0.5}"
  ].join(' ')
  MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert|
    convert.background('black')
    convert.virtual_pixel('background')
    convert.distort(:SRT, srt)
  end
end
DEBUG -- : [0.28s] mogrify -background black -virtual-pixel background -distort SRT 457.0,402.0 0.43636363636363634 -47.189156 48.0,48.0 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91316-icip1v.jpg
DEBUG -- : [0.30s] mogrify -background black -virtual-pixel background -distort SRT 210.5,290.5 0.4085106382978723 -38.082096 48.0,48.0 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91316-13jij7r.jpg

f:id:sugyan:20160428205400j:image:w640


6. 指定サイズでcrop

既に左上に寄せてあるので、offsetなしで切り取れば良いだけ、となる。

detected['responses'].first['faceAnnotations'].each do |annotation|
  xs = annotation['fdBoundingPoly']['vertices'].map { |v| v['x'] }
  ys = annotation['fdBoundingPoly']['vertices'].map { |v| v['y'] }
  srt = [
    "#{(xs.min + xs.max) * 0.5},#{(ys.min + ys.max) * 0.5}",
    size / [xs.max - xs.min, ys.max - ys.min].max,
    - annotation['rollAngle'],
    "#{size * 0.5},#{size * 0.5}"
  ].join(' ')
  MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert|
    convert.background('black')
    convert.virtual_pixel('background')
    convert.distort(:SRT, srt)
    convert.crop("#{size}x#{size}+0+0")
  end
end
DEBUG -- : [0.28s] mogrify -background black -virtual-pixel background -distort SRT 457.0,402.0 0.43636363636363634 -47.189156 48.0,48.0 -crop 96x96+0+0 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91967-1wh75bk.jpg
DEBUG -- : [0.27s] mogrify -background black -virtual-pixel background -distort SRT 210.5,290.5 0.4085106382978723 -38.082096 48.0,48.0 -crop 96x96+0+0 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91967-un47j8.jpg

f:id:sugyan:20160428205355j:image

できあがり。


…ということで、

$ convert <元画像> -background black -virtual-pixel background -distort SRT '<顔の中心座標> <拡大縮小倍率> <回転角度> <中心移動先座標>' -crop <切り出しサイズ>+0+0 <出力画像>

のような形でconvertmogrifyを使えば一発で検出された顔の領域を指定サイズで得ることができることが分かった。


のでRMagickの使用を止めてこの方法で顔画像領域を取得するよう変更した。今のところは問題なく動いているっぽい。


結論

音咲セリナちゃんも宇佐美幸乃ちゃんも可愛い。

2016-04-16

署名などの検証に定数時間の比較関数を使う

全裸bot for LINE - すぎゃーんメモ の記事にフィードバックいただきまして。

全然知らなかったのだけど、Timing attackという攻撃手法が存在するそうで。

たとえば文字列の比較で、先頭から1文字ずつ比較していってその中身が異なっていたらreturnする、という処理をしている場合。

func cmpstring(s1, s2 string) int {
	l := len(s1)
	if len(s2) < l {
		l = len(s2)
	}
	for i := 0; i < l; i++ {
		c1, c2 := s1[i], s2[i]
		if c1 < c2 {
			return -1
		}
		if c1 > c2 {
			return +1
		}
	}
	if len(s1) < len(s2) {
		return -1
	}
	if len(s1) > len(s2) {
		return +1
	}
	return 0
}

パスワードや署名の検証など「正しい文字列が与えられたらtrue、そうでない場合はfalse」という場面でこういう方法で文字列比較を行っていると、与える文字列によって処理の演算回数が変わるので、その実行時間を計測しながら何度も試行することで正しい文字列が推測できてしまう、ということらしい。

今回のようなHTTP経由での数バイト文字列比較ではネットワーク遅延などの誤差の方が遥かに大きく この手法で破れるとはあまり思えないけれど、こういったものを防ぐために「入力の値にかかわらず定数時間で処理を行う比較関数」がちゃんと用意されているので、それを使うのがベターでしょう。

func (bot *Bot) checkSignature(signature string, body []byte) bool {
	decoded, err := base64.StdEncoding.DecodeString(signature)
	if err != nil {
		return false
	}
	hash := hmac.New(sha256.New, []byte(bot.ChannelSecret))
	hash.Write(body)
	return hmac.Equal(decoded, hash.Sum(nil))
}

このhmac.Equalが中でsubtle.ConstantTimeCompareを使うようになっていて、その実装が

// ConstantTimeCompare returns 1 iff the two slices, x
// and y, have equal contents. The time taken is a function of the length of
// the slices and is independent of the contents.
func ConstantTimeCompare(x, y []byte) int {
	if len(x) != len(y) {
		return 0
	}

	var v byte

	for i := 0; i < len(x); i++ {
		v |= x[i] ^ y[i]
	}

	return ConstantTimeByteEq(v, 0)
}

// ConstantTimeByteEq returns 1 if x == y and 0 otherwise.
func ConstantTimeByteEq(x, y uint8) int {
	z := ^(x ^ y)
	z &= z >> 4
	z &= z >> 2
	z &= z >> 1

	return int(z)
}

のようになっていて、必ずすべてのbyteについて比較してその結果が等しくなっているかどうか、という計算になっているようだ。

こういった比較関数を使うことでTiming attackを防ぐことができる。

2016-04-13

全裸bot for LINE

BOT API Trial Account Overview - LINE Business Center が公開されて、LINEbotが作れるようになった、ということで 遅ればせながら自分も 過去に作ったTwitter bot を移植してみた。

f:id:sugyan:20160413152641p:image

テキストを受け取ってちょっと改変してオウム返しする、というのは練習としては良い題材ですね。kagome を使えばPure Go形態素解析できるし、ということでGoで書いてみた。

source code
https://github.com/sugyan/line-zenra-bot

Callback URLSSLが必須、送信側はIP Whilelistに登録している必要がある、ということでちょっと制限があるけれど、これくらいのお遊び程度のものならherokuで受けて fixie addonを使えばIP固定させて使うことができる。もっと本格的に使おうと思ったらちゃんとした構成を考える必要があるでしょうけども。


実装の参考に、と幾つかの記事や公開コードを読んでみたのですが Signature validationを無視しているものが多くて気になりました。

お遊び程度のデモなら気にすることもないのかもしれないけど、Callback URLに対して誰でも出鱈目なJSONをPOSTできる状態で そのまま信頼してそのデータを使うなんてとんでもない。

そんなに難しいことでもないし、ちゃんとSignature validationはしておこう。

func (bot *Bot) handle(w http.ResponseWriter, req *http.Request) {
	defer req.Body.Close()
	body, err := ioutil.ReadAll(req.Body)
	if err != nil {
		...
	}
	if !bot.checkSignature(req.Header.Get("X-Line-Channelsignature"), body) {
		return nil, errors.New("invalid signature")
	}
	...
}

func (bot *Bot) checkSignature(signature string, body []byte) bool {
	hash := hmac.New(sha256.New, []byte(bot.ChannelSecret))
	hash.Write(body)
	return signature == base64.StdEncoding.EncodeToString(hash.Sum(nil))
}

※追記しました

参照

などなど

2016-03-28

TensorFlowでのDeep Learningによるアイドルの顔識別 のためのデータ作成

続・TensorFlowでのDeep Learningによるアイドルの顔識別 - すぎゃーんメモ の続き、というかなんというか。

前回までは「ももいろクローバーZのメンバー5人の顔を識別する」というお題でやっていたけど、対象をより広範囲に拡大してみる。


様々なアイドル、応援アプリによる自撮り投稿

あまり知られていないかもしれないけど、世の中にはものすごい数のアイドルが存在しており、毎日どこかで誰かがステージに立ち 歌って踊って頑張っている。まだまだ知名度は低くても、年間何百という頻度でライブを中心に活動している、所謂「ライブアイドル」。俗に「地下アイドル」と言ったりする。

そういったアイドルさんたち 活動方針も様々だけど、大抵の子たちはブログTwitterを中心としてWebメディアも活用して積極的に情報や近況を発信していたりする。

そんな中、近年登場した「自撮り投稿サービス」。

概要としては、各アイドルさんが自撮りなどの写真を投稿し、ファンのユーザーが「応援」としてポイントを送ることでその数で順位がつく、というもの。ある期間で上位にランクインすると街頭広告雑誌インタビューに掲載されるといったイベントも開催されるため、アイドルさんは可愛い自撮りをたくさん投稿するしファンポイント貯めたり買ったりして頑張って応援する。有名なところで以下の2つのサービスが登場している。

それぞれのサービスに登録されているアイドルさんを 雑にスクレイピング して取得したところ、軽く1000件を超えた。それだけたくさんアイドルがいるわけで、最終目的としては入力した顔画像をそれら1000人以上のアイドルに分類すること、となる。

…のだけど、つまりそれを学習させるためにそれらのアイドルさんたちの「顔画像」と「(それが誰であるかを示す)ラベル」のセットが大量に必要となるわけで。


自撮り投稿から顔識別用データセットを作るには

多くのアイドルさんが利用しているこの自撮り投稿サービスから、投稿者と画像をセットで引っ張ってくれば「自撮り」なんだから自動でラベル付け済みの顔画像データセットを作れるんじゃないか、と考えたのだけど、まぁそんなに簡単な話でもなく。

まず「主に自撮りが投稿される」のであって必ず投稿した本人だけの顔が写っているとは限らない。別にそういうルールがあるわけではないので、共演した別のアイドルさんとの2ショットだったり 同じグループのメンバーと一緒の仲良しショットがあったりすることも珍しくない。そういった写真は顔検出は自動で出来ても、複数検出されたもののうちどの顔が投稿者本人なのかは分からない。

あと、そもそも上記の自撮り投稿サービスたちはデータ取得用のAPIが公開されていない。CHEERZの方はWeb版があるのでスクレイピングすれば可能そうではあるけど、DMM.yellはアプリ専用なのでそれなりにハックが必要になる。


Twitterから収集、管理するWebアプリ

…やはりラベル付けは人力でやるしかない、と覚悟を決めて、とにかくまずは収集してみることにした。

アプリには取得APIが無いが、投稿時にTwitterと連動する機能がついているようで、大抵のアイドルさんは自分のTwitterアカウントと紐付けてそちらにも画像付きTweetで流している。ので、連動投稿に付与されている「#CHEERZ」「#dmmyell」といったハッシュタグTwitter検索をかければ大抵の画像は取得できる。それらの投稿から各アイドルさんの個人アカウントも把握できるので、自撮りアプリ連携ではない普段の画像Tweetも収集対象になるように登録しておく。

そうやって定期ジョブTwitterからひたすら画像付きTweetを取得し、 自作の顔検出器 にかけて顔部分を抽出して保存していく。このあたりは 前回までももクロちゃんデータセットを作成したときと同様で、管理用のWebアプリを自作している。

以前はこれをHeroku運用していたのだけど、今回はあっという間に顔画像が10000件を超えて 無料枠で利用できる範囲をオーバーしてしまったので、以前から持っていたのにほぼ使っていなかったさくらVPSサーバに移行した。


ひたすら目視でラベル付け

こんな感じで、様々なアイドルさんの顔画像が取得できる。(かわいい)

f:id:sugyan:20160323195015p:image

が、これらをそれぞれ「誰であるか」のラベル付けをする必要があり。僕だって一応ドルヲタとして年間200〜300の現場に足を運び何百組というアイドルを見てきたし そのへんの人よりはアイドルに詳しいつもりだから 100人とか200人くらいなら顔を識別できる自信はあるけれど、1000人くらいの顔がごちゃ混ぜになっていると流石に無理がある。

とりあえずは知ってる顔はどんどん片付けていって、あとは知らない子でも普通に自撮りっぽい画像で1人で写っていればそれはまぁ本人に違いないだろう、と判断できるのでラベルを付けていく。本当に見たことのない知らない子たちの集合写真とかはまったく判別できないので後回し。

というのを考えて、顔が1つだけ検出されている画像を優先でランダムで選択しつつラベル付けしていったり。いちおう入力しやすいよう補完つけたりインタフェースを工夫したりはしている。

f:id:sugyan:20160327235241g:image

気の遠くなるような作業ではあるけれど、まぁ好きなので意外と飽きない。数千件は自力でラベル付けできた。


とりあえず学習させてみる

と、こんな作業を続けているうちに 何人かはある程度の枚数の顔画像が揃うので、集まっている限りのデータを使って 前回のもの と同じ、96x96 size, 3 channelの画像を入力とする4層の畳み込みと3層の全結合によるネットワークを使って学習させてみる。

1分類につき10枚程度だと心許ないが、30枚くらい学習させたらある程度の特徴を掴んでそれっぽいものは判別してくれるようになるのでは…?という目論見。

学習用と評価用とかデータセットを分ける余裕はないので、とりあえずはラベル付けしたものは全部学習用に使う。前回と同じTFRecordのファイルを入力に使うため、 ダウンロードできるように顔画像JPEGバイナリとラベル番号のセットを含むTFRecordバイナリを吐くエンドポイント実装 した。

ラベル付けして30枚以上集まっている顔画像セットに、それ以外のものとして 数枚しか集まっていないものやそもそも顔画像じゃないものも「分類対象外」のラベルとして3割ほど混ぜる。数千枚を50クラスくらいに分類するもの、となる。これは前回と同じモデル(むしろ畳み込み層のパラメータ数は減らしてる)で 1000〜2000 stepくらいでも十分にcross entropyが減少して 教師データに対してはほぼミスなく ちゃんと分類してくれるようになる。


推論結果を確認、修正

で、ある程度学習が済んだモデルが出来上がったら、未分類の顔画像に対してその分類器にかけて推論してもらう。

f:id:sugyan:20160328002101p:image

学習済みの顔に近いものは高いスコアで識別されるはずなので、これを確認することで、未分類の顔画像に対して「誰であるか」を考えるのではなく「○○と推測されているが、合っているか」だけを考えることになるので、人間の負担が軽減し作業が捗る。

そして当然ながらまだまだ学習データ数が少ないので、この推論はすごくよく間違う(上記の画像のは全部あってます)。

傾向としては、一人金髪の子を学習すると 髪の明るい人物は大抵その子と認識するようになる とか、画質がボヤけ気味のが多い子を学習しているとボヤけてる画像はだいたいその子と認識する、とか。髪の短い子や 頬にほくろのある子 とか 一応なんらかの特徴を掴んでいるようで 似たようなものはそれっぽく分類することは多いけど、やっぱりヒトが見たら全然ちがうだろって思うような間違いをしていたりはする。

それはそれで確認しながら正しくラベル付けしてやることでまた学習に使えるデータが増えるので、ある程度の答え合わせが済んだら増えたデータセットを元に再び学習してやる。そうすることで以前間違えたようなものはもう同じ間違いはしないし、さらに精度の高い推論をするよう進化する。

似たような入力に対し何度も学習プロセスを繰り返すのに 毎回まっさらな状態から始めるのは非効率だと思ったので、学習し直すときは前回の学習済みモデルのパラメータをロードしてそこから始めることにした。分類対象数が増えた場合でも、それは最後の全結合の 隠れ層→出力層 の部分だけしか構造は変わらないので、それ以外のパラメータはそのまま使っても 初期化状態から始めるよりは早い、はず。

def restore_or_initialize(sess):
    if os.path.exists(FLAGS.checkpoint_path):
        for v in tf.all_variables():
            print 'restore variables "%s"' % v.name
            try:
                restorer = tf.train.Saver([v])
                restorer.restore(sess, FLAGS.checkpoint_path)
            except Exception:
                print 'could not restore, initialize!'
                sess.run(tf.initialize_variables([v]))
    else:
        print 'initialize all variables'
        sess.run(tf.initialize_all_variables())

https://github.com/sugyan/tf-classifier/blob/master/models/v2/train.py#L30-L46


結論

ということで、学習用のデータを用意するのは大変だけど、

  • ある程度集まったらとりあえず学習させる
  • 学習させたモデルを使って推論させてみる
  • 推論結果を検証することで学習データを増やし、再び学習させることで精度が上がる

というサイクルを続けることで、なんだかんだで自力で13000点ほどの分類済みのアイドル顔データを作ることができている。



現時点での最新の学習済み分類器での結果はたとえば

f:id:sugyan:20160328004436p:image

というかんじで、100クラス以上の分類数である中で Luce Twinkle Wink☆ のメンバー5人中4人くらいは一応判別できるようになっている。


課題

30枚くらい集まれば学習対象になって 推論結果にも出るからさらにデータを増やすのに使えるけれど、そもそもその30枚くらいまで集めるのが大変なわけで。

似たような特徴を持つ顔をクラスタリングしてまとめてラベル付けできたら良いのかな…?と思って、学習済みモデルの隠れ層の出力パターンが似たものとかで分類できないかと調べてみたのだけど ちょっと有意な傾向は把めなそうだった…。

あとは推論結果に対する検証なんかはアイドルに詳しいドルヲタの方々に手伝ってもらう形で集合知を利用して実現できれば良いのだけど、なんとも良いインタフェースが思い浮かばない。


余談

分類作業しているうちに、知らなかった子の顔もけっこう覚えるようになる。人間のラーニング能力もすごいな、って。


今後の展望

だんだんアイドルの顔画像」が集まってきたので、今度はこれらを利用して"生成する"というのにもチャレンジしてみたいと思っている。

他にもなにか良いアイディアがあれば。現時点で作ったデータセットも何かに利用したい、という方がおりましたら提供の相談させていただきますのでお気軽に連絡ください。