Fixing NoData and Alpha Band Confusion in Exports

The orthomosaic has a black border in one viewer, a transparent one in another, and a client’s GIS reports its minimum elevation as −32,768. All three are the same problem: two independent mechanisms exist for saying “this pixel has no data”, the export carried one and not the other, and different software prefers different ones.

This page picks the right mechanism per raster type, makes it survive the export chain, and eliminates the failure where a sentinel value collides with a real measurement.

Two mechanisms, three ways they diverge

A NoData value is a number declared in the raster’s metadata: any pixel holding that value is absent. It costs nothing in storage, works for any band count, and has one weakness — the value must be one the data can never legitimately take.

An alpha band is an extra band carrying per-pixel opacity. It cannot collide with anything, it is what browsers and image viewers understand, and it costs a full band of storage plus the requirement that everything downstream knows to consult it.

Three divergences follow.

A value that collides. Elevation NoData of −9999 is safe; 0 is not, because sea level is a real elevation. On imagery, 0 is a real black pixel and choosing it makes every shadow transparent.

One mechanism set and not the other. A raster with an alpha band and no NoData renders correctly in a browser and reports its statistics over the whole rectangle in a GIS. The reverse renders black in a browser and computes correctly.

Neither surviving a stage. Several operations drop one or both. A translate that changes data type may drop the NoData declaration; a compositing step may flatten alpha into the colour bands.

Which consumers honour which mechanism A matrix of four consumers against the two absence mechanisms. A web browser or image viewer honours an alpha band and ignores a NoData value entirely. A desktop GIS honours both, preferring NoData for statistics. A raster statistics computation honours NoData and ignores alpha, so a raster with alpha alone reports statistics over the whole rectangle including the empty border. A tile server honours alpha and treats NoData inconsistently depending on the driver. A note concludes that a survey deliverable needs both mechanisms set consistently, because its consumers span the whole matrix. NoData value alpha band browser / image viewer desktop GIS raster statistics tile server ignoredhonoured honouredhonoured honouredignored driver-dependenthonoured No single mechanism satisfies every consumer, so a deliverable sets both — consistently.

Figure 1 — Why the answer is “both”. Each mechanism is ignored by some consumer a survey deliverable will meet, so setting one and not the other guarantees the raster is wrong somewhere.

Minimal reproducible solution

Choose the NoData value from the data type and the physical range, then set both mechanisms and assert they agree.

import numpy as np
import rasterio

# NoData sentinels that cannot collide with a real measurement.
SAFE_NODATA = {
    "float32": float("nan"),      # cannot equal any measurement, by definition
    "float64": float("nan"),
    "int16": -32768,              # the type's minimum; no elevation reaches it
    "int32": -2147483648,
    "uint8": None,                # every value is legitimate — use alpha only
    "uint16": None,
}


def choose_nodata(dtype: str, data_min: float | None = None):
    """A sentinel that cannot be a real value, or None if the type has none."""
    if dtype not in SAFE_NODATA:
        raise ValueError(f"no safe sentinel defined for {dtype}")
    nd = SAFE_NODATA[dtype]
    if nd is not None and data_min is not None and data_min <= nd:
        raise ValueError(
            f"data reaches {data_min}, at or below the sentinel {nd} — "
            "widen the data type rather than picking a value inside the range")
    return nd


def export_with_absence(src_path: str, dst_path: str) -> None:
    """Write a raster whose absent pixels are described both ways."""
    with rasterio.open(src_path) as src:
        profile = src.profile.copy()
        data = src.read(masked=True)
        dtype = src.dtypes[0]

    valid = ~np.ma.getmaskarray(data).any(axis=0)
    nd = choose_nodata(dtype, float(data.min()) if data.count() else None)

    if nd is None:
        # Unsigned integer imagery: alpha is the only safe mechanism.
        profile.update(count=src.count + 1, nodata=None)
        alpha = np.where(valid, 255, 0).astype(dtype)
        with rasterio.open(dst_path, "w", **profile) as dst:
            dst.write(data.filled(0))
            dst.write(alpha, src.count + 1)
            dst.colorinterp = (*src.colorinterp, rasterio.enums.ColorInterp.alpha)
    else:
        profile.update(nodata=nd)
        with rasterio.open(dst_path, "w", **profile) as dst:
            dst.write(data.filled(nd))
            dst.write_mask(np.where(valid, 255, 0).astype("uint8"))

The uint8 case returning None is the important asymmetry. Eight-bit imagery uses every value from 0 to 255 legitimately, so no sentinel is safe — choosing one makes some real pixels disappear. Alpha is the only correct mechanism there, and pretending otherwise is how shadows become holes.

For float elevation, NaN is the ideal sentinel because it is the one value that compares unequal to everything including itself, so a collision is impossible by construction. It requires the readers to handle it, which every modern raster library does.

Edge-case matrix

