☰
地图瓦片下载与拼接实战:从XYZ坐标换算到断点续传工具详解
2026/10/20 6:39:39 网站建设 项目流程

简介:地图瓦片下载工具(AZMap 4.9)是一款面向离线地图获取与地理信息分析的实用程序,适合GIS开发者、测绘人员及户外应用爱好者,用于从在线地图服务批量下载指定区域的不同层级瓦片,解决弱网环境下地图数据使用与备份难题。压缩包共841个文件,大小约47.15MB,包含可执行的AZMap.exe、地理信息配置(prj)、瓦片数据(png)、样式脚本(js/css)及动态库(dll)等,覆盖从启动、参数设置到任务执行所需的完整文件结构。包内还提供了河北区域瓦片数据库(.azdb)、百度中国省市区域边界配置以及IIS服务批处理脚本,用户可直接套用或调整,快速建立本地瓦片服务或用于GIS二次开发。已有1210人学习下载,整体结构清晰,可直接上手或二次整合,是地图数据离线化处理的高性价比参考方案。

1. 地图瓦片下载工具:为什么别人五分钟跑完你卡在一半

同样是拉一份 16 级瓦片,有人五分钟跑完,有人跑到一半就被一串 403 卡死。我拆这套地图瓦片下载工具时最大的感受是:问题多数不是出在网速,而是出在对瓦片编号的理解——x/y 坐标差一个符号,后面所有拼接工作全部白做,纯纯的“黑匣子”式返工。这个工具解决的事情很具体:把经纬度范围拆成 XYZ 瓦片编号,按并发限速加失败重试把图片拉下来,最后按坐标顺序拼成一张大图并输出配准信息。适合做地图数据预处理、成果留档截图、或者给内部系统提供离线底图的开发者。先声明一句:工具只用于你有权访问的数据,目标站点怎么接入、版权怎么约定,必须自己把关。下面所有参数和脚本,都是我从模拟项目 X 的实测中一点点抠出来的。

2. XYZ 瓦片规则与坐标换算:从四个参数读懂瓦片地址

2.1 瓦片地址不是随机字符串:先看懂 z/x/y 的编码逻辑

大部分在线地图的瓦片地址长得像这样:

https://tiles.example.com/{z}/{x}/{y}.png

花括号里不是随便填的。整个瓦片体系是一个四叉树:第 0 级只有 1 张瓦片覆盖全球,第 1 级切成 4 张,第 2 级切成 16 张,每升一级瓦片数量变成上一级的 4 倍。翻译成公式,第 z 级的总瓦片数是 2 的 z 次方的平方,也就是(1 << z) * (1 << z)。x 是从左往右数的列号,y 是从上往下数的行号,原点在左上角,这一点和屏幕坐标系一致。

这个规则决定了两件事。第一,如果某服务商把瓦片地址写成{z}/{y}/{x}或者{z}/{x}/{y},只是路径顺序不同,底层坐标语义一样。第二,y 轴方向不是所有地方都一致。少数服务采用 TMS 规则,y 从下往上数,第 0 行在地图最南边;XYZ 规则第 0 行在最北边。差一个翻转关系,换算就得把 y 变成(1 << z) - 1 - y。

这里我放一张参数表,方便对照理解:

参数含义常见取值影响
z缩放级别0~19级别越高,单张瓦片覆盖范围越小
x列号0 ~ (1<<z)-1决定瓦片的经度方向位置
y行号0 ~ (1<<z)-1XYZ 自左上起算;TMS 自左下起算
瓦片尺寸单张边长256 或 512 像素决定最终出图分辨率

2.2 经纬度与瓦片编号互转:公式就这么短,坑却不少

开发者常用的换算公式是这两段:

import math def lonlat_to_tile(lon: float, lat: float, zoom: int): n = 1 << zoom x = int((lon + 180.0) / 360.0 * n) y = int((1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n) return x, y def tile_to_lonlat(x: int, y: int, zoom: int): n = 1 << zoom lon = x / n * 360.0 - 180.0 lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * y / n))) lat = math.degrees(lat_rad) return lon, lat

