Building a Normalized DSM
TL;DR: nDSM = DSM − DTM on identical grids. Either write both rasters with the same origin_x, origin_y, width, height and resolution in writers.gdal and subtract them with rasterio, or skip the subtraction by computing HeightAboveGround with filters.hag_nn and rasterizing its per-cell maximum. Clamp small negative values to zero and keep NoData where either input is missing.
# Context and Motivation
This guide is part of DSM Generation. A DSM describes the top of everything — roofs, canopy, bridges — in absolute elevation, which makes it awkward for asking “how tall is that?”. A 12 m building on a hill and a 12 m building in a valley have very different DSM values. Subtracting the terrain gives heights above ground everywhere, and that normalized surface is what building-height maps, canopy analysis, urban morphology and change detection actually need. Over vegetation, the nDSM is essentially a canopy height model; over built areas, it is a building-height map.
Getting it right is mostly about alignment: two rasters that differ by half a cell produce ghost edges along every wall.
# Prerequisites and Assumptions
- A classified point cloud, or an existing DSM and DTM of the same area.
- PDAL, rasterio and NumPy.
- The same CRS for both rasters, and both built at the same resolution.
# Step-by-Step Implementation
# Step 1 — Define one grid
Compute an origin and size from the tile bounds, rounded to the resolution, and use them for every raster.
# Step 2 — Write DSM and DTM on that grid
DSM: maximum Z of first returns. DTM: IDW of ground returns. Both with the shared origin_x, origin_y, width, height.
# Step 3 — Subtract
Read both with rasterio, subtract where both are valid, and set NoData elsewhere.
# Step 4 — Clean small negatives
Values slightly below zero come from interpolation differences between the two surfaces; clamp values between −0.5 m and 0 to zero, and treat larger negatives as errors to inspect.
# Step 5 — Alternative: rasterize HAG directly
filters.hag_nn followed by writers.gdal with dimension: "HeightAboveGround" and output_type: "max" yields an nDSM in one pipeline, without a separate DTM.
# Complete Working Example
"""nDSM from aligned DSM and DTM rasters, plus the one-pipeline HAG alternative."""
from __future__ import annotations
import json
import math
import numpy as np
import pdal
import rasterio
SRC, RES = "tiles/t_0431.laz", 0.5
def grid(src: str, res: float) -> dict:
b = pdal.Pipeline(json.dumps({"pipeline": [src]})).quickinfo["readers.las"]["bounds"]
ox, oy = math.floor(b["minx"] / res) * res, math.floor(b["miny"] / res) * res
return {"origin_x": ox, "origin_y": oy,
"width": math.ceil((b["maxx"] - ox) / res), "height": math.ceil((b["maxy"] - oy) / res)}
def rasters(src: str, res: float) -> None:
g = grid(src, res)
common = {"resolution": res, "data_type": "float32", "nodata": -9999, **g}
pdal.Pipeline(json.dumps({"pipeline": [
src,
{"type": "filters.range", "limits": "Classification![7:7],Classification![18:18]", "tag": "clean"},
{"type": "filters.range", "inputs": ["clean"], "limits": "ReturnNumber[1:1]", "tag": "first"},
{"type": "writers.gdal", "inputs": ["first"], "filename": "out/dsm.tif",
"output_type": "max", "radius": res * 0.71, **common},
{"type": "filters.range", "inputs": ["clean"], "limits": "Classification[2:2]", "tag": "ground"},
{"type": "writers.gdal", "inputs": ["ground"], "filename": "out/dtm.tif",
"output_type": "idw", "window_size": 6, **common},
]})).execute()
def ndsm(dsm: str, dtm: str, dst: str) -> None:
with rasterio.open(dsm) as a, rasterio.open(dtm) as b:
assert a.transform == b.transform and a.shape == b.shape, "grids differ"
s, t = a.read(1, masked=True), b.read(1, masked=True)
profile = a.profile
h = s - t
h = np.ma.where((h < 0) & (h > -0.5), 0.0, h)
with rasterio.open(dst, "w", **profile) as out:
out.write(h.filled(-9999).astype("float32"), 1)
print(f"nDSM: max {h.max():.1f} m, cells < −0.5 m: {(h < -0.5).sum()}")
def ndsm_from_hag(src: str, dst: str, res: float) -> None:
pdal.Pipeline(json.dumps({"pipeline": [
src,
{"type": "filters.range", "limits": "Classification![7:7],Classification![18:18]"},
{"type": "filters.hag_nn", "count": 2},
{"type": "writers.gdal", "filename": dst, "dimension": "HeightAboveGround",
"output_type": "max", "resolution": res, "radius": res * 0.71,
"data_type": "float32", "nodata": -9999, **grid(src, res)},
]})).execute()
if __name__ == "__main__":
rasters(SRC, RES)
ndsm("out/dsm.tif", "out/dtm.tif", "out/ndsm.tif")
ndsm_from_hag(SRC, "out/ndsm_hag.tif", RES)# Key Parameter Table
| Setting | DSM | DTM | Why |
|---|---|---|---|
| input | first returns | class 2 | Top surface versus terrain |
output_type |
max |
idw |
Highest return; smooth terrain |
radius |
0.71 × res | — | Covers each cell |
window_size |
— | 6 | Fills small ground gaps |
| grid | shared | shared | Identical origin, width, height |
nodata |
−9999 | −9999 | Masked in the subtraction |
# Verification
- Grids identical. The transform and shape assertion in
ndsmmust pass. - Ground near zero. On open ground and roads, nDSM values should be within ±0.2 m of zero.
- Known heights. A few buildings of known height should read correctly to within a few decimetres.
- Two methods agree. The subtraction and the HAG route should agree closely except at object edges.
# Gotchas and Edge Cases
Water and bridges. Bridges appear as tall objects over rivers; water surfaces may show small positive or negative values from sparse returns. Mask both if the nDSM feeds building or vegetation statistics.
DTM errors become heights. Where ground classification failed — a building accepted as ground — the nDSM shows zero height there. nDSM quality is bounded by DTM quality.
Pits in the DSM. Laser pulses that penetrate gaps in canopy leave low DSM cells, producing pits in the nDSM. Clean the DSM first; see removing pits and spikes from a DSM.
Resolution mismatch. Resampling a 1 m DTM to 0.5 m before subtraction is fine; resampling a 0.5 m DSM down to 1 m loses roof edges. Build both at the target resolution where possible.
# Frequently Asked Questions
What is a normalized DSM?
A raster of heights above the terrain, computed as the digital surface model minus the digital terrain model. Buildings and trees appear with their actual heights regardless of the ground elevation beneath them.
How do I make sure the DSM and DTM align?
Write both with writers.gdal using the same origin_x, origin_y, width, height and resolution, and assert that the transforms and shapes match before subtracting.
Why does my nDSM have negative values?
Small negatives come from differences in how the two surfaces are interpolated and are usually clamped to zero. Large negatives indicate a problem, such as a DSM pit or a ground point misclassified above the true terrain.
Is an nDSM the same as a canopy height model?
Over vegetation, effectively yes. A canopy height model is typically built from vegetation returns only, while an nDSM includes buildings and other objects too.
# Related
- DSM Generation — surface models in general
- Building a DSM from First Returns — the DSM input
- DTM vs DSM: Which Surface Model? — choosing between surfaces
- Rasterizing a Canopy Height Model from HAG — the vegetation-only version
- Building Extraction from LiDAR — using heights to find buildings