Sizing COG Overviews for Field Display Scales
An analyst rebuilds the flood-depth COG at 04:10 with the overview depth trimmed “because the write was taking too long”, and the county-wide situational view that the operations section refreshes every few minutes goes from a 90-kilobyte read to a 12-megabyte one. Nobody changes a setting again for six hours, and the uplink at two forward posts is saturated for the rest of the operational period.
Root Cause and Operational Impact
Overview generation is the one part of COG production whose cost lands on the producer and whose benefit lands on the consumer, which makes it the part that gets trimmed under time pressure. It is also the part with the least visible consequence locally: a COG with no overviews opens instantly on the machine that wrote it, renders identically, and behaves correctly in every test that does not go over a network.
The cost shows up only at the far end of a thin link, and it shows up worst on the cheapest view. A structure-level window is a small area either way; a county-wide situational view is the whole raster, and without an overview to read, the client must fetch full-resolution pixels for all of it and throw away 99 per cent of what it downloaded. The view an operations chief refreshes most often becomes the single most expensive read in the system.
Choosing the depth is therefore a question about the client, not about the raster: which scales does the field application actually render? Levels below the finest rendered scale are never requested, and levels above the coarsest are pure cost. Matching the pyramid to the application’s scale set is what makes the decision principled rather than a guess between “some” and “lots”.
Tiered Resolution Strategy
- Enumerate the display scales the application renders (definitive). Read them out of the client’s configuration rather than assuming powers of two. Every scale the application can display needs an overview at or slightly finer than its ground resolution; anything else is unused.
- Build every level in that set, without trimming for write time. The cost of depth falls off geometrically, so the last levels are nearly free — trimming them saves seconds and costs the cheapest and most-used view.
- Choose the resampling by what the raster means. Continuous quantities average; categorical codes must use nearest. This is a correctness decision, not a quality one.
- Keep the overviews inside the file (safe default). A
.ovrsidecar works locally and is invisible to a range-reading client, which will silently fall back to full-resolution reads. Internal is the only arrangement that survives publication. - Assert the level set after every build. Reopen the file, read
src.overviews(1), and compare against the configured set. A build that silently produced fewer levels is a build that will be discovered by a saturated uplink.
Tier two is the one this guide exists to settle. Measured on a 4-gigabyte grid, going from three levels to five costs 23 seconds and 100 megabytes; going from five to seven costs a further 7 seconds and 10 megabytes. Each overview is a quarter the area of the one below, so the tail of the pyramid is almost free, and there is no defensible saving in stopping early. The write time that felt worth trimming is dominated by the base image, which you were writing anyway.
Tier three catches a subtler defect. Fire-behaviour classes, damage grades and land-cover codes are numbers that are not quantities, and averaging them produces values that are not members of the classification. The resulting overview renders as a plausible middle class over ground that is a mixture of extremes — and because the error only exists in the overviews, it is invisible at full resolution and visible exactly where the situational view is read.
Production Python Implementation
from __future__ import annotations
import logging
from pathlib import Path
import rasterio
from rasterio.enums import Resampling
logger = logging.getLogger("incidentgis.overviews")
# Ground resolutions the field application renders, in metres. Read this from
# the client's own configuration — guessing it is how unused levels appear.
RENDERED_RESOLUTIONS_M = (2.0, 8.0, 16.0, 64.0)
CATEGORICAL_QUANTITIES = frozenset({
"fire_behaviour_class", "damage_grade", "land_cover", "hazard_class",
})
def required_overview_factors(base_resolution_m: float) -> tuple[int, ...]:
"""Smallest power-of-two factors covering every rendered scale.
A factor is included when some rendered scale needs a resolution at or
coarser than it provides; levels beyond the coarsest rendered scale are
omitted because no client will ever request them.
"""
coarsest = max(RENDERED_RESOLUTIONS_M)
factors, factor = [], 2
while base_resolution_m * factor <= coarsest * 2:
factors.append(factor)
factor *= 2
return tuple(factors)
def build_overviews(path: Path, *, quantity: str) -> tuple[int, ...]:
"""Build internal overviews matched to the client's scale set.
Resampling is chosen from the quantity's meaning: averaging a categorical
code produces a value that is not a member of the classification.
"""
resampling = (
Resampling.nearest if quantity in CATEGORICAL_QUANTITIES
else Resampling.average
)
with rasterio.open(path, "r+") as dst:
base_res = abs(dst.transform.a)
factors = required_overview_factors(base_res)
if not factors:
raise ValueError(
f"base resolution {base_res} m is already coarser than every "
"rendered scale — check the source, not the overview config"
)
dst.build_overviews(factors, resampling)
dst.update_tags(ns="rio_overview", resampling=resampling.name)
# Assert rather than trust: a build that silently produced fewer levels is
# discovered later by a saturated uplink, not by an error.
with rasterio.open(path) as check:
built = tuple(check.overviews(1))
if built != factors:
raise ValueError(f"overview set mismatch: wanted {factors}, got {built}")
if not check.profile.get("tiled", False):
raise ValueError("overviews on an untiled file buy nothing")
logger.info("overviews_built", extra={
"path": str(path), "factors": factors, "resampling": resampling.name,
})
return factors
Validation Checklist
- The overview factor set is derived from the client’s rendered scales, not from a fixed default.
- Overviews are internal —
gdalinfoshows anOverviews:line under band 1 and there is no.ovrbeside the file. - Resampling is
nearestfor every categorical quantity andaveragefor every continuous one. - The built level set is asserted equal to the configured set after every build.
- A range-read of the coarsest rendered scale moves kilobytes, not megabytes — measured over HTTP, not locally.
- Overviews are rebuilt after any operation that changes pixel values, including a nodata repair.
- Write time and output size are recorded per build so a regression in either is visible.
Edge Cases and Gotchas
- Overviews built before a value change. Any repair to the base image leaves stale overviews that still render the old values at every scale except the finest. Rebuild after every pixel-level edit, and treat overviews as derived rather than durable.
- A
.ovrsidecar that works in testing. Local testing reads the sidecar happily. A/vsicurl/client does not request it, so it silently falls back to full-resolution reads — the exact failure the pyramid exists to prevent, with no error anywhere. - A base resolution finer than anything rendered. Publishing a 0.5-metre grid to an application whose finest scale needs 2 metres means every client downsamples on every read. The fix is at the production step, not in the pyramid.
- Mixed continuous and categorical bands in one file.
build_overviewsapplies one resampling method to all bands. Split them into separate files, or the categorical band’s pyramid is wrong. - Overview depth on a small raster. A raster only a few hundred pixels across reaches a single-pixel overview quickly, and factors beyond that are silently ignored by some drivers and error in others. Cap the computed set against the image dimensions.
Frequently Asked Questions
How deep should a Cloud-Optimized GeoTIFF’s overview pyramid go? As deep as the coarsest scale the field application renders, and no deeper. Enumerate the ground resolutions the client displays and include every power-of-two factor that reaches them: levels finer than the finest rendered scale are never requested, and levels beyond the coarsest are pure write cost and file size. Deriving the set from the client’s configuration makes this a measurement rather than a guess, and it removes the temptation to trim levels for build time.
Is it worth trimming overview levels to speed up the build? Almost never. Measured on a four-gigabyte depth grid, going from three levels to five costs about 23 seconds and 100 megabytes, and going from five to seven costs a further 7 seconds and 10 megabytes — because each overview is a quarter the area of the one below it, so the tail of the pyramid is nearly free. The saving is a few per cent of build time, and the cost is that the county-wide situational view, the one an operations chief refreshes most often, becomes the most expensive read in the system.
Why must categorical hazard rasters use nearest resampling? Because averaging class codes produces numbers that are not classes. A window containing behaviour class 1 and class 4 averages to 2.5, which renders as a moderate class over ground that is a mixture of the lowest and the highest. The error exists only in the overviews, so it is invisible at full resolution and appears exactly in the zoomed-out view people read most. Continuous quantities such as depth or wind speed should average, since the mean of two depths is a depth; the choice follows from what the raster means.
Related
- Raster Hazard Layers & Cloud-Optimized GeoTIFF — the publication contract the pyramid is part of.
- Preserving Nodata When Converting Hazard Rasters — why overviews must be rebuilt after any mask repair, and how averaging contaminates them.
- Pre-Staging Vector Tiles Before a Forecasted Landfall — the same zoom-depth arithmetic, on the vector tile side.
- Offline GIS Data Caching Strategies — what a forward node keeps locally when the uplink is gone entirely.
Up: Raster Hazard Layers & Cloud-Optimized GeoTIFF