Computing NDVI Zonal Means per Parcel

One NDVI number per land parcel looks like a two-line calculation and almost never is: the division can overflow, cloud-masked pixels can arrive downstream as a convincing 0.0, and a parcel whose imagery is nine-tenths missing will hand you a mean that looks exactly like a real one. This guide is for anyone building an agronomy, subsidy-audit or crop-monitoring table and needing every row to be either trustworthy or visibly marked. It sits under Zonal Statistics & Raster Sampling in Spatial Analysis & Advanced Query Techniques, and assumes you can already open a scene with Rasterio.

Why This Approach / What Goes Wrong

NDVI is (NIR − red) / (NIR + red), and each of those three operations has a trap. The sum is where integer rasters break first: Sentinel-2 L2A bands are int16 and NAIP orthos are uint8, so nir + red is evaluated in the source dtype and can wrap silently — two 8-bit values of 200 sum to 400, which stores as 144, and the index that comes out is arithmetically valid and physically nonsense. Promoting both bands to float32 before any arithmetic removes the wrap and gives the division a sane denominator. The division itself then needs a guard, because in deep shadow or over calibrated black targets nir + red approaches zero and the quotient explodes to ±10⁵ or to inf.

The more insidious failure is the nodata path. A scene's fill value — 0 for Sentinel-2, or whatever the cloud mask wrote — is a perfectly ordinary integer as far as NumPy is concerned. If it flows into the ratio, the result is not an error but a number, usually something near 0.0, which downstream reads as bare soil rather than "no observation". By the time it reaches a parcel mean it is indistinguishable from data. The fix is to establish a boolean validity mask once, before the division, and to carry it all the way through to the output raster's own nodata value — never letting an unobserved pixel acquire a plausible number at any stage.

Band math with the nodata mask carried end to end A left-to-right data flow. The red B04 band and the near-infrared B08 band are read as masked int16 arrays and cast to float32. Both feed a validity gate that requires isfinite on each band and an absolute band sum greater than one times ten to the minus six; pixels passing the gate go to the division, pixels failing it keep the sentinel value minus nine thousand nine hundred and ninety-nine. The division computes NIR minus red over NIR plus red with a where clause, and the result is clipped to the range minus one to one and written as a single-band tiled float32 GeoTIFF whose nodata value is minus nine thousand nine hundred and ninety-nine. A strip of eight pixels below traces two nodata cells through the three stages, showing they stay marked as missing in the input, in the boolean mask, and in the NDVI output rather than turning into zero. The nodata mask has to survive every arithmetic step B04 red int16 · nodata 0 read(1, masked=True) B08 near-infrared int16 · nodata 0 cast to float32 validity gate isfinite(red) & isfinite(nir) & abs(nir + red) > 1e-6 True → divide False → keep −9999 np.divide (nir − red) / (nir + red) out= prefilled where=valid NDVI float32 clip(−1.0, 1.0) nodata = −9999 one band, tiled written to disk Two nodata pixels traced through the pipeline input pixels valid mask NDVI output nodata 0 in the source boolean, same shape −9999 where invalid A masked pixel must never reach the parcel mean as 0.0 — that reads as bare soil, not as missing.
The validity mask is computed once, before the division, and is the same object that decides the output raster's nodata cells.

The last problem is geometric. Parcel boundaries do not follow the 10 m grid, so most edge pixels are partly inside and partly outside. Counting each touched pixel wholly in or wholly out biases the mean toward whatever the boundary happens to clip — badly for narrow strips and headlands. Weighting each pixel by the fraction of its cell that the polygon actually covers removes that bias, which is what exactextract computes exactly rather than by sampling; the trade-offs against the pure-Python alternative are laid out in zonal statistics with rasterstats vs exactextract.

Prerequisites

conda install -c conda-forge "rasterio=1.3.*" "numpy=1.26.*" \
  "geopandas=1.0.*" "exactextract=0.2.*" "pyarrow=14.*"

Install everything from conda-forge in one solve: rasterio, geopandas and exactextract all bind GDAL and GEOS, and mixing wheels across channels produces the ABI mismatches that surface as import-time segfaults rather than clean errors.

Step-by-Step Implementation

1. Read both bands as masked arrays and prove they share a grid.

Band math is per-pixel, so the two rasters must be co-registered — same CRS, same affine transform, same shape. Overlapping is not enough. Sentinel-2 delivers B04 and B08 at 10 m on the identical tile grid, so the assertions pass; anything resampled or reprojected upstream must be aligned first with reproject_match on a raster cube.

