Why Does My GIF Map Animation Have Colour Banding?

On this page

Problem statement

A smooth colour ramp, such as satellite infrared or a temperature field, looks perfect in each PNG frame. Saved as an animated GIF it shows stepped bands where the gradient was smooth, and in some versions the bands shimmer between frames. GIF stores at most 256 colours per frame, so every frame has to be squeezed into a palette, and how that is done decides what the reader sees. This page reproduces the banding on a GOES-16 infrared loop, measures it, and compares the three fixes: dithering, one shared palette, and a different format.

Quick answer

Build one palette from all frames, quantise every frame to it, and decide deliberately whether to dither:

sample = Image.fromarray(np.concatenate([f[::4, ::4] for f in FRAMES]))          # every frame contributes
palette = sample.quantize(colors=256, method=Image.Quantize.MEDIANCUT)
gif_frames = [Image.fromarray(f).quantize(palette=palette, dither=Image.Dither.NONE) for f in FRAMES]
gif_frames[0].save("data/shared.gif", save_all=True, append_images=gif_frames[1:], duration=150, loop=0)
print(f"{len(gif_frames)} frames, one palette, {Path('data/shared.gif').stat().st_size / 1e6:.2f} MB")
24 frames, one palette, 2.44 MB
Magnified crops of a cloud edge over shaded relief from the Hurricane Beryl loop: the original, a GIF with a palette per frame, a GIF with one shared palette, and a shared palette with dithering, above amplified error maps; the shared-palette error shows contour-like band edges, the dithered one a speckle of noise. Magnified crops of a cloud edge over shaded relief from the Hurricane Beryl loop: the original, a GIF with a palette per frame, a GIF with one shared palette, and a shared palette with dithering, above amplified error maps; the shared-palette error shows contour-like band edges, the dithered one a speckle of noise.
The same crop of frame 13, original and as decoded from three GIFs, with the difference from the original multiplied by ten below. The shared palette's steps show up as contour lines; dithering turns them into noise.

Count colours, measure banding, check for shimmer

1. Count the colours you are asking GIF to keep

A single colour ramp is a path through RGB space, so it can only produce a few hundred distinct colours, and those fit a GIF palette almost exactly. Banding starts when colours combine: cloud blended over a shaded-relief basemap mixes two images into thousands of colours. Count both:

ramp = (plt.get_cmap("viridis")(np.clip((TEMPS[0] - 180) / 140, 0, 1))[..., :3] * 255).astype(np.uint8)
print(f"temperature on a viridis ramp: {len(np.unique(ramp.reshape(-1, 3), axis=0)):,} distinct colours")
for f in FRAMES[:1]:
    print(f"cloud over shaded relief: {len(np.unique(f.reshape(-1, 3), axis=0)):,} distinct colours; GIF allows 256")
print(f"all {len(FRAMES)} frames: {len(np.unique(np.concatenate([f.reshape(-1, 3) for f in FRAMES]), axis=0)):,} distinct colours")
temperature on a viridis ramp: 183 distinct colours
cloud over shaded relief: 9,621 distinct colours; GIF allows 256
all 24 frames: 18,560 distinct colours
Distinct colours in a single colour ramp, one blended frame and the whole loop, against GIF’s 256. how many colours does GIF have to drop?temperature on viridis183: fitsGIF palette256cloud over relief, 1 frame9,621all 24 frames18,560010k20kGOES-16 band 13 over Natural Earth shaded relief, 8 July 2024 spatialworkflow.io
A single colour ramp fits in a GIF palette. Cloud blended over relief does not, by a factor of almost forty.

2. Measure the banding

Save the frames three ways and read them back: Pillow's default (it picks a palette for each frame), one shared palette without dithering, and a shared palette with dithering. Compare each with the original frames:

def save(path, frames):
    frames[0].save(path, save_all=True, append_images=frames[1:], duration=150, loop=0)
    back = [np.asarray(f.convert("RGB")) for f in ImageSequence.Iterator(Image.open(path))]
    err = np.mean([np.abs(a.astype(int) - b.astype(int)).mean() for a, b in zip(FRAMES, back)])
    return back, Path(path).stat().st_size / 1e6, err

variants = {
    "per-frame palette (Pillow default)": [Image.fromarray(f) for f in FRAMES],
    "shared palette, no dither": gif_frames,
    "shared palette, dithered": [Image.fromarray(f).quantize(palette=palette, dither=Image.Dither.FLOYDSTEINBERG) for f in FRAMES],
}
BACK = {}
for name, frames in variants.items():
    BACK[name], mb, err = save(f"data/{len(BACK)}.gif", frames)
    print(f"{name:35s}: {mb:.2f} MB, mean error {err:.2f} of 255")
