[JavaScript] 極小2.4MBのOpenCV Wasmを自作してブラウザで画像処理(Inpainting)を動かす

[JavaScript] 極小2.4MBのOpenCV Wasmを自作してブラウザで画像処理(Inpainting)を動かす

はじめに: なぜOpenCVをWasm化するのか

Astroで構築している当ブログの環境においても、フロントエンド単体での表現力や処理能力を高める上でWebAssembly(Wasm)は非常に強力な選択肢です。今回はC++で書かれたOpenCVをEmscriptenでビルドし、ブラウザ上で直接インタラクティブな画像処理(Inpainting)を動かす実装手順をまとめました。

スクリーンショット

画像を読み込み。

[JavaScript] 極小2.4MBのOpenCV Wasmを自作してブラウザで画像処理(Inpainting)を動かす

クリックで消したい所の模様を塗りつぶす。

[JavaScript] 極小2.4MBのOpenCV Wasmを自作してブラウザで画像処理(Inpainting)を動かす

洗濯を消去ボタンで生成消去。

[JavaScript] 極小2.4MBのOpenCV Wasmを自作してブラウザで画像処理(Inpainting)を動かす

1. Wasmビルド環境の構築とOpenCVの軽量化(2.4MBへの道)

通常のOpenCVをそのままWeb用にビルドすると、ファイルサイズが10MBを優に超え、ブラウザの初期ロードで画面がフリーズする原因になります。Webフロントエンドで実用レベルのパフォーマンスを出すため、機能を限定して極限まで削ぎ落とした軽量ビルド(約2.4MB)を作成しました。

Emscripten SDKのセットアップ

OpenCVをWasmにコンパイルするために、C/C++をWebAssemblyに変換するツールチェーンである「Emscripten」をセットアップします。

# emsdkのリポジトリをクローン
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk

# 最新バージョンをインストールして有効化
./emsdk install latest
./emsdk activate latest

# パスを通す
source ./emsdk_env.sh

OpenCVソースコードの取得

次にOpenCVのソースコードを取得します。今回はWasmビルドが安定している最新の4.x系を使用しました。

git clone https://github.com/opencv/opencv.git
cd opencv
git checkout 4.x

