Preparing Rasters for the Web

An analytical raster and a web raster want different things. The analytical product wants the sensor’s native projection, its full bit depth, and nothing thrown away; the web rendition wants Web Mercator, eight bits, an aggressive stretch and a pyramid that lines up with tile zoom levels. Dynamic tiling hides much of that difference, but not all of it, and knowing which conversions still pay is the substance of this topic — part of Visualization, Tiling & Web Delivery.

The governing rule is that the web copy is a derivative. It is lossy in value and in geometry, it can be regenerated whenever the rendering decisions change, and it must never become the file people analyse.


Prerequisites

pip install "rasterio>=1.3.0" "rio-cogeo>=5.0" "numpy>=1.23" "morecantile>=5.0" "pillow>=10.0"
Package Minimum version Why required
rasterio 1.3.0 Reprojection, resampling and writing
rio-cogeo 5.0 COG creation aligned to the Web Mercator grid
numpy 1.23 Stretch arithmetic and dtype conversion
morecantile 5.0 Tile matrix definitions and zoom-level resolutions
pillow 10.0 PNG and JPEG encoding for thumbnails and static tiles

You need to know the source CRS and the display stretch, which come from extracting and parsing raster metadata and from the rendering decisions in rendering rasters with matplotlib respectively.


Step-by-Step Workflow

Step 1 — Decide whether a Web Mercator copy is worth it

When a reprojected copy pays for itself If the layer is viewed rarely, serve the analytical file directly and let the tiler reproject per request. If it is viewed constantly, or spans several UTM zones, or must be pre-rendered as static tiles, a Web Mercator copy removes per-request warping and is worth the extra storage. Reproject, or let the tiler do it per request? How often is the layer viewed? occasionally serve the analytical file warp per request is cheap no copy, no drift constantly make a Web Mercator copy aligned to the tile grid saves a warp on every tile spans UTM zones copy, regardless of traffic one grid beats a mosaic of clashing projections Every copy is a thing that can go stale. Make one only when the traffic or the geometry demands it.

The multi-zone case is the one people underestimate. A product covering a country that straddles three UTM zones cannot be a single analytical file without a projection choice, and Web Mercator is a reasonable choice for the display copy precisely because it is a single global grid. It is a poor choice for analysis — area is badly distorted away from the equator — so the two copies serve genuinely different purposes.

Step 2 — Reproject onto the tile grid, not merely into the CRS

import rasterio
from rasterio.enums import Resampling
from rasterio.warp import calculate_default_transform, reproject
import morecantile

TMS = morecantile.tms.get("WebMercatorQuad")


def reproject_to_tile_grid(src_path: str, dst_path: str, *, zoom: int,
                           resampling: Resampling = Resampling.bilinear) -> None:
    """Reproject to Web Mercator at exactly the resolution of a tile zoom level."""
    target_res = TMS.matrix(zoom).cellSize      # metres per pixel at this zoom

    with rasterio.open(src_path) as src:
        transform, width, height = calculate_default_transform(
            src.crs, "EPSG:3857", src.width, src.height, *src.bounds,
            resolution=(target_res, target_res),
        )
        profile = src.profile | {
            "crs": "EPSG:3857", "transform": transform,
            "width": width, "height": height,
            "compress": "deflate", "tiled": True,
            "blockxsize": 512, "blockysize": 512,
        }
        with rasterio.open(dst_path, "w", **profile) as dst:
            for band in range(1, src.count + 1):
                reproject(
                    rasterio.band(src, band), rasterio.band(dst, band),
                    src_crs=src.crs, dst_crs="EPSG:3857",
                    src_transform=src.transform, dst_transform=transform,
                    src_nodata=src.nodata, dst_nodata=src.nodata,
                    resampling=resampling, num_threads=4,
                )

Passing an explicit resolution is what turns “in Web Mercator” into “on the tile grid”. Without it the default transform picks a resolution derived from the source, tile requests land between pyramid levels, and every tile is resampled from a level that does not match — losing most of the benefit the copy was made for. The general reprojection mechanics are in reprojecting a raster from UTM to WGS84 with rasterio; only the resolution argument is specific to this use.

