続・TensorFlowでのDeep Learningによるアイドルの顔識別

TensorFlowによるディープラーニングで、アイドルの顔を識別する - すぎゃーんメモ の続き。

前回は最も簡単に画像分類を試すために TensorFlow に同梱されているtensorflow.models.image.cifar10パッケージのモデルや学習機構を利用して約75%の識別正答率の分類器を作ったが、それよりも良い結果を出したいし色々ためしてみたい、ということで今回は色々と自前で実装したり改良を加えてみた。

結論だけ先に書くと、約90%の正答率のものを作ることができた。分類数も変えてしまっているので一概には前回のものと比較できないけど。

入力画像の変更

まずは入力の画像について。
前回はCIFAR-10のデータセットに合わせて、検出して切り出した顔画像を32x32サイズに縮小したものを利用していた。

32x32 → inside 96x96 of 112x112

流石に32x32では小さすぎて人間が見てもなかなか区別できなかったりしたし、もうすこしハッキリと分かるくらいの画像サイズを入力に使えるようにしよう ということで各辺3倍サイズの96x96画像を入力にすることにした(画素数で言うと9倍)。
そして、ある程度のスケーリング誤差も吸収できるようにと 顔画像収集時には検出された顔領域の1.2倍ほどの少し大きめの領域で切り出し112x112サイズで取得し、そこから96〜112の間でランダムに切り出してさらに収縮させて最終的に96x96サイズに収まるように、というのを後述のDistortionのところで行った。

6 → 5 Classification

切り出す領域を変更したので顔画像は収集し直してラベルも付け直した。収集方法は同じで、ももクロメンバー5人についてそれぞれ200点、計1000点を学習用のデータセットとして用いた。
前回は6番目のラベルとして「ももクロ以外」の人物の顔を学習・評価に使っていたが、どうにも種類が少なくて分類のラベルとして使うのに適しているとは思えなかったので除外することにした。

TFRecord file

CIFAR-10のバイナリデータの場合、各ピクセルについてのR, G, Bの値を1byteずつ使って表す形だったので1画像あたり32 * 32 * 3 = 3072byteだったが、これが各辺3倍にするとデータサイズが9倍になってしまう。1000点集めると96 * 96 * 3 * 1000 = 27648000byte(26.4MB)。
まぁ別にそれくらいならどうってことないのだけど、もう少し小さいサイズで済むならそれに越したことはない。

TensorFlowには"TFRecords"というバイナリデータ列も含めたシリアライズのファイル形式をサポートするReader & Writerがあり、固定長でない構造的なデータなども複数格納したりできる。

ので、ここに分類の正解ラベルの値とJPEG画像のバイナリデータ列をセットで入れてシリアライズして書き込むことで、112x112サイズでも1画像あたり3~5KB程度でデータセットを作成できる。
使うときはtf.TFRecordReaderでTFRecord fileを読んでFeatureを取り出せばあとはJPEGバイナリデータからtf.image.decode_jpegで画像に復元できる。
(tf.parse_single_exampleあたりはtensorflow-0.6.0と最新コードでは引数などインタフェースが異なるので注意。最新masterのドキュメント読みながらコード書いてたら動かなくてハマった)

Distortion

読み込んでdecodeした画像を、学習データとしてさらにランダムに加工して使う。これはtensorflow.models.image.cifar10.distorted_inputsでも使われている手法。

TensorFlowにはtf.image.random_crop, tf.image.random_flip_left_right, tf.image.random_brightness, tf.image.random_contrastなどの画像加工系メソッドが用意されており、これらによる加工処理を入れることで明るさや色合いを変えたり反転・拡大縮小したりできるので、1つの顔画像からも異なる複数の画像を生成して学習に利用できる。

例えば

という具合に。
各random系メソッドでは加工の度合いの上限・下限を指定できたりするので、その幅を拡げてもっと極端にすると

のようになったりする。
どの程度までやるのが適切なのかは分からないけど 異常になりすぎない程度に抑えておいた。

Code

コードとしてはこんなかんじ。

