「とりあえず動いた」Vision 実装が本番で躓く3つの場所
画像を Base64 にして messages.create に渡せば、Claude はその場で内容を説明してくれます。ここまでは30分で動きます。
問題はその先です。私自身、画像解析のパイプラインを運用に乗せる過程で、検証段階では見えなかった壁に3回ぶつかりました。
ひとつ目はコストです。画像はテキストよりはるかに多くのトークンを消費します。リサイズせずに高解像度画像を流し続けると、請求額が想定の数倍になります。
ふたつ目は出力の不安定さです。「JSON で返してください」とプロンプトで頼む方式は、9割は動きます。しかし残り1割で前置きの文章が混ざり、json.loads が例外を投げ、深夜のバッチが停止します。
みっつ目は PDF です。PDF を画像に変換してから送る古い実装をそのまま使うと、テキストレイヤーの情報を捨ててしまい、精度もコストも悪化します。
ここからは、この3つの壁をひとつずつ越えていきます。コード例はすべて Python で、そのまま実行できる形にしてあります。
入力方式は3つ — 再利用するかどうかで決める
Claude API に画像を渡す方法は Base64 直接埋め込み・URL 参照・Files API の3通りです。どれを選ぶかは「同じ画像を何回使うか」と「画像が公開可能か」で決まります。
| 方式 | 向いている条件 | 注意点 |
|------|----------|------|
| Base64 | 1回きりの解析・非公開画像 | リクエストサイズが膨らむ |
| URL 参照 | 公開済み画像・CDN 配信資産 | 非公開画像には使えない |
| Files API | 同じ画像を複数回解析する | アップロードの一手間が必要 |
Base64 直接埋め込み — 最初の選択肢
非公開の画像を1回だけ解析するなら、Base64 が最も素直です。
import anthropic
import base64
from pathlib import Path
client = anthropic.Anthropic() # ANTHROPIC_API_KEY を環境変数から読み込み
MEDIA_TYPES = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".gif": "image/gif", ".webp": "image/webp",
}
def encode_image(path: str) -> tuple[str, str]:
"""画像を Base64 化し、media_type と合わせて返します"""
p = Path(path)
media_type = MEDIA_TYPES.get(p.suffix.lower(), "image/jpeg")
data = base64.standard_b64encode(p.read_bytes()).decode("utf-8")
return data, media_type
def analyze_image(path: str, prompt: str) -> str:
data, media_type = encode_image(path)
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": data}},
{"type": "text", "text": prompt},
],
}],
)
return message.content[0].text
print(analyze_image("screenshot.png", "この画面に表示されているエラーメッセージを抽出してください。"))
ここで気をつけたいのは、リクエスト全体のサイズ上限が 32MB という点です。Base64 化すると元ファイルの約1.33倍に膨らむため、20MB の画像を複数枚束ねるとあっさり超えます。複数画像を扱う設計なら、後述するリサイズを必ず挟んでください。
URL 参照 — 公開資産ならこちら
すでに CDN で配信している画像なら、URL を渡すだけで済みます。リクエストが軽くなり、自前で Base64 化する処理も不要になります。
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{"type": "image",
"source": {"type": "url", "url": "https://example.com/assets/diagram.png"}},
{"type": "text", "text": "この図の処理フローを箇条書きで説明してください。"},
],
}],
)
ただし、Anthropic のサーバーが取得できる URL である必要があります。社内ネットワーク限定の URL や、認証付きのストレージの署名なし URL は失敗します。失敗時は invalid_request_error が返るので、URL 方式を採用する場合はこのエラーを Base64 へのフォールバックに接続しておくと運用が安定します。
Files API — 同じ画像を何度も解析するなら
同じ画像に対して「まず分類、次に詳細解析、最後にメタデータ抽出」のように複数回リクエストを投げる設計では、毎回 Base64 を送り直すのは無駄です。Files API で一度アップロードし、file_id で参照する方が転送量もコードの見通しも良くなります。
# アップロードは一度だけ
uploaded = client.beta.files.upload(
file=("design.png", open("design.png", "rb"), "image/png"),
)
# 以降は file_id で参照
message = client.beta.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
betas=["files-api-2025-04-14"],
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "file", "file_id": uploaded.id}},
{"type": "text", "text": "この UI デザインの配色をリストアップしてください。"},
],
}],
)
私はこの3方式を「再利用2回以上なら Files API、公開済みなら URL、それ以外は Base64」という基準で選んでいます。迷ったら Base64 で始めて、転送量が気になり始めた段階で移行する順番が現実的です。
画像トークンの計算 — リサイズだけでコストが数分の一になる
Vision のコストで最も効くのはモデル選択ではなくリサイズです。画像のトークン消費は概ね次の式で見積もれます。
トークン数 ≈ (幅 × 高さ) ÷ 750
1920×1080 のスクリーンショットなら約 2,765 トークン。4032×3024 のスマートフォン写真ならそのままで約 16,257 トークンです。なお長辺が 1568px を超える画像は API 側で自動的に縮小されますが、転送量の削減とアスペクト比のコントロールのために、送信前に自分でリサイズしておく方が確実です。
from PIL import Image
import io, base64
def resize_for_claude(path: str, max_edge: int = 1568, quality: int = 85) -> tuple[str, str]:
"""長辺を max_edge に収め、JPEG 圧縮して Base64 を返します"""
img = Image.open(path)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
ratio = max_edge / max(img.size)
if ratio < 1:
img = img.resize(
(int(img.width * ratio), int(img.height * ratio)),
Image.LANCZOS,
)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality)
data = base64.standard_b64encode(buf.getvalue()).decode("utf-8")
return data, "image/jpeg"
def estimate_image_tokens(width: int, height: int) -> int:
return (width * height) // 750
実測の感覚では、OCR や表抽出のように文字を読むタスクは長辺 1568px を維持した方が精度が落ちません。一方「何が写っているか」の分類タスクなら長辺 1092px まで落としてもほとんど劣化せず、トークンは半分近くまで減ります。タスクの種類でリサイズ幅を変える、この一手間が月次コストに直結します。
コストの目安も置いておきます。1024×1024 の画像1枚は約 1,398 トークンです。入力 $3/MTok のモデルなら1枚あたり約 $0.0042、1万枚処理して約 $42 です。ここに後述の Batch API を組み合わせると半額になります。
PDF はネイティブ document ブロックに任せる
かつての定番だった「pdf2image でページを画像化して送る」方式は、もう使う理由がありません。現在の Claude API は PDF をそのまま受け取れます。
import base64
from pathlib import Path
pdf_data = base64.standard_b64encode(Path("report.pdf").read_bytes()).decode("utf-8")
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
messages=[{
"role": "user",
"content": [
{"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": pdf_data}},
{"type": "text", "text": "この決算資料から売上高・営業利益・前年比をテーブルで抽出してください。"},
],
}],
)
print(message.content[0].text)
ネイティブ処理の利点は2つあります。第一に、各ページがテキストレイヤーと画像の両方として解釈されるため、文字情報はテキストとして正確に読まれ、図表はビジョンで理解されます。画像化方式ではこのテキストレイヤーを丸ごと捨てていました。第二に、ページ分割・解像度調整・順序維持を API 側が引き受けてくれるので、pdf2image と poppler のインストールという依存関係そのものが消えます。
制限は最大 100 ページ・32MB です。それを超える PDF は分割してから送ります。
from pypdf import PdfReader, PdfWriter
import io
def split_pdf(path: str, pages_per_chunk: int = 90) -> list[bytes]:
"""100ページ制限に収まるよう PDF を分割します"""
reader = PdfReader(path)
chunks = []
for start in range(0, len(reader.pages), pages_per_chunk):
writer = PdfWriter()
for page in reader.pages[start:start + pages_per_chunk]:
writer.add_page(page)
buf = io.BytesIO()
writer.write(buf)
chunks.append(buf.getvalue())
return chunks
citations で「どのページに書いてあったか」を取る
ドキュメント解析を業務に組み込むと、必ず「その数字はどこから取った?」と聞かれます。citations を有効にすると、回答の各部分に出典箇所が付与されます。
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
messages=[{
"role": "user",
"content": [
{"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": pdf_data},
"citations": {"enabled": True}},
{"type": "text", "text": "解約条項の要点をまとめてください。"},
],
}],
)
for block in message.content:
if block.type == "text":
print(block.text)
for c in getattr(block, "citations", None) or []:
print(f" └ 出典: {c.start_page_number}〜{c.end_page_number - 1}ページ")
検証作業の負荷が大きく変わるので、社内向けのドキュメント要約パイプラインでは原則オンにすることをお勧めします。
OCR と表抽出 — 「JSON で返して」をやめる
ここが本記事でいちばんお伝えしたい部分です。
プロンプトに「JSON 形式で返してください」と書いて json.loads(message.content[0].text) でパースする実装は、本番では必ず壊れます。「以下が抽出結果です」という前置きが付いたり、コードフェンスで囲まれたり、数千件に数件の割合で不正な JSON が混ざるためです。
解決策は Tool Use です。抽出結果の構造をツールの入力スキーマとして定義し、tool_choice でそのツールの使用を強制します。モデルの出力は API レベルでスキーマに拘束されるため、前置き問題もフェンス問題も構造ズレも起きません。
TABLE_TOOL = {
"name": "record_tables",
"description": "画像から抽出した表データを記録します",
"input_schema": {
"type": "object",
"properties": {
"tables": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "表のタイトルまたは見出し"},
"headers": {"type": "array", "items": {"type": "string"}},
"rows": {
"type": "array",
"items": {"type": "array", "items": {"type": "string"}},
},
"notes": {"type": "string", "description": "欠損・判読困難箇所の注記"},
},
"required": ["headers", "rows"],
},
}
},
"required": ["tables"],
},
}
def extract_tables(image_path: str) -> dict:
data, media_type = resize_for_claude(image_path) # OCR系は1568pxを維持
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
tools=[TABLE_TOOL],
tool_choice={"type": "tool", "name": "record_tables"},
messages=[{
"role": "user",
"content": [
{"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": data}},
{"type": "text",
"text": "画像内のすべての表を抽出してください。"
"セルが判読できない場合は空文字にし、notes に位置を記録してください。"},
],
}],
)
for block in message.content:
if block.type == "tool_use":
return block.input # ここは常にスキーマ準拠の dict
return {"tables": []}
この方式に切り替えてから、私の手元のパイプラインではパース起因の失敗が実質ゼロになりました。json.loads の try-except も、正規表現でコードフェンスを剥がす前処理も、すべて削除できました。コードが減って信頼性が上がる、数少ない改修です。
抽出した表はそのまま pandas に渡せます。
import pandas as pd
result = extract_tables("financial_report.png")
for i, table in enumerate(result["tables"]):
df = pd.DataFrame(table["rows"], columns=table["headers"])
df.to_csv(f"table_{i}.csv", index=False)
if table.get("notes"):
print(f"table_{i}: 要確認 — {table['notes']}")
手書き・低品質スキャンの精度を上げる小技
手書き文書や古いスキャンは、次の3点で precision が目に見えて変わります。
- 判読不能箇所の逃げ道を用意する — 「読めない文字は
[判読不可] と記録」と指示します。逃げ道がないとモデルは推測で埋めようとし、それが一番危険です。
- ドメイン語彙を先に渡す — 請求書なら「勘定科目・取引先名の候補リスト」をプロンプトに添えると、曖昧な文字の解決精度が上がります。
- 信頼度を自己申告させる — スキーマに
confidence(high/medium/low)を入れ、low だけ人間がレビューするフローにすると、全件目視と比べて確認工数が大幅に減ります。
スクリーンショット理解を UI レビューに使う
Vision の用途はドキュメント処理だけではありません。開発フローに組み込むと、スクリーンショットがそのままレビュー対象になります。
私の場合、リリース前のスクリーンショット一式に対して「テキストの見切れ・コントラスト不足・タップ領域が 44×44pt 未満の要素」をチェックさせる使い方が定着しています。人間のレビューを置き換えるものではありませんが、機械的に潰せる指摘を先に消化しておくと、レビューが本質的な議論に集中できます。
UI_REVIEW_TOOL = {
"name": "record_ui_issues",
"description": "UI スクリーンショットの問題点を記録します",
"input_schema": {
"type": "object",
"properties": {
"issues": {
"type": "array",
"items": {
"type": "object",
"properties": {
"severity": {"type": "string", "enum": ["high", "medium", "low"]},
"category": {"type": "string",
"enum": ["text-truncation", "contrast", "tap-target", "layout", "other"]},
"location": {"type": "string", "description": "画面内の位置の説明"},
"description": {"type": "string"},
"suggestion": {"type": "string"},
},
"required": ["severity", "category", "description"],
},
}
},
"required": ["issues"],
},
}
注意点がひとつあります。座標を尋ねるのはやめた方がよいです。「このボタンの位置をピクセルで返して」という指示は誤差が大きく、自動操作には使えません。位置は「画面右上の保存ボタン」のような記述で受け取り、特定は別のレイヤー(アクセシビリティツリーや DOM)に任せるのが現実的な役割分担です。
大量画像のバッチ処理 — 並列実行と Batch API の使い分け
数百枚を超える画像処理には2つの選択肢があります。即時性が必要なら並列実行、翌朝までに終わればよいなら Message Batches API です。
判断基準は明快で、結果を今すぐ使うかどうかだけです。Batch API は全リクエストが 50% 割引になる代わりに、完了まで最大24時間(実際は多くの場合1時間以内)かかります。夜間バッチで翌朝までに結果があればよい処理を ThreadPoolExecutor で回すのは、単純に倍の料金を払っているのと同じです。
即時処理: 並列実行とレート制限
from concurrent.futures import ThreadPoolExecutor, as_completed
def analyze_one(path: str, prompt: str) -> tuple[str, str]:
data, media_type = resize_for_claude(path, max_edge=1092) # 分類タスクは小さめで十分
message = client.messages.create(
model="claude-haiku-4-5-20251001", # 分類は Haiku で足りる
max_tokens=256,
messages=[{
"role": "user",
"content": [
{"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": data}},
{"type": "text", "text": prompt},
],
}],
)
return path, message.content[0].text
def analyze_parallel(paths: list[str], prompt: str, workers: int = 4) -> dict:
results, errors = {}, {}
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(analyze_one, p, prompt): p for p in paths}
for future in as_completed(futures):
try:
path, text = future.result()
results[path] = text
except anthropic.RateLimitError:
errors[futures[future]] = "rate_limit"
except Exception as e:
errors[futures[future]] = str(e)
return {"results": results, "errors": errors}
並列数は控えめに始めてください。Vision リクエストは画像トークンが大きく、テキスト処理と同じ感覚で 20 並列にするとあっという間に入力トークンのレート制限に当たります。私は4並列から始めて、429 が出なければ少しずつ上げる運用にしています。
非同期処理: Message Batches API
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
def submit_batch(paths: list[str], prompt: str) -> str:
requests = []
for i, path in enumerate(paths):
data, media_type = resize_for_claude(path, max_edge=1092)
requests.append(Request(
custom_id=f"img-{i}-{Path(path).stem}",
params=MessageCreateParamsNonStreaming(
model="claude-haiku-4-5-20251001",
max_tokens=256,
messages=[{
"role": "user",
"content": [
{"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": data}},
{"type": "text", "text": prompt},
],
}],
),
))
batch = client.messages.batches.create(requests=requests)
return batch.id
def collect_batch(batch_id: str) -> dict:
batch = client.messages.batches.retrieve(batch_id)
if batch.processing_status != "ended":
return {}
results = {}
for entry in client.messages.batches.results(batch_id):
if entry.result.type == "succeeded":
results[entry.custom_id] = entry.result.message.content[0].text
else:
results[entry.custom_id] = f"ERROR: {entry.result.type}"
return results
1バッチには最大 10万件・256MB まで詰められます。画像が大きい場合は件数より先に 256MB に当たるので、リサイズ済み画像で1バッチ 500〜1,000 件程度に分けるのが扱いやすいサイズ感です。
ここで効いてくるのがモデルの選び分けです。「画像に何が写っているか」「どのカテゴリか」という認識タスクは Haiku で十分な精度が出ます。Haiku + Batch API の組み合わせは、Sonnet 即時実行と比較して1枚あたりのコストが10分の1以下になります。全件 Sonnet で流す前に、まず Haiku で精度を検証する順番を強くお勧めします。
プロンプトキャッシュで繰り返し解析を安くする
同じ画像や PDF に対して質問を変えながら複数回リクエストする対話型の使い方では、プロンプトキャッシュが効きます。cache_control を画像・ドキュメントブロックに付けると、2回目以降の読み込みが入力料金の10分の1になります。
def ask_about_pdf(pdf_data: str, question: str) -> str:
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
messages=[{
"role": "user",
"content": [
{"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": pdf_data},
"cache_control": {"type": "ephemeral"}}, # ここがキャッシュ指定
{"type": "text", "text": question},
],
}],
)
usage = message.usage
print(f"cache write: {usage.cache_creation_input_tokens}, "
f"cache read: {usage.cache_read_input_tokens}")
return message.content[0].text
キャッシュの生存時間は既定で5分、リクエストのたびに延長されます。「100ページの資料を読み込ませて、ユーザーが順に質問する」チャット UI では、2問目以降のドキュメント分の入力コストが10%になる計算です。書き込み時に25%の上乗せがあるため1回きりの解析では逆に損をしますが、2回以上聞くならほぼ確実に元が取れます。
ローカル側のキャッシュも併用する価値があります。同一画像×同一プロンプトの結果をハッシュキーで保存しておくだけで、リトライや再実行時の無駄な課金を防げます。
import hashlib, json
from pathlib import Path
class ResultCache:
def __init__(self, cache_dir: str = ".vision_cache"):
self.dir = Path(cache_dir)
self.dir.mkdir(exist_ok=True)
def key(self, image_bytes: bytes, prompt: str) -> str:
return hashlib.sha256(image_bytes + prompt.encode()).hexdigest()
def get(self, image_bytes: bytes, prompt: str):
f = self.dir / f"{self.key(image_bytes, prompt)}.json"
return json.loads(f.read_text()) if f.exists() else None
def put(self, image_bytes: bytes, prompt: str, result: dict):
f = self.dir / f"{self.key(image_bytes, prompt)}.json"
f.write_text(json.dumps(result, ensure_ascii=False))
本番運用のエラー対処 — 画像特有の失敗を分類する
Vision パイプラインで実際に遭遇するエラーは、おおよそ次の4種類に分類できます。それぞれ対処が異なります。
| エラー | 主な原因 | 対処 |
|------|----------|------|
| 400 invalid_request_error | 破損画像・非対応形式・サイズ超過 | リトライ無意味。検証して隔離 |
| 413 request_too_large | リクエスト合計 32MB 超 | リサイズ・分割して再送 |
| 429 rate_limit_error | トークン/分の上限超過 | 指数バックオフで再送 |
| 529 overloaded_error | API 側の一時的過負荷 | 間隔を空けて再送 |
重要なのは、400 系をリトライ対象から外すことです。破損した画像は何度送っても失敗します。リトライループに400が混ざると、失敗キューが詰まり続けるうえ、無駄なリクエストでレート制限まで消費します。
import time
def analyze_with_recovery(path: str, prompt: str, max_retries: int = 3):
"""エラー種別ごとに対処を分けた解析ラッパー"""
try:
data, media_type = resize_for_claude(path)
except Exception as e:
return {"status": "invalid_image", "error": str(e)} # 隔離キューへ
for attempt in range(max_retries):
try:
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": data}},
{"type": "text", "text": prompt},
],
}],
)
return {"status": "ok", "text": message.content[0].text,
"input_tokens": message.usage.input_tokens}
except anthropic.BadRequestError as e:
return {"status": "bad_request", "error": str(e)} # リトライしない
except (anthropic.RateLimitError, anthropic.InternalServerError):
wait = 2 ** attempt * 5
time.sleep(wait)
return {"status": "retry_exhausted"}
もうひとつ、見落としやすいのが 使用トークンの記録 です。message.usage を毎回ログに残し、画像1枚あたりの平均入力トークンを日次で眺めてください。リサイズ処理の退行(誰かが max_edge を変えた、変換前の画像が混入した)は、この数字が突然跳ねることで最初に発見できます。請求書で気づくのでは遅すぎます。
2つのドキュメントを比較する — 差分検出の実装
契約書の新旧比較や、デザインカンプと実装画面の突き合わせのように、「2つの画像の違い」を取りたい場面は意外と多くあります。複数の画像ブロックを1つのメッセージに並べ、それぞれにラベルを付けるのが基本形です。
DIFF_TOOL = {
"name": "record_differences",
"description": "2つのドキュメントの相違点を記録します",
"input_schema": {
"type": "object",
"properties": {
"differences": {
"type": "array",
"items": {
"type": "object",
"properties": {
"section": {"type": "string", "description": "相違がある箇所"},
"document_a": {"type": "string", "description": "1枚目の記載内容"},
"document_b": {"type": "string", "description": "2枚目の記載内容"},
"significance": {"type": "string", "enum": ["substantive", "formatting"]},
},
"required": ["section", "document_a", "document_b", "significance"],
},
},
"identical": {"type": "boolean"},
},
"required": ["differences", "identical"],
},
}
def compare_documents(path_a: str, path_b: str) -> dict:
data_a, type_a = resize_for_claude(path_a)
data_b, type_b = resize_for_claude(path_b)
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
tools=[DIFF_TOOL],
tool_choice={"type": "tool", "name": "record_differences"},
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "1枚目(旧版):"},
{"type": "image",
"source": {"type": "base64", "media_type": type_a, "data": data_a}},
{"type": "text", "text": "2枚目(新版):"},
{"type": "image",
"source": {"type": "base64", "media_type": type_b, "data": data_b}},
{"type": "text",
"text": "2つのドキュメントを比較し、すべての相違点を記録してください。"
"文言の実質的な変更と、レイアウトだけの変更は区別してください。"},
],
}],
)
for block in message.content:
if block.type == "tool_use":
return block.input
return {"differences": [], "identical": False}
ポイントは画像の前にテキストでラベルを挟むことです。「1枚目」「2枚目」と明示しておかないと、回答内で「最初の画像」「もう一方」のような曖昧な参照になり、後段の処理で扱いにくくなります。
また significance で実質的変更と書式変更を分けているのは、実運用で「フォントが変わっただけの差分」が大量に報告されてレビューが埋もれた経験からです。スキーマ設計の段階でノイズを分類しておくと、下流の負荷がまったく違います。
Vision が苦手なこと — 設計段階で避けるべき依頼
最後に、ビジョン機能に任せない方がよいタスクを共有しておきます。ここを知らずに設計すると、後から仕様ごと作り直すことになります。
正確な数え上げ。画面内の細かい要素が20個を超えると、数え間違いが目立ち始めます。在庫写真の個数カウントのような業務は、検出専用モデル(YOLO 系など)と組み合わせる構成が無難です。Claude には「数える」のではなく「分類する・説明する」役割を持たせます。
ピクセル座標の特定。前述の通り、要素の位置をピクセル値で返させるのは精度が出ません。座標が必要な自動操作は、アクセシビリティ API や DOM 情報と組み合わせて解決してください。
厳密な色判定。「この2色は同じか」「ブランドカラー #FF6B35 と一致するか」のような判定は、JPEG 圧縮やリサイズの影響もあり信頼できません。色は PIL でピクセル値を直接読む方が確実で、しかも無料です。
人物の識別。顔から個人を特定する用途には応答しない設計になっています。これは制限というより仕様ですので、要件に含まれる場合は別のアプローチを検討する必要があります。
逆に言えば、これらを外したタスク — 文書の読解、構造の抽出、内容の説明、品質の指摘 — では、専用 OCR エンジンを置き換えられるだけの精度が安定して出ます。役割分担を決めるのは設計者の仕事です。
次の一歩 — まず表抽出を Tool Use に置き換える
ここまでの内容をすべて一度に導入する必要はありません。効果の大きい順に並べると、私の経験では次のようになります。
- 出力パースを Tool Use のスキーマ強制に置き換える(信頼性が一段変わります)
- タスク別のリサイズ基準を導入する(コストが目に見えて下がります)
- 急がないバッチを Message Batches API に移す(同じ処理が半額になります)
特に1番目は、既存コードへの影響が「JSON で返して」のプロンプトとパース処理をツール定義に差し替えるだけで済み、半日あれば移行できます。今夜のバッチが json.loads の例外で止まる前に、ぜひ試してみてください。
お読みいただきありがとうございました。同じようにビジョン処理の本番化に取り組んでいる方の参考になれば幸いです。