Skip to content

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:

  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-13T02:04:36.391401Z",
"modified_on": "2026-10-13T02:04:47.490667Z",
"checksum": "679ac763106440f998d563f5afe8f97c",
"source": {
"tile_metadata": {
"tile_count": 1,
"native_crs": "EPSG:32611",
"tiles": [
"https://rangeland.ntsg.umt.edu/data/naip-chm/2023/11/m_4611416_nw_11_060_20231019_20240103_chm.tif"
],
"acquisition_dates": null,
"tile_source": null
},
"alignment": {
"method": null,
"target": "domain",
"resolution": null
},
"name": "canopy",
"description": "NAIP high-resolution canopy height model at ~0.6m resolution (CONUS)",
"extent_buffer_cells": 0,
"product": "naip"
},
"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, green crowns on cream ground, with 294 small white 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. Brighter cells are taller canopy.

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 dark 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 (white 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.

Here is that comparison on a domain that has both: the 53 ha Blackfoot River domain from From lidar to a tree inventory, east of Missoula, Montana. It has a NAIP CHM and a 1 m CHM built from 3DEP lidar. Both are detected with identical parameters (lmf, footprint_size 3, min_height 2 m):

Two canopy height maps of the same 120 metre stand side by side, both green-on-cream with white treetop dots and a dashed line marking a transect. The NAIP panel on the left shows smooth, round crown mounds with one dot each, 112 in all. The lidar panel on the right shows sharp, blocky crowns, some carrying two or three dots, plus many dots scattered over low vegetation, 219 in all.

The same 120 m stand. NAIP detects 3,715 treetops across the domain; the lidar CHM detects 9,248, 2.5× 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 1.8 m for NAIP and 2.9 m for the lidar CHM.

A line chart of two canopy height profiles along a 120 metre transect. The green NAIP curve is smooth and rounded; the brown lidar curve is rougher and taller on most crowns. Two shaded bands, each with an arrow and a note, mark where one source records a tree and the other records bare ground: lidar sees a 7.0 metre tree that NAIP misses, and NAIP sees a 10.0 metre tree that lidar misses.

The transect marked in the stand above. The lidar CHM reads taller on the same crowns, 23.8 m against 20.1 m on the tallest, and the two sources disagree outright twice in 120 m, in both directions.

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.

A horizontal bar chart of three detection runs. NAIP with footprint 3 detects 3,715 treetops with a 1.8 metre search window; NAIP with footprint 5 detects 3,461 with a 3 metre window; the lidar CHM with footprint 3 detects 9,248 with the same 3 metre window.

Widening NAIP’s footprint to 5 px matches the lidar run’s 3 m search window and drops NAIP to 3,461, still 2.7× 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:

Grouped horizontal bars comparing detected treetop counts by height band for the two sources. The lidar CHM leads in every band, by 4.2 times in the 2 to 4 metre band, about 2 times through the main canopy from 8 to 20 metres, and 5.9 times in the 25 to 40 metre band.

Detected treetops by height. The gap is widest at both ends: 4.2× among 2–4 m treetops, and 5.9× above 25 m, where NAIP’s modeled surface rarely reaches. Its 99th percentile canopy height is 24.3 m, against 28.3 m for the lidar CHM.

What to do with this. Neither count is the true stem count, and neither is “the correct one”. The lidar CHM’s 175 detections per hectare include detections on low vegetation and on a power line, and NAIP’s smoothing hides real stems. 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. Inspect the overlay and adjust until the dots track the crowns in this raster.

  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.