💨

AI-OCR × 現場帳票で始める「脱・紙&手入力」 #2

に公開

AI-OCR × 現場帳票で始める「脱・紙&手入力」 #2

Python + AI-OCR API で最小サンプルをつくる

はじめに

第1回では、

  • 現場帳票のパターン整理
  • AI-OCR 導入前に決めておくべき事項
  • 全体アーキテクチャと PoC → 本番のステップ

を、現場サイドの視点で整理しました。

第2回となる本記事では、

Python + 汎用 AI-OCR API を使って、
「現場帳票 1 種類」を対象とした最小 PoC パイプラインを動かす

ことをゴールにします。

特定のベンダー名には依存せず、

  • 画像ファイルを Python から送信
  • JSON で返ってきた結果をパース
  • 帳票項目にマッピング
  • CSV として出力

という一連の流れを、あくまで「最小構成」で整理します。


前提とゴール

対象読者

  • Python 3.x の基本的なコードが読める
  • コマンドラインから python / pip を実行できる
  • レイアウト固定の帳票を 1 種類決めて PoC をしたい

今回のゴール

  1. input/ フォルダにある帳票画像(例:sample_001.jpg)を Python で読み込む
  2. 汎用 AI-OCR API に投げて、結果 JSON を取得
  3. JSON から「日付・担当者名・温度・チェック結果」などの項目を抽出
  4. output/result.csv として書き出す

ここまでできれば、過去帳票をまとめて処理して精度検証に進めます。


想定するサンプル帳票

説明を具体化するため、サンプル帳票を次のように仮定します。

  • 帳票名:日次点検チェックシート(1 枚 1 日分)
  • 主な項目:
    • date:点検日(例:2025/12/15)
    • operator:点検者名
    • temp_fridge:冷蔵庫温度(℃)
    • temp_freezer:冷凍庫温度(℃)
    • check_ok:総合結果(OK / NG)

A4 縦、レイアウト固定、毎日同じフォーマットで利用されている前提です。


Step 1:環境準備

1-1. ディレクトリ構成

PoC 用に、最低限次のような構成を用意します。

aiocr_poc/
  input/
    sample_001.jpg
    sample_002.jpg
    ...
  output/
  src/
    config_example.yaml
    main.py
    ocr_client.py
    mapper.py
    writer.py

・input/:帳票画像(JPEG / PNG など)
・output/:CSV などの出力先
・src/:Python スクリプト一式

1-2. 仮想環境と依存ライブラリ

仮想環境は任意ですが、分けておくと管理しやすくなります。

cd aiocr_poc

python -m venv .venv

# Windows:
.venv\Scripts\activate

# macOS / Linux:
source .venv/bin/activate

pip install requests pyyaml

今回は最小構成として、
・requests:HTTP クライアント
・PyYAML:設定ファイル読み込み用
のみ利用します。

Step 2:AI-OCR API クライアント

特定ベンダーには依存せず、「画像を送り、JSON が返る」抽象クライアントを実装します。

2-1. 設定ファイル

src/config_example.yaml(コピーして config.yaml として利用):

ocr:
  endpoint: "https://api.example-ocr.com/v1/ocr"  # 実際のエンドポイントに置き換え
  api_key: "YOUR_API_KEY_HERE"                    # 実際の API キーに置き換え
  timeout_sec: 30

実運用では config.yaml は .gitignore し、
config_example.yaml のみリポジトリに含める運用が一般的です。

2-2. 汎用 AI-OCR クライアント実装

src/ocr_client.py:

import base64
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict

import requests
import yaml


@dataclass
class OcrConfig:
    endpoint: str
    api_key: str
    timeout_sec: int = 30


def load_config(path: Path) -> OcrConfig:
    with path.open("r", encoding="utf-8") as f:
        data = yaml.safe_load(f)
    ocr_cfg = data["ocr"]
    return OcrConfig(
        endpoint=ocr_cfg["endpoint"],
        api_key=ocr_cfg["api_key"],
        timeout_sec=int(ocr_cfg.get("timeout_sec", 30)),
    )


