はじめに
本記事はAxonOnnxでVGG16を読み込んで
LiveViewで物体認識を行うアプリケーションを作成した際の忘備録になります
Axon
行列演算ライブラリNxを使用して作られたディープラーニングフレームワーク
AxonOnnx
AxonにONNXフォーマットの学習済みモデルを読み込むライブラリ
Project
いつもどおりプロジェクトの作成です
mix phx.new live_onnx --no-ecto
cd live_onnx
axon_onnxと画像の読み込み、リサイズを行うstb_imageを追加します
defmodule LiveOnnx.MixProject do
...
  defp deps do
    [
      ...
      # 以下追加
      {:axon_onnx, github: "elixir-nx/axon_onnx"},
      {:stb_image, "~> 0.4.0"}
    ]
  end
end
ONNXを読み込めない? 2022/06/03 現在
まだまだ開発中のため読み込めないモデルが多々あるようです
transformer系は注力していたようで幾つか成功しています
onnx model zooのclassification
https://github.com/onnx/models/tree/main/vision/classification
のモデルは現在 importで失敗します 多分dynamic inputになってるせいかと思います
dynamic inputはAxonOnnxでは現在サポートしていません
Pytorch onnx export
AxonOnnxのテストを見た感じPyTorchで学習済みモデル読み込んでonnx exportしているようなので、
それにならってVGG16をonnx exportしてみましょう
学習済みモデルはtorchvisionから読み込みます
pip install torch torchvision
# インポート
import torchvision
import torch
net = torchvision.models.vgg16(pretrained = True)
# モデル出力のための設定
model_onnx_path = "vgg16.onnx" # 出力するモデルのファイル名
input_names = [ "input" ] # データを入力する際の名称
output_names = [ "output" ] # 出力データを取り出す際の名称
# ダミーインプットの作成
input_shape = (3, 224, 224) # 入力データの形式
batch_size = 1 # 入力データのバッチサイズ
dummy_input = torch.randn(batch_size, *input_shape) # ダミーインプット生成
# 変換実行!!
output = torch.onnx.export(net, dummy_input, model_onnx_path, \
                   verbose=False, input_names=input_names, output_names=output_names)
モデル読み込み
{model, params} = AxonOnnx.import("vgg16.onnx")
detsに保存
vgg16は500MBあった読み込みに時間がかかるのでdets形式にして読み込みの高速化を図ります
:dets.open_file("vgg16", type: :bag, file: 'vgg16.dets')
:dets.insert("vgg16",{1,{model,params}}) 
:dets.sync("vgg16")
:dets.stop
推論アプリの作成
LiveViewを使ってアップロードした画像に対して物体認識を行う機能を実装していきます
mount
モデルと正解リストを読み込んでアサインします
推論する画像をアップロードするために live_file_inputをallow_uploadで使えるようにします
defmodule LiveOnnxWeb.PageLive do
  use LiveOnnxWeb, :live_view
  @impl true
  def mount(_params, _session, socket) do
    {:ok, params} = :dets.open_file('vgg16.dets')
    [{1, {model, params}}] = :dets.lookup(params, 1)
    list = File.read!("model/classlist.json") |> Jason.decode!()
    {
      :ok,
      socket
      |> assign(:model, model)
      |> assign(:params, params)
      |> assign(:list, list)
      |> allow_upload(
        :image,
        accept: :any
      )
    }
  end
  @impl true
  def handle_event("validate", _params, socket) do
    {:noreply, socket}
  end
end
classlistはこちらをvscodeで加工してjsonに形式に変換しました
UI
tailwindで1からデザインが面倒なのでcdnでblumaをimport
@import "https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css";
<div class="columns">
  <aside class="column is-2 menu">
    <p class="menu-label">Actions</p>
    <dl class="menu-list">
      <dt><button class="button is-fullwidth">Detect</button></dt>      
    </dl>
  </aside>
  <div class="column is-10" >
    <div class="columns is-centered">
      <form phx-change="validate" >
          <div class="file is-boxed" phx-drop-target={ @uploads.image.ref }>
            <label class="file-label">
              <%= live_file_input @uploads.image, class: "file-input" %>
              <input class="file-input" type="file" name="resume">
              <span class="file-cta">
                <span class="file-label p-6">
                  Choose a file…
                </span>
              </span>
            </label>
          </div>
      </form>
    </div>
  </div>
