5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Tesseract OCRの誤読を人の確認画面で吸収する設計

5
Posted at

Tesseract OCRの誤読を人の確認画面で吸収する設計

はじめに

書類をOCRして業務システムへ取り込む仕組みを考えると、最初は「どうすれば認識精度を上げられるか」に目が向きます。

私も書類の読み取りやデータ整理を自動化するとき、OCRの後ろまで含めて考えるようにしています。理由は単純で、画像から文字を100%正しく読み取れることを前提に業務を組むと、少しの誤読がそのまま後工程へ流れてしまうからです。

この記事では、Tesseract OCRとPythonを使い、前処理、OCR、項目抽出、要確認判定、人が最後に直すためのデータ生成までを一つのパイプラインとして組みます。持ち帰れる成果物として、そのまま試せるPythonコード全文も載せます。

結論

OCRは「全文を正しく読む装置」ではなく、「人が確認する候補を作る装置」として扱います。
前処理とOCRだけで終わらせず、項目単位の検証と要確認フラグまで作ります。
人は全文を読み直すのではなく、怪しい項目を中心に確認します。

環境

この記事では、2026年9月時点で確認できる次の構成を前提にします。

OS: Ubuntu 24.04 LTS
Python: 3.13.x
Tesseract OCR: 5.5.3
pytesseract: 0.3.13
Pillow: 12.3.0

Tesseract 5.5.3は2026年7月24日に公開されています。Pythonについては3.13系を使いますが、パッチバージョンに処理を依存させない構成にしています。

Ubuntu系で試す場合、Tesseract本体と日本語の学習データを用意します。

sudo apt update
sudo apt install tesseract-ocr tesseract-ocr-jpn

Python側は仮想環境を作り、必要なパッケージを入れます。

python3 -m venv .venv
source .venv/bin/activate

pip install pytesseract==0.3.13 Pillow==12.3.0

インストール後は、利用できる言語を確認しておきます。

tesseract --version
tesseract --list-langs

一覧に jpn があれば、日本語の認識に利用できます。

ここで大事なのは、Tesseractのバージョンを上げれば帳票認識が自動的に安定する、と考えないことです。Tesseractの公式ドキュメントでも、入力画像の品質改善が認識結果に関係することが説明されています。

実装

Tesseract OCRの誤読を人の確認画面で吸収する設計

今回作る流れは次のようにします。

ポイントは、OCRの直後にデータベースへ書き込まないことです。

OCR結果と確定データの間に「登録候補」という状態を置きます。ここに、読み取った値だけでなく、OCRの信頼度や業務ルールによる検証結果も持たせます。

OCRの全文ではなく単語情報を取る

pytesseract.image_to_string() を使うと簡単に全文を取得できます。ただ、人が確認する画面まで作るなら、文字列だけでは情報が足りません。

そこで image_to_data() を使います。

from PIL import Image
import pytesseract
from pytesseract import Output

image = Image.open("sample.png")

data = pytesseract.image_to_data(
    image,
    lang="jpn",
    config="--psm 6",
    output_type=Output.DICT,
)

for i, text in enumerate(data["text"]):
    text = text.strip()
    if not text:
        continue

    print(
        text,
        data["conf"][i],
        data["left"][i],
        data["top"][i],
        data["width"][i],
        data["height"][i],
    )

ここでは認識した文字だけでなく、信頼度と画像上の位置も取れます。

位置情報を残す理由は、確認画面で元画像の該当部分を見せられるようにするためです。

たとえば「合計金額が怪しい」と分かったとき、利用者に帳票全体を探してもらうより、該当箇所をすぐ確認できるほうが修正しやすくなります。

前処理は一律に強くしない

OCR精度が悪いと、すぐ二値化を試したくなります。しかし、前処理を強くすれば必ず良くなるわけではありません。

細い文字や小さな記号は、しきい値によって消えることがあります。帳票によって背景や印刷状態も違います。

まずは比較しやすいように、前処理を関数として分離します。

from PIL import Image, ImageEnhance, ImageOps


def preprocess(image: Image.Image) -> Image.Image:
    image = ImageOps.exif_transpose(image)
    image = image.convert("L")

    image = ImageEnhance.Contrast(image).enhance(1.5)

    return image

ここでは、EXIFに従った向きの補正、グレースケール化、コントラスト調整だけにしています。

二値化を試すなら別関数にします。

def binarize(image: Image.Image, threshold: int = 180) -> Image.Image:
    gray = ImageOps.grayscale(image)

    return gray.point(
        lambda pixel: 255 if pixel > threshold else 0
    )

こうしておくと、

元画像
グレースケール+コントラスト
二値化