Step 3 — Convert to display dtype with a recorded stretch

import json

import numpy as np
import rasterio


def to_display_8bit(src_path: str, dst_path: str,
                    limits: dict[int, tuple[float, float]]) -> None:
    """Rescale each band onto 1-255, reserving 0 for nodata, and record the stretch."""
    with rasterio.open(src_path) as src:
        profile = src.profile | {"dtype": "uint8", "nodata": 0,
                                 "compress": "deflate", "tiled": True}
        with rasterio.open(dst_path, "w", **profile) as dst:
            for band in range(1, src.count + 1):
                arr = src.read(band, masked=True).astype("float32")
                lo, hi = limits[band]
                scaled = np.clip((arr - lo) / max(hi - lo, 1e-9), 0, 1) * 254 + 1
                out = scaled.filled(0).astype("uint8")
                dst.write(out, band)
                dst.update_tags(band, stretch_min=str(lo), stretch_max=str(hi))
            dst.update_tags(display_stretch=json.dumps(
                {str(k): list(v) for k, v in limits.items()}))

The stretch must be fixed and recorded, not recomputed per scene. Percentiles derived per scene make every image internally well contrasted and mutually incomparable, so a mosaic of them has visible seams at every scene boundary for reasons that have nothing to do with the ground. Choosing one stretch for a whole collection is the same discipline as fixing a colour map, and the histogram tools in histogram matching across scenes help when the scenes genuinely differ.

Step 4 — Build the pyramid and validate

import rasterio
from rasterio.enums import Resampling
from rio_cogeo.cogeo import cog_translate, cog_validate
from rio_cogeo.profiles import cog_profiles


def finalise_web_cog(src_path: str, dst_path: str, *, categorical: bool = False) -> None:
    resampling = "mode" if categorical else "average"
    cog_translate(
        src_path, dst_path, cog_profiles.get("deflate"),
        overview_level=6, overview_resampling=resampling,
        web_optimized=True,                  # aligns the grid to WebMercatorQuad
        in_memory=False, quiet=True,
    )
    valid, errors, _ = cog_validate(dst_path)
    if not valid:
        raise RuntimeError(errors)

web_optimized=True does the alignment work described in step two automatically, which makes it the shorter route when the stretch and dtype conversion have already happened. It is worth knowing what it does rather than treating it as a magic flag, because when the output looks subtly resampled the reason is almost always that the source resolution did not correspond to a zoom level.


Static Tiles and When They Still Win

Dynamic against static tiles Dynamic tiling has no build step, lets rendering change per request, and costs compute per cache miss. Static tiles cost a full pyramid build and a large number of small objects, fix the rendering permanently, and then serve from plain storage with no compute at all. The break-even is very high traffic or an environment where no server can run. Two ways to answer a tile request dynamic build step: none storage: one COG rendering: changeable per request serving: compute per cache miss the default for nearly everything static build step: full pyramid, hours storage: millions of small objects rendering: frozen at build time serving: plain storage, no compute for fixed basemaps and offline use A zoom-14 pyramid over one country is on the order of a million tiles — plan the object count, not just the bytes.

The object count is the part that surprises people. Each additional zoom level quadruples the tile count, so extending a pyramid from zoom 12 to zoom 14 multiplies it by sixteen. Storage cost is usually fine; the per-object overhead, the listing time and the sheer awkwardness of managing millions of small files are what make static pyramids unattractive for anything that changes.

Where they remain the right answer is genuinely fixed content — a basemap, a one-off published map, an offline deployment on a vessel or in the field with no server — and there the build is a one-time cost paid to remove all serving infrastructure.

import morecantile
from rio_tiler.io import Reader

TMS = morecantile.tms.get("WebMercatorQuad")


