😃

OpenCVの新しい顔検出をブラウザでも試してみる

2021/12/23に公開

この記事はOpenCV Advent Calendar 2021の 23 日目の記事です。

はじめに

3 日目の記事で紹介されているように、OpenCV 4.5.4 では新しく顔検出/顔認識の API が実装されました。この記事ではこの顔検出 API をブラウザから呼んでみることにします。ブラウザから呼び出すにあたって、先にきちんとパフォーマンスを確認して使用する解像度を決めます。更に高速化のために SIMD とマルチスレッドを使った OpenCV の Wasm バイナリを作ります。その後、実用的な環境を想定して React のフロントエンドから呼び出すようにしてみます。ついでに WebRTC で実際に加工した画像が送信できることのデモまで行います。

OpenCV.js での新機能の扱い

OpenCV.js で JavaScript から呼び出せる機能はホワイトリスト形式になっており、ビルドする際に与える設定ファイル(Python で記述しますが)によって決まっています。標準だと https://github.com/opencv/opencv/blob/4.5.4/platforms/js/opencv_js.config.py のファイルが使われることになり、OpenCV のドキュメントサイトで配布されている OpenCV.js もこの設定になっているはずです。

cv::FaceDetectorYN はこの中に記載されていません。そのためこのままビルドしても JavaScript からは呼び出せません。ちょっと試してみましょう。実は OpenCV のドキュメントサイトにある OpenCV.js Tutorials ではちょっと制限はあるものの、手軽に OpenCV.js の機能を試すことができます。チュートリアルのページから適当に Basic Operations on Images のページを開いて、少し下の方にスクロールします。すると、Try it と書かれたフレームがあります。

開発者ツールのコンソールを開くと、ビルド情報が出力されています。バージョンもちゃんと 4.5.4 になっていますね。

ここで JavaScript のソースの一番下に console.log(cv.FaceDetectorYN) と入れて Try it ボタンを押してみます。するとコンソールには非情にも undefined と出てきます。

自前での OpenCV の WebAssembly ビルド

それでは、新しい顔検出をブラウザで使ってみたいので、OpenCV をビルドするところから始めます。上で説明したように設定ファイルでホワイトリストになっているので、そこに記載すれば JavaScript バインディングを作ってくれる…はずですが、以前の記事に書いたように JavaScript から OpenCV を呼ぶとメモリ管理が煩雑なので C++ で書くことにします。WebAssembly のライブラリとしてビルドした OpenCV を C++ から呼び出すのであれば、上記のホワイトリストは関係なく、どんな機能も呼び出すことが出来ます(cuda とか gapi とかは別の仕組みで disabled にされてますが)。

C++/Emscripten のビルド環境は VS Code の dev Container を使って構築します。ちゃんと設定すれば補完とかが効いて便利です。構築法は以前書いた記事を元に構築します。更に CMake の設定では別の記事に書いたように google/benchmark を組み込んでおきます。

Docker イメージは MS が提供している C++ 開発向けイメージを元に

を組み込んだ上で、Dockerfile 内で事前に WebAssembly 版/ネイティブ版 OpenCV をビルドしておいてます。

OpenCV のビルドはこのあたりでやっています。ざっくり解説すると以下のステップでビルドしています。

  • git で該当バージョンのソースを取ってくる(今回の記事とは関係ないが contrib も取ってきている)。
  • ビルド対象を設定する CMake オプションを用意する。
    • テストやベンチマーク用で png/jpg 読み込めるようにしたかったが、-DWITH_JPEG を ON にすると CMake の挙動がおかしくなり断念して png だけにした。
  • platform/js/build_js.py を利用して --config_only を付けることで CMake の設定だけをやってもらっている。
    • CMake のオプションを指定する際に、 build_js.py では 1 つのオプションごとに --cmake_option= を付けないとならないのでちょっと冗長な書き方をしている。
    • --simd オプションで SIMD が、--threads オプションでマルチスレッドが、それぞれ有効になるので、組み合わせで 4 通りをビルドしている。
  • 実行パフォーマンスの比較用にネイティブ版もビルドしている。

CMakeLists.txt ではこのあたりで WebAssembly バイナリの設定、この行以降でベンチマークの設定をしています。

