Rendering Rasters with Matplotlib

Most of what goes wrong when a satellite raster is plotted comes down to three defaults that are reasonable for a photograph and wrong for geospatial data: the axes are in pixels, the colour limits come from the array’s extremes, and the image is interpolated between samples. Fixing those three takes four arguments, and the result is a figure that can be trusted. This topic is part of Visualization, Tiling & Web Delivery.

Beyond the basics, the interesting decisions are about honesty: which stretch to apply, whether a colour ramp is implying structure the data does not have, and how to render a nodata region so nobody mistakes it for a value. Those decisions carry through to every other delivery path, because a tile server is doing the same arithmetic on a 256-pixel window.


Prerequisites

pip install "matplotlib>=3.8" "rasterio>=1.3.0" "numpy>=1.23"
Package Minimum version Why required
matplotlib 3.8 imshow, norms, colour maps, figure export
rasterio 1.3.0 Decimated masked reads and dataset bounds
numpy 1.23 Percentiles and array stacking

You need a raster whose nodata value is set correctly — the check is in extracting nodata and dtype from a GeoTIFF — and ideally one with overviews, so the decimated read in step one is cheap rather than merely correct.


Step-by-Step Workflow

Step 1 — Read only as many pixels as the figure can show

A figure has a fixed pixel budget: width in inches times dpi. Reading more than that is pure waste, and on a large scene it is the difference between a figure that renders instantly and one that exhausts memory.

import numpy as np
import rasterio