def inputs(files, distort=False):
    fqueue = tf.train.string_input_producer(files)
    reader = tf.TFRecordReader()
    key, value = reader.read(fqueue)
    features = tf.parse_single_example(value, features={
        'label': tf.FixedLenFeature([], tf.int64),
        'image_raw': tf.FixedLenFeature([], tf.string),
    })
    image = tf.image.decode_jpeg(features['image_raw'], channels=3)
    image = tf.cast(image, tf.float32)
    image.set_shape([IMAGE_SIZE, IMAGE_SIZE, 3])

    if distort:
        cropsize = random.randint(INPUT_SIZE, INPUT_SIZE + (IMAGE_SIZE - INPUT_SIZE) / 2)
        framesize = INPUT_SIZE + (cropsize - INPUT_SIZE) * 2
        image = tf.image.resize_image_with_crop_or_pad(image, framesize, framesize)
        image = tf.image.random_crop(image, [cropsize, cropsize])
        image = tf.image.random_flip_left_right(image)
        image = tf.image.random_brightness(image, max_delta=0.4)
        image = tf.image.random_contrast(image, lower=0.6, upper=1.4)
        image = tf.image.random_hue(image, max_delta=0.04)
        image = tf.image.random_saturation(image, lower=0.6, upper=1.4)
    else:
        image = tf.image.resize_image_with_crop_or_pad(image, INPUT_SIZE, INPUT_SIZE)

    min_fraction_of_examples_in_queue = 0.4
    min_queue_examples = int(FLAGS.num_examples_per_epoch_for_train * min_fraction_of_examples_in_queue)
    images, labels = tf.train.shuffle_batch(
        [tf.image.per_image_whitening(image), tf.cast(features['label'], tf.int32)],
        batch_size=BATCH_SIZE,
        capacity=min_queue_examples + 3 * BATCH_SIZE,
        min_after_dequeue=min_queue_examples
    )
    images = tf.image.resize_images(images, INPUT_SIZE, INPUT_SIZE)
    tf.image_summary('images', images)
    return images, labels

Inference

分類推定のモデルは、 VGGNet と呼ばれる画像分類のための畳み込みネットワークを参考に、独自に定義して作った。

x2 conv layers

VGGNetは「3x3でのconvolutionと2x2でのmax pooling」の組み合わせを複数(5回?)繰り返した後に 3層の全結合で最終的な出力を得ている。これを真似して、cifar10のときには2回だった畳み込み&プーリングを4回行なうようにして

  1. 96 * 96 * 348 * 48 * 32の畳み込み&プーリング層
  2. 48 * 48 * 3224 * 24 * 64の畳み込み&プーリング層
  3. 24 * 24 * 6412 * 12 * 128の畳み込み&プーリング層
  4. 12 * 12 * 1286 * 6 * 256の畳み込み&プーリング層
  5. 9216(= 6 * 6 * 256) * 1024の全結合層
  6. 1024 * 256の全結合隠れ層
  7. 256 * 5の全結合出力層

とした。畳み込みのときの出力channel数や中間層の数など、どれくらいに設定するのが適切なのかはよく分からないので適当に。

パラメータ数としてはweightだけで計算すると
(3 * 3 * 3 * 32) + (3 * 3 * 32 * 64) + (3 * 3 * 64 * 128) + (3 * 3 * 128 * 256) + (9216 * 1024) + (1024 * 256) + (256 * 5) = 10088544
くらい。cifar10のものでは
(5 * 5 * 3 * 64) + (5 * 5 * 64 * 64) + (4096 * 384) + (384 * 192) + (192 * 6) = 1754944
だったので5.7倍くらいには増えている。

Code

コードとしてはこんなかんじ。