per-frame palette (Pillow default) : 3.51 MB, mean error 0.41 of 255
shared palette, no dither          : 2.44 MB, mean error 1.54 of 255
shared palette, dithered           : 3.39 MB, mean error 1.12 of 255
File size, mean error and shimmer of a 24-frame GOES loop saved as GIF three ways. three ways to fit 9,621 colours into 256sizemean errorshimmerper-frame palette!3.51 MBβœ“0.41βœ•1.42 (2.5Γ—)shared paletteβœ“2.44 MB!1.54βœ“0.57 (1.0Γ—)shared + dither!3.39 MB1.12βœ•2.51 (4.4Γ—)Shimmer: change in pixels whose temperature moved < 0.5 K, against 0.57 in the original frames spatialworkflow.io
No option wins on all three. For an animation, the shared palette is the only one that does not add shimmer.

3. Check for shimmer between frames

The per-frame palette has the smallest error in each frame, because every frame gets 256 colours of its own. That is also its problem in an animation: the band edges fall in different places in each frame, so pixels that did not change can change colour. Measure it on pixels whose brightness temperature moved less than 0.5 K:

for name, back in BACK.items():
    moved = [np.abs(b[1:].astype(int) - b[:-1].astype(int)).sum(axis=-1)[np.abs(np.diff(T, axis=0)) < 0.5].mean()
             for b, T in ((np.stack(back), np.stack(TEMPS)),)][0]
    print(f"{name:35s}: still pixels change by {moved:.2f} (sum of RGB steps) per frame")
orig = np.abs(np.diff(np.stack(FRAMES).astype(int), axis=0)).sum(axis=-1)[np.abs(np.diff(np.stack(TEMPS), axis=0)) < 0.5].mean()
print(f"{'original frames':35s}: {orig:.2f}")
per-frame palette (Pillow default) : still pixels change by 1.42 (sum of RGB steps) per frame
shared palette, no dither          : still pixels change by 0.57 (sum of RGB steps) per frame
shared palette, dithered           : still pixels change by 2.51 (sum of RGB steps) per frame
original frames                    : 0.57

The same GOES frames as WebP and MP4

The data used on this page

GOES-16 ABI band 13 (10.3 Β΅m) brightness temperature, CONUS sector, 06:00–11:45 UTC on 8 July 2024 (Hurricane Beryl), from the NOAA GOES archive on AWS, one scan per 15 minutes, cropped to 27–31.5Β° N, 98–92Β° W and drawn at twice the satellite resolution as cloud over the Natural Earth shaded relief with water (1:50m), reprojected to the satellite's grid.

import re
from pathlib import Path

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
import xarray as xr
import rasterio
from PIL import Image, ImageSequence
from rasterio.transform import from_origin
from rasterio.warp import Resampling, reproject
from scipy.ndimage import zoom
from pyproj import Proj

Path("data/goes").mkdir(parents=True, exist_ok=True)
B = "https://noaa-goes16.s3.amazonaws.com/"
keys = []
for h in range(6, 12):
    text = requests.get(B, params={"list-type": 2, "prefix": f"ABI-L2-CMIPC/2024/190/{h:02d}/OR_ABI-L2-CMIPC-M6C13"}, timeout=60).text
    keys += re.findall(r"<Key>([^<]+)</Key>", text)
slot = lambda k: pd.to_datetime(re.search(r"_s(\d{11})", k).group(1), format="%Y%j%H%M").floor("15min")
KEYS = list(pd.Series(keys).groupby([slot(k) for k in keys]).first())


def fetch(key):
    path = Path("data/goes", key.split("/")[-1])
    if not path.exists():
        path.write_bytes(requests.get(B + key, timeout=300).content)
    return path


with xr.open_dataset(fetch(KEYS[0]), engine="h5netcdf") as ds0:
    p = ds0.goes_imager_projection
    x, y = ds0.x.values * p.perspective_point_height, ds0.y.values * p.perspective_point_height
proj = Proj(proj="geos", h=p.perspective_point_height, lon_0=p.longitude_of_projection_origin, sweep=p.sweep_angle_axis,
            a=p.semi_major_axis, b=p.semi_minor_axis)
bx, by = proj([-98, -92, -98, -92], [27, 27, 31.5, 31.5])
IX, IY = (x >= min(bx)) & (x <= max(bx)), (y >= min(by)) & (y <= max(by))
def load(key):
    with xr.open_dataset(fetch(key), engine="h5netcdf") as ds:
        return ds.CMI.values[np.ix_(IY, IX)].astype(float)