import numpy as np
import rasterio

RED_PATH = "s2_b04_red_10m.tif"   # Sentinel-2 L2A red
NIR_PATH = "s2_b08_nir_10m.tif"   # Sentinel-2 L2A near-infrared

with rasterio.open(RED_PATH) as red_src, rasterio.open(NIR_PATH) as nir_src:
    assert red_src.crs == nir_src.crs, "Bands are in different CRSs"
    assert red_src.transform == nir_src.transform, "Bands sit on different grids"
    assert red_src.shape == nir_src.shape, "Bands differ in size"

    # masked=True honours the file's nodata value; astype keeps the mask
    red = red_src.read(1, masked=True).astype("float32")
    nir = nir_src.read(1, masked=True).astype("float32")

    raster_crs = red_src.crs
    profile = red_src.profile
    pixel_area_m2 = abs(red_src.transform.a * red_src.transform.e)  # 100.0 at 10 m

print(f"masked red pixels: {np.ma.getmaskarray(red).sum():,}")

2. Compute NDVI behind a validity gate, then clip.

filled(np.nan) turns the mask into NaN, which np.isfinite then folds into a single boolean array alongside the near-zero denominator test. The output buffer is pre-filled with the sentinel, and np.divide(..., where=valid) writes only where the gate passed — invalid cells keep -9999 because nothing ever touches them. Clipping afterwards is applied only to the valid subset, so the sentinel is not dragged up to -1.0.

NDVI_NODATA = np.float32(-9999.0)

red_f = red.filled(np.nan)      # plain ndarray; masked cells become NaN
nir_f = nir.filled(np.nan)

denom = nir_f + red_f
valid = np.isfinite(denom) & (np.abs(denom) > 1e-6)

ndvi = np.full(red_f.shape, NDVI_NODATA, dtype="float32")
np.divide(nir_f - red_f, denom, out=ndvi, where=valid)
ndvi[valid] = np.clip(ndvi[valid], -1.0, 1.0)

print(f"valid NDVI pixels: {valid.sum():,} of {valid.size:,}")

Because both bands were promoted to float32 first, the subtraction and the sum are floating point — no int16 wraparound, and no RuntimeWarning: invalid value encountered in divide, since the division never runs on the bad cells.

3. Write the NDVI surface as a tiled, compressed GeoTIFF.

predictor=3 is the floating-point predictor and typically halves the DEFLATE output on a smooth index surface. Declaring the sentinel as the file's nodata is the step that makes the mask legible to every downstream reader, including exactextract.

profile.update(
    driver="GTiff",
    dtype="float32",
    count=1,
    nodata=float(NDVI_NODATA),
    compress="deflate",
    predictor=3,
    tiled=True,
    blockxsize=512,
    blockysize=512,
    BIGTIFF="IF_SAFER",
)

with rasterio.open("parcel_ndvi_surface.tif", "w", **profile) as dst:
    dst.write(ndvi, 1)
    dst.set_band_description(1, "NDVI")

Adding overviews here pays for itself if the same file also feeds a web map — the settings are covered in resampling and overviews when writing COGs.

4. Bring the parcels onto the raster's CRS.

exactextract compares polygon coordinates against the raster's grid directly and will not reproject for you; mismatched inputs return all-NaN rather than an error. Reproject the vector side, because the Sentinel-2 tile is already in a metric UTM zone and warping the raster would resample the values you are about to average. GeoPandas routes to_crs through pyproj with always_xy=True semantics, so you never hand-swap coordinates even though EPSG:4326's authority axis order is latitude-then-longitude — the details live in Coordinate Systems with PyProj.

import geopandas as gpd

parcels = gpd.read_file("parcels_2024.gpkg", layer="parcels")

if parcels.crs != raster_crs:
    parcels = parcels.to_crs(raster_crs)

assert parcels.crs.is_projected, "Coverage ratios need a projected, metric CRS"

parcels = parcels[parcels.geometry.notna() & ~parcels.geometry.is_empty].copy()
parcels["geometry"] = parcels.geometry.make_valid()   # bad rings break coverage math
parcels["parcel_area_m2"] = parcels.geometry.area

Self-intersecting rings make coverage fractions meaningless before they make anything crash, so repair them up front — see fixing self-intersecting polygons programmatically.

5. Run the coverage-weighted extraction.

