ブラウザで動く画像処理 〜MediaDevices / WebWorker / WebAssembly / OpenCVで遊ぶ〜


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を想定しています。

```bash
$ 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にコンパイルする](https://developer.mozilla.org/ja/docs/WebAssembly/C_to_wasm)

### 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](https://docs.opencv.org/master/d4/da1/tutorial_js_setup.html)


WebWorkerで動かす
---------------------

ビルドしたopencv.jsをWorkerの中に組み込んで呼び出すところまで。です。簡単です。


### OpenCV.jsを組み込む

生成されたopencv.jsは各種モジュールの形式に対応しており、Webpack, Rollup.jsなどでrequire / importすればそのまま使うことができます。

```javascript
import cv from 'opencv'

cv.onRuntimeInitialized = () => {
  // ...
}
```

OpenCV Runtimeの準備が整ったときに実行されるハンドラを登録して、準備ができたら処理を開始するようにします。

opencv.jsの中にはWebAssemblyのコードが含まれているのですが、使用するときは、そのことを特に意識することはありませんでした。普通に使えます。


### rollup-plugin-web-worker-loader

普通に書いても良いのですが、webworkerを呼び出すrollup pluginがあったので使ってみました。
webpackにも同様のものがあると思います。

```javascript:rollup.config.js
// ...
import webWorkerLoader from 'rollup-plugin-web-worker-loader'

const base = {

  plugins: [
    webWorkerLoader({
      inline: false,
      loadPath: '/assets/'
    }),

    //...
```

```javascript
import DetectWorker from 'web-worker:./detect-worker'

function initWorker() {
  worker = new DetectWorker()
  worker.addEventListener('message', ({ data }) => {
  // ...
```




ビデオストリームの取得と操作
--------------------

ここはWebWorkerではなくて通常のjavascriptの世界です。
カメラからのビデオストリームを取得して、画像処理を行うWorkerに渡します。

### キャンバスの作成と2Dコンテキストの取得

Canvasを作ってcontextを得てから作業することが多いので、何かしらutil的に書いておくと良いと思います。

```javascript
  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などから試すときは注意が必要です。

```javascript
  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を閉じる必要があります。

```javascript
  stream.getTracks().forEach((track) => track.stop())
```

参考: [MediaDevices](https://developer.mozilla.org/ja/docs/Web/API/MediaDevices)

### Video要素にStreamを割り当てる

video要素のsrcObjectにstreamを設定してplay()すると、video要素でキャプチャした動画が流れます。

```javascript
  const video = document.querySelector('video')
  video.srcObject = stream
  video.play()
```


### キャプチャを作る

canvasに描画してからImageData形式のデータを取得します。

```javascript
  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で送信できます。

```javascript
  worker.postMessage({type: 'frame', imageData}, [imageData.data.buffer])
```

Transferablesを指定していますが、目に見える速度差は出ず。このへんはもうちょっと追ってみたいところです。

受け側（worker内）はこんな感じ。


```javascript
  self.addEventListener('message', ({ data }) => {
    if (data.type === 'frame') {
      const imageData = data.imageData
      // ...
    }
    // ...
  })
```


OpenCVを使う
-----------------

再びWebWorkerの中。OpenCVの使い方の話です。

Matの使い方など、javascriptで扱うための基本的なことはチュートリアルをやると良いと思います。

参考: [OpenCV.js Tutorials](https://docs.opencv.org/4.1.2/d5/d10/tutorial_js_root.html)

### Mat形式に変換する

```javascript
  const img = cv.matFromImageData(imageData)
  // ...
  img.delete()
```

imageDataは[ImageData](https://developer.mozilla.org/ja/docs/Web/API/ImageData)型です。基本的にCanvasに描画したものをImageDataとして取得する形になります。

変換後のimgはRGBAのMatデータになっており、OpenCVのAPIでいろいろ変換しつつ処理していくことになります。

あと、非常に大事なことですが、cv.Mat()は必ずdelete()する必要があります。


### ImageData形式に変換する

処理は変換元のMatのbit数やColorの状態によって異なります。最終的には8bit RGBAのMat形式に変換してからImageData形式に変換します。

```javascript:グレースケールの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へのバインディングは次のファイルを見るとわかります。
オプションの渡し方なんかがわからないときに。

```cpp:opencv/build_wasm/modules/js/bindings.cpp
    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による高精度な直線検出](https://rest-term.com/archives/3393/)のLineSegmentDetectorのjavascriptの実装を使わせてもらうことにしました。こちらはかなり高速。

作りかけのプログラム
---------------

確率的ハフ変換による線分検出〜消失点検出〜射影変換まで。昔卒研で書いたやつを移植したものです。

何をやろうとしているかというと、傾いた平面に描かれた格子模様を手がかりに、その平面を正面から見たように補正を行う。というものです。

まあ、その先を目指しているわけですが、とりあえずそこまで行っていないというこどで...。

https://github.com/otolab/shogi-eye

<img width="689" alt="スクリーンショット 2019-11-29 23.46.16.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27483/bd9223f1-a1a3-7fcb-3694-d41326e312f2.png">



まとめ
--------------

こうしてみてみると、わりあい使いやすく整備されていることがわかりますね。OpenCVのjs/wasmビルドが公式からちゃんと出ているのは大きい。

また、mediaDevice系のWeb APIも互換性に大きな問題はないようで、遊ぶ分には十分使えるなという印象です。

WebでOpenCVが使えると、処理途中の映像を見ながら調整できたりUIを作りやすかったりと、（速度の問題はあるにせよ）メリットは大きいなとも思いました。卒研のあの頃にコレが欲しかった。


（おまけ）SharedArrayBufferが動かない問題について
------------------

多くのブラウザに一度は実装されたものの、セキュリティの問題からDisableにされており、このまま落ちるんじゃないか。という心配もしてしまうSharedArrayBufferですが……。

Chromeで復活したものの他のブラウザは未対応の状態が続いているようです。

コレがあると処理が早くなるのですが、現在のOpenCV.jsでは使わないように調整してあるようです。

どうにかならないかなぁ。