class AiOcrClient:
    def __init__(self, config: OcrConfig) -> None:
        self.config = config

    def _encode_image_base64(self, image_path: Path) -> str:
        with image_path.open("rb") as f:
            b = f.read()
        return base64.b64encode(b).decode("ascii")

    def recognize(self, image_path: Path) -> Dict[str, Any]:
        """
        汎用 AI-OCR API に画像を投げて JSON を返す。
        レスポンス形式は利用する API に合わせて調整する。
        """
        img_b64 = self._encode_image_base64(image_path)

        payload = {
            "image": img_b64,
            "options": {
                "language": "ja",
                "format": "json",
            },
        }

        headers = {
            "Authorization": f"Bearer {self.config.api_key}",
            "Content-Type": "application/json",
        }

        response = requests.post(
            self.config.endpoint,
            headers=headers,
            data=json.dumps(payload),
            timeout=self.config.timeout_sec,
        )
        response.raise_for_status()
        return response.json()

利用する AI-OCR サービスの仕様に応じて payload は調整してください。

Step 3:帳票項目へのマッピング

AI-OCR のレスポンスはサービスごとに形式が違いますが、
ここでは例として次のような JSON を想定します。

{
  "pages": [
    {
      "blocks": [
        { "text": "2025/12/15", "bbox": [100, 80, 200, 120] },
        { "text": "山田 太郎",   "bbox": [120, 140, 260, 180] },
        { "text": "2.5",         "bbox": [400, 220, 450, 250] }
      ]
    }
  ]
}

・text:認識文字列
・bbox:画像上の座標(左上 x, y, 右下 x, y)
この座標をもとに、各項目ごとに読むエリアを定義します。

3-1. 座標によるエリア定義

src/mapper.py:

from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple


@dataclass
class FieldRegion:
    name: str
    # (x1, y1, x2, y2)
    bbox: Tuple[int, int, int, int]


# 帳票テンプレートに合わせて座標を定義
FIELD_REGIONS: List[FieldRegion] = [
    FieldRegion(name="date",          bbox=(90,  70, 210, 130)),
    FieldRegion(name="operator",      bbox=(110, 130, 280, 190)),
    FieldRegion(name="temp_fridge",   bbox=(390, 210, 470, 260)),
    FieldRegion(name="temp_freezer",  bbox=(390, 260, 470, 310)),
    FieldRegion(name="check_ok",      bbox=(100, 320, 220, 380)),
]


def _is_inside(inner: List[int], outer: Tuple[int, int, int, int]) -> bool:
    x1, y1, x2, y2 = inner
    ox1, oy1, ox2, oy2 = outer
    return x1 >= ox1 and y1 >= oy1 and x2 <= ox2 and y2 <= oy2


def map_ocr_result_to_fields(ocr_json: Dict[str, Any]) -> Dict[str, str]:
    """
    OCR 結果の JSON から、帳票項目ごとの値を抜き出す。
    """
    result: Dict[str, str] = {f.name: "" for f in FIELD_REGIONS}

    pages = ocr_json.get("pages", [])
    if not pages:
        return result

    blocks = pages[0].get("blocks", [])
    for field in FIELD_REGIONS:
        texts: List[str] = []
        for block in blocks:
            bbox = block.get("bbox")
            text = block.get("text")
            if not bbox or not text:
                continue
            if _is_inside(bbox, field.bbox):
                texts.append(str(text))
        # 同じエリアに複数ブロックがあれば結合
        if texts:
            result[field.name] = " ".join(texts)

    return result

実際には、利用する OCR サービスのレスポンスに合わせて
pages / blocks の部分を読み替えます。

3-2. 簡易的な後処理(数字・日付など)

最低限の型変換もこの段階でまとめて行います。