def write_static_tiles(cog_path: str, out_dir: str, *, min_zoom: int = 8,
                       max_zoom: int = 13, rescale: tuple[float, float] = (0, 3000)):
    """Pre-render a pyramid to disk in the conventional z/x/y layout."""
    from pathlib import Path

    with Reader(cog_path) as cog:
        for z in range(min_zoom, max_zoom + 1):
            for tile in TMS.tiles(*cog.geographic_bounds, [z]):
                try:
                    img = cog.tile(tile.x, tile.y, tile.z, tilesize=256)
                except Exception:
                    continue                      # tile outside the data footprint
                path = Path(out_dir) / str(z) / str(tile.x)
                path.mkdir(parents=True, exist_ok=True)
                (path / f"{tile.y}.png").write_bytes(
                    img.rescale(in_range=((rescale[0], rescale[1]),))
                       .render(img_format="PNG"))

Thumbnails and Quicklooks

Every published product should carry a small rendered image next to it. It costs a second of compute, makes the archive browsable with no tooling, and catches defects that no schema check will.

import numpy as np
import rasterio
from PIL import Image


def write_thumbnail(src_path: str, png_path: str, *, width: int = 512,
                    limits: tuple[float, float] | None = None) -> None:
    with rasterio.open(src_path) as src:
        factor = max(1, src.width // width)
        arr = src.read(1, out_shape=(max(1, src.height // factor),
                                     max(1, src.width // factor)), masked=True)
    lo, hi = limits or (float(np.percentile(arr.compressed(), 2)),
                        float(np.percentile(arr.compressed(), 98)))
    scaled = np.clip((arr.astype("float32") - lo) / max(hi - lo, 1e-9), 0, 1)
    rgba = np.zeros((*scaled.shape, 4), dtype="uint8")
    rgba[..., :3] = (scaled.filled(0) * 255).astype("uint8")[..., None]
    rgba[..., 3] = np.where(np.ma.getmaskarray(scaled), 0, 255)
    Image.fromarray(rgba, mode="RGBA").save(png_path, optimize=True)

Writing the alpha channel from the mask is what makes a thumbnail honest: nodata is transparent rather than black, so a scene that is half missing looks half missing rather than half dark. Registering the thumbnail as an asset on the product’s catalog record — the thumbnail role described in attaching model metadata to STAC items — means every catalog browser shows it without further work.


What Web Mercator Costs You

Reprojecting for display is not free in information, and it is worth being explicit about what is given up so nobody analyses the web copy by mistake.

Area distortion by latitude in Web Mercator At the equator a Web Mercator pixel represents its nominal ground area. At thirty degrees the area is inflated by about a third, at forty-five degrees it is doubled, and at sixty degrees it is four times too large. Any area computed on a Web Mercator grid inherits that factor, which is why the projection is fine for looking at and wrong for measuring. A pixel covers less ground the further from the equator you go 1.0x 1.3x 2.0x 4.0x 0 deg 30 deg 45 deg 60 deg Area estimates belong on the analytical grid; the web copy is for looking at.

Three specific losses come with the display copy. Area is distorted by the square of the secant of the latitude, so any hectare figure computed from a Web Mercator raster at mid-latitudes is roughly double the truth — the area work in estimating area with stratified random sampling must be done on the analytical file. Values are quantised to 256 levels by the 8-bit conversion, so subtle gradients become visible steps and no meaningful difference can be computed. And every pixel has been resampled once, so the crisp edges of a class raster are softened unless mode resampling was used throughout.

None of those matter for a map somebody looks at, and all of them matter for a number somebody quotes. Keeping the two files clearly named and clearly documented — *_analysis.tif and *_web.tif, with the web copy’s tags saying what was done to it — prevents the failure where an analyst picks up the display rendition because it was the one that loaded quickly.


Parameter Reference

Parameter Type Default Usage note
resolution (x, y) derived Set from the target zoom’s cell size to land on the tile grid
web_optimized bool False True aligns output to WebMercatorQuad; equivalent to doing it by hand
overview_resampling str nearest average for continuous, mode for categorical
overview_level int auto 6 levels covers a country-scale layer down to a whole-view zoom
dtype for display str uint8 With 0 reserved for nodata and the stretch recorded in tags
tilesize int 256 512 for static pyramids halves the object count
in_memory bool auto False for large inputs, or the conversion will exhaust RAM
thumbnail width int 512 Enough to judge a scene, small enough to load in a catalog list

Verification & Testing

import morecantile
import rasterio

TMS = morecantile.tms.get("WebMercatorQuad")

with rasterio.open("web_copy.tif") as src:
    assert src.crs.to_epsg() == 3857, "not in Web Mercator"
    res = abs(src.transform.a)
    zooms = {z: TMS.matrix(z).cellSize for z in range(6, 18)}
    nearest = min(zooms, key=lambda z: abs(zooms[z] - res))
    ratio = res / zooms[nearest]
    assert 0.99 < ratio < 1.01, f"resolution is off the tile grid (z{nearest}, {ratio:.3f})"
    assert src.overviews(1), "no overviews"
    assert src.profile["tiled"], "not internally tiled"

The resolution-to-zoom check is the one worth automating, because a file that is nominally correct but sits between zoom levels produces tiles that are always slightly resampled — a soft, subtly blurred layer that people describe as “looking worse than the original” without being able to say why.

For the 8-bit conversion, verify that the stretch tags round-trip and that the nodata footprint survived:

with rasterio.open("web_display.tif") as src:
    assert src.tags().get("display_stretch"), "stretch not recorded"
    assert src.nodata == 0
    arr = src.read(1)
    assert arr.min() == 0 or (arr > 0).all(), "0 must mean nodata, not a valid value"

Troubleshooting

The web layer looks blurrier than the source

The file’s resolution does not match a zoom level, so every tile is resampled. Reproject with an explicit resolution from the tile matrix, or use web_optimized=True.

Scene boundaries are visible in a mosaic of web copies

Each scene was stretched on its own percentiles. Fix one stretch for the collection and apply it everywhere.

The conversion runs out of memory

in_memory defaulted to true for a file just under the threshold. Pass in_memory=False explicitly for anything above a few hundred megabytes.

Static tiles are missing around the edges of the data

Tiles outside the footprint raise rather than returning empty, and the exception handler skipped them — which is correct — but the footprint used for the tile list came from the file’s bounds rather than its valid-data mask. Use the data footprint if the scene has large nodata regions.

The thumbnail is black

Nodata dominated the percentile calculation, or the read was not masked. Read with masked=True and derive limits from compressed() values only.


Frequently Asked Questions

Q: Do I have to reproject to Web Mercator? No — a dynamic tiler reprojects per request, and for most layers that is cheap enough. Make a Web Mercator copy when a layer is viewed constantly, when the source spans several UTM zones, or when tiles must be pre-rendered.

Q: Should the web copy replace the analytical file? Never. The 8-bit Web Mercator rendition is a derivative for display and is lossy in both value and geometry. Keep the analytical product as the authoritative file and treat the web copy as something that can be regenerated at any time.

Q: Are static tiles ever the right answer now? For a fixed basemap served at very high rates, or for an offline or air-gapped deployment where no server can run, yes. For anything that changes or needs adjustable rendering, dynamic tiling is simpler and cheaper.

Q: What about JPEG compression for the web copy? It is reasonable for a true-colour display rendition and wrong for anything else. JPEG is lossy in a way that alters pixel values, so a classification or an index copy must stay lossless; for imagery intended only to be looked at, the size saving is substantial and the artefacts are invisible at normal zoom.

Q: How do I keep the web copy from going stale when the product is reprocessed? Generate it in the same pipeline run that produces the analytical file, never as a separate manual step, and write the source product’s identifier into the web copy’s tags. A nightly job that regenerates display copies independently will eventually run against a product that has since been superseded, and nothing in the file itself will say so. Deriving both in one run also means a single failure leaves neither published, which is far easier to reason about than a display layer that quietly describes last month’s data.


Deep-Dive Articles