Resampling Sentinel-2 20m Bands to 10m

Sentinel-2 delivers B05, B11, and B12 at 20 m and B04/B08 at 10 m, so before you can compute an index across those bands they must share one grid. Open a native 10 m band as the template and reproject_match each 20 m band onto it with bilinear resampling:

import rioxarray
from rasterio.enums import Resampling

ref_10m = rioxarray.open_rasterio("B04.tif")     # native 10 m reference grid
swir_20m = rioxarray.open_rasterio("B11.tif")    # 20 m band to upsample

swir_10m = swir_20m.rio.reproject_match(
    ref_10m, resampling=Resampling.bilinear,
)
# swir_10m now shares B04's CRS, transform, width, and height exactly

This page sits within Handling Pixel Resolution and Scaling and covers the specific case of aligning Sentinel-2’s mixed 10 m / 20 m bands so downstream index math is pixel-for-pixel correct.


Why This Arises in Remote Sensing Workflows

Sentinel-2’s Multispectral Instrument samples different bands at different ground sampling distances: the visible and near-infrared bands (B02, B03, B04, B08) at 10 m; the red-edge and shortwave-infrared bands (B05, B06, B07, B8A, B11, B12) at 20 m; and atmospheric bands at 60 m. Any index that crosses those groups — NDMI from B08 and B11, a red-edge NDVI from B08 and B05, or a burn ratio from B08 and B12 — needs the operands on an identical grid.

If you simply load a 10 m band (shape 10980×10980) and a 20 m band (shape 5490×5490) and try to subtract them, the arrays do not broadcast and the operation fails. Force them to the same shape carelessly and you get a subtler bug: the pixel origins are offset by half a pixel, so band A’s pixel (0,0) does not cover the same ground as band B’s pixel (0,0). Per-pixel index values are then computed from mismatched footprints and are quietly wrong.

The correct fix is grid snapping: pick one native 10 m band as the reference and resample every coarser band to its exact transform. This is the alignment prerequisite that feeds spectral index calculation and any multi-band stacking. For the general theory of when to use each interpolation kernel, see Choosing the Right Resampling Method for Sentinel-2 in the Advanced Resampling & Upscaling Techniques cluster. For the broader context, see Core Raster Fundamentals & STAC Mapping.


Grid Snapping: 20 m Onto the 10 m Reference

The diagram shows why reproject_match matters: it aligns the coarse grid to the fine one so cell edges coincide, rather than floating on an offset origin.

Snapping a Sentinel-2 20 m band onto the 10 m reference grid On the left, a 20 metre grid of large cells overlaps a 10 metre grid of small cells with an offset origin, producing misaligned edges. reproject_match resamples the 20 m band so its cell boundaries coincide with the 10 m grid on the right, aligning every band to one shared transform. Before: offset grids 20 m edges (blue) miss 10 m lines reproject_ match After: snapped grid 20 m edges land on 10 m grid lines

Bilinear interpolation is the right kernel for continuous reflectance: each output 10 m value is a distance-weighted blend of the four nearest 20 m samples, which preserves the smooth radiometric surface. Nearest-neighbour would replicate whole 20 m blocks and reintroduce the coarse look; it belongs only on categorical layers such as the SCL mask, where blending class codes is meaningless.

Band group Native GSD Example bands Resampling to 10 m
Visible / NIR 10 m B02, B03, B04, B08 none — these define the reference grid
Red-edge / SWIR 20 m B05, B06, B07, B8A, B11, B12 Resampling.bilinear (continuous reflectance)
Scene Classification 20 m SCL Resampling.nearest (categorical codes)
Atmospheric 60 m B01, B09, B10 Resampling.bilinear, but expect heavy smoothing

The 60 m atmospheric bands can be upsampled the same way, but a 6× jump interpolates across a very coarse signal, so treat those results as approximate context rather than per-pixel truth.


Environment & Setup

Package Minimum version Why required
rioxarray 0.13.0 rio.reproject_match and CRS-aware DataArrays
xarray 2023.1 N-dimensional labelled arrays underlying rioxarray
rasterio 1.3.0 Resampling enum and the GDAL warp backend
pip install "rioxarray>=0.13.0" "rasterio>=1.3.0"

