はじめに
半年くらい前(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
をクリックすると、グレースケース画像が下に表示される
改善箇所の説明
動きは以前の記事と全く同じですが、処理が少し改善されています
本来 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 を適当な値に変更します
カメラの使用を許可するための設定を追加します
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 します
うまくいけばビルドが成功し、アプリが起動します
まとめ
2022年9月時点と比べると、公式サンプルのバグ等が改修されていたのですんなり実装できました
あとは EXLA や evision などを如何にモバイルで動かすか、ですね