LiveViewとNxで画像処理

# はじめに
この記事は[fukuoka.ex Elixir／Phoenix Advent Calendar 2021](https://qiita.com/advent-calendar/2021/fukuokaex)の12日目の記事です。
11日目は @kikuyuta さんの記事でした

行列計算NxとTensorFlowのXLAでNxの行列計算を高速化するExlaを使って
LiveView上で画像処理をします

# 環境
Operating System: macOS
CPU Information: Apple M1
Elixir 1.12.3
Erlang 24.1.5
Phoenix 1.6.2


# setup 
DBは使わないので`--no-ecto`オプションでphoenixプロジェクトを作成します

```
mix phx.new live_yolo --no-ecto
cd live_yolo
```

ライブラリに行列計算ライブラリNxとNx高速化ライブラリExlaを追加します

```mix.exs
defmodule LiveCanvas.MixProject do
...
  defp deps do
    [
      ...
      {:plug_cowboy, "~> 2.5"},
      {:exla, "~> 0.1.0-dev", github: "elixir-nx/nx", sparse: "exla"},
      {:nx, "~> 0.1.0-dev", github: "elixir-nx/nx", sparse: "nx", override: true}
    ]
  end
...
end
```



# 準備
スタイリングにはbulmaを使います
miligramと競合する箇所があるのでphoxnix.cssの9行目を削除しておくこと

```assets/css/app.css
@import "./phoenix.css";
@import "https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css"; // 追加
...
```


# page_live.ex
最初にブランクページを作ります

```live/live_canvas_wev/live/page_live.ex
defmodule LiveCanvasWeb.PageLive do
  use LiveCanvasWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {
      :ok,
      socket
    }
  end
end
```

```leex:lib/live_canvas_web/live/page_live.html.heex
<div>
    page live
</div>
```

```lib/live_canvas_web/router.ex
defmodule LiveCanvasWeb.Router do
...
  scope "/", LiveYoloWeb do
    pipe_through :browser

    live "/", PageLive, :index
  end
...
end
```

## ファイルアップロード
ファイルアップロードに必要な項目は３つで
mount時にフィアルアップロードの設定を行う`allow_upload`
LiveView用のfile inputの `live_file_input`
formの変更検知を行う`validate`イベント

```live/live_canvas_wev/live/page_live.ex
defmodule LiveCanvasWeb.PageLive do
  use LiveCanvasWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {
      :ok,
      socket
      |> allow_upload(:image, accept: :any)
    }
  end

  @impl true
  def handle_event("validate", _params, socket) do
    {:noreply, socket}
  end
end
```
allow_uploadを追加するとファイルアップロード関連の変数が`@uploads`でアクセスできます
@uploads.image.entriesでformにセットされた画像にアクセスできるので `live_img_preview`で表示することができます
またphx-drop-targetを使うことでdrag and dropでのアップロードもできます

```leex:lib/live_canvas_web/live/page_live.html.heex
<div>
  <div class="columns is-centered" style={ if @uploads.image.entries != [], 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>
  <%= for entry <- @uploads.image.entries do %>
    <figure>
      <%= live_img_preview entry %>
      <figcaption><%= entry.client_name %></figcaption>
    </figure>
  <% end %>
</div>
```

[![Image from Gyazo](https://i.gyazo.com/924370f4c04a6ed6f3be65ebf8b73b5d.gif)](https://gyazo.com/924370f4c04a6ed6f3be65ebf8b73b5d)

このままだと画像処理がしにくいのでCanvasで表示します

## アップロードした画像をCanvasで描画
通常サーバーサイド側からJSを実行するのはめんどくさいのですが、LiveViewではJS Hooksという機能があり
Elixir側からpush_eventという関数を実行する事によって簡単にJSを実行することができます

LiveViewマウント時にcanvasオブジェクトとコンテキストを作成して
Elixir側からdrawイベントを実行された際にDataURL形式で画像を生成してCanvasに描画します
その際に幅と高さの最大値を５１２にしています
処理前と処理後を並べるのでCanvasを２つ用意します

```assets/js/hooks.js
let Hooks = {};
Hooks.Canvas = {
  mounted() {
    let canvas = document.getElementById("canvas");
    let context = canvas.getContext("2d");
    let canvas2 = document.getElementById("canvas2");
    let context2 = canvas2.getContext("2d");
    let img = new Image();

    this.handleEvent("draw", (data) => {
      img.src = `data:${data.mime};base64,${data.src}`;
      img.onload = () => {
        let width = img.width < 512 ? img.width : 512;
        let height = img.height < 512 ? img.height : 512;
        canvas.width = width;
        canvas2.width = width;
        canvas.height = height;
        canvas2.height = height;
        context.drawImage(img, 0, 0);
      };
    });
  },
};
export default Hooks;
```
hooksを参照できるように liveSocketに追加します

```assets/js/app.js
...
import Hooks from "./hooks"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}})
...
```
id属性を付けた要素に使用するphx-hookを指定します
canvasやsvgはlive_viewに変更検知されて再レンダリングされないように`phx-update="ignore"`を追加する必要があります

```leex:lib/live_canvas_web/live/page_live.html.heex
<div>
...
  <div class="columns">
    <div id="canvas" class="column is-half" phx-hook="Canvas">
      <canvas phx-update="ignore"></canvas>
    </div>
    <div class="column is-half">
      <canvas id="canvas2" phx-update="ignore"></canvas>
    </div>
  <div>
</div>
```

LiveView側
allow_upload option

- chunk_size -> progress内でゴニョゴニョする際にデフォルトだと足りないため x100 詳細はドキュメント読んでもよくわからなかった
- progress -> upload時に実行する関数
- auto_upload -> ファイルが選択 or dndされた時点でアップロードされ progressで指定した関数が実行されます

handle_progress
progressで指定する関数
アップロードしたファイルは一時ファイルでprogressの関数の実行後すぐ削除されるので`consume_uploaded_entries`関数内で File.cp!なりクラウドストレージにアップロードする必要があります
今回はバイナリデータとしてsocketにアサインしています
最後に`push_event("draw")`で Canvasにbase64 encodeした画像データを送信して描画しています

```lib/live_canvas_web/live/page_live.ex
defmodule LiveCanvasWeb.PageLive do
  use LiveCanvasWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {
      :ok,
      socket
      |> assign(:upload_file, 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, mime} =
      consume_uploaded_entries(socket, :image, fn %{path: path}, entry ->
        {:ok, file} = File.read(path)
        {file, entry.client_type}
      end)
      |> List.first()
    {
      :noreply,
      socket
      |> assign(:upload_file, upload_file)
      |> push_event("draw", %{src: Base.encode64(upload_file), mime: mime})
    }
  end

  @impl true
  def handle_event("validate", _params, socket) do
    {:noreply, socket}
  end
end
```
file_inputの表示はauto_uploadの場合はentriesが空でない状態が一瞬なので upload_fileの有無で判断するようにします

```leex:lib/live_canvas_web/live/page_live.html.heex
<div>
  <div class="columns is-centered" style={ if @upload_file != nil, do: "display:none" }>
    <form phx-change="validate" >
      ...
    </form>
  </div>
  ...
</div>
```

結果は上の動画と同じなので割愛

## Remove
サイドバー領域を確保し、removeボタンを追加

```leex:lib/live_canvas_web/live/page_live.html.heex
<div class="columns">
  <aside class="column is-2 menu">
    <p class="menu-label">Actions</p>
    <ul class="menu-list">
      <li><button class="button is-fullwidth" phx-click="remove">remove</button></li>
    </ul>
  </aside>

  <div class="column is-10">
    <div class="columns is-centered" style={ if @upload_file != nil, do: "display:none" }>
      <form phx-change="validate" >
      ...
      </form>
    </div>
    ...
  </div>
</div>
```

remove実装
removeはassignを削除してcanvasも初期化します

```lib/live_canvas_web/live/page_live.ex
defmodule LiveCanvasWeb.PageLive do
  use LiveCanvasWeb, :live_view

  ...
  @impl true
  def handle_event("remove", _params, socket) do
    {
      :noreply,
      socket
      |> assign(upload_file: nil)
      |> push_event("remove", %{})
    }
  end
end
```
２つのCanvas全体をclearReactします

```assets/js/hooks.js
let Hooks = {};
Hooks.Canvas = {
  mounted() {
    ...

    this.handleEvent("remove", () => {
      context.clearRect(0, 0, canvas.width, canvas.height);
      context2.clearRect(0, 0, canvas.width, canvas.height);
    });
  },
};
export default Hooks;
```

これで準備が整ったので画像処理を行っていきます

canvasで描画しているデータを`getImageData`で取得してElixirに投げます
getImageDataで取得するデータは[ここ](https://developer.mozilla.org/ja/docs/Web/API/Canvas_API/Tutorial/Pixel_manipulation_with_canvas)によると以下のようになっているそうです

> data プロパティは、生のピクセルデータを参照するためにアクセス可能な Uint8ClampedArray を返します。それぞれのピクセルは 4 つの 1 バイト値 (赤、緑、青、アルファの順、すなわち "RGBA" 形式) で表します。また、それぞれの色成分は 0 から 255 の間の整数で表します。さらに、それぞれの成分は配列内で連続した添字が割り当てられており、左上のピクセルの赤色成分が配列の添え字 0 になります。配列の中でピクセルは左から右へ進み、さらに下へと進んでいきます。

こんなデータです

```js
[
  {"864714", 61},
  {"858981", 25},
  {"753417", 99},
  {"985846", 59},
  {"993186", 117},
  {"486748", 234},
  {"149540", 231},
  {"948539", 255},
  ...
]
```

ソートされていないのでこのままでは使えませんのできれいにします

```assets/js/hooks.js
let Hooks = {};
Hooks.Canvas = {
  mounted() {
    this.handleEvent("draw", (data) => {
      img.src = `data:${data.mime};base64,${data.src}`;
      img.onload = () => {
        ...
        let pixel = context.getImageData(
          0,
          0,
          canvas.clientWidth,
          canvas.clientHeight
        );
        this.pushEvent("drew", pixel);
      };
    });
  },
};

export default Hooks;
```

```lib/live_canvas_web/live/page_live.ex
defmodule LiveCanvasWeb.PageLive do
  use LiveCanvasWeb, :live_view
  ...
  def handle_event("drew", %{"data" => pixel}, socket) do
    pixel =
      pixel
      |> Map.to_list()
      |> Enum.map(fn {k, v} -> {String.to_integer(k), v} end)
      |> Enum.sort()
      |> Enum.map(fn {_k, v} -> v end)
      |> Nx.tensor()

    {row} = Nx.shape(pixel)
    pixel = pixel |> Nx.reshape({div(row, 4), 4})
    {:noreply, socket |> assign(:org_pixel, pixel)}
  end
  ...
end
```
上記はだいたいこんな処理をしています

Map 
|> {key(string), val(integer)}のタプルリスト 
|> {key(integer), val(integer)}のタプルリスト
|> タプルのkeyでsort
|> タプルのvalのみ抽出
|> Nx.tensor化
|> [r, g, b, a] の配列にreshape

```elixir
#Nx.Tensor<
  s64[262144][4]
  [
    [226, 137, 125, 255],
    [226, 137, 125, 255], 
    [223, 137, 133, 255],
    [223, 136, 128, 255],
    [226, 138, 120, 255],
    [226, 129, 116, 255],
    [228, 138, 123, 255],
    [227, 134, 124, 255],
    [227, 140, 127, 255],
    [225, 136, 119, 255],
    [228, 135, 126, 255],
    [225, 134, 121, 255],
    [223, 130, ...],
    ...
  ]
>
```

これで画像処理をやりやすい形にできました

# 画像処理 worker

```lib/live_canvas/worker.ex
defmodule LiveCanvas.Worker do
  use GenServer
  import Nx.Defn

  @name __MODULE__
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, :ok, opts)
  end

  def init(:ok) do
    {:ok, []}
  end
end
```

```lib/live_canvas/application.ex
defmodule LiveCanvas.Application do
  # See https://hexdocs.pm/elixir/Application.html
  # for more information on OTP Applications
  @moduledoc false

  use Application

  @impl true
  def start(_type, _args) do
    children = [
      ...
      {LiveCanvas.Worker, [name: LiveCanvas.Worker]} # 追加
    ]

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

# ネガポジ反転

```lib/live_canvas/worker.ex
defmodule LiveCanvas.Worker do
  ...
  def invert(pixel) do
    GenServer.call(@name, {:invert, pixel})
  end

  def handle_call({:invert, pixel}, _from, state) do
    rgb = reverse(pixel)
    a = Nx.slice_axis(pixel, 4, 1, -1)
    pixel =
      Nx.concatenate([rgb, a], axis: -1)
      |> Nx.to_flat_list()
    {:reply, pixel, state}
  end

  defn reverse(pixel) do
    pixel
    |> Nx.slice_axis(0, 3, -1)
    |> Nx.map(fn x -> 255 - x end)
  end
end
```

```leex:lib/live_canvas_web/live/page_live.html.heex
<div class="columns">
  <aside class="column is-2 menu">
    <p class="menu-label">Actions</p>
    <ul class="menu-list">
      <li><button class="button is-fullwidth mb-3" phx-click="invert">invert</button></li> 追加
      <li><button class="button is-fullwidth" phx-click="remove">remove</button></li>
    </ul>
  </aside>
  ...
</div>
```


```lib/live_canvas_web/live/page_live.ex
defmodule LiveCanvasWeb.PageLive do
  use LiveCanvasWeb, :live_view
  ...
  @impl true
  def handle_event("invert", _params, %{assigns: %{org_pixel: org_pixel}} = socket) do
    pixel = LiveCanvas.Worker.invert(org_pixel)
    {:noreply, push_event(socket, "manipulate", %{pixel: pixel})}
  end
end
```

```assets/js/hooks.js
let Hooks = {};
Hooks.Canvas = {
  mounted() {
    ...
    this.handleEvent("manipulate", (data) => {
      let imageData = new ImageData(
        new Uint8ClampedArray(data.pixel),
        canvas.clientWidth,
        canvas.clientHeight
      );
      context2.putImageData(imageData, 0, 0);
    });
  }
}
```

# グレースケール
```lib/live_canvas/worker.ex
defmodule LiveCanvas.Worker do
  ...
  def grayscale(pixel) do
    GenServer.call(@name, {:grayscale, pixel})
  end

  def handle_call({:grayscale, pixel}, _from, state) do
    gray_pixel = gray(pixel)
    rgb =
      gray_pixel
      |> Nx.to_flat_list()
      |> Enum.map(fn avg -> [avg, avg, avg] end)
      |> Nx.tensor()

    a = Nx.slice_axis(pixel, 4, 1, -1)
    pixel =
      Nx.concatenate([rgb, a], axis: -1)
      |> Nx.to_flat_list()
    {:reply, pixel, state}
  end

  defn gray(pixel) do
    pixel
    |> Nx.slice_axis(0, 3, -1)
    |> Nx.mean(axes: [-1])
    |> Nx.round()
  end
end
```

```leex:lib/live_canvas_web/live/page_live.html.heex
<div class="columns">
  <aside class="column is-2 menu">
    <p class="menu-label">Actions</p>
    <ul class="menu-list">
      <li><button class="button is-fullwidth mb-3" phx-click="invert">invert</button></li>
      <li><button class="button is-fullwidth mb-3" phx-click="grayscale">grayscale</button></li> 追加
      <li><button class="button is-fullwidth" phx-click="remove">remove</button></li>
    </ul>
  </aside>
  ...
</div>
```


```lib/live_canvas_web/live/page_live.ex
defmodule LiveCanvasWeb.PageLive do
  use LiveCanvasWeb, :live_view
  ...
  @impl true
  def handle_event("grayscale", _params, %{assigns: %{org_pixel: org_pixel}} = socket) do
    pixel = LiveCanvas.Worker.grayscale(org_pixel)
    {:noreply, push_event(socket, "manipulate", %{pixel: pixel})}
  end
end
```

# 動作確認
[![Image from Gyazo](https://i.gyazo.com/83247a3645a537037e2928ce01c2a560.gif)](https://gyazo.com/83247a3645a537037e2928ce01c2a560)

drag and dorp upload, ネガポジ反転、グレースケール、画像の削除ができることが確認できました

# EXLAで高速化
Nxはpure Elixirなのでそこまで早くないのですが、tensorflowの[XLA](https://www.tensorflow.org/xla?hl=ja)を使用して高速化を行ったのがExlaになります。
Exlaを使用する方法はdefn関数を実行する関数のバックエンドをexlaにするだけで完了します
GPUモードもあるのですが、 MacなのでCPUモードで行います

```config/config.exs
import Config

config :exla, :clients, default: [platform: :host, memory_fraction: 0.8]
...
```

```lib/live_canvas/worker.ex
defmodule LiveCanvas.Worker do
  use GenServer
  import Nx.Defn
  ...
  @defn_compiler {EXLA, [platform: :host]}
  defn reverse(pixel) do
    pixel
    |> Nx.slice_axis(0, 3, -1)
    |> Nx.map(fn x -> 255 - x end)
  end

  @defn_compiler {EXLA, [platform: :host]}
  defn gray(pixel) do
    pixel
    |> Nx.slice_axis(0, 3, -1)
    |> Nx.mean(axes: [-1])
    |> Nx.round()
  end
end
```

[![Image from Gyazo](https://i.gyazo.com/c6f3b4e5c9e9e44e2ac44f27f975e8dc.gif)](https://gyazo.com/c6f3b4e5c9e9e44e2ac44f27f975e8dc)
上の動画に比べてだいぶ高速に実行できていますね
invertとgrayscale両方計測したところ、invertは13倍、grayscaleは６倍ほど早くなっています
CPUでこのくらいなのでGPUだともっと早くなりそうですね
Exlaは前はコンパイルにかなりかかっていましたが現在はコンパイル済みをダウンロードしてくれるらしくだいぶ楽になりました

```
invert
exla:  44254
nx   1305142
grayscale
exla: 151269
nx:   572535
```

# 最後に
LiveViewでアップロードした画像をCanvasで表示して、
ピクセルデータをphoenix側にJS Hookで送って
GenServerで動かしているNxで画像処理を行い
Exlaで高速化することができました

ちょっと盛りすぎた感がありますが、本記事は以上になります

次は @the_haigo でLiveViewでGoogleMapAPIを使うになります

# コード
https://github.com/thehaigo/live_canvas


# 参考サイト
https://github.com/elixir-nx/nx/tree/main/nx
https://github.com/elixir-nx/nx/tree/main/exla
https://www.tensorflow.org/xla?hl=ja
https://kuroeveryday.blogspot.com/2017/10/image-filters-with-canvas-algorithm.html
https://developer.mozilla.org/ja/docs/Web/API/Canvas_API/Tutorial/Pixel_manipulation_with_canvas
https://hexdocs.pm/phoenix_live_view/uploads.html#consume-uploaded-entries
https://github.com/elixir-nx/nx