LiveViewとBumblebeeでサクッと画像分類Webアプリを作ってみる

# はじめに
本記事はPhoenix LiveViewとBumblebeeを使用して画像分類を行うWebアプリケーションを構築します

# 画像分類とは？
画像に何が写っているのかを分類を行う分類器を通して、判別します

![スクリーンショット 2023-07-25 20.45.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/de9031d0-af0e-226e-a598-3024da38b267.png)

画像のどこに何が写っているかを判別するのは物体検知で別のものです

![スクリーンショット 2023-07-25 20.47.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/4b49f8a7-3873-5e5c-0a8f-865219ed4e3b.png)

# Elixirで画像分類はどうやるのか？

色々出てきました
- [Bumblebee](https://github.com/elixir-nx/bumblebee) -> 学習済みネットワーク構築ライブラリ
- [AxonOnnx](https://github.com/elixir-nx/axon_onnx) -> ONNX モデルコンバーター
- [Evision](https://github.com/cocoa-xu/evision) -> OpenCV Elixir Binding
- [Ortex](https://github.com/elixir-nx/ortex) -> ONNX Runtime Elixir Binding
- [EXGBoost](https://github.com/acalejos/exgboost) -> XGBoost Elixir Binding

今回はBumblebeeを使います

# Bumblebeeとは

![スクリーンショット 2023-07-25 15.54.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/b4d60bfd-67ce-74c0-4cb0-19fc1cb6d8b4.png)


- 学習済みの機械学習のモデルをAxonで動かすライブラリ
- モデルと学習データを[HuggingFace](https://huggingface.co/)からダウンロードしていい感じにモデルを構築してくれる
- Nx.Serveを使用してElixirのGenServerのWorkerとして簡単にPhoenixに組み込める

# アプリケーションの作成
では実際にBumblebeeをPhoenix Liveviewに組み込んで、サクッと画像分類Webアプリを作ってみます

## プロジェクトの作成
アプリ名はclassification
DBは使わないので`--no-ecto`オプションをつけます

```
mix phx.new classification --no-ecto
```

## ライブラリの追加
完了したら移動して以下のように３つのライブラリを追加します

- [stb_image](https://github.com/elixir-nx/stb_image) -> 画像をデータに変換
- [exla](https://github.com/elixir-nx/nx/tree/main/exla) -> 行列計算(Nx)を高速化
- bumblebee -> 学習済みモデル構築

```mix.exs
defmodule Classification.MixProject do
  use Mix.Project

  ...
  defp deps do
    [
      ...
      {:stb_image, "~> 0.6"},
      {:bumblebee, "~> 0.3.0"},
      {:exla, ">= 0.0.0"}
    ]
  end
end
```

## Exlaの設定
ExlaをPhoenixで使用するために config.exsで以下の設定が必要です

```config/config.exs
import Config

config :nx, default_backend: EXLA.Backend # 追加
```

## 初期ページの作成

最低限動かすにはmountを追加します
```lib/classification_web/live/page_live.ex
defmodule ClassificationWeb.PageLive do
  use ClassificationWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    socket
    |> then(&{:ok, &1})
  end
end
```

アクションサイドバーとファイルのドロップエリアを作っておきます

```leex:lib/classification_web/live/page_live.html.heex
<div class="flex h-screen">
  <div>
    Actions
  </div>
  <div class="w-[32em] h-1/2 mx-auto p-4 border-2 rounded-lg shadow-lg">
    <form id="upload-form" class="h-full">
      <div class="h-full">
        <label>
          <input
            type="file"
            class="opacity-0 h-1/2"
          />
          <h1 class="-mt-6 text-center text-4xl">
            Select or Drop File
          </h1>
        </label>
      </div>
    </form>
  </div>
</div>
```

ページができたらroutingに追加します

```diff_elixir:lib/classification_web/router.ex
defmodule ClassificationWeb.Router do
  use ClassificationWeb, :router
  ...
  scope "/", ClassificationWeb do
    pipe_through :browser
-   get "/", PageController, :home
+   live "/", PageLive
  end
  ...
end
```

これで初期ページができました

![スクリーンショット 2023-07-25 21.27.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/0d595a49-2766-bf8e-1989-9a832aaa3099.png)

## フォームのアップロードイベントの追加

formにphx-changeイベントを追加して、handle_eventでフォームの変更を検知します

```diff_elixir:lib/classification_web/live/page_live.html.heex
<div class="flex h-screen">
  <div>
    Actions
  </div>
  <div class="w-[32em] h-1/2 mx-auto p-4 border-2 rounded-lg shadow-lg">
-    <form id="upload-form" class="h-full">
+    <form id="upload-form" class="h-full" phx-change="upload">
      <div class="h-full">
        <label>
          <input
            type="file"
            class="opacity-0 h-1/2"
          />
          <h1 class="-mt-6 text-center text-4xl">
            Select or Drop File
          </h1>
        </label>
      </div>
    </form>
  </div>
</div>
```

```diff_elixir:lib/classification_web/live/page_live.ex
defmodule ClassificationWeb.PageLive do
  use ClassificationWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    socket
    |> then(&{:ok, &1})
  end

+  @impl true
+  def handle_event("upload", _params, socket) do
+    {:noreply, socket}
+  end
end
```

## ファイルアップロード設定

画像ファイルのバイナリデータとファイル名を格納する変数を準備
- アップロード設定
  - アクセス名を:imageに設定
  - accept -> 許可する画像形式に `jpg jpeg png`を設定
  - progress -> アップロード時に`handle_progress/3`を実行する
  - auto_upload -> submitを押さずにuploadを完了する

```diff_elixir:lib/classification_web/live/page_live.ex
defmodule ClassificationWeb.PageLive do
  use ClassificationWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    socket
+    |> assign(:upload_file, nil)
+    |> assign(:filename, nil)
+    |> allow_upload(
+      :image,
+      accept: ~w(.jpg .jpeg .png),
+      progress: &handle_progress/3,
+      auto_upload: true
+    )
    |> then(&{:ok, &1})
  end

+  def handle_progress(:image, entry, socket)
+      when entry.done? == false,
+      do: {:noreply, socket}
 ...
end
```

handle_progressはuploadが完了しない場合は何もしないようにする

```diff_elixir:lib/classification_web/live/page_live.ex
defmodule ClassificationWeb.PageLive do
  use ClassificationWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    ...
  end

+  def handle_progress(:image, entry, socket)
+      when entry.done? == false,
+      do: {:noreply, socket}
 ...
end
```

完了した場合は以下の処理を行う
- バイナリデータを取り込んで、ファイルを破棄する
- entriesなので先頭を取得
- バイナリをassign
- ファイル名をassign

```diff_elixir:lib/classification_web/live/page_live.ex
defmodule ClassificationWeb.PageLive do
  use ClassificationWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    ...
  end

  def handle_progress(:image, entry, socket)
      when entry.done? == false,
      do: {:noreply, socket}

+  def handle_progress(:image, entry, socket) do
+    upload_file =
+      consume_uploaded_entries(socket, :image, fn %{path: path}, _entry -> File.read(path) end)
+      |> hd()
+
+    socket
+    |> assign(:upload_file, upload_file)
+    |> assign(:filename, entry.client_name)
+    |> then(&{:noreply, &1})
+  end
...
end
```

## Drag and Dropで画像のアップロードを許可する
現在だとクリックしてファイルを選択しますが`phx-drop-target`でｄropを許可します

```diff_elixir:lib/classification_web/live/page_live.html.heex
<div class="flex h-screen">
  <div>
    Actions
  </div>
  <div class="w-[32em] h-1/2 mx-auto p-4 border-2 rounded-lg shadow-lg">
    <form id="upload-form" class="h-full" phx-change="upload">
-      <div class="h-full">
+      <div class="h-full" phx-drop-target={@uploads.image.ref}>
        <label>
-          <input
-            type="file"
-            class="opacity-0 h-1/2" />
+          <.live_file_input 
+            class="opacity-0 h-1/2" type="file"
+            upload={@uploads.image}
+          />
          <h1 class="-mt-6 text-center text-4xl">
            Select or Drop File
          </h1>
        </label>
      </div>
    </form>
  </div>
</div>
```


## アップロードした画像を表示する
アップロードした画像は通常`live_img_preview`で表示できるのですが、`consume_upload_entries`を実行してentryにアクセスできないので別の方法で表示します

画像データがある場合
- フォームを非表示にする
- 画像データをBase64にエンコードして表示
- ファイル名を表示

```diff_elixir:lib/classification_web/live/page_live.ex
<div class="flex h-screen">
  <div>
    Actions
  </div>
-  <div class="w-[32em] h-1/2 mx-auto p-4 border-2 rounded-lg shadow-lg">
+  <div class={
+    "w-[32em] h-1/2 mx-auto p-4 border-2 rounded-lg shadow-lg"
+    <> if is_nil(@upload_file), do: "", else: " hidden"
+  }>
    <form id="upload-form" class="h-full" phx-change="upload">
      <div class="h-full" phx-drop-target={@uploads.image.ref}>
        <label>
          <.live_file_input 
            class="opacity-0 h-1/2" type="file"
            upload={@uploads.image}
          />
          <h1 class="-mt-6 text-center text-4xl">
            Select or Drop File
          </h1>
        </label>
      </div>
    </form>
  </div>
+  <%= if @upload_file do %>
+    <figure>
+      <img
+        alt=""
+        class="w-full ml-4"
+        src={"data:image/png;base64,#{Base.encode64(@upload_file)}"}
+      />
+      <figcaption>File Name:<%= @filename %></figcaption>
+    </figure>      
+  <% end %>
</div>
```

## モデルのWorkerを作る
モデルの設計図と学習済みデータをダウンロードしてきて、モデルを構築します

モデルは`Microsoftのresnet-50`を使用します
load_modelでモデルの設計図と学習済みデータを読み込みます
load_featurizerでPythonの画像のデータをモデルに流し込むための前処理と結果を使いやすい形に整形する後処理のコードをElixirのコードに変換します
Vision.image_classificationで分類器を作成します
オプションはそれぞれ以下を指定しています
- 結果のトップ１のみを返す
- 最大で10の分類のリクエストをまとめて行う
- 行列演算の高速化にExlaを使う

```lib/classification/worker.ex
defmodule Classification.Worker do
  def build_model() do
    {:ok, model_info} = Bumblebee.load_model({:hf, "microsoft/resnet-50"})
    {:ok, featurizer} = Bumblebee.load_featurizer({:hf, "microsoft/resnet-50"})

    Bumblebee.Vision.image_classification(model_info, featurizer,
      top_k: 1,
      compile: [batch_size: 10],
      defn_options: [compiler: EXLA]
    )
  end
end
```

## Phoenix起動時にWorkerも起動するように設定
起動時にPhoenix以外のアプリケーションを一緒に起動する時は
application.exの起動するアプリ一覧のchildrenに追加します

BumblebeeやAxonのモデルをWorkerとして起動する場合はNx.Servingを使用します
servingは提供するモデル、nameはPhoenixから使う際のモジュール名、batch_timeはまとめて行う処理の受付時間をそれぞれ指定しています。


```diff_elixir:lib/classification/application.ex
defmodule Classification.Application do
  @moduledoc false

  use Application

  @impl true
  def start(_type, _args) do
    children = [
      # Start the Telemetry supervisor
      ClassificationWeb.Telemetry,
      # Start the PubSub system
      {Phoenix.PubSub, name: Classification.PubSub},
      # Start Finch
      {Finch, name: Classification.Finch},
      # Start the Endpoint (http/https)
-      ClassificationWeb.Endpoint
+      ClassificationWeb.Endpoint,
+      {Nx.Serving,
+       serving: Classification.Worker.build_model(),
+       name: Classification.Serving,
+       batch_timeout: 100}
    ]

    # See https://hexdocs.pm/elixir/Supervisor.html
    # for other strategies and supported options
    opts = [strategy: :one_for_one, name: Classification.Supervisor]
    Supervisor.start_link(children, opts)
  end
  ...
end
```

application.exを更新したら phoenixを再起動しましょう

## LiveViewからWorkerを使用する
推論結果を表示する変数も追加します

```diff_elixir:lib/classification_web/live/page_live.ex
defmodule ClassificationWeb.PageLive do
  use ClassificationWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    socket
    |> assign(:upload_file, nil)
    |> assign(:filename, nil)
+   |> assign(:ans, "")
+   |> assign(:score, "")
    |> allow_upload(
      :image,
      accept: ~w(.jpg .jpeg .png),
      progress: &handle_progress/3,
      auto_upload: true
    )
    |> then(&{:ok, &1})
  end
  ...
end
```

```diff_elixir:lib/classification_web/live/page_live.html.heex
<div class="flex h-screen">
  ...
  <%= if @upload_file do %>
    <figure>
      <img
        alt=""
        class="w-full ml-4"
        src={"data:image/png;base64,#{Base.encode64(@upload_file)}"}
      />
      <figcaption>File Name:<%= @filename %></figcaption>
+     <h1><%= "Anser:#{@ans}" %></h1>
+     <h1><%= "Score: #{@score}" %></h1>
    </figure>      
  <% end %>
</div>
```

最初に作ったサイドバーにボタンを追加します

```diff_elixir:lib/classification_web/live/page_live.html.heex
<div class="flex h-screen">
  <div>
    Actions
+    <div class="flex flex-col gap-2">
+      <button
+        class="rounded-lg bg-blue-500 px-5 py-2.5 text-center text-white"
+        phx-click="predict"
+      >Predict</button>
+      <button 
+        class="rounded-lg border bg-white px-5 py-2.5 text-center text-gray-700"
+        phx-click="clear"
+      >Clear</button>
+    </div>
  </div>
  ...
</div>
```

各イベントを実装します

predictは以下のようなことやっています
- `StbImage.read_binary!`でバイナリをStbImage形式のデータに変換
- `StbImage.to_nx`でStbImage形式のデータををNx形式のデータに変換
- `Nx.Serving.batched_run`でApplicationで指定したServingにデータを渡して分類のリクエストに追加する
- `分類結果をassign`

clearはassignされた関連データを全て初期化しています

```diff_elixir:lib/classification_web/live/page_live.ex
defmodule ClassificationWeb.PageLive do
  use ClassificationWeb, :live_view

  ...
  @impl true
  def handle_event("upload", _params, socket) do
    {:noreply, socket}
  end

+  def handle_event("predict", _params, socket) do
+    tensor =
+      socket.assigns.upload_file
+      |> StbImage.read_binary!()
+      |> StbImage.to_nx()
+
+    %{predictions: [%{label: ans, score: score}]} =
+      Nx.Serving.batched_run(Classification.Serving, tensor)
+
+    socket
+    |> assign(:ans, ans)
+    |> assign(:score, score)
+    |> then(&{:noreply, &1})
+  end
+
+  def handle_event("clear", _params, socket) do
+    socket
+    |> assign(:upload_file, nil)
+    |> assign(:filename, "")
+    |> assign(:ans, "")
+    |> assign(:score, "")
+    |> then(&{:noreply, &1})
+  end

  ...
end
```

これで完成になります

# 実行結果

![スクリーンショット 2023-07-25 23.30.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/3255822b-523e-0651-e5ed-12caea2f6877.png)

アップロードした画像が bee eater(ハチクイ)という鳥と無事分類されました


# 最後に

いかがでしたでしょうか？
大半はLiveViewから画像をどう渡すかで、機械学習の部分は20行にも満たない小さなコードでした
Bumblebeeは他にも色々なモデルが使えるのでぜひいろいろ試してみてください
本記事は以上になりますありがとうございました

# 参考
https://github.com/elixir-nx/bumblebee
https://github.com/elixir-nx/axon_onnx
https://github.com/cocoa-xu/evision
https://github.com/elixir-nx/ortex
https://github.com/acalejos/exgboost
https://github.com/elixir-nx/stb_image
https://github.com/elixir-nx/nx/tree/main/exla
https://huggingface.co/
https://hexdocs.pm/phoenix_live_view/uploads.html#content