C++
JavaScript
OpenCV
WebAssembly

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

More than 1 year has passed since last update.

はじめに

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

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

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

動作はここから確認できます

お題

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

やってみる

環境構築とOpenCVのwasmビルド

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

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

https://github.com/Web-Sight/opencvjs をcloneして、下記の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本家をclone、カレントディレクトリをホストにマウントして、make.pyを叩くとbuildできます。

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

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");
bindings.cpp(追加分)
    register_vector<cv::DMatch>("DMatchVector");
    register_vector<std::vector<cv::DMatch>>("DMatchVectorVector");
    register_vector<std::vector<char>>("CharVectorVector");

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

docker-compose.yml
version: '2'

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

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

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

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

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

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

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

とりあえず、wasmをロードして実行できるようにしておきたいので、以下の.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();

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

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ないので、メモリの解放は自前で1
  • デフォルトパラメータいちいち調べるのダルい
  • PythonのAPIに慣れてると、何番目がoutに相当する引数なのかわからなくなる

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

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

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

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

僕の環境(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のサイズを小さくできます。

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/ab0523639ed864984e6bhttps://developer.mozilla.org/ja/docs/WebAssembly/Caching_modules に詳しいです。

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

Web Workers

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

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

といってもそれほど難しいことはなく、下記のように importScripts でEmscriptenが吐いた.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') {
    // 主処理の実行
  }
});
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で怒られてしまいました3

cannot transfer WebAssembly/asm.js ArrayBuffer

仕方ないので、下記のように一度別のTyped Array作ることで回避しています。ある程度の効果があるのは以前に検証済み4

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

おわりに

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

既存のC/C++ライブラリをブラウザで利用したい、というケースはOpenCVでなくともありそうですが、利用側が自分でビルドできないと厳しそうです。
webpackがwasmに手を出す とか rust-loaderが爆誕しそう とか言われていますが、結局のところ自分でmakeする体力が無いと使いこなすのは無理でしょ、という気持ち。

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

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

参考/脚注


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

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

  3. Chromeだと大丈夫。Shared Array Buffer使え、とかそういう話なのかな。詳しい人いたらコメント等で教えて欲しい。 

  4. まさか3年前に書いた記事が今更役に立つとは思いもよらず。びっくり。