def normalize_fields(raw: Dict[str, str]) -> Dict[str, Any]:
    """
    生テキストから、アプリケーションで扱いやすい型に変換する例。
    """
    from datetime import datetime

    out: Dict[str, Any] = dict(raw)

    # 日付
    date_str = raw.get("date", "").replace("年", "/").replace("月", "/").replace("日", "")
    for fmt in ("%Y/%m/%d", "%Y-%m-%d"):
        try:
            dt = datetime.strptime(date_str, fmt)
            out["date"] = dt.strftime("%Y-%m-%d")
            break
        except ValueError:
            continue

    # 温度(数値変換)
    for key in ("temp_fridge", "temp_freezer"):
        v = raw.get(key, "").replace("℃", "").replace("°C", "").strip()
        try:
            out[key] = float(v)
        except ValueError:
            out[key] = None

    # チェック結果(OK/NG 正規化)
    check = raw.get("check_ok", "").upper()
    if "OK" in check or "〇" in check:
        out["check_ok"] = "OK"
    elif "NG" in check or "×" in check:
        out["check_ok"] = "NG"

    return out

Step 4:CSV への書き出し

帳票 1 枚につき 1 行の CSV として書き出します。
src/writer.py:

import csv
from pathlib import Path
from typing import Any, Dict, List

COLUMNS = ["file_name", "date", "operator", "temp_fridge", "temp_freezer", "check_ok"]


def write_csv(output_path: Path, rows: List[Dict[str, Any]]) -> None:
    output_path.parent.mkdir(parents=True, exist_ok=True)
    with output_path.open("w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=COLUMNS)
        writer.writeheader()
        for row in rows:
            writer.writerow({col: row.get(col, "") for col in COLUMNS})

Step 5:全体をつなぐ main.py

最後に、ocr_client.py・mapper.py・writer.py をまとめて呼び出す main.py を用意します。
src/main.py:

from pathlib import Path
from typing import Any, Dict, List

from ocr_client import AiOcrClient, load_config
from mapper import map_ocr_result_to_fields, normalize_fields
from writer import write_csv


def process_single_image(client: AiOcrClient, image_path: Path) -> Dict[str, Any]:
    print(f"[INFO] processing: {image_path.name}")
    ocr_json = client.recognize(image_path)
    raw_fields = map_ocr_result_to_fields(ocr_json)
    norm_fields = normalize_fields(raw_fields)
    norm_fields["file_name"] = image_path.name
    return norm_fields


def main() -> None:
    base_dir = Path(__file__).resolve().parent.parent
    config_path = base_dir / "src" / "config.yaml"
    input_dir = base_dir / "input"
    output_csv = base_dir / "output" / "result.csv"

    cfg = load_config(config_path)
    client = AiOcrClient(cfg)

    rows: List[Dict[str, Any]] = []
    for image_path in sorted(input_dir.glob("*.jpg")):
        try:
            row = process_single_image(client, image_path)
            rows.append(row)
        except Exception as e:
            print(f"[ERROR] failed: {image_path.name}: {e}")

    write_csv(output_csv, rows)
    print(f"[INFO] done. output: {output_csv}")


if __name__ == "__main__":
    main()

実行方法:

cd aiocr_poc

# 仮想環境を有効化したうえで
python src/main.py

・input/*.jpg が順に AI-OCR にかけられ、
・各帳票の項目が抽出され、
・output/result.csv に 1 行ずつ出力されます。

ここまでで分かることと、次のステップ

この最小パイプラインまで構築できると、
・帳票ごとの読み取り精度(数値・日付・チェック欄など)
・項目ごとの誤読パターン(0/8、1/7、記入位置のズレ 等)
・前処理の有無による精度差(傾き補正・二値化 等)
を定量的に評価できるようになります。

次回以降は、今回のパイプラインをベースに、
・スキャン条件・撮影条件が悪い画像に対する前処理(傾き補正・トリミング)
・帳票テンプレートが複数存在する場合の「帳票種類判定」
・Web UI 上での確認・修正画面(人による最終チェックを組み込んだワークフロー)
といった実務寄りの内容を扱っていく予定です。

Discussion