PLAID AdventCalendar 2019 二日目。
さいきん趣味で書いている(簡単な)画像処理のプログラムを題材に、ブラウザ上で画像処理を回してみよう!という記事です。
10年近く前の卒研でOpenCVやらSift特徴量やらやっていたのですが、その古いコードを引っ張り出してきて、ブラウザ上でなにか動くものを作ろうと画策しているところです。
持つべきものは先達ということで、参考にしたのは次のデモ。すごいです。
https://github.com/mecab/opencvjs-wasm-webworker-webpack-demo
これをベースにいろいろいじる過程で得たノウハウをまとめてみました。
OpenCVの準備
OpenCVをWebAssembly版としてコンパイルして、opencv.jsを作るところまで。です。
emsdkの準備
emscripten(C/C++をjavascriptに変換するコンパイラ)を簡単に導入できるようにするパッケージです。Nodeにおけるnvmのように、複数バージョンの管理などの機能も持っています。
環境は基本的な開発環境が整ったMacを想定しています。
$ brew install cmake
$ git clone https://github.com/emscripten-core/emsdk.git
$ cd emsdk
$ ./emsdk update
$ ./emsdk install latest
$ ./emsdk activate latest
$ source ./emsdk_env.sh
globalにactivateする方法もあるようなので、そのへんは調べて試してください。
参考: C/C++からWebAssemblyにコンパイルする
opencv.jsのビルド
$ git clone https://github.com/opencv/opencv.git
$ cd opencv
$ python ./platforms/js/build_js.py --emscripten_dir=${EMSDK}/upstream/emscripten --build_wasm build_wasm
しばらく待ちます。(JDKのインストールを求めらるかもしれませんが無視しても大丈夫です)
OpenCV.js location: <作業ディレクトリのパス>/opencv/build_wasm/bin/opencv.js
と出てくれば完了です。
一時期のバージョンだとSharedArrayBufferを使っているせいでSafariでエラーが起き、コンパイルオプションを調整する必要があったのですが、現在は解消しているようです。
ちなみに、ビルドオプションとして--simd
オプションもあるのですが、Chrome 80(Canary)でもこのように言われてしまいました。
CompileError: WebAssembly.instantiate(): Compiling function #98 failed: Invalid opcode (enable with --experimental-wasm-simd) @+48080
ざんねん。
参考: Build OpenCV.js
WebWorkerで動かす
ビルドしたopencv.jsをWorkerの中に組み込んで呼び出すところまで。です。簡単です。
OpenCV.jsを組み込む
生成されたopencv.jsは各種モジュールの形式に対応しており、Webpack, Rollup.jsなどでrequire / importすればそのまま使うことができます。
import cv from 'opencv'
cv.onRuntimeInitialized = () => {
// ...
}
OpenCV Runtimeの準備が整ったときに実行されるハンドラを登録して、準備ができたら処理を開始するようにします。
opencv.jsの中にはWebAssemblyのコードが含まれているのですが、使用するときは、そのことを特に意識することはありませんでした。普通に使えます。
rollup-plugin-web-worker-loader
普通に書いても良いのですが、webworkerを呼び出すrollup pluginがあったので使ってみました。
webpackにも同様のものがあると思います。
// ...
import webWorkerLoader from 'rollup-plugin-web-worker-loader'
const base = {
plugins: [
webWorkerLoader({
inline: false,
loadPath: '/assets/'
}),
//...
import DetectWorker from 'web-worker:./detect-worker'
function initWorker() {
worker = new DetectWorker()
worker.addEventListener('message', ({ data }) => {
// ...
ビデオストリームの取得と操作
ここはWebWorkerではなくて通常のjavascriptの世界です。
カメラからのビデオストリームを取得して、画像処理を行うWorkerに渡します。
キャンバスの作成と2Dコンテキストの取得
Canvasを作ってcontextを得てから作業することが多いので、何かしらutil的に書いておくと良いと思います。
const canvas = document.createElement('canvas')
canvas.setAttribute('width', width)
canvas.setAttribute('height', height)
const ctx = canvas.getContext('2d')
デバイスの選択とStreamの取得
Safariの対応がちょっと中途半端で、enumurateDevices()の返り値の情報が少ないですが、ストリーム開始の手順としては一本化されています。
SafariだとlocalhostかHTTPSのページからしかデバイスを開けないようなので、iPhoneなどから試すときは注意が必要です。
const devices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = devices.filter((d) => d.kind === 'videoinput')
const option = {
video: videoDevices[0].deviceId
}
const stream = await navigator.mediaDevices.getUserMedia(option)
なお、deviceを切り替えるには取得済みのstreamを閉じる必要があります。
stream.getTracks().forEach((track) => track.stop())
参考: MediaDevices
Video要素にStreamを割り当てる
video要素のsrcObjectにstreamを設定してplay()すると、video要素でキャプチャした動画が流れます。
const video = document.querySelector('video')
video.srcObject = stream
video.play()
キャプチャを作る
canvasに描画してからImageData形式のデータを取得します。
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight, 0, 0, canvas.width, canvas.height)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
Workerに送る
ImageData形式だとそのままpostMessageで送信できます。
worker.postMessage({type: 'frame', imageData}, [imageData.data.buffer])
Transferablesを指定していますが、目に見える速度差は出ず。このへんはもうちょっと追ってみたいところです。
受け側(worker内)はこんな感じ。
self.addEventListener('message', ({ data }) => {
if (data.type === 'frame') {
const imageData = data.imageData
// ...
}
// ...
})
OpenCVを使う
再びWebWorkerの中。OpenCVの使い方の話です。
Matの使い方など、javascriptで扱うための基本的なことはチュートリアルをやると良いと思います。
Mat形式に変換する
const img = cv.matFromImageData(imageData)
// ...
img.delete()
imageDataはImageData型です。基本的にCanvasに描画したものをImageDataとして取得する形になります。
変換後のimgはRGBAのMatデータになっており、OpenCVのAPIでいろいろ変換しつつ処理していくことになります。
あと、非常に大事なことですが、cv.Mat()は必ずdelete()する必要があります。
ImageData形式に変換する
処理は変換元のMatのbit数やColorの状態によって異なります。最終的には8bit RGBAのMat形式に変換してからImageData形式に変換します。
const dst = new cv.Mat();
img.convertTo(dst, cv.CV_8U)
cv.cvtColor(dst, dst, COLOR_GRAY2RGBA)
const imgData = new ImageData(new Uint8ClampedArray(dst.data, dst.cols, dst.rows), img.size().width, img.size().height)
OpenCV APIの叩き方の調べ方
javascriptへのバインディングは次のファイルを見るとわかります。
オプションの渡し方なんかがわからないときに。
function("HoughLinesP", select_overload<void(const cv::Mat&, cv::Mat&, double, double, int, double, double)>(&Wrappers::HoughLinesP_wrapper));
function("HoughLinesP", select_overload<void(const cv::Mat&, cv::Mat&, double, double, int, double)>(&Wrappers::HoughLinesP_wrapper_1));
function("HoughLinesP", select_overload<void(const cv::Mat&, cv::Mat&, double, double, int)>(&Wrappers::HoughLinesP_wrapper_2));
こんな感じです。
たまにバインドが存在しないAPIもあり、すべての機能を実行できるわけではないのがわかります。LineSegmentDetector使ってみたかったんですが、バインドがなくて使えなかった……。
結局、OpenCVのHoughLinesPが遅くて、かつスマホでの動作の障害になっている感じだったので、JavaScriptでLSDによる高精度な直線検出のLineSegmentDetectorのjavascriptの実装を使わせてもらうことにしました。こちらはかなり高速。
作りかけのプログラム
確率的ハフ変換による線分検出〜消失点検出〜射影変換まで。昔卒研で書いたやつを移植したものです。
何をやろうとしているかというと、傾いた平面に描かれた格子模様を手がかりに、その平面を正面から見たように補正を行う。というものです。
まあ、その先を目指しているわけですが、とりあえずそこまで行っていないというこどで...。
まとめ
こうしてみてみると、わりあい使いやすく整備されていることがわかりますね。OpenCVのjs/wasmビルドが公式からちゃんと出ているのは大きい。
また、mediaDevice系のWeb APIも互換性に大きな問題はないようで、遊ぶ分には十分使えるなという印象です。
WebでOpenCVが使えると、処理途中の映像を見ながら調整できたりUIを作りやすかったりと、(速度の問題はあるにせよ)メリットは大きいなとも思いました。卒研のあの頃にコレが欲しかった。
(おまけ)SharedArrayBufferが動かない問題について
多くのブラウザに一度は実装されたものの、セキュリティの問題からDisableにされており、このまま落ちるんじゃないか。という心配もしてしまうSharedArrayBufferですが……。
Chromeで復活したものの他のブラウザは未対応の状態が続いているようです。
コレがあると処理が早くなるのですが、現在のOpenCV.jsでは使わないように調整してあるようです。
どうにかならないかなぁ。