Handling nodata and Scale Factors in Band Math
To compute an index correctly, mask the fill values first, convert digital numbers to physical units second, and only then do the arithmetic:
import rioxarray
import numpy as np
# masked=True promotes the declared nodata to NaN before anything else happens
red = rioxarray.open_rasterio("B04.tif", masked=True).squeeze()
nir = rioxarray.open_rasterio("B08.tif", masked=True).squeeze()
red = (red.astype("float32") * 0.0001).where(red.notnull())
nir = (nir.astype("float32") * 0.0001).where(nir.notnull())
ndvi = (nir - red) / (nir + red) # NaN propagates through, as it should
This is the correctness layer under everything in Band Math Operations with xarray.
Why This Arises in Remote Sensing Workflows
Satellite reflectance products almost never store reflectance. They store scaled integers, because integers compress well and halve the storage of a float archive, and they mark absent pixels with a sentinel value chosen from the same integer range. Both facts are metadata, and both are routinely lost between the archive and the analysis.
The two failures compound. An unmasked fill value of 0 or −9999 enters the arithmetic as a real measurement; an unapplied scale factor leaves values in the thousands where the equations expect fractions. Neither raises an exception, and a normalised difference index is particularly good at hiding them, because dividing two large numbers still produces a number between −1 and 1.
The order matters as much as the operations. Masking is defined in the stored integer space, so it has to happen before any transformation of the values; scaling is defined on valid pixels only, so it has to happen after. Reversing them produces a mask that no longer matches its sentinel, and a fill value that has been quietly multiplied into a plausible-looking reflectance.
Environment & Setup
| Package | Version | Why |
|---|---|---|
rioxarray |
≥0.15 | open_rasterio(masked=True), CRS-aware writing |
xarray |
≥2023.6 | Labelled arithmetic and where |
rasterio |
≥1.3.0 | Underlying I/O, scales and offsets |
numpy |
≥1.23 | dtype control and NaN handling |
pip install "rioxarray>=0.15" "xarray>=2023.6" "rasterio>=1.3.0"
Complete Working Example
This function reads a band, resolves its scaling from the file’s own metadata where possible, and returns a masked float array in physical units — the only form the arithmetic should ever see.
import numpy as np
import rasterio
import rioxarray
import xarray as xr
def read_physical(
path: str,
*,
default_scale: float = 1.0,
default_offset: float = 0.0,
band: int = 1,
) -> xr.DataArray:
"""Open a band as masked float32 in physical units.
Scaling is taken from the file when it declares one, falling back to the
caller's defaults, and the resolved values are recorded on the output.
"""
with rasterio.open(path) as src:
# rasterio exposes per-band scales/offsets when the file declares them
scale = src.scales[band - 1] if src.scales else default_scale
offset = src.offsets[band - 1] if src.offsets else default_offset
tags = src.tags(band)
# Some providers write the factor as a tag instead of a band attribute
scale = float(tags.get("scale_factor", scale)) or default_scale
offset = float(tags.get("add_offset", offset))
da = rioxarray.open_rasterio(path, masked=True).squeeze(drop=True) # fill → NaN
physical = da.astype("float32") * np.float32(scale) + np.float32(offset)
physical.attrs.update(
scale_factor_applied=scale,
add_offset_applied=offset,
units="reflectance" if scale != 1.0 else da.attrs.get("units", "unknown"),
)
physical.rio.write_nodata(np.nan, inplace=True)
return physical
def normalised_difference(a: xr.DataArray, b: xr.DataArray, *, name: str) -> xr.DataArray:
"""(a − b) / (a + b) with a guard on the zero denominator."""
denom = a + b
out = xr.where(denom != 0, (a - b) / denom, np.nan)
out.name = name
out.attrs.update(long_name=name, valid_min=-1.0, valid_max=1.0)
return out.astype("float32")
if __name__ == "__main__":
red = read_physical("B04.tif", default_scale=0.0001)
nir = read_physical("B08.tif", default_scale=0.0001)
ndvi = normalised_difference(nir, red, name="ndvi")
finite = ndvi.where(np.isfinite(ndvi))
assert float(finite.min()) >= -1.0001 and float(finite.max()) <= 1.0001, "scaling mismatch"
print("valid pixels:", int(finite.count()), "of", ndvi.size)
The assertion at the end is the cheapest available detector for every failure described above: a scaling applied twice, applied to one band only, or not applied at all all push the result outside ±1.
Variant Patterns
1. Baselines that add an offset
Some processing baselines introduce an additive offset alongside the multiplier, and a pipeline that handles only the multiplier produces values that are plausibly ranged and consistently wrong.
# Baseline with an offset: physical = (DN + offset) * scale
red = (rioxarray.open_rasterio("B04.tif", masked=True).squeeze(drop=True)
.astype("float32")
.pipe(lambda da: (da - 1000.0) * 0.0001)) # offset then scale, per the product spec
Because a normalised difference is invariant to a shared multiplicative scale but not to an additive offset, mixing baselines inside one time series produces a step change in the index that is easily mistaken for a real event. Record the baseline per scene and check it before stacking — the drift-detection argument from Extracting and Parsing Raster Metadata.
2. Combining a fill mask with a quality mask
Fill values and cloud flags are different kinds of invalidity, and both belong in the same mask before arithmetic.
import numpy as np
import rioxarray
scl = rioxarray.open_rasterio("SCL_20m.tif").squeeze(drop=True)
scl10 = scl.rio.reproject_match(red, resampling=rasterio.enums.Resampling.nearest)
invalid_classes = [0, 1, 3, 8, 9, 10, 11]
quality_ok = ~scl10.isin(invalid_classes)
red_clean = red.where(quality_ok) # NaN where cloudy, shadowed or absent
nir_clean = nir.where(quality_ok)
ndvi = normalised_difference(nir_clean, red_clean, name="ndvi")
Nearest-neighbour is mandatory for the class layer, and applying the mask before the ratio rather than after is what keeps cloud edges out of the result — the argument developed in Masking Clouds with the Sentinel-2 SCL Band.
3. Writing a float result back into an integer file
import numpy as np
SCALE, FILL = 10000, -32768
scaled = (ndvi * SCALE).round()
packed = scaled.fillna(FILL).astype("int16")
packed.rio.write_nodata(FILL, inplace=True)
packed.attrs.update(scale_factor=1 / SCALE, long_name="ndvi")
packed.rio.to_raster("ndvi_int16.tif", driver="COG", compress="DEFLATE", blocksize=512)
fillna before astype is essential: casting NaN to an integer dtype is undefined and produces whatever the platform’s conversion happens to yield, which is rarely the fill value you intended.
Common Errors
The index is uniformly close to −1 or +1
One band was scaled and the other was not, so the difference is dominated by the magnitude gap rather than by the spectral contrast. Assert that both bands share a scale factor before the arithmetic.
RuntimeWarning: invalid value encountered in divide
The denominator is zero somewhere — usually a pixel where both bands are zero after masking. Guard with xr.where(denom != 0, …, np.nan) rather than suppressing the warning.
Output opens with values in the tens of thousands
A packed integer result was written without scale_factor metadata, so the reader has no way to know it is scaled. Write the tag, or write float32.
Frequently Asked Questions
Q: Should I mask before or after scaling? Before. The fill value is expressed in the stored integer space, so comparing against it after scaling means comparing against a transformed sentinel — which usually no longer matches exactly, and silently leaves fill pixels in the data.
Q: Why do my NDVI values exceed 1 after applying a scale factor? A normalised difference is scale-invariant when both bands share the same factor, so a value outside ±1 means the two bands were scaled differently, or an additive offset was applied to one and not the other.
Q: How do I write a float result to an integer output? Pick a fill value that cannot occur in the data, replace NaN with it, scale the floats to the integer range, and record both the scale factor and the fill value in the output metadata.
Related
- Band Math Operations with xarray — the parent topic covering alignment, chunking and export.
- Calculating NDVI Directly from xarray DataArrays — the arithmetic this page prepares the inputs for.
- Extracting nodata and dtype from a GeoTIFF — where fill values come from and how to find them.
- Handling Pixel Resolution and Scaling — the radiometric and spatial scaling relationship in full.