瓦片地图服务参数避坑:坐标系、矩阵集与自检清单

简介:这份文档面向地理信息系统(GIS)开发者和地图应用集成人员,系统整理了谷歌地图、搜搜地图与天地图三种瓦片地图服务的关键参数差异,帮助读者解决多源地图切片加载、坐标对齐与缩放级别匹配等实际问题。资源共1个docx文件,压缩包约15KB,以表格化文档形式集中呈现切片原点、全图范围、地图投影、X/Y轴正方向及显示级别比例尺集合等信息,便于随时查阅对照。文档中谷歌地图与搜搜地图均基于WGS84 Web Mercator投影,但切片原点、Y轴方向与级别区间(0-17级、4-18级)存在细节差异;天地图则采用China Geodetic Coordinate System 2000,切片原点为(-180, 90),覆盖1-18级。这些参数直接决定地图可视范围、精度与切片生成方式,读者可据此完成瓦片服务接入、地图数据转换与多服务兼容性适配。目前已有101人学习,适合需要快速核对参数细节的中初级开发人员参考。

1. 瓦片地图服务的参数看着像一堆字符串,其实是坐标系里的一份契约

上周帮一个团队排查“点位整体偏了 300 米”的问题,业务代码翻了两遍没毛病,最后发现是切图时瓦片原点用错了方向。瓦片地图服务的参数就是这样:z/x/y、原点、scaleDenominator、tileSize、矩阵集名称,单个拎出来都是一串普通字符串,可它们背后绑着一套几何约定;切图端、服务端、缓存层、前端渲染,只要有一方没按同一套约定走,表现就是错位、白块、上下颠倒这类“说不清哪坏了”的症状。这里把我自建瓦片服务和对接外部瓦片服务时整理的参数清单摊开讲,从编号规则一路写到 WMTS 请求参数和上线前的自检脚本,正在搭底图、做切片,或者要接别人瓦片服务的同学可以直接照着对。

2. 瓦片编号三件套:z/x/y、原点位置与 TMS 的行号翻转

2.1 一张 256×256 的图片怎么对应到地面上一块正方形

瓦片地图的底层只有一个投影:Web 墨卡托。它把地球当成半径 6378137 米的球,展开成一个边长2 × π × R ≈ 40075016.686米的正方形,原点在正方形正中。缩放级别z决定把这个正方形切成2^z × 2^z块,z=0是整块,z=1切成四块,往后每加一级,每边翻倍、单块瓦片覆盖的地面边长减半。所以z、x、y三个数不是三个独立参数,它们和投影范围一起,唯一确定了地面上一个矩形。

碰参数之前先把这层关系算明白,后面所有报错都能反推。下面这段只依赖标准库,在本地就能跑:

import math TILE_SIZE = 256 # 单块瓦片的像素边长 EARTH_RADIUS = 6378137.0 # Web 墨卡托采用的球体半径(米) ORIGIN_SHIFT = math.pi * EARTH_RADIUS # 20037508.342789244,世界范围半边长 def lonlat_to_tile(lon, lat, z, tile_size=TILE_SIZE): """经纬度 -> XYZ 瓦片行列号(原点在左上角)""" # 经度越界按环绕处理,否则会算出 x = -1 这种不存在的瓦片 if not (-180.0 <= lon <= 180.0): lon = ((lon + 180.0) % 360.0) - 180.0 # 墨卡托的纬度上限约 85.0511 度,再往高走 tan 会直接爆掉 lat = max(min(lat, 85.05112877980659), -85.05112877980659) n = 1 << z # 每行/每列的瓦片数 = 2^z x = int((lon + 180.0) / 360.0 * n) lat_rad = math.radians(lat) # asinh(tan(lat)) 就是墨卡托 y 的解析式,等价于 ln(tan + sec) y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) # 浮点误差会让边界点的结果正好等于 n,钳回 n-1 return min(x, n - 1), min(y, n - 1) def tile_to_bbox(x, y, z, tile_size=TILE_SIZE): """XYZ 瓦片行列号 -> 墨卡托平面上的矩形范围(米)""" n = 1 << z span = 2 * ORIGIN_SHIFT / n # 单块瓦片的地面边长 minx = -ORIGIN_SHIFT + x * span maxy = ORIGIN_SHIFT - y * span return minx, maxy - span, minx + span, maxy