以下のポイントがちょっと気を使った部分です。

  • WebAssembly ではファイルシステムアクセスが出来ないため、--embed-file オプションを使って、モデルやベンチマーク対象画像を *.wasm ファイル内に埋め込んだ。
  • ベンチマークは --emrun オプションを使い、SUFFIX .html の指定をすることで emrun コマンドで実行可能にした。
  • SIMD あり・なし、SIMD + マルチスレッドをそれぞれビルドした(マルチスレッドオンリーはさぼって作ってない)。
  • ベンチマークはネイティブ版もビルド出来るようにする

ソースコードの記述

ベンチマークのソースコードはこんな風になります。

opencv_perf.cpp
#include <benchmark/benchmark.h>
#include <fmt/core.h>
#include <opencv2/opencv.hpp>

#ifdef EMSCRIPTEN
const char *imgDir = "/images";
const char *imgPath = "/images/selfie.png";
const char *onnxPath = "/yunet.onnx";
#else
const char *imgDir = "../images";
const char *imgPath = "../images/selfie.png";
const char *onnxPath = "yunet.onnx";
#endif

void BM_FaceDetectYN(benchmark::State &state) {
  cv::Mat origImg = cv::imread(imgPath, cv::IMREAD_COLOR);
  if (origImg.empty()) {
    state.SkipWithError("Load image error");
  }
  cv::Mat img;
  cv::resize(origImg, img, cv::Size(state.range(0), state.range(1)));
  if (img.empty()) {
    state.SkipWithError("Resize error");
  }

  auto faceDetector = cv::FaceDetectorYN::create(onnxPath, "", cv::Size(0, 0));
  if (faceDetector.empty()) {
    state.SkipWithError("ONNX load error");
  }

  faceDetector->setInputSize(cv::Size(img.cols, img.rows));
  for (auto _ : state) {
    cv::Mat faces;
    auto ret = faceDetector->detect(img, faces);
  }
}

BENCHMARK(BM_FaceDetectYN)
    ->Args({320, 180})
    ->Args({640, 360})
    ->Args({1280, 720})
    ->Args({1920, 1080})
    ->Args({640, 480});

Google benchmark の機能で、後ろの方の for (auto _ : state) のループの内側が計測対象になります。また、BENCHMARK の後の Args で与えた引数が state.range(0), state.range(1) に入った状態で Args を指定した数だけ呼ばれることを利用して、元画像をリサイズし、様々な解像度でベンチマークを取る設定を一発で行っています。

ビルド

先にビルドの話をしましょう。上の方で説明したように CMakeLists.txt で小細工をしたのでネイティブ版と WebAssembly 版の両方がビルドできます。

ネイティブ版のビルド

mkdir build && cd build && cmake -DCMAKE_BUILD_TYPE=Release .. && cmake --build . -j$(nproc) とすればビルドできます。ベンチマークの実行バイナリは opencv_perf です。Debug/Release 等のビルド対象の設定は cmake --build . --config Release でビルド時に指定するのではなく、コンフィグ時に設定しておかないと、google/benchmark のライブラリがリリースビルドにならず、実行時に指摘されます。

WebAssembly 版のビルド

emcmake を挟みます。. /emsdk/emsdk_env.sh && mkdir build_wasm && cd build_wasm && emcmake cmake -DCMAKE_BUILD_TYPE=Release .. && cmake --build -j$(nproc) とすればビルドできます。こちらは生成物が多くなります。

ベンチマーク関連のファイルは opencv_perf*.{wasm,js,html} です。*.wasm が肝心の WebAssembly バイナリそのもの、*.js がその Emscripten ラッパー、*.html がさらにその JS を呼び出す html で、本来はこの HTML をブラウザで表示することで WebAssembly を実行します。今回は --emrun の指定をしたので、コマンドラインで確認することができます。opencv_perf.* が SIMDあり・マルチスレッド無しのバイナリ、opencv_perf_simd.* が SIMD を有効にしたもの、opencv_perf_simd_thred.* が SIMD + マルチスレッドを両方有効にしたものです。

ベンチマーク結果

ベンチマーク結果を見てみましょう。ネイティブ版は単に ./opencv_perf として実行するだけです。

$ ./opencv_perf
2021-12-20T12:50:28+00:00
Running ./opencv_perf
Run on (24 X 4300 MHz CPU s)
CPU Caches:
  L1 Data 32 KiB (x12)
  L1 Instruction 32 KiB (x12)
  L2 Unified 512 KiB (x12)
  L3 Unified 32768 KiB (x1)
