11
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ElixirAdvent Calendar 2022

Day 22

Elixir Desktop で YOLOv3 (evision)を動かしてみた

Last updated at Posted at 2022-07-29

はじめに

最近は Elixir ばかりですが(他の業務もこなしていますよ)、
とうとう Elixir Desktop で YOLO v3 を動かしてみます

全コードはこちら

参考にした @the_haigo さんのリポジトリーはこちら

実行環境

  • macOS Monterey 12.4
  • Elixir 1.13.4
  • Erlang OTP24

フォーク

まずは Elixir Desktop 公式のサンプルをフォークしてきます

フォークしたものをローカルにクローンしてきて

todoyoloTodoYolo に一括置換します

依存パッケージの変更

@the_haigo さんの記事を参考にして、 mix.exs を以下のように編集します

特筆していない箇所は @the_haigo さんのままなので、そちらを参照してください

  ...
  defp deps do
    [
      {:desktop, github: "elixir-desktop/desktop", tag: "v1.4.0"},

      # Phoenix
      {:phoenix, "~> 1.6"},
      {:phoenix_live_view, "~> 0.17.4"},
      {:phoenix_html, "~> 3.0"},
      {:phoenix_live_reload, "~> 1.3", only: [:dev]},
      {:gettext, "~> 0.18"},
      {:plug_cowboy, "~> 2.5"},
      {:jason, "~> 1.2"},

      # Assets
      {:esbuild, "~> 0.2", runtime: Mix.env() == :dev},
      {:tailwind, "~> 0.1", runtime: Mix.env() == :dev},
      {:petal_components, "~> 0.17"},

      # Credo
      {:credo, "~> 1.5", only: [:dev, :test], runtime: false},

      # YOLO
      {:evision, "~> 0.1.0-dev", github: "cocoa-xu/evision", branch: "main"},
      {:exla, "~> 0.3.0-dev", github: "elixir-nx/nx", sparse: "exla"},
      {:nx, "~> 0.3.0-dev", [env: :prod, git: "https://github.com/elixir-nx/nx.git", sparse: "nx", override: true]},
      {:stb_image, "~> 0.4.0"}
    ]
  end
end

PetalCompoent を使うため、 dart_sass を消して tailwind と petal_components を追加しています

また、機械学習用に evision exla nx stb_image を追加します

ecto_sqlite3 など、使わないものは消しています

その他、全体的に DB (Repo) を使っている箇所を削除します

詳細はこちら

モデルのダウンロード

Darknet の YOLOv3 モデルをダウンロードして priv 配下に置きます

#!/bin/bash

mkdir -p priv/models

wget -c \
  -N https://pjreddie.com/media/files/yolov3.weights \
  -O ./priv/models/yolov3.weights

wget -c \
  -N https://raw.githubusercontent.com/pjreddie/darknet/master/cfg/yolov3.cfg \
  -O ./priv/models/yolov3.cfg

wget -c \
  -N https://raw.githubusercontent.com/pjreddie/darknet/master/data/coco.names \
  -O ./priv/models/labels.txt

これらのファイルは .gitignore に追加して git では無視するようにしておきます

.gitignore

...
# models
priv/models

モデルのロード

検出実行の度にモデルをロードしたくないので、
モデルを保持しておくための Store を実装します

lib/yolo_app/store.ex

defmodule YoloApp.Store do
  use Agent
  alias __MODULE__
  alias Evision, as: OpenCV

  def start_link(opts) do
    # priv ディレクトリー配下から取得する
    priv_path = List.to_string(:code.priv_dir(:yolo_app))

    cfg_path = priv_path <> "/models/yolov3.cfg"
    weights_path = priv_path <> "/models/yolov3.weights"
    labels_path = priv_path <> "/models/labels.txt"

    net = OpenCV.DNN.readNet!(weights_path, config: cfg_path, framework: "")
    out_names = OpenCV.DNN.Net.getUnconnectedOutLayersNames!(net)
    label_list =
      labels_path
      |> File.stream!()
      |> Enum.map(&String.trim/1)

    # Agent に入れておく
    Agent.start_link(fn ->
      %{
        net: net,
        out_names: out_names,
        label_list: label_list,
      }
    end, name: __MODULE__)
  end

  # 使用時に Agent から取り出す
  def get(key) do
    Agent.get(__MODULE__, &Map.get(&1, key))
  end
end

DBの初期化を行なっていた箇所をモデルロードに置き換えます

  • 変更前

lib/todo_app.ex

...
Application.put_env(:todo_app, TodoApp.Repo,
  database: Path.join(config_dir(), "/database.sq3")
)

{:ok, sup} = Supervisor.start_link([TodoApp.Repo], name: __MODULE__, strategy: :one_for_one)
TodoApp.Repo.initialize()
...
  • 変更後

lib/yolo_app.ex