</div> 
router
ルーターに追加
defmodule LiveOnnxWeb.Router do
  use LiveOnnxWeb, :router
  scope "/", LiveOnnxWeb do
    pipe_through :browser
    live "/", PageLive # 追加
  end
アップロードした画像をbinaryデータとNx tensorに変換する
defmodule LiveOnnxWeb.PageLive do
  use LiveOnnxWeb, :live_view
  @impl true
  def mount(_params, _session, socket) do
    {:ok, params} = :dets.open_file('vgg16.dets')
    [{1, {model, params}}] = :dets.lookup(params, 1)
    list = File.read!("model/classlist.json") |> Jason.decode!()
    {
      :ok,
      socket
      |> assign(:model, model)
      |> assign(:params, params)
      |> assign(:list, list)
      |> assign(:upload_file, nil) # 追加
      |> assign(:tensor, nil) # 追加
      |> allow_upload(
        :image,
        accept: :any,
        chunk_size: 6400_000, # 追加
        progress: &handle_progress/3, # 追加
        auto_upload: true # 追加
      )
    }
  end
  # 以下追加
  def handle_progress(:image, _entry, socket) do
    # バイナリデータへ変換
    upload_file =
      consume_uploaded_entries(socket, :image, fn %{path: path}, _entry ->
        File.read(path)
      end)
      |> List.first()
    # 読み込み
    {:ok, image} = StbImage.from_binary(upload_file)
    # 224x224へリサイズ
    {:ok, image} = StbImage.resize(image, 224, 224)
    tensor =
      # Nx.Tensorへ変換
      StbImage.to_nx(image)
      # 値が0~1の範囲になるように変換
      |> Nx.divide(255)
      # 正規化 by torchvisonのドキュメント
      |> Nx.subtract(Nx.tensor([0.485, 0.456, 0.406]))
      |> Nx.divide(Nx.tensor([0.229, 0.224, 0.225]))
      # 224x224x3を3x224x224に変換
      |> Nx.transpose()
            # 1x3x224x224になるように軸を追加
      |> Nx.new_axis(0)
    {
      :noreply,
      socket
      |> assign(:upload_file, upload_file)
      |> assign(:tensor, tensor)
    }
  end
  ... 
end
正規化ですが精度を上げるように 全体からmeanの値を引いてstbの値で除算を行います
https://pytorch.org/vision/stable/models.html#models-and-pre-trained-weights
The images have to be loaded in to a range of [0, 1] and then normalized using mean = [0.485, 0.456, 0.406] and std = [0.229, 0.224, 0.225]. You can use the following transform to normalize:
アップロードした画像を表示
binaryなのでbase64エンコードしてオブジェクトURLで表示させます
<div class="columns">
  <aside class="column is-2 menu">
    <p class="menu-label">Actions</p>
    <dl class="menu-list">
      <dt><button class="button is-fullwidth">Detect</button></dt>      
    </dl>
  </aside>
  <div class="column is-10" >
    <div class="columns is-centered">      
      <div style={ if @upload_file != nil, do: "display:none" }> <!--- 追加 --->
      <form phx-change="validate" >
          <div class="file is-boxed" phx-drop-target={ @uploads.image.ref }>
            <label class="file-label">
              <%= live_file_input @uploads.image, class: "file-input" %>
              <input class="file-input" type="file" name="resume">
              <span class="file-cta">
                <span class="file-label p-6">
                  Choose a file…
                </span>
              </span>
            </label>
          </div>
      </form>
      </div> <!--- 追加 --->
      <!--- 以下追加 --->
      <%= if @upload_file do %>
        <div><img alt="" class="w-full" src={"data:image/png;base64,#{Base.encode64(@upload_file)}"}/></div>
      <% end %>
    </div>
  </div>
