Building a Band-Stacked GeoTIFF with Rasterio
Somebody opens a five-band reflectance raster from last season and needs the red edge band. Band three looks plausible. It is in fact near-infrared, because the sensor that produced it orders its output by wavelength and the one before it ordered by an internal channel number, and nothing in the file says which convention applied.
A band stack without descriptions is a file whose meaning lives in an email. This page covers writing one that carries its own meaning: band names, wavelengths, units, correct NoData for float reflectance, and compression that does not alter the values. It is the output stage of band alignment and stacking for multispectral sets.
Four decisions that make or break the file
Band identification. GeoTIFF supports a description per band, and almost nothing sets it. Writing the band name there, plus the centre wavelength as a tag, turns positional guessing into a lookup.
Data type and NoData. Reflectance is a float in roughly [0, 1]. Storing it as an integer with a scale factor saves space and reintroduces quantisation the calibration worked to avoid. NoData must be NaN rather than a sentinel, because every plausible sentinel — 0, −1, 255 — is either a valid reflectance or an integer the float band cannot represent exactly.
Compression. Reflectance rasters compress well with DEFLATE or ZSTD and a floating-point predictor. A lossy codec is never acceptable: an index is a difference of two bands, and a codec that alters each band by a fraction of a percent alters their difference by considerably more.
Tiling and overviews. A stacked raster is read band by band and window by window, which is what internal tiling is for. Without it, reading a small region of one band reads entire scanlines of all of them.
Figure 1 — What a self-describing band stack carries.
Minimal reproducible solution
import numpy as np
import rasterio
BAND_WAVELENGTHS_NM = {"blue": 475.0, "green": 560.0, "red": 668.0,
"red_edge": 717.0, "nir": 840.0}
def write_stack(bands: dict[str, np.ndarray], out_path: str, *,
transform, crs, order: list[str] | None = None,
wavelengths: dict[str, float] | None = None) -> dict:
"""Write a self-describing multi-band reflectance raster.
Band order is explicit and recorded as a file tag as well as per-band
descriptions, so a consumer that indexes positionally has something to
check against rather than an assumption.
"""
wavelengths = wavelengths or BAND_WAVELENGTHS_NM
order = order or sorted(bands, key=lambda b: wavelengths[b])
missing = [b for b in order if b not in bands]
if missing:
raise KeyError(f"bands {missing} were requested but not supplied")
first = bands[order[0]]
stack = np.stack([bands[b].astype("float32") for b in order])
profile = {
"driver": "GTiff", "height": first.shape[0], "width": first.shape[1],
"count": len(order), "dtype": "float32", "crs": crs,
"transform": transform, "nodata": np.nan,
"compress": "deflate", "predictor": 3, "zlevel": 6,
"tiled": True, "blockxsize": 512, "blockysize": 512,
"BIGTIFF": "IF_SAFER",
}
with rasterio.open(out_path, "w", **profile) as dst:
dst.write(stack)
for i, band in enumerate(order, start=1):
dst.set_band_description(i, band)
dst.update_tags(i, WAVELENGTH_NM=f"{wavelengths[band]:.1f}",
UNITS="reflectance", VALID_RANGE="0,1")
dst.update_tags(BAND_ORDER=",".join(order),
PRODUCT="calibrated surface reflectance")
dst.build_overviews([2, 4, 8, 16], rasterio.enums.Resampling.average)
return {"bands": order, "path": out_path}
predictor=3 is the floating-point predictor, and it is the difference between a stack that compresses to a third of its size and one that barely compresses at all. It is lossless — the predictor is a reversible transform applied before the entropy coder — so there is no trade-off to weigh.
Reading it back safely
import numpy as np
import rasterio
def read_bands(path: str, names: list[str]) -> dict[str, np.ndarray]:
"""Read named bands, failing loudly if the file does not identify them.
Never index positionally. A stack written by a different sensor, or by an
older version of the pipeline, may order its bands differently, and a
positional read produces an index computed from the wrong pair with no
error at all.
"""
with rasterio.open(path) as src:
descriptions = [d or "" for d in src.descriptions]
if not any(descriptions):
raise ValueError(f"{path} has no band descriptions — it cannot be "
"read safely by name")
out = {}
for name in names:
if name not in descriptions:
raise KeyError(f"band {name!r} not among {descriptions}")
idx = descriptions.index(name) + 1
out[name] = src.read(idx, masked=True).filled(np.nan)
return out
def band_metadata(path: str) -> list[dict]:
"""Per-band descriptions and tags, for a manifest or a sanity check."""
with rasterio.open(path) as src:
return [{"index": i, "description": src.descriptions[i - 1],
**src.tags(i)} for i in range(1, src.count + 1)]
Figure 3 — Four fields that decide whether the file is usable by anyone else.
Edge-case matrix
| Situation | Consequence | Handling |
|---|---|---|
| No band descriptions | Positional guessing downstream | Refuse to read; re-write with descriptions |
| Integer dtype with a scale | Quantisation reintroduced | Write float32 |
| NoData of 0 | Valid reflectance treated as missing | NaN NoData |
| Lossy compression | Index errors larger than band errors | DEFLATE or ZSTD only |
| No predictor | File three times larger | predictor=3 for float |
| Not tiled | Windowed reads pull whole scanlines | Internal tiling |
| Bands of different shapes | Stack fails or silently pads | Assert shapes before stacking |
| Band order differs between flights | Positional reads break | Read by name; record BAND_ORDER |
Verification snippet
import numpy as np
import rasterio
def verify_stack(path: str, expected_bands: list[str]) -> dict:
"""Structural and value checks a delivered reflectance stack must pass."""
problems = []
with rasterio.open(path) as src:
descriptions = [d or "" for d in src.descriptions]
if descriptions != expected_bands:
problems.append(f"band order is {descriptions}, expected {expected_bands}")
if src.dtypes[0] != "float32":
problems.append(f"dtype is {src.dtypes[0]}, expected float32")
if src.nodata is not None and not np.isnan(src.nodata):
problems.append(f"nodata is {src.nodata}, expected NaN")
if not src.profile.get("tiled"):
problems.append("raster is not internally tiled")
if src.overviews(1) == []:
problems.append("no overviews built")
sample = src.read(1, masked=True).filled(np.nan)
finite = sample[np.isfinite(sample)]
if finite.size:
out_of_range = float(np.count_nonzero((finite < -0.05) | (finite > 1.1))
/ finite.size)
if out_of_range > 0.01:
problems.append(f"{out_of_range:.1%} of band 1 is outside [0, 1] — "
"check the calibration")
return {"bands": descriptions, "problems": problems, "ok": not problems}
The value-range check is the one that catches calibration errors at the point where they are still cheap to fix. Reflectance outside [0, 1] beyond a fraction of a percent means something upstream is wrong, and finding it in the writer is much better than finding it in a plot statistic.
Figure 2 — Why one of these four is the answer and one of them is disqualified.
Stacking indices alongside the bands, or not
A recurring question is whether computed indices belong in the same file as the reflectance bands. There is a good case each way and the decision is worth making deliberately rather than by habit.
Keeping them separate is the cleaner default. Reflectance is a measurement; an index is a derived product with its own definition, version and masking policy, and those change more often than the bands do. A separate index raster can be recomputed and reissued without touching the reflectance product, and its own metadata can record the formula and the mask thresholds that produced it.
Keeping them together suits delivery to a client who will open one file in a desktop GIS and expects everything in it. Where that is the audience, append the indices as additional bands with descriptions such as ndvi and ndre, and tag each with the formula used — the same discipline that makes the reflectance bands readable applies to them.
What does not work is a file containing indices and no record of how they were computed. An NDVI with an unspecified denominator floor, computed from an unspecified band pair, is a raster of numbers rather than a measurement, and it will be compared against next season’s NDVI computed slightly differently.
def index_band_tags(name: str, formula: str, *, mask: dict,
source_bands: list[str]) -> dict:
"""Tags that make a derived index band self-describing."""
return {"PRODUCT": "vegetation index", "INDEX_NAME": name,
"FORMULA": formula, "SOURCE_BANDS": ",".join(source_bands),
"MASK": ";".join(f"{k}={v}" for k, v in mask.items()),
"UNITS": "dimensionless", "VALID_RANGE": "-1,1"}
When to escalate
- The stack must interoperate with software that cannot read float NaN. Some older packages cannot. Supply a companion integer product with a documented scale factor rather than compromising the primary one.
- Band descriptions are lost by a downstream tool. Assert them on every read and re-set them on every write; a tool that drops them is a fact to work around rather than to argue with.
- The file is too large for the delivery channel. Overviews and tiling make range-based access practical; consider serving it rather than shipping it, as in serving COG range requests without a tile server.