def inference(images):
    def _variable_with_weight_decay(name, shape, stddev, wd):
        var = tf.get_variable(name, shape=shape, initializer=tf.truncated_normal_initializer(stddev=stddev))
        if wd:
            weight_decay = tf.mul(tf.nn.l2_loss(var), wd, name='weight_loss')
            tf.add_to_collection('losses', weight_decay)
        return var

    def _activation_summary(x):
        tensor_name = x.op.name
        tf.scalar_summary(tensor_name + '/sparsity', tf.nn.zero_fraction(x))

    with tf.variable_scope('conv1') as scope:
        kernel = tf.get_variable('weights', shape=[3, 3, 3, 32], initializer=tf.truncated_normal_initializer(stddev=0.1))
        conv = tf.nn.conv2d(images, kernel, [1, 1, 1, 1], padding='SAME')
        biases = tf.get_variable('biases', shape=[32], initializer=tf.constant_initializer(0.0))
        bias = tf.nn.bias_add(conv, biases)
        conv1 = tf.nn.relu(bias, name=scope.name)
        _activation_summary(conv1)
    pool1 = tf.nn.max_pool(conv1, ksize=[1, 3, 3, 1], strides=[1, 2, 2, 1], padding='SAME', name='pool1')

    with tf.variable_scope('conv2') as scope:
        kernel = tf.get_variable('weights', shape=[3, 3, 32, 64], initializer=tf.truncated_normal_initializer(stddev=0.1))
        conv = tf.nn.conv2d(pool1, kernel, [1, 1, 1, 1], padding='SAME')
        biases = tf.get_variable('biases', shape=[64], initializer=tf.constant_initializer(0.0))
        bias = tf.nn.bias_add(conv, biases)
        conv2 = tf.nn.relu(bias, name=scope.name)
        _activation_summary(conv2)
    pool2 = tf.nn.max_pool(conv2, ksize=[1, 3, 3, 1], strides=[1, 2, 2, 1], padding='SAME', name='pool2')

    with tf.variable_scope('conv3') as scope:
        kernel = tf.get_variable('weights', shape=[3, 3, 64, 128], initializer=tf.truncated_normal_initializer(stddev=0.1))
        conv = tf.nn.conv2d(pool2, kernel, [1, 1, 1, 1], padding='SAME')
        biases = tf.get_variable('biases', shape=[128], initializer=tf.constant_initializer(0.0))
        bias = tf.nn.bias_add(conv, biases)
        conv3 = tf.nn.relu(bias, name=scope.name)
        _activation_summary(conv3)
    pool3 = tf.nn.max_pool(conv3, ksize=[1, 3, 3, 1], strides=[1, 2, 2, 1], padding='SAME', name='pool3')

    with tf.variable_scope('conv4') as scope:
        kernel = tf.get_variable('weights', shape=[3, 3, 128, 256], initializer=tf.truncated_normal_initializer(stddev=0.1))
        conv = tf.nn.conv2d(pool3, kernel, [1, 1, 1, 1], padding='SAME')
        biases = tf.get_variable('biases', shape=[256], initializer=tf.constant_initializer(0.0))
        bias = tf.nn.bias_add(conv, biases)
        conv4 = tf.nn.relu(bias, name=scope.name)
        _activation_summary(conv4)
    pool4 = tf.nn.max_pool(conv4, ksize=[1, 3, 3, 1], strides=[1, 2, 2, 1], padding='SAME', name='pool4')

    with tf.variable_scope('fc5') as scope:
        dim = 1
        for d in pool4.get_shape()[1:].as_list():
            dim *= d
        reshape = tf.reshape(pool4, [BATCH_SIZE, dim])
        weights = _variable_with_weight_decay('weights', shape=[dim, 1024], stddev=0.02, wd=0.005)
        biases = tf.get_variable('biases', shape=[1024], initializer=tf.constant_initializer(0.0))
        fc5 = tf.nn.relu(tf.nn.bias_add(tf.matmul(reshape, weights), biases), name=scope.name)
        _activation_summary(fc5)

    with tf.variable_scope('fc6') as scope:
        weights = _variable_with_weight_decay('weights', shape=[1024, 256], stddev=0.02, wd=0.005)
        biases = tf.get_variable('biases', shape=[256], initializer=tf.constant_initializer(0.0))
        fc6 = tf.nn.relu(tf.nn.bias_add(tf.matmul(fc5, weights), biases), name=scope.name)
        _activation_summary(fc6)

    with tf.variable_scope('fc7') as scope:
        weights = tf.get_variable('weights', shape=[256, NUM_CLASSES], initializer=tf.truncated_normal_initializer(stddev=0.02))
        biases = tf.get_variable('biases', shape=[NUM_CLASSES], initializer=tf.constant_initializer(0.0))
        fc7 = tf.nn.bias_add(tf.matmul(fc6, weights), biases, name=scope.name)
        _activation_summary(fc7)

    return fc7

Loss

損失関数はcifar10のものと同様で、入力画像に対する出力と正解ラベルとのクロスエントロピー、そこに汎化性能向上のための正則化手法として(?)全結合層の最初と中間層のパラメータに適当な割合でweight decayを入れて それらを合計したものを最小化対象のtotal lossとしている。

Code
def loss(logits, labels):
    sparse_labels = tf.reshape(labels, [BATCH_SIZE, 1])
    indices = tf.reshape(tf.range(BATCH_SIZE), [BATCH_SIZE, 1])
    concated = tf.concat(1, [indices, sparse_labels])
    dense_labels = tf.sparse_to_dense(concated, [BATCH_SIZE, NUM_CLASSES], 1.0, 0.0)

    cross_entropy = tf.nn.softmax_cross_entropy_with_logits(logits, dense_labels)
    mean = tf.reduce_mean(cross_entropy, name='cross_entropy')
    tf.add_to_collection('losses', mean)
    return tf.add_n(tf.get_collection('losses'), name='total_loss')

Train

AdamOptimizer

学習にはGradientDescentOptimizerではなくAdamOptimizerというのを使ってみた。
ちゃんと比較はしていないのだけど、GradientDescentOptimizerよりも格段に早く(少ないstepで)lossが減少するように学習が進んでいるのは観測した。Learning Rateを減衰させて調整する、といったこともここでは行っていない。
ただ1500stepくらいまで行くとそれ以降1stepあたりの計算時間が4〜5倍かかるようになったのだけど これはAdamOptimizerのせいなのかな…?よく分かってない

