0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PDF・画像・表をまとめて扱うマルチモーダル文書解析パイプラインを作る

0
Last updated at Posted at 2026-10-30

業務でPDFを解析するとき、テキスト抽出だけでは足りない場面がよくあります。スキャンされた帳票、画像として貼られた表、グラフ、レイアウトによって意味が変わる文書などです。

今回は、PDFをそのまま入力しつつ、必要なページを画像として補助的に渡し、文書の内容をJSONへ変換するパイプラインをPythonで作りました。請求書と議事録PDFで試したところ、本文だけでなく表の項目や画像内の注記も同じ処理の流れで扱えました。

ポイントは、最初からOCRだけに寄せないことです。

  1. PDFファイルを入力する
  2. 必要に応じてページを画像化する
  3. テキスト・画像・表をまとめてモデルへ渡す
  4. JSONとして保存し、後段の処理につなげる

1. 構成と使い分け

PDFを主入力にし、必要なページ画像を補助的に加えてJSON化する流れは次の通りです。

今回の構成は次のとおりです。

PDF
 ├─ PDFファイル入力 ─┐
 └─ ページ画像化 ────┼─> マルチモーダルモデル
                     └─> 構造化JSON

PDFを毎ページ画像化してOCRする方法もありますが、ページ数が多い文書では画像データが大きくなります。そのため、PDF自体を主入力にし、表や図が重要なページだけ画像として追加する構成にしました。

PDF内にテキストレイヤーがある場合は、モデルが文章を読み取りやすくなります。一方、スキャンPDFでは画像化したページを渡すことで、文字や表の位置関係も確認できます。

Responses APIでは、PDFなどのファイル入力と画像入力を同じリクエストに含められます。実装上の詳細はOpenAI公式クイックスタートも確認できます。

2. 環境を準備する

まず、必要なライブラリをインストールします。

python -m venv .venv
.venv\Scripts\activate
pip install openai pymupdf python-dotenv

macOSやLinuxの場合は、仮想環境の有効化を次のようにします。

source .venv/bin/activate

APIキーはコードに直接書かず、環境変数で渡します。

# Windows PowerShell
$env:OPENAI_API_KEY = "ここにAPIキー"

# macOS / Linux
export OPENAI_API_KEY="ここにAPIキー"

今回のサンプルでは、PDFのページ画像を作るためにPyMuPDFを使います。画像はBase64のデータURLに変換して、画像入力として渡します。

3. PDFとページ画像を解析する

次が最小構成のサンプルです。3ページ目までを画像化し、PDF本体と一緒にモデルへ渡します。

import base64
import json
import sys
from pathlib import Path

import fitz
from openai import OpenAI


def page_images_as_data_url(pdf_path: str, max_pages: int = 3):
    """PDFの先頭ページをPNGのdata URLへ変換する"""
    document = fitz.open(pdf_path)

    try:
        for page_number, page in enumerate(document):
            if page_number >= max_pages:
                break

            # 144dpi相当。細かい表では倍率を上げる
            matrix = fitz.Matrix(2.0, 2.0)
            pixmap = page.get_pixmap(matrix=matrix, alpha=False)
            png_bytes = pixmap.tobytes("png")
            encoded = base64.b64encode(png_bytes).decode("ascii")

            yield f"data:image/png;base64,{encoded}"
    finally:
        document.close()


