やっぱりWasm は C++!!!~Wasm/EmscriptenでOpenCVを使う~
はじめに質問
Wasm は何で書く?Go? Rust? AssembyScript?
やっぱりWasm は C++!!!~Wasm/EmscriptenでOpenCVを使う~
※現状での個人の見解です。
ということで、Emscripten で OpenCV を扱うことについて記事です。
この記事の元ネタはWebAssembly Night #10の発表内容です。
そしてOpenCV Advent Calendar 2020 の 9 日目でもあります。
画像処理 on your Browserの時代
Wasm といえば最近はすっかり Go や Rust で書くことが盛り上がっていますが、まだまだ C++/Emscripten も活躍しどころがあります。ブラウザでのクライアント画像処理が代表的なものでしょう。
WebRTCインフラの充実
時雨堂 WebRTC SFU Sora/NTT Com. SkyWay/Amazon Chime SDK/
Azure Communication Services/Zoom Customizable SDK...
といった WebRTC を簡単に使うためのツールキットは増えています。そのため、Web 会議の実装がより簡単になってきて、差別化要素が別に必要になってきています。
例えば、Google Meet 背景ぼかし Meet 最強 記事に書いたように、Google はその技術を結集して高度な背景ぼかしをブラウザだけで実現しています。
画像処理を自前で書く?
- まずは 20 年の歴史を持つOpenCVの力を借りよう
- Go や Rust にも独自の画像処理ライブラリはあるが、充実度で言えばやっぱり OpenCV
- Go/Rust の OpenCV ラッパー? 現状の Wasm だと FFI は…
ということで、現状だと Emscripten で OpenCV を叩くのがちょうど良い感じになっています。
また、画像処理以外のマルチメディア・解析処理として以下のようなライブラリを叩くこともあるでしょう。
- 動画: ffmpeg/gstreamer(話題の ffmpeg.Wasm はちょっと違う気がする…)
- 音声: Essentia(Essentia.js あり)
- 自然言語処理: MeCab
- 推論処理: Tensorflow/Caffe/OpenVINO...
近い将来の変化
近い将来、ブラウザには MediaStreamTrack Insertable Media Processing using Streams が入ってきます。
これはカメラからのストリームにフレーム単位で触れるようになるもので、Wasm 呼び出しとの相性も良いはずです。
→シンプルに画像処理に閉じた Wasm の作成が求められる。
※Web Audio API の方が先行していて Wasm で作成した処理ノード作るのが流行ってるっぽい(Meet でも音声処理っぽい Wasm を使ってる)
典型的ブラウザ画像処理
典型的なブラウザ画像処理の例として背景ぼかしを挙げます。
TypeScript/tensorflow.js で作られたBodyPixがよく使われます。
処理の流れ:
Web カメラ => getUserMedia()
=> video タグ => BodyPix => canvas タグ => captureStream()
=> WebRTC
構成要素は以下の通り。
- 推論:tensorflow.js(WebGL バックエンド/Wasm バックエンド)
- 後処理(ぼかし):BrowserJS/CSS で canvas お絵描き頑張る
- WebRTC への送信:canvas の
captureStream()
ぼかしはライブラリ側が用意しているものの、バーチャル背景(背景差し替え)は? JS で頑張る? OpenCV 使って楽をする?
また、カメラ映像を処理するため、30fps=33.3333 ミリ秒が出来れば達成したい目標の処理時間となります。
画像処理の基礎の基礎とPythonでの常識
画像は img[row][column][color]
かimg[(row * width + column) * num_color + color]
あたりでメモリに格納される。
for row in range(height):
for column in range(width):
for color in range(num_color):
img[row][column][color] = min(255, img[row][column][color] * 2)
しかし、上記のようにやるとパフォーマンスが出ない。Python の場合はnumpyを使いループは
ネイティブライブラリで回すというのがほぼ常識。
意外と JS だとパフォーマンスが出て BodyPix では JS でゴリゴリ書いた部分も…。
背景差し替え(ぼかし)処理
BodyPix の推論処理はマスク画像を得られる→マスクを使って合成(ぼかし)処理からなります。
元画像
→マスク画像
+背景画像
→合成画像
※マスク画像だけはただの Uint8Array で 1 チャンネル画像と看做せる。他は RGBA 画像。
「+」の処理(合成処理)はどうする?
※もうちょい複雑な話は「Meet 最強」記事にて解説してます。
合成処理(単純版)
JS での合成処理の単純版はこんな感じになります。
for(row=0; row<h; row++)
for(col=0; col<w; col++)
composed[(row*w+col)*4+3] = mask[row*w+col];
ctx.drawImage(bg); // 背景を先に描く
ctx.putImageData(...); // アルファチャンネル使って合成
これが OpenCV.js を使うとこうなります。
cv.split(original, imageRGBA); // RGBAチャンネルの分離
imageRGBA.set(3, mask); // 3:Alphaをmaskに差し替え
cv.merge(imageRGBA, composed); // 結合して戻す
ctx.drawImage(bg); // 背景を先に描く
ctx.putImageData(...); // アルファチャンネル使って合成
のように、ループはネイティブ側で処理できます。また、単純な合成でなく他の様々な画像処理もやりやすいでしょう。
OpenCV.jsの実態
OpenCV.js は実際のところこんな感じです。
- C++ライブラリを Emscripten ビルドしたもの
- js ライブラリのドキュメントはそこまで充実してない
- C++のドキュメントを見ながら書く
- ラッパーの都合上、メモリ開放(
img.delete()
など)を自前で面倒みる必要がある
簡単な処理を書いて比較してみる
画像の明るさを-100~+100 でフレームごとに変化させてみる(ちょっとサボって RGB で-100~+100 にしておく)
- 配布されている opencv.js 版
- 自分でビルドした opencv.js 版
- C++で書いたフル Wasm 版
GitHub kounoike/webassembly-night-sampleで公開しています。
Docker/docker-compose があればビルドできるはず。
opencv.js版(配布/自分でビルドともにソースは同じ)
let cnt = 0;
const updateCanvas = () => {
const srcMat = new cv.Mat(video.height, video.width, cv.CV_8UC4);
const rgbMat = new cv.Mat();
const rgbOutMat = new cv.Mat();
const outMat = new cv.Mat();
cap.read(srcMat); // videoタグから画像読み込み
cv.cvtColor(srcMat, rgbMat, cv.COLOR_RGBA2RGB); // RGBA->RGB
rgbMat.convertTo(rgbOutMat, -1, 1, cnt++ % 200 - 100); // -100~+100
cv.cvtColor(rgbOutMat, outMat, cv.COLOR_RGB2RGBA); // RGBAに戻す
cv.imshow("canvas", outMat); // canvasに描画
srcMat.delete(); // メモリ解放処理
rgbMat.delete();
rgbOutMat.delete();
outMat.delete();
requestAnimationFrame(updateCanvas); // rAFでループ
};
updateCanvas();
※実際は変数のスコープを持ち上げたりして解放処理サボったりします。
C++/Emscripten版
C++処理関数はいかのようになります。
void doOpenCvTask(size_t addr, int width, int height, int cnt) {
auto data = reinterpret_cast<void *>(addr);
cv::Mat rgbaMat(height, width, CV_8UC4, data);
cv::Mat rgbMat;
cv::Mat rgbOutMat;
cv::Mat outMat;
cv::cvtColor(rgbaMat, rgbMat, cv::COLOR_RGBA2RGB); // RGBA->RGB
rgbMat.convertTo(rgbOutMat, -1, 1.0, cnt % 200 - 100.0); // -100~+100
cv::cvtColor(rgbOutMat, outMat, cv::COLOR_RGB2RGBA); // RGBAに戻す
// メモリ解放処理いらない
if (SDL_MUSTLOCK(screen)) // 描画処理はちょっと面倒
SDL_LockSurface(screen);
cv::Mat dstRGBAImage(height, width, CV_8UC4, screen->pixels);
outMat.copyTo(dstRGBAImage);
if (SDL_MUSTLOCK(screen))
SDL_UnlockSurface(screen);
SDL_Flip(screen);
}
C++版呼び出し JS は以下のようになります。
const updateCanvas = () => {
context.drawImage(video, 0, 0);
const data = context.getImageData(0, 0, width, height);
const buffer = Module._malloc(data.data.length);
Module.HEAPU8.set(data.data, buffer);
Module.doOpenCvTask(buffer, width, height, cnt++);
Module._free(buffer);
requestAnimationFrame(updateCanvas);
};
updateCanvas();
※JS 側でヒープメモリを管理している
(確保・解放をループの外側にしてもいいけど)
以上のように…
- OpenCV 関数の呼び出し方は C++/JS で大して変わらない
- OpenCV.js 専用ドキュメントはないので C++版を見ながら書く
- JavaScript はメモリ管理しなければならなくて面倒
- C++はメモリ管理がいらなくて簡単
「C++は JavaScript と違ってメモリ管理がいらなくて簡単」 という一見謎な結論が出ました。
その他もろもろ
実行速度:画像サイズや処理内容(OpenCV の関数呼び出し回数)などにもよるが若干 C++版の方が速い、かな? というくらいです。
サイズ:以下の通り。
- OpenCV.js は配布版・フルビルド版ともに 8MB ほど
- C++版は数百 KB
- リンカで必要な関数だけになってる?
もうちょっと複雑なことをやってみる
DNNモジュールで顔検出の実装
C++での実装記事を参考にゴニョゴニョと実装しました。
結果
0.5fps くらい@Ryzen 初代(Windows 10/Chrome Canary)
高速化
→SIMD 対応で Wasm ビルドする。chrome://flags
で SIMD を有効化する。
→8fps くらい(16 倍高速化)。
ソースは先のリポジトリのfacedetect-full-wasmにあります。これもdocker-compose up --build
してください。
この辺のマクロが有効になって抽象化された形で SIMD を扱っています。
まとめ&補足
- C++で Wasm 書くのは便利
- 各種マルチメディア・自然言語処理などのライブラリ活用
- 「C++はJavaScriptと違ってメモリ管理がいらなくて簡単」
- 実際はやりたいことと各言語の現状次第
- 単機能 Wasm を組み合わせて使えばそれぞれで言語選択が可能
-
なぜ WebAssembly 生成を Go にしたのか(WebRTC E2EE の話)
- 「Pure Go で信頼できる暗号化ライブラリがある」など
- Apache TVM(Rust:推論ランタイム on Wasm)とかも頑張ってる
- Wasm そのものはまだまだ遅いので SIMD/Thread/WebGPU などの発展に期待
明日も私は発表をしているので、そのネタを記事にします。WebRTC Meetup Online #2で WebRTC で Web カメラがどう使われているかを話しますが、それの OpenCV 版といった感じの記事です。
Discussion