Match white balance across two AI product photos with Pillow
Two image models gave your product photos different color casts. Fix both with a gray-world gain in Pillow, check the channel means, and know when it fails.

Compute each image's mean red, green, and blue with ImageStat, scale each channel so the three means meet at their average, and apply the result with Image.point. That is a gray-world white balance, and it takes about ten lines. It pulls two product photos from different models toward the same neutral, but it assumes the scene averages to gray, so it can wreck a scene dominated by one color.
Why two models disagree on color
Mixing models in a catalog is common: one for hero shots, a cheaper one for variants. Each has its own tendency, such as a slightly warm cast or a cool shadow. Side by side on a product page the difference reads as inconsistency, even when each photo looks fine alone.
A correction after generation is cheaper than regenerating. A re-run costs a full image again, while the Pillow step costs nothing.
The gain function
The mean of each channel is the evidence. If red averages 150, green 120, and blue 100, the overall mean is about 123, so red gets a gain below 1, green about 1.03, and blue about 1.23. Image.point takes a table of 256 values per band, concatenated, which is why the list below has 768 entries.
import os, io, requests
from PIL import Image
H = {"Authorization": "Bearer " + os.environ["SUME_API_KEY"]}
def gen(**body):
r = requests.post("https://api.sume.com/v1/images", json=body, headers=H, timeout=60)
r.raise_for_status()
if r.status_code == 202:
raise SystemExit("queued, read /v1/jobs/{id}/result: " + r.text)
return r.json()
def fetch(u):
return Image.open(io.BytesIO(requests.get(u, timeout=60).content))
from PIL import ImageStat
def gray_world(img):
r, g, b = ImageStat.Stat(img).mean
k = (r + g + b) / 3
gains = (k / r, k / g, k / b)
table = [min(255, round(i * gains[c])) for c in range(3) for i in range(256)]
return img.point(table)
P = "White ceramic planter with a small cactus on a wooden shelf, studio light"
for name, model in (("a", "google/nano-banana-2"), ("b", "black-forest-labs/flux.2-pro")):
im = fetch(gen(model=model, prompt=P)["data"][0]["url"]).convert("RGB")
fixed = gray_world(im)
print(name, [round(x) for x in ImageStat.Stat(im).mean], "->",
[round(x) for x in ImageStat.Stat(fixed).mean])
fixed.save(name + "_balanced.png")What to check afterward
The printed channel means after correction should sit within a point or two of each other. If they do not, a channel clipped at 255 and the gain could not push it further.
| Step | Call | What it returns |
|---|---|---|
| Per-channel average | ImageStat.Stat(img).mean | List of three floats for an RGB image |
| Apply a table | img.point(table) | New image, 768 entries for RGB |
| Convert first | img.convert('RGB') | Drops alpha and palette modes |
When gray-world fails
- A scene that is mostly one color, such as a red sofa or a green lawn, is not average gray. The gain will push it toward gray and drain the color you wanted.
- Warm product lighting is sometimes intentional. Apply the fix only when the brief calls for a neutral studio look.
- If a photo has a known white or gray object, sample that patch instead of the whole frame.
Pair it with a color check
After balancing, measure the result against your brand colors using the delta E check, and keep the balanced files as separate outputs so the originals stay available for the regression test. Request PNG or JPEG output consistently, since a different encoder can shift tones slightly.
Sources
Related posts
More in Developers
- max_spend_usd is optional on Sume MCP: send it on every paid call
Sume enforces max_spend_usd only when you send it. Make your coding agent send it with dry_run and an idempotency_key. Wallet admission is the real gate.
- MCP 2026 roadmap lists audit trails: what Sume records for agents
The MCP 2026 roadmap names audit trails as an enterprise priority. Here is what Sume records for an agent's paid work today: jobs, receipts, the usage ledger.
- RFC 9207 iss validation in the 2026-07-28 MCP spec, and Sume OAuth
The 2026-07-28 MCP spec adds RFC 9207 iss validation (SEP-2468). Sume's OAuth issuer is the MCP origin, so a client should expect mcp.sume.com and nothing else.
- MCP SEP-2352: credentials bound to issuer, re-register on a new host
MCP SEP-2352 binds client credentials to the issuer and deprecates DCR for Client ID Metadata Documents. Re-register if the Sume host changes.
Written by Sume