Code
def train(total_loss, global_step):
    loss_averages = tf.train.ExponentialMovingAverage(0.9, name='avg')
    losses = tf.get_collection('losses')
    loss_averages_op = loss_averages.apply(losses + [total_loss])

    for l in losses + [total_loss]:
        tf.scalar_summary(l.op.name + ' (raw)', l)

    # Apply gradients, and add histograms
    with tf.control_dependencies([loss_averages_op]):
        opt = tf.train.AdamOptimizer()
        grads = opt.compute_gradients(total_loss)
    apply_gradient_op = opt.apply_gradients(grads, global_step=global_step)
    for var in tf.trainable_variables():
        tf.histogram_summary(var.op.name, var)
    for grad, var in grads:
        if grad:
            tf.histogram_summary(var.op.name + '/gradients', grad)

    # Track the moving averages of all trainable variables
    variable_averages = tf.train.ExponentialMovingAverage(MOVING_AVERAGE_DECAY, global_step)
    variables_averages_op = variable_averages.apply(tf.trainable_variables())

    with tf.control_dependencies([apply_gradient_op, variables_averages_op]):
        train_op = tf.no_op(name='train')
    return train_op

Graph

最終的に出来た全体像のグラフはこんなかんじ。

潰れてしまって全然読めないと思うけど、下の方からTFRecordsのファイルを読み込んで加工した画像とラベルのbatchを作って 真ん中の分岐で左側では畳み込みニューラルネットワークに通して出力を計算して 右側を通るラベルと結果を突き合わせてクロスエントロピーを出して損失を計算したりしている、っていう感じになってるはず。

Results


学習結果はこのように、数百〜千step程度でじゅうぶんに0に近い値までlossが減少した。
最初 いくら学習を繰り返しても全然cross entropyが減少しなくて、なんでだ!?と思ったら序盤の畳み込み層の初期値(の幅、具体的にはtf.truncated_normal_initializerに渡すstddev)が小さすぎて途中の出力がすべて同一の値になってしまっていたのが原因だったようで そこを適切に設定し直すことで上記のように上手く学習が進むようになった。

各段階で保存した変数を使って「学習に使っていない」データを使って評価した結果が以下。

1600stepくらいのところでようやく90%ラインに到達して、あとはそこから上がらず まぁ誤差の範囲内かな くらいに。


実際に試すWebアプリも少しアップデートして結果の表示方法をちょっと変えた。冒頭に載せたやつは上手くいった例。
https://momoclo-face-recognizer.herokuapp.com/

↓最新アルバムのジャケ写。百田さんが「有安」になってしまっている

↓前回にも使ったやつ。佐々木さんが「有安」に。

↓だいぶ昔のアー写。百田さんが「高城」に。

↓比較的最近のアー写。玉井さんが「有安」に。

…という具合に集合写真だとやっぱり5人中1人くらいは間違う感覚。

考察

問題設定を変えてしまったので比較しづらくなってしまったのだけど… 結局6クラス分類が75%に上がるのと5クラス分類が90%に上がるのはあまり変わらない気はする。。
けどまぁ少なくとも悪化はしていないはず。あとはやっぱり学習データ数かな、と。


なんとかもっとラクに大量の学習データを用意する方法は無いだろうか…

TensorFlowによるディープラーニングで、アイドルの顔を識別する

以前は MNISTの例を使って画像識別を試してみた けど、次はカラー画像についての識別を試してみる。

「アイドルなんてみんな同じ顔に見える」って 最近も言われてるのかどうか知らないけど、自分もつい5年前くらいまではそう思っていたわけで。その識別を機械学習でやってみよう という試み。
最近はほとんどライブに行かなくなってしまったけど大好きなももいろクローバーZちゃんを題材にしてみることに。
5人のメンバーの顔は機械学習によってどれくらい分類できるようになるのか??

CIFAR-10

CIFAR-10 という、32×32サイズのカラー画像を10種類のクラスに分類する識別課題があり、そのデータセットが公開されている。これを実際にTensorFlowで学習するための畳み込みニューラルネットワークのモデルや関数などがtensorflow.models.image.cifar10パッケージに同梱されているので、これを利用して学習させてみることにした。

画像収集

まず課題となるのが訓練用のデータセットの用意。教師あり学習を行うため、「顔の画像」と「それがどの人物の顔であるか(どう分類されるのが正解か)、を示すラベル」のセットが必要で、CIFAR-10では各6000枚の画像とラベルのセットが用意され提供されている。ももクロの5人の顔識別においては現実的にどれくらいの量が必要か分からないけど、最低でも各100くらいは用意したいところ。
ももクロちゃんはずっとアメブロを続けてきているので、そこに自撮り画像などはある程度蓄積されている。それを利用することにして、

