top of page

【入門編】Langfuseで画像OCRの精度検証をシンプルに始める方法

  • 執筆者の写真: Hiromi Kuwa
    Hiromi Kuwa
  • 1月26日
  • 読了時間: 6分

Geminiの性能向上によりOCRは実用的になりましたが、高精度を目指すならプロンプト調整は必須です。しかし、調整のたびに画像と結果を目視で見比べるのは、手間がかかりミスも誘発します。


そこで本記事では、Langfuseを使ってこの作業を自動化します。「評価」と聞くと難しそうですが、今回は複雑な指標や設定を使わず、かつ、チーム運用は一旦忘れ、まずは「自分のPC上で、正解データと一致するか」だけをチェックする気軽な構成を目指します。


なお、プロンプト改善のサイクルを回すことが目的なので、今回は繰り返しテストに適した Dataset Run 機能を利用します。


Datasetの準備


Dataset Runを実行するには、データセットの作成が必要です。

※ 基本的な作成方法については、以前の記事(Langfuseデータセット構築ガイド:UI・CSV・SDKの徹底比較)にて紹介しています。


しかし、ここで一つ問題が発生します。

期待する結果の値(Expected Output)はテキストなので問題なく登録できますが、こちらの記事でも言及している通り、 2026年1月現在、LangfuseのDataset Itemsはマルチモーダルコンテンツ(画像やPDFの直接保持)をサポートしていません。

また、Web UIから実行する場合のDataset Run機能も、同様にマルチモーダルには未対応です。


そこで今回は、SDK を使ってDataset Runを実行するアプローチをとります。

「スクリプトを書く必要がある」と聞くと手間に感じるかもしれませんが、Web上で完結させないことによるメリットもあります。

スクリプトが動く環境に画像ファイルを置いておけば、Langfuseに画像実体をアップロードする必要や、Amazon S3 や Google Cloud Storageなどのストレージサービスを準備する必要も無く、LLMに投げる直前にローカルファイルを読みこむだけで済むため、構成がシンプルになります。


データセットの構造


ファイルの実体はローカルに置きますが、「どのデータセット項目が、どの画像ファイルに対応するか」を紐付ける必要があります。

今回は手軽さを優先し、ひとまず Datasetの `input` にはファイル名(パス)だけを記述することにします。


例:


この形式なら、実ファイルを含める必要が無いため、CSVを使った一括インポートなど、好きな方法でDataset Itemsを登録できます。


評価データとスクリプトの準備


ディレクトリ構成イメージ


今回は以下の画像ファイルを評価にかけます。

ディレクトリ構成は以下の通りです。

├── ocr.py          # 今回作成するスクリプト
└── data/            # 画像置き場
   ├── image_001.png
   ├── image_002.png
   └── ...

評価データ


今回はテスト用に以下を準備しました。


  • テスト用画像ファイル:Nano Banana Proを用いて、適当なレシート画像を作成

  • Dataset:テストデータ(csv)をLangfuseのWeb UIから「Import CSV」で一括登録

テスト用画像ファイル

テストデータ(csv)

Expected Output に shop_name、date、price をマッピングしています。

input

shop_name

date

price

image001.png

DAILY STORE

2024/01/25

780

image002.png

スーパーABC

2024/01/26

1098

image003.png

COFFEE STAND

2024/01/27

1600

実際のデータセット(一部)


スクリプトの準備


今回は手軽にやるため、処理内容・評価関数共に最低限の作りにします。

LLMは Vertex AI(Gemini 2.5 Flash)を利用し、評価関数としては「店舗名の取得が正常に行えているか?」のチェックのみを実施します。

また、プロンプトの改善を容易にするため、プロンプトはコードへの記述ではなく、Langfuseで管理する形にしました。


import os
from dotenv import load_dotenv
from langfuse import Langfuse, Evaluation
from google import genai
import json

# 設定(APIキー等は環境変数から読み込み)
load_dotenv()
langfuse = Langfuse()

client = genai.Client(
    project=os.getenv("GOOGLE_CLOUD_PROJECT"),
    location=os.getenv("GOOGLE_CLOUD_REGION", "asia-northeast1"),
    vertexai=True,
    http_options=genai.types.HttpOptions()
)

target_file_path = "data/"
prompt = langfuse.get_prompt("ocr")
dataset = langfuse.get_dataset(name="ocr_test")

