[Astro] #133 Text Extractor v1 — Tesseract OCR + Whisper STT デュアルモードのブラウザ完結型テキスト抽出ツール

[Astro] #133 Text Extractor v1 — Tesseract OCR + Whisper STT デュアルモードのブラウザ完結型テキスト抽出ツール

はじめに

「メディアからテキストを抽出する」というタスクには、大きく2つの入力がある。画像(OCR)と音声(Speech-to-Text)です。

どちらも入力はメディアファイル、出力はプレーンテキスト、処理はWASMモジュール+言語モデルのオンデマンドロード。UIもドロップ→処理→テキスト表示→コピー/ダウンロードと共通パターン。であれば、1つのツールに統合するのが自然です。

本記事では、tesseract.js(Tesseract WASM)と @huggingface/transformers(Whisper ONNX WASM)を1つのAstroページに統合し、タブ切替でOCR/STTを共存させた「Text Extractor」の実装を記録します。

スクリーンショット

Text Extractor v1 アプリケーション起動時

OCR mode

画像ファイルを読み込み、テキスト抽出

Text Extractor OCR mode

STT mode

音声・動画ファイルを読み込み、テキスト抽出

Text Extractor STT mode

動画(GIF)

OCR mode

Text Extractor OCR mode

STT mode

Text Extractor STT mode

Sample data

音声サンプルは「プロの声素材 PRO-VIDEO」様よりお借りしてます。

1. 全体アーキテクチャ

1ページ2モードの構成です。WASMモジュールはそれぞれ使用時にのみlazy loadし、同時にロードすることはありません。

[ Astroページ (text-extractor.astro) ]

   ├── タブUI: [🔍 OCR] [🎤 STT]

   ├── OCRモード
   │   ├─ tesseract.js v5 (CDN)
   │   ├─ Tesseract WASM Core (SIMD LSTM)
   │   └─ 言語モデル (jpn ~15MB, eng ~4MB) — CDNから自動DL

   └── STTモード
       ├─ @huggingface/transformers v3 (CDN, dynamic import)
       ├─ ONNX Runtime WASM Backend
       └─ Whisper モデル (base q8 ~75MB) — HF Hubから自動DL&Cache API保存

共有するもの:プログレスバー、結果テキストエリア、コピー/ダウンロード機能。 共有しないもの:WASMランタイム、入力ファイル、設定パネル。


2. OCRモード — Tesseract.js

ライブラリ選定

Tesseract.jsはtesseract.js v5を採用。LSTM認識エンジンのWASMビルドが同梱されており、CDNからの<script>タグ一行で導入できます。

// CDNからロード
await new Promise(function(resolve, reject) {
  var script = document.createElement('script');
  script.src = 'https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js';
  script.onload = resolve;
  script.onerror = function() { reject(new Error('tesseract.js load failed')); };
  document.head.appendChild(script);
});

Worker初期化

createWorker に言語コードを渡すと、対応するtraineddataモデルをCDNからダウンロードしてWASMワーカーを起動します。

const { createWorker } = Tesseract;
ocrWorker = await createWorker('jpn', 1, {
  logger: function(m) {
    if (m.status === 'recognizing text') {
      showProgress('Recognizing...', m.progress);
    }
  }
});

logger コールバックで認識の進捗をリアルタイム取得し、プログレスバーに反映します。

多言語対応

Primary / Secondary の2言語を選択可能にしました。jpn+eng のように + で結合して渡すと、混在テキストを認識します。

function getOCRLangs() {
  var p = langPrimary.value;
  var s = langSecondary.value;
  return s && s !== p ? p + '+' + s : p;
}

ただし実測では、日本語テキスト内のアルファベット(関数名やURL等)はjpn単体でもそこそこ認識できるため、Secondaryは None デフォルトとしました。2言語同時だとモデルのダウンロード量が倍になり、ロード失敗のリスクもあるためです。

バッチ処理

