Computing Band Ratios with Dask-Backed DataArrays
Open each band with identical chunks, write the ratio as ordinary arithmetic, and let the write drive the computation:
import rioxarray
chunks = {"x": 2048, "y": 2048}
red = rioxarray.open_rasterio("B04.tif", chunks=chunks, mask_and_scale=True).squeeze("band")
nir = rioxarray.open_rasterio("B08.tif", chunks=chunks, mask_and_scale=True).squeeze("band")
ndvi = (nir - red) / (nir + red) # a graph, not an array
ndvi.rio.to_raster("ndvi.tif", tiled=True, lock=False, compress="zstd")
Nothing is read until the write starts, and at no point does the whole scene sit in memory. This page belongs to band math operations with xarray in Core Raster Fundamentals & STAC Mapping.
What Dask Is Doing Underneath
Because no chunk depends on any other, the graph is as parallel as it can be and the memory high-water mark is set by the number of chunks in flight, not by the scene size. That is the property that lets the same three lines run on a laptop for one tile and on a cluster for a continent.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
rioxarray |
>=0.15 |
Chunked, masked-and-scaled reads and lock-free writes |
xarray |
>=2024.1 |
Lazy labelled arithmetic |
dask[array] |
>=2024.1 |
The chunked execution engine |
distributed |
>=2024.1 |
Optional: a dashboard and a local cluster |
pip install "rioxarray>=0.15" "xarray>=2024.1" "dask[array]>=2024.1" "distributed>=2024.1"
Complete Working Example
import rioxarray
import xarray as xr
from dask.distributed import Client, LocalCluster
def open_band(path: str, chunk: int = 2048) -> xr.DataArray:
da = rioxarray.open_rasterio(path, chunks={"x": chunk, "y": chunk},
mask_and_scale=True, lock=False)
return da.squeeze("band", drop=True)
def ratio_to_cog(a_path: str, b_path: str, out_path: str, *,
chunk: int = 2048) -> None:
"""(a - b) / (a + b) for two single-band rasters, streamed to a tiled GeoTIFF."""
a, b = open_band(a_path, chunk), open_band(b_path, chunk)
if a.rio.transform() != b.rio.transform() or a.shape != b.shape:
b = b.rio.reproject_match(a) # align grids first
b = b.chunk(a.chunks) # identical chunk boundaries
den = a + b
ratio = ((a - b) / den).where(den != 0) # zero denominator -> NaN
ratio = ratio.astype("float32").rio.write_nodata(float("nan"))
ratio.rio.to_raster(
out_path, tiled=True, blockxsize=512, blockysize=512,
compress="zstd", predictor=3, lock=False, # lock=False: parallel writes
BIGTIFF="IF_SAFER",
)
if __name__ == "__main__":
with LocalCluster(n_workers=4, threads_per_worker=2, memory_limit="4GB") as cluster, \
Client(cluster) as client:
print("dashboard:", client.dashboard_link)
ratio_to_cog("S2A_36NYF_B08.tif", "S2A_36NYF_B04.tif", "ndvi.tif")
lock=False on both read and write matters for throughput: by default rioxarray serialises GDAL access through a lock, which is safe and slow. With independent chunks and a tiled output, parallel access is safe, and removing the lock lets every worker read and write at once. The mechanics of the cluster itself are covered in scaling raster processing with Dask.
Chunk Alignment Across Bands
Misalignment usually arrives through a resampling step: a 20 m band upsampled to 10 m keeps its own chunking, which no longer matches the native 10 m band. Dask handles it correctly, by inserting a rechunk that moves data between workers, but the cost shows up as memory spikes and slow progress in the dashboard. Calling b.chunk(a.chunks) once, explicitly, makes the cost visible and pays it only once. The deeper treatment of chunk choice is in tuning Dask chunk sizes for raster cubes.
Computing Several Ratios at Once
When a pipeline needs NDVI, NDWI and NDMI from the same bands, computing them separately reads every band three times. Building all three into one graph and writing them together lets Dask share the reads:
import dask
import xarray as xr
b03, b04, b08, b11 = (open_band(p) for p in ("B03.tif", "B04.tif", "B08.tif", "B11.tif"))
b11 = b11.rio.reproject_match(b08).chunk(b08.chunks)
def nd(x, y):
den = x + y
return ((x - y) / den).where(den != 0).astype("float32")
stack = xr.Dataset({"ndvi": nd(b08, b04), "ndwi": nd(b03, b08), "ndmi": nd(b08, b11)})
dask.compute(*[stack[v].rio.to_raster(f"{v}.tif", tiled=True, lock=False,
compress="zstd", compute=False)
for v in stack.data_vars])
Passing compute=False to each write returns a delayed object, and computing them together in one call means each band chunk is read once and consumed by all three indices. On remote data, where reads dominate, that alone can make a multi-index job two to three times faster.
Verification
import dask.array as da
import numpy as np
import rasterio
assert isinstance(ratio.data, da.Array), "result was computed eagerly"
assert ratio.chunks == a.chunks, "chunks drifted from the inputs"
with rasterio.open("B08.tif") as n, rasterio.open("B04.tif") as r, \
rasterio.open("ndvi.tif") as out:
win = ((1000, 1256), (1000, 1256))
nv = n.read(1, window=win).astype("float32") * 1e-4
rv = r.read(1, window=win).astype("float32") * 1e-4
eager = (nv - rv) / (nv + rv)
lazy = out.read(1, window=win)
assert np.allclose(eager, lazy, equal_nan=True, atol=1e-6)
The eager comparison on one window is cheap and catches the bugs that laziness hides: a mask applied in one path and not the other, or a grid mismatch that the lazy path resolved differently from the eager one.
Common Errors
Memory climbs until workers are killed
A .compute() or .values materialised the full result. Write directly with to_raster, which streams chunk by chunk.
The write is no faster than a single thread
The default GDAL lock serialised every access. Pass lock=False to reads and writes on tiled files.
Every task shows a rechunk in the dashboard
Band chunks differ. Rechunk one band to the other’s chunks once, before the arithmetic.
Output has seams at chunk boundaries
A neighbourhood operation crept into the graph without overlap. Pure ratios cannot seam; check for a focal step and use map_overlap for it.
Frequently Asked Questions
Q: Why must every band have the same chunks? Because element-wise arithmetic pairs chunk i of one band with chunk i of the other. If the chunk boundaries differ, Dask must rechunk one band to match before it can compute, which adds a shuffle step and extra memory to every operation.
Q: When should I call persist? Only when an intermediate is reused several times and fits in cluster memory. For a single ratio written straight to disk, persisting forces the whole result into memory for no benefit; let the write drive the computation instead.
Q: What chunk size suits a Sentinel-2 band? A multiple of the file’s internal tile size, typically 1024 or 2048 pixels square for 10 metre bands. That gives chunks of a few to a few tens of megabytes and means each chunk reads whole internal tiles rather than fragments.
Q: Is Dask worth it for a single scene on a laptop? Often yes, because it bounds memory rather than because it is faster. A scene that does not fit in RAM as float32 simply cannot be processed eagerly; chunked, it runs in a fixed memory budget at close to disk speed.
Related
- Band Math Operations with xarray — the parent topic.
- Computing NDVI with xarray on a Dask Cluster — the same computation at cluster scale.
- Writing Reusable Index Functions with xarray apply_ufunc — packaging index logic that works eagerly and lazily.
- Diagnosing Dask Memory Spills in Raster Workflows — when the memory bound is not holding.