Generate a tree inventory from a CHM
You are viewing in-progress documentation for v2 (Beta). Switch to the stable version for the current production release.
When you have a canopy height model (CHM) — a raster of vegetation height — you can detect individual trees from it directly, rather than sampling them statistically from TreeMap. FastFuels does this in two steps:
- Build a CHM grid — worked through below with NAIP aerial imagery
(
canopy/naip), a ~0.6 m canopy height model covering the conterminous US. It is one of several sources; see Choosing a source. - Isolate stems — run a tree-detection algorithm over the CHM to place
one tree at each detected treetop (
tree/chm).
For the ideas behind step 2 — why a treetop is a local maximum, and how the two algorithms differ — see How tree detection from a CHM works.
Prerequisites
Section titled “Prerequisites”-
An API key: my-api-key.
-
A domain: your-domain-id. See Create a domain.
Choosing a source
Section titled “Choosing a source”Four canopy constructors emit a chm band, and a fifth route lets you bring your
own raster. Any of them can feed Step 2:
| Source | Endpoint | Cell size | What it is |
|---|---|---|---|
| NAIP | grids/canopy/naip | ~0.6 m | A deep-learning canopy height model derived from aerial imagery. CONUS only. Used in Step 1 below. |
| Meta | grids/canopy/meta | ~1 m | A deep-learning canopy height model with global coverage. |
| Airborne lidar | grids/canopy/point_cloud | 1 m or coarser | Rasterized from a point cloud you fetched from 3DEP or uploaded. A direct measurement rather than a model. |
| LANDFIRE | grids/canopy/landfire | 30 m | A national canopy layer carrying chm alongside cbd, cbh, and cc. Too coarse for individual-tree detection — a 30 m cell is wider than a crown. |
| Your own raster | grids/upload/geotiff | whatever you supply | Any CHM the API does not produce itself. |
Two things decide the choice more often than quality does:
- Coverage. The lidar route needs a point cloud, and 3DEP does not cover everywhere. Of six 1 km² boxes we tried around Missoula, Montana, three had 3DEP lidar and three had none — including the Blue Mountain domain used throughout these guides. Check coverage before you plan around it. NAIP and Meta have no such gap.
- Cell size against crown size. Detection resolves peaks in a raster, so a cell must be well inside a crown for a crown to have a peak. That rules LANDFIRE out for this job and makes it the wrong tool here, however good it is for stand-scale canopy fuel.
Step 1 — Build the CHM grid
Section titled “Step 1 — Build the CHM grid”NAIP imagery is processed by a deep-learning model into a ~0.6 m canopy height surface, so this grid is fine-grained. It is a modeled surface (≈1 m typical height error) that also captures buildings and other tall structures — see Common pitfalls. No version or band selection is needed — the source resolves the NAIP-CHM tiles that cover your domain.
curl -X 'POST' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/grids/canopy/naip' \ -H 'accept: application/json' \ -H 'api-key: my-api-key' \ -H 'Content-Type: application/json' \ -d '{ "name": "NAIP CHM"}'import fastfuels_sdk.v2 as ff
ff.set_api_key("my-api-key")
domain = ff.Domain.from_id("your-domain-id")
chm = ff.grids.create_canopy_height_grid_from_naip_chm(domain, name="NAIP CHM")chm.wait()Poll to completed and record the grid id: your-chm-grid-id.
{ "id": "your-chm-grid-id", "domain_id": "your-domain-id", "status": "completed", "source": { "tile_metadata": { "acquisition_dates": null, "tiles": [ "https://rangeland.ntsg.umt.edu/data/naip-chm/2023/11/m_4611416_nw_11_060_20231019_20240103_chm.tif" ], "tile_source": null, "tile_count": 1, "native_crs": "EPSG:32611" }, "alignment": { "method": null, "target": "domain", "resolution": null }, "extent_buffer_cells": 0, "product": "naip", "description": "NAIP high-resolution canopy height model at ~0.6m resolution (CONUS)", "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": 3216975, "nodata_count": 0, "min": 0.0, "max": 32.689998626708984, "mean": 5.950183918105713, "std": 7.25633903389633 } } ], "name": "NAIP CHM", "description": "", "progress": { "message": "Complete", "percent": 100 }, "created_on": "2026-08-27T13:07:17.371609+00:00", "modified_on": "2026-08-27T13:07:20.783515+00:00", "checksum": "1a1b879885dc42e084e294c58e8ebe1c", "modifications": [], "georeference": { "crs": "EPSG:32611", "transform": [ 0.5997260672151095, 0.0, 720226.0, 0.0, -0.5997260672150779, 5190646.595949142 ], "shape": [1475, 2181] }, "error": null, "chunks": { "shape": [512, 512], "count": 15, "count_by_axis": { "x": 5, "y": 3 } }, "tags": []}The grid carries a single chm band (canopy height, in meters).
Step 2 — Isolate stems
Section titled “Step 2 — Isolate stems”Point source_chm_grid_id at the completed CHM grid and pick a stem-isolation
algorithm:
lmf(local maxima filter) — finds treetops as local height maxima within a fixedfootprint_sizewindow; ignores anything belowmin_height.vwf(variable window filter) — scales the search window with canopy height, which separates large and small crowns better in mixed stands.
Pick your algorithm below. For the full request schema and every field default, see the live API reference.
curl -X 'POST' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/inventories/tree/chm' \ -H 'accept: application/json' \ -H 'api-key: my-api-key' \ -H 'Content-Type: application/json' \ -d '{ "name": "Tree inventory from CHM", "source_chm_grid_id": "your-chm-grid-id", "algorithm": { "name": "lmf", "min_height": 2, "footprint_size": 3 }}'from fastfuels_sdk.v2.client_library.models import StemIsolationLmf
inventory = ff.inventories.create_tree_inventory_from_chm_grid( domain, chm, algorithm=StemIsolationLmf(min_height=2, footprint_size=3), name="Tree inventory from CHM",){ "id": "your-inventory-id", "domain_id": "your-domain-id", "type": "tree", "name": "Tree inventory from CHM", "description": "", "status": "pending", "progress": null, "created_on": "2026-06-05T13:46:55.133410", "modified_on": "2026-06-05T13:46:55.133410", "source": { "name": "chm", "source_chm_grid_id": "your-chm-grid-id", "algorithm": { "name": "lmf", "min_height": 2.0, "footprint_size": 3 } }, "modifications": [], "treatments": [], "columns": [ { "key": "x", "type": "continuous", "unit": "m" }, { "key": "y", "type": "continuous", "unit": "m" }, { "key": "height", "type": "continuous", "unit": "m" } ], "georeference": null, "error": null, "tags": []}curl -X 'POST' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/inventories/tree/chm' \ -H 'accept: application/json' \ -H 'api-key: my-api-key' \ -H 'Content-Type: application/json' \ -d '{ "name": "Tree inventory from CHM", "source_chm_grid_id": "your-chm-grid-id", "algorithm": { "name": "vwf", "min_height": 2, "crown_ratio": 0.1, "crown_offset": 1.0 }}'from fastfuels_sdk.v2.client_library.models import StemIsolationVwf
inventory = ff.inventories.create_tree_inventory_from_chm_grid( domain, chm, algorithm=StemIsolationVwf(min_height=2, crown_ratio=0.1, crown_offset=1.0), name="Tree inventory from CHM",){ "id": "your-inventory-id", "domain_id": "your-domain-id", "type": "tree", "name": "Tree inventory from CHM", "description": "", "status": "pending", "progress": null, "created_on": "2026-06-05T14:25:45.963971", "modified_on": "2026-06-05T14:25:45.963971", "source": { "name": "chm", "source_chm_grid_id": "your-chm-grid-id", "algorithm": { "name": "vwf", "min_height": 2.0, "spatial_resolution": null, "crown_ratio": 0.1, "crown_offset": 1.0 } }, "modifications": [], "treatments": [], "columns": [ { "key": "x", "type": "continuous", "unit": "m" }, { "key": "y", "type": "continuous", "unit": "m" }, { "key": "height", "type": "continuous", "unit": "m" } ], "georeference": null, "error": null, "tags": []}Record the inventory id: your-inventory-id. Poll until completed:
curl -X 'GET' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/inventories/your-inventory-id' \ -H 'accept: application/json' \ -H 'api-key: my-api-key'inventory.wait(){ "id": "your-inventory-id", "domain_id": "your-domain-id", "type": "tree", "status": "completed", "source": { "name": "chm", "algorithm": { "max_height": 120.0, "footprint_size": 3, "min_height": 2.0, "name": "lmf" }, "source_chm_grid_checksum": "1a1b879885dc42e084e294c58e8ebe1c", "source_chm_grid_id": "your-chm-grid-id" }, "name": "Tree inventory from CHM", "description": "", "progress": { "message": "Complete", "percent": 100 }, "created_on": "2026-08-27T13:07:22.830758+00:00", "modified_on": "2026-08-27T13:07:36.915534+00:00", "checksum": "d2e5e9f481014fe5aa035809b691848c", "modifications": [], "treatments": [], "columns": [ { "key": "x", "type": "continuous", "unit": "m", "summary": { "type": "continuous", "count": 9227, "null_count": 0, "min": 720226.2998630336, "max": 721533.7026895626, "mean": 720772.0657284688, "std": 365.3882023680253 } }, { "key": "y", "type": "continuous", "unit": "m", "summary": { "type": "continuous", "count": 9227, "null_count": 0, "min": 5189762.299863034, "max": 5190646.296086108, "mean": 5190226.879943475, "std": 250.60264716338543 } }, { "key": "height", "type": "continuous", "unit": "m", "summary": { "type": "continuous", "count": 9227, "null_count": 0, "min": 2.009999990463257, "max": 32.689998626708984, "mean": 14.734069580554808, "std": 6.691886304929943 } } ], "forestry_metrics": null, "georeference": { "crs": "EPSG:32611", "bounds": [720226.0, 5189762.0, 721534.0, 5190646.0] }, "error": null, "tags": []}On the Blue Mountain domain, lmf with a 3-pixel footprint and a 2 m minimum
detects ≈9,200 treetops; vwf with the default crown scaling detects ≈8,500.
These are overstory detections, not a full stem count — trees beneath the
dominant canopy aren’t seen (why).
The inventory’s data columns are x, y, and height — position and treetop
height, the observables a CHM provides.
Both counts are specific to this CHM at this cell size. footprint_size is
measured in pixels, so a 3-pixel footprint is 1.8 m on this 0.6 m NAIP grid and
would be 3 m on a 1 m lidar grid — and the surfaces differ in more than their
lattice. The source changes the answer shows
how far apart the same request lands on two sources.

