TensorFlowで顔検出器を自作する

f:id:sugyan:20170820172842j:plain

19日に行われた Kyoto.なんか #3 で発表・デモをさせていただいた内容まとめです。

はじめに: 検出器の重要性

アイドル顔識別 をずっとやっている中で、顔の識別・分類(Classification)はCNNを使って出来ているけれど まだ上手く出来ていない別のタスクがあって。

それが画像内からの顔領域の検出 (Detection, Localization)。

「画像内に写っている人物が誰であるか」を識別するためには、まずはその画像に写っている「顔」を検出する必要がある。 その検出された顔それぞれについて分類器にかけて「この顔は○○さん」「この顔は××さん」と分類していくことになるわけで。

分類器に与える入力画像を切り抜いて抽出するのにもまず顔領域を検出する必要があるし、その分類器を学習させるためのデータセットも、様々な画像から顔領域を検出して切り抜いてそれぞれに対してラベル付けすることで作っている。 なので、顔識別タスクには「顔領域の検出」が不可欠となっている。

従来の方法

今までは、データセット作成のための顔画像収集にはOpenCVを使った回転補正機能つきの検出器を自作して使っていた。

OpenCVのHaar特徴によるカスケード型分類器を使った領域検出は、正面向き顔・目などを検出するため学習済みデータが標準で同梱されており、最も手軽に使える検出器と言える。 しかし、この検出器は斜めに傾いた顔に対しては一気に精度が下がるという弱点があり、斜めに写っていることが多いアイドルの自撮りでは上手く検出できない場合が多い。 それを克服するために、元画像を少しずつ回転させたものを生成し それぞれに対して検出器にかけ それらの結果をマージする、という方法を使って斜めのものもそれなりの精度で顔検出できるものを作った。

詳しくは過去のこの記事。

memo.sugyan.com

これによってある程度の精度で顔領域を検出することができ、また 顔と同時に両目の位置も検出するようにしたので その検出された目の座標のx差分, y差分を使った逆正接 atan2 で傾き角度も求めることができる。

これで大体やりたいことは実現できそうだったので、自分の取り組んでいるアイドル顔識別においてはこの検出器を使ってデータセット用の顔画像抽出を行ってきた。

しかし この検出器でもまだ問題は残っていて。

  • とにかく処理が重く時間がかかる
    • 回転した複数の画像を作る、それぞれから検出する、ので当然
  • まだ誤検出が多い
    • 顔ではない壁や服の模様を顔として検出してしまうことが多い
  • 学習させカスタマイズ、などしづらい
    • データセットを用意して学習させることは出来るらしいが…

遅いのはデータセット用の収集にはそれほど問題ではないけれど、例えば顔識別BOTのようにインタラクティブにレスポンスを返したい場面においては致命的で、仕方ないのでBot用の検出には Cloud Vision API を使うようにしているのが現状。

また精度的にも少し問題があって、特に金髪の人物の場合に 実際の顔領域より大きく検出されることが多いようだった。顔と髪の区別がつきづらい、から…?

例:


LBPなど他の特徴を使った検出器に切り替える、またdlibなど他のライブラリを使用することで改善も出来たかもしれないけど、折角なのでここは Deep Learning を使った検出器を作って自前の学習データを食わせて学習させたい、という思いがあり 今回はそれに挑戦してみることにした。

Deep Learning による物体検出

Deep Learning を使った物体検出の手法もたくさん研究されていて近年めざましい発展を遂げているようで、代表的な手法としてこんなものが提案されてきた、と下記記事で紹介されている。

tech-blog.abeja.asia

一番最近のものとして紹介されている SSD (Single Shot MultiBox Detector) がとても高速に高精度で検出をできそうで良さそうだな と思って、いちおう論文も少し目を通してみた。 元の実装はcaffeによるもので、TensorFlow版も書いている人が数人いたけど 何となくの原理は分かったような気がするし自分でも勉強がてらTensorFlowで書いてみよう…として、難しすぎて途中で挫折した。 のが今年の1月頃の話。

Object Detection API

時は過ぎ、今年の6月中旬。 TensorFlow公式のモデル群 TensorFlow Models リポジトリで、 “Object Detection API” が公開された。 ここでは、MS COCO dataset を使って学習済みの5種類の一般物体検出モデルが公開されている。

github.com

Model name Speed COCO mAP
ssd_mobilenet_v1_coco fast 21
ssd_inception_v2_coco fast 24
rfcn_resnet101_coco medium 30
faster_rcnn_resnet101_coco medium 32
faster_rcnn_inception_resnet_v2_atrous_coco slow 37

