Terrain and Hillshade Automation

Shaded relief is the one map element where a wrong parameter produces something that still looks like a map. A hillshade computed with the wrong vertical exaggeration is not obviously broken — it simply describes terrain that does not exist, with mountains too flat or too jagged, and nothing in the pipeline raises an error. This page covers the automation of terrain derivatives end to end: establishing the DEM contract, computing the z-factor correctly, generating shaded relief that reads as terrain rather than as texture, and compositing it under thematic colour without destroying the data the map exists to show.

Everything here assumes a batch context. A cartographer working interactively can tune an azimuth until the map looks right; a pipeline rendering four hundred atlas sheets cannot, and needs parameters derived from the data rather than chosen by eye.

Prerequisites and Environment Configuration

python==3.11
GDAL==3.8.4
rasterio==1.3.9
numpy==1.26.4
scipy==1.12.0
matplotlib==3.8.4

Three properties of the source DEM must be known before any derivative is computed, and none of them can be safely assumed:

  • The horizontal CRS and its units. A DEM in EPSG:4326 has degrees on both horizontal axes; a DEM in a UTM zone has metres. The slope and hillshade algorithms compute a gradient, which is a ratio of vertical change to horizontal distance, so the units of both must be reconciled before the ratio means anything.
  • The vertical unit. Metres and feet are both common, and nothing in a GeoTIFF forces the vertical unit to be recorded. A DEM in feet processed as metres yields relief exaggerated by 3.28, which reads as spectacular alpine terrain in a region that has none.
  • The nodata value. Ocean cells and voids are typically encoded as a sentinel such as −32768 or −9999. If nodata is not declared, the gradient across the boundary between land and sentinel is enormous, and a bright rim appears along every coastline.

Assert all three at load time and fail loudly when any is missing. A pipeline that guesses these values produces plausible output, which is precisely the failure mode that survives review.

For projection choice, the same reasoning as in Projection Selection Algorithms applies with one addition: terrain derivatives are computed per pixel from neighbouring pixels, so a projection whose scale varies strongly across the extent produces relief whose apparent steepness varies with position. For a national terrain product, reproject the DEM to an equal-area or conformal projection appropriate to the extent before deriving anything.

Conceptual Foundation: What the Shading Algorithm Actually Computes

Hillshade is a single number per pixel: the cosine of the angle between the surface normal and a vector toward the light source, clamped to zero and scaled to the output range. The surface normal comes from the local gradient, estimated over the eight neighbours of each cell using Horn’s method. Everything cartographers argue about — azimuth, altitude, exaggeration, multidirectional blending — is a modification of that one calculation.

The gradient is where units enter. Horn’s estimator divides the elevation difference between neighbours by the horizontal distance between them, and both quantities have to be in the same unit for the resulting slope to be an angle. The z-factor is the conversion constant that makes that true:

import math


def z_factor(crs_is_geographic: bool, centre_latitude: float,
             vertical_unit_metres: float = 1.0) -> float:
    """Vertical exaggeration that reconciles vertical and horizontal units.

    For a projected CRS in metres with elevation in metres the factor is 1.0.
    For a geographic CRS the horizontal unit is degrees, whose ground length
    varies with latitude, so the factor depends on where the raster sits.
    """
    if not crs_is_geographic:
        return vertical_unit_metres

    metres_per_degree = 111320.0 * math.cos(math.radians(centre_latitude))
    if metres_per_degree <= 0:
        raise ValueError("degenerate latitude for z-factor computation")
    return vertical_unit_metres / metres_per_degree

Two consequences follow that catch out most first implementations. First, the factor for a geographic DEM is a very small number, around 1.1e-5 at the equator — not the 0.00001 or 111120 that appear in various forum answers, both of which are the same idea with a different sign of error. Second, because the factor depends on latitude, a single value applied across a continental DEM exaggerates northern relief relative to southern. For a map series spanning more than about ten degrees of latitude, compute the factor per sheet.