というのを出来るwebアプリをまずrailsで作った。画像加工はRMagickでだいたいできるので便利。

face-collector
https://github.com/sugyan/face-collector

そしてこれを使って自動抽出された顔画像たちを目視で確認しながらラベル付け。これだけは残念ながら人力で行うしかない。集合知を上手く利用できればこのへんもある程度は自動化できるのかもしれないけど…。
とりあえず5人の各メンバーについて200点ずつくらいはすぐにデータを作ることができ(有安さんは安定の自撮りが多くて集めやすい、玉井さんは自撮り少なくて苦労した…あと高城さんは変顔が多くて判断に迷うことが多かったw)、メンバー以外のスタッフや共演者さんの顔画像なども幾つかあったのでそれらも「ももクロメンバーではない」という6つ目のラベルとして混ぜて、訓練用と評価用でデータセットを作成した。

管理上はユニークなデータとしていても同じ顔の写った同じ写真を複数のメンバーがブログに載せていたりもするので、実際には完全にユニークではなく数組ほぼ同じものが混ざっていたりもするかもしれない。

学習

これらを使って、 Tutorial とほぼ同様に学習させていく。CIFAR-10と同形式でファイルを用意しておけば、cifar10.input()の関数1つでファイルからのデータ読み込み、加工、キューイングまですべて済んだ入力データを得られるので便利。
実際に書いたコードはこれだけで、

cifar10.IMAGE_SIZE = 32
cifar10.NUM_CLASSES = 6
cifar10.NUM_EXAMPLES_PER_EPOCH_FOR_TRAIN = 1000
cifar10.INITIAL_LEARNING_RATE = 0.08

FLAGS = tf.app.flags.FLAGS

tf.app.flags.DEFINE_integer('max_steps', 10000,
                            """Number of batches to run.""")
tf.app.flags.DEFINE_string('train_dir', 'train',
                           """Directory where to write event logs """
                           """and checkpoint.""")

def train():
    # ops
    global_step = tf.Variable(0, trainable=False)
    images, labels = cifar10.distorted_inputs()
    logits = cifar10.inference(tf.image.resize_images(images, cifar10.IMAGE_SIZE, cifar10.IMAGE_SIZE))
    loss = cifar10.loss(logits, labels)
    train_op = cifar10.train(loss, global_step)
    summary_op = tf.merge_all_summaries()

    with tf.Session() as sess:
        saver = tf.train.Saver(tf.all_variables(), max_to_keep=21)
        summary_writer = tf.train.SummaryWriter(FLAGS.train_dir)

        # restore or initialize variables
        ckpt = tf.train.get_checkpoint_state(FLAGS.train_dir)
        if ckpt and ckpt.model_checkpoint_path:
            saver.restore(sess, ckpt.model_checkpoint_path)
        else:
            sess.run(tf.initialize_all_variables())

        # Start the queue runners.
        tf.train.start_queue_runners(sess=sess)

        start = sess.run(global_step)
        for step in xrange(start, FLAGS.max_steps):
            start_time = time.time()
            _, loss_value = sess.run([train_op, loss])
            duration = time.time() - start_time

            assert not np.isnan(loss_value), 'Model diverged with loss = NaN'

            print '%d: %f (%.3f sec/batch)' % (step, loss_value, duration)

            if step % 100 == 0:
                summary_str = sess.run(summary_op)
                summary_writer.add_summary(summary_str, step)
            if step % 500 == 0 or (step + 1) == FLAGS.max_steps:
                checkpoint_path = os.path.join(FLAGS.train_dir, 'model.ckpt')
                saver.save(sess, checkpoint_path, global_step=step)

cifar10.inferenceにバッチ入力を渡すことで畳み込みニューラルネットワークモデルとその出力が作られ、cifar10.lossにその出力と正解ラベルを渡すことで誤差を計算、それをcifar10.trainに渡すことで学習が行われる。分かりやすい。
いくつかの定数値は上書きして変更することができ、ここでは

  • distorted_inputsでは32x32サイズの画像をさらにランダムに24x24サイズで切り出して入力としていたが、今回は顔画像領域に既に切り出されているし不要と判断し32x32そのまま入力とする
  • 分類数はメンバー5人+それ以外、で6種類に
  • 何度か学習を試してみたが途中でlossが発散してしまうことがあったのでINITIAL_LEARNING_RATE0.1から0.08に少し下げた

など。

