An XYZ address maps onto WMTS almost directly — x becomes TILECOL, y becomes TILEROW, and both count downward from the top-left, same as slippy maps. The two things you cannot assume are the tile matrix identifier, which is frequently EPSG:3857:9 rather than 9, and the row direction of the source, because the older TMS convention counts upward and produces a perfectly plausible vertically mirrored map.
Slippy-map XYZ, as used by every web mapping library, places tile (0,0) at the top-left of the world and counts rows downward. WMTS does the same: TILEROW increases southward from the declared TopLeftCorner. TMS — the older OSGeo specification whose URLs look identical — counts rows upward from the bottom.
Because the three URL forms are visually indistinguishable, a source labelled only “tile server” can be any of them, and the failure is not an error but a map that is mirrored about the equator. At low zoom that is obvious; at high zoom over featureless terrain it is not, and it has shipped to production more than once. The conversion y_tms = 2**z - 1 - y_xyz is trivial — knowing which side of it you are on is the actual work.
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from xml.etree import ElementTree as ET
import requests
NS = {
"wmts": "http://www.opengis.net/wmts/1.0",
"ows": "http://www.opengis.net/ows/1.1",
}
@dataclass(frozen=True)
class MatrixLevel:
identifier: str # what TILEMATRIX must literally contain
matrix_width: int
matrix_height: int
def contains(self, col: int, row: int) -> bool:
return 0 <= col < self.matrix_width and 0 <= row < self.matrix_height
@dataclass(frozen=True)
class TileMatrixSet:
identifier: str
levels: dict[int, MatrixLevel] # zoom -> level
def level(self, zoom: int) -> MatrixLevel:
try:
return self.levels[zoom]
except KeyError:
raise KeyError(
f"zoom {zoom} is not in tile matrix set {self.identifier!r}; "
f"available: {sorted(self.levels)}") from None
@lru_cache(maxsize=16)
def read_matrix_set(capabilities_url: str, set_id: str) -> TileMatrixSet:
resp = requests.get(capabilities_url, timeout=60, params={
"SERVICE": "WMTS", "VERSION": "1.0.0", "REQUEST": "GetCapabilities"})
resp.raise_for_status()
root = ET.fromstring(resp.content)
for tms in root.iter(f"{{{NS['wmts']}}}TileMatrixSet"):
identifier = tms.findtext("ows:Identifier", "", NS)
if identifier != set_id:
continue
levels: dict[int, MatrixLevel] = {}
for matrix in tms.findall("wmts:TileMatrix", NS):
raw = matrix.findtext("ows:Identifier", "", NS)
# The identifier may be "9" or "EPSG:3857:9" — the zoom is the
# trailing integer, but TILEMATRIX must carry the whole string.
zoom = int(raw.rsplit(":", 1)[-1])
levels[zoom] = MatrixLevel(
identifier=raw,
matrix_width=int(matrix.findtext("wmts:MatrixWidth", "0", NS)),
matrix_height=int(matrix.findtext("wmts:MatrixHeight", "0", NS)))
return TileMatrixSet(identifier, levels)
raise KeyError(f"tile matrix set {set_id!r} is not advertised")
def tms_to_xyz(y: int, zoom: int) -> int:
"""Flip a TMS row index into the XYZ/WMTS convention."""
return (1 << zoom) - 1 - y
def gettile_params(matrix_set: TileMatrixSet, layer: str, zoom: int,
x: int, y: int, style: str = "default",
fmt: str = "image/png", source: str = "xyz") -> dict[str, str]:
"""Build KVP GetTile parameters from a slippy-map address.
`source` states the convention of the incoming y: 'xyz' counts down
from the top, 'tms' counts up from the bottom.
"""
if source not in {"xyz", "tms"}:
raise ValueError("source must be 'xyz' or 'tms'")
row = y if source == "xyz" else tms_to_xyz(y, zoom)
level = matrix_set.level(zoom)
if not level.contains(x, row):
raise IndexError(
f"tile {zoom}/{x}/{row} is outside matrix {level.identifier} "
f"({level.matrix_width}x{level.matrix_height})")
return {
"SERVICE": "WMTS", "VERSION": "1.0.0", "REQUEST": "GetTile",
"LAYER": layer, "STYLE": style, "FORMAT": fmt,
"TILEMATRIXSET": matrix_set.identifier,
"TILEMATRIX": level.identifier, # the full string, not the zoom
"TILEROW": str(row), "TILECOL": str(x),
}
def restful_url(template: str, matrix_set: TileMatrixSet, layer: str,
zoom: int, x: int, y: int, style: str = "default",
source: str = "xyz") -> str:
"""Fill a ResourceURL template — the CDN-friendly binding."""
row = y if source == "xyz" else tms_to_xyz(y, zoom)
level = matrix_set.level(zoom)
if not level.contains(x, row):
raise IndexError(f"tile {zoom}/{x}/{row} is outside {level.identifier}")
return (template
.replace("{TileMatrixSet}", matrix_set.identifier)
.replace("{TileMatrix}", level.identifier)
.replace("{TileRow}", str(row))
.replace("{TileCol}", str(x))
.replace("{Layer}", layer)
.replace("{Style}", style))
def fetch(url: str, params: dict[str, str] | None = None) -> bytes | None:
"""None means the tile is legitimately absent, not that a call failed."""
resp = requests.get(url, params=params, timeout=30)
if resp.status_code == 404:
return None
resp.raise_for_status()
if "xml" in resp.headers.get("content-type", ""):
raise RuntimeError(f"service exception: {resp.text[:300]}")
return resp.content
TILEMATRIX takes the identifier, not the zoom. This is the mistake that costs the most time, because a service using bare numeric identifiers works perfectly with a client that sends the zoom, and the same client fails completely against a service using EPSG:3857:9. Reading the identifiers from capabilities and keeping the zoom only as a lookup key handles both without a special case.
The source argument makes the convention explicit. A function that silently assumes XYZ is a function that will one day be handed TMS indices. Naming the convention at the call site turns an invisible assumption into a visible argument, and tms_to_xyz documents the flip in one place.
Bounds-check before requesting. A column or row outside the matrix produces a 404 or a TileOutOfRange exception depending on the server, and both are indistinguishable from a legitimately empty tile. Checking locally against MatrixWidth and MatrixHeight — which the tile matrix set guide explains are 2**z for a quad pyramid — separates the two cases cleanly.
Prefer the RESTful binding for anything cached. A path-based tile URL carries no query string, so a CDN caches it on the path alone with no configuration. The ResourceURL template is advertised in capabilities exactly for this, and filling it is a string substitution.
Confirm the identifiers and that a known tile resolves:
matrix_set = read_matrix_set(caps_url, "WebMercatorQuad")
print({z: lvl.identifier for z, lvl in sorted(matrix_set.levels.items())[:4]})
params = gettile_params(matrix_set, "orthophoto", zoom=12, x=2145, y=1436)
data = fetch(base_url, params)
print(params["TILEMATRIX"], len(data) if data else "empty")
{0: 'EPSG:3857:0', 1: 'EPSG:3857:1', 2: 'EPSG:3857:2', 3: 'EPSG:3857:3'}
EPSG:3857:12 24518
Then check the orientation deliberately: fetch tile (1,0) and (1,1) at zoom 1 and confirm the northern hemisphere is the one with TILEROW=0. It is the only check that catches a flip.
A 404 is a normal response. Sparse tile caches return 404 for tiles that were never seeded, and treating that as an error turns a partially seeded layer into a stream of exceptions. Returning None and letting the caller decide — draw nothing, fall back to a lower zoom, trigger a render — is the useful behaviour.
TileMatrixSetLimits narrow the valid range further. A layer may support a tile matrix set globally while only holding data in one country, and it advertises that as per-level row and column limits inside its TileMatrixSetLink. Bounds-checking against the matrix alone therefore over-estimates what exists; reading the limits removes most of the 404s in advance.
Not every service offers WebMercatorQuad. A national service may publish only its own grid, in which case there is no XYZ equivalent at all and the conversion is meaningless — the client has to work in the service’s grid. Checking that the requested set is advertised, rather than assuming, is what read_matrix_set raising a KeyError is for.
Cache the capabilities document. It is large, it changes rarely, and fetching it per tile turns a fast tile proxy into a slow one. The lru_cache above is process-local; a shared cache with a short TTL is the equivalent for a multi-process deployment.
Yes. Both count downward from the top-left origin, so an XYZ y maps to TILEROW unchanged. The convention that differs is TMS, which counts upward from the bottom. Since TMS and XYZ URLs look identical, the only safe approach is to state which convention a source uses rather than infer it.
Because the tile matrix identifier is a string chosen by the service, and many implementations qualify it with the reference system — ‘EPSG:3857:9’ rather than ‘9’. The parameter must carry that string verbatim. Reading the identifiers from capabilities and indexing them by the trailing integer handles both styles without branching.
RESTful whenever the service advertises a ResourceURL template, especially behind a CDN, because a path-based URL is cacheable on the path with no cache-key configuration. KVP is the fallback and is fine for a server-side client that is not caching.
Read the TileMatrixSetLimits from the layer’s TileMatrixSetLink and bounds-check against those rather than against the full matrix dimensions. That removes almost all of the 404s for a layer with a limited footprint, which matters when a viewport at high zoom would otherwise fire dozens of requests for empty ocean.
Back to WMTS Tile Matrix Sets Explained
Related