☰
地图瓦片下载工具实战:瓦片金字塔、XYZ协议与多线程批量拉取详解
2026/10/10 3:15:45 网站建设 项目流程

简介:地图瓦片下载工具是一套面向GIS开发者、地图制图与户外数据采集人群的专业软件资源,核心为AZMap.exe(版本4.9),支持从Google Maps、Bing等在线地图源批量获取指定区域的多层级瓦片图像,解决网络受限时高分辨率地图数据的获取与离线使用问题。压缩包共841个文件,约47.15MB,包含主程序、JS/CSS界面资源、PROJ坐标参考、PNG/DLL库文件,以及CSV/DXF/GPX等多种地理数据格式,另附初始化服务器批处理与区域任务索引(如河北、百度中国省市区域数据),便于部署与测试。这份资源已有1210人学习,适合需要自建本地地图、开展GIS分析或备份在线地图服务的进阶用户。下载后可自主配置地图源、层级、坐标系统与输出格式,结合ArcTiler等组件完成瓦片管理、整合与发布,显著提升地图数据获取效率。

1. 地图瓦片下载工具:离线底图批量拉取的完整链路

做GIS可视化或者离线地图Demo的时候,最现实的问题就是地图瓦片下载工具从哪来。手动拖窗保存一张张图不现实,按坐标爬又要处理并发限制、断点续传一堆隐性条件。这套工具把瓦片金字塔、XYZ编号、多线程下载整合成一个工程脚本,输入经纬度范围和缩放级数,批量拉取并自动重试。适合做数据可视化、需要本地底图做原型验证、或者想自建缓存层的开发者。这篇直接拆到参数级,把计算规则、配置落点和翻车现场一次讲完。

2. 瓦片金字塔与XYZ协议:动手前先搞懂坐标系和命名规则

2.1 从Web墨卡托到金字塔:一张图被切成几万张小块的规则

现在所有在线瓦片服务的底层逻辑都是同一个:把全球地图放进一个正方形平面,然后按2的幂次不断细分。这个正方形的基础就是Web墨卡托投影(EPSG:3857),本质上是把球面坐标拉平。第0级整张世界地图只有1张瓦片,第1级切成2×2、4张,依此类推,第z级就是2^z × 2^z张。层级越深,文件数量呈指数膨胀,但单张瓦片恒定256×256像素,所以加载快、缓存好做。地图应用最核心的体验就来自这个金字塔结构。

瓦片编号规则是最容易翻车的地方。XYZ协议里,x从左到右递增,y从上到下递增。某点经纬度在zoom级下的编号计算,我习惯写成下面这个函数:

import math def wgs84_to_tile(lat, lon, zoom): lat_rad = math.radians(lat) n = 2.0 ** zoom x = int((lon + 180.0) / 360.0 * n) y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) return x, y

这里的n就是当前层级的单边瓦片数。x的公式本质是把经度映射到0到n-1的整数区间;y用了反双曲正弦变换,是Web墨卡托的逆运算,比早期写法里的log(tan+sec)更稳定,尤其在高纬度区域不容易出现浮点越界。我一般会把返回值顺手打印出来,和在线地图调试工具里的坐标对一下,确认编号体系没搞混,再进行批量任务。

2.2 XYZ、TMS与WMTS:三种协议看请求和Y轴

下载之前还有一个选型问题:你要面对的瓦片源是按哪套协议组织的。XYZ是最常见的,路径直接就是/{z}/{x}/{y}.png,很多在线切片服务都暴露这种接口。TMS历史上用于Mapnik和早期离线地图,它的Y轴方向和XYZ是反的,从下往上递增。WMTS是OGC标准,请求里通常带layer、style、matrixSet这些参数,灵活性高但也更啰嗦。

