ブラウザだけで動くOCRで、応募券のシリアル入力を楽にした話

ブラウザだけで動くOCRで応募券のシリアル入力を楽にした話のアイキャッチ画像 ツール

CDに封入されている応募券には、英数字10数桁のシリアル番号が印刷されています。これをWebの応募ページに1枚ずつ手入力するのが、枚数が増えるとかなりの手間です。しかも1文字でも打ち間違えると登録に失敗します。

そこで、スマホで応募券を撮影すると、写真からシリアルを読み取って入力欄に入れてくれる仕組みを、ブラウザだけで作ってみました。文字の読み取り(OCR)もスマホのブラウザの中で動くので、写真がどこかのサーバーに送られることはありません。

この記事では、作るときに考えたことや、ハマったところを技術寄りに紹介します。

※ 応募サービスの利用規約との関係があるため、ツールそのものは公開していません。ここで紹介する技術は、ほかの「紙に印刷された番号をWebフォームに入れる」作業にも応用できます。

全体の流れ

  1. 応募ページを開いた状態で、ブックマークレットを実行する(画面の下に操作パネルが出る)
  2. 応募券を撮影する(ライブラリから複数枚まとめて選ぶこともできる)
  3. ブラウザ内のOCRで文字を読み取り、シリアルらしい文字列だけを取り出す
  4. 一覧で確認・修正する(自信のない読み取りは黄色、桁数が合わないものは赤で表示)
  5. 「入力枠へ反映」で、ページの入力欄に流し込む
  6. 登録ボタンは自分で押す

※ アニメーション内のURL・フォーム・番号はすべて説明用の架空のものです。

最後の登録だけはあえて自動にしていません。OCRは100%ではないので、送信する前に必ず人の目で確認する流れにしています。

ブラウザの中でOCRを動かす

OCRには、中国のBaiduが公開している PaddleOCR のモデル(PP-OCRv4)を使いました。文字の「場所を探すモデル」と「文字を読むモデル」の2段構えで、英数字の認識精度がかなり高いのが特徴です。

これをブラウザで動かすために、次のライブラリを組み合わせています。

  • onnxruntime-web:ONNX形式のAIモデルを、WebAssemblyでブラウザ上で実行する
  • @gutenye/ocr-browser:PaddleOCRの前処理・後処理をまとめたライブラリ
  • OpenCV.js:画像処理(ライブラリの内部で使われる)
  • esbuild:これらを1つのJavaScriptファイルにまとめる

OCRエンジン部分のコードは、実はこれだけです。

import * as ort from 'onnxruntime-web'
import Ocr from '@gutenye/ocr-browser'

export function init(baseUrl) {
  ort.env.wasm.wasmPaths = baseUrl + 'ort/'
  // 対象ページは crossOriginIsolated ではないのでシングルスレッドで動かす
  ort.env.wasm.numThreads = 1
  return Ocr.create({
    models: {
      detectionPath: baseUrl + 'models/ch_PP-OCRv4_det_infer.onnx',
      recognitionPath: baseUrl + 'models/ch_PP-OCRv4_rec_infer.onnx',
      dictionaryPath: baseUrl + 'models/ppocr_keys_v1.txt',
    },
  })
}

export async function detect(baseUrl, imageUrl) {
  const ocr = await init(baseUrl)
  const { texts } = await ocr.detect(imageUrl)
  return texts.map((t) => ({ text: t.text, mean: t.mean })) // mean = 信頼度
}

ポイントは numThreads = 1 です。onnxruntime-web はマルチスレッドで動かすと速いのですが、そのためにはページ側が特別なヘッダー(Cross-Origin Isolation)を出している必要があります。他人のサイトの上で動かすツールなので、ページの設定は変えられません。そのため、シングルスレッドで割り切りました。

モデルと実行ファイルを合わせると約40MBあります。初回だけダウンロードに時間がかかりますが、2回目以降はブラウザのキャッシュが効きます。

ブックマークレットで、既存のページに機能を足す

応募ページそのものは他社のサービスなので、手を入れることはできません。そこでブックマークレット(ブックマークに保存したJavaScript)を使って、開いているページの上に自分のパネルを重ねる方式にしました。

ブックマークに登録するのは、本体のスクリプトを読み込むだけの短いコードです。

javascript:(function(){
  var s = document.createElement('script');
  s.src = 'https://example.com/ocr-tool.js?' + Date.now();
  document.body.appendChild(s);
})()

本体を別ファイルにしておくと、ブックマークを登録し直さなくても機能を更新できます。末尾の Date.now() は、スマホのブラウザが古いスクリプトをキャッシュし続けるのを防ぐためのものです。

本体のスクリプトは document.currentScript.src から自分の置き場所を調べて、OCRエンジンやモデルを同じ場所から読み込みます。こうしておけば、置き場所を変えてもコードを書き換えずに済みます。

