How to Create a DTM from LiDAR Ground Points in Python
Problem statement
A digital terrain model from lidar is a per-cell reduction of the ground-classified returns. The code is short. The decisions are not:
- What cell size? Fine enough to be useful, coarse enough to be occupied.
- Which reduction? Minimum, mean or something more careful.
- What about the empty cells? At any usable resolution there will be some.
- Is the CRS metric? If not, every distance in the process is wrong.
Measured on a real 3DEP survey β 12,968,770 points, 16.9 per square metre, 22.9% classified ground β at 1 m cells:
DTM 15.9 .. 194.3 m, 10.93% of cells with no ground point
One cell in nine has to be filled or left empty, and that decision changes what the product is.
Quick answer
import numpy as np
import rasterio
from rasterio.transform import from_origin
def dtm_from_points(x, y, z, classification, cell=1.0, crs="EPSG:32605"):
"""Per-cell minimum of ground-classified returns."""
ground = classification == 2
if not ground.any():
raise ValueError("no ground-classified points β run a ground filter first")
left, top = x.min(), y.max()
width = int(np.ceil((x.max() - left) / cell))
height = int(np.ceil((top - y.min()) / cell))
col = np.clip(((x[ground] - left) / cell).astype(int), 0, width - 1)
row = np.clip(((top - y[ground]) / cell).astype(int), 0, height - 1)
dtm = np.full(width * height, np.inf)
np.minimum.at(dtm, row * width + col, z[ground])
dtm[np.isinf(dtm)] = np.nan
counts = np.zeros(width * height)
np.add.at(counts, row * width + col, 1)
print(f" {width} x {height} at {cell} m, "
f"{np.isnan(dtm).mean():.2%} empty, "
f"median {np.median(counts[counts > 0]):.0f} ground points per cell")
return (dtm.reshape(height, width), counts.reshape(height, width),
from_origin(left, top, cell, cell))
np.minimum.at does the whole reduction in one vectorised pass. A Python loop over 3 million ground points takes minutes; this takes under a second.
Step-by-step solution
1. Reproject to a metric CRS first
The survey used here is stored in EPSG:3857, where its extent reads as 1,613 m and is really 877 m. A DTM built in those units has cells 1.92 times larger than requested, and any slope derived from it is wrong by the same factor.
from pyproj import Transformer
transformer = Transformer.from_crs(3857, 32605, always_xy=True)
x, y = transformer.transform(las.x, las.y)
2. Filter noise before reducing
The per-cell minimum is maximally sensitive to a single low point. Drop the noise classes explicitly:
ground = (classification == 2) & ~np.isin(classification, [7, 18])
This survey contained exactly two points classified as low noise β a reminder that classified noise is the easy case. Unclassified low points are the ones that hurt.
3. Choose the cell size from occupancy
0.25 m: 78.32% of cells have no ground point
0.50 m: 38.11%
1.00 m: 10.93%
2.00 m: 7.51%
5.00 m: 7.33%
The floor at about 7.3% is water, which returns nothing β no cell size removes it. Take the finest size whose empty fraction is close to that floor: 1 m here, or 2 m for a gap-free product.
4. Decide what to do with the empty cells
Three defensible options, and one bad one:
- Leave them as NoData. Honest, and awkward for anything that needs a continuous surface.
- Interpolate, and record it. Fill from surrounding ground cells and ship a mask saying which cells are observed.
- Coarsen until they mostly vanish. Loses resolution everywhere to fix a problem in one cell in nine.
- Fill with zero. Never. A zero elevation in a valley reads as a hole in every hillshade and every flow model.
5. Consider using last returns to reduce holes β carefully
DTM from last returns vs from classified ground
holes 7.48% vs 10.93%
bias -0.040 m mean, p1 -0.33 m, p99 +0.00 m
Last returns fill a third of the holes, and the resulting surface is never higher than the classified-ground one β because taking a minimum over a superset can only go down. The 4 cm mean bias is downward, driven by low returns the classifier had excluded.
That is the trade: fewer holes, a slightly lower and noisier surface. For hydrological work the extra continuity often wins; for volume calculations the bias may not.
Code examples
Example 1 β DTM with interpolation and an observation mask
import numpy as np
import rasterio
from rasterio.transform import from_origin
from scipy.ndimage import distance_transform_edt
def dtm_with_fill(x, y, z, classification, cell=1.0, crs="EPSG:32605",
max_fill_distance=None, out_path=None):
"""DTM plus a mask band recording which cells were observed."""
dtm, counts, transform = dtm_from_points(x, y, z, classification, cell, crs)
observed = np.isfinite(dtm)
# nearest-observed fill, limited by distance so large voids stay empty
indices = distance_transform_edt(~observed, return_distances=False,
return_indices=True)
distance = distance_transform_edt(~observed)
filled = dtm[tuple(indices)]
if max_fill_distance is not None:
too_far = distance > (max_fill_distance / cell)
filled[too_far] = np.nan
print(f" left {too_far.sum():,} cells empty "
f"(further than {max_fill_distance} m from any ground point)")
print(f" filled {int((~observed & np.isfinite(filled)).sum()):,} cells "
f"({(~observed & np.isfinite(filled)).mean():.2%})")
if out_path:
profile = dict(driver="GTiff", height=dtm.shape[0], width=dtm.shape[1],
count=2, dtype="float32", crs=crs, transform=transform,
nodata=np.nan, compress="deflate", tiled=True)
with rasterio.open(out_path, "w", **profile) as dst:
dst.write(filled.astype("float32"), 1)
dst.write(observed.astype("float32"), 2)
dst.set_band_description(1, "elevation (m)")
dst.set_band_description(2, "1 = observed, 0 = interpolated")
dst.update_tags(cell_size=cell, reduction="min",
source_class="2 (ground)")
return filled, observed, transform
Nearest-neighbour fill is deliberately crude: it produces visible facets, which is honest. A smooth interpolation over a large void looks like terrain and is not.
max_fill_distance is the important parameter. Filling a 2 m gap between ground points is reasonable; filling a 200 m lake is fabrication.
Example 2 β comparing reductions on your own data
import numpy as np
def compare_reductions(x, y, z, classification, cell=1.0):
"""Minimum, mean and a low-percentile reduction, side by side."""
ground = classification == 2
left, top = x.min(), y.max()
width = int(np.ceil((x.max() - left) / cell))
height = int(np.ceil((top - y.min()) / cell))
col = np.clip(((x[ground] - left) / cell).astype(int), 0, width - 1)
row = np.clip(((top - y[ground]) / cell).astype(int), 0, height - 1)
flat = row * width + col
zg = z[ground]
minimum = np.full(width * height, np.inf)
np.minimum.at(minimum, flat, zg); minimum[np.isinf(minimum)] = np.nan
total = np.zeros(width * height); counts = np.zeros(width * height)
np.add.at(total, flat, zg); np.add.at(counts, flat, 1)
mean = np.where(counts > 0, total / np.maximum(counts, 1), np.nan)
difference = np.abs(minimum - mean)
print(f" min vs mean: mean |diff| {np.nanmean(difference):.3f} m, "
f"p99 {np.nanpercentile(difference, 99):.3f} m, "
f"max {np.nanmax(difference):.2f} m")
return minimum.reshape(height, width), mean.reshape(height, width)
min vs mean: mean |diff| 0.078 m, p99 0.326 m, max 0.94 m
An 8 cm typical difference means the choice barely matters on this terrain. Run it on your own data before assuming that transfers β on steep slopes with large cells the gap grows quickly.
Example 3 β a fallback ground filter when the survey has none
import numpy as np
def simple_ground_filter(x, y, z, coarse_cell=10.0, tolerance=0.5,
fine_cell=1.0):
"""Two-pass minimum-surface filter, for clouds with no ground class."""
def cell_min(px, py, pz, cell):
left, top = x.min(), y.max()
width = int(np.ceil((x.max() - left) / cell))
height = int(np.ceil((top - y.min()) / cell))
col = np.clip(((px - left) / cell).astype(int), 0, width - 1)
row = np.clip(((top - py) / cell).astype(int), 0, height - 1)
grid = np.full(width * height, np.inf)
np.minimum.at(grid, row * width + col, pz)
grid[np.isinf(grid)] = np.nan
return grid.reshape(height, width), row, col, (left, top, width, height)
coarse, row_c, col_c, meta = cell_min(x, y, z, coarse_cell)
seed = coarse[row_c, col_c]
candidate = z <= seed + tolerance
fine, row_f, col_f, _ = cell_min(x[candidate], y[candidate],
z[candidate], fine_cell)
print(f" {candidate.mean():.1%} of points kept as ground candidates")
print(f" {np.isnan(fine).mean():.2%} of fine cells empty")
return fine, candidate
This is a crude approximation of what a real ground filter does β progressive TIN densification or cloth simulation β and it will clip ridges and accept flat roofs. Use it to get a first look, and use a proper filter for anything that matters.
Explanation
Why the minimum is the standard reduction
Ground classification is not perfect, and its errors are asymmetric: low vegetation and building edges are occasionally accepted as ground, while genuine ground points are rarely invented.
So the ground-classified set within a cell tends to contain the true ground plus a few things above it. The minimum is the most likely true ground, and the mean is biased upward by the contaminants.
The cost is sensitivity in the other direction. One multipath return below the surface sets the whole cell, so filtering noise classes first is not optional.
Why filling is a model and should be labelled
A filled cell is not a measurement. Nothing in a single-band GeoTIFF distinguishes an observed 42.3 m from an interpolated one, and every downstream user will treat them identically.
Shipping an observation mask as a second band costs one band and makes the product honest. It also makes it possible for a careful user to mask the derived slope, hillshade or watershed where the terrain was invented.
Why last returns are a tempting shortcut
Every ground point is a last return β measured at 100.0% here. So filtering to last returns keeps all the ground and adds whatever else reached the ground level, and taking a per-cell minimum then approximates the ground surface.
The measurement shows both sides. Holes fall from 10.93% to 7.48%, and the surface is 4 cm lower on average with a 1st percentile of β0.33 m. It cannot be higher, because a minimum over a superset only goes down.
The right choice depends on which error you can tolerate. Continuity matters more for flow routing; unbiased elevation matters more for volumes.
Why the CRS check comes first
Everything in this workflow is metric: cell size, fill distance, the eventual slope. In Web Mercator at 58.6Β° north, all of those are inflated by 1.92, and none of it raises an error.
The tell is that the extent looks too big for the area you thought you had. Check it once, at load time, and reproject before anything else.
Edge cases or notes
- Reproject to a projected CRS first. Web Mercator inflated distances by 1.92 here.
- Drop noise classes before taking a minimum.
- Choose the cell size from occupancy, not from nominal spacing.
- Never fill NoData with zero.
- Limit the fill distance so large voids stay empty.
- Ship an observation mask as a second band.
np.minimum.atis the fast reduction; a Python loop is minutes rather than seconds.- Water returns nothing. Do not interpolate a lake into a hillside.
Internal links
- DSM, DTM and CHM from LiDAR: how each surface is derived β the three surfaces compared
- Point density explained β choosing the cell size
- My LiDAR DTM has holes, spikes or terraces β diagnosing a bad DTM
- How to filter a point cloud by class and return number β selecting ground points
- laspy returns the wrong coordinates: scale, offset and CRS β the reprojection step
- How to rasterise a point cloud to a grid in Python β the general reduction
- How to calculate slope and aspect from a DEM in Python β what the DTM feeds
- How to fill sinks and derive flow direction from a DEM β where holes matter most
FAQ
How do I make a DTM from lidar in Python?
Reproject to a metric CRS, select ground-classified returns, and take the per-cell minimum with np.minimum.at. Then decide explicitly what to do with the empty cells.
What cell size should I use?
The finest whose empty-cell fraction is close to the irreducible floor. On a 16.9 points per square metre survey that was 1 m, leaving 10.93% empty against a 7.3% floor.
Should I use the minimum or the mean per cell?
Minimum. It resists the upward bias from misclassified low vegetation, at the cost of sensitivity to low noise β so filter noise classes first. They differed by 8 cm on average here.
How do I fill the holes?
Nearest-neighbour or IDW from surrounding ground cells, with a maximum fill distance so large voids stay empty, and an observation mask shipped alongside.
Can I use last returns instead of ground classification?
It reduces holes from 10.93% to 7.48% and lowers the surface by 4 cm on average, because a minimum over a superset can only fall. Useful for continuity, biased for volumes.
Why is my DTM full of spikes?
Low noise points setting the per-cell minimum. Filter noise classes, and consider a low percentile instead of the strict minimum.
What if my cloud has no ground classification?
Run a ground filter. A crude two-pass minimum-surface filter gets a first look; progressive TIN densification or cloth simulation is what production work uses.