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 (erosion then dilation) removes isolated canopy peaks smaller than the element.
  • Closing (dilation then erosion) 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.
Black top-hat profile across a canopy gap A transect of canopy height. Two tall crowns sit on either side of a low gap. The grey-closing operation drapes a smooth envelope from crown to crown, leaving the gap as a deep valley. The vertical distance between that envelope and the real surface — the closing-minus-original residual — is shaded; where it exceeds the height threshold and the surface is itself below the threshold, pixels are flagged as gap. canopy height (m) transect distance → gap_height_thresh grey_closing envelope black top-hat = closing − CHM detected gap crown crown

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.

Flat-threshold mask versus black top-hat mask on the same CHM Two panels share the same canopy height underlay shaded by a diagonal slope gradient. Left, the naive flat threshold flags scattered ragged fragments and a wedge of false gaps that tracks the downslope edge. Right, the black top-hat returns three compact, well-formed gap polygons sitting inside taller canopy and ignores the slope. flat threshold (CHM < h) slope false positives black top-hat compact gap polygons · slope suppressed same CHM underlay · darker = downslope

Common Pitfalls

  • Geographic CRS input. Running on WGS84 degrees makes struct_radius_m / res meaningless 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 as NaN, the closing spreads it across the whole block. The chm_block[~valid_mask] = 0 line must run before grey_closing, exactly as written.
  • Insufficient tile overlap. An overlap_px smaller than the element radius leaves a seam of false gaps along block edges. Keep overlap_px ≥ 2 × struct_radius_m / res.
  • Tall understory read as canopy. In multi-strata stands, regeneration above gap_height_thresh hides real gaps. Raise the threshold to ≥3 m or mask known shrub zones with a separate land-cover raster before detection.

Up to the parent workflow: Forest Gap & Understory Analysis · Up to the pipeline overview: Canopy Height Modeling & Terrain Extraction.