入力画像の種類が少ないこともあってか、数千stepでもう十分なくらいに学習が進む。VPS上でDocker立ち上げて回してみていたけど数時間で10000stepの学習が終了した。

で、学習に使っていない評価用テストデータを使って正答率を計測してみたところ

のようになり、75%くらいまで到達した後 それ以上はもう上がらないようだった。

学習結果を使ったWebアプリ

せっかくここまで作ったのなら、前回のように実際に誰でも試せるようにWebアプリにして公開してみよう、と。

TensorFlowでのMNIST学習結果を、実際に手書きして試す - すぎゃーんメモ のときと同様に、学習済みのデータを使って画像を受け取り判定結果を返すJSON APIを用意し、それを使って判定結果を描画する。
任意の画像をDrag and Dropで受け取るので、まずはそこから判定するための顔領域だけを切り出す必要があり、自作の顔検出器では遅すぎるので ここではLIMITED PREVIEW版の Cloud Vision API を使ってみている。
色んな画像を上げて試してみてください。

https://momoclo-face-recognizer.herokuapp.com/

考察

冒頭の画像では玉井さんが高城さんと誤判定されている以外は当たっている。テストデータでの評価は75%程度だしまぁこんなものかと。
実際色んな画像で試してみるともっと残念な結果になるものの方が多いかんじ。実用的なレベルにはまだまだ達しない。

  • 訓練用の画像1000枚ではまだまだ足りていないのかも?画質の良くない自撮りや変顔なんかも多いし、もうちょっとバリエーションがあった方が良さそう
  • 入力32x32では小さすぎる、というのもある?さすがにそのくらい縮小されると自分で目視しても分かりづらかったりするし誤判定されても仕方ない気はする
    • あと顔領域の切り出し方によっても結構判定結果が変わるようだったので、やはりランダムcropは使った方が良かったのかも
  • ニューラルネットワーク自体が単純すぎる?今回のtensorflow.models.image.cifar10パッケージのものだと2階層だけの畳み込み-プーリングでそこから全結合のものとなっている。もうちょっと深いものだとまた精度が変わったりするだろうか?

色々ためしてみたいところではある。他のアイドルさんの画像も集めて分類数も増やしていきたい。

Repository

AOJはじめました

「AIZU ONLINE JUDGE」通称(AOJ)という、"提出されたプログラムの正しさ・効率の自動判定を行うオンラインジャッジシステム"がある。

いわゆる競技プログラミングプログラミングコンテストの過去問題などが多数掲載されており、各問題に対してソースコードを提出すると その問題の入力に対する正しい出力が得られているか否かを自動で判定してくれる。


…ていうのを何となくは知っていたのだけど実際に触ったことはなくて。先日 チームラボVSドワンゴ!競技プログラミング勉強会@ドワンゴオフィス - connpass というイベントに参加したときにオンラインジャッジに関する解説などがあり 実際に数問やってみる、ということでユーザ登録して挑戦してみたので、その後も継続して挑戦してみることにした。


べつに競技プログラミングで強くなりたい、とかではなく 主に「思考力・実装力を鍛える」という目的で、特に早解きやコードゴルフ的なことは意識しないことにした。
方針としては

  • まずはC++で頑張って自力で解く。
  • グローバル変数はできるだけ使わず、関数の入出力で回答を生成できるように。
  • どうにも上手くいかないときは他人のコードを見たりググって調べたりしても良い。
  • でも最終的にはちゃんと自分でコードを書く。
  • 解けても、もっと良いやり方がありそうであればリファクタリングする。
  • ついでにRubyでも解いてみる。
  • コードコメントは書かないが、考え方をメモしてgithubに上げる

という感じでやってみている。ようやく10問くらいできたところ。

https://github.com/sugyan/aoj


問題は山ほどあるけど、1番目から順番に…というのもアレなので ランダムで問題を選択するスクリプト を適当に作って、それで出てきたものに挑戦する、ようにしている。
とりあえず問題だけ読んで、移動中の電車の中で実装を考えて ちょっと気分転換するタイミングで実際にコードを書いてみたり。それくらいの気軽さで。


どの問題も数十行くらいで解けるようなものなのだけど、実際にやってみるととにかく予想外に詰まることが多くて、自分の力の無さを痛感する。他の人の回答を見て目から鱗、なことも多い。あとC++で書いたものをRubyに移植してみると すごく短く簡潔に書けたり すごく処理時間が増大したりするのを実感できて面白い。

週に2〜3問くらいのペースかな、とりあえず出来るだけ続けていきたいと思ってるけど もうちょい継続するモチベーションが欲しい気もするw 身近で同じ問題に挑戦したりレビューしあえるような仲間がいると良いのかなぁ

全自動水玉コラ生成マシーン Web版

