Pixels do not respect polygon boundaries. Every zonal statistic therefore rests on a rule for what to do with the pixels that straddle the edge: include them whole (all_touched=True), include them only when their centre falls inside (the default), or weight them by the fraction of their area that lies inside (exact extraction). The three give measurably different answers, and which one is correct depends on how large your polygons are relative to your pixels.
This page compares the rules and shows how to pick. For the full zonal workflow, see Zonal Statistics for Polygon Aggregation.
Why This Fails in Geospatial ML Pipelines
The bias is systematic, not random, and that is what makes it dangerous. Under all_touched, every polygon acquires a ring of pixels from outside itself, so every zonal mean is pulled towards the surrounding landscape. Small polygons have proportionally more ring, so the contamination scales inversely with polygon size — which means the error correlates with a variable (parcel size) that is often itself predictive. The model then learns a relationship between size and target that is an artefact of the extraction rule.
Under the default centre rule the bias runs the other way: edge pixels are dropped, so a polygon’s statistic reflects only its core. For compact fields that is nearly harmless. For elongated features — a riparian strip, a hedgerow, a road verge — it can discard most of the polygon, and for anything narrower than a pixel it returns None, which most pipelines then impute with a mean and never mention again.
Third, the rule is rarely recorded. Two teams computing “mean NDVI per field” with different all_touched settings produce feature tables that differ by a few percent per row, with the difference concentrated in small fields. When one team’s model is evaluated on the other’s features, performance drops for reasons nobody can locate — the same class of silent contract mismatch as re-fitting a scaler, discussed in scaling features consistently between training and inference.
Core Principles
- Measure the exposure first. Perimeter-to-area in pixel units tells you whether the rule matters at all.
- Prefer exact area weighting when it does.
exactextractis both more accurate and faster at scale. - Never let
all_touchedbe the silent default for area classes. It inflates and contaminates. - Emit the pixel count as a feature. A mean over 6 pixels is not a mean over 6,000.
- Record the rule in the output metadata. It is part of the feature’s definition.
- Handle sub-pixel polygons explicitly rather than imputing their
Noneaway.
Production-Ready Code
from __future__ import annotations
import logging
import geopandas as gpd
import numpy as np
import pandas as pd
import rasterio
logger = logging.getLogger(__name__)
def boundary_exposure(gdf: gpd.GeoDataFrame, cell_size: float) -> pd.Series:
"""Fraction of a polygon's area that sits within one pixel of its boundary.
A useful proxy for how much the coverage rule can change the answer:
below ~2% any rule is fine; above ~20% use exact weighting.
"""
area = gdf.geometry.area
inner = gdf.geometry.buffer(-cell_size).area.clip(lower=0)
return ((area - inner) / area.replace(0, np.nan)).rename("boundary_frac")
def zonal_exact(gdf: gpd.GeoDataFrame, raster_path: str,
stats: tuple = ("mean", "stdev", "count")) -> pd.DataFrame:
"""Area-weighted zonal statistics via exactextract.
Each pixel contributes in proportion to the fraction of it inside the polygon,
so boundary pixels are neither dropped nor counted whole.
"""
from exactextract import exact_extract
with rasterio.open(raster_path) as src:
if gdf.crs != src.crs:
logger.info("reprojecting polygons %s -> %s",
gdf.crs.to_string(), src.crs.to_string())
gdf = gdf.to_crs(src.crs)
result = exact_extract(raster_path, gdf, list(stats), output="pandas")
result.index = gdf.index
return result
def zonal_with_rule(gdf: gpd.GeoDataFrame, raster_path: str,
all_touched: bool) -> pd.DataFrame:
"""rasterstats zonal means under an explicit coverage rule, for comparison."""
from rasterstats import zonal_stats
with rasterio.open(raster_path) as src:
nodata = src.nodata
if gdf.crs != src.crs:
gdf = gdf.to_crs(src.crs)
rows = zonal_stats(gdf, raster_path, stats=["mean", "count"],
nodata=nodata, all_touched=all_touched)
out = pd.DataFrame(rows, index=gdf.index)
return out.rename(columns={"mean": f"mean_at{int(all_touched)}",
"count": f"count_at{int(all_touched)}"})
def compare_rules(gdf: gpd.GeoDataFrame, raster_path: str,
cell_size: float) -> pd.DataFrame:
"""Side-by-side of the three rules plus the exposure metric."""
exposure = boundary_exposure(gdf, cell_size)
strict = zonal_with_rule(gdf, raster_path, all_touched=False)
loose = zonal_with_rule(gdf, raster_path, all_touched=True)
exact = zonal_exact(gdf, raster_path).rename(columns={"mean": "mean_exact"})
out = pd.concat([exposure, strict, loose, exact[["mean_exact"]]], axis=1)
out["spread"] = out[["mean_at0", "mean_at1", "mean_exact"]].max(axis=1) - \
out[["mean_at0", "mean_at1", "mean_exact"]].min(axis=1)
return outStep-by-Step Walkthrough
Step 1 — Measure how exposed your polygons are
import geopandas as gpd
import rasterio
fields = gpd.read_file("fields.gpkg")
with rasterio.open("ndvi.tif") as src:
cell = abs(src.transform.a)
fields = fields.to_crs(src.crs)
exposure = boundary_exposure(fields, cell)
print(exposure.describe())
print(f"{(exposure > 0.2).mean():.1%} of polygons are more than 20% boundary")If the 75th percentile is under about 0.05, the coverage rule barely matters for this dataset and you can use whichever tool is convenient. If a meaningful share exceed 0.2, exact weighting is not an optimisation — it is a correctness requirement.
Step 2 — Compare the three rules on a sample
sample = fields.sample(min(2_000, len(fields)), random_state=0)
cmp = compare_rules(sample, "ndvi.tif", cell)
print(cmp[["boundary_frac", "mean_at0", "mean_at1", "mean_exact", "spread"]].describe())
print("\nlargest disagreements:")
print(cmp.nlargest(5, "spread")[["boundary_frac", "mean_at0", "mean_at1", "mean_exact"]])The correlation between boundary_frac and spread is the diagnostic: if it is strong, the rule is injecting a size-dependent bias into the features.
Step 3 — Compute the production statistics with exact weights
stats = zonal_exact(fields, "ndvi.tif", stats=("mean", "stdev", "count", "coverage_fraction"))
fields = fields.join(stats.add_prefix("ndvi_"))
fields["ndvi_pixels"] = fields["ndvi_count"]
fields["ndvi_thin"] = fields["ndvi_count"] < 10 # thin-evidence flag
print(f"{fields['ndvi_thin'].mean():.1%} of polygons rest on fewer than 10 pixels")Step 4 — Deal with sub-pixel polygons deliberately
tiny = fields.loc[fields["ndvi_count"].isna() | (fields["ndvi_count"] < 1)]
if len(tiny):
logger.warning("%d polygons are smaller than one pixel", len(tiny))
centroid_vals = point_sample("ndvi.tif", tiny.geometry.centroid)
fields.loc[tiny.index, "ndvi_mean"] = centroid_vals
fields.loc[tiny.index, "ndvi_thin"] = TrueSampling the centroid is an honest answer for a sub-pixel polygon — it is literally the only information the raster has — provided the ndvi_thin flag travels with it so the model can discount it.
Step 5 — Record the rule with the features
fields.attrs["zonal_rule"] = "exactextract area-weighted"
fields.attrs["raster"] = "ndvi.tif"
fields.attrs["cell_size_m"] = cell
fields.to_parquet("field_features.parquet")Verification
import numpy as np
from shapely.geometry import box
def test_exact_weights_match_the_geometric_fraction():
"""A polygon covering exactly half a pixel must give that pixel weight 0.5."""
# 10 m pixels; polygon spans one full pixel and half of the next.
poly = box(0, 0, 15, 10)
weights = pixel_coverage(poly, transform=from_origin(0, 10, 10, 10), shape=(1, 2))
assert np.allclose(weights, [[1.0, 0.5]], atol=1e-6)
def test_all_touched_never_reduces_the_pixel_count():
strict = zonal_with_rule(fields, "ndvi.tif", all_touched=False)
loose = zonal_with_rule(fields, "ndvi.tif", all_touched=True)
assert (loose["count_at1"] >= strict["count_at0"]).all()
def test_exact_mean_lies_between_the_two_rules():
"""Area weighting should not fall outside the bracket the two rules define."""
cmp = compare_rules(fields.head(200), "ndvi.tif", cell_size=10.0)
lo = cmp[["mean_at0", "mean_at1"]].min(axis=1) - 1e-6
hi = cmp[["mean_at0", "mean_at1"]].max(axis=1) + 1e-6
ok = cmp["mean_exact"].between(lo, hi)
assert ok.mean() > 0.95, "exact means fall outside the bracket too often"
def test_subpixel_polygon_is_flagged_not_silently_dropped():
tiny = fields_with_a_2m_polygon()
out = zonal_exact(tiny, "ndvi_10m.tif")
assert out["count"].iloc[0] < 1A useful field check: compute the same feature under both rules, fit the model twice, and compare held-out scores under spatial cross-validation. If the scores differ materially, the extraction rule is a hyperparameter and deserves to be recorded as one.
FAQ
When does partial pixel coverage actually matter?
When polygons are small relative to pixels. A 100 ha field on a 10 m raster has a boundary effect well under 1%; a 0.4 ha allotment is about 40 pixels, half of which touch the edge. The boundary_exposure metric above turns that intuition into a number you can threshold.
Is exactextract always better than rasterstats?
It is more accurate for small or elongated polygons, and faster on large jobs. For large compact polygons the two agree to a fraction of a percent, so the simpler dependency is perfectly reasonable — see computing zonal statistics with rasterstats.
How should I handle a polygon smaller than one pixel?
Accept that the statistic is one pixel’s value, record the pixel count so the model knows the evidence is thin, and ask whether a finer raster or a different analysis unit is the real fix.
Related
- Zonal Statistics for Polygon Aggregation — the full zonal workflow
- Computing Zonal Statistics with rasterstats — the baseline implementation
- Rasterizing Vector Layers for Model Inputs — the same coverage rule, applied in the other direction
- Fixing Invalid Geometries Before Buffering — geometry repair that exact extraction also requires
Part of: Zonal Statistics for Polygon Aggregation Part of: Spatial Feature Engineering for Machine Learning