逻辑上,经度是线性映射,纬度经过墨卡托投影的反变换,所以必须套atan(sinh())而不是直接按比例缩放。参数说明里有两处要和你确认:zoom必须和请求 URL 里的 z 一致;x 和 y 的取值范围在 0 到(1<<zoom)-1之间,越界了说明经纬度超范围。

实际使用中还有一个精度问题:换算后是取整,不同语言、不同库对负数取整行为不一致。比如经度 -179.9 度,某些语言里int(-0.02)得到 0,有些得到 -1。解决办法是先把经度加 180 保证非负,再乘 n 取整,代码里我已经做了这个处理。

2.3 边界误差的典型表现:多一行少一行导致拼接留缝

经纬度转瓦片编号时最容易被忽略的是“瓦片边界是否闭合”这个问题。一个区域范围(lon_min, lat_min, lon_max, lat_max),如果四个边界都按同一套公式取整,常常会漏掉最东边或最南边的一行瓦片。原因在于经纬度转出的编号是区间左闭右开:lon_max对应的 x 值刚好卡在下一张瓦片起点时,会被取整到新一列。

我一般会这样处理:对最大经纬度做一次“右移半个像素”的修正,再取整。具体做法是在换算前给lon_max加上一个极小偏移,例如1e-6,让边界值安全落在当前瓦片内部。这种偏移不会影响坐标落到哪个级别,但对拼接时“是否缺一条缝”影响很大。

另一个边界问题是 zoom 的选择。多数在线瓦片服务把最高级别限制在 19 级左右,超过这个级别请求会直接返回空图或 404。工具包里默认配置只生成 0 到 19 级的任务,如果你业务上确实需要 20 级,先确认目标服务是否支持,再改配置,不要盲目把 zoom 上限拉高。

3. 搭建可断点续传的下载器:并发限速与失败重试参数怎么定

3.1 生成任务清单:先算全量坐标再决定要不要中途停

下载器第一步不是发请求,而是把待下载的瓦片坐标全部算出来,落成一个任务清单。重点在于“全量”两个字:先整体算一遍,再决定怎么切分,否则中途想续传的时候连自己缺什么都没法枚举。

from pathlib import Path def generate_tasks(zoom_min, zoom_max, lon_min, lat_min, lon_max, lat_max): tasks = [] for zoom in range(zoom_min, zoom_max + 1): x_min, y_min = lonlat_to_tile(lon_min, lat_max, zoom) x_max, y_max = lonlat_to_tile(lon_max, lat_min, zoom) for x in range(x_min, x_max + 1): for y in range(y_min, y_max + 1): tasks.append({ "zoom": zoom, "x": x, "y": y, "path": f"tiles/{zoom}/{x}/{y}.png", }) return tasks

这里的lonlat_to_tile直接复用第 2 章的换算函数。逻辑顺序是:先按最小经度、最大纬度求左上角瓦片编号,再按最大经度、最小纬度求右下角瓦片编号,然后双重循环把矩形区域内的瓦片坐标全部枚举出来。参数说明:zoom_min和zoom_max是你要抓的缩放级别区间,lon_min和lat_min是西南角坐标,lon_max和lat_max是东北角坐标。注意传入顺序,我把lat_max传给第一个函数是因为格式化输出文本里习惯写成“西、南、东、北”,但坐标计算的时候一定要按左上角到右下角的顺序。

任务清单生成完之后,建议先打印数量再开始下载。一个 16 级的城市级范围,瓦片数量通常是几万到十几万张,如果清单数量超过预期,先回头检查经纬度边界是不是填反了。这种检查成本远低于下载到一半再发现范围错的返工成本。

3.2 并发下载与限速:抢带宽不是目的,不被限流才是

下载瓦片这个动作本身不难,难点在网络调度。并发开太小跑得慢,开太大容易触发服务端限流,经验做法是把并发控制在 8 到 16 之间,每一批任务之间加一个很短的随机间隔。

