Fixing Misaligned Tiles and Wrong Web Mercator Extents
The tile set loads, the imagery appears, and it is not quite where it should be: a building offset from its footprint on the basemap, a road running parallel to the one in the orthomosaic. Or everything is in the right place and visibly softer than the source. Both are geometry faults in the warp rather than problems with the tiles, and there are four of them.
Four ways a tile set ends up wrong
Pixel misalignment. Without target-aligned pixels the warped raster’s origin falls wherever the reprojected corner landed, which is not on a tile boundary. Every tile then resamples the source at a fractional offset, and the whole set is softer than it should be. Position is correct; sharpness is lost.
Resampling choice. Nearest-neighbour on visual imagery produces aliasing at every zoom; cubic on a categorical raster invents values. Position is correct; content is wrong.
Sphere versus ellipsoid. Web Mercator is defined on a sphere of the WGS84 semi-major axis while the coordinates it consumes are ellipsoidal. Treating EPSG:3857 as an ordinary ellipsoidal Mercator introduces a latitude-dependent northing error reaching about 20 km at mid-latitudes. Modern GDAL handles this correctly; hand-rolled conversion formulas frequently do not.
The latitude limit. The Web Mercator grid is square, which requires truncating at about ±85.051°. A survey above that cannot be tiled in this scheme at all, and a naive transformation returns a northing that grows without bound rather than failing.
Figure 1 — Two symptoms, four causes. The first question narrows it to two candidates, and each of those has a one-line check.
Minimal reproducible solution
Check the warped raster’s geometry directly. All four faults are visible in its metadata and bounds, before a single tile is generated.
import math
import rasterio
from rasterio.warp import transform_bounds
WEB_MERCATOR_LIMIT_DEG = 85.05112877980659
EARTH_CIRCUM_M = 2 * math.pi * 6378137.0
def check_warped_for_tiling(path: str) -> list[str]:
"""Every geometry fault a tile pyramid can inherit, from the warped raster."""
problems: list[str] = []
with rasterio.open(path) as ds:
if ds.crs.to_epsg() != 3857:
problems.append(f"CRS is {ds.crs}, not EPSG:3857")
return problems
res = abs(ds.transform.a)
ox, oy = ds.transform.c, ds.transform.f
# -tap alignment: the origin must be an integer multiple of the
# resolution measured from the grid origin at the top-left corner.
half = EARTH_CIRCUM_M / 2
for name, origin in (("x", ox + half), ("y", half - oy)):
frac = (origin / res) % 1.0
if min(frac, 1 - frac) > 1e-6:
problems.append(
f"{name} origin is {frac:.4f} of a pixel off the grid — "
"warp without -tap; every tile resamples at an offset")
west, south, east, north = transform_bounds(ds.crs, "EPSG:4326", *ds.bounds)
if max(abs(north), abs(south)) > WEB_MERCATOR_LIMIT_DEG:
problems.append(
f"extent reaches {max(abs(north), abs(south)):.3f}° — beyond the "
f"Web Mercator limit of {WEB_MERCATOR_LIMIT_DEG:.3f}°")
if ds.count < 4 and ds.nodata is None:
problems.append("no alpha band and no NoData — the footprint will "
"render as opaque")
tags = ds.tags()
if tags.get("RESAMPLING", "").lower() in {"near", "nearest"} and ds.count >= 3:
problems.append("nearest-neighbour resampling on multi-band imagery "
"— expect aliasing at every zoom")
return problems
The alignment test is the one that repays writing. It converts “the tiles look soft” — a subjective judgement nobody can act on — into a number: the fraction of a pixel by which the grid is offset. Zero means aligned; anything else means the warp needs -tap and nothing about the tile generation will help.
The sphere-versus-ellipsoid fault does not appear in this list because GDAL handles it correctly. It appears when someone converts coordinates by hand:
import math
def lonlat_to_3857(lon_deg: float, lat_deg: float) -> tuple[float, float]:
"""The correct spherical formula. Note there is no eccentricity term.
Web Mercator is defined on a sphere of the WGS84 semi-major axis, so any
formula containing the ellipsoid's eccentricity is computing a different
projection — one that differs by kilometres at mid-latitudes.
"""
if abs(lat_deg) > WEB_MERCATOR_LIMIT_DEG:
raise ValueError(f"latitude {lat_deg} is outside the Web Mercator grid")
r = 6378137.0
x = r * math.radians(lon_deg)
y = r * math.log(math.tan(math.pi / 4 + math.radians(lat_deg) / 2))
return x, y
Edge-case matrix
| Fault | Position | Sharpness | Check |
|---|---|---|---|
No -tap |
correct | degraded | Origin modulo resolution |
| Nearest on imagery | correct | aliased | Resampling tag, band count |
| Cubic on a classification | correct | invented values | Band count, dtype |
| Ellipsoidal formula | up to 20 km off | unaffected | Compare against pyproj |
| Beyond ±85.051° | fails or wraps | — | Bounds check |
| Source CRS undeclared | arbitrary | — | CRS check before warping |
| Alpha absent | correct | correct | Band count and NoData |
| Warped twice | correct | doubly degraded | Provenance in the manifest |
The warped-twice row is a quiet one. A raster reprojected to Web Mercator by an earlier step and then handed to a tiler that warps again is resampled twice, and the softness compounds. Recording the CRS in the run manifest at each step is what makes it visible; the raster itself looks fine.
Verification snippet
Position is best verified against something external — a known coordinate, checked through the tile grid arithmetic rather than through the same library that produced the raster.
import math
import rasterio
def assert_known_point_lands_correctly(tile_dir, warped_path: str,
lon: float, lat: float,
zoom: int, tol_px: float = 1.0) -> None:
"""A surveyed point must fall at the expected pixel of the expected tile."""
n = 2 ** zoom
x = (lon + 180.0) / 360.0 * n
y = (1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n
tx, ty = int(x), int(y)
px, py = (x - tx) * 256, (y - ty) * 256
tile = tile_dir / str(zoom) / str(tx) / f"{ty}.png"
assert tile.exists(), f"expected tile {zoom}/{tx}/{ty} does not exist"
# Independently, where does the warped raster put the same coordinate?
with rasterio.open(warped_path) as ds:
from pyproj import Transformer
tr = Transformer.from_crs("EPSG:4326", ds.crs, always_xy=True)
mx, my = tr.transform(lon, lat)
row, col = ds.index(mx, my)
# Convert the raster position into the same tile-pixel frame.
res = abs(ds.transform.a)
world_per_tile = 2 * math.pi * 6378137.0 / n
exp_px = ((mx + math.pi * 6378137.0) % world_per_tile) / world_per_tile * 256
assert abs(exp_px - px) <= tol_px, (
f"the raster places the point at pixel {exp_px:.1f} of its tile while "
f"the tile grid expects {px:.1f} — the warp is not aligned to the grid")
Computing the expected tile from the tile-grid formula and the actual position from the raster’s own transform is what makes this a real check: two independent paths to the same answer, which only agree if the warp respected the grid.
Figure 2 — Why misalignment is easy to live with and worth fixing. It produces no artefact, no seam and no error — only a uniform loss of the resolution the survey paid for.
When to escalate
- The alignment check passes and the imagery is still soft. The source was warped more than once, or the top zoom exceeds the source resolution so the client is looking at upsampled tiles. Both are visible in the run manifest if the CRS and the zoom range were recorded.
- A survey extends beyond ±85°. Web Mercator cannot represent it. Use a polar projection and a tile scheme that supports it; there is no adjustment to the warp that makes this work.
- The tiles align and the client’s basemap does not. Their basemap may be in a different scheme or a different datum realisation. Compare a surveyed coordinate against both rather than assuming the newer product is the wrong one.
Run the geometry checks on the warped raster rather than on the tiles, and run them before generation. Every fault on this page is present in the warped raster and takes milliseconds to detect there; the same fault found after a four-hour pyramid build costs the build.
Related
- Tiling and serving orthomosaics as XYZ tiles
- Building XYZ tile pyramids with gdal2tiles
- Managing coordinate reference systems in GDAL
← Tiling and Serving Orthomosaics as XYZ Tiles
Figure 3 — The error from one missing assumption. GDAL never makes it; hand-rolled conversion code makes it regularly, and the result is a survey that is internally consistent and in the wrong country’s worth of offset.