def read_for_figure(path: str, band: int = 1, *, target_px: int = 1600):
    """Read a decimated, masked band plus the map extent for plotting."""
    with rasterio.open(path) as src:
        factor = max(1, int(max(src.width, src.height) / target_px))
        arr = src.read(
            band,
            out_shape=(max(1, src.height // factor), max(1, src.width // factor)),
            masked=True,                       # nodata becomes a masked array
        )
        b = src.bounds
    return arr, (b.left, b.right, b.bottom, b.top)

masked=True is doing two jobs. It keeps the fill value out of the percentile calculation, and it makes matplotlib draw those cells as transparent rather than as the bottom of the colour ramp. Without it, a scene with a -9999 fill renders as a uniform block with one bright corner.

Step 2 — Derive display limits from the data

Why the default stretch renders nothing Reflectance values occupy a narrow band of the sixteen-bit range, roughly from three hundred to three thousand five hundred. Stretching across the whole dtype range compresses every real value into the darkest few per cent of the colour ramp. A second to ninety-eighth percentile stretch maps the actual data across the full ramp instead. Where the data actually lives in the dtype range p2 to p98 — the useful stretch the int16 range, which is what a naive renderer uses 0 32767 Everything real sits in the left tenth; stretched across the whole axis it is a black rectangle.
import numpy as np


def percentile_limits(arr: np.ma.MaskedArray, lower: float = 2.0,
                      upper: float = 98.0) -> tuple[float, float]:
    """Display limits from valid pixels only."""
    valid = arr.compressed() if np.ma.isMaskedArray(arr) else arr[np.isfinite(arr)]
    if valid.size == 0:
        raise ValueError("no valid pixels to derive a stretch from")
    return float(np.percentile(valid, lower)), float(np.percentile(valid, upper))

The choice of percentiles is a judgement about what to sacrifice. Two and ninety-eight clips a little detail at each end and gives good contrast across the body of the distribution; one and ninety-nine preserves more extremes at the cost of contrast. For a product where the extremes are the point — a burn severity map, say — use fixed limits chosen from the physics rather than percentiles from this particular scene, or two scenes of the same fire will be rendered on different scales.

Step 3 — Draw in map coordinates

import matplotlib.pyplot as plt


def plot_raster(path: str, band: int = 1, *, cmap: str = "viridis",
                title: str | None = None):
    arr, extent = read_for_figure(path, band)
    vmin, vmax = percentile_limits(arr)

    fig, ax = plt.subplots(figsize=(8, 8))
    im = ax.imshow(arr, extent=extent, cmap=cmap, vmin=vmin, vmax=vmax,
                   interpolation="nearest")           # never invent intermediate values
    ax.set_xlabel("Easting (m)")
    ax.set_ylabel("Northing (m)")
    ax.ticklabel_format(style="plain")                # no 1e6 offset on the axis
    if title:
        ax.set_title(title)
    cb = fig.colorbar(im, ax=ax, shrink=0.75, pad=0.02)
    cb.set_label("reflectance")
    return fig, ax

ticklabel_format(style="plain") is a small fix with a large payoff: by default matplotlib factors out a common offset and prints +3.9e5 under the axis, which turns readable UTM coordinates into a puzzle.

Step 4 — True-colour composites

A true-colour image is three bands stacked, each stretched on its own statistics. Stretching them together preserves the raw colour balance, which for satellite reflectance is dominated by atmospheric scattering in the blue.

import numpy as np
import rasterio


def true_colour(path: str, bands: tuple[int, int, int] = (3, 2, 1),
                *, target_px: int = 1600, lower: float = 2.0, upper: float = 98.0):
    """Stack red, green and blue with an independent stretch per band."""
    with rasterio.open(path) as src:
        factor = max(1, int(max(src.width, src.height) / target_px))
        shape = (max(1, src.height // factor), max(1, src.width // factor))
        planes = []
        for b in bands:
            a = src.read(b, out_shape=shape, masked=True).astype("float32")
            lo, hi = percentile_limits(a, lower, upper)
            planes.append(np.clip((a - lo) / max(hi - lo, 1e-6), 0, 1))
        extent = (src.bounds.left, src.bounds.right,
                  src.bounds.bottom, src.bounds.top)
    rgb = np.ma.dstack(planes)
    return rgb, extent

For false-colour composites the same function serves — pass near infrared, red and green as the band triple and vegetation renders red, which is the standard way to make crop structure legible. The band conventions differ per sensor, and getting them from band descriptions rather than positions avoids the usual mix-up, as discussed in reading and writing GDAL tags and band descriptions.

Step 5 — Export for the destination

fig.savefig("scene_ndvi.png", dpi=200, bbox_inches="tight", pad_inches=0.05)
fig.savefig("scene_ndvi.pdf", bbox_inches="tight")     # vector axes, raster image

A PDF keeps the axes, labels and colour bar as vectors while the image stays a raster, which is exactly what a publication wants. For a slide deck, a PNG at 150–200 dpi is enough; going higher only increases the file size, since the underlying array has no more detail to give.


Rendering Categorical Data

A class raster needs a legend, not a colour bar Drawn with a continuous colour ramp, a four-class raster implies that class three is between class two and class four in some quantity, and the colour bar offers meaningless intermediate values. Drawn with a discrete palette and a legend of labelled patches, each class is distinct and nothing is implied about ordering. continuous ramp + colour bar discrete palette + legend 3.5? 2.2? implies an ordering the classes do not have built forest water each class distinct, nothing between them Use ListedColormap with BoundaryNorm, and build the legend from the class table. Better still, read the palette from the raster's own colour table so every renderer agrees.
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.colors import BoundaryNorm, ListedColormap
from matplotlib.patches import Patch

CLASSES = {1: ("forest", "#267300"), 2: ("cropland", "#a8cc54"),
           3: ("built", "#c4281b"), 4: ("water", "#1c5ca8")}


def plot_classes(arr, extent):
    codes = sorted(CLASSES)
    cmap = ListedColormap([CLASSES[c][1] for c in codes])
    norm = BoundaryNorm([codes[0] - 0.5] + [c + 0.5 for c in codes], cmap.N)

    fig, ax = plt.subplots(figsize=(8, 8))
    ax.imshow(np.ma.masked_equal(arr, 0), extent=extent, cmap=cmap, norm=norm,
              interpolation="nearest")
    ax.legend(handles=[Patch(facecolor=CLASSES[c][1], label=CLASSES[c][0])
                       for c in codes],
              loc="lower right", framealpha=0.9)
    return fig, ax

BoundaryNorm with half-integer edges is what pins each class code to exactly one colour; without it, a four-class raster drawn with a four-colour map still interpolates at the boundaries when the figure is resampled. Masking class 0 rather than colouring it keeps unclassified area transparent, which is almost always what you want over a basemap.


Diverging Data and the Zero Problem

Change images, anomalies and differences all share a property that trips up naive rendering: the sign matters as much as the magnitude. A field that gained vegetation and one that lost it are opposite results, and a sequential ramp renders them as merely different shades.

Pinning the neutral colour to zero A difference image ranges from minus zero point two to plus zero point six. A diverging ramp stretched linearly across that range puts its neutral colour at plus zero point two, so mild gains render as losses. Using a norm that pins the neutral colour to zero keeps the sign readable, at the cost of an asymmetric ramp. Where the neutral colour sits decides what the map says naive zero is here but the ramp turns neutral here TwoSlopeNorm zero sits on the neutral colour -0.2 +0.6 The asymmetry is the honest choice: the data is asymmetric.
import matplotlib.pyplot as plt
from matplotlib.colors import TwoSlopeNorm

lo, hi = percentile_limits(diff, 2, 98)
norm = TwoSlopeNorm(vmin=min(lo, -1e-6), vcenter=0.0, vmax=max(hi, 1e-6))

fig, ax = plt.subplots(figsize=(8, 8))
im = ax.imshow(diff, extent=extent, cmap="RdBu", norm=norm, interpolation="nearest")
fig.colorbar(im, ax=ax, shrink=0.75).set_label("NDVI change")

TwoSlopeNorm maps the negative side and the positive side with different slopes so the centre lands exactly on zero. The colour bar then shows an uneven spacing, which looks odd the first time and is exactly right: the data really does extend further in one direction, and hiding that by symmetrising the range would either clip real change or waste half the ramp on values that never occur.

The alternative — forcing a symmetric range around zero — is defensible when several images must be compared on one scale, because then every map shares a legend. Choose deliberately, state which you used, and never let the choice vary between figures in the same document. The underlying arithmetic these figures render is covered in computing NDVI difference between two dates.


Parameter Reference

Parameter Type Default Usage note
out_shape tuple full size Size it to the figure; uses overviews when present
masked bool False True keeps nodata out of statistics and renders it transparent
extent tuple None (left, right, bottom, top) from src.bounds; fixes axes and orientation
interpolation str antialiased nearest for anything where invented values would mislead
vmin / vmax float data range Always set explicitly from percentiles or physics
cmap str or object viridis Sequential, diverging or ListedColormap per the data structure
norm object None TwoSlopeNorm for diverging, BoundaryNorm for categorical
dpi (savefig) int 100 150–200 for slides, 300 for print; beyond that adds bytes, not detail

Annotation That Earns Its Place

A figure destined for anything other than a quick check needs three annotations, and rarely more: a scale bar, a north arrow where the orientation is not obvious, and a note of the source and date. Each answers a question a reader will otherwise ask.

from matplotlib.patches import Rectangle


def add_scale_bar(ax, length_m: float = 5000, *, label: str = "5 km") -> None:
    """A simple scale bar in data coordinates, anchored bottom-left."""
    x0, x1 = ax.get_xlim()
    y0, y1 = ax.get_ylim()
    pad_x, pad_y = 0.06 * (x1 - x0), 0.06 * (y1 - y0)
    bx, by = x0 + pad_x, y0 + pad_y

    ax.add_patch(Rectangle((bx, by), length_m, 0.008 * (y1 - y0),
                           facecolor="#0f3460", edgecolor="white", linewidth=0.8))
    ax.text(bx + length_m / 2, by + 0.018 * (y1 - y0), label,
            ha="center", va="bottom", fontsize=9, color="#0f3460")

The scale bar works in data coordinates, which is only meaningful because the axes are in map units — another payoff from passing extent in step three. In a geographic CRS the same code would be wrong, since a degree is not a fixed distance; reproject to a projected CRS before drawing anything with a scale bar on it.

What to leave out matters as much. Gridlines over imagery obscure the data and rarely help; a colour bar on a true-colour composite means nothing, since there is no single quantity being shown; and a title repeating what the caption already says wastes the space a legend could use. The discipline is the same one that applies to the data itself: every element should answer a question someone actually has.


Verification & Testing

The useful checks are visual, but three of them can be automated.

import numpy as np

arr, extent = read_for_figure("scene.tif")
assert extent[0] < extent[1] and extent[2] < extent[3], "extent order wrong"

valid = arr.compressed()
assert valid.size > 0.05 * arr.size, "over 95% of the scene is nodata"

vmin, vmax = percentile_limits(arr)
assert vmax > vmin, "degenerate stretch: the band may be constant"
spread = (vmax - vmin) / max(abs(vmax), 1e-6)
assert spread > 0.01, "stretch is extremely narrow — check the nodata value"

The narrow-stretch assertion catches the commonest silent failure: a fill value that was not declared, so it survives into compressed() and drags one percentile to an extreme. If it fires, print the unique values at the tails and the culprit will be obvious.

For a human check, render a quicklook of every product in a batch and view them as a contact sheet, as described on the section overview. Defects that no assertion catches — an inverted class map, a diagonal seam, a scene that is half cloud — are instantly visible in a grid of thumbnails.


Troubleshooting

The image is upside down

extent was passed with top and bottom swapped, or origin="lower" was set without adjusting the extent. Take the order straight from src.bounds as (left, right, bottom, top) and leave origin alone.

The whole figure is one flat colour

Either the band is genuinely constant, or a fill value is dominating the stretch. Read with masked=True and check arr.count() against arr.size.

Axis labels show +3.9e5

Matplotlib factored out an offset. Call ax.ticklabel_format(style="plain"), and consider dividing coordinates by 1000 and labelling in kilometres for a cleaner axis.

The figure takes minutes to save

The full-resolution array reached imshow. Read with out_shape sized to the figure; a 10,980-pixel scene in an 8-inch figure is 99% wasted work.

Colours differ between two scenes of the same product

Percentile limits were computed per scene. For a comparable series, fix the limits once — from a representative scene or from the physics — and reuse them everywhere, exactly as a tile service must.


Frequently Asked Questions

Q: Why is my raster upside down in matplotlib? Because imshow puts row zero at the top while a north-up raster has its highest northing in row zero. Passing extent with bottom and top in the right order fixes both the orientation and the axis labels; setting origin=‘lower’ without swapping the extent flips the image without fixing the coordinates.

Q: How do I make a true-colour composite that does not look washed out? Stretch each band independently on its own percentiles, then stack. A single stretch applied to all three bands preserves the colour balance of the raw data, which for satellite reflectance is heavily blue-biased by atmospheric scattering.

Q: Should I plot the full resolution raster? Never. A figure eight inches wide at 150 dpi shows 1,200 pixels, so reading a 10,980 pixel scene wastes almost all the work and the memory. Read with out_shape sized to the figure and let the overviews do the decimation.

Q: Can I overlay vector data on the same axes? Yes, and it works cleanly as long as both are in the same CRS. Reproject the vector layer to the raster’s CRS first, then plot it onto the same axes — the extent is already in map coordinates, so the geometries land in the right place without further work.


Deep-Dive Articles