Merging Shapefiles and Rasters in Python with rasterio.features.rasterize and sample_gen
Operationalizing spatial data integration requires a clear conceptual shift: in a forestry workflow the term “merge” rarely denotes direct concatenation. Instead, it describes a controlled overlay where polygon boundaries are rasterized to a common grid, raster values are sampled into vector attributes, or gridded surfaces are clipped to a vector extent. Vectors and rasters inhabit fundamentally different mathematical domains — continuous Euclidean geometry versus a discrete matrix indexed by an affine transform. This page covers the narrow, high-frequency sub-task of binding a stand-boundary shapefile to a LiDAR-derived raster (canopy height, a vegetation index, or a terrain layer) without corrupting topology or measurement units. It sits within the Raster-Vector Overlay Techniques methodology, which in turn is part of the broader Ecological GIS Data Foundations in Python framework for reproducible conservation analysis.
When to Use Each Overlay Strategy
There is no single “merge” operation — the correct tool depends on whether your output should be a raster, an enriched vector, or a clipped subset. Choose deliberately before writing code, because the wrong strategy silently produces statistically invalid results rather than an error.
| Goal | Strategy | Tool | Output |
|---|---|---|---|
| Burn stand IDs / class labels onto the raster grid | Rasterize vector → raster | rasterio.features.rasterize() |
Integer/float raster aligned to the source grid |
| Attach a single pixel value to each plot | Point sampling at centroids | rasterio.sample.sample_gen |
Vector with a new attribute column |
| Aggregate pixels within each polygon (mean, percentile) | Zonal statistics | rasterstats.zonal_stats() |
Vector with per-polygon summary columns |
| Restrict a raster to a polygon extent | Clip / mask | rasterio.mask.mask() |
Cropped raster + updated transform |
If you only need to clip a raster to a boundary, do not rasterize — use mask(). If you need a per-tree or per-plot value, sampling at centroids is cheap; if you need a distribution across each stand, you must aggregate with zonal statistics. The CRS alignment step below is a non-negotiable prerequisite for all four strategies, and depends on the same projection discipline covered in how to fix CRS mismatches in geopandas.
Coordinate Reference System Alignment
The most frequent failure point in overlay workflows is unaligned coordinate systems. When a forest inventory plot shapefile uses EPSG:32615 (WGS 84 / UTM zone 15N) and a LiDAR-derived canopy height raster uses EPSG:4269 (NAD83 geographic), direct overlay either silently produces NaN-filled output or raises rasterio.errors.CRSError. Before any spatial intersection, transform both datasets to a single projected system whose linear units (metres) preserve area for ecological metrics.
Reproject the vector with to_crs() and warp the raster with an explicit transformation pipeline:
import geopandas as gpd
import rasterio
from rasterio.warp import calculate_default_transform, reproject, Resampling
target_crs = "EPSG:32615" # UTM Zone 15N for area-preserving metrics
# Vector CRS alignment
plots_gdf = gpd.read_file("inventory_plots.shp").to_crs(target_crs)
# Raster CRS alignment
with rasterio.open("canopy_height.tif") as src:
transform, width, height = calculate_default_transform(
src.crs, target_crs, src.width, src.height, *src.bounds
)
kwargs = src.meta.copy()
kwargs.update({
"crs": target_crs,
"transform": transform,
"width": width,
"height": height,
})
with rasterio.open("canopy_height_aligned.tif", "w", **kwargs) as dst:
for i in range(1, src.count + 1):
reproject(
source=rasterio.band(src, i),
destination=rasterio.band(dst, i),
src_transform=src.transform,
src_crs=src.crs,
dst_transform=transform,
dst_crs=target_crs,
resampling=Resampling.bilinear,
)
Use Resampling.bilinear for continuous surfaces (height, NDVI) and Resampling.nearest for categorical rasters (land-cover classes) so resampling never invents fractional class codes. Official reprojection guidance is maintained in the GeoPandas CRS documentation.
Minimal Reproducible Example
The complete, copy-pasteable workflow below rasterizes stand polygons onto the aligned raster grid, then samples the raster back into each plot’s attributes. Both halves assume plots_gdf and the raster already share target_crs from the step above.
import numpy as np
import rasterio
import geopandas as gpd
from rasterio.features import rasterize
from rasterio.sample import sample_gen
ALIGNED = "canopy_height_aligned.tif"
# --- 1. Rasterize stand boundaries onto the source grid -------------------
with rasterio.open(ALIGNED) as src:
out_shape = src.shape # (rows, cols)
out_transform = src.transform # affine mapping pixel <-> CRS coords
nodata = src.nodata
shapes = ((geom, val) for geom, val in
zip(plots_gdf.geometry, plots_gdf["stand_id"]))
stand_grid = rasterize(
shapes=shapes,
out_shape=out_shape,
transform=out_transform,
fill=0,
all_touched=True, # keep thin riparian / boundary pixels
dtype="int32",
)
# --- 2. Sample the raster value at each plot centroid ---------------------
def sample_raster_at_centroids(raster_path, vector_gdf, column="sampled_value"):
gdf = vector_gdf.copy()
coords = [(pt.x, pt.y) for pt in gdf.geometry.centroid]
with rasterio.open(raster_path) as src:
if src.crs.to_epsg() != gdf.crs.to_epsg():
raise ValueError("Vector and raster CRS differ — reproject first.")
sampled = list(sample_gen(src, coords))
nodata = src.nodata
gdf[column] = [
float(v[0]) if (v[0] != nodata and np.isfinite(v[0])) else np.nan
for v in sampled
]
return gdf
plots_enriched = sample_raster_at_centroids(ALIGNED, plots_gdf, "canopy_ht_m")
sample_gen streams (x, y) coordinate tuples and reads only the pixels it needs, so it never loads the full raster into RAM — the correct choice for per-plot extraction across a regional inventory. For polygon-level distributions (mean, 95th-percentile height) rather than a single centroid value, swap in rasterstats.zonal_stats(), which performs windowed I/O internally.
Parameter Reference
The defaults of rasterize() and sample_gen are tuned for general use, not for narrow ecological boundaries. The values below are the ones that matter when stand edges, riparian buffers, or nodata gaps must be preserved exactly.
| Parameter | Type | Default | Recommended | Ecological rationale |
|---|---|---|---|---|
all_touched |
bool | False |
True for thin features |
False only burns pixels whose centre falls inside a polygon, dropping narrow riparian corridors and stand slivers entirely |
fill |
scalar | 0 |
0 or src.nodata |
Background value for unburned pixels; reuse the raster’s nodata so empty area is masked, not treated as stand 0 |
dtype |
str | float64 |
match source (int32 for IDs, float32 for indices) |
A float32 index silently truncates if the output array is int32; an ID field overflows int8 |
out_shape / transform |
tuple / Affine | — | always pass src.shape + src.transform |
Guarantees identical grid topology; mismatched values raise shapes not aligned |
resampling (warp) |
enum | nearest |
bilinear for continuous, nearest for categorical |
Bilinear smooths height/NDVI; nearest preserves discrete class codes |
merge_alg |
enum | MergeAlg.replace |
MergeAlg.add for overlap counts |
replace lets later polygons overwrite earlier ones; add accumulates where stands overlap |
Expected Output and Verification
A correct rasterization yields an array of out_shape whose unique values are exactly the stand_id set plus the fill value, with non-background pixels covering the union of the polygons. A correct sampling pass yields one finite value per plot, with NaN only where a centroid falls on nodata. Assert these invariants before trusting downstream metrics:
# Grid topology matches the source raster exactly
assert stand_grid.shape == out_shape
assert set(np.unique(stand_grid)) <= set(plots_gdf["stand_id"]).union({0})
# Burned area is plausible (non-empty, not the whole scene)
burned = np.count_nonzero(stand_grid != 0)
assert 0 < burned < stand_grid.size
# Every plot received a value; flag suspicious nodata hits
n_nan = plots_enriched["canopy_ht_m"].isna().sum()
print(f"{n_nan} of {len(plots_enriched)} plots sampled nodata")
assert plots_enriched["canopy_ht_m"].dropna().between(0, 80).all() # m
For a visual check, write stand_grid to a GeoTIFF with the source profile and overlay it on the original raster in QGIS — the burned polygons must register pixel-for-pixel with the underlying surface. The 0–80 m bound on canopy height is a sanity gate; tighten it to your forest type to catch unit errors (feet vs. metres) early.
Common Pitfalls
- CRS mismatch read as empty output. When the vector and raster disagree on projection, sampling returns
nodatafor every centroid and rasterization burns nothing — verifygdf.total_boundsoverlapssrc.boundsbefore blaming the data. ValueError: shapes not aligned. Theout_shapeyou passed does not match the affinetransform. Always read bothsrc.shapeandsrc.transformfrom the same opened raster and pass them together.- Silent dtype truncation. Rasterizing a
float32vegetation index into the defaultint32floor every value to an integer with no warning. Setdtypeexplicitly to match the source band. - Nodata counted as a real value.
sample_genreturns the raster’s rawnodatasentinel (often-9999or0); compare each sample againstsrc.nodataand convert tonp.nanso means and percentiles are not poisoned.
When integrating these outputs into conservation policy mapping, confirm the merged dataset preserves original measurement units and topological integrity, so downstream work — such as the canopy and biomass metrics built on Raster-Vector Overlay Techniques — stays audit-ready.
Related
- Raster-Vector Overlay Techniques — parent guide to integrating gridded surfaces with vector boundaries.
- How to Fix CRS Mismatches in geopandas — resolve the projection errors that break every overlay.
- Calculating NDVI from Sentinel-2 with rasterio — produce the continuous raster you will sample into plots.
- Stratified Random Sampling for Forest Plots — design the plot shapefile that drives the extraction.
- Ecological GIS Data Foundations in Python — the full reproducible-pipeline framework this workflow belongs to.