Generating Hillshade Rasters in Python
Combine slope and aspect with a light direction, then scale for display:
import numpy as np
zenith = np.radians(90 - altitude_deg) # 45° altitude by convention
azimuth = np.radians(azimuth_deg) # 315° = north-west light
shade = (np.cos(zenith) * np.cos(slope)
+ np.sin(zenith) * np.sin(slope) * np.cos(azimuth - aspect))
hillshade = np.clip(shade, 0, 1) * 255
Slope and aspect in radians, aspect clockwise from north — and the rest is one formula. This page belongs to terrain analysis and DEM-derived products in Satellite Processing Workflows & Index Pipelines.
The Illumination Formula
The formula is the cosine of the angle between the slope’s surface normal and the light direction, expanded in terms of slope, aspect, and the light’s zenith and azimuth. Flat ground gets the cosine of the zenith — about 0.7 for a 45° light — which is why hillshade images have a mid-grey base with bright and dark slopes on either side, rather than white plains.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
numpy |
>=1.23 |
Trigonometry |
scipy |
>=1.11 |
Gradient kernels |
rasterio |
>=1.3.0 |
Reading the DEM and writing uint8 output |
matplotlib |
>=3.8 |
Optional: blending hillshade under a colour layer |
pip install "numpy>=1.23" "scipy>=1.11" "rasterio>=1.3.0" "matplotlib>=3.8"
Complete Working Example
import numpy as np
import rasterio
from scipy import ndimage
KX = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]) / 8.0
KY = np.array([[1, 2, 1], [0, 0, 0], [-1, -2, -1]]) / 8.0
def hillshade(dem: np.ndarray, res: float, *, azimuth: float = 315.0,
altitude: float = 45.0, z_factor: float = 1.0) -> np.ndarray:
dzdx = ndimage.convolve(dem, KX, mode="nearest") / res * z_factor
dzdy = ndimage.convolve(dem, KY, mode="nearest") / res * z_factor
slope = np.arctan(np.hypot(dzdx, dzdy))
aspect = np.arctan2(-dzdx, -dzdy) # downslope, clockwise from north
zen = np.radians(90.0 - altitude)
az = np.radians(azimuth)
shade = (np.cos(zen) * np.cos(slope)
+ np.sin(zen) * np.sin(slope) * np.cos(az - aspect))
return np.clip(shade, 0, 1).astype("float32")
def write_hillshade(dem_path: str, out_path: str, **kwargs) -> None:
with rasterio.open(dem_path) as src:
dem = src.read(1, masked=True).filled(np.nan).astype("float64")
res = abs(src.transform.a)
profile = src.profile | {"dtype": "uint8", "nodata": 0, "count": 1,
"compress": "deflate", "tiled": True}
hs = hillshade(np.nan_to_num(dem, nan=np.nanmean(dem)), res, **kwargs)
out = (hs * 254 + 1).astype("uint8")
out[~np.isfinite(dem)] = 0
with rasterio.open(out_path, "w", **profile) as dst:
dst.write(out, 1)
dst.update_tags(azimuth=str(kwargs.get("azimuth", 315)),
altitude=str(kwargs.get("altitude", 45)))
Storing hillshade as uint8 with 0 reserved for nodata keeps it tiny and makes it directly usable as a display layer. It is a picture of relief rather than a measurement, so eight bits lose nothing that matters — the same reasoning as in converting rasters to 8-bit for display.
Multidirectional Shading
def multidirectional(dem, res, azimuths=(225, 270, 315, 360), altitude=45.0):
return np.mean([hillshade(dem, res, azimuth=a, altitude=altitude) for a in azimuths], axis=0)
A single light direction hides features aligned with it: a ridge running parallel to the light is lit equally on both flanks and nearly vanishes. Averaging several azimuths removes that bias, at the cost of a slightly flatter overall look. For base maps covering varied terrain, multidirectional shading is usually the better default; for a small area with one dominant grain, a single well-chosen azimuth can be more striking.
Hillshade as a Base Layer
Hillshade is rarely shown alone. Its main use is underneath other data — a land-cover map, an NDVI layer, an elevation colour ramp — where it adds a sense of relief without competing for attention. The standard technique is to blend it multiplicatively with the colour layer, so bright slopes leave the colour unchanged and shaded slopes darken it.
import matplotlib.pyplot as plt
from matplotlib.colors import LightSource
ls = LightSource(azdeg=315, altdeg=45)
rgb = ls.shade(elevation, cmap=plt.cm.gist_earth, blend_mode="soft",
vert_exag=1.5, dx=res, dy=res)
Keep the hillshade subtle in such blends. At full strength it overwhelms the colour and makes classes on shaded slopes hard to read; a soft blend, or the hillshade drawn at around 40% opacity beneath a translucent data layer, gives relief without distortion. The layer-ordering advice in interactive raster exploration in Jupyter applies directly.
Verification
import numpy as np
hs = hillshade(dem, res=30.0)
flat = np.cos(np.radians(90 - 45))
assert 0.0 <= hs.min() and hs.max() <= 1.0
assert abs(np.median(hs) - flat) < 0.15, "median far from the flat-ground value — check the DEM units"
The median of a hillshade over mixed terrain sits close to the flat-ground value, the cosine of the light’s zenith. A median far from it — every pixel near white or near black — usually means the DEM is still in degrees, so every slope is effectively vertical. Rendering the result and checking that valleys look like valleys is the final, most reliable test.
Common Errors
Relief looks inverted
The light comes from the south-east, or the aspect convention is flipped. Use azimuth 315 and check aspect against a known slope before changing anything else.
The whole image is near white or near black
The DEM is in geographic degrees. Reproject to metres before shading.
Hillshade is noisy and speckled
The DEM carries vertical noise that the gradient amplifies. Smooth lightly first, or use a coarser DEM.
Flat regions show no relief at all
Relief is genuinely subtle. Apply a vertical exaggeration of two to five, and record the factor used in the output tags.
Frequently Asked Questions
Q: Why is hillshade lit from the north-west? Convention and perception. Light from the upper left of a north-up map makes relief read correctly for most viewers; light from the south-east makes valleys look like ridges, an illusion known as relief inversion.
Q: Should hillshade use the real sun position? For display, no — a fixed conventional light direction reads best. For correcting imagery, yes — the illumination must match the sun at the moment of acquisition, which is a different product with a different purpose.
Q: What is vertical exaggeration for? Making subtle relief visible. Multiplying the gradients by a factor of two to five brings out low hills in flat regions; in mountains it saturates and should be left at one.
Q: Can hillshade be served as web tiles? Yes, and it is an ideal candidate: small, static, and reused under many layers. Build it once, convert it to a COG with overviews, and serve it like any other layer, as covered in serving raster tiles with TiTiler.
Q: Does hillshade need to be recomputed for each map? No. For a given DEM and light direction it never changes, so compute it once, store it as a tiled, compressed layer with overviews, and reuse it under every map of that area. Recomputing it per figure is pure waste, and storing it guarantees every map shares the same relief shading.
Related
- Terrain Analysis and DEM-Derived Products — the parent topic.
- Computing Slope and Aspect from a DEM — the inputs to the formula.
- Applying a Terrain Illumination Correction — the same formula with the real sun.
- Choosing Colour Ramps for Continuous Raster Data — picking the colour layer that sits on top.