複数画像のドロップに対応。サムネイル一覧表示と EXTRACT ALL ボタンで順次処理します。


3. STTモード — Whisper (transformers.js)

ライブラリ選定

ブラウザでWhisperを動かすアプローチは複数ありますが、@huggingface/transformers v3を選択しました。理由は以下の通りです。

  • ONNX Runtime Web のWASMバックエンドで推論するため、WebGPU非対応ブラウザでも動作する
  • Hugging Face Hub上の量子化済みモデル(onnx-community/whisper-base 等)をそのまま利用可能
  • pipeline() APIで数行のコードで音声認識パイプラインが完成する
  • モデルはブラウザのCache APIに自動保存され、2回目以降はダウンロード不要

遅延ロード

transformers.jsはモジュールサイズが大きいため、STTタブを初めて使うときにのみdynamic importでロードします。OCRモードの起動速度に影響を与えません。

async function loadTransformers() {
  if (sttTransformersLoaded) return;
  var mod = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@3');
  window.HFTransformers = mod;
  sttTransformersLoaded = true;
}

パイプライン構築

pipeline('automatic-speech-recognition', modelId) で、モデルのダウンロード→ONNX Session構築→推論パイプラインの生成を一括で行います。

var pipeline = window.HFTransformers.pipeline;
sttPipeline = await pipeline('automatic-speech-recognition', modelId, {
  dtype: 'q8',       // 8bit量子化モデル
  device: 'wasm',    // WASM バックエンド
  progress_callback: function(p) {
    if (p.status === 'progress' && p.total) {
      showProgress('Downloading ' + (p.file || 'model') + '...', p.loaded / p.total);
    }
  }
});

progress_callback でモデルダウンロードの進捗を取得し、プログレスバーに反映します。初回のbase モデルは約75MBのダウンロードが必要です。

音声デコード

Whisperは16kHz モノラルのFloat32Array を入力として要求します。ブラウザのAudioContextでデコードし、チャンネルミックスします。

async function decodeAudioFile(file) {
  var arrayBuffer = await file.arrayBuffer();
  var audioCtx = new AudioContext({ sampleRate: 16000 });
  var decoded = await audioCtx.decodeAudioData(arrayBuffer);

  // ステレオ→モノラル ミックスダウン
  var mono = new Float32Array(decoded.length);
  var numChannels = decoded.numberOfChannels;
  for (var ch = 0; ch < numChannels; ch++) {
    var chData = decoded.getChannelData(ch);
    for (var i = 0; i < decoded.length; i++) {
      mono[i] += chData[i] / numChannels;
    }
  }
  audioCtx.close();
  return { samples: mono, duration: decoded.duration };
}

AudioContext のコンストラクタに sampleRate: 16000 を指定することで、デコード時にリサンプリングが自動的に行われます。

モデルサイズと精度のトレードオフ

モデルサイズ (q8)日本語精度備考
tiny~45MB低(ハルシネーション多発)「の新型の新型の…」と繰り返す
base~75MB実用的プロ音声で高精度
small~250MBダウンロードが重い

tiny モデルでは日本語のハルシネーション(音声が無い区間にテキストを生成する現象)が顕著に発生しました。デフォルトは base としています。


4. UIの統合 — タブ切替アーキテクチャ

モード切替

左パネル上部のタブボタンで ocr / stt モードを切替。各モードの設定パネル全体を display: none で出し入れします。

document.querySelectorAll('.mode-tab').forEach(function(tab) {
  tab.addEventListener('click', function() {
    var mode = tab.dataset.mode;
    if (mode === currentMode) return;
    currentMode = mode;
    // パネル切替
    document.getElementById('panel-ocr').style.display = mode === 'ocr' ? '' : 'none';
    document.getElementById('panel-stt').style.display = mode === 'stt' ? '' : 'none';
    // プレビューエリアの切替
    // ...
  });
});

