search
LoginSignup
54
Help us understand the problem. What are the problem?

More than 5 years have passed since last update.

posted at

updated at

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

はじめに

このエントリは、画像解析ライブラリである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年前に書いた記事が今更役に立つとは思いもよらず。びっくり。 

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
54
Help us understand the problem. What are the problem?