顔画像生成のためのデータセットを作る

動機

TensorFlowの登場をきっかけに 機械学習によるアイドル顔識別 という取り組みをしていて、3年以上かけてコツコツとアイドルの自撮りを収集してラベルをつけてデータセットを作ってきたけど、 アイドルヲタクはもう辞めてしまって 現場にも全然行かなくなり、卒業・脱退の情報を追いながらラベルを更新していく作業を続ける情熱はすっかり薄れてしまった。 もうアイドル顔識別プロジェクトは終了にしよう、と思った。

しかし折角今まで集めたデータを捨ててしまうのは勿体無い。せめて最後に何か活用できないものか。 と考えて、「画像生成」に再び取り組んでみることにした。

過去に試したことはあったけど、それほど上手くはいっていない。

この記事を書いたのが2016年。 この後の数年だけでもGANの技術はすさまじく進歩していて、今や 1024x1024 のような高解像度の 写真と見分けがつかないくらいの綺麗な顔画像を生成できるようになったらしい。是非とも試してみたいところだ。

目標

PGGAN (Progressive Growing of GANs) や StyleGAN で使われた CelebA-HQ datasetは、1024x1024 サイズの高解像度の画像を 30,000 枚用意して作られているようだ。

今回はそこまでいかなくとも、せめて 512x512 の画像を 10,000 枚くらいは集めたい。

設計の失敗

しかし自分がアイドル顔識別のために収集してラベル付けしたデータセットは、投稿された自撮り画像から顔領域を検出し 96x96 にリサイズして切り抜いたものだけしか保存していなかった。 あまりストレージに余裕が無くケチった運用をしていたため、元の高解像度の画像をクラウド上に残しておくなどをまったくしていなかった。 つまり 96x96 よりも高解像度の顔画像は手に入らない…。

集め直し

DBから候補となる画像URLを抽出

とはいえ、手元には「元画像のURL」「元画像にひもづいた、抽出された顔画像」「顔画像に対するラベル」のデータは残っている。

  • 各アイドルのTwitterから取得した画像情報 1,654,503
  • 自作の検出器で検出して抽出した顔画像 2,158,681
    • そのうち、人力の手作業でラベル付けしたもの 204,791

などが、自分が3年以上かけて続けたアノテーション作業の成果だ。

高解像度のアイドル顔画像データセットを構築するためには、resize & crop する前の元画像を取得しなおして、今度は解像度を保ったままで顔領域を抽出しなおせば良い。

目当ての「アイドルの自撮り顔画像」だけを選別するには、

  • 写真の中に1枚だけ顔が検出されている
    • → 集合写真などではない単独の自撮りで 高解像度で写っている可能性が高い
  • その顔画像が正しくアイドルとしてラベル付けされている
    • → 顔検出されていても誤検出が一定割合で起きているし、認識対象外のラベル付けをしていたりするので、それらを除外する

という条件のものを抽出すればできるはず。

SELECT
    faces.id,
    photos.id, photos.source_url, photos.photo_url, photos.posted_at,
    labels.id, labels.name
FROM faces
    INNER JOIN photos ON photos.id = faces.photo_id
    INNER JOIN labels ON labels.id = faces.label_id
WHERE
    photos.id in (
        SELECT
            photos.id
        FROM faces
            INNER JOIN photos ON photos.id = faces.photo_id
        WHERE faces.label_id IS NOT NULL
        GROUP BY photos.id
        HAVING COUNT(faces.id) = 1
    )
ORDER BY faces.updated_at DESC

こうして、「おそらくアイドルが単独で写っていたであろう元画像」196,455 枚のURLを取得できた。

しかし 画像URLが取得できていても、それを投稿したアイドルさんが卒業・解散などの後にTwitterアカウントが削除されたり非公開になっていたりすると、もうその画像は参照できなくなってしまう。

実際に取得を試みてダウンロードできたのは このうち 132,513 件だった。