を簡単に比較できます。

「OCR前処理」という巨大な関数に全部詰め込むより、処理ごとに分けたほうが帳票との相性を確認しやすくなります。

OCR結果を自分の型へ変換する

次に、Tesseractの出力をそのまま後工程へ渡さないようにします。

from dataclasses import dataclass


@dataclass
class OcrToken:
    text: str
    confidence: float
    left: int
    top: int
    width: int
    height: int

変換処理は次のようにします。

def extract_tokens(image: Image.Image) -> list[OcrToken]:
    data = pytesseract.image_to_data(
        image,
        lang="jpn",
        config="--psm 6",
        output_type=Output.DICT,
    )

    tokens: list[OcrToken] = []

    for i, raw_text in enumerate(data["text"]):
        text = raw_text.strip()

        if not text:
            continue

        try:
            confidence = float(data["conf"][i])
        except (TypeError, ValueError):
            confidence = -1.0

        tokens.append(
            OcrToken(
                text=text,
                confidence=confidence,
                left=int(data["left"][i]),
                top=int(data["top"][i]),
                width=int(data["width"][i]),
                height=int(data["height"][i]),
            )
        )

    return tokens

この層を一つ挟んでおくと、後でOCRエンジンを変更した場合でも、業務側のコードへの影響を抑えやすくなります。

私が業務の自動化を考えるときは、この境界をかなり意識します。外部の認識処理が返した結果と、業務上「使ってよいデータ」は同じものとして扱わないほうが安全です。

信頼度だけでは確定しない

次に確認判定です。

ここで避けたいのが、

if confidence >= 90:
    confirmed = True

のように、OCRの信頼度だけで確定する設計です。

OCRエンジンの信頼度は参考にはなりますが、それだけで「業務上正しい」とは判断できません。

たとえば日付なら、

2026/09/26

が、

2026/09/28

になっていても、形式上は正しい日付です。

金額も同じです。数字として成立していることと、元の帳票を正しく読めたことは違います。

そこで私は、少なくとも次の二種類を分けます。

OCR由来の不確かさと業務ルール上の不自然さです。

from dataclasses import dataclass, field


@dataclass
class ReviewField:
    name: str
    value: str
    confidence: float | None
    needs_review: bool = False
    reasons: list[str] = field(default_factory=list)

日付を例にすると、次のように検証できます。

from datetime import datetime


def validate_date(field: ReviewField) -> ReviewField:
    try:
        datetime.strptime(field.value, "%Y/%m/%d")
    except ValueError:
        field.needs_review = True
        field.reasons.append("日付形式として解釈できません")

    if field.confidence is None:
        field.needs_review = True
        field.reasons.append("OCR信頼度を取得できません")
    elif field.confidence < 80:
        field.needs_review = True
        field.reasons.append("OCR信頼度が確認基準を下回っています")

    return field

ここで 80 はTesseract共通の正解ラインではありません。説明用の確認基準です。

実運用では、自分たちの帳票と「誤読したら困る項目」に合わせて決めます。

たとえば金額は慎重に確認し、備考欄は多少曖昧でも候補として表示する、といった違いがあってよいと思います。

項目ごとに確認条件を変える

業務書類には、同じ重要度の項目だけが並んでいるわけではありません。

たとえば、こんな帳票を想像してみてください。

発行日
伝票番号
取引先名
小計
税額
合計
備考

この場合、すべてを同じルールで判定する必要はありません。

金額は数字として解釈できるか確認できます。

import re


def normalize_amount(value: str) -> str:
    return (
        value.replace(",", "")
        .replace(",", "")
        .replace(" ", "")
        .replace("¥", "")
        .replace("¥", "")
    )


def validate_amount(field: ReviewField) -> ReviewField:
    normalized = normalize_amount(field.value)

    if not re.fullmatch(r"\d+", normalized):
        field.needs_review = True
        field.reasons.append("金額として解釈できません")

    if field.confidence is None or field.confidence < 85:
        field.needs_review = True
        field.reasons.append("金額項目を目視確認してください")

    return field

さらに、小計と税額と合計があるなら、項目間の整合性も確認できます。

def validate_totals(
    subtotal: int,
    tax: int,
    total: int,
) -> list[str]:
    reasons = []

    if subtotal + tax != total:
        reasons.append(
            "小計と税額の合算が合計と一致しません"
        )

    return reasons

これはOCRそのものの改善ではありません。

しかし、業務として見れば重要です。OCRが各数字をそれらしく読めていても、三つの関係が成立していなければ、人に見てもらう理由になります。

認識精度だけではなく、業務データとして矛盾していないかを見るわけです。

人が確認するデータをJSONにする

