Raster-Vector Overlay Techniques for Forestry and Ecological Workflows in Python

Forest inventory and ecological monitoring constantly require pairing a continuous raster surface — a LiDAR canopy height model, a Sentinel-2 reflectance tile, a modelled climate grid — with discrete vector boundaries such as stands, plots, riparian buffers, or conservation easements. The recurring technical challenge is computing a defensible per-polygon summary of the raster (mean canopy height per stand, NDVI percentiles per management unit, biomass density per ownership parcel) without introducing the silent geometric and statistical errors that creep in when grids and polygons are misaligned. This page builds a reproducible, auditable Python overlay pipeline that a conservation agency or research team can run unattended across thousands of stands. It is one component of the broader Ecological GIS Data Foundations in Python workflow, which orchestrates the upstream and downstream steps this overlay depends on.

A worked scenario anchors the rest of the page: you have a 1 m LiDAR-derived canopy height raster covering a watershed and a shapefile of ~4,000 forest stand polygons, and you need mean and 95th-percentile canopy height for every stand, exported with the original attribute table intact.

Raster-Vector Overlay Pipeline Data Flow Two inputs enter from the left: a continuous canopy height raster (a grid of cells) and a set of forest stand polygons (vector boundaries). The vector branch is reprojected onto the raster CRS and its topology repaired, while the raster stays authoritative. Both feed a mask-and-crop stage that clips the grid to the stand extent, then a zonal-extraction stage that computes coverage-weighted statistics per polygon. The result is a per-stand table whose rows carry the original stand id plus new columns for mean, 95th-percentile, and pixel count of canopy height. Inputs Raster CHM grid + nodata Vector stand polygons authoritative CRS — grid untouched Prepare vector CRS Align to_crs(raster_crs) reproject vector Topology Repair make_valid drop slivers Overlay core Mask & Crop clip grid to extent bound memory Zonal Extraction zonal_stats per zone centroid weighting Output id mean p95 count S-0124.631.0812 S-0218.226.4540 S-0329.137.8963 S-0421.728.9704 attributes preserved + chm_* columns harmonise CRS → repair topology → mask & crop → extract per polygon → export table

Prerequisites

Confirm each of these before running the pipeline. Mismatches here are the dominant cause of wrong-but-plausible output later.

Concept Background: What “Overlay” Means for a Grid and a Polygon

A raster is a regular matrix of cells, each anchored to ground coordinates by an affine transform; a vector polygon is continuous Euclidean geometry. Zonal extraction reconciles the two by deciding which cells belong to each polygon and how partial cells are weighted.

For a polygon overlaid on raster cells indexed by , the zonal mean is the coverage-weighted average of cell values :

where is the area of cell that falls inside the polygon and is the full cell area, so . Two simplifications dominate practice:

  • Centroid sampling (all_touched=False): a cell counts fully () only if its centroid is inside the polygon, otherwise . This is the standard, unbiased choice for forestry plots larger than a few cells.
  • All-touched (all_touched=True): every cell intersected by the polygon counts fully. This inflates small or thin polygons (riparian strips, narrow buffers) by pulling in edge cells and should be reserved for cases where you would rather over-include than miss a feature.

The three rules differ only in how they treat the boundary cells — the cells the polygon edge cuts through — and that single decision drives the bias between methods:

Boundary-Cell Selection: Centroid vs All-Touched vs Coverage-Fraction Three identical 6-by-6 raster grids each show the same forest-stand polygon outline. Under centroid sampling (all_touched=False) a boundary cell is included only when its centre dot falls inside the polygon, so the selected region hugs the edge tightly and some partly-covered cells are dropped. Under all-touched (all_touched=True) every cell the polygon edge passes through is included fully, so the selected region bulges outward and over-counts edge cells. Under coverage-fraction weighting each boundary cell carries a partial weight equal to the fraction of its area inside the polygon, giving the most faithful zonal mean. The interior cells are fully counted by all three rules; only the boundary differs. Centroid (all_touched=False) cell counts only if its centre is inside All-touched (all_touched=True) every intersected cell counts fully Coverage fraction (0 < w < 1) edge cells weighted by area inside Same polygon, same grid — only the boundary cells (which the edge crosses) are treated differently. centroid can under-count edges · all-touched over-counts them · coverage fraction is the faithful reference

True fractional weighting () requires a coverage-fraction engine such as exactextract; for most stand-level inventory the centroid rule is accurate and far cheaper. The bias from centroid sampling shrinks as polygon area grows relative to cell size, which is why grid resolution and minimum mapping unit should be chosen together.

Step-by-Step Python Pipeline

The pipeline runs in four ordered stages: harmonise CRS, repair topology, mask and extract, then export. Each step below is runnable in isolation and composes into the final driver.

Step 1 — Harmonise the coordinate framework

Treat the raster CRS as authoritative for a single overlay and reproject the vector to match it. Reprojecting the raster instead would resample every cell and blur the values you are about to summarise.

import geopandas as gpd
import rasterio