# OCRタスクの定義
def ocr_test(item):
    # Datasetのinput(ファイル名)から実際のパスを生成
    input_filename = item.input
    compiled_prompt = prompt.compile(input = item.input)

    # ローカルファイルをバイナリで読み込む
    file_path = os.path.join(target_file_path, input_filename)
    with open(file_path, "rb") as f:
        file_data = f.read()

    # Geminiへ送信(画像データ + プロンプト)
    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=[
            genai.types.Part.from_bytes(data=file_data, mime_type='image/png'),
            compiled_prompt,
        ],
        config=genai.types.GenerateContentConfig(
            response_mime_type="application/json",
            temperature=0.0,
            response_schema={
                "type": 'ARRAY',
                "items": {
                    "type": "OBJECT",
                    "properties": {
                        "shop_name": {
                            "type": 'STRING',
                            "description": '店舗名',
                        },
                        "date": {
                            "type": 'STRING',
                            "description": '日付',
                        },
                        "price": {
                            "type": 'NUMBER',
                            "description": '金額',
                        }
                    },
                    "required": [
                        "shop_name",
                        "date",
                        "price"
                    ],
                },
            }
        ),
    )

    return response.text

# 評価関数(単純一致)
def simple_evaluator(*, input, output, expected_output=None, **kwargs):    
    # 実務では output のチェックや、改行コードの削除や正規化(strip等)を推奨
    response_data = json.loads(output)
    check = response_data[0].get("shop_name") == expected_output.get("shop_name")
    if check:
      comment = "Success"
    else:
      comment = "False"

    return Evaluation(
        name='output_check',
        value=check,
        metadata={
          "expected": expected_output,
          "actual": output
        },
        comment=comment,
        data_type="BOOLEAN"
    )

# status: ACTIVEのみを取得
active_items = [item for item in dataset.items if item.status == "ACTIVE"]

langfuse.run_experiment(
    name=f"{prompt.name}_{prompt.version}",
    description=f"Dataset Run from SDK: {prompt.name}_{prompt.version}",
    task=ocr_test,
    data=active_items,
    evaluators=[simple_evaluator],
)

※ dataset.run_experiment でも Dataset Run は実行できますが、データのフィルタリング(data 引数の指定)が出来ません。そのため、ARCHIVE済みアイテムも含む全件が対象となってしまいます。


評価について


LLMによる採点(LLM-as-a-judge)を入れた方がそれらしく見えますが、LLMを使う以上コストが発生します。OCRのように正解が明確なタスク(完全一致または簡単なルールベースで比較ができる場合)であれば、LLMより単純な比較の方が精度が見込める場合も多いです。


今回はスクリプトで評価まで完結させていますが、「あいまいな表現に対しても評価したい」など、別軸でLLM-as-a-judgeも適応させたい場合は、Langfuseの設定で後から追加することも可能です。


実行結果


スクリプトを実行すると、Langfuseの画面に結果が反映されます。


今回は結果がBOOLEANとなる評価を設定しましたが、このようにEvaluatorで設定した項目に対して、True・Falseの件数が表示されます。



詳細画面を確認すると、このように実際の値の比較や、どのデータが OK/NG だったかの確認も一目で出来ます。


今回は項目が少なく、評価軸もシンプルなので、Output と Expected Output を見比べれば何が問題かを容易に読み取れます。

実際の運用では、Metadataなどを活用するとよさそうです。


上記画面では、結果に対してマウスオーバーで Evaluation にて設定したコメントやMetadataが表示されます。

吹き出しにマウスオーバーした場合(comment="Success"が表示)
吹き出しにマウスオーバーした場合(comment="Success"が表示)

{...}部分にマウスオーバーした場合(metadataの中身が表示)
{...}部分にマウスオーバーした場合(metadataの中身が表示)


さらに、過去の実行結果の比較も出来るため、「さっきのプロンプトの方が精度が良かったな…」といった振り返りも容易です。



差分の比較方法については、LangfuseのExperiments Compare ViewのBaseline機能を解説もあわせて確認頂くと、より詳細な比較が行えるかと思います。


まとめ


今回はとにかく「マルチモーダルな評価」を「とりあえず簡単にやってみる」ことに着目して実験しました。

この構成なら、ひとまずLLMが使える状態さえ準備出来れば、スクリプトを実行するだけで、OCRの精度評価をLangfuseに任せてしまえます。


チーム開発への展望


今回は個人のPCで完結させましたが、もしチームで実行したい場合は Amazon S3

Google Cloud Storage などのストレージサービスを利用するのがおすすめです。

スクリプト上のローカルファイルからファイルを取得する処理を、各々のストレージサービスに対応した形に修正すれば、同じように実行できます。


一例として、Vertex AI(Gemini) + Google Cloud Storage (GCS) の構成であれば、スクリプト上でファイルを読み込む必要もありません。権限の設定は必要ですが、GCS上のファイル(`gs://...`)は直接Geminiに読み込ませることが可能となっています。

(スクリプトの「ローカルファイルをバイナリで読み込む」ブロックの処理も不要となります。)

コメント


bottom of page