Skip to content

Check and adjust CHM tree detection

You are viewing in-progress documentation for v2. Switch to the stable version for the current production release.

Generate a tree inventory from a CHM places one tree at each peak the detection algorithm finds in the canopy height model, and its settings decide what counts as a peak. The defaults — lmf, footprint_size 3, min_height 2 — are a starting point, not a fit for your stand, and a finished inventory gives no warning when they are wrong.

Detection goes wrong in three ways:

  • One crown, several trees (over-detection).
  • Several crowns, one tree (under-detection).
  • Trees where there are none: on low shrubs taller than min_height, and on buildings or power lines.

How tree detection from a CHM works explains why each happens.

The tree count alone cannot tell you which of these you have: a plausible count can hide over-detection in one stand and under-detection in the next. This guide runs a few candidate settings on the same CHM and compares them over a close-up you can judge by eye.

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

  2. A domain (your-domain-id) with a completed CHM grid (your-chm-grid-id). Step 1 of Generate a tree inventory from a CHM builds one.

  3. Python with fastfuels-sdk and matplotlib, for the overlay.

Step 1 — Detect with a few candidate settings

Section titled “Step 1 — Detect with a few candidate settings”

Each candidate is an ordinary tree inventory from the same CHM grid. Change one setting at a time against the default, so that each comparison shows one effect:

POST inventories/tree/chm — one candidate
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": "lmf, footprint_size 9",
"source_chm_grid_id": "your-chm-grid-id",
"algorithm": {
"name": "lmf",
"min_height": 2,
"footprint_size": 9
}
}'

Send one request per candidate, changing only the algorithm block.

On the Blue Mountain domain the four candidates detect:

lmf, footprint_size 3: 8,040 trees
lmf, footprint_size 9: 6,556 trees
vwf, default window: 7,369 trees
lmf, footprint_size 3, min_height 8: 6,401 trees

A spread of 1,600 trees across 116 ha says the settings matter here. It does not say which one is right.

Step 2 — Overlay them on the same close-up

Section titled “Step 2 — Overlay them on the same close-up”

Draw each candidate’s treetops over one close-up of the CHM. Pick a window that holds both dense canopy and open ground, small enough (about 60 m) that single crowns are legible. This continues from the code above:

Overlay the candidates
import matplotlib.pyplot as plt
# A close-up, in metres from the CHM's south-west corner. Keep it small enough
# (about 60 m) that single crowns are legible.
X0, Y0, SIZE = 1060, 410, 60
height = chm.to_numpy("chm")
dx, _, west, _, dy, north = chm.georeference.transform
south = north + dy * height.shape[0]
extent = (west, west + dx * height.shape[1], south, north)
x0, y0 = west + X0, south + Y0
fig, axes = plt.subplots(1, len(inventories), figsize=(4 * len(inventories), 4.4), sharey=True)
for ax, (label, inventory) in zip(axes, inventories.items()):
trees = inventory.to_dataframe()
ax.imshow(height, extent=extent, cmap="Greens", vmin=0, vmax=30, interpolation="nearest")
ax.scatter(trees.x, trees.y, s=16, c="#d6336c", edgecolors="white", linewidths=0.6)
ax.set(xlim=(x0, x0 + SIZE), ylim=(y0, y0 + SIZE), aspect="equal", title=label)
fig.savefig("candidates.png", dpi=150)
Four panels of the same 60 metre square of the canopy height model, each with pink dots for one candidate's treetops. Footprint 3: clusters of dots on the large crowns at top left, and dots scattered over the open ground below. Footprint 9: far fewer dots on the large crowns. The default vwf window: between the two. Footprint 3 with min_height 8: the open ground is clear, but the large crowns still carry clusters. Four panels of the same 60 metre square of the canopy height model, each with pink dots for one candidate's treetops. Footprint 3: clusters of dots on the large crowns at top left, and dots scattered over the open ground below. Footprint 9: far fewer dots on the large crowns. The default vwf window: between the two. Footprint 3 with min_height 8: the open ground is clear, but the large crowns still carry clusters.

Four candidates over one 60 m close-up of the Blue Mountain NAIP CHM. Footprint 3 places 47 treetops here, 13 of them under 8 m on the open ground; footprint 9 places 34, the default vwf window 37, and footprint 3 with min_height 8 places 34, none under 8 m.