What the z-factor does to a ridge profile Three profiles of the same ridge. With a factor a tenth of the correct value the profile is almost flat and the shading carries almost no information. With the correct factor the ridge has a natural slope and the shaded flanks are clearly differentiated. With a factor ten times too large the profile is spiky, shadows are hard-edged, and gentle terrain disappears into black. z-factor ×0.1 shading carries almost no signal correct flanks differentiated, gentle ground still visible z-factor ×10 hard-edged shadows; gentle terrain crushed to black Derive the factor from the CRS and the vertical unit; never from taste. 1.0 for metric projected DEMs
All three panels use identical elevation data. Only the exaggeration constant differs, and it changes what the map claims about the landscape.

Step-by-Step Implementation

Step 1: Validate the DEM before deriving anything

import rasterio


def load_dem(path: str) -> dict:
    """Read a DEM and assert the three properties every derivative depends on."""
    with rasterio.open(path) as src:
        if src.nodata is None:
            raise ValueError(f"{path}: no nodata value declared — coastlines will glow")
        if src.crs is None:
            raise ValueError(f"{path}: no CRS — the z-factor cannot be computed")

        bounds = src.bounds
        centre_lat = (bounds.bottom + bounds.top) / 2 if src.crs.is_geographic else 0.0
        return {
            "path": path,
            "crs": src.crs,
            "is_geographic": src.crs.is_geographic,
            "centre_latitude": centre_lat,
            "nodata": src.nodata,
            "res": src.res,
        }

The vertical unit cannot be read from the file in the general case, so it belongs in the dataset’s own manifest alongside its licence and vintage. Treat it as required metadata rather than something to infer.

Step 2: Generate the shaded relief

The gdaldem utility implements Horn’s method and is faster than any pure-Python equivalent. Drive it through subprocess rather than reimplementing the gradient:

import subprocess


def hillshade(dem: dict, out_path: str, azimuth: float = 315.0,
              altitude: float = 45.0, multidirectional: bool = False) -> str:
    """Render shaded relief from a validated DEM."""
    zf = z_factor(dem["is_geographic"], dem["centre_latitude"])

    cmd = ["gdaldem", "hillshade", dem["path"], out_path,
           "-z", f"{zf:.10g}", "-compute_edges",
           "-co", "COMPRESS=DEFLATE", "-co", "TILED=YES"]

    if multidirectional:
        cmd.append("-multidirectional")   # azimuth is ignored in this mode
    else:
        cmd += ["-az", str(azimuth), "-alt", str(altitude)]

    subprocess.run(cmd, check=True)
    return out_path

Two flags matter more than they appear to. -compute_edges fills the one-pixel border that would otherwise be nodata, which prevents a hairline seam at every tile boundary in a mosaicked product. -multidirectional replaces the single light source with a weighted combination of four, and its effect is specific: terrain whose ridges run parallel to the light direction is invisible under single-source illumination, because both flanks receive identical light. Multidirectional shading recovers those ridges at the cost of slightly softer overall contrast.

Step 3: Choose the azimuth deliberately

The conventional north-west azimuth of 315 degrees is not arbitrary. Human depth perception assumes light from above, and a light source in the southern half of the compass inverts the reading: ridges appear as valleys and craters as domes. This is the hollow-mountain illusion, and it is strong enough that readers do not merely find the map ambiguous — they confidently read it backwards.

Keep the primary azimuth between about 270 and 360 degrees. When a particular ridge system runs north-west to south-east and disappears under that lighting, use multidirectional mode rather than rotating the light southward.

The azimuth arc that inverts a reader's depth perception A compass rose with the arc from 270 to 360 degrees marked as safe and the arc from 90 to 270 degrees marked as producing inverted relief. The conventional 315 degree azimuth is marked within the safe arc. Beside it, two small ridge profiles show the same terrain lit from the north-west, reading as a ridge, and from the south-east, reading as a valley. N · 0° E · 90° S · 180° W · 270° safe arc 270–360° dashed arc inverts relief lit from 315° — reads as a ridge lit flank left, shadowed flank right lit from 135° — reads as a valley identical elevations, confidently misread
Readers do not experience the lower panel as ambiguous — they read it as a valley without hesitation. This is why azimuth is a constraint rather than a preference.

