Generating Web Mercator Tile Pyramids
Reproject at exactly a zoom level’s cell size, with the grid snapped to tile edges:
import morecantile
TMS = morecantile.tms.get("WebMercatorQuad")
zoom = 14
cell = TMS.matrix(zoom).cellSize # ~9.55 m at the equator
# resolution=(cell, cell) plus bounds snapped to multiples of the tile size
# makes every pixel edge coincide with a tile edge at this zoom
Get the resolution and the origin right and every zoom level below it lands exactly on a stored overview. This page belongs to preparing rasters for the web in Visualization, Tiling & Web Delivery.
Zoom Levels Are Fixed Resolutions
Picking a maximum zoom much finer than the source resolution is the commonest waste in pyramid generation. Each extra level quadruples the base raster’s pixel count while adding no information, because the pixels are merely upsampled copies of the source.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
rasterio |
>=1.3.0 |
Warping and writing the aligned raster |
morecantile |
>=5.0 |
Tile matrix definitions and cell sizes |
rio-cogeo |
>=5.0 |
Overviews and COG validation |
pip install "rasterio>=1.3.0" "morecantile>=5.0" "rio-cogeo>=5.0"
Complete Working Example
import math
import morecantile
import rasterio
from rasterio.enums import Resampling
from rasterio.transform import from_origin
from rasterio.warp import reproject, transform_bounds
TMS = morecantile.tms.get("WebMercatorQuad")
def best_zoom(src_res_m: float) -> int:
"""Smallest zoom whose cell size is at least as fine as the source."""
for z in range(0, 24):
if TMS.matrix(z).cellSize <= src_res_m:
return z
return 23
def aligned_web_mercator(src_path: str, dst_path: str, *,
resampling: Resampling = Resampling.bilinear) -> int:
with rasterio.open(src_path) as src:
src_res = abs(src.transform.a) # assumes metres
zoom = best_zoom(src_res)
cell = TMS.matrix(zoom).cellSize
tile_m = cell * 256
left, bottom, right, top = transform_bounds(src.crs, "EPSG:3857",
*src.bounds, densify_pts=21)
# Snap outward to whole tiles so pixel and tile edges coincide
origin_x = TMS.xy_bounds(morecantile.Tile(0, 0, 0)).left
origin_y = TMS.xy_bounds(morecantile.Tile(0, 0, 0)).top
left = origin_x + math.floor((left - origin_x) / tile_m) * tile_m
right = origin_x + math.ceil((right - origin_x) / tile_m) * tile_m
top = origin_y - math.floor((origin_y - top) / tile_m) * tile_m
bottom = origin_y - math.ceil((origin_y - bottom) / tile_m) * tile_m
width = int(round((right - left) / cell))
height = int(round((top - bottom) / cell))
transform = from_origin(left, top, cell, cell)
profile = src.profile | {
"crs": "EPSG:3857", "transform": transform,
"width": width, "height": height,
"tiled": True, "blockxsize": 256, "blockysize": 256,
"compress": "deflate",
}
with rasterio.open(dst_path, "w", **profile) as dst:
for b in range(1, src.count + 1):
reproject(rasterio.band(src, b), rasterio.band(dst, b),
src_transform=src.transform, src_crs=src.crs,
dst_transform=transform, dst_crs="EPSG:3857",
src_nodata=src.nodata, dst_nodata=src.nodata,
resampling=resampling, num_threads=4)
factors = [2 ** i for i in range(1, 7)]
dst.build_overviews(factors, Resampling.average)
return zoom
A block size of 256 here is deliberate and differs from the 512 used elsewhere on this site. With the grid snapped to 256-pixel tiles at the maximum zoom, each internal block corresponds exactly to one map tile at that zoom, so serving a tile is a single block read with no resampling at all.
Why Alignment Matters
The unaligned case is not wrong, exactly — the tiler resamples and produces a plausible image. But every tile is resampled on every request, the image is subtly softer than the source, and each request reads a little more than a tile’s worth of data. Over a heavily viewed layer those costs add up, and they are the entire reason to make a Web Mercator copy in the first place.
The alternative to doing this by hand is rio cogeo create --web-optimized, which performs the same snapping and resolution choice. It is the shorter path in practice; knowing what it does is what lets you diagnose a layer that looks soft despite the flag being set — usually a source whose resolution sat awkwardly between two zooms.
Choosing Resampling for the Warp
Reprojecting into Web Mercator resamples every pixel once, and the choice of kernel matters in the usual way. Continuous imagery and indices take bilinear or cubic, which produce a smooth result without inventing extremes; class rasters take nearest, and their overviews take mode, for the reasons set out in choosing the right resampling method for Sentinel-2.
There is one consideration specific to Web Mercator: because the maximum zoom’s cell size rarely equals the source resolution exactly, the warp is also a slight resize — 10 m onto 9.55 m, for instance. That is a 5% upsample, invisible with bilinear resampling and noticeable as occasional duplicated rows and columns with nearest. For a class raster that must stay categorical, accept the duplication; for anything continuous, bilinear hides it completely.
Verification
import math
import morecantile
import rasterio
TMS = morecantile.tms.get("WebMercatorQuad")
with rasterio.open("web_copy.tif") as src:
assert src.crs.to_epsg() == 3857
res = src.transform.a
zoom = min(range(24), key=lambda z: abs(TMS.matrix(z).cellSize - res))
cell = TMS.matrix(zoom).cellSize
assert abs(res / cell - 1) < 1e-3, f"resolution off grid for z{zoom}"
origin_x = TMS.xy_bounds(morecantile.Tile(0, 0, 0)).left
offset = (src.transform.c - origin_x) / (cell * 256)
assert abs(offset - round(offset)) < 1e-6, "origin not on a tile boundary"
assert len(src.overviews(1)) >= 6
The origin check is the one that catches a hand-rolled pyramid that got the resolution right and the snapping wrong. Such a file looks nearly perfect and serves every tile with a fraction-of-a-pixel resample — the defect is real but only visible as a slight softness, which is exactly why it is worth asserting rather than eyeballing.
Common Errors
The web layer is blurrier than the source
The resolution is between zoom levels or the origin is not snapped. Check both with the assertions above, or regenerate with the web-optimized option.
The file is far larger than expected
The maximum zoom is one or two levels too fine, quadrupling the pixel count each time. Choose the zoom from the source resolution.
Reprojection fails near the poles
Web Mercator is undefined beyond about 85 degrees of latitude. Clip the source to that band first.
Overviews look blocky for a continuous product
Nearest resampling was used for the overviews. Use average for continuous data and reserve mode for class rasters.
Frequently Asked Questions
Q: What maximum zoom suits 10 metre imagery? Zoom 14, whose cell size at the equator is about 9.55 metres, is the natural match for 10 metre data. Zoom 15 would upsample every pixel by two in each direction and store four times the data for no additional detail.
Q: Why snap the bounds to the tile grid? So that pixel edges coincide with tile edges. An unaligned grid means every tile straddles pixel boundaries and must be resampled, which softens the image and makes each tile read slightly more data than it needs.
Q: Does the web_optimized flag do the same thing? Yes — rio-cogeo’s web_optimized option performs the alignment and resolution choice for you. Doing it by hand is worth understanding so you can tell whether the flag did what you expected when a layer looks soft.
Q: Should the web copy keep the source’s nodata value? Yes, carried through the warp as both source and destination nodata, so that the corners outside the rotated footprint are transparent rather than a solid colour. A missing nodata is the usual cause of black wedges around a reprojected scene.
Related
- Preparing Rasters for the Web — when a Web Mercator copy is worth making at all.
- Converting Rasters to 8-bit for Display — the value-side conversion that usually accompanies this one.
- Adding Internal Overviews with the Right Resampling — overview resampling in general.
- Publishing Static Tiles to Object Storage — pre-rendering from an aligned pyramid.