韓国のバックエンドエンジニアです。留学経験を活かして、日本語でも技術発信を始めてみました。初投稿です。
モバイルブラウザでレシートをリアルタイム検出するスキャナーを作成したところ、iOS Safariで40秒ごとにページがリフレッシュされる現象が発生。OpenCV.jsを取り除き、ONNX Runtime Web + 純粋なJSに置き換えてメモリを96%削減するまでのプロセスを記録します。
問題の状況
レシートの写真を撮ると自動で輪郭を検出し、遠近補正まで行うWebアプリだ。最初は当然OpenCV.jsを使った。
iOS Safari でテストしてみると問題が発生した。
- OpenCV.js WASMバンドル: 128MB
- カメラフレームごとのキャンバスGPUメモリ: 7.9MB
- ONNXテンソルリーク: 推論あたり 〜2.4MB
iPhone 17 Proで40秒ほど動かすと、累積メモリが79MBを超えたところでブラウザがタブを強制終了した。ユーザー側から見ると、突然ページがリフレッシュされる現象として現れる。
なぜこの方法を選んだか
選択肢はいくつかあった:
| 方針 | メリット | デメリット |
|---|---|---|
| OpenCV.js 軽量ビルド | 既存コードを維持できる | それでも50MB超のWASM |
| TensorFlow.js | エコシステムが広い | バンドルが重い、モバイルで不安定 |
| ONNX Runtime Web | 4.7MB WASM、Workerによる分離が可能 | 前処理を自前で実装する必要あり |
| サーバーサイド処理 | クライアント負荷ゼロ | ネットワーク遅延、リアルタイム処理不可 |
ONNX Runtime Webを選んだ理由:
- U2-NetPモデルが4.7MBと軽量(レシートのような文書検出には十分)
- Web Workerで動かせばメインスレッドのブロッキングがゼロ
-
worker.terminate()一行でWASMヒープ全体をOSへ返却できる - OpenCVがやっていた輪郭抽出は、純粋なJS 63行で代替可能
全体アーキテクチャ
実際の実装
ステップ1: OpenCVを除去し、純粋なJSで頂点を抽出
OpenCVの findContours + approxPolyDP を代替する maskToCorners 関数を書いた。
// workers/detection-worker.ts
function maskToCorners(
mask: Float32Array, maskW: number, maskH: number,
videoW: number, videoH: number,
): Point[] | null {
const left: Point[] = [];
const right: Point[] = [];
let area = 0;
// 各行で左右の境界をスキャン
for (let y = 0; y < maskH; y++) {
const rowOff = y * maskW;
let lx = -1, rx = -1;
for (let x = 0; x < maskW; x++) {
if (mask[rowOff + x] > MASK_THRESHOLD) {
if (lx === -1) lx = x;
rx = x;
}
}
if (lx !== -1) {
left.push({ x: lx, y });
right.push({ x: rx, y });
area += rx - lx + 1;
}
}
if (left.length < 10) return null;
// 閉じた輪郭線 → 4つの極点(TL, TR, BR, BL)を抽出
const contour = [...left, ...right.reverse()];
let tl = contour[0], tr = contour[0], br = contour[0], bl = contour[0];
for (const p of contour) {
const sum = p.x + p.y; // TL=最小, BR=最大
const diff = p.y - p.x; // TR=最小, BL=最大
if (sum < minSum) tl = p;
if (sum > maxSum) br = p;
if (diff < minDiff) tr = p;
if (diff > maxDiff) bl = p;
}
// マスク座標 → ビデオ座標へスケーリング
const sx = videoW / maskW, sy = videoH / maskH;
return [
{ x: tl.x * sx, y: tl.y * sy },
{ x: tr.x * sx, y: tr.y * sy },
{ x: br.x * sx, y: br.y * sy },
{ x: bl.x * sx, y: bl.y * sy },
];
}
U2-NetPが出力するセグメンテーションマスクから行単位で左右の境界を求め、x+y の和と y-x の差で4つの頂点を見つける。OpenCVの複雑な輪郭近似アルゴリズムがなくても、レシートのような矩形文書には十分だった。
ステップ2: Web Workerの分離と200回での自動再起動
ONNX推論を別のWeb Workerに分離した。最も重要な設計は200回の推論ごとにWorkerを再起動することだ。
// workers/detection-worker.ts
const MAX_INFERENCES = 200;
// 推論後
if (inferenceCount >= MAX_INFERENCES) {
self.postMessage({ type: 'restart-needed' });
}
// hooks/useEdgeDetection.ts — Worker再起動のハンドリング
case 'restart-needed': {
if (disposedRef.current) break; // アンマウント後の孤立Worker防止
const oldWorker = workerRef.current;
if (oldWorker) oldWorker.terminate(); // WASMヒープ全体をOSへ返却
const newWorker = createDetectionWorker();
newWorker.onmessage = handleWorkerMessage;
newWorker.postMessage({ type: 'init' });
workerRef.current = newWorker;
break;
}
worker.terminate() がポイントだ。JavaScriptのGCはWASMヒープ内部を管理できないが、Workerを終了させればプロセスレベルでヒープ全体が解放される。
ステップ3: リークを塞ぐ
U2-NetPモデルは出力ヘッドが7つ(d0〜d6)ある。必要なのは完成版のd0ひとつだけなのに、既存のコードは7つ全部を割り当てて1つだけを解放していた(推論あたり2.4MBのリーク)。
// 修正前: 7つのヘッドすべてを割り当て
const results = await session.run(feeds);
const output = results[outputName];
output.dispose(); // d0のみ解放、d1〜d6はリーク!
// 修正後: 必要なヘッドだけリクエスト + すべて解放
const results = await session.run(feeds, [outputName]); // d0のみリクエスト
// finallyブロックですべてのテンソルを破棄
finally {
if (inputTensor) try { inputTensor.dispose(); } catch {}
if (results) {
for (const key of Object.keys(results)) {
try { results[key].dispose(); } catch {}
}
}
}
ステップ4: キャンバスのGPUメモリを自動解放
10秒間検出アクティビティがなければキャンバスのサイズを0に設定し、GPUメモリを強制解放する。
// hooks/useEdgeDetection.ts
const IDLE_CLEANUP_MS = 10_000;
if (now - lastDetectTimeRef.current > IDLE_CLEANUP_MS) {
if (detectCanvasRef.current) {
detectCanvasRef.current.width = 0; // GPUメモリ解放
detectCanvasRef.current.height = 0;
}
}
ユーザーには何も気づかれないよう、検出再開時にキャンバスは自動で再割り当てされる。
メモリライフサイクルのタイムライン
結果とトレードオフ
| 項目 | OpenCV(以前) | ONNX(以後) | 改善 |
|---|---|---|---|
| 静的ライブラリ | 128MB | 4.7MB | 96.3% 削減 |
| フレームあたりのメモリ | 7.9MB | 1.5MB | 81% 削減 |
| テンソルリーク/推論 | 〜2.4MB | 0 | 完全解決 |
| iOS Safari安定性 | 40秒後にクラッシュ | 無制限動作 | 完全解決 |
| 最悪ケース40秒時のメモリ | 〜79MB | 〜15〜20MB | 75% 削減 |
トレードオフ:
- 検出解像度を512×512にダウンスケールした。精度はやや落ちるが、レシートは通常矩形なので実用上は問題なかった。
- OpenCVの精密な輪郭近似の代わりに極点ベースの単純なアルゴリズムを使うため、折れたレシートや不規則な形状では精度が落ちる。
- Worker再起動時に〜0.5秒の空白時間が生じる。200回(約50秒)に1回なので体感されない。
学んだこと
-
WASMメモリはJS GCが管理しない。
dispose()の呼び出しを忘れると、GCが補ってくれることなくそのまま累積される。 -
worker.terminate()は最も確実なメモリ解放手段だ。 WASMヒープを含むすべてのメモリがプロセスレベルで解放される。 -
128MBのライブラリが本当に必要か、まず問い直すべきだ。 OpenCVの数千の機能のうち実際に使っていたのは
findContoursひとつだけで、63行の純粋なJSで代替できた。 - モバイルブラウザのメモリ上限は思ったより低い。 iOS Safariは95MB(iPhone 17 Proでテスト)。



