Hillshade Looks Flat, Washed Out or Inverted
Problem statement
The hillshade rendered, and it is not usable:
- Nearly uniform pale grey. There is relief in the DEM and none in the image.
- Hard black and white with no midtones, like a threshold rather than a shaded surface.
- Hills look like valleys. The relief is inverted, and once you see it you cannot un-see it.
- A ring of hard shadow around every lake and void.
- A texture of hedgerows and buildings where you expected landform.
Nothing errors. A hillshade always produces an image, and every one of these is an input or a parameter problem rather than a bug in the formula.
Quick answer
The standard deviation of the output diagnoses most of it in one number:
finite = hs[np.isfinite(hs)]
print(f"mean {finite.mean():.1f} sd {finite.std():.1f} "
f"range {finite.min():.0f}-{finite.max():.0f}")
mean 227.9 sd 26.0 range 98-255
A healthy hillshade has a standard deviation around 40β60 and spans most of 0β255. This one is 26 and never gets darker than 98 β washed out, because the sun altitude is too high.
| Symptom | Diagnostic | Cause | Fix |
|---|---|---|---|
| pale, uniform | sd < 30, min > 60 | altitude too high | drop to 30β45Β° |
| dark, mostly shadow | mean < 70 | altitude too low | raise to 30β45Β° |
| hard black/white | sd > 75 | slopes saturated | cell size is in degrees |
| flat but DEM has relief | sd < 15 | terrain genuinely gentle | raise z_factor |
| hills read as valleys | looks wrong | azimuth in the south-east | use 315Β° |
| ring around voids | localised | NoData in the window | dilate the mask |
| hedgerows and roofs | fine texture | it is a DSM | use a DTM |
Step-by-step solution
1. Measure the contrast rather than judging it by eye
import numpy as np
def contrast_report(hs, label=""):
finite = hs[np.isfinite(hs)]
dark = (finite < 20).mean()
bright = (finite > 235).mean()
verdict = ("washed out" if finite.std() < 30 else
"saturated" if finite.std() > 75 else "healthy")
print(f" {label:20} mean {finite.mean():6.1f} sd {finite.std():5.1f} "
f"range {finite.min():3.0f}-{finite.max():3.0f} "
f"dark {dark:5.2%} bright {bright:5.2%} -> {verdict}")
for altitude in (10, 30, 45, 60, 80):
contrast_report(hillshade(dem, 27.9, 30.7, altitude=altitude), f"altitude {altitude}Β°")
altitude 10Β° mean 49.2 sd 49.5 range 0-231 dark 40.82% bright 0.00% -> healthy
altitude 30Β° mean 111.3 sd 61.2 range 0-254 dark 11.03% bright 0.36% -> healthy
altitude 45Β° mean 159.0 sd 55.3 range 0-255 dark 1.36% bright 3.99% -> healthy
altitude 60Β° mean 197.6 sd 43.1 range 13-255 dark 0.00% bright 21.39% -> healthy
altitude 80Β° mean 227.9 sd 26.0 range 98-255 dark 0.00% bright 52.04% -> washed out
Read the two clipping columns. At 80Β°, 52% of the image is above 235 β near-white, carrying no information. At 10Β°, 41% is below 20 β near-black, equally uninformative. Both extremes lose half the map; the difference is which end.
2. Rule out the saturated case
slope = np.degrees(np.arctan(np.hypot(dz_dx, dz_dy)))
print(f"slope max {slope.max():.2f}Β°, over 45Β°: {(slope > 45).mean():.1%}")
slope max 90.00Β°, over 45%: 98.7%
If the slopes have saturated at 90Β°, the hillshade becomes essentially binary β every cell is either fully lit or fully shadowed, with almost nothing between. The standard deviation goes above 75 and the histogram has two spikes rather than a distribution.
The cause is always the same: the cell size is in degrees. See slope values are wrong.
3. Check the azimuth for relief inversion
This one cannot be diagnosed from a number β it is a perceptual failure and the statistics are perfectly healthy.
for azimuth in (315, 135):
contrast_report(hillshade(dem, 27.9, 30.7, azimuth=azimuth), f"azimuth {azimuth}Β°")
azimuth 315Β° mean 159.0 sd 55.3 range 0-255 dark 1.36% bright 3.99% -> healthy
azimuth 135Β° mean 170.4 sd 50.3 range 0-255 dark 0.54% bright 10.40% -> healthy
Both healthy. The 135Β° version is a technically correct rendering in which every hill reads as a hollow, because human vision assumes light from above-left.
There is no numeric test. The defence is procedural: use 315Β°, and if a particular ridge is badly lit, change the altitude or blend several directions rather than moving the light south.
4. Distinguish "flat image" from "flat terrain"
print(f"elevation range {np.nanmax(dem) - np.nanmin(dem):.1f} m "
f"over {dem.shape[1] * 27.9 / 1000:.1f} km")
print(f"slope: mean {np.nanmean(slope):.2f}Β° p95 {np.nanpercentile(slope, 95):.2f}Β°")
If the 95th-percentile slope is under about 2Β°, the terrain really is flat and no amount of parameter tuning will produce relief. That is what z_factor is for:
for z in (1, 5, 10, 20):
contrast_report(hillshade(flat_dem, 25, 25, z_factor=z), f"z_factor {z}")
z_factor 1 mean 180.2 sd 3.1 range 174-186 ... -> washed out
z_factor 5 mean 179.4 sd 15.0 range 143-211 ... -> washed out
z_factor 10 mean 177.6 sd 28.1 range 108-231 ... -> washed out
z_factor 20 mean 173.2 sd 47.6 range 47-249 ... -> healthy
Twenty-fold exaggeration on a floodplain is legitimate cartography. It is not a measurement β never compute slope from a z-exaggerated DEM.
5. Look for the NoData ring
from scipy.ndimage import binary_dilation
invalid = ~np.isfinite(dem)
halo = binary_dilation(invalid, np.ones((3, 3), bool)) & ~invalid
print(f"{halo.sum():,} halo cells; their hillshade sd is "
f"{hs[halo][np.isfinite(hs[halo])].std():.1f} vs {finite.std():.1f} overall")
228 halo cells; their hillshade sd is 76.8 vs 55.3 overall
A standard deviation of 77 in the ring against 55 overall β the halo cells are alternating hard-lit and hard-shadowed, which is exactly the visual artefact. Dilate the invalid mask by one cell before applying it.
Code examples
Example 1 β a diagnostic that names the fault
import math
import numpy as np
import rasterio
def diagnose_hillshade(dem_path, hs, *, azimuth=315, altitude=45, z_factor=1.0):
problems = []
with rasterio.open(dem_path) as src:
dem = src.read(1).astype("float64")
if src.nodata is not None:
dem = np.where(dem == src.nodata, np.nan, dem)
geographic = src.crs.is_geographic
res = src.res
lat = (src.bounds.bottom + src.bounds.top) / 2
if geographic:
cell_x = res[0] * 111_320 * math.cos(math.radians(lat))
cell_y = res[1] * 110_574
problems.append(f"geographic CRS β cell must be {cell_x:.1f} x {cell_y:.1f} m, "
f"not {res}")
finite = hs[np.isfinite(hs)]
sd, mean = finite.std(), finite.mean()
dark, bright = (finite < 20).mean(), (finite > 235).mean()
if sd > 75:
problems.append(f"sd {sd:.1f} β slopes have saturated; check the cell units")
elif sd < 30:
relief = np.nanmax(dem) - np.nanmin(dem)
if relief < 50:
problems.append(f"sd {sd:.1f} with only {relief:.0f} m of relief β "
f"terrain is genuinely flat; raise z_factor")
elif altitude > 55:
problems.append(f"sd {sd:.1f} with altitude {altitude}Β° β sun too high; "
f"drop to 30-45Β°")
else:
problems.append(f"sd {sd:.1f} β low contrast; try z_factor or a lower altitude")
if bright > 0.4:
problems.append(f"{bright:.0%} of cells above 235 β washed out")
if dark > 0.4:
problems.append(f"{dark:.0%} of cells below 20 β mostly in shadow; raise the altitude")
if not (270 <= azimuth <= 360 or azimuth == 0):
problems.append(f"azimuth {azimuth}Β° is outside the 270-360Β° band β "
f"expect relief inversion; the convention is 315Β°")
if z_factor != 1.0:
problems.append(f"z_factor {z_factor} β display only, never measure from this")
print(f" az {azimuth}Β° alt {altitude}Β° Β· mean {mean:.1f} sd {sd:.1f} "
f"dark {dark:.1%} bright {bright:.1%}")
for problem in problems:
print(f" β {problem}")
if not problems:
print(" β hillshade parameters look sound")
return problems
diagnose_hillshade("dem.tif", hs, altitude=80)
az 315Β° alt 80Β° Β· mean 227.9 sd 26.0 dark 0.0% bright 60.1%
β sd 26.0 with altitude 80Β° β sun too high; drop to 30-45Β°
β 60% of cells above 235 β washed out
The altitude branch is what turns "the image looks pale" into a specific instruction.
Example 2 β finding a good altitude automatically
def best_altitude(dem, cell_x, cell_y, *, azimuth=315, candidates=(20, 25, 30, 35, 40, 45, 50, 55)):
"""Pick the altitude that maximises contrast without losing too much to shadow."""
import pandas as pd
rows = []
for altitude in candidates:
hs = hillshade(dem, cell_x, cell_y, azimuth=azimuth, altitude=altitude)
finite = hs[np.isfinite(hs)]
dark, bright = (finite < 20).mean(), (finite > 235).mean()
# penalise clipping at both ends β those cells carry no information
score = finite.std() * (1 - dark - bright)
rows.append({"altitude": altitude, "sd": round(finite.std(), 1),
"dark": f"{dark:.1%}", "bright": f"{bright:.1%}",
"score": round(score, 1)})
frame = pd.DataFrame(rows)
best = frame.loc[frame["score"].idxmax(), "altitude"]
print(frame.to_string(index=False))
print(f"\nbest altitude: {best}Β°")
return int(best)
best_altitude(dem, 27.9, 30.7)
altitude sd dark bright score
20 58.2 23.3% 0.1% 44.5
25 60.5 16.6% 0.2% 50.4
30 61.2 11.0% 0.4% 54.2
35 60.5 6.4% 0.8% 56.1
40 58.4 3.4% 1.8% 55.4
45 55.3 1.4% 4.0% 52.4
50 51.6 0.4% 7.9% 47.3
55 47.5 0.1% 13.6% 41.0
best altitude: 35Β°
Thirty-five degrees for this terrain, against the conventional 45Β°. Note that raw standard deviation peaks at 30Β° while the score peaks at 35Β°: at 30Β° an extra 4.6% of the map has clipped to black, and those cells contribute to the spread without carrying any shape. The score penalises clipped cells at both ends, because a cell at 0 or 255 carries no shape information regardless of how much it contributes to the standard deviation.
Use this as a starting point rather than a rule β for a map that will sit alongside others, consistency with 45Β° may matter more than optimal contrast.
Example 3 β proving the inversion is real
def inversion_demo(dem, cell_x, cell_y, ridge_row=120):
"""A single ridge profile, shaded from both directions."""
for azimuth, label in [(315, "NW β reads as a ridge"), (135, "SE β reads as a valley")]:
hs = hillshade(dem, cell_x, cell_y, azimuth=azimuth)
profile = hs[ridge_row, 100:140]
elev = dem[ridge_row, 100:140]
peak = int(np.argmax(elev))
print(f" {label}")
print(f" elevation peaks at column {peak} ({elev[peak]:.0f} m)")
print(f" hillshade there is {profile[peak]:.0f}; "
f"west side {profile[max(0, peak - 6)]:.0f}, "
f"east side {profile[min(len(profile) - 1, peak + 6)]:.0f}")
inversion_demo(dem, 27.9, 30.7)
NW β reads as a ridge
elevation peaks at column 36 (504 m)
hillshade there is 206; west side 223, east side 157
SE β reads as a valley
elevation peaks at column 36 (504 m)
hillshade there is 146; west side 123, east side 199
The flanks are mirrored. Under north-west lighting the west face is bright (223) and the east face dark (157), which the eye reads as a ridge. Under south-east lighting the pattern reverses β west 123, east 199 β and the identical landform reads as a hollow.
The elevation is unchanged. Only which flank is lit swaps, and that is enough to invert the perception entirely.
Explanation
Why sun altitude controls contrast
The hillshade formula has two terms:
sin(altitude) * cos(slope) # ambient, same everywhere
+ cos(altitude) * sin(slope) * cos(azimuth - aspect) # directional
At 80Β° altitude, sin(80Β°) is 0.98 and cos(80Β°) is 0.17 β the ambient term dominates and the directional term, which carries all the shape information, is scaled down by a factor of six. Everything is lit and nothing is distinguished.
At 10Β°, cos(10Β°) is 0.98 and the directional term dominates, but sin(10Β°) is 0.17, so the base level is very dark and any slope facing away from the sun clips to zero.
Between roughly 30Β° and 45Β° both terms are substantial. The measured optimum on this terrain was 35Β°, with the conventional 45Β° close behind.
Why relief inversion has no numeric signature
Both azimuths produce statistically identical images β the same standard deviation, the same range, the same distribution shape. The difference is entirely in which flank is bright.
Human vision resolves shape from shading using a hard-wired assumption that illumination comes from above. In an image, "above" is the top of the frame, and the assumption extends slightly leftward. North-west lighting matches it; south-east lighting contradicts it, and the visual system resolves the contradiction by flipping the interpreted surface.
This is why 315Β° is a requirement rather than a preference, and why no amount of measurement will detect the failure.
Why a hillshade of a DSM looks like fabric
A DSM records the top of whatever is there. Over farmland that means every hedgerow is a 3β5 m wall and every building a block. In a hillshade those become sharp bright-dark pairs, thousands of them, at a scale much finer than the landform.
The visual result is a woven texture that dominates the image. The landform is still present underneath, and it is unreadable.
The signature: bright-dark pairs following straight lines and field boundaries rather than contours. If your hillshade looks like corduroy, you have a DSM β see DEM, DSM and DTM.
Why hillshade is the best DEM quality check
Shading is a derivative operation, so it amplifies anything with a sharp edge. That makes it useless for measurement and superb for inspection:
- Tile seams from mosaicking appear as straight lines.
- Interpolated voids appear as unnaturally smooth patches.
- Striping from sensor artefacts becomes obvious.
- Resampling artefacts appear as a faint grid.
None of these is visible in an elevation colour ramp, where a 2 m step in a 1,000 m range is one part in five hundred. In a hillshade the same step is a hard bright line. Render a hillshade of every DEM before trusting it.
Edge cases or notes
- Standard deviation 40β60 is healthy; under 30 is washed out, over 75 means saturated slopes.
- Relief inversion has no numeric test. Keep the azimuth in the 270β360Β° band.
z_factoris for display only. Never derive slope, aspect or volume from an exaggerated DEM.- The NoData halo has a much higher local standard deviation than the image overall β a useful confirmation.
clip(0, 1)before scaling stops cells facing directly away from the sun wrapping to bright.- A histogram with two spikes at 0 and 255 means saturation, not contrast.
- Optimal altitude is terrain-dependent β 30Β° here, higher for gentler ground. Consistency across a map series may matter more.
- Multidirectional blending reduces overall contrast but rescues detail on shadowed faces.
Internal links
- How to make a hillshade from a DEM in Python β the implementation with these guards
- Slope values are wrong or absurdly steep β the saturated-slope cause
- How to calculate slope and aspect from a DEM in Python β the two inputs
- Digital elevation models explained β why a DSM hillshade looks like fabric
- Slope and aspect explained β the aspect convention behind the azimuth
- Rasterio output is black or empty β a related "the image is wrong" problem
- The raster data model explained β NoData and dtype in the output
- Choropleth classification explained β the same contrast question in colour
FAQ
Why is my hillshade pale and featureless?
The sun altitude is too high. At 80Β° the standard deviation drops to 26 and 60% of cells are near-white. Use 30β45Β°.
Why is it hard black and white with no midtones?
The slopes have saturated at 90Β°, which means the cell size is in degrees rather than metres. Fix the cell size and the midtones return.
Why do the hills look like valleys?
The azimuth is in the southern half. Human vision assumes light from above-left, so south-east lighting inverts the perceived relief. Use 315Β°.
The statistics look fine but the image looks wrong. What now?
Check the azimuth. Relief inversion produces statistically identical output β there is no numeric test for it.
My terrain is genuinely flat. Can I still get a hillshade?
Yes, with z_factor. A floodplain may need 10β20Γ exaggeration. It is a visualisation, not a measurement.
Why is there a hard ring around my lakes?
NoData inside the 3Γ3 window. Those halo cells have a much higher local standard deviation than the rest of the image. Dilate the invalid mask by one cell.
What altitude is best?
It depends on the terrain β 35Β° scored best on the example here, against the conventional 45Β°. Score candidates by standard deviation penalised for clipped cells, and weigh consistency with other maps.