◉GEMINI LABJP
●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…
Articles/API / SDK
◈ API / SDK/2026-04-28Advanced

Production-Grade Spatial Understanding with Gemini 2.5 Pro: Bounding Boxes and Segmentation Done Right

A production-focused guide to wiring Gemini 2.5 Pro's bounding-box and segmentation outputs into mobile and web apps — covering coordinate normalization, mask decoding, hallucination detection, and automatic fallback to YOLO.

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

✦ Premium Article

"Tell me where the products are on this shelf." "Highlight the area on this receipt that contains the total amount." For a long time, those "where is what in this image" jobs belonged firmly to dedicated computer vision (CV) models. When I needed object detection in a mobile app, my first move was always to reach for YOLO or a CoreML detector. Gemini 2.5 Pro's improved spatial understanding has shifted that calculus, but pulling it into a real production app is, honestly, a bit trickier than the marketing pages suggest.

This article distills the implementation patterns I rely on to take Gemini's bounding-box and segmentation outputs from "neat demo" to "stable feature in shipping software." That includes the surprising 0–1000 coordinate convention, the base64-PNG quirk in mask outputs, and the hallucinated-coordinate problem that the official docs barely touch on.

Why Gemini's Spatial Understanding Is a "When to Use It" Tool

Let me set expectations first. Gemini 2.5 Pro's spatial understanding is not a drop-in replacement for YOLO or Detectron2. From hands-on experience, the strengths and weaknesses divide cleanly.

Gemini shines when you need to point at things using conceptual queries. Asking "where is the total amount written on this receipt?" or "crop just the knobs of the gas range in this kitchen photo" leans on semantic interpretation that purpose-built detectors can't do without retraining. Gemini handles those zero-shot.

It is weak at latency and throughput. A request takes hundreds of milliseconds to a few seconds, and you pay tokens per image. If you need 30 fps real-time detection, Gemini is the wrong tool. The sweet spot is "user takes one photo, we analyze it" or "an image was uploaded, label it asynchronously" — low frequency, high semantic complexity.

In practice, I split the workload: dedicated detectors do the high-frequency, low-latency work, and Gemini handles the conceptual queries. The fallback design later in this article assumes that hybrid setup.

There's also a meta point worth saying out loud: the cost-per-detection numbers swing wildly depending on what you ask for. A simple "find all bottles" against a 1280-pixel image costs a fraction of a cent, but multi-object detection with masks against a 4K photo can climb into the cents. If your app does this thousands of times a day, that adds up. I track per-image cost in the same telemetry table as per-image latency so I can spot regressions when prompts or image sizes change.

Asking for Bounding Boxes the Right Way

There are three traps to know before you write any code.

First, coordinates come back as integers in the 0–1000 range, not 0–1. The image's top-left is (0, 0) and the bottom-right is (1000, 1000). Second, the order is [ymin, xmin, ymax, xmax] — Y comes first. I burned an afternoon once because my rendered boxes looked rotated and I assumed Gemini was broken. Third, Gemini gives you its best guess; if you ask about an object that isn't there, it can confidently fabricate coordinates. We'll handle that further down.

A useful mental model: treat Gemini as a senior intern who has just looked at the image. The intern can usually point at the right thing — but won't push back when you ask for something that isn't there, and will sometimes describe what "should" be in the photo rather than what is. The schema, prompts, and validators in this article are how you turn that helpful but suggestible intern into a system you can ship.

Prompting and the JSON Schema

If you want stable output, lean on response_schema to force the JSON shape. Free-form output drifts into Markdown or prose and breaks parsers in production.

# 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
from pydantic import BaseModel, Field
 
class Detection(BaseModel):
    label: str = Field(description="The detected object's label")
    box_2d: List[int] = Field(
        description="[ymin, xmin, ymax, xmax] normalized to 0-1000",
        min_length=4, max_length=4,
    )
    confidence: float = Field(description="0.0 to 1.0", 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"Detect every instance of '{target}' in the image. "
        "Return each box_2d as integers in [ymin, xmin, ymax, xmax] order, "
        "normalized to 0-1000. Use an empty detections array if nothing matches. "
        "Only include objects you actually see — do not guess."
    )
 
    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,  # keep low for stability
        ),
    )
    # Expected output: parses cleanly into DetectionResult
    return DetectionResult.model_validate_json(resp.text)
 
if __name__ == "__main__":
    result = detect_objects("./shelf.jpg", "PET bottles of beverages")
    print(json.dumps(result.model_dump(), indent=2))

A few deliberate choices here. Passing a Pydantic model to response_schema lets Gemini absorb minor type drift on its side rather than blowing up your parser. I usually set temperature=0.1 rather than 0; at exactly zero, Gemini gets so conservative that it skips legitimate detections. The "don't guess" instruction in the prompt is your first line of defense against hallucinated coordinates.

Converting 0–1000 Coordinates to Pixels

