Rasterizing a Canopy Height Model from HAG
TL;DR: writers.gdal with "dimension": "HeightAboveGround", "output_type": "max" and a cell size your ground density can actually support — then clamp negatives to zero at the raster stage, not in the point cloud, so the evidence survives.
# Context and Motivation
This guide is part of Canopy Height Models with filters.hag_nn. The normalised cloud already knows how tall everything is; this page is about turning that into a raster without losing the information that made the point-domain route worth taking.
Three choices do all the work. The dimension, which decides whether you get a canopy height model or an accidental DSM. The reducer, which decides whether a cell reports its tallest return or a smoothed crown surface. And the cell size, which is not a quality dial but a statement about how much ground data supports the product — a one-metre CHM over a block with 0.4 ground returns per square metre is mostly interpolation wearing a resolution.
# Prerequisites and Assumptions
| Requirement | Detail |
|---|---|
| A normalised cloud | carrying HeightAboveGround, from filters.hag_nn |
| PDAL | 2.4+ with writers.gdal |
| A defensible cell size | supported by ground density, not chosen for appearance |
| GDAL | for the post-processing clamp and for validation |
# Step-by-Step Implementation
# Step 1 — Point the writer at the right dimension
{"type": "writers.gdal", "filename": "chm.tif",
"dimension": "HeightAboveGround", "output_type": "max",
"resolution": 1.0, "nodata": -9999, "gdaldriver": "GTiff"}Omit dimension and you rasterize Z, producing a perfectly plausible DSM that nobody notices is wrong until it is compared against field measurements.
# Step 2 — Fill small gaps, and only small ones
{"window_size": 3}Three cells fills the pinholes between crowns. Larger windows bridge real gaps and manufacture canopy where there is none — the same trade as in filling NoData voids in DTM rasters.
# Step 3 — Clamp negatives at the raster, not in the cloud
Small negative heights are interpolation noise. Clamping them to zero in the raster is honest presentation; deleting the points that produced them destroys the evidence that the ground surface was slightly wrong.
# Step 4 — Write a count band alongside
{"output_type": "max,count"}The count band is the confidence layer: a cell with two returns and a cell with forty both report a height, and only one of them means much.
# Complete Working Example
"""Rasterize a canopy height model from a normalised cloud, with a count band."""
from __future__ import annotations
import json
import logging
from pathlib import Path
import numpy as np
import pdal
LOG = logging.getLogger("chm_raster")
def rasterize(src: Path, out: Path, resolution: float = 1.0, window: int = 3) -> int:
spec = json.dumps({"pipeline": [
{"type": "readers.las", "filename": str(src)},
# Non-vegetation returns are heights above ground too; exclude them
# explicitly rather than hoping the maximum happens to be a tree.
{"type": "filters.range", "limits": "Classification[3:5]"},
{"type": "writers.gdal", "filename": str(out),
"dimension": "HeightAboveGround",
"output_type": "max,count",
"resolution": resolution,
"window_size": window,
"nodata": -9999,
"gdaldriver": "GTiff",
"gdalopts": "COMPRESS=DEFLATE,TILED=YES"},
]})
return pdal.Pipeline(spec).execute()
def summarise(src: Path) -> dict:
"""Report the height distribution from the cloud, which the raster generalises."""
p = pdal.Pipeline(json.dumps({"pipeline": [
{"type": "readers.las", "filename": str(src)},
{"type": "filters.range", "limits": "Classification[3:5]"},
]}))
p.execute()
hag = p.arrays[0]["HeightAboveGround"]
return {
"vegetation_points": int(len(hag)),
"p50": round(float(np.percentile(hag, 50)), 2),
"p95": round(float(np.percentile(hag, 95)), 2),
"max": round(float(hag.max()), 2),
"below_zero": int((hag < 0).sum()),
}
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
n = rasterize(Path("normalised.laz"), Path("chm.tif"))
LOG.info("rasterized %d vegetation returns", n)
print(json.dumps(summarise(Path("normalised.laz")), indent=2))# Key Parameter Table
| Option | Value | Effect |
|---|---|---|
dimension |
HeightAboveGround |
Without it the raster is a DSM |
output_type |
max |
Canopy top; p95 where a single high return is a risk |
resolution |
1.0–2.0 m | Set by ground density, not by how it looks |
window_size |
3 | Fills pinholes; larger bridges real gaps |
nodata |
−9999 | Never 0, which is a legitimate height |
gdalopts |
COMPRESS=DEFLATE,TILED=YES |
Cheap, and makes the product usable over a network |
# Verification
The raster is heights, not elevations. Sample a cell over open ground: it should read near zero, not near the terrain elevation. This single check catches the missing dimension option.
The maximum is plausible. Compare against the tallest local species.
NoData is where you expect it. Over water and hard surfaces if you filtered to vegetation classes; not scattered randomly through the canopy, which would mean the cell size is too fine for the data.
The count band supports the height band. Cells with one or two returns should be treated as provisional, and a product that does not ship the count band cannot express that.
# Gotchas and Edge Cases
Non-vegetation returns are heights too. A building roof is 12 m above ground and will win the max reducer. Filter to Classification 3 to 5 if the product is about vegetation.
Zero is a legitimate height. Setting nodata to 0 makes every bare-earth cell disappear. Use −9999.
A large window_size invents canopy. The fill does not know that the gap it is bridging is a clearing.
Epochs must match. Comparing a 0.5 m CHM against a 2 m one measures the cell size, as the chart above shows.
# Frequently Asked Questions
Why does my canopy height model look like a surface model?
Because the dimension option was not set, so writers.gdal rasterized Z. The result is elevations above sea level and looks entirely reasonable, which is why it usually survives until someone compares it with field measurements.
Should I use max or mean as the reducer?
Max, in almost every case — it reports the tallest return in the cell, which is the canopy top. A mean over returns spread through a crown reports a height at which no physical surface sits. Where one spurious high return is a real risk, a high percentile is the robust compromise.
Does cell size change the numbers or just the picture?
The numbers. A larger cell is more likely to contain a gap, so mean canopy height falls as the grid coarsens — around four metres between a half-metre and a four-metre raster over the same block. Change detection between epochs is only meaningful if the cell sizes match.
What should NoData be?
Anything but zero. Zero is a legitimate canopy height over bare ground, so using it as the NoData value silently deletes every open cell from the product.
# Related
- Canopy Height Models with filters.hag_nn — the parent workflow and its two routes
- Computing Height Above Ground with filters.hag_nn — producing the normalised cloud this rasterizes
- Extracting Individual Tree Heights from a CHM — what to do with the raster once it exists
- Generating a DTM GeoTIFF with writers.gdal — the same writer, configured for bare earth
- Filling NoData Voids in DTM Rasters — the fill-window trade-off, in its original setting