確認画面へ渡すデータは、値だけではなく理由も持たせます。

{
  "document_id": "document-001",
  "status": "needs_review",
  "fields": [
    {
      "name": "issued_date",
      "value": "2026/09/28",
      "confidence": 73.2,
      "needs_review": true,
      "reasons": [
        "OCR信頼度が確認基準を下回っています"
      ]
    },
    {
      "name": "total",
      "value": "12800",
      "confidence": 96.4,
      "needs_review": false,
      "reasons": []
    }
  ]
}

ここまで作っておけば、フロントエンド側は単純です。

needs_review == true の項目を目立たせ、元画像の該当箇所と並べます。

重要なのは、利用者に「OCR結果を確認してください」とだけ伝えないことです。

OCR信頼度が低い
日付として成立しない
金額として解釈できない
合計が一致しない
必須項目が空

のように理由を表示できれば、確認する人は何を見るべきか分かります。

確認画面では原文と候補を離さない

画面設計では、次のような配置を考えます。

┌─────────────────────┬────────────────────┐
│                     │ 発行日             │
│    元の帳票画像     │ [ 2026/09/28 ]     │
│                     │ 要確認             │
│   該当部分を強調    │ OCR信頼度が低い    │
│                     │                    │
│                     │ 合計               │
│                     │ [ 12800 ]          │
└─────────────────────┴────────────────────┘

確認項目をクリックしたら、左側の画像もその位置へ移動する形にすると扱いやすくなります。

そのため、先ほど保存した

left
top
width
height

が使えます。

OCR結果を文字列だけ保存してしまうと、この段階で元画像との対応を作り直す必要があります。後から確認画面を作る可能性があるなら、座標は最初から残しておくほうが楽です。

最小構成のコード全文

ここまでの考え方を、単一ファイルで試せる形にまとめます。

このサンプルでは特定帳票のレイアウト解析までは行わず、OCRトークンを取得し、信頼度の低い箇所を確認候補としてJSONへ出します。

from __future__ import annotations

import json
from dataclasses import asdict, dataclass
from pathlib import Path

import pytesseract
from PIL import Image, ImageEnhance, ImageOps
from pytesseract import Output


@dataclass
class OcrToken:
    text: str
    confidence: float
    left: int
    top: int
    width: int
    height: int
    needs_review: bool
    reasons: list[str]


def preprocess(image: Image.Image) -> Image.Image:
    image = ImageOps.exif_transpose(image)
    image = image.convert("L")
    image = ImageEnhance.Contrast(image).enhance(1.5)
    return image


def extract_tokens(
    image: Image.Image,
    review_threshold: float = 80.0,
) -> list[OcrToken]:
    data = pytesseract.image_to_data(
        image,
        lang="jpn",
        config="--psm 6",
        output_type=Output.DICT,
    )

    tokens: list[OcrToken] = []

    for i, raw_text in enumerate(data["text"]):
        text = raw_text.strip()

        if not text:
            continue

        try:
            confidence = float(data["conf"][i])
        except (TypeError, ValueError):
            confidence = -1.0

        reasons: list[str] = []

        if confidence < review_threshold:
            reasons.append(
                "OCR信頼度が確認基準を下回っています"
            )

        tokens.append(
            OcrToken(
                text=text,
                confidence=confidence,
                left=int(data["left"][i]),
                top=int(data["top"][i]),
                width=int(data["width"][i]),
                height=int(data["height"][i]),
                needs_review=bool(reasons),
                reasons=reasons,
            )
        )

    return tokens


def build_review_document(
    image_path: Path,
    tokens: list[OcrToken],
) -> dict:
    needs_review = any(
        token.needs_review for token in tokens
    )

    return {
        "source_image": image_path.name,
        "status": (
            "needs_review"
            if needs_review
            else "ready"
        ),
        "tokens": [
            asdict(token)
            for token in tokens
        ],
    }


def main() -> None:
    image_path = Path("sample.png")
    output_path = Path("review.json")

    with Image.open(image_path) as source:
        processed = preprocess(source)
        tokens = extract_tokens(processed)

    document = build_review_document(
        image_path=image_path,
        tokens=tokens,
    )

    output_path.write_text(
        json.dumps(
            document,
            ensure_ascii=False,
            indent=2,
        ),
        encoding="utf-8",
    )

    review_count = sum(
        token.needs_review for token in tokens
    )

    print(f"tokens: {len(tokens)}")
    print(f"needs_review: {review_count}")
    print(f"output: {output_path}")


if __name__ == "__main__":
    main()

実行します。

python ocr_review.py

生成される review.json を確認画面用APIへ渡せば、まずは「怪しい箇所だけ人が見る」という最小構成を作れます。