Quick check that the two grids differ before you align them:

import rioxarray

b04 = rioxarray.open_rasterio("B04.tif")   # 10 m
b11 = rioxarray.open_rasterio("B11.tif")   # 20 m
print("B04:", b04.rio.resolution(), b04.shape)
print("B11:", b11.rio.resolution(), b11.shape)  # coarser cells, smaller shape

Complete Working Example

This script aligns a set of 20 m bands to a 10 m reference, stacks them, and asserts pixel-perfect alignment before returning the cube.

What each resampling method does to one coarse pixel Upsampling a 20 metre band to 10 metres turns every source pixel into four. Nearest neighbour copies the parent value into all four, preserving the exact class codes a mask needs. Bilinear weights the four surrounding source centres, producing smooth transitions that suit reflectance but destroy categorical values. Same source pixel, two methods source, 20 m 4 SCL class 4 one pixel nearest — class preserved 4 4 4 4 still a valid class map bilinear — values invented 4.3 5.1 4.8 6.2 class 5 and 6 never existed here Reflectance bands (B05, B11, B12): bilinear or cubic. Class layers (SCL, land cover): nearest, always. The average of two class codes is not a class.
import rioxarray
import xarray as xr
from rasterio.enums import Resampling


def align_s2_bands_to_10m(
    reference_10m_path: str,
    coarse_20m_paths: dict[str, str],
) -> xr.Dataset:
    """
    Upsample Sentinel-2 20 m bands onto a native 10 m reference grid.

    Parameters
    ----------
    reference_10m_path : str
        Path to a native 10 m band (B04 or B08) defining the target grid.
    coarse_20m_paths : dict[str, str]
        Mapping of band name -> path for each 20 m band to align.

    Returns
    -------
    xr.Dataset
        All bands on one 10 m grid, ready for index computation.
    """
    # The reference defines CRS, transform, width and height for everything.
    reference = rioxarray.open_rasterio(reference_10m_path, masked=True)

    aligned: dict[str, xr.DataArray] = {"reference": reference.squeeze()}

    for name, path in coarse_20m_paths.items():
        band_20m = rioxarray.open_rasterio(path, masked=True)
        # reproject_match copies the reference grid exactly onto the 20 m band.
        # bilinear = smooth interpolation appropriate for continuous reflectance.
        band_10m = band_20m.rio.reproject_match(
            reference, resampling=Resampling.bilinear,
        )
        aligned[name] = band_10m.squeeze()

    # Every array now shares one grid, so a Dataset merge is unambiguous.
    cube = xr.Dataset(aligned)

    # Alignment gate: shapes, transform and CRS must match the reference.
    ref_shape = reference.rio.shape
    ref_transform = reference.rio.transform()
    for name, arr in aligned.items():
        assert arr.rio.shape == ref_shape, f"{name} shape mismatch"
        assert arr.rio.transform() == ref_transform, f"{name} transform mismatch"
        assert arr.rio.crs == reference.rio.crs, f"{name} CRS mismatch"

    return cube


if __name__ == "__main__":
    cube = align_s2_bands_to_10m(
        reference_10m_path="B08.tif",           # 10 m NIR as the template
        coarse_20m_paths={"b05": "B05.tif",     # red-edge, 20 m
                          "b11": "B11.tif",     # SWIR-1, 20 m
                          "b12": "B12.tif"},    # SWIR-2, 20 m
    )
    # NDMI = (NIR - SWIR1) / (NIR + SWIR1), now valid pixel-for-pixel.
    nir, swir1 = cube["reference"], cube["b11"]
    ndmi = (nir - swir1) / (nir + swir1)
    print("NDMI grid:", ndmi.rio.shape, ndmi.rio.resolution())

Passing masked=True promotes the file’s nodata value to NaN, so bilinear interpolation does not smear a sentinel like 0 into valid pixels along scene edges.


Variant Patterns

1. Pure rasterio.warp — no xarray dependency