import random import time import requests from concurrent.futures import ThreadPoolExecutor, as_completed SESSION = requests.Session() SESSION.headers.update({"User-Agent": "Mozilla/5.0 (compatible; tile-downloader/1.0)"}) def download_one(task, retries=3, timeout=10): url = f"https://tiles.example.com/{task['zoom']}/{task['x']}/{task['y']}.png" path = Path(task["path"]) if path.exists() and path.stat().st_size > 0: return True path.parent.mkdir(parents=True, exist_ok=True) tmp_path = path.with_suffix(".part") for attempt in range(retries): try: resp = SESSION.get(url, timeout=timeout) if resp.status_code == 200: tmp_path.write_bytes(resp.content) tmp_path.rename(path) return True elif resp.status_code in (403, 429): wait = 1 + attempt * 2 + random.random() time.sleep(wait) else: time.sleep(0.5) except requests.RequestException: time.sleep(1 + attempt) return False def run(tasks, max_workers=10): failed = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = {executor.submit(download_one, t): t for t in tasks} for future in as_completed(future_map): if not future.result(): failed.append(future_map[future]) return failed

逻辑上,这个下载器做了三件事:先判断文件是否已存在并大于 0 字节,命中就直接跳过;下载时先写.part临时文件,完整落盘后再重命名,防止半截文件污染正式目录;遇到 403 或 429 状态码时按退避策略等待后重试。参数说明里最值得调的是max_workers、retries和timeout。max_workers建议从 10 起步,带宽够可以加到 16,加多了触发限流的概率会明显上升。timeout设 10 秒是折中值,太短容易在弱网下误判失败,太长会拖住整个线程池。retries设 3 次,加上退避等待,单张瓦片的最长等待时间大约在 7 秒左右,这个量级在线地图服务通常能接受。

关于 User-Agent,我一般会伪装成一个常规浏览器内核的标识。这不是为了绕过什么限制,而是因为很多服务对默认的 Python-requests 标识会直接返回 403,属于基本的接入礼仪。如果你用的服务对 UA 校验很严格,请把 UA 换成目标场景里允许的合法标识。

3.3 断点续传与文件校验:用已存在状态代替数据库

断点续传最朴素且最可靠的方案不是建立下载记录表,而是“文件系统即状态”。每一张瓦片的落盘路径天然具有唯一性:tiles/{zoom}/{x}/{y}.png就是一个主键。重新运行时,只要这张文件存在且大小大于 0,就认为它已经下载成功,跳过即可。

def collect_missing(tasks): missing = [] for t in tasks: p = Path(t["path"]) if not p.exists() or p.stat().st_size == 0: missing.append(t) return missing

逻辑上是把第 3.1 节的任务清单拿来过一次筛子,缺的留下,存在的放走。参数说明:这里只判断文件是否存在和大小是否非零,不校验内容完整性。更严格的做法是用 PIL 打开图片确认头信息,但那种方式速度慢,适合二次校验,不适合任务启动时全量执行。我在工具包里把校验分成两级:启动时用collect_missing快速续传,跑完全量后再用Image.verify()抽查一批文件有没有被截断或损坏。后者的代码在拼接章节里一并说。

续传的逻辑看似简单,实际有一个坑:任务清单必须和之前完全一致。如果两次运行传入的经纬度范围或 zoom 范围不同,文件系统里已有的瓦片和新的任务清单会对不上,导致拼图缺块。我开始跑数据时也犯过这个错,改了一个边界参数之后直接续传,结果拼出来的图缺了东边一条。解决方法是把任务清单序列化保存一份到本地,续传时优先读旧的清单,而不是重新生成。

4. 顺序拼接与坐标落位:从千张瓦片拼成一张可定位大图

4.1 按坐标字典拼接:顺序依赖目录结构是新手最常踩的坑