Load Average: 0.97, 5.34, 4.55
--------------------------------------------------------------------
Benchmark                          Time             CPU   Iterations
--------------------------------------------------------------------
BM_FaceDetectYN/320/180      1859779 ns      1785769 ns          407
BM_FaceDetectYN/640/360      4824544 ns      4784573 ns          130
BM_FaceDetectYN/1280/720    19145708 ns     18964265 ns           34
BM_FaceDetectYN/1920/1080   57264523 ns     56865623 ns           13
BM_FaceDetectYN/640/480      6356397 ns      6295318 ns          101

CPU 情報が表示されたりして便利ですね。コンソール実行時は色付けがされます(が、別にわかりやすいわけではないような)。時間の単位がナノ秒なのでぱっと見が分かりにくいですが、単位変更のオプションとかはなさそうでした。

画素数が 4 倍(幅 2 倍、高さ 2 倍)になると実行時間が 4 倍弱になっていますね。ネイティブだったら 1280x720 でも 19ms 程度で終わるので、30fps のカメラ入力が捌ききれる、って感じでしょうか。

続いて SIMD/スレッドサポートなしの WebAssembly 版を見てみます。

$ emrun --browser chrome --browser_args="--headless --remote-debugging-port=9222" --kill_exit opencv_perf.html 
[1220/124956.823716:ERROR:bus.cc(393)] Failed to connect to the bus: Failed to connect to socket /var/run/dbus/system_bus_socket: No such file or directory

DevTools listening on ws://127.0.0.1:9222/devtools/browser/ddee1ae0-bd47-45e0-a2b7-279ef1d59d39
[1220/124956.875807:ERROR:sandbox_linux.cc(376)] InitializeSandbox() called with multiple threads in process gpu-process.
failed to open /proc/cpuinfo
2021-12-20T12:49:58+00:00
Running ./this.program
Run on (-1 X 1000 MHz CPU )
--------------------------------------------------------------------
Benchmark                          Time             CPU   Iterations
--------------------------------------------------------------------
BM_FaceDetectYN/320/180     36558947 ns     36558684 ns           19
BM_FaceDetectYN/640/360    146970000 ns    146971000 ns            5
BM_FaceDetectYN/1280/720   581739999 ns    581745000 ns            1
BM_FaceDetectYN/1920/1080 1315415000 ns   1315420000 ns            1
BM_FaceDetectYN/640/480    193160000 ns    193161250 ns            4

WebAssembly からは CPU 情報が取れません。完全な余談ですが、Google Meet は事前に入れてある拡張機能と通信して CPU 情報を取ってきてたりするのでズルいです。
さて、ベンチマーク結果の方に戻ると、320x180 ですら 36ms と、なかなか厳しいです。これでは厳しいので続けて SIMD 有効版をみてみましょう。

$ emrun --browser chrome --browser_args="--headless --remote-debugging-port=9222" --kill_exit opencv_perf_simd.html 
[1220/130922.934755:WARNING:discardable_shared_memory_manager.cc(198)] Less than 64MB of free space in temporary directory for shared memory files: 61
[1220/130922.936799:ERROR:bus.cc(393)] Failed to connect to the bus: Failed to connect to socket /var/run/dbus/system_bus_socket: No such file or directory
[1220/130922.937974:ERROR:socket_posix.cc(150)] bind() failed: Address already in use (98)
[1220/130922.938034:ERROR:socket_posix.cc(150)] bind() failed: Cannot assign requested address (99)
[1220/130922.938057:ERROR:devtools_http_handler.cc(298)] Cannot start http server for devtools.
[1220/130922.965562:ERROR:sandbox_linux.cc(376)] InitializeSandbox() called with multiple threads in process gpu-process.
[1220/130922.969968:ERROR:command_buffer_proxy_impl.cc(125)] ContextResult::kTransientFailure: Failed to send GpuControl.CreateCommandBuffer.
failed to open /proc/cpuinfo
2021-12-20T13:09:24+00:00
Running ./this.program
Run on (-1 X 1000 MHz CPU )
--------------------------------------------------------------------
Benchmark                          Time             CPU   Iterations
--------------------------------------------------------------------
BM_FaceDetectYN/320/180      7868315 ns      7868258 ns           89
BM_FaceDetectYN/640/360     29240833 ns     29240833 ns           24
BM_FaceDetectYN/1280/720   119776000 ns    119775000 ns            5
BM_FaceDetectYN/1920/1080  281255000 ns    281257500 ns            2
BM_FaceDetectYN/640/480     39878611 ns     39878611 ns           18

