🔍

【AI OCR】マルチモーダル LLM を使った証明書 AI OCR のベストプラクティス

に公開

はじめに

こんにちは、ナウキャストで LLM エンジニアをしている Ryotaro です。

先日、Finatext グループで生成 AI コンテストが開催されました。テクノロジー部門のテーマは 「AI を活用した新規価値創造/業務改善アプリケーションの AI 駆動開発」というもの。

我々のチームは、マルチモーダルの LLM を使って証明書から情報を抽出する AI OCR システムを作りました。今回は、そのシステムの作成についてお話しします。

システムの概要

このシステムは証明書の画像をアップロードすると、AI があらかじめ指定した項目の情報を抽出してくれる API です。

イメージはこんな感じです。源泉徴収票を例にすると、氏名・住所・支払金額などの情報を抽出してくれます。

システムのイメージ

AI OCR の実装

今回メインとなるのはマルチモーダルの LLM を使った AI OCR です。

検証対象のモデルは以下の 6 つです。

  • Azure OpenAI
    • gpt-4o
    • gpt-4.1
    • o3
    • o4-mini
  • AWS Bedrock
    • claude 3.5 sonnet
    • claude 3.7 sonnet

それぞれのモデルをつかってどのように OCR を実装するか、どのように精度を向上させるか、どのモデルが一番精度が高いかを検証します。

Azure OpenAI

Azure OpenAI のコードは以下の通りです。image を base64 でエンコードして、image_url に渡します。

import base64
from OpenAI import AzureOpenAI

client = AzureOpenAI(
    api_key="<api_key>",
    azure_deployment="<azure_deployment>",
    azure_endpoint="<azure_endpoint>",
    api_version="<api_version>",
)

# 画像を base64 でエンコード
with open(image_path, "rb") as image_file:
    image_bytes = image_file.read()
    base64_image = base64.b64encode(image_bytes).decode("utf-8")
mime_type = "image/png"
image_url = f"data:{mime_type};base64,{base64_image}"

# メッセージを作成
messages = [
    {
        "role": "user",
        "content": [
            {"type": "text", "text": "この画像の内容を json 形式に変換してください。"},
            {"type": "image_url", "image_url": {"url": image_url}},
        ],
    }
]

# モデルを呼び出し
response = client.responses.create(
    model=model_name, # gpt-4o, gpt-4.1, o3, o4-mini
    input=messages,
)

Azure OpenAI(OpenAI) の場合、base64 で画像をエンコードして、image_url に渡します。

ただ、このコードでは厳密に型定義をしていないため予期せぬ出力を返す可能性があります。なので beta.chat.completions.parse を使って Structured Output 機能を使い明示的に出力の型を定義します。

from pydantic import BaseModel

class Schema(BaseModel):
    name: str
    address: str
    amount: int

response = client.beta.chat.completions.parse(
    model=model_name, # gpt-4o, gpt-4.1, o3, o4-mini
    messages=messages,
    response_format=schema, # 抽出する項目を指定する
)

# 結果を表示
print(response.choices[0].message.parsed)
>>>
{
    "name": "山田太郎",
    "address": "東京都千代田区永田町1-7-1",
    "amount": 100000
}
>>>

これにより、確実に欲しい型のデータを取得することができます。

AWS Bedrock

AWS Bedrock のコードは以下の通りです。
AWS Bedrock(Claude) には OpenAI のように Structured Output のような機能はないので、tool を使って再現します。

import boto3
from pydantic import BaseModel

class Schema(BaseModel):
    name: str
    address: str
    amount: int

# クライアントを作成
client = boto3.client(
    service_name="bedrock-runtime",
    region_name="<region_name>", # cross region の場合は us-east-1 などにするs
)

# メッセージを作成
messages = [
    {
        "role": "user",
        "content": [
            {"text": system_prompt},
            {"image": {"format": "png", "source": {"bytes": image_bytes}}},
        ],
    }
]

# tool を作成
tool_definition = {
    "toolSpec": {
        "name": "build_base_model",
        "description": "build the base model object",
        "inputSchema": {"json": Schema.model_json_schema()},
    }
}
tool_choice = {
    "tool": {
        "name": "build_base_model",
    }
}

