evision で画像から顔を検出する

## はじめに

Elixir の evision （OpenCV） を使って、画像から顔を検出します

https://github.com/cocoa-xu/evision

@the_haigo さんがすでにやっていたので、もう少し詳しくやります

https://qiita.com/the_haigo/items/f1ffbdcb875eb83e52f4

いつものように Livebook を使います

https://livebook.dev/

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/evision/detect_face.livemd

## 実行環境

- Elixir: 1.14.2 OTP 24
- Livebook: 0.8.1

以下のリポジトリーの Docker コンテナ上で起動しています

https://github.com/RyoWakabayashi/elixir-learning

:::note info

Docker が使える環境であれば簡単に実行できます

https://docs.docker.com/engine/install/

Docker Desktop を無償利用できない場合は Rancher Desktop を使ってください

https://rancherdesktop.io/

:::

## セットアップ

必要なモジュールをインストールします

```elixir
Mix.install([
  {:req, "~> 0.3"},
  {:evision, "~> 0.1"},
  {:kino, "~> 0.8"}
])
```

Req はモデルファイルや画像を Web からダウンロードするのに使っています

https://github.com/wojtekmach/req

## モデルのダウンロード

OpenCV の GitHub リポジトリーから Haar カスケード分類器のモデルファイルをダウンロードしてきます

```elixir
prefix = "https://github.com/opencv/opencv/raw/master/data/haarcascades"

frontal_face_model_path = "frontal_face_model.xml"

"#{prefix}/haarcascade_frontalface_default.xml"
|> Req.get!(connect_options: [timeout: 300_000], output: frontal_face_model_path)

eye_model_path = "eye_model.xml"

"#{prefix}/haarcascade_eye.xml"
|> Req.get!(connect_options: [timeout: 300_000], output: eye_model_path)

profile_face_model_path = "profile_face_model.xml"

"#{prefix}/haarcascade_profileface.xml"
|> Req.get!(connect_options: [timeout: 300_000], output: profile_face_model_path)
```

- frontal_face_model.xml: 正面の顔を検出するモデル
- eye_model.xml: 目を検出するモデル
- profile_face_model.xml: 横顔を検出するモデル

他にも色々なモデルが公開されています

https://github.com/opencv/opencv/tree/4.x/data/haarcascades

Haar カスケードの原理については以下のサイトが参考になります

https://algorithm.joho.info/machine-learning/haar-like-cascades-face-detection/

ざっくり仕組みを解説すると、以下のような流れで物体を検出しています

- 一つのモデルは複数の分類器をカスケード（連結）したものになっている
- 一つの分類器は以下の処理を行う
  - 画像から一部の領域（四角形）を切り取る
  - 切り取った領域が対象の物体なのかどうか分類する
  - 領域の位置や大きさを変化させながら画像全体の領域を分類していく
  - 「対象の物体である」と分類された領域は、次の分類器に送られる
- 最初はざっくりとした分類器で、進むにつれ厳密な分類器になる
- 全ての分類器で「対象の物体である」と分類された領域を「画像内で対象の物体が存在する領域」とする

## モデルファイルの読み込み

各モデルファイルを読み込み、カスケード分類器を用意します

カスケード分類器の読み込みは `Evision.CascadeClassifier.cascadeClassifier` で行います

```elixir
frontal_face_model = Evision.CascadeClassifier.cascadeClassifier(frontal_face_model_path)

eye_model = Evision.CascadeClassifier.cascadeClassifier(eye_model_path)

profile_face_model = Evision.CascadeClassifier.cascadeClassifier(profile_face_model_path)
```

## 画像のダウンロード

いつものレナさんをダウンロードして画像として読み込みます

```elixir
img =
  "https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png"
  |> Req.get!()
  |> then(& &1.body)
  |> Evision.imdecode(Evision.Constant.cv_IMREAD_COLOR())
```

![lenna](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/0b1aae1e-196f-a3db-bc57-6ebcc41ffc84.png)

## 正面の顔の検出

### detectMultiScale

カスケード分類器による物体検出は `Evision.CascadeClassifier.detectMultiScale`　で実行します

```elixir
face_rect_list = Evision.CascadeClassifier.detectMultiScale(frontal_face_model, img)
```

実行結果は以下のように、位置情報（バウンディングボックス）の配列になっています

```elixir
[{217, 201, 173, 173}]
```

一つのバウンディングボックスはタプルになっていて、左から以下の値を表します