【重要】JSバインディングのホワイトリスト編集(opencv_js.config.py

Wasmのサイズを劇的に下げる最大の秘訣は、「JS側に公開する関数(バインディング)を必要最小限に絞り込む」ことです。 デフォルトでは大量の関数がエクスポートされるため、platforms/js/opencv_js.config.py を直接編集し、今回使用するモジュールだけを残します。

# platforms/js/opencv_js.config.py の編集例

# core, imgproc, photo 以外の重いモジュール(dnn, objdetect, features2d等)の定義を削除またはコメントアウト
core = {
    '': [
        'Mat', 'MatVector', 'Size', 'Point', 'Scalar', 'Rect',
        'split', 'merge'
    ]
}

imgproc = {
    '': ['cvtColor', 'resize', 'circle', 'floodFill', 'threshold']
    # 意図的に grabCut などの重い処理をここで除外(※これが後のエラー原因になります)
}

photo = {
    '': ['inpaint'] # 今回の本命である生成消去モジュール
}

white_list = makeWhiteList([core, imgproc, photo])

このホワイトリストから除外された関数はWasmにコンパイルされてもJavaScriptからは呼び出せなくなり、ビルド時のデッドコード削除(Dead Code Elimination)によって最終的なバイナリサイズが大幅に削減されます。

Wasmビルドコマンドの実行

設定が完了したら、OpenCVに同梱されているビルドスクリプト build_js.py を実行します。この際、CMakeの引数(--cmake_args)を渡し、そもそも不要なC++モジュール(DNNやビデオ解析など)をビルドプロセスから完全に除外します。

# build_wasm ディレクトリにWasm版をビルド
python3 platforms/js/build_js.py build_wasm \
  --build_wasm \
  --cmake_args="-DBUILD_opencv_dnn=OFF \
                -DBUILD_opencv_calib3d=OFF \
                -DBUILD_opencv_features2d=OFF \
                -DBUILD_opencv_flann=OFF \
                -DBUILD_opencv_ml=OFF \
                -DBUILD_opencv_objdetect=OFF \
                -DBUILD_opencv_video=OFF \
                -DBUILD_opencv_videoio=OFF \
                -DBUILD_opencv_stitching=OFF \
                -DBUILD_opencv_gapi=OFF"

最適化オプションによる最終圧縮

Emscriptenのビルドプロセスでは、デフォルトで -O3(最高レベルの実行速度・サイズ最適化)が適用されます。 ビルドが完了すると build_wasm/bin ディレクトリに以下の2つのファイルが生成されます。

  • opencv.js (WasmをロードしてJSと繋ぐためのグルーコード)
  • opencv_bg.wasm (C++がコンパイルされた実体)

この絞り込みとCMakeの除外設定を組み合わせることで、初期状態で10MB前後あった opencv_bg.wasm を、フロントエンドの実用ラインである約2.4MBまで圧縮することに成功しました。

2. CanvasとWasmを連携させたInpaintingの実装

2.4MBに軽量化したWasmモジュールが準備できたら、次はフロントエンド側の実装です。ブラウザのHTML5 <canvas> を活用し、ユーザーがマウスでなぞった部分をリアルタイムに消去するインタラクティブなUIを構築しました。

Wasmメモリへの転送と動的マスク生成

通常のJavaScript画像処理とは異なり、OpenCVで処理を行うにはデータをWasm側のメモリ空間に渡す必要があります。

  • ピクセルデータの転送: cv.imread 関数を使用することで、画面上の <canvas> 要素から直接ピクセルデータを読み込みます。この処理により、JS側のデータが瞬時にOpenCVの Mat オブジェクト(多次元配列)としてWasm空間へ転送されます。
  • マスク用行列の初期化: Inpainting処理には「元画像」と「消したい場所を示すマスク画像」の2つが必要です。Wasm内で元画像と同サイズの真っ黒な(全て0の)行列を生成して待機させます。
  • リアルタイム描画同期: <canvas>mousemove イベントをフックし、ユーザーのドラッグ軌跡を取得します。その座標をもとにWasm側のマスク行列へ cv.circle で白い円(255)を描画することで、画面上の操作と内部データが遅延なく同期します。

マスクが完成したら、本命である修復処理を呼び出して結果を画面に出力します。

実行処理: cv.inpaint(srcMat, maskMat, dstMat, 3, cv.INPAINT_NS)

  • ここで指定している cv.INPAINT_NS は、流体力学を応用したNavier-Stokes(ナビエ・ストークス)方程式に基づくアルゴリズムです。
  • AIが「存在しない背景を描き足す」のとは異なり、指定領域のすぐ外側にあるピクセルの色や勾配(色の変化率)を数学的に計算し、内部へ向かって滑らかに伝播させて穴を埋めていきます。

検証結果と処理の特徴

構築したWasmエンジン上で実際に様々な画像をテストした結果、古典的画像処理アルゴリズムならではの得意・不得意が明確になりました。

  • 完璧に消去できるケース: プラスチックケース、白い壁、均一な木目などの単色や平滑な面。周囲のグラデーションが単純なため、方程式による穴埋め計算が理想的に機能し、継ぎ目が全く分からないレベルで消去できました。
  • ぼやけてしまうケース: 複雑なケーブルの束やメッシュ、服の細かいプリントなど。構造やパターンを認識しているわけではないため、周囲のピクセル色が混ざり合い、すりガラスでぼかしたような結果になります。

3. トラブルシューティングと技術的限界

今回のWasm実装の過程では、極限まで軽量化を追求したことによる「意図せぬ機能の欠落」と、AIではなく古典的アルゴリズムを使用することの「限界」にも直面しました。

grabCut 関数の欠落エラー

当初、Inpaintingに加えて「被写体の自動切り抜き(背景透過)」機能もブラウザ上で動かす予定でした。グラフカット理論を用いて前景と背景を分離するOpenCVの強力なアルゴリズム cv.grabCut を実行しようとしたところ、コンソールで以下のエラーが発生しました。

TypeError: cv.grabCut is not a function

原因は単純で、2.4MBへの極限の軽量化に際して、WasmのJSバインディング(opencv_js.config.py)から高度な処理を除外していたためです。不要な機能と共に、本来使いたかった重い関数群もごっそりとビルド対象から外れていました。

代替手段(FloodFill)による透過処理の破綻

grabCut が使えないため、代替として四隅から背景色を探索して塗りつぶす cv.floodFill (簡易背景透過)を実装して検証を行いました。しかし、この手法は実用に耐えませんでした。

  • 実写写真のノイズ: カーペットの網目や照明の複雑な陰影がある写真では、単一色による判定がすぐに途切れてしまいます。
  • 境界線の消失(浸食): 「白背景」に対して「白っぽい服や色白の肌のキャラクター」の画像を処理した場合、境界の輝度差がほとんどないため、アルゴリズムが被写体の内部まで背景と誤認して穴だらけになってしまいました。

まとめ:フロントエンド・アーキテクチャにおける「割り切り」

今回の検証を通して、ブラウザ上で動かすWasm版OpenCVには明確な役割分担が必要であると結論付けました。

  • 適している領域: 今回成功したInpaintのような、軽量なピクセル操作や単純なレタッチ機能。2.4MBというサイズならフロントエンドの初期ロードにも優しく、瞬時に動作します。
  • 適していない領域: 被写体と背景の高度な文脈推論、失われた複雑な模様の再生成。これらが必要な場合は、OpenCVの数式アルゴリズムで無理に解決しようとせず、WebGPUやONNX Runtimeを活用した軽量なディープラーニングモデル(LaMaなど)へ処理を委譲するべきです。

「すべてを一つのライブラリで解決しようとせず、Wasmエンジンの強みである軽量さと高速なメモリアクセスに特化させる」。この設計の割り切りこそが、フロントエンドでリッチな画像処理を破綻なく組み込むための重要な鍵となります。