Skip to content

Generate a tree inventory from a CHM

You are viewing in-progress documentation for v2. 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:

  1. 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.
  2. 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.

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

  2. A domain: your-domain-id. See Create a domain.

Four canopy constructors emit a chm band, and a fifth route lets you bring your own raster. Any of them can feed Step 2:

SourceEndpointCell sizeWhat it is
NAIPgrids/canopy/naip~0.6 mA deep-learning canopy height model derived from aerial imagery. CONUS only. Used in Step 1 below.
Metagrids/canopy/meta~1 mA deep-learning canopy height model with global coverage.
Airborne lidargrids/canopy/point_cloud1 m or coarserRasterized from a point cloud you fetched from 3DEP or uploaded. A direct measurement rather than a model.
LANDFIREgrids/canopy/landfire30 mA 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 rastergrids/upload/geotiffwhatever you supplyAny 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.

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.

POST grids/canopy/naip
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"
}'

Poll to completed and record the grid id: your-chm-grid-id.

{
"id": "your-chm-grid-id",
"domain_id": "your-domain-id",
"name": "NAIP CHM",
"description": "",
"status": "completed",
"progress": {
"percent": 100,
"message": "Complete"
},
"created_on": "2026-10-30T22:06:40.721017Z",
"modified_on": "2026-10-30T22:07:09.140489Z",
"checksum": "51959f1066d0423ab668c4241ac3c302",
"source": {
"name": "canopy",
"tile_metadata": {
"acquisition_dates": null,
"tile_count": 1,
"tile_source": null,
"native_crs": "EPSG:32611",
"tiles": [
"https://rangeland.ntsg.umt.edu/data/naip-chm/2023/11/m_4611416_nw_11_060_20231019_20240103_chm.tif"
]
},
"alignment": {
"target": "domain",
"method": null,
"resolution": null
},
"product": "naip",
"description": "NAIP high-resolution canopy height model at ~0.6m resolution (CONUS)",
"extent_buffer_cells": 0
},
"modifications": [],
"bands": [
{
"key": "chm",
"name": "Canopy Height",
"description": "Height above ground of the canopy top.",
"type": "continuous",
"unit": "m",
"index": 0,
"nodata": null,
"summary": {
"type": "continuous",
"count": 3216975,
"nodata_count": 0,
"min": 0.0,
"max": 32.689998626708984,
"mean": 5.950183918105713,
"std": 7.25633903389633
}
}
],
"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).

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 fixed footprint_size window; ignores anything below min_height.
  • vwf (variable window filter) — scales the search window with canopy height, which separates large and small crowns better in mixed stands. The window diameter in meters is crown_offset + crown_ratio × height. The defaults are crown_offset 3.0 and crown_ratio 0.05.

Pick your algorithm below. For the full request schema and every field default, see the live API reference.

POST inventories/tree/chm — lmf
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
}
}'

Record the inventory id: your-inventory-id. Poll until completed:

GET inventory status
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'

On the Blue Mountain domain, lmf with a 3-pixel footprint and a 2 m minimum detects 8,040 treetops; vwf with the default window detects 7,369. These are overstory detections, not a full stem count — trees beneath the dominant canopy aren’t seen (why). The inventory’s data columns are tree_id, x, y, and height — an id, 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.

A 175 metre square of the NAIP canopy height model with 294 small pink dots, one on each canopy peak. A 175 metre square of the NAIP canopy height model with 294 small pink dots, one on each canopy peak.

Detected treetops (LMF, footprint_size 3) 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.

Add crown_segmentation to the request to measure each tree’s crown on the CHM. Each crown grows outward from its treetop over the CHM cells, within the detection height range, until it reaches the crown’s edge. Each cell belongs to at most one tree. The inventory gets a crown_radius column (m): the radius of a circle with the crown’s area. Each radius is at least that of one CHM cell and at most max_crown_radius (10 m by default).

The CHM grid must be at 2 m resolution or finer, or the request returns 422. {} uses the defaults; the API reference lists every field.

POST inventories/tree/chm — with crown_segmentation
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, with crown radius",
"source_chm_grid_id": "your-chm-grid-id",
"algorithm": {
"name": "lmf",
"min_height": 2,
"footprint_size": 3
},
"crown_segmentation": {}
}'

The same 8,040 trees come back, now with a crown_radius column.

Left: a 50 metre square of the NAIP canopy height model with stair-stepped crown outlines, a pink dot at each treetop and a pink circle around each dot whose radius is that tree's crown_radius; each circle covers about the same area as the outline around it. Right: a histogram of crown_radius for all 8,040 trees, a single peak with a median of 4.1 metres and a range from about 0.3 to 8.7 metres. Left: a 50 metre square of the NAIP canopy height model with stair-stepped crown outlines, a pink dot at each treetop and a pink circle around each dot whose radius is that tree's crown_radius; each circle covers about the same area as the outline around it. Right: a histogram of crown_radius for all 8,040 trees, a single peak with a median of 4.1 metres and a range from about 0.3 to 8.7 metres.

Left: each tree’s measured crown (outline) and a circle with its crown_radius (pink). The circle has the crown’s area, not its shape. Right: crown_radius across the Blue Mountain domain.

To use the radius, complete the inventory with GDAM, which keeps the crown_radius column, then voxelize it with measured crown radii. To keep each crown’s full outline instead of one radius, create a crown Feature from the inventory and the CHM grid. For how the two compare, see Where crown geometry comes from.

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. On the 53 ha Blackfoot River domain from From lidar to a tree inventory, lmf with footprint_size 3 and min_height 2 m detects 3,715 treetops on the NAIP CHM and 9,248 on a 1 m CHM from 3DEP lidar: 2.5× more, from the same request body. Widening NAIP’s footprint to match the lidar run’s 3 m window only brings it to 3,461.

Neither count is the true stem count: the lidar detections include low vegetation and a power line, and NAIP’s smoothing hides real stems. For why the two surfaces disagree, see Why the source changes the count. Treat the source and the parameters as one choice, not two:

  1. Re-tune whenever you change source. Parameters carried over from another CHM are not a starting point, they are a different question. Compare candidate settings on the new CHM and choose again.

  2. Convert footprint_size to metres before comparing settings — multiply by the grid’s cell size. Two guides quoting “footprint 3” may mean different windows.

  3. 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.

  • Too many or too few trees. footprint_size and min_height control detection sensitivity: a smaller footprint finds more (and more spurious) treetops; a higher min_height drops understory. Tune them to your stand, or switch to vwf for 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 (classification or derived), ground_coverage, and max_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 Read source.ground.
  • Comparing inventories built from different CHM sources. The counts are not comparable — see The source changes the answer. Neither is footprint_size across 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/chm reads the grid’s data; poll step 1 to completed first.