# モデルを呼び出し
response = client.converse(
    modelId=model_id, # claude-3-5-sonnet-20240620-v1:0, claude-3-7-sonnet-20240229-v1:0
    messages=messages,
    inferenceConfig={
        "maxTokens": 10000,
    },
    toolConfig={
        "tools": [tool_definition],
        "toolChoice": tool_choice,
    },
)

# 結果を表示
output = response["output"]["message"]["content"][0]["toolUse"]["input"]
print(Schema.model_validate(output))
>>>
{
    "name": "山田太郎",
    "address": "東京都千代田区永田町1-7-1",
    "amount": 100000
}
>>>

余談:Converse API と Invoke API の違い

Amazon Bedrock の API はややこしくて InvokeModel APIConverse API の違いに詰まりました。どちらも画像には対応していて OCR をする分には意識しなくても良いですが、json 形式で返すようにしたい場合は Converse API を使う必要があります。

  • InvokeModel API
    • 低レベルの"一発呼び出し"API。プロンプトもレスポンス形式も(モデルごとに)バラバラで、テキスト/画像生成・埋め込み取得などあらゆるモダリティに使える。会話の状態は保持しない。
  • Converse API
    • 2024 年末に追加されたメッセージベースのチャット用 API。messages=[...] にシステム/ユーザ/アシスタント/ツールのロールを書くだけでどの"会話対応モデル"でも同じコードが動く。マルチターン会話・ツール呼び出し・ガードレールなどが標準化されている。

そもそも二つある背景は、Amazon Bedrock は最初に汎用の InvokeModel を提供し、任意のモデルごとに「好きな JSON をそのまま渡す」方式を採用しました。しかしモデル間でパラメータ名が異なり、チャット履歴やツール呼び出しを自前で埋め込む必要があって開発負荷が高い――そこで Converse が導入され、「メッセージ+共通推論パラメータ」で統一しよう、ということになったらしいです。

精度向上施策

次の段階として、精度を向上させるためにいくつかの工夫を行います。

出力スキーマの拡張

まず基本的に LLM に指示するときは、欲しい回答があればそれに従って出力してもらうようにフォーマットを指定します。何でもかんでも自由に出力してもらうのではコストも速度もパフォーマンスが悪く、逆に欲しい情報がとれない場合もあります。そこで、あらかじめ欲しい項目を列挙し型として定義、それに従って出力してもらうようにします。前述の Structured Output がこれにあたります。

Pydantic には Field というものがあり、項目ごとに description を定義できます。OpenAI の Structured Output では、description を読み取り、prompt に組み込むようにできているため、これを追加することで LLM が抽出する情報を付加することができます。

class OcrWithHoldingWithholdingSlipOutput(BaseModel):
    """
    OCRで読み取った源泉徴収票の情報を保持するクラス
    """
    japanese_year: str = Field(..., description="年度")
    mynumber: Optional[str] = Field(None, description="個人番号")
    real_name_kana: str = Field(..., description="氏名(カナ)")
    real_name: str = Field(..., description="氏名")
    applicable_info: str = Field(..., description="摘要欄")
    job_status: str = Field(..., description="中途就退職情報")
    payer_name: str = Field(..., description="支払者氏名")
    annual_salary: int = Field(..., description="年収額")

description の他にも patternmaximum などのパラメーターを定義することで、LLM により詳細な項目の制約を与えることができます。

OpenAI では以下のパラメータが利用可能です。

  • Supported string properties:
    • pattern — 正規表現でマッチする必要がある
    • format — 文字列の形式を定義する。現在サポートされている形式は以下の通りです。
      • date-time
      • time
      • date
      • duration
      • email
      • hostname
      • ipv4
      • ipv6
      • uuid
  • Supported number properties:
    • multipleOf — 数値はこの値の倍数でなければならない
    • maximum — 数値はこの値以下でなければならない
    • exclusiveMaximum — 数値はこの値未満でなければならない
    • minimum — 数値はこの値以上でなければならない
    • exclusiveMinimum — 数値はこの値より大きくなければならない
  • Supported array properties:
    • minItems — 配列はこの数以上の要素を持たなければならない
    • maxItems — 配列はこの数以下の要素を持たなければならない