瓦片下载完成后,把它们按顺序拼起来,这个“顺序”不是文件名排序,而是 x/y 坐标排序。文件名里y可能是 9、10、11,字符串排序的结果是 10 排在 9 前面,直接拼就会错位。正确做法是把坐标解析出来,放进字典里按坐标取图。

from PIL import Image TILE_SIZE = 256 def stitch_tiles(zoom, x_range, y_range, tile_root="tiles"): width = (x_max - x_min + 1) * TILE_SIZE height = (y_max - y_min + 1) * TILE_SIZE canvas = Image.new("RGB", (width, height)) for x in range(x_min, x_max + 1): for y in range(y_min, y_max + 1): img_path = f"{tile_root}/{zoom}/{x}/{y}.png" try: tile = Image.open(img_path).convert("RGB") except Exception: tile = Image.new("RGB", (TILE_SIZE, TILE_SIZE), color=(255, 255, 255)) canvas.paste(tile, ((x - x_min) * TILE_SIZE, (y - y_min) * TILE_SIZE)) return canvas

逻辑上,canvas.paste的位置不是简单乘 x,而是先减去x_min,拿到相对坐标后再乘以瓦片边长,这样保证输出图左上角对应任务范围左上角。参数说明:zoom决定你取哪个级别的瓦片,x_range和y_range是这一级别下你要覆盖的列范围与行范围,tile_root是瓦片根目录。这段代码的核心是convert("RGB"),因为部分瓦片是 RGBA 模式,直接粘贴到 RGB 画布上会报模式不匹配。

代码里对缺失瓦片补白块的做法是阶段性的,拼出来看整体效果还可以,但如果要生成正式成果,缺片不能静默补白,应该把缺失坐标单独收集起来,重跑下载流程。工具包里我的做法是让stitch_tiles返回两个值:画布和缺失列表,缺片信息统一写进日志文件,方便下次补下。

4.2 把经纬度写回输出图:配准信息不是可选项

很多人在这一步就停了,拼出一张漂亮的街景图,却完全不知道它的地理范围。这样的成果只能看,不能导入 GIS 工具,也不能和别的数据叠加。正确做法是把输出图的左上角经纬度、右下角经纬度一并算出来,写成一个简单的坐标描述文件。

def export_bounds(zoom, x_min, y_min, x_max, y_max, out="/tmp/bounds.txt"): lon_left, lat_top = tile_to_lonlat(x_min, y_min, zoom) lon_right, lat_bottom = tile_to_lonlat(x_max + 1, y_max + 1, zoom) with open(out, "w") as f: f.write(f"zoom={zoom}\n") f.write(f"left={lon_left}\n") f.write(f"top={lat_top}\n") f.write(f"right={lon_right}\n") f.write(f"bottom={lat_bottom}\n")

逻辑上,左上角坐标就是(x_min, y_min)对应的经纬度,右下角则要取(x_max + 1, y_max + 1)对应的经纬度,因为瓦片边界是闭区间,取 x_max 本身只能得到最后一张瓦片的左上角,取加一后的编号才能得到整张输出图的右下角。参数说明:zoom、x_min等变量必须和生成拼接画布时完全一致,否则配准信息对不上图。这套办法输出的文本是通用格式,后续要转成 GeoTIFF 时可以据此填到对应字段。

如果目标应用需要人工在 GIS 软件里加载,我一般还会生成一个*.aux.xml的旁路文件,或者直接在拼接完的 PNG 同级目录放一份同名的 Word 文档说明边界范围。别嫌这个动作多余,真实项目中“图片边界对不上”是返工率最高的错误之一。

4.3 多级瓦片与内存策略:先拼底层级,再抽稀叠加

拼接一张 16 级城市级大图时,输出尺寸轻松超过两万乘两万像素。PIL 的Image.new("RGB", ...)对这种尺寸的内存占用很高,一个像素 3 字节,两万乘两万就是 1.2GB 左右,开发机内存不够就直接MemoryError翻车。我一般采取的方案是分块拼接:把大图拆成固定大小的块,例如每块 4096 像素见方,各块单独拼接,最后再拼接成总图。

