多源瓦片地图参数对照:切片原点、轴向与比例尺避坑 简介这份文档面向GIS与地图应用开发人员聚焦瓦片地图服务的基础参数对照帮助读者快速厘清谷歌地图、搜搜地图与天地图在切片原点、全图范围、地图投影、显示级别及比例尺集合上的差异。文档按服务分节整理关键数值标明谷歌与搜搜采用WGS84 Web Mercator投影天地图采用China Geodetic Coordinate System 2000投影并记录各自X、Y轴方向及0—17级、4—18级、1—18级等显示区间。资源包共1个docx文件约15KB体量轻便便于随项目资料归档和检索。目前已有101人学习。其价值在于把分散参数集中成可比对清单读者可据此核对瓦片坐标范围、设置显示层级与比例尺减少集成多源地图服务时的试错并为切片制作、地图初始化、数据转换和兼容性处理提供参考依据。1. 三种瓦片参数摆到一起原点、轴向、比例尺分别在解决什么问题把谷歌、搜搜、天地图三种瓦片地图服务的底图叠在同一张画布上最先出问题的通常不是代码逻辑而是那三组切片原点和比例尺集合。这份 doc 文档做的就是将三者并列对照切片原点、全图范围、地图投影、X/Y 轴正方向、显示级别、比例尺集合六个参数一组三家并列。它不教怎么搭服务但配 Leaflet、OpenLayers、Cesium或者自己写瓦片拼接脚本时缺的往往就是这几行数。适合已经能跑起一张底图、正准备接第二第三数据源的人也适合只做数据侧、拿到一批切片需要判断它属于哪套规范的场景。2. 切片原点与全图范围20037508.342789 这串数是怎么算出来的2.1 从 6378137 到半周长边界不是拍脑袋定的Web MercatorEPSG:3857用的是一个正球体近似半径取 WGS84 的长半轴 6378137 米不做椭球纬度改正。赤道周长 2πR 40075016.685578488 米所以经度方向能表达的极限就是 ±πR即 ±20037508.342789244 米。纬度方向不能无限延伸因为墨卡托投影在极点发散。工程上统一截断在 ±85.0511287798066°这个值的来源是让投影后的 Y 恰好也等于 ±πR于是整个世界被裁成一个正方形。这一步很关键它意味着 Web Mercator 下的瓦片金字塔是一个正方形金字塔第 0 级一张 256×256 的瓦片就能覆盖全球之后每级四叉切分。所以看到 20037508.342789 这个数字基本可以断定这套切片走的是 Web Mercator不用再去看投影声明。反过来看到原点写成 (-180, 90) 这种那就是经纬度直接切片两套体系不能混着算瓦片号。2.2 三家的原点写法差异在精度截断不在投影三套参数摆在一起看差异其实比想象中小服务切片原点全图范围投影Y 轴级别谷歌(-20037508.342789199, 20037508.342789199)(-20037508.342789199, -20037508.342789199, 20037508.342789199, 20037508.342789199)WGS84 Web Mercator向下0–17搜搜(-20037508.342789244, 20037508.342789091)(-20037508.342789244, -20037508.34278914, 20037508.342789244, 20037508.342789091)WGS84 Web Mercator向上4–18天地图(-180, 90)(-180, -90, 180, 90)CGCS2000向下1–18谷歌写的是 ...789199搜搜写的是 ...789244两者相差 4.5×10⁻⁸ 米也就是 45 纳米。这个量级在地图渲染里完全是噪声起因只是不同年代、不同语言的标准库对双精度浮点的十进制输出格式不一样一个是截断打印一个是完整打印。真正需要盯的是搜搜的上边界 20037508.342789091 和它的下边界 -20037508.34278914 —— 这两个数的绝对值不相等差了 4.9×10⁻⁸。这不是 bug是原点在左上角 Y 轴向上这种组合下全图范围的上边界就等于原点的 Y 值下边界由瓦片数反推浮点误差就露在这里。天地图那一行才是真正的分水岭。原点是度范围也是度Y 轴向下投影写的是 CGCS2000。这套参数意味着它的瓦片是按经纬度等分切的不是按墨卡托平面等分切的。第 1 级全球 2×2 张瓦片每张覆盖 180°×90°第 2 级 4×4每张 90°×45°以此类推。你拿 Web Mercator 的瓦片号公式去算天地图的瓦片纬度越高错得越离谱这是后面避坑章节要重点说的一条。2.3 用一段代码把原点和瓦片范围对起来光看数字没有体感写个函数把给定级别和瓦片号算出这块瓦片的地理范围跑一遍原点对不对立刻现形。import math R 6378137.0 SHIFT math.pi * R # 20037508.342789244Web Mercator 的半周长 def tile_bbox(z, x, y, origin_x-SHIFT, origin_ySHIFT, tile_size256, y_downTrue): 计算瓦片的地理范围Web Mercator 米 z: 级别 x, y: 瓦片行列号 origin_x, origin_y: 切片原点左上角为 (-SHIFT, SHIFT) y_down: True 表示 Y 轴向下谷歌/天地图风格False 表示 Y 轴向上搜搜风格 n 2 ** z span (2 * SHIFT) / n # 当前级别单张瓦片覆盖的地面宽度单位米 min_x origin_x x * span max_x min_x span if y_down: max_y origin_y - y * span # 从原点往下减 min_y max_y - span else: min_y origin_y y * span # Y 向上从原点往上加 max_y min_y span return round(min_x, 3), round(min_y, 3), round(max_x, 3), round(max_y, 3) # 自检第 0 级唯一的瓦片范围必须等于全图范围 print(tile_bbox(0, 0, 0)) # 期望输出(-20037508.343, -20037508.343, 20037508.343, 20037508.343) # 再验证第 1 级右下角那块X 上界应回到 SHIFT print(tile_bbox(1, 1, 1))参数说明上span是这段代码的核心全球宽度 2×SHIFT 除以 2^z就是这一级每张瓦片负责的地面宽度。origin_x x * span直接把列号换算成西边界这是所有瓦片拼接库的通用做法。y_down这个开关对应的就是文档里Y 轴正方向向上/向下那一行谷歌和天地图填 True搜搜填 False。跑完这个自检你会得到一个很实用的判断习惯任何一份瓦片参数只要第 0 级瓦片的 bbox 推不出全图范围那原点或者级别定义里必有一处抄错了。我见过最典型的是把 -20037508.342789 抄成 -20037508.34看着只差 0.002789 米但按 18 级的瓦片宽度约 1.19 米算最后一级的边缘瓦片会整体偏出小半个像素拼接缝上就是一条肉眼可见的细线。3. 轴向与投影Y 轴向上还是向下决定你的行号是否需要翻转3.1 行号翻转TMS 与 XYZ 最容易出错的地方文档里X 轴正方向向右、Y 轴正方向向下和Y 轴正方向向上这两句话看起来只是描述坐标系实际决定的是瓦片行号的编号方向。谷歌和天地图是 Y 向下原点在左上角行号从上往下递增这是 OGC 的 XYZ 约定也是绝大多数前端地图库默认的行为。搜搜是 Y 向上原点虽然在左上角但行号从下往上数这是 TMS 约定OSGeo 那套瓦片服务用的是这个。两种约定的换算只有一行def xyz_to_tms(z, y_xyz): XYZ 行号转 TMS 行号反之亦然这个变换是自反的 return (2 ** z) - 1 - y_xyz # 第 2 级共 4 行XYZ 的第 0 行最北对应 TMS 的第 3 行最南 print(xyz_to_tms(2, 0)) # 3 print(xyz_to_tms(2, 3)) # 0这个函数是自反的正着用反着用都一样所以在代码里只要判断一次源服务是哪种约定读配置的时候就统一翻成 XYZ后续所有逻辑都按 XYZ 走不要在两套约定之间来回切换。血泪经验是这类问题在小范围、低纬度、低级别的时候经常看不出来因为差的瓦片可能刚好是空白或者海面等到放大到城市级别整块底图偏出去一半才开始怀疑人生。还有一种更隐蔽的情况某家服务的元数据里写的是 Y 向上但实际发布的瓦片目录结构是按 Y 向下组织的。这种不一致没法从文档里看出来只能拿同一块区域的已知地物去比对比如找一条笔直的主干道看它在两个源上是否重合。3.2 CGCS2000 与 WGS84能不能当同一套用文档里天地图的投影写的是 China Geodetic Coordinate System 2000另外两家是 WGS84 Web Mercator。这里需要说清楚两件事避免过度处理。第一Web Mercator 是投影坐标系CGCS2000 和 WGS84 是地理坐标系大地坐标系三者不在一个层级上不能直接比较差多少米。真正要比的是 CGCS2000 椭球和 WGS84 椭球的参数CGCS2000 的长半轴 6378137 米、扁率倒数 298.257222101WGS84 的长半轴 6378137 米、扁率倒数 298.257223563。两者长半轴完全一致扁率的差异在小数点后第九位反映到地表位置上是厘米级甚至更小。第二天地图提供的是经纬度切片也就是把地理坐标直接按度数等分没有经过墨卡托投影。所以它和另外两家的差异主要来自投影方式不同而不是基准不同。把 WGS84 的经纬度直接拿去请求天地图瓦片在普通 Web 展示精度下是可以接受的如果做高精度量算或者大比例尺成图才需要走正式的坐标转换流程。这一点很多人会过度设计上来就接一套七参数转换结果发现转换前后瓦片位置没变白折腾。判断方法很简单拿同一个已知点的经纬度分别按两套椭球算一遍如果差值在你业务的容差之内就别转。3.3 经纬度到瓦片号两套公式不能互换两种切片方式对应两套换算公式混用是另一个高频翻车点。import math def lonlat_to_tile_mercator(lon, lat, z): Web Mercator 切片谷歌、搜搜等输入 WGS84 经纬度 n 2 ** z lat max(min(lat, 85.0511287798066), -85.0511287798066) # 先截断否则 tan 会爆 x int((lon 180.0) / 360.0 * n) lat_rad math.radians(lat) y int((1.0 - math.log(math.tan(lat_rad) 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n) return min(x, n - 1), min(y, n - 1) # 边界点防越界 def lonlat_to_tile_geodetic(lon, lat, z): 经纬度切片天地图风格按度数等分不做墨卡托变换 n 2 ** z x int((lon 180.0) / 360.0 * n) y int((90.0 - lat) / 180.0 * n) # 北纬 90 度是第 0 行 return min(x, n - 1), min(y, n - 1) # 同一个点同一级别两套公式给出的行列号完全不同 print(lonlat_to_tile_mercator(116.39, 39.90, 10)) # 墨卡托结果 print(lonlat_to_tile_geodetic(116.39, 39.90, 10)) # 经纬度结果lonlat_to_tile_mercator里那行截断不能省。纬度超过 85.0511 之后tan会迅速趋向无穷log结果爆炸int()直接抛异常或者给出一个荒唐的负数。我一般会把截断写在函数最前面而不是指望调用方保证数据干净因为真实数据里出现 90 或者 89.999 的情况并不罕见。lonlat_to_tile_geodetic里y的算法是(90 - lat) / 180 * n本质是把纬度区间 [-90, 90] 线性映射到 [0, n]。这里注意天地图的级别是从 1 开始的n 2 ** z在 z1 时是 2全球 2×2 张瓦片这个和谷歌 z1 时也是 2×2 张是巧合两套金字塔的瓦片总数一样但覆盖范围的定义不一样所以千万别因为都是 2×2就以为可以互换。还有一个边界细节int()是向零取整对于正数等价于向下取整但经度正好等于 180 的时候(180180)/360*n n会越界。所以末尾的min(x, n-1)是必须的处理的是刚好落在东边界上这一根线的点。4. 显示级别与比例尺集合0–17、4–18、1–18 的错位怎么处理4.1 比例尺集合是同一个等比数列的不同窗口文档里三家的比例尺集合乍看是一堆乱七八糟的科学计数法实际规律非常干净它们全都是同一个等比数列公比 2只是起始位置不同。谷歌从 1.6901635716026553E-9 开始搜搜从 2.7042617145642484E-8 开始天地图从 3.3803271432053105E-9 开始。对照一下就能发现2.7042617145642484E-8 正好是谷歌数组的第 4 个元素索引从 0 数3.3803271432053105E-9 正好是谷歌数组的第 1 个元素。也就是说三家的比例尺不是三套不同的数列而是同一套数列被裁掉了前后不同的部分。那这个数本身代表什么1.6901635716026553E-9 是比例尺分母的倒数也就是 1:591658710 里的那个 1/591658710。要换成我们熟悉的米/像素得乘上 DPI 和英寸换算分辨率(米/像素) 0.0254 / (scale × DPI)取 DPI 96 代入第 0 级0.0254 / (1.6901635716026553E-9 × 96) 156543.03 米/像素。这个数正是 Web Mercator 第 0 级的标准分辨率赤道周长 40075016.68 米除以 256 像素完全对得上。所以这套比例尺集合是可验证的不是随便给的。理解到这一层后面所有的级别换算都有了依据级别之间的差异本质是分辨率翻倍而分辨率可以从比例尺反推。不用死记哪家从几级开始直接拿比例尺去对齐。4.2 级别对齐按比例尺对齐不要按数字对齐假设你有一份业务数据落在级别 5要分别去三家取瓦片。直觉做法是三家用同一个数字 5这是错的。正确做法是先确定基准再按比例尺找对应级别。以谷歌的级别号作为内部基准对齐关系如下内部级别基准谷歌搜搜天地图比例尺取谷歌数组00不支持不支持1.6901635716026553E-911不支持13.3803271432053105E-944442.7042617145642484E-8101010102.7691639957137904E-5171717172.2153311965710323E-418不支持18184.4306623931420646E-4表格里透露了一个反直觉的结论对齐之后三家的级别数字其实是一样的。搜搜的 4 级和谷歌的 4 级是同一个比例尺天地图的 1 级和谷歌的 1 级也是同一个比例尺。文档里4–181–18这些描述说的是可用范围不是偏移量。所以真正要处理的是边界不是偏移内部级别 0–3 时搜搜没有数据内部级别 0 时天地图没有数据内部级别 18 时谷歌没有数据。这三个空缺必须有明确的降级策略不能让它自己撞上去 404。4.3 用一份配置驱动多源底图把上面这些参数固化成一个配置结构代码里就再也不用记数字了。import math SOURCES { google: { origin: (-20037508.342789199, 20037508.342789199), y_down: True, min_zoom: 0, max_zoom: 17, scales: [1.6901635716026553E-9, 3.3803271432053105E-9, 6.760654286410621E-9, 1.3521308572821242E-8, 2.7042617145642484E-8], projection: mercator, }, soso: { origin: (-20037508.342789244, 20037508.342789091), y_down: False, # Y 轴向上需要翻转行号 min_zoom: 4, max_zoom: 18, scales: [2.7042617145642484E-8, 5.408523429128497E-8, 1.0817046858256994E-7, 2.1634093716513987E-7, 4.3268187433027975E-7], projection: mercator, }, tianditu: { origin: (-180.0, 90.0), # 度不是米 y_down: True, min_zoom: 1, max_zoom: 18, scales: [3.3803271432053105E-9, 6.760654286410621E-9, 1.3521308572821242E-8, 2.7042617145642484E-8, 5.408523429128497E-8], projection: geodetic, }, } def resolve_zoom(source, internal_zoom): 把内部级别映射到目标源的可用级别 超出范围时钳制并向调用方报告是否发生了降级 cfg SOURCES[source] if internal_zoom cfg[min_zoom]: return cfg[min_zoom], True # 第二返回值标记已降级 if internal_zoom cfg[max_zoom]: return cfg[max_zoom], True return internal_zoom, False def scale_to_zoom(source, scale): 按比例尺反查该源的级别用于跨源对齐校验 scales SOURCES[source][scales] base scales[0] idx round(math.log(base / scale, 2)) # 等比数列取对数即得偏移 return SOURCES[source][min_zoom] idx # 校验谷歌第 0 级的比例尺在天地图里应该是第 0 级即不存在钳到 1 print(resolve_zoom(tianditu, 0)) # (1, True) print(resolve_zoom(soso, 2)) # (4, True) print(scale_to_zoom(tianditu, 3.3803271432053105E-9)) # 1resolve_zoom的第二个返回值很有用。降级发生时前端应该把瓦片图层的maxNativeZoom卡住让客户端自己拉伸放大而不是继续向上请求不存在的瓦片。拉伸虽然糊一点但至少不会出现整片空白。scale_to_zoom用的是等比数列取对数的技巧既然是公比 2 的数列log2(基准/目标)出来的就是索引差取整即可。这个函数我一般放在启动自检里拿两三个已知级别跑一遍对不上就说明配置抄错了。5. 排查清单五个把底图叠错的实际原因5.1 现象整块底图偏出去约一半瓦片原因几乎总是 Y 轴方向理解反了。文档写Y 轴正方向向上实际数据是按 Y 向下组织的或者反过来。表现是低级别看不出来瓦片少、差异被缩放掩盖一放大就整体南北错位。解决是加一次2^z - 1 - y的翻转并且把翻转动作固定在一个地方。别在两处代码里各翻一次两次翻转等于没翻排查起来极其折磨。5.2 现象放大到 18 级后整片空白控制台一堆 404原因是拿内部级别直接去请求了不支持该级别的源。谷歌只到 17搜搜从 4 开始天地图从 1 开始边界上没有数据。解决分两步请求前用钳制函数卡住级别同时告诉前端图层原生最大级别是 N让客户端自己做像素拉伸。这两步缺一不可只钳制不设置maxNativeZoom会出现明明放大了但底图不变大的诡异观感。5.3 现象天地图和另外两家叠在一起偏移随纬度变化原因是投影混用。天地图是经纬度切片另外两家是墨卡托切片同一级别下瓦片的南北边界根本不在一个位置。低纬度偏得少高纬度偏得离谱这就是随纬度变化的来源。解决是统一投影。要么把天地图瓦片当作独立的经纬度图层单独渲染要么在客户端做重投影。临时用一个固定偏移量去补是错误的因为偏移量本身随纬度和级别都在变补得了一处补不了全图。5.4 现象同一级别下两家瓦片分辨率对不上接缝处模糊或锯齿原因是按数字对齐级别而不是按比例尺对齐。天地图的 1 级和谷歌的 0 级看着都是最低级实际比例尺差一倍3.38E-9 对 1.69E-9。解决是启动时用比例尺反查级别跑一遍对齐表把结果缓存下来。我一般会把对齐表打印到日志里出问题时第一眼看这个。5.5 现象瓦片能拼上但接缝有一条 1 像素的细线原因是瓦片地理范围的计算用了中心点 半宽而不是左上角 全宽两者差半个像素或者原点坐标抄的时候小数位截断太多。解决是把 bbox 计算统一成原点加偏移的写法如 2.3 节那段代码并且原点从配置里读不要在多处硬编码。抄小数点这件事少三位看着无所谓实际在 18 级会放大成可见误差。6. 把六组参数固化成自检脚本一次抄错原点之后的补救参数类文档最大的问题是看的时候都懂抄的时候全错。我后来的做法是拿到任何一份瓦片参数先花十分钟写一个断言脚本跑通再动业务代码。import math SHIFT math.pi * 6378137.0 def check_source(name, cfg, tile_size256): 对单份瓦片参数做三项基本自检 ox, oy cfg[origin] # 1) Web Mercator 源的原点必须落在 ±SHIFT 附近容差 1 米 if cfg[projection] mercator: assert abs(abs(ox) - SHIFT) 1.0, f{name} 原点 X 偏离半周长{ox} assert abs(abs(oy) - SHIFT) 1.0, f{name} 原点 Y 偏离半周长{oy} # 2) 比例尺集合必须是严格 2 倍等比 for i in range(1, len(cfg[scales])): ratio cfg[scales][i] / cfg[scales][i - 1] assert abs(ratio - 2.0) 1e-6, f{name} 第 {i} 项比例尺不是 2 倍{ratio} # 3) 最小级别那层的瓦片数必须是不小于 1 的 2 的幂 n 2 ** cfg[min_zoom] assert n 2 or cfg[min_zoom] 0, f{name} 起始级别异常{cfg[min_zoom]} print(f{name} 通过原点{ox:.6f},{oy:.6f} 级别{cfg[min_zoom]}-{cfg[max_zoom]})三项断言分别对应三类错误原点抄错量级、比例尺集合漏抄一项、起始级别写错。第二项特别有用——比例尺数组最容易在中间漏一个值肉眼完全看不出来但等比校验一跑就露。漏一项的后果是整个数组后半段全部错位一级表现是越高层级瓦片越糊很多人会误以为是源数据质量问题。tile_size参数现在都是 256但保留着不吃亏因为有些源用 512换算分辨率的时候要一起改。从那以后我每次接新底图不管是三家的哪一种都强制先把原点、比例尺、起始级别这三项喂进这个脚本跑一遍通过之后再写业务层。多花十分钟省掉的是半夜盯着错位底图猜原因的几个小时。希望帮到你。本文还有配套的精品资源点击获取