スマートフォンで撮った写真を、何の前処理もせず Gemini API に投げていた時期がありました。商品画像から名前と推定価格を JSON で取り出す個人プロジェクトで、レスポンスが返るまで毎回 8〜12 秒かかります。気になって usage_metadata を確認したら、画像 1 枚あたり 1,500 トークン前後を消費していました。
Pillow で長辺を 1024px に縮小してから送るように変えたところ、同じ精度のままレスポンスは 2〜3 秒、画像由来のトークンは 258 前後にまで下がりました。ここではなぜそうなるのかと、私が落ち着いた前処理パターンを共有します。
画像はファイル単位ではなく「タイル単位」で課金される
最初に押さえておきたいのは、Gemini API が画像を「ファイル 1 枚」ではなく「タイル」という単位に分割して処理することです。
Google の公式ドキュメントによれば、Gemini 2.5 系のモデルは画像を 768×768 ピクセルのタイルに切り、タイルごとに 258 トークン換算で料金を計算しています。1024×1024 の画像なら 4 タイル、4032×3024 ピクセル(iPhone 標準解像度)の写真なら 24 タイル前後です。
つまり、4032×3024 の写真をそのまま送ると、1 リクエストあたり 6,000 トークン以上を画像だけで使ってしまうことになります。Pillow で 1024px に縮小すれば 1〜4 タイル(258〜1,032 トークン)に収まり、商品識別やシーン理解の用途では精度はほぼ落ちません。
Gemini API のコスト最適化を体系的に整理した別記事 ではキャッシュ戦略の話題が中心ですが、画像の前処理は「キャッシュより先にやるべき節約」だと感じています。
なぜクライアント側での縮小が効くのか
API 側でも内部的に縮小処理は走りますが、それはあくまで上限を超えたときの救済です。クライアント側で先に整えておくと、3 つの効果がはっきり出ます。
- トークン消費が減る — タイル数が直接料金に効くので、長辺を半分にすればトークンも 1/4 に近づきます
- アップロードが速くなる — 4032×3024 の JPEG は数 MB ですが、1024px に縮小すれば 200KB 前後。家庭用回線で 2〜3 秒の差が出ました
- API 側の処理時間も短くなる — タイル数が減るぶん、モデルのビジョンエンコーダ側の前処理も軽くなります
私が触っているプロジェクトでは、平均レスポンスタイムが 9.2 秒から 2.7 秒へ、月間 API 料金は約 1/5 になりました。
私が普段使っている前処理関数
Pillow の thumbnail() を使うと、アスペクト比を保ちながら長辺を指定サイズに縮小できます。resize() だと縦横比を自分で計算する必要がありますが、thumbnail() は inplace で完結する点が便利です。
from PIL import Image, ImageOps
from io import BytesIO
def prepare_image_for_gemini(
src_path: str,
max_side: int = 1024,
jpeg_quality: int = 85,
) -> tuple[bytes, str]:
"""Gemini API に渡すための画像前処理。
- 長辺 max_side ピクセルに縮小(アスペクト比は保持)
- JPEG として再エンコード(透過は捨てる)
- 戻り値: (バイト列, mime_type)
"""
with Image.open(src_path) as img:
# EXIF の向きに合わせて回転(Gemini は EXIF を読まない)
img = ImageOps.exif_transpose(img)
# RGBA / P モードは RGB に変換(JPEG にするため)
if img.mode != "RGB":
img = img.convert("RGB")
# アスペクト比を保ちながら長辺を max_side に
img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
buf = BytesIO()
img.save(buf, format="JPEG", quality=jpeg_quality, optimize=True)
return buf.getvalue(), "image/jpeg"
# 使い方
from google import genai
client = genai.Client(api_key="YOUR_API_KEY")
image_bytes, mime = prepare_image_for_gemini("photo.jpg", max_side=1024)
resp = client.models.generate_content(
model="gemini-2.5-flash",
contents=[
{"inline_data": {"mime_type": mime, "data": image_bytes}},
"この商品の名前と推定価格を JSON で返してください。",
],
)
print(resp.usage_metadata)
# 例: prompt_token_count=292 (画像 258 + テキスト 34)Image.Resampling.LANCZOS は Pillow 9.1.0 以降の書き方で、それ以前のバージョンでは Image.LANCZOS を使ってください。ImageOps.exif_transpose() を入れているのは、Gemini が EXIF タグを読まないため、向きを先に補正しておかないと「横向きに撮った写真」を縦扱いで投げてしまうからです。これも私が一度ハマった箇所でした。
「画像が小さすぎて精度が落ちる」境目を知る
縮小しすぎると逆に認識精度が落ちます。私が手元の 200 枚程度の商品画像で実験したところ、用途別の目安は次のようになりました。
- 商品ラベル・OCR が目的のとき: 長辺 1280px 以上を保つ(細かい文字が潰れない)
- 物体識別・カテゴリ分類: 長辺 1024px で十分
- シーン理解・全体的な状況把握: 長辺 768px でも精度はほぼ変わらない
- 顔の特徴を扱う用途(プライバシーに配慮した範囲のみ): 長辺 1280px 以上
OCR 用途では、文字部分のみクロップしてから縮小するとさらに精度が安定します。pytesseract でラフな文字領域を検出し、その周辺だけ送るパターンを併用しています。
画像が認識されないときの診断記事 と組み合わせて読むと、mime_type の付け方や向きのトラブルも合わせて整理できます。
File API か inline_data か
20MB を超える画像は inline_data では送れず、File API 経由になります。ただし実用上、長辺 1024px に縮小しておけばインラインで困ることはほぼありません。
私が File API を選ぶのは、
- 同じ画像を複数回のリクエストで使い回したいとき(File API は 48 時間キャッシュされる)
- 動画や大容量 PDF など、
inline_dataでは収まらないファイルを扱うとき
この 2 つに限定しています。単発の画像認識ならインラインのほうが round trip が 1 回少なく、体感も速いです。
リクエストごとのトークン消費を見える化したい場合は、usage_metadata の記録パターンをまとめた別記事 も参考にしてみてください。
サーバー側に任せると料金は減らない
少し細かい話ですが、Gemini はリクエストが大きすぎる画像でも内部で縮小処理を行います。ただし、これはあくまで上限を超えたときの「救済」であって、料金を抑える仕組みではありません。トークンはあくまで「サーバー側で正規化された後のタイル数」で計算されるため、API 任せにしていても課金額は下がりません。
タイル数を本当に減らしたいなら、API に渡す前にすでに小さい画像を用意するしかありません。これが、本記事をわざわざ書こうと思った理由です。
バッチ処理ではこの差が大きく出ます。私が触っているプロジェクトでは、数百枚の商品画像をまとめて処理するスクリプトが、前処理を入れる前は 3 時間で約 $4 かかっていました。prepare_image_for_gemini を挟むようにしてからは、40 分で $1 を切るようになっています。
自分のプロジェクトで効果を確認する
「本当にトークンが減るのか」を自分の環境で確かめたい場合は、次のような小さなスクリプトで十分です。
for size in (4032, 1280, 1024, 768):
img_bytes, mime = prepare_image_for_gemini("test.jpg", max_side=size)
resp = client.models.generate_content(
model="gemini-2.5-flash",
contents=[
{"inline_data": {"mime_type": mime, "data": img_bytes}},
"この画像に何が写っていますか?",
],
)
print(size, len(img_bytes), resp.usage_metadata.prompt_token_count)実際の入力画像を 1 枚使い、サイズだけを変えて回すと、トークン数が階段状に下がるのが見えるはずです。私はこの結果を見て、すべてのマルチモーダル経路に前処理を入れる決心がつきました。
次に試してみてほしいこと
手元のプロジェクトでまだ生の写真を送っているなら、prepare_image_for_gemini を 1 関数挟むだけで構いません。次の月末に届く請求書を見比べると、効果は静かに、しかしはっきり現れるはずです。
お読みいただきありがとうございました。