🦾

やっぱりWasm は C++!!!~Wasm/EmscriptenでOpenCVを使う~

8 min read

はじめに質問

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.3333msが出来れば達成したい目標の処理時間となります。

画像処理の基礎の基礎と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版といった感じの記事です。

この記事に贈られたバッジ