Step 4: Composite under the thematic layer

This is where most terrain automation goes wrong. Placing a grey hillshade over a thematic map at partial alpha mixes grey into every class, desaturating the palette and pulling the whole map toward the middle of the value range. The legend swatches, drawn without the overlay, then no longer match anything on the map.

The correct composite puts relief underneath and uses a blend that modulates value while preserving hue:

import numpy as np


def multiply_blend(thematic_rgb: np.ndarray, shade: np.ndarray,
                   strength: float = 0.55) -> np.ndarray:
    """Composite shaded relief under a thematic layer, preserving hue.

    thematic_rgb : float array (h, w, 3) in 0..1
    shade        : float array (h, w) in 0..1, 0.5 = neutral illumination
    """
    if thematic_rgb.shape[:2] != shade.shape:
        raise ValueError("shade and thematic layer must share a grid")

    # Pull the shade toward neutral by `strength`, so 0 leaves colour untouched.
    modulated = 1.0 - strength * (1.0 - shade[..., None])
    return np.clip(thematic_rgb * modulated, 0.0, 1.0)

Because multiply only ever darkens, the palette’s lightest class stays recognisable and every class keeps its hue. A strength around 0.5 gives visible relief without compromising the contrast ratios validated under Accessibility Sync in Cartography — although the validation must be re-run on the composited output, because a class that passed against white may not pass against its own shaded form.

Three ways to composite relief, and what each costs Three composites of the same thematic layer. Grey hillshade placed over the map at 40 per cent alpha desaturates every class and drops label contrast from 5.2 to 3.1. Multiply blend underneath preserves hue and holds contrast at 4.6. Soft light underneath preserves hue and holds 4.9 but gives weaker relief on gentle terrain. grey shade over, 40% alpha hue washed out label contrast 3.1:1 — fails multiply, underneath hue preserved, strong relief label contrast 4.6:1 — passes soft light, underneath hue preserved, gentler label contrast 4.9:1 — passes Re-run contrast validation on the composite; the pre-composite palette result no longer applies. Multiply is the default for thematic maps. Soft light suits imagery basemaps, where the underlying texture already carries most of the terrain information.
Only the first strategy changes the palette's hue, and it is the one that a naive layer stack produces by default.

Complete Working Code Example

import math
import subprocess
import numpy as np
import rasterio


def terrain_basemap(dem_path: str, thematic_rgb: np.ndarray,
                    out_path: str, strength: float = 0.55) -> str:
    """Derive shaded relief from a DEM and composite it under a thematic layer.

    thematic_rgb must already be on the DEM's grid; resample it beforehand
    rather than here, so the resampling method is an explicit decision.
    """
    with rasterio.open(dem_path) as src:
        if src.nodata is None or src.crs is None:
            raise ValueError(f"{dem_path}: DEM is missing nodata or CRS")
        profile = src.profile
        centre_lat = (src.bounds.bottom + src.bounds.top) / 2
        geographic = src.crs.is_geographic

    zf = 1.0
    if geographic:
        zf = 1.0 / (111320.0 * math.cos(math.radians(centre_lat)))

    shade_path = out_path + ".shade.tif"
    subprocess.run(
        ["gdaldem", "hillshade", dem_path, shade_path,
         "-z", f"{zf:.10g}", "-multidirectional", "-compute_edges",
         "-co", "COMPRESS=DEFLATE", "-co", "TILED=YES"],
        check=True,
    )

    with rasterio.open(shade_path) as shd:
        shade = shd.read(1).astype("float32") / 255.0

    if shade.shape != thematic_rgb.shape[:2]:
        raise ValueError("thematic layer is not on the DEM grid — resample first")

    modulated = 1.0 - strength * (1.0 - shade[..., None])
    composite = np.clip(thematic_rgb * modulated, 0.0, 1.0)

    profile.update(count=3, dtype="uint8", nodata=None,
                   compress="deflate", tiled=True)
    with rasterio.open(out_path, "w", **profile) as dst:
        dst.write((composite * 255).astype("uint8").transpose(2, 0, 1))

    return out_path

