LoginSignup
8
0

More than 1 year has passed since last update.

Elixir Desktop iOS でカメラ撮影【2023年3月版】

Last updated at Posted at 2023-03-07

はじめに

半年くらい前(2022年9月)に以下の記事を書きましたが、すっかりモバイルから離れていたので改めて最新版で同じことをしてみます

Erlang や Elixir は asdf でインストールしています

実行環境

  • 開発環境・デスクトップ版実行環境

    • macOS Ventura 13.2.1
    • asdf v0.11.2
  • モバイル版実行環境

    • iPhone SE 第2世代
    • iOS 16.3.1

実装コード

デスクトップ用

iOS用

デスクトップ版

デスクトップ版準備

公式のサンプルコードをベースにしていきます

git clone https://github.com/elixir-desktop/desktop-example-app.git
cd desktop-example-app

Elixir や Erlang について、 .tool-versions に記載されたバージョンをインストールします

asdf plugin-add erlang
asdf plugin-add elixir
asdf install

画像処理に使うライブラリ(Nx)を追加します

他との兼ね合いの関係でバージョンは 0.4.2 に固定します

mix.exs

...
  # Run "mix help deps" to learn about dependencies.
  defp deps do
    deps_list = [
      ...

      # Credo
-     {:credo, "~> 1.5", only: [:dev, :test], runtime: false}
+     {:credo, "~> 1.5", only: [:dev, :test], runtime: false},
+
+     # Image Processing
+     {:nx, "== 0.4.2"}
    ]
...

Elixir 側の依存ライブラリを取得します

mix deps.get

JS 側の依存ライブラリを取得します

cd assets
npm install
cd ..
mix assets.deploy

以前の記事でやっていた iOS の場合だけ bridge を追加する、というような対応は既に取り込まれています

デスクトップ版実装

テンプレートファイルを todo_live.html.leex から todo_live.html.heex にリネームし、
内容を以下のように変更します

lib/todo_web/live/todo_live.html.heex

<video id="local-video" playsinline autoplay muted width="400"></video>
<button id="shutter" class="button" phx-hook="TakePicture">Take a pickture</button>
<canvas id="canvas" style="display: none"></canvas>
<canvas id="canvas-gray"></canvas>

JS 側の処理を変更します

assets/js/app.js

追加処理

...
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")

// video にカメラ映像を流す
async function initStream() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({video: true, width: "1280"})
    localStream = stream
    document.getElementById("local-video").srcObject = stream
  } catch (e) {
    console.log(e)
  }
}

let Hooks = {}

// 写真撮影用フック
Hooks.TakePicture = {
  mounted() {
    initStream()

    const width = 400;
    const video = document.getElementById("local-video");
    const canvas = document.getElementById("canvas");
    const canvasGray = document.getElementById("canvas-gray");

    canvas.width = width;
    canvasGray.width = width;

    const context = canvas.getContext("2d");
    const contextGray = canvasGray.getContext("2d");

    // button クリック時
    this.el.addEventListener("click", event => {
      // canvas にカメラ映像を貼り付け
      const height = parseInt(width * video.videoHeight / video.videoWidth);
      canvas.height = height;
      canvasGray.height = height;
      canvas.getContext('2d').drawImage(video, 0, 0, width, height);

      // ピクセルデータを取得
      const pixel = context.getImageData(0, 0, width, height)["data"].toString()

      // ピクセルデータをElixirに送信
      this.pushEvent("take", {pixel}, (payload) => {
        let imageData = new ImageData(
          new Uint8ClampedArray(payload.image),
          width,
          height
        );
        // ピクセルデータを canvas に貼り付け
        contextGray.putImageData(imageData, 0, 0);
      })
    })
  }
}

let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}})
...

Elixir 側の処理を変更します

lib/todo_web/live/todo_live.ex

...
  # 写真撮影時の処理
  # 画像をグレースケールに変換する
  def handle_event("take", %{"pixel" => pixel}, socket) do
    width = 400

    # ピクセルデータをテンソルに変換
    pixel_tensor =
      "<<#{pixel}>>"
      |> Code.eval_string()
      |> elem(0)
      |> Nx.from_binary({:u, 8})

    {length} = Nx.shape(pixel_tensor)
    height = div(length, width * 4)

    pixel_tensor = Nx.reshape(pixel_tensor, {width, height, 4})
    alpha_tensor = Nx.slice_along_axis(pixel_tensor, 3, 1, axis: -1)

    # グレースケールに変換
    gray_tensor =
      pixel_tensor
      |> Nx.slice_along_axis(0, 3, axis: -1)
      |> Nx.mean(axes: [-1], keep_axes: true)
      |> Nx.as_type({:u, 8})

    # ピクセルデータに変換
    gray_pixel =
      [gray_tensor, gray_tensor, gray_tensor, alpha_tensor]
      |> Nx.concatenate(axis: -1)
      |> Nx.to_flat_list()

    {:reply, %{image: gray_pixel}, socket}
  end
