A tile matrix set is a set of numbers that must agree with each other. Walk the levels, assert that matrix dimensions double and scale denominators halve within a relative tolerance of about 1e-4, that ground resolution equals the scale denominator times 0.00028 metres, and that the top-left corner is written in the reference system’s own axis order. Anything hand-edited fails at least one of these.
Each TileMatrix in a WMTS capabilities document publishes an identifier, a scale denominator, a top-left corner, a tile width and height, and a matrix width and height. Those seven values are not independent — the scale denominator determines the ground resolution, which with the tile size and matrix dimensions determines the extent covered, which must match the reference system.
Because the values are published rather than derived, a service can advertise a set that is internally inconsistent, and nothing rejects it. The tiles then render, at slightly the wrong place, in a way that gets worse with zoom. Clients that hard-code a well-known scale progression — which most web mapping libraries do — silently request the wrong level, which is the failure the configuration guide warns about from the publishing side.
from __future__ import annotations
from dataclasses import dataclass
from xml.etree import ElementTree as ET
import requests
from pyproj import CRS
NS = {"wmts": "http://www.opengis.net/wmts/1.0",
"ows": "http://www.opengis.net/ows/1.1"}
# The OGC standard rendering pixel size. Ground resolution in CRS units is
# always scale_denominator * this value.
STANDARD_PIXEL_M = 0.00028
TOLERANCE = 1e-4
@dataclass(frozen=True)
class Level:
identifier: str
scale_denominator: float
top_left: tuple[float, float]
tile_width: int
tile_height: int
matrix_width: int
matrix_height: int
def resolution(self, metres_per_unit: float) -> float:
return self.scale_denominator * STANDARD_PIXEL_M / metres_per_unit
def span(self, metres_per_unit: float) -> tuple[float, float]:
res = self.resolution(metres_per_unit)
return (self.matrix_width * self.tile_width * res,
self.matrix_height * self.tile_height * res)
@dataclass
class Finding:
level: str
invariant: str
detail: str
def __str__(self) -> str:
return f"{self.level:>16} {self.invariant:<18} {self.detail}"
def parse_levels(capabilities: bytes, set_id: str) -> tuple[str, list[Level]]:
root = ET.fromstring(capabilities)
for tms in root.iter(f"{{{NS['wmts']}}}TileMatrixSet"):
if tms.findtext("ows:Identifier", "", NS) != set_id:
continue
srs = tms.findtext("ows:SupportedCRS", "", NS)
levels = []
for m in tms.findall("wmts:TileMatrix", NS):
corner = [float(v) for v in
(m.findtext("wmts:TopLeftCorner", "", NS)).split()]
levels.append(Level(
identifier=m.findtext("ows:Identifier", "", NS),
scale_denominator=float(m.findtext("wmts:ScaleDenominator", "0", NS)),
top_left=(corner[0], corner[1]),
tile_width=int(m.findtext("wmts:TileWidth", "256", NS)),
tile_height=int(m.findtext("wmts:TileHeight", "256", NS)),
matrix_width=int(m.findtext("wmts:MatrixWidth", "0", NS)),
matrix_height=int(m.findtext("wmts:MatrixHeight", "0", NS))))
return srs, levels
raise KeyError(f"tile matrix set {set_id!r} is not advertised")
def _close(a: float, b: float, tolerance: float = TOLERANCE) -> bool:
return abs(a - b) <= tolerance * max(abs(a), abs(b), 1.0)
def validate(srs: str, levels: list[Level]) -> list[Finding]:
findings: list[Finding] = []
crs = CRS.from_user_input(srs)
metres_per_unit = 1.0 if crs.axis_info[0].unit_name.startswith("met") else 111319.49
lat_first = crs.axis_info[0].direction.lower() in {"north", "south"}
area = crs.area_of_use
for previous, level in zip(levels, levels[1:]):
if not _close(previous.scale_denominator / 2, level.scale_denominator):
findings.append(Finding(
level.identifier, "scale progression",
f"expected {previous.scale_denominator / 2:.6f}, "
f"published {level.scale_denominator:.6f}"))
for axis, prev_n, this_n in (("width", previous.matrix_width, level.matrix_width),
("height", previous.matrix_height, level.matrix_height)):
if this_n != prev_n * 2:
findings.append(Finding(
level.identifier, f"matrix {axis}",
f"expected {prev_n * 2}, published {this_n}"))
if level.top_left != previous.top_left:
findings.append(Finding(
level.identifier, "origin",
f"moved from {previous.top_left} to {level.top_left}"))
# Geometry checks against the reference system's own extent.
if levels and area is not None:
first = levels[0]
span_x, span_y = first.span(metres_per_unit)
expected_x = abs(area.east - area.west)
expected_y = abs(area.north - area.south)
if lat_first and first.top_left[0] < first.top_left[1]:
findings.append(Finding(
first.identifier, "origin axis order",
f"{srs} is latitude-first but TopLeftCorner reads "
f"{first.top_left} — expected northing first"))
if span_x + TOLERANCE < expected_x:
findings.append(Finding(
first.identifier, "coverage",
f"level 0 spans {span_x:.4f} but the CRS extent is {expected_x:.4f}"))
return findings
def validate_url(capabilities_url: str, set_id: str) -> list[Finding]:
resp = requests.get(capabilities_url, timeout=60, params={
"SERVICE": "WMTS", "VERSION": "1.0.0", "REQUEST": "GetCapabilities"})
resp.raise_for_status()
srs, levels = parse_levels(resp.content, set_id)
return validate(srs, levels)
Compare neighbours, not absolutes. Checking each level against a hard-coded reference progression only works for the well-known sets. Comparing each level against its predecessor validates any set, including legitimate custom ones, and localises a defect to the level where the ratio breaks rather than reporting every level after it.
The tolerance is relative and deliberately loose. Published scale denominators are typically truncated to six decimal places, so an exact halving comparison fails on almost every real service. A relative tolerance of 1e-4 accepts that rounding and still catches a level that is out by a part in a thousand, which is enough to misplace tiles visibly.
0.00028 metres is not a magic number. It is the standardised rendering pixel size the OGC uses to relate a scale denominator to a ground resolution, and it is why the same scale denominator means the same ground resolution across every conformant service. A set whose resolution does not match this relation is one where the publisher computed scales from a different assumed screen DPI.
The origin must follow the reference system’s axis order. For a geographic system defined latitude-first, TopLeftCorner reads 90 -180, not -180 90. Deriving that expectation from the registry definition rather than from a list of codes means the check is correct for grids in systems nobody anticipated.
Run it against a service and read the findings:
python validate_tms.py https://example.org/wmts WebMercatorQuad
A conformant set prints nothing. A hand-edited one prints exactly where it broke:
EPSG:3857:11 scale progression expected 272989.386752, published 272989.400000
EPSG:3857:14 matrix width expected 16384, published 16383
EPSG:3857:14 matrix height expected 16384, published 16383
The first line is rounding at the edge of the tolerance and is harmless. The second and third are real: a matrix one tile short on each axis leaves the eastern and southern edges of the world unaddressable at that zoom, which shows as missing tiles along two edges and nowhere else.
A ratio other than two is legal. WMTS does not require a quad pyramid. Sets with a ratio of three, or with an irregular progression drawn from a national mapping series, are conformant and will fail a doubling check. Where a service publishes such a set deliberately, the validator should assert the ratio is consistent rather than that it equals two.
MatrixHeight need not equal MatrixWidth. For a geographic tile matrix set covering the whole globe, the world is twice as wide as it is tall, so level zero is commonly two tiles by one. Assuming a square level zero is a frequent source of false findings.
TileMatrixSetLimits are a different thing. They live on the layer’s TileMatrixSetLink, not on the set, and they narrow which tiles a particular layer holds. A validator that conflates the two reports a set as under-covering when it is the layer that is limited.
Validate at startup, not on the first user report. A tile matrix set is static configuration, so running this check when a service starts — or in the pipeline described in CI/CD and Compliance Testing for Spatial Services — costs one request and catches a class of defect that is otherwise found visually, months later.
A relative tolerance around one part in ten thousand. Published denominators are truncated, so an exact comparison produces false findings on almost every service, while a looser tolerance than 1e-3 starts to admit errors large enough to misplace tiles by a visible fraction of a tile at high zoom.
No. WMTS permits any scale progression, and national mapping agencies frequently publish sets derived from their traditional paper map scales. What matters is that the ratio is consistent and that clients read the published denominators rather than assuming a doubling — which is exactly why hard-coded progressions in web mapping libraries cause trouble against such services.
Because the tile addressing scheme is defined relative to it. If the origin moves between levels, tiles at different zooms describe different anchors and a client zooming in sees the map jump. A published set with a per-level origin is either a generation bug or a set that cannot be used as a pyramid.
The scale and matrix ratios, yes — those are pure arithmetic on the published numbers. The axis order and coverage checks need to know what the declared reference system actually is, and pyproj is the practical way to ask. Skipping them leaves the two geometry defects undetected, which are precisely the ones that offset an entire grid.
Back to WMTS Tile Matrix Sets Explained
Related