Estimating Canopy Gap Fraction and Effective LAI From LiDAR Returns
Gap fraction — the probability that a laser pulse passes through the canopy without striking foliage — is the single measurement that lets airborne LiDAR stand in for a hemispherical photograph or a ceptometer transect. From it you can invert the Beer–Lambert law to an effective Leaf Area Index (LAI), the metric that drives light-competition, evapotranspiration, and fuel models across a whole survey rather than at scattered plots. This guide is a focused task within Forest Gap & Understory Analysis, part of the broader Canopy Height Modeling & Terrain Extraction workflow: it computes gap fraction directly from the ratio of ground-reaching returns to total returns, then converts it to LAI over a regular grid with laspy and numpy.
The core idea is a counting argument on the point cloud, not a raster operation. Within each grid cell you count how many returns reached the ground (or fell below a height threshold) versus how many arrived in total. That ratio is the gap fraction, and it is a physical quantity independent of the canopy surface geometry.
Concept: From Return Counts to LAI
For a grid cell, the gap fraction is the fraction of returns that penetrated to below a canopy height threshold:
where counts returns with height above ground below the threshold and counts all returns falling in the cell. This is the LiDAR analogue of the laser penetration index. The Beer–Lambert law relates that transmittance to the leaf area intercepted along the beam path, and inverting it gives effective LAI:
Here is the beam zenith angle (near 0 for narrow-swath airborne scans, so ) and is the canopy extinction coefficient, typically 0.3–0.6 depending on leaf angle distribution. The result is called effective LAI because LiDAR cannot separate foliage from woody material or resolve clumping, so it estimates the light-intercepting area rather than the true one-sided leaf area.
When to Use This Approach
Return-ratio gap fraction is the right tool when you have a classified or height-normalized discrete-return cloud and want a wall-to-wall LAI surface. It differs from the raster routes that share this section:
| Method | Input | Output | Best for | Trade-off |
|---|---|---|---|---|
| Return-ratio gap fraction (this page) | Classified/normalized point cloud | Per-cell gap fraction and effective LAI | Landscape LAI where field plots are sparse | Sensitive to pulse density and return classification |
| CHM threshold cover | A built CHM raster | Fractional canopy cover | Stand-level cover from an existing height grid | First-surface only; ignores within-canopy penetration |
| Morphological gap mask | A built CHM raster | Gap polygons and geometry | Delineating discrete openings | Reports gap shape, not a continuous transmittance |
If you need discrete opening geometry rather than a continuous transmittance field, use the raster route in identifying canopy gaps using morphological filters instead. The LAI surface produced here also feeds fire-behaviour inputs — canopy fuel estimates in fuel load mapping from LiDAR consume the same penetration statistics.
Minimal Reproducible Example
The workflow bins every return into a grid, counts total and below-threshold returns per cell with numpy.bincount, divides, and inverts Beer–Lambert. Vectorized binning keeps a multi-million-point tile well within memory.
import numpy as np
import laspy
def gap_fraction_lai(las_path: str, cell_size: float = 20.0,
height_thresh: float = 1.0, k: float = 0.5,
zenith_deg: float = 0.0, first_return_only: bool = False):
"""Grid-wise canopy gap fraction and effective LAI from a normalized cloud.
Assumes `z` is height above ground (a height-normalized point cloud).
Returns (gap_fraction, lai) arrays plus the affine origin for georeferencing.
"""
las = laspy.read(las_path)
x = np.asarray(las.x)
y = np.asarray(las.y)
z = np.asarray(las.z) # height above ground after normalization
if first_return_only:
keep = np.asarray(las.return_number) == 1
x, y, z = x[keep], y[keep], z[keep]
xmin, ymax = x.min(), y.max()
ncols = int(np.ceil((x.max() - xmin) / cell_size))
nrows = int(np.ceil((ymax - y.min()) / cell_size))
col = np.clip(((x - xmin) / cell_size).astype(np.int64), 0, ncols - 1)
row = np.clip(((ymax - y) / cell_size).astype(np.int64), 0, nrows - 1)
flat = row * ncols + col
n_total = np.bincount(flat, minlength=nrows * ncols)
ground = z < height_thresh
n_ground = np.bincount(flat[ground], minlength=nrows * ncols)
with np.errstate(divide="ignore", invalid="ignore"):
gap = (n_ground / n_total).reshape(nrows, ncols)
cos_t = np.cos(np.radians(zenith_deg))
gap_clamped = np.clip(gap, 1e-4, 1.0) # avoid ln(0) in closed canopy
lai = (-cos_t / k) * np.log(gap_clamped)
empty = (n_total.reshape(nrows, ncols) == 0)
gap[empty] = np.nan
lai[empty] = np.nan
return gap, lai, (xmin, ymax, cell_size)
if __name__ == "__main__":
gap, lai, origin = gap_fraction_lai("normalized.laz", cell_size=20.0, k=0.5)
print(f"mean gap fraction: {np.nanmean(gap):.3f}")
print(f"mean effective LAI: {np.nanmean(lai):.2f}")
To write the LAI grid as a GeoTIFF, build an affine transform from origin with rasterio.transform.from_origin(xmin, ymax, cell_size, cell_size) and pass the point cloud’s CRS. Keep the cloud height-normalized first: if z still carries terrain, the height threshold no longer separates ground from canopy. Normalization is handled upstream in LiDAR Point Cloud Preprocessing.
Parameter Reference
| Parameter | Type | Default | Recommended range | Ecological rationale |
|---|---|---|---|---|
cell_size |
float (m) | 20.0 |
10–30 |
Must hold enough returns for a stable ratio; at 4–8 pulses/m² a 20 m cell yields thousands of returns and low sampling noise |
height_thresh |
float (m) | 1.0 |
0.5–2.0 |
Separates ground and low-shrub returns from canopy; raise it to exclude understory in multilayered stands |
k |
float | 0.5 |
0.3–0.6 |
Extinction coefficient; near 0.5 for spherical leaf-angle distributions, lower for erectophile (grass, some conifers), higher for planophile canopies |
zenith_deg |
float (°) | 0.0 |
0–15 |
Beam zenith; airborne swaths are near-nadir so cos θ ≈ 1, but wide-FOV or off-nadir scans need the correction |
first_return_only |
bool | False |
— | First-return-only removes the multiple-return bias where one pulse contributes several returns, at the cost of fewer samples per cell |
The extinction coefficient is the largest source of systematic error in the LAI conversion; gap fraction itself is a direct count and comparatively robust. Calibrate against a handful of field LAI measurements — hemispherical photography or an LAI-2200 — rather than adopting a literature value blindly, and document it with the output.
Expected Output and Verification
Gap fraction must lie in and effective LAI must be non-negative, rising as gap fraction falls. A closed conifer stand typically returns a gap fraction of 0.05–0.2 and effective LAI of 2–5; open woodland and savanna sit above 0.5 gap fraction and below LAI 1. Two assertions catch the usual failures:
gap, lai, _ = gap_fraction_lai("normalized.laz", cell_size=20.0, k=0.5)
# Gap fraction is a probability; anything outside [0, 1] means the counting
# or the height normalization is wrong.
valid = np.isfinite(gap)
assert np.all((gap[valid] >= 0.0) & (gap[valid] <= 1.0)), "gap fraction out of range"
# LAI must be non-negative and monotonic in gap fraction (lower gap -> higher LAI).
assert np.all(lai[np.isfinite(lai)] >= 0.0), "negative LAI from a gap fraction above 1"
As a physical cross-check, plot LAI against gap fraction: it must trace the negative-log Beer–Lambert curve exactly, since one is a deterministic transform of the other. Any scatter there signals a bug, not ecology. Finally, confirm that cells flagged empty (n_total == 0) are NaN rather than zero — a zero would falsely read as a total gap and inflate mean LAI toward the log-clamp ceiling.
Common Pitfalls
- Un-normalized heights. Feeding a raw cloud where
zis elevation, not height above ground, makes the height threshold meaningless and turns terrain into false canopy. Normalize against the ground surface first. - Sparse cells. At low pulse density a small cell may hold only a handful of returns, so one stray ground hit swings the gap fraction wildly. Enforce a minimum return count per cell (e.g. mask cells with
n_total < 30) or enlargecell_size. - Double-counting multiple returns. Counting every discrete return lets a single pulse contribute two or three points, biasing the ratio. Use
first_return_only=True, or weight returns, when the sensor records many returns per pulse. - Blind extinction coefficient. Applying everywhere ignores leaf-angle differences between species and seasons; an erectophile canopy at yields a very different LAI from the same gap fraction. Calibrate against field LAI where possible.
Frequently Asked Questions
Why is it called effective LAI rather than true LAI?
LiDAR cannot separate leaves from stems and branches, nor resolve foliage clumping within crowns. The gap-fraction inversion therefore estimates the light-intercepting plant area — effective LAI — which is typically lower than destructively measured true LAI. A clumping correction is needed to bridge the two.
Should I count returns below the height threshold or only classified ground points?
Either works if applied consistently. Using a height threshold on a normalized cloud captures ground plus low understory in one pass and does not depend on classification quality. Using classified ground returns is stricter but inherits any errors from the ground-classification step.
Related
- Forest Gap & Understory Analysis — the parent workflow that frames gap and understory metrics
- Identifying Canopy Gaps Using Morphological Filters — discrete gap geometry rather than a continuous transmittance field
- LiDAR Point Cloud Preprocessing — the height normalization this method depends on
- Fuel Load Mapping From LiDAR — a fire application that consumes the same penetration statistics
Up: Forest Gap & Understory Analysis · Canopy Height Modeling & Terrain Extraction