How to Create a Canopy Height Model from LiDAR
Problem statement
A canopy height model is the difference between two surfaces β the top of everything and the bare ground β and it inherits every weakness of both.
Derived from a real 3DEP survey at 1 m cells:
DSM 16.0 .. 203.1 m, 7.47% of cells empty
DTM 15.9 .. 194.3 m, 10.93% of cells empty
CHM 0.0 .. 22.7 m, 10.93% undefined
The CHM's holes are exactly the DTM's, because a cell with no ground height has no height above ground. And that survey classifies only ground β there are no vegetation classes at all β so the height has to come from the returns rather than from a class.
Quick answer
import numpy as np
def canopy_height_model(x, y, z, classification, cell=1.0, ground_class=2):
"""DSM minus DTM, per cell, with the undefined cells left undefined."""
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 - left) / cell).astype(int), 0, width - 1)
row = np.clip(((top - y) / cell).astype(int), 0, height - 1)
flat = row * width + col
ground = classification == ground_class
dsm = np.full(width * height, -np.inf)
np.maximum.at(dsm, flat, z)
dsm[np.isinf(dsm)] = np.nan
dtm = np.full(width * height, np.inf)
np.minimum.at(dtm, flat[ground], z[ground])
dtm[np.isinf(dtm)] = np.nan
chm = (dsm - dtm).reshape(height, width)
print(f" CHM {np.nanmin(chm):.1f}..{np.nanmax(chm):.1f} m, "
f"{np.isnan(chm).mean():.2%} undefined")
return chm
Note what this cannot produce: a negative value. The DSM is a maximum over all returns and the DTM a minimum over a subset of them, so DSM >= DTM in every cell by construction β and the measurement confirms zero negative cells.
Step-by-step solution
1. Reproject first
Everything here is metric β cell size, heights, the eventual crown diameters. The survey used here is stored in Web Mercator, where distances are inflated by 1.92 at its latitude. Heights are unaffected, so a CHM built without reprojecting has correct heights on cells the wrong size.
2. Build both surfaces on the same grid
Not two grids that happen to have the same cell size. Compute the origin and shape once and use them for both, so the subtraction is aligned by construction rather than by luck.
3. Choose a cell size the ground can support
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%
The CHM is limited by the DTM, which is limited by the ground density β 3.9 points per square metre here against 16.9 overall. A 0.5 m CHM would be undefined over more than a third of the area.
4. Decide about the DTM holes before subtracting
Three options, with different consequences:
- Leave them. The CHM is undefined there. Honest and awkward.
- Interpolate the DTM, then subtract. Fills the CHM, and introduces the possibility of negative values.
- Interpolate the CHM afterwards. Worse: it interpolates a difference rather than a surface, and difference fields are rougher.
Interpolate the DTM, and ship a mask.
5. Expect negative values once you interpolate
The per-cell CHM cannot be negative. An interpolated one can, wherever the filled ground height exceeds the highest return in that cell β most often at a break of slope, where a linear fill overshoots.
Negative CHM is a diagnostic about the DTM, not about the canopy. Clipping it to zero throws the diagnostic away; investigate first, then clip if the magnitudes are small.
6. Smooth only if you know why
A CHM built from per-cell maxima is noisy, because the maximum is a single point. Smoothing makes tree crowns look like tree crowns, and it also lowers every peak β which matters if you are measuring tree height.
Code examples
Example 1 β CHM with filling, masking and a negative check
import numpy as np
import rasterio
from rasterio.transform import from_origin
from scipy.ndimage import distance_transform_edt
def chm_with_mask(x, y, z, classification, cell=1.0, crs="EPSG:32605",
max_fill_m=5.0, out_path=None):
"""CHM, an observation mask, and a report on any negative values."""
left, top = x.min(), y.max()
width = int(np.ceil((x.max() - left) / cell))
height = int(np.ceil((top - y.min()) / cell))
transform = from_origin(left, top, cell, cell)
col = np.clip(((x - left) / cell).astype(int), 0, width - 1)
row = np.clip(((top - y) / cell).astype(int), 0, height - 1)
flat = row * width + col
ground = classification == 2
dsm = np.full(width * height, -np.inf); np.maximum.at(dsm, flat, z)
dsm[np.isinf(dsm)] = np.nan
dtm = np.full(width * height, np.inf)
np.minimum.at(dtm, flat[ground], z[ground]); dtm[np.isinf(dtm)] = np.nan
dsm = dsm.reshape(height, width)
dtm = dtm.reshape(height, width)
observed = np.isfinite(dtm)
indices = distance_transform_edt(~observed, return_distances=False,
return_indices=True)
distance = distance_transform_edt(~observed) * cell
dtm_filled = dtm[tuple(indices)]
dtm_filled[distance > max_fill_m] = np.nan
chm = dsm - dtm_filled
negative = np.nansum(chm < 0)
if negative:
print(f" {int(negative):,} negative cells "
f"({negative / np.isfinite(chm).sum():.2%}), "
f"minimum {np.nanmin(chm):.2f} m β inspect the DTM fill")
print(f" CHM {np.nanmin(chm):.1f}..{np.nanmax(chm):.1f} m, "
f"{np.isnan(chm).mean():.2%} undefined, "
f"{(~observed).mean():.2%} of ground interpolated")
if out_path:
profile = dict(driver="GTiff", height=height, width=width, 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(chm.astype("float32"), 1)
dst.write(observed.astype("float32"), 2)
dst.set_band_description(1, "canopy height (m)")
dst.set_band_description(2, "1 = ground observed, 0 = interpolated")
return chm, observed, transform
Example 2 β canopy metrics from normalised points, not from the CHM
import numpy as np
def canopy_metrics(x, y, z, dtm, transform, cell_dtm, cell_out=10.0,
cover_threshold=2.0, min_points=10):
"""Cover and height percentiles, which a CHM has already thrown away."""
left, top = transform.c, transform.f
h, w = dtm.shape
col = np.clip(((x - left) / cell_dtm).astype(int), 0, w - 1)
row = np.clip(((top - y) / cell_dtm).astype(int), 0, h - 1)
above = z - dtm[row, col]
ok = np.isfinite(above)
x, y, above = x[ok], y[ok], above[ok]
width = int(np.ceil((x.max() - x.min()) / cell_out))
height = int(np.ceil((y.max() - y.min()) / cell_out))
oc = np.clip(((x - x.min()) / cell_out).astype(int), 0, width - 1)
orr = np.clip(((y.max() - y) / cell_out).astype(int), 0, height - 1)
flat = orr * width + oc
order = np.argsort(flat)
fs, hs = flat[order], above[order]
edges = np.searchsorted(fs, np.arange(width * height + 1))
p95 = np.full(width * height, np.nan)
cover = np.full(width * height, np.nan)
for i in range(width * height):
a, b = edges[i], edges[i + 1]
if b - a < min_points:
continue
cell_heights = hs[a:b]
p95[i] = np.percentile(cell_heights, 95)
cover[i] = (cell_heights > cover_threshold).mean()
print(f" {cell_out} m cells: median p95 height "
f"{np.nanmedian(p95):.1f} m, median cover {np.nanmedian(cover):.1%}")
return p95.reshape(height, width), cover.reshape(height, width)
Canopy cover β the fraction of returns above a threshold β cannot be recovered from a CHM at all, because the CHM has reduced each cell to one number. Working from normalised points keeps the whole vertical distribution.
Example 3 β individual tree detection, with its assumptions stated
import numpy as np
from scipy.ndimage import maximum_filter, label
def detect_trees(chm, cell=1.0, min_height=3.0, window_m=5.0):
"""Local maxima as tree tops. A crude method with visible assumptions."""
size = max(3, int(round(window_m / cell)) | 1) # odd window
smoothed = np.where(np.isfinite(chm), chm, 0.0)
peaks = (maximum_filter(smoothed, size=size) == smoothed) & \
(chm > min_height) & np.isfinite(chm)
labels, n = label(peaks)
rows, cols = np.nonzero(peaks)
heights = chm[rows, cols]
print(f" {n} local maxima above {min_height} m "
f"with a {size}x{size} window ({window_m} m)")
print(f" heights {heights.min():.1f}..{heights.max():.1f} m, "
f"median {np.median(heights):.1f} m")
print(f" density {n / (chm.size * cell ** 2 / 1e4):.1f} per hectare")
return rows, cols, heights
The window size is the assumption: it asserts a minimum crown spacing. Too small and one tree becomes several; too large and a group becomes one. Report the window with the tree count, and expect the count to move substantially with it.
Explanation
Why the CHM inherits the DTM's holes and not the DSM's
Height above ground needs a ground height. A cell with returns but no ground point has a DSM value and no DTM value, so the difference is undefined.
Measured, that is the gap between 7.47% and 10.93% β 3.46% of cells where the laser reached something but never the ground. Under closed canopy that fraction rises steeply, which is why forest CHMs rest on more interpolation than open-ground ones.
Why a per-cell CHM cannot be negative
DSM = max(z over all returns in the cell), DTM = min(z over ground returns in the cell). The ground returns are a subset of all returns, so the maximum over the superset is at least the minimum over the subset.
The measurement confirms it: zero negative cells out of the 89% that are defined.
Once the DTM is filled or smoothed, the ground height in a cell no longer comes from that cell's own points, and the guarantee is gone. Negatives then mark places where the fill overshot β typically the top of a bank or the edge of a terrace.
Why maxima make a noisy surface
A DSM cell is a single point: the highest return that happened to land there. Neighbouring cells take different points from different pulses, so the surface is speckled at the scale of the point spacing.
Smoothing fixes the appearance and biases the heights downward, because a maximum is at the top of the local distribution and any average of it with neighbours is lower. If tree height is the output, smooth as little as possible; if the CHM is a background layer, smooth freely.
Why normalised points beat a CHM for structure
A CHM answers "how tall is the tallest thing here". Most forestry questions are about the distribution β cover, layering, the height below which 50% of returns fall β and a per-cell maximum has discarded all of it.
Normalising the cloud (z - dtm[row, col]) keeps every return with its height above ground, and every metric becomes a per-cell statistic over that array. It is more work and strictly more informative.
Edge cases or notes
- The CHM's holes are the DTM's holes. Fix the ground surface, not the difference.
- Build both surfaces on one grid computed once.
- A per-cell CHM cannot be negative. Negatives mean the DTM was filled.
- Do not clip negatives to zero without looking at where they are.
- Interpolate the DTM, not the CHM. A difference field is rougher than a surface.
- Smoothing lowers every peak. Avoid it if height is the measurement.
- Cell size is limited by ground density, not overall density.
- Water has no returns, so both surfaces are undefined there.
Internal links
- DSM, DTM and CHM from LiDAR: how each surface is derived β the three surfaces compared
- How to create a DTM from LiDAR ground points in Python β the surface a CHM depends on
- Point density explained β choosing a cell size the ground supports
- How to filter a point cloud by class and return number β selecting returns when classes are missing
- My LiDAR DTM has holes, spikes or terraces β where negative CHM values come from
- How to rasterise a point cloud to a grid in Python β the reduction underneath
- How to measure building heights from LiDAR in Python β the same normalisation, different target
- How to calculate zonal statistics in Python β summarising a CHM by stand or parcel
FAQ
How do I make a canopy height model in Python?
Build a DSM as the per-cell maximum of all returns and a DTM as the per-cell minimum of ground returns on the same grid, then subtract.
Why is my CHM full of holes?
Because the DTM is. A cell with returns but no ground point has no height above ground β 3.46% of cells here, on top of 7.47% with no returns at all.
Why does my CHM have negative values?
Because the DTM was interpolated or smoothed, so the ground height in a cell is no longer bounded by that cell's own points. A per-cell CHM cannot go negative.
Should I clip negative CHM values to zero?
Only after investigating. They point at where the ground fill overshot, usually at a break of slope, and clipping hides that.
What cell size should a CHM use?
One the ground density supports. On this survey, 1 m left 10.93% undefined and 0.5 m would have left 38%.
Should I smooth the CHM?
Only if it is a background layer. Smoothing lowers every peak, which biases tree heights downward.
How do I get canopy cover rather than height?
Normalise the point cloud against the DTM and compute the fraction of returns above a threshold per cell. A CHM has already reduced each cell to a single number.