My LiDAR DTM Has Holes, Spikes or Terraces

On this page

Problem statement

Three defects appear in almost every lidar terrain model, and they have different causes and different fixes.

Holes β€” cells with no value. Measured on a real survey at 1 m cells, 3.67% of the cells it covered had no ground point, and none had no return at all. (A bounding-box count says 10.93% and 7.47%, because a grid over a rotated survey has empty corners.)

Spikes β€” single cells far below or above their neighbours. The per-cell minimum is maximally sensitive to one low point.

Terraces β€” flat steps across a slope, from a cell size too coarse for the terrain, or from a fill that propagated one value across a gap.

Quick answer

Diagnose before fixing:

import numpy as np


def diagnose_dtm(dtm, counts, cell=1.0, surveyed=None):
    s = np.ones(dtm.shape, bool) if surveyed is None else surveyed   # cells that count
    holes = ~np.isfinite(dtm)
    print(f"  holes           {holes[s].mean():7.2%}")
    print(f"  single-point cells {(counts == 1)[s].mean():7.2%} "
          "(one bad point sets the whole cell)")

    padded = np.pad(dtm, 1, constant_values=np.nan)
    neighbours = np.stack([padded[a:a + dtm.shape[0], b:b + dtm.shape[1]]
                           for a in range(3) for b in range(3)
                           if (a, b) != (1, 1)])
    residual = dtm - np.nanmedian(neighbours, axis=0)

    print(f"  residual vs 8-neighbour median: "
          f"p1 {np.nanpercentile(residual, 1):+.2f} m, "
          f"p99 {np.nanpercentile(residual, 99):+.2f} m")
    print(f"  cells more than 2 m below their neighbours: "
          f"{int(np.nansum(residual < -2)):,}")
    return residual


residual = diagnose_dtm(dtm, counts, surveyed=inside)
  holes             3.67%
  single-point cells   6.94% (one bad point sets the whole cell)
  residual vs 8-neighbour median: p1 -0.29 m, p99 +0.32 m
  cells more than 2 m below their neighbours: 1

A cell built from one ground point, sitting two metres below its neighbours, is a spike. A cell sitting two metres below with forty supporting points is terrain.

Three DTM defects: holes where no ground point landed, spikes from single low points, and terraces from coarse cells or propagated fills. three defects, three different fixeswhat you seewhat it isholesno ground point in the cell β€”check the countsingle-cell pitsone low noise point set theminimumflat steps across a slopecell too coarse, or a propagatedfilla plateau with a hard edgenearest-neighbour fill across avoidA smoothing filter treats all four the same and fixes none properly spatialworkflow.io
Different causes, different fixes. A smoothing filter treats all three the same and fixes none properly.

Which holes, cell size first, spikes and terraces

1. Holes: work out which kind you have

Measured at 1 m (Example 1), with a third kind of empty cell that a bounding-box grid adds:

outside the survey footprint :  7.67% of the grid
surveyed, no return at all   :  0.00% of surveyed cells
returns but no ground point  :  3.67%
ground observed              : 96.33%

The kinds need different treatment. Cells with returns but no ground point may be recoverable with a better ground filter, or by accepting last returns. Cells with no returns at all cannot be improved by any processing β€” still water often returns nothing to the laser β€” and this survey had none. Cells outside the footprint were never surveyed and should not be in the count.

Interpolating a lake into a hillside is the classic result of not making this distinction.

2. Holes: choose the cell size before choosing a fill

Counted over surveyed cells (point density explained runs the sweep):

0.25 m:  76.53% of cells have no ground point
0.50 m:  32.99%
1.00 m:   3.67%
2.00 m:   0.13%
5.00 m:   0.00%

Chasing a 0.5 m DTM on this survey means filling a third of it β€” at which point much of the product is a model, not a measurement.

3. Spikes: filter noise classes, then use a low percentile

The per-cell minimum takes the single lowest point. One multipath return below the surface sets that cell.

