Avoiding Integer Overflow in Index Calculations
Cast to float before any arithmetic, and the problem cannot occur:
import numpy as np
red = red_uint16.astype("float32")
nir = nir_uint16.astype("float32")
den = nir + red
ndvi = np.divide(nir - red, den, out=np.full_like(den, np.nan), where=den != 0)
Every integer overflow bug in band math is a version of doing the arithmetic before that astype. This page belongs to raster dtypes, scaling and numerical precision in Core Raster Fundamentals & STAC Mapping.
What Wrapping Looks Like
The resulting NDVI is not merely inaccurate; it is a finite number that passes every range check once the ratio is taken, because the wrapped numerator and a possibly wrapped denominator combine into something between −1 and 1. That is why overflow bugs survive into published products.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
numpy |
>=1.23 |
Dtype control and guarded division |
rasterio |
>=1.3.0 |
Reading bands in their stored dtype |
pytest |
>=8.0 |
Extreme-value tests for index functions |
pip install "numpy>=1.23" "rasterio>=1.3.0" "pytest>=8.0"
Complete Working Example
import numpy as np
def as_float(*bands: np.ndarray) -> list[np.ndarray]:
"""Cast every input to float32 before any arithmetic touches it."""
return [b.astype("float32", copy=False) for b in bands]
def normalised_difference(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""(a - b) / (a + b), overflow-proof for any integer or float input."""
a, b = as_float(a, b)
den = a + b
return np.divide(a - b, den, out=np.full(den.shape, np.nan, dtype="float32"),
where=den != 0)
def evi(nir: np.ndarray, red: np.ndarray, blue: np.ndarray, *,
scale: float = 1e-4) -> np.ndarray:
"""EVI needs reflectance, not raw counts — convert, then compute."""
nir, red, blue = (x * np.float32(scale) for x in as_float(nir, red, blue))
den = nir + 6 * red - 7.5 * blue + 1
return np.divide(2.5 * (nir - red), den,
out=np.full(den.shape, np.nan, dtype="float32"), where=den != 0)
if __name__ == "__main__":
red = np.array([[400, 60000]], dtype="uint16")
nir = np.array([[3000, 62000]], dtype="uint16")
print("unsafe:", ((nir - red) / (nir + red))) # wrapped
print("safe: ", normalised_difference(nir, red))
EVI makes the point that casting is necessary but not sufficient. Its formula contains an additive constant of 1 that only makes sense in reflectance units, so feeding it raw digital numbers — even as floats — produces a result dominated by the wrong term. Convert to the physical units the formula assumes, then compute; the scale conventions are set out in converting int16 reflectance to float safely.
Where Overflow Hides
Squaring deserves special mention because it appears inside standard deviations and texture measures, often buried in a helper function. uint16 values above 255 overflow when squared, which is essentially every reflectance value; a focal standard deviation computed on raw counts is meaningless everywhere. The focal statistics in feature engineering for pixel-based models are written against float input for exactly this reason.
Making It Impossible Rather Than Unlikely
The durable fix is to make integer arrays unable to reach arithmetic in the first place. Two habits achieve that.
The first is a single read function for every band in the pipeline that always returns float32 with nodata as NaN, so there is no code path that yields raw integers to a caller. The second is a type annotation discipline — npt.NDArray[np.float32] on every index function’s parameters — together with a runtime assertion at the top of each one. Neither is elaborate; together they mean a new contributor adding an index function receives floats whether or not they know about overflow.
For large pipelines, add a lint-style check that searches for arithmetic on arrays read with src.read() directly. It is crude, but it catches the one pattern responsible for nearly every overflow in practice: a quick script that reads two bands and subtracts them.
Overflow in Other Places
Band math is the most common site, but not the only one. Three others catch people regularly.
Accumulators in reductions inherit the input dtype unless told otherwise. np.sum over a large uint16 array promotes to a wider type on most platforms, but hand-written loops, Dask reductions with an explicit dtype, and some library helpers do not, and a sum over a whole scene overflows long before it finishes. Passing dtype="float64" for sums and dtype="float32" for means removes the question.
Histogram bins are a quieter case. Binning uint16 data into 256 bins with integer arithmetic on the edges truncates the bin width, so the top bin silently collects everything above the last full edge. Using float edges from np.linspace rather than integer division avoids it.
Resampling with integer output is the third. A bilinear resample of uint8 data can compute an intermediate slightly above 255 at a sharp edge; whether that clips or wraps depends on the library and its version. For continuous data, resample in float and cast afterwards; the resampling choices themselves are covered in choosing the right resampling method for Sentinel-2.
Verification
import itertools
import numpy as np
import pytest
@pytest.mark.parametrize("dtype", ["uint16", "int16"])
def test_normalised_difference_never_leaves_range(dtype):
info = np.iinfo(dtype)
levels = [0, info.max // 2, info.max]
for a, b in itertools.product(levels, repeat=2):
out = normalised_difference(np.array([a], dtype=dtype), np.array([b], dtype=dtype))
assert np.isnan(out[0]) or -1.0 <= out[0] <= 1.0, (a, b, out)
Nine input combinations at zero, mid-range and the dtype maximum exercise every overflow path a two-band index has, and the test runs in milliseconds. Adding it for every index function in a pipeline is the cheapest insurance available against this entire class of bug.
Common Errors
NDVI around 0.95 over bare soil
The numerator wrapped from negative to a large positive number. Cast before subtracting.
A standard deviation raster that looks like noise
Values were squared in their integer dtype. Cast to float32 before any focal or variance calculation.
Sums over many bands come out small
The accumulator overflowed. Accumulate in float32, or pass dtype="float32" to the reduction.
xarray arithmetic still overflows
The DataArray holds raw integers. Open with mask_and_scale=True or call .astype("float32") first.
Frequently Asked Questions
Q: Why doesn’t NumPy raise an error on overflow? Because integer array arithmetic in NumPy follows fixed-width machine semantics for speed: the result wraps modulo the dtype’s range. Python integers grow without limit, but array elements do not, and there is no check on every addition.
Q: Is subtraction on uint16 also dangerous? More so. Unsigned subtraction that would go negative wraps to a large positive number, so red minus near infrared over vegetation becomes something near 65,000. The numerator of NDVI is exactly that subtraction.
Q: Does xarray protect against overflow? Only when the data has been decoded to float, for instance by mask_and_scale. If the DataArray still holds raw uint16 values, xarray arithmetic wraps exactly as NumPy does.
Q: Is float16 a safe compromise for arithmetic? No. Its maximum is about 65,500 and it has only three significant digits, so band sums of raw counts overflow to infinity and small differences vanish. Use float32 for computation and reserve compact types for storage.
Related
- Raster Dtypes, Scaling and Numerical Precision — the parent topic.
- Calculating NDVI Directly from xarray DataArrays — the labelled-array version with the same safeguards.
- Computing EVI and NDWI from Sentinel-2 Bands — indices whose constants assume reflectance units.
- Writing Reusable Index Functions with xarray apply_ufunc — packaging overflow-safe functions for reuse.