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 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.
Prerequisites
Section titled “Prerequisites”-
An API key: my-api-key.
-
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:
"""Rasterize a stored point cloud into a canopy height model, then read theground-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']}")Step 1 — Create the grid
Section titled “Step 1 — Create the grid”The minimal request names the cloud. Everything else has a default:
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"]}'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", description="CHM rasterized from the pinned Blackfoot 3DEP point cloud.", tags=["blackfoot", "chm"],){ "id": "your-chm-grid-id", "domain_id": "your-domain-id", "status": "pending", "source": { "name": "canopy", "product": "point_cloud", "description": "Canopy height model rasterized from a point cloud", "extent_buffer_cells": 0, "alignment": { "target": "domain", "resolution": 1.0, "method": null }, "source_point_cloud_id": "your-point-cloud-id", "source_point_cloud_checksum": "17b1ab87d67c44a7a21d5d5807321309", "ground": null, "spike_filter": { "min_canopy_footprint_m": 3.0, "min_prominence_m": 25.0 }, "aggregation": { "method": "max" } }, "bands": [ { "key": "chm", "type": "continuous", "index": 0, "name": "Canopy Height", "description": "Height above ground of the canopy top.", "unit": "m", "nodata": null, "summary": null } ], "name": "Canopy height from 3DEP lidar", "description": "CHM rasterized from the pinned Blackfoot 3DEP point cloud.", "progress": null, "created_on": "2026-08-27T12:41:39.505517", "modified_on": "2026-08-27T12:41:39.505517", "checksum": "cdf8b0e55dff49dc8ae66149f8b9b022", "modifications": [], "georeference": null, "error": null, "chunks": { "shape": [512, 512], "count": null, "count_by_axis": null }, "tags": ["blackfoot", "chm"]}Two fields on source are worth noting immediately:
alignment.resolutiondefaults to1.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_checksumcaptures 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.
Step 2 — Poll to completed
Section titled “Step 2 — Poll to completed”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'import fastfuels_sdk.v2 as ff
ff.set_api_key("my-api-key")
grid = ff.get_grid("your-domain-id", "your-chm-grid-id")grid.wait()# source.ground is not typed on the model — read it from the dict.print(grid.to_dict()["source"]["ground"]){ "id": "your-chm-grid-id", "domain_id": "your-domain-id", "status": "completed", "source": { "aggregation": { "method": "max" }, "alignment": { "method": null, "target": "domain", "resolution": 1.0 }, "extent_buffer_cells": 0, "source_point_cloud_checksum": "17b1ab87d67c44a7a21d5d5807321309", "ground": { "ground_source": "classification", "ground_coverage": 0.9053, "max_ground_distance_m": 35.2 }, "spike_filter": { "min_canopy_footprint_m": 3.0, "min_prominence_m": 25.0 }, "source_point_cloud_id": "your-point-cloud-id", "description": "Canopy height model rasterized from a point cloud", "product": "point_cloud", "name": "canopy" }, "bands": [ { "key": "chm", "type": "continuous", "index": 0, "name": "Canopy Height", "description": "Height above ground of the canopy top.", "unit": "m", "nodata": null, "summary": { "type": "continuous", "count": 495428, "nodata_count": 34492, "min": 0.0, "max": 37.64274215698242, "mean": 4.683827075584505, "std": 6.62168395136597 } } ], "name": "Canopy height from 3DEP lidar", "description": "CHM rasterized from the pinned Blackfoot 3DEP point cloud.", "progress": { "message": "Complete", "percent": 100 }, "created_on": "2026-08-27T12:41:39.505517+00:00", "modified_on": "2026-08-27T12:41:45.870834+00:00", "checksum": "cdf8b0e55dff49dc8ae66149f8b9b022", "modifications": [], "georeference": { "crs": "EPSG:32612", "transform": [1.0, 0.0, 294095.0, 0.0, -1.0, 5199750.0], "shape": [768, 690] }, "error": null, "chunks": { "shape": [512, 512], "count": 4, "count_by_axis": { "x": 2, "y": 2 } }, "tags": ["blackfoot", "chm"]}Step 3 — Read source.ground
Section titled “Step 3 — Read source.ground”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:
| Field | Meaning |
|---|---|
ground_source | classification — 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_coverage | Fraction of cells containing at least one ground return. Heights over cells far from any ground return rest on an interpolated surface. |
max_ground_distance_m | Distance 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.

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.
Choosing the lattice
Section titled “Choosing the lattice”alignment controls the output grid. Two targets are supported.
Against the domain (the default)
Section titled “Against the domain (the default)”target: "domain" tiles the domain’s bounding box. resolution defaults to
1 m; pass a coarser value to trade detail for size.
Against another grid
Section titled “Against another grid”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:
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" }}'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")
# Omitting output_resolution_m matches the target grid cell for cell.grid = ff.grids.create_canopy_height_grid_from_point_cloud( point_cloud, align_to="your-topography-grid-id", name="Canopy height aligned to the topography grid",){ "id": "your-chm-grid-id", "domain_id": "your-domain-id", "status": "completed", "source": { "aggregation": { "method": "max" }, "alignment": { "grid_id": "your-topography-grid-id", "method": null, "target": "grid", "resolution": null }, "extent_buffer_cells": 0, "source_point_cloud_checksum": "17b1ab87d67c44a7a21d5d5807321309", "ground": { "ground_source": "classification", "ground_coverage": 0.9514, "max_ground_distance_m": 30.0 }, "spike_filter": { "min_canopy_footprint_m": 3.0, "min_prominence_m": 25.0 }, "source_point_cloud_id": "your-point-cloud-id", "description": "Canopy height model rasterized from a point cloud", "product": "point_cloud", "name": "canopy" }, "bands": [ { "key": "chm", "type": "continuous", "index": 0, "name": "Canopy Height", "description": "Height above ground of the canopy top.", "unit": "m", "nodata": null, "summary": { "type": "continuous", "count": 5151, "nodata_count": 162, "min": 0.0, "max": 38.08360290527344, "mean": 13.724057683106325, "std": 8.333204427626045 } } ], "name": "Canopy height aligned to the topography grid", "description": "", "progress": { "message": "Complete", "percent": 100 }, "created_on": "2026-08-27T12:41:55.823987+00:00", "modified_on": "2026-08-27T12:42:03.979084+00:00", "checksum": "52d713c1b20246e78dc9acd9dc72e73b", "modifications": [], "georeference": { "crs": "EPSG:32612", "transform": [10.0, 0.0, 294095.0, 0.0, -10.0, 5199752.0], "shape": [77, 69] }, "error": null, "chunks": { "shape": [512, 512], "count": 1, "count_by_axis": { "x": 1, "y": 1 } }, "tags": []}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.

The same point cloud on two lattices: domain-anchored at 1 m, and aligned cell-for-cell to a 10 m topography grid.
When the request is rejected
Section titled “When the request is rejected”{ "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.
{ "detail": "alignment.target 'native' is not supported for a point cloud source: there is no source raster whose pixel anchor could be preserved. Use 'domain' or 'grid'."}native preserves a source raster’s pixel anchor, and a point cloud has no
pixels. Use domain or grid.
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.
Next steps
Section titled “Next steps”- Detect trees from the CHM
— point
source_chm_grid_idat this grid and isolate stems. - Choosing a source — why you would use lidar here rather than NAIP, Meta, or LANDFIRE, and how far apart the two land on the same forest.
- About point clouds — why ground quality bounds canopy height.
Common pitfalls
Section titled “Common pitfalls”- 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.