Grid snapping, and the half-pixel shift that breaks it Sentinel-2 20 metre and 10 metre grids share an origin, so each coarse pixel divides exactly into four fine pixels. If you derive the destination transform yourself and get the origin wrong by half a pixel, every upsampled value lands between fine pixels and the band is permanently offset against the rest of the stack. shared origin — exact 2:1 nesting origin off by 10 m — permanent shift each 20 m cell = exactly 4 whole 10 m cells every coarse cell straddles four partial fine cells rio.reproject_match copies the reference transform, which is why it is safer than composing a transform by hand.

When you work in rasterio arrays rather than DataArrays, reproject gives the same grid snapping by passing the reference band’s transform, crs, and shape as the destination:

import numpy as np
import rasterio
from rasterio.warp import reproject, Resampling

with rasterio.open("B04.tif") as ref:          # 10 m template
    dst_profile = ref.profile
    dst = np.empty((ref.height, ref.width), dtype="float32")

    with rasterio.open("B11.tif") as src:      # 20 m source
        reproject(
            source=rasterio.band(src, 1),
            destination=dst,
            src_transform=src.transform, src_crs=src.crs,
            dst_transform=ref.transform, dst_crs=ref.crs,
            resampling=Resampling.bilinear,
        )
# dst is now a 10 m array aligned to B04

2. Nearest-neighbour for the SCL mask

The Scene Classification Layer is a 20 m categorical band. Resample it to 10 m with Resampling.nearest so class codes stay valid — bilinear would produce fractional, meaningless codes:

import rioxarray
from rasterio.enums import Resampling

ref_10m = rioxarray.open_rasterio("B04.tif")
scl_20m = rioxarray.open_rasterio("SCL.tif")

scl_10m = scl_20m.rio.reproject_match(
    ref_10m, resampling=Resampling.nearest,   # preserve integer class codes
)

3. Aligning many bands at once from a STAC item

When bands arrive as asset hrefs from a catalog query, resolve the 10 m reference once and loop. Pair this with Optimizing Rasterio Window Reads for Memory Efficiency to avoid loading whole 10980×10980 tiles when you only need a subset:

import rioxarray
from rasterio.enums import Resampling

def load_aligned(reference_href: str, hrefs: list[str]):
    ref = rioxarray.open_rasterio(reference_href, masked=True)
    return [
        rioxarray.open_rasterio(h, masked=True)
        .rio.reproject_match(ref, resampling=Resampling.bilinear)
        for h in hrefs
    ]

Common Errors

ValueError: operands could not be broadcast together

You tried arithmetic on a 10 m array (e.g. 10980×10980) and a 20 m array (5490×5490) without aligning them. Run reproject_match on the coarse band against the 10 m reference first, then compute the index.

Index values look right but are spatially shifted

The grids were forced to the same shape without snapping origins, leaving a half-pixel offset. Use reproject_match with the native 10 m band as the template rather than resampling to a bare resolution=10, and assert transform equality afterwards.

Scene-edge pixels bloom into invalid values after bilinear

The nodata sentinel (often 0) was interpolated as if it were real reflectance. Open the coarse band with masked=True so nodata becomes NaN, or set the nodata value before resampling so GDAL excludes it from the interpolation.


Frequently Asked Questions

Q: Which resampling method should I use to upsample Sentinel-2 reflectance? Use bilinear for continuous reflectance bands. It interpolates smoothly between the four nearest 20 m pixels and avoids the blocky artefacts of nearest-neighbour. Reserve nearest for categorical layers such as the Scene Classification (SCL) mask, where interpolation would invent invalid class codes.

Q: Why use reproject_match instead of just setting a resolution? reproject_match copies the reference band’s exact CRS, transform, width, and height onto the target, so the output snaps pixel-for-pixel to the 10 m grid. Merely reprojecting to a 10 m resolution can leave a half-pixel origin offset that misaligns bands and corrupts per-pixel index math.

Q: Does upsampling 20 m bands to 10 m add real detail? No. Upsampling does not create new information; it only resamples the existing 20 m signal onto a finer grid so bands align. The effective spatial resolution of B05, B11, and B12 remains 20 m. Alignment is the goal, not sharpening.