This guide is part of Raster Mosaicking & Tiling for Pipeline Outputs, within the broader Automated Vector & Raster Cleaning Workflows reference.

Generating XYZ Tiles from a Raster

Web map tiles are a different product from an analytical raster: a fixed quadtree in Web Mercator, 256 pixels square, addressed by zoom, column and row. Producing them from a pipeline output is mostly a question of deciding how many to produce and when.

Why Tile Generation Goes Wrong

  • Rendering every zoom level. Zoom 18 over a country is billions of tiles, almost none of which will ever be requested.
  • Rendering empty tiles. Without a footprint intersection, most tiles in a bounding box contain no data and cost storage anyway.
  • Implicit contrast stretching. A per-tile stretch makes neighbouring tiles disagree, producing visible checkerboarding in the map.
  • Ignoring the projection change. Data in a national grid must be reprojected to Web Mercator, and doing that per tile without a common grid reintroduces the seam problem.

Version and Environment Compatibility

Component Version Note
rio-tiler >=6.4 Reads a window from a COG and resamples into a tile
morecantile >=5.0 Tile matrix sets, including the WebMercatorQuad definition
rasterio >=1.3 Underlying windowed reads and warping
numpy >=1.26 Colour mapping applied over arrays
pip install "rio-tiler>=6.4" "morecantile>=5.0" "rasterio>=1.3"

Choosing the Zoom Range

Where the data stops adding detail Ground resolution per Web Mercator zoom level at the equator, alongside the tile count for a fixed area. Zoom 12 gives 38 metres per pixel, zoom 14 gives 9.5, which matches a 10 metre source, and zoom 16 gives 2.4 metres while producing sixteen times as many tiles containing no additional information. zoom · m/pixel at the equator · relative tile count z12 · 38.2 m coarser than the data — usable, loses detail ×1 z14 · 9.55 m matches a 10 m source — the natural maximum ×16 z16 · 2.39 m interpolation only — no new information ×256 z18 · 0.60 m pure upsampling, four levels past the data ×4096 Render to the level that matches the source and let the client upsample beyond it — the pixels are identical and the storage is not.

The tiles_for_raster Recipe

from __future__ import annotations

import logging
import math
from pathlib import Path

import morecantile
import rasterio
from rasterio.warp import transform_bounds
from rio_tiler.io import Reader

logger = logging.getLogger(__name__)
TMS = morecantile.tms.get("WebMercatorQuad")


def max_useful_zoom(resolution_m: float, latitude: float = 0.0) -> int:
    """Highest zoom whose ground resolution is still finer than the source."""
    equator_circumference = 40_075_016.686
    scale = math.cos(math.radians(latitude))
    for zoom in range(0, 24):
        tile_resolution = equator_circumference * scale / (256 * 2 ** zoom)
        if tile_resolution <= resolution_m:
            return zoom
    return 23


def tiles_for_raster(cog_path: Path, min_zoom: int | None = None) -> list[morecantile.Tile]:
    """Enumerate only the tiles the raster's footprint actually covers."""
    with rasterio.open(cog_path) as src:
        resolution = abs(src.transform.a)
        west, south, east, north = transform_bounds(src.crs, "EPSG:4326", *src.bounds, densify_pts=21)

    centre_latitude = (south + north) / 2
    max_zoom = max_useful_zoom(resolution, centre_latitude)
    start = min_zoom if min_zoom is not None else max(0, max_zoom - 6)

    tiles = [
        tile
        for zoom in range(start, max_zoom + 1)
        for tile in TMS.tiles(west, south, east, north, [zoom])
    ]
    logger.info("%s: zooms %d–%d, %d tiles", cog_path.name, start, max_zoom, len(tiles))
    return tiles


def render_tile(cog_path: Path, tile: morecantile.Tile, out_root: Path,
                rescale: tuple[float, float], colormap: dict) -> Path | None:
    """Render one tile, skipping any whose window contains no data at all."""
    with Reader(str(cog_path)) as reader:
        image = reader.tile(tile.x, tile.y, tile.z, tilesize=256)

    if image.mask.max() == 0:
        return None   # entirely nodata — do not write an empty object

    image.rescale(in_range=(rescale,))
    content = image.render(img_format="PNG", colormap=colormap)

    destination = out_root / str(tile.z) / str(tile.x) / f"{tile.y}.png"
    destination.parent.mkdir(parents=True, exist_ok=True)
    destination.write_bytes(content)
    return destination

