Removing Pits and Spikes from a DSM
TL;DR: Compare the DSM with a 3×3 or 5×5 median-filtered copy; cells more than a threshold below it (for example 2 m) are pits, cells more than a threshold above it are spikes. Replace only those cells with the median value and leave everything else untouched. Fix what you can upstream — remove classes 7 and 18, keep first returns only, use output_type: "max" with a radius that covers each cell — so fewer cells need repair.
# Context and Motivation
This guide is part of DSM Generation. A DSM from the highest return per cell should trace roofs and canopy tops. Two artefacts spoil it. Pits are cells far below their neighbours: a pulse passed through a gap in a crown or between roof tiles and no first return higher up landed in that cell, so the maximum is a branch or the ground. Spikes are cells far above their neighbours: a bird, a wire, a crane jib or a high-noise return. In a hillshade, pits look like holes drilled into canopy and spikes like needles; in a canopy height model, pits break crowns into fragments and spikes create giant trees.
Blanket smoothing removes both but blurs every roof edge. The better approach is to detect the artefacts explicitly and replace only them.
# Prerequisites and Assumptions
- A DSM GeoTIFF from first returns, or the classified point cloud to rebuild it.
- rasterio, NumPy and SciPy.
- A sense of expected surface roughness: canopy tolerates larger local variation than roofs.
# Step-by-Step Implementation
# Step 1 — Prevent artefacts upstream
Remove noise classes before rasterizing, use first returns only, and set radius so each cell sees returns from its neighbours — which fills many pits before they exist.
# Step 2 — Build a reference surface
A median filter of 3×3 (or 5×5 in canopy) is robust to single-cell outliers and preserves edges better than a mean.
# Step 3 — Flag pits and spikes
Pits: DSM − median < −pit_threshold. Spikes: DSM − median > spike_threshold. Thresholds of 1.5–3 m suit canopy; 0.5–1 m suit urban roofs.
# Step 4 — Replace only flagged cells
Copy the median value into flagged cells; all other cells keep their original values.
# Step 5 — Iterate once if needed
Clusters of adjacent pits survive a 3×3 median. A second pass with 5×5 catches most of them.
# Complete Working Example
"""Detect and repair pits and spikes in a DSM without blurring real edges."""
from __future__ import annotations
import numpy as np
import rasterio
from scipy import ndimage as ndi
def repair(src: str, dst: str, pit_m: float = 2.0, spike_m: float = 3.0,
sizes: tuple[int, ...] = (3, 5)) -> dict:
with rasterio.open(src) as ds:
z = ds.read(1, masked=True)
profile = ds.profile
valid = ~z.mask
arr = z.filled(np.nan).astype("float64")
stats = {"pits": 0, "spikes": 0}
for size in sizes:
filled = np.where(valid, arr, np.nanmedian(arr))
med = ndi.median_filter(filled, size=size)
diff = arr - med
pits = valid & (diff < -pit_m)
spikes = valid & (diff > spike_m)
arr[pits | spikes] = med[pits | spikes]
stats["pits"] += int(pits.sum())
stats["spikes"] += int(spikes.sum())
out = np.where(valid, arr, profile.get("nodata", -9999)).astype("float32")
with rasterio.open(dst, "w", **profile) as o:
o.write(out, 1)
stats["share_repaired"] = round((stats["pits"] + stats["spikes"]) / valid.sum(), 5)
return stats
if __name__ == "__main__":
print(repair("out/dsm.tif", "out/dsm_clean.tif"))And the upstream PDAL settings that reduce how much repair is needed:
{
"pipeline": [
"tiles/t_0431.laz",
{ "type": "filters.range", "limits": "Classification![7:7],Classification![18:18]" },
{ "type": "filters.outlier", "method": "statistical", "mean_k": 10, "multiplier": 3.0, "class": 18 },
{ "type": "filters.range", "limits": "Classification![18:18]" },
{ "type": "filters.range", "limits": "ReturnNumber[1:1]" },
{ "type": "writers.gdal", "filename": "out/dsm.tif", "resolution": 0.5, "radius": 0.5,
"output_type": "max", "data_type": "float32", "nodata": -9999 }
]
}A radius equal to the resolution (rather than 0.71 × resolution) lets each cell take the maximum of a slightly larger neighbourhood, filling many single-cell pits at the cost of marginally widening objects.
# Key Parameter Table
| Setting | Canopy | Urban | Effect |
|---|---|---|---|
pit_m |
2–3 m | 0.5–1 m | Depth below the median flagged as a pit |
spike_m |
3–5 m | 1–2 m | Height above the median flagged as a spike |
| median sizes | (3, 5) | (3,) | Larger windows catch clustered pits, risk edges |
radius in writers.gdal |
1.0 × res | 0.71 × res | Larger fills pits upstream, widens objects |
| input returns | first | first | Last returns create pits by design |
# Verification
- Repaired share. Typically under 1 percent of cells in canopy and far less in towns. Much more suggests thresholds are too tight and real texture is being flattened.
- Edges untouched. Difference original and repaired DSMs; non-zero cells should be isolated points, not lines along roof edges.
- Hillshade. Holes in canopy and needles on roofs should be gone.
# Gotchas and Edge Cases
Thresholds too tight in canopy. Crowns are rough; a 1 m pit threshold in forest flags genuine gaps between branches and smooths the canopy. Use canopy-appropriate thresholds, or separate masks for built and vegetated areas.
Chimneys and masts. Real narrow objects look like spikes. If they matter — telecom masts, chimneys for obstruction surveys — raise the spike threshold or protect known structures.
Gaps between buildings. Narrow alleys a cell or two wide look like pits between roofs. The urban thresholds help; so does classification-aware repair that does not touch cells whose lowest return is ground.
Repairing the nDSM instead. If you build an nDSM, repair the DSM before subtraction. Repairing the difference mixes DTM and DSM artefacts and is harder to reason about.
# Frequently Asked Questions
What causes pits in a LiDAR DSM?
Cells where no first return from the top surface landed, so the highest return is from lower down — a branch inside the crown or the ground through a gap. They are most common in canopy and at low point densities.
How do I remove spikes from a DSM?
Flag cells that rise more than a threshold above a median-filtered version of the DSM and replace only those cells with the median value. Removing high-noise returns before rasterizing prevents most spikes.
Why not just smooth the whole DSM?
Smoothing removes artefacts but also blurs roof edges, crown boundaries and every other sharp feature. Detecting and replacing only anomalous cells keeps the rest of the surface exactly as measured.
Does writers.gdal radius affect pits?
Yes. A radius at least as large as the cell size lets each cell take the maximum from a slightly larger neighbourhood, which fills many single-cell pits before any post-processing.
# Related
- DSM Generation — surface models in general
- Building a DSM from First Returns — the upstream settings
- Building a Normalized DSM — the next step after cleaning
- Building a Pit-Free Canopy Height Model — the vegetation-specific method
- Removing Noise Classes Before Processing — preventing spikes