这段逻辑里有三个点值得盯。n = 1 << z是位移,z越大n指数增长,z=20时已经是 1048576,所以后面做循环时别拿2**z反复算。asinh(tan(lat))是墨卡托纵坐标的闭式解,很多人手写log(tan(π/4 + lat/2)),两者数学上等价,但前者在lat接近上限时数值更稳。最后的min(x, n - 1)看着多余,实际是血泪经验:经度正好 180 度、纬度正好 0 度这类边界值,浮点结果经常正好等于n,不钳一下就会请求到一块不存在的瓦片。

拿几个点验证一下心里就有底了:lon=0, lat=0在z=1时应落在(1, 1),在z=2时应落在(2, 2);lon=90, lat=45在z=3时落在(6, 2)。如果你的实现算出来不一样,别怀疑,先查原点方向。

z每边瓦片数单瓦片地面边长(米)分辨率(米/像素)
0140075016.686156543.034
1220037508.34378271.517
2410018754.17139135.758
385009377.08619567.879
4162504688.5439783.940

这张表建议直接存到项目文档里。后面调scaleDenominator、排查偏移、跟前端对分辨率,全靠它。

2.2 原点在左上还是左下:XYZ 与 TMS 差了一整行

这是瓦片地图服务参数里最容易翻车的一处。同一块地对同一级z,存在两套编号体系:一套原点在左上角、y从上往下递增,业内习惯叫 XYZ 或 Google 风格;另一套原点在左下角、y从下往上递增,那套早期规范叫 TMS。x在两边完全一致,只有y相反,换算就一行:

def xyz_to_tms(x, y, z): """XYZ(左上原点)转 TMS(左下原点)""" return x, (1 << z) - 1 - y def tms_to_xyz(x, y, z): return x, (1 << z) - 1 - y

看着是互逆的同一个式子,因为翻转两次就回去了。但这一行带来的故障非常有辨识度:经度方向基本正确,纬度方向整体上下颠倒,而且只在z较大时肉眼才明显——z=1时翻转也就是上下两块对调,z=15时整屏都是错的。

判断自己处在哪套体系,最省事的办法是看服务端返回的瓦片矩阵定义里TopLeftCorner的值。它给的是左上角坐标,如果这个角对应的是世界的西北角(也就是y为正的大值),那就是左上原点;如果给的是BottomLeftCorner且y为正,那就是左下原点。前端拿到这套定义之后,请求时用的行号必须和定义一致,不能一边按左上原点切片、一边按左下原点拼图。

我一般会在切图任务的配置里显式写死这套约定,而不是依赖工具默认值:

{ "scheme": "xyz", "origin": "top-left", "tileSize": 256, "minZoom": 3, "maxZoom": 18, "bounds": [-20037508.343, -20037508.343, 20037508.343, 20037508.343], "resampling": "bilinear", "format": "webp" }

scheme写死成xyz是为了让后来接手的人一眼看到;origin是给人看的注释性字段,真正起作用的是切片工具内部的那套逻辑;bounds用完整的世界范围而不是数据的外包框,好处是各级瓦片的行列号从 0 开始规整排列,坏处是数据周边会切出一堆全透明瓦片——这个取舍放到第 4 章讲缓存时再展开。

2.3 环绕与极点:wrapX 关掉之后,边缘那圈瓦片永远 404

世界是左右接起来的。用户把地图往东拖过 180 度经线,地图库会继续请求x = 2^z、2^z + 1这类瓦片。如果服务端没开环绕,这些请求全部 404,屏幕上就是一条白带;如果开了,x会先对2^z取模再回落到正常编号。这个开关在多数地图库的图层配置里叫wrapX,在服务端侧叫“经度环绕”或“tile wrapping”。

