Diagnosing Empty or Black Orthomosaic Tiles

Your export finished, the GeoTIFF is on disk at the expected size, gdalinfo reports the right dimensions and CRS — and yet the orthomosaic opens as a solid black rectangle in QGIS, or the web tiles render as opaque black squares over the basemap. Nothing errored. The file is not corrupt: you can read pixels from it. This is one of the most confusing failure modes in raster export precisely because every obvious signal says the file is fine. The problem is almost never the pixel data itself; it is how the fill regions and band semantics are declared. A raster where nodata was never set, where the collar around the ortho is stored as literal 0 that a viewer draws as black, where an alpha band is missing, or where the band order was scrambled will all render black while remaining perfectly valid GeoTIFFs. This page shows how to read the raster’s statistics and masks to tell those apart, and how to apply the one correct fix for each.

Why an orthomosaic renders black despite valid pixels

Rendering is an interpretation of pixel values plus metadata, and a black tile is what you get when that interpretation goes wrong in one of five specific ways:

  1. Nodata was never declared, and the collar is 0. Orthomosaics are rarely rectangular — the flight footprint leaves a border of fill around the mapped area. If that fill is stored as 0 and no nodata value is set, a viewer treats 0 as a legitimate black pixel. When the fill dominates the tile (common at the edges of a survey), the whole tile reads black.
  2. The alpha or mask band is missing. RGB orthos often rely on a fourth alpha band or an internal mask to mark transparency. If the export dropped it, the fill is drawn opaque instead of transparent, so edge tiles that are mostly collar render as solid black.
  3. Band order or count is wrong. A writer that emits bands in BGR order, or writes a single-band raster the viewer expects to be RGB, produces a wrong colour interpretation. A single band with a Gray interpretation whose values sit near zero renders black even though the data is present.
  4. Scaling or dtype mismatch. Float reflectance in [0, 1] written into a Byte raster truncates to 0; a 16-bit ortho displayed with a 0–255 stretch shows near-black because the real values live in the thousands.
  5. Overviews built from nodata. If overviews were generated before nodata was declared, the downsampling averaged the 0 fill into the real pixels. Zoomed out, the tile is black; zoomed fully in, it looks fine — the classic “black until I zoom in” signature.

The unifying insight is that a black tile is a metadata defect, not a pixel defect, so the diagnosis is done by reading statistics and mask flags rather than by staring at the image. This failure sits inside the broader troubleshooting DEM and raster export failures methodology, and the overview-related variant connects directly to setting compression and overviews for orthomosaic COGs.

Diagnosing a black orthomosaic tile by reading masks and statistics A tile that renders black flows into a rasterio inspection reading masks and band statistics. Three causes branch from it: nodata was never set so zero fill is drawn as data, no alpha or mask band exists so the collar is opaque, and the band order or scaling is wrong. All three converge on a single fix: set nodata or add an alpha band, correct the band order, and rebuild overviews after nodata is declared. Tile renders black file exists · no error Read masks + stats read_masks · statistics nodata unset 0 fill drawn as data no alpha / mask collar opaque black wrong band / scale order or dtype off Set nodata / alpha · fix bands then rebuild overviews

Minimal reproducible solution

Diagnose first, then fix. The routine below reads each band’s statistics and its mask so it can distinguish the five causes without opening a viewer. A band whose valid-data mask is entirely zero means the whole band is masked or fill; a band whose min == max means it is constant; and a mask that reports “all valid” on a raster with an obvious collar means no transparency was ever declared. It returns a diagnosis code you branch on.

import numpy as np
import rasterio
from rasterio.enums import MaskFlags


def diagnose_black(path: str) -> str:
    """Classify why a tile renders black by reading masks and statistics."""
    with rasterio.open(path) as ds:
        band1 = ds.read(1)
        mask = ds.read_masks(1)                 # 0 = masked/transparent, 255 = valid

        # Case A: mask is entirely 0 -> everything is transparent/fill.
        if mask.max() == 0:
            return "ALL_MASKED"
        # Case B: no mask exists (all_valid) but the data is nearly all zero.
        no_mask = MaskFlags.all_valid in ds.mask_flag_enums[0]
        near_zero = float(np.count_nonzero(band1)) / band1.size < 0.01
        if no_mask and near_zero and ds.nodata is None:
            return "ZERO_FILL_NO_NODATA"         # collar stored as 0, nodata unset
        # Case C: RGB expected but band count or colorinterp is wrong.
        interps = [ci.name for ci in ds.colorinterp]
        if ds.count >= 3 and interps[:3] != ["red", "green", "blue"]:
            return "WRONG_BAND_ORDER"
        # Case D: 16-bit data displayed as if 8-bit (values far above 255).
        if ds.dtypes[0] in ("uint16", "int16") and band1.max() > 255:
            return "SCALE_MISMATCH"
        return "UNKNOWN"

