Watershed Crown Segmentation from a CHM in Python
Detection gives you a stem location; segmentation gives that stem an extent. This guide covers the delineation half only: you already have tree-top markers from Local Maxima Tree Detection with a Variable Window Filter, and you need crown polygons whose areas are usable in an allometric equation. The wider workflow is Individual Tree Detection and Crown Segmentation, inside Canopy Height Modeling & Terrain Extraction.
When to use this approach
Marker-controlled watershed is the default because it guarantees one crown per marker: the segment count is decided by detection and cannot drift. Its weakness is boundary placement in stands where crowns interlock without a height dip between them.
| Method | Segment count | Boundary quality in interlocking crowns | Cost | When it wins |
|---|---|---|---|---|
| Marker-controlled watershed | Fixed by markers | Fair — the ridge follows the height valley, which may not exist | Low | Almost always; the reliable default |
| Compact watershed | Fixed by markers | Better — a compactness term keeps crowns from sprawling | Low | Wide crowns bleeding into their neighbours |
| Region growing from seeds | Fixed by seeds | Good with a well-chosen height-drop rule | Moderate | Conifers with a strong conical profile |
| Unseeded watershed | Discovered | Poor — every local dip becomes a crown | Low | Never, for inventory work |
Use the plain watershed first. Reach for the compact variant only when validation shows crown areas that are implausibly large at stand edges, which is the signature of a flood that had nowhere to stop.
Minimal reproducible example
The whole segmentation is one call plus the bookkeeping that makes it usable. The inputs are the smoothed height array, an integer marker raster in which each tree has its own label, and a boolean forest mask.
import numpy as np
import rasterio
from scipy import ndimage as ndi
from skimage.segmentation import watershed
def segment_crowns(chm_path: str, top_rows, top_cols, min_height: float = 2.0):
"""Marker-controlled watershed over a CHM. Returns (labels, heights, profile)."""
with rasterio.open(chm_path) as src:
chm = src.read(1, masked=True).astype("float32")
profile = src.profile
heights = chm.filled(0.0)
heights[heights < 0] = 0.0
forest = heights >= min_height
markers = np.zeros(heights.shape, dtype="int32")
markers[np.asarray(top_rows), np.asarray(top_cols)] = np.arange(
1, len(top_rows) + 1, dtype="int32")
labels = watershed(-heights, markers=markers, mask=forest)
return labels.astype("int32"), heights, profile
Two details carry most of the risk. The surface is negated because a watershed floods basins and a crown is a peak. The mask is not optional: without it the flood assigns every clearing, road, and river to whichever crown happens to be nearest, and crown areas at stand edges become meaningless.
Parameter reference
| Parameter | Type | Default | Recommended range | Ecological rationale |
|---|---|---|---|---|
mask |
boolean array | forest ≥ 2 m | — | Confines the flood to canopy; without it crown area is a distance artefact |
connectivity |
int | 1 | 1–2 | 1 uses 4-neighbours and produces blockier but more conservative crowns |
compactness |
float | 0 | 0–0.01 | Penalises sprawl; raise it only when edge crowns come back implausibly large |
watershed_line |
bool | False | — | True leaves a one-pixel gap between crowns, which loses about 5% of crown area |
min_area |
float (m²) | 1.5 | 1.0–4.0 | Drops slivers created where three basins meet |
max_area |
float (m²) | 400 | 150–600 | Flags merged clumps; set above the largest plausible single crown |
Leave watershed_line at False for inventory work. The visual appeal of separated crowns is not worth systematically shrinking every crown by its perimeter.
Clean up and vectorise
Raw watershed output always contains a few slivers where three floods meet, and occasionally one enormous region where a marker was missing. Filter on area, then polygonise.
import geopandas as gpd
from rasterio.features import shapes
from shapely.geometry import shape
def crowns_to_polygons(labels, heights, transform, crs,
min_area=1.5, max_area=400.0):
"""Vectorise crown labels, attach metrics, and drop implausible polygons."""
keep, dropped = [], {"sliver": 0, "clump": 0}
for geom, value in shapes(labels, mask=labels > 0, transform=transform):
poly = shape(geom)
if poly.area < min_area:
dropped["sliver"] += 1
continue
if poly.area > max_area:
dropped["clump"] += 1
continue
sel = labels == int(value)
keep.append({
"tree_id": int(value),
"height_m": float(heights[sel].max()),
"crown_area_m2": float(poly.area),
"crown_diam_m": float(2.0 * (poly.area / np.pi) ** 0.5),
"compactness": float(4 * np.pi * poly.area / (poly.length ** 2)),
"geometry": poly,
})
return gpd.GeoDataFrame(keep, crs=crs), dropped
The compactness column — the isoperimetric ratio, 1.0 for a perfect circle — is worth keeping. Values below about 0.3 mark polygons that snaked around an obstacle, which is nearly always a segmentation error rather than a genuinely ragged crown.
Expected output and verification
A correct segmentation has one polygon per detection (minus the slivers you deliberately dropped), and a crown-area distribution that rises with height in the way local allometry predicts. The strongest single check is that regression, because it needs no field data at all:
import numpy as np
def crown_area_sanity(crowns) -> dict:
"""Crown area should increase with height; a flat or negative slope is a red flag."""
h = crowns["height_m"].to_numpy()
r = np.sqrt(crowns["crown_area_m2"].to_numpy() / np.pi)
slope, intercept = np.polyfit(h, r, 1)
return {
"n_crowns": int(len(crowns)),
"radius_vs_height_slope": float(slope),
"median_area_m2": float(np.median(crowns["crown_area_m2"])),
"p95_area_m2": float(np.percentile(crowns["crown_area_m2"], 95)),
"median_compactness": float(np.median(crowns["compactness"])),
}
stats = crown_area_sanity(crowns)
assert stats["radius_vs_height_slope"] > 0.02, "crown radius does not grow with height"
assert stats["median_compactness"] > 0.35, "crowns are snaking — check the mask"
A slope near zero means the markers are wrong, not the watershed: if every crown gets roughly the same area regardless of height, the flood is being stopped by marker spacing rather than by the height surface, which happens when the detection window was far too small.
Common pitfalls
- Segmenting the unsmoothed CHM while detecting on the smoothed one. The two surfaces have slightly different ridges, so crown boundaries land a pixel or two off every marker. Flood the same array you detected on, then read heights from the raw surface.
- Markers outside the mask. A detection on a pixel just below the height threshold becomes a label with no basin, and
skimagesilently returns an empty region for it — the crown count then quietly disagrees with the detection count. - Forgetting that label 0 is background. Marker numbering must start at 1; a tree numbered 0 is treated as unlabelled and its crown is absorbed by a neighbour.
- Filtering slivers before checking how many there are. A handful is normal. Hundreds means the marker set has clusters of duplicates and the fix belongs upstream in detection, not in an area filter.
Frequently Asked Questions
Should I use watershed_line=True so crowns do not touch?
Only for cartography. The line consumes one pixel around every crown, which at 0.5 m resolution removes roughly 5–10% of the area of a typical crown and biases every area-based allometric estimate downward. Keep it off for analysis and dissolve or buffer for display instead.
My crowns look square. Is something wrong?
Probably not. A watershed on a raster produces pixel-edge boundaries, and with connectivity=1 those edges are axis-aligned. If the shapes matter — for a species classifier that uses crown outline, or for a figure — simplify with a tolerance near half a pixel after computing area, never before.
How do I handle crowns that straddle a tile boundary?
Segment on a buffered tile and keep only crowns whose marker falls inside the nominal extent. That assigns each straddling tree to exactly one tile with no duplicates and no losses, and it is the same halo rule used for the classification stages in Tiling and Buffering Large LiDAR Surveys with PDAL.
Related
- Individual Tree Detection and Crown Segmentation — the end-to-end workflow this step belongs to
- Local Maxima Tree Detection with a Variable Window Filter — produces the markers this page floods from
- Identifying Canopy Gaps Using Morphological Filters — the complementary operation on the unmasked space
- Canopy Height Model Creation — the surface being segmented
Up: Individual Tree Detection & Segmentation · Canopy Height Modeling & Terrain Extraction