To draw boxes or crop regions, you need pixel coordinates. Wrap the conversion in a helper — getting the order wrong leaks into every downstream consumer.

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)
    # Clamp into image bounds
    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  # discard pinprick boxes that are usually noise
        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)

The clamp matters because Gemini occasionally returns values that nudge past 1000 (1003 isn't unusual). If you trust the raw output, downstream cropping will throw IndexError on the edges.

When the Crop Comes Up a Hair Short — Estimating the 0–1000 Rounding

I was wiring up a receipt pipeline late one evening, cropping the total-amount region and handing it to OCR, when I laid the crops out side by side and stopped. The boxes sat in the right place, but on some of them the tops of the digits were shaved off. I suspected the prompt, then the parser. The cause sat earlier than both — in the convention itself, that [ymin, xmin, ymax, xmax] comes back as integers.

0–1000 isn't a continuous range. It's a grid with a thousand divisions. On a photo 3024 pixels wide, one tick is roughly 3.02 pixels. The moment the model rounds a true edge to the nearest tick, each edge picks up an error of less than half a tick — and the rounding falls inward as often as it falls outward.

You can measure how much that costs without spending a single API call. Build a true box in pixel space, quantize it to 0–1000, convert it back, and compare.

"""How much does 0-1000 grid rounding cost a crop? No API key required."""
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 the recovered box fails to cover the true box, it is cutting content
        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} max={mx:.2f}px mean={mean:.2f}px clipped={clip:.2f}%")

Here is what it printed on my machine (20,000 boxes per condition, fixed seed).

Image sizePadding (ticks)Max edge errorMean edge errorBoxes that cut content
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%

The rightmost column is the one that caught me. Crop with no padding and, regardless of image size, more than nine boxes in ten shave content off at least one edge. The number is not mysterious: each of the four edges rounds inward with probability 1/2 independently, so the chance that at least one goes inward is 1 − (1/2)⁴ = 93.75%. The measured 93.77% lands right on top of the theory.

The amount lost is tiny — 0.22 pixels per edge on average at 1024 wide. Nobody notices it in a box drawn on screen. Thin targets are a different story. Crop a 28-pixel-tall band out of a 3024×4032 photo — about the height of one line on a receipt — and with no padding you lose 3.70% of the band's area on average and 13.82% in the worst case (same script, with bw pinned to 600 and bh to 28, measuring the area of the true box left uncovered). That is where the OCR misreads were coming from: the top edge of the digits, quietly gone.

The grid is not continuous, so when you crop, step one tick outward. One tick is enough. Rounding error is under half a tick on every edge by definition, so a single tick of padding drives the clipping to zero both in theory and in the measurement above.

def to_pixel_box_padded(box_2d: List[int], img_w: int, img_h: int,
                        pad_units: int = 1):
    """For cropping: widen by pad_units grid ticks before converting to pixels."""
    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)

Padding isn't free, though. On a 6000-pixel-wide image one tick is 6 pixels, and when you're cutting out products that sit shoulder to shoulder on a shelf, that slack is simply a slice of the neighbouring product. I split it by purpose — plain to_pixel_box for anything drawn on screen, to_pixel_box_padded for crops headed to OCR or a search backend, and for shelf photos where targets touch, a quick iou check against the neighbours before deciding how far to widen.

It's worth tying this back to smart_resize, which comes up later. Shrinking the long edge to 1024 before you send the image costs you nothing in coordinate precision, because the coordinates come back normalized — what it costs is the model's ability to see fine detail. The coarseness of the grid, meanwhile, is set by the original image, so whether or not you resized, the crop padding has to be reasoned about in the original image's pixels.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦If you've been frustrated by bogus coordinates from Gemini's spatial outputs, you'll walk away with prompts and parsers that produce reliable results
✦You'll see a 20,000-box measurement showing that unpadded crops clip content 93.77% of the time, plus the one-tick padding that drives it to 0%
✦You'll learn how to detect hallucinated coordinates and design automatic fallbacks to traditional CV models like YOLO
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

◈ API / SDK2026-07-15
Sample what you already accepted — an audit budget that catches silent quality drift
A confidence gate only ever looks at output the model hesitated on. Silent drift sinks into the batch that sailed through. Working from a fixed 30-minutes-a-day review budget, this walks through deriving detection time from the binomial, reallocating the same budget across risk strata, and catching slow decay with a cumulative monitor.
◈ API / SDK2026-07-04
When Gemini API Leaks Japanese Into Your English Output Once in a While — Field Notes on Measuring the Contamination Rate and Tightening It in Stages
You told Gemini to answer in English, and 3 out of 100 runs slip a Japanese sentence into the tail. Here is why you cannot stop that 'once in a while', and a production pattern that measures the contamination rate as an SLO and tightens it with graded recovery, with working code.
◈ API / SDK2026-05-20
Evolving Gemini API Structured Output Schemas in Production — Design Notes from an Indie App
How I rebuilt the JSON contract layer for a Gemini-powered recommendation feature in a long-running indie app — Dual-Emit, Sunset protocol, and a Python compatibility checker.