协议路径模板Y轴方向适用场景
XYZ{base}/{z}/{x}/{y}.png上→下递增多数在线瓦片源、通用缓存
TMS{base}/{z}/{x}/{y}.png下→上递增早期地图、部分离线包
WMTS{base}?layer=…&matrix={z}&row={y}&col={x}依规范定义标准服务、数据交换

判断方法很简单:下载同一层级的两张靠边瓦片,分别放进来拼一下,Y轴反了视觉上立刻错位。或者看服务文档里对行列号起点的说明。我的经验是优先选XYZ,因为后续拼图、切片、缓存工具的默认值都围绕它设计,TMS和WMTS往往要做一层适配,多花的时间完全能把转换逻辑省下来的时间挣回来。

2.3 存储膨胀与请求策略:先算账再动手

批量下载最大的风险不是代码,而是没算账。第18级的瓦片全量下载是2^18 × 2^18张,数量级在690亿张,没有任何单机方案能全量拉完。实际操作时,应该先根据范围计算数量:比如一个覆盖约25km×25km的城市范围,第16级大约只有几千张瓦片,第18级会翻4倍。这里说4倍可能保守了——层级每加一,瓦片数实际乘以4,放大两个层级就是16倍。

计算公式很简单:层级z、范围[min_x, max_x] × [min_y, max_y]的瓦片总数 = (max_x - min_x + 1) × (max_y - min_y + 1)。这还没有考虑瓦片平均大小。PNG底图单张通常30到100KB,JPG会更大。先把总量乘上单张平均大小,再留出30%的余量,才是一个靠谱的磁盘预估。请求策略上,在线瓦片源都有并发限制,全开线程池把瓦片源逼到返回429,整个下载队列就会进入失败重试的恶性循环。所以后面的章节里,我给出的代码会把全局并发控制在8到16之间。

3. 工程拆解:下载器、任务队列与断点续传的实现方式

3.1 项目结构与核心依赖:为什么选Python这一套

Python的requests、concurrent.futures、PIL三方库组合非常成熟。requests处理HTTP重试和连接复用;concurrent.futures的ThreadPoolExecutor用于多线程并发;PIL用于最后拼图验证。项目结构我一般拆成四个文件,职责清晰,后续换地图源只改配置不改代码:

tile_downloader/ ├── config.json ├── tile_math.py ├── downloader.py └── task_queue.py

config.json放坐标系和路径模板,tile_math.py放编号计算函数,downloader.py封装HTTP下载和重试,task_queue.py负责并发调度。这样一个拉瓦片的朋友在模拟项目X里用这套结构,从拿到需求到跑出第一版底图,大概只用了一个小时。问题不在于代码量,而在于每一层都能单独测、单独换。

3.2 下载器模块:单瓦片请求与重试机制

核心下载函数不复杂,但有几个边界得提前想清楚:超时不能写死、重试要有限制、404和204这种“源上确实没有”的状态码不能当作失败来重试。我习惯写成下面这样:

import requests def download_one(session, url, file_path, retries=3, timeout=15): for attempt in range(retries): try: resp = session.get(url, timeout=timeout) if resp.status_code == 200: with open(file_path, 'wb') as fh: fh.write(resp.content) return True if resp.status_code in (404, 204): # 源上确实没有这张瓦片,不算失败 return True resp.raise_for_status() except requests.RequestException: if attempt == retries - 1: raise return False

timeout参数单位是秒,这里设15秒,覆盖从连接到读响应体的完整等待。retries是3次,不是越多越好,重试太频繁反而容易触发源的反爬策略。session要由外部传入,因为requests.Session会复用TCP连接,比每次新建连接快很多;传同一个session进去,几百张瓦片下载时三次握手次数会大幅下降。

提示:如果下载的是高分辨率影像瓦片,建议把timeout调大到30秒,否则网络抖动时容易误判失败。

3.3 任务队列:多线程并发与限流控制

单线程下载太慢,一个几百张瓦片的任务要跑十几分钟;线程开多了又容易触发源的限流。我一般用ThreadPoolExecutor搭配BoundedSemaphore,既保证并发数可控,又防止任务积压时内存暴涨:

from concurrent.futures import ThreadPoolExecutor import threading def run_download(task_list, worker=8, queue_depth=128): semaphore = threading.BoundedSemaphore(queue_depth) def safe_download(item): with semaphore: return download_one(item["session"], item["url"], item["path"]) with ThreadPoolExecutor(max_workers=worker) as executor: list(executor.map(safe_download, task_list))

worker是同时工作的线程数,queue_depth是等待队列里最多排队的任务数。BoundedSemaphore的值就是队列积压上限,超过之后,executor.map会自然阻塞,等前面的任务完成后才继续提交。这样即使task_list有几万个任务,内存也不会被一次性吃满。参数上我建议worker从8起步,如果源响应快、没有429,再逐步加到16;queue_depth保持worker的16倍左右比较稳妥。

4. 批量下载实战:从经纬度范围到瓦片文件的参数落点

4.1 从经纬度到瓦片边界:先算范围再进循环

批量下载的第一步是计算范围的四角编号。很多人直接拿起点和终点坐标各做一次wgs84_to_tile,结果边缘瓦片少算了一整列。正确做法是用范围的四角去算,然后取x和y各自的最小最大值:

def bbox_to_tile_range(min_lat, min_lon, max_lat, max_lon, zoom): min_x, max_y = wgs84_to_tile(max_lat, min_lon, zoom) max_x, min_y = wgs84_to_tile(min_lat, max_lon, zoom) return min_x, max_x, min_y, max_y

传参顺序容易绕晕,我习惯固定写成min_lat、min_lon、max_lat、max_lon。注意这里调用函数时,算min_x用的是max_lat,因为经纬度转换到编号是反直觉的:高纬度对应较小的y,所以min_x那一行传入的是max_lat。写错这个,下载范围会整体偏移一个层级或跑到完全不同的区域。

4.2 配置文件与命令行参数设计

config.json是我所有下载任务的第一道关卡,参数拆得越细,后面调试越省事。一个最小可用的配置长这样:

{ "min_lat": 30.0, "min_lon": 100.05, "max_lat": 30.05, "max_lon": 100.1, "min_zoom": 14, "max_zoom": 16, "concurrency": 8, "output_dir": "./tiles", "source_url": "http://{base_url}/{z}/{x}/{y}.png", "tile_size": 256, "retries": 3, "timeout": 15 }

这里min_lat、min_lon、max_lat、max_lon是你要下载范围的四角经纬度;min_zoom和max_zoom是层级区间,注意max_zoom越大,瓦片数量指数增长;concurrency就是前面说的线程数,8是保守值;source_url是瓦片源路径模板,实际请求时需要把{z}、{x}、{y}替换成具体编号。tile_size和timeout分别是瓦片像素尺寸和请求超时,拼图验证时会用到tile_size。

4.3 一个可运行的批量下载核心循环

把前面的函数组合起来,就能得到一个完整可跑的下载脚本。下面这个版本去掉了无关的日志和统计,保留最核心的动作:

import os, json, requests from concurrent.futures import ThreadPoolExecutor def build_task_list(config): tasks = [] for zoom in range(config["min_zoom"], config["max_zoom"] + 1): min_x, max_x, min_y, max_y = bbox_to_tile_range( config["min_lat"], config["min_lon"], config["max_lat"], config["max_lon"], zoom ) for x in range(min_x, max_x + 1): for y in range(min_y, max_y + 1): tasks.append((zoom, x, y)) return tasks def make_download_item(config, zoom, x, y): url = config["source_url"].replace("{z}", str(zoom)).replace("{x}", str(x)).replace("{y}", str(y)) out_path = os.path.join(config["output_dir"], str(zoom), str(x), f"{y}.png") os.makedirs(os.path.dirname(out_path), exist_ok=True) return {"url": url, "path": out_path} with open("config.json", encoding="utf-8") as f: config = json.load(f) task_list = build_task_list(config) session = requests.Session() session.headers["User-Agent"] = "tile-downloader/1.0" items = [ {**make_download_item(config, z, x, y), "session": session} for z, x, y in task_list ] run_download(items, worker=config["concurrency"], queue_depth=config["concurrency"] * 16)

