概要 今回はOCR の精度を F1 / CER / 4-gram recall で客観的に測るベンチマークの作り方 について紹介していきます。
OCR の改善を「なんとなく良くなった気がする」で進めると、ある日いきなり精度が落ちていることに気付く 事故が必ず起きます。 実際、僕も複数の補正パターンをまとめて入れたら精度が50% から 30%ほどに下がって、原因を切り分けるのに半日溶かしました(- -;
数値で測れるベンチを最初に整えておくと、その手の事故を防げます。 それではやっていきましょう!
目次
完成イメージ npm run bench で走らせると、各画像の TP / FP / FN と集計値、テキスト品質の 3 指標が並ぶ、という構成を目指します。
ベンチ結果の CLI 出力イメージ(画像ごとの TP/FP/FN と集計 F1 を表示)
これを 前処理・トークナイザ・しきい値 をいじるたびに走らせて、改善・劣化を追うのが基本運用になります。
ベンチに必要なもの 用意するのはこの 3 つだけです。
<ベンチの構成物>
テスト用の画像群(実データ 5 〜 10 枚)
期待される抽出結果(expected.json)
指標を計算して集計するテストランナ(Vitest がおすすめ)
画像は実運用に近いものを少数、多様性重視で選ぶと良いです。全部を綺麗な画像で固めないで、暗い・ブレ・斜め・変な背景、など条件を散らばせる のがコツです。
期待値 JSON の設計 各画像に対して「こういう単語(or 辞書エントリの ID)が抽出されるはず」をリスト化しておきます。
tests/bench/fixtures/expected.json
1 2 3 4 5 6 7 8 9 10 { "businesscard_01.jpg" : { "truth" : "山田 太郎\nABC 商事\n03-1234-5678\nyamada@example.com" , "expected" : [ "山田太郎" , "ABC商事" , "03-1234-5678" , "yamada@example.com" ] } , "receipt_02.jpg" : { "truth" : "珈琲 450 円\nサンドイッチ 620 円\n合計 1070 円" , "expected" : [ "珈琲" , "サンドイッチ" , "合計" ] } }
truth は OCR の生テキスト品質 を測るための正解、expected はマッチ結果の精度 を測るための正解です。 両方を用意しておくと、改善が「OCR 側」で効いたのか「マッチング側」で効いたのかを切り分けやすくなります。
この工程は面倒ですが、誤字脱字が無いように気を付けて頑張りましょう!
指標 1:TP / FP / FN と F1 マッチング結果に対する基本指標です。
<分類の定義>
TP(True Positive):期待値に含まれ、実際にも抽出されたもの
FP(False Positive):期待値に無いのに抽出されてしまったもの
FN(False Negative):期待値にあるのに抽出されなかったもの
そこから Precision / Recall / F1 を計算します。
metrics.ts
1 2 3 4 5 6 7 8 9 10 11 12 export function scoreMatch (expected : string [], actual : string [] ) { const exp = new Set (expected); const act = new Set (actual); let tp = 0 ; for (const a of act) if (exp.has (a)) tp++; const fp = act.size - tp; const fn = exp.size - tp; const precision = tp / Math .max (1 , tp + fp); const recall = tp / Math .max (1 , tp + fn); const f1 = (2 * precision * recall) / Math .max (1e-9 , precision + recall); return { tp, fp, fn, precision, recall, f1 }; }
画像ごとに出したスコアを、最後に集計 F1 としてまとめます。
aggregate.ts
1 2 3 4 5 6 7 8 export function aggregateF1 (scores : { tp: number ; fp: number ; fn: number }[] ) { const tp = scores.reduce ((s, r ) => s + r.tp , 0 ); const fp = scores.reduce ((s, r ) => s + r.fp , 0 ); const fn = scores.reduce ((s, r ) => s + r.fn , 0 ); const precision = tp / Math .max (1 , tp + fp); const recall = tp / Math .max (1 , tp + fn); return (2 * precision * recall) / Math .max (1e-9 , precision + recall); }
画像ごとの F1 を平均するのではなく、TP/FP/FN を足してから F1 を出す(マイクロ平均) ようにすると、画像サイズの偏りに影響されにくくなります。
指標 2:CER(文字誤り率) 生テキストの品質を測る指標です。 Levenshtein 距離を正解長で割ります。
cer.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 export function cer (truth : string , predicted : string ): number { const t = [...truth.replace (/\s+/g , "" )]; const p = [...predicted.replace (/\s+/g , "" )]; const dp = Array .from ({ length : t.length + 1 }, () => new Int32Array (p.length + 1 )); for (let i = 0 ; i <= t.length ; i++) dp[i][0 ] = i; for (let j = 0 ; j <= p.length ; j++) dp[0 ][j] = j; for (let i = 1 ; i <= t.length ; i++) { for (let j = 1 ; j <= p.length ; j++) { const cost = t[i - 1 ] === p[j - 1 ] ? 0 : 1 ; dp[i][j] = Math .min ( dp[i - 1 ][j] + 1 , dp[i][j - 1 ] + 1 , dp[i - 1 ][j - 1 ] + cost ); } } return dp[t.length ][p.length ] / Math .max (1 , t.length ); }
0 に近いほど良い指標で、前処理の変更でまず動くのがここ です。 二値化や解像度を触るとだいたい CER が動きます。
指標 3:4-gram recall とカタカナ語 recall CER はマクロな誤りを見るだけで、「欲しい単語の連なりが生きているか」は測れません。 そこで、正解テキストから連続する 4 文字を全部取り出し、それらが OCR 結果に含まれる割合を見ます。
ngramRecall.ts
1 2 3 4 5 6 7 8 9 10 export function recall4gram (truth : string , predicted : string ): number { const t = truth.replace (/\s+/g , "" ); const p = predicted.replace (/\s+/g , "" ); const grams = new Set <string >(); for (let i = 0 ; i <= t.length - 4 ; i++) grams.add (t.slice (i, i + 4 )); if (grams.size === 0 ) return 1 ; let hit = 0 ; for (const g of grams) if (p.includes (g)) hit++; return hit / grams.size ; }
固有名詞や専門用語が壊れていないか を測るのに、4-gram recall は結構な解像度があります。 ついでに、対象テキスト内にカタカナ 5 文字以上の単語がある場合は、それが OCR 結果にそのまま出ているかの比率も測ると、OCR がカタカナを崩していないか のチェックに使えます。
Vitest で組み立てる Vitest は bench 専用のモードを持っていますが、通常の describe / it でも十分です。test.each で画像を回し、各画像ごとに計測結果を集計する 構成にします。
tests/bench/run.test.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import { describe, it, expect } from "vitest" ;import fs from "node:fs" ;import path from "node:path" ;import expected from "./fixtures/expected.json" ;import { recognizeAndMatch } from "../src/pipeline" ;import { scoreMatch, aggregateF1, cer, recall4gram } from "./metrics" ;describe ("OCR bench" , () => { const scores : Array <ReturnType <typeof scoreMatch>> = []; for (const [file, spec] of Object .entries (expected)) { it (file, async () => { const image = fs.readFileSync (path.join ("tests/bench/fixtures/images" , file)); const result = await recognizeAndMatch (image); const match = scoreMatch (spec.expected , result.matched ); const c = cer (spec.truth , result.text ); const r4 = recall4gram (spec.truth , result.text ); console .log (`${file} : F1=${match.f1.toFixed(3 )} CER=${c.toFixed(3 )} R4=${r4.toFixed(3 )} ` ); scores.push (match); expect (match.f1 ).toBeGreaterThanOrEqual (0 ); }); } it ("aggregate" , () => { const f1 = aggregateF1 (scores); console .log (`aggregate F1=${f1.toFixed(3 )} ` ); }); });
expect は通る範囲にゆるくして、テストを「失敗させる」のではなく「ログを残す」ために使う のがポイントです。 ここで失敗にしてしまうと、改善の試行錯誤中にベンチが走らなくなってしまいます。
高速反復のキャッシュ OCR の実行はそれなりに遅いです(1 枚で数秒〜十数秒)。トークナイザ側を変えるだけのときは、OCR 生テキストを一度キャッシュして使い回す と劇的に速くなります。
tests/bench/cache.ts
1 2 3 4 5 6 7 8 9 10 11 12 import fs from "node:fs" ;import path from "node:path" ;const CACHE_DIR = "tests/bench/fixtures/ocr-raw" ;export async function getOcrText (name : string , run : () => Promise <string > ): Promise <string > { const cachePath = path.join (CACHE_DIR , `${name} .txt` ); if (fs.existsSync (cachePath)) return fs.readFileSync (cachePath, "utf8" ); const text = await run (); fs.mkdirSync (CACHE_DIR , { recursive : true }); fs.writeFileSync (cachePath, text, "utf8" ); return text; }
前処理を変えたときは必ずキャッシュを消す (rm -rf tests/bench/fixtures/ocr-raw)運用にしておかないと、古いテキストを使い続けることになるので注意です。
バリアント比較 「Sauvola の窓サイズを変えたら F1 はどうなるか」「denoise を入れたらどうか」といった A/B を、環境変数で前処理を切り替えてキャッシュを別フォルダに分ける だけで並列に比較できます。
runVariants.ts
1 2 3 4 5 6 const variants = ["baseline" , "denoise" , "upscale1200" , "allpsms" ];for (const v of variants) { const env = { ...process.env , BENCH_VARIANT : v }; spawnSync ("node" , ["scripts/bench-ocr.mjs" ], { env, stdio : "inherit" }); }
変更ごとに F1 を控えておけば、どのバリアントを採用するかが数字で決まります。「なんとなく良さげ」を避けて「確かに効いた」と言い切れる ようにするのがベンチの役目です。
メリット・デメリットまとめ
<OCR ベンチを導入するメリット>
改善が「体感」から「数字」に変わる
回帰を自動で気付ける
採用するバリアントを客観的に選べる
<デメリットと対策>
期待値を作るのが地味に大変 → 最初は 5 画像から始めて少しずつ増やす
OCR 実行が遅い → 生テキストをキャッシュ
画像の著作権に注意 → 自前撮影 or 許諾済み素材だけで作る
締め OCR の改善は測れないと再現性が無い 作業です。 F1 / CER / 4-gram recall の 3 指標だけあれば、「前処理は効いたが、マッチ側はむしろ下がった」 のような分離ができるようになり、判断が一気に楽になります。
次回は、これまで紹介した 5 本のシリーズをまとめて、ブラウザ完結 OCR アプリを作ってみた経緯と結果 を振り返ります。
以上となります。 ここまで疲れた。。。あとはラストまとめを書いて完結です! それではお疲れさまでした。