</div>
detectの実装
axonをCPU高速化モードで起動するようにして
推論後データをランキングになるように変換します
defmodule LiveOnnxWeb.PageLive do
  use LiveOnnxWeb, :live_view
  require Axon # 追加
  EXLA.set_as_nx_default([:tpu, :cuda, :rocm, :host]) # 追加
  @impl true
  def mount(_params, _session, socket) do
    {:ok, params} = :dets.open_file('vgg16.dets')
    [{1, {model, params}}] = :dets.lookup(params, 1)
    list = File.read!("model/classlist.json") |> Jason.decode!()
    {
      :ok,
      socket
      |> assign(:model, model)
      |> assign(:params, params)
      |> assign(:list, list)
      |> assign(:upload_file, nil)
      |> assign(:ans, []) # 追加
      |> allow_upload(
        :image,
        accept: :any,
        chunk_size: 6400_000,
        progress: &handle_progress/3,
        auto_upload: true
      )
    }
  end
  ...
  @impl true
  def handle_event(
        "detect",
        _params,
        %{assigns: %{tensor: tensor, model: model, params: params}} = socket
      ) do
    ans =
      # 推論実行
      Axon.predict(model, params, tensor)
      # 結果の1x1000を1000に変換
      |> Nx.flatten()
      # 値が小さい順にindexを並べる
      |> Nx.argsort()
      # 大きい順に並べる
      |> Nx.reverse()
      # 上位5つのみ取得
      |> Nx.slice([0], [5])
      # Listに変換
      |> Nx.to_flat_list()
    {:noreply, assign(socket, :ans, ans)}
  end
end
detectイベントの追加+答えの表示
最後にボタンにdetectイベントを追加して完了です
<div class="columns">
  <aside class="column is-2 menu">
    <p class="menu-label">Actions</p>
    <dl class="menu-list">
      <!--- phx-clickを追加 --->
      <dt><button class="button is-fullwidth" phx-click="detect">Detect</button></dt>      
    </dl>
    <!--- 以下追加 --->
    <%= for {ans, index} <- Enum.with_index(@ans) do %>
      <h5><%= "#{index + 1}: " <> Map.get(@list, to_string(ans)) %></h5>
    <% end %>
  </aside>
  <div class="column is-10">
  ...
  </div>
</div>
デモ
これで物体認識アプリができました!
他のモデルも使いたい
こちらに利用可能モデルがあるので、vgg16と同様にexportすれば使えるかと思います
convnextは検証済みです
AxonOnnxでmix testを実行する
おまけでAxonOnnxのmix testを行う際の環境構築手順を書いておきます
pip install onnx transformer sentencepiece
ubuntu
pip install onnxruntime
Mac
pip install -i https://test.pypi.org/simple/ onnxruntime==1.8.2.dev20210816004
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
学習済みネットワークはGBクラスもあるので15GBはダウンロードされる覚悟をもって実行してください
mix text
最後に
AxonOnnxがまだまだ開発中なので読み込めないモデルがありますが、
モデルを読み込んで簡単にアプリケーションに組み込むことができました!
Elixirのみでディープラーニングアプリケーションが作れるので
Elixir DesktopやNervesなどマルチプラットフォームへの対応の夢が広がります
本記事は以上になりますありがとうございました
コード
参考
https://www.granvalley.co.jp/blog/convert_from_pytorch-model_to_openvino
https://gist.githubusercontent.com/yrevar/942d3a0ac09ec9e5eb3a/raw/238f720ff059c1f82f368259d1ca4ffa5dd8f9f5/imagenet1000_clsidx_to_labels.txt
https://pytorch.org/vision/stable/models.html#models-and-pre-trained-weights
https://qiita.com/MuAuan/items/c350f64b7abb396973ed#transforms%E3%81%AE%E6%95%B4%E7%90%86