def analyze_pdf(pdf_path: str) -> dict:
    client = OpenAI()

    with open(pdf_path, "rb") as file:
        uploaded_file = client.files.create(
            file=file,
            purpose="user_data",
        )

    content = [
        {
            "type": "input_text",
            "text": """
この文書を解析し、必ずJSONだけを返してください。
読み取れない値は推測せず、nullにしてください。

必要な項目:
- document_type: 文書種別
- title: 文書タイトル
- dates: 文書内の日付の配列
- parties: 登場する会社名・組織名の配列
- tables: 表の配列
  - name: 表の名称
  - headers: 列名の配列
  - rows: 行データの配列
- figures: 図・画像・グラフの説明配列
- uncertain_fields: 判断に自信がない項目の配列
""",
        },
        {
            "type": "input_file",
            "file_id": uploaded_file.id,
        },
    ]

    for image_url in page_images_as_data_url(pdf_path):
        content.append(
            {
                "type": "input_image",
                "image_url": image_url,
                "detail": "high",
            }
        )

    response = client.responses.create(
        model="gpt-5",
        input=[
            {
                "role": "user",
                "content": content,
            }
        ],
    )

    try:
        return json.loads(response.output_text)
    except json.JSONDecodeError as error:
        raise ValueError(
            "JSONとして解釈できない応答が返りました:\n"
            + response.output_text
        ) from error


if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("使い方: python analyze.py sample.pdf")
        sys.exit(1)

    result = analyze_pdf(sys.argv[1])
    print(json.dumps(result, ensure_ascii=False, indent=2))

実行します。

python analyze.py sample.pdf > result.json

PDF本体を渡しているので、テキストレイヤーがある文書では文章を解析できます。さらにページ画像も渡しているため、画像化された表や図の位置関係を補足できます。

4. 表を壊さないためのプロンプト設計

表の解析では、単に「表を抽出してください」と書くだけだと、列のずれや単位の欠落が起こります。私は次のような制約をプロンプトに入れました。

  • 列数が不明な場合は、ヘッダーと行の対応を説明する
  • セルが空欄なら空文字列を使い、推測で埋めない
  • 金額の通貨単位を保持する
  • 複数行セルは改行を残す
  • 合計行や小計行を通常の明細行と区別する
  • 読み取れない文字はnullにする

特に重要なのは「推測しない」ことです。文書解析では、もっともらしい誤りがそのまま後続処理へ流れる危険があります。

たとえば、金額表をデータベースへ保存する前に、uncertain_fieldsが空か確認します。

result = analyze_pdf("invoice.pdf")

if result["uncertain_fields"]:
    print("要確認:", result["uncertain_fields"])
else:
    print("自動処理へ進めます")

本番処理では、JSON SchemaやPydanticで型検証を追加し、日付や金額の形式もチェックします。モデルの出力をそのまま信頼せず、機械的な検証を必ず挟むのが安全です。

5. 実運用で気を付けたこと

実際に試して気付いた注意点は、ページ画像の解像度です。小さな注記や表の脚注がある場合、低解像度では文字がつぶれます。一方で解像度を上げすぎると、入力サイズと処理時間が増えます。

そこで私は、次のように使い分けました。

  • 通常の文書: 144dpi程度
  • 細かい表や帳票: 200〜250dpi
  • ページ数が多いPDF: 表があるページだけ画像化
  • 画像が不要なページ: PDF入力だけにする

また、個人情報や機密情報を含むPDFを扱う場合は、送信先、保存期間、アクセス権限を事前に確認します。解析後にアップロードしたファイルを削除する処理を追加するのも有効です。

client.files.delete(uploaded_file.id)

エラー時に削除処理が実行されるよう、実際のアプリケーションではtryとfinallyで囲むと安心です。

まとめ

PDF・画像・表を扱う文書解析では、OCRだけ、テキスト抽出だけに限定すると情報を落としやすくなります。

今回の実装では、PDFを主入力にしながら、必要なページを画像として追加することで、文章・表・図を同じパイプラインで処理しました。

重要なのは次の3点です。

  • PDF本体とページ画像を用途に応じて使い分ける
  • 出力形式をJSONに固定する
  • 不確実な値を検出し、人が確認できるようにする

まずは手元の数ページのPDFで試し、表の崩れ方や読み取りミスを確認してから、ページ選択、スキーマ検証、再試行、監査ログを追加していくのがおすすめです。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?