Bilinear vs Nearest-Neighbour Resampling for Ecological Covariates
Every time you reproject or regrid an ecological covariate raster, GDAL has to invent values at the new pixel centres, and the interpolation rule you pass decides whether those values are physically meaningful or quietly corrupted. Pick Resampling.bilinear for a categorical land-cover grid and you manufacture class codes that never existed; pick Resampling.nearest for a continuous elevation surface and you inject blocky terracing that propagates into every slope, aspect, and index derived from it. This guide narrows in on that single choice — bilinear versus nearest-neighbour (and when cubic earns its keep) — for the covariate stacks that feed forest and habitat models. It sits inside the Coordinate Reference Systems for Forestry workflow, part of the broader Ecological GIS Data Foundations in Python framework for reproducible spatial analysis.
When to Use Each Resampling Method
The decision is driven almost entirely by one property of the raster: is the cell value a measurement on a continuous scale (temperature, elevation, reflectance, canopy height) or a label from a finite set of classes (land-cover type, soil group, sampling stratum)? Interpolating between two class codes — say, 41 (deciduous forest) and 52 (shrub) — to produce 46 is nonsense; there is no half-forest-half-shrub category. Averaging two elevations, by contrast, is exactly what you want.
| Method | Resampling enum |
Best for | Neighbourhood | Effect on values |
|---|---|---|---|---|
| Nearest-neighbour | Resampling.nearest |
Categorical: land-cover, strata, soil class, masks | 1 cell | Copies the closest source value; no new values invented |
| Bilinear | Resampling.bilinear |
Continuous: climate, elevation, spectral reflectance | 4 cells (2×2) | Distance-weighted average; smooths, slightly narrows range |
| Cubic convolution | Resampling.cubic |
Continuous where edges/gradients matter (DEM, canopy height) | 16 cells (4×4) | Sharper than bilinear; can overshoot beyond the input range |
| Mode (majority) | Resampling.mode |
Categorical downsampling to a coarser grid | window | Assigns the most frequent class in the window |
| Average | Resampling.average |
Continuous downsampling to a coarser grid | window | Mean of contributing cells; conserves the areal mean |
The two rows that cover the vast majority of covariate work are the first two. A rule of thumb: if you would ever call numpy.unique() on the raster and expect a short list of integer codes, use nearest; otherwise use bilinear. Cubic convolution is worth the extra cost only for continuous surfaces where you care about crisp gradients — a digital elevation model destined for terrain derivatives, or a canopy height model — and where a small out-of-range overshoot is acceptable or clamped afterwards.
Downsampling (going to a coarser pixel) is a special case: nearest and bilinear both throw away most of the contributing cells, so prefer mode for categorical and average for continuous when the target resolution is markedly coarser than the source. These continuous resampling choices flow directly into the predictor grids assembled in Environmental Predictor Stacking, where every layer must share one grid before modelling.
Minimal Reproducible Example
The snippet reprojects two covariates from their native CRS to a common analytical grid (EPSG:5070, CONUS Albers equal-area): a continuous mean-temperature raster with bilinear, and a categorical land-cover raster with nearest. Both use rasterio.warp.reproject driven by the Resampling enum, and a single helper enforces the method per raster type.
import numpy as np
import rasterio
from rasterio.warp import calculate_default_transform, reproject, Resampling
DST_CRS = "EPSG:5070" # NAD83 / CONUS Albers — equal-area, metres
def warp_raster(src_path: str, dst_path: str, resampling: Resampling) -> None:
"""Reproject a single-band raster to DST_CRS with an explicit resampling rule."""
with rasterio.open(src_path) as src:
transform, width, height = calculate_default_transform(
src.crs, DST_CRS, src.width, src.height, *src.bounds
)
meta = src.meta.copy()
meta.update(crs=DST_CRS, transform=transform, width=width, height=height)
with rasterio.open(dst_path, "w", **meta) as dst:
reproject(
source=rasterio.band(src, 1),
destination=rasterio.band(dst, 1),
src_transform=src.transform,
src_crs=src.crs,
dst_transform=transform,
dst_crs=DST_CRS,
resampling=resampling,
src_nodata=src.nodata,
dst_nodata=src.nodata,
)
# Continuous covariate: smooth interpolation is correct.
warp_raster("mean_temp.tif", "mean_temp_5070.tif", Resampling.bilinear)
# Categorical covariate: never interpolate class codes.
warp_raster("landcover.tif", "landcover_5070.tif", Resampling.nearest)
The only thing that changes between the two calls is the Resampling member. That is the whole point: the reprojection machinery is identical, and the correctness of the output hinges on matching the interpolation rule to the data semantics. For a stack of many layers, drive it from a small manifest so the method is recorded, not guessed:
COVARIATES = [
("mean_temp.tif", "mean_temp_5070.tif", Resampling.bilinear),
("elevation.tif", "elevation_5070.tif", Resampling.cubic),
("landcover.tif", "landcover_5070.tif", Resampling.nearest),
("soil_class.tif", "soil_class_5070.tif", Resampling.nearest),
]
for src_path, dst_path, method in COVARIATES:
warp_raster(src_path, dst_path, method)
Parameter Reference
These are the reproject arguments that change the ecological correctness of the output, not just its mechanics.
| Argument | Type | Default | Recommended | Ecological rationale |
|---|---|---|---|---|
resampling |
Resampling enum |
nearest |
bilinear continuous / nearest categorical |
The single decision that preserves or corrupts covariate semantics |
src_nodata |
number | inherited | set explicitly | Excludes fill from averaging so cloud/edge fill does not bleed into real cells |
dst_nodata |
number | src_nodata |
match source | Keeps masked regions masked instead of receiving an interpolated value |
num_threads |
int | 1 |
cores available | Speeds large climate/DEM mosaics; no effect on values |
dst_resolution |
float / tuple | native | match the target stack | Forces a shared pixel size so downstream layers align cell-for-cell |
warp_mem_limit |
int (MB) | 64 |
raise for big tiles | Prevents excessive re-tiling I/O on continental covariates |
A subtlety that bites forestry pipelines: if src_nodata is unset on a continuous raster, bilinear will happily average the nodata sentinel (often -9999) with valid neighbours along every masked edge, dragging border cells toward large negative values. Always declare nodata before warping.
Expected Output and Verification
The defining, checkable property of a correct categorical resample is that no new class codes appear: the set of output values must be a subset of the input values (plus nodata). For a continuous resample, the output range must stay within the input range for nearest and bilinear (cubic may slightly overshoot), and the smoothing must not shift the mean appreciably.
import numpy as np
import rasterio
def assert_categorical_preserved(src_path: str, dst_path: str) -> None:
"""A nearest resample must invent no new class codes."""
with rasterio.open(src_path) as s:
src_vals = set(np.unique(s.read(1)).tolist())
with rasterio.open(dst_path) as d:
dst_vals = set(np.unique(d.read(1)).tolist())
new_codes = dst_vals - src_vals
assert not new_codes, f"Resampling invented class codes: {sorted(new_codes)}"
def assert_continuous_bounded(src_path: str, dst_path: str, tol: float = 1e-3) -> None:
"""A bilinear resample must not exceed the source value range."""
with rasterio.open(src_path) as s:
a = s.read(1, masked=True)
with rasterio.open(dst_path) as d:
b = d.read(1, masked=True)
assert b.min() >= a.min() - tol, "Output dipped below source minimum"
assert b.max() <= a.max() + tol, "Output exceeded source maximum — used cubic?"
assert_categorical_preserved("landcover.tif", "landcover_5070.tif")
assert_continuous_bounded("mean_temp.tif", "mean_temp_5070.tif")
If assert_categorical_preserved fails, you almost certainly warped a class raster with bilinear. If a continuous check fails only on the maximum, you likely used cubic, whose convolution kernel can overshoot at sharp edges — clamp with np.clip to the physical range if that matters (for example, a reflectance layer bounded to [0, 1]). The verified continuous grids are then ready to feed a Vegetation Index Calculation in Python step, where any resampling artefact would propagate straight into NDVI or EVI.
Common Pitfalls
- Interpolating class codes. Warping land-cover, soil, or strata rasters with
bilinear/cubicblends integer codes into fractional values that map to the wrong — or nonexistent — class. Categorical always meansnearest(upsampling) ormode(downsampling). - Terracing a DEM with
nearest. Reprojecting continuous elevation or canopy height withnearestcopies the same value across blocks, creating stair-step artefacts that inflate slope and aspect at every block edge. Usebilinearorcubicfor terrain. - Nodata leaking into the average. With
src_nodataunset,bilinearaverages the fill sentinel into edge cells, producing a fringe of physically impossible values. Setsrc_nodataanddst_nodataexplicitly. - Resampling then stacking mismatched grids. Warping each covariate to the same CRS but not the same transform and resolution leaves layers one pixel offset; pin
dst_resolutionand align the transform so the stack is cell-for-cell coincident.
Frequently Asked Questions
Is cubic always better than bilinear for continuous data?
No. Cubic convolution samples a 4×4 neighbourhood and preserves sharp gradients better, which helps for DEMs and canopy height models, but its kernel can overshoot beyond the true value range at edges — producing, for example, negative reflectance. Bilinear is smoother, cheaper, and stays within the input range. Use cubic when edge fidelity matters and clamp the output; otherwise bilinear is the safe default.
Which method should I use when downsampling to a coarser grid?
Neither nearest nor bilinear is ideal for large downsampling because both ignore most contributing cells. Use Resampling.average for continuous data to conserve the areal mean, and Resampling.mode for categorical data to assign the majority class within each output cell’s footprint.
Related
- Coordinate Reference Systems for Forestry — the parent workflow where reprojection and resampling choices are set.
- Vegetation Index Calculation in Python — spectral indices whose inputs must be resampled without artefacts.
- Environmental Predictor Stacking — assembling many resampled covariates onto one shared grid.
- Ecological GIS Data Foundations in Python — the reproducible-pipeline framework this guide belongs to.