exact_extract accepts a raster path and a GeoDataFrame and returns a DataFrame in feature order. mean is the coverage-weighted mean over cells that hold data, and count is the sum of coverage fractions across those same cells — an effective pixel count, so a parcel with 391.4 means it received the equivalent of 391.4 fully-covered valid cells. That second number is the one that makes the low-coverage check possible.

from exactextract import exact_extract

stats = exact_extract(
    "parcel_ndvi_surface.tif",
    parcels,
    ["mean", "count", "min", "max", "stdev"],
    include_cols=["parcel_id"],
    output="pandas",
)

print(stats.head(3).to_string(index=False))
# parcel_id     mean      count    min    max  stdev
#    101884 0.712043 391.402710 0.4120 0.8641 0.0714
#    101885 0.630918 118.994141 0.3805 0.7712 0.0663
#    101886 0.481277  27.331251 0.1002 0.7440 0.1918

For a single-band raster the columns are named after the operation; a multi-band input prefixes them band_1_, band_2_ and so on. Passing a rioxarray DataArray instead of a path skips the intermediate GeoTIFF entirely, which is worthwhile inside a raster cube workflow.

Three parcels over an NDVI surface, one of them under a nodata block On the left, a ten-metre NDVI pixel grid carries three parcel outlines. Parcel A and parcel B sit on clean imagery and report means of 0.71 and 0.63. Parcel C overlaps a dashed nodata block left by the cloud mask, so most of its pixels carry no value; its reported mean of 0.48 is computed from the small remaining sliver and is flagged. The table on the right lists each parcel with its NDVI mean, its valid coverage share and its status: A at 0.98 coverage and B at 1.00 are marked ok, while C at 0.21 coverage is marked low and its mean is withheld. A panel underneath states the three relationships: the mean is the sum of value times coverage divided by the sum of coverage, the count is the sum of coverage over valid cells only, and valid coverage is the count times the pixel area divided by the parcel area. Coverage-weighted means — and the parcel that must be flagged cloud mask → nodata parcel A mean 0.71 parcel B mean 0.63 parcel C 0.48 · flagged grid = 10 m NDVI pixels · dashed block = nodata after masking parcel ndvi_mean valid_cov status A 0.712 0.98 ok B 0.631 1.00 ok C NA 0.21 low How the three numbers relate mean = Σ(value × cov) / Σ(cov) count = Σ cov over valid cells valid_cov = count × 100 m² / area
Parcel C's mean is arithmetically correct and practically useless: it summarises a fifth of the field, so the coverage ratio has to travel with it.

6. Turn coverage into an explicit status, and withhold the untrustworthy means.

Two thresholds do the work. A relative one — the share of the parcel area backed by valid pixels — catches cloud, shadow and scene-edge gaps. An absolute one catches slivers and access strips narrower than the grid, where a high coverage ratio can still rest on two or three cells. Neither threshold deletes anything: every parcel keeps its row, and ndvi_mean is populated only for the rows that earned it.

MIN_COVERAGE = 0.60          # share of parcel area backed by valid NDVI pixels
MIN_EFFECTIVE_PIXELS = 4.0   # guards slivers narrower than the 10 m grid

result = parcels.merge(stats, on="parcel_id", how="left", validate="one_to_one")

effective_cells = result["count"].fillna(0.0)
result["valid_coverage"] = effective_cells * pixel_area_m2 / result["parcel_area_m2"]

result["status"] = np.select(
    [
        effective_cells == 0,
        effective_cells < MIN_EFFECTIVE_PIXELS,
        result["valid_coverage"] < MIN_COVERAGE,
    ],
    ["no_data", "too_small", "low_coverage"],
    default="ok",
)
result["ndvi_mean"] = result["mean"].where(result["status"] == "ok")
Attrition through the two coverage gates, with nothing dropped A left-to-right funnel over four stages. Two thousand four hundred and seventeen extracted rows enter, one per parcel. The first gate requires an effective cell count greater than zero and passes two thousand three hundred and seventy-nine; the thirty-eight rejects fall into a tray marked status equals no_data with a null NDVI mean. The second gate requires valid coverage of at least zero point six and passes two thousand two hundred and eighty-three; the ninety-six rejects fall into a tray marked status equals low_coverage, where the value is kept but not averaged. The final stage holds two thousand two hundred and eighty-three usable means marked status equals ok. A banner underneath notes that thirty-eight plus ninety-six plus two thousand two hundred and eighty-three equals the original two thousand four hundred and seventeen, so every input parcel still has a row on disk. The gates set a status — they never drop a parcel extracted rows 2,417 one per parcel exact_extract out gate 1 count > 0 2,379 pass gate 2 valid_cov >= 0.60 2,283 pass usable means 2,283 status = ok safe to average fail fail 38 status = no_data ndvi_mean stays null 96 status = low_coverage value kept, not averaged 38 + 96 + 2,283 = 2,417 — every input parcel still has a row on disk
Reporting the rejects as counts rather than deleting them is what makes the table auditable a season later.