// 前端图层配置里常见的环绕写法 const layer = { tileSize: 256, minZoom: 3, maxZoom: 18, wrapX: true, // 经度方向环绕;跨 180 度经线时不会出现白带 noWrap: false, crossOrigin: 'anonymous', extent: [-20037508.343, -20037508.343, 20037508.343, 20037508.343] };

crossOrigin单独说一下,它跟环绕没关系,但和环绕一样常被漏掉:不设这个属性,浏览器会把瓦片当成“脏图”,任何对 canvas 的截图、导出、像素级操作都会抛安全错误——这就是那个经典的安全异常,黑匣子里的报错信息会让你怀疑人生。设成anonymous之后,只要服务端返回的 CORS 头放行,这套流程就通了。

极点方向没有环绕,只有钳制。墨卡托在南北纬 85.0511 度以外发散,所有超过这个范围的瓦片编号都会被钳到y = 0或y = 2^z - 1。因此底图上南北极区永远是被拉伸的,这不是切片工具出了问题,是投影本身决定的。如果业务必须覆盖极区,就别在墨卡托上较劲了,换等积或方位投影另起一套矩阵集。

3. 缩放级别与分辨率:scaleDenominator、DPI 和 maxZoom 的连锁反应

3.1 每一级的分辨率是算出来的,不是拍脑袋定的

分辨率(resolution)指一个像素代表多少米,它和z是一一对应的,公式是2πR / (tileSize × 2^z)。tileSize取 256 时,z=0的分辨率就是40075016.686 / 256 ≈ 156543.034米/像素;z=10时是152.874;z=18时是0.597。这三个数不用记,记住“每加一级除 2”就够了。

真正容易出岔子的是反过来用:拿到一个“需要看清 0.5 米精度”的需求,要反推需要切到哪一级。算法是z = log2(2πR / (tileSize × 目标分辨率)),算出来通常不是整数,向上取整,然后让前端在那一级做适度放大。如果直接四舍五入往下取,最坏情况下用户看到的是被拉伸两倍的模糊瓦片。

import math def zoom_for_resolution(target_res, tile_size=256): """给定目标分辨率(米/像素),反推需要的最大缩放级别""" base = (2 * math.pi * 6378137.0) / tile_size # z=0 时的分辨率 z = math.log2(base / target_res) return math.ceil(z), z # 返回(建议级别,精确值) def resolution_of_zoom(z, tile_size=256): return (2 * math.pi * 6378137.0) / (tile_size * (1 << z))

返回值里同时给出精确值和取整值是我自己的习惯:精确值用来判断“这一级是不是差得有点多”,取整值用来配置maxZoom。比如精确值算出 16.2,取整到 17,代价是瓦片数量在 16 级基础上再翻四倍(每边翻倍),这个成本要提前算清楚,别等切图任务跑了八小时才发现磁盘爆了。

3.2 scaleDenominator 里的 DPI 玄学:0.28 毫米和 96 DPI 差了 5.8%

WMTS 的矩阵定义里不用分辨率,用的是比例尺分母scaleDenominator。它和分辨率的换算关系是scaleDenominator = resolution / pixelSize,其中pixelSize是“一个屏幕像素在物理世界里的边长”。规范里给的标准值是0.00028米,也就是 0.28 毫米——这实际上假设了显示设备是 90.7 DPI。而屏幕世界里的通用假设是 96 DPI,对应0.0254 / 96 ≈ 0.000264583米。

两个值一除,比值是0.00028 / 0.000264583 ≈ 1.0583,也就是 5.8% 的差。

z分辨率(米/像素)按 0.28mm 算按 96 DPI 算偏差
10152.8745459795777875.8%
154.77717062180575.8%
180.597213322575.8%

5.8% 在z=18只差 124,看着无所谓;在z=10差了三万多,一屏一千像素宽就是几十像素的整体缩放偏差。这就是“叠加图层老是差一点点、放大缩小偏差还会变”的玄学来源:服务端的矩阵集按 0.28 毫米算,前端地图库按自己内部的分辨率表渲染,两边不承认同一个z对应的地面尺度。