Read the panels against each other:

  • Footprint 3 puts clusters of dots on the large crowns at the top left — several trees per crown — and dots across the low vegetation below.
  • Footprint 9 clears most of the clusters. The dots on the open ground stay.
  • vwf thins the clusters less than footprint 9 does. Its dots on the open ground stay too.
  • min_height 8 clears the open ground and does nothing about the clusters.

footprint_size is in pixels: multiply by the CHM’s cell size for the window in metres. On this 0.6 m NAIP grid, 3 px is 1.8 m and 9 px is 5.4 m; on a 1 m lidar CHM the same numbers mean 3 m and 9 m.

Match what you see to the change that fixes it:

You seeCauseChange
Several dots on one crownWindow smaller than the crownRaise footprint_size, or switch to vwf
One dot on two crowns you can see apartWindow wider than the gap between themLower footprint_size, or lower vwf’s crown_ratio or crown_offset
Dots on low shrubs or open groundmin_height below the shrub layerRaise min_height
Dots in lines or on roofsPower lines or buildingsRemove them with a spatial modification; see Remove trees with features

Settings combine: here, vwf with a raised min_height addresses both the clusters and the open ground. Detect the combination as one more candidate and overlay it the same way.

Then decide:

  • By eye. Prefer the candidate whose dots sit one per visible crown. Check two or three close-ups that look like most of your domain, not only the densest one.
  • Against field data, if you have it. Compare the detected trees per hectare with plot counts of overstory trees taller than min_height. Compare against the overstory only: detection cannot see trees beneath the dominant canopy, so it will always undercount total stems (why).

Keep the candidate you chose and delete the others, with inventory.delete() in the SDK or DELETE /domains/{domain_id}/inventories/{inventory_id}. Each candidate is a full inventory and counts toward your quotas.

To inspect detection in QGIS or ArcGIS rather than Python, export the CHM grid as a GeoTIFF and an inventory as GeoJSON. Exports are asynchronous: the create call returns an export in status: "pending", which you poll until completed.

POST grids/{grid_id}/exports/geotiff
curl -X 'POST' \
'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/grids/your-chm-grid-id/exports/geotiff' \
-H 'accept: application/json' \
-H 'api-key: my-api-key' \
-H 'Content-Type: application/json' \
-d '{
"name": "Blue Mountain NAIP CHM",
"bands": ["chm"]
}'
POST inventories/{inventory_id}/exports/geojson
curl -X 'POST' \
'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/inventories/your-inventory-id/exports/geojson' \
-H 'accept: application/json' \
-H 'api-key: my-api-key' \
-H 'Content-Type: application/json' \
-d '{
"name": "Treetops"
}'
GET export status
curl -X 'GET' \
'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/exports/your-export-id' \
-H 'accept: application/json' \
-H 'api-key: my-api-key'

Each export has its own id (your-export-id). A completed export carries a signed_url: a direct download that needs no API key and stays valid for seven days. It is what the SDK’s .to_file() downloads.

{
"id": "your-export-id",
"domain_id": "your-domain-id",
"name": "Blue Mountain NAIP CHM",
"description": "",
"status": "completed",
"progress": {
"percent": 100,
"message": "Complete"
},
"created_on": "2026-10-30T22:29:31.466795Z",
"modified_on": "2026-10-30T22:29:32.615009Z",
"source": {
"grid_id": "your-chm-grid-id",
"bands": ["chm"],
"name": "geotiff"
},
"signed_url": "https://storage.googleapis.com/silvx-fastfuels-exports-v2/your-export-id/Blue_Mountain_NAIP_CHM.tif?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=...&X-Goog-Date=...&X-Goog-Expires=604800&X-Goog-SignedHeaders=host&response-content-disposition=attachment%3B%20filename%3D%22Blue_Mountain_NAIP_CHM.tif%22&X-Goog-Signature=...",
"expires_on": "2026-11-06T22:29:31.466795Z",
"error": null,
"tags": []
}

With curl, download each signed_url yourself:

Terminal window
curl -o chm.tif "<CHM_SIGNED_URL>"
curl -o treetops.geojson "<TREETOPS_SIGNED_URL>"

Exports are not deleted with their domain; delete each one when you are done with DELETE /exports/{export_id}, or export.delete() in the SDK.