ちょうど休眠アカウント削除というのが最近ニュースになった。卒業後に残っているアイドルのアカウントたちはどうなってしまうのだろうか…。今のうちに画像だけでも取得しておくことが出来て良かったのかもしれない。

Dlibによる単一顔検出

さて、高解像度(といっても 900x1200 程度だけど)の アイドルさんたちの画像を入手することが出来た。

以前はここから OpenCVHaar Feature-based Cascade Classifiers を使って顔検出し、その領域を resize & crop してデータとして使っていた。 また、アイドルの自撮りの特徴として「斜めに傾いて写っているもの」が多く検出しづらい問題があり、それを考慮して回転補正をかけて検出するという仕組みを自作していた。

今回も同様の検出をすることになるが、より高精度に また目・口の位置も検出したいというのもあり、ここでは dlib を使ってみることにした。 dlib は OpenCV同様に顔領域を検出できるほか、その顔領域内のlandmarkとして顔の輪郭や目・鼻・口などの位置まで簡単に検出することができる。

やはり斜めに傾いた顔などにはあまり強くないようなので、以前のものと同様に回転補正をかけて検出を試みるといったことは必要そうだった。 ただ今回はそもそも「対象の画像には顔が一つだけ写っている」という仮定で その単一の顔の部分だけ検出できれば良いので 少し処理は簡単になる。

例えば、宇宙一輝くぴょんぴょこアイドル 宇佐美幸乃ちゃん の場合。

まずは画像を回転することによってはみ出して消えてしまう部分がないように 元画像対角線の長さを持つ正方形領域を作って、その中央に元画像を配置する。

def detect(self, img):
    # Create a large image that does not protrude by rotation
    h, w, c = img.shape
    hypot = math.ceil(math.hypot(h, w))
    hoffset = round((hypot-h)/2)
    woffset = round((hypot-w)/2)
    padded = np.zeros((hypot, hypot, c), np.uint8)
    padded[hoffset:hoffset+h, woffset:woffset+w, :] = img

この画像をそれぞれ少しずつ回転させたものを生成し、それぞれに対して顔検出を試みる。 このとき、 fhog_object_detector.run(image, upsample_num_times, adjust_threshold)APIで検出をかけることで、その検出結果の confidence score も取得できるので それらを含めて全パターンの結果を集める。

手元で試した限りでは -48° 〜 +48° で 12°ずつの回転幅で試すのが、多くの回転角を少ない検出試行で網羅できて良さそうだった。

    self.detector = dlib.get_frontal_face_detector()
    self.predictor = dlib.shape_predictor(datafile)

    ...

    # Attempt detection by rotating at multiple angles
    results = []
    for angle in [-48, -36, -24, -12, 0, 12, 24, 36, 48]:
        rotated = self._rotate(padded, angle)
        dets, scores, indices = self.detector.run(rotated, 0, 0.0)
        if len(dets) == 1:
            results.append([dets[0], scores[0], angle, rotated])
    if len(results) == 0:
        self.logger.info('there are no detected faces')
        return


def _rotate(self, img, angle):
    h, w, _ = img.shape
    mat = cv2.getRotationMatrix2D((w/2, h/2), angle, 1.0)
    return cv2.warpAffine(img, mat, (w, h), cv2.INTER_LANCZOS4)

9つのパターンの中でもっとも高いscoreで顔が検出されたものが、おそらく最も正解に近い傾き角度である、として それを採用する。

f:id:sugyan:20200118212320p:plain

この場合はまったく回転しない 0° でも顔は検出されている(score: 0.3265)が、少し傾けた -12°のものの方が 0.5834 と高いscoreになっているので、そちらを仮の回転角として採用する。

