Masking Rasters to Stand Boundaries with rasterio.mask

Clipping a raster to a stand boundary is the most-run operation in forest GIS and the one with the most silent variants. Whether a pixel on the boundary belongs to the stand, whether the result keeps the original grid or gets a new one, and what happens to pixels outside the polygon are three independent decisions, and different defaults across tools give measurably different hectare figures. This guide settles them, as a focused operation inside Raster-Vector Overlay Techniques, part of Ecological GIS Data Foundations in Python.

When to use this approach

Task Tool Note
Clip to a polygon, keep the array rasterio.mask.mask The default choice; returns array plus transform
Clip to a polygon, write a file rasterio.mask then dst.write Same call, plus an updated profile
Summarise per polygon, no output raster rasterstats.zonal_stats Skip the clip entirely if you only need statistics
Clip an xarray stack rioxarray.clip Same semantics, applied across a time dimension
Exclude an area rather than keep it mask(..., invert=True) Removing lakes, roads, or an exclusion zone

If the end product is a table of means and areas per stand, do not clip at all — zonal statistics is a single pass and avoids writing a raster per stand. Clipping earns its place when the masked pixels themselves are the product, or when a subsequent operation needs a raster.

The decision that changes your numbers

all_touched decides what happens to a pixel the boundary crosses. With the default False, a pixel is included when its centre falls inside the polygon; with True, when any part of it is covered.

The difference scales with the perimeter-to-area ratio, which is why it matters far more in forestry than in most raster work. A compact 100 ha stand at 20 m resolution has a boundary about 4 km long, touching roughly 200 pixels out of 2,500 — so the two settings can differ by several percent. A narrow riparian buffer 30 m wide is nearly all boundary, and the two settings can differ by half.

Centre-based versus all-touched pixel selection at a stand boundary The same irregular stand polygon is drawn over a pixel grid twice. In the left panel, selection is by pixel centre, so the kept pixels form a shape slightly inside the polygon and several boundary pixels are excluded. In the right panel, all touched selection keeps every pixel the boundary passes through, adding a complete ring of partly covered pixels and enlarging the masked area. all_touched = False (pixel centre) all_touched = True 16 pixels · 6.4 ha 32 pixels · 12.8 ha Same polygon, same raster. The ring of boundary pixels is the entire disagreement.

The rule that keeps results defensible: use all_touched=False when you are measuring (area, mean, biomass), because centre-based selection is unbiased — boundary pixels are as likely to fall outside as inside. Use all_touched=True when you are extracting for display or for a subsequent operation that must not have holes, such as a mask that will be eroded anyway.

Minimal reproducible example

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


def clip_to_stands(raster_path: str, stands_path: str, out_path: str,
                   all_touched: bool = False, nodata: float = -9999.0):
    """Clip a raster to a stand layer, writing a cropped GeoTIFF."""
    stands = gpd.read_file(stands_path)
    with rasterio.open(raster_path) as src:
        shapes = stands.to_crs(src.crs).geometry.values          # CRS match is mandatory
        data, transform = mask(
            src, shapes,
            crop=True,               # shrink the output to the geometry's extent
            all_touched=all_touched,
            filled=True,
            nodata=nodata,
        )
        profile = src.profile | {
            "height": data.shape[1],
            "width": data.shape[2],
            "transform": transform,
            "nodata": nodata,
            "compress": "deflate",
            "predictor": 2,
            "tiled": True,
        }
    with rasterio.open(out_path, "w", **profile) as dst:
        dst.write(data)
    return out_path

filled=True with an explicit nodata is deliberate. The alternative, filled=False, returns a masked array, which is more expressive in memory but loses the mask the moment anyone calls .data or writes it without the mask — a class of bug that produces stands whose “outside” pixels are quietly zero.

To exclude rather than keep — cutting lakes, roads, or a retention area out of a stand raster — invert:

def exclude_features(raster_path: str, exclusions_path: str, out_path: str,
                     buffer_m: float = 0.0, nodata: float = -9999.0):
    """Set every pixel inside the exclusion layer to NoData, keeping the rest."""
    excl = gpd.read_file(exclusions_path)
    with rasterio.open(raster_path) as src:
        shapes = excl.to_crs(src.crs).geometry
        if buffer_m:
            shapes = shapes.buffer(buffer_m)
        data, transform = mask(src, shapes.values, invert=True, crop=False,
                               filled=True, nodata=nodata)
        profile = src.profile | {"nodata": nodata, "transform": transform}
    with rasterio.open(out_path, "w", **profile) as dst:
        dst.write(data)
    return out_path

Note crop=False here. When excluding, the output should keep the original extent and grid; cropping to the exclusion layer’s bounds would be exactly backwards.

Parameter reference

