evision で動画から物体検出する

## はじめに

以前、 evision と YOLOv3 で画像（静止画）から物体検出するコードを実装したました

https://qiita.com/RyoWakabayashi/items/f7960ba642dfbaa05a9c

今回は動画から物体検出したいと思います

いつものように Livebook で実装していきます

https://livebook.dev/

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

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

## 参考記事

実装にあたっては @the_haigo さんの以下の記事を参考にしました

https://qiita.com/the_haigo/items/3f8b4d1801cdbba3bfe8

また、 Kino.animate で動画を表示する方法については [あんちぽ](https://zenn.dev/kentarok) さんの以下の記事を参考にしました

https://zenn.dev/kentarok/articles/4c92dacfe1d1fe

## 実行環境

- 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/

:::

### 実行環境の注意点

コンパイル済の evision （OpenCV）では mp4 ファイルを読み込めないため、 evision をソースからコンパイルする必要があります

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

前述の私のコンテナ（Ubuntu）では、以下のモジュールを追加で `apt-get install` しました

- unzip
- ffmpeg
- libavcodec-dev
- libavformat-dev
- libavutil-dev
- libswscale-dev

unzip がないとビルド時にエラーが発生します

それ以外は FFmpeg で mp4 などのコーデックを使えるようにするために追加しています

https://ffmpeg.org/

## 事前準備

何らかの mp4 形式の動画ファイルを `/tmp/sample.mp4` に保存しているものとします

## セットアップ

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

- [Nx](https://github.com/elixir-nx/nx/tree/main/nx): 行列演算
- [Evision](https://github.com/cocoa-xu/evision): 画像処理
- [Req](https://github.com/wojtekmach/req): HTTP リクエスト
- [Kino](https://github.com/livebook-dev/kino): Livebook での入出力

環境変数 `EVISION_PREFER_PRECOMPILED` に `false` を指定することで、 evision をソースからコンパイルするよう指定しています

evision のコンパイルには非常に時間がかかるため、30分程度待ちます

## 機械学習モデルのロード

今回は Darknet の YOLOv3 を使います

公式で配布している重み、設定ファイル、COCOのラベル一覧をダウンロードします

https://pjreddie.com/darknet/yolo/

```elixir
weights_path = "yolov3.weights"
cfg_path = "yolov3.cfg"
label_path = "label.txt"

"https://pjreddie.com/media/files/yolov3.weights"
|> Req.get!(connect_options: [timeout: 300_000], output: weights_path)

"https://raw.githubusercontent.com/pjreddie/darknet/master/cfg/yolov3.cfg"
|> Req.get!(output: cfg_path)

"https://raw.githubusercontent.com/pjreddie/darknet/master/data/coco.names"
|> Req.get!(output: label_path)
```

`Req.get!` は `:output` で指定したパスにファイルをダウンロードすることができます

`yolov3.weights` だけファイルサイズが大きいため、タイムアウト時間を長く指定しています

## モデルの読込

`Evision.DNN.DetectionModel.detectionModel` を使ってモデルを読み込みます

`Evision.DNN.DetectionModel.setInputParams` で入力パラメータを指定しています

- scale: 画像ファイルで 0 から 255 になっている色を 0 から 1 に変換するため、 1.0 / 255.0 を指定
- size: モデルに入力する際に 608 × 608 にリサイズするよう指定
- swapRB: BGR を RGB に変換するため true を指定
- crop: リサイズ時に画像を切り抜かないので false を指定

```elixir
model =
  weights_path
  |> Evision.DNN.DetectionModel.detectionModel(config: cfg_path)
  |> Evision.DNN.DetectionModel.setInputParams(
    scale: 1.0 / 255.0,
    size: {608, 608},
    swapRB: true,
    crop: false
  )
```

検出結果の番号を名称に変換するため、ラベルの一覧を読み込みます

```elixir
label_list =
  label_path
  |> File.stream!()
  |> Enum.map(&String.trim/1)
```

実行結果は以下のような、長さ 80 のリストになります

```elixir
["person", "bicycle", "car", "motorbike", "aeroplane", "bus", "train", "truck", "boat",
 "traffic light", "fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat", "dog",
 "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe", "backpack", "umbrella", "handbag",
 "tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball", "kite", "baseball bat",
 "baseball glove", "skateboard", "surfboard", "tennis racket", "bottle", "wine glass", "cup",
 "fork", "knife", "spoon", "bowl", "banana", "apple", "sandwich", "orange", ...]
```

## 画像の読込

テスト用の画像をダウンロードして読み込みます

```elixir
img = 
  "https://raw.githubusercontent.com/pjreddie/darknet/master/data/dog.jpg"
  |> Req.get!()
  |> then(& &1.body)
  |> Evision.imdecode(Evision.Constant.cv_IMREAD_COLOR())
```

![dog.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/47e9ba52-fffc-879e-1fac-6573fe87279b.png)

## 静止画からの物体検出

`Evision.DNN.DetectionModel.detect` で物体検出を実行します

- confThreshold: 検出結果の確信度に対する閾値
- nmsThreshold: [Non-Maximum Suppression](https://python-ai-learn.com/2021/02/14/nmsfast/) で検出した枠が同じ物体だと判断する IoU の閾値

検出結果は `{class_ids, scores, boxes}` として取得されます

- class_ids: 物体が何であるかを示す番号のリスト
- scores: 物体の枠に対する確信度×分類に対する確信度
- boxes: 物体の位置を示す枠（左上のX座標、Y座標、幅、高さ）

後の処理で使いやすいように score は小数点以下2桁で丸め、 class_id は名称に変換します

```elixir
predictions =
  model
  |> Evision.DNN.DetectionModel.detect(img, confThreshold: 0.8, nmsThreshold: 0.7)
  |> then(fn {class_ids, scores, boxes} ->
    Enum.zip_with([class_ids, scores, boxes], fn [class_id, score, box] ->
      %{
        box: box,
        score: Float.round(score, 2),
        class: Enum.at(label_list, class_id)
      }
    end)
  end)
```

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

```elixir
[
  %{box: {99, 135, 499, 299}, class: "bicycle", score: 1.0},
  %{box: {478, 81, 211, 86}, class: "truck", score: 0.94},
  %{box: {134, 219, 184, 325}, class: "dog", score: 1.0}
]
```

自転車とトラックと犬が一つずつ検出できました

## 静止画への検出結果の書込

検出結果を画像に書き込みます

```elixir
predictions
|> Enum.reduce(img, fn prediction, drawn_img ->
  {left, top, width, height} = prediction.box

  drawn_img
  # 四角形を描画する
  |> Evision.rectangle(
    {left, top},
    {left + width, top + height},
    {255, 0, 0},
    thickness: 4
  )
  # ラベル文字を書く
  |> Evision.putText(
    prediction.class,
    {left + 6, top + 26},
    Evision.Constant.cv_FONT_HERSHEY_SIMPLEX(),
    0.8,
    {0, 0, 255},
    thickness: 2
  )
end)
```

![dog_detected.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/ab7234f1-41e4-c66d-7316-43c1f765cc3f.png)

正しい位置に検出できています

## 動画の読込

いよいよ動画から物体検出を実行します

`Evision.VideoCapture.videoCapture` に動画ファイルのパスを指定します

ここでは、``"/tmp/sample.mp4"`` に置いてある動画を使っていますが、お手元の任意の動画を指定してください

```elixir
input_video = Evision.VideoCapture.videoCapture("/tmp/sample.mp4")
```

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

```elixir
%Evision.VideoCapture{
  fps: 30.0,
  frame_count: 98.0,
  frame_width: 720.0,
  frame_height: 1280.0,
  isOpened: true,
  ref: #Reference<0.1975750727.2452226059.250740>
}
```

動画ファイルのフレームレート（fps）やフレーム数(frame_count)などが取得できています

もし読み込めていない場合、 `fps` などの値が `0.0` になり、 `isOpened` が `false` になります

その場合は FFmpeg のインストールなどが上手くいっていないので、環境構築を見直してください

`Evision.VideoCapture.read` で動画内のフレームを画像として読み込みます

```elixir
Evision.VideoCapture.read(input_video)
```

![chair.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/d8e85b4f-cdfb-36e9-e454-b6bd886edbdf.png)

`Evision.VideoCapture.get` で動画の情報を取得します

`Evision.Constant.cv_CAP_PROP_POS_FRAMES` を指定すると、現在読込対象になっているフレーム番号を取得します

```elixir
Evision.VideoCapture.get(input_video, Evision.Constant.cv_CAP_PROP_POS_FRAMES)
```

実行結果は `1.0` になります

先ほど一回 read したことにより、 0 番のフレームが読み込まれ、次が 1 番になっています

[あんちぽさんの記事](https://zenn.dev/kentarok/articles/4c92dacfe1d1fe) で紹介されていた方法で動画を表示します

先頭のフレームから読み込み直すため、対象フレームを 0 に設定してから動かします

動画の最終フレームを過ぎると `Evision.VideoCapture.read` の結果に false が返り、それによって終了するようにしています

```elixir
Evision.VideoCapture.set(input_video, Evision.Constant.cv_CAP_PROP_POS_FRAMES, 0)

Kino.animate(1, 0, fn _, frame_index ->
  frame = Evision.VideoCapture.read(input_video)

  if frame do
    {:cont, frame, frame_index + 1}
  else
    :halt
  end
end)
```

![chair.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/b07952d7-e7ae-95a3-8342-594d7e78a6e3.gif)

ゆっくり動画が再生されました

全フレームを読み込んで表示しているため、どうしても元より遅くなります

## 動画からの物体検出

静止画で行った物体検出を関数にしておきます

```elixir
detect = fn img, model ->
  model
  |> Evision.DNN.DetectionModel.detect(img, confThreshold: 0.8, nmsThreshold: 0.7)
  |> then(fn {class_ids, scores, boxes} ->
    Enum.zip_with([class_ids, scores, boxes], fn [class_id, score, box] ->
      %{
        box: box,
        score: Float.round(score, 2),
        class: Enum.at(label_list, class_id)
      }
    end)
  end)
  |> Enum.reduce(img, fn prediction, drawn_img ->
    {left, top, width, height} = prediction.box

    drawn_img
    |> Evision.rectangle(
      {left, top},
      {left + width, top + height},
      {255, 0, 0},
      thickness: 8
    )
    |> Evision.putText(
      "#{prediction.class} #{prediction.score}",
      {left + 6, top + 52},
      Evision.Constant.cv_FONT_HERSHEY_SIMPLEX(),
      1.6,
      {0, 0, 255},
      thickness: 4
    )
  end)
end
```

全フレームに対して検出を実行します

```elixir
Evision.VideoCapture.set(input_video, Evision.Constant.cv_CAP_PROP_POS_FRAMES, 0)

Kino.animate(100, 0, fn _, frame_index ->
  frame = Evision.VideoCapture.read(input_video)

  if frame do
    {:cont, detect.(frame, model), frame_index + 1}
  else
    :halt
  end
end)
```

![chair_detected.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c6a628c1-7fec-86c1-381e-f42f386871ff.gif)

フレーム毎に物体検出結果が書き込まれました

## 動画への検出結果の書込

検出結果を動画ファイルとして保存します

mp4 形式で保存するように `Evision.VideoWriter.fourcc` でコーデックを指定します

```elixir
fourcc = Evision.VideoWriter.fourcc(hd('m'), hd('p'), hd('4'), hd('v'))
```

`Evision.VideoWriter.videoWriter` で出力の設定をします

フレームレートや画像の縦横サイズを指定します

```elixir
output_video =
  Evision.VideoWriter.videoWriter(
    "/tmp/sample_detected.mp4",
    fourcc,
    input_video.fps,
    {
      trunc(input_video.frame_width),
      trunc(input_video.frame_height)
    }
  )
```

再びフレームを 0 まで戻して、全フレームに対して物体検出を実行します

今回は Livebook 上に結果を表示しないで動画ファイルに書き込みます

```elixir
Evision.VideoCapture.set(input_video, Evision.Constant.cv_CAP_PROP_POS_FRAMES, 0)

0
|> Stream.iterate(&(&1 + 1))
|> Stream.map(fn frame_index ->
  IO.inspect("#{frame_index}/#{trunc(input_video.frame_count)}")
  input_frame = Evision.VideoCapture.read(input_video)
  if input_frame do
    output_frame = detect.(input_frame, model)
    Evision.VideoWriter.write(output_video, output_frame)
    true
  else
    false
  end
end)
|> Stream.take_while(& &1)
|> Enum.to_list()
```

実行すると以下のように経過が表示されていきます

```elixir
"0/98"
"1/98"
"2/98"
...
```

最終的にフレーム数分の true が入ったリストが返ります

全フレーム処理し終わったらファイルを解放します

```elixir
Evision.VideoWriter.release(output_video)
```

## 検出結果の確認

ちゃんと書き込めたか確認しましょう

```elixir
detected_video = Evision.VideoCapture.videoCapture("/tmp/sample_detected.mp4")

Kino.animate(1, 0, fn _, frame_index ->
  frame = Evision.VideoCapture.read(detected_video)

  if frame do
    {:cont, frame, frame_index + 1}
  else
    :halt
  end
end)
```

![chair_detected_writed.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/12dcc4a7-495f-7e1c-41bd-cbe56994b194.gif)

ちゃんと全フレーム書き込めていました

## まとめ

Python + OpenCV と同じ要領で Elixir + evision で動画処理できました

CPU 環境では遅いので、 GPU でどれだけ速くなるかも検証してみます