0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OpenCV.jsを取り除き、ONNX Runtime Webでレシートスキャナーのメモリを96%削減する

0
Last updated at Posted at 2026-04-02

韓国のバックエンドエンジニアです。留学経験を活かして、日本語でも技術発信を始めてみました。初投稿です。

image.png

モバイルブラウザでレシートをリアルタイム検出するスキャナーを作成したところ、iOS Safariで40秒ごとにページがリフレッシュされる現象が発生。OpenCV.jsを取り除き、ONNX Runtime Web + 純粋なJSに置き換えてメモリを96%削減するまでのプロセスを記録します。

問題の状況

レシートの写真を撮ると自動で輪郭を検出し、遠近補正まで行うWebアプリだ。最初は当然OpenCV.jsを使った。

image.png

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を選んだ理由:

  1. U2-NetPモデルが4.7MBと軽量(レシートのような文書検出には十分)
  2. Web Workerで動かせばメインスレッドのブロッキングがゼロ
  3. worker.terminate() 一行でWASMヒープ全体をOSへ返却できる
  4. OpenCVがやっていた輪郭抽出は、純粋なJS 63行で代替可能

全体アーキテクチャ

image.png

実際の実装

ステップ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;
  }
}

ユーザーには何も気づかれないよう、検出再開時にキャンバスは自動で再割り当てされる。

メモリライフサイクルのタイムライン

image.png

結果とトレードオフ

項目 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回なので体感されない。

学んだこと

  1. WASMメモリはJS GCが管理しない。 dispose() の呼び出しを忘れると、GCが補ってくれることなくそのまま累積される。
  2. worker.terminate() は最も確実なメモリ解放手段だ。 WASMヒープを含むすべてのメモリがプロセスレベルで解放される。
  3. 128MBのライブラリが本当に必要か、まず問い直すべきだ。 OpenCVの数千の機能のうち実際に使っていたのは findContours ひとつだけで、63行の純粋なJSで代替できた。
  4. モバイルブラウザのメモリ上限は思ったより低い。 iOS Safariは95MB(iPhone 17 Proでテスト)。
0
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?