Python
画像処理
DeepLearning
顔認識

Facenetを使った類似顔画像検索のための特徴量抽出

はじめに

「この人の名前を知りたい」という場合、トレーニング画像としてとして、一人あたり複数枚の画像があれば、一般物体識別としてVGGやAlexNetやResNetなどの識別モデルが適用できそうです。例えば、すぎゃーん氏のアイドル顔画像識別が有名です。

ですが、トレーニング画像を集めるのは大変です。
想定するタスクによっては、1人あたり1枚くらいしか学習・識別に利用できる画像がないという場合もあります。
また、「2つの顔が同じかどうか調べたい」「ある顔と似ている顔を検索したい」「似ている顔を分類したい」といったときには、識別モデルの適用ではなく別の手法を取る必要があります。

手法としては、以下が考えられます。

  1. 識別モデルの中間層の出力を利用する方法
  2. GoogleのFaceNetをベースとしたOpenfacedavidsandberg/FaceNetなどの学習済みモデルを利用する
  3. MicrosoftのFaceAPIを利用する

1.については、一般物体識別の場合は有効だと考えられますが、個々人の顔の微妙な違いを捉えるにはあまり適切でないと思います。(Kerasで学ぶ転移学習が詳しいです)
3.は最も手軽で、かつ強力ですが、例えば、様々なクラスタリング手法を適用してみたりすることはできず、与えられたAPI以外のことは難しいです。ちなみに、Microsoftは MS-Celeb-1Mというような大規模データセットの公開とコンペティションも開いていて、顔画像処理の巨人になりそうです。

今回は、2.を使ってみます。
facenetを利用して、tripletにより顔画像の特徴量(ベクトル)を抽出します。
これを使えば、距離(非類似度)を測ったり、クラスタリングやSVMなど様々な手法が使えます。
また、自分でトレーニングデータを追加できるのも利点です。

openfaceとfacenetについて

GoogleのFacenet論文の説明は 論文輪読資料「FaceNet: A Unified Embedding for Face Recognition and Clustering」が詳しいです。
Tripletで画像をベクトルに落とし込めて、類似度計算などにも簡単に応用できるので、例えば、ディープラーニングによるファッションアイテム検出と検索でも活用されています。

OpenFaceは、このFaceNet論文を元にしたオープンソースの実装です。
Openfaceでは学習済みモデルも公開されていて、画像の前処理などのツールも豊富、かつdockerでも動かせるので、非常に手軽に利用でき、すでに幾つかの利用報告がされています。

davidsandberg/FaceNetは、openfaceにインスパイアされて開発されたものです。
こちらはTensorflowで実装されていて、実験条件の確認は詳しくはしていませんが、LFWでの精度もopenfaceより高そうです。
こちらについても先程述べたMS-Celebなどを用いた学習済みモデルが公開されています。

それぞれのアプリケーションのインストール方法は、どちらも丁寧なインストール方法が記載されているので、説明は省略します。
どちらも簡単に使ったことがありますが、お手軽さはopenface、主観的な精度はfacenetが分があるかなと思います。
今回はfacenetを使います。

facenetでの特徴量取得の方法

src/compare.pyを元に結構書き換えました。
tensorflowはあまり書いたことがないので、より効率的な書き方があるかも知れません。結果をpickleで固めています。

# -*- coding: utf-8 -*-
"""
画像特徴量を取得する
"""

# MIT License
# 
# Copyright (c) 2016 David Sandberg
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import tensorflow as tf
import numpy as np
import sys
import os
import argparse
import facenet
import align.detect_face
import pickle
from scipy import misc

def main(args):
    args_filepaths = args.image_files
    image_size = args.image_size
    margin = args.margin
    gpu_memory_fraction = args.gpu_memory_fraction
    model = args.model

    batch_size = args.batch_size

    embs = []
    extracted_filepaths = []

    with tf.Graph().as_default():

        with tf.Session() as sess:

            # Load the model
            facenet.load_model(model)

            # Get input and output tensors
            images_placeholder = tf.get_default_graph().get_tensor_by_name("input:0")
            embeddings = tf.get_default_graph().get_tensor_by_name("embeddings:0")
            phase_train_placeholder = tf.get_default_graph().get_tensor_by_name("phase_train:0")

            for i in range(0, len(args_filepaths), batch_size):
                target_filepaths = args_filepaths[i:i+batch_size]
                print("target_filepaths len:{}".format(len(target_filepaths)))
                images, target_filepaths = load_and_align_data(target_filepaths, image_size, margin, gpu_memory_fraction)
                print("target_filepaths len:{}".format(len(target_filepaths)))
                # Run forward pass to calculate embeddings
                feed_dict = { images_placeholder: images, phase_train_placeholder:False }
                emb = sess.run(embeddings, feed_dict=feed_dict)
                print("emb len:{}".format(len(emb)))

                for j in range(len(target_filepaths)):
                    extracted_filepaths.append(target_filepaths[j])
                    embs.append(emb[j, :])

    save_embs(embs, extracted_filepaths)  