下の方がより精度が高く、その分モデルは大きくなるし処理も重くなるようだ。 上2つはまさに SSD を使ったものであり、ベースとなる CNN を元論文では VGG16 を使っていたのに対し軽量な MobileNet を使ったもの、 Inception V2 を使ったもの と2種類それぞれで実現しているようだ。 Faster RCNN などを使ったものより検出精度は劣るものの、やはり処理速度は圧倒的に SSD の方が早そう。

さらにこのリポジトリでは、学習済みモデルを利用した転移学習で 別のデータセットを利用して学習しなおす方法についても仕組みが用意され 丁寧に説明されている。 つまり、このリポジトリのモデルで扱う形に適合した tfrecord ファイルを自分で用意できれば、簡単にそれを使った検出器を学習させ使うことができる、ということのようだ。

これを使わない手は無い、ということで試してみた。

FDDB dataset から学習用データセットを作る

自分が集めてきたアイドル顔画像から用意しても良かったけど、まずは一般に公開されているデータセットで試してみよう、と思って探してみたところ、FDDB というデータセットがヒットした。

FDDB : Main

2,845点の画像それぞれについて、写っている顔領域を楕円で表現し その中心座標、長径・短径、傾き角度 のセットがアノテーションとして計5,171件 与えられている。

これで顔領域の検出だけなら学習させられそうだけど、これだけでは顔の傾きは取得できない。 OpenCVを使ったものと同様、両目の位置さえ取れればそこから角度は算出できそうだけど、両目の位置の情報は残念ながら付属のアノテーションには含まれていない。

しかし「顔の存在する位置」「傾き」が与えられているなら、その領域を狙い打ちして OpenCV で検出することも可能なはず。

アノテーションそれぞれについて、

  • 与えられている傾きを補正するよう回転させて
  • 顔の中心座標を中心とする 長径 * 1.1 の、少し大きめのサイズの正方形で切り抜く

という操作で「縦に真っ直ぐになった顔が写っているはずの領域」を抽出した画像を一度作り、それに対して OpenCV による顔検出をかける。 そうして検出された目の領域を表す座標をそれぞれ回転前の座標に変換すれば、元画像に対する目の領域も取得できる。

やはりある程度の誤検出はあるので、適当にフィルタリングして補正し、除外。 こんな感じのコードで

import cv2
import math
import os

CASCADES_DIR = os.path.normpath(os.path.join(cv2.__file__, '..', '..', '..', '..', 'share', 'OpenCV', 'haarcascades'))
FACE_CASCADE = cv2.CascadeClassifier(os.path.join(CASCADES_DIR, 'haarcascade_frontalface_default.xml'))
EYES_CASCADE = cv2.CascadeClassifier(os.path.join(CASCADES_DIR, 'haarcascade_eye.xml'))

def detect_faces(img, lines):
    results = []
    for line in lines:
        e = line.split(' ')
        size = max(float(e[0]), float(e[1])) * 1.1
        # 小さすぎるものは除去
        if size < 60.0:
            break
        # 真っ直ぐになっているはずの領域を切り抜く
        center = (int(float(e[3])), int(float(e[4])))
        angle = float(e[2]) / math.pi * 180.0
        if angle < 0:
            angle += 180.0
        M = cv2.getRotationMatrix2D(center, angle - 90.0, 1)
        M[0, 2] -= float(e[3]) - size
        M[1, 2] -= float(e[4]) - size
        target = cv2.warpAffine(img, M, (int(size * 2), int(size * 2)))

        # 切り抜いた画像から顔と目を検出する
        faces = FACE_CASCADE.detectMultiScale(target)
        if len(faces) != 1:
            print('{} faces found...'.format(len(faces)))
            break
        face = faces[0]
        face_img = target[face[1]:face[1] + face[3], face[0]:face[0] + face[2]]
        eyes = []
        for eye in EYES_CASCADE.detectMultiScale(face_img):
            # 始点の高さが元画像の下半分にあるようならおそらくそれは誤検出
            if eye[1] > face_img.shape[0] / 2:
                break
            eyes.append(eye)
        if len(eyes) != 2:
            print('{} eyes found...'.format(len(eyes)))
            break
        # 両目のサイズがあまりにも異なるのは不自然なので検出失敗とする
        if not (2. / 3. < eyes[0][2] / eyes[1][2] < 3. / 2. and 2. / 3. < eyes[0][3] / eyes[1][3] < 3. / 2.):
            break
    ...