Detected treetops (LMF) over the NAIP CHM, a 175 m window of the Blue Mountain domain. Each dot is one row of the inventory, placed at a local height maximum. Greener cells are taller canopy.
The source changes the answer
Section titled “The source changes the answer”Those counts belong to a NAIP CHM at 0.6 m. Run the same request against a CHM built from lidar and you get a different inventory of the same forest.
Here is that comparison on a domain that has both — a 136 ha stand on the
Blackfoot River near Bonner, Montana, with a NAIP CHM and a 1 m CHM built from
3DEP lidar, detected with identical parameters (lmf, footprint_size 3,
min_height 2 m):

The same 120 m stand. NAIP detects 23,513 treetops across the domain; the lidar CHM detects 64,317 — 2.7× more, from the same request body.
The reason is visible in the panels. NAIP is a modeled surface: a network predicts a height for every pixel, and it predicts smoothly, so a crown is a round mound with one summit. The lidar CHM is a measurement: each cell holds the tallest return that happened to land in it, so a crown is a rough cluster of peaks and troughs. A local-maxima filter counts summits, and one of these surfaces has far more of them.
That roughness is measurable. Over canopy cells, in a matched 3 m ground window, the mean local height standard deviation is 2.0 m for NAIP and 3.2 m for the lidar CHM.

