◉GEMINI LABEN
●ARGON — gemini-4-argon が 10/13 に GA。複雑なコーディング・エージェント・数学・科学向けです●DEEP RES — deep-research-pro-preview-12-2025 は 10/23 に停止、残り6日。移行先は deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 が GA(10/06)。gemini-3.1-flash-image は非推奨で停止日は未発表です●MCP — ツール結果が 8.5万トークンに膨らんだ事例を、ADK で 310 トークンに包んで抑えた報告が出ています●FLASH — gemini-3.7-flash と 3.5-flash は新しい Flash モデルに自動で回されます(10/08)●NEW — クラッシュ報告を毎回ゼロから読ませない — 既知バグ早見表を先に grep し…●ARGON — gemini-4-argon が 10/13 に GA。複雑なコーディング・エージェント・数学・科学向けです●DEEP RES — deep-research-pro-preview-12-2025 は 10/23 に停止、残り6日。移行先は deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 が GA(10/06)。gemini-3.1-flash-image は非推奨で停止日は未発表です●MCP — ツール結果が 8.5万トークンに膨らんだ事例を、ADK で 310 トークンに包んで抑えた報告が出ています●FLASH — gemini-3.7-flash と 3.5-flash は新しい Flash モデルに自動で回されます(10/08)●NEW — クラッシュ報告を毎回ゼロから読ませない — 既知バグ早見表を先に grep し…
記事一覧/API / SDK
◈ API / SDK/2026-04-28上級

Gemini 2.5 Pro の空間理解(バウンディングボックス・セグメンテーション)を本番アプリで使いこなす

Gemini 2.5 Pro の空間理解出力を本番のモバイル/Webアプリに組み込むための実装ガイド。座標正規化・マスクのデコード・ハルシネーション検出・YOLOへの自動フォールバックまで、動作するコードと共に解説します。

gemini-2-5-pro3image-understandingbounding-boxsegmentationcomputer-visionproduction105multimodal24ai-app

✦ プレミアム記事

