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 — here from NAIP aerial imagery (canopy/naip), a ~0.6 m canopy height model covering the conterminous US.
  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.

The whole flow in one script:

Detect trees from a CHM (both steps + polling)
import time
import requests
API_KEY = "my-api-key"
DOMAIN_ID = "your-domain-id"
BASE = "https://api-v2-prod-nyvjyh5ywa-uw.a.run.app"
HEADERS = {"api-key": API_KEY}
def poll(kind: str, resource_id: str) -> dict:
while True:
r = requests.get(
f"{BASE}/domains/{DOMAIN_ID}/{kind}/{resource_id}", headers=HEADERS
).json()
if r["status"] in ("completed", "failed"):
return r
time.sleep(5)
# Step 1: a canopy height model from NAIP aerial imagery (~0.6 m, CONUS).
chm = requests.post(
f"{BASE}/domains/{DOMAIN_ID}/grids/canopy/naip",
headers=HEADERS,
json={"name": "NAIP CHM"},
).json()
chm = poll("grids", chm["id"])
# Step 2: isolate individual stems from the CHM with a local-maxima filter.
inv = requests.post(
f"{BASE}/domains/{DOMAIN_ID}/inventories/tree/chm",
headers=HEADERS,
json={
"name": "Tree inventory from CHM",
"source_chm_grid_id": chm["id"],
"algorithm": {"name": "lmf", "min_height": 2, "footprint_size": 3},
},
).json()
inv = poll("inventories", inv["id"])
print(inv["id"], inv["status"]) # -> <inventory id> completed

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-06-05T13:46:28.404212Z",
"modified_on": "2026-06-05T13:46:50.517489Z",
"source": {
"product": "naip",
"name": "canopy",
"tile_metadata": {
"native_crs": "EPSG:32611",
"tile_count": 1,
"tiles": [
"https://rangeland.ntsg.umt.edu/data/naip-chm/2023/11/m_4611416_nw_11_060_20231019_20240103_chm.tif"
],
"tile_source": null,
"acquisition_dates": null
},
"description": "NAIP high-resolution canopy height model at ~0.6m resolution (CONUS)",
"alignment": {
"resolution": null,
"target": "domain",
"method": null
},
"extent_buffer_cells": 0
},
"modifications": [],
"bands": [
{
"key": "chm",
"name": "Canopy Height",
"description": "Height of the canopy top above ground.",
"type": "continuous",
"unit": "m",
"index": 0,
"nodata": null
}
],
"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.

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
}
}'
{
"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": []
}

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

A close-up of the NAIP canopy height model with white dots marking each detected treetop sitting on a canopy peak.

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.

  • 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 buildings as trees. NAIP-CHM is a surface model that includes buildings, powerlines, and other tall structures. Over developed or wildland-urban-interface land, detection places treetops on infrastructure — mask it (for example, with building footprints) or restrict the domain to vegetated areas.
  • 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.