DPI and Resolution Management in Automated Cartographic Workflows
In automated cartographic pipelines, DPI is not a cosmetic rendering hint — it is a first-class pipeline parameter that controls memory allocation, coordinate precision, print compliance, and downstream colour management. When GIS engineers scale from single-map exports to batch generation, inconsistent resolution handling introduces silent failures: aliased linework, stripped metadata, and mismatched physical dimensions that break prepress workflows. Resolving these failures requires treating DPI as a derived constraint from physical output specifications rather than a number passed at the end of a savefig call.
Resolution Pipeline: From Physical Spec to Pixel Grid
Prerequisites and Environment Configuration
Before implementing automated resolution control, pin your dependency versions explicitly. Headless rendering on CI/CD runners exposes platform-specific backend behaviour that version drift will silently change.
- Python 3.9+ with
venvorcondaisolation matplotlib>=3.7— Agg backend required; earlier versions havesavefigDPI rounding bugsPillow>=9.0— for post-export DPI tag inspectionrasterio>=1.3— windowed reading andwarp.reprojectwith selectable resampling kernelsnumpy>=1.24,geopandas>=0.13,pyproj>=3.6- System RAM: minimum 16 GB for 300 DPI rasterisation of regional datasets; NVMe scratch space for tile assembly
- CRS requirement: all input layers must be projected to a metric CRS matching the target physical output (e.g., EPSG:3857 for web, EPSG:326xx for UTM regional print). Unprojected geographic coordinates in EPSG:4326 distort scale bar calculations and grid spacing at high DPI
- Backend configuration: call
matplotlib.use('Agg')before importingpyplot; the Agg backend is the only matplotlib backend that accepts explicit DPI overrides in headless environments
Conceptual Foundation: Physical Dimensions Drive Pixel Grids
The core principle that stabilises automated resolution control is inverting the usual raster workflow: start with physical dimensions and derive pixels, rather than starting with pixels and inferring size. This matters because:
- Prepress software operates in physical units. InDesign, Acrobat, and offset presses work with millimetres or inches. A map delivered as 2550 × 3300 pixels without DPI metadata will be auto-scaled by the receiving application — usually incorrectly.
- DPI is a derived relationship, not an independent setting.
DPI = pixel_count / physical_inches. Changing either without the other changes the relationship. Fixing physical dimensions first and computing pixels deterministically prevents thesavefigcall from silently recalculating canvas size. - Batch consistency requires integer pixel matrices. Fractional pixels are floored or rounded differently by different rendering backends and OS font subsystems. Computing exact integers upstream, before any drawing occurs, eliminates the rounding disagreement that produces sub-pixel misalignment at scale.
The affine transformation model documented in the GDAL Raster Data Model formalises this relationship for GeoTIFF workflows: the six-coefficient GeoTransform encodes both spatial resolution (metres per pixel) and physical position simultaneously, making it impossible to separate pixel count from physical scale in geospatially-tagged rasters.
A second critical decision at this stage is resampling kernel selection. The choice of kernel when upscaling or downscaling raster data at a target DPI determines both visual fidelity and whether class boundaries remain intact. The diagram below shows why this choice matters differently for continuous and categorical data:
Step-by-Step Implementation
Step 1: Define Physical Output Specifications
Establish target dimensions in inches before touching a renderer. Every downstream parameter — pixel matrix, figure size, stroke widths, font point sizes — is derived from these two numbers and the target DPI.
# Physical specifications — edit these; derive everything else
TARGET_W_IN: float = 8.5 # US Letter width
TARGET_H_IN: float = 11.0 # US Letter height
TARGET_DPI: int = 300 # Offset print standard (600 for fine linework)
# For large-format: 24" × 36" at 150 DPI for wide-format inkjet
# LARGE_FORMAT_W, LARGE_FORMAT_H, LARGE_FORMAT_DPI = 24.0, 36.0, 150
When preparing assets for commercial offset printing, account for bleed (typically 3 mm / 0.125 in) and registration marks in the specification phase, before rasterisation begins, so the canvas dimensions align with press requirements.
Step 2: Compute Integer Pixel Matrix
Derive pixel dimensions using numpy.round to integer — never int() truncation or floating-point arithmetic.
import numpy as np
def compute_pixel_matrix(width_in: float, height_in: float, dpi: int) -> tuple[int, int]:
"""
Return exact pixel dimensions with strict integer rounding.
Using np.round rather than int() prevents the asymmetric truncation
that accumulates to a 1-2 px error on the long axis of a US Letter sheet.
"""
px_w = int(np.round(width_in * dpi))
px_h = int(np.round(height_in * dpi))
return px_w, px_h
WIDTH_PX, HEIGHT_PX = compute_pixel_matrix(TARGET_W_IN, TARGET_H_IN, TARGET_DPI)
# → (2550, 3300) for 8.5" × 11" @ 300 DPI
Floating-point pixel dimensions cause rendering engines to silently floor or round fractional values, producing moiré patterns or jagged typography at print scale. Integer matrices are safe to cache in a batch manifest and reused across the entire run.
Step 3: Configure the Rendering Backend
Set the backend and figure DPI before drawing any geospatial elements. Matplotlib’s default behaviour recalculates figure size based on screen DPI, which destroys headless pipeline reproducibility. This is especially important when applying visual hierarchy through stroke weights and font sizing, since those values are anchored to the figure’s point-per-inch assumption.
import matplotlib
matplotlib.use('Agg') # Must precede pyplot import
import matplotlib.pyplot as plt
def init_cartographic_figure(
width_in: float,
height_in: float,
dpi: int,
) -> tuple:
"""
Create a figure with exact physical dimensions and explicit DPI.
constrained_layout=False is mandatory: the feature adjusts margins
based on rendered text size, shifting physical dimensions unpredictably
across batch runs where label counts vary per sheet.
"""
fig = plt.figure(
figsize=(width_in, height_in),
dpi=dpi,
constrained_layout=False,
)
# Explicit axes placement: [left, bottom, width, height] as figure fractions
ax = fig.add_axes([0.08, 0.08, 0.84, 0.84])
return fig, ax
Always disable constrained_layout and tight_layout for print-ready exports. Both features dynamically adjust margins based on rendered text, introducing unpredictable physical dimension shifts across batch runs.
Step 4: Render and Export with Locked DPI
Pass dpi explicitly to savefig. Do not rely on the figure-level DPI propagating correctly through all export code paths; some matplotlib backends reset it during format negotiation.
import os
def export_map(
fig: plt.Figure,
output_path: str,
dpi: int,
fmt: str = "tiff",
) -> str:
"""
Save the figure with strict DPI enforcement and metadata embedding.
fmt: 'tiff' for prepress (preserves XResolution/YResolution tags);
'png' for digital delivery (writes pHYs chunk);
'pdf' delegates to the PDF vector backend (DPI controls embedded raster resolution).
"""
os.makedirs(os.path.dirname(output_path), exist_ok=True)
save_kwargs = dict(
dpi=dpi,
bbox_inches=None, # None = use figure bbox exactly; 'tight' recomputes canvas
pad_inches=0,
format=fmt,
)
if fmt == "tiff":
save_kwargs["pil_kwargs"] = {
"compression": "tiff_lzw",
"dpi": (dpi, dpi),
}
fig.savefig(output_path, **save_kwargs)
plt.close(fig)
return output_path
Note that bbox_inches='tight' recomputes canvas dimensions based on the renderer’s bounding-box estimate. In batch workflows where label density varies per sheet, tight introduces non-deterministic physical-size variation — avoid it.
Complete Working Code Example
"""
cartographic_dpi_pipeline.py
End-to-end DPI-managed cartographic export:
load → project → render → export → validate
"""
import matplotlib
matplotlib.use('Agg')
import geopandas as gpd
import matplotlib.pyplot as plt
import numpy as np
import rasterio
from rasterio.enums import Resampling
from rasterio.windows import from_bounds
from PIL import Image
import os
from pathlib import Path
# ── 1. Specification ────────────────────────────────────────────────────────
SPEC = {
"width_in": 8.5,
"height_in": 11.0,
"dpi": 300,
"crs": "EPSG:32617", # UTM 17N for north-eastern US datasets
"fmt": "tiff",
}
WIDTH_PX = int(np.round(SPEC["width_in"] * SPEC["dpi"])) # 2550
HEIGHT_PX = int(np.round(SPEC["height_in"] * SPEC["dpi"])) # 3300
# ── 2. Load and project vector layers ───────────────────────────────────────
def load_and_project(path: str, target_crs: str) -> gpd.GeoDataFrame:
gdf = gpd.read_file(path)
if gdf.crs is None:
raise ValueError(f"No CRS defined in {path}; cannot project to {target_crs}")
return gdf.to_crs(target_crs)
# ── 3. Stream raster basemap at target resolution ───────────────────────────
def stream_basemap_window(
tiff_path: str,
bbox: tuple, # (minx, miny, maxx, maxy) in SPEC["crs"]
width_px: int,
height_px: int,
) -> np.ndarray:
"""
Read only the visible extent at the target pixel resolution.
Uses windowed reading to avoid loading the full raster into memory.
Lanczos resampling for photorealistic imagery; swap to Resampling.nearest
for categorical data (land cover, zoning classifications).
"""
with rasterio.open(tiff_path) as src:
window = from_bounds(*bbox, transform=src.transform)
data = src.read(
out_shape=(src.count, height_px, width_px),
window=window,
resampling=Resampling.lanczos,
)
# rasterio returns (bands, rows, cols); imshow expects (rows, cols, bands)
return np.moveaxis(data, 0, -1)
# ── 4. Compose and export ───────────────────────────────────────────────────
def render_and_export(
basemap_array: np.ndarray,
vector_layers: list[gpd.GeoDataFrame],
output_path: str,
spec: dict,
) -> str:
fig = plt.figure(
figsize=(spec["width_in"], spec["height_in"]),
dpi=spec["dpi"],
constrained_layout=False,
)
ax = fig.add_axes([0.08, 0.08, 0.84, 0.84])
ax.imshow(basemap_array, origin="upper", interpolation="none")
for gdf in vector_layers:
gdf.plot(ax=ax, linewidth=0.5, edgecolor="currentColor", facecolor="none")
ax.axis("off")
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
fig.savefig(
output_path,
dpi=spec["dpi"],
bbox_inches=None,
pad_inches=0,
format=spec["fmt"],
pil_kwargs={"compression": "tiff_lzw", "dpi": (spec["dpi"], spec["dpi"])},
)
plt.close(fig)
return output_path
# ── 5. Validate ─────────────────────────────────────────────────────────────
def validate_export_dpi(filepath: str, expected_dpi: int, tolerance: int = 2) -> bool:
"""
Verify that pHYs (PNG) or XResolution/YResolution (TIFF) tags match
the export specification. Raises ValueError on mismatch.
"""
with Image.open(filepath) as img:
dpi_info = img.info.get("dpi", (0, 0))
dpi_x, dpi_y = float(dpi_info[0]), float(dpi_info[1])
if abs(dpi_x - expected_dpi) > tolerance or abs(dpi_y - expected_dpi) > tolerance:
raise ValueError(
f"DPI mismatch in {filepath}: "
f"expected {expected_dpi}, found ({dpi_x:.1f}, {dpi_y:.1f})"
)
# Also verify pixel dimensions match the integer matrix
with Image.open(filepath) as img:
w, h = img.size
if w != WIDTH_PX or h != HEIGHT_PX:
raise ValueError(
f"Canvas mismatch: expected {WIDTH_PX}×{HEIGHT_PX}, got {w}×{h}"
)
return True
Performance Optimization Patterns
High-DPI rasterisation multiplies memory consumption quadratically with resolution. A 300 DPI export of a regional dataset can exceed 4 GB of RAM during intermediate compositing; 600 DPI exceeds that fourfold.
1. Windowed raster reading (O(viewport) not O(dataset)). The rasterio.windows.from_bounds approach in the example above streams only the pixels that fall within the visible map extent at the target resolution. For a county-level map at 300 DPI drawn from a national GeoTIFF, this reduces the data read from gigabytes to tens of megabytes.
2. Batch manifest with pre-computed pixel matrices. Compute all (width_px, height_px, dpi) tuples once at pipeline startup and cache them in a dict keyed by output format. Avoid recomputing inside the render loop; even trivial floating-point arithmetic accumulates rounding disagreements across thousands of batch jobs.
3. Tile-strip rendering for large-format outputs. For poster-scale exports (24" × 36" at 300 DPI = 7200 × 10800 px = ~233 MP per band), split the canvas into horizontal strips, render each strip independently with ax.set_ylim clamped to the strip extent, and reassemble with Pillow.Image.new in append mode. This keeps peak RAM proportional to strip height rather than total canvas height.
4. Resampling kernel selection. For photorealistic basemap imagery use Resampling.lanczos (6-tap sinc approximation, sharpest upscaling). For categorical rasters — land cover classifications, zoning grids, and colour-theory-driven thematic palettes — use Resampling.nearest exclusively. Bilinear and Lanczos kernels blend adjacent class indices, producing fringe colours at class boundaries that become visible as artefacts at 300+ DPI.
Common Pitfalls and Debugging
1. constrained_layout or tight_layout silently resizes canvas.
Symptom: exported TIFF is 2548 × 3297 instead of 2550 × 3300. The layout engine computes a slightly different bounding box based on rendered text on that run. Fix: set constrained_layout=False and pass bbox_inches=None to savefig.
2. pHYs chunk DPI does not match the savefig argument.
Symptom: Pillow reports dpi=(299, 299) after saving at dpi=300. Matplotlib converts floating-point DPI to an integer pixels_per_unit value in the PNG pHYs chunk. If your target DPI is not a clean integer (e.g., 254.0 for 10 px/mm), the stored value will round. Fix: always use integer DPI values; verify with img.info['dpi'] immediately after writing.
3. Font rasterisation drift across operating systems.
Symptom: legend text overflows its bounding box on Linux CI but not on a macOS developer machine. System font fallbacks alter glyph metrics, shifting text anchors by 1–3 pixels. Fix: embed fonts at pipeline startup using matplotlib.font_manager.fontManager.addfont() with a pinned .ttf file committed to the repository, then select it explicitly via rcParams['font.family']. The typography rules for cartographic text cover font selection and point-size constraints for map labels at print resolution.
4. Metadata stripping during format conversion.
Symptom: TIFF exported at 300 DPI loses its XResolution tag after running through an image optimisation step. Many batch-resize tools (ImageMagick convert, pngquant) discard DPI metadata by default. Fix: re-inject resolution tags as the final pipeline step using Pillow’s save with explicit dpi=(TARGET_DPI, TARGET_DPI) in pil_kwargs, or use exiftool -XResolution=300 -YResolution=300 as a post-process.
5. CRS mismatch between raster basemap and vector overlays.
Symptom: vector linework is shifted 5–200 km from the basemap. The basemap was in EPSG:4326 (geographic) while vectors were projected to EPSG:32617 (UTM). Because matplotlib axes use raw coordinate values, geographic and projected coordinates plot on entirely different numeric scales. Fix: call gdf.to_crs(SPEC['crs']) on every layer before plotting; verify with gdf.crs.to_epsg(). The Projection Selection Algorithms guide covers CRS decision logic for regional vs. national extents.
Conclusion
Deterministic DPI management transforms batch map export from a fragile GUI task into a reproducible engineering workflow. The key discipline is defining physical dimensions first and deriving every downstream parameter — pixel matrix, figure size, rasterio window, resampling kernel, and validation tolerance — from that specification. Pair that with explicit backend configuration, windowed raster streaming, and post-export metadata validation, and your pipeline will produce bit-identical outputs whether it runs on a developer laptop or a headless CI runner. For workflows requiring crisp vector linework, topographic contours, or scalable typography alongside high-DPI rasters, the natural next step is High-Resolution Vector Export, which removes the pixel grid entirely and scales output without resolution constraints.
FAQ
Why does matplotlib ignore the dpi argument when saving to PNG?
If constrained_layout or tight_layout is active, matplotlib recalculates figure dimensions after layout, overriding the effective DPI stored in the pHYs chunk. Disable both layout managers and pass dpi explicitly to savefig rather than relying on the figure-level default.
How does rasterio.warp.reproject preserve classification boundaries at high DPI?
Use Resampling.nearest for categorical rasters (land cover, zoning). Bilinear or Lanczos kernels blend adjacent class values, introducing fractional pixel colours that appear as fringe artefacts on class boundaries at 300+ DPI. Nearest-neighbour is O(1) per pixel and preserves hard edges exactly.
What causes sub-pixel linework aliasing at 300 DPI in matplotlib?
Stroke widths specified in display points map to fractional physical pixels at high DPI. The Agg renderer snaps fractional strokes to the nearest integer pixel row, creating irregular weight along diagonal lines. Set linewidth in points relative to dpi/72 so the physical width is always an integer multiple of the pixel grid.
Why does Pillow report a different DPI than what was passed to savefig?
PNG stores resolution in the pHYs chunk as an integer pixels_per_unit. Matplotlib converts the floating-point DPI to an integer before writing, so a target of 299.72 DPI is stored as 299. TIFF stores it as a rational fraction (XResolution/YResolution), which is more precise but must be read with img.tag_v2[282] rather than img.info['dpi'] to avoid rounding by Pillow’s tag parser.
Related
- High-Resolution Vector Export — bypass pixel grids entirely with PDF/SVG/EPS for infinite-scale vector deliverables
- Scale Mapping for Web and Print — physical scale calculations and DPI-aware scale bar generation
- Projection Selection Algorithms — CRS decision logic for regional and national print extents
- Color Theory for GIS — palette selection and resampling-safe colour encoding for thematic rasters