For a few months I was happily uploading iPhone photos straight from disk to the Gemini API for a small product-cataloging side project. Every request took eight to twelve seconds. When I finally checked usage_metadata, every single image was eating about 1,500 tokens.
After adding a single Pillow resize step in front of the API call, the same accuracy now arrives in two to three seconds, and the per-image token count dropped to roughly 258. This article walks through why that happens and the helper function I have settled on.
Images are billed by tile, not by file
The first thing worth knowing is that Gemini does not bill you for "an image." It bills you for the tiles the image gets split into.
According to Google's documentation, Gemini 2.5 models split an input image into 768×768-pixel tiles and count each tile as roughly 258 tokens. A 1024×1024 image therefore lands at four tiles, while an unprocessed 4032×3024 iPhone photo sits at around 24 tiles. That single iPhone photo will quietly burn 6,000+ tokens just on the visual side of the request.
A 1024-pixel longest-edge thumbnail brings the count to 258 to 1,032 tokens — usually with no measurable accuracy loss for product recognition or scene understanding tasks.
Here is what the math looked like in my project before and after the change:
- Before: 4032×3024 raw photos, 24 tiles, ~6,200 tokens per image, average response 9.2s
- After: 1024px longest edge, 4 tiles, ~1,032 tokens per image, average response 2.7s
- Monthly bill: roughly one fifth of the previous number
If you are building a more comprehensive cost-cutting plan, the complete cost optimization guide covers caching strategies in depth. Image preprocessing is the simpler win that I think belongs before you reach for caching. Caching only helps when the same input repeats, but resizing helps every single request.
Why client-side resizing matters
Yes, the API also resizes oversized images on the server side, but only as a fallback. Doing it yourself first wins on three fronts.
- Token usage falls in step with tile count. Halve the longest edge and you typically quarter the tile count.
- The upload itself becomes faster. A multi-megabyte iPhone JPEG vs. a 200KB resized JPEG is a two-to-three-second difference on most home connections.
- The model's preprocessing time also drops. Fewer tiles mean less work for the vision encoder before the language model takes over.
There is a fourth, less obvious benefit: stable bills. When you let the server decide how to downsize, you cannot reliably predict how many tokens any given request will cost. When you resize on the client, the upper bound is fixed by your max_side value. That predictability is what made me comfortable pricing a small product against the Gemini API in the first place.
The helper function I reuse everywhere
Pillow's thumbnail() keeps the aspect ratio while constraining the longest edge to whatever you ask for. That makes it a better fit than resize() for this job, because you don't have to compute the new dimensions yourself.
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]:
"""Resize and re-encode an image for the Gemini API.
- Longest edge becomes max_side (aspect ratio preserved)
- Re-encoded as JPEG (transparency is dropped)
- Returns (bytes, mime_type)
"""
with Image.open(src_path) as img:
# Honor EXIF orientation — Gemini does not read EXIF tags
img = ImageOps.exif_transpose(img)
# JPEG cannot store alpha or palette modes
if img.mode != "RGB":
img = img.convert("RGB")
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"
# Usage
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}},
"Return the product name and an estimated price as JSON.",
],
)
print(resp.usage_metadata)
# Example: prompt_token_count=292 (image 258 + text 34)A note on the EXIF handling: Gemini does not read EXIF orientation tags, so a photo taken in landscape might be interpreted as portrait if you skip ImageOps.exif_transpose(). I tripped over this once and the model started misreading product labels because everything was rotated 90 degrees. The fix is one line, and it is the kind of bug you only catch on real photos shot from a phone.
Image.Resampling.LANCZOS is the Pillow 9.1.0+ spelling. On older Pillow, use Image.LANCZOS instead.
How small can you go before accuracy drops?
Push the size too low and recognition accuracy tanks. Across roughly 200 product images I tested, here is what worked for different jobs.
- Product labels and OCR: keep the longest edge at 1280px or above so fine print survives
- Object recognition and category classification: 1024px is plenty
- General scene understanding: 768px is usually fine
- Face features for privacy-safe use cases: stay at 1280px or above
For OCR specifically, I get even better stability by cropping the text region first (using pytesseract to find a rough bounding box) and then resizing only that region. Sending a tightly cropped 1024×400 strip of just the label is wildly cheaper than sending the entire bottle, and the recognition accuracy is usually higher because the model is not distracted by the background.
If your image is being rejected entirely rather than coming back with low accuracy, the diagnosis guide for "I cannot see the image" responses covers mime_type and orientation problems that look identical at first glance.
A note on JPEG quality
I use quality=85 as the default in prepare_image_for_gemini. Below that, JPEG artifacts start to be visible enough that fine OCR drops in accuracy. Above 90, the byte size grows quickly with no measurable accuracy gain on Gemini's vision encoder. If you store the originals somewhere else and only need the thumbnail to send to Gemini, 85 is the sweet spot.
For non-photographic content — screenshots of dashboards, line drawings, or charts with sharp edges — JPEG can introduce ringing artifacts. In those cases I switch to PNG and skip the convert("RGB") step:
img.save(buf, format="PNG", optimize=True)
return buf.getvalue(), "image/png"PNGs are larger byte-wise but they round-trip text and lines cleanly. For mixed content like a UI screenshot that includes both photos and crisp text, I usually still go with JPEG at quality 90 — the photographic regions need fewer bytes, and the text regions remain readable enough for Gemini's vision encoder. PNG only wins when the image is mostly sharp edges.
Resizing before upload, not on the server
A subtle point worth spelling out: even though Gemini will downscale oversized images server-side, that does not save you tokens. The token count is computed after the server normalizes the image to its tile grid. What server-side resizing prevents is hitting hard limits — it is not a billing safeguard. So if you are reading the docs and thinking "Gemini will figure it out," you are right about the technical handling and wrong about the money.
The only way to bring the tile count down is to give the API an already-small image. That is the entire reason this article exists.
There is also a quieter benefit for batch jobs: when you process hundreds of images in a tight loop, the savings compound. A pipeline that took three hours and $4 of API spend for me ran in 40 minutes for under $1 once every image went through prepare_image_for_gemini first. If your project does any kind of batch enrichment — categorizing inventory, captioning a photo backup, transcribing receipt scans — this is where the impact is largest.
File API vs. inline_data
Anything above 20MB has to go through the File API rather than inline_data. In practice, once you're shrinking down to 1024px the inline path is almost always sufficient. The cases where I still pick the File API:
- I'm reusing the same image across multiple requests (the File API caches it for 48 hours)
- The asset is a video or large PDF that simply does not fit inline
For one-off image recognition, inline saves a round trip and feels faster. The File API also adds a small upload step before you can run the actual generate_content, which doubles the perceived latency for single-image work.
If you want to actually see those token numbers per request, the usage_metadata logging pattern pairs well with this preprocessing step — the moment you have both, you can attribute every dollar in your monthly invoice to a specific endpoint or input.
A small benchmark you can run yourself
If you want to verify these numbers before you trust them, here is a five-line check that costs about one cent of API quota:
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}}, "Describe."],
)
print(size, len(img_bytes), resp.usage_metadata.prompt_token_count)Run that against one of your real input photos and you will see the token count drop in clean steps. That feedback loop is what convinced me to add resizing as the default for every multimodal endpoint I write.
What to try next
If a project of yours still pipes raw camera photos straight into Gemini, drop prepare_image_for_gemini in front of every call this week and re-run a representative sample. Compare the prompt_token_count value before and after — that single number tells you most of what you need to know. The next monthly invoice will quietly confirm the rest. For me, it was the highest-leverage refactor I made all year.