- 左上X座標
- 左上Y座標
- 幅
- 高さ

検出結果が正しいかどうか、バウンディングボックスを画像上に描画します

四角形の配列を画像に描画する関数を用意します

```elixir
draw_rects = fn img, rect_list ->
  Enum.reduce(rect_list, img, fn rect, drawed_mat ->
    {left, top, width, height} = rect

    Evision.rectangle(
      drawed_mat,
      {left, top},
      {left + width, top + height},
      {255, 0, 0},
      thickness: 4
    )
  end)
end
```

関数を実行します

```elixir
draw_rects.(img, face_rect_list)
```

![lenna_frontal.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/42785530-f54f-619b-4f26-cee4ad125b37.png)

確かに正しそうです

### detectMultiScale2

物体検出には `detectMultiScale2` という関数も用意されています

```elixir
{face_rect_list, number_of_detections} =
  Evision.CascadeClassifier.detectMultiScale2(frontal_face_model, img)
```

実行結果は以下のようになります

```elixir
{[{217, 201, 173, 173}], 'D'}
```

実行結果は `{<バウンディングボックスの配列>, <各バウンディングボックスの近傍に検出された領域数>}` となっており、 `detectMultiScale` よりも返り値が増えています

`'D'` と言われても何のことが分かりませんが、これは領域数の配列が文字リストだと判断されているせいです

以下のようにして値を取り出します

```elixir
hd(number_of_detections)
```

すると値が `68` と返ってきます

つまり顔として検出できた領域には、最初のざっくり分類器の時点で68個の顔領域候補がありました、ということを表しています

が、その情報は特に何の意味もないので使いません

### detectMultiScale3

更に `detectMultiScale3` という関数も用意されています

:::note warn
optional ですが、引数に `outputRejectLevels: true` を指定しないとエラーになります
:::

こちらは３つの値が返ってきます

```elixir
{face_rect_list, reject_levels, level_weights} =
  Evision.CascadeClassifier.detectMultiScale3(frontal_face_model, img, outputRejectLevels: true)
```

実行結果は以下のようになります

```elixir
{[{217, 201, 173, 173}], [25], [8.02876805968117]}
```

`{<バウンディングボックスの配列>, <何番目の分類器で排除されたかの配列>, <バウンディングボックスの確信度の配列>}` という形です

2番目の値に意味はありませんが（最終的に排除されなかった領域なので）、最後の値は確信度なので利用価値がありそうです

## 目の検出

目の検出モデルを実行してみましょう

```elixir
eye_rect_list = Evision.CascadeClassifier.detectMultiScale(eye_model, img)
```

実行結果は以下のとおりで、両目を検出できています

```elixir
[{242, 241, 51, 51}, {308, 245, 43, 43}]
```

検出位置を可視化します

```elixir
draw_rects.(img, eye_rect_list)
```

![eyes.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/4d93a2ca-78a2-a3aa-ef16-67208a16a4e5.png)

ちゃんとそれぞれ位置は妥当ですね

## 横顔の検出

横顔のモデルを実行します

```elixir
profile_face_rect_list = Evision.CascadeClassifier.detectMultiScale(profile_face_model, img)
```

実行結果は `[]` となり、顔は検出できませんでした

このモデルは顔の左側しか学習していないため、主に顔の右側を見せているこの写真では検出できないのです

https://answers.opencv.org/question/204685/why-it-cannot-detect-some-profile-face/

向きを変えてみましょう

```elixir
profile_face_rect_list =
  img
  |> Evision.flip(1)
  |> then(&Evision.CascadeClassifier.detectMultiScale(profile_face_model, &1))
```

:::note info
`Evision.flip(img, 1)` で画像を左右反転しています

`Evision.flip(img, 0)` だと上下反転、 `Evision.flip(img, -1)` だと上下左右反転になります

https://hexdocs.pm/evision/0.1.28/Evision.html#flip/2
:::

結果は以下のようになり、顔を検出できました

```elixir
[{130, 177, 221, 221}]
```

バウンディングボックスを反転した画像に描き込みます

```elixir
img
|> Evision.flip(1)
|> draw_rects.(profile_face_rect_list)
```

![lenna_profile.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/de7b2415-561b-48e6-a89d-68320b8c6e34.png)

位置も正しいですね

## まとめ

物体検出では深層学習モデル（DNN）が主流になっているので、実務で使うことはあまりないと思いますが、色々なモデルがあって面白いですね