解法只有一个:以服务端矩阵集里返回的scaleDenominator为准,前端不要自己用公式现算。多数地图库支持直接把矩阵集定义喂进去,只要两边都用同一份定义,偏差就消掉了。如果前端必须自己算,那就把pixelSize显式写成0.00028,跟服务端对齐。

def scale_denominator(z, tile_size=256, pixel_size=0.00028): """按 WMTS 惯例算比例尺分母""" res = (2 * math.pi * 6378137.0) / (tile_size * (1 << z)) return res / pixel_size

pixel_size这个参数是整段代码里最该被注释的一个,它默认的0.00028是全行业事实标准,但没有任何一处会强制你用。团队里只要有一个人手滑写了0.0254/96,整套图在叠加时就偏,而且偏得很隐蔽。

3.3 minZoom / maxZoom、overzoom 与 512 像素高清瓦片

minZoom和maxZoom这两个参数决定的是“切到哪一级”,不是“看到哪一级”。用户可以把地图拉到 20 级,但只要maxZoom=18,18 级以上的画面就是第 18 级瓦片放大出来的,这叫 overzoom。overzoom 不是错误,很多场景下是刻意为之,能省掉大量切图成本和存储,代价是放大后发虚。

关键在于前后端要对齐这个上限。服务端只切到 18,前端配置里maxZoom写了 20,地图库就会老老实实去请求 19、20 级瓦片,全部 404,然后它自己会回落到 18 级并拉伸——大部分库能自动兜住,但有些库只在maxZoom设对了的情况下才做这个回落,设错了就是白屏。所以maxZoom这个值,前端配的和服务端切出来的必须一致,这属于必须写进验收项的一类参数。

另一个容易被忽视的是瓦片尺寸。前面所有公式都默认tileSize = 256。如果切的是 512 像素的高清瓦片,同样的z下每边的瓦片数量减半,单块瓦片覆盖的地面范围翻倍,分辨率也要重新算:

def tile_count_and_resolution(z, tile_size): n = 1 << z res = (2 * math.pi * 6378137.0) / (tile_size * n) return n, res # 同样是 z=15,256 和 512 的差别 print(tile_count_and_resolution(15, 256)) # (32768, 4.777) print(tile_count_and_resolution(15, 512)) # (32768, 2.388)

注意每边瓦片数都是2^z,只有分辨率变了——因为 512 的瓦片是“一块顶四块”,它覆盖的地面范围是 256 瓦片的两倍,但像素也多一倍,所以分辨率减半。这就带来一个强制约束:256 和 512 的瓦片不能混在同一个矩阵集里。切图时按 512 切,前端却按 256 去算行号,两边的z语义就对不上了,表现是整体缩小一半或者放大一倍。

很多团队为了移动端清晰度会额外切一套 512 的方案,然后在配置里用zoomOffset或者带后缀的 URL 模板来切换。这类做法我不反对,但必须把两套矩阵集当成两套独立的东西维护,各自的z范围、分辨率表、URL 模板全部独立,别想着用一套配置加个开关糊过去。糊过去的后果是半夜被叫起来查“为什么只有高分辨率屏上位置偏”。

4. 瓦片矩阵集与请求参数:WMTS 的 KVP 怎么填才不出错

4.1 GetCapabilities 里必须核对的那几个字段

对接一个不熟悉的瓦片服务,第一件事永远是拉取能力文档。别急着拼请求 URL,先看它自己声明了什么。

# 拉取能力文档,落到本地慢慢看 curl -s "http://tile.local/wmts?SERVICE=WMTS&REQUEST=GetCapabilities&VERSION=1.0.0" \ -o caps.xml # 快速看有哪些图层和矩阵集 grep -o '<ows:Identifier>[^<]*</ows:Identifier>' caps.xml | head -50

拿到文档之后,我固定核对这几项:

字段位置为什么必须核对
TileMatrixSet标识图层引用的矩阵集名名字拼错直接 400,大小写敏感
SupportedCRS矩阵集内确认是EPSG:3857还是EPSG:4326,混用必偏
TopLeftCorner每个 TileMatrix 内决定原点方向,是翻转排查的第一现场
ScaleDenominator每个 TileMatrix 内决定分辨率,前端应以此为准
TileWidth/TileHeight每个 TileMatrix 内256 还是 512,混用必缩放错
MatrixWidth/MatrixHeight每个 TileMatrix 内决定 x/y 的合法上界,越界即 404

SupportedCRS这一项尤其值得多看一眼。EPSG:3857的矩阵集,TopLeftCorner是-20037508.343 20037508.343这种量级的米制坐标;EPSG:4326的矩阵集,TopLeftCorner是-180 90这种经纬度。两套矩阵集在同一个服务里并存很常见,前端如果按名字取错了,拿到的是另一套坐标系的定义,叠加时整体偏移、缩放全错,症状和 DPI 不一致非常像,但排查路径完全不同——这也是我建议把“核对 CRS”放在所有排查步骤第一位的原因。

4.2 TILEMATRIX / TILEROW / TILECOL:三个参数各有各的坑

WMTS 的 KVP 请求参数看着不少,真正决定“取哪一块”的只有三个:

# 取某一块瓦片:矩阵标识 + 行 + 列 curl -sI "http://tile.local/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0\ &LAYER=base&STYLE=default&TILEMATRIXSET=webmercator\ &TILEMATRIX=12&TILEROW=1548&TILECOL=3372&FORMAT=image/webp"

TILEMATRIX是矩阵标识,注意它不一定等于z。规范允许它是个自定义标签,比如EPSG:3857:12、L12这种字符串。很多服务端为了兼容会同时接受数字和自定义标签,但客户端如果只按数字拼,遇到只认自定义标签的服务就是一片 400。我的做法是从能力文档里把TileMatrix的标识列表整段读出来,建一个z -> 标签的映射表,而不是直接用z拼字符串。

def build_matrix_labels(caps_xml): """从能力文档里抽出 z -> TileMatrix 标识 的映射""" import xml.etree.ElementTree as ET ns = {'wmts': 'http://www.opengis.net/wmts/1.0'} labels = {} for tm in caps_xml.findall('.//wmts:TileMatrix', ns): ident = tm.find('wmts:Identifier', ns).text # 数字型标识直接当 z 用,非数字的按顺序补进映射 if ident.isdigit(): labels[int(ident)] = ident else: labels[len(labels)] = ident return labels

这段的关键在最后的分支:数字型标识能直接映射,非数字型只能靠文档里的出现顺序来推。这不算优雅,但比硬编码可靠。真要稳妥,就把ScaleDenominator也一起读出来,用比例尺反查z,那才是唯一不会骗人的锚点。

TILEROW和TILECOL的坑在顺序上。KVP 里是 ROW 在前、COL 在后,对应的是y和x;而 RESTful 风格的路径模板通常是{TileMatrix}/{TileRow}/{TileCol},还是同一个顺序。我见过不止一次把行列写反导致请求 404 的情况,因为大多数x/y范围一致时写反了照样能返回一张图,只是那张图是镜像的——这种错最难发现,得把瓦片拼起来才看得出来。

FORMAT也不是随便填的。能力文档里每个图层会列出SupportedTileFormats,常见的有image/png、image/jpeg、image/webp,矢量瓦片是application/vnd.mapbox-vector-tile。填了不支持的值,有的服务返回 400,有的返回一张默认图,后者更坑。前端配置对应的字段:

const wmtsLayer = { url: 'http://tile.local/wmts', layer: 'base', style: 'default', matrixSet: 'webmercator', format: 'image/webp', tileMatrixPrefix: '', // 矩阵标识前缀,自定义标签时用 tileMatrixLabels: [], // 从能力文档读出的映射表,非数字标识必须填 requestEncoding: 'KVP' // 或 'RESTful',必须和能力文档一致 };

requestEncoding这一项是最容易漏的。同一个服务往往同时开 KVP 和 RESTful 两种入口,但能力文档里的ResourceURL模板只会给其中一种。前端按 KVP 拼、服务端只认 RESTful,就是全站 404,而你去浏览器里手动敲 KVP 地址又是通的——因为那条路径确实存在,只是模板里没声明,某些实现会拒绝。以文档为准,别以“我试了能通”为准。

4.3 服务端侧参数:格式、缓存头、压缩与 CORS

参数不只在前端和协议层,服务端自己的输出配置同样属于“瓦片地图服务参数”的一部分,而且这一层的配置会直接影响上面所有几何参数的表现。

格式选型上,PNG 适合带透明和线划图的底图,JPEG 适合影像底图但损失透明度,WebP 在两者之间有不错的平衡,体积通常比 PNG 小不少、比 JPEG 略大但支持透明。我不建议一开始就上 WebP,除非确认目标浏览器范围;用 PNG 起步、稳定后再换,成本最低。矢量瓦片的pbf格式是另一条路线,参数上要注意buffer(瓦片边缘的额外像素,防止要素在瓦片边界被切断)和简化容差,这两个值调小了会在拼接处看到断线,调大了体积暴涨。

缓存这块,瓦片的Cache-Control应该给长周期。瓦片内容基本不变,除非数据更新,所以max-age给到天级别甚至更长都合理,配合 URL 里带版本号或者目录版本号来失效。

# 检查返回头:缓存、压缩、跨域三件套 curl -sI "http://tile.local/tiles/12/1548/3372.webp" | \ grep -Ei 'cache-control|content-encoding|content-type|access-control'

理想输出里应该能看到Cache-Control: public, max-age=86400、Content-Type: image/webp、Access-Control-Allow-Origin: *。压缩这一项对图片格式通常不生效(图片本身已压缩),但矢量瓦片必须开Content-Encoding: gzip,一个几万字节的 pbf 能压到三分之一。跨域头前面提过,它决定了前端能不能对 canvas 做像素操作,属于“不出问题没人管、出了问题全站挂”的配置。

5. 避坑与排查:错位、白块和 404 的现场记录

5.1 位置对不上:几何参数引发的三条翻车记录

现象一:底图整体上下颠倒,经度方向正常。原因是切片端和渲染端用了不同的原点约定,一边按左上原点出图、一边按左下原点拼图,或者更常见的是切图配置里写了tms而前端按xyz请求。解决方式是先确认矩阵集的TopLeftCorner,再用一次xyz_to_tms换算验证:随便取一个已知点,算出两种方案下的y,看哪个和服务端返回的瓦片内容吻合。吻合之后把约定写死在配置里,别依赖默认值。

现象二:叠加图层整体偏移几十米,缩放级别越低偏得越多。八成是EPSG:4326和EPSG:3857的矩阵集混用了。矢量数据如果是经纬度、底图是墨卡托,而投影转换那一步漏了或者转错,偏移量随纬度变化,赤道附近几乎为零、高纬度最明显。另一种可能是TopLeftCorner写成了-180 90而不是米制值,这种偏移是固定量级的,一测就能分辨。排查手法是把两边的矩阵集定义打印出来逐字段对比,别看代码。

现象三:高纬度区域的瓦片被拉得很扁,或者出现规则的白边。这是墨卡托的固有特性,不是 bug。纬度超过 85.0511 度之后,y会被钳制到边界值,画面被无限拉伸。如果业务需要覆盖极区,只能换投影和矩阵集;如果只是数据稍微超了一点,可以在切片时按bounds裁掉,让边缘少切一堆无意义的瓦片。白边还有一种可能是切片工具给瓦片补了边距(防止边缘要素被切),而前端拼接时没考虑这层重叠——这类问题在矢量瓦片上出现得更多,检查buffer参数是否和渲染端一致。

5.2 请求发不出去或全是空格子:请求参数与缓存类的记录

现象四:地图往东拖过 180 度经线就出现一条白带,控制台一堆 404。wrapX没开,或者开了但服务端侧不对越界x做取模,直接返回 404。前端侧的解法是开wrapX;服务端侧的解法是在路由层把x对2^z取模之后再交给瓦片查找逻辑。两边必须有一边兜住,最好是两边都兜,因为用户的交互行为不可控,缓存层也可能把越界请求透传下去。

现象五:某一级整片空白,其他级别都正常。三个可能:这一级压根没切(切图任务中途失败或者maxZoom设小了);矩阵集里缺了这一级的TileMatrix定义,客户端请求时用了错误的标签;或者这一级的瓦片目录权限不对、静态文件服务的路由规则漏了这一级。最快的定位方式是手动拼一个该级别的请求 URL 直接访问,看返回的是 404、还是 200 但内容为空、还是 200 且内容正常——这三种结果指向三个完全不同的方向,别一上来就翻代码。

现象六:桌面端正常,移动端整体发虚,或者位置整体缩了一圈。高分屏的devicePixelRatio是 2 或 3,256 的瓦片被放大到两倍显示。解法要么切一套 512 的高清瓦片并在前端配置对应的矩阵集,要么接受模糊。注意别想着用同一套 URL 加个@2x后缀就完事,那等于凭空多出一套矩阵集,行列号规则、分辨率全变了,必须当成独立的一套配置维护,否则位置一定会偏。这条我踩过,教训是“看起来只改了个后缀,其实动了整套几何参数”。

6. 上线前跑一遍自检:把参数锁进一张对照表和一段脚本

参数配完不算完,得有个能重复跑的检查。我一般会写一个几十行的脚本,把能力文档、瓦片内容、返回头三样东西串起来验一遍:

import urllib.request, urllib.error, xml.etree.ElementTree as ET def check_tile(base, layer, matrix_set, z, x, y, fmt='image/webp'): url = (f"{base}?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0" f"&LAYER={layer}&STYLE=default&TILEMATRIXSET={matrix_set}" f"&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT={fmt}") req = urllib.request.Request(url, method='GET') try: with urllib.request.urlopen(req, timeout=5) as r: body = r.read() # 400 字节以下的瓦片基本可以判定为空白或错误图 return {'status': r.status, 'type': r.headers.get('Content-Type'), 'cache': r.headers.get('Cache-Control'), 'cors': r.headers.get('Access-Control-Allow-Origin'), 'size': len(body), 'blank': len(body) < 400} except urllib.error.HTTPError as e: return {'status': e.code, 'url': url}

这段返回的字典就是我验收时要看的全部内容。status必须是 200,type要和请求的FORMAT对得上,cache里应该有max-age,cors在前端需要像素操作时不能为空,size太小就要怀疑是不是切到了空白区域。最后一个检查是把tile_to_bbox算出来的中心点喂回lonlat_to_tile,看能不能还原出同一个(x, y)——能还原,说明编号逻辑自洽;不能,说明中间哪一步的取整或者钳制写错了。

验收表我一般按这个结构列,每个服务实例填一遍:

检查项期望值失败时的第一嫌疑
矩阵集SupportedCRS与数据源一致投影混用
每级ScaleDenominator与分辨率公式吻合DPI 取值不一致
TileWidth/Height全级别统一256 与 512 混用
最低/最高级瓦片均返回 200 且非空切图任务未跑完
越界x(-1 或 2^z)200 或按约定 404环绕策略未对齐
返回头含缓存、CORS、正确 Content-Type网关层覆盖了响应头

这张表跑一遍通常十几分钟,但能省掉后面几天的扯皮。我自己的习惯是把它挂在切图任务的最后一步,切完自动跑,失败就打日志不发布——瓦片这种东西,出了问题用户看到的是整片空白,而日志里什么都没有,事后追查的成本远高于事前跑一遍。

最后留一句实在话:这套参数里没有一个是“配一次就忘掉”的,投影、原点、分辨率、矩阵集、缓存头,任何一项在迭代中被改动,都要重新跑一遍自检。我吃过最大的亏就是加了新数据源之后忘了比对矩阵集,上线三天才发现叠加层整体偏了——希望帮到你。

本文还有配套的精品资源,点击获取