その回転後の画像に対して landmark を検出し、左右の目の中央位置を算出する。 正しく回転して真っ直ぐになっていたら目の高さは同じになるはず、それがズレているのなら そのぶんだけまだ少し傾きがある、という考えで、その左右の目の位置座標から atan2 を使ってその微妙な角度を計算する。

    ...

    # Choose the best angle by scores, and then adjust the angle using the eyes coordinates
    results.sort(key=lambda x: x[1], reverse=True)
    det, _, angle, rotated = results[0]
    shape = self.predictor(rotated, det)
    eyel, eyer = self._eye_center(shape)
    d = eyer - eyel
    angle += math.degrees(math.atan2(d[1], d[0]))
    self.logger.info(f'angle: {angle:.5f}')


    def _eye_center(self, shape):
        eyel, eyer = np.array([0, 0]), np.array([0, 0])
        for i in range(36, 42):
            eyel[0] += shape.part(i).x
            eyel[1] += shape.part(i).y
        for i in range(42, 48):
            eyer[0] += shape.part(i).x
            eyer[1] += shape.part(i).y
        return eyel / 6, eyer / 6

f:id:sugyan:20200118212350p:plain

元々の回転角度と 計算した角度を足して、最終的な回転角とする。 この画像の場合は -12 + 0.156403 = -11.843597° の回転でほぼ真っ直ぐの状態になる、と計算された。

その回転角での画像をもう一度生成し、正しく顔とlandmarkが検出されることを確認する。

    ...

    # Detect face and shapes from adjusted angle
    adjusted = self._rotate(padded, angle)
    dets = self.detector(adjusted)
    if len(dets) != 1:
        self.logger.info('faces are not detected in the rotated image')
        return
    shape = self.predictor(adjusted, dets[0])

次に、見切れている部分の補完を行う。 データセットには顔の周辺部分まで含めて切り取って使うことになるので、その周辺部分で画像が切れていたりすると非常に不自然な領域が存在してしまうことになる。

PGGANの手法では 元の画像から鏡面反射した画像を繋げて広げて(mirror padding)、そこから切り取ることで不自然さを和らげているようだ。 同様にやってみる。

    ...

    # Create a large mirrored image to rotate and crop
    margin = math.ceil(hypot * (math.sqrt(2) - 1.0) / 2)
    mirrored = np.pad(
        img,
        ((hoffset + margin, hypot - h - hoffset + margin),
         (woffset + margin, hypot - w - woffset + margin),
         (0, 0)), mode='symmetric')
    rotated = self._rotate(mirrored, angle)[margin:margin+hypot, margin:margin+hypot, :]

f:id:sugyan:20200118212412p:plain

たしかに背景の壁などはそのまま続いているかのように見えて不自然な領域は減りそうだ。

ここから、両目の位置と口の端の位置・その各点間の距離を使って 切り取るべき顔領域の中心座標と大きさを算出している。 論文内の手法では

  • x: 両目の幅 = e1 - e0
  • y: 両目の中心 から 口の中心 の距離 = (e0 + e1) / 2 - (m0 + m1) / 2
  • c: 切り取る中心座標 = (e0 + e1) / 2 - 0.1 * y
  • s: 切り取るサイズ = max(4.0 * x, 3.6 * y)

といった計算でやっているようだ。そのまま使って適用してみる。

    # Calculate the center position and cropping size
    # https://arxiv.org/pdf/1710.10196v3.pdf
    e0, e1 = self._eye_center(shape)
    m0 = np.array([shape.part(48).x, shape.part(48).y])
    m1 = np.array([shape.part(54).x, shape.part(54).y])
    x = e1 - e0
    y = (e0 + e1) / 2 - (m0 + m1) / 2
    c = (e0 + e1) / 2 + y * 0.1
    s = max(np.linalg.norm(x) * 4.0, np.linalg.norm(y) * 3.6)
    xoffset = int(np.rint(c[0] - s/2))
    yoffset = int(np.rint(c[1] - s/2))
    if xoffset < 0 or yoffset < 0 or xoffset + s >= hypot or yoffset + s >= hypot:
        self.logger.info('cropping area has exceeded the image area')
        return
    size = int(np.rint(s))
    cropped = rotated[yoffset:yoffset+size, xoffset:xoffset+size, :]