TEMPS = [load(k) for k in KEYS]
TEMPS = [zoom(t, 2, order=1) for t in TEMPS]                               # drawn at 2x with bilinear smoothing
TEMPS = [t[: t.shape[0] // 2 * 2, : t.shape[1] // 2 * 2] for t in TEMPS]  # even sizes, for video
H, W = TEMPS[0].shape
res = (x[IX][1] - x[IX][0]) / 2
GRID = from_origin(x[IX].min() - res, y[IY].max() + res, res, res)
RELIEF = Path("data/NE1_50M_SR_W.zip")
if not RELIEF.exists():
    RELIEF.write_bytes(requests.get("https://naciscdn.org/naturalearth/50m/raster/NE1_50M_SR_W.zip", timeout=600).content)
base = np.zeros((3, H, W), np.uint8)
with rasterio.open(f"zip://{RELIEF}!NE1_50M_SR_W/NE1_50M_SR_W.tif") as r:
    for b in range(3):
        reproject(rasterio.band(r, b + 1), base[b], src_crs="EPSG:4326", dst_transform=GRID, dst_crs=proj.crs,
                  resampling=Resampling.bilinear)
BASE = np.moveaxis(base, 0, -1).astype(float)


def render(t):
    """Cloud over shaded relief: colder cloud tops are more opaque and whiter."""
    alpha = np.clip((295 - t) / 75, 0, 1)[..., None]
    cloud = (200 + 55 * np.clip((260 - t) / 60, 0, 1))[..., None]
    return (BASE * (1 - alpha) + cloud * alpha).astype(np.uint8)


FRAMES = [render(t) for t in TEMPS]
print(f"{len(FRAMES)} frames of {FRAMES[0].shape[1]} x {FRAMES[0].shape[0]} px")
24 frames of 610 x 416 px

Example 1: the same frames as WebP and MP4

import imageio
Image.fromarray(FRAMES[0]).save("data/frames.webp", save_all=True, append_images=[Image.fromarray(f) for f in FRAMES[1:]],
                                duration=150, loop=0, lossless=True)
with imageio.get_writer("data/frames.mp4", format="FFMPEG", fps=1000 / 150, codec="libx264", quality=8, macro_block_size=2) as w:
    for f in FRAMES:
        w.append_data(f)
for f in ("data/shared.gif", "data/frames.webp", "data/frames.mp4"):
    print(f"{f:18s}: {Path(f).stat().st_size / 1e6:.2f} MB")
data/shared.gif   : 2.44 MB
data/frames.webp  : 5.20 MB
data/frames.mp4   : 0.95 MB

What a palette does to a gradient

Banding is what a palette does to a gradient. Cloud thinning out over shaded relief mixes two images into nearly 10,000 colours per frame. The GIF palette offers 256, so neighbouring shades collapse onto the same palette entry and the gradient becomes a staircase; the error map shows its steps as contour lines. Dithering hides the steps by mixing palette colours in a pattern, trading bands for fine noise. Here it cut the mean error from 1.54 to 1.12 but made the file 39% larger, because noise does not compress.

In an animation there is a second problem. If each frame gets its own palette, the steps fall in different places from frame to frame, and pixels whose data did not change jump between palette entries: the bands shimmer. Here, unchanged pixels moved two and a half times as much as in the original frames. One palette built from all frames fixes the steps in place, and unchanged pixels moved exactly as much as in the original. Dithering brings the shimmer back, because the noise pattern is different in every frame. When the frames are genuinely smooth imagery, the better fix may be another format. MP4 stored these frames in 0.95 MB against the shared-palette GIF's 2.44 MB. Lossless WebP keeps every colour but took 5.20 MB.

Classified maps, thin lines and transparency

GIF banding problems and fixes. GIF palette problemsSymptomFixstepped bands in smooth cloudshared palette, or MP4bands shimmer between framesone palette for all framesdithered GIF shimmers and growsno dither for animationsnetCDF crash at exitengine="h5netcdf", close datasetsPillow, 24 frames, 610 Γ— 416 px spatialworkflow.io
GIF palette problems, and one environment problem met on the way.
  • Classified maps with a handful of flat colours do not band, and neither does a single colour ramp (183 colours here); GIF is fine for them.
  • Text and thin lines add antialiasing colours; a shared palette built from all frames keeps them consistent.
  • Palette size below 256 (e.g. 64 colours) shrinks files further and bands more; measure before choosing.
  • Transparency uses one palette entry; reserve it explicitly if frames have transparent areas.
  • Reading GOES files: in the environment used for this page, xarray's default netCDF4 engine aborted the interpreter at exit after reading these files (double free or corruption); engine="h5netcdf" with the datasets closed ran cleanly.
  • Tools differ: gifski and FFmpeg's palettegen/paletteuse make high-quality shared palettes and are worth trying for long loops.

FAQ

Why does my GIF have colour banding?

GIF can store only 256 colours per frame, so smooth gradients are reduced to steps. Use dithering, a better palette, or a format without the limit (WebP, MP4).

How do I make a GIF with one palette in Python?

Build a palette with Image.quantize() from a sample of all frames, then call frame.quantize(palette=palette) on each frame before saving.

Should I dither my GIF?

Dithering hides bands but adds noise, makes the file larger and makes animations shimmer. For a still image of smooth imagery try it; for animations prefer a shared palette without dithering.

Why do the colours in my GIF shimmer?

Each frame has its own palette, so the colour steps move between frames. Use one shared palette.