全自動水玉コラ生成マシーン - onk.ninja が面白いなーと思って、

ていう流れになって。
ちょうどつい先日 OpenCVを使ったWebアプリについての知見を得ていたところだったので、やってみようと。

微妙に違うところもあるけど だいたいそのまま移植してみたつもり。

しかし実際試してみるとなかなか上手くいく例はない… もうちょっと色んな改良が必要そうです。Webでやるには処理速度的に今くらいのが限界っぽい感じもするけど。。

El Capitanにしたらzsh上でのPATHが上書きされた

先日ようやく El Capitanに上げたのだけど、そうしたらtmux上でzshログインした際にPATHがおかしくなる、という問題が起きて。
どうやらEl Capitanでは/etc/zprofileというのが作られていて、こいつが

# system-wide environment settings for zsh(1)
if [ -x /usr/libexec/path_helper ]; then
        eval `/usr/libexec/path_helper -s`
fi

となっていて、PATHを書き換えてしまうようで。
tmuxで新しくwindowを開いたりする際にこいつが呼ばれてしまうのが原因だったらしい。
最初はそいつをrenameして対応したけど、

ということだったのでsetopt no_global_rcsで解決しました。ありがとうございます!

画像の検出まわりの資料など

メモ。

Heroku + OpenCVで簡易顔検出API

Docker Image of Python with OpenCV 3.0 for Heroku - すぎゃーんメモ の続き的なかんじで。

OpenCVでよく使われるObject Detection機能で、画像から顔を検出するAPIを作ってみた。


Heroku app
https://face-detector.herokuapp.com/
Github repository
https://github.com/sugyan/face-detector

顔検出 基礎

一番簡単なオブジェクト検出の手法が、Haar-like特徴に基づくカスケード型分類器(Haar Feature-based Cascade Classifiers)というのを用いるやつ。

OpenCVには顔や目などに関して学習済みのデータが同梱されているので、これを使うことで簡単に画像から顔を検出できる。
ここではhaarcascade_frontalface_alt2.xmlというのを使う。他との違いはあんまりよく分かってない。

import cv2
from os import path

# cascadeファイルをロードする
cascades_dir = path.normpath(path.join(cv2.__file__, '..', '..', '..', '..', 'share', 'OpenCV', 'haarcascades'))
cascade_f = cv2.CascadeClassifier(path.join(cascades_dir, 'haarcascade_frontalface_alt2.xml'))

# 画像を読み込む
img = cv2.imread('sugyan.jpg')
# グレイスケールに変換
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 検出して四角で囲む
faces = cascade_f.detectMultiScale(gray)
for (x, y, w, h) in faces:
        cv2.rectangle(gray, (x, y), (x + w, y + h), (0, 0, 0), 2)

こんな感じで、簡単に顔の領域を検出してマーキングすることができる。

before:

after:

傾き(回転)に対応する

ところで最近は色んなアイドルさんが自撮り画像を上げてくれてたりする。
最近とても気になっているのが 虹のコンキスタドール の、「ののた」こと奥村野乃花ちゃん。

ののた可愛い。


それはともかく、、こういった自撮り画像、結構な角度で傾いているものだったりする。
OpenCVでのHaar Feature-based Cascade Classifiersによる顔検出はちょっとでも傾きがあると精度が一気に変わってしまうようで、先述の例のような真正面の顔が期待できない場合はそのままではほぼ使えない。ののたの自撮りは傾いているものが多くて厳しい。

ということで 以下の記事を参考に、元画像を徐々に回転したものを生成して、それぞれを対象に繰り返し検出を試みる。

斜辺サイズの枠を用意して、中央に元画像を配置してそれを中心に回転行列をかけて画像を変換。

import math
import numpy

...

rows, cols, colors = img.shape
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# 元画像の斜辺サイズの枠を作る(0で初期化)
hypot = int(math.hypot(rows, cols))
frame = numpy.zeros((hypot, hypot), numpy.uint8)
frame[(hypot - rows) * 0.5:(hypot + rows) * 0.5, (hypot - cols) * 0.5:(hypot + cols) * 0.5] = gray
# 各loopで違う角度の回転行列をかけた結果のものに対して検出を試みる
for deg in range(-50, 51, 5):
    M = cv2.getRotationMatrix2D((hypot * 0.5, hypot * 0.5), -deg, 1.0)
    rotated = cv2.warpAffine(frame, M, (hypot, hypot))
    faces = cascade_f.detectMultiScale(rotated)
    for (x, y, w, h) in faces:
        cv2.rectangle(rotated, (x, y), (x + w, y + h), (0, 0, 0), 2)

とりあえず5°ずつのstepでやってみた結果が以下。

