LoginSignup
13
10

More than 1 year has passed since last update.

はじめに

この記事は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

無事起動しました!
スクリーンショット 2022-07-20 14.33.35.png

ライブラリのインストール

画像分類に必要なものとPetalComponentsをインストールしていきます
petalで0.17以降のLiveViewが要求されるのでバージョンをあげます

mix.exs
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

assets/tailwind.config.js
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を追加します

lib/todo_web.ex
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/config.exs
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/dev.exs
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)$"
    ]
  ]
mix.exs
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にリネームします

diff_cssassets/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

lib/todo_web/templates/layout/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に変更できました!

画像分類部分の実装

基本的にこちらをベースにしていきます

ファイルをアップロードして画像を表示させる

lib/todo_web/live/todo_live.ex
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でエンコードして表示させます
残りのイベントは後ほど実装します

lib/todo_web/live/todo_live.html.heex
<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>

83a974e8f9654a504fbf7f482eb54a23.gif

Nxのセットアップ

Axonの計算部分を行うNxは設定をしないとpureElixirで計算を行い大変遅いので
計算部分(Backendという)にgoogle xlaを使うExlaをセットします

config/config.exs
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

推論部分の実装

画像のバイナリファイルを引数にとり

  • 前処理
  • モデルの読み込み
  • 推論の実行
    する関数を実装します
lib/todo_app/worker.ex
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ボタンを押したときに答えと画像を初期化する処理を書いて完了です

lib/todo_web/live/todo_live.ex
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

d803cb000e0518a2950856ef4bfe147a.gif

最後に

無事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

13
10
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
13
10