「写っている棚の商品の位置を教えて」「このレシートの合計金額が書かれている領域を抜き出して」——こうした「画像のどこに何があるか」を答えさせる仕事は、長らく専用のコンピュータビジョン(CV)モデルの出番でした。私自身も、モバイルアプリで物体検出が必要になったときは、まず YOLO や CoreML の検出器を最初に検討してきました。Gemini 2.5 Pro が空間理解(spatial understanding)を強化してから状況は変わりつつあるのですが、本番アプリに組み込もうとすると、想像していたより躓きどころが多いというのが正直な感想です。
Gemini 2.5 Pro のバウンディングボックスとセグメンテーションマスクを「アプリに組み込んで安定して動かす」ところまで持っていくための実装パターンを実例とともに整理しました。座標が 0〜1000 で返ってくるという独特の規約、マスクが base64 PNG で埋め込まれていること、そしてハルシネーション座標をどう排除するか——公式ドキュメントだけ読んでいると見落としやすい部分を中心に取り上げます。
なぜ Gemini の空間理解が「使いどころ」なのか — 専用CVモデルと比較する
最初に立ち位置を整理しておきます。Gemini 2.5 Pro の空間理解は、YOLO や Detectron2 といった専用検出器を「置き換えるもの」ではありません。私の現場感覚で言うと、得意なシーンとそうでないシーンが明確に分かれます。
Gemini が向いているのは、概念的な指示で物体を引き当てたい場面です。たとえば「このレシートの『合計金額』が書かれている領域を返して」「キッチンの写真から『ガスコンロの取手』だけを切り出して」のように、クラス名がカテゴリで定義しきれない、または事前学習されていない用途に強い。専用検出器は事前にラベルを与えていないと動きませんが、Gemini はゼロショットで意味を解釈してくれます。
一方、苦手なのはリアルタイム性とスループットです。1リクエスト数百ミリ秒〜数秒かかりますし、料金も画像1枚あたりトークンを消費します。1秒あたり30フレーム検出するような用途には向きません。「カメラを構えて1枚撮って解析する」「アップロードされた画像を非同期でラベリングする」といった頻度の低い処理が中心になります。
実装方針としては、私は次のように使い分けています。低レイテンシ・高頻度の検出は専用モデルに任せ、人間の意図に近い「探したい対象」が来たら Gemini を呼ぶ。両者を組み合わせるのが現実的だと感じています。後述するフォールバック設計はこの考え方を前提にしています。
もう一つ実務で大事な視点として、検出単価が「何を聞くか」で大きく揺れます。1280px の画像に対する単純な物体検出なら数銭〜数十銭の世界ですが、4Kサイズに対してマスクまで要求すると数十円に跳ね上がることがあります。日に数千回呼ぶアプリだと無視できない金額になりますので、私は画像ごとのコストとレイテンシを同じテレメトリテーブルに残し、プロンプトや画像サイズが変わったときに退化を即時検知できるようにしています。
バウンディングボックス出力の正しい要求方法
ここからが本題です。Gemini の空間出力には「ハマりやすい3つのポイント」があるので、コードを書く前に押さえておきましょう。
第一に、座標は 0〜1000 の正規化値 で返ってきます。0〜1 ではありません。画像の左上が (0, 0)、右下が (1000, 1000) です。第二に、出力順は [ymin, xmin, ymax, xmax] です——一般的な (x, y) 順ではなく、Y が先です。私はここで一度ハマって、画面に表示されるボックスが90度回転しているように見えてしばらく悩みました。第三に、Gemini はあくまで「最善の推測」を返してくるため、画像に存在しない物体を聞かれても自信ありげに座標を作ることがあります(後で対策します)。
イメージとしては、Gemini を「画像を一度見ただけの優秀なインターン」だと思って付き合うのが近いです。だいたい正しいところを指してくれますが、「ない物」を聞かれても遠慮なく『ありそうな場所』を返してきますし、写真の内容そのものよりも「写っていそうな状況」を語ることがあります。本記事のスキーマ・プロンプト・バリデータは、その聡明だが暗示にかかりやすい同僚を、本番投入できる仕組みに磨き上げるための装具のようなものだと考えてください。
プロンプトの書き方と JSON スキーマ
座標を確実に取り出したい場合は、プロンプトで形式を指定し、response_schema で JSON 構造を強制するのが鉄則です。形式を曖昧にすると、Gemini はマークダウンや散文で返してくることがあり、パーサが壊れます。
# requirements:
# pip install google-genai pillow
# env:
# export GEMINI_API_KEY="YOUR_GEMINI_API_KEY"
import json
import os
from io import BytesIO
from typing import List
from google import genai
from google.genai import types
from PIL import Image, ImageDraw, ImageFont
from pydantic import BaseModel, Field
class Detection ( BaseModel ):
label: str = Field( description = "検出した物体のラベル" )
box_2d: List[ int ] = Field(
description = "座標 [ymin, xmin, ymax, xmax](0〜1000で正規化)" ,
min_length = 4 , max_length = 4 ,
)
confidence: float = Field( description = "0〜1の信頼度" , ge = 0 , le = 1 )
class DetectionResult ( BaseModel ):
detections: List[Detection]
def detect_objects (image_path: str , target: str ) -> DetectionResult:
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
image = Image.open(image_path)
prompt = (
f "画像から『 { target } 』に該当する物体をすべて検出してください。"
"座標は [ymin, xmin, ymax, xmax] の順で 0〜1000 の整数で返してください。"
"存在しない場合は detections を空配列で返してください。"
"推測ではなく実際に視認できるものだけを含めてください。"
)
resp = client.models.generate_content(
model = "gemini-2.5-pro" ,
contents = [prompt, image],
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = DetectionResult,
temperature = 0.1 , # 安定化のため低めに
),
)
# 期待出力: DetectionResult をそのまま受け取れる
return DetectionResult.model_validate_json(resp.text)
if __name__ == "__main__" :
result = detect_objects( "./shelf.jpg" , "ペットボトルの飲料" )
print (json.dumps(result.model_dump(), indent = 2 , ensure_ascii = False ))
ここで意図的に行っている工夫をいくつか説明します。response_schema に Pydantic モデルを渡すことで、型のズレを Gemini 側で吸収させています。temperature=0.1 はゼロにしたいところですが、ゼロだと「自信が持てない物体は検出しない」傾向が極端に強まり、再現率が落ちます。経験上、0.1〜0.2 が「安定」と「網羅性」のバランスが取れる範囲です。プロンプトに「推測ではなく実際に視認できるものだけ」と明記しているのは、ハルシネーション抑制の最初の防波堤になります。
0〜1000 正規化座標を画像ピクセルに変換する
検出結果を画面に描画したり、後段のクロップ処理に渡したりするには、ピクセル座標に変換する必要があります。座標順序を間違えるとボックスが斜めにズレるので、関数として切り出して使い回すのがおすすめです。
def to_pixel_box (box_2d: List[ int ], img_w: int , img_h: int ):
"""[ymin, xmin, ymax, xmax] (0-1000) -> (x1, y1, x2, y2) in pixels."""
ymin, xmin, ymax, xmax = box_2d
x1 = round (xmin / 1000 * img_w)
y1 = round (ymin / 1000 * img_h)
x2 = round (xmax / 1000 * img_w)
y2 = round (ymax / 1000 * img_h)
# 念のため画像範囲にクランプ
x1, x2 = max ( 0 , x1), min (img_w, x2)
y1, y2 = max ( 0 , y1), min (img_h, y2)
return x1, y1, x2, y2
def render_detections (image_path: str , detections: List[Detection], out_path: str ):
img = Image.open(image_path).convert( "RGB" )
draw = ImageDraw.Draw(img)
w, h = img.size
for d in detections:
x1, y1, x2, y2 = to_pixel_box(d.box_2d, w, h)
if (x2 - x1) < 4 or (y2 - y1) < 4 :
continue # ノイズの極小ボックスは捨てる
draw.rectangle([x1, y1, x2, y2], outline = "red" , width = 3 )
draw.text((x1, max ( 0 , y1 - 14 )), f " { d.label } { d.confidence :.2f } " , fill = "red" )
img.save(out_path)
min(x_or_y, img_w_or_h) でクランプしているのは、Gemini がときどき 1000 をわずかに超えた値(1003 など)を返すことがあるためです。座標を信用しきらず、必ず画像範囲に収める処理を入れておくと、後段のクロップが IndexError で落ちるのを防げます。
セグメンテーションマスクを扱う
バウンディングボックスは矩形ですが、実用ではもっと正確な形——たとえば商品の輪郭そのもの——が欲しい場面があります。Gemini 2.5 Pro はセグメンテーションマスクも出力でき、これを使うと矩形では捉えきれない領域を取り出せます。
マスクの取得形式と base64 PNG のデコード
Gemini が返すマスクは「ボックスにフィットさせた小さな PNG 画像」を base64 で埋め込んだ形式です。これをデコードして、元画像のサイズに合わせてリサイズし、ボックスの位置に貼り付けることで、元画像座標系のマスクが得られます。この変換ステップを忘れると、マスクが画像の左上に小さくズレた状態で表示されてしまいます。
import base64
from PIL import Image
from io import BytesIO
class Segment ( BaseModel ):
label: str
box_2d: List[ int ]
mask: str = Field( description = "data:image/png;base64,... 形式の base64 マスク" )
class SegmentationResult ( BaseModel ):
segments: List[Segment]
def segment_objects (image_path: str , target: str ) -> SegmentationResult:
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
image = Image.open(image_path)
prompt = (
f "画像から『 { target } 』に該当する物体ごとに、バウンディングボックスとセグメンテーションマスクを返してください。"
"mask は data:image/png;base64,... 形式で、box_2d の領域にフィットさせた PNG にしてください。"
"存在しない場合は segments を空配列で返してください。"
)
resp = client.models.generate_content(
model = "gemini-2.5-pro" ,
contents = [prompt, image],
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = SegmentationResult,
temperature = 0.1 ,
),
)
return SegmentationResult.model_validate_json(resp.text)
def decode_mask_to_full_image (seg: Segment, img_w: int , img_h: int ) -> Image.Image:
"""base64マスクを元画像サイズの2値マスクに展開する。"""
# data:image/png;base64, XXX のヘッダを除去
b64 = seg.mask.split( "," , 1 )[ 1 ] if "," in seg.mask else seg.mask
box_png = Image.open(BytesIO(base64.b64decode(b64))).convert( "L" )
# ピクセル座標のボックス
x1, y1, x2, y2 = to_pixel_box(seg.box_2d, img_w, img_h)
box_w, box_h = max ( 1 , x2 - x1), max ( 1 , y2 - y1)
# ボックスにフィットしている小マスクを、ボックスのサイズへリサイズ
box_png = box_png.resize((box_w, box_h), Image. NEAREST )
# 元画像サイズの黒キャンバスに合成
full = Image.new( "L" , (img_w, img_h), 0 )
full.paste(box_png, (x1, y1))
return full
Image.NEAREST を使っているのは、マスクは2値(あるいは閾値で2値化される前提のグレースケール)なので、補間で中間値が出ると後段の閾値処理で輪郭がブレるためです。BILINEAR を使うと、僅かに滑らかにはなりますが、> 127 で2値化したときに輪郭が震えます。
実用では、このマスクを使って元画像から物体だけを切り抜いたり、半透明にして他要素にブレンドしたりします。たとえば商品認識アプリでは、マスクで物体を切り抜いてサムネイル化し、検索インデックスに突っ込むという使い方ができます。
実用上のもう一つのコツは、マスクを保存するときに必ず元画像と一緒に紐付けて保管することです。マスクだけが残っても再活用が難しく、再描画・検出デグレの調査・後の独自モデル学習いずれにおいても、いつでも元画像から切り抜きを再生成できる状態にしておくと将来の自分が助かります。
ハルシネーション座標を防ぐ4つのチェック
ここが本番運用で一番大切な部分です。Gemini はときどき、画像に存在しない物体について「自信ありげに」座標を返します。これをそのままユーザーに見せると、商品検出アプリで存在しない商品を「ある」と表示することになり、信頼を一瞬で失います。
私が現場で適用しているチェックは4段階です。
チェック1: 信頼度フィルタ。 プロンプトで confidence を要求し、0.5 未満は捨てます。Gemini は厳密な確率を出すわけではありませんが、「0.95」と返してくる検出と「0.55」と返してくる検出には体感的に明確な差があり、後者には誤検知が混ざりやすい傾向があります。
チェック2: ボックスサイズの常識チェック。 画像全体の99%を占めるような巨大なボックスや、5x5ピクセルしかない極小ボックスは、ほぼノイズです。私は area_ratio < 0.001 or area_ratio > 0.95 の範囲を捨てています。
チェック3: 多重検出の一貫性チェック。 同じ画像に対して temperature を変えて2〜3回検出を走らせ、同じ位置に出てくる検出だけを採用するパターンです。コストは増えますが、精度を最重視するワークフローではこれが効きます。IoU(Intersection over Union)が 0.5 以上で重なる検出を「一致」とみなします。
チェック4: 画像内容との整合性。 これは Gemini に「同じ画像の中で本当にこの物体があるか、はい/いいえで答えて」と聞き返す方法です。検出結果と切り出し画像を渡して、別の判定モデル(同じ Gemini で OK)に確認させます。コストは2倍になりますが、誤検知が許されないシーン——たとえば医療や金額抽出——では検討する価値があります。
def iou (a: List[ int ], b: List[ int ]) -> float :
"""[ymin, xmin, ymax, xmax] 同士の IoU。"""
ay1, ax1, ay2, ax2 = a
by1, bx1, by2, bx2 = b
iy1, ix1 = max (ay1, by1), max (ax1, bx1)
iy2, ix2 = min (ay2, by2), min (ax2, bx2)
iw, ih = max ( 0 , ix2 - ix1), max ( 0 , iy2 - iy1)
inter = iw * ih
if inter == 0 :
return 0.0
area_a = (ay2 - ay1) * (ax2 - ax1)
area_b = (by2 - by1) * (bx2 - bx1)
return inter / (area_a + area_b - inter)
def filter_hallucinations (
detections: List[Detection],
img_w: int , img_h: int ,
min_conf: float = 0.5 ,
) -> List[Detection]:
out = []
for d in detections:
if d.confidence < min_conf:
continue
x1, y1, x2, y2 = to_pixel_box(d.box_2d, img_w, img_h)
bw, bh = x2 - x1, y2 - y1
if bw <= 0 or bh <= 0 :
continue
ratio = (bw * bh) / float (img_w * img_h)
if ratio < 0.001 or ratio > 0.95 :
continue
out.append(d)
return out
def cross_check (detections_run1, detections_run2, iou_threshold: float = 0.5 ):
"""2回の検出結果のうち、IoU >= 閾値で重なるものだけ残す。"""
consistent = []
for d1 in detections_run1:
for d2 in detections_run2:
if d1.label == d2.label and iou(d1.box_2d, d2.box_2d) >= iou_threshold:
consistent.append(d1)
break
return consistent
このフィルタを通したあとは、検出精度は明らかに上がります。私のテストでは、フィルタ前は「いかにもありそうな架空の検出」が10件中1〜2件混じっていたものが、ほぼゼロになりました。代償として再現率は数%落ちますが、本番アプリでは「誤って表示する」よりも「ユーザーに見せない」方が許容しやすいので、この trade-off は受け入れる価値があると考えています。
大きな画像・複数オブジェクトを安定処理する戦略
実用画像はしばしば 4000x3000 ピクセル超になります。Gemini に巨大画像をそのまま投げると、トークン消費が増え、空間理解の精度も微妙に落ちる傾向があります(公式は明言していませんが、私の手元の実験ではそう感じます)。
対策はシンプルで、長辺を 1024〜1536 ピクセルにリサイズしてから投げる、という前処理を入れるだけです。
def smart_resize (img: Image.Image, max_side: int = 1280 ) -> Image.Image:
w, h = img.size
longest = max (w, h)
if longest <= max_side:
return img
scale = max_side / longest
return img.resize(( round (w * scale), round (h * scale)), Image. LANCZOS )
リサイズ後の画像で検出した座標は、box_2d が 0〜1000 正規化値なので、リサイズしてもしなくても同じ意味になります(元画像で再生したいときは、元画像のサイズに対して to_pixel_box を呼べばOK)。座標が「画像サイズに対しての比率」で返ってくるのは、こうした使い回しが効くという点で、地味に便利な設計だと思います。
物体数が極端に多い(たとえば100件以上検出したい)画像では、Gemini は中間で出力を打ち切ることがあります。これに対しては、画像をタイル分割して4枚に分けて検出 → タイル境界を考慮してマージ、というアプローチが堅実です。本番アプリで100物体が必要な場面は意外と少ないので、まずは「上位 N 件だけ返してほしい」とプロンプトで指示する方が現実的です。
もう一つの安定化テクニックは、プロンプトに「期待する画像の俯瞰情報」を添えることです。「これはスーパーの陳列棚を引きで撮った写真です」「これは縦持ちで撮影した1枚のレシート画像です」のような前提を一行入れるだけで、空間理解の精度が体感できるレベルで向上します。モデルはメタ情報を持っていないので、文章でヒントを渡してあげるイメージです。私はワークフローごとに小さな「シーンプリセット」を用意していて、そちらを差し込む方が、モデルパラメータをいじるよりも検出品質に効きやすい印象です。
専用CVモデルへの自動フォールバック設計(本番想定)
Gemini が落ちたとき、レート制限に引っかかったとき、あるいは検出ゼロが続くとき——本番アプリは止まれません。私が組んでいるフォールバック構成は、Cloudflare Workers 上で次のような分岐になります。
// Cloudflare Workers 想定
import { GoogleGenAI } from "@google/genai" ;
interface DetectInput {
imageUrl : string ;
target : string ;
}
interface Detection {
label : string ;
box_2d : [ number , number , number , number ];
confidence : number ;
source : "gemini" | "yolo" ;
}
const SOFT_FAIL_PATTERNS = [
/quota/ i ,
/rate . ? limit/ i ,
/server . ? error/ i ,
];
async function detectWithGemini ( input : DetectInput , env : Env ) : Promise < Detection []> {
const ai = new GoogleGenAI ({ apiKey: env. GEMINI_API_KEY });
const imageBytes = await ( await fetch (input.imageUrl)). arrayBuffer ();
const result = await ai.models. generateContent ({
model: "gemini-2.5-pro" ,
contents: [
{
role: "user" ,
parts: [
{
text: `画像から『${ input . target }』を検出。box_2dは[ymin,xmin,ymax,xmax](0-1000)。`
},
{
inlineData: {
mimeType: "image/jpeg" ,
data: btoa (String. fromCharCode ( ...new Uint8Array (imageBytes))),
},
},
],
},
],
config: {
responseMimeType: "application/json" ,
responseSchema: {
type: "object" ,
properties: {
detections: {
type: "array" ,
items: {
type: "object" ,
properties: {
label: { type: "string" },
box_2d: { type: "array" , items: { type: "integer" } },
confidence: { type: "number" },
},
required: [ "label" , "box_2d" , "confidence" ],
},
},
},
required: [ "detections" ],
},
temperature: 0.1 ,
},
});
const parsed = JSON . parse (result.text ?? "{ \" detections \" :[]}" );
return parsed.detections. map (( d : Detection ) => ({ ... d, source: "gemini" as const }));
}
async function detectWithYolo ( input : DetectInput , env : Env ) : Promise < Detection []> {
// 自前 YOLO 推論サーバー(Replicate, Modal, 自社GPU)に飛ばすイメージ
const r = await fetch (env. YOLO_ENDPOINT , {
method: "POST" ,
headers: { "Authorization" : `Bearer ${ env . YOLO_TOKEN }` },
body: JSON . stringify ({ image_url: input.imageUrl, target: input.target }),
});
if ( ! r.ok) return [];
const json = await r. json <{ detections : Detection [] }>();
return json.detections. map (( d ) => ({ ... d, source: "yolo" }));
}
export async function detect ( input : DetectInput , env : Env ) : Promise < Detection []> {
try {
const dets = await detectWithGemini (input, env);
if (dets. length > 0 ) return dets;
// ゼロ件は信用せず、確認のために YOLO も走らせる
const fallback = await detectWithYolo (input, env);
return fallback;
} catch ( e : any ) {
const msg = String (e?.message ?? e);
const recoverable = SOFT_FAIL_PATTERNS . some (( p ) => p. test (msg));
if (recoverable) {
console. warn ( "Gemini soft-fail, falling back to YOLO:" , msg);
return detectWithYolo (input, env);
}
throw e;
}
}
ポイントは「ゼロ件のときも YOLO を呼ぶ」ところです。Gemini は誤検知を減らすために慎重に判断しすぎる傾向があり、「実際にはあるのに検出しない」ケースが生じます。ここを YOLO で補完すると、再現率が安定します。逆に「Gemini はあると言ったが YOLO は否定した」場合は、信頼度を下げる、もしくは表示を保留するのが安全です。
実用シナリオ — モバイルカメラアプリへの組み込み
私はモバイルアプリを個人で作ってきた経験から、この機能の使い所をいくつか挙げておきます。
ECサイト用の「写真から商品を探す」機能では、ユーザーが撮った棚の写真から候補商品の領域を切り出し、その切り出し画像を商品検索エンジンへ送る、という設計が組めます。Gemini は「ペットボトルの飲料」のような自然言語クエリに強いので、UI 上でユーザーが入力した検索語をそのまま渡せるのが利点です。
レシート/請求書 OCR の前処理にも便利です。私はかつて、レシートの「合計」「日付」「店名」を別々の OCR で抜く処理に苦労していたのですが、Gemini に「合計金額の領域だけバウンディングボックスで返して」と頼み、その領域だけ高解像度で OCR にかける、という二段構成にしてからは精度が大きく改善しました。
癒し系・写真系アプリでは、ユーザーが投稿した写真から「印象的な被写体」だけを切り出してフレーム加工する、といった使い方もできます。「印象的な」のような概念的なクエリは専用検出器では引けないので、ここでは Gemini が最も強みを発揮します。
レイテンシが許される画面遷移の隙——たとえば撮影直後のローディング中——に走らせるのが現実的です。私の経験では 1〜2 秒の処理時間なら、ユーザーが「処理されている」と認識できる範囲なので、ストレスを与えにくいと感じます。
アクセシビリティ機能——カメラに映る景色を読み上げるアプリや、視覚に障害のある方向けの環境説明——を作る場面でも、Gemini の空間出力は強い武器になります。「左に湯のみ、中央にスマートフォン、右にノートが置かれています」のように位置情報込みでナレーションできるのは、固定クラスの検出器では到底実現できない表現です。私が見てきた中でも、最も意義のあるユースケースの一つだと感じています。
開発手法をさらに
よくある間違い・落とし穴
ここからは、実際に組み込んでいて私自身が踏んだ罠と、その回避策をまとめます。
間違い1: 座標の Y/X 順を (x, y) だと思い込む。 一番ハマりやすい罠です。Gemini は [ymin, xmin, ymax, xmax] で返してきます。box_2d[0] を X だと思って描画すると、ボックスが回転して見える、あるいは画像外にはみ出します。共有関数 to_pixel_box のように、変換関数を一箇所に閉じ込めるのが安全策です。
間違い2: マスクをそのまま元画像座標系で使おうとします。 マスクは「ボックスの中身にフィットさせた小さな PNG」です。元画像にそのまま貼り付けると左上にズレた小さな塊になります。decode_mask_to_full_image のように、ボックスサイズへリサイズしてから合成しないと意味がありません。
間違い3: temperature をゼロに設定します。 一見「決定的にしたい」と思いがちですが、ゼロだと検出が極端に保守的になり、ユーザーから見ると「全然見つけてくれない」アプリになります。0.1〜0.2 が経験上ちょうど良いです。
間違い4: 信頼度をそのまま「確率」だと信じる。 Gemini が返す confidence は較正された確率ではありません。アプリ内の閾値判断には使えますが、「0.85 の検出は 85% の確率で正しい」と統計的に主張するのは無理があります。min_conf で足切りに使う程度に留めましょう。
間違い5: 大きな画像をそのまま投げる。 4000ピクセル級の画像を投げると、トークン消費だけでなく検出品質も微妙に落ちます。smart_resize で長辺を 1024〜1536 にしてから投げる前処理は、ほぼ常に入れる価値があります。
間違い6: 「ない物体」を聞いたときの挙動を考えありません。 Gemini はゼロ検出を渋ることがあり、無理矢理それらしい領域を返してくることがあります。プロンプトで「存在しない場合は空配列」と明記し、後段でも filter_hallucinations を通す——この二段構えがないと、本番で必ず誤検知が出ます。
間違い7: 並列リクエストでレート制限に引っかかる。 UI 側で「画像が選ばれたら即検出」を素直に実装すると、ユーザーが連続選択したときにレート制限を超えます。デバウンスとリクエストキャンセル(AbortController)を入れて、最後の選択だけが処理されるようにすると、UX もコストも改善します。
間違い8: 生のレスポンスをログに残さありません。 後段のフィルタや変換を重ねていくほど、「なぜこの結果になったのか」を後追いで調べられなくなります。私は入力画像のハッシュをキーに、Gemini からの生 JSON をオブジェクトストレージに保存しています。「AI が違う物を検出した」とユーザーから報告が来たとき、再課金せずに当時の応答を再生できるのは、デバッグの現実的な近道です。
これらの落とし穴は、関連する Gemini API のレート制限とクォータ管理ガイド や Gemini API ストリーミングのキャンセルと AbortController 完全ガイド と組み合わせて読むと、本番運用の地図がつながりやすくなると思います。
デグレを早期発見するテスト戦略
Gemini ベースの検出を本番投入する際、地味だけれど見落としやすいのが「モデル側の挙動が静かにずれていく」ことへの対策です。モデルのバージョン更新、プロンプトの微修正、季節要因による負荷変化——どれもが検出品質を上下させる可能性があります。とはいえ通常のユニットテストでは扱いにくく、現場で機能するのは「フィクスチャベースのスナップショットテスト」です。
代表的な画像のフォルダを用意し、各画像に「期待される検出(ラベル + 概略の box)」を JSON で添えておきます。テストランナーは検出を実行し、IoU を一致基準として比較します。
import json
from pathlib import Path
def evaluate (predicted, expected, iou_thr = 0.4 ):
# IoU >= iou_thr かつ同じラベルを「マッチ」とみなして precision/recall を返す
matched_pred = set ()
matched_exp = set ()
for i, e in enumerate (expected):
for j, p in enumerate (predicted):
if j in matched_pred:
continue
if e[ "label" ] != p[ "label" ]:
continue
if iou(e[ "box_2d" ], p[ "box_2d" ]) >= iou_thr:
matched_pred.add(j)
matched_exp.add(i)
break
precision = len (matched_pred) / max ( 1 , len (predicted))
recall = len (matched_exp) / max ( 1 , len (expected))
return precision, recall
def run_regression_suite (fixtures_dir: str , threshold_p: float = 0.7 , threshold_r: float = 0.7 ):
failures = []
for case in Path(fixtures_dir).glob( "*.json" ):
spec = json.loads(case.read_text())
result = detect_objects(spec[ "image_path" ], spec[ "target" ])
precision, recall = evaluate(
[d.model_dump() for d in result.detections],
spec[ "expected" ],
)
if precision < threshold_p or recall < threshold_r:
failures.append({ "case" : case.name, "precision" : precision, "recall" : recall})
return failures
これを CI で小規模なフィクスチャに対して回します。モデルやプロンプトを差し替えたときに品質が下がっていないかを、ユーザーよりも先に検知できます。リリース毎に数百回の追加課金が発生しますが、「金曜の夜に『AIが急に商品を見つけなくなった』と Slack に投げ込まれる」事態を防げると思えば、十分割の合うコストです。
実運用でのコストとレイテンシ感覚
具体的な数字は更新が早いので断定的には書きませんが、目安として共有します。Gemini 2.5 Pro に 1280px の画像で単純な検出(5〜10件)を投げると、おおよそ数銭〜十数銭、所要時間は 1〜2 秒という肌感です。セグメンテーションマスクを追加すると、出力トークンが増えるためレイテンシは概ね倍に、コストも一段上がります。
これがプロダクト判断にどう効くかというと、検出のみであれば「アップロードされた全画像にかける」が現実的、一方マスク取得は「輪郭を表示する」トグルや、後段で実際にマスクが必要になる工程に絞った方が無難、ということになります。UX 上、結果をその場で見せる場面では検出のみ、アップロード進捗を見せている裏で処理できる場面ならマスクまで——という使い分けが効きやすい印象です。
大量ボリュームのパイプラインでは、私はまず Gemini 2.5 Flash で粗い検出を回し、信頼度が低かったり結果ゼロだったりした難しいケースだけ 2.5 Pro にエスカレーションする、という構成にしています。Flash → Pro のエスカレータ + YOLO フォールバック、という3層構造にすると、コスト・レイテンシ・精度のバランスを単一モデルでは絶対に取れない位置に収められます。
二週目以降に直面する運用的な懸念
リリース後しばらく経ってから出てくる類の問題もまとめておきます。最初に意識しておくと、追加スプリントの「これ考えてなかった」を一つでも減らせます。
画像のプライバシーと保管期間。 Gemini の利用規約は処理目的での画像送信を許可していますが、それでもユーザーの画像は機密として扱う前提でいるべきです。自社ストレージに保管期間を明示して保存し、生のリクエストをログに残さない、EXIF を送信前にスクラブする——この辺りは初期段階で組み込んでおきます。私は GPS 座標や端末シリアルなどを EXIF から落とすようにしています。ここを油断すると、一発でユーザーの自宅住所まで露出してしまうことがあります。
多言語 UI への波及。 同じ「ペットボトル」でも、日本語・英語・スペイン語のユーザーはそれぞれ「PET bottles」「ペットボトル」「botellas de plástico」と入力します。プロンプトの前に正準化(たとえば英語に翻訳)するか、原文をそのまま渡すかは設計判断ですが、私の経験上は、英語ターゲットラベルが画像内容を問わず最も安定する一方、明確に視認できる物体については原文でも問題ありません。意外と苦戦するのは外来語・ブランド名で、ここは小さな翻訳ステップを噛ませると改善します。
モデルバージョン固定。 Gemini は新しいモデルエイリアスが追加されると、コードを変えていなくても検出結果が微妙に変わることがあります。私は本番設定で具体的なモデルバージョンをピン留めし、前項のリグレッションスイートを通してから前進させています。「最新を黙って追従する」設定にしていた時代に、品質低下に気づくまで数日かかるという目に二度遭ったので、これは譲れません。
画像のキャッシュ。 ユーザーがネットワーク不調でアップロードを再試行する場面など、同じ画像が複数回送られてくることは少なくありません。画像バイト列のハッシュとターゲット文字列をキーに、Cloudflare KV で 24 時間 TTL のキャッシュを持つだけで、無駄な再課金が大きく減ります。
観測性。 一回ごとのコストとレイテンシだけでなく、ターゲット文字列・画像ハッシュ・使用モデル・生のボックス数・フィルタ後のボックス数・信頼度ヒストグラム・どのフォールバック経路が発火したか——を構造化イベントとして残しておくと、「検出が時間とともに悪化していないか」「YOLO フォールバックがどれくらい救ってくれているか」といった問いに即答できます。記録のコストはたかが知れていますが、これがないと検出デグレの調査が一気に手探りになります。
全体を振り返って — 次の一歩
ここまでお読みいただきありがとうございます。一つ具体的な行動を提案するなら、まずは手元の画像を1枚用意して、本記事の detect_objects をそのまま動かしてみてください。座標が [ymin, xmin, ymax, xmax] で 0〜1000 正規化、という独特の規約を一度自分の手で扱うと、その後の応用がぐっと楽になります。
そのうえで、本番投入前には filter_hallucinations と cross_check、そして YOLO への自動フォールバックの3点を必ず仕込んでください。Gemini の空間理解は、専用検出器の代替ではなく「概念的なクエリで物体を引き当てる」用途で最も輝きます。両者を一つのパイプラインに組み合わせることが、実用アプリで使い物になる近道だと思っています。