Generating Slope and Aspect Rasters with gdaldem
TL;DR: gdaldem slope dtm.tif slope.tif -alg Horn -compute_edges and gdaldem aspect dtm.tif aspect.tif -zero_for_flat, both on a DTM whose vertical and horizontal units match — and remember that slope is a magnitude you can average and aspect is a direction you cannot.
# Context and Motivation
This guide is part of Hillshade, Slope and Aspect, which covers the derivatives a terrain model supports. Hillshade is for looking at; slope and aspect are for computing with, and that difference is where the mistakes live.
Both come from the same two gradients that hillshade uses — the Horn stencil over a three by three neighbourhood. Slope is the magnitude of that gradient, in degrees or percent. Aspect is its direction, in degrees clockwise from north. They are produced by one pass each over the raster and cost almost nothing. What costs is using them without noticing that one of them is circular.
# Prerequisites and Assumptions
| Requirement | Detail |
|---|---|
| GDAL | 3.x, for gdaldem |
| A DTM | bare earth; slope over a DSM measures roof pitch |
| Matching units | vertical and horizontal in the same unit, or a -s scale factor |
| A void-aware workflow | NoData cells propagate to their neighbours in the derivatives |
# Step-by-Step Implementation
# Step 1 — Confirm the units agree
A DTM in US survey feet on a metric grid needs -s 0.3048. Without it every slope is 3.28 times too steep — the failure shown in exporting hillshade from a LiDAR DTM.
# Step 2 — Compute slope
gdaldem slope dtm.tif slope_deg.tif -alg Horn -compute_edges \
-co COMPRESS=DEFLATE -co TILED=YESAdd -p for percent rise instead of degrees. -compute_edges fills the one-pixel border that would otherwise be NoData.
# Step 3 — Compute aspect
gdaldem aspect dtm.tif aspect_deg.tif -compute_edges -zero_for_flat \
-co COMPRESS=DEFLATE -co TILED=YESWithout -zero_for_flat, flat cells get −9999, which is correct and awkward; with it they get 0, which is north and wrong. Choose deliberately and record which you chose.
# Step 4 — Reclassify aspect before any statistics
Eight sectors, or a north-south exposure index, both behave arithmetically. Raw degrees do not.
# Complete Working Example
"""Derive slope and aspect, then summarise them correctly."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import numpy as np
import rasterio
def derive(dtm: Path, out_dir: Path, z_factor: float = 1.0) -> dict[str, Path]:
out_dir.mkdir(parents=True, exist_ok=True)
slope = out_dir / "slope_deg.tif"
aspect = out_dir / "aspect_deg.tif"
common = ["-compute_edges", "-co", "COMPRESS=DEFLATE", "-co", "TILED=YES"]
subprocess.run(["gdaldem", "slope", str(dtm), str(slope),
"-alg", "Horn", "-s", str(1.0 / z_factor), *common], check=True)
subprocess.run(["gdaldem", "aspect", str(dtm), str(aspect),
"-zero_for_flat", *common], check=True)
return {"slope": slope, "aspect": aspect}
def summarise(slope: Path, aspect: Path) -> dict:
with rasterio.open(slope) as s:
sl = s.read(1).astype("float64")
sl_nodata = s.nodata
with rasterio.open(aspect) as a:
asp = a.read(1).astype("float64")
valid = np.isfinite(sl)
if sl_nodata is not None:
valid &= sl != sl_nodata
# Slope is a magnitude: ordinary statistics are fine.
slope_stats = {
"mean_deg": round(float(sl[valid].mean()), 2),
"p95_deg": round(float(np.percentile(sl[valid], 95)), 2),
}
# Aspect is circular: average the unit vectors, not the degrees.
sloped = valid & (sl > 2.0) # a flat cell has no meaningful aspect
theta = np.radians(asp[sloped])
mean_dir = np.degrees(np.arctan2(np.sin(theta).mean(), np.cos(theta).mean())) % 360.0
resultant = float(np.hypot(np.sin(theta).mean(), np.cos(theta).mean()))
return {
"slope": slope_stats,
"aspect": {
"mean_direction_deg": round(float(mean_dir), 1),
"concentration": round(resultant, 3),
"naive_mean_deg": round(float(asp[sloped].mean()), 1),
},
}
if __name__ == "__main__":
paths = derive(Path("dtm.tif"), Path("derivatives"))
print(json.dumps(summarise(paths["slope"], paths["aspect"]), indent=2))The naive_mean_deg field is deliberate: printing both makes the difference visible to whoever reads the output next.
# Key Parameter Table
| Flag | Applies to | Effect |
|---|---|---|
-alg Horn |
slope, aspect | The standard 3×3 estimator; ZevenbergenThorne is smoother |
-p |
slope | Percent rise instead of degrees |
-s |
slope, hillshade | Ratio of vertical to horizontal units; 0.3048 for feet over metres |
-compute_edges |
all | Fills the one-pixel NoData border |
-zero_for_flat |
aspect | Flat cells become 0 rather than −9999 |
-co |
all | GeoTIFF creation options; compress and tile by default |
# Verification
Slope has no impossible values. Nothing above 90 degrees, and a maximum near 90 over natural terrain means the DTM has a spike.
Flat areas read as flat. Sample a car park or a lake margin; slope should be under a degree.
The circular mean differs from the naive one. On terrain with a genuine dominant aspect the two will disagree, and that disagreement is the whole reason for the extra code.
The derivative is of the DTM, not the DSM. Slope over a surface model measures roof pitch and canopy, which is occasionally what you want and usually not.
# Gotchas and Edge Cases
Resampling an aspect raster produces nonsense. Interpolating between 355 and 5 gives 180. Resample the DTM and re-derive instead.
NoData spreads. A single void becomes a 3×3 block of NoData in every derivative. Fill voids in the DTM first — see filling NoData voids.
Slope depends on cell size. A 0.5 m DTM yields steeper slopes than a 2 m DTM of the same hill. Comparisons must use one grid.
Flat cells have no aspect. Mask by slope before using aspect for anything.
# Frequently Asked Questions
Why can I not average aspect values?
Because aspect wraps at 360 degrees. Five cells facing 340, 350, 358, 6 and 18 degrees all face north, and their arithmetic mean is 176 degrees — almost due south. Average the unit vectors instead, with atan2 of the mean sine over the mean cosine, or reclassify to sectors first.
Should I use -zero_for_flat?
It depends what consumes the raster, and the important thing is to decide rather than inherit a default. Without it flat cells are NoData, which is honest and awkward. With it they are zero, which is a legitimate aspect value meaning north, so a downstream statistic will treat every flat cell as north-facing.
Why does my slope raster look far too steep?
Almost always a unit mismatch: elevations in US survey feet on a metric grid, with no scale factor. Every slope then comes out 3.28 times too steep. Pass the ratio with -s, or better, rasterize in a CRS whose vertical unit matches its horizontal one.
Can I resample an aspect raster to a coarser grid?
No. Interpolating between 355 and 5 degrees gives 180, so resampling manufactures south-facing cells out of north-facing ones. Resample the DTM and re-derive the aspect from it.
# Related
- Hillshade, Slope and Aspect — the parent guide to terrain derivatives
- Exporting Hillshade from a LiDAR DTM — the third derivative, and the unit trap in its original setting
- Building a Seamless DTM Mosaic from Tiles — why a seam in the DTM becomes a line in every derivative
- Filling NoData Voids in DTM Rasters — because a void spreads to a 3×3 block in every derivative
- Ground Filtering and DTM/DSM Generation with PDAL — the section overview