はじめに
このエントリは、画像解析ライブラリであるOpenCVをWeb Assemblyとしてビルドしてブラウザで動かす、というのを一通りやってみたメモです。
主なコンテンツとして下記を含みます。
- OpenCVのwasmビルド方法、.wasmのカスタマイズ方法
- 性能改善(モジュールのキャッシュ、Web Workersなど)
お題
主な主眼は「ブラウザでOpenCV動かす」部分で、build筋鍛えること自体が目的なので、題材は正直なんでもいいのですが、ここでは2枚の特徴点を抽出してマッチングする、というのをJavaScriptでやってみることにしました。インターネッツに山のようにサンプルが転がっていますが、Pythonで書くと下記です。
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さんで試すと、↓のような結果となります:
やってみる
環境構築とOpenCVのwasmビルド
http://blog.bokuweb.me/entry/wasm-opencv-laughingman あたりを参考にしながら進めていきます。
どうせ最後にはCIでbuildするんだし、とかを考えて、Dockerベースでbuild環境を用意しました。
https://github.com/Web-Sight/opencvjs をcloneして、下記の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が足りなくなってブラウザ実行時に詰むので、今のうちに追加しておきましょう。
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");
register_vector<cv::DMatch>("DMatchVector");
register_vector<std::vector<cv::DMatch>>("DMatchVectorVector");
register_vector<std::vector<char>>("CharVectorVector");
さて、buildに戻ります。実際にbuildを実行する部分をdocker-composeで書くと下記となります。
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を作っておきます。
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();
つづいて、主処理部分です。
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のサイズを小さくできます。
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
主処理(=特徴点抽出とマッチング)の実行時間を短くする術は思いつきませんが2、これをUIスレッドで実行することが悪手であることは間違いないでしょう。
画素数にもよりますが、数秒間かかる処理をUIスレッドで実行してしまうと、その間に発生するイベントがブロッキングされてしまうからです。
そこで、Web Assemblyの処理を別スレッド、すなわちWeb Workersで実行するように変更します。
といってもそれほど難しいことはなく、下記のように importScripts
でEmscriptenが吐いた.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') {
// 主処理の実行
}
});
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を生成する部分はうまいこと学べば色々できそうです。
なお、作成したブツ達は下記からソースを確認できます:
- https://github.com/Quramy/opencvjs : OpenCVをwasm buildする側
- https://github.com/Quramy/opencv-wasm-knnmatch-demo : wasmを使うweb app側
参考/脚注
-
https://github.com/WebAssembly/gc#gc-proposal-for-webassembly とかにGCの提案あるみたいだけど、今回のケースでなんとかなるような気がしない... ↩
-
Bruteforceの代わりにFLANN使う、みたいな改善はもちろんあり得るのですが、今回はfeatures2dのみに.wasmを絞っちゃってるので割愛。 ↩
-
Chromeだと大丈夫。Shared Array Buffer使え、とかそういう話なのかな。詳しい人いたらコメント等で教えて欲しい。 ↩
-
まさか3年前に書いた記事が今更役に立つとは思いもよらず。びっくり。 ↩