【OpenCV × WebAssembly × Next.js】QRコード検出アプリを開発してみた

あけましておめでとうございます。
この記事は、私が年末年始に取り組んだ、QRコード検出アプリ開発の記録です。

# はじめに
画像からQRコードを検出するWebアプリ「**[QR Scanner Online](https://qr-scanner-online.wowry.dev/)**」を開発しました。
![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282840/01bfd6b2-25fc-3aea-3c26-b0e35dad8480.gif)

### 特徴
**1. 画像のコピー&ペースト、ドラッグ&ドロップに対応**
**2. 複数の QR コード検出に対応**
**3. C++, WebAssembly を用いて、QR コード検出処理を高速化**

### App URL
https://qr-scanner-online.wowry.dev/

### GitHubリポジトリ
https://github.com/wowry/qr-scanner-online

# アプリケーション構成
![アプリケーション構成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282840/b4fc5b79-e027-9e7c-a576-b45bb7448dc5.png)

# 実装
## QRコード検出処理
### C++側
本アプリでは、QRコード検出に、OpenCVの`QRCodeDetector`クラスの`detectAndDecodeMulti`関数を使用しています。

https://docs.opencv.org/4.5.5/de/dc3/classcv_1_1QRCodeDetector.html#a188b63ffa17922b2c65d8a0ab7b70775

この関数により、入力画像に含まれる複数のQRコードを同時検出し、それらのデコード結果（URL）を一度に得ることができます。
下記リンク先のOpenCVのソースコードを見ると分かる通り、この関数では`detectMulti`関数と`decodeMulti`関数を内部的に呼び出すことで、この機能を実現しています。

https://github.com/opencv/opencv/blob/4.x/modules/objdetect/src/qrcode.cpp#L3646

QRコード検出処理は、高解像度の画像が入力された場合や、多数のQRコードを検出する必要がある場合に、処理に時間がかかることが想定されます。そこで本アプリでは、検出処理をC++で実装することで、処理の高速化を図っています。

```c++:src/models/src/detect.cpp
/**
 * 複数のQRコードを同時検出する
 * @param addr 入力画像のアドレス
 * @param width 入力画像の幅
 * @param height 入力画像の高さ
 * @return QRコードが検出されたかどうか
 */
bool Detect::detect(size_t addr, int width, int height)
{
  auto data = reinterpret_cast<void *>(addr);
  cv::Mat img(height, width, CV_8UC4, data);

  cv::QRCodeDetector detector;

  bool hasCode = detector.detectAndDecodeMulti(img, _decoded_info, _points);

  return hasCode;
}
```
```c++:src/models/src/detect.hpp
//! QRコードの座標
cv::Mat_<cv::Vec2f> _points;

//! QRコードのデコード結果（URL）
std::vector<std::string> _decoded_info;
```
`detectAndDecodeMulti`関数の第一引数で入力画像のデータを指定し、第二引数、第三引数にそれぞれ検出されたQRコードのURLおよび座標を代入するための変数を渡しています。

以上のC++ソースコードを、**WebAssemblyにコンパイルし、JavaScriptから呼び出します**。JavaScriptから呼び出すにあたって、今回はEmscriptenの[Embind](https://emscripten.org/docs/porting/connecting_cpp_and_javascript/embind.html)を用います。Embindを使うことで、あたかもJavaScriptによって実装された変数・関数・クラスかのように、透過的にC++ソースコードを呼び出すことができます。

Embindでは、以下のように`EMSCRIPTEN_BINDINGS`を用いて、JavaScriptから呼び出したいオブジェクトをcppファイルに記述します。以下では`Detect`クラスの`detect`関数、`getPoints`関数、`getDecodedInfo`関数を指定しています。ここで、`getPoints`関数と`getDecodedInfo`関数の戻り値の型はそれぞれ`cv::Mat_<cv::Vec2f>`および`std::vector<std::string>`なのですが、このような`std::vector<T>`型をJavaScriptから呼び出すためには、以下のように`emscripten::register_vector`を記述する必要があります。

```c++:src/models/src/detect.cpp
EMSCRIPTEN_BINDINGS(my_module)
{
  emscripten::class_<Detect>("Detect")
      .constructor()
      .function("detect", &Detect::detect)
      .function("getPoints", &Detect::getPoints)
      .function("getDecodedInfo", &Detect::getDecodedInfo);

  emscripten::register_vector<float>("vector<float>");
  emscripten::register_vector<std::string>("vector<string>");
}
```

### WebAssembly
> WebAssembly はモダンなウェブブラウザーで実行できる新しいタイプのコードです。ネイティブに近いパフォーマンスで動作するコンパクトなバイナリー形式の低レベルなアセンブリ風言語です。さらに、 C/C++ や Rust のような言語のコンパイル対象となって、それらの言語をウェブ上で実行することができます。 WebAssembly は JavaScript と並行して動作するように設計されているため、両方を連携させることができます。

> 出典：https://developer.mozilla.org/ja/docs/WebAssembly

このように、C/C++やRustなどの言語をWebAssemblyにコンパイルすることで、ブラウザ上で実行することができるようになります。これにより、処理を高速化したい部分をC/C++やRustで実装するといった工夫をWebアプリ開発においても行えるようになります。

C/C++ソースコードは、[Emscripten](https://emscripten.org/)というツールを用いることで、WebAssemblyにコンパイルできます。今回は、以下のように[emscripten/emsdk](https://hub.docker.com/r/emscripten/emsdk)のDockerイメージを用いてコンパイルします。

```Dockerfile:src/models/Dockerfile
FROM emscripten/emsdk:latest

WORKDIR /app
RUN git clone https://github.com/opencv/opencv.git --depth 1

WORKDIR /app/opencv
RUN python3 ./platforms/js/build_js.py build_wasm --build_wasm --emscripten_dir=/emsdk/upstream/emscripten \
  --config_only --cmake_option="-DCMAKE_INSTALL_PREFIX=/usr/local"

WORKDIR /app/opencv/build_wasm
RUN emmake make -j8
RUN emmake make install
```
まずOpenCVのリポジトリをcloneします。そしてEmscriptenを用いてOpenCV.jsをビルドし、コンテナにインストールします。ビルド手順は以下のドキュメントページを参考にしました。

https://docs.opencv.org/4.5.5/d4/da1/tutorial_js_setup.html

次に、インストールしたOpenCVと、自作関数を定義したcppファイルをまとめてWebAssemblyにコンパイルします。

```Dockerfile:src/models/Dockerfile
WORKDIR /app
COPY CMakeLists.txt .
COPY src ./src

WORKDIR /app/build
RUN emcmake cmake ..
RUN emmake make
```
```cmake:src/models/CMakeLists.txt
cmake_minimum_required(VERSION 2.8.12)
project(detector LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} --bind -O3 \
  -s ALLOW_MEMORY_GROWTH=1 -s ENVIRONMENT=worker -s SINGLE_FILE=1 \
  -s EXPORT_ES6=1 -s MODULARIZE=1 -s EXPORT_NAME=detector")

include_directories(/usr/local/include/opencv4)

file(GLOB_RECURSE sources_main "src/detect.cpp")
add_executable(${PROJECT_NAME} ${sources_main})

file(GLOB opencv_lib "/usr/local/lib/*.a")
file(GLOB opencv_lib_3rdparty "/usr/local/lib/opencv4/3rdparty/*.a")
target_link_libraries(${PROJECT_NAME} ${opencv_lib} ${opencv_lib_3rdparty})
```
今回はemcmakeのビルドオプションに`SINGLE_FILE=1`を指定しているので、WebAssemblyのコードを内部に含む単一のJavaScriptファイルが出力されます。
※ `SINGLE_FILE=0`の場合は、WebAssemblyファイル（.wasm）とJavaScriptファイル（.js）が別々に出力されます。

### JavaScript側
次に、コンパイルして得られたWebAssemblyを含むJavaScriptファイルを呼び出す側のJavaScriptソースコードを実装します。本アプリでは、上記のC++で実装したQRコード検出処理を、Web Workerを用いてワーカースレッドで実行しています。これにより、メインスレッドの処理をブロックせずに検出処理を実行することができます。

```js:src/libs/worker.js
import detector from "../models/build/detector";

addEventListener("message", ({ data: { data, width, height } }) => {
  detector().then((Module) => {
    const detector = new Module.Detect();

    const buffer = Module._malloc(data.data.length);
    Module.HEAPU8.set(data.data, buffer);

    const hasCode = detector.detect(buffer, width, height);

    const decodedInfo = detector.getDecodedInfo();
    const codeNum = decodedInfo.size();
    const points = detector.getPoints();

    if (codeNum === 0) {
      postMessage({ codeNum: 0 });
    }

    for (var i = 0; i < codeNum; i++) {
      let pointArray = [];

      for (let j = 0; j < 8; j++) {
        pointArray.push(points.get(i * 8 + j));
      }

      const url = decodedInfo.get(i);

      postMessage({ i, codeNum, pointArray, url });
    }
  });
});
```
1行目で、コンパイルの結果得られた`detector.js`というファイルをimportしています。そして5行目の`const detector = new Module.Detect();`で、C++で実装された`Detect`クラスを呼び出し、インスタンスを生成しています。インスタンスを生成した後は、`detector.detect()`のように、通常のJavaScriptで実装されたクラスのメソッドと同じようにして呼び出せます。また、`Module._malloc`関数や`Module.HEAPU8.set`関数は、デフォルトで（`EMSCRIPTEN_BINDINGS`で指定しなくても）呼び出すことができ、メモリの動的確保をC++と同様に行うことができます。

ここでは、`detector.detect`関数でQRコード検出処理を実行し、`detector.getDecodedInfo`関数および`detector.getPoints`関数で検出結果を受け取っています。そして最後に`postMessage({ i, codeNum, pointArray, url });`で、複数のQRコード検出結果を1つずつメインスレッドに返しています。

以上のワーカースレッドでの処理を、以下のようにメインスレッドから呼び出します。

```js:src/components/Scanner/_components/InputPane/ImageCard.tsx
// ワーカースレッドで検出処理を実行
const worker = new Worker(
  new URL("../../../../libs/worker", import.meta.url)
);

worker.postMessage({ data, width, height });

// 検出結果を1つずつ取得
worker.addEventListener(
  "message",
  ({ data: { i, codeNum, pointArray, url } }) => {
    // ...![検出結果.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282840/a5eaa01a-f3cd-014d-1a1b-d39b59e7ba0d.png)

    console.log(url);

    const color = `hsl(${(i * 360) / codeNum}, 100%, 40%)`;

    dispatch(pushResult({ url: url, color: color }));

    ctx.strokeStyle = color;
    ctx.lineWidth = 20;

    // lineWidth分オフセットして矩形を描画
    const x = pointArray[0] - ctx.lineWidth / 2;
    const y = pointArray[1] - ctx.lineWidth / 2;
    const width = pointArray[2] - pointArray[0] + ctx.lineWidth;
    const height = pointArray[7] - pointArray[1] + ctx.lineWidth;

    ctx.strokeRect(x, y, width, height);

    // 全コード検出完了
    if (i === codeNum - 1) {
      setIsLoading(false);
      dispatch(complete());
    }
  }
);
```
ワーカースレッドからQRコードの検出結果を1つずつ受け取り、その度に上記の`message`イベントハンドラがトリガーされます。イベントハンドラでは、受け取ったQRコードの座標データ`pointArray`から、QRコードの周囲に矩形を描画する処理を行っています。矩形の線色は、色相環を検出されたQRコードの数`codeNum`で等分した点の色をそれぞれ使用しています。

## UI
本アプリでは、UIの実装に[Next.js](https://nextjs.org/)を使用しています。また、状態管理に[Redux Toolkit](https://redux-toolkit.js.org/)、スタイリングに[MUI (Material-UI) ](https://mui.com/)および[emotion](https://emotion.sh/docs/introduction)を使用しています。

### トップページ（検出前）
![アプリトップページ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282840/8ccfeb1c-3a89-67c5-9500-04802915ec2b.png)

### トップページ（検出後）
![検出結果.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282840/62940e3b-8028-e446-a11b-6f7faa9aae3f.png)

# CI/CD
本アプリでは、[GitHub Actions](https://github.co.jp/features/actions)を用いてCI/CD環境を構築しています。GitHub Actionsでは主に以下の4ステップをmainブランチへのpush時に自動実行しています。

1. Dockerイメージのビルド、[ghcr.io](https://docs.github.com/ja/packages/working-with-a-github-packages-registry/working-with-the-container-registry)へのプッシュ
2. Dockerコンテナの立ち上げ、WebAssemblyを含むJavaScriptファイルの入手
3. Next.jsのビルド
4. [Vercel](https://vercel.com/)へのデプロイ

![CICD.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282840/e850cc18-d13f-2d74-789b-3113c2854a5f.png)

# その他
Reactコンポーネントのデバッグに、[Storybook](https://storybook.js.org/)を使用しました。Storybookを使うことで、ブラウザ上でコンポーネント1つ1つの見た目や挙動を確認することができます。またブラウザ上でスタイルやstate、propsの値を書き換えながらテストすることも可能です。
![storybook.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282840/20913a60-800d-a677-6374-36e7cc027638.png)

# おわりに
本記事では、私が先日個人開発したQRコード検出アプリについて、技術的な内容を紹介しました。今回の開発ではWebAssemblyやNext.jsなど初めて使用する技術が多く、非常に学びの多い開発でした。特にWebAssemblyについては、機械学習アプリケーションに代表されるように年々リッチになるWebアプリ開発において、今後ますます重要な技術になり、普及が進むものと思われます。例えば、Google Meetの背景ぼかし機能やバーチャル背景機能は、WebAssemblyを用いて実装されています。

https://ai.googleblog.com/2020/10/background-features-in-google-meet.html

機械学習ライブラリのTensorFlow.jsでは、WebGLバックエンドの他に、WebAssemblyバックエンドもデフォルトでサポートされているため、比較的簡単にWebAssemblyを用いたWebMLアプリケーションを開発できそうです。
（ただし、WebGLとWebAssemblyのどちらが高速かは、MLモデルのサイズやデバイスなどによって異なります）

https://www.tensorflow.org/js/guide/platform_environment?hl=ja

機会があればこのようなWebMLアプリケーション開発にも取り組んでみたいと思います。
:wave: