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.
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.
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.
Related
- Troubleshooting DEM and raster export failures
- Diagnosing empty or black orthomosaic tiles
- Exporting Cloud-Optimized GeoTIFFs with rasterio
← Troubleshooting DEM and Raster Export Failures
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.