紙の申請書や古いPDFをOCRすると、「0」と「O」、「1」と「I」、「ロ」と「口」のような誤認識が頻繁に発生します。単純な文字列置換だけでは文脈に応じた修正ができず、後工程の検索やデータ登録で問題になります。
この記事では、Tesseract OCRで抽出したテキストをLLMで補正し、補正前後の差分とOCR信頼度も保存するパイプラインを作ります。実際に試したところ、LLMに全文を自由変換させるよりも、「原文をできるだけ維持し、明らかな誤りだけを修正する」という制約を与えた方が、固有名詞や数値の破壊を抑えられました。
全体の処理フロー
OCRからLLMによる補正、検証、人手レビューまでの処理全体は次の流れです。
今回の処理は次の5段階です。
- 画像からTesseractでOCRする
- OCR結果と単語ごとの信頼度を取得する
- 信頼度の低い箇所を含むテキストだけLLMに渡す
- JSON形式で補正結果を受け取る
- 差分と信頼度を確認し、必要なら人手レビューに回す
特に重要なのは、LLMの出力をそのまま正解として扱わないことです。請求金額、日付、型番などは、補正後に形式チェックを行います。
今回はローカルLLMとしてOllamaを使いました。API部分を差し替えれば、別のLLM実行環境でも同じ構成を利用できます。
環境構築とOCR
まず必要なパッケージをインストールします。
python -m venv .venv
.venv\Scripts\activate
pip install pytesseract pillow requests
Tesseract本体と日本語データも必要です。インストール後、jpn が利用できるか確認します。
tesseract --list-langs
出力に jpn が含まれていない場合は、日本語の言語データを追加します。
Ollamaのモデルは、例として次のように取得しました。
ollama pull qwen2.5:7b
OCR結果と信頼度を取得するコードは以下です。
from pathlib import Path
import pytesseract
from PIL import Image
from pytesseract import Output
def run_ocr(image_path: str) -> tuple[str, list[dict]]:
image = Image.open(image_path)
data = pytesseract.image_to_data(
image,
lang="jpn+eng",
config="--psm 6",
output_type=Output.DICT,
)
words = []
text_parts = []
for i, text in enumerate(data["text"]):
text = text.strip()
if not text:
continue
confidence = float(data["conf"][i])
item = {
"text": text,
"confidence": confidence,
"left": data["left"][i],
"top": data["top"][i],
"width": data["width"][i],
"height": data["height"][i],
}
words.append(item)
text_parts.append(text)
return " ".join(text_parts), words
if __name__ == "__main__":
text, words = run_ocr("sample.png")
print(text)
low_confidence = [
word for word in words if word["confidence"] >= 0
and word["confidence"] < 70
]
print(f"低信頼度の単語数: {len(low_confidence)}")
conf は必ずしも正確な確率ではありませんが、LLMに渡す対象を絞る目安として使えます。私の検証では、全ページを毎回補正するより、低信頼度のページや項目だけを対象にした方が、処理時間と不要な変更を減らせました。
LLMに補正させる
LLMには、次の制約を明示します。
- 誤りがない部分は変更しない
- 数字、日付、型番は推測で変更しない
- 判断できない場合は原文を維持する
- JSON以外を返さない
- 変更理由を短く記録する
import json
import requests
OLLAMA_URL = "http://localhost:11434/api/chat"
MODEL = "qwen2.5:7b"
def correct_with_llm(ocr_text: str, words: list[dict]) -> dict:
low_confidence = [
word for word in words
if 0 <= word["confidence"] < 70
]
prompt = f"""
あなたはOCR校正担当です。
以下のOCR原文を確認し、文脈上明らかな誤認識だけを補正してください。
制約:
- 原文をできるだけ維持する
- 数字、日付、金額、型番、固有名詞は推測で変更しない
- 判断できない箇所は変更しない
- 改行や記号は可能な限り維持する
- 次のJSON形式だけを返す
],
"needs_review": true
}}
OCR原文:
{ocr_text}
低信頼度候補:
{json.dumps(low_confidence, ensure_ascii=False)}
""".strip()
response = requests.post(
OLLAMA_URL,
json={
"model": MODEL,
"messages": [
{
"role": "system",
"content": "指定されたJSON以外は出力しないでください。",
},
{"role": "user", "content": prompt},
],
"stream": False,
"format": "json",
"options": {"temperature": 0},
},
timeout=120,
)
response.raise_for_status()
content = response.json()["message"]["content"]
return json.loads(content)
temperature を0にしても、LLMの出力が完全に一定になるとは限りません。そのため、業務データでは補正結果を上書きせず、原文と並べて保存します。
補正結果を検証する
LLMの回答を受け取ったら、最低限の検証を行います。まず、出力が空になっていないか、変更量が大きすぎないかを確認します。
import difflib
import re
def validate_result(original: str, result: dict) -> list[str]:
errors = []
corrected = result.get("corrected_text", "")
if not corrected.strip():
errors.append("補正後テキストが空です")
return errors
ratio = len(corrected) / max(len(original), 1)
if ratio < 0.7 or ratio > 1.3:
errors.append("文字数の変化が大きすぎます")
original_numbers = re.findall(r"\d[\d,.-]*", original)
corrected_numbers = re.findall(r"\d[\d,.-]*", corrected)
if original_numbers != corrected_numbers:
errors.append("数字列が変更されています")
if len(result.get("changes", [])) > 20:
errors.append("変更件数が多すぎます")
return errors
def show_diff(original: str, corrected: str) -> None:
diff = difflib.unified_diff(
original.splitlines(),
corrected.splitlines(),
fromfile="ocr",
tofile="corrected",
lineterm="",
)
print("\n".join(diff))
数字を完全に変更禁止にすると、OCRが「2024」を誤認識したケースまで直せなくなります。実運用では、項目ごとにルールを分けるのが現実的です。例えば、住所は文字の補正を許可し、請求金額は数字列が変わったら必ずレビューに回します。
処理結果は次のようなJSONで保存すると、後から監査できます。
{
"source": "invoice_001.png",
"ocr_text": "請求金額 1O,500円",
"corrected_text": "請求金額 10,500円",
"changes": [
{
"before": "1O,500",
"after": "10,500",
"reason": "金額欄でOが0と判断できる"
}
],
"needs_review": false,
"ocr_low_confidence_count": 1
}
実運用で気を付けた点
最初はOCR全文をLLMに渡していましたが、ページ数が増えるほど不要な言い換えが発生しました。現在は、帳票の種類ごとにプロンプトを分け、項目名や許可される変更範囲を指定しています。
例えば、商品コードは正規表現で検証します。
import re
def validate_product_code(value: str) -> bool:
return bool(re.fullmatch(r"[A-Z]{2}-\d{4}", value))
また、個人情報を含む画像を外部のAPIへ送信する場合は、契約、保存期間、アクセス制御を確認する必要があります。ローカルLLMを使う場合でも、ログや一時ファイルに原文が残る点には注意が必要です。
LLMの信頼度を別途数値化するより、次の条件を組み合わせてレビュー判定する方が扱いやすいと感じました。
- OCR信頼度が低い
- 変更された文字数が多い
- 数字や型番が変更された
- 許可されていない文字種が含まれる
- LLMが
needs_review: trueを返した
まとめ
OCR誤認識の補正では、LLMに全文を自由に書き直させるのではなく、OCR結果、単語ごとの信頼度、帳票固有の検証ルールを組み合わせることが重要です。
今回の構成では、Tesseractで候補を抽出し、LLMには「明らかな誤りだけを変更する」と指示しました。さらに、原文・補正結果・変更理由を保存し、数字や型番の変更を自動検知することで、人手確認が必要な文書だけを切り出せます。
最初から完全自動化を目指すのではなく、補正候補を提示する支援ツールとして導入し、実データで誤補正のパターンを集めながらルールとプロンプトを調整するのが安全です。