Tiling Orthomosaics for Web Map Delivery

A Cloud-Optimized GeoTIFF streams beautifully to a client that speaks GDAL, but the moment you want a Roman villa survey to appear in a public Leaflet map, an offline field tablet, or a partner’s MapLibre viewer, you need a tile pyramid: the orthomosaic pre-rendered into millions of 256-pixel images addressed by zoom, column, and row. Tiling trades storage and build time for the simplest possible client — a plain HTTP directory or a single portable file that any web map can consume with no server logic. This guide, part of orthomosaic generation and tiling, covers gdal2tiles.py, the XYZ-versus-TMS distinction that catches everyone, zoom-range selection, MBTiles packaging, and serving.

Tile pyramid zoom levels A pyramid showing one tile at low zoom expanding to four then sixteen tiles at higher zoom for a web map. Each zoom level quadruples the tile count Zoom 14 whole site Zoom 18 trench detail Zoom 22 native GSD Stop at the zoom that matches your GSD; over-zooming only wastes storage

Context & When to Use

Tile when the consumer is a browser or a field device, not a GIS analyst. An XYZ pyramid needs no tile server — a static file host or CDN serves the {z}/{x}/{y}.png directory directly, which is why it suits public-facing heritage viewers and low-cost hosting. The costs are real: a full-resolution 2 cm survey tiled to native zoom can generate hundreds of thousands of tiles and take significant build time, and every re-survey means a full rebuild. Two decisions govern the outcome. First, zoom range: the maximum zoom should match your GSD — tiling past the point where one tile pixel is finer than one ortho pixel just upsamples blur and multiplies file count for nothing. Second, XYZ versus TMS tile addressing: both schemes lay out identical tiles but number the Y axis in opposite directions. XYZ (Google/OSM/Leaflet/MapLibre default) counts rows from the top; TMS (the OGC-rooted scheme) counts from the bottom. Mismatch the two and your map renders upside-down in the vertical axis. gdal2tiles.py defaults to --xyz in modern GDAL, which is what web clients expect.

Implementation

Tile the validated COG from the Cloud-Optimized GeoTIFF guide. Web tile pyramids are served in Web Mercator, so the source is reprojected to EPSG:3857; keep the original in your site datum (EPSG:27700# substitute your site's EPSG) as the master and treat the tiles as a derivative.

# GDAL 3.8.4
# Reproject the COG to Web Mercator once, then tile
gdalwarp -t_srs EPSG:3857 -r bilinear site_ortho_cog.tif site_ortho_3857.tif

gdal2tiles.py --xyz --zoom=14-22 --processes=8 \
  --tilesize=256 --resampling=average \
  site_ortho_3857.tif ./tiles/

The --xyz flag forces top-origin addressing; omit it only if your client explicitly wants TMS. --zoom=14-22 should end at your GSD-matched level. To automate the whole step and pack the result into a single portable file for the field, drive GDAL from Python and let it write MBTiles — a SQLite container holding every tile in one file, ideal for offline sync to a tablet:

# requirements.txt
# GDAL Python bindings 3.8.4
# GDAL 3.8.4 (osgeo.gdal)
import subprocess
from pathlib import Path

def tile_to_mbtiles(cog: Path, mbtiles: Path, zmin: int, zmax: int) -> None:
    """Produce an MBTiles pyramid from a COG for offline field delivery."""
    # gdal_translate writes MBTiles directly; ZOOM_LEVEL_STRATEGY keeps
    # the top level from over-decimating below the site extent.
    subprocess.run([
        "gdal_translate", "-of", "MBTILES",
        "-co", "TILE_FORMAT=PNG",       # lossless tiles; use JPEG for smaller visual base
        "-co", f"ZOOM_LEVEL_STRATEGY=AUTO",
        str(cog), str(mbtiles),
    ], check=True)
    # Build the pyramid inside the MBTiles container
    subprocess.run([
        "gdaladdo", "-r", "average", str(mbtiles),
        *[str(2 ** n) for n in range(1, zmax - zmin + 1)],
    ], check=True)


if __name__ == "__main__":
    tile_to_mbtiles(Path("site_ortho_3857.tif"), Path("site_ortho.mbtiles"), 14, 22)

For a directory pyramid, gdal2tiles.py also writes a ready leaflet.html and openlayers.html preview into the output folder — open it to sanity-check the layer before wiring it into your own viewer. Serve the ./tiles/ directory from any static host and point a Leaflet layer at https://host/tiles/{z}/{x}/{y}.png.

Verification

Confirm the pyramid structure and a sample tile before publishing. The directory scheme should contain one folder per zoom level:

ls tiles/ | sort -n
# expected: 14 15 16 17 18 19 20 21 22

Check a mid-zoom tile decodes and has the right dimensions:

# GDAL 3.8.4
gdalinfo tiles/18/130026/86545.png | grep "Size is"
# expected: Size is 256, 256

For the MBTiles container, query its metadata table to assert the zoom bounds landed as intended:

sqlite3 site_ortho.mbtiles "SELECT name, value FROM metadata WHERE name IN ('minzoom','maxzoom','format');"
# expected:
# minzoom|14
# maxzoom|22
# format|png

Finally, load the layer in the generated leaflet.html and confirm the orthomosaic sits over its true location on the base map — a north-up, correctly-placed site is the real acceptance test that XYZ/TMS addressing is right.

Field note. Tiles reprojected into Web Mercator (EPSG:3857) are for display only — never take a measurement off the tiled layer. Mercator scale distortion grows with latitude, so a rampart measured on the web tiles reads longer than it is on the ground. Keep the EPSG:27700 COG as the metric source and let the tiles be the picture.

Common Errors & Fixes

  • ERROR 1: Input file must have a georeferencing from gdal2tiles.py — you fed it a plain image or a raster whose CRS was stripped. Run gdalinfo to confirm a Coordinate System is: block, and reproject to EPSG:3857 first.
  • Map renders upside-down / tiles appear at wrong rows — XYZ/TMS mismatch. Re-run with --xyz for Leaflet/MapLibre, or set your client to TMS if the tiles were built without it.
  • ERROR 1: Attempt to create MBTiles dataset ... but content is not in EPSG:3857 — MBTiles only accepts Web Mercator. Reproject with gdalwarp -t_srs EPSG:3857 before the translate step; tiling a native-datum raster straight to MBTiles always fails here.

Part of Photogrammetry & 3D Site Mapping Pipelines.