Masking Soil and Shadow Before Index Statistics
The plot means came back lower than last month across the whole trial, uniformly, with no treatment pattern. The crop did not go backwards; the sun was lower, the rows cast more shadow, and the proportion of shadowed pixels inside each plot went up. The index of the crop did not change at all.
A plot mean over every pixel inside a polygon is a mixture of crop, soil, shadow and track, weighted by how much of each the polygon happens to contain. Masking is what turns it into a measurement of the crop, and doing it consistently is what makes two flights comparable. This page covers the three masks that matter, the consistency problem they introduce, and how to report what they removed. It is the masking detail behind computing vegetation indices with rasterio.
Three confounders, three masks
Soil between rows. At a row spacing of 12 cm and a ground resolution of 2 cm, roughly a third of pixels in an early-season plot are soil. As the canopy closes that fraction falls to near zero, so an unmasked plot mean rises through the season partly because there is less soil in it — an effect that has nothing to do with the plants getting greener.
Shadow. Canopy self-shadowing depends on sun elevation, row orientation and canopy architecture. A flight at 10 am and one at noon over an identical crop produce different shadow fractions and therefore different unmasked means.
Plot boundary. The outer two or three pixels of any plot are mixed — part crop, part track, part neighbouring plot — and any residual band misalignment concentrates its artefacts there.
Each mask removes a different confounder, and none of them substitutes for the others.
Figure 1 — The same crop, two series. One of them is mostly an artefact of composition.
Minimal reproducible solution
import numpy as np
from scipy import ndimage
def build_masks(index: np.ndarray, nir: np.ndarray, plot_mask: np.ndarray, *,
soil_threshold: float = 0.20,
shadow_percentile: float = 10.0,
boundary_px: int = 2) -> dict:
"""Soil, shadow and boundary masks, and the canopy mask that survives all three.
Each mask is returned separately as well as combined, because the share
each one removes is itself a reported quantity — a plot whose shadow
fraction doubled between flights has changed in a way the index does not
describe.
"""
interior = ndimage.binary_erosion(plot_mask, iterations=boundary_px)
finite = np.isfinite(index) & np.isfinite(nir)
soil = interior & finite & (index <= soil_threshold)
vegetated = interior & finite & (index > soil_threshold)
if vegetated.any():
cutoff = np.percentile(nir[vegetated], shadow_percentile)
shadow = vegetated & (nir <= cutoff)
else:
shadow = np.zeros_like(vegetated)
canopy = vegetated & ~shadow
total = max(int(np.count_nonzero(plot_mask)), 1)
return {
"canopy": canopy,
"fractions": {
"soil": float(np.count_nonzero(soil) / total),
"shadow": float(np.count_nonzero(shadow) / total),
"boundary": float(np.count_nonzero(plot_mask & ~interior) / total),
"canopy": float(np.count_nonzero(canopy) / total),
"nodata": float(np.count_nonzero(interior & ~finite) / total),
},
}
Using near-infrared rather than overall brightness for the shadow test is the detail that makes it work. Vegetation is bright in near-infrared under direct and diffuse light alike, so a low near-infrared value within a vegetated pixel indicates shade rather than sparse cover — which a brightness test cannot distinguish.
The consistency problem
A mask built from a threshold on the data is not the same mask between flights, and that inconsistency can manufacture change. Two approaches resolve it, and the right one depends on what is being measured.
Fixed thresholds — the same soil cutoff and the same shadow percentile every flight — make the masks comparable by construction. The risk is that a fixed threshold that suits early season removes real canopy later, or vice versa.
Adaptive thresholds with reported fractions — derive the cutoff per flight but report what each mask removed — keep the mask appropriate and make the inconsistency visible rather than hidden. A reader can then see that the canopy fraction changed and judge accordingly.
def choose_threshold_policy(stage: str) -> dict:
"""Which masking policy suits which kind of comparison."""
return {
"within_flight": {"policy": "adaptive",
"note": "ranking plots in one flight; the mask is common"},
"between_flights": {"policy": "fixed",
"note": "a moving threshold manufactures change"},
"seasonal_trajectory": {"policy": "fixed, with fractions reported",
"note": "the composition change is itself a finding"},
}[stage]
Whichever is chosen, it must be recorded with the statistics. Two plot medians computed with different soil thresholds are not comparable, and nothing in the numbers says so.
Figure 3 — Masking order decides what the number is about.
Edge-case matrix
| Situation | Naive behaviour | Handling |
|---|---|---|
| Early season, sparse crop | Mask removes most of the plot | Report canopy fraction; expect a small sample |
| Closed canopy | Soil mask removes nothing | Expected; shadow mask does the work |
| Dry senescent crop | Low index, masked as soil | Raise the soil threshold for senescence |
| Very low sun | Large shadow fraction | Fixed percentile still works; report the fraction |
| Overcast, no shadow | Shadow percentile removes real canopy | Skip the shadow mask under diffuse light |
| Weeds between rows | Counted as canopy | No spectral fix; use row geometry if it matters |
| Plot smaller than the boundary buffer | Nothing survives erosion | Reduce the buffer, or reject the plot |
| Tramlines inside a plot | Counted as soil, correctly | Fine, provided the fraction is reported |
The overcast row deserves care because it is a genuine trap. Under fully diffuse light there is no shadow to remove, so a shadow mask that always discards the darkest ten percent of canopy is discarding real, slightly less vigorous crop. Detecting the condition is easy — the near-infrared distribution within the canopy is much narrower — and skipping the mask is the right response.
import numpy as np
def shadow_mask_warranted(nir: np.ndarray, vegetated: np.ndarray,
*, min_relative_spread: float = 0.12) -> bool:
"""Is there enough variation in canopy brightness for a shadow mask to mean anything?"""
values = nir[vegetated & np.isfinite(nir)]
if values.size < 200:
return False
spread = float(np.percentile(values, 90) - np.percentile(values, 10))
return spread / max(float(np.median(values)), 1e-9) > min_relative_spread
Verification snippet
import numpy as np
def masking_report(fractions_by_flight: dict[str, dict]) -> dict:
"""Did the masking stay comparable across a series of flights?"""
keys = ["soil", "shadow", "canopy"]
findings = []
series = {k: [f[k] for f in fractions_by_flight.values()] for k in keys}
for k in keys:
values = np.asarray(series[k])
swing = float(values.max() - values.min())
if k == "shadow" and swing > 0.15:
findings.append(f"shadow fraction ranges {values.min():.0%}–{values.max():.0%}"
" — flights were under different illumination")
if k == "canopy" and swing > 0.4:
findings.append(f"canopy fraction ranges {values.min():.0%}–{values.max():.0%}"
" — composition change dominates any index change")
return {"series": series, "findings": findings,
"comparable": not findings}
The canopy-fraction swing is the number to watch. When it moves by more than about forty points across a series, the plots are compositionally different enough that comparing their index medians is comparing different things, and the honest report says so.
Figure 2 — Publishing the mask composition alongside the index turns an invisible confounder into a visible one.
When to escalate
- Canopy fraction is below about a fifth. The statistic is being computed on a small and possibly unrepresentative sample of the plot. Report it with the fraction and treat it as indicative.
- Weeds are inflating the canopy fraction. No spectral mask separates weed from crop reliably at these bands. Row-geometry masking helps where rows are regular; otherwise it is a limitation to state.
- Two flights disagree and the fractions are similar. The masking is not the explanation, so the difference is either real or lies in the calibration. Check a permanent target before looking further at the crop.
- Masks must match an external protocol. Trial protocols sometimes specify a fixed threshold. Follow it and report the fractions anyway, so the effect of the specified mask is visible.