...

iex -S mix で実行すると、以下のように動作します

  • カメラへのアクセス許可を求めるダイアログが表示される
  • Allow をクリックすると、カメラ映像が video タグに表示される
  • Take a picture をクリックすると、グレースケース画像が下に表示される

Sep-20-2022 19-05-50.gif

改善箇所の説明

動きは以前の記事と全く同じですが、処理が少し改善されています

本来 Elixir で画像データを扱う場合、 StbImage や evision を使います

しかし、これらのモジュールを iOS で動かすことが(現状の方法では)できないため、別の方法で画像データをやり取りします(これは前回も同じ)

前回は context.getImageData(0, 0, 400, height) で取得した Array をそのまま ELixir に渡していました

しかし、そうするとデータは以下のような形式で Elixir に来てしまいます

%{
  "1234" => 123,
  "0" => 43,
  "23" => 56,
  ...
}

これは JavaScript の Array をインデックスと各値の Map にしているためです

この形式で受け取ると、 Elixir ではまずこの Map をキーでソートして、値だけを取り出して List に変換する、という無駄な処理が必要になります

そこで、今回は context.getImageData(0, 0, width, height)["data"].toString() というように Array を String に変換してから送るようにしています

これによって、データは以下のような形式で Elixir に来ます

"43,12,55,255,22,33,11,255,..."

つまり、ピクセルの各値をカンマで繋いだ文字列です

もちろん、これを , で分割して List にした後、各値を Integer に変換してからテンソルにする、という手段も使えます

しかし、以下のようにしてバイナリとしてそのままテンソル化することで処理を簡素にしています

    pixel_tensor =
      "<<#{pixel}>>"
      |> Code.eval_string()
      |> elem(0)
      |> Nx.from_binary({:u, 8})

<<1,2,3>> というような文字列を Code.eval_string() によってバイナリとして解釈し、 Nx にそのまま読み込んでもらいます

これで前回より多少速くできました(当然、 EXLA などを使っていないので、まだまだ実用的な速度ではない)

iOS版

iOS版準備

ここまでで出来上がったデスクトップ版を自分用のリポジトリーにプッシュします

GitHub で空のリポジトリーを作成した後、以下のコマンドを実行してください

git remote set-url origin <自分のリポジトリーのURL>

ここまでの変更をコミット、プッシュします

次に、 別の場所に iOS版のサンプルコードをクローンしてきます

以前の記事だとiOS版のサンプルコードを一部改修する必要がありましたが、そこは修正されています

iOS版ビルド

自分のデスクトップ版リポジトリーを参照するように、 run_mix を変更します

run_mix

if [ ! -d "elixir-app" ]; then
- git clone https://github.com/elixir-desktop/desktop-example-app.git elixir-app
+ git clone -b <ブランチ> <自分のリポジトリーのURL> elixir-app
fi

Elixir アプリケーションをビルドします

./run_mix

Swift の依存ライブラリを取得します

carthage update --use-xcframeworks

iOS版設定変更

XCode で todoapp.xcodeproj を開きます

Team を自分のものに変更し、 Bundle Identifier を適当な値に変更します

スクリーンショット 2023-03-07 10.12.27.png

カメラの使用を許可するための設定を追加します

todoapp/Info.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
+	<key>NSCameraUsageDescription</key>
+	<string>If you want to use the camera, you have to give permission.</string>
	<key>CFBundleDevelopmentRegion</key>
	<string>$(DEVELOPMENT_LANGUAGE)</string>
...

todoapp/WebView.swift

...
        let configuration = WKWebViewConfiguration()
        configuration.limitsNavigationsToAppBoundDomains = true
        configuration.preferences = preferences
        configuration.defaultWebpagePreferences = page
+       configuration.allowsInlineMediaPlayback = true
        
        webview = WKWebView(frame: CGRect.zero, configuration: configuration)
...

iOS版実行

iPhone を接続して Run します

うまくいけばビルドが成功し、アプリが起動します

mobile.gif

まとめ

2022年9月時点と比べると、公式サンプルのバグ等が改修されていたのですんなり実装できました

あとは EXLA や evision などを如何にモバイルで動かすか、ですね

8
0
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
8
0