これでだいたいは上手く検出できたようだった。

この方法で上手く検出でき、与えられているアノテーションと同数の顔が正しく両目と共に検出されたものだけを用いてデータセットを作成。 結果として、使用できたのは2,845点のうち936点だった。 ちょっと少ないけど仕方ない。 train用とvalidation用に分ける必要があるようだったのでこれをさらに 843:93 に分割して使用した。

で、あとはこれをそれぞれ画像に対する image/objcet/bbox/*image/object/class/* といったkeyに情報を含めて tfrecord 形式に書き出す。

feature = {
    'image/height': tf.train.Feature(int64_list=tf.train.Int64List(value=[h])),
    'image/width': tf.train.Feature(int64_list=tf.train.Int64List(value=[w])),
    'image/filename': tf.train.Feature(bytes_list=tf.train.BytesList(value=[filepath.encode('utf-8')])),
    'image/source_id': tf.train.Feature(bytes_list=tf.train.BytesList(value=[filepath.encode('utf-8')])),
    'image/encoded': tf.train.Feature(bytes_list=tf.train.BytesList(value=[encoded])),
    'image/format': tf.train.Feature(bytes_list=tf.train.BytesList(value=['jpeg'.encode('utf-8')])),
    'image/object/bbox/xmin': tf.train.Feature(float_list=tf.train.FloatList(value=xmin)),
    'image/object/bbox/xmax': tf.train.Feature(float_list=tf.train.FloatList(value=xmax)),
    'image/object/bbox/ymin': tf.train.Feature(float_list=tf.train.FloatList(value=ymin)),
    'image/object/bbox/ymax': tf.train.Feature(float_list=tf.train.FloatList(value=ymax)),
    'image/object/class/text': tf.train.Feature(bytes_list=tf.train.BytesList(value=class_text)),
    'image/object/class/label': tf.train.Feature(int64_list=tf.train.Int64List(value=class_label)),
}
example = tf.train.Example(features=tf.train.Features(feature=feature))
writer.write(example.SerializeToString())

これで一応、データセットが作成できたので あとはこれを使って学習させる。 ssd_inception_v2_coco の学習済みモデルをベースにFine-Tuningする形で。 Google Cloud Machine Learning を使う方法も書いてあったのだけど ちょっと何故か上手くいかなかった(要 再挑戦)ので、今回はEC2のg2.2xlargeインタンスを使って学習を行った。 1stepあたり2秒弱くらい、丸一日で 50,000stepほど学習が進み、だいたいは学習が出来た雰囲気だった。

f:id:sugyan:20170820190012p:plain

これを使って検出してみた結果が冒頭の画像。

用意したデータセットに傾いている顔もある程度は含まれていたので、そういうものもある程度は検出できるようだった。 たった800件ちょっとの画像でのデータを用意だけでもこれだけ検出できるようになっているのだから十分かな、という感触。 ここからさらにデータセットを増やしていけばどんどん精度は上げられそうな気がする。

あとは実際の顔識別に使うような自撮りの多い画像たちを どうアノテーション付けてどう管理し、どう性能評価していくか、って話になってくると思う

Webアプリ化

ここからは完全に余談なのだけど、せっかく高速に顔検出できるモデルをTensorFlowで構築できたのだから、Webサービスとして公開できるようにしよう、と。 顔検出モデルはFlaskを使ってJSON API化できる。 あとはフロントエンドだけどうにかしてUIを作るだけ。

以前もちょいちょいReactとかwebpackとか使って似たようなものは作っていたので使い回しだけど、今回はTypeScriptで.tsxを書いてts-loaderでトランスパイル、という感じでやってみた。 型が付くと分かりやすく書きやすい、んだけど なかなか慣れなくて思った以上に苦戦した…

で、とりあえず最低限動くところまで出来たので公開したのがこちら。

これくらいならHerokuで動かせるかと思ったけど、いざdeployしてみたところ “Memory quota exceeded” のエラーが出まくってしまって、どうもメモリの使用量がヤバいらしい…。 いちおう動くことは動くけど、いつ止まってしまってもおかしくない、という感じ。 畳み込み4層の識別モデルくらいなら大丈夫だったけど これくらいの規模だと厳しいか、、、

Herokuでメモリ多めのdynoにアップグレードすると$25くらいかかるみたいだし、それだったらどこかのVPSで2GBくらいあるやつを借りた方がいいか…? 真面目に運用することになったら考えよう。。

Repository