Exporting Publication Quality Map Figures
Size the figure to its destination, read only the pixels that fit, and export with fonts embedded:
import matplotlib as mpl
mpl.rcParams["pdf.fonttype"] = 42 # embed TrueType, do not reference
mpl.rcParams["savefig.bbox"] = "tight"
fig.set_size_inches(3.5, 3.5) # a single journal column
fig.savefig("figure3.pdf") # vector axes, raster image
fig.savefig("figure3.png", dpi=300) # raster fallback
Almost every “the figure looks wrong in the published version” problem is one of those three lines. This page belongs to rendering rasters with matplotlib in Visualization, Tiling & Web Delivery.
The Pixel Budget
A figure can only show as many raster pixels as its printed size and resolution allow. Exceeding that budget wastes memory and file size; falling short of it produces a visibly soft image.
The practical rule is to compute target_px = width_inches * dpi and pass that to the decimated read described in plotting a GeoTIFF with correct extent and axes. Reading a little more than the budget — say 1.5 times — gives some headroom for cropping without waste.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
matplotlib |
>=3.8 |
Figure sizing, annotation, vector and raster export |
rasterio |
>=1.3.0 |
Decimated reads sized to the pixel budget |
numpy |
>=1.23 |
Stretch arithmetic |
pip install "matplotlib>=3.8" "rasterio>=1.3.0" "numpy>=1.23"
Complete Working Example
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import rasterio
from matplotlib.patches import Rectangle
mpl.rcParams.update({
"pdf.fonttype": 42, # embed TrueType fonts in PDF
"ps.fonttype": 42,
"font.size": 8, # journal figures are small; 8-9 pt reads well
"axes.linewidth": 0.6,
"savefig.bbox": "tight",
"savefig.pad_inches": 0.02,
})
def publication_figure(path: str, out_stem: str, *, width_in: float = 3.5,
dpi: int = 300, cmap: str = "YlGn",
limits: tuple[float, float] | None = None,
scale_km: float = 5.0):
target_px = int(width_in * dpi * 1.5) # a little headroom
with rasterio.open(path) as src:
factor = max(1, int(max(src.width, src.height) / target_px))
arr = src.read(1, out_shape=(max(1, src.height // factor),
max(1, src.width // factor)), masked=True)
b = src.bounds
extent = (b.left / 1000, b.right / 1000, b.bottom / 1000, b.top / 1000)
vmin, vmax = limits or np.percentile(arr.compressed(), [2, 98])
fig, ax = plt.subplots(figsize=(width_in, width_in))
im = ax.imshow(arr, extent=extent, cmap=cmap, vmin=vmin, vmax=vmax,
interpolation="nearest")
ax.set_aspect("equal")
ax.ticklabel_format(style="plain")
ax.set_xlabel("Easting (km)")
ax.set_ylabel("Northing (km)")
# Scale bar in data coordinates (km), anchored bottom-left
x0, x1 = ax.get_xlim()
y0, y1 = ax.get_ylim()
bx, by = x0 + 0.07 * (x1 - x0), y0 + 0.07 * (y1 - y0)
ax.add_patch(Rectangle((bx, by), scale_km, 0.008 * (y1 - y0),
facecolor="#0f3460", edgecolor="white", linewidth=0.5))
ax.text(bx + scale_km / 2, by + 0.02 * (y1 - y0), f"{scale_km:g} km",
ha="center", va="bottom", fontsize=7)
# North arrow: the raster is north-up, so this is simply "up"
ax.annotate("N", xy=(0.94, 0.94), xytext=(0.94, 0.86),
xycoords="axes fraction", textcoords="axes fraction",
ha="center", fontsize=8, fontweight="bold",
arrowprops=dict(arrowstyle="-|>", color="#0f3460", linewidth=0.8))
cb = fig.colorbar(im, ax=ax, shrink=0.7, pad=0.02, fraction=0.045)
cb.outline.set_linewidth(0.6)
cb.set_label("NDVI")
fig.savefig(f"{out_stem}.pdf")
fig.savefig(f"{out_stem}.png", dpi=dpi)
plt.close(fig)
Setting font.size to 8 before creating the figure is more important than it looks. Matplotlib’s default of 10 points is sized for a figure viewed at its nominal size; a 3.5-inch figure scaled into a column keeps its physical text size, so the defaults are usually right — but a figure created at 8 inches and shrunk to 3.5 has text at less than half the intended size, which is the commonest reason published map figures are unreadable.
Multi-Panel Layouts
import matplotlib.pyplot as plt
fig, axes = plt.subplots(2, 2, figsize=(7, 6), sharex=True, sharey=True,
constrained_layout=True)
for ax, (label, arr) in zip(axes.ravel(), panels.items()):
im = ax.imshow(arr, extent=extent, cmap="YlGn", vmin=0.0, vmax=0.9,
interpolation="nearest")
ax.set_title(label, fontsize=8, loc="left")
ax.set_aspect("equal")
fig.colorbar(im, ax=axes, shrink=0.8, label="NDVI") # one bar for the grid
sharex and sharey remove duplicate tick labels from the interior panels, and constrained_layout handles the spacing so the shared colour bar does not overlap anything. Passing ax=axes — the whole array rather than a single axes — is what makes one bar serve the grid.
Sharing vmin and vmax across the panels is not a convenience but a requirement: a grid of dates rendered on independent scales shows differences in stretch that readers will interpret as differences on the ground, which is the same failure described for split maps in comparing two dates with a split map.
Verification
from pathlib import Path
import matplotlib.pyplot as plt
for stem in ("figure3",):
pdf, png = Path(f"{stem}.pdf"), Path(f"{stem}.png")
assert pdf.exists() and png.exists()
assert pdf.stat().st_size < 8_000_000, "PDF too large — is the array decimated?"
print(stem, f"{pdf.stat().st_size/1e6:.2f} MB pdf,",
f"{png.stat().st_size/1e6:.2f} MB png")
The size bound is a proxy for the mistake that matters: a PDF of tens of megabytes almost always means the full-resolution array was embedded, which no journal will accept and no reader benefits from.
The other check is physical. Print the figure at its intended size, or view the PDF at 100%, and read the smallest label. If it is uncomfortable at arm’s length it will be illegible in a printed column, and the fix is a larger base font rather than a larger figure.
Common Errors
Text is tiny in the published version
The figure was created large and scaled down. Create it at the final size and set the font size explicitly.
The PDF is enormous
The full-resolution raster was embedded. Decimate the read to the pixel budget before plotting.
Fonts differ on another machine
pdf.fonttype was left at its default of 3, which references Type 3 fonts. Set it to 42 to embed TrueType.
Frequently Asked Questions
Q: What dpi should a map figure be exported at? 300 for print, 150 to 200 for slides and web. Exporting higher than the underlying array supports adds file size and no detail, so check the pixel budget first: figure width in inches times dpi is how many raster pixels the figure can actually show.
Q: PDF or PNG for a journal figure? PDF, because the axes, labels and colour bar stay as vectors while the image itself remains a raster. That gives crisp text at any print size without inflating the file, and most publishers prefer it.
Q: Why do my fonts change when the figure is opened elsewhere? The fonts were referenced rather than embedded. Set the PDF font type to 42 so TrueType fonts are embedded, and the figure renders identically on any machine.
Q: Should the figure include a north arrow? Only when the orientation is not obvious. A north-up projected raster with labelled eastings and northings already tells the reader which way is up, and an arrow adds furniture. Include one when the map is rotated, when it uses an unusual projection, or when the audience is not used to reading coordinates.
Related
- Rendering Rasters with Matplotlib — the stretches and ramps these figures use.
- Plotting a GeoTIFF with Correct Extent and Axes — the map axes a scale bar depends on.
- Choosing Colour Ramps for Continuous Raster Data — ramps that survive greyscale printing.
- Producing PNG Thumbnails and Quicklooks — the throwaway counterpart to a publication figure.