あけましておめでとうございます。
この記事は、私が年末年始に取り組んだ、QRコード検出アプリ開発の記録です。
はじめに
画像からQRコードを検出するWebアプリ「QR Scanner Online」を開発しました。
特徴
1. 画像のコピー&ペースト、ドラッグ&ドロップに対応
2. 複数の QR コード検出に対応
3. C++, WebAssembly を用いて、QR コード検出処理を高速化
App URL
GitHubリポジトリ
アプリケーション構成
実装
QRコード検出処理
C++側
本アプリでは、QRコード検出に、OpenCVのQRCodeDetector
クラスのdetectAndDecodeMulti
関数を使用しています。
この関数により、入力画像に含まれる複数のQRコードを同時検出し、それらのデコード結果(URL)を一度に得ることができます。
下記リンク先のOpenCVのソースコードを見ると分かる通り、この関数ではdetectMulti
関数とdecodeMulti
関数を内部的に呼び出すことで、この機能を実現しています。
QRコード検出処理は、高解像度の画像が入力された場合や、多数のQRコードを検出する必要がある場合に、処理に時間がかかることが想定されます。そこで本アプリでは、検出処理をC++で実装することで、処理の高速化を図っています。
/**
* 複数の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;
}
//! QRコードの座標
cv::Mat_<cv::Vec2f> _points;
//! QRコードのデコード結果(URL)
std::vector<std::string> _decoded_info;
detectAndDecodeMulti
関数の第一引数で入力画像のデータを指定し、第二引数、第三引数にそれぞれ検出されたQRコードのURLおよび座標を代入するための変数を渡しています。
以上のC++ソースコードを、WebAssemblyにコンパイルし、JavaScriptから呼び出します。JavaScriptから呼び出すにあたって、今回はEmscriptenのEmbindを用います。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
を記述する必要があります。
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 と並行して動作するように設計されているため、両方を連携させることができます。
このように、C/C++やRustなどの言語をWebAssemblyにコンパイルすることで、ブラウザ上で実行することができるようになります。これにより、処理を高速化したい部分をC/C++やRustで実装するといった工夫をWebアプリ開発においても行えるようになります。
C/C++ソースコードは、Emscriptenというツールを用いることで、WebAssemblyにコンパイルできます。今回は、以下のようにemscripten/emsdkのDockerイメージを用いてコンパイルします。
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をビルドし、コンテナにインストールします。ビルド手順は以下のドキュメントページを参考にしました。
次に、インストールしたOpenCVと、自作関数を定義したcppファイルをまとめてWebAssemblyにコンパイルします。
WORKDIR /app
COPY CMakeLists.txt .
COPY src ./src
WORKDIR /app/build
RUN emcmake cmake ..
RUN emmake make
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を用いてワーカースレッドで実行しています。これにより、メインスレッドの処理をブロックせずに検出処理を実行することができます。
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つずつメインスレッドに返しています。
以上のワーカースレッドでの処理を、以下のようにメインスレッドから呼び出します。
// ワーカースレッドで検出処理を実行
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を使用しています。また、状態管理にRedux Toolkit、スタイリングにMUI (Material-UI) およびemotionを使用しています。
トップページ(検出前)
トップページ(検出後)
CI/CD
本アプリでは、GitHub Actionsを用いてCI/CD環境を構築しています。GitHub Actionsでは主に以下の4ステップをmainブランチへのpush時に自動実行しています。
- Dockerイメージのビルド、ghcr.ioへのプッシュ
- Dockerコンテナの立ち上げ、WebAssemblyを含むJavaScriptファイルの入手
- Next.jsのビルド
- Vercelへのデプロイ
その他
Reactコンポーネントのデバッグに、Storybookを使用しました。Storybookを使うことで、ブラウザ上でコンポーネント1つ1つの見た目や挙動を確認することができます。またブラウザ上でスタイルやstate、propsの値を書き換えながらテストすることも可能です。
おわりに
本記事では、私が先日個人開発したQRコード検出アプリについて、技術的な内容を紹介しました。今回の開発ではWebAssemblyやNext.jsなど初めて使用する技術が多く、非常に学びの多い開発でした。特にWebAssemblyについては、機械学習アプリケーションに代表されるように年々リッチになるWebアプリ開発において、今後ますます重要な技術になり、普及が進むものと思われます。例えば、Google Meetの背景ぼかし機能やバーチャル背景機能は、WebAssemblyを用いて実装されています。
機械学習ライブラリのTensorFlow.jsでは、WebGLバックエンドの他に、WebAssemblyバックエンドもデフォルトでサポートされているため、比較的簡単にWebAssemblyを用いたWebMLアプリケーションを開発できそうです。
(ただし、WebGLとWebAssemblyのどちらが高速かは、MLモデルのサイズやデバイスなどによって異なります)
機会があればこのようなWebMLアプリケーション開発にも取り組んでみたいと思います。