news 2026/10/25 13:06:45

多源瓦片地图参数对照:切片原点、轴向与比例尺避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多源瓦片地图参数对照:切片原点、轴向与比例尺避坑

简介:这份文档面向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 Mercator(EPSG: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.342789244,Web Mercator 的半周长 def tile_bbox(z, x, y, origin_x=-SHIFT, origin_y=SHIFT, tile_size=256, y_down=True): """ 计算瓦片的地理范围(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.257222101,WGS84 的长半轴 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在 z=1 时是 2,全球 2×2 张瓦片,这个和谷歌 z=1 时也是 2×2 张是巧合,两套金字塔的瓦片总数一样但覆盖范围的定义不一样,所以千万别因为"都是 2×2"就以为可以互换。

还有一个边界细节:int()是向零取整,对于正数等价于向下取整,但经度正好等于 180 的时候(180+180)/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-9
11不支持13.3803271432053105E-9
44442.7042617145642484E-8
101010102.7691639957137904E-5
171717172.2153311965710323E-4
18不支持18184.4306623931420646E-4

表格里透露了一个反直觉的结论:对齐之后,三家的级别数字其实是一样的。搜搜的 4 级和谷歌的 4 级是同一个比例尺,天地图的 1 级和谷歌的 1 级也是同一个比例尺。文档里"4–18""1–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)) # 1

resolve_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_size=256): """对单份瓦片参数做三项基本自检""" 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,换算分辨率的时候要一起改。

从那以后我每次接新底图,不管是三家的哪一种,都强制先把原点、比例尺、起始级别这三项喂进这个脚本跑一遍,通过之后再写业务层。多花十分钟,省掉的是半夜盯着错位底图猜原因的几个小时。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/25 13:03:06

基于OpenCV+dlib的疲劳驾驶检测系统设计与实现

简介&#xff1a;围绕疲劳驾驶检测这一计算机视觉落地场景&#xff0c;这份毕业设计文档给出了基于 dlib 模型的系统设计与实现方案&#xff0c;适合计算机视觉、Web 设计方向的学生&#xff0c;以及需要完成课程设计、毕业设计的学习者参考。内容从研究背景与意义切入&#xf…

作者头像 李华
网站建设 2026/10/25 12:56:51

C++正则转最小化DFA:AST解析到词法分析器完整实现

简介&#xff1a;这是一份以C写就的正则表达式转最小化DFA源码&#xff0c;面向编译原理课程学习者、自动机算法爱好者及需要完成词法分析实验的学生。代码共1000余行&#xff0c;按正则表达式解析、NFA构造、子集构造法转DFA、DFA最小化四个阶段组织&#xff1b;实现中大量使用…

作者头像 李华
网站建设 2026/10/25 12:41:55

BERT中文文本情感分类实战:从环境搭建到模型部署全流程

简介&#xff1a;这份压缩包是一套面向毕业设计的中文文本情感分类项目实现&#xff0c;基于BERT预训练模型与Python开发&#xff0c;适合自然语言处理入门者及需要快速完成课程设计的学生。项目覆盖数据准备、Tokenizer词元化、BERT微调、分类层设计与输出预测等关键环节&…

作者头像 李华
网站建设 2026/10/25 12:41:46

音乐AI实战:从歌词生成旋律到旋律生成伴奏的深度学习实现

简介&#xff1a;这份资源围绕深度学习驱动的音乐生成设计完整实践教程&#xff0c;面向AI开发者、算法学习者及音乐科技爱好者。内容以歌词为输入生成旋律&#xff0c;再以旋律为输入生成伴奏&#xff0c;两个模型可分别训练&#xff0c;覆盖自然语言处理、音乐特征建模与序列…

作者头像 李华