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.

Four sources of bad values in an index raster, by signature A table of four sources against their signature and severity. Deliberate masking produces NaN in a spatially coherent pattern following shadows and borders, and is correct. A zero denominator produces NaN or infinity scattered at frame edges, and indicates unmasked NoData. Negative bands produce finite values outside minus one to one, and indicate a calibration fault. A sentinel treated as data produces finite values at exactly plus or minus one in a spatially coherent block, and is the most dangerous because downstream statistics use it. signature severity deliberate masking zero denominator negative bands sentinel as data NaN, spatially coherent follows shadow and borders NaN or inf, scattered concentrated at frame edges finite, outside [−1, 1] no NaN at all finite, at exactly ±1 coherent blocks correct fix upstream calibration fault dangerous Only the last one produces values a statistic will happily consume. Which is why a raster with no NaN at all deserves more suspicion than one with some.

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.

Distinguishing the four sources of a non-finite index pixel Four rows. Infinity with a consistent sign across a region indicates a zero denominator, where both input bands went to zero, typically over deep shadow or water. Not-a-number indicates a zero divided by zero, or arithmetic reaching a nodata value that was stored as not-a-number rather than as a sentinel. Non-finite values only at the raster edge indicate a nodata border that entered the computation because the mask was applied after rather than before. Non-finite values scattered at random indicate a corrupted input band, and are the one case where the fix is upstream of the arithmetic entirely. infinity, consistent sign zero denominator — both bands at zero, over shadow or water not-a-number zero over zero, or nodata stored as NaN reaching the arithmetic only at the raster edge a nodata border entered because the mask was applied too late scattered at random a corrupted input band — the fix is upstream of the arithmetic The spatial pattern of the non-finite pixels identifies the cause without any further work.

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.

Spatial pattern of NaN for correct masking against an arithmetic fault Two plan views of the same field. On the left, NaN forms a few large connected regions following a water body, a tree shadow and the survey boundary, with the largest region holding sixty-two percent of all NaN pixels; this is correct masking. On the right, NaN is scattered as thousands of isolated pixels concentrated along straight lines at frame edges, with the largest region holding under two percent; this is an arithmetic fault at warp borders. A note gives the connected-component share as the discriminator. correct masking arithmetic fault largest region = 62 % of all NaN largest region under 2 % — scattered along frame edges The connected-component share separates them without knowing the provenance. Real features are large and connected; arithmetic faults follow the frame grid.

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.

Troubleshooting Multispectral and Thermal Failures