日本語 OCR の誤認識を正規表現で補正する方法(カタカナ混同と文脈パターン)

概要

今回は日本語 OCR の誤認識を正規表現パターンで補正する方法について紹介していきます。

Tesseract.js の日本語認識はそこそこ優秀なんですが、カタカナの ソ↔ンシ↔ツュ↔ョ などで頻繁に崩れます
前処理を頑張っても、根本的に字形が似ている文字は 1 文字ずれたまま出てきやすいです。

そこで、OCR 結果をそのままファジーマッチにかける前に、混同しやすい字のペアから変種を生成して辞書に当てることと、語レベルの正規表現で既知の表記ゆれを潰すことの 2 本立てで補正します。

地味ですが、これだけで取りこぼしが目に見えて減るので、辞書ベースの OCR アプリを作るなら外せないテクニックです。

それではやっていきましょう!

目次

日本語 OCR はどこで崩れるか

Tesseract.js で日本語を読ませてよく出るのが、以下のような誤りです。

<代表的な誤認識パターン>
  • カタカナの になる
  • 小書き仮名の になる
  • 数字混じりの V.CVC、逆に VCV.C になる
  • 漢字で偏・旁の一部だけ取れて似た字に化ける(例: 酸化示化 になる)
  • 段組の境目でレイアウト解釈がズレ、関係ない文字列が連結される

これらは前処理では直りません。テキスト側で補正が必要です

戦略 1:混同ペアで変種を生成して辞書に当てる

一番素直なのは、「怪しい文字が含まれるトークンについて、あり得る誤読の組み合わせを全部作ってファジーマッチに投げる」方法です。

まず、混同しやすいペアを配列で持ちます。

misreadPairs.ts

1
2
3
4
5
6
7
8
9
10
11
12
export const MISREAD_PAIRS: Array<[string, string]> = [
["ソ", "ン"],
["シ", "ツ"],
["ユ", "ョ"],
["ュ", "ョ"],
["ッ", "ツ"],
["ロ", "口"],
["カ", "力"],
["エ", "工"],
["ー", "一"],
// ...実際には 90 組以上を登録しておく
];

使い方はシンプルで、入力トークンに対して、各ペアを片方向ずつ置換した派生を生成します。

expandVariants.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
import { MISREAD_PAIRS } from "./misreadPairs";

export function expandVariants(token: string, limit = 10): string[] {
const set = new Set<string>([token]);
for (const [a, b] of MISREAD_PAIRS) {
for (const v of [...set]) {
if (v.includes(a)) set.add(v.split(a).join(b));
if (v.includes(b)) set.add(v.split(b).join(a));
if (set.size >= limit) return [...set];
}
}
return [...set];
}

生成されたトークン群を順にファジーマッチにかけ、どれか 1 つでもヒットしたら採用します。
無制限に展開すると組み合わせ爆発するので、limit で 10 件程度に抑えておくのが安全です。

戦略 2:語レベルの正規表現パターン

混同ペアだけでは取れない、「ドット付きの略記」「全角半角の混在」「特定の二文字熟語の定型的な誤読」などは、語レベルの正規表現で置換してから渡します。

multiPatterns.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Pattern = { re: RegExp; to: string };

export const MULTI_PATTERNS: Pattern[] = [
{ re: /V[.\uFF0E]C/g, to: "ビタミンC" },
{ re: /VC(?![A-Za-z])/g, to: "ビタミンC" },
{ re: /\uFF36\uFF0EC/g, to: "ビタミンC" }, // V.C
{ re: /\u30b3\u30fc\u30d2[\uff70\u30fc]/g, to: "コーヒー" }, // ヴァリエーション吸収
// 誤認で頻出する熟語置換(仮例)
{ re: /示化/g, to: "酸化" },
{ re: /損水/g, to: "湯水" },
];

export function normalizeText(input: string): string {
let out = input;
for (const { re, to } of MULTI_PATTERNS) out = out.replace(re, to);
return out;
}

個人的には汎化しすぎると副作用が出る気がします。
例えば が工加工 に寄せたくなりますが、これを無条件でやると全然関係ない「が工務店」みたいなよくわからない文字列がヒットします。