这段代码里,build_task_list把每个层级当独立单元处理,任务列表里存的是(zoom, x, y)三元组;make_download_item负责把三元组映射成最终URL和本地路径,同时用os.makedirs把多级目录建好,目录结构和XYZ命名天然对齐。User-Agent我建议改成一个能描述自己项目的字符串,不要用默认的python-requests,有些源会直接拒绝默认UA的请求。执行之后,tiles目录下会出现zoom/x/y.png这样的结构,拿GIS软件或者web服务器直接指向这个目录就能当瓦片源用。

5. 避坑排查:五个常见翻车现场与修复方案

5.1 瓦片位置偏移,拼接起来错位

现象:下载完成之后,用拼接工具把瓦片拼成一张大图,道路和河流出现重复边缘,或者不同层级之间内容位置对不上。

原因:多半是坐标系没对齐。部分区域的在线瓦片源使用加密坐标系,而你计算编号时用的是WGS84经纬度,两者在平面上的投影差能达到几百米级别。另一个常见原因是把TMS的y轴当成XYZ用,导致南北方向颠倒。

解决:先确认源的坐标系类型。如果源是加密坐标系,在计算编号前对经纬度做偏移修正。我一般会先下载一个层级相邻的四张瓦片,用在线地图同区域对比,确认边界是否对齐再批量跑。Y轴颠倒的判断方法更简单,下载一张边缘瓦片,观察它的文件名和实际位置,如果上下方向反了,把y换成(2^zoom - 1 - y)即可。

5.2 瓦片全白,文件大小一模一样

现象:所有下载的瓦片都能打开,但内容全是纯白,或者都是一张默认的空白占位图,文件大小完全相同。

原因:源返回的是防盗链占位图或登录占位图。请求缺少Referer、Cookie或token时,很多瓦片源不会返回404,而是返回一张无内容的默认底图,让你误以为下载成功。

解决:用浏览器开发者工具抓一次真实瓦片请求,对比需要的请求头,把关键Header补进session。常见需要的是Referer和Cookie。调试时不要只看HTTP状态码,要随机抽查几张瓦片的文件大小和字节内容。如果所有文件size一致,几乎可以断定拿到的全是占位图。

5.3 下载到一半卡死,网络没有流量

现象:任务跑了几分钟,日志停止输出,CPU占用很低,网络流量为零,卡住的时间从几十秒到几分钟不等。

原因:连接池被占满,或者某个请求进入了无限等待。requests默认没有超时会一直等;另外每张瓦片新建连接,虽然代码能跑,但大量TIME_WAIT连接会耗尽系统资源。

解决:所有请求统一设timeout,下载器模块里我已经把timeout默认设成15秒。同时用同一个Session对象发起所有请求,打开连接复用。还有一个便宜好用的方法:在download_one里把异常捕获范围收窄,只捕获RequestException而不是裸的Exception,这样至少能判断问题出在网络层还是逻辑层。

5.4 磁盘占用突然爆掉

现象:任务原本预计需要2GB磁盘,但下载到一半时磁盘满了,任务中断。

原因:预估时只考虑了瓦片数量和平均大小,但忽略了源返回的图片格式可能是WEBP或高分辨率PNG,单张大小远超预期。尤其是包含卫星影像的源,单张瓦片轻松到几百KB,放大到100块瓦片数量级就会差出一个数量级。

解决:先按范围随机抽30张瓦片下载,计算平均大小,再乘以预估总数,最后乘以1.3的余量系数。脚本里用一个变量实时统计已下载字节数,每500张打印一次,磁盘紧张时能及时发现。另外,如果图片不是必须无损,可以把PNG转成JPG存储,体积能砍掉一半不止。

