Skip to content

DEM contour tutorial

Generate contour isobands from DEMs using contourrs.contours_arrow and visualize them with GeoPandas + Matplotlib.

We start with a synthetic Gaussian-peak DEM, then apply the same workflow to a real Mt. Rainier elevation model.

from pathlib import Path

import geopandas as gpd
import matplotlib.pyplot as plt
import numpy as np
import rasterio
from contourrs import contours_arrow

Synthetic DEM

Sum of three Gaussian peaks to simulate a mountain range.

size = 256
y, x = np.mgrid[-3 : 3 : complex(size), -3 : 3 : complex(size)]
dem = (
    np.exp(-(x**2 + y**2))
    + 0.7 * np.exp(-((x - 1.5) ** 2 + (y - 1) ** 2) / 0.5)
    + 0.5 * np.exp(-((x + 1.5) ** 2 + (y + 1.5) ** 2) / 0.8)
).astype(np.float32)

print(f"DEM shape: {dem.shape}, range: [{dem.min():.3f}, {dem.max():.3f}]")
DEM shape: (256, 256), range: [0.000, 1.003]

Extract contour isobands

Define elevation thresholds and use contours_arrow to produce filled contour polygons (isobands) as an Arrow table. Convert to a GeoDataFrame for easy plotting.

thresholds = [0.1, 0.3, 0.5, 0.7, 0.9, 1.1]

table = contours_arrow(dem, thresholds=thresholds)
gdf = gpd.GeoDataFrame.from_arrow(table)

print(f"Isobands: {len(gdf)} polygons")
print(f"Threshold values: {sorted(gdf['value'].unique())}")
gdf.head()
Isobands: 7 polygons
Threshold values: [np.float64(0.1), np.float64(0.3), np.float64(0.5), np.float64(0.7), np.float64(0.9)]
geometry value
0 POLYGON ((15.519 65, 15.504 64, 15.511 63, 15.... 0.1
1 POLYGON ((36.505 65, 36.485 64, 36.501 63, 36.... 0.3
2 POLYGON ((59 65.488, 58.932 65, 58.948 64, 59 ... 0.5
3 POLYGON ((91.058 125, 91.038 124, 91.039 123, ... 0.5
4 POLYGON ((101.531 127, 101.516 126, 101.536 12... 0.7

Synthetic raster and contour polygons

Side-by-side comparison of the continuous DEM and the filled contour bands.

h, w = dem.shape

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# Left: continuous DEM
axes[0].imshow(dem, cmap="terrain", interpolation="nearest", extent=(0, w, h, 0))
axes[0].set_title("Synthetic DEM")
axes[0].set_axis_off()
axes[0].set_xlim(0, w)
axes[0].set_ylim(h, 0)
axes[0].set_aspect("equal")

# Right: filled contour polygons
gdf.plot(
    ax=axes[1],
    column="value",
    cmap="terrain",
    edgecolor="black",
    linewidth=0.3,
    legend=True,
    legend_kwds={"label": "Threshold", "shrink": 0.8},
)
axes[1].set_title(f"Isobands ({len(gdf)} polygons)")
axes[1].set_axis_off()
axes[1].set_xlim(0, w)
axes[1].set_ylim(h, 0)
axes[1].set_aspect("equal")

plt.tight_layout()
plt.show()

png

Real DEM: Mt. Rainier

Apply the same contour workflow to a real 2048x2048 DEM of Mt. Rainier (examples/data/mt_rainier_dem_2048.tif).

dem_path = Path("data/mt_rainier_dem_2048.tif")
if not dem_path.exists():
    dem_path = Path("examples/data/mt_rainier_dem_2048.tif")

with rasterio.open(dem_path) as src:
    real_dem = src.read(1)
    bounds = src.bounds
    nodata = src.nodata
    transform = src.transform

print(f"Shape: {real_dem.shape}, dtype: {real_dem.dtype}")
print(f"Elevation range: [{real_dem.min():.0f}, {real_dem.max():.0f}] m")
print(f"Bounds: {bounds}")
Shape: (2048, 2048), dtype: float32
Elevation range: [874, 4392] m
Bounds: BoundingBox(left=-121.8552777780595, bottom=46.758055555634236, right=-121.66564814822587, top=46.94768518546787)

Extract contours from Mt. Rainier

Use elevation thresholds spaced every 250 m.

# Mask out nodata
mask = np.ones(real_dem.shape, dtype=bool)
if nodata is not None:
    mask &= real_dem != nodata

valid = real_dem[mask]
vmin = float(valid.min())
vmax = float(valid.max())

# Quantile-based thresholds for visually balanced elevation bins
n_bins = 8
real_thresholds = [float(np.quantile(valid, q)) for q in np.linspace(0, 1, n_bins + 1)]
print(
    f"Thresholds ({len(real_thresholds)}):"
    f" {real_thresholds[0]:.0f} .. {real_thresholds[-1]:.0f} m"
)

real_table = contours_arrow(
    real_dem,
    thresholds=real_thresholds,
    nodata=nodata,
    transform=transform,
)
gdf_real = gpd.GeoDataFrame.from_arrow(real_table)

print(f"Elevation bins: {len(gdf_real)} polygons")
gdf_real.head()
Thresholds (9): 874 .. 4392 m


Elevation bins: 426 polygons
geometry value
0 POLYGON ((-121.85528 46.9018, -121.85519 46.90... 874.071167
1 POLYGON ((-121.85528 46.87116, -121.85519 46.8... 874.071167
2 POLYGON ((-121.85528 46.84545, -121.85523 46.8... 874.071167
3 POLYGON ((-121.85528 46.8143, -121.85523 46.81... 874.071167
4 POLYGON ((-121.85528 46.79706, -121.85519 46.7... 874.071167

Mt. Rainier raster and contour polygons

extent = (bounds.left, bounds.right, bounds.bottom, bounds.top)
h, w = real_dem.shape

fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# Left: real DEM raster
axes[0].imshow(real_dem, cmap="terrain", interpolation="nearest", extent=extent)
axes[0].set_title('Mount Rainier DEM (USGS 3DEP 1/3")')
axes[0].set_axis_off()
axes[0].set_xlim(bounds.left, bounds.right)
axes[0].set_ylim(bounds.bottom, bounds.top)
axes[0].set_aspect("equal")

# Right: contour isobands
gdf_real.plot(
    ax=axes[1],
    column="value",
    cmap="terrain",
    edgecolor="black",
    linewidth=0.2,
    legend=True,
    legend_kwds={"label": "Elevation (m)", "shrink": 0.8},
)
axes[1].set_title(f"Elevation bins ({len(gdf_real)} polygons)")
axes[1].set_axis_off()
axes[1].set_xlim(bounds.left, bounds.right)
axes[1].set_ylim(bounds.bottom, bounds.top)
axes[1].set_aspect("equal")

plt.tight_layout()
plt.show()

png