320x180 が 7.8ms とかなり高速化しました。推論実行に SIMD は有効なようです。640x360 が 29ms なので(他の処理を考えると) 30fps がギリギリっぽい感じです。
もうちょっと頑張って欲しいのでスレッドも有効にしたバージョンを見てみましょう。

$ emrun --browser chrome --browser_args="--headless --remote-debugging-port=9222" --kill_exit opencv_perf_simd_thread.html 
[1220/131336.200363:ERROR:bus.cc(393)] Failed to connect to the bus: Failed to connect to socket /var/run/dbus/system_bus_socket: No such file or directory

DevTools listening on ws://127.0.0.1:9222/devtools/browser/b6cf9581-0683-4d34-870d-ce0da4d1823f
[1220/131336.227971:ERROR:sandbox_linux.cc(376)] InitializeSandbox() called with multiple threads in process gpu-process.
[1220/131336.232235:ERROR:command_buffer_proxy_impl.cc(125)] ContextResult::kTransientFailure: Failed to send GpuControl.CreateCommandBuffer.
failed to open /proc/cpuinfo
2021-12-20T13:13:37+00:00
Running ./this.program
Run on (-1 X 1000 MHz CPU )
Blocking on the main thread is very dangerous, see https://emscripten.org/docs/porting/pthreads.html#blocking-on-the-main-browser-thread
--------------------------------------------------------------------
Benchmark                          Time             CPU   Iterations
--------------------------------------------------------------------
BM_FaceDetectYN/320/180     16152500 ns     16152500 ns           44
BM_FaceDetectYN/640/360     19923824 ns     19923676 ns           34
BM_FaceDetectYN/1280/720    39404722 ns     39404722 ns           18
BM_FaceDetectYN/1920/1080   75650714 ns     75651429 ns            7
BM_FaceDetectYN/640/480     22737258 ns     22737258 ns           31

更に速くなりました。もっとも、スレッドプールを 16 個使う設定にしているので、並列数の割には速くなってない気もします。この辺は WebAssembly のスレッドサポートが実際には Web Worker として実装されていることに関係がありそうですが、さすがにそこまで詳しくは調べられていません。ともあれ、VGA の 640x480 くらいなら 22ms で 30fps 達成できそうなので、この解像度で行くことにします(ネイティブ版の実行結果にもあったように、私の PC はかなり強めのスペックです)。

ブラウザから呼び出して使う関数のソースコードの記述

