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.

Unmasked plot mean against the true canopy index across a season Two series across five flights from early season to canopy closure. The masked canopy index rises smoothly and modestly from zero point five eight to zero point seven four. The unmasked plot mean rises much more steeply from zero point three one to zero point seven one, because the soil fraction inside the plot falls from thirty-four percent to four percent over the same period. A third trace shows the soil fraction falling, annotated as the cause. A note states that most of the apparent growth in the unmasked series is the disappearance of soil from the average. flight 1 2 3 4 5 masked canopy index — modest, real growth unmasked plot mean soil fraction: 34 % → 4 % Most of the unmasked rise is soil leaving the average, not chlorophyll arriving. Which is why an unmasked series always looks more dramatic than the crop it describes.

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.

Masking before the statistic compared with masking after Two columns. The masking before column notes that soil and shadow pixels never enter the mean, that the resulting statistic describes the canopy rather than the canopy plus its background, that the masked fraction is itself a reportable measure of canopy closure, and that the mask must be recorded for the number to be reproducible. The masking after column notes that the statistic mixes canopy and background, that it moves with canopy closure independently of plant health, and that two plots at different growth stages become incomparable for a reason nothing in the output reveals. mask before the statistic soil and shadow never enter the mean the statistic describes the canopy the masked fraction measures canopy closure record the mask or it is not reproducible mask after, or not at all the statistic mixes canopy and background moves with closure, not with health plots at different stages become incomparable and nothing in the output says so The masked fraction is a deliverable in its own right, not an intermediate to discard.

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.

Mask composition of one plot across three flights Three stacked bars showing what each mask removed from the same plot on three flights. In flight one, thirty-four percent soil, nine percent shadow, six percent boundary and fifty-one percent canopy. In flight two, eighteen percent soil, twenty-two percent shadow because it was flown later in the day, six percent boundary and fifty-four percent canopy. In flight three, four percent soil, eleven percent shadow, six percent boundary and seventy-nine percent canopy. A note observes that flight two's larger shadow fraction is an illumination difference rather than a crop difference. flight 1 — early season flight 2 — flown later in the day flight 3 — canopy closed soil shadow boundary canopy Flight 2's shadow fraction is an illumination difference, not a crop difference. Reporting the composition is what lets a reader tell the two apart. Canopy share rises from 51 % to 79 % across the three, which moves any unmasked mean on its own.

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.

Computing Vegetation Indices with Rasterio