調教したOCR精度を測るベンチマークの作り方( F1 / CER / 4-gram recall)

概要

今回はOCR の精度を F1 / CER / 4-gram recall で客観的に測るベンチマークの作り方について紹介していきます。

OCR の改善を「なんとなく良くなった気がする」で進めると、ある日いきなり精度が落ちていることに気付く事故が必ず起きます。
実際、僕も複数の補正パターンをまとめて入れたら精度が50% から 30%ほどに下がって、原因を切り分けるのに半日溶かしました(- -;

数値で測れるベンチを最初に整えておくと、その手の事故を防げます。
それではやっていきましょう!

目次

完成イメージ

npm run bench で走らせると、各画像の TP / FP / FN と集計値、テキスト品質の 3 指標が並ぶ、という構成を目指します。

ベンチ結果の CLI 出力イメージ(画像ごとの TP/FP/FN と集計 F1 を表示)
OCR ベンチマーク実行結果のサンプル出力

これを 前処理・トークナイザ・しきい値をいじるたびに走らせて、改善・劣化を追うのが基本運用になります。

ベンチに必要なもの

用意するのはこの 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 アプリを作ってみた経緯と結果を振り返ります。

以上となります。
ここまで疲れた。。。あとはラストまとめを書いて完結です!
それではお疲れさまでした。