Zonal Statistics for Forest Inventory with rasterstats
Forest inventory constantly asks the same question of a raster: what does this surface look like inside each stand, plot, or ownership parcel? Mean canopy height per stand, total biomass per parcel, the 95th-percentile height per plot, or the dominant land-cover class per management unit — all of these are zonal statistics, a per-polygon reduction of raster cells. The rasterstats library computes them in one call, reading the raster window-by-window so a national mosaic never lands in RAM at once. This guide covers that narrow task for inventory work, including a rioxarray/numpy alternative when you need full control. It sits within the Raster-Vector Overlay Techniques workflow, part of the broader Ecological GIS Data Foundations in Python framework.
When to Use rasterstats vs a rioxarray/numpy Approach
Both approaches answer “summarise a raster inside these polygons,” but they trade convenience against control. rasterstats.zonal_stats is the fast path: it handles rasterisation, nodata, and windowed reads internally and returns a list of dicts. A hand-rolled rioxarray + numpy approach is worth it when you need a reduction rasterstats does not expose (a weighted percentile, a custom biomass allometry per cell), or when the raster is already an in-memory xarray cube from an earlier step.
| Requirement | Use rasterstats |
Use rioxarray + numpy |
|---|---|---|
| Standard reducers (mean, sum, min, max, std, count) | ✔ one call | ✔ but you write the reduction |
| Percentiles per zone | ✔ percentiles=[...] |
✔ via np.nanpercentile |
| Categorical majority / class counts | ✔ categorical=True |
✔ via np.bincount |
| Raster too large for RAM | ✔ windowed reads by path | needs manual windowing |
| Custom / weighted reducer | limited (add_stats) |
✔ full control |
| Raster already an xarray cube | reopen from disk | ✔ operate in place |
| Exact area-weighted (fractional cells) | ✗ (centroid rule) | use exactextract instead |
For routine per-stand inventory metrics, reach for rasterstats. Drop to rioxarray only when the reducer is non-standard or the data already lives in memory. If you need true fractional-cell weighting on small polygons, neither is exact — use a coverage-fraction engine such as exactextract, as discussed in the parent Raster-Vector Overlay Techniques guide.
Minimal Reproducible Example
This computes mean, sum, standard deviation, count, and the 90th and 95th percentiles of a canopy height model for every stand polygon, then joins the results back onto the original attribute table so stand identifiers travel with their statistics. Note the two non-negotiables: the vector must be in the raster’s CRS, and the raster must carry an explicit nodata value.
import geopandas as gpd
import rasterio
from rasterstats import zonal_stats
RASTER = "chm.tif" # 1 m canopy height model, metres
VECTOR = "stands.gpkg" # forest stand polygons
stands = gpd.read_file(VECTOR)
# 1. Align the vector to the raster CRS (never warp the raster for one overlay).
with rasterio.open(RASTER) as src:
raster_crs = src.crs
nodata = src.nodata
if nodata is None:
raise ValueError("Raster nodata is None; set it before zonal_stats.")
stands = stands.to_crs(raster_crs)
# 2. Reduce the raster inside every polygon.
stats = zonal_stats(
vectors=stands,
raster=RASTER, # pass the PATH so reads are windowed
stats=["mean", "sum", "std", "count"],
percentiles=[90, 95], # adds percentile_90, percentile_95
nodata=nodata,
all_touched=False, # centroid rule — unbiased for large plots
geojson_out=False,
)
# 3. Join the statistics back onto the stand attributes.
for key in ("mean", "sum", "std", "count", "percentile_90", "percentile_95"):
stands[f"chm_{key}"] = [s.get(key) for s in stats]
stands.to_file("stands_with_chm.gpkg", driver="GPKG")
For a categorical raster — a land-cover, forest-type, or fuel-class grid — switch to class tallies. categorical=True returns, per zone, a dict of {class_code: pixel_count}; a small helper turns that into the majority class and its purity fraction, which is the usual inventory need.
from rasterstats import zonal_stats
cat = zonal_stats(
vectors=stands, raster="landcover.tif",
categorical=True, nodata=0, all_touched=False,
)
def majority(counts: dict) -> tuple[int | None, float]:
"""Dominant class code and its share of valid pixels in the zone."""
if not counts:
return None, 0.0
total = sum(counts.values())
code, n = max(counts.items(), key=lambda kv: kv[1])
return code, n / total
stands["lc_majority"], stands["lc_purity"] = zip(*(majority(c) for c in cat))
The rioxarray/numpy alternative, useful when the reducer is custom, rasterises each geometry to a mask and reduces the masked array directly:
import numpy as np
import rioxarray
from rasterio.features import geometry_mask
chm = rioxarray.open_rasterio("chm.tif", masked=True).squeeze("band", drop=True)
transform = chm.rio.transform()
records = []
for _, row in stands.to_crs(chm.rio.crs).iterrows():
mask = geometry_mask([row.geometry], out_shape=chm.shape,
transform=transform, invert=True)
cells = chm.values[mask]
cells = cells[np.isfinite(cells)]
records.append({
"mean": float(np.mean(cells)) if cells.size else None,
"p95": float(np.percentile(cells, 95)) if cells.size else None,
})
Parameter Reference
These zonal_stats arguments materially change the result; pin them deliberately rather than relying on defaults.
| Argument | Type | Default | Recommended | Ecological rationale |
|---|---|---|---|---|
vectors |
GeoDataFrame / path | — | GeoDataFrame in raster CRS | Geometries must match the raster grid or every zone reads nodata |
raster |
path / ndarray | — | file path | A path enables windowed reads; an array forces the whole raster into RAM |
stats |
list of str | ["count","min","max","mean"] |
name reducers explicitly | Documents intent; add sum for biomass, std for heterogeneity |
percentiles |
list of number | none | [95] for canopy |
Percentiles resist outliers better than max for dominant height |
nodata |
number | raster’s nodata | must equal raster fill | If wrong, fill values are averaged as real data |
all_touched |
bool | False |
False for stands |
Centroid rule; True over-includes edge cells and inflates thin polygons |
categorical |
bool | False |
True for class rasters |
Returns per-class pixel counts instead of summary statistics |
add_stats |
dict | none | for custom reducers | Register a named callable (e.g. a weighted mean) without leaving the library |
Expected Output and Verification
zonal_stats returns a list the same length and order as the input vectors, one dict per feature. Three checks confirm the numbers are real before you report them: every zone has a positive pixel count, no statistic exceeds the raster’s physical ceiling, and the joined frame did not silently reorder.
import numpy as np
chm_count = np.array([s["count"] for s in stats])
chm_max = np.array([s["max"] if s["max"] is not None else np.nan for s in stats])
# 1. No empty zones (a zero count means off-grid or all-nodata).
assert (chm_count > 0).all(), "Some stands returned zero valid pixels — check CRS/nodata."
# 2. Physical plausibility (a 1 m CHM should never report 400 m).
assert np.nanmax(chm_max) < 120.0, "Implausible height — nodata likely leaked in."
# 3. Row alignment preserved after the join.
assert len(stats) == len(stands), "Stats length != feature count."
A zero count almost always means a CRS mismatch left the polygon off-grid, or the stand fell entirely on nodata; either way, fix it before reporting rather than treating the None as a real value. Once verified, these per-stand heights commonly pair with a Canopy Height Model Creation product upstream and feed forest-health checks via Vegetation Index Calculation in Python.
Common Pitfalls
nodataleft asNone.rasterstatsthen counts the fill value (often-9999or0) as data, dragging means toward the fill. Readsrc.nodata, assert it is set, and pass it explicitly.- CRS mismatch. If the vector and raster CRS differ, polygons land off-grid and return all-nodata zones. Reproject the vector to the raster CRS before the call, never the reverse for a single overlay.
all_touched=Trueon plots. Setting it inflates small or thin polygons — narrow riparian buffers, fixed-radius plots a few cells wide — by pulling in every edge cell. Use the centroid rule (False) unless omission is worse than over-inclusion.- Passing an in-memory array for a huge raster. Handing
zonal_statsanumpyarray forces the whole raster into RAM. Pass the file path so it reads only the window each polygon needs.
Frequently Asked Questions
How do I compute a statistic rasterstats does not provide, like a weighted mean?
Use the add_stats argument to register a named callable that receives the masked pixel array for each zone and returns a scalar. For example, add_stats={"p10": lambda a: float(np.nanpercentile(a.compressed(), 10))} adds a p10 field. For reductions that need per-cell weights (an area or biomass weighting), the rioxarray/numpy approach gives you the mask and the weights together and is usually clearer.
Why does a stand return None for its mean?
The zone contained no valid pixels — every cell was nodata, or the polygon fell off the raster after a CRS mismatch. Confirm the vector was reprojected to the raster CRS, that the raster has an explicit nodata value, and that the polygon actually overlaps the raster extent. A non-zero count in the result is the signal that a mean is trustworthy.
Related
- Raster-Vector Overlay Techniques — the parent workflow on aligning and overlaying grids with polygons.
- Canopy Height Model Creation — produce the CHM these per-stand statistics summarise.
- Vegetation Index Calculation in Python — extract per-plot index values for forest-health validation.
- Ecological GIS Data Foundations in Python — the reproducible-pipeline framework this guide belongs to.