はじめに
この記事はElixirでマルチプラットフォームなネィティブアプリケーションをLiveViewベースで作れるElixirDesktopというライブラリで以下のことを実装した記事になります
- プロジェクトをつくる
- saasからTailwind,PetalComponentに差し替える
- Axonで画像分類を実装する
ElixirDesktopとは
WxWidgetとPhoenixを組み合わせてwebサーバーを起動することなくスタンドアローンでネイティブアプリを起動・実装できるライブラリです
DBにはSqlite3を使います
また、自分はまだ成功していませんが、Android, iOSでも同一のコードでネィティブアプリを起動することができます
iOS: https://github.com/elixir-desktop/ios-example-app
Android: https://github.com/elixir-desktop/android-example-app
PetalCompoentとは
PETALとは
Phoenix
Elixir
Tailwind
AlpineJS
LiveView
の技術スタックの頭文字です
Tailwindで実装したLiveViewComponentのUIコンポーネント集になります
環境
Elixir 1.13以降
Erlang 24以降 wxwidgetsを含む
プロジェクトの作成
では早速作っていこうと思いますが、まだmix phx.new
に該当するコマンドは無いようなので
サンプルプロジェクトをgit cloneしてきます
git clone https://github.com/elixir-desktop/desktop-example-app.git desktop
cd desktop
次にassetsをインストールします
cd assets
npm install
準備が整ったので起動しましょう
mix deps.get
iex -S mix
ライブラリのインストール
画像分類に必要なものとPetalComponentsをインストールしていきます
petalで0.17以降のLiveViewが要求されるのでバージョンをあげます
defmodule Todo.MixProject do
use Mix.Project
...
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:ecto_sqlite3, "~> 0.7"},
# {:desktop, path: "../desktop"},
{:desktop, github: "elixir-desktop/desktop", tag: "v1.4.0"},
# Phoenix
{:phoenix, "~> 1.6"},
{:phoenix_live_view, "~> 0.17.4"}, # 0.16から0.17.4に変更
{:phoenix_html, "~> 3.0"},
{:phoenix_live_reload, "~> 1.3", only: [:dev]},
{:gettext, "~> 0.18"},
{:plug_cowboy, "~> 2.5"},
{:jason, "~> 1.2"},
# Assets
{:esbuild, "~> 0.2", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.1", runtime: Mix.env() == :dev}, # 追加
{:petal_components, "~> 0.17"}, # 追加
# Credo
{:credo, "~> 1.5", only: [:dev, :test], runtime: false},
# 以下追加
# ml
{:axon_onnx, github: "elixir-nx/axon_onnx"},
{:exla, "~> 0.3.0-dev", github: "elixir-nx/nx", sparse: "exla"},
{:nx, "~> 0.3.0-dev", [env: :prod, git: "https://github.com/elixir-nx/nx.git", sparse: "nx", override: true]},
{:stb_image, "~> 0.4.0"}
]
end
end
追加したので更新します
mix deps.get
Tailwind,PetalComponentセットアップ
tailwindはこちらを参考にしてください
完了したらconfigを以下のようにします
https://petal.build/components#install_tailwind
const colors = require("tailwindcss/colors");
module.exports = {
content: [
"../lib/*_web.ex",
"../lib/*_web/**/*.*ex",
"./js/**/*.js",
"../deps/petal_components/**/*.*ex",
],
darkMode: "class",
theme: {
extend: {
colors: {
primary: colors.blue,
secondary: colors.pink,
},
},
},
plugins: [require("@tailwindcss/forms")],
};
次に ViewHelperにPetalComponentを追加します
defmodule TodoWeb do
...
defp view_helpers do
quote do
# Use all HTML functionality (forms, tags, etc)
use Phoenix.HTML
use PetalComponents # 追加
# Import LiveView helpers (live_render, live_component, live_patch, etc)
import Phoenix.LiveView.Helpers
# Import basic rendering functionality (render, render_layout, etc)
import Phoenix.View
import TodoWeb.ErrorHelpers
import TodoWeb.Gettext
end
end
end
次に元のCSSとSASSの設定を削除していきます
config :esbuild,
version: "0.12.18",
default: [
args: ~w(js/app.js --bundle --target=es2016 --outdir=../priv/static/assets),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
+ config :tailwind,
+ version: "3.1.6",
+ default: [
+ args: ~w(
+ --config=tailwind.config.js
+ --input=css/app.css
+ --output=../priv/static/assets/app.css
+ ),
+ cd: Path.expand("../assets", __DIR__)
+ ]
- config :dart_sass,
- version: "1.39.0",
- default: [
- args: ~w(css/app.scss ../priv/static/assets/app.css),
- cd: Path.expand("../assets", __DIR__)
- ]
config :todo_app, TodoWeb.Endpoint,
debug_errors: true,
code_reloader: true,
check_origin: false,
watchers: [
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
+ tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
- sass:
- {DartSass, :install_and_run,
- [:default, ~w(--embed-source-map --source-map-urls=absolute --watch)]}
],
# Watch static and templates for browser reloading.
live_reload: [
patterns: [
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/todo_app/.*(ee?x)$",
~r"lib/todo_web/(live|views)/.*(ex)$",
~r"lib/todo_web/templates/.*(eex)$"
]
]
defmodule Todo.MixProject do
use Mix.Project
...
defp aliases do
[
"assets.deploy": [
"phx.digest.clean --all",
"esbuild default --minify",
+ "tailwind default --minify",
- "sass default --no-source-map --style=compressed",
"phx.digest"
]
]
end
end
app.scssをapp.cssにリネームします
/* This file is for your main application css. */
@use "../node_modules/nprogress/nprogress.css";
- @import "./todo.scss";
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
最後にレイアウトファイルをPetalComponentに対応させます
rename live.html.leex -> live.html.heex
<.container>
<.alert
color="info"
class="mb-5"
label={live_flash(@flash, :info)}
phx-click="lv:clear-flash"
phx-value-key="info"
/>
<.alert
color="danger"
class="mb-5"
label={live_flash(@flash, :error)}
phx-click="lv:clear-flash"
phx-value-key="error"
/>
<%= @inner_content %>
</.container>
これでcssをsassからTailwindに変更できました!
画像分類部分の実装
基本的にこちらをベースにしていきます
ファイルをアップロードして画像を表示させる
defmodule TodoWeb.TodoLive do
@moduledoc """
Main live view of our TodoApp. Just allows adding, removing and checking off
todo items
"""
use TodoWeb, :live_view
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:list, File.read!("model/classlist.json") |> Jason.decode!())
|> assign(:upload_file, nil)
|> assign(:ans, [])
|> allow_upload(
:image,
accept: :any,
chunk_size: 6400_000,
progress: &handle_progress/3,
auto_upload: true
)
{:ok, socket}
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()
{:noreply, assign(socket, :upload_file, upload_file)}
end
@impl true
def handle_event("validate", _params, socket) do
{:noreply, socket}
end
end
画像をドラッグアンドドロップしたら、バイナリデータにして imgタグのsrcにbase64でエンコードして表示させます
残りのイベントは後ほど実装します
<div class="flex items-start justify-between pt-4 h-screen">
<div class="relative w-60 mr-4 h-full mb-4">
<.card class="h-5/6">
<.card_content category="Actions">
<.button class="m-1 w-full" label="Detect" phx-click="detect" variant="shadow" />
<.button class="m-1 w-full" label="Clear" color="white" variant="shadow" phx-click="clear" />
<%= for {ans, index} <- Enum.with_index(@ans) do %>
<h5><%= "#{index + 1}: " <> Map.get(@list, to_string(ans)) %></h5>
<% end %>
</.card_content>
</.card>
</div>
<div class="flex flex-col w-full pr-4 h-full">
<.card class="h-5/6">
<.card_content class="p-4">
<div style={ if @upload_file != nil, do: "display:none" }>
<form phx-change="validate" >
<div class="border border-dashed border-gray-500 relative"
phx-drop-target={@uploads.image.ref}
>
<%= live_file_input @uploads.image, class: "cursor-pointer relative block opacity-0 w-full h-full p-20 z-50" %>
<div class="text-center p-10 absolute top-0 right-0 left-0 m-auto">
<h4>
Drop files anywhere to upload
<br/>or
</h4>
<p class="">Select Files</p>
</div>
</div>
</form>
</div>
<%= if @upload_file do %>
<div class="w-full h-full mb-4">
<img alt="" class="object-cover" src={"data:image/png;base64,#{Base.encode64(@upload_file)}"}/></div>
<% end %>
</.card_content>
</.card>
</div>
</div>
Nxのセットアップ
Axonの計算部分を行うNxは設定をしないとpureElixirで計算を行い大変遅いので
計算部分(Backendという)にgoogle xlaを使うExlaをセットします
config :nx, :default_backend, EXLA.Backend
config :nx, :default_defn_options, compiler: EXLA
推論に使うモデルの準備
torchvisonからonnxにコンバートした動作確認済みリストがここにあるので、お好きなモデルを使ってください
今回はsqueezenetを使っています
https://github.com/thehaigo/live_onnx/tree/main/model
推論部分の実装
画像のバイナリファイルを引数にとり
- 前処理
- モデルの読み込み
- 推論の実行
する関数を実装します
defmodule TodoApp.Worker do
require Axon
def detect(binary) do
tensor = preprocess(binary)
{model, params} = AxonOnnx.import("model/squeezenet/model.onnx")
Axon.predict(model, params, tensor)
|> Nx.flatten()
|> Nx.argsort()
|> Nx.reverse()
|> Nx.slice([0], [5])
|> Nx.to_flat_list()
end
def preprocess(binary) do
{:ok, image} = StbImage.from_binary(binary)
{:ok, image} = StbImage.resize(image, 224, 224)
# 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)
end
end
正規化ですが精度を上げるように 全体からmeanの値を引いてstbの値で除算を行います
https://pytorch.org/vision/stable/models.html#models-and-pre-trained-weights
detectボタンを押したときに推論の開始
celarボタンを押したときに答えと画像を初期化する処理を書いて完了です
defmodule TodoWeb.TodoLive do
@moduledoc """
Main live view of our TodoApp. Just allows adding, removing and checking off
todo items
"""
use TodoWeb, :live_view
...
@impl true
def handle_event("detect",_params,%{assigns: %{upload_file: binary}} = socket) do
{:noreply, assign(socket, :ans, TodoApp.Worker.detect(binary))}
end
@impl true
def handle_event("clear", _params, socket) do
{
:noreply,
socket
|> assign(:upload_file, nil)
|> assign(:ans, [])
}
end
end
DEMO
最後に
無事ElixirDesktopでもAxon, Nx, Exlaが動きました!
まだ ElixirDesktopはパッケージングは実装されていませんが
Livebookのデスクトップアプリケーション版がElixirDesktopをベースにしたAppBuilderというモジュールを作成し、使用しているようなので近いうちに実装されるかもしれません
これを応用して
スマートフォン等のエッジで完結する図鑑アプリ
YOLOなどの物体検知を読み込んでアノテーションツール
果てはNodeでつないでスマホで分散学習
など夢が広がりますね!
本記事は以上になりますありがとうございました
コード
参考
https://petal.build/components
https://github.com/elixir-desktop/desktop
https://github.com/elixir-desktop/ios-example-app
https://github.com/elixir-desktop/android-example-app
https://github.com/elixir-desktop/desktop-example-app
https://qiita.com/hayabusa333/items/b8805d7274f60e0deb4d
https://github.com/thehaigo/live_onnx
https://github.com/thehaigo/live_onnx/tree/main/model
https://qiita.com/the_haigo/items/8f5157a185e08f6d6bce
https://pytorch.org/vision/stable/models.html#models-and-pre-trained-weights
https://hexdocs.pm/exla/EXLA.html
https://tailwindcss.com/
https://tailwindcss.com/docs/guides/phoenix
https://tailwindcomponents.com/component/file-upload-drop-zone
https://hexdocs.pm/phoenix_live_view/0.17.0/uploads.html#content