LoginSignup
14
1

More than 1 year has passed since last update.

LiveViewとNxで画像処理

Last updated at Posted at 2021-12-12

はじめに

この記事はfukuoka.ex Elixir/Phoenix Advent Calendar 2021の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
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

ファイルアップロード

ファイルアップロードに必要な項目は3つで
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でのアップロードもできます

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

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

アップロードした画像をCanvasで描画

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

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

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"を追加する必要があります

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の有無で判断するようにします

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ボタンを追加

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

2つの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で取得するデータはここによると以下のようになっているそうです

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

こんなデータです

[
  {"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

#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
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
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

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

EXLAで高速化

Nxはpure Elixirなのでそこまで早くないのですが、tensorflowのXLAを使用して高速化を行ったのが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
上の動画に比べてだいぶ高速に実行できていますね
invertとgrayscale両方計測したところ、invertは13倍、grayscaleは6倍ほど早くなっています
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/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

14
1
0

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
14
1