The function deliberately refuses to resample. Choosing between nearest-neighbour and bilinear for a thematic raster is a cartographic decision — nearest preserves class boundaries, bilinear invents intermediate classes that appear in no legend — and hiding it inside a compositing helper is how a categorical layer silently acquires values that do not exist.

Performance Optimization Patterns

Derive once, reuse across sheets. Hillshade depends only on the DEM, the z-factor and the light parameters. None of those vary per atlas sheet, so the derivative belongs in a build step that runs once, not inside the per-sheet loop. This is the same hoisting argument made about font metrics and spatial indexes in the performance section of the fundamentals overview, and terrain is usually the largest instance of it.

Compute at the delivery resolution, not the source resolution. A one-metre lidar DEM composited into a 1:50 000 sheet contributes roughly one useful sample per 25 output pixels; deriving hillshade at full source resolution and downsampling afterwards costs 625 times the memory for output that is very slightly softer. Resample the DEM to approximately the output pixel size first, then derive.

Tile large derivations with an overlap. A national DEM will not fit in memory. Process it in tiles with an overlap of at least two pixels on every side, because the gradient estimator reads the eight neighbours of each cell; without the overlap, a one-pixel band of wrong values appears along every internal tile edge and mosaics into a visible grid.

Use the compressed, tiled GeoTIFF options. COMPRESS=DEFLATE with TILED=YES typically reduces a hillshade to a quarter of its uncompressed size and makes windowed reads cheap, which matters when the compositing step reads the raster back one block at a time.

Common Pitfalls and Debugging

Glowing coastlines. An undeclared nodata value makes the gradient between a −32768 sentinel and a 3-metre coastal cell enormous, and the shading algorithm renders that as a bright rim. Declare nodata; if the source genuinely lacks one, mask the sentinel explicitly before deriving.

Terracing on gentle slopes. An integer DEM has a one-unit vertical quantum. Where the terrain rises by less than one unit per pixel, the derived slope snaps between discrete values and the hillshade shows contour-like steps. Convert to float and apply a light Gaussian smoothing — a sigma of about one pixel — before deriving. Raising the z-factor makes this worse, not better, which is why it is often misdiagnosed.

Relief that varies in strength across a large map. This is the latitude-dependent z-factor problem. If a single factor was used across a continental extent, northern terrain will read as steeper than southern terrain of identical slope. Recompute per tile or reproject to a projected CRS first.

The thematic layer looks flat after compositing. Almost always a sign that the shade raster was applied over rather than under, or that alpha was used instead of multiply. Check the composite order first; it is a one-line fix that is easy to overlook because the output still looks like a reasonable map.

Contrast failures that appear only on shaded areas. A palette validated against a white background can fail against its own shaded form, because multiply darkens the fill while the label colour stays fixed. Run the contrast validation on the composited raster, sampling in both the brightest and darkest shaded regions.

Conclusion

Terrain shading is a deterministic transform with a small number of parameters, and every one of those parameters can be derived from the data rather than chosen by eye: the z-factor from the CRS and vertical unit, the azimuth from a perceptual constraint, the blend mode from whether a thematic layer is present, the resolution from the delivery scale. Automating it well means resolving each of those from measurable inputs and asserting the DEM contract loudly enough that a missing nodata value fails the build rather than the map. From here, the compositing decisions connect directly to Color Theory for GIS, and the per-sheet reuse pattern to the batch machinery in Batch Queue Orchestration.


Back to Automated Cartographic Design Fundamentals