ちなみに、ブックマークレットはログイン中のページの上で任意のJavaScriptを実行できる仕組みです。便利な反面、出どころのわからないブックマークレットを使うのは危険なので、自分で中身を把握しているものだけにしましょう。

読み取り精度を上げるための工夫

写真は長辺2000pxに縮小してから読む

最初はスマホで撮った写真をそのままOCRに渡していましたが、Androidでタブが落ちました。OCRライブラリが画像をフル解像度のまま検出モデルに入れるため、1200万画素クラスの写真だとメモリが足りなくなるのが原因でした。

そこでCanvasで縮小してから渡すようにしました。ただ、小さくしすぎると今度は文字を取りこぼします。試した範囲では、長辺1600pxでは取りこぼしが出て、2000pxなら全件読めました。

var scale = Math.min(1, 2000 / Math.max(img.naturalWidth, img.naturalHeight))
canvas.width = Math.round(img.naturalWidth * scale)
canvas.height = Math.round(img.naturalHeight * scale)
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height)
canvas.toBlob(callback, 'image/jpeg', 0.92)

シリアルらしい文字列だけを拾う

応募券には、シリアル以外にも注意書きや商品名などの文字がたくさん印刷されています。OCRはそれも全部読むので、「英大文字と数字だけ」「決まった桁数」の行だけをシリアルの候補として残します。

桁数は応募の回によって違う(13桁の回もあれば14桁の回もある)ので、決め打ちにはしませんでした。ページの入力欄の maxLength を読んで、その回の桁数を自動で判定しています。

また、桁数が合わないものも捨てずに、赤く表示して残すようにしました。別の回の応募券が混ざっていたときに、黙って消えるより「これは桁数が違う」とわかる方が親切だからです。

信頼度の低い読み取りを目立たせる

PaddleOCRは、読み取った文字列ごとに信頼度(0〜1)を返します。実際の応募券で試したところ、正しく読めたものは0.98前後、光の反射などで1文字だけ誤読したものは0.95前後でした。

そこで、0.97未満のものは黄色で「要確認」と表示するようにしました。全部を同じように見直すのは大変なので、怪しいものだけに目が行くようにしています。

Reactで作られた入力欄に、外から値を入れる

いちばんハマったのがここです。応募ページはReactで作られていて、input.value = '...' で値を入れても、画面上は入ったように見えるのに、送信すると空として扱われます。

Reactは入力欄の値を自分で管理していて、value の書き込みを横取りしています。そのため、Reactを通さずに書き込むと変化に気づいてもらえません。そこで、ブラウザ本来の value のセッターを直接呼んでから input イベントを発生させ、「ユーザーが入力した」ことをReactに伝えます。

var nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set

function setReactValue(input, value) {
  nativeSetter.call(input, value)
  input.dispatchEvent(new Event('input', { bubbles: true }))
}

これはReactで作られたページを外から操作するときの定番テクニックで、Chrome拡張や自動テストでも同じ方法がよく使われています。

入力欄が足りないときは、ページにある「入力枠を追加」ボタンを押して増やしてから値を入れています。ボタンを押した直後はまだ入力欄が描画されていないので、少し待ってから次の処理に進むようにしました。

スマホならではのハマりどころ

  • カメラを起動すると、元のタブが消えることがある:Androidでは、カメラアプリを開いている間にブラウザのタブがメモリから追い出され、撮影から戻るとページが読み込み直しになることがありました。最初はOCRエンジンを先読みしていましたが、それがメモリを圧迫していたので、撮影後に読み込むように変えました。
  • 読み取った結果を消さない:ページが読み込み直しになっても読み取り結果が消えないように、sessionStorage に保存しています。
  • どの版が動いているかを表示する:スマホはキャッシュが強く、修正したのに古い版が動いていた、ということがよくありました。パネルにバージョンを表示して、すぐ確認できるようにしています。
  • iPhoneのHEIC画像:ブラウザによってはHEIC形式の写真を読み込めないので、エラーメッセージで案内するようにしました。

まとめ

  • PaddleOCR + onnxruntime-web で、スマホのブラウザだけで実用的な英数字OCRが動く
  • スマホの写真は、そのまま渡すとメモリ不足になる。長辺2000px程度に縮小するのがちょうどよかった
  • OCRの信頼度を使うと、人が確認すべきところだけを目立たせられる
  • Reactの入力欄には、ネイティブのセッター+inputイベントで値を入れる
  • 最後の送信は人が行う。自動化するのは「面倒なところ」までにとどめる

紙に印刷された番号をWebフォームに打ち込む作業は、応募券に限らず、仕事の現場にもたくさんあります。ブラウザだけで完結するので、アプリのインストールが難しい環境でも使える方法です。

コメント

タイトルとURLをコピーしました