BLOCK = 4096 def stitch_by_block(zoom, x_min, y_min, x_max, y_max): x_block_count = math.ceil((x_max - x_min + 1) * TILE_SIZE / BLOCK) y_block_count = math.ceil((y_max - y_min + 1) * TILE_SIZE / BLOCK) for bi in range(x_block_count): for bj in range(y_block_count): x_start = x_min + bi * (BLOCK // TILE_SIZE) x_end = min(x_max, x_start + BLOCK // TILE_SIZE - 1) y_start = y_min + bj * (BLOCK // TILE_SIZE) y_end = min(y_max, y_start + BLOCK // TILE_SIZE - 1) block_img = stitch_tiles(zoom, (x_start, x_end), (y_start, y_end)) block_img.save(f"blocks/{bi}_{bj}.png")

逻辑上,每个块最多包含BLOCK // TILE_SIZE张瓦片,典型值是 16 张。先按块保存,最后再拼总图时每个块作为一张大图参与合成,内存占用峰值从“整张图”降为“单块图加最终画布”。参数说明:BLOCK是分块边长,单位像素,太大失去分块意义,太小会产生大量碎片文件,我通常取 4096 或 8192。这个方案还能带来一个附加好处:某个块拼接失败时,只需要重拼这一个块,不用整张图重来。

多级瓦片同时下载时,我建议先拼最低层级,也就是 zoom 值最小的那一级。低层级瓦片数量少、单张覆盖范围大,拼出来能快速验证坐标和范围有没有问题。确认无误后再拼更高层级,避免在最高层级上浪费几十分钟后发现范围错误。

5. 避坑指南:五个高频翻车点与排查顺序

5.1 现象:拼接出的图片上下颠倒

有一次我拼完整个区域,发现地图上半部分是下半部分的镜像,路网全都反了。原因是目标服务使用 TMS 坐标,y 轴方向从南向北递增,而我的拼接代码按 XYZ 规则把第 0 行放在了最上方。解决很简单:在生成任务清单时做一次 y 翻转,把y替换为(1 << zoom) - 1 - y。排查顺序是:先看单张瓦片的 URL 参数是y还是tms,再看拼接代码是不是统一用了同一套坐标。

5.2 现象:下载任务全跑完,拼接时却报大量文件不存在

这个问题的典型原因是任务清单和实际下载使用的文本编码或范围不一致。我遇到过两次:一次是经纬度边界在配置文件里被写成整数,边界值直接丢失了小数部分,导致最外圈瓦片少算;另一次是换了一台机器续传,路径分隔符被 Windows 风格改成反斜杠,文件系统找不到目录。解决办法是任务清单文件里显式保存zoom_min、zoom_max、经纬度边界等元信息,续传前先对比清单元信息,不一致就让下载器重新生成任务。不要相信“这次范围和上次一样”,除非清单文件里白纸黑字写着。

5.3 现象:断点续传后文件存在,但图片是 0 字节或半截图

.part临时文件机制能防一部分问题,但不能防全部。有些下载中途连接被重置,write_bytes还没执行完,进程就被杀掉了,留下 0 字节正式文件。我的习惯是下载完成后立刻写一个文件头校验:打开图片,检查前 8 个字节是否为PNG或JPEG的文件签名。代码里可以用resp.content[:8]直接判断,不需要全量解码。排查顺序:先看报错文件的大小,0 字节就直接删除重下;大于 0 字节的用Image.verify()验证,验证失败的也删除重下。

5.4 现象:并发跑高了以后大量请求返回 429

我一开始把max_workers调到 32,下载速度确实快了一倍以上,但很快连续返回 429,整个目录被限流了一段时间。这是因为服务对每 IP 的每分钟请求数有限制,并发越高越容易触发。解决办法不是把并发调回 4 这么保守,而是把“失败后的退避等待”做得更聪明:429 后的等待时间按指数退避,并且对不同批次之间加入随机抖动。参数配置上,我用的是retries=4,等待时间依次是 1 秒、3 秒、7 秒、15 秒,重试期间如果遇到 200 就立即返回成功。从那以后我再也不追求“把带宽跑满”,下载器对目标服务更友好,整体完成时间反而更短。

5.5 现象:拼接画布出现大块黑色或白色方块

黑块通常说明瓦片下载成功但内容是空白占位图,白块通常说明瓦片本身不存在或返回了 404。这两种情况不能混为一谈。排查方法是把单张瓦片打开看看内容,如果是透明或空白的 PNG,多半是目标服务在该级别没有切割这一区域;如果是黑色占位图,多半是范围超出了服务覆盖范围。解决方式是先检查经纬度范围是否在服务覆盖区域内,然后对特定缺失的瓦片尝试换一种底图样式或换一个 zoom 级别验证范围是否合理。

还有一个我后来才发现的细节:部分服务在高 zoom 级别会把瓦片切成 512 像素而不是 256 像素,如果你下载的瓦片尺寸和拼接代码里的TILE_SIZE不一致,拼接结果会重叠或留缝。下载完成后随机取一张瓦片,看一眼尺寸再跑拼接,这一步零成本,能省掉一整轮返工。

6. 进阶技巧:瓦片合并去重与三层验证习惯

多级下载完成后,磁盘里同一个区域往往同时存在 15、16、17 级的瓦片,文件数量很大。很多场景下并不需要全部保留,我惯用的做法是以“覆盖范围完全包含”为原则做去重:如果 zoom=16 的某张瓦片存在,且它对应的 zoom=15 瓦片下载完整,就删掉 16 级的同区域瓦片。判断依据是瓦片编号的继承关系:16 级编号(tx, ty)对应 15 级编号(tx // 2, ty // 2)。按这个关系逐级向上合并,磁盘占用能降到一个级别左右。

def dedupe_upwards(root="tiles", max_zoom=17): for zoom in range(max_zoom, 0, -1): for px in range(1 << zoom): for py in range(1 << zoom): upper_x, upper_y = px // 2, py // 2 lower_file = f"{root}/{zoom}/{px}/{py}.png" upper_file = f"{root}/{zoom-1}/{upper_x}/{upper_y}.png" if Path(upper_file).exists() and Path(lower_file).exists(): Path(lower_file).unlink()

逻辑上是自顶向下逐级删冗余,保留覆盖范围大的低层级瓦片。参数说明:max_zoom是你要保留的最高层级,建议 17 就够了,超过这个值文件数量指数级上升,但视觉差异并不明显。这段代码有个前提:缺片不能删,所以删之前必须确认低层级文件存在且非 0 字节,否则会丢数据。

隐蔽水印是瓦片场景里绕不开的话题。有些服务会在瓦片边缘叠加一层很淡的半透明色带,拼成大图后形成肉眼可见的网格线。我的处理方法分三步:先随机取几张大图连接处的瓦片,看边缘有没有规律色差;如果有,在拼接时对四条边做 1 像素的裁剪后再粘贴;如果水印在整张图上,可以按目标站点的合法授权约定做适当提亮处理。这里的边界在于你能对下载后的数据做什么,必须遵守目标站点的条款,不要自己脑补规则。

最后说一个我现在的固定习惯:生成最终大图后,我不直接看全图,而是做三层验证。第一层,取左上角、中心、右下角三个点,把对应经纬度写进日志;第二层,把这三个点放到原始瓦片 URL 里手动请求一次,确认坐标对应区域的纹理特征一致;第三层,用拼接前记录的任务清单数量减去拼接时缺失的瓦片数量,核对最终画布是否完整。三层都通过,这份成果才算真的能用。

我之所以养成这个习惯,是因为有一次坐标偏移了半个瓦片宽度,全图看完全正常,直到叠加另一份数据才发现所有位置都偏了将近 200 米。从那以后,坐标抽查被我列为每次出图的强制性动作,比任何格式化检查都管用。如果你也在做地图瓦片相关的下载与拼接,建议把这一层验证放进你的工具流程里。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询