Choosing a Nodata Value That Survives Band Math
Store with a sentinel the data can never take, compute with NaN, and convert only at the boundaries:
import numpy as np
SENTINEL = -32768 # int16 storage
x = raw.astype("float32")
x[raw == SENTINEL] = np.nan # read boundary: sentinel -> NaN
result = (a - b) / (a + b) # NaN propagates on its own
packed = np.where(np.isfinite(result), np.round(result / 1e-4), SENTINEL).astype("int16")
Nothing in the middle of the pipeline ever sees the sentinel, so nothing in the middle can mistake it for data. This page belongs to raster dtypes, scaling and numerical precision in Core Raster Fundamentals & STAC Mapping.
How a Sentinel Poisons a Computation
The ratio case is the dangerous one. When both bands carry the same sentinel, (a − b) / (a + b) becomes 0 / −65536 = 0; when only one does, the result is close to ±1. Either way the output is a finite number inside the valid index range, and no plausibility check can distinguish it from a real pixel.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
numpy |
>=1.23 |
NaN handling and nan-aware reductions |
rasterio |
>=1.3.0 |
Reading and declaring nodata per band |
xarray |
>=2024.1 |
where, fillna and CF _FillValue handling |
pip install "numpy>=1.23" "rasterio>=1.3.0" "xarray>=2024.1"
Complete Working Example
import numpy as np
import rasterio
SENTINELS = {
"uint8": 0, # acceptable only when 0 is impossible as data (class maps)
"uint16": 65535,
"int16": -32768,
"int32": -2147483648,
"float32": np.nan,
}
def read_for_compute(path: str, band: int = 1) -> np.ndarray:
"""float32 array with every declared or conventional sentinel turned into NaN."""
with rasterio.open(path) as src:
raw = src.read(band)
declared = src.nodata
dtype = src.dtypes[band - 1]
out = raw.astype("float32")
sentinel = declared if declared is not None else SENTINELS.get(dtype)
if sentinel is not None and not (isinstance(sentinel, float) and np.isnan(sentinel)):
out[raw == sentinel] = np.nan
return out
def write_from_compute(arr: np.ndarray, profile: dict, path: str, *,
dtype: str = "int16", scale: float = 1e-4) -> None:
"""Pack a float array with NaN into an integer dtype with a sentinel."""
sentinel = SENTINELS[dtype]
packed = np.round(arr / scale)
packed = np.where(np.isfinite(packed), packed, sentinel)
info = np.iinfo(dtype)
valid = packed != sentinel
if valid.any() and (packed[valid].min() <= info.min or packed[valid].max() > info.max):
raise OverflowError("valid values collide with the sentinel or overflow the dtype")
with rasterio.open(path, "w", **(profile | {"dtype": dtype, "nodata": sentinel,
"count": 1})) as dst:
dst.write(packed.astype(dtype), 1)
dst.scales = (scale,)
The overflow check does double duty: it catches values too large for the dtype, and it catches a valid value landing exactly on the sentinel, which would silently turn a real pixel into missing data. The <= on the minimum is deliberate for that reason — with int16 and a sentinel of −32768, the lowest valid packed value must be −32767 or higher.
NaN-Aware Reductions
Choosing between them is a real decision rather than a technicality. np.mean returning NaN for any window with a missing pixel is conservative — it refuses to summarise incomplete data — and is sometimes exactly right. np.nanmean summarises whatever is present, which is usually what a composite or a zonal statistic wants. For those, record how many valid pixels went into each result, because a mean of one pixel and a mean of nine are not equally trustworthy; the same reasoning drives the observation-count feature in building temporal feature stacks from image time series.
Carrying Masks Through Resampling
Resampling is where nodata handling most often breaks after it has been done correctly everywhere else. A bilinear kernel over a region containing a sentinel averages the sentinel into its neighbours, producing a ring of corrupted pixels along every nodata edge; the same kernel over NaN produces NaN in the whole kernel footprint, which erodes the valid region by a pixel.
The robust approach is to pass the nodata value explicitly to the warp — src_nodata and dst_nodata in rasterio’s reproject — so GDAL excludes missing pixels from each kernel and fills uncovered output cells cleanly. That is covered in depth in reprojecting a raster from UTM to WGS84 with rasterio, and it applies to every resampling step in a pipeline, not only reprojection.
Verification
import numpy as np
a = read_for_compute("B04.tif")
b = read_for_compute("B08.tif")
idx = (b - a) / (b + a)
missing_in = ~np.isfinite(a) | ~np.isfinite(b)
missing_out = ~np.isfinite(idx)
assert (missing_in <= missing_out).all(), "a missing input produced a valid output"
extra = missing_out & ~missing_in
print(f"{extra.sum()} pixels became missing (zero denominators): acceptable")
The one-directional comparison is intentional. A band ratio can legitimately create new missing pixels where the denominator is zero, but it must never turn a missing input into a valid output. That asymmetry is exactly what an unmasked sentinel violates.
Common Errors
A valid dark pixel disappears
Its packed value equals the sentinel. Choose a sentinel outside the packed valid range and check for collisions before writing.
NaN turns into a random integer on write
NaN was cast directly to an integer dtype. Replace it with the sentinel before astype.
The nodata region grows after resampling
NaN inside a kernel spreads to the whole kernel. Pass src_nodata and dst_nodata to the warp instead.
A file reports no nodata, but has obvious fill
The value was never declared. Declare it on a copy at ingest so every reader masks it.
Frequently Asked Questions
Q: Why is zero a bad nodata value? Because zero is a plausible measurement — dark water, deep shadow, and many index values near zero — so masking it removes real data, and failing to mask it treats missing pixels as real dark ones. A value outside the physically possible range removes the ambiguity.
Q: Should NaN be used as nodata on disk? For float32 files it is the cleanest choice, since NaN cannot be confused with a measurement and every nan-aware function skips it. For integer files it is impossible, so a sentinel is needed there, converted to NaN when the data is read for computation.
Q: What happens to nodata in a ratio of two bands? With NaN, the result is NaN wherever either input is missing, which is exactly right. With an unmasked sentinel, the ratio is a finite and entirely meaningless number that will be averaged and thresholded like real data.
Q: What should a class raster use as nodata? Zero, reserved and never assigned to a real class. Class codes start at one, which leaves zero free and makes the convention easy to remember and to check. It is the one place zero is the right choice.
Related
- Raster Dtypes, Scaling and Numerical Precision — the parent topic.
- Extracting Nodata and Dtype from a GeoTIFF — reading what a file declares.
- Auditing CRS and Nodata Drift across a Collection — finding files whose conventions differ.
- Packing Float Rasters into int16 with Scale and Offset — the write side of the boundary.