5.5 任务开始后源返回大量403或429

现象:刚开始下载一切正常,跑了10分钟后,突然连续出现403或429,之后所有请求都被拒绝。

原因:并发过高触发了源的限流策略。重试机制在这种场景下反而是帮凶——失败一次立刻重试,等于加急送人头,很快源会把你整个IP封掉。

解决:全局并发降到4到8,同时重试之间加指数退避。退避时间不复杂,比如第1次重试等2秒,第2次等4秒,第3次等8秒。我自己的脚本里是重试上限3次、退避基数2秒,实际使用下来很少触发限流。如果源有正式的API配额,优先申请一个合法key,把配额用满比什么技巧都强。

6. 验证与进阶:瓦片计数、抽样拼接与增量更新技巧

6.1 瓦片计数验证完整性

下载完成不是终点,交付前我总会做一次计数校验。方法是用预期范围和实际文件数对比:

import os def count_expected(config): total = 0 for zoom in range(config["min_zoom"], config["max_zoom"] + 1): min_x, max_x, min_y, max_y = bbox_to_tile_range( config["min_lat"], config["min_lon"], config["max_lat"], config["max_lon"], zoom ) total += (max_x - min_x + 1) * (max_y - min_y + 1) return total def count_actual(config): total = 0 for zoom in range(config["min_zoom"], config["max_zoom"] + 1): base = os.path.join(config["output_dir"], str(zoom)) for x in os.listdir(base): total += len([f for f in os.listdir(os.path.join(base, x)) if f.endswith(".png")]) return total

实际数量和预期数量对不上时,优先检查的是边缘瓦片。因为范围边界上的x、y可能被取整丢掉一格,或者地图源在边界区域故意不生成某些层级的瓦片。找出差异后,单独补下缺失的编号即可,不必全量重跑。

6.2 抽样拼接小图确认视觉正确

瓦片文件都对不一定视觉正确,尤其是跨坐标系时,即使编号正确、边缘无损,拼起来还是会有细微错位。我习惯每次下载完拼一块小图验证:

from PIL import Image def stitch_tiles(zoom, min_x, max_x, min_y, max_y, output_path): cols = max_x - min_x + 1 rows = max_y - min_y + 1 canvas = Image.new("RGB", (256 * cols, 256 * rows)) for x in range(min_x, max_x + 1): for y in range(min_y, max_y + 1): path = f"tiles/{zoom}/{x}/{y}.png" if not os.path.exists(path): continue tile = Image.open(path) canvas.paste(tile, ((x - min_x) * 256, (y - min_y) * 256)) canvas.save(output_path)

这段代码按XYZ行列顺序把瓦片贴到画布上,参数里的min_x、max_x、min_y、max_y直接取build_task_list里的计算结果。验证时挑一个道路或水体特征明显的区域,拼一张10×10左右的瓦片,视觉检查道路是否连续、有没有白缝或重影。拼图通过之后,这组数据才能放心交给下游。

6.3 增量更新与版本化习惯

离线底图不是一次下载就永久生效。底图源更新、开会要最新的路网影像,都需要重新拉取部分瓦片。这里的关键是增量策略:下载时先检查目标路径是否存在,存在且文件大小不为零就跳过,这样第一次全量、后续只补新增层级或新增范围。边界情况是源更新了同一层级的数据,此时需要手动加一个force参数覆盖旧文件,我一般建议在配置文件里加一个"overwrite": false开关,日常增量默认关掉覆盖。

从那以后我每次跑完批量下载,都会强制走一遍计数校验和抽样拼接,确认瓦片数量对得上、拼接图没有明显错位,再交付给下游任务。这套流程看起来多花五分钟,实际上能省下交付后被来回追问“瓦片是不是漏了”的时间。希望帮到你。

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

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

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

立即咨询