Diagnosing NaN and Inf in Index Rasters
The plot statistics all come back as nan. Or the raster opens and renders, and its mean is inf. Or — the worst case — everything works and the histogram has two narrow spikes at exactly +1 and −1 that correspond to nothing in the field.
Non-finite values in an index raster are not one problem. They are four, with different causes and different fixes, and the first useful step is to establish which one is present. This page covers that, as part of the diagnostic sequence in troubleshooting multispectral and thermal failures.
Four sources of non-finite values
Deliberate masking. A denominator floor or a NoData mask writes NaN where no measurement exists. This is correct behaviour and the majority of NaN in a well-built product.
Zero denominator. Both bands exactly zero produces 0/0, which is NaN, or a non-zero numerator over zero, which is inf. This happens where an unmasked NoData border met the arithmetic.
Negative bands. A calibration that produced negative reflectance can make the denominator negative, and the index then lies outside [−1, 1] without being non-finite — a related fault with a different signature.
Sentinel NoData treated as data. A band read with -9999 intact produces an index of almost exactly ±1, which is finite and wrong. This is the source of the histogram spikes, and it is the only one of the four that produces values a downstream statistic will happily use.
Figure 1 — Four sources, four signatures. The spatial pattern tells them apart before any code runs.
Minimal reproducible solution
import numpy as np
from scipy import ndimage
def classify_bad_values(index: np.ndarray, *, extreme_tolerance: float = 1e-4) -> dict:
"""Identify which of the four sources is present, from the raster alone.
Spatial coherence is the discriminator. Deliberate masking follows real
features and forms large connected regions; a zero-denominator fault is
scattered along frame edges; a sentinel block is coherent and sits at
exactly the index extremes.
"""
nan_mask = np.isnan(index)
inf_mask = np.isinf(index)
finite = index[np.isfinite(index)]
findings = []
if inf_mask.any():
findings.append(f"{inf_mask.mean():.3%} infinite — a non-zero numerator "
"over an exactly zero denominator")
if finite.size:
at_extreme = np.isfinite(index) & (np.abs(np.abs(index) - 1.0) < extreme_tolerance)
if at_extreme.mean() > 0.001:
labelled, n = ndimage.label(at_extreme)
sizes = ndimage.sum(at_extreme, labelled, range(1, n + 1)) if n else []
biggest = float(max(sizes)) if len(sizes) else 0.0
coherent = biggest / max(at_extreme.sum(), 1)
findings.append(
f"{at_extreme.mean():.2%} at exactly ±1, {coherent:.0%} of it in one "
"block — a NoData sentinel is being treated as data")
out_of_range = np.isfinite(index) & (np.abs(index) > 1.0001)
if out_of_range.mean() > 0.0001:
findings.append(f"{out_of_range.mean():.3%} outside [-1, 1] — a band is negative")
if nan_mask.any():
labelled, n = ndimage.label(nan_mask)
sizes = ndimage.sum(nan_mask, labelled, range(1, n + 1)) if n else []
largest = float(max(sizes)) / max(nan_mask.sum(), 1) if len(sizes) else 0.0
findings.append(f"{nan_mask.mean():.1%} NaN, largest connected region is "
f"{largest:.0%} of it — "
+ ("coherent, consistent with deliberate masking"
if largest > 0.3 else
"scattered, consistent with an arithmetic fault"))
return {"nan_fraction": float(nan_mask.mean()),
"inf_fraction": float(inf_mask.mean()),
"findings": findings}
The connected-component measure is what separates the benign case from the faults without needing to know how the raster was produced. Deliberate masking follows real features — a tree line, a water body, the survey boundary — and therefore forms a few large regions; an arithmetic fault produces thousands of isolated pixels.
Finding where they entered
Once the class is known, the source stage is usually one step upstream, and checking the bands directly identifies it.
import numpy as np
import rasterio
def band_diagnostics(stack_path: str, bands: list[str]) -> dict:
"""Per-band checks for the conditions that produce non-finite indices."""
out = {}
with rasterio.open(stack_path) as src:
descriptions = [d or "" for d in src.descriptions]
for name in bands:
if name not in descriptions:
out[name] = {"problem": "band not present"}
continue
arr = src.read(descriptions.index(name) + 1, masked=True).filled(np.nan)
finite = arr[np.isfinite(arr)]
if finite.size == 0:
out[name] = {"problem": "band is entirely NoData"}
continue
out[name] = {
"min": float(finite.min()), "max": float(finite.max()),
"negative_fraction": float((finite < 0).mean()),
"near_zero_fraction": float((np.abs(finite) < 0.01).mean()),
"sentinel_suspected": bool(finite.min() < -100),
"nan_fraction": float(1 - finite.size / arr.size),
}
return out
sentinel_suspected firing means a NoData value survived into the band array, which is the fault to fix — not in the index function but in the read that produced the band. Fixing it in the index masks the symptom and leaves the same wrong values available to anything else that reads the stack.
Figure 3 — Where the bad pixels are tells you what made them.
Edge-case matrix
| Observation | Source | Fix |
|---|---|---|
| NaN in large coherent regions | Deliberate masking | None; this is correct |
| NaN scattered at frame edges | Zero denominator on warp borders | Mask the border before the index |
| Infinite values | Non-zero over exactly zero | Use a where-masked division |
| Finite values at exactly ±1 | Sentinel treated as data | Fix the band read, not the index |
| Values outside [−1, 1] | Negative band | Fix the dark-level subtraction |
| No NaN at all in a real survey | Nothing was masked | Suspicious; check the floor was applied |
| NaN fraction rose since last month | Masking or reconstruction changed | Compare band NaN fractions |
| NaN only in one band’s footprint | That band failed to warp | Check the alignment stage |
The “no NaN at all” row is worth taking seriously. Every real survey has some region — water, deep shadow, the warped border of a frame — where an index is undefined, and a raster with none has almost certainly had its floor set to zero or its masking skipped.
Verification snippet
import numpy as np
def assert_index_sane(index: np.ndarray, *, max_nan: float = 0.35,
min_nan: float = 0.0001) -> None:
"""Gate an index raster on the shape of its non-finite population."""
nan_fraction = float(np.isnan(index).mean())
if np.isinf(index).any():
raise ValueError("infinite values present — the division was not masked")
if nan_fraction > max_nan:
raise ValueError(f"{nan_fraction:.0%} NaN — the denominator floor is too high "
"or the source has large NoData regions")
if nan_fraction < min_nan:
raise ValueError("no NaN at all — masking was probably not applied")
finite = index[np.isfinite(index)]
if finite.size and np.abs(finite).max() > 1.0001:
raise ValueError("values outside [-1, 1] — a source band is negative")
Gating on both an upper and a lower bound is the unusual part and the useful one. Too much NaN is a visible problem; too little is an invisible one, and only an explicit lower bound catches it.
Figure 2 — Two rasters with a similar NaN fraction and entirely different causes.
Keeping NaN out of the statistics without hiding it
Once the raster is correct, the remaining question is how downstream code handles the NaN that legitimately remains. Two habits avoid the two failure modes.
Use nan-aware reductions everywhere. np.nanmean, np.nanmedian and np.nanpercentile ignore non-finite values rather than propagating them, so a single masked pixel does not turn a plot statistic into NaN. Making this the house style removes an entire class of confusing results.
Report how many values were ignored. A median computed from 200 of a plot’s 4,800 pixels is a different claim from one computed from 4,700, and a nan-aware reduction gives no indication which it was. Returning the count alongside every statistic is one extra line and prevents the silent version of the same problem.
import numpy as np
def robust_stat(values: np.ndarray) -> dict:
"""A statistic that ignores NaN and says how much it ignored."""
finite = values[np.isfinite(values)]
if finite.size == 0:
return {"n": 0, "median": None, "used_fraction": 0.0}
return {"n": int(finite.size),
"median": float(np.median(finite)),
"used_fraction": float(finite.size / values.size)}
A used_fraction below about half is a signal to look at the masking before acting on the number, and it travels with the statistic rather than needing to be reconstructed.
When to escalate
- The NaN fraction is large and the bands are clean. The denominator floor is set for a different scene — usually reflectance values much smaller than expected, which points back at the calibration.
- Values at exactly ±1 persist after fixing the read. Check for a second path into the stack: an intermediate product written with a sentinel and re-read later.
- A band is entirely NoData. The alignment or warping stage produced nothing for it. That is a geometry problem, not an index one.