安全に入れるなら、前後の文字を境界条件に入れて文脈限定する方が安牌だと思います(^^

contextPattern.ts

1
2
3
4
5
// NG: 汎化しすぎ
// { re: /が工/g, to: "加工" },

// OK: 「デン」が続く時だけ置換する
const safe: Pattern = { re: /が工(?=デン)/g, to: "加工" };

戦略 3:ノイズ領域の切り落とし

OCR 対象の画像には、欲しい情報の外に関係ないテキストが写り込むことがよくあります。
名刺なら裏面の定型文、書類ならページ下部の注意書き、商品ラベルなら広告的な説明文などです。

対策は、キーワードを基準にテキスト末尾をカットする方法です。

trimTail.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const NOISE_TAIL_KEYWORDS = [
"お問い合わせ",
"お客さま相談",
"詳しくは",
"公式サイト",
"製造者",
];

const NOISE_TAIL_RE = new RegExp(
`(${NOISE_TAIL_KEYWORDS.map(escape).join("|")})`
);

function escape(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

export function trimTail(text: string): string {
const m = text.match(NOISE_TAIL_RE);
if (!m || m.index === undefined) return text;
return text.slice(0, m.index);
}

キーワードは少しずつ追加して、誤削除が出ないか必ず確認します。
「詳しくは」みたいな一般語は、切っていい時と悪い時があるので、慎重に入れましょう。

戦略 4:除外ブロックの範囲削除

ノイズ末尾ではなく、本文の中に「関係ない範囲」が挟まるケースも補正できます。
例えば「※ 注意:〜」のような注釈ブロックは、後続の辞書マッチに時々悪い影響が出ます。

removeBlock.ts

1
2
3
4
5
export function removeBlocks(text: string): string {
return text
.replace(/\u203b[^\n]*\n?/g, "") // ※ から改行までを削除
.replace(/\([^)]{0,20}\u6ce8[^)]{0,20}\)/g, ""); // (注 ... )
}

正規表現での範囲削除は、改行や全角括弧の扱いを決め打ちしないと取りきれないことがあるので、実サンプルをもとに調整します。

すべてをパイプラインに並べる

補正は呼び出し順が大事です。
文字置換 → ノイズ削除 → ブロック除去 → トークン分割 → 変種生成、の順で並べるのがいい感じだと思います。

ocrPipeline.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
import { normalizeText } from "./multiPatterns";
import { trimTail } from "./trimTail";
import { removeBlocks } from "./removeBlock";
import { expandVariants } from "./expandVariants";

export function prepareTokens(raw: string): string[] {
const cleaned = removeBlocks(trimTail(normalizeText(raw)));
const tokens = cleaned
.split(/[\s,、//・()()\[\]【】\n]+/)
.map((t) => t.trim())
.filter((t) => [...t].length >= 2);
return tokens.flatMap((t) => expandVariants(t));
}

分割のセパレータは、実データを見ながら 1 ヶ月ほどかけてまったり育成していきましょう!

運用のコツ:1 変更 1 計測

この手の補正は、まとめて投入すると簡単に精度が落ちます
個人的にやってハマったのが、複数のパターンを一気に追加したら、F1 スコアが 49% から 34.8% に下がったケース。
原因は、どの置換が効いてどの置換が副作用を出したのか切り分け不能になったことでした。

以下のルールを守ると、だいたい回帰は防げます。

<1 変更 1 計測のルール>
  • パターンや混同ペアの追加は 1 件ずつ
  • 追加ごとにベンチを走らせて F1・FP の数値を確認
  • 悪化したらその場で revert
  • 汎化しすぎる式は境界条件付きに書き直す

最後の「境界条件付き」は特に重要で、先読み・後読みを活用して文脈限定すると副作用をかなり抑えられます。

メリット・デメリットまとめ

<正規表現補正のメリット>
  • モデル差し替え無しで精度を底上げできる
  • 影響範囲が狭く、原因特定がしやすい
  • ドメイン特化の「ここは絶対直したい」にピンポイントで対応できる
<デメリットと対策>
  • 汎化させすぎると副作用で他の正解が壊れる → 文脈限定
  • パターン数が増えて読めなくなる → 種類別にファイル分割
  • まとめ投入で回帰する → 1 変更 1 計測

締め

OCR の精度は、前処理・二段階推論・補正パターン・ファジーマッチの 4 層で作られています。
今回の補正レイヤは地味ですが、実データに対する「最後の一押し」として効くので、まずはカタカナ混同 10 組と、自分のドメインでよく見る誤読 3 〜 5 個を入れるところから始めるのがおすすめです。

次回は、OCR の精度を客観的に測るためのF1・CER・4-gram recall のベンチマーク設計について書いていきます。

以上となります。
次はOCRの精度を知るのに便利なベンチマークを図る方法です!
それではお疲れさまでした。