Each returned code maps to exactly one fix. The most common by far is ZERO_FILL_NO_NODATA, and the correct remedy is a metadata-only change — declare that 0 means nodata — that never rewrites a pixel:

import rasterio


def declare_nodata(path: str, nodata_value: float = 0.0) -> None:
    """Tell viewers that the fill value is transparent, without touching pixels."""
    with rasterio.open(path, "r+") as ds:      # r+ = in-place metadata update
        ds.nodata = nodata_value               # declares fill; does NOT fill
    print(f"nodata={nodata_value} declared on {path}")

If the collar must be transparent in an RGB deliverable rather than merely flagged, add an alpha band instead of a scalar nodata, because a scalar nodata cannot distinguish a genuine black pixel in the imagery from fill. Whichever you choose, if the raster already has overviews you must rebuild them after declaring nodata — the next section’s edge-case matrix explains why, and the setting compression and overviews for orthomosaic COGs guide covers doing it correctly at write time.

Edge-case matrix

These variants each produce a black or empty render, with the correct handling for each.

Input variant Downstream symptom if unchecked Expected handling
Collar stored as 0, nodata unset Whole edge tile renders black ds.nodata = 0 (metadata only)
RGB collar needs transparency Black instead of see-through over basemap Add an alpha band or per-dataset mask
Overviews built before nodata set Black when zoomed out, fine zoomed in Rebuild overviews after nodata is declared
Bands written BGR Colours wrong or channels read as black Reorder to RGB, set colorinterp
16-bit ortho, 0–255 stretch Near-black; real values in the thousands Apply correct min/max stretch or scale
Float [0,1] written to Byte Everything truncates to 0 → black Scale to [0,255] before writing, or keep Float

The overview row is the subtle one: overviews are a cached downsample, so a nodata declaration made afterward does not retroactively fix them. The averaged-in 0 fill is baked into the pyramid until you rebuild it.

Verification snippet

Confirm the fix by asserting on masks and statistics, not by eyeballing the render. The block below proves the tile now has valid, varied pixels and a declared transparency mechanism, and that overviews (if present) agree with the base band’s validity.

import numpy as np
import rasterio
from rasterio.enums import MaskFlags


def verify_not_black(path: str) -> None:
    """Assert the tile has real, varied data and a declared fill mechanism."""
    with rasterio.open(path) as ds:
        band1 = ds.read(1)
        # 1. Data is not degenerate (rules out an all-zero / all-fill band).
        assert band1.min() != band1.max(), "band 1 is constant; still all-fill"
        assert np.count_nonzero(band1) > 0, "band 1 is entirely zero"
        # 2. Fill is declared, via nodata or an explicit mask/alpha band.
        has_mask = (MaskFlags.per_dataset in ds.mask_flag_enums[0]
                    or MaskFlags.alpha in ds.mask_flag_enums[0])
        assert ds.nodata is not None or has_mask, "no nodata and no mask declared"
        # 3. If overviews exist, the coarsest one is not entirely fill.
        if ds.overviews(1):
            ov = ds.read(1, out_shape=(ds.height // 8 or 1, ds.width // 8 or 1))
            assert ov.min() != ov.max(), "overview is flat; rebuild after nodata"
    print(f"render OK: varied data, fill declared -> {path}")

The overview assertion is what catches the “black until I zoom in” case that the base-band checks miss entirely. If it fails, delete and rebuild the overviews after the nodata declaration.

When to escalate

The fixes above resolve rendering-metadata faults. Escalate when the black tile is a symptom of something earlier:

  • The pixels really are all fill (no imagery was written). If diagnose_black returns ALL_MASKED and the base band is genuinely empty, this is not a rendering bug — the export produced no data, and you should work the metadata-inspection step in the troubleshooting DEM and raster export failures guide to find where the pixels were lost.
  • The tile is black only after COG conversion. A valid GeoTIFF that renders correctly but breaks as a COG points at overview or masking structure specific to the COG driver; resolve it via fixing COG validation errors in GDAL.
  • Colours are present but wrong, not black. A scrambled but non-black render is a band-order or colour-interpretation problem better handled at write time; see the compression-and-overviews and COG workflows in the exporting Cloud-Optimized GeoTIFFs with rasterio guide.

Troubleshooting DEM and Raster Export Failures