はじめに
この記事は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を追加します
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行目を削除しておくこと
@import "./phoenix.css";
@import "https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css"; // 追加
...
page_live.ex
最初にブランクページを作ります
defmodule LiveCanvasWeb.PageLive do
use LiveCanvasWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{
:ok,
socket
}
end
end
<div>
page live
</div>
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
イベント
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でのアップロードもできます
<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>
このままだと画像処理がしにくいのでCanvasで表示します
アップロードした画像をCanvasで描画
通常サーバーサイド側からJSを実行するのはめんどくさいのですが、LiveViewではJS Hooksという機能があり
Elixir側からpush_eventという関数を実行する事によって簡単にJSを実行することができます
LiveViewマウント時にcanvasオブジェクトとコンテキストを作成して
Elixir側からdrawイベントを実行された際にDataURL形式で画像を生成してCanvasに描画します
その際に幅と高さの最大値を512にしています
処理前と処理後を並べるのでCanvasを2つ用意します
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に追加します
...
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"
を追加する必要があります
<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した画像データを送信して描画しています
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の有無で判断するようにします
<div>
<div class="columns is-centered" style={ if @upload_file != nil, do: "display:none" }>
<form phx-change="validate" >
...
</form>
</div>
...
</div>
結果は上の動画と同じなので割愛
Remove
サイドバー領域を確保し、removeボタンを追加
<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も初期化します
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します
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},
...
]
ソートされていないのでこのままでは使えませんのできれいにします
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;
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
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
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
ネガポジ反転
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
<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>
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
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);
});
}
}
グレースケール
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
<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>
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
動作確認
drag and dorp upload, ネガポジ反転、グレースケール、画像の削除ができることが確認できました
EXLAで高速化
Nxはpure Elixirなのでそこまで早くないのですが、tensorflowのXLAを使用して高速化を行ったのがExlaになります。
Exlaを使用する方法はdefn関数を実行する関数のバックエンドをexlaにするだけで完了します
GPUモードもあるのですが、 MacなのでCPUモードで行います
import Config
config :exla, :clients, default: [platform: :host, memory_fraction: 0.8]
...
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
上の動画に比べてだいぶ高速に実行できていますね
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