Clipping Rasters to Field Boundaries with rasterio.mask
TL;DR: Reproject the field-boundary polygon into the raster’s CRS first (a mismatch silently yields an all-nodata output), pass the geometries to rasterio.mask.mask with crop=True and an explicit nodata, write the returned out_transform into the profile, and save a Cloud-Optimized GeoTIFF — then assert the result actually contains valid pixels.
Why Clipping to a Boundary Arises
An orthomosaic or a derived index raster covers the whole flight block: the target field plus headlands, roads, treelines, and the neighbour’s crop. Every downstream statistic — mean NDVI, canopy cover, a yield-zone histogram — is contaminated the moment it includes pixels outside the field. Clipping to the boundary polygon is the step that turns a flight-block raster into a field-specific dataset, and doing it correctly is what makes a zonal mean trustworthy rather than diluted by whatever surrounds the field.
rasterio.mask.mask is the workhorse, but it has one failure mode that catches almost everyone: it does no CRS reconciliation. Hand it a polygon in EPSG:4326 (degrees) and a raster in EPSG:32615 (metres) and it does not raise — the polygon’s tiny decimal-degree coordinates fall nowhere near the metre-scale raster, the intersection is empty, and you get a raster full of nodata with no error. Aligning coordinate systems up front is therefore non-negotiable; if the concept is unfamiliar, read understanding CRS in precision agriculture before running this. The boundary polygon itself typically comes out of field boundary extraction with GeoPandas.
The diagram below shows the four gates every clip passes through.
Prerequisites
Only what differs from the Field Boundary Extraction with GeoPandas guide:
rasterio==1.3.10
geopandas==0.14.4
numpy==1.26.4
Install with:
pip install rasterio==1.3.10 geopandas==0.14.4 numpy==1.26.4
Input requirements:
- A raster (orthomosaic or single-band index such as an NDVI GeoTIFF) with a defined CRS and a set
nodatavalue. - A field-boundary polygon as a GeoPackage, GeoParquet, or shapefile readable by GeoPandas — a
PolygonorMultiPolygon, valid geometry, any CRS (the script reprojects it). - The two extents must genuinely overlap in the raster CRS; a boundary for the wrong field produces a legitimately empty clip that the final assert will reject.
Step-by-Step
Step 1 — Load both and align the CRS
Open the raster to read its CRS, then reproject the boundary GeoDataFrame into that exact CRS with to_crs. Never assume they match, even when both files “came from the same project” — a boundary digitised in QGIS often lands in EPSG:4326 while the mosaic is in a UTM zone. This single reprojection is what prevents the silent all-nodata output.
Step 2 — Extract geometries for the mask
rasterio.mask.mask wants an iterable of GeoJSON-like geometry mappings, not a GeoDataFrame. Dissolve multiple rows into one if the field is split across features, then pass gdf.geometry (rasterio accepts Shapely objects directly in 1.3). Guard against empty or invalid geometry here rather than debugging a blank raster later.
Step 3 — Run the mask with crop and nodata
Call mask(src, geoms, crop=True, nodata=<value>, all_touched=False, filled=True). crop=True trims the output to the polygon’s bounding box and returns a fresh out_transform. filled=True replaces outside pixels with the nodata value; filled=False instead returns a masked array if you want to keep computing before writing. Leave all_touched=False for area-accurate zonal statistics; flip it on only when you must retain every pixel the polygon grazes.
Step 4 — Update the profile with the clipped transform
The clipped array has new dimensions and a new origin, so the source profile is stale. Copy it and overwrite height, width, transform (from out_transform), and nodata. Skipping this is why some clipped rasters open shifted or squashed — the pixels are right but the georeferencing points at the old window.
Step 5 — Write a Cloud-Optimized GeoTIFF and verify
Add tiling and internal overviews so the output streams efficiently. The complete, directly runnable script:
import numpy as np
import geopandas as gpd
import rasterio
from rasterio.mask import mask
from rasterio.enums import Resampling
RASTER_PATH = "ndvi_flight_block.tif"
BOUNDARY_PATH = "field_boundary.gpkg"
OUT_PATH = "ndvi_field_clip.tif"
NODATA = -9999.0
ALL_TOUCHED = False # True keeps every pixel the polygon touches
with rasterio.open(RASTER_PATH) as src:
raster_crs = src.crs
assert raster_crs is not None, "Source raster has no CRS — cannot align the mask"
# ── 1. Load boundary and align CRS to the raster (prevents empty output) ──
gdf = gpd.read_file(BOUNDARY_PATH)
assert gdf.crs is not None, "Boundary has no CRS — set it before reprojecting"
if gdf.crs != raster_crs:
gdf = gdf.to_crs(raster_crs)
# Dissolve multi-feature boundaries into one geometry set; drop invalids.
gdf = gdf[gdf.geometry.notna() & gdf.geometry.is_valid]
assert not gdf.empty, "No valid boundary geometry after cleaning"
# Fail fast if the extents do not overlap (wrong field / wrong CRS).
rb = src.bounds
minx, miny, maxx, maxy = gdf.total_bounds
overlaps = (minx < rb.right and maxx > rb.left and
miny < rb.top and maxy > rb.bottom)
assert overlaps, "Boundary and raster extents do not intersect in the raster CRS"
geoms = list(gdf.geometry)
# ── 2 & 3. Clip: crop to bbox, fill outside with nodata ──────────────────
out_image, out_transform = mask(
src,
geoms,
crop=True,
nodata=NODATA,
all_touched=ALL_TOUCHED,
filled=True,
)
# ── 4. Update the profile with the new window ────────────────────────────
profile = src.profile.copy()
profile.update(
height=out_image.shape[1],
width=out_image.shape[2],
transform=out_transform,
nodata=NODATA,
driver="GTiff",
tiled=True,
blockxsize=512,
blockysize=512,
compress="deflate",
)
# ── 5. Write a Cloud-Optimized GeoTIFF (tiled + internal overviews) ──────────
with rasterio.open(OUT_PATH, "w", **profile) as dst:
dst.write(out_image)
dst.build_overviews([2, 4, 8, 16], Resampling.average)
dst.update_tags(ns="rio_overview", resampling="average")
# ── Verification: the clip must contain valid (non-nodata) pixels ───────────
with rasterio.open(OUT_PATH) as chk:
band1 = chk.read(1, masked=True)
valid = int(band1.count()) # count of unmasked (valid) pixels
total = band1.size
print(f"Clipped: {chk.width}×{chk.height} px, {valid}/{total} valid "
f"({100 * valid / total:.1f}% inside boundary)")
assert valid > 0, "Clip produced zero valid pixels — check CRS alignment and extent"
assert chk.crs == raster_crs, "Output CRS drifted from the source raster"
print(f"OK: wrote {OUT_PATH}")
Inline verification: the assert valid > 0 above is the load-bearing check — an all-nodata result (the classic CRS-mismatch symptom) fails here instead of silently propagating into your zonal statistics. The reported percentage of valid pixels also flags a boundary that only partly overlaps the raster.
Gotchas and Edge Cases
- Silent empty output from a CRS mismatch. If you skip the
to_crsreproject in Step 1,maskreturns a full-nodata array with no exception. The extent-overlap assert and the finalvalid > 0assert both catch it, but the root fix is always aligning the polygon to the raster CRS first — never the other way around, since reprojecting the raster resamples pixels.
-
crop=Falseleaves a giant sparse raster. Withoutcrop=True, a 2-hectare field clipped from a 200-hectare mosaic keeps the full mosaic dimensions with nearly everything set to nodata — huge on disk and slow to read. Usecrop=Trueand remember it hands back a newout_transformyou must write into the profile. -
all_touched=Trueinflates area statistics. It keeps every pixel the polygon so much as grazes, adding up to half a pixel of border all the way around. For a mean-NDVI or a total-canopy-area number that shifts the result; keep the default centre-in-polygon rule for statistics and reserveall_touched=Truefor preserving thin features you cannot afford to drop. -
Forgetting nodata on the output profile. If the source had no
nodataand you do not set one, the filled border pixels become a real data value (often 0), which then counts as valid NDVI of zero and drags your mean down. Always set an explicitnodataboth in themaskcall and in the written profile.
Frequently Asked Questions
Why does rasterio.mask return an all-nodata raster?
Almost always a CRS mismatch. If the boundary polygon and the raster are in different coordinate systems, their coordinates do not overlap in space, so the mask geometry falls entirely outside the raster and every pixel is filled with nodata. Reproject the polygon into the raster CRS before calling mask, and confirm the two extents actually intersect.
What does all_touched do when clipping to a field boundary?
By default a pixel is kept only if the polygon covers its centre, which shaves a thin rim of edge pixels. Setting all_touched to true keeps every pixel the polygon touches at all, growing the retained area by up to half a pixel around the perimeter. Keep the default for area-accurate statistics and enable all_touched when you must not drop any pixel along a narrow feature.
Do I need crop set to true when masking?
Not strictly, but you almost always want it. With crop set to false the output keeps the full raster dimensions and only replaces outside pixels with nodata, wasting space on a small field in a large mosaic. With crop set to true rasterio trims the array to the polygon bounding box and returns a new transform, which you must write into the output profile so the clipped raster stays georeferenced.
Parent Guide
This guide is part of Field Boundary Extraction with GeoPandas — see there for the full pipeline that produces and validates the boundary polygon this clip consumes.
Related
- Field Boundary Extraction with GeoPandas — how the boundary polygon that drives this clip is extracted and cleaned
- Understanding CRS in Precision Agriculture — the CRS-alignment prerequisite that prevents a silent all-nodata clip
- Georeferencing Orthomosaics with Ground Control Points — accurately seat the mosaic in world coordinates before you clip it to a field
- Validating Coordinate Systems for Variable-Rate Maps — confirm the clipped raster’s CRS is controller-ready before downstream export