はじめに
以前、 evision と YOLOv3 で画像(静止画)から物体検出するコードを実装したました
今回は動画から物体検出したいと思います
いつものように Livebook で実装していきます
実装したノートブックはこちら
参考記事
実装にあたっては @the_haigo さんの以下の記事を参考にしました
また、 Kino.animate で動画を表示する方法については あんちぽ さんの以下の記事を参考にしました
実行環境
- Elixir: 1.14.2 OTP 24
- Livebook: 0.8.1
以下のリポジトリーの Docker コンテナ上で起動しています
Docker が使える環境であれば簡単に実行できます
https://docs.docker.com/engine/install/
Docker Desktop を無償利用できない場合は Rancher Desktop を使ってください
実行環境の注意点
コンパイル済の evision (OpenCV)では mp4 ファイルを読み込めないため、 evision をソースからコンパイルする必要があります
前述の私のコンテナ(Ubuntu)では、以下のモジュールを追加で apt-get install
しました
- unzip
- ffmpeg
- libavcodec-dev
- libavformat-dev
- libavutil-dev
- libswscale-dev
unzip がないとビルド時にエラーが発生します
それ以外は FFmpeg で mp4 などのコーデックを使えるようにするために追加しています
事前準備
何らかの mp4 形式の動画ファイルを /tmp/sample.mp4
に保存しているものとします
セットアップ
Mix.install(
[
{:nx, "~> 0.4"},
{:evision, "~> 0.1"},
{:req, "~> 0.3"},
{:kino, "~> 0.8"}
],
system_env: [
{"EVISION_PREFER_PRECOMPILED", "false"}
]
)
環境変数 EVISION_PREFER_PRECOMPILED
に false
を指定することで、 evision をソースからコンパイルするよう指定しています
evision のコンパイルには非常に時間がかかるため、30分程度待ちます
機械学習モデルのロード
今回は Darknet の YOLOv3 を使います
公式で配布している重み、設定ファイル、COCOのラベル一覧をダウンロードします
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 を指定
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
)
検出結果の番号を名称に変換するため、ラベルの一覧を読み込みます
label_list =
label_path
|> File.stream!()
|> Enum.map(&String.trim/1)
実行結果は以下のような、長さ 80 のリストになります
["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", ...]
画像の読込
テスト用の画像をダウンロードして読み込みます
img =
"https://raw.githubusercontent.com/pjreddie/darknet/master/data/dog.jpg"
|> Req.get!()
|> then(& &1.body)
|> Evision.imdecode(Evision.Constant.cv_IMREAD_COLOR())
静止画からの物体検出
Evision.DNN.DetectionModel.detect
で物体検出を実行します
- confThreshold: 検出結果の確信度に対する閾値
- nmsThreshold: Non-Maximum Suppression で検出した枠が同じ物体だと判断する IoU の閾値
検出結果は {class_ids, scores, boxes}
として取得されます
- class_ids: 物体が何であるかを示す番号のリスト
- scores: 物体の枠に対する確信度×分類に対する確信度
- boxes: 物体の位置を示す枠(左上のX座標、Y座標、幅、高さ)
後の処理で使いやすいように score は小数点以下2桁で丸め、 class_id は名称に変換します
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)
実行結果は以下のようになります
[
%{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}
]
自転車とトラックと犬が一つずつ検出できました
静止画への検出結果の書込
検出結果を画像に書き込みます
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)
正しい位置に検出できています
動画の読込
いよいよ動画から物体検出を実行します
Evision.VideoCapture.videoCapture
に動画ファイルのパスを指定します
ここでは、"/tmp/sample.mp4"
に置いてある動画を使っていますが、お手元の任意の動画を指定してください
input_video = Evision.VideoCapture.videoCapture("/tmp/sample.mp4")
実行結果は以下のようになります
%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
で動画内のフレームを画像として読み込みます
Evision.VideoCapture.read(input_video)
Evision.VideoCapture.get
で動画の情報を取得します
Evision.Constant.cv_CAP_PROP_POS_FRAMES
を指定すると、現在読込対象になっているフレーム番号を取得します
Evision.VideoCapture.get(input_video, Evision.Constant.cv_CAP_PROP_POS_FRAMES)
実行結果は 1.0
になります
先ほど一回 read したことにより、 0 番のフレームが読み込まれ、次が 1 番になっています
あんちぽさんの記事 で紹介されていた方法で動画を表示します
先頭のフレームから読み込み直すため、対象フレームを 0 に設定してから動かします
動画の最終フレームを過ぎると Evision.VideoCapture.read
の結果に false が返り、それによって終了するようにしています
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)
ゆっくり動画が再生されました
全フレームを読み込んで表示しているため、どうしても元より遅くなります
動画からの物体検出
静止画で行った物体検出を関数にしておきます
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
全フレームに対して検出を実行します
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)
フレーム毎に物体検出結果が書き込まれました
動画への検出結果の書込
検出結果を動画ファイルとして保存します
mp4 形式で保存するように Evision.VideoWriter.fourcc
でコーデックを指定します
fourcc = Evision.VideoWriter.fourcc(hd('m'), hd('p'), hd('4'), hd('v'))
Evision.VideoWriter.videoWriter
で出力の設定をします
フレームレートや画像の縦横サイズを指定します
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 上に結果を表示しないで動画ファイルに書き込みます
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()
実行すると以下のように経過が表示されていきます
"0/98"
"1/98"
"2/98"
...
最終的にフレーム数分の true が入ったリストが返ります
全フレーム処理し終わったらファイルを解放します
Evision.VideoWriter.release(output_video)
検出結果の確認
ちゃんと書き込めたか確認しましょう
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)
ちゃんと全フレーム書き込めていました
まとめ
Python + OpenCV と同じ要領で Elixir + evision で動画処理できました
CPU 環境では遅いので、 GPU でどれだけ速くなるかも検証してみます