Generating Smooth Contours Without Staircase Artifacts
The contour set comes back looking like a pixel-art staircase: long runs along cell edges joined by right-angle turns, every line following the raster grid rather than the terrain. It is unmistakably wrong and the instinct — run a line-smoothing filter over the geometry — makes the drawing look correct while moving the contours off the surface they claim to describe.
The staircase is a symptom of the DEM, not of the contour generator. This page identifies which of the three causes produced it and fixes each at the source.
Three ways a DEM produces stepped contours
Elevation quantisation. The DEM is stored as an integer type, or as a float that was rounded, so elevations take discrete values. A contour at 118.0 m then follows the boundary between cells valued 118 and 119, which is a cell edge — so the line is literally a staircase because the surface is.
Nearest-neighbour resampling. A DEM reprojected or resampled with nearest-neighbour carries each output cell the value of one input cell. Every cell in a block of four then shares a value, and the contour traces the block boundary.
A grid coarser than the contour interval implies. With a 50 cm interval on a 1 m grid over gentle terrain, consecutive cells often differ by less than the interval, so a contour crosses whole runs of identical cells and follows their edges.
All three produce the same appearance and have the same cure: the surface must vary smoothly at the scale the contour interval samples.
Figure 1 — The staircase is in the data. A contour generator can only interpolate between the values it is given, and between two identical values there is nothing to interpolate.
Minimal reproducible solution
Diagnose first, because the three causes have different fixes and one of them is not a smoothing problem at all.
import numpy as np
import rasterio
def diagnose_quantisation(dem_path: str) -> str:
"""Name the cause from the DEM's values, not from the contour geometry."""
with rasterio.open(dem_path) as ds:
band = ds.read(1, masked=True)
dtype = ds.dtypes[0]
cell = abs(ds.transform.a)
vals = band.compressed()
if vals.size < 1000:
return "too little valid data to diagnose"
if dtype.startswith(("int", "uint")):
return f"integer dtype {dtype} — elevations are quantised to 1 unit"
# Distinct values relative to sample size: heavy repetition means either
# rounding or nearest-neighbour resampling.
sample = vals[:: max(1, vals.size // 200_000)]
distinct_ratio = np.unique(sample).size / sample.size
if distinct_ratio < 0.02:
return (f"only {distinct_ratio:.1%} distinct values — rounded elevations "
"or nearest-neighbour resampling")
# Local relief per cell against the contour interval matters too.
gy, gx = np.gradient(band.filled(np.nan))
step = float(np.nanmedian(np.hypot(gx, gy)))
return f"continuous; median elevation change per cell is {step:.3f} m at {cell:.2f} m cells"
The distinct-value ratio is the diagnostic that separates rounding from nearest-neighbour resampling in practice. A genuinely continuous float DEM has almost as many distinct values as cells; anything below a couple of percent means values are being repeated at scale, and repeated values are what a staircase is made of.
The fix is at the surface, in this order.
import numpy as np
import rasterio
from scipy.ndimage import gaussian_filter
def prepare_for_contours(src_path: str, dst_path: str,
sigma_cells: float = 1.0) -> None:
"""Write a float32 DEM, smoothed enough that the interval is resolvable.
Casting to float32 removes integer quantisation; the smoothing then
removes the residual terracing that rounding left behind.
"""
with rasterio.open(src_path) as src:
band = src.read(1, masked=True)
profile = src.profile
arr = band.filled(np.nan).astype(np.float32)
valid = np.isfinite(arr)
num = gaussian_filter(np.where(valid, arr, 0.0), sigma_cells)
den = gaussian_filter(valid.astype(np.float32), sigma_cells)
out = np.where(den > 1e-6, num / np.maximum(den, 1e-6), np.nan)
out[~valid] = np.nan
profile.update(dtype="float32", nodata=np.nan, compress="deflate", predictor=3)
with rasterio.open(dst_path, "w", **profile) as dst:
dst.write(out, 1)
dst.update_tags(CONTOUR_PREP_SIGMA_CELLS=str(sigma_cells))
Recording the sigma in the tags is what makes a later revision reproducible, and it is the same value the contour and hillshade workflow uses for every other derived product — so the contours and the slope raster describe one surface.
Edge-case matrix
| Cause | Diagnostic | Fix |
|---|---|---|
| Integer DEM | dtype is int or uint | Cast to float32, then smooth |
| Rounded floats | very few distinct values | Smooth; the information is gone |
| Nearest-neighbour resampling | values repeat in blocks | Resample again from the source, bilinear |
| Interval finer than the relief | continuous, small per-cell change | Widen the interval |
| Interval finer than the accuracy | continuous | Widen; see the interval rule |
| Smoothing applied to the lines | geometry departs from the DEM | Undo; smooth the surface instead |
| Voids filled with a constant | flat plateaus, ringed by contours | Fill properly, or contour unfilled |
| Contours from a hillshade | nonsense | Contour the DEM, not a rendering |
The nearest-neighbour row is the one worth going back for. If the DEM was resampled that way, smoothing recovers a plausible surface and not the original one — the information was discarded at the resample. Going back to the source raster and resampling bilinearly costs one command and produces a genuinely better DEM.
Verify the fix worked
Two things must hold: the lines must be smooth, and they must still lie on the surface.
import numpy as np
import fiona
import rasterio
def contour_turn_angles(gpkg: str, layer: str | None = None) -> np.ndarray:
"""Interior angles at contour vertices, in degrees."""
angles = []
with fiona.open(gpkg, layer=layer) as src:
for feat in src:
pts = np.asarray(feat["geometry"]["coordinates"], dtype=float)[:, :2]
if len(pts) < 3:
continue
v1, v2 = pts[1:-1] - pts[:-2], pts[2:] - pts[1:-1]
cos = np.einsum("ij,ij->i", v1, v2) / (
np.linalg.norm(v1, axis=1) * np.linalg.norm(v2, axis=1) + 1e-12)
angles.append(np.degrees(np.arccos(np.clip(cos, -1, 1))))
return np.concatenate(angles) if angles else np.array([])
def assert_smooth_and_on_surface(gpkg: str, dem_path: str,
max_right_angle_frac: float = 0.02,
tol_m: float = 0.02) -> None:
"""Smooth lines that still sit at their stated elevation."""
ang = contour_turn_angles(gpkg)
right = float(np.mean(np.abs(ang - 90.0) < 5.0))
assert right <= max_right_angle_frac, (
f"{right:.1%} of vertices turn through ~90° — the staircase remains")
worst = 0.0
with rasterio.open(dem_path) as dem, fiona.open(gpkg) as src:
for feat in src:
elev = float(feat["properties"]["elev"])
pts = [tuple(p[:2]) for p in feat["geometry"]["coordinates"][::10]]
for v in dem.sample(pts, indexes=1):
if np.isfinite(v[0]):
worst = max(worst, abs(float(v[0]) - elev))
assert worst <= tol_m, (
f"contours depart from the DEM by up to {worst:.3f} m — the geometry "
"was smoothed rather than the surface")
Running both assertions together is the point. Line smoothing passes the first and fails the second; leaving the DEM quantised passes the second and fails the first. Only fixing the surface passes both.
Figure 2 — Why both assertions are needed. The tempting fix passes exactly the test that was being looked at, which is what makes it tempting.
When to escalate
- The DEM is float, has plenty of distinct values, and contours still step. The interval is finer than the terrain’s relief per cell, so consecutive cells straddle several intervals in some places and none in others. Widen the interval rather than smoothing further; smoothing at that point starts erasing real features.
- Smoothing enough to fix the steps erases a kerb or a bund. The interval is too fine for the data. This is the accuracy question rather than a rendering one, and it is resolved by the two-to-three-times-RMSE rule in the contour workflow.
- Contours are smooth in the open and stepped over one region. That region was filled or resampled differently — usually a void patched with a constant. Check the fill mask before touching the smoothing, since the plateau is data, not quantisation.
Finally, resist the temptation to fix this at the drawing stage even when it is quicker. A contour set that has been geometrically smoothed carries no record of the fact, so the next person to sample the DEM along those lines — for a profile, a volume, or a cross-section — gets values that disagree with the contour labels and has no way to know why. Smoothing the surface leaves the two products consistent by construction, and the sigma recorded in the raster tags says exactly what was done.
Related
- Deriving contours and hillshade with GDAL
- Computing slope and aspect rasters in Python
- Fixing holes and voids in drone DSMs
← Deriving Contours and Hillshade with GDAL
Figure 3 — The sigma is a survey decision. One cell removes the artefact; three removes features the client is paying to see, and nothing in the contour geometry distinguishes the two.