Computing Vegetation Indices with Rasterio
An index is two bands and a division, which makes it look like the easiest stage in a multispectral pipeline. It is in fact the stage where the most values go silently wrong, because a division is exactly the operation that turns small problems in its inputs into large problems in its output.
A near-zero denominator produces an unbounded value. A NoData sentinel that was not masked produces a number computed from −9999. A shadowed pixel produces a ratio of two small noisy numbers. None of these raises an error, all of them land in the raster, and any statistic computed afterwards is dominated by whichever went furthest wrong.
This page covers computing indices at survey scale in a way that handles those cases explicitly, along with the masking and summarising that turn an index raster into a number an agronomist can act on. It follows the calibration and alignment stages in multispectral, thermal and index mapping pipelines.
Audience and prerequisites. Python 3.10+, rasterio, and a calibrated band-stacked reflectance raster with band descriptions. An index computed from uncalibrated digital numbers is not comparable between flights, whatever else this page does for it.
Prerequisites
| Library / tool | Minimum version | Install command | Role |
|---|---|---|---|
rasterio |
≥ 1.3 | pip install "rasterio>=1.3" |
Windowed reads and writes |
numpy |
≥ 1.24 | pip install numpy |
The arithmetic |
shapely |
≥ 2.0 | pip install shapely |
Plot polygons |
rasterstats |
≥ 0.19 | pip install rasterstats |
Zonal summaries (or hand-rolled) |
scipy |
≥ 1.10 | pip install scipy |
Morphology for masking |
Conceptual architecture
A normalised difference index is for two bands and . Its virtue is that multiplicative effects common to both bands — illumination, mostly — cancel, which is why indices work at all on imperfectly calibrated data. Its vice is the denominator.
Where both bands are large the denominator is well conditioned and small errors in the inputs produce small errors in the output. Where both are small — deep shadow, water, the NoData border of a warped frame — the denominator approaches zero and the index becomes an amplifier: a one percent error in a band becomes a large error in the ratio, and at the limit the value is unbounded.
The correct response is a floor on the denominator with masking beyond it, rather than clipping the output. Clipping produces a plausible value where no measurement exists; masking says so.
Figure 1 — Why the denominator floor is a physical decision rather than a numerical one.
Step 1: Compute windowed, so survey scale is not a problem
A five-band 30,000 × 30,000 reflectance raster is 18 GB as float32. Rasterio’s windowed reads make computing an index over it a matter of memory discipline rather than machine size.
import numpy as np
import rasterio
from rasterio.windows import Window
def compute_index(stack_path: str, out_path: str, *, band_a: str, band_b: str,
min_sum: float = 0.02, block: int = 2048) -> dict:
"""Normalised difference of two named bands, computed window by window.
Reading by band description rather than index is what makes this safe
across sensors, and the windowed loop is what makes it work on a raster
larger than memory.
"""
with rasterio.open(stack_path) as src:
names = [d or "" for d in src.descriptions]
for band in (band_a, band_b):
if band not in names:
raise KeyError(f"band {band!r} not in {names}")
ia, ib = names.index(band_a) + 1, names.index(band_b) + 1
profile = src.profile
profile.update(count=1, dtype="float32", nodata=np.nan,
compress="deflate", predictor=3, tiled=True,
blockxsize=512, blockysize=512)
masked_px = 0
total_px = 0
with rasterio.open(out_path, "w", **profile) as dst:
for row in range(0, src.height, block):
for col in range(0, src.width, block):
win = Window(col, row,
min(block, src.width - col),
min(block, src.height - row))
a = src.read(ia, window=win, masked=True).filled(np.nan)
b = src.read(ib, window=win, masked=True).filled(np.nan)
total = a + b
valid = np.isfinite(total) & (np.abs(total) > min_sum)
out = np.full(a.shape, np.nan, dtype="float32")
np.divide(a - b, total, out=out, where=valid)
masked_px += int(np.count_nonzero(~valid & np.isfinite(total)))
total_px += int(np.isfinite(total).sum())
dst.write(out, 1, window=win)
dst.set_band_description(1, f"nd_{band_a}_{band_b}")
dst.update_tags(1, FORMULA=f"({band_a} - {band_b}) / ({band_a} + {band_b})",
MIN_SUM=str(min_sum), UNITS="dimensionless")
return {"masked_fraction": masked_px / max(total_px, 1),
"output": out_path}
Recording the formula and the denominator floor as tags is what makes this index comparable with the next one. Two NDVI rasters computed with different floors are different products, and the difference shows up exactly where the crop is sparse.
Step 2: Mask before summarising
A plot polygon contains crop, soil, shadow and often a strip of track. Averaging all of it produces a number that moves with row spacing and sun angle as much as with the crop.
import numpy as np
from scipy import ndimage
def canopy_mask(index: np.ndarray, nir: np.ndarray, *,
veg_threshold: float = 0.2,
shadow_percentile: float = 10.0,
erode_px: int = 2) -> np.ndarray:
"""Canopy pixels that are lit and away from the plot boundary.
The shadow test uses near-infrared rather than brightness because
vegetation is bright in NIR under any illumination; a low NIR value in a
vegetated pixel therefore means shade rather than sparse cover.
"""
vegetated = np.isfinite(index) & (index > veg_threshold)
if not vegetated.any():
return vegetated
cutoff = np.percentile(nir[vegetated & np.isfinite(nir)], shadow_percentile)
lit = vegetated & (nir > cutoff)
if erode_px > 0:
lit = ndimage.binary_erosion(lit, iterations=erode_px)
return lit
Each of the three masks removes a different confounder, and the erosion is the one most often skipped — it removes the mixed pixels at plot edges where any residual band misalignment concentrates.
Step 3: Summarise robustly
import numpy as np
def plot_summary(index: np.ndarray, mask: np.ndarray,
*, min_pixels: int = 50) -> dict:
"""Robust summary statistics for one plot.
Median and interquartile range rather than mean and standard deviation:
index distributions within a plot are routinely skewed by a patch of poor
establishment, and the median tracks the bulk of the crop while the
spread describes the variability agronomists act on.
"""
values = index[mask & np.isfinite(index)]
if values.size < min_pixels:
return {"pixels": int(values.size), "usable": False,
"note": "too few valid canopy pixels to summarise"}
q25, q50, q75 = np.percentile(values, [25, 50, 75])
return {"pixels": int(values.size), "usable": True,
"median": float(q50), "iqr": float(q75 - q25),
"p10": float(np.percentile(values, 10)),
"p90": float(np.percentile(values, 90)),
"coverage": float(mask.sum() / max(np.isfinite(index).sum(), 1))}
Reporting the coverage — the share of the plot that survived masking — alongside the statistics is what keeps a comparison honest. A plot whose canopy coverage fell from 0.9 to 0.4 between flights has changed in a way the median index does not describe, and reporting both makes that visible.
Figure 2 — What masking removes, and what it does to the number the agronomist reads.
Step 4: Attach the plot geometry without rasterising twice
Per-plot statistics need the index values inside each polygon. The naive implementation rasterises the polygon over the whole raster extent once per plot, which on a trial with four hundred plots is four hundred full-extent rasterisations of a grid that is mostly empty.
The efficient version reads only the window each polygon covers and rasterises within it.
import numpy as np
import rasterio
from rasterio.features import geometry_mask
from rasterio.windows import from_bounds
def plot_windows(index_path: str, plots: list[dict]) -> list[dict]:
"""Per-plot index statistics, reading only each plot's own window.
`plots` carry an id and a shapely geometry in the raster's CRS. Reading a
window per plot turns an operation that scales with plots times raster
area into one that scales with the total plot area.
"""
results = []
with rasterio.open(index_path) as src:
for plot in plots:
geom = plot["geometry"]
try:
win = from_bounds(*geom.bounds, transform=src.transform)
except ValueError:
results.append({"id": plot["id"], "usable": False,
"note": "plot lies outside the raster"})
continue
data = src.read(1, window=win, masked=True, boundless=True,
fill_value=np.nan).filled(np.nan)
win_transform = src.window_transform(win)
inside = ~geometry_mask([geom], out_shape=data.shape,
transform=win_transform, invert=False)
values = data[inside & np.isfinite(data)]
results.append({"id": plot["id"], "pixels": int(values.size),
"usable": values.size >= 50,
"median": float(np.median(values)) if values.size else None})
return results
boundless=True handles plots that extend past the raster edge, which happens on trial sites where the flight boundary was drawn tight. Without it, those plots raise rather than returning the partial coverage they actually have — and the partial coverage, correctly reported, is more useful than an exception.
Step 5: Compare flights, not rasters
The output a programme actually consumes is a time series per plot, and assembling it correctly needs two things the per-flight statistics do not carry.
A consistent plot geometry. Re-digitising plot boundaries between flights introduces a change of a few percent in every plot mean, indistinguishable from a real effect. Store the polygons once with the trial and reuse them.
A comparability check per pair of flights. Two plot medians are comparable only if the index was computed the same way, the masks used the same thresholds and the calibration was consistent. Those facts live in the tags written earlier; checking them is mechanical.
import rasterio
COMPARABILITY_TAGS = ("FORMULA", "MIN_SUM")
def flights_comparable(path_a: str, path_b: str,
masks_a: dict, masks_b: dict) -> dict:
"""Are two index rasters and their masking policies like-for-like?"""
def tags(path):
with rasterio.open(path) as src:
return src.tags(1)
ta, tb = tags(path_a), tags(path_b)
problems = []
for key in COMPARABILITY_TAGS:
if ta.get(key) != tb.get(key):
problems.append(f"{key} differs: {ta.get(key)!r} vs {tb.get(key)!r}")
for key in set(masks_a) | set(masks_b):
if masks_a.get(key) != masks_b.get(key):
problems.append(f"mask {key} differs: {masks_a.get(key)} vs {masks_b.get(key)}")
return {"comparable": not problems, "problems": problems}
A series assembled without that check will, sooner or later, contain a step caused by somebody adjusting a threshold. The check costs nothing and makes the step impossible to introduce silently.
Step 6: Decide what the index is being asked to do
Three quite different uses get called “computing an index”, and they have different tolerances.
Relative ranking within one flight — which plot is doing best today — is the least demanding. Calibration errors common to the whole flight cancel in the ranking, so even an uncalibrated index ranks plots correctly provided the illumination was stable during the flight. This is where most agronomic scouting sits, and it is why uncalibrated indices remain popular despite everything on this page.
Change within a plot between flights is more demanding, because the flights differ. Here calibration matters, masking consistency matters, and the plot geometry must not move. This is the usual monitoring case and the one the pipeline above is built for.
Absolute values compared against a published threshold is the most demanding and the least often achievable. A statement such as “NDVI above 0.75 indicates canopy closure” comes from a study with its own sensor, its own band centres and its own calibration, and a value from a different rig is not directly comparable to it — band centres alone shift an index by several hundredths.
def index_use_requirements(use: str) -> dict:
"""What a given use of an index actually requires of the pipeline."""
return {
"ranking": {"calibration": "optional", "masking": "consistent within flight",
"geometry": "consistent within flight",
"note": "illumination must be stable during the flight"},
"change": {"calibration": "required", "masking": "identical across flights",
"geometry": "fixed across flights",
"note": "the usual monitoring case"},
"absolute": {"calibration": "required", "masking": "identical",
"geometry": "fixed",
"note": "band centres must match the reference study; "
"usually they do not"},
}[use]
Being explicit about which of the three a deliverable serves prevents the most common disappointment in multispectral work: a carefully calibrated programme whose results are then compared against a literature threshold measured on a different sensor.
Parameter deep-dive
| Parameter | Type | Default | Valid range | Effect |
|---|---|---|---|---|
min_sum |
float | 0.02 | 0.005–0.10 | Denominator floor; below it the index is an amplifier |
veg_threshold |
float | 0.2 | 0.1–0.35 | Separates canopy from soil; crop and stage dependent |
shadow_percentile |
float | 10.0 | 0–25 | Share of the darkest canopy treated as shaded |
erode_px |
int | 2 | 0–5 | Removes mixed pixels at the plot boundary |
min_pixels |
int | 50 | 20–500 | Below this the summary is not stable |
block |
int | 2048 | 512–8192 | Windowed read size; memory against overhead |
| Band pair | names | red/nir | — | Must be read by name, never by index |
| Statistic | enum | median | median/mean | Median is robust to a poor patch |
Verification and output inspection
import numpy as np
import rasterio
def verify_index_raster(path: str, *, max_masked: float = 0.25) -> dict:
"""Checks an index raster should pass before anybody summarises it."""
with rasterio.open(path) as src:
arr = src.read(1, masked=True).filled(np.nan)
tags = src.tags(1)
finite = arr[np.isfinite(arr)]
problems = []
if finite.size == 0:
problems.append("no finite values at all")
return {"problems": problems}
masked_fraction = 1 - finite.size / arr.size
if masked_fraction > max_masked:
problems.append(f"{masked_fraction:.0%} of the raster is masked")
if np.any(np.abs(finite) > 1.0001):
problems.append("values outside [-1, 1] — the denominator floor is too low")
if "FORMULA" not in tags:
problems.append("no formula recorded — this raster is not comparable")
if "MIN_SUM" not in tags:
problems.append("no denominator floor recorded")
return {"masked_fraction": masked_fraction,
"median": float(np.median(finite)),
"p01": float(np.percentile(finite, 1)),
"p99": float(np.percentile(finite, 99)),
"problems": problems}
The out-of-range check is the definitive one for a normalised difference: the formula cannot produce a value outside [−1, 1] unless a band was negative, which means the calibration produced negative reflectance somewhere and that is a calibration problem rather than an index problem.
Figure 3 — Three scopes, and only the first is free.
Troubleshooting
The index raster is full of NaN. Either the bands were read as a sentinel that was not masked, or the denominator floor is far too high. Check the band statistics before the index.
Values outside −1 to 1. A band is negative. That comes from the dark-current subtraction in radiometric calibration in Python; clip at zero there rather than here.
Plot medians move between flights with no agronomic cause. Compare canopy coverage as well as the median. A change in what survived masking looks like a change in the crop.
Extreme values ring every field boundary. Band misregistration, not index arithmetic. See fixing band misregistration artifacts in index rasters.
Two indices from the same data disagree about which plot is best. Expected, and informative: different indices saturate at different canopy densities. See choosing between NDVI, NDRE and GNDVI.
Computation is slow on a large raster. Check that the source is internally tiled; a windowed read of a striped raster reads whole scanlines, so a small window costs almost as much as the full row.
A plot returns no usable pixels. Either it lies outside the flight extent, or masking removed everything — which on a plot of bare soil is the correct answer rather than a fault.