7. Write the result as GeoParquet.

Parquet keeps float64 statistics at full precision and does not truncate valid_coverage to a ten-character field name, which is the practical reason this table is not a shapefile — the full comparison is in GeoParquet vs Shapefile for storage. The covering-bbox column lets readers push spatial filters down without decoding geometry.

out = result[[
    "parcel_id", "parcel_area_m2", "ndvi_mean", "mean", "min", "max",
    "stdev", "count", "valid_coverage", "status", "geometry",
]].rename(columns={"mean": "ndvi_mean_raw", "count": "valid_cell_equivalents"})

out.to_parquet(
    "parcel_ndvi_2024.parquet",
    index=False,
    compression="zstd",
    geometry_encoding="WKB",
    schema_version="1.1.0",
    write_covering_bbox=True,
)

Verification

Check the raster side and the table side separately: the sentinel must be untouched by the clip, and the parcel count must be identical before and after the join.

import geopandas as gpd

# Raster: valid cells are inside the physical range, invalid cells are exactly the sentinel
assert ndvi[valid].min() >= -1.0 and ndvi[valid].max() <= 1.0
assert (ndvi[~valid] == NDVI_NODATA).all(), "Sentinel was overwritten by the clip"

check = gpd.read_parquet("parcel_ndvi_2024.parquet")

assert len(check) == len(parcels), "Row count changed — the merge fanned out"
assert check.crs == parcels.crs, "CRS lost on the Parquet round-trip"

usable = check[check["status"] == "ok"]
assert usable["ndvi_mean"].between(-1.0, 1.0).all(), "Nodata leaked into a mean"
assert check.loc[check["status"] != "ok", "ndvi_mean"].isna().all()

print(check["status"].value_counts().to_string())
# ok              2283
# low_coverage      96
# no_data           38
print(f"mean NDVI over usable parcels: {usable['ndvi_mean'].mean():.3f}")
# mean NDVI over usable parcels: 0.634

If low_coverage dominates on a clear-sky scene, the thresholds are not the problem — the parcels are probably straddling the tile edge, or one of the two bands was masked more aggressively than the other.

Edge Cases & Debugging

Frequently Asked Questions

Should I average NDVI per parcel, or compute NDVI from averaged bands? Average the NDVI. The index is a nonlinear ratio, so the mean of the ratio and the ratio of the means are different numbers, and the second is pulled toward bright, high-reflectance pixels — roads, roofs and dry headlands inside the parcel boundary. Averaging per-pixel NDVI is the convention every agronomic reference assumes, so mixing the two makes your values incomparable with everyone else's.

Why weight by coverage instead of just masking the raster? A binary mask forces a yes-or-no decision on every boundary pixel, and both defaults are wrong: centroid-based inclusion under-counts narrow fields, while all_touched over-counts by pulling in cells barely clipped by the edge. Coverage weighting assigns each cell the exact fraction the polygon covers, so an edge cell half inside contributes half as much. The bias only disappears for large, compact parcels — for strips and small fields it is the dominant error, as rasterstats vs exactextract quantifies.

What coverage threshold should I actually use? There is no universal number; it depends on parcel size relative to pixel size and on what the mean feeds. At 10 m over roughly one-hectare parcels, 0.60 is a reasonable default because a parcel that size holds around 100 cells, so 60 % still rests on a real sample. The important part is not the threshold but shipping valid_coverage as a column, so a downstream consumer can raise the bar without re-running the extraction.

Can I run this over a time series without writing a GeoTIFF per date? Yes — exact_extract accepts an in-memory rioxarray DataArray, so a stacked cube can loop over the time dimension and extract each slice without touching disk, appending a date column per pass. That path is described in xarray & rioxarray raster cubes; the intermediate GeoTIFF is only worth writing when the NDVI surface itself is a deliverable.