動機
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 程度だけど)の アイドルさんたちの画像を入手することが出来た。
以前はここから OpenCV の Haar 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で顔が検出されたものが、おそらく最も正解に近い傾き角度である、として それを採用する。
この場合はまったく回転しない 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
元々の回転角度と 計算した角度を足して、最終的な回転角とする。
この画像の場合は -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, :]
たしかに背景の壁などはそのまま続いているかのように見えて不自然な領域は減りそうだ。
ここから、両目の位置と口の端の位置・その各点間の距離を使って 切り取るべき顔領域の中心座標と大きさを算出している。 論文内の手法では
x: 両目の幅= e1 - e0y: 両目の中心 から 口の中心 の距離= (e0 + e1) / 2 - (m0 + m1) / 2c: 切り取る中心座標= (e0 + e1) / 2 - 0.1 * ys: 切り取るサイズ= 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, :]
いい感じにそれっぽく、正規化された顔画像として切り抜くことが出来そうだ。
こうして 検出器が出来たので、132,513 件のURLから実際にこの方法による検出を試みた。
そこそこ重い処理ではあるものの、手元のMacBookでも数日かけてゆっくり実行し続けた結果 72,334 件ほどの顔画像を収集することができた。
見切れ領域の多い画像に起こる問題点
こうして見ると良い画像データが揃っているように見えるが、実際には全然そんなに上手くはいかない。
多くの自撮り画像は かなり寄り気味に撮られていて、顔や頭の輪郭まで全部は写っていない場合が多い。 そうするとどうなるか。鏡面反射で補完しても見切れた顔や頭が反射されて映るだけで 結局不自然な画像になってしまう。
例えば前述の例でも、もしもっと寄り気味に撮られていて頭などが見切れていたら…