https://platform.OpenAI.com/docs/guides/structured-outputs?api-mode=responses&example=structured-data#supported-schemas

先ほどの例に、追加で patternmaximumminimum 制約を加えてみると、以下のようになります。

class OcrWithHoldingWithholdingSlipOutput(BaseModel):
    """
    OCRで読み取った源泉徴収票の情報を保持するクラス
    """
    japanese_year: str = Field(..., description="年度", pattern=r"^令和\d{1,2}年$")
    mynumber: Optional[str] = Field(None, description="個人番号", pattern=r"^\d{12}$")
    real_name_kana: str = Field(..., description="氏名(カナ)", pattern=r"^[ァ-ヶー]+$")
    real_name: str = Field(..., description="氏名", pattern=r"^[ぁ-んァ-ン一-鿿]+$")
    applicable_info: Optional[str] = Field(None, description="摘要欄", pattern=r"^[ぁ-んァ-ン一-鿿]+$")
    job_status: Optional[str] = Field(None, description="中途就退職情報", pattern=r"^[ぁ-んァ-ン一-鿿]+$")
    payer_name: str = Field(..., description="支払者氏名", pattern=r"^[ぁ-んァ-ン一-鿿]+$")
    annual_salary: int = Field(..., description="年収額", ge=0, le=9999999999)

具体的に request 内でどのように response_format が OpenAI に送られているかというと、以下のようになります。

