Skip to content

Build a canopy height model from a point cloud

You are viewing in-progress documentation for v2 (Beta). Switch to the stable version for the current production release.

POST /domains/{domain_id}/grids/canopy/point_cloud turns a stored airborne point cloud into a canopy height model — a raster where each cell holds the greatest height above ground of any return that falls in it.

The result carries the same chm band as the Meta, NAIP, and LANDFIRE canopy sources, so it drops straight into tree detection.

A canopy height model of the Blackfoot River domain rendered as a green-on-cream height map. Individual tree crowns are visible as distinct dots, dense along both river banks, with a pale grey band winding through the middle where the river itself returns no data.

A 1 m CHM built from 10.5 million 3DEP returns. Individual crowns resolve as separate mounds; the pale band is the river, which absorbs the pulse and returns nothing.

  1. An API key: my-api-key.

  2. A completed, airborne point cloud in this domain: your-point-cloud-id. See Fetch from 3DEP or Upload your own, and check it is fit to build on first.

The whole flow in one script:

Build the CHM and read its ground quality
"""Rasterize a stored point cloud into a canopy height model, then read the
ground-quality fields the grid reports back."""
import fastfuels_sdk.v2 as ff
ff.set_api_key("my-api-key")
point_cloud = ff.get_point_cloud("your-domain-id", "your-point-cloud-id")
grid = ff.grids.create_canopy_height_grid_from_point_cloud(
point_cloud,
name="Canopy height from 3DEP lidar",
tags=["chm"],
)
grid.wait() # polls until completed or failed
data = grid.to_dict()
chm = next(b for b in data["bands"] if b["key"] == "chm")
print(f"{data['georeference']['shape']} cells at "
f"{data['source']['alignment']['resolution']} m")
print(f"Heights {chm['summary']['min']:.1f}-{chm['summary']['max']:.1f} m "
f"(mean {chm['summary']['mean']:.2f})")
# How well was the ground under the canopy constrained?
ground = data["source"]["ground"]
print(f"ground_source: {ground['ground_source']}")
print(f"ground_coverage: {ground['ground_coverage']:.1%}")
print(f"max_ground_distance_m: {ground['max_ground_distance_m']}")

The minimal request names the cloud. Everything else has a default:

POST grids/canopy/point_cloud
curl -X 'POST' \
'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/grids/canopy/point_cloud' \
-H 'accept: application/json' \
-H 'api-key: my-api-key' \
-H 'Content-Type: application/json' \
-d '{
"source_point_cloud_id": "your-point-cloud-id",
"name": "Canopy height from 3DEP lidar",
"description": "CHM rasterized from the pinned Blackfoot 3DEP point cloud.",
"tags": ["blackfoot", "chm"]
}'

Two fields on source are worth noting immediately:

  • alignment.resolution defaults to 1.0. Unlike the raster-backed canopy sources there is no source pixel size to inherit, so the default is applied here and recorded, making the stored grid a record of exactly what it was built at. Resolution is validated at 1 m or coarser — a point-cloud CHM cannot go sub-metre.
  • source_point_cloud_checksum captures the cloud’s content version at build time. That is the value the staleness check compares against later.

ground is null until the grid completes.

Record the id: your-chm-grid-id.

GET the grid
curl -X 'GET' \
'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/grids/your-chm-grid-id' \
-H 'accept: application/json' \
-H 'api-key: my-api-key'

This is the part of the response most worth your attention, and the reason this endpoint is not simply “rasterize and hope.”

A canopy height is a difference — a canopy return’s elevation minus the ground beneath it — so the grid is only as good as the ground surface under it. The completed grid reports how that surface was established:

FieldMeaning
ground_sourceclassification — the cloud’s own ASPRS class 2 returns were used. derived — no ground classification was present, so the surface was inferred from the data.
ground_coverageFraction of cells containing at least one ground return. Heights over cells far from any ground return rest on an interpolated surface.
max_ground_distance_mDistance from the cell furthest from any ground return to the nearest one.

The grid above is healthy: ground came from classification, 90.5% of cells hold a ground return, and the worst cell is 35.2 m from one.

Left: a map of cells with a canopy return, mostly solid green with a wide pale band where the river runs. Right: a log-scale histogram of canopy heights, peaking sharply near zero and tailing off past 35 metres, with a dashed line marking the 4.83 metre mean.

93.5% of cells carry a canopy return. The pale band is the river — nodata here is water, not a hole in the point cloud.

What an unhealthy result looks like: ground_source: "derived" with a low ground_coverage, or a max_ground_distance_m in the hundreds of metres. A large distance usually means one of three things — a wide building footprint, canopy so closed that no pulse reaches the floor, or a hole in the point cloud itself. The third is the one to rule out first, since it is the cheapest to fix.

alignment controls the output grid. Two targets are supported.

target: "domain" tiles the domain’s bounding box. resolution defaults to 1 m; pass a coarser value to trade detail for size.

target: "grid" co-registers the CHM with a grid you already have — which is what you need before an export, since every role grid must share one lattice. Omitting resolution matches the target cell for cell:

POST — aligned to an existing grid
curl -X 'POST' \
'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/grids/canopy/point_cloud' \
-H 'accept: application/json' \
-H 'api-key: my-api-key' \
-H 'Content-Type: application/json' \
-d '{
"source_point_cloud_id": "your-point-cloud-id",
"name": "Canopy height aligned to the topography grid",
"alignment": { "target": "grid", "grid_id": "your-topography-grid-id" }
}'

The output takes the target’s CRS, transform, and shape — so it also covers the target’s extent rather than the domain’s. Passing a resolution instead keeps the target’s origin at the new cell size. The target grid must be in this domain’s CRS.

Two canopy height maps side by side of the same area. The left, at 1 metre, resolves individual tree crowns against a pale background. The right, at 10 metres, is visibly blockier and much greener overall, with far less pale background.

The same point cloud on two lattices: domain-anchored at 1 m, and aligned cell-for-cell to a 10 m topography grid.

{
"detail": "Point cloud 'your-point-cloud-id' has type 'tls'. A canopy height model requires an airborne (als) point cloud."
}

A scan taken from inside a plot has no landscape canopy surface to rasterize. Terrestrial clouds store and list, and feed nothing.

The cloud must also be completed and in this domain — a cloud that is still pending, or belongs to another domain, returns a 404 rather than a 422.

  • Ignoring source.ground. A CHM built on inferred ground looks exactly like one built on measured ground until you read these three fields.
  • Comparing canopy statistics across cell sizes. See the table above — a max statistic is strongly resolution-dependent.
  • Requesting target: "native". Rejected; there is no pixel anchor.
  • Pointing at a terrestrial cloud. Rejected; airborne only.
  • Building before the cloud is completed. Returns a 404, not a helpful wait.
  • Expecting sub-metre cells. Resolution is validated at 1 m or coarser. If you need finer canopy height, NAIP is ~0.6 m.
  • Reading nodata as a coverage gap. Water returns nothing, and so do genuine holes — the difference is visible in where the nodata is.