はじめに
今週、遅い夏休みを取ったので前々から検証してみたかったPython+LLMによるOCRツールを作成してみました。
OCRで伝票を読み込む処理などは、開発会社がそれぞれノウハウを持っている世界だとは思っていますが、今回はその部分をLLMである程度支援する形で作りました。
ある程度の処理の流れを考えた後、コード作成にはかなりGPT5を利用しました。
作成背景
年初~中旬に少し体調を崩して頻繁に通院していたり、歯科で初めて自由診療を今年受けました。
その為、来年の確定申告で医療費控除を申告する必要があり、そこそこ枚数がある領収書をスキャンするだけで領収額と支払先、通院日(領収書発行日)を抽出するツールを作りたいと思いました。
ChatGPTなどにスキャンしたデータを張り付ければすぐに抽出できると思いますが、病院の領収書などをあまりインターネットにはあげたくは無いので、WindowsPCに入れたAnaconda上のPython環境から同じPCのOllamaのLLMを利用したスクリプトを作成しようと思いました。
環境について
環境はAnaconda上のPython 3.12.11を利用しました。
スクリプト概要
スクリプトは、PDF形式の医療費領収書を入力とし、以下の流れで処理を行うようにしました。
- PDF → 画像変換
pdf2image.convert_from_path を利用し、指定DPIでPDFを画像化。 - OCR処理 (EasyOCR)
通常モード: 前処理(CLAHE + 適応的二値化)後にOCR実行。
--ocr-safe モード: 前処理・フィルタなしの素のOCR実行。
信頼度閾値(--min-conf)を満たす文字列のみを採用。 - 可視化 (オプション)
--viz 指定時、OCR認識結果を矩形とテキストで描画したPNGを保存。 - ヒューリスティクス抽出
guess_name:病院名・薬局名らしきテキストを抽出。
jdate_to_iso:日付を YYYY-MM-DD 形式に正規化。
parse_amount:金額らしき数値を抽出。 - LLM補正 (Ollama)
LangChain経由でOllamaのLLMへOCRテキストを送信。
JSON形式で {name, date, amount} を出力させ、抽出結果を上書き。
タイムアウト・解析失敗時はヒューリスティクス結果を利用。 - CSV出力
領収書ごとに1行を出力。
フィールド: source_file(元のPDFファイル名), page(PDFページ数), name(病院名), date(領収日), amount(請求金額), status(LLM利用ができたかどうか), avg_conf(OCR結果の平均信頼度), notes(LLMが失敗したときのメッセージなど)
ハマりポイント①
当初はTesseractを利用しました。ただし、OCRの認識精度があまりよくなく、他の方法を調べました。
2個目に使おうと試みたのがPaddleOCRでした。ただ、こちらはWindows+Anacondaの環境の問題か依存関係ではまり、導入を諦めました。
最終的にEasyOCRを利用しました。
EasyOCRはCPUを利用するモードで使っています。追々、GPUを利用したOCRも試してみたいですが、まずは動作刺させることを優先しました。
OCRライブラリ比較
あまりOCRも詳しくなかったので、Pythonで使えるものをこのタイミングで調べておきました。
項目 | Tesseract | PaddleOCR | EasyOCR |
---|---|---|---|
開発元 / 由来 | Googleが開発支援(OSS、歴史が長い) | Baidu(PaddlePaddleプロジェクトの一部) | Jaided AI(PyTorchベース) |
言語サポート | 100+言語、日本語あり(精度低め) | 多言語対応、日本語モデルあり | 80+言語、日本語対応あり |
インストール性 |
容易:pip install pytesseract + バイナリ必要 |
難しい:CUDAやバージョン依存が厳しく、PyMuPDFやnumpy互換性問題 |
容易:pip install easyocr で完結、PyTorch依存 |
GPU利用 | 対応なし(CPU専用) | 対応(GPUなら非常に高速) | 対応(PyTorch依存、GPUがあれば高速) |
追加機能 | 簡易的なOCRのみ(事後処理は自前) | レイアウト解析、文書理解、強力な前処理機能あり | シンプルなAPI、矩形情報や信頼度を返す |
ハマりポイント②
当初はollama CLIをsubprocessで呼び出して実装しようとしましたが、試行錯誤の中LLMのパラメータを指定したり、タイムアウト処理をさせる事を検討した結果、引数が長くなる事やサブプロセスが思うように中断されなかったので、LangChainを経由させる形にしました。
結果として、仕事で使っているwatsonx.aiなどにも応用できるようになったと思います。
実行結果
LLM無しのEasyOCRのみでは、病院名が同じ病院でも揺らぎがあったり、正しく切り出せない状態でしたが、LLMと組み合わせることでかなり正確にマッチするようになりました。
まだ、改善の余地や人間のチェックは必要ですが、
最終的には下記のようなCSVを書きだすことができました。
(数値・病院名は架空のもの)
source_file,page,name,date,amount,status,avg_conf,notes
img202509XX_12384587.pdf,1,自費診療歯科医院,2025-09-27,144000,ok,0.532,
img202509XX_12404530.pdf,1,山岡医院,2025-09-20,5030,ok,0.826,
img202509XX_12442967.pdf,1,タナボタ薬局,2025-07-20,4100,ok,0.871,
img202509XX_12452024.pdf,1,お花畑クリニック,2023-09-20,2400,ok,0.799,
img202509XX_12462749.pdf,1,XX中央眼科,2025-09-16,1830,ok,0.642,
img202509XX_12470895.pdf,1,XX歯科,2025-08-26,1190,ok,0.47,
img202509XX_12480882.pdf,1,XX中央眼科,2025-08-26,700,ok,0.689,
img202509XX_12522036.pdf,1,XX中央眼科,2025-08-26,3140,ok,0.611,
img202509XX_12531333.pdf,1,自費診療歯科医院,2023-03-03,1100,ok,0.522,
img202509XX_12540402.pdf,1,タナボタ薬局,2025-02-23,3190,ok,0.848,
img202509XX_12550295.pdf,1,お花畑クリニック,2023-08-23,1000,ok,0.804,
関数について
下記の関数を作成しました。
関数名 | 説明 |
---|---|
jdate_to_iso(s) |
日本語表記や区切り付きの日付を正規化して YYYY-MM-DD 形式に変換 |
parse_amount(s) |
全角→半角正規化し、金額を抽出。複数候補がある場合は最大値を返す |
guess_name(s) |
テキスト中から病院/薬局らしい名称を推定して返す |
build_llm(...) |
Ollama用のLangChain ChatOllama クライアントを構築 |
llm_extract(llm, ocr_text, ...) |
OCRテキストをLLMに送信し、JSON解析結果を取得 |
save_viz(image_pil, results, out_path, font_path) |
OCR結果を画像に可視化して保存 |
_preprocess_for_ocr(pil_img) |
OpenCVを用いた前処理(CLAHE + 適応的二値化) |
condense_text_for_llm(txt) |
LLM入力用に必要な行だけ抽出・要約 |
ocr_pages_from_pdf(...) |
PDFを1ページごとにOCR処理し、結果をリストで返す |
main() |
引数処理 → OCR → LLM → CSV出力までの全体制御 |
引数
こちらは試行錯誤をしつつ、GPTとやり取りしていくにつれ、かなり増えてしまいました。
引数 | デフォルト値 | 説明 |
---|---|---|
--input |
./pdf |
入力PDFフォルダ |
--output |
./medical_expenses_easyocr.csv |
出力CSVファイル |
--dpi |
300 |
PDF→画像変換のDPI |
--poppler |
D:\path\to\poppler-25.07.0\Library\bin |
Popplerのbinパス(Windows用) |
--gpu |
False |
GPUを使用(CUDA環境がある場合) |
--model |
mistral-small3.2 |
OllamaのLLMモデル名 |
--llm-timeout |
30 |
LLM応答のタイムアウト秒 |
--llm-disable |
False |
LLMを使わずヒューリスティクスのみで抽出 |
--num-predict |
128 |
Ollamaへのmaxトークン数 |
--viz |
False |
OCR結果の可視化画像を保存するか |
--viz-dir |
./viz |
可視化画像の保存先ディレクトリ |
--font |
C:\Windows\Fonts\meiryo.ttc |
可視化で使う日本語フォント |
--ollama-url |
http://127.0.0.1:11434 |
OllamaサーバーのURL |
--debug-llm |
False |
LLM入出力を標準出力に表示 |
--llm-max-chars |
1600 |
LLMに渡すOCRテキストの最大文字数 |
--min-conf |
0.35 |
OCR文字列の採用最小信頼度 |
--ocr-safe |
False |
OCRを素の設定で実行(前処理/フィルタなし) |
スクリプト本体
from __future__ import annotations
import argparse, csv, re, json, concurrent.futures, sys
from pathlib import Path
from typing import Dict, Any, Optional, List
from pdf2image import convert_from_path
import easyocr
from langchain.output_parsers import PydanticOutputParser
from pydantic import BaseModel, Field
from langchain.prompts import ChatPromptTemplate
from langchain_core.messages import HumanMessage
# ?? LLM (Ollama via LangChain) ?? #
from langchain_ollama import ChatOllama
from langchain_core.messages import HumanMessage
from PIL import ImageDraw, ImageFont
import numpy as np
import unicodedata
try:
import cv2
import numpy as np
_CV2_OK = True
except Exception:
_CV2_OK = False
np = None # 参照エラー回避
class Receipt(BaseModel):
name: str = Field(description="病院/薬局の名称")
date: str = Field(description="支払年月日 (YYYY-MM-DD)")
amount: int = Field(description="支払金額 (整数のみ)")
_parser = PydanticOutputParser(pydantic_object=Receipt)
_prompt = ChatPromptTemplate.from_template("""
以下のOCR抽出テキストを解析し、必ず JSON を1行だけ返してください。
出力フォーマット:
{format_instructions}
制約:
- JSON以外の文章は禁止(前後の説明・記号も不可)
- 不明な場合は name="" / date="" / amount=0
""".strip())
# =========================
# 便利関数
# =========================
def jdate_to_iso(s: str) -> str:
"""
「2025年9月27日」「2025/09/27」「2025-9-27」などを YYYY-MM-DD に正規化。
見つからなければ "" を返す。
"""
s = s.replace(" ", "")
m = re.search(r'(?P<y>20\d{2})[年/\-\.](?P<m>\d{1,2})[月/\-\.](?P<d>\d{1,2})[日]?', s)
if not m:
return ""
y, mth, d = m.group("y"), int(m.group("m")), int(m.group("d"))
try:
return f"{int(y):04d}-{mth:02d}-{d:02d}"
except Exception:
return ""
def parse_amount(s: str) -> int:
"""
金額らしき数値を抽出。全角→半角はNFKCで正規化、カンマ除去、円/\ を無視。
例: "領収金額:1,090円" -> 1090
複数候補がある場合は最大値を採用(合計っぽいものを優先)。
"""
# 全角→半角などの互換分解(全角数字・全角句読点・全角¥もASCIIへ)
s2 = unicodedata.normalize("NFKC", s)
# 通貨記号・単位は消す(OCRで \ になることもある)
s2 = s2.replace("円", "").replace("\", "").replace("\\", "")
# 数字候補を拾う(3桁区切りあり/なし)
nums = re.findall(r'(?:\d{1,3}(?:,\d{3})+|\d+)', s2)
candidates = []
for n in nums:
try:
candidates.append(int(n.replace(",", "")))
except Exception:
pass
return max(candidates) if candidates else 0
def guess_name(s: str) -> str:
lines = [ln.strip() for ln in s.splitlines() if ln.strip()]
key = re.compile(r'(医療法人|病院|医院|歯科|眼科|クリニック|薬局)')
# 先に領収書や合計などの“総称”は除外
bad = re.compile(r'(領収|合計|今回|入金|金額|内訳|診療|保険|点数|税込|税抜)')
# キーワード含む短文を優先
for ln in lines[:20]:
if key.search(ln) and not bad.search(ln) and len(ln) <= 40:
return ln
# 電話や住所を含む行から法人名っぽい語だけ抜く
for ln in lines[:30]:
m = re.search(r'([^\s\d〒\-ー?]+(医院|歯科|眼科|クリニック|薬局|病院))', ln)
if m: return m.group(1)
return ""
def build_llm(model: str, temperature: float, num_predict: int, request_timeout: int,
base_url: str) -> ChatOllama:
return ChatOllama(
base_url=base_url,
model=model,
temperature=temperature,
request_timeout=request_timeout,
model_kwargs={"num_predict": num_predict},
)
def llm_extract(llm: ChatOllama, ocr_text: str, timeout_sec: int, debug: bool=False,
max_chars: int = 1600) -> Optional[Dict[str, Any]]:
# 以前の版のように“1メッセージに全部詰める”
fmt = _parser.get_format_instructions()
body = _prompt.format(format_instructions=fmt) + "\n\nOCRテキスト:\n" + ocr_text[:max_chars]
msg = HumanMessage(content=body)
if debug: print("[LLM] invoke…")
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
fut = ex.submit(lambda: llm.invoke([msg]))
try:
res = fut.result(timeout=timeout_sec)
except concurrent.futures.TimeoutError:
if debug: print(f"?? Timeout after {timeout_sec} sec")
return None
raw = (res.content or "").strip()
if debug:
print("=== RAW ===")
print(raw[:800])
print("=== /RAW ===")
# 生JSONを取り出してパース
try:
m = re.search(r"\{.*\}", raw, re.S)
if not m:
raise ValueError("No JSON found")
data = _parser.parse(m.group(0)).dict()
return data
except Exception as e:
if debug: print(f"?? Parse error: {e}")
return None
def save_viz(image_pil, results, out_path: Path, font_path: Optional[str] = None):
"""
EasyOCRの結果を画像に描画して保存。
- 多角形の枠
- 認識テキストと信頼度(小さめ)
"""
img = image_pil.copy()
draw = ImageDraw.Draw(img)
# フォント準備(フォントが無ければデフォルト)
font = None
small = None
try:
if font_path and Path(font_path).exists():
font = ImageFont.truetype(font_path, 20)
small = ImageFont.truetype(font_path, 16)
except Exception:
font = None
small = None
for box, text, conf in results:
poly = [tuple(p) for p in box]
draw.line(poly + [poly[0]], width=3, fill=(255, 0, 0))
label = f"{text} ({conf:.2f})"
# 高さ・幅を正しく算出
if small or font:
fnt = small or font
tw = int(draw.textlength(label, font=fnt)) + 10
th = fnt.size + 6 # ←ここを修正
else:
tw = max(120, len(label) * 10)
th = 22
x, y = poly[0]
draw.rectangle([x, y - th, x + tw, y], fill=(255, 255, 0))
if small or font:
draw.text((x + 5, y - th + 3), label, fill=(0, 0, 0), font=fnt)
else:
draw.text((x + 5, y - th + 3), label[:32], fill=(0, 0, 0))
out_path.parent.mkdir(parents=True, exist_ok=True)
img.save(str(out_path), format="PNG")
# 追加: 前処理関数(あるときだけ使う)
def _preprocess_for_ocr(pil_img):
"""CLAHE + 適応的二値化。cv2が無ければ元画像を返す。"""
if not _CV2_OK:
return np.array(pil_img.convert("RGB")) if np is not None else pil_img
g = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2GRAY)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
g = clahe.apply(g)
# ブロックサイズは奇数、明るさ補正Cは領収書なら 8?12 が無難
b = 35 if g.shape[1] > 800 else 23
b = b if b % 2 == 1 else b + 1
th = cv2.adaptiveThreshold(g, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, b, 10)
rgb = cv2.cvtColor(th, cv2.COLOR_GRAY2RGB)
return rgb
# 追加:キーワード周辺だけ抜く関数
def condense_text_for_llm(txt: str, max_chars: int = 1600) -> str:
# 病院名/日付/金額に関係しそうな行だけ優先
keys = ["病院", "医院", "歯科", "眼科", "クリニック", "薬局",
"領収", "今回", "入金", "金額", "領収金額", "発行日", "年月日", "\", "円"]
lines = [ln.strip() for ln in txt.splitlines() if ln.strip()]
picked = []
for ln in lines:
if any(k in ln for k in keys) or re.search(r"\d{4}年\d{1,2}月\d{1,2}日", ln) or re.search(r"[\¥]?\s*\d{3,}", ln):
picked.append(ln)
snippet = "\n".join(picked) or txt
return (snippet[:max_chars]) if len(snippet) > max_chars else snippet
# =========================
# メイン処理
# =========================
# ocr_pages_from_pdf を修正(低conf除外&平均conf計算)
# ocr_pages_from_pdf の先頭で、--ocr-safe のときは“そのまま”読むルートを用意
def ocr_pages_from_pdf(pdf_path: Path, dpi: int, poppler_path: Optional[str],
reader: easyocr.Reader, min_conf: float=0.0,
safe_mode: bool=False):
images = convert_from_path(str(pdf_path), dpi=dpi, poppler_path=poppler_path)
out = []
for page_img in images:
if safe_mode:
# 何もいじらずに読む(以前の動いていた版に近い)
np_img = np.array(page_img.convert("RGB"))
results = reader.readtext(np_img)
use = results # フィルタしない
else:
np_img = _preprocess_for_ocr(page_img)
results = reader.readtext(np_img)
use = [(b,t,c) for (b,t,c) in results if c is None or c >= min_conf]
page_text = "\n".join([t for _, t, _ in use]) if use else ""
nconf = [c for *_, c in use if c is not None]
avg_conf = round(sum(nconf)/max(1,len(nconf)), 3) if nconf else 0.0
out.append({"image": page_img, "text": page_text,
"results": use, "avg_conf": avg_conf, "n_lines": len(use)})
return out
def main():
ap = argparse.ArgumentParser(description="EasyOCR + Ollamaで領収書PDFからCSVを生成")
ap.add_argument("--input", type=str, default="./pdf", help="PDFフォルダ")
ap.add_argument("--output", type=str, default="./medical_expenses_easyocr.csv", help="出力CSV")
ap.add_argument("--dpi", type=int, default=300, help="PDF->画像 変換DPI(300推奨)")
ap.add_argument("--poppler", type=str, default=r"D:\path\to\poppler-25.07.0\Library\bin",
help="Windows の Poppler bin パス(WSL/Linuxなら未指定でもOK)")
ap.add_argument("--gpu", action="store_true", help="GPUを使う(PyTorch CUDAが入っている場合)")
ap.add_argument("--model", type=str, default="mistral-small3.2", help="OllamaのLLM名(例:mistral, llama3.2, qwen2.5など)")
ap.add_argument("--llm-timeout", type=int, default=30, help="LLM応答タイムアウト秒")
ap.add_argument("--llm-disable", action="store_true", help="LLMを使わず規則ベースのみで抽出")
ap.add_argument("--num-predict", type=int, default=128, help="Ollamaへのmaxトークン(num_predict)")
ap.add_argument("--viz", action="store_true", help="OCR結果の可視化画像を保存する")
ap.add_argument("--viz-dir", type=str, default="./viz", help="可視化画像の出力先フォルダ")
ap.add_argument("--font", type=str, default=r"C:\Windows\Fonts\meiryo.ttc", help="日本語表示用フォント(TTC/TTF)。未インストールなら空文字で")
ap.add_argument("--ollama-url", type=str, default="http://127.0.0.1:11434", help="Ollama のベースURL")
ap.add_argument("--debug-llm", action="store_true", help="LLMへの送受信を標準出力に表示する")
# 追加:引数
ap.add_argument("--llm-max-chars", type=int, default=1600,
help="LLMに渡すOCRテキストの最大文字数(先頭&キーワード周辺を抽出)")
# 追加:引数
ap.add_argument("--min-conf", type=float, default=0.35,
help="EasyOCRの文字列採用の最小信頼度")
ap.add_argument("--ocr-safe", action="store_true",
help="EasyOCRを素の設定で実行(前処理/信頼度フィルタなし)")
args = ap.parse_args()
input_dir = Path(args.input)
output_csv = Path(args.output)
pdfs = sorted([p for p in input_dir.glob("*.pdf")])
if not pdfs:
print(f"PDFが見つかりません: {input_dir}")
sys.exit(1)
# EasyOCR 準備
reader = easyocr.Reader(['ja', 'en'], gpu=args.gpu)
# LLM 準備(必要なら)
llm = None
if not args.llm_disable:
llm = build_llm(
model=args.model,
temperature=0.0,
num_predict=args.num_predict,
request_timeout=args.llm_timeout + 5,
base_url=args.ollama_url,
)
try:
if args.debug_llm: print(f"[LLM] connect test -> {args.ollama_url} / model={args.model}")
_pong = llm.invoke([HumanMessage(content="pong")])
if args.debug_llm: print(f"[LLM] test ok: {(_pong.content or '')[:120]!r}")
except Exception as e:
print(f"[LLM] 接続/モデル確認に失敗: {e}\n→ LLMを無効化して継続します(ヒューリスティクスのみ)") # CSV 出力
llm = None
with output_csv.open("w", newline="", encoding="utf-8-sig") as f:
fieldnames = ["source_file","page","name","date","amount","status","avg_conf","notes"]
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
total = len(pdfs)
for i, pdf in enumerate(pdfs, start=1):
print(f"[{i}/{total}] {pdf.name} を処理中...")
try:
pages = ocr_pages_from_pdf(
pdf,
dpi=args.dpi,
poppler_path=args.poppler if args.poppler else None,
reader=reader,
min_conf=args.min_conf if not args.ocr_safe else 0.0,
safe_mode=args.ocr_safe
)
except Exception as e:
# PDF自体が読めない場合も行を出す(1ページ扱いで空)
writer.writerow({
"source_file": pdf.name, "page": 1,
"name": "", "date": "", "amount": 0,
"status": "pdf_error", "notes": str(e)
})
continue
for pidx, page in enumerate(pages, start=1):
txt = page["text"]
results = page["results"]
img = page["image"]
# --- 可視化の保存(必要なら) ---
if args.viz:
out_png = Path(args.viz_dir) / f"{pdf.stem}_p{pidx:02d}.png"
try:
save_viz(img, results, out_png, font_path=args.font if args.font else None)
except Exception as e:
print(f"viz失敗: {out_png.name} ({e})")
# 以下、元の処理(fallback抽出→LLM抽出→CSV書き込み)
fallback = {
"name": guess_name(txt),
"date": jdate_to_iso(txt),
"amount": parse_amount(txt),
}
final = dict(fallback)
status = "heuristic"
# LLM が使えるなら LLM で上書き(タイムアウトでもCSV行は必ず出す)
llm_res = None
if llm:
llm_res = llm_extract(llm, txt, timeout_sec=args.llm_timeout, debug=args.debug_llm, max_chars=args.llm_max_chars)
if llm_res:
# マージ:LLMで空ならヒューリスティクス値を維持
if llm_res.get("name"): final["name"] = llm_res["name"]
if llm_res.get("date"): final["date"] = jdate_to_iso(llm_res["date"]) or final["date"]
if isinstance(llm_res.get("amount"), int) and llm_res["amount"] > 0:
final["amount"] = llm_res["amount"]
status = "ok"
else:
status = "timeout_or_parse_error"
if args.debug_llm and status != "ok":
excerpt = "\n".join(txt.splitlines()[:10])
print("? OCR excerpt ?")
print(excerpt)
print("???????")
# 行書き込み時
writer.writerow({
"source_file": pdf.name,
"page": pidx,
"name": final.get("name",""),
"date": final.get("date",""),
"amount": final.get("amount",0),
"status": status,
"avg_conf": page.get("avg_conf", 0.0),
"notes": "" if status=="ok" else "LLMなし/失敗" if args.llm_disable else status
})
print(f"完了: {output_csv}")
if __name__ == "__main__":
main()