機械学習初心者が2次元美少女の顔認識をした話

# 概要

少し前から機械学習を独学で学んでおり、最近は深層学習を用いたキャラクターの顔画像の分類に取り組んでいます(このあたりのことは今度書きます)。
そのためにデータセットとして女の子のキャラクターの顔画像を大量に収集していたのですが、イラストから顔画像を抽出し終わったあたりで、"このデータセットを使えば高い精度を持った顔検出器ができるじゃん！"と思ったので実際に作ってみました。

追記: GitHubにてカスケードファイルを公開しました
https://github.com/torocat88/NijifaceCascade

# 使用した環境

* CPU: Intel core-i7 3770k
* RAM: 16GB
* OS: WSL2(Ubuntu18.04) on Windows10
* python = 3.6.7 on pyenv + Anaconda3-5.2.0
* opencv = 3.3.1

# 画像の収集・前処理

## 画像の収集

データの詳しい収集方法に関しては、また今度深層学習の話をするときに書こうと思います。
ざっくり言うと、主に[google-images-download](https://github.com/Joeclinton1/google-images-download/tree/patch-1)を使って収集しました。
使い方については、以下の記事を参考にしました。

* [google-images-downloadの使い方](https://qiita.com/Zumwalt/items/157967e380a4de52c8d7)
* [たった2つのコマンドで1000枚の画像をダウンロードする](https://qiita.com/Ikko_Kojima/items/4d943c60ff5e886a0544)
* [google image downloadが動かなかったのでその対応](https://qiita.com/taedookim/items/63759e79426514c8a729)

ここで得られた画像の中には学習用データとしてふさわしくない画像(画質が極端に荒いもの、関係のないものが大きく写り込んでいるもの、そもそも必要なものが写っていないなど)が存在します。こういった画像はこの時点で除外しておきます。またgoogle-images-downloadのようにスクレイピングによって画像を集める際は、一定枚数集めるごとに1分間のインターバルをとるなどの工夫をし、サーバーに過負荷を与えないような配慮が必要です。

## 顔画像の抽出

OpenCVのdetectMultiScale機能を用いて、集めたイラストから顔画像を抽出します。
"えっ!? 検出器を作るために検出器を使うの?"と思われるかもしれませんが、先人の作ったものを利用しない手はありません。  
Python + OpenCVによる物体検出は以下の記事を参考にしました。

* [【入門者向け解説】openCV顔検出の仕組と実践(detectMultiScale)](https://qiita.com/FukuharaYohei/items/ec6dce7cc5ea21a51a82)
* [Python+OpenCVで顔検出（回転不変）](https://qiita.com/shngt/items/0bf59d813dc15578f1a6)
* [Heroku + OpenCVで簡易顔検出API](https://memo.sugyan.com/entry/20151203/1449137219)
* [[OpenCV] detectMultiScaleの使いかた](https://www.chihayafuru.jp/tech/index.php/archives/1934)
* [Face Detection using Haar Cascades](https://docs.opencv.org/3.0.0/d7/d8b/tutorial_py_face_detection.html)

カスケードファイルは付属の[haarcascade_profileface.xml](https://github.com/opencv/opencv/blob/master/data/haarcascades/haarcascade_profileface.xml)や有志の方が作られた[lbpcascade_animeface.xml](https://github.com/nagadomi/lbpcascade_animeface)を使用しました。lbpcascade_animeface.xmlのほうアニメや漫画の顔を検出するために作られただけあって用意した画像とかなり相性が良かったです。  
以下のコードでは、'source'ディレクトリ以下のjpg画像を読み込んだのち、
画像を10度ずつ回転させながら顔画像を検出して'face'ディレクトリに保存しています。また顔が検出された場所に枠を付け、'detected'に保存するようにしています。
実際に顔検出を行っているのはdetectMultiScaleの記述ですが、検出漏れを抑えるため、scaleFactorの値を1に近い値に設定しています(ただしscaleFactorの値を小さくすると計算時間と誤検出が増えます。誤検出が増えると画像を手作業で選別する時間が増えるので、scaleFactorは使える時間と相談して決めましょう)。

```python:detector.py
import math
import os

import cv2
import numpy as np


face_cascade = cv2.CascadeClassifier('haarcascade_profileface.xml')
# face_cascade = cv2.CascadeClassifier('lbpcascade_animeface.xml')
factor = 1.02

os.makedirs('./faces/', exist_ok=True)     # directory for face images
os.makedirs('./detected/', exist_ok=True)  # directory for source images with detected-face frame

for img_i in os.listdir('./source/'):
    if os.path.splitext(img_i)[-1] == '.jpg':  # skip processing when the extension is not 'jpg'.
        facenum = 0

        src = cv2.imread('./source/' + img_i)

        srcwidth, srcheight = src.shape[:2]

        imwidth  = int(math.hypot(srcwidth, srcheight)) + 2
        imheight = imwidth

        # rotate image by 10 degrees and detect
        for angle_i in range(0, 360, 10):
            tM = np.float32([[1, 0, (imheight - srcheight) / 2], [0, 1, (imwidth - srcwidth) / 2]])
            img_moved = cv2.warpAffine(src, tM, (imwidth, imheight))  # move image to center

            tM = cv2.getRotationMatrix2D((imwidth * 0.5, imheight * 0.5), angle_i, 1.0)
            img_rotated = cv2.warpAffine(img_moved, tM, (imwidth, imheight))  # rotate image

            src_gray = cv2.cvtColor(img_rotated, cv2.COLOR_BGR2GRAY)

            faces = face_cascade.detectMultiScale(src_gray, scaleFactor=factor)

            if len(faces) >= 1:
                for x, y, w, h in faces:
                    face = img_rotated[y: y + h, x: x + w]
                    cv2.imwrite('./faces/' + os.path.splitext(os.path.basename(img_i))[0] + '_' + str(angle_i).zfill(3) + '_' + str(facenum) + '.jpg', face)  # save face image
                    cv2.rectangle(img_rotated, (x, y), (x + w, y + h), (255, 0, 0), 2)  # draw frame at detected area of source image
                    facenum += 1

                tM = cv2.getRotationMatrix2D((imheight * 0.5, imwidth * 0.5), -angle_i, 1.0)
                img_crotated = cv2.warpAffine(img_rotated, tM, (imheight, imwidth))

                tM = np.float32([[1, 0, -(imheight - srcheight) / 2], [0, 1, -(imwidth - srcwidth) / 2]])
                img_cmoved = cv2.warpAffine(img_crotated, tM, (srcheight, srcwidth))

                cv2.imwrite('./detected/' + os.path.splitext(os.path.basename(img_i))[0] + '_' + str(angle_i).zfill(3) + '.jpg', img_cmoved)  # save source image with frame
```

## 顔画像の選別

上の操作によって得られた顔画像のうち、学習用データとしてふさわしくない画像を除外します。
ふさわしくない画像としては、

* 画像が小さすぎる、または画質が粗すぎるもの
* 顔が上下反転しているもの、または大きく傾いているもの
* 顔の一部分しか入っていないもの
* 顔が入っていない、もしくは画像全体にたいして顔が小さすぎるもの

などが挙げられます。

# 作業用ディレクトリの準備

OpenCVの検出器を作成する前に、下記のような構造の作業スペースを準備します。

```plaintext
work/
├── cascade/
├── neg/
│   ├── neg_0001.jpg
│   ├── negdir1/
│   │   ├── neg_0002.jpg
│   │   ├── neg_0003.jpg
│   └── ︙
├── pos/
│   ├── face_0001.jpg
│   ├── face_0002.jpg
│   ├── posdir1/
│   │   ├── face_0003.jpg
│   └── ︙
├── vec/
```

'pos'ディレクトリには正解画像(ここでは顔画像)を配置します。画像は'pos'ディレクトリの直下になくても問題ありません。
'neg'ディレクトリには不正解画像を配置します。
不正解画像は、顔が写っていなければなんでもいいです。私は上記のコードを応用して[画像の収集](#画像の収集)で得られた画像の顔部分を塗りつぶした画像、及び'pos'ディレクトリの顔画像を上下反転させたものを使用しました。
'pos', 'neg'それぞれに必要な画像の枚数ですが、実用的な精度には正解画像が7000枚程度、不正解画像が3000枚程度必要とされています(私は正解画像を約86万枚、不正解画像を約110万枚用意しました)。
また'cascade'ディレクトリは完成したカスケードファイルを、'vec'ディレクトリは以下で作成するベクトルファイルを入れるためのディレクトリになります。

# 画像ファイルのリストの作成

## 正解画像のリストの作成

'work'ディレクトリにて以下のコマンドを実行し、'pos'ディレクトリに入っている画像のリストを作成します。

```shell-sesion
$ find ./pos -name '*.jpg' > poslist.txt
```

```plaintext:poslist.txt
./pos/face_0001.jpg
./pos/face_0002.jpg
./pos/posdir1/face_0003.jpg
︙
```

poslist.txtには対象物(ここでは顔)の数と位置(縦横の範囲)を記載する必要があります。私は全ての顔画像を64x64にリサイズしていたので、以下のようになりました。

```plaintext:poslist.txt
./pos/face_0001.jpg 1 0 0 64 64
./pos/face_0002.jpg 1 0 0 64 64
./pos/posdir1/face_0003.jpg 1 0 0 64 64
︙
```

ちなみに、画像サイズが全て一致している場合(例えば64x64)は以下のようにすると楽です。

```shell-sesion
$ find ./pos -name '*.jpg' | sed 's/$/ 1 0 0 64 64/g' > poslist.txt
```

## 不正解画像のリストの作成

'work'ディレクトリにて以下のコマンドを実行し、'neg'ディレクトリに入っている画像のリストを作成します。
環境によってはフルパスで記載しないと訓練の実行時にエラーが出るようです。

```shell-sesion
$ find ./neg -name '*.jpg' > neglist.txt
```

```plaintext:neglist.txt
./pos/face_0001.jpg
./pos/negdir1/neg_0002.jpg
./pos/negdir1/neg_0003.jpg
︙
```

# ベクトルファイルの作成

opencv_createsamplesコマンドを用い、データ拡張を行います。またここで作られたベクトルファイルが訓練時の入力データになります。
opencv_createsamples、及び後述のopencv_traincascadeの使い方については、以下の記事を参考にしました。

* [OpenCVをいじってみた【後編】](http://pontago.hatenablog.com/entry/2012/07/08/194216)
* [Data Augmentationに使えるopencv_createsamplesの使い方](https://qiita.com/peroon/items/231501d8dbe643a20c41)
* [OpenCVの勉強③（分類器を作成してみる）](https://qiita.com/takanorimutoh/items/5bd88f3d17239a147581)
* [OpenCVを使って物体認識その4、createsamplesとtraincascade](https://mecobalamin.hatenablog.com/entry/2020/02/01/104247)
* [OpenCVで物体検出器を作成する⑥ ～traincascade～](https://www.pro-s.co.jp/blog/system/opencv/6471)
* [Cascade Classifier Training](https://docs.opencv.org/3.4/dc/d88/tutorial_traincascade.html)
* [OpenCVのカスケード分類器の学習の章を訳してみた](https://iwaki2009.blogspot.com/2013/12/opencv_11.html)

```shell-sesion
opencv_createsamples -info poslist.txt -vec vec/positive.vec -num 864706
```

'-info'には正解画像のリストが記載されたファイルを、'-vec'はベクトルファイルの出力先を、'-num'には正解画像の枚数と同じ数を指定します(デフォルトでは1000)。
その他のオプションはデフォルトのままとしました。
実行時間は扱う画像の枚数が多いほど時間がかかります。私の場合は10分程度でした。

# 分類器の作成

いよいよ分類器の作成です。参考記事を見ながら、以下のように打ち込み、学習を行いました。  
ここで、'-numPos'には正解画像のサンプル数を指定するのですが、用意した枚数の8~9割程度にするのが良いようです。
'-numNeg'には不正解画像のサンプル数を指定します。
また'-precalcValBufSize'と'-precalcIdxBufSize'には特徴量のメモリサイズを指定するのですが、'-precalcIdxBufSize'を大きく指定すると計算が早くなるらしいです。
学習が終わると、'cascade'ディレクトリの中に各学習ステージの学習結果、及び目的の分類器cascade.xmlファイルが生成されます。  
(当初は用意したpos画像のほとんどを使おうと、numPosを75万にして学習を始めたのですが、あまりに時間がかかりすぎたため正解画像、不正解画像をそれぞれ10万にして学習を行いました。これでも学習には5日ちょっとかかりました。)

```shell-session
opencv_traincascade -data cascadelbp -vec vec/positive.vec -bg neglist.txt -numPos 100000 -numNeg 100000 -precalcValBufSize 3000 -precalcIdxBufSize 8000 -featureType LBP -numThreads 8
```

<details><summary>学習経過</summary><div>

```shell-session
PARAMETERS:
cascadeDirName: cascadelbp
vecFileName: vec/positive.vec
bgFileName: neglist.txt
numPos: 100000
numNeg: 100000
numStages: 20
precalcValBufSize[Mb] : 3000
precalcIdxBufSize[Mb] : 8000
acceptanceRatioBreakValue : -1
stageType: BOOST
featureType: LBP
sampleWidth: 24
sampleHeight: 24
boostType: GAB
minHitRate: 0.995
maxFalseAlarmRate: 0.5
weightTrimRate: 0.95
maxDepth: 1
maxWeakCount: 100
Number of unique features given windowSize [24,24] : 8464

===== TRAINING 0-stage =====
<BEGIN
POS count : consumed   100000 : 100000
NEG count : acceptanceRatio    100000 : 1
Precalculation time: 34
+----+---------+---------+
|  N |    HR   |    FA   |
+----+---------+---------+
|   1|        1|        1|
+----+---------+---------+
|   2|        1|        1|
+----+---------+---------+
|   3|  0.99923|  0.14014|
+----+---------+---------+
END>
Training until now has taken 0 days 0 hours 6 minutes 2 seconds.

===== TRAINING 1-stage =====
<BEGIN
POS count : consumed   100000 : 100077
NEG count : acceptanceRatio    100000 : 0.142112
Precalculation time: 34
+----+---------+---------+
|  N |    HR   |    FA   |
+----+---------+---------+
|   1|        1|        1|
+----+---------+---------+
|   2|        1|        1|
+----+---------+---------+
|   3|  0.99833|  0.35606|
+----+---------+---------+
END>
Training until now has taken 0 days 0 hours 12 minutes 6 seconds.

===== TRAINING 2-stage =====
<BEGIN
POS count : consumed   100000 : 100244
NEG count : acceptanceRatio    100000 : 0.046786
Precalculation time: 34
+----+---------+---------+
|  N |    HR   |    FA   |
+----+---------+---------+
|   1|        1|        1|
+----+---------+---------+
|   2|        1|        1|
+----+---------+---------+
|   3|        1|        1|
+----+---------+---------+
|   4|  0.99875|  0.59019|
+----+---------+---------+
|   5|  0.99559|   0.4199|
+----+---------+---------+
END>
Training until now has taken 0 days 0 hours 21 minutes 40 seconds.

~省略~

===== TRAINING 15-stage =====
<BEGIN
POS count : consumed   100000 : 106886
NEG count : acceptanceRatio    100000 : 3.50609e-06
Precalculation time: 34
+----+---------+---------+
|  N |    HR   |    FA   |
+----+---------+---------+
|   1|        1|        1|
+----+---------+---------+
|   2|        1|        1|
+----+---------+---------+
~省略~
+----+---------+---------+
|  44|  0.99501|  0.49814|
+----+---------+---------+
END>
Training until now has taken 5 days 7 hours 29 minutes 15 seconds.

===== TRAINING 16-stage =====
<BEGIN
POS count : consumed   100000 : 107484
NEG count : acceptanceRatio    0 : 0
Required leaf false alarm rate achieved. Branch training terminated.
```

</div></details>

# 性能評価

## 方法

[パブリックドメインQ](https://publicdomainq.net)に掲載されている著作権フリーのイラストを使用して、検証を行いました。ちなみに、これらのイラストは学習には使用しておりません。  
検証を行うときは、上のコード`detector.py`の、カスケードファイルを読み込むところを

```python
face_cascade = cv2.CascadeClassifier('cascade/cascade.xml')
```

と指定します。

## 結果

![0005_010.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273680/cbb1856e-1982-e563-ae0e-eacffb304cf1.jpeg)


うまく認識できました。猫の顔は認識しておらず、期待通りに動作しています。

![0004_000.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273680/eebc50ad-f769-ce00-bc2e-a91e1c47a7de.jpeg)


斜め前を向いた顔もきちんと認識しています。

![0006.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273680/b2cff03a-4625-3696-e1dc-f7cc20c8072a.jpeg)


横顔は...厳しいですね。学習データには横顔の画像をあまり入れていなかったので一応想定通りの動作ではあります。

# まとめ

正面顔、斜めを向いた顔はきちんと検出できており、概ね満足のいく検出器を作成することができました。権利の問題があるのでここには出せないのですが、異なるタッチで描かれたイラストにも対応することも確認できました。また、今回は時間の関係で学習データは少なめでパラメーターの最適化を行わずに学習を行ったのですが、学習に用いる画像の枚数を増やしたり、ステージ数などの各種パラメーターを調整したりするともっと汎化性能の高い検出器が作れそうだと感じました。