Key Implementation Notes

  • The maximum zoom comes from the data, not from the map. Rendering past the source resolution multiplies storage by four per level while adding nothing a client could not interpolate itself.
  • The rescale range is passed in, not computed per tile. A per-tile stretch is the cause of visible checkerboarding, because each tile then maps a different value range onto the same colours.
  • Empty tiles are skipped, not written. A transparent PNG costs storage, a request and a cache entry; a 404 costs none of those and most clients handle it correctly.
  • The colormap is an argument. It belongs in versioned configuration next to the product, because a changed ramp changes every published tile and needs the same version bump as changed data.
  • Latitude enters the zoom calculation. Web Mercator resolution scales with the cosine of latitude, so a Scandinavian product reaches its natural zoom a level earlier than an equatorial one.
  • Tiles are written under a zoom/x/y path. That is the convention every client expects, and it makes a whole zoom level deletable as a prefix.
Dynamic tiling versus pre-rendering Two serving models compared. The dynamic tiler receives a tile request, reads the matching window from the cloud-optimized GeoTIFF and renders on the fly, so nothing is stored and updates are immediate. The pre-rendered model writes every tile in advance to object storage, which serves with no compute but must be regenerated and invalidated whenever the data or the colour ramp changes. dynamic · read a window per request z/x/y request tiler + COG PNG no storage, instant updates, compute per request pre-rendered · write everything first render job object store PNG no compute, but rebuild and invalidate on every change

Cache Headers and Versioned Paths

A tile that is served without a cache policy is fetched again on every pan, which wastes bandwidth on both sides and makes the map feel slow. A tile that is cached forever cannot be corrected. The way out is to make the path change when the content changes.

Publishing tiles under a version segment Two tile prefixes coexist. Version seven holds the tiles clients are currently pointed at, cached with a long immutable lifetime. Version eight is written by the new render and becomes live when the client configuration changes. Because no object was overwritten, nothing needs invalidating and a rollback is a single pointer change back to version seven. tiles/ndvi/{version}/{z}/{x}/{y}.png v7 · live, cached immutably stays readable after the switch v8 · rendering now nothing overwritten client points at a version the switch and the rollback are the same edit Overwriting in place instead means an invalidation that some CDN somewhere will not honour for a week.

Put a version segment in the tile path — tiles/ndvi/v7/{z}/{x}/{y}.png — and set a long immutable cache lifetime on the objects. A new render publishes under v8, the client is pointed at the new prefix, and nothing needs to be invalidated because nothing was overwritten. The old version stays available until it is deliberately expired, which also makes a rollback a one-line change.

The alternative, overwriting tiles in place and relying on cache invalidation, works until a CDN somewhere holds a stale copy for a week and nobody can explain why one region of the map disagrees with the data.

Troubleshooting Tile Rendering

Symptom Likely cause Fix
Visible checkerboarding between tiles Per-tile contrast stretch Pass one fixed rescale range for the whole product
Storage far larger than expected Rendering past the source resolution Cap at the zoom derived from ground resolution
Blank tiles in the middle of the coverage Nodata not honoured during render Confirm the COG’s mask or nodata is set
Tiles misaligned with the base map Product not reprojected to Web Mercator Warp to EPSG:3857 before tiling, on one grid
Updates not visible to users Tiles overwritten behind a CDN cache Version the tile path and publish under a new prefix

Integration Note

Tile generation is a publication concern, not a processing one: it consumes the validated COGs from writing cloud-optimized GeoTIFFs with rio-cogeo and produces a presentation artefact. Run it as a separate downstream job so that a slow render cannot delay the data pipeline, and key it on the same tile identifiers so that only the map tiles overlapping a rebuilt data tile are regenerated.

Serving Values, Not Only Colours

A tiled map answers “what does this look like”; analysts also ask “what is the value here”. Three additions cover that without a second product.

A point-query endpoint reads the single pixel under a coordinate straight from the COG. It is the same windowed read the tiler already performs, bounded to one pixel, and it returns the actual value rather than a colour.

Numeric tiles — sometimes called terrain-RGB — encode values into the colour channels so a client can decode them. Useful for elevation and continuous indices where a browser needs the numbers, at the cost of an encoding scheme every consumer must implement identically.

A legend document published alongside the tiles states the ramp, its breaks and the units. Without it a colour map is uninterpretable, and the legend is invariably the thing that goes stale first — which is another reason to version it in the same path segment as the tiles it describes.