{
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "schema": {
        "description": "OCRで読み取った源泉徴収票の情報を保持するクラス",
        "properties": {
          "japanese_year": {
            "description": "年度",
            "pattern": "^令和\\d{1,2}年$",
            "title": "Japanese Year",
            "type": "string"
          },
          "mynumber": {
            "anyOf": [
              {
                "pattern": "^\\d{11}$",
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "description": "個人番号",
            "title": "Mynumber"
          },
          "real_name_kana": {
            "description": "氏名(カナ)",
            "pattern": "^[ァ-ヶー]+$",
            "title": "Real Name Kana",
            "type": "string"
          },
          "real_name": {
            "description": "氏名",
            "pattern": "^[ぁ-んァ-ン一-鿿]+$",
            "title": "Real Name",
            "type": "string"
          },
          "applicable_info": {
            "anyOf": [
              {
                "pattern": "^[ぁ-んァ-ン一-鿿]+$",
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "description": "摘要欄",
            "title": "Applicable Info"
          },
          "job_status": {
            "anyOf": [
              {
                "pattern": "^[ぁ-んァ-ン一-鿿]+$",
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "description": "中途就退職情報",
            "title": "Job Status"
          },
          "payer_name": {
            "description": "支払者氏名",
            "pattern": "^[ぁ-んァ-ン一-鿿]+$",
            "title": "Payer Name",
            "type": "string"
          },
          "annual_salary": {
            "description": "年収額",
            "maximum": 9999999999,
            "minimum": 0,
            "title": "Annual Salary",
            "type": "integer"
          }
        },
        "required": [
          "japanese_year",
          "mynumber",
          "real_name_kana",
          "real_name",
          "applicable_info",
          "job_status",
          "payer_name",
          "annual_salary"
        ],
        "title": "OcrWithHoldingWithholdingSlipOutput",
        "type": "object",
        "additionalProperties": false
      },
      "name": "OcrWithHoldingWithholdingSlipOutput",
      "strict": true
    }
  }
}

うまく行くパターン

年度は pattern を利用して正規表現(pattern=r"^令和\d{1,2}年$")でマッチするようにしていて、これはうまく行きました。
設定していない時は 7年 になったり、 7 になったりします。もちろん prompt に記載することで、これを防ぐことができますが、項目特有の制約がある場合は pattern を利用することで、Structured Output の機能で validation しながら抽出することができます。

また、マイナンバーのような数値だけど、0 から始まる場合もあります。この場合は number として gtlt をつかうのではなく、 String として pattern を利用して正規表現でマッチするようにすることで、正確に 12 桁の数字を抽出することができます。

うまく行かないパターン

逆に名前や摘要欄のような、バリエーションが多い項目は、pattern を利用しても精度はあがりませんでした。おそらくこれは pattern の情報は LLM に渡ったとしてもあくまでもヒントになるだけで、制御することはできないためと思われます。
フリー記述の様な項目はどの様な値が OCR で出力されるかわからないので、かえってエラー発生確率を上げてしまう可能性があります。そのため、このような項目は description を与えるくらいで制約はかけないほうがよさそうです。

{
  "japanese_year": "令和6年",
  "is_mynumber_masked": false,
  "real_name_kana": "タナカユミコ",
  "real_name": "田中美保",
  "applicable_info": "株式会社ドリムファクトリ一福岡市博多区祇園町",
  "job_status": "退職",
  "payer_name": "株式会社ゴバルソリュ一ションズパ一トナ一",
  "annual_salary": 4200000
}

プロンプトチューニング

LLM に message を渡すときには、同時に text の指示も prompt として渡すことができます。

"content": [
    {"type": "text", "text": "この画像の内容を json 形式に変換してください。"},
    {"type": "image_url", "image_url": {"url": image_url, "detail": detail}},
]

json 形式に変換してください。 という単純な prompt でも Structured Output を利用することで、ある程度は正確に抽出することができます。ただそれでは取りこぼしがある可能性があるので、タスクの内容とスキーマ全体に関して説明します。

この画像から以下の項目のみを json 形式で抽出してください。

# 抽出する項目の構造情報

- 年度(japanese_year):
  - note: 年度は必ず抽出してください。
  - location: "タイトル行左"
- 個人番号(mynumber):
  - note: 個人番号は 12 桁の数字です。記載がない場合は null を返してください。
  - location: "右上『個人番号』欄"
- 氏名(real_name_kana):
  - note: 氏名のカナは必ず抽出してください。
  - location: "上段中央『氏名』欄"
- 氏名(real_name):
  - note: 氏名は必ず抽出してください。
  - location: "上段中央『氏名』欄"
- 摘要欄(applicable_info):
  - note: 摘要欄は必ず抽出してください。記載がない場合は空文字を返してください。
  - location: "中央『摘要欄』"
- 中途就退職情報(job_status):
  - note: 中途就退職情報は必ず抽出してください。記載がない場合は空文字を返してください。
  - location: "下表『中途就・退職』欄"
- 支払者氏名(payer_name):
  - note: 支払者氏名は必ず抽出してください。
  - location: "最下段『支払者』欄"
- 年収額(annual_salary):
  - note: 年収額は必ず数字で抽出してください。
  - location: "上段『支払金額』列"
- 電話番号(phone_number):
  - note: 電話番号は必ず抽出してください。記載がない場合は空文字を返してください。
  - location: "下段『支払者』欄"

ここでのポイントはそれぞれの項目がどの位置にあるかを指定することです。年収などの数字は画像の中に複数存在することが多いので、それぞれの項目がどの位置にあるかを指定することで、正確に抽出することができます。

- location: "右上『個人番号』欄"

また、記載がない場合の指示を明確にしておくことで、LLM がそれに従って出力するようにすることができます。これがないと無理やり値を返してしまうハルシネーションがかなり起きやすいです。

- note: 個人番号は 12 桁の数字です。**記載がない場合は null を返してください。**

画像に対する操作

スキーマ定義や prompt を調整しても、画像そのものが小さくなってしまうと、LLM が抽出する情報が少なくなってしまいます。

この検証でよく遭遇したのが氏名の抽出ができない問題です。多くの画像で数字や住所は取れることが多いのですが、氏名はバリエーションが多かったり、難しい漢字の場合そもそも学習が十分にされていないために認識の精度が低い傾向にあります。

たとえば下図のような文字がガビガビな状態の画像(424×810 ピクセル)を gpt-4.1 では以下のような結果になります。

入力

氏名が取れない例

結果

{
  "japanese_year": "令和6年分",
  "mynumber": "123456789012",
  "real_name_kana": "サンプル タロウ",
  "real_name": "サンプル 太郎",
  "applicable_info": "",
  "job_status": "",
  "payer_name": "サンプル株式会社 代表取締役 源泉 太郎",
  "annual_salary": 5000000,
  "phone_number": "03-1234-5678"
}

数字のデータや比較的大きい年度のデータは取れているようですが、他は全く異なってしまっています。

そこで OpenAI の image input 機能では detail というオプションを利用することで、画像の詳細な情報を取得することができます。変更するのは message 内の image_url の部分です。

messages = [
    {
        "role": "user",
        "content": [
            {"type": "text", "text": system_prompt},
-            {"type": "image_url", "image_url": {"url": image_url}},
+            {"type": "image_url", "image_url": {"url": image_url, "detail": "high"}},
        ],
    }
]

指定できる値は lowhighauto の 3 つです。

  • low(低詳細):
    • 画像を最大 512×512 ピクセルに縮小して処理します。モデルは粗いサムネイル程度の解像度で画像を理解するため、微細な部分は捉えにくいですが、その分処理が高速でトークン消費も少ないモードです。このモードでは画像 1 枚あたり約 85 トークンの固定コストで処理されます。
  • high(高詳細): 画像の詳細解析を有効にするモードです。モデルはまず低解像度版の画像を見た後、画像を最大 2048×2048 ピクセルに収まるよう縮小します(長辺が 2048px になるようスケーリング)。次に短辺が 768px になるようさらに縮小します(短辺>768px の場合のみ)。そして元画像を 512×512 ピクセルのタイル(区画)に分割して細部を精査します。
  • auto(自動):
    • 上記 2 つのモードを自動的に切り替える設定です。一般に画像が小さい場合は low、大きい場合は highが選択されます。auto はユーザが明示指定しなくても適切なモードを適用しますが、要件に応じて手動で low または high を指定することでコストと精度の制御が可能です。

つまり、どんなに大きな画像を input しても、2048×2048 ピクセルを超えることはないため、画像のサイズを拡大する最大サイズは 2048×2048 ピクセルです。

この high を指定するために単純に横と縦を 2 倍ずつ拡大して 424×810 ピクセルから 848×1620 ピクセルの画像に変換し、OCR を行うと以下のような結果になります。

{
  "japanese_year": "令和6年分",
  "mynumber": "485291736025",
  "real_name_kana": "サトウ シュンタ",
  "real_name": "佐藤 順太",
  "applicable_info": "",
  "job_status": "",
  "payer_name": "ギャラクシー・モーターズ株式会社",
  "annual_salary": 5000000,
  "phone_number": "03-1234-5678"
}

これによって、個人番号・支払い者氏名は正しく、氏名は惜しいですが苗字は正しく抽出されるようになりました。単純に画像を大きくするだけでも効果があることがわかります。

画像を拡大する関数
import io
from typing import Optional

from PIL import Image

def resize_image_double(
    image_bytes: bytes,
    *,
    out_format: Optional[str] = None,
    resample: int = Image.LANCZOS,
) -> bytes:
    # Load the input bytes into a Pillow image
    with Image.open(io.BytesIO(image_bytes)) as img:
        width, height = img.size
        doubled = img.resize((width * 2, height * 2), resample=resample)

        # Prepare output buffer
        buffer = io.BytesIO()
        doubled.save(buffer, format=out_format or img.format)
        return buffer.getvalue()

# example
with open("input.png", "rb") as f:
    original_bytes = f.read()

resized_bytes = resize_image_double(original_bytes)

一方で Anthropic の Claude では、画像サイズの limit が 8000×8000 ピクセル、かつ 5MB までとなっていますので、さらに画像の拡大が可能です。

OpenAI の detail=high と同じ条件で、2 倍に拡大した画像を Claude で OCR を行うと以下のような結果になります。

{
  "japanese_year": "令和6年分",
  "mynumber": "123456789012",
  "real_name_kana": "サイトウ ショウタ",
  "real_name": "齋藤 翔太",
  "applicable_info": "",
  "job_status": "",
  "payer_name": "キャラクター・モータース株式会社",
  "annual_salary": 5000000,
  "phone_number": "03-1234-5678"
}

個人番号が間違っていたり、支払い者氏名が間違っていたりしますが、それ以外は正しく抽出されています。若干 GPT-4.1 の high の方が精度が高いようです。
今度は最大まで拡大した画像を 8 倍にして 3392×6480 ピクセルの画像を Claude で OCR を行うと以下のような結果になります。

{
  "japanese_year": "令和6年分",
  "mynumber": "123456789012",
  "real_name_kana": "サイトウ ショウタ",
  "real_name": "齋藤 翔太",
  "applicable_info": "",
  "job_status": "",
  "payer_name": "キャラクター・モータース株式会社",
  "annual_salary": 5000000,
  "phone_number": "03-1234-5678"
}

これは Claude 側が 長辺 1,568 px を超えると自動縮小するため、実質的な有効解像度は 約 1.15 MP 程度となるからのようです。なので画像は Claude の場合は 1,568 を最大に大きくするという処理がベストとなります。

最適なパフォーマンスを得るため、画像が大きすぎる場合は、アップロード前にサイズを変更することをおすすめします。画像の長辺が 1568 ピクセルを超える場合、またはトークン数が約 1,600 を超える場合は、まずアスペクト比を維持しながら、サイズ制限内に収まるまで縮小されます。

https://docs.anthropic.com/en/docs/build-with-claude/vision#evaluate-image-size

検証結果

合計 6 個のモデルでいくつかの種類の証明書に対して検証を行いました。

以上であげた精度向上施策点は統一して、

  • スキーマ定義
  • prompt の調整
  • 画像のサイズを拡大(縦横 2 倍で最大 2000×2000)

という条件で 6 つのモデルで検証した結果がこちらです。

モデル 正解率 合計料金
gpt-4.1 77.6% $0.006255
gpt-4o 67.4% $0.005610
o3 80.4% $0.009886
claude 3.5 sonnet 74.6% $0.012234
claude 3.7 sonnet 78.8% $0.012234
o4-mini 89.0% $0.002071

使ったモデルの中では o4-mini が最も精度が高く、かつ最も安いという結果になりました。claude が若干高いのはおそらく tool を使った処理が入っているために、消費する token 数が多いためです。

また以上のモデルなかでも gpt-4oclaude 3.5 sonnet では Pydantic の Field 制約により、Validation Error となってしまうケースが多くありました。安定性を重視する場合は gpt-4.1o3o4-mini を使うのが良いと思われます。

まとめ

今回の検証ではマルチモーダルの LLM を利用することで、画像からの情報抽出を行うことができました。

スキーマの定義や prompt の調整、画像のサイズを拡大をすることで、LLM が抽出する情報を明示的に指示することができました。

追加の検証としては、ChatGPT の o3 のように、画像を Agentic に切り抜きと思考を繰り返しながら抽出するような仕組みにすることで格段に精度を向上させることができると思います。その分コストがかかるのでその点はトレードオフですが、今回氏名がとりづらかった点はそのような仕組みで解決できると思います。

おまけ: 構造化 yaml を利用した OCR

構造化 yaml という技術があり、画像の内容を yaml 形式で表現しそれを使って画像生成をすることで、従来よりはるかに正確に画像を生成することができるという手法が話題を呼んでいます。

https://news.yahoo.co.jp/expert/articles/56c7d003653f03f8be1951676431321c1cc43c3c

https://x.com/kamui_qai/status/1870418506837291223

今回は画像生成ではなく、OCR ですが、画像の構造を yaml 形式で表現することで、従来よりはるかに正確に OCR を行うことができるという仮説のもと検証してみます。

たとえばこの画像を o3 を使って yaml 形式にすると以下のようになります。

prompt

この画像を yaml 形式に変換して、別の model でも読み込めるように、この画像の文字、レイアウト、フォント、背景などを yaml に構造化し、一般化してください

構造化 yaml の入力

入力

源泉徴収サンプル

yaml 構造化の出力結果
withholding_slip_v1:
  template:
    meta:
      doc_type: "給与所得の源泉徴収票"
      paper:
        size: "A4"
        orientation: "portrait"
        dpi_reference: 300 # used when converting % → px
      default_font:
        family: "MS Gothic"
        size_pt: 8
        weight: "normal"
        color_hex: "#000000"
      heading_font:
        family: "MS Gothic"
        size_pt: 12
        weight: "bold"
      background:
        color_hex: "#FFFFFF"
        grid_line:
          color_hex: "#000000"
          width_mm: 0.2
    # Every element lives in a high-level “section”; each field has type
    # info so downstream models can validate or format intelligently.
    sections:
      header:
        bbox_pct: [0.00, 0.00, 1.00, 0.09] # x0, y0, x1, y1
        elements:
          - type: label
            id: head_title
            text: "給与所得の源泉徴収票"
            font: heading_font
            anchor: [0.50, 0.03] # center-top
          - type: year
            id: era_year
            text_template: "{{era}} {{year_in_era}} 年分"
            anchor: [0.08, 0.03]
      payer_info:
        bbox_pct: [0.00, 0.09, 0.65, 0.20]
        fields:
          payer_address:
            label: "住所又は所在地"
            type: string
            max_chars: 60
          payer_name:
            label: "名称"
            type: string
            max_chars: 40
      recipient_info:
        bbox_pct: [0.65, 0.09, 1.00, 0.37]
        fields:
          recipient_number:
            label: "整理番号"
            type: numeric
            length: 10
          my_number:
            label: "個人番号"
            type: numeric
            length: 12
            privacy: "maskable"
          name_kana:
            label: "氏名(フリガナ)"
            type: string
            max_chars: 40
          name_kanji:
            label: "氏名"
            type: string
            max_chars: 40
          classification:
            label: "区分"
            type: enum
            enum: ["一般", "役員", "日雇", "退職者", "非居住者"]
      payment_amounts:
        bbox_pct: [0.00, 0.20, 1.00, 0.30]
        fields:
          gross_payment_yen:
            label: "支払金額"
            type: currency
            unit: "JPY"
          income_after_deductions_yen:
            label: "給与所得控除後の金額"
            type: currency
            unit: "JPY"
          total_deductions_yen:
            label: "所得控除の額の合計額"
            type: currency
            unit: "JPY"
          withholding_income_tax_yen:
            label: "源泉徴収税額"
            type: currency
            unit: "JPY"
      dependents_summary:
        bbox_pct: [0.00, 0.30, 1.00, 0.38]
        description: |
          Line showing dependent counts and insurance / housing
          deduction subtotals. All zeros in sample but kept for completeness.
        fields:
          dependents_under_16: { type: integer, default: 0 }
          dependents_over_16: { type: integer, default: 0 }
          insurance_total_yen: { type: currency, unit: "JPY", default: 0 }
          housing_deduction_yen: { type: currency, unit: "JPY", default: 0 }
      cert_and_footer:
        bbox_pct: [0.00, 0.80, 1.00, 1.00]
        fields:
          receiving_date:
            label: "受給者生年月日"
            type: date
            format: "gengo" # era / year / month / day boxes
          issuer_address: { type: string, max_chars: 60 }
          issuer_name: { type: string, max_chars: 40 }
          issuer_tel: { type: string, regex: "^0\\d{1,3}-\\d{2,4}-\\d{3,4}$" }
          remarks: { type: string, multiline: true, max_chars: 200 }

この yaml の情報を prompt 内の構造情報とすり替えて、OCR を行うと以下のような結果になります。

gpt-4.1

{
  "japanese_year": "令和6年分",
  "mynumber": "485291736025",
  "real_name_kana": "サトウ シュンタ",
  "real_name": "佐藤 順太",
  "applicable_info": null,
  "job_status": null,
  "payer_name": "ギャラクシー・モーターズ株式会社",
  "annual_salary": 5000000,
  "phone_number": "03-1234-5678"
}

claude 3.5 sonnet

{
  "japanese_year": "令和6年分",
  "mynumber": "485291736025",
  "real_name_kana": "サイトウ ショウタ",
  "real_name": "斎藤 翔太",
  "applicable_info": null,
  "job_status": null,
  "payer_name": "キャラクター・モーターズ株式会社",
  "annual_salary": 5000000,
  "phone_number": "03-1234-5678"
}

以前氏名に関しては正確に取れないですが、job status や電話番号に関しては安定的に取れるようになりました。
ただ思ったよりは精度が高くなったという印象はないです。

ユースケースによっては画像から膨大な情報を抽出する必要がある場合もあると思います。
その場合は構造化 yaml を利用することで、より正確に情報を抽出することができると思います。

Finatext Tech Blog

Discussion