OCR(画像の中の文字をテキストに起こす処理)をブラウザの中だけで動かすのは、無理があると思っていた。日本語の OCR は、文字の種類が多い分だけモデルが大きい。日本語 OCR が使うモデルは、標準の構成で合計 153,408,816 バイト、およそ 150 MB ある。
それでも、このツールは画像をサーバーに送らない。契約書や身分証のように、クラウドの OCR に上げたくない書類を扱えるようにするためである。150 MB のモデルをブラウザに届けて端末の CPU で動かすまでに、ブラウザの制約にいくつも行き当たった。
行を見つけ、読み、並べる
使っているのは、国立国会図書館が CC BY 4.0 で公開している NDLOCR-Lite のモデルである。ブラウザ向けの実装は、橋本悠太氏の NDLOCR-Lite Web(CC BY 4.0)を土台に改変した。
NDLOCR-Lite は、三つの段階でページを文字列にする。まず行検出のモデル(DEIMv2)がページ全体から文字の行を探す。次に、切り出した行を文字認識のモデル(PARSeq)が読む。認識モデルは、入力の幅が 256、384、768 と異なる三つを、行の長さに応じて使い分ける。最後に、XY-Cut というアルゴリズムで余白を手がかりに読み順を決める。段階ごとの詳しい説明は、NDLOCR-Lite とはに書いた。
ブラウザにダウンロードさせるのは、行検出のモデル 1 本(39,907,982 バイト)と認識モデル 3 本(35,794,151、36,841,857、40,864,826 バイト)である。
ONNX と onnxruntime-web
機械学習のモデルは、学習に使ったフレームワークごとに保存の形式が違う。ONNX は、それをフレームワークに依存しない形で書き出すための共通形式で、NDLOCR-Lite のモデルもこの形式で配布されている。
ONNX のモデルを動かすのが onnxruntime で、そのブラウザ版が onnxruntime-web である(公式の説明)。計算の本体は WebAssembly(ブラウザで動く機械語に近い形式のプログラム)で書かれていて、このツールが配っているその WASM ファイルは 14,239,897 バイトある。
onnxruntime-web は、GPU で計算する WebGPU のバックエンドも持っている。速くなりそうなので試したが、このツールでは使っていない。開発時に手元の環境で測ろうとしたところ、onnxruntime-web 1.29 では 4 本のモデルのどれも WebGPU で最後まで実行できなかった。認識モデルは GPU 向けの計算コードの生成で、行検出のモデルは行列の積の形の不整合でエラーになった。その環境では GPU の実機も使えず、速度の比較までは届いていない。WebGPU に対応した版の WASM ファイルは、今使っている版の 2 倍ほど(onnxruntime-web 1.30.0 で 28,312,028 バイト)ある。動くことを確かめられていないものに、その大きさを払うのは見送った。
CPU を複数使うための条件
CPU だけで動かすなら、せめてコアを複数使いたい。onnxruntime-web は複数のスレッドで計算を分担できるが、そのためにはスレッド同士でメモリを共有する SharedArrayBuffer という機能が要る。
SharedArrayBuffer は、Spectre
と呼ばれる種類の攻撃への対策として、ページが「クロスオリジン分離」という状態にあるときしか使えないことになっている(MDN の解説)。この状態は、サーバーがページに Cross-Origin-Opener-Policy と
Cross-Origin-Embedder-Policy(COEP)の二つのヘッダーを付け、ページが他のサイトから読み込むものを制限すると成立する。
ここで一つ衝突があった。COEP
を厳しい値(require-corp)にすると、他のサイトから読み込むファイルは、相手が許可のヘッダーを返さない限り読み込めなくなる。アクセス解析に使っている
Google
アナリティクスのスクリプトはこのヘッダーを返さないので、止まってしまう。そこで、認証情報を付けずに読み込むことを条件に許可のヘッダーを求めない値(credentialless)を選んだ。onnxruntime-web
の WASM ファイルも、外部の配信サービスではなく同じサイトから配っている。
スレッド数は端末のコア数と 4 の小さいほうにしている。分離が成立しないブラウザでは、1 スレッドに落として動かす。
150 MB をどう届け、どこに置くか
サイトは Cloudflare Workers の静的ファイル配信で公開している。この配信には、1 ファイル 25 MiB までという上限がある(Cloudflare の制限)。モデルは 1 本 35〜40 MB あるので、そのままでは置けない。そこで 20 MiB ずつに割って置き、ブラウザで二つずつ並行してダウンロードしてつなぎ合わせる。つないだあとは SHA-256 のハッシュ値を計算し、配布時に記録した値と一致するかを確かめる。通信の途中で壊れたモデルを使わないためである。
毎回 150 MB をダウンロードさせるわけにはいかないので、つないだモデルはブラウザの Cache API(MDN の解説)に保存する。次にページを開いたときは、そこから読む。ブラウザの設定でサイトのデータを消せば、モデルもダウンロードし直しになる。
以前は Service Worker(ページとネットワークのあいだに入って通信を肩代わりする仕組み)でページ一式も保存し、通信のない環境でも開けるようにしていた。そこまでは要らないと判断し、今はやめている。古い版を保存したブラウザが古いページを表示し続けるのを防ぐため、今は Service Worker を自分で解除させるファイルだけを置いている。モデルを Cache API に保存するのは、Service Worker とは別の仕組みなので続けている。
ダウンロードの量とは別に、モデルを読み込んで実行の準備をする時間もかかる。onnxruntime は読み込み時にモデルの計算の組み方を整理(最適化)するが、この作業はモデルの中身が同じなら毎回同じ結果になる。そこで、配布する前に一度だけ最適化を済ませ、ブラウザでは省くようにした。開発時の計測では、4 本の準備にかかる時間が、ブラウザで最適化したときの 16.2 秒から 7.5 秒に縮んだ(onnxruntime-web 1.29.0、Intel Core i7-10700K、4 スレッド)。
いちばん遅いのは、行を探す段階だった
それでも読み取りは速くない。どこに時間がかかっているかを測ると、行検出だった。同じ計測で、行検出のモデルの推論は 1 ページあたり約 2.5 秒、認識モデルの推論は 1 行あたり 88〜293 ミリ秒(行が長いほど大きいモデルを使うので長くなる)だった。行検出はページごとに 1 回、認識は行ごとに 1 回なので、行の少ないページほど、待ち時間に占める行検出の割合が大きくなる。
そこで、行検出だけを別のモデルに差し替えられるようにした。PaddleOCR(Apache License 2.0)の PP-OCRv6 tiny で、RapidOCR が ONNX 形式で配布しているものを使っている。ファイルは 1,829,618 バイトと、DEIMv2 の 20 分の 1 にも満たない。
ブラウザで比べると差ははっきりしている(onnxruntime-web 1.30.0、4 スレッド)。モデルの準備と 1 ページ目の推論を合わせて、DEIMv2 は 6.2 秒、PP-OCRv6 tiny は 0.28 秒だった。画面ではこれを「高速モード」として選べるようにしてある。認識の 3 本は共通なので、変わるのは行の見つけ方だけである。
それでも、既定は速いほうに切り替えていない。PP-OCRv6 tiny は、文字の並びに大きな空白があると、そこで行を二つに割りやすい。評価用に作ったページで行の検出の正確さ(F1 値、1 が満点)を比べると、DEIMv2 の 0.996 に対し、PP-OCRv6 tiny は割れた行をつなぎ直す処理を足しても 0.947 だった。差は、項目名と金額が離れて並ぶ領収書や表に集中している。普通の本文なら差は小さいが、こうした資料を読ませる利用者のために、既定は DEIMv2 のままにした。
PP-OCR の行検出は、モデルが出す「この画素が文字らしい確率」の地図から行の矩形を組み立てる後処理を必要とする。Python の実装は画像処理ライブラリ(OpenCV など)に頼っているが、ブラウザ向けには外部のライブラリを使わずに TypeScript で書き直した。評価用のページでは、元の実装と同じ F1 値になることを確かめている。
サーバーがしていること
サーバーが配るのは、ページ、プログラム、onnxruntime-web の WASM、分割したモデルのファイルだけである。画像や PDF は端末の中で処理し、サーバーには届かない。モデルも同じサイトから配っているので、読み取りのために外部のサービスと通信することもない。
冒頭で、ブラウザの中だけで動かすのは無理があると思っていた、と書いた。遅さを受け入れれば、動かすことはできた。専用のアプリケーションより遅いのは、今のところ変えられない。スマートフォンのように、150 MB のモデルと計算用のメモリを同時に抱えるには苦しい端末で、どこまで安定して動くかも、まだ十分に確かめられていない。行検出の速さと精度のどちらを取るかは、今は利用者に選んでもらっている。いずれ WebGPU が使えるようになれば、この二択を迫らずに済むかもしれない。