f:id:sugyan:20200118212441p:plain

いい感じにそれっぽく、正規化された顔画像として切り抜くことが出来そうだ。

こうして 検出器が出来たので、132,513 件のURLから実際にこの方法による検出を試みた。 そこそこ重い処理ではあるものの、手元のMacBookでも数日かけてゆっくり実行し続けた結果 72,334 件ほどの顔画像を収集することができた。

f:id:sugyan:20200119232129g:plain

見切れ領域の多い画像に起こる問題点

こうして見ると良い画像データが揃っているように見えるが、実際には全然そんなに上手くはいかない。

多くの自撮り画像は かなり寄り気味に撮られていて、顔や頭の輪郭まで全部は写っていない場合が多い。 そうするとどうなるか。鏡面反射で補完しても見切れた顔や頭が反射されて映るだけで 結局不自然な画像になってしまう。

例えば前述の例でも、もしもっと寄り気味に撮られていて頭などが見切れていたら…

f:id:sugyan:20200118214632p:plain

という感じになって、顔やlandmarkは確かに検出されるかもしれないけど、頭や他の部分が変な形に繋がってしまっておかしなものになってしまう。

f:id:sugyan:20200119232246g:plain

ちょっとくらいなら問題ないかもしれないけど、流石に目が複数見えてたりするのはヤバそう…

抽出した顔画像を使った生成テスト

とりあえずは変な形になってしまったデータが存在してしまっていても仕方ない、と割り切って、検出して得ることが出来た顔画像を 10,000 件ほど使って生成モデルでの学習を試みてみた。

512 x 512 にリサイズしたものをデータセットとして使い、 StyleGAN を使って何epochか学習してみた。

f:id:sugyan:20200118215620g:plain

確かにアイドルの顔っぽい画像が生成されるが、やはり右上や左上などに鏡面反射した顔が繋がっているような奇妙な形のものが生成されやすいようだ。。

まぁ、そういう画像を含んだものを学習データとして与えてしまっているのでそうなるのは当然の結果ではある。

画像選別と管理のためのWebアプリケーション

となると今度はデータのクリーニングが必要になってくる。

目視で1枚1枚 画像を確認し、「学習データに使える、顔全体がきれいに入っている画像」と「学習データに使いたくない、不自然な画像」を選別することにした。

ローカル環境でデータを管理したくない、自分好みのUIで作業・確認したい、などの理由もあり、例によって管理用のWebアプリケーションを自作した。

Google App Engine 上で動作するよう、画像を Cloud Storage にアップロード、それにひもづく情報を Cloud Firestore に保存 (以前は Cloud Datastore だったけど 次の時代はFirestoreらしい、ということで今回初めて触ってみた)。Frontendを Create React App で作って、SPAから App Engine Go Standard Environment で作った API を叩く形のアプリケーション。自分しか閲覧・操作できないよう Firebase Authentication で認証するようにしている。

各画像に対して Status というフィールドを用意しておき、 Ready, NG, Pending, OK の4つの値をセットできるようにした。初期値はすべて ReadyReady のものをひたすら見ていって、きれいで使えそうなものを OK、ダメそうなものを NG に変更する。判断に迷ったものは Pending に。1, 2, 3 のボタン操作1つで次々スピーディーに更新していけるようにUIを工夫した。

f:id:sugyan:20200119203714g:plain

誤操作も有り得るので NG だからといって削除したりはせず、 NG として残しておく。これが後で役に立った。

こうして選別作業していって、OK になったものだけを抽出して学習データに使えば、きっときれいな画像が生成できるようになるはず…

選別作業効率化へ

作業は1枚1秒程度でサクサク進むが、実際にやってみると NG の画像が非常に多いことが分かった。

やはり多くのアイドルさんは顔までは写していても頭全体まで写るような自撮りをしていることは少なく、それによってmirror paddingされたものはだいたい頭の形がおかしい画像になってしまう。