本番では、このコードの extract_tokens() と build_review_document() の間に、帳票ごとの項目抽出処理を入れるイメージです。

ハマりどころ

--psm を変えると結果が変わる

TesseractにはPage Segmentation Modeがあります。

この記事のサンプルでは、

--psm 6

を指定しました。

これは一つの均一なテキストブロックとして扱うモードです。しかし、どの帳票にも 6 が適しているわけではありません。

たとえば、まばらに文字が配置された書類と、文章がまとまっている書類ではレイアウトの性質が違います。

確認は次のコマンドでできます。

tesseract --help-psm

OCRが不安定なときは画像処理だけを疑わず、ページ分割の前提が入力画像に合っているかも確認します。

日本語だけを指定すればよいとは限らない

帳票には、日本語だけでなく英字や数字が混在します。

Tesseractは複数言語を指定できます。

data = pytesseract.image_to_data(
    image,
    lang="jpn+eng",
    config="--psm 6",
    output_type=Output.DICT,
)

ただし、言語を増やせば必ず改善するというものでもありません。

入力する書類の種類を決め、その書類で比較するのが先です。

信頼度の平均だけを見ると危ない

帳票全体の平均信頼度を計算すると、分かりやすい指標に見えます。

しかし、業務では平均より「どこを間違えたか」が重要な場合があります。

会社名が多少怪しくても後で照合できる一方、合計金額の一桁が変わると困る業務もあります。

そのため、

帳票全体の平均値

だけではなく、

項目
OCR信頼度
業務上の重要度
形式チェック
項目間の整合性

を組み合わせて確認対象を決めるほうが扱いやすいです。

前処理後の画像を捨てない

開発中は、OCRへ渡した画像を一時的に保存できるようにしておくと原因を追いやすくなります。

processed.save("debug_processed.png")

元画像では読めるのにOCR結果がおかしいとき、debug_processed.png を見ると、前処理で文字が欠けていることがあります。

「OCRが間違えた」と思って調べていたら、その前の処理で必要な画素を消していた、という切り分けをしやすくなります。

本番環境で保存する場合は、書類に個人情報や機密情報が含まれる可能性を考え、保存期間、アクセス権、ログへの出力範囲を別途設計する必要があります。

修正結果を上書きだけで終わらせない

人がOCR結果を修正したら、最終値だけ残したくなります。

しかし、改善材料として考えるなら、

OCRが出した値
人が確定した値
確認理由
対象項目

を区別できる形にしておくと便利です。

たとえば、

{
  "field": "issued_date",
  "ocr_value": "2026/09/28",
  "confirmed_value": "2026/09/26",
  "review_reason": "low_confidence"
}

という形です。

これを残しておけば、「どの項目で修正が多いのか」を後から確認できます。

ただし、帳票そのものや修正履歴には機密情報が含まれることがあります。分析用だから無期限に残す、という設計にはせず、必要性と保存方針を先に決めます。

OCRと業務ルールを同じ関数にしない

小さく始めると、次のような関数を作りがちです。

def read_invoice(image):
    # 前処理
    # OCR
    # 金額抽出
    # 日付チェック
    # DB登録
    ...

最初は楽ですが、帳票が増えると変更しづらくなります。

私は少なくとも、

画像前処理
OCR
OCR結果の正規化
項目抽出
業務ルール検証
確認判定
確定処理

を分けて考えます。

OCRエンジンを変えたいときに業務ルールまで書き直す必要はありませんし、金額チェックを変えたいだけなのに画像処理へ触れる必要もありません。

まとめ

OCRの精度が出ないとき、画像処理やOCRエンジンの設定を調整することはもちろん必要です。ただ、業務自動化として考えるなら、それだけで終わらせないほうがよいと考えています。

私が大切にしているのは、最初から全部を自動確定しようとしないことです。

まずOCRに下ごしらえを任せます。次に形式や項目間の関係をコードで確認します。それでも判断できないところだけ、人が元画像を見て決めます。

そのためには、OCR結果として文字列だけを保存するのでは足りません。

認識した値
信頼度
画像上の位置
業務ルールの検証結果
確認が必要な理由
人が確定した値

までつなげて考える必要があります。

OCRを100%に近づけることだけを目標にすると、最後の数%に時間を使い続けることがあります。人が確認する前提を設計に含めれば、「どこまで機械に任せ、どこから人が決めるか」という別の選択肢が持てます。

テクノロジーを人の代わりに置くのではなく、人が判断しやすい状態を作る味方として置く。書類の読み取り自動化では、そのくらいの距離感が扱いやすいと思っています。

参考文献

5
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?