「写っている棚の商品の位置を教えて」「このレシートの合計金額が書かれている領域を抜き出して」——こうした「画像のどこに何があるか」を答えさせる仕事は、長らく専用のコンピュータビジョン(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 で落ちるのを防げます。

切り抜きが一皮足りない — 0〜1000 格子の丸めを見積もる

レシートの合計金額を切り出して OCR に回す処理を書いていた夜、切り抜いた画像を並べて眺めていて手が止まりました。ボックスの位置は合っているのに、数字の上端がほんの少しだけ削れている画像が混じっていたのです。プロンプトもパーサも疑いましたが、原因はもっと手前にありました——[ymin, xmin, ymax, xmax] が整数で返ってくる、という規約そのものです。

0〜1000 は連続値ではなく、1000 分割の格子です。幅 3024 ピクセルの写真なら、1 目盛りが約 3.02 ピクセルに相当します。モデルが真の境界を最も近い目盛りへ丸めた瞬間、辺ごとに半目盛り未満の誤差が入ります。そして丸めは、内側にも外側にも同じ確率で転びます。

どれくらい効くのかは、API を呼ばなくても測れます。真のボックスをピクセル座標で作り、0〜1000 へ量子化して戻し、元のボックスと比べるだけです。

"""0〜1000 格子の丸めが切り抜きにどれだけ効くかを測る。API キー不要。"""
import random
import statistics
 
def quantize(box_px, w, h):
    x1, y1, x2, y2 = box_px
    return [round(y1 * 1000 / h), round(x1 * 1000 / w),
            round(y2 * 1000 / h), round(x2 * 1000 / w)]
 
def to_pixel(box_1000, w, h):
    ymin, xmin, ymax, xmax = box_1000
    return [xmin * w / 1000, ymin * h / 1000, xmax * w / 1000, ymax * h / 1000]
 
def measure(w, h, n=20000, pad_units=0, seed=42):
    rnd = random.Random(seed)
    errs, clipped = [], 0
    for _ in range(n):
        bw = rnd.uniform(0.01, 0.5) * w
        bh = rnd.uniform(0.01, 0.5) * h
        x1 = rnd.uniform(0, w - bw)
        y1 = rnd.uniform(0, h - bh)
        true_px = [x1, y1, x1 + bw, y1 + bh]
        q = quantize(true_px, w, h)
        if pad_units:
            q = [q[0] - pad_units, q[1] - pad_units,
                 q[2] + pad_units, q[3] + pad_units]
            q = [max(0, min(1000, v)) for v in q]
        got = to_pixel(q, w, h)
        errs.extend(abs(got[i] - true_px[i]) for i in range(4))
        # 復元したボックスが真のボックスを覆いきれなければ、内容を削っている
        if (got[0] > true_px[0] or got[1] > true_px[1]
                or got[2] < true_px[2] or got[3] < true_px[3]):
            clipped += 1
    return max(errs), statistics.mean(errs), clipped / n * 100
 
for w, h, label in [(1024, 768, "1024x768"),
                    (3024, 4032, "3024x4032"),
                    (6000, 4000, "6000x4000")]:
    for pad in (0, 1, 2):
        mx, mean, clip = measure(w, h, pad_units=pad)
        print(f"{label} pad={pad} 最大={mx:.2f}px 平均={mean:.2f}px 削り={clip:.2f}%")

手元で走らせた結果です(各条件 20,000 ボックス、乱数シード固定)。

画像サイズ余白(目盛り)辺の誤差 最大辺の誤差 平均内容を削ったボックスの割合
1024×76800.51 px0.22 px93.77%
1024×76811.54 px0.89 px0.00%
3024×403202.02 px0.88 px93.77%
3024×403216.05 px3.52 px0.00%
6000×400003.00 px1.25 px93.77%
6000×400019.00 px4.99 px0.00%

いちばん目を引いたのは右端の列でした。余白を足さずに切り抜くと、画像サイズにかかわらず 9 割以上のボックスが、どこか一辺で内容を削っています。理屈のうえでも自然な数で、四辺それぞれが独立に内側へ丸まる確率が 1/2 ですから、少なくとも一辺が内側へ転ぶ確率は 1 −(1/2)の 4 乗 = 93.75% になります。実測の 93.77% は、その理論値にきれいに重なりました。

削れる量そのものは小さく、1024 ピクセルの画像なら辺あたり平均 0.22 ピクセルです。描画して人に見せるだけなら、誰も気づきません。ところが対象が細いと話が変わります。3024×4032 の写真に写った高さ 28 ピクセルの帯——レシートの一行はおおむねこの太さです——を切り抜くと、余白なしでは帯の面積の平均 3.70%、最悪 13.82% が失われました(同じスクリプトで bw を 600、bh を 28 に固定し、真のボックスと復元ボックスの重なりから失われた面積の割合を測った値です)。数字の上端が削れて桁が化ける、という OCR 側の誤読は、ここから来ていたのです。

格子は連続値ではありません。切り抜きには、1 目盛り分の逃げを。 逃がす量は 1 目盛りで足ります。丸め誤差は定義上どの辺でも半目盛り未満ですから、1 目盛りの余白があれば、理論上も実測でも削りはゼロになります。

def to_pixel_box_padded(box_2d: List[int], img_w: int, img_h: int,
                        pad_units: int = 1):
    """切り抜き用。0〜1000 の格子で pad_units 目盛りだけ外へ広げてからピクセル化します。"""
    ymin, xmin, ymax, xmax = box_2d
    ymin, xmin = max(0, ymin - pad_units), max(0, xmin - pad_units)
    ymax, xmax = min(1000, ymax + pad_units), min(1000, xmax + pad_units)
    return to_pixel_box([ymin, xmin, ymax, xmax], img_w, img_h)

ただし余白はただでは手に入りません。6000 ピクセル幅の画像では 1 目盛りが 6 ピクセルで、隣り合った商品を個別に切り出す場面では、その余白がそのまま隣の商品の写り込みになります。私は用途で分けています——画面への描画は素の to_pixel_box、OCR や検索エンジンへ渡す切り抜きは to_pixel_box_padded、そして対象どうしが接している棚の写真では、広げる前に iou で隣との重なりを確かめてから余白を決める、という順です。

後述する smart_resize との関係も、ここで整理しておきます。長辺を 1024 に縮めてから投げても、座標は正規化されて返るので、変換そのものの精度は落ちません。落ちるのはモデルが細部を見る力のほうです。一方で格子の粗さは元画像の大きさで決まりますから、縮小したかどうかに関わらず、切り抜きの余白は元画像のサイズを基準に考えることになります。

✦

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
✦Gemini に画像内の物体位置を聞いたら座標がズレていた、と困っていた方が、信頼できる出力を得るためのプロンプトとパーサを今日から使えます
✦余白なしの切り抜きが 93.77% のボックスで内容を削るという 20,000 件の実測と、1 目盛りの余白で 0% にする描画・保存コードが手に入ります
✦ハルシネーション座標を検出して棄却する仕組みと、専用CVモデル(YOLO等)への自動フォールバック設計まで実装できます
Stripe による安全な決済 · いつでもキャンセル可能
✦

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Gemini Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

◈ API / SDK2026-07-04
Gemini API の英語出力に日本語が『たまに』混ざるとき — 混入率を計測して段階的に締める運用メモ
英語出力を指示したGemini APIが、100回に数回だけ日本語を混ぜてくる——この『たまに』を止められない本当の理由と、混入率をSLOとして計測し、段階的リカバリで本番品質まで締める運用パターンを実装コード付きで整理します。
◈ API / SDK2026-05-20
Gemini API 構造化出力スキーマを本番アプリで安全に進化させる設計記録
本番アプリで Gemini API の JSON 出力スキーマを変えるとき、何を契約として守り、何を段階的に切り替えるか。旧版と新版を同時に返す Dual-Emit パターン、旧スキーマ利用箇所を見積もる compatibility checker、Sunset プロトコルの実装記録です。
◈ API / SDK2026-05-18
Gemini Vision で壁紙アプリの自動カテゴリ分類を実装した話
個人開発の壁紙アプリで Gemini Vision API を使い、画像の自動カテゴリ分類を実装した実体験です。精度改善のプロセスと、公式ドキュメントには載っていない落とし穴、GPT-4o Vision とのコスト比較までまとめました。