Converting GeoTIFF to Cloud-Optimized GeoTIFF for Emissions Pipelines
A plain GeoTIFF dumped from a processing chain is, more often than not, a striped raster with no internal tiling and no overviews — a file that forces every reader to pull the whole thing over the wire just to sample one parcel. This guide is the task-level recipe under Cloud-Optimized Geospatial Formats, the storage-layer discipline within the Satellite Imagery Processing for Emissions Tracking stack. The COGs it produces are exactly what the range-request readers behind async satellite tile processing with Dask expect: internally tiled, overview-backed, and streamable straight from object storage.
Converting a GeoTIFF to a Cloud-Optimized GeoTIFF (COG) is not a cosmetic reformat. The wrong internal block size doubles the read amplification of a Dask worker; the wrong overview resampling silently corrupts a categorical land-cover raster; a dropped nodata tag turns fill pixels into phantom emissions. This recipe walks the full conversion — inspect the input, choose parameters by data type, translate, build overviews, and validate with rio-cogeo before a single byte reaches the Sentinel-2 and Landsat cloud masking workflows that consume it downstream. The objective is a deterministic, self-describing artifact whose block layout, compression, and provenance all survive third-party MRV scrutiny.
Root Cause Analysis: Why a Plain GeoTIFF Breaks the Pipeline
A GeoTIFF that has never been optimized carries three structural defects, and each one surfaces as a distinct, expensive failure once the file lands in an emissions pipeline.
First, striped storage forces whole-file reads. Most tools write GeoTIFFs in horizontal strips — rows of pixels stored contiguously — because that is the historical default. A COG-aware reader that wants a single 512×512 window over one project parcel cannot fetch it; it must pull every strip that intersects that window, which on a striped file means the full width of the raster for every affected row. At continental extent the read amplification is brutal: a worker that needed 1 MB pulls 400 MB, and the object-store egress bill and the wall-clock latency both scale with the mistake, not with the data actually used.
Second, missing overviews make zoomed-out reads pathological. Without internal overviews (reduced-resolution copies of the raster), any consumer rendering a national mosaic or computing a coarse statistic must decode the full-resolution array and downsample on the fly, every time. For an interactive verifier dashboard or a Dask reduction that only needs a coarse pass, this is orders of magnitude more I/O than a pre-built overview pyramid would cost. Overviews are not optional polish for MRV workloads — they are the mechanism that makes multi-scale reads tractable.
Third, untyped conversion silently corrupts categorical data and fill pixels. This is the failure that survives QA and reaches an auditor. Building overviews with average resampling on a categorical raster — a land-cover class map, a forest/non-forest mask — invents class values that never existed: averaging class 3 and class 7 yields class 5, a category the classifier never assigned. Likewise, a conversion that drops or misreports the nodata tag lets fill pixels enter downstream sums as if they were real measurements, injecting phantom area or phantom emissions. Both defects are invisible in a thumbnail and only manifest as a discrepancy the verifier cannot reconcile.
Diagnostic Pipeline: Inspecting Block Layout and Overviews
Before converting anything, inspect the source to confirm which of the defects above actually apply — many rasters are already partially optimized, and re-tiling a good file wastes cycles. The diagnostic below opens the raster, reads its internal block layout, overview decimation levels, compression, and nodata tag, and warns when it finds striping or an absent overview pyramid. It treats a block size that will not align with the downstream Dask chunk grid as a first-class warning, because a block/chunk mismatch is one of the quietest performance regressions in a tiled pipeline.
import rasterio
from rasterio.enums import ColorInterp
import structlog
log = structlog.get_logger()
def inspect_raster(path: str, dask_chunk: int = 512) -> dict:
"""Inspect a GeoTIFF's COG-readiness before conversion.
Reports internal block layout, overview levels, compression, nodata, and
CRS. Warns on striped storage, absent overviews, and block sizes that will
not tile evenly against the downstream Dask chunk grid.
"""
with rasterio.open(path) as src:
block = src.block_shapes[0] # (rows, cols) of band 1's internal blocks
blk_rows, blk_cols = block
overviews = src.overviews(1) # decimation factors, e.g. [2, 4, 8, 16]
is_tiled = blk_rows == blk_cols and blk_cols in (256, 512, 1024)
is_striped = blk_rows == 1 or blk_cols == src.width
report = {
"path": path,
"crs": str(src.crs) if src.crs else None,
"dtype": src.dtypes[0],
"shape": [src.height, src.width],
"block_shape": [int(blk_rows), int(blk_cols)],
"overviews": [int(o) for o in overviews],
"compression": src.compression.value if src.compression else None,
"nodata": src.nodata,
"is_tiled": bool(is_tiled),
"is_striped": bool(is_striped),
"cog_ready": bool(is_tiled and overviews),
}
# Categorical rasters are commonly single-band paletted or thematic.
report["likely_categorical"] = (
src.colorinterp[0] == ColorInterp.palette
or src.dtypes[0] in ("uint8", "int8")
)
if src.crs is None:
log.warning("raster.no_crs", path=path)
if is_striped:
log.warning("raster.striped", path=path, block_shape=report["block_shape"])
if not overviews:
log.warning("raster.no_overviews", path=path)
if is_tiled and (blk_cols % dask_chunk) and (dask_chunk % blk_cols):
# Block size and Dask chunk are not integer multiples of each other.
log.warning("raster.block_chunk_mismatch",
block=blk_cols, dask_chunk=dask_chunk)
log.info("raster.inspected", **report)
return report
A raster reporting cog_ready=True with a 512 block and a full overview list needs no conversion. Everything else routes into the transformation step, and the likely_categorical flag is what decides the resampling method there — get that flag wrong and the overviews will be built with the wrong reducer.
Deterministic Transformation Logic
The conversion itself is a single translation pass that pins every parameter explicitly rather than trusting a library default. Internal tiling is fixed at 512×512: it is large enough to amortize per-block overhead on object storage yet small enough that a typical parcel window touches only a handful of blocks, and it aligns cleanly with the 512-pixel Dask chunk convention used across the tile-processing layer. Overview resampling is chosen by data type — average for continuous reflectance, NDVI, or biomass surfaces, and nearest for any categorical or class raster so no invented class values appear at coarse zoom. Compression pairs DEFLATE or ZSTD with a predictor (2 for integer data, 3 for floating point) to exploit spatial autocorrelation losslessly; LERC is offered for surfaces where a bounded, controlled loss is acceptable in exchange for a much smaller file.
The routine below reads the source, builds an explicit COG profile, translates with rio-cogeo, preserves the CRS and nodata, and refuses to return a file that does not pass cog_validate.
from datetime import datetime, timezone
import rasterio
from rasterio.enums import Resampling
from rio_cogeo.cogeo import cog_translate, cog_validate
from rio_cogeo.profiles import cog_profiles
import structlog
log = structlog.get_logger()
def convert_to_cog(
src_path: str,
dst_path: str,
categorical: bool = False,
compression: str = "deflate", # "deflate" | "zstd" | "lerc"
blocksize: int = 512,
lerc_max_z_error: float = 0.001,
) -> dict:
"""Convert a plain/striped GeoTIFF into a validated COG for MRV pipelines.
Continuous data uses average-resampled overviews with a predictor; categorical
data uses nearest-neighbour overviews and no predictor. CRS and nodata are
preserved from the source. The output is validated before the path is returned.
"""
with rasterio.open(src_path) as src:
src_crs = src.crs
src_nodata = src.nodata
dtype = src.dtypes[0]
if src_crs is None:
raise ValueError(f"{src_path}: untagged CRS; refusing to guess a datum.")
# Resampling: averaging a class map invents categories that never existed.
overview_resampling = "nearest" if categorical else "average"
# Predictor: 2 for integers, 3 for floats; never a predictor for LERC.
is_float = dtype in ("float32", "float64")
predictor = "3" if is_float else "2"
profile = cog_profiles.get(compression)
profile.update(
blockxsize=blocksize,
blockysize=blocksize,
overview_resampling=overview_resampling,
)
if compression in ("deflate", "zstd") and not categorical:
profile["predictor"] = predictor
else:
profile.pop("predictor", None) # categorical + LERC: no predictor
if compression == "lerc":
profile["max_z_error"] = lerc_max_z_error
log.info("cog.translate.start", src=src_path, dst=dst_path,
compression=compression, blocksize=blocksize,
overview_resampling=overview_resampling,
predictor=profile.get("predictor"), dtype=dtype)
cog_translate(
src_path,
dst_path,
profile,
nodata=src_nodata, # carry fill value forward explicitly
overview_resampling=overview_resampling,
web_optimized=False, # preserve native CRS, do not reproject
in_memory=False,
quiet=True,
)
# Gate: a COG that does not validate must never leave this function.
is_valid, errors, warnings = cog_validate(dst_path, quiet=True)
if not is_valid:
log.error("cog.validate.failed", dst=dst_path, errors=errors)
raise RuntimeError(f"COG validation failed for {dst_path}: {errors}")
with rasterio.open(dst_path) as chk:
manifest = {
"src_path": src_path,
"dst_path": dst_path,
"crs": str(chk.crs),
"nodata": chk.nodata,
"blocksize": blocksize,
"overviews": [int(o) for o in chk.overviews(1)],
"compression": compression,
"predictor": profile.get("predictor"),
"overview_resampling": overview_resampling,
"categorical": categorical,
"cog_valid": True,
"validation_warnings": list(warnings),
"generated_utc": datetime.now(timezone.utc).isoformat(),
}
# Assert overviews were actually written — an empty pyramid is a silent failure.
if not manifest["overviews"]:
raise RuntimeError(f"{dst_path}: no overviews written; conversion incomplete.")
log.info("cog.translate.done", **manifest)
return manifest
Two guards in this routine catch the failures that QA otherwise misses. The CRS check refuses an untagged source outright, because a COG published without a datum relocates every parcel it feeds. The overview assertion after translation catches the case where cog_translate succeeds but writes an empty pyramid — a file that validates structurally yet still forces full-resolution reads on every zoomed-out request.
Compliance Gating & Audit Trail Generation
The manifest returned by the conversion is the audit record. Every field an auditor would ask about — the CRS carried forward from the source, the nodata value, the block size, the exact overview decimation levels, the compression and predictor, and the resampling method — is captured at the moment of writing and travels with the artifact. Because the function raises on a failed cog_validate or an empty overview list, an invalid COG can never be published; the gate is structural, not advisory.
Three gates enforce MRV integrity on every conversion:
- Validation gate.
cog_validatemust return no errors. A file that fails the COG structural specification is rejected before upload, so downstream range-request readers never encounter a malformed pyramid or a mis-ordered IFD. - Resampling-correctness gate. The
categoricalflag forcesnearestoverviews for class rasters. This prevents invented class values at coarse zoom — the single most consequential silent corruption in thematic MRV data, and one a thumbnail will never reveal. - Nodata and CRS integrity. The source nodata and datum are asserted and carried forward, so fill pixels cannot enter downstream area or emissions sums and no parcel is silently relocated.
These manifests are the raw material for MRV data lineage and provenance tracking, and the block-layout and compression fields map directly onto the storage-format expectations documented in the MRV data schema reference. For the authoritative structural definition, consult the Cloud Optimized GeoTIFF specification and the GDAL COG driver documentation.
Production Integration
Deploy the conversion inside the tile-processing framework, following a fixed ingest → diagnose → transform → validate → export → submit sequence:
- Ingest. Pull the source GeoTIFF from the processing chain or a staging bucket, reading its header without loading the full array so the inspection step stays cheap even on large rasters.
- Diagnose. Run
inspect_rasterto read block layout, overviews, compression, and nodata; skip any file already reportingcog_ready=Trueand route the rest forward with thelikely_categoricalflag resolved. - Transform. Call
convert_to_cogwithcategorical=Truefor class rasters and masks,compression="zstd"for high-throughput lossless output, orcompression="lerc"where a bounded loss is acceptable on a continuous surface. - Validate. The function’s built-in
cog_validategate rejects any structurally invalid output and the overview assertion rejects an empty pyramid; treat either as a hard pipeline failure, not a warning. - Export. Upload the validated COG to object storage alongside its manifest, so the readers behind async satellite tile processing with Dask can stream 512-pixel windows by range request without re-reading whole files.
- Submit. Register the manifest with the lineage store and hand the COG to the Sentinel-2 and Landsat cloud masking workflows and the compositing stages that consume it as verified activity data.
Run the conversions as a fan-out across a dask.distributed pool, one task per source raster, and align the conversion blocksize with the reader’s chunk size so the block/chunk mismatch the diagnostic warns about never reaches production. By pinning tiling, choosing resampling by data type, preserving nodata and CRS, and gating on cog_validate, the conversion turns an unstreamable striped GeoTIFF into a self-describing, auditable COG — the storage primitive every scalable emissions pipeline is built on.
Related guides
- Cloud-Optimized Geospatial Formats — the parent storage discipline this conversion recipe sits within.
- Async Satellite Tile Processing with Dask — the range-request readers that consume the COGs this step produces.
- Sentinel-2 & Landsat Cloud Masking Workflows — the downstream stage that reads these COGs as activity data.
- MRV Data Schema Reference — the schema the block-layout and compression fields map onto.
- MRV Data Lineage & Provenance Tracking — where each conversion manifest becomes queryable provenance.