Article:

直近の作業でブラウザ上からOpenCVを利用したくなったので、Emscriptenを用いてOpenCVとそれを呼び出すC++コードをwasmに変換し、それを呼び出すサンプルのレポジトリを作成しました。

基本的にはレポジトリのコードだけ見れば事足りる話ではあるのですが、細かい補足等をメモ程度にこの記事に残しておこうと思います。

レポジトリ: https://github.com/totegamma/opencv-web-sample

レポジトリはサブモジュールが含まれているので、--recursiveをつけてcloneするのをお忘れなく。

実行するとカメラ画像を入力にとり、カメラ映像の平均色をdocumentの背景色として表示するアプリケーションが動きます。

ビルド方法

emsdkのインストール

まず、C++コードをwasmにコンパイルするEmscriptenをインストールする必要があります。 homeディレクトリなどの適当なレポジトリに本体を配置する形のインストール方法になります。

1
2
3
4
5
$ git clone https://github.com/emscripten-core/emsdk.git --depth 1
$ cd emsdk
$ ./emsdk install latest
$ ./emsdk activate latest
$ source ./emsdk_env.sh # rcファイルに追加しておくと良い

環境変数を通すためにemsdk_env_shを実行する必要があるのですが、デフォルトだとめちゃめちゃログを垂れ流します

zshrc等でこれをやられるとあまりにもうるさすぎるので、export EMSDK_QUIET=1を直前で実行しておくことでこれを黙らせることができます。

OpenCVのwasmモジュールのビルド

今回のサンプルレポジトリはサブモジュールとしてOpenCVのレポジトリが含めてありますが、共用の場所に置いておく場合は別途cloneします。

1
git clone https://github.com/opencv/opencv.git

opencvのディレクトリ内で、emcmakeを実行することでopencvをビルドします。5分くらいかかります。

1
emcmake python ./platforms/js/build_js.py build_wasm --build_wasm

OpenCV本体のインストール

ヘッダーファイルが共通で必要になるので本体もインストールしておきます。 これはわざわざ自分でビルドしなくてもパッケージマネージャ等からもインストールできるのですが、僕の場合はバージョン違いなどでアプリケーションのビルド時に参照エラーを起こしてしまったので、同じレポジトリからインストールすることにしました。

1
2
3
4
5
mkdir build
cd build
cmake ..
make -j8
sudo make install

C++コードのビルド

やっとアプリケーションをビルドできます。

build.shを実行するのみですが、中身はこのようになっています。

1
2
3
4
5
6
emcc main.cpp \
    -I /usr/local/include/opencv4  \
    -L ./opencv/build_wasm/lib \
    -l opencv_core \
    -s NO_DISABLE_EXCEPTION_CATCHING \
    -s EXPORTED_FUNCTIONS="['_malloc', '_free']"

includeやlibraryの指定に注意です。ヘッダーファイルは共通のものを利用していますが、ここでリンクしているライブラリopencv_coreはwasmモジュールなので、-Lオプションで共通の方ではなくbuild_wasmを向くように仕向けてあげる必要があることに注意です。

また、exported_functions_malloc, _freeを個別に指定していますが、これはjavascript側からc++側の配列にアクセスする際に必要になるので、別途exportしておく必要があります。

webアプリケーションの実行

あとは通常のviteアプリケーションと同様に実行可能です。

1
2
$ pnpm i
$ pnpm dev

詳細

アプリケーション

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <emscripten.h>
#include <opencv2/opencv.hpp>
#include <cstdint>


extern "C" {
    EMSCRIPTEN_KEEPALIVE uint8_t* meanColor(const char* imgData, int width, int height);
}

uint8_t* meanColor(const char* imgData, int width, int height) {
    cv::Mat img(height, width, CV_8UC4, (void*)imgData);
    cv::Scalar mean = cv::mean(img);
    uint8_t* arr = new uint8_t[4];
    arr[0] = mean[0];
    arr[1] = mean[1];
    arr[2] = mean[2];
    arr[3] = mean[3];

    return arr;
}

javascript側から呼び出す関数はextern "C"に指定しておきます。 また、勝手にコンパイラに消されないように、EMSCRIPTEN_KEEPALIVEマクロを指定しておきます。

これだけ気を付ければ、ほかの関数は特に普通のc++として記述できます。すごい。

wasmのjavascriptへの読み込み

ビルドすると、デフォルトでa.out.jsa.out.wasmが作成されます。 この二つをpublicディレクトリに入れて(/で解決できるようにしておいて)、index.htmlでa.out.jsを読み込みます。

1
2
3
4
<head>
  ...
  <script src="a.out.js"></script>
</head>

これで、javascript側からはModuleの名前空間でwasmモジュールにアクセスできるようになります。

javascript側からの呼び出し・データのやり取り

まずtypescriptから利用するために型定義が必要です。

1
2
3
4
5
6
declare const Module: {
    _malloc: (size: number) => number;
    _free: (ptr: number) => void;
    _meanColor: (ptr: number, width: number, height: number) => number;
    HEAPU8: Uint8Array;
}

これをc++の定義から自動で作れたらそれはそれて便利そうなんですが、どう解釈させるかというのは一意に定まらなさそうなところもあり、難しい…。ひとまず手動で作ってしまいます。 また、関数はCマングルされているため、先頭に_がつきます。

配列としてデータを渡すためには、まずはc++アプリケーション側でメモリを確保して、そのポインタを得る必要があります。ここで、exportしたmallocを利用します。

1
const ptr = Module._malloc(width * height * 4);

また、TypedArrayを利用することにより、こうして確保したpointerに対してjavascriptからデータをバインドすることができます。

1
2
3
const data = new Uint8Array(Module.HEAPU8.buffer, ptr, width * height * 4);
const imgData = ctx.getImageData(0, 0, width, height);
data.set(imgData.data);

これで、c++側に配列データ(ここでは動画データ)を送る準備ができました。

呼び出し

あとは、通常通り呼び出しできます。

1
2
const meanColor = Module._meanColor(ptr, width, height);
Module._free(ptr);

通常のc++と同じく、利用後のメモリをfreeするのを忘れないように。

データの読み込み

読み込みは簡単で、返ってきたポインタをUint8Arrayとしてバインドします。

1
2
3
const color = new Uint8Array(Module.HEAPU8.buffer, meanColor, 4);
document.body.style.backgroundColor = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3]})`;
Module._free(meanColor);

ここでも、返ってきてるmeanColorポインタは呼び出し先の関数でmallocしたものなので、読み込んだらfreeしてあげます。 ただし、Uint8Arrayでバインドしているのは共通のメモリになるので、この配列の値を利用する前にfreeすると中身の完全性は保証されません。

中身を読み込んでから、freeするようにします。