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

## はじめに

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

https://qiita.com/RyoWakabayashi/items/554a88cb35f24ec594ca

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

https://asdf-vm.com/

## 実行環境

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

  - macOS Ventura 13.2.1
  - asdf v0.11.2

- モバイル版実行環境

  - iPhone SE 第２世代
  - iOS 16.3.1

## 実装コード

デスクトップ用

https://github.com/RyoWakabayashi/elixir-desktop-camera-20230305

iOS用

https://github.com/RyoWakabayashi/elixir-desktop-camera-ios-20230305

## デスクトップ版

### デスクトップ版準備

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

https://github.com/elixir-desktop/desktop-example-app

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

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

```bash
asdf plugin-add erlang
asdf plugin-add elixir
asdf install
```

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

https://github.com/elixir-nx/nx

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

mix.exs

```diff_elixir
...
  # 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 側の依存ライブラリを取得します

```bash
mix deps.get
```

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

```bash
cd assets
npm install
cd ..
mix assets.deploy
```

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

### デスクトップ版実装

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

lib/todo_web/live/todo_live.html.heex

```elixir
<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

追加処理

```javascript
...
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

```elixir
...
  # 写真撮影時の処理
  # 画像をグレースケールに変換する
  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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/bb1f4bb5-18b5-b1ed-f493-30f617889675.gif)

### 改善箇所の説明

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

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

https://github.com/elixir-nx/stb_image

https://github.com/cocoa-xu/evision

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

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

しかし、そうするとデータは以下のような形式で 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 に来ます

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

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

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

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

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

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

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

## iOS版

### iOS版準備

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

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

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

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

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

https://github.com/elixir-desktop/ios-example-app

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

### iOS版ビルド

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

run_mix

```diff_bash
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 アプリケーションをビルドします

```bash
./run_mix
```

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

```bash
carthage update --use-xcframeworks
```

### iOS版設定変更

XCode で todoapp.xcodeproj を開きます

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

![スクリーンショット 2023-03-07 10.12.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c65773e7-a707-04b7-7725-9adff0bb660f.png)

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

todoapp/Info.plist

```diff_xml
<?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

```diff_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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/02f549ac-9776-75bc-405c-b139f348e067.gif)

## まとめ

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

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