usable = ~np.isin(classification, [7, 18])       # declared noise
print(f"{(~usable).sum()} declared noise points")
2 declared noise points

This survey declares only two noise points, so the declared classes are not the problem. The fix that helps is replacing the strict minimum with a low percentile:

# 10th percentile of ground points in the cell, where there are enough
elevation = np.percentile(cell_points, 10) if len(cell_points) >= 5 \
    else cell_points.min()

With a median of four ground points per cell at 1 m, a percentile is only meaningful in the better-populated cells β€” which is exactly where the strict minimum is riskiest.

4. Spikes: check against the neighbourhood, not against a global threshold

A ground point three metres below its neighbours is a spike on a plateau and normal in a ravine. Compare each cell with the median of its eight neighbours and flag large negative residuals.

5. Terraces: check the cell size and the fill method

Terraces come from two places. A cell size much larger than the terrain's roughness quantises a smooth slope into steps. And a nearest-neighbour fill propagates one value across a gap, producing a plateau with a hard edge.

Nearest-neighbour fill is honest β€” the facets are visible and read as artefacts. A smooth interpolation over the same gap looks like terrain and is not. Choose deliberately, and ship the mask either way.

Cells with no returns at all separated from cells with returns but no ground point, with only the latter recoverable by reprocessing. two kinds of hole, one of them fixableground observed96.33%returns but no ground3.67%no return at all0.00%00.51share of surveyed 1 m cellsCounted over the bounding box, 7.47% have no return β€” the corners outside the survey spatialworkflow.io
Only one of these two hole types can be improved by better processing.

Separating hole types and filtering spikes

The data used on this page

The USGS 3DEP lidar survey of Brooks Camp, Katmai National Park, Alaska (2012), from the USGS Entwine Point Tile archive on AWS, AK_BrooksCamp_2012: every node under octree node 4-3-13-7 β€” 287 LAZ files, 12,968,770 points β€” joined into tile.laz, reprojected from Web Mercator to UTM zone 5N and loaded as arrays. The setup also builds the 1 m lowest-ground-point DTM with its per-cell ground counts, and inside marks the cells within the survey's footprint: the patch is turned 2.4Β° against the UTM grid, so the grid's bounding box has corners that were never surveyed.

import json
import os
import urllib.request

import laspy

EPT = "https://s3-us-west-2.amazonaws.com/usgs-lidar-public/AK_BrooksCamp_2012"
ROOT = (4, 3, 13, 7)                                     # depth, x, y, z of the octree node