Raster Safe NoData Alpha needed? Failure if wrong
float32 DEM NaN no Sentinel appears in statistics
int16 DEM −32768 no 0 collides with sea level
uint8 RGB ortho none yes Shadows become transparent
uint16 multispectral none yes Dark pixels become absent
Classification raster a class code outside the scheme no A real class disappears
Float DEM saved as int −32768, after scaling no Precision lost silently
Ortho with a mask band mask, not alpha Some readers ignore a mask
Reprojected raster inherited inherited Warp may drop either

The mask-versus-alpha distinction in the seventh row is worth knowing. GDAL supports an internal mask band, which is smaller than a full alpha band and honoured by GDAL-based readers; browsers and image viewers do not see it. For a raster that will be tiled for the web, a real alpha band is the safer choice even though it is larger.

Verification snippet

Verify that both mechanisms agree, and that neither was dropped along the way.

import numpy as np
import rasterio


def assert_absence_consistent(path: str) -> None:
    """The NoData mask and the alpha band must describe the same pixels."""
    with rasterio.open(path) as ds:
        nd = ds.nodata
        has_alpha = rasterio.enums.ColorInterp.alpha in ds.colorinterp

        assert nd is not None or has_alpha, (
            "raster declares neither a NoData value nor an alpha band — "
            "its empty border will be treated as data by every consumer")

        if nd is not None and has_alpha:
            ai = list(ds.colorinterp).index(rasterio.enums.ColorInterp.alpha) + 1
            alpha = ds.read(ai)
            band = ds.read(1, masked=True)
            nd_mask = np.ma.getmaskarray(band)
            disagree = float(np.mean(nd_mask != (alpha == 0)))
            assert disagree < 0.001, (
                f"NoData and alpha disagree on {disagree:.2%} of pixels — "
                "one of them was set after the other and they have drifted")

        # A sentinel must not appear as a legitimate value anywhere.
        if nd is not None and not (isinstance(nd, float) and np.isnan(nd)):
            valid = ds.read(1, masked=True).compressed()
            assert not np.any(valid == nd), (
                f"the NoData value {nd} occurs among valid pixels — it collides "
                "with real measurements and some of them will vanish")

The collision assertion is the one that catches the worst outcome, and it is cheap. A raster whose NoData value also occurs as real data loses those pixels to every consumer that honours the declaration, and the loss is scattered rather than at the border — so it does not look like a masking problem at all.

A colliding sentinel removes scattered real pixels Two views of a DEM whose NoData value was set to zero. The elevation histogram shows a large population near sea level, including genuine zero-elevation cells on a tidal flat. In the rendered raster, those cells are treated as absent and appear as scattered holes across the flat rather than as a border, so the fault does not resemble a masking problem. A note contrasts this with a correctly chosen sentinel, where the only absent pixels are at the survey boundary and the histogram is untouched. nodata = 0 on a coastal DEM 0 m elevation → scattered holes across the tidal flat nodata = NaN elevation → (unchanged) absence only at the survey boundary

Figure 2 — The signature of a colliding sentinel. Absence at a boundary is a masking decision; absence scattered through the data is a value collision, and the histogram shows which.

When to escalate

  • Both mechanisms are set and a client’s software still shows a black border. That software honours neither, which some older desktop packages genuinely do not. Supply a boundary polygon alongside the raster; it is the one representation everything can consume.
  • The alpha band survives the export and is lost at the tiling stage. JPEG tiles cannot carry alpha. Use PNG or WebP for anything with an irregular footprint, as covered in building XYZ tile pyramids with gdal2tiles.
  • Statistics are computed over the whole rectangle despite a NoData declaration. The statistics were cached before the declaration was set. Recompute them explicitly rather than trusting the stored values, which several tools persist in a sidecar file.

Where a deliverable will be consumed by software you cannot predict, shipping the survey boundary as a vector alongside the raster removes the ambiguity entirely: it is the one representation of “where the data is” that every GIS reads identically.

Troubleshooting DEM and Raster Export Failures

Where an absence declaration gets dropped along an export chain A four-stage export chain with the absence mechanisms tracked through it. The rasterisation stage sets a NoData value. A type conversion to eight-bit drops it, because no eight-bit sentinel is safe, and nothing replaces it. A compositing step flattens the alpha band into the colour bands. The final Cloud-Optimized GeoTIFF therefore carries neither mechanism, and its border renders as opaque black. Each transition is annotated with what was lost, and a note recommends asserting the declaration after every stage rather than only at the end. rasterise NoData set convert to 8-bit NoData dropped composite alpha flattened COG export neither mechanism Each stage is individually reasonable; the loss is cumulative and invisible. Assert the declaration after every stage, not only at the end. The assertion is two lines and it names the stage that dropped it, which the final raster cannot.

Figure 3 — Absence declarations are lost stage by stage, and the final raster records only the outcome. Checking after each stage is the difference between a fix and an archaeology exercise.