Slope Values Are Wrong or Absurdly Steep
Problem statement
The slope raster came out and the numbers cannot be right:
print(f"mean {slope.mean():.2f}Β° max {slope.max():.2f}Β° over 45Β°: {(slope > 45).mean():.1%}")
mean 88.79Β° max 90.00Β° over 45Β°: 98.7%
Ninety-eight percent of the landscape steeper than 45Β°. Snowdonia is steep; it is not a wall.
Or the failure is quieter β everything looks plausible and is 15% off. Or there is a ring of 90Β° cells around every lake. Or the slope map is fine and the aspect map has an enormous north-facing plateau that does not exist.
Slope is a derived product, and every one of these is an input problem rather than a formula problem.
Quick answer
Print the four numbers that determine the answer:
import math
with rasterio.open("dem.tif") as src:
print(f"CRS {src.crs} (geographic: {src.crs.is_geographic})")
print(f"res {src.res}")
print(f"nodata {src.nodata}")
dem = src.read(1)
print(f"dtype {dem.dtype}")
print(f"elev range {dem.min()} to {dem.max()}")
if src.crs.is_geographic:
lat = (src.bounds.bottom + src.bounds.top) / 2
print(f"cell metres {src.res[0] * 111_320 * math.cos(math.radians(lat)):.1f} x "
f"{src.res[1] * 110_574:.1f}")
CRS EPSG:4326 (geographic: True)
res (0.00041666666666666664, 0.0002777777777777778)
nodata None
dtype int16
elev range -32768 to 1074
cell metres 27.9 x 30.7
Three faults visible at once: degrees being used as a distance, an undeclared -32768 NoData, and an integer dtype.
| Symptom | Cause | Fix |
|---|---|---|
| mean near 89Β°, max exactly 90Β° | cell size in degrees | convert to metres by latitude |
| ring of 90Β° around water or voids | NoData in the 3Γ3 window | mask before computing, dilate after |
| plausible but ~4% off | square-cell assumption | use both cell_x and cell_y |
| ~15% too shallow via GDAL | -s 111120 on a lat-adjusted DEM |
reproject first, or compute per-axis |
| slope fine, aspect rotated 90Β° | np.gradient axis order |
rows are y, columns are x |
| huge phantom north-facing area | flat cells | arctan2(0, 0) is 0 β mask it |
| cliffs at every treeline | it is a DSM, not a DTM | use a bare-earth DTM |
Step-by-step solution
1. Check the cell units before anything else
if src.crs.is_geographic:
raise ValueError(f"res {src.res} is in DEGREES β a gradient computed from it "
f"is ~90,000x too large")
This is the cause of the 88.79Β° result, and the arithmetic is worth seeing. A 100 m rise over one cell:
rise = 100.0
print(f"over 0.000417 degrees: gradient {rise / 0.000417:>12,.0f} -> "
f"{math.degrees(math.atan(rise / 0.000417)):.4f}Β°")
print(f"over 27.9 metres: gradient {rise / 27.9:>12,.2f} -> "
f"{math.degrees(math.atan(rise / 27.9)):.4f}Β°")
over 0.000417 degrees: gradient 239,808 -> 89.9998Β°
over 27.9 metres: gradient 3.58 -> 74.4272Β°
arctan of 239,808 is indistinguishable from 90Β°, so every cell with any relief at all saturates. That is why the mean is 88.79Β° rather than something merely too large.
2. Find the NoData that is not declared
import numpy as np
print("declared:", src.nodata)
print("values below -100:", np.unique(dem[dem < -100])[:5])
print("count:", (dem < -100).sum())
declared: None
values below -100: [-32768]
count: 3,412
-32768 is a common int16 NoData sentinel. Left in place it produces a height difference of 33,000 m across a 28 m cell β a gradient of 1,200, which is 89.95Β°.
And it does not affect only those cells. Every 3Γ3 window containing one is contaminated, so a NoData lake gets a one-cell ring of 90Β° cliffs around its entire shoreline:
dem = np.where(dem < -100, np.nan, dem)
3. Mask the output one cell wider than the input
from scipy.ndimage import binary_dilation
invalid = ~np.isfinite(dem)
halo = binary_dilation(invalid, np.ones((3, 3), bool))
slope = np.where(halo, np.nan, slope)
Masking only the original NoData cells leaves the ring behind, because those neighbouring cells had valid elevations β their windows did not. The dilation is what removes the halo.
4. Use both cell dimensions
print(f"cell_x {cell_x:.1f} m, cell_y {cell_y:.1f} m, ratio {cell_x / cell_y:.3f}")
cell_x 27.9 m, cell_y 30.7 m, ratio 0.909
Copernicus DEM adjusts longitude spacing by latitude band so ground cells stay near-square β "near" being 9% out at 53Β°N. Using cell_x for both axes gives:
using both: mean 21.33Β° over 45Β°: 2.8%
using cell_x: mean 22.28Β° over 45Β°: 3.9%
A 4.5% error in the mean and a 39% error in "how much land is steeper than 45Β°". Nothing about the output looks wrong.
5. Check the aspect separately
Slope can be right while aspect is wrong, because slope uses the gradient magnitude and aspect uses its direction.
import pandas as pd
octants = pd.cut(aspect_valid, bins=np.arange(-22.5, 361, 45),
labels=["N", "NE", "E", "SE", "S", "SW", "W", "NW", "N2"], ordered=False)
print(octants.value_counts(normalize=True).sort_index().round(3).to_string())
A real landscape has an uneven but not extreme aspect distribution. Two red flags:
- One octant far above 30% β usually flat cells defaulting to north.
- The distribution rotated by 90Β° compared with what the terrain looks like β an axis-order mistake in
np.gradient.
Code examples
Example 1 β a diagnostic that names the fault
import math
import numpy as np
import rasterio
def diagnose_slope(dem_path, slope=None, *, nodata_below=-100):
problems = []
with rasterio.open(dem_path) as src:
raw = src.read(1)
crs, res, declared, bounds = src.crs, src.res, src.nodata, src.bounds
if crs is None:
problems.append("no CRS β cell units unknown")
elif crs.is_geographic:
lat = (bounds.bottom + bounds.top) / 2
cell_x = res[0] * 111_320 * math.cos(math.radians(lat))
cell_y = res[1] * 110_574
problems.append(
f"geographic CRS: res {res} is DEGREES. Ground cell is "
f"{cell_x:.1f} x {cell_y:.1f} m at {lat:.2f}Β°N"
)
if abs(cell_x / cell_y - 1) > 0.02:
problems.append(f"cells are not square (ratio {cell_x / cell_y:.3f}) β "
f"use both dimensions, not one")
else:
cell_x, cell_y = res
if np.issubdtype(raw.dtype, np.integer):
problems.append(f"dtype is {raw.dtype} β cast to float64 before dividing")
hidden = raw < nodata_below
if declared is None and hidden.any():
problems.append(
f"nodata is None but {hidden.sum():,} cells are below {nodata_below} "
f"(values {np.unique(raw[hidden])[:3]}) β undeclared NoData"
)
if slope is not None:
finite = slope[np.isfinite(slope)]
if finite.size:
if finite.max() > 89.9:
problems.append(f"max slope {finite.max():.2f}Β° β cell units are wrong")
elif finite.max() > 80:
problems.append(f"max slope {finite.max():.2f}Β° β implausible for real terrain")
steep = (finite > 45).mean()
if steep > 0.3:
problems.append(f"{steep:.0%} of cells over 45Β° β implausible")
print(f" slope: mean {finite.mean():.2f}Β° max {finite.max():.2f}Β° "
f"over 45Β°: {steep:.1%}")
print(f" {crs} Β· res {res} Β· dtype {raw.dtype} Β· "
f"elev {raw.min()} to {raw.max()}")
for problem in problems:
print(f" β {problem}")
if not problems:
print(" β inputs look sound")
return problems
diagnose_slope("dem.tif", slope)
EPSG:4326 Β· res (0.0004166666666666666, 0.0002777777777777778) Β· dtype int16 Β· elev -32768 to 1074
slope: mean 88.79Β° max 90.00Β° over 45%: 98.7%
β geographic CRS: res (0.0004166666666666666, 0.0002777777777777778) is DEGREES. Ground cell is 27.9 x 30.7 m at 53.07Β°N
β cells are not square (ratio 0.909) β use both dimensions, not one
β dtype is int16 β cast to float64 before dividing
β nodata is None but 3,412 cells are below -100 (values [-32768]) β undeclared NoData
β max slope 90.00Β° β cell units are wrong
β 99% of cells over 45Β° β implausible
Six findings from one call, four of them independent faults. Every one would have to be fixed before the slope raster meant anything.
Example 2 β removing the NoData halo
from scipy.ndimage import binary_dilation
def masked_slope(dem, cell_x, cell_y, *, nodata_below=-100):
invalid = ~np.isfinite(dem) | (dem < nodata_below)
filled = np.where(invalid, np.nanmean(dem[~invalid]), dem)
p = np.pad(filled, 1, mode="edge")
a, b, c = p[:-2, :-2], p[:-2, 1:-1], p[:-2, 2:]
d, f = p[1:-1, :-2], p[1:-1, 2:]
g, h, i = p[2:, :-2], p[2:, 1:-1], p[2:, 2:]
dz_dx = ((c + 2 * f + i) - (a + 2 * d + g)) / (8 * cell_x)
dz_dy = ((g + 2 * h + i) - (a + 2 * b + c)) / (8 * cell_y)
slope = np.degrees(np.arctan(np.hypot(dz_dx, dz_dy)))
naive = np.where(invalid, np.nan, slope)
halo = binary_dilation(invalid, np.ones((3, 3), bool))
correct = np.where(halo, np.nan, slope)
print(f" masking only NoData: max {np.nanmax(naive):.2f}Β° "
f"cells over 80Β°: {np.nansum(naive > 80):,}")
print(f" masking the halo too: max {np.nanmax(correct):.2f}Β° "
f"cells over 80Β°: {np.nansum(correct > 80):,}")
print(f" the halo cost {halo.sum() - invalid.sum():,} extra cells")
return correct
slope = masked_slope(dem, cell_x=27.9, cell_y=30.7)
masking only NoData: max 84.42Β° cells over 80Β°: 47
masking the halo too: max 65.70Β° cells over 80Β°: 0
the halo cost 228 extra cells
Every cell over 80Β° disappears β all 47 of them were halo cells, and the maximum drops from 84.42Β° to 65.70Β°, which is the real terrain maximum. Losing 228 cells to NaN is a far better outcome than keeping 47 fake cliffs and a corrupted maximum.
The size of the effect depends on how the void boundary is oriented relative to the slope, which is why the maximum here is 84Β° rather than the 90Β° a deep -32768 sentinel would produce.
Example 3 β catching the aspect axis-order mistake
def compare_aspect(dem, cell_x, cell_y):
"""np.gradient takes spacings in ARRAY order β rows (y) first."""
dy_right, dx_right = np.gradient(dem, cell_y, cell_x)
dy_wrong, dx_wrong = np.gradient(dem, cell_x, cell_y) # swapped
def summarise(dz_dx, dz_dy, label):
gradient = np.hypot(dz_dx, dz_dy)
slope = np.degrees(np.arctan(gradient))
aspect = np.where(gradient < 1e-8, np.nan,
np.degrees(np.arctan2(dz_dy, -dz_dx)) % 360)
counts, _ = np.histogram(aspect[np.isfinite(aspect)], bins=np.linspace(0, 360, 9))
share = counts / counts.sum()
names = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
print(f" {label:16} slope mean {np.nanmean(slope):6.2f}Β° aspect: "
+ " ".join(f"{n}{v:.0%}" for n, v in zip(names, share)))
summarise(dx_right, dy_right, "correct order")
summarise(dx_wrong, dy_wrong, "swapped")
compare_aspect(dem_clean, cell_x=27.9, cell_y=30.7)
correct order slope mean 21.55Β° aspect: N8% NE11% E11% SE13% S13% SW15% W16% NW12%
swapped slope mean 21.76Β° aspect: N7% NE12% E13% SE12% S12% SW16% W19% NW10%
The slopes differ by 0.21Β° β invisible. The aspect distribution shifts subtly and stays entirely plausible. This is why the axis-order mistake survives review: nothing about either output is obviously wrong, and the aspect map is quietly rotated.
The defence is not inspection, it is not using np.gradient for this. The explicit nine-slice form in the slope how-to has no axis ambiguity.
Explanation
Why the degree error saturates rather than merely exaggerating
If the cell size were merely 10Γ too small you would see slopes 10Γ too steep and notice immediately. The degree error is about 90,000Γ too small, and arctan compresses everything above a gradient of roughly 50 into the last degree before vertical.
So the output is not "too steep" in a way that scales β it is uniformly 89-point-something everywhere there is any relief at all, with a mean of 88.79Β° and a max of exactly 90.00Β°. That flatness at the top is the signature: a genuinely steep landscape has a spread of values, not a spike at the maximum.
Why the square-cell error is the dangerous one
Every other fault on the list is either obvious (90Β° everywhere) or localised (a ring around a lake). Assuming square cells produces a slope raster that is plausible everywhere and wrong by a few percent.
Where it bites is thresholds. A 4.5% shift in the mean becomes a 39% shift in "land steeper than 45Β°", because the threshold sits on the tail of the distribution where a small horizontal shift moves a lot of cells. Any analysis with a slope cutoff β buildable land, machine-accessible forestry, avalanche terrain β inherits that amplification.
Why a DSM produces cliffs at treelines
A DSM records the canopy top. At a forest edge the surface drops 15β25 m over one or two cells, which is a genuine gradient of the surface and a completely fictional gradient of the ground.
The signature is unmistakable once you look for it: slope maxima that trace field boundaries, hedgerows and plantation edges rather than following the terrain. If your steepest cells form long straight lines through gentle countryside, you have a DSM.
Global DEMs β Copernicus, SRTM, ASTER β are all DSMs. See DEM, DSM and DTM.
Why to check against something you know
Every fault here is caught by one comparison against reality:
print(f"max elevation {np.nanmax(dem):.1f} m (Snowdon is 1085 m)")
print(f"max slope {np.nanmax(slope):.2f}Β° (real terrain rarely exceeds 70Β° over 30 m)")
print(f"land over 45Β°: {np.nanmean(slope > 45):.1%} (a few percent, even in mountains)")
max elevation 1074.5 m (Snowdon is 1085 m)
max slope 65.70Β° (real terrain rarely exceeds 70Β° over 30 m)
land over 45Β°: 2.8% (a few percent, even in mountains)
Three numbers, three known reference points. This catches unit errors, NoData contamination, datum problems and dtype truncation in one pass, and it takes longer to read than to run.
Edge cases or notes
- A max of exactly 90.00Β° is always a bug. Real terrain over a 30 m cell tops out near 70Β°.
nodata=Nonedoes not mean there is none. Check for-32768,-9999and0-as-sea.- Integer DEMs truncate the gradient. Cast to
float64first. - The NoData halo is one cell wide for a 3Γ3 window. Dilate the mask before applying it.
np.gradientspacings are in array order β rows then columns. Swapping them rotates aspect and leaves slope essentially unchanged.- Flat cells have no aspect. Guard on the gradient magnitude, or you get a phantom north-facing plateau.
gdaldem -scannot fix a latitude-adjusted geographic DEM β one factor for two different ground spacings. Reproject or compute per-axis.- Slope statistics are resolution-dependent. A 1 m DTM and a 30 m DEM give genuinely different answers for the same hillside; report the source resolution.
Internal links
- How to calculate slope and aspect from a DEM in Python β the implementation with these guards in place
- Slope and aspect explained β what the four decisions are
- Digital elevation models explained β DSM cliffs at treelines
- Vertical datums explained β why slope is unaffected by the vertical zero
- The raster data model explained β dtype, NoData and the transform
- Hillshade looks flat, washed out or inverted β the same inputs, a different symptom
- Rasterio output is black or empty β NoData contaminating a different product
- Projected vs geographic CRS explained β why degrees are not distances
FAQ
Why is my maximum slope exactly 90Β°?
The cell size is in degrees. The gradient is roughly 90,000 times too large and arctan saturates. Convert the cell size to metres using the latitude.
Why is there a ring of cliffs around my lake?
NoData inside the 3Γ3 window. The lake cells were masked; their neighbours were not, but their windows touched the void. Dilate the invalid mask by one cell before applying it.
My slope looks fine but is a few percent off. What causes that?
Assuming square cells. On a Copernicus DEM at 53Β°N the ground cell is 27.9 Γ 30.7 m, and using one value for both shifts the mean by 4.5% and the "over 45Β°" share by 39%.
Why is my aspect map mostly north-facing?
Flat cells. arctan2(0, 0) returns 0, which reads as due north. Set aspect to NaN where the gradient is essentially zero.
The slope is right but the aspect looks rotated. Why?
np.gradient takes spacings in array order β rows (y) first. Swapping them rotates aspect while leaving slope almost unchanged, so it survives casual review.
Why do my steepest cells form straight lines across farmland?
You have a DSM, and those lines are hedgerows and field boundaries. Use a bare-earth DTM for terrain questions.
How do I know my slope raster is right?
Check three things against reality: the maximum elevation against a known summit, the maximum slope against about 70Β°, and the share of land over 45Β° against a few percent.