...
    {:ok, sup} = Supervisor.start_link([YoloApp.Store], name: __MODULE__, strategy: :one_for_one)
...

ワーカーの実装

物体検出を実行するワーカーを実装します

lib/yolo_app/worker.ex

defmodule YoloApp.Worker do
  use Agent
  alias YoloApp.Store
  alias YoloApp.Worker
  alias Evision, as: OpenCV

  def detect(binary) do
    label_list = Store.get(:label_list)

    mat = to_mat(binary)

    predictions =
      mat
      |> preprocess()
      |> predict()
      |> to_tensor()
      |> filter_predictions(0.8)
      |> format_predictions()
      |> nms(0.8, 0.7)
      |> Enum.map(&Map.put(&1, :class, Enum.at(label_list, &1.class)))

    drawed =
      OpenCV.imencode!(".png", draw_predictions(mat, predictions))
      |> IO.iodata_to_binary()

    {predictions, drawed}
  end

  def measure(function) do
    {time, result} = :timer.tc(function)
    IO.puts "Time: #{time}ms"
    result
  end

  def to_mat(binary) do
    binary
    |> StbImage.from_binary()
    |> elem(1)
    |> StbImage.to_nx()
    |> OpenCV.Nx.to_mat!()
  end

  def preprocess(mat) do
    OpenCV.DNN.blobFromImage!(mat, size: [608, 608], swapRB: true, crop: false)
  end

  def predict(blob) do
    net = Store.get(:net)
    out_names = Store.get(:out_names)

    net
    |> OpenCV.DNN.Net.setInput!(
      blob,
      name: "",
      scalefactor: 1 / 255,
      mean: [0, 0, 0]
    )
    |> OpenCV.DNN.Net.forward!(outBlobNames: out_names)
  end

  def to_tensor(predictions) do
    predictions
    |> Enum.map(fn prediction ->
      OpenCV.Nx.to_nx(prediction)
    end)
    |> Nx.concatenate()
  end

  def filter_predictions(predictions, score_threshold) do
    size =
      predictions
      |> Nx.shape()
      |> elem(0)

    threshold_tensor =
      score_threshold
      |> Nx.tensor()
      |> Nx.broadcast({size})

    index_list =
      Nx.transpose(predictions)[4]
      |> Nx.greater(threshold_tensor)
      |> Nx.to_flat_list()
      |> Enum.with_index()
      |> Enum.filter(fn {value, _} -> value == 1 end)
      |> Enum.map(&elem(&1, 1))
      |> Nx.tensor()

    Nx.take(predictions, index_list)
  end

  def format_predictions(predictions) do
    predictions
    |> Nx.to_batched_list(1)
    |> Enum.map(fn t ->
      class_score_list = t[0][5..-1//1]
      class_id = class_score_list |> Nx.argmax() |> Nx.to_number()
      class_score = class_score_list[class_id] |> Nx.to_number()
      score = t[0][4] |> Nx.to_number() |> Kernel.*(class_score)

      center_x = t[0][0] |> Nx.to_number()
      center_y = t[0][1] |> Nx.to_number()
      box_width = t[0][2] |> Nx.to_number()
      box_height = t[0][3] |> Nx.to_number()
      min_x = center_x - box_width / 2
      min_y = center_y - box_height / 2
      max_x = center_x + box_width / 2
      max_y = center_y + box_height / 2

      box = [min_x, min_y, max_x, max_y]

      %{
        box: box,
        score: score,
        class: class_id
      }
    end)
  end

  def nms(formed_predictions, score_threshold, nms_threshold) do
    box_list = Enum.map(formed_predictions, & &1.box)
    score_list = Enum.map(formed_predictions, & &1.score)

    box_list
    |> OpenCV.DNN.nmsBoxes!(score_list, score_threshold, nms_threshold)
    |> Enum.map(&Enum.at(formed_predictions, &1))
  end

  def draw_predictions(mat, predictions) do
    {height, width, _} = OpenCV.Mat.shape!(mat)

    predictions
    |> Enum.reduce(mat, fn prediction, drawed_mat ->
      box = prediction.box
      left = Enum.at(box, 0) |> Kernel.*(width) |> trunc()
      top = Enum.at(box, 1) |> Kernel.*(height) |> trunc()
      right = Enum.at(box, 2) |> Kernel.*(width) |> trunc()
      bottom = Enum.at(box, 3) |> Kernel.*(height) |> trunc()

      drawed_mat
      |> OpenCV.rectangle!(
        [left, top],
        [right, bottom],
        [255, 0, 0],
        thickness: 4
      ) # 四角形を描画する
      |> OpenCV.putText!(
        prediction.class,
        [left + 6, top + 26],
        OpenCV.cv_FONT_HERSHEY_SIMPLEX,
        0.8,
        [0, 0, 255],
        thickness: 2
      ) # ラベル文字を書く
    end)
    |> OpenCV.cvtColor!(OpenCV.cv_COLOR_BGR2RGB)
  end
end

結果表示

検出結果の一覧と、結果の枠を書き込まれた画像を取得し、アサインします

lib/yolo_web/live/yolo_live.ex

...
  @impl true
  def handle_event("detect",_params,%{assigns: %{upload_file: binary}} = socket) do
    {predictions, drawed} = YoloApp.Worker.detect(binary)
    socket =
      socket
      |> assign(:upload_file, drawed)
      |> assign(:ans, predictions)
    {:noreply, socket}
  end
...

lib/yolo_web/live/yolo_live.html.heex

        <%= for {ans, index} <- Enum.with_index(@ans) do %>
          <h5><%= "#{index + 1}: " <> to_string(ans.class) %></h5>
        <% end %>

実行結果

こんな感じで実行できました!

yolo.gif

処理速度の向上

実は最初実装したとき、閾値による抽出処理は以下のようになっていました

      predictions
      |> Nx.to_batched_list(1)
      |> Enum.filter(fn t ->
        t[0][4]
        |> Nx.to_number()
        |> Kernel.>(score_threshold)
      end)

しかし、これだとテンソルの 0 次元軸についてループするため、非常に遅いです

ちょっと計測してみましょう

measure 関数で抽出処理を囲むと、、、

    predictions = measure(fn -> filter_predictions(predictions, 0.8) end)

なんと 9.21 sec も抽出に掛かっています

Time: 9212428

なぜこんなに掛かっているのか

YOLOv3 の検出結果は以下のような行列で与えられます

X座標 Y座標 高さ スコア クラス1確信度 クラス2確信度 ... クラス80確信度
0.2 0.3 0.05 0.03 0.3 0.8 0.0 ... 0.0
0.1 0.4 0.12 0.2 0.92 0.0 0.0 ... 0.0
0.6 0.05 0.3 0.2 0.95 0.0 0.9 ... 0.0
... ... ... ... ... ... ... ... ...

入力になっている predictions の shape を見てみると

{22743, 85}

これを普通にループしてしまうと、 2.2 万回も比較を行うことになるので、非常に遅いのです

高速化するためには、抽出をループではなく行列計算で行う必要があります

というわけで、以下のようなことをしています

  • shape が {22743, 1} で、中身が全て閾値の行列を作る
閾値1 閾値2 閾値3 ... 閾値22741 閾値22742 閾値22743
0.8 0.8 0.8 ... 0.8 0.8 0.8
size =
  predictions
  |> Nx.shape()
  |> elem(0)

threshold_tensor =
  score_threshold
  |> Nx.tensor()
  |> Nx.broadcast({size})
  • predictions からスコアの列だけ取り出す
    shape は {22743, 1} になる
スコア1 スコア2 スコア3 ... スコア22741 スコア22742 スコア22743
0.3 0.92 0.95 ... 0.2 0.9 0.3
Nx.transpose(predictions)[4]
  • 閾値の行列とスコアの行列を比較する
    スコアが閾値を超えているところだけ 1 , それ以外は 0 の行列になる
比較結果1 比較結果2 比較結果3 ... 比較結果22741 比較結果22742 比較結果22743
0 1 1 ... 0 1 0
greater = Nx.greater(predictions, threshold_tensor)
  • 値が1のところのインデックスを取り出す
index_list =
    Nx.to_flat_list(greater)
    |> Enum.with_index()
    |> Enum.filter(fn {value, _} -> value == 1 end)
    |> Enum.map(&elem(&1, 1))
    |> Nx.tensor()
インデックス1 インデックス2 インデックス3 ... インデックスn
1 2 15 ... 22741
  • 指定したインデックスの推論結果だけを取り出す
Nx.take(predictions, index_list)

このようにした結果、、、

Time: 121790

9.21 sec -> 0.12 sec なので、圧倒的な差です

如何にループではなく行列演算にすべきか、ということですね

バックエンド

ちなみに、 main ブランチは EXLA を Nx のバックエンドにしています

Torchx でも問題なく動作しました

Torchx の場合の抽出処理(行列演算バージョン)の速さは

Time: 54847

なんと 0.05 sec

十分な回数試していないので、 Torchx のほうが速いとは言えませんが、
実用できる速度で動いています

ついでに、バックエンドを指定していない場合、
つまりバイナリバックエンドを使っている場合も見てみましょう

Time: 1909958

1.9 sec なので、思ったよりは速いです

行列演算は偉大ですね

iOS での実行

残念ながら、まだ iOS では動かせていません

EXLA や evision が iOS では動かなさそうです

今後に期待ですね

おわりに

evision を使った方法は一旦袋小路のようなので、
AxonOnnx を使ってみたいと思います

11
1
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?