Writing Cloud-Optimized GeoTIFFs for Exposure Rasters
This guide, part of Cloud-Native Geospatial Storage for Surveillance, covers writing the raster products from the exposure section so that the thousands of small reads an assignment job performs are cheap rather than catastrophic.
Problem Context & Constraints
An exposure surface has an unusual access profile. It is written once and read enormously: a cohort assignment samples it at a million points, a zonal statistic reads a few thousand windows, and a map viewer requests overview tiles. Every one of those is a small, scattered read.
A striped GeoTIFF — the historical default — stores data in full-width rows. Reading a 3-by-3 window at a point therefore requires reading three complete rows of the state, which at 30 m resolution is several megabytes for nine pixels. Over a million points that is the difference between minutes and days, and over network storage it is the difference between feasible and not.
Three properties make a raster cloud-optimized in practice. Internal tiling, so a window read fetches a block rather than a row. Internal overviews, so a viewer drawing a thumbnail reads a downsampled copy rather than the full resolution. And a header layout that places all metadata at the front, so a reader learns the file’s structure in one request rather than seeking through it.
The fourth constraint is specific to exposure work: the prediction and its variance belong in the same file as separate bands, because separating them guarantees that somewhere downstream only one of the two gets carried.
Prerequisites
python3.11rasterio1.3.10,numpy1.26.4- The exposure surface and its prediction variance as arrays or an intermediate raster
- A declared projected coordinate reference system and a nodata value
- Object storage that supports ranged reads, which is where the layout pays off
Step-by-Step Solution
Write the file tiled with a sensible block size, build overviews internally, declare nodata and band descriptions, and then validate that the result is genuinely cloud-optimized rather than merely tiled.
# Write and validate a cloud-optimized exposure raster with prediction and variance.
# Pinned: rasterio==1.3.10, numpy==1.26.4
import logging
import numpy as np
import rasterio
from rasterio.enums import Resampling
from rasterio.shutil import copy as rio_copy
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("storage.cog")
BLOCK = 512
NODATA = -9999.0
def write_exposure_cog(prediction: np.ndarray, variance: np.ndarray,
transform, crs, dst_path: str,
clear_count: np.ndarray | None = None,
tags: dict | None = None) -> None:
"""Write prediction, variance and an optional support band as one COG."""
if prediction.shape != variance.shape:
raise ValueError("prediction and variance grids differ")
bands = [("prediction", prediction), ("prediction_variance", variance)]
if clear_count is not None:
bands.append(("support_count", clear_count.astype("float32")))
profile = {
"driver": "GTiff", "height": prediction.shape[0], "width": prediction.shape[1],
"count": len(bands), "dtype": "float32", "crs": crs, "transform": transform,
"nodata": NODATA, "tiled": True, "blockxsize": BLOCK, "blockysize": BLOCK,
"compress": "deflate", "predictor": 2, "BIGTIFF": "IF_SAFER",
}
tmp = dst_path + ".tmp.tif"
with rasterio.open(tmp, "w", **profile) as dst:
for i, (name, arr) in enumerate(bands, start=1):
a = np.where(np.isfinite(arr), arr, NODATA).astype("float32")
dst.write(a, i)
dst.set_band_description(i, name)
dst.build_overviews([2, 4, 8, 16, 32], Resampling.average)
dst.update_tags(ns="rio_overview", resampling="average")
if tags:
dst.update_tags(**{k: str(v) for k, v in tags.items()})
# Rewrite through the COG driver so the header and overviews land in the
# required order; a tiled file with overviews is NOT automatically a COG.
rio_copy(tmp, dst_path, driver="COG", compress="DEFLATE", predictor="YES",
blocksize=BLOCK, overview_resampling="average", BIGTIFF="IF_SAFER")
log.info("wrote COG %s bands=%s block=%d", dst_path,
[b[0] for b in bands], BLOCK)
def validate_cog(path: str) -> dict:
"""Assert the properties that actually make windowed reads cheap."""
problems = []
with rasterio.open(path) as src:
prof = src.profile
if not prof.get("tiled", False):
problems.append("not tiled: every window read fetches full rows")
bs = (prof.get("blockxsize"), prof.get("blockysize"))
if bs[0] != bs[1] or not bs[0] or bs[0] < 128:
problems.append(f"unsuitable block size {bs}")
if not src.overviews(1):
problems.append("no internal overviews: a thumbnail reads full resolution")
if src.nodata is None:
problems.append("nodata not declared")
if src.crs is None:
problems.append("no CRS")
missing_desc = [i for i in range(1, src.count + 1)
if not src.descriptions[i - 1]]
if missing_desc:
problems.append(f"bands without descriptions: {missing_desc}")
info = {"tiled": prof.get("tiled"), "block": bs,
"overviews": src.overviews(1), "bands": src.count,
"descriptions": list(src.descriptions), "nodata": src.nodata,
"crs": src.crs.to_string() if src.crs else None}
if problems:
log.error("COG validation failed for %s: %s", path, "; ".join(problems))
else:
log.info("COG validated: %s", info)
return {"ok": not problems, "problems": problems, "info": info}
The rewrite through the dedicated driver is the step most implementations skip. A file that is tiled and has overviews still is not cloud-optimized if its header and overview blocks are scattered through the file, because a reader then needs several round trips to discover the structure before reading any data. The driver lays them out so one ranged request at the start tells the reader everything it needs.
Carrying prediction, variance and a support band together is what keeps the downstream analysis honest. The assignment code in Zonal Statistics for Raster Exposure at Address Points reads band descriptions rather than positions, so a file without them is a file whose bands will eventually be swapped by somebody.
Validation & Edge Cases
Validation that only checks tiling. A file can be tiled, have overviews, and still require many round trips. Validate the layout with a dedicated checker, and confirm by timing a windowed read over the network rather than locally.
Nodata confused with a real value. A sentinel of −9999 in a float band is fine when declared and dangerous when not. Declare it, and additionally range-check the band on write so a genuine value cannot collide with the sentinel.
Compression that costs more than it saves. A high compression level slows every read for a modest size reduction. Deflate with a horizontal predictor suits continuous surfaces; measure decompression time before choosing something more aggressive.
Overviews built with the wrong resampling. Nearest-neighbour overviews of a continuous surface produce visibly wrong thumbnails; average resampling is right for exposure values and wrong for a categorical band. Where a file mixes both, build separate products.
Very large rasters and the classic format limit. A state-wide 10 m multi-band float raster can exceed the four-gigabyte offset limit. Setting the big-file flag conditionally avoids both the failure and the incompatibility of forcing it unnecessarily.
Band order assumed downstream. Always set band descriptions, and read by description rather than index in every consumer.
INFO storage.cog wrote COG /archive/exposure/pm25_2026.tif bands=['prediction', 'prediction_variance', 'support_count'] block=512
ERROR storage.cog COG validation failed for /archive/exposure/ndvi_2026.tif: no internal overviews: a thumbnail reads full resolution; bands without descriptions: [2]
The overview resampling choice in that list is visible in a thumbnail, which is exactly where users first judge whether a surface looks right:
Compliance Notes
Write provenance into the file’s own tags: the run identifier, the configuration digest, the source dataset vintages, the model parameters and the validation statistics. A raster is the artifact most likely to be copied out of a system and sent to a partner, and tags travel with it while a sidecar metadata file does not.
Exposure rasters themselves contain no protected data and should be published where policy allows, which makes them the easiest part of the system to share and the most useful to other agencies. The one caution is resolution: a very fine surface published alongside an areal release can help an adversary narrow a residence to a contour band, so the publication decision belongs with the disclosure review for the products built on it rather than being automatic.
Related Topics
- Cloud-Native Geospatial Storage for Surveillance — the parent guide and the archive these rasters live in.
- Zonal Statistics for Raster Exposure at Address Points — the reader whose access pattern this layout serves.
- Interpolating Environmental Exposure Surfaces — where the prediction and variance bands come from.