Choosing Colour Ramps for Continuous Raster Data
Classify the data first, then pick a ramp that matches it:
import matplotlib.pyplot as plt
# Sequential: a quantity with a low and a high, no special middle
ax.imshow(ndvi, cmap="YlGn", vmin=0.0, vmax=0.9)
# Diverging: a quantity with a meaningful zero
from matplotlib.colors import TwoSlopeNorm
ax.imshow(ndvi_change, cmap="RdBu", norm=TwoSlopeNorm(vmin=-0.4, vcenter=0.0, vmax=0.6))
The choice is not decorative: a ramp that is not monotonic in lightness invents structure the data does not contain. This page belongs to rendering rasters with matplotlib in Visualization, Tiling & Web Delivery.
Perceptual Uniformity, and What Breaks Without It
A perceptually uniform ramp increases steadily in lightness from one end to the other, so equal steps in data value produce equal apparent steps in colour. A rainbow ramp does not: it has bright yellow in the middle and darker colours at both ends, which makes the middle of the range look like a boundary and compresses detail at the extremes.
The consequence for remote sensing is concrete. An NDVI map in a rainbow ramp shows a hard line at the yellow transition that readers interpret as a real vegetation boundary, and two genuinely different values — one on each side of the peak — render at the same lightness, so a greyscale printout collapses them.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
matplotlib |
>=3.8 |
Colour maps, norms and the rendering |
numpy |
>=1.23 |
Luminance conversion for the greyscale test |
pip install "matplotlib>=3.8" "numpy>=1.23"
Complete Working Example
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.colors import TwoSlopeNorm
SEQUENTIAL = {"ndvi": "YlGn", "elevation": "cividis", "reflectance": "gray",
"temperature": "magma", "precipitation": "Blues"}
DIVERGING = {"change": "RdBu", "anomaly": "BrBG", "difference": "PuOr"}
def render(arr, extent, *, kind: str, vmin: float, vmax: float,
diverging: bool = False, label: str = ""):
"""Render a continuous raster with a ramp appropriate to its structure."""
if diverging:
if not (vmin < 0 < vmax):
raise ValueError("a diverging ramp needs values on both sides of zero")
cmap = DIVERGING.get(kind, "RdBu")
norm = TwoSlopeNorm(vmin=vmin, vcenter=0.0, vmax=vmax)
kwargs = {"cmap": cmap, "norm": norm}
else:
kwargs = {"cmap": SEQUENTIAL.get(kind, "viridis"), "vmin": vmin, "vmax": vmax}
fig, ax = plt.subplots(figsize=(8, 8))
im = ax.imshow(arr, extent=extent, interpolation="nearest", **kwargs)
ax.set_aspect("equal")
ax.ticklabel_format(style="plain")
fig.colorbar(im, ax=ax, shrink=0.75, pad=0.02).set_label(label or kind)
return fig, ax
def greyscale_test(fig) -> np.ndarray:
"""Render the figure and return its luminance, for the legibility check."""
fig.canvas.draw()
rgba = np.asarray(fig.canvas.buffer_rgba(), dtype="float32") / 255.0
return (0.2126 * rgba[..., 0] + 0.7152 * rgba[..., 1] + 0.0722 * rgba[..., 2])
The vmin < 0 < vmax guard is worth having because a diverging ramp applied to strictly positive data is a common and confusing mistake: half the ramp goes unused, and readers who know the convention will assume the light end means negative values.
Ramps for Specific Products
The greyscale row deserves a defence, since a grey image looks unfinished next to a coloured one. For a single reflectance band there is exactly one quantity, and greyscale represents it with maximum fidelity and no implied categories. Adding a colour map to it is decoration that costs interpretability, and it is the reason so many published band images have an inexplicable orange cast.
Where colour genuinely earns its place is in distinguishing a quantity from its surroundings — an NDVI layer over a true-colour basemap, for instance — or in making a diverging quantity’s sign readable at a glance. Those are the situations the ramps above are for.
Verification
import numpy as np
lum = greyscale_test(fig)
inner = lum[80:-80, 80:-80] # crop away the axes and colour bar
contrast = float(inner.max() - inner.min())
assert contrast > 0.35, "structure disappears in greyscale — the ramp is not uniform"
print(f"greyscale contrast: {contrast:.2f}")
Running the greyscale test in code rather than by eye makes it a gate rather than an intention. A perceptually uniform ramp applied to data with real structure gives a luminance range well above 0.5; a rainbow ramp on the same data typically gives under 0.3, because its light and dark ends both map to mid-grey.
A second check worth doing once per project is to simulate a colour vision deficiency on a rendered figure, which several small libraries do in a line. Around one in twelve men has some form of red-green deficiency, and a ramp that fails for them fails for a meaningful share of any audience.
Common Errors
The diverging ramp’s neutral colour is not at zero
A plain vmin/vmax was used instead of a norm. Use TwoSlopeNorm with vcenter=0.0, and accept the asymmetric colour bar that results.
The map looks banded
The colour map was given too few levels, or BoundaryNorm was applied to continuous data. Use the continuous ramp directly unless the data really is categorical.
Two figures of the same product look different
Different vmin/vmax were derived per scene. Fix the limits for the collection, exactly as for a composite stretch.
Frequently Asked Questions
Q: What is wrong with a rainbow colour ramp? It is not perceptually uniform, so equal steps in value look like very different steps in colour, and the sharp yellow-to-green transition creates an apparent boundary where the data is smooth. It also collapses for viewers with a colour vision deficiency, who see much of it as one band.
Q: How do I test a ramp quickly? Render the image, convert it to luminance, and look at it. If the structure is still legible, the ramp is monotonic in lightness and will survive printing, projection and colour-blind viewers. If it dissolves into bands, choose another ramp.
Q: Should elevation use a terrain colour scheme? Only when the green-to-brown convention genuinely helps a reader, and never for a quantity that is not elevation. Terrain ramps are not perceptually uniform and their colours imply land cover, so using one for rainfall or temperature actively misleads.
Q: Does the same advice apply to tile services? Yes, and it matters more there because a tile URL’s colour map parameter is often chosen once and then used by everyone. Pick the ramp with the same care as for a figure, record it next to the product, and use the identical name in both places so the map and the report agree.
Related
- Rendering Rasters with Matplotlib — stretches, categorical palettes and figure export.
- Building True Colour Composites with Contrast Stretch — the three-band case where no ramp is involved.
- Adding Dynamic Rescaling and Colormaps to Tile URLs — the same choice expressed as a URL parameter.
- Computing NDVI Difference between Two Dates — the product that most needs a diverging ramp.