40°まで回転させたところでようやく顔を検出している。35°のところでは全然関係ない箇所が誤検出されている。
ちなみにこのへんの精度はある程度detectMultiScaleの引数を変更することで調整することもできる。

例えば第2引数のscaleFactorをデフォルトの1.1から1.05にすると

のようになり、35°〜50°でも顔が検出できるようになるのだけど、10°, 15°, 25°など誤検出が増える。
そこで第3引数のminNeighborsをデフォルトの3から4に増やすと

となり、誤検出が消える。

ただこのへんはケースにもよるので「どの値が一番良い」みたいなものは一概には言えなそう。あと処理速度にも影響があってscaleFactorを小さくすると処理量が増大する。


顔が検出できたら さらにその検出された領域で両目を検出してみる。これはhaarcascade_eye.xmlという別のファイルを使うだけで、同じ要領でできる。デフォルトの引数のままで35°付近の結果を試してみると

cascade_e = cv2.CascadeClassifier(path.join(cascades_dir, 'haarcascade_eye.xml'))

...

for deg in range(30, 50):
    M = cv2.getRotationMatrix2D((hypot * 0.5, hypot * 0.5), -deg, 1.0)
    rotated = cv2.warpAffine(frame, M, (hypot, hypot))
    faces = cascade_f.detectMultiScale(rotated)
    if len(faces) > 0:
        (x, y, w, h) = faces[0]
        # 検出された顔の領域だけの画像を取得し、目の検出を試みる
        roi = rotated[y:y + h, x:x + w]
        eyes = cascade_e.detectMultiScale(roi)
        for (ex, ey, ew, eh) in eyes:
            cv2.rectangle(roi, (ex, ey), (ex + ew, ey + eh), (0, 0, 0), 2)


のようになる。33°, 35°はそもそも顔領域が変な位置で検出されている場合で、当然ながら目は見つからない。その他のものでは 正しく目の部分を検出できている場合もあれば全然ちがう部分を検出しまくってグロいことになっている場合もある。ののたゴメン…。

1°の変化もだいぶ結果が違う。。とは言え多くの場合は目を検出できているので、例えば顔の下半分領域で検出されたものは目なはずないので無視する、とかフィルタリングはできると思う。


とりあえず、顔領域から目を2つ検出した場合のみを「正しく顔を検出できた」と見なして、そのときの顔の中心、目の中心の座標を取り 回転行列の逆変換をかけてやれば、元画像における顔や目の中心座標が取得できる。複数の回転角度で取得されて重複している場合は 検出した両目がより水平に近い(つまりatan2(y, x)が最も0に近い)ものを選択する、などするとより良い結果を得られるかな、ということでそうしてみた。

のが今のコード。
https://github.com/sugyan/face-detector/blob/cc22fd576416f79f291674e756cc00e4841289f9/lib/detector.py

数十行くらいでこれくらいのロジックが書けてしまうしpython + cv2便利〜。

JSON APIでHeroku deploy

で、これはただcv2を使っただけのpythonのコードなので、 OpenCV用のdocker image などがあればHerokuに上げてJSON APIとして使ったりすることができる。

https://face-detector.herokuapp.com/ ではそのAPIを使って、入力されたURLの画像における顔の座標を各軸の%座標で取得し、canvasでその結果を描画しているだけ。

精度と速度のトレードオフ

まぁ動かしてみると分かるけどレスポンスはかなり遅い。普通に十数秒かかったりする。
やっぱりfor loopで各角度に回転した画像を生成してそこから顔を検出して…というのを複数回繰り返すのはかなりの負荷で、±50°くらいの範囲で5°ずつで20回くらいやるだけで相当つらい。
かといってstepを10°ずつにすると2倍ちかく高速になるものの35°くらいの傾きの顔をピンポイントで見逃したりもする。試してみたかんじでは6〜8°ずつくらいならそれほど精度落ちないかなぁ、と。
あと元画像が大きいときは処理が重くなりすぎないように最大数百ピクセル以内になるようリサイズしているけど、それによる精度の影響とかも起こるようで、これも より大きい画像を許容するようにすれば精度上がるかもしれないけどそのぶん処理が重くなる。
あとは前述のscaleFactor, minNeighborsをどうするか、とか 顔の検出時と目の検出時で別の値にすべきか、とか 色々な調整箇所はあると思う。

結局どういった画像を対象にどれくらいの精度で出したいかはケースによると思うので正解は無いと思うけど 汎用的に使えるように、っていうのは難しいですね。


商用の顔検出サービスとかってどういう技術を使っているんだろう… このへんもDeep Learningで劇的に改善できるものなんだろうか…?

結論

ののた可愛い。