OpenCVの新しい顔検出を試してみる

:::note info
この記事で紹介している新しい顔検出がOpenCV 4.8.0からアップデートされYuNet v2（202303）になります。  
APIに変更は無いのでソースコードは修正の必要は無く、モデルを差し替えるだけでそのまま利用できると思います。ただし、各種閾値などのパラメーターは調整が必要になる可能性があります。  
詳しくは該当のPull Requestを参照してください。  
https://github.com/opencv/opencv/pull/23020
https://github.com/opencv/opencv_extra/pull/1038
:::

この記事は[OpenCV Advent Calendar 2021](https://qiita.com/advent-calendar/2021/opencv)の3日目の記事です。

## 新しい顔検出/顔認識のAPI

OpenCVでは従来からHaar-like特徴量を用いたカスケード型分類器による顔検出やResNet10ベースのSSDモデル（OpenCV Face Detector）による顔検出が提供されてきました。

* Haar-like特徴量を用いたカスケード型分類器による顔検出
    * [haarcascade_frontalface_default.xml](https://github.com/opencv/opencv/blob/master/data/haarcascades/haarcascade_frontalface_default.xml)
    * [cv::CascadeClassifier](https://docs.opencv.org/4.5.4/d1/de5/classcv_1_1CascadeClassifier.html)
    
```python
cascade = cv2.CascadeClassifier("haarcascade_frontalface_default.xml")
boxes = cascade.detectMultiScale(gray_image)
```

* ResNet10ベースのSSDモデル（OpenCV Face Detector）による顔検出
    * [opencv_face_detector.caffemodel](https://github.com/opencv/opencv/blob/4.5.4/samples/dnn/models.yml#L7-L19)
    * [opencv_face_detector.prototxt](https://github.com/opencv/opencv/blob/4.5.4/samples/dnn/models.yml#L7-L19)
    * [cv::dnn::DetectionModel](https://docs.opencv.org/4.5.4/d3/df1/classcv_1_1dnn_1_1DetectionModel.html)
    
```python
model = cv2.dnn_DetectionModel("opencv_face_detector.caffemodel", "opencv_face_detector.prototxt")
model.setInputSize(300, 300)
model.setInputMean((104.0, 177.0, 123.0))
_, _, boxes = model.detect(image)
```

OpenCV 4.5.4から新しく顔検出/顔認識のAPIが実装されました。  

* [cv::FaceDetectorYN](https://docs.opencv.org/4.5.4/df/d20/classcv_1_1FaceDetectorYN.html) ... YuNetによる顔とランドマークの検出
* [cv::FaceRecognizerSF](https://docs.opencv.org/4.5.4/da/d09/classcv_1_1FaceRecognizerSF.html) ... SFaceによる顔の認識

<iframe width="560" height="315" src="https://www.youtube.com/embed/sZknToRbQ-A" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>  

これらのAPIをサンプルプログラムとともに紹介していきます。
この記事ではまずYuNetによる顔検出を紹介します。長くなるのでSFaceによる顔認識は[別の記事](https://qiita.com/UnaNancyOwen/items/8c65a976b0da2a558f06)で紹介することにします。  

## YuNetによる顔とランドマークの検出

YuNetは非常に高速に動作する顔検出モデルです。
顔を囲むバウンディングボックスと右目、左目、鼻、右口角、左口角の5点のランドマークを検出することができます。
サンプルプログラム全体は以下で公開しています。

https://gist.github.com/UnaNancyOwen/3f06d4a0d04f3a75cc62563aafbac332

### モデルを準備する

ここでは以下の学習済みモデルを利用します。リンクからダウンロードしてください。

* [yunet.onnx](https://github.com/ShiqiYu/libfacedetection.train/blob/master/tasks/task1/onnx/yunet.onnx)  

### モデルを読み込む

学習済みのモデルファイルを読み込み、顔検出器を生成します。
`cv2.FaceDetectorYN.create()`にはYuNetの学習済みのモデル、入力画像サイズを指定します。
入力画像サイズはあとから指定することもできます。そのため、ここでは暫定的に`(0, 0)`としています。
（第二引数は構成ファイルを指定しますが、ここではONNXフォーマットのモデルを利用するため不要です。）

```python
# モデルを読み込む
face_detector = cv2.FaceDetectorYN.create("yunet.onnx", "", (0, 0))
```

---

### NOTE
OpenCVのPython APIには`cv2.*_create()`という名前のクラスのインスタンスを生成するAPIが出てきます。たとえば、`cv2.AKAZE_create()`や`cv2.SIFT_create()`などですね。C++ APIでは`cv::Ptr<T>`を返す`cv::AKAZE::create()`や`cv::SIFT::create()`に対応します。
これらはドキュメントでは`cv2.*_create()`という形で記述されていますが、C++ APIと対比してみると少し不自然ですね。
実はこれらの`cv2.*_create()`は`cv2.*.create()`のようにも書くことができます。`cv2.*_create()`よりも`cv2.*.create()`の方がC++ APIの`cv::*::create()`に近く自然な形ですね。
このあたりは好みなのでどちらかお好きな方をご利用ください。  

```python
# どちらでもよい
face_detector = cv2.FaceDetectorYN_create("yunet.onnx", "", (0, 0))
face_detector = cv2.FaceDetectorYN.create("yunet.onnx", "", (0, 0))
```

---

### 入力サイズを指定する

`cv2.FaceDetectorYN.setInputSize()`で入力画像の大きさに合わせてサイズを設定します。ここでは単純に入力画像のサイズを指定しています。
ここで指定したサイズに合わせて出力結果のバウンディングボックスやランドマークの座標が出力されます。
高速化のため縮小した画像を入力をする場合は、出力結果に元画像のサイズと入力画像のサイズの比率を掛けて調整してください。

```python
# 入力サイズを指定する
height, width, _ = image.shape
face_detector.setInputSize((width, height))
```

### 顔を検出する

`cv2.FaceDetectorYN.detect()`に画像を入力して顔を検出します。
戻り値は成功/失敗の結果（True/False）と顔検出の結果（バウンディングボックス、ランドマーク）です。
顔検出の結果（バウンディングボックス、ランドマーク）はリストで帰ってきます。
顔が検出できなかった場合はNone Objectで帰ってくるので、ここでは後ほどfor文で扱いやすいように空のリストを代入しています。

```python
# 顔を検出する
_, faces = face_detector.detect(image)
faces = faces if faces is not None else []
```

### 顔を描画する

ここでは検出した顔のバウンディングボックスとランドマークを描画します。
顔検出の結果（バウンディングボックス、ランドマーク）は顔ごとにリストになっているので、for文で取り出していきます。
顔検出の結果は15要素のリストで、最初の4個の要素がバウンディングボックスのX座標、Y座標、幅、高さです。次の10個の要素がランドマークの右目、左目、鼻、右口角、左口角のX座標とY座標が順に並んでいます。最後の要素が信頼度です。

<table>
  <tr>
    <th>0</th>
    <th>1</th>
    <th>2</th>
    <th>3</th>
    <th>4</th>
    <th>5</th>
    <th>6</th>
    <th>7</th>
    <th>8</th>
    <th>9</th>
    <th>10</th>
    <th>11</th>
    <th>12</th>
    <th>13</th>
    <th>14</th>
  </tr>
  <tr>
    <td colspan="4">バウンディングボックス</td>
    <td colspan="10">ランドマーク</td>
    <td>信頼度</td>
  </tr>
  <tr>
    <td>X座標</td>
    <td>Y座標</td>
    <td>幅</td>
    <td>高さ</td>
    <td>右目 X座標</td>
    <td>右目 Y座標</td>
    <td>左目 X座標</td>
    <td>左目 Y座標</td>
    <td>鼻 X座標</td>
    <td>鼻 Y座標</td>
    <td>右口角 X座標</td>
    <td>右口角 Y座標</td>
    <td>左口角 X座標</td>
    <td>左口角 Y座標</td>
    <td>信頼度</td>
  </tr>
</table>
表1. 顔検出の結果

#### ・バウンディングボックス

バウンディングボックスを取り出すには、`list(map(int, face[:4]))`のように最初の4個の要素をスライスして`int`型にキャストします。
このように取り出したバウンディングボックスを`cv2.rectangle()`に指定して描画します。

```python
box = list(map(int, face[:4]))
```

#### ・ランドマーク

ランドマークを取り出すには、`list(map(int, face[4:len(face)-1]))`のように4つめの要素から最後の手前の要素まで10個の要素をスライスします。これも同様に`int`型にキャストしておきます。
このままでは扱い難いので`numpy.array_split()`でランドマークの数に分割します。これで[[x, y], [x, y], ...]のようにランドマークごとに分割できました。あとはfor文で1つずつランドマークを取り出して`cv2.circle()`で描画します。

```python
landmarks = list(map(int, face[4:len(face)-1]))
landmarks = np.array_split(landmarks, len(landmarks) / 2)
```

#### ・信頼度

信頼度を取り出すには、`face[-1]`のように最後の要素を取り出すだけです。
`float`型の値なので文字列に変換して`cv2.putText()`で描画します。

```python
confidence = face[-1]
confidence = "{:.2f}".format(confidence)
```

すべての結果を取り出して描画するとこのようになります。

```python
# 検出した顔のバウンディングボックスとランドマークを描画する
for face in faces:
    # バウンディングボックス
    box = list(map(int, face[:4]))
    color = (0, 0, 255)
    thickness = 2
    cv2.rectangle(image, box, color, thickness, cv2.LINE_AA)

    # ランドマーク（右目、左目、鼻、右口角、左口角）
    landmarks = list(map(int, face[4:len(face)-1]))
    landmarks = np.array_split(landmarks, len(landmarks) / 2)
    for landmark in landmarks:
        radius = 5
        thickness = -1
        cv2.circle(image, landmark, radius, color, thickness, cv2.LINE_AA)
        
    # 信頼度
    confidence = face[-1]
    confidence = "{:.2f}".format(confidence)
    position = (box[0], box[1] - 10)
    font = cv2.FONT_HERSHEY_SIMPLEX
    scale = 0.5
    thickness = 2
    cv2.putText(image, confidence, position, font, scale, color, thickness, cv2.LINE_AA)
```

### 実行結果

サンプルプログラムを実行すると以下のように顔が検出され表示されます。
YuNetは入力サイズによっては1.41[ms]で処理できるようです。めちゃくちゃ速いですね。[^1]

![yunet.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34834/2913766c-5d07-8cae-3a44-c11a4c437b08.jpeg)
図1. 実行結果 (FDDBより引用)

[^1]: https://github.com/ShiqiYu/libfacedetection#cnn-based-face-detection-on-intel-cpu

---

## 【おまけ】OpenCVの実装の気に入らないところ

新しい顔検出APIが追加されましたが、手放しで喜べるばかりではありません。
私が個人的に気に入らないところは以下の通りです。

1. objdetectモジュールに実装されている
    `cv::FaceDetectorYN`はdnnモジュールの機能をYuNetによる顔検出用にラップして実装されています。
    dnnモジュールではそのようなタスク別のAPIをHigh Level APIと呼んでおり、すでにオブジェクト検出やクラス分類などがdnnモジュールに実装されています。
    しかしながら、`cv::FaceDetectorYN`はobjdetectモジュールに実装されています。個人的にはこれをdnnモジュールに移行してHigh Level APIとして実装し直すべきだと思います。

2. 出力結果がユーザーフレンドリーではない
    `cv::FaceDetectorYN::detect()`の出力結果はバウンディングボックスとランドマークと信頼度が一緒になって帰ってきます。
    この記事で紹介したようにユーザーが1つ1つ結果を取り出さなくてはならずユーザーフレンドリーではありません。
    dnnモジュールに実装されているHigh Level APIでは、たとえばオブジェクト検出ならクラスID、信頼度、バウンディングボックスが別のリストになって帰ってきます。
    これは結果を取り出すのが簡単でユーザーに優しい設計です。どうせラップするならこれくらいしてほしいですね。

このあたり時間があったら実装してPull Requestを投げる予定なのでOpenCV 5.xではこの記事の内容は変わってるかもしれないです。