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
Hillshade standard deviation bands: under 30 washed out, 40 to 60 healthy, over 75 saturated.
One number separates three of the failure modes. Compute it before looking at the image.

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.

Three hillshade value histograms: a narrow peak near white for washed out, a broad spread for healthy, and two spikes at the extremes for saturated.
The histogram shape names the fault. A healthy hillshade is broad; the two failures are a narrow peak or two spikes.

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.

A ridge profile with the west flank bright and east flank dark under north-west light, and the pattern mirrored under south-east light.
Identical elevations, identical summit shade. Only the flanks swap β€” and that inverts the perception.

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_factor is 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.

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.