def save_embs(embs, paths):
    # 特徴量の取得
    reps = {}
    for i, (emb, path) in enumerate(zip(embs, paths)):
        #print('%1d: %s' % (i, paths))
        #print(emb)
        try:
            basename = os.path.basename(path)
            reps[basename] = emb
        except:
            print('error %1d: %s' % (i, path) )
    # 特徴量の保存
    with open('img_facenet.pkl', 'wb') as f:
        pickle.dump(reps, f)


def load_and_align_data(image_paths, image_size, margin, gpu_memory_fraction):
    # 処理が正常に行えた画像パス
    extracted_filepaths = []

    minsize = 20 # minimum size of face
    threshold = [ 0.6, 0.7, 0.7 ]  # three steps's threshold
    factor = 0.709 # scale factor

    print('Creating networks and loading parameters')
    with tf.Graph().as_default():
        gpu_options = tf.GPUOptions(per_process_gpu_memory_fraction=gpu_memory_fraction)
        sess = tf.Session(config=tf.ConfigProto(gpu_options=gpu_options, log_device_placement=False))
        with sess.as_default():
            pnet, rnet, onet = align.detect_face.create_mtcnn(sess, None)

    nrof_samples = len(image_paths)
    img_list = [] #[None] * nrof_samples
    for i in range(nrof_samples):
        print('%1d: %s' % (i, image_paths[i]))
        img = misc.imread(os.path.expanduser(image_paths[i]))
        img_size = np.asarray(img.shape)[0:2]
        try:
            bounding_boxes, _ = align.detect_face.detect_face(img, minsize, pnet, rnet, onet, threshold, factor)
            det = np.squeeze(bounding_boxes[0,0:4])
            bb = np.zeros(4, dtype=np.int32)
            bb[0] = np.maximum(det[0]-margin/2, 0)
            bb[1] = np.maximum(det[1]-margin/2, 0)
            bb[2] = np.minimum(det[2]+margin/2, img_size[1])
            bb[3] = np.minimum(det[3]+margin/2, img_size[0])
            cropped = img[bb[1]:bb[3],bb[0]:bb[2],:]
            aligned = misc.imresize(cropped, (image_size, image_size), interp='bilinear')
            prewhitened = facenet.prewhiten(aligned)
            #img_list[i] = prewhitened
            img_list.append(prewhitened)
            extracted_filepaths.append(image_paths[i])
        except:
            print("cannot extract_image_align")

    image = np.stack(img_list)
    return image, extracted_filepaths

def parse_arguments(argv):
    parser = argparse.ArgumentParser()

    parser.add_argument('model', type=str, 
        help='Could be either a directory containing the meta_file and ckpt_file or a model protobuf (.pb) file')
    parser.add_argument('image_files', type=str, nargs='+', help='Images to compare')
    parser.add_argument('--image_size', type=int,
        help='Image size (height, width) in pixels.', default=160)
    parser.add_argument('--margin', type=int,
        help='Margin for the crop around the bounding box (height, width) in pixels.', default=44)
    parser.add_argument('--gpu_memory_fraction', type=float,
        help='Upper bound on the amount of GPU memory that will be used by the process.', default=1.0)
    parser.add_argument('--batch_size', type=int,
        help='Batch size for extraction image emb', default=1000)
    return parser.parse_args(argv)

if __name__ == '__main__':
    main(parse_arguments(sys.argv[1:]))

実行は以下のようなコマンドです。

$ python src/extract_vector_facenet.py model/20170512-110547 data/images/*

(model/20170512-110547はDLしたモデルへのパス)

pickleで固めたデータを読み込んで利用します。

距離の計算

先程のコマンドで抽出した、facenetの2枚のテスト画像(これこれ)の128次元のベクトルを使って、その距離を算出してみます。

import pickle
from scipy import spatial
pkl_path = "img_facenet.pkl"

with open(pkl_path, 'rb') as f:
    data = pickle.load(f)

A = data['Anthony_Hopkins_0001.jpg']
B = data['Anthony_Hopkins_0002.jpg']

print(scipy.spatial.distance.euclidean(A, B))

結果は大体0.65となりました。
このベクトルを使えば、似てる人を検索したり、しきい値を用いることで、2枚の画像が同じ人かどうか、というのを決めることができます。

学習済みモデルの利用について

openface自体はApache License 2.0で配布されています。
openfaceの学習済みデータセットについては、FaceScrub や CASIA-WebFaceを使って構築されていますが、
DL時の出力メッセージで書かれる通り Apache License 2.0で配布されています。

一方、facenet自体はMITライセンスで配布されています。ただし、学習済みモデルのライセンスについては明確には記述されてなさそうです。
facenetでの学習済みモデルは、元のデータとして、CASIA-WebFaceとMS-Celeb-1Mの2種類が提供されています。

CASIA-WebFaceはその制約について、

The database is released for research and educational purposes. We hold no liability for any undesirable consequences of using the database. All rights of the CASIA WebFace database are reserved.

と記述されています。

一方、MS-Celeb-1M

The data is released for non-commercial research purpose only.

と非営利での利用に限定されています。

学習済みモデルの利用において、元データの著作権や構築に利用されたデータセットの規約が学習済みモデルの利用まで及ぶのか、というところには議論があるかと思います。
詳しい方がいれば、ぜひ教えてください。

顔画像検索システムについて

この特徴量を使ったアプリケーションについては、別記事で記述する予定です。
→ 書きました Facenetを使った類似AV女優検索