Identifying Canopy Gaps Using Morphological Filters (scipy.ndimage Black Top-Hat)
Identifying canopy gaps with a morphological black top-hat transform gives you a deterministic, scale-aware alternative to flat height-thresholding or manual digitization. When you reduce an airborne LiDAR Canopy Height Model (CHM) to a binary CHM < 2 m mask, you inherit fragmented gap edges, false positives from tall understory, and terrain-induced artifacts on slopes. Treating the CHM as a continuous elevation surface and probing it with a structuring element of known radius fixes all three. This page is the morphological-filter implementation that the Forest Gap & Understory Analysis workflow references, and it consumes the normalized rasters produced upstream in the Canopy Height Modeling & Terrain Extraction pipeline. If your CHM still carries ground-interpolation error, fix that first in Canopy Height Model Creation — morphological filters cannot recover a depression that the height normalization never preserved.
When to Use a Morphological Filter
Reach for a morphological black top-hat when you need gaps defined relative to their local canopy envelope rather than against a single global height. The table contrasts the three approaches you will realistically choose between for raster-based gap delineation.
| Method | How it defines a gap | Strengths | Weaknesses |
|---|---|---|---|
Flat height threshold (CHM < h) |
Any pixel below an absolute height | Trivial, one parameter | Ignores local context; flags low-stature stands and slope shadows; ragged edges |
| Morphological black top-hat (this page) | Pixel sits in a depression deeper than the structuring element can bridge and below h |
Scale-aware, suppresses noise, consolidates fragments deterministically | Two coupled parameters (radius + drop); sensitive to CRS units |
| Region-growing / watershed | Seeded flood-fill from local minima | Captures irregular gap shapes well | Stochastic seeding, slower, harder to reproduce at tile scale |
Choose the morphological approach when reproducibility and tile-parallel throughput matter — the structuring element makes the scale of detection explicit, and the operation is a pure function of its parameters. The combination used here is closing minus original (a black top-hat), which is large precisely where the CHM sits in a basin of low canopy surrounded by taller crowns.
- Opening (
erosionthendilation) removes isolated canopy peaks smaller than the element. - Closing (
dilationthenerosion) bridges narrow canopy breaks and fills minor depressions. - Black top-hat (
closing − original) extracts depressions that fall below the structurally smoothed surface — the basis of this detector.
Minimal Reproducible Example
The implementation uses scipy.ndimage for the morphology and rasterio for windowed I/O, so it never loads an entire regional CHM into memory. Each block is read with explicit overlap padding so edge pixels always see a full neighbourhood; the padding is trimmed before write-back. For the windowing contract, see the Rasterio windowed read/write documentation.
import numpy as np
import rasterio
from rasterio.windows import Window
from scipy.ndimage import grey_closing, label, sum as ndimage_sum
from skimage.morphology import disk
def detect_canopy_gaps(
chm_path: str,
output_path: str,
gap_height_thresh: float = 2.0,
min_gap_area_m2: float = 15.0,
struct_radius_m: float = 5.0,
overlap_px: int = 32,
) -> None:
"""
Identify canopy gaps with a morphological black top-hat transform
(grey_closing - chm), which highlights dark valleys — i.e. low spots
surrounded by taller canopy. Processed in overlapping windows so edge
pixels never see a truncated neighbourhood.
"""
with rasterio.open(chm_path) as src:
if src.count != 1:
raise ValueError("Input CHM must be a single-band raster.")
if src.crs is None or src.transform is None:
raise ValueError("Input CHM must contain valid CRS and transform metadata.")
res = src.res[0] # Assume square pixels for simplicity
struct_elem = disk(max(1, int(round(struct_radius_m / res))))
area_thresh_px = min_gap_area_m2 / (res ** 2)
meta = src.meta.copy()
meta.update(dtype='uint8', count=1, nodata=0)
with rasterio.open(output_path, 'w', **meta) as dst:
for _ji, window in src.block_windows(1):
# Expand window with overlap to prevent edge truncation.
col_start_src = max(window.col_off - overlap_px, 0)
row_start_src = max(window.row_off - overlap_px, 0)
win = Window(
col_start_src,
row_start_src,
min(window.width + 2 * overlap_px, src.width - col_start_src),
min(window.height + 2 * overlap_px, src.height - row_start_src),
)
chm_block = src.read(1, window=win).astype(np.float32)
# Treat non-positive samples as nodata for top-hat math.
valid_mask = chm_block > 0
if not np.any(valid_mask):
continue
chm_block[~valid_mask] = 0
# Black top-hat: closing(chm) - chm — large where chm is locally low.
closed = grey_closing(chm_block, structure=struct_elem)
gap_surface = closed - chm_block
# Pixels are gaps if they sit in a deep depression AND fall below
# the absolute height threshold (so tall canopy with a small dip
# is not flagged as ground).
raw_gap_mask = (
(gap_surface >= gap_height_thresh)
& (chm_block < gap_height_thresh)
).astype(np.uint8)
# Filter connected components by minimum area.
labeled, num_features = label(raw_gap_mask)
if num_features > 0:
sizes = ndimage_sum(raw_gap_mask, labeled, range(1, num_features + 1))
keep_labels = np.where(sizes >= area_thresh_px)[0] + 1
final_mask = np.isin(labeled, keep_labels).astype(np.uint8)
else:
final_mask = np.zeros_like(raw_gap_mask, dtype=np.uint8)
# Trim the overlap padding before writing back to the un-padded window.
# The expanded window starts `min(overlap_px, window.row_off)` rows
# *above* the original — that prefix is the slice we discard.
row_start = min(overlap_px, window.row_off)
col_start = min(overlap_px, window.col_off)
h = min(window.height, final_mask.shape[0] - row_start)
w = min(window.width, final_mask.shape[1] - col_start)
dst.write(
final_mask[row_start:row_start + h, col_start:col_start + w],
1,
window=window,
)
The two coupled tests — gap_surface >= gap_height_thresh (the depression is deep enough that the structuring element could not bridge it) and chm_block < gap_height_thresh (the pixel is genuinely low) — are what separate a true gap from a small dip in tall, closed canopy.
Parameter Reference
| Parameter | Type | Default | Recommended range | Ecological rationale |
|---|---|---|---|---|
gap_height_thresh |
float (m) |
2.0 |
2.0–3.5 closed canopy; 1.0–1.5 open woodland | Minimum vertical drop that defines a gap; doubles as the absolute ceiling below which a pixel is considered sub-canopy. |
min_gap_area_m2 |
float (m²) |
15.0 |
10–50 temperate/boreal | Removes micro-depressions and single-pixel noise that carry no ecological meaning; converted to pixels via native resolution. |
struct_radius_m |
float (m) |
5.0 |
3–8 | Should approximate the dominant crown radius. Too large merges adjacent gaps; too small leaves canopy noise unsuppressed. |
overlap_px |
int (px) |
32 |
≥ 2 × struct_radius_m / res |
Pad width that lets every edge pixel see a full neighbourhood; prevents seams at tile boundaries. |
Two hard constraints sit underneath this table. First, the CHM must be in a metric projected CRS (UTM, State Plane) — geographic degrees distort the structuring-element radius and invalidate every area calculation. Second, area_thresh_px is computed from the raster’s native resolution, so the same min_gap_area_m2 yields a different pixel count at 0.5 m versus 1.0 m; never hard-code a pixel area.
Expected Output and Verification
detect_canopy_gaps writes a single-band uint8 GeoTIFF, 1 for gap pixels and 0 (nodata) elsewhere, perfectly co-registered with the input CHM. Before trusting it downstream, assert three things: the grid aligns, the result is genuinely binary, and the total gap fraction is ecologically plausible (closed temperate canopy is rarely more than ~20–30 % gap).
import numpy as np
import rasterio
with rasterio.open("chm.tif") as chm, rasterio.open("gaps.tif") as gaps:
# 1. Grid alignment — gaps must inherit the CHM transform and shape.
assert chm.transform == gaps.transform, "Output is not co-registered with the CHM."
assert chm.shape == gaps.shape, "Output grid differs from input grid."
mask = gaps.read(1)
# 2. Strictly binary.
assert set(np.unique(mask)).issubset({0, 1}), "Mask is not binary."
# 3. Sanity-check the gap fraction.
gap_fraction = mask.mean()
print(f"Gap fraction: {gap_fraction:.1%}")
assert gap_fraction < 0.40, "Gap fraction implausibly high — re-check struct_radius_m."
For a visual check, overlay the mask on the CHM in QGIS or with rasterio.plot.show: true gaps should be compact, sit inside taller canopy, and follow real openings (skid trails, blowdowns, treefall gaps) rather than tracking the slope aspect — the latter signals leftover terrain bias from the DTM.
Common Pitfalls
- Geographic CRS input. Running on WGS84 degrees makes
struct_radius_m / resmeaningless and area filtering silently wrong. Reproject to a metric CRS during Canopy Height Model Creation, not here. - NaN propagation through
grey_closing. If nodata reaches the morphology asNaN, the closing spreads it across the whole block. Thechm_block[~valid_mask] = 0line must run beforegrey_closing, exactly as written. - Insufficient tile overlap. An
overlap_pxsmaller than the element radius leaves a seam of false gaps along block edges. Keepoverlap_px ≥ 2 × struct_radius_m / res. - Tall understory read as canopy. In multi-strata stands, regeneration above
gap_height_threshhides real gaps. Raise the threshold to ≥3 m or mask known shrub zones with a separate land-cover raster before detection.
Frequently Asked Questions
Should the height threshold be absolute or relative to the stand?
Relative, wherever the stand height varies across the analysis extent. A fixed 2 m threshold identifies genuine openings in a 25 m stand and labels an entire young plantation as gap. Expressing the threshold as a fraction of the local dominant height — typically a quarter to a third, computed over a neighbourhood a few crown widths wide — keeps the definition consistent across an ownership containing several age classes. Where the extent is a single even-aged stand, an absolute threshold is simpler and equivalent.
Does the order of opening and closing matter?
Yes, and they are not interchangeable. Opening first removes isolated specks before closing joins anything, which is usually what you want. Closing first can weld a cluster of specks into a single region that the subsequent opening then keeps, manufacturing a gap that was never there. Run the opening first, and size its structuring element from the minimum gap you intend to map rather than from what makes the output look tidy.
How do I stop a road being mapped as a canopy gap?
Exclude it explicitly rather than trying to filter it by shape. A road is a genuine opening by every height criterion, so no threshold will remove it; what distinguishes it is that it is not ecologically a gap. Mask road, track and watercourse buffers out of the analysis extent before delineation, and record that you did — an eight percent gap fraction with roads included and five percent with them excluded are both defensible numbers and only one of them answers a regeneration question.
Related
- Forest Gap & Understory Analysis — the parent workflow this detector plugs into (fragmentation metrics, PAR estimation).
- Canopy Height Model Creation — produce the normalized CHM this method consumes.
- Generating high-res DTM from ALS data — the terrain surface whose quality determines whether gaps align with field observation.
- Normalizing LiDAR point clouds with PDAL — height-above-ground normalization that precedes any CHM.
Up to the parent workflow: Forest Gap & Understory Analysis · Up to the pipeline overview: Canopy Height Modeling & Terrain Extraction.