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.

5 min readSume
All posts

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.

Gray-world step reference, Pillow calls read 2026-10-05
StepCallWhat it returns
Per-channel averageImageStat.Stat(img).meanList of three floats for an RGB image
Apply a tableimg.point(table)New image, 768 entries for RGB
Convert firstimg.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

All Developers posts

Written by Sume