8,500 枚ほど選別作業してみてようやく OK のものが 1,250 枚ほど。 約7枚に1枚しか現れず、8割以上は NG もしくは Pending にする感じになった。思った以上に NG の山の中から少数の OK を探すのはストレスフルだし効率が悪い。

NG の 頭の形がおかしくなってるような画像なんて誰でも区別できるし機械にでもやらせればええやん…

と思ったので、一次選別するための機械学習モデルを自作することにした。

幸い、 NG にした画像も削除せずに明確に「NG である」とラベル付けした状態で残している。 画像を入力して、「OKNG か」だけを予測する分類モデルを用意した。 画像に写っている人物が誰か、は関係なく、生成用のデータとして OK なものか NG なものか、だけを判別させるモデルとして学習させることになる。

そこまで厳密に精度を求めるものでもないし、適当に TensorFlow Hub からImageNetで学習済みの InceptionV3 を利用して 2 classes の classification のためのmodelとした。

import tensorflow as tf
import tensorflow_hub as hub

IMAGE_SIZE = (299, 299)


def cnn()
    return hub.KerasLayer("https://tfhub.dev/google/imagenet/inception_v3/feature_vector/4",
                          trainable=trainable, arguments=dict(batch_norm_momentum=0.997))


def train():
    labels = ['ok', 'ng']

    model = tf.keras.Sequential([
        cnn(),
        tf.keras.layers.Dropout(rate=0.1),
        tf.keras.layers.Dense(
            len(labels),
            activation='softmax',
            kernel_regularizer=tf.keras.regularizers.l2(1e-4)),
    ])
    model.build([None, *IMAGE_SIZE, 3])
    model.summary()
    model.compile(
        optimizer=tf.keras.optimizers.RMSprop(),
        loss=tf.keras.losses.CategoricalCrossentropy(),
        metrics=[tf.keras.metrics.CategoricalAccuracy()])

データセットとしては、既にラベル付け済みの NG のものを 4,800 件、 OK のものを 1,200 件 抽出して使用した。 それぞれを 4000:800, 1000:200trainvalidation & test に分割。

最初は全結合層を学習させるだけの転移学習だけでどうにかなるかな、と思ってちょっと試してみたけどダメそうだったので、結局ネットワーク全体を学習させるfine-tuningで。 Google ColaboratoryGPU Runtimeで数十分ほど学習。

NG データの方が多いので学習初期は NG に全振りして accuracy 0.8 とかになるけど、だんだん改善していって 40 epochほど進めると 0.947 まで上がった。

最終的な結果としては、学習に使わなかった validation & test セットに対する推論で 以下のようなConfusion Matrixになった。

f:id:sugyan:20200118235824p:plain

OK label に対しては Precision 0.9016, Recall 0.8250 といったところで、まぁそれなりに学習してくれている、という感覚ではある。

実際に、未使用の Ready の画像 1,000 枚に対してこの分類モデルに判別させてみたところ、197 枚が OK として分類された。 それらを目視で確認してみたところ、 そのうち 128 枚が OK になった。 期待したよりは低かったが、これまでは 数枚に1枚しか現れない OK を探し続ける作業だったのが 今後はこの 一次選別されたものから作業すれば 半数以上は OK を選べるので、作業の心理的ストレスは格段に軽減されて効率的になる。

また、今後さらにデータが増えたら この分類モデルも再度学習させることで、さらに高精度に一次選別を進めることが出来るようになることが期待できる。

現状と今後

こうして、現時点で 1,900 枚くらいまでは OK な画像を集めることができた。 もう少し増えたらそれらを使って生成を再度試してみたいところ。

が、全体の枚数と割合で概算すると 今あるすべての収集画像に対して選別してもまだ OK10,000 枚に届かないかもしれない…。 自撮りのキレイなオススメアイドルさんをご存知の方がいらっしゃったら是非とも教えていただきたいところ。。

Repository