ブラウザから呼び出して使う WebAssembly のコードを書いていきます。opencv_wasm.cpp に全て記述されています。初期化部分と推論部分を分けて、画像入出力は事前確保したバッファを経由させています。ちょっと独特なのは入出力画像がYUV_I420になっていることでしょうか。これは、[MediaStreamTrack Insertable Streams (a.k.a Breakout Box)])(https://chromestatus.com/feature/5499415634640896) という機能を使った際に元々得られる VideoFrame というデータ構造が YUV_I420 で画像を保持しているためです。ブラウザの機能を使えば RGBA に変換してから WebAssembly で処理することもできますが、結局もう一度それを BGR に変換しないとならないので、だったら最初から YUV_I420 から処理しよう、という判断です。ちなみに今気づいたのですが、バッファサイズを RGBA 想定の HxWx4 で確保したままですね…。

呼び出す側の記述

React やらのフロントエンド側の話になるので細かいことは省略します。リポジトリの demo ディレクトリを見てください。

Emscripten で作成した .js.wasm の扱いですが、最近は色々と改善して、-s EXPORT_ES6 オプションや webpack 5 側の対応などで何とかモジュールバンドラで取り扱えるようになりつつあります。しかし、今回のようにマルチスレッドを有効にした場合は Worker 側を独立したファイルにしなければならず、そのファイル名が…とか色々と問題があります。そういう事情があるので、今のところは public ディレクトリに静的コンテンツとして .js.wasm を置いておきます。

MediaStreamTrack Insertable Streams を使って呼び出すあたりのソースコードと wasm モジュールの型情報を lib ディレクトリに入れてある他は pages/index.tsx に全部書いてあります。

WebAssembly のマルチスレッド機能を使うためには SharedArrayBuffer が必要で、SharedArrayBuffer を使うには cross-origin isolation が構成されている必要があります。具体的にはこの設定のように COEP/COOP ヘッダを付与すれば良いです。…が、なぜかデモサイトを置いた vercel からだと普通に動いていて謎…。

動作デモ

デモサイトを用意したので、Google Chrome または MS Edge でアクセスしてみてください。カメラ使用の権限付与をすると Web カメラの映像に対して顔検出を行い、矩形描画して可視化して表示されます。左が元映像、右が加工後の映像です。カメラ(ついでにマイク)は上のドロップダウンで選択します。

スクリーンショットを撮って気づいたのですが、I420 で色差情報が間引かれている関係で、1 ピクセル幅の矩形描画の色情報が欠落してますね。右と上は青く描画されてますが、左と下が黒くなっています(ソースコードでは普通に全部青く描いた)。

今回、ちょっとしたおまけ機能として WebRTC に接続して、加工済みの映像を流す機能を含めています。時雨堂さんの Sora Labo という WebRTC SFU の検証用のサービスを利用しています。

Sora Labo の利用条件、ドキュメントを良く読んで、 GitHub アカウント連携をするとダッシュボードにアクセスすることが出来、そこにシグナリングキーが発行されています。このキーを先ほどのデモサイトの Signaling Key のフィールドに入力します。channel のフィールドにはドキュメントに従いチャンネル ID を入力します。GitHubアカウント名@適当な文字列 の形式になっている必要があります。

この 2 つの文字列を入力して Connect ボタンを押すと Sora Labo のサーバへ WebRTC で映像送信が始まります。実際に別のウィンドウで受信してみましょう。Sora Labo のダッシュボードに戻り、「Sora DevTools を利用したサンプル」の「マルチストリーム受信」のリンクを開きます。channelId がデフォルトでは GitHubアカウント名@sora-devtools となっているので、デモサイトで指定したチャンネル ID に書き換えます。他のオプションはそのままで connect ボタンを押すと受信が始まります。

WebRTC でサーバ経由で通信しているので、マルチストリーム受信の URL を別の PC やスマホで開けば、そちらでも受信することが出来ます。

おまけ

せっかくコンテナに仕込んでおいたので firefox でもパフォーマンスベンチマークの結果を見てみましょう。emrun の起動オプションで簡単に切り替えられます。茶番は無しで SIMD + マルチスレッド有効のものだけ。

$ emrun --browser firefox --browser_args="--headless" --kill_exit opencv_perf_simd_thread.html 
*** You are running in headless mode.
[GFX1-]: glxtest: libpci missing
[GFX1-]: glxtest: libGL.so.1 missing
[GFX1-]: glxtest: libEGL missing
[GFX1-]: No GPUs detected via PCI
[GFX1-]: RenderCompositorSWGL failed mapping default framebuffer, no dt
failed to open /proc/cpuinfo
2021-12-20T16:55:46+00:00
Running ./this.program
Run on (-1 X 1000 MHz CPU )
Blocking on the main thread is very dangerous, see https://emscripten.org/docs/porting/pthreads.html#blocking-on-the-main-browser-thread
--------------------------------------------------------------------
Benchmark                          Time             CPU   Iterations
--------------------------------------------------------------------
BM_FaceDetectYN/320/180     14309583 ns     14310000 ns           48
BM_FaceDetectYN/640/360     19874286 ns     19874286 ns           35
BM_FaceDetectYN/1280/720    46812000 ns     46812000 ns           15
BM_FaceDetectYN/1920/1080   92808571 ns     92808571 ns            7
BM_FaceDetectYN/640/480     23574000 ns     23574000 ns           30

良い感じに見えますね。フロントエンド側を Chrome 前提で作っちゃったので今回はパスしますが、requestAnimationFrame ベースで作ればまあまあ行けそうに見えます。

おまけ 2

上の動作デモではエルシャダイ公式トレーラー動画の高画質動画を使わせていただきました。OBS-Studio を使って動画を仮想カメラに出力して、それをブラウザの入力としています。こういったカメラ入力を用いる開発では、仮想カメラを設定しておくと、再現性のある入力映像を得られるので何かと便利です。

おわりに

他人の褌で相撲を取ってる上にワンテーマなのであっちこっち脱線しながら書いてしまいました。自分で WebAssembly として OpenCV をビルドするだけでなく、更に発展させて以下のようなことも説明しました。

  • OpenCV.js のチュートリアルページがプレイグラウンド的に使えること
  • google/benchmark が便利なこと
  • next.js のような実用的な Web フロントエンド開発環境への組み込み
  • 画像処理したストリームが実際に WebRTC でオンライン会議システムに使えるレベルになっていること

といった辺りまで解説しました。

明日のアドベントカレンダーの担当も私です。頑張って書きました。

Discussion