One transect across that stand. The lidar CHM reads taller on the same crowns — 19.6 m against 16.2 m on the tallest — and the two sources disagree outright twice in 120 m, in both directions.
It is not just the cell size
Section titled “It is not just the cell size”footprint_size is measured in pixels, so the same number is a different
distance on the ground: 3 px is 1.8 m on a 0.6 m NAIP grid and 3.0 m on a 1 m
lidar grid. That matters, and it is worth correcting for — but correcting for it
does not close the gap.

Widening NAIP’s footprint to 5 px matches the lidar run’s 3 m search window and drops NAIP to 19,795 — still 3.2× fewer than the lidar CHM. The difference is in the surfaces, not only in the lattice.
Nor is the gap confined to short vegetation that one source resolves and the other smooths away. Every height band gains:

Detected treetops by height. The gap is widest at both ends — 6.7× among 2–4 m stems, and 20.8× above 25 m, where NAIP’s modeled surface rarely reaches (its 99th percentile height is 20.4 m against the lidar’s 23.6 m).
What to do with this. Neither count is the true stem count, and neither is “the correct one” — 472 detections per hectare from the lidar CHM is over-detection at these settings just as surely as NAIP’s smoothing hides real stems. Treat the source and the parameters as one choice, not two:
-
Re-tune whenever you change source. Parameters carried over from another CHM are not a starting point, they are a different question. Inspect the overlay and adjust until the dots track the crowns in this raster.
-
Convert
footprint_sizeto metres before comparing settings — multiply by the grid’s cell size. Two guides quoting “footprint 3” may mean different windows. -
Never compare stem counts across sources. A change in detected trees between two inventories is only meaningful if both came from the same CHM source at the same cell size.
Next steps
Section titled “Next steps”- Fill in morphology, then voxelize. A CHM inventory carries only
x,y, andheight, so it can’t be voxelized as-is — building 3D crowns needsdbh,crown_ratio, and species. Run GDAM allometry to impute those columns, then voxelize the inventory into a 3D canopy fuel grid. This is the intended path from a CHM to fuel. - Understand it — how tree detection from a CHM works: why a treetop is a local maximum, fixed vs. variable windows, and the over-/under-detection trade-off.
- Tune it — inspect the detection and adjust the parameters until the treetops track the crowns. Do this again after any change of source.
- Feed it lidar instead — build a CHM from a point cloud, the source used in the comparison above.
- Trim it — remove trees near roads or water.
- Read the treetops — fetch and stream the inventory data.
Common pitfalls
Section titled “Common pitfalls”- Too many or too few trees.
footprint_sizeandmin_heightcontrol detection sensitivity: a smaller footprint finds more (and more spurious) treetops; a highermin_heightdrops understory. Tune them to your stand, or switch tovwffor height-varying crowns. See How tree detection from a CHM works for the trade-offs. - Detecting structures as trees. This is a property of surface models in general, not of any one source: a CHM records what is physically above the ground, not what is vegetation. Buildings, powerlines, and towers all appear as tall cells, and detection will place treetops on them. Lidar is if anything more exposed to it than imagery — a conductor spanning a valley holds its elevation while the ground falls away beneath it, producing a line of very tall cells, and it sits in ASPRS class 1 whenever the vendor did not classify wires. Over developed or wildland-urban-interface land, mask it (for example, with building footprints) or restrict the domain to vegetated areas.
- Detecting on a CHM whose ground was inferred rather than measured. A
canopy height is a difference — a canopy return’s elevation minus the ground
beneath it — so a CHM is only as good as the ground surface under it. A
point-cloud CHM reports how that surface was established in
source.ground:ground_source(classificationorderived),ground_coverage, andmax_ground_distance_m. Read them before detecting. Nothing downstream re-checks: detection will place stems on interpolated heights without complaint, and the resulting inventory looks exactly like a well-grounded one. See Readsource.ground. - Comparing inventories built from different CHM sources. The counts are not
comparable — see The source changes the answer.
Neither is
footprint_sizeacross different cell sizes, since it is expressed in pixels. - Expecting species or DBH. A CHM doesn’t observe them — the inventory has
height only, so anything that needs per-tree morphology (voxelization,
silvicultural treatments) can’t run on it directly. Don’t hand-roll a
height–diameter relationship or abandon the CHM for TreeMap: fill in
dbh,crown_ratio, and species with the purpose-built GDAM allometry step, which imputes them from each tree’s position and height. - Treating the canopy as a complete fuel column. A CHM sees the overstory tops, not the sub-canopy and ladder fuels that carry fire into the crowns. For physics-based fire simulation, pair the CHM canopy with a modeled sub-canopy and surface layer (why).
- Creating the inventory before the CHM grid is
completed.tree/chmreads the grid’s data; poll step 1 tocompletedfirst.