モード切替時、各モードのファイルや結果はメモリに保持されます。OCRで画像を処理した後にSTTに切替え、また戻ってもOCRの結果はそのまま残ります。

波形表示

STTモードでは音声ファイルのロード時にCanvasで簡易波形を描画します。

var step = Math.ceil(data.length / canvas.width);
ctx.strokeStyle = '#00ff88';
ctx.beginPath();
for (var i = 0; i < canvas.width; i++) {
  var min = 1, max = -1;
  for (var j = 0; j < step; j++) {
    var val = data[i * step + j] || 0;
    if (val < min) min = val;
    if (val > max) max = val;
  }
  ctx.moveTo(i, (1 + min) * canvas.height / 2);
  ctx.lineTo(i, (1 + max) * canvas.height / 2);
}
ctx.stroke();

各ピクセル列でサンプルのmin/maxを取り、垂直線で描画するシンプルな方式です。


5. ハマったポイント

Astro スコープドCSSと動的要素

バッチモードのサムネイルを innerHTML で動的生成していたところ、Astroのスコープドスタイルが適用されず、56x56pxのはずのサムネイルがフルサイズで表示されるバグが発生しました。

Astroの <style> タグはビルド時にスコープ属性(data-astro-cid-xxxx)を付与し、CSSセレクタにもその属性を追加します。しかし、JavaScriptで動的に生成したDOM要素にはこの属性がないため、スタイルがマッチしません。

解決策: 動的生成要素にはインラインスタイルを直接適用。

return '<img style="width:56px;height:56px;border:2px solid ' + border +
       ';object-fit:cover;cursor:pointer" src="' + src + '">';

is:global でスタイルをグローバル化する方法もありますが、他のコンポーネントとの衝突リスクを避けるため、インラインスタイルを選択しました。

Tesseract.js のコンソール警告

Tesseract.js v5のLSTMエンジンは、初期化時に Parameter not found: tesseract-core-simd-lstm.wasm.js 系の警告を大量に出力します。これはLSTMモデルのパラメータ参照に関するライブラリ内部の問題で、認識精度には影響しません。コンソールが賑やかになりますが、こちら側のコードから抑制する手段はありません。

img.onload のレースコンディション

画像ドロップ後にプレビューが表示されない問題が発生。原因は ocrHandleFiles 内で ocrActiveIdx = 0 を設定した後、img.onload コールバック内の条件分岐が ocrActiveIdx === -1 をチェックしていたため、既に0が設定された時点で条件が偽となり ocrSetActive が呼ばれませんでした。

解決策: ファイルドロップ時点で即座に previewImg.src に Object URL を設定し、img.onload ではサイズ情報の更新のみ行う形に分離。


6. ローカルに必要なファイル

ゼロ

  • tesseract.js → jsDelivr CDN
  • tesseract WASMコア → CDN(tesseract.js が自動取得)
  • 言語モデル (traineddata) → CDN(tesseract.js が自動取得)
  • transformers.js → jsDelivr CDN(dynamic import)
  • ONNX Runtime WASM → CDN(transformers.js が自動取得)
  • Whisper モデル → Hugging Face Hub(Cache APIに保存)

/public にファイルを置く必要は一切ありません。Astroページ1ファイルで完結します。


7. まとめ

  • OCR(Tesseract.js): CDNからのスクリプト1行で導入、12言語対応、バッチ処理対応。日本語の認識精度は実用的。
  • STT(Whisper via transformers.js): dynamic importで遅延ロード、ONNX Runtime WASMバックエンドでWebGPU不要。base モデル(q8, ~75MB)で日本語音声を実用的な精度で文字起こし。
  • 統合UI: タブ切替で2モードを共存。WASMモジュールは各モード使用時にのみロードし、メモリ効率を維持。

ブラウザだけで画像OCRと音声テキスト起こしが完結する。サーバーへのアップロードも不要。「プライバシーが気になるファイルでも安心して処理できる」というのが最大の価値です。