def align_to_raster(vector_path: str, raster_path: str) -> gpd.GeoDataFrame:
    """Load vector boundaries and reproject them onto the raster's CRS."""
    gdf = gpd.read_file(vector_path)
    if gdf.crs is None:
        raise ValueError(f"{vector_path} has no CRS; assign the correct EPSG before overlay.")
    with rasterio.open(raster_path) as src:
        raster_crs = src.crs
    if raster_crs is None:
        raise ValueError(f"{raster_path} has no CRS; the raster cannot be georeferenced.")
    return gdf.to_crs(raster_crs)

Step 2 — Repair vector topology

Stand polygons exported from desktop GIS routinely carry self-intersections, slivers, and unclosed rings. These trigger silent failures during sampling — null statistics or misattributed cells — so repair them before they reach the raster. The same discipline underpins Merging Shapefiles and Rasters in Python, where mismatched grid topology is the recurring failure.

import geopandas as gpd
from shapely.validation import make_valid

def clean_vector(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Fix invalid geometries and drop empty or degenerate features."""
    gdf = gdf.copy()
    invalid = ~gdf.geometry.is_valid
    if invalid.any():
        gdf.loc[invalid, "geometry"] = gdf.loc[invalid, "geometry"].apply(make_valid)
    # make_valid can emit GeometryCollections; keep only (multi)polygon parts.
    gdf["geometry"] = gdf.geometry.apply(
        lambda g: g if g.geom_type in ("Polygon", "MultiPolygon") else g.buffer(0)
    )
    gdf = gdf[~gdf.geometry.is_empty].copy()
    return gdf

Step 3 — Mask, extract, and aggregate

With clean, aligned inputs the core overlay proceeds through masking (clip the grid to the vector extent to cut memory and edge artefacts) and zonal extraction (per-polygon statistics). rasterstats.zonal_stats reads the source raster directly and intersects each geometry natively.

import geopandas as gpd
import rasterio
from rasterio.mask import mask
from rasterstats import zonal_stats

def run_forestry_overlay(raster_path: str, gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Per-stand canopy statistics from a raster, with attributes preserved."""
    with rasterio.open(raster_path) as src:
        raster_crs = src.crs
        src_nodata = src.nodata          # capture before the dataset closes
        # Clip once to confirm overlap and bound memory; out_image unused downstream.
        mask(src, gdf.geometry, crop=True)

    if src_nodata is None:
        raise ValueError(
            "Raster nodata is None; rasterstats would treat fill as a real value. "
            "Set an explicit nodata before overlay."
        )

    stats = zonal_stats(
        vectors=gdf,
        raster=raster_path,
        stats=["mean", "std", "min", "max", "count"],
        percentiles=[95],                # adds a "percentile_95" field per zone
        nodata=src_nodata,
        all_touched=False,               # centroid sampling — standard for stand plots
        geojson_out=False,
    )

    out = gdf.copy()
    for key in ("mean", "std", "min", "max", "count", "percentile_95"):
        out[f"chm_{key}"] = [s.get(key) for s in stats]
    return gpd.GeoDataFrame(out, geometry=gdf.geometry, crs=raster_crs)

The percentiles=[95] argument adds a percentile_95 field to each result, giving the 95th-percentile canopy height the scenario asked for alongside the mean. Because attributes are joined back onto the original GeoDataFrame, every stand identifier and ownership field travels with its new statistics.

Step 4 — Compose and export

def overlay_to_file(raster_path: str, vector_path: str, output_path: str) -> gpd.GeoDataFrame:
    gdf = clean_vector(align_to_raster(vector_path, raster_path))
    result = run_forestry_overlay(raster_path, gdf)
    # GeoPackage preserves geometry + dtypes; CSV drops geometry but is portable.
    result.to_file(output_path, driver="GPKG")
    print(f"Overlay complete: {len(result)} stands written to {output_path}.")
    return result

Configuring rasterstats for Forestry Output

zonal_stats is controlled by a small set of parameters that materially change the result. Pin them deliberately rather than relying on defaults.

zonal_stats(
    vectors=gdf,                       # GeoDataFrame or path; CRS must match raster
    raster="chm.tif",                  # source raster, read window-by-window
    stats=["mean", "std", "count"],    # named reducers over each zone
    percentiles=[90, 95],              # appended as percentile_90 / percentile_95
    nodata=-9999,                      # MUST match the raster fill value
    all_touched=False,                 # centroid rule; True over-includes edge cells
    categorical=False,                 # True for class rasters (e.g. land-cover counts)
    boundless=True,                    # tolerate zones extending past the raster edge
)

For categorical inputs such as a forest-type or fire-fuel class raster, set categorical=True and zonal_stats returns a per-zone count of each class code instead of summary statistics — useful when an overlay feeds a fuel or habitat-class tally rather than a continuous metric.

Validation and Verification

Never trust an overlay you have not checked for spatial overlap and nodata handling. Run this guard before the expensive extraction; it fails loudly if the inputs cannot produce valid statistics.

import geopandas as gpd
import rasterio
from shapely.geometry import box

def check_overlay_alignment(raster_path: str, vector_path: str) -> bool:
    """Return True only if vector and raster extents overlap in a shared CRS."""
    with rasterio.open(raster_path) as src:
        raster_box = box(*src.bounds)
        raster_crs = src.crs
        if src.nodata is None:
            raise ValueError("Raster nodata is undefined; set it before overlay.")
        if abs(src.res[0] - src.res[1]) > 1e-9:
            print(f"Warning: non-square pixels {src.res}; area-weighting needs care.")
    gdf = gpd.read_file(vector_path).to_crs(raster_crs)
    if not raster_box.intersects(box(*gdf.total_bounds)):
        raise ValueError(
            "No spatial overlap after CRS alignment.\n"
            f"Raster bounds: {tuple(round(b, 2) for b in raster_box.bounds)}\n"
            f"Vector bounds: {tuple(round(b, 2) for b in gdf.total_bounds)}"
        )
    return True

Three post-run sanity checks confirm the numbers are real:

  1. Coverage — every polygon should have chm_count > 0. A zero count means the stand fell on nodata or off-grid; investigate before reporting.
  2. Plausible rangechm_max must not exceed the raster’s physical ceiling (a 1 m CHM should not report 400 m heights; if it does, nodata leaked into the statistics).
  3. Spot-check against a manual zonal mean — clip one stand in a desktop GIS and compare; agreement within rounding confirms the centroid rule and nodata mask behaved as expected.

Failure Modes and Gotchas

  • CRS mismatch slips through — if the vector lacks a CRS, to_crs cannot reproject it and the geometries land at the wrong ground location, producing all-nodata zones. Validate CRS presence at ingestion, not after.
  • nodata left as Nonerasterstats then counts the fill value (often -9999 or 0) as real data, dragging means toward the fill. Always set an explicit nodata on the raster.
  • all_touched=True on thin polygons — riparian buffers and stand edges absorb neighbouring cells and report inflated means; reserve all-touched for cases where omission is worse than over-inclusion.
  • Non-square pixels in area-weighted work — rectangular cells break the simple area weighting and silently bias hectare-normalised metrics; resample to square pixels first or use a coverage-fraction engine.
  • NaN propagation — a single NaN in a band with nodata=None poisons mean/std for the whole zone; mask or set nodata before reducing.
  • Memory blow-up on big rasters — calling mask(..., crop=True) on a continental tile materialises the whole window in RAM; window-read or tile instead (below).

Performance and Scale Notes

The watershed scenario (4,000 stands, one tile) runs comfortably in memory, but regional inventories spanning hundreds of tiles need a different strategy.

  • Let zonal_stats window-read. Passing the raster path (not an in-memory array) lets rasterstats read only the window each polygon needs, keeping memory flat regardless of total raster size. Avoid pre-masking the whole raster into RAM.
  • Partition by tile, parallelise by zone group. Split the vector by raster tile with a spatial join, then process tile groups across processes with concurrent.futures.ProcessPoolExecutor. Zonal statistics are embarrassingly parallel because polygons are independent.
  • Use a spatial index for the join. geopandas.sjoin backed by the built-in R-tree assigns each polygon to its tile in near-linear time, avoiding an O(n·m) bounding-box scan.
  • Stream results to a columnar store. Append each tile’s output to a Parquet or GeoPackage rather than concatenating GeoDataFrames in memory, so peak memory stays bounded by the largest tile.

These outputs rarely terminate here: extracted canopy metrics commonly combine with multispectral indices to validate forest health, so a tuned overlay is the on-ramp to Vegetation Index Calculation in Python, and the polygon layer itself often originates from a defensible Spatial Plot Sampling Design.

Frequently Asked Questions

Should I reproject the raster or the vector before an overlay?

Reproject the vector. Warping the raster resamples every cell and alters the very values you are about to summarise, introducing interpolation bias. Moving the lightweight polygon geometry onto the raster’s CRS leaves the grid untouched and is also far faster.

Why does rasterstats return None or zero for some polygons?

The most common causes are a polygon that falls entirely on nodata, a CRS mismatch that placed the polygon off-grid, or a nodata value left as None so the fill was counted as data. Run check_overlay_alignment first and confirm every result has a non-zero count.

When should I use all_touched=True?

Only when missing a feature is worse than over-counting it — typically thin or small polygons such as riparian strips narrower than a cell. For ordinary forest stands spanning many cells, centroid sampling (all_touched=False) is the unbiased default.

How do I get an exact area-weighted mean instead of the centroid approximation?

Use a coverage-fraction engine such as exactextract, which computes the fractional area of every boundary cell (). It is slower than rasterstats but eliminates centroid bias, which matters most for small polygons or coarse rasters where edge cells are a large share of the zone.

Can I overlay a categorical raster like land-cover or fuel class?

Yes — pass categorical=True to zonal_stats. Instead of mean/std it returns a per-zone count of each class code, which feeds fuel-load tallies, habitat-class summaries, or disturbance-class accounting directly.

How do I keep my stand attribute table attached to the results?

Join the statistics back onto the original GeoDataFrame rather than building a fresh frame from the stats dicts. The pipeline above copies the input gdf and adds chm_* columns, so every stand identifier and ownership field stays aligned with its new metrics.

Up: Ecological GIS Data Foundations in Python