Parameter Type Default Recommended Effect
all_touched bool False False for measurement Includes every pixel the boundary crosses when True
crop bool False True when clipping Shrinks the output to the geometry’s bounding window
invert bool False True to exclude Masks inside the geometry instead of outside
filled bool True True Returns a plain array with NoData written in
nodata float source nodata explicit, never 0 0 is a valid height, reflectance and index value
pad bool False False Adds half a pixel to the crop window; changes the grid
indexes int or list all bands the bands you need Avoids reading and masking bands you will discard

Expected output and verification

The masked raster’s pixel count should reconstruct the polygon area to within a boundary pixel or so. That comparison is the check worth automating, because it catches CRS mismatches, all_touched surprises, and NoData confusion in one number.

import geopandas as gpd
import numpy as np
import rasterio


def verify_mask(clipped_path: str, stands_path: str, equal_area_epsg: int = 3035) -> dict:
    """Compare masked pixel area against the polygon area from an equal-area CRS."""
    stands = gpd.read_file(stands_path)
    poly_ha = stands.to_crs(epsg=equal_area_epsg).area.sum() / 10_000.0

    with rasterio.open(clipped_path) as src:
        arr = src.read(1, masked=True)
        cell_m2 = abs(src.transform.a) * abs(src.transform.e)
    valid_px = int((~arr.mask).sum()) if np.ma.isMaskedArray(arr) else int(arr.size)
    raster_ha = valid_px * cell_m2 / 10_000.0

    return {
        "polygon_ha": round(poly_ha, 2),
        "raster_ha": round(raster_ha, 2),
        "diff_pct": round(100.0 * (raster_ha - poly_ha) / poly_ha, 2),
        "valid_px": valid_px,
    }


report = verify_mask("chm_stands.tif", "stands.gpkg")
assert abs(report["diff_pct"]) < 5.0, f"masked area disagrees with the polygons: {report}"

A difference of a percent or two is the boundary discretisation and is expected. A difference of 30% or more means all_touched is not what you thought, or the raster is in a different CRS than the polygons were reprojected to. A difference near 100% means the mask returned nothing and the geometries do not overlap the raster at all.

Masking discrepancy against stand shape Four bars show the percentage by which all-touched masking exceeds centre-based masking for four stand shapes of the same nominal area: a compact block, a rectangular block, an irregular stand, and a narrow riparian strip. The discrepancy grows from about two percent for the compact block to over forty percent for the riparian strip, because the strip is almost entirely boundary. compact block rectangular block irregular stand riparian strip, 30 m 2% 5% 11% 43% 0 12 24 36 48 all_touched area minus centre-based area (%)

Common pitfalls

  • Passing geometries in the wrong CRS. mask does not reproject. Mismatched CRSs usually raise “Input shapes do not overlap raster”, but when the two happen to overlap numerically you get a plausible clip of the wrong ground.
  • Leaving nodata at 0. Zero is a real canopy height, a real reflectance, and a real NDVI. Use a sentinel outside the data range and set it in the profile.
  • crop=True when excluding. Inverting to remove lakes and cropping at the same time returns a raster the size of the lakes.
  • Assuming the clip preserves the grid. With crop=True the origin moves to the geometry’s bounding window. That is fine, unless you are stacking several clips that must align — in which case crop them all to one common window instead.
  • Masking band by band in a loop. mask handles all bands at once and reads the window once; looping multiplies the I/O.
Per-stand windows against one shared window On the left, three stands are each cropped to their own bounding window, so the three output rasters have different origins and cannot be stacked without resampling. On the right, the same three stands are clipped to a single shared window computed from their union, so all three outputs share an origin and grid and stack directly into a multi-band array. per-stand windows one shared window three origins, three grids one origin, one grid, stacks directly Decide the grid once and every later comparison is free; decide it per stand and every one costs a resample.

Frequently Asked Questions

Should I dissolve the stand polygons before masking?

If you want one raster covering all stands, yes — passing many overlapping geometries makes mask compute a union anyway, and dissolving first is clearer and faster. If you want one raster per stand, loop over the rows and keep each geometry separate.

How do I keep several clipped rasters on the same grid?

Compute one common window from the union of the geometries and pass the same crop=False output extent to each, or clip with crop=True and then align them afterwards. The first is far less error-prone: the grid is decided once, and every output shares it by construction.

Can I get fractional pixel coverage instead of a binary mask?

Not from rasterio.mask, which is binary by design. rasterstats.zonal_stats does support fractional weighting, and for a small stand relative to the cell size that is materially more accurate than either all_touched setting — it is the right tool when the polygons are close to pixel-sized.

Up: Raster-Vector Overlay Techniques · Ecological GIS Data Foundations in Python