def under(key):
    d, x, y, z = map(int, key.split("-"))
    step = 2 ** (d - ROOT[0])
    return d >= ROOT[0] and (x // step, y // step, z // step) == ROOT[1:]


hierarchy = json.load(urllib.request.urlopen(f"{EPT}/ept-hierarchy/0-0-0-0.json"))
nodes = sorted(k for k in hierarchy if under(k))
os.makedirs("nodes", exist_ok=True)
for key in nodes:
    path = f"nodes/{key}.laz"
    if not os.path.exists(path):
        urllib.request.urlretrieve(f"{EPT}/ept-data/{key}.laz", path + ".part")
        os.replace(path + ".part", path)

if not os.path.exists("tile.laz"):                       # the whole subtree as one file
    with laspy.open(f"nodes/{nodes[0]}.laz") as first:
        header = first.header
    with laspy.open("tile.laz", mode="w", header=header) as writer:
        for key in nodes:
            writer.write_points(laspy.read(f"nodes/{key}.laz").points)
with laspy.open("tile.laz") as reader:
    print(len(nodes), "nodes,", f"{reader.header.point_count:,} points,",
          f"{sum(os.path.getsize(f'nodes/{k}.laz') for k in nodes) / 1e6:.1f} MB of nodes")

import numpy as np
from pyproj import Transformer

las = laspy.read("tile.laz")
x, y = Transformer.from_crs(3857, 32605, always_xy=True).transform(las.x, las.y)   # UTM 5N metres
z = np.asarray(las.z)
classification = np.asarray(las.classification)
return_number = np.asarray(las.return_number)
number_of_returns = np.asarray(las.number_of_returns)

import shapely
from scipy.spatial import ConvexHull

hull = ConvexHull(np.c_[x, y])
footprint = shapely.Polygon(np.c_[x, y][hull.vertices])     # the ground the survey covers


def surveyed(transform, shape):
    """True for grid cells whose centre lies inside the survey footprint."""
    rows, cols = np.indices(shape)
    cx, cy = transform * (cols + 0.5, rows + 0.5)
    return shapely.contains_xy(footprint, cx, cy)

from rasterio.transform import from_origin

# the 1 m lowest-ground-point DTM and its ground-point counts, over the bounding box
CELL = 1.0
left, top = x.min(), y.max()
width, height = int(np.ceil((x.max() - left) / CELL)), int(np.ceil((top - y.min()) / CELL))
transform = from_origin(left, top, CELL, CELL)
ground = classification == 2
flat = (np.clip(((top - y[ground]) / CELL).astype(int), 0, height - 1) * width
        + np.clip(((x[ground] - left) / CELL).astype(int), 0, width - 1))
dtm = np.full(width * height, np.inf)
np.minimum.at(dtm, flat, z[ground])
dtm[np.isinf(dtm)] = np.nan
dtm = dtm.reshape(height, width)
counts = np.bincount(flat, minlength=width * height).reshape(height, width)
inside = surveyed(transform, dtm.shape)
287 nodes, 12,968,770 points, 82.8 MB of nodes

Example 1 β€” separating the hole types

import numpy as np


def classify_holes(x, y, classification, surveyed_cells, cell=1.0, ground_class=2):
    """Which empty cells were never surveyed, which had no return, and which had no ground?"""
    left, top = x.min(), y.max()
    width = int(np.ceil((x.max() - left) / cell))
    height = int(np.ceil((top - y.min()) / cell))

    def occupancy(mask):
        col = np.clip(((x[mask] - left) / cell).astype(int), 0, width - 1)
        row = np.clip(((top - y[mask]) / cell).astype(int), 0, height - 1)
        grid = np.zeros(width * height, bool)
        grid[row * width + col] = True
        return grid.reshape(height, width)

    any_return = occupancy(np.ones(len(x), bool))
    ground = occupancy(classification == ground_class)

    outside = ~surveyed_cells
    no_data = surveyed_cells & ~any_return
    no_ground = any_return & ~ground

    print(f"  outside the footprint   {outside.mean():7.2%}  of the grid (never surveyed)")
    s = surveyed_cells
    print(f"  no return at all        {no_data[s].mean():7.2%}  "
          "(water or a gap β€” not recoverable)")
    print(f"  returns but no ground   {no_ground[s].mean():7.2%}  "
          "(canopy or buildings β€” may be recoverable)")
    print(f"  ground observed         {ground[s].mean():7.2%}")
    return {"outside": outside, "no_data": no_data, "no_ground": no_ground, "ground": ground}


holes = classify_holes(x, y, classification, inside)
  outside the footprint     7.67%  of the grid (never surveyed)
  no return at all          0.00%  (water or a gap β€” not recoverable)
  returns but no ground     3.67%  (canopy or buildings β€” may be recoverable)
  ground observed          96.33%

Filling only no_ground and leaving no_data and outside empty is the honest default. It also produces a DTM whose holes are lakes, which looks right on a hillshade.

Holes, spikes and fills measured on the 1 m DTM of the Brooks Camp lidar, counting only cells inside the survey. the 1 m Brooks Camp DTM, checked for each defectcheckresultsurveyed cells with no ground point3.67%surveyed cells with no return at all0.00%cells built from a single ground point6.94%residual against the 8 neighbours, p1 to p99βˆ’0.29 to +0.32 mcells more than 1.5 m below their neighbours2, both thinly supportedholes left after a 5 m nearest fillnone inside the surveyA clean survey: its only defects were thin cells and small holes spatialworkflow.io
The quick answer and Examples 1–3 on this survey. Run the same checks before choosing a fix; here there was little to fix.

Example 2 β€” a spike filter that uses the neighbourhood

import numpy as np
from scipy.ndimage import generic_filter


def remove_spikes(dtm, counts, drop_below=1.5, min_support=3):
    """Flag cells far below their neighbours, especially thinly supported ones."""
    def neighbour_median(window):
        centre = window[len(window) // 2]
        others = np.delete(window, len(window) // 2)
        others = others[np.isfinite(others)]
        return np.nan if others.size < 3 else np.nanmedian(others)

    medians = generic_filter(dtm, neighbour_median, size=3, mode="constant",
                             cval=np.nan)
    residual = dtm - medians

    spike = (residual < -drop_below) & np.isfinite(residual)
    thin = spike & (counts < min_support)

    print(f"  {int(spike.sum()):,} cells more than {drop_below} m below "
          f"their neighbours ({spike.mean():.3%})")
    print(f"  of those, {int(thin.sum()):,} have fewer than {min_support} "
          "supporting ground points")

    cleaned = dtm.copy()
    cleaned[thin] = medians[thin]
    print(f"  replaced {int(thin.sum()):,} thinly supported spikes")
    return cleaned, spike


cleaned, spike = remove_spikes(dtm, counts)
  2 cells more than 1.5 m below their neighbours (0.000%)
  of those, 2 have fewer than 3 supporting ground points
  replaced 2 thinly supported spikes

Replacing only the thinly supported spikes is the important restriction. A cell three metres below its neighbours with forty ground points in it is a ditch, and smoothing it away destroys real terrain. This survey was clean: two cells fell more than 1.5 m below their neighbours, and both rested on fewer than three ground points.

Example 3 β€” a fill with a distance limit and a mask

import numpy as np
from scipy.ndimage import distance_transform_edt


def fill_dtm(dtm, cell=1.0, max_distance_m=5.0, method="nearest",
             never_fill=None):
    """Fill small gaps, leave large ones, and record which is which."""
    observed = np.isfinite(dtm)
    if never_fill is not None:
        fillable = ~observed & ~never_fill
    else:
        fillable = ~observed

    distance = distance_transform_edt(~observed) * cell
    indices = distance_transform_edt(~observed, return_distances=False,
                                     return_indices=True)

    filled = dtm.copy()
    target = fillable & (distance <= max_distance_m)
    filled[target] = dtm[tuple(indices)][target]

    print(f"  {int(observed.sum()):,} observed, "
          f"{int(target.sum()):,} filled (within {max_distance_m} m), "
          f"{int((~observed & ~target).sum()):,} left empty")
    if never_fill is not None:
        print(f"  {int(never_fill.sum()):,} cells excluded from filling "
              "(never surveyed, or no returns at all)")
    return filled, observed


filled, observed = fill_dtm(cleaned, never_fill=holes["outside"] | holes["no_data"])
print(f"  surveyed cells still empty: {np.isnan(filled[inside]).mean():.2%}")
  684,275 observed, 26,019 filled (within 5.0 m), 57,958 left empty
  58,898 cells excluded from filling (never surveyed, or no returns at all)
  surveyed cells still empty: 0.00%

Passing never_fill is what keeps the lakes as lakes. Without it, the nearest-neighbour fill extends the shoreline elevation across the water and produces a flat plateau where the lake was. Here it held the grid's unsurveyed corners β€” the only cells with no returns β€” and every hole inside the survey was within 5 m of a ground point and got filled.

Why the minimum reduction is fragile

Why the minimum reduction is fragile

The per-cell minimum is chosen because ground classification errs upward: low vegetation and building edges are occasionally accepted as ground, and genuine ground points are rarely invented. Taking the lowest is the best defence against that.

The cost is total sensitivity in the other direction. A single point below the true surface β€” multipath, a sensor artefact, a misregistered flight line β€” sets the cell entirely, regardless of how many good points are also there.

With a median of four ground points per cell at 1 m, most cells have very little redundancy. A 10th percentile is barely different from the minimum at four points, which is why the honest fix is often a coarser cell rather than a cleverer statistic.

Why some holes cannot be fixed

Water absorbs near-infrared strongly. A pulse hitting still water returns nothing detectable, so the cell is empty and no reprocessing changes that.

A floor that does not move with cell size is the signature of real absence rather than sampling β€” but check what it is before calling it water. Counted over the bounding box, this survey showed a floor of 7.47% at 1 m and 7.29% at 5 m; counted over the surveyed cells it vanished, because every one of those cells was outside the survey.

Cells with returns but no ground point are a different matter. There, the laser reached something; the ground classifier either could not see the ground through the canopy or declined to accept a candidate. Some of those are recoverable with a different filter.

Why nearest-neighbour fill is often the right choice

A smooth interpolation across a hole produces something that looks like terrain: continuous, plausibly sloped, and indistinguishable from measurement in the output raster.

Nearest-neighbour fill produces visible facets. That is uglier and more honest β€” a reader looking at the hillshade can see where the data stops.

Either way the mask is what matters. A second band recording observed versus filled costs one band and lets every downstream user decide.

Why terracing is usually a cell-size problem

If the cell is much larger than the horizontal scale of the terrain's variation, each cell reduces a range of elevations to one number, and the result is a staircase.

The tell is that the step height matches the elevation change across one cell. If a 5 m DTM on a 20% slope has 1 m steps, that is exactly the cell size times the gradient, and the fix is a finer cell β€” if the point density supports one.

Where it does not, the honest response is to accept the coarser product rather than to interpolate a smooth surface that implies detail the survey never measured.

A cell far below its neighbours judged by how many ground points support it. a low cell is a spike or a ditch β€” the count decides2 m below neighboursverdict1 supporting pointβœ•yesβœ•likely a spike4 supporting points!yes!uncertain40 supporting pointsβœ“yesβœ“terrainReplace only thinly supported outliers; a well-supported low cell is a ditch spatialworkflow.io
Depth alone cannot distinguish a spike from a ditch. The supporting point count can.

Water, fill distance and no-ground cells

  • Separate "no returns" from "no ground points". Only the second is recoverable.
  • Never fill across water. Pass an exclusion mask.
  • Limit the fill distance. Filling a 2 m gap is reasonable; a 200 m one is fabrication.
  • Ship an observation mask as a second band.
  • Filter noise classes before any minimum-based reduction.
  • Only smooth thinly supported spikes. A well-supported low cell is terrain.
  • Terraces usually mean the cell is too coarse for the slope.
  • Never fill with zero. It reads as a hole in every hillshade and every flow model.

FAQ

Why does my lidar DTM have holes?

Two reasons with different fixes: cells where no pulse returned at all (water, flight-line edges β€” none here) and cells with returns but no ground-classified point (canopy, buildings β€” 3.67% of the surveyed 1 m cells here). Count only cells inside the survey, or its bounding box's corners join the first group.

Should I fill the holes?

Fill the small ones, limit the fill distance, exclude water, and ship a mask saying which cells were observed.

Why does my DTM have single-cell pits?

The per-cell minimum takes the lowest point, so one low noise return sets the whole cell. Filter noise classes, and replace only thinly supported outliers with the neighbourhood median.

Why is my DTM terraced?

Usually the cell size is too coarse for the slope, so each cell reduces a range of elevations to one value. Check whether the step height equals the cell size times the gradient.

Can I just smooth the DTM?

Smoothing treats holes, spikes and terraces identically and fixes none properly. It also removes real terrain detail β€” a genuine ditch looks exactly like a spike to a smoothing filter.

What cell size avoids holes?

On this survey, 2 m left 0.13% of the surveyed cells empty and 1 m left 3.67%. Below 1 m the hole fraction rises steeply: a third at 0.5 m.

Why should I never fill with zero?

Zero is a valid elevation. A filled zero in a valley reads as a deep pit in every hillshade, contour set and flow-direction model.