[OpenCV][WebAssembly]ブラウザで2画像の特徴量比較してみる

## はじめに
このエントリは、画像解析ライブラリであるOpenCVをWeb Assemblyとしてビルドしてブラウザで動かす、というのを一通りやってみたメモです。

主なコンテンツとして下記を含みます。

- OpenCVのwasmビルド方法、.wasmのカスタマイズ方法
- 性能改善(モジュールのキャッシュ、Web Workersなど)

[動作はここから確認できます](https://quramy.github.io/opencv-wasm-knnmatch-demo/)。

## お題
主な主眼は「ブラウザでOpenCV動かす」部分で、build筋鍛えること自体が目的なので、題材は正直なんでもいいのですが、ここでは2枚の特徴点を抽出してマッチングする、というのをJavaScriptでやってみることにしました。インターネッツに山のようにサンプルが転がっていますが、Pythonで書くと下記です。

```python:matching.py
import cv2

img1 = cv2.imread('img1.png')
img2 = cv2.imread('img2.png')

akaze = cv2.AKAZE_create()
kp1, des1 = akaze.detectAndCompute(img1, None)
kp2, des2 = akaze.detectAndCompute(img2, None)
bf = cv2.BFMatcher(2)
matches = bf.knnMatch(des1, des2, k = 2)
ratio = 0.5
good = []
for m, n in matches:
    if m.distance < ratio * n.distance:
        good.append([m])
mathching_img = cv2.drawMatchesKnn(img1, kp1, img2, kp2, good, None, flags = 2)

cv2.imwrite('out.png', mathching_img)
```

Lenaさんで試すと、↓のような結果となります：

![out.png](https://qiita-image-store.s3.amazonaws.com/0/8808/b9aa4514-0b6d-398d-7c4b-c0d284435c89.png)

## やってみる
### 環境構築とOpenCVのwasmビルド

http://blog.bokuweb.me/entry/wasm-opencv-laughingman あたりを参考にしながら進めていきます。

どうせ最後にはCIでbuildするんだし、とかを考えて、Dockerベースでbuild環境を用意しました。

https://github.com/Web-Sight/opencvjs をcloneして、下記のDockerfileを配置します。

```Dockerfile:Dockerfile
FROM trzeci/emscripten:sdk-tag-1.37.21-64bit

RUN emsdk install clang-e1.37.21-64bit
RUN emsdk activate clang-e1.37.21-64bit

# Patch binding header and .js
COPY ./patch_emscripten.diff /src/patch_emscripten.diff
RUN patch -p0 -d /emsdk_portable/sdk/ < /src/patch_emscripten.diff
```

上記の例ではclangを入れちゃってるけど、多分binaryenだけ入れてactivateするでもいいような。

patch当ててるのは、Emscriptenが公開しているbindingのheaderだと、微妙にOpenCVのFeatures Frameworkのバインドが書けないからっぽいです。

[OpenCV本家](https://github.com/opencv/opencv)をclone、カレントディレクトリをホストにマウントして、make.pyを叩くとbuildできます。

ですが、このままbuildすると後でbindingが足りなくなってブラウザ実行時に詰むので、今のうちに追加しておきましょう。

```cpp:bindings.cpp
EMSCRIPTEN_BINDINGS(Utils) {

    register_vector<int>("IntVector");
    register_vector<char>("CharVector");
    register_vector<unsigned>("UnsignedVector");
    register_vector<unsigned char>("UCharVector");
    register_vector<std::string>("StrVector");
    register_vector<emscripten::val>("EmvalVector");
```

```cpp:bindings.cpp（追加分）
    register_vector<cv::DMatch>("DMatchVector");
    register_vector<std::vector<cv::DMatch>>("DMatchVectorVector");
    register_vector<std::vector<char>>("CharVectorVector");
```

さて、buildに戻ります。実際にbuildを実行する部分をdocker-composeで書くと下記となります。

```yaml:docker-compose.yml
version: '2'

services:
    emcc:
        build: "."
        volumes:
            - ".:/var/repository"
        working_dir: "/var/repository"
        command: python make.py --wasm
```

これでbuildが実行できます。

```sh
$ docker-compose build
$ docker-compose run --rm emcc
```

これで、buildディレクトリ配下に成果物が生成されます。

```txt
build
├── cv-wasm.data
├── cv-wasm.js
├── cv-wasm.wasm
├── cv.data
└── cv.js
```

### wasmをブラウザで動作させる

これも大本のレポジトリのtestディレクトリ以下に様々なサンプルが転がっているので、詳細は端折ります。

とりあえず、wasmをロードして実行できるようにしておきたいので、以下の.jsを作っておきます。



```js:module.js
class ModuleClass {
  locateFile(baseName) {
    return `build/${baseName}`;
  }

  instantiateWasm(imports, callback) {
    fetch(this.locateFile("cv-wasm.wasm"))
      .then((res) => res.arrayBuffer())
      .then((buff) => WebAssembly.instantiate(buff, imports))
      .then((result) => callback(result.instance))
    ;
    return { };
  }

  onInit(cb) {
    this._initCb = cb;
  }

  onRuntimeInitialized() {
    if (this._initCb) {
      return this._initCb(this);
    }
  }
}

var Module = new ModuleClass();
```

[^ref_module]: https://kripken.github.io/emscripten-site/docs/api_reference/module.html

つづいて、主処理部分です。

```js:main.js
Module.onInit(cv => {

  const img1Raw = cv.matFromArray(getInput('img1'), 24), img1 = new cv.Mat();
  cv.cvtColor(img1Raw, img1, cv.ColorConversionCodes.COLOR_RGBA2RGB.value, 0);

  const img2Raw = cv.matFromArray(getInput('img2'), 24), img2 = new cv.Mat();
  cv.cvtColor(img2Raw, img2, cv.ColorConversionCodes.COLOR_RGBA2RGB.value, 0);

  const mask = new cv.Mat(), kp1 = new cv.KeyPointVector(), des1 = new cv.Mat(), kp2 = new cv.KeyPointVector(), des2 = new cv.Mat();
  const akaze = new cv.AKAZE(5, 0, 3, 0.001, 4, 4, 1); // パラメータのデフォ値はOpenCVのAPI見て調べるべし
  akaze.detectAndCompute(img1, mask, kp1, des1, false);
  akaze.detectAndCompute(img2, mask, kp2, des2, false);

  const matches = new cv.DMatchVectorVector();
  const bf = new cv.BFMatcher(2, false);
  bf.knnMatch(des1, des2, matches, 2, mask, false);

  const ratio = .5, good = new cv.DMatchVectorVector();
  for (let i = 0; i < matches.size(); i++) {
    const m = matches.get(i).get(0), n = matches.get(i).get(1);
    if (m.distance < ratio * n.distance) {
      const t = new cv.DMatchVector();
      t.push_back(m);
      good.push_back(t);
    }
  }

  const matchingImage = new cv.Mat(), mc = new cv.Scalar(-1, -1, -1, -1), sc = new cv.Scalar(0, 255, 0, 0), maskingCharVecVec = new cv.CharVectorVector();
  cv.drawMatchesKnn(img1, kp1, img2, kp2, good, matchingImage, mc, sc, maskingCharVecVec, 2);
  showImage(matchingImage, 'output');

  // 確保したメモリは自分で解放しないとダメ
  [img1Raw, img2Raw, img1, img2, akaze, mask, kp1, des1, kp2, des2, bf, matches, good, matchingImage, mc, sc, maskingCharVecVec].forEach(m => m.delete());

});
```

なるべく冒頭のPythonのコードと変数名等は揃うように書いてみたのですが、Pythonと比べると簡潔さが大分損なわれましたね。

- GCないので、メモリの解放は自前で[^webassembly_gc_spec]
- デフォルトパラメータいちいち調べるのダルい
- PythonのAPIに慣れてると、何番目がoutに相当する引数なのかわからなくなる

JavaScriptというよりはC++のコードを書く気持ちで向き合った方が良さそうです。

下2つは、今回ベースにしたOpenCV.jsに含まれるbindingの問題なので、頑張ればなんとなかなるのかもしれないけど。。。

[^webassembly_gc_spec]: https://github.com/WebAssembly/gc#gc-proposal-for-webassembly とかにGCの提案あるみたいだけど、今回のケースでなんとかなるような気がしない...

## 動いた！...でも遅いんだけど。

とりあえず動くには動きますが、体感時間がヤバいです。

僕の環境(MBP、ローカル、Chrome 61)で、マッチング画像がcanvasに表示されるまでに14秒程度かかっています。
ChromeのdevToolで計測してみたころ、およそ以下のような内訳を得ました。

- emscriptenが吐いた.jsのevaluate, .wasmのローディング: 1,200 msec 程度
- .wasmのcompile: 3,000 msec程度
- 主処理の実行部: 8,000 msec程度

※ Profilerのon/offでも数秒レベルで結果が変わるので要注意...

### サイズ削減
まず、そもそもの.wasmのサイズが超大です。5MBを超えています。
.wasmのサイズを削減することを考えます。

今回のサンプルでは2D Features Frameworkさえあればよいですし、実際に利用する際もOpenCV全ての機能を利用することはあまり無いでしょう。

今回利用したレポジトリでは、bindings-gen配下のPythonスクリプトからbindings.cppが生成され、生成された.cppをemccに食わせることでwasmとなっています。
bindings-gen/embindgen.py の末尾に、取り込むhppを列挙している箇所があります。ここを必要なモジュールのみに絞り込んでからbuildすることで、wasmのサイズを小さくできます。

```py:bindings-gen/embindgen.py
    srcFiles = opencv_hdr_list = [
                    "../opencv/Modules/core/include/opencv2/core.hpp",
                    "../opencv/Modules/core/include/opencv2/core/types.hpp",
                    "../opencv/Modules/core/include/opencv2/core/ocl.hpp",
                    "../opencv/Modules/core/include/opencv2/core/mat.hpp",
                    "../opencv/Modules/flann/include/opencv2/flann.hpp",
                    # 中略
                    ]
```

今回は core, imgproc, features2d のみにして、5.1MB -> 3.0 MB程度まで削減しました。

### Indexed DBによるCache
サイズを削減したところで、それでもまだ3MBもあると、やはり無視できないオーダーのcompile時間がかかります。
そこでcompile済みのWeb Assemblyモジュールをキャッシュすることにします。
方法は https://qiita.com/ukyo/items/ab0523639ed864984e6b や https://developer.mozilla.org/ja/docs/WebAssembly/Caching_modules に詳しいです。

とはいうものの、ChromeではIndexedDBへのWeb Assemblyモジュールのストア実行で例外を吐いてしまいました。キャッシュについてはFireFoxでしか確認できませんでした。。。

### Web Workers
主処理（＝特徴点抽出とマッチング）の実行時間を短くする術は思いつきませんが[^use_flann]、これをUIスレッドで実行することが悪手であることは間違いないでしょう。
画素数にもよりますが、数秒間かかる処理をUIスレッドで実行してしまうと、その間に発生するイベントがブロッキングされてしまうからです。

[^use_flann]: Bruteforceの代わりにFLANN使う、みたいな改善はもちろんあり得るのですが、今回はfeatures2dのみに.wasmを絞っちゃってるので割愛。

そこで、Web Assemblyの処理を別スレッド、すなわちWeb Workersで実行するように変更します。

といってもそれほど難しいことはなく、下記のように `importScripts` でEmscriptenが吐いた.jsを読み込むように変更するだけです。

```js:worker.js
importScripts('wasm-util.js', 'module.js', 'build/cv-wasm.js');
Module.onInit((cv) => {
  self.postMessage({ type: 'init' });
});

self.addEventListener('message', ({ data }) => {
  if (data.type === 'req_match') {
    // 主処理の実行
  }
});
```

```js:main.js
const worker = new Worker('worker.js');
worker.addEventListener('message', ({ data }) => {
  if (data.type === 'init') {
    const img1 = getInputImage('img1');  // canvas.getImageData呼ぶやつ
    const img2 = getInputImage('img2');
    worker.postMessage({ type: 'req_match', img1, img2 }, [img1.data.buffer, img2.data.buffer]);
  }
});
```

`postMessage` の第2引数忘れると、スレッド間の転送が激重になるので注意。

なお、worker -> mainスレッドで、OpenCVで確保した `mat.data()` をTransferしようとしたら、FireFoxで怒られてしまいました[^cannot_transfer]。

```
cannot transfer WebAssembly/asm.js ArrayBuffer
```

仕方ないので、下記のように一度別のTyped Array作ることで回避しています。ある程度の効果があるのは[以前に検証済み](https://qiita.com/Quramy/items/8c12e6c3ad208c97c99a)[^worker_transfer]。

```js
const data = new Uint8Array(mat.data());
postMessage({ /* 略 */ }, [data.buffer]);
```

[^cannot_transfer]: Chromeだと大丈夫。Shared Array Buffer使え、とかそういう話なのかな。詳しい人いたらコメント等で教えて欲しい。
[^worker_transfer]: まさか3年前に書いた記事が今更役に立つとは思いもよらず。びっくり。

## おわりに
今回、はじめてWeb Assembly触ってみたのですが、動作自体は問題ないものの、そこに至る過程には色々と辛みがありますね。。。

既存のC/C++ライブラリをブラウザで利用したい、というケースはOpenCVでなくともありそうですが、利用側が自分でビルドできないと厳しそうです。
[webpackがwasmに手を出す](https://medium.com/webpack/webpack-awarded-125-000-from-moss-program-f63eeaaf4e15) とか [rust-loaderが爆誕しそう](https://github.com/webpack/webpack/issues/4950) とか言われていますが、結局のところ自分でmakeする体力が無いと使いこなすのは無理でしょ、という気持ち。

今回はemccするようにCMakeを叩いてくれている.pyが既にあったので助かりましたが、1からポーティングしようとなると、この部分から自分でやっていくしかないです。
当然、bindingsも自分で書いていくしかないですが、OpenCVの場合は、本家におけるPython bindingsを自動で生成しており、今回利用したembindgen.pyは(おそらく)こいつをforkして作成されたものです。
.hppをゴリッとparseして、C++のクラス定義からEmscripten用のbindingsを生成する部分はうまいこと学べば色々できそうです。

なお、作成したブツ達は下記からソースを確認できます：

- https://github.com/Quramy/opencvjs : OpenCVをwasm buildする側
- https://github.com/Quramy/opencv-wasm-knnmatch-demo : wasmを使うweb app側

## 参考/脚注