简介这款地图瓦片下载工具AZMap 4.9面向需要离线地图数据、GIS分析、地图应用开发或网络地图备份的用户解决了从Google Maps、Bing Maps、OpenStreetMap等在线源批量获取指定区域和层级瓦片的难题可应用于户外探险、地图制图、地理数据分析、网络地图服务备份等场景。压缩包共841个文件大小约47.15MB包含核心执行程序AZMap.exe、DLL运行库、BAT批处理脚本、JS/HTML/CSS前端文件、PNG/GIF图标图片、PRJ投影文件及CSV/azdb/db数据文件类型覆盖程序运行、自动化部署、界面交互、坐标定义和瓦片数据存储等环节。该工具已有1210人学习/下载在同类工具中较具参考性。下载后可获得可运行的AZMap主程序与服务器启停脚本、配置目录和瓦片数据库样本既能直接体验完整的瓦片获取流程也能通过内部文件理解瓦片金字塔结构、坐标系与地图源配置方式同时支持调整地图源、瓦片层级范围、坐标系统及输出格式为本地地图服务搭建、离线底图生产或二次开发提供实用基础。1. 地图瓦片下载工具先想清楚你要解决的是下载慢还是重复抓很多人第一次接触地图瓦片下载工具都以为是网络带宽不够导致抓得慢于是拼命加线程。真正跑过一次批量任务就会明白拖垮整个工程的往往不是下载速度而是对瓦片寻址规则没吃透、失败重试策略写得太粗暴、以及无数重复请求把服务端惹烦了。地图瓦片下载工具要解决的核心不是「把瓦片拿下来」而是「用可控的并发、可续跑的任务队列、可校验的结果把一个矩形地理范围按金字塔层级完整地落到本地磁盘」。这个工具适合三类人做内网离线地图展示的开发者、需要固定区域影像底图做分析的数据工程师、以及长期在弱网环境下作业、要把在线底图备份到本地的实施人员。判断你需不需要它有一个简单标准如果你只需要偶尔十几张瓦片浏览器控制台手工拼就行了如果你要下载整个城市的 10 到 17 级少则几万、多则上百万张那就必须走工程化路线。本文按我自己的实现路径来写先摸清瓦片 URL 规律和行列号算法再设计调度与断点续传然后处理拼接与坐标系对齐最后把常踩的坑和提速技巧一并交代。2. 吃透瓦片寻址规则XYZ、TMS 与行列号换算公式2.1 XYZ 与 TMS两套寻址体系别用错互联网地图瓦片最主流的组织方式是割接金字塔把全球地图按四叉树切分成多层每一层又切成若干 256×256也有 512×512的小方块。寻址时用三个整数定位缩放层级 z、列号 x、行号 y。绝大多数在线瓦片服务的 URL 模板长这样https://tile.example.com/{z}/{x}/{y}.png把 z、x、y 换成具体数字就是一个可下载的瓦片。需要注意不同的服务对 y 轴方向的定义不一样。XYZ 规范里y 从北到南递增也就是左上角是 (0, 0)往下走行号变大而 TMS 规范把原点放在左下角y 从南到北递增。同一个地理范围在 XYZ 里是第 5 行在 TMS 里可能是 2^z - 1 - 5 行。# 把 TMS 行号转成 XYZ 行号 def tms_y_to_xyz_y(tms_y: int, z: int) - int: return 2 ** z - 1 - tms_y逻辑说明TMS 和 XYZ 的 x 轴方向一致只有 y 轴翻转。转换公式的本质是「总行数减一再减去当前行号」。如果你下载工具里拼的 URL 一直 404先别急着怀疑网络多半是这里翻车了。参数说明z 的取值范围取决于服务商常见是 0 到 18也有支持到 20 的。写工具时不要把 z 写死在配置里让 z 作为输入参数更方便。有的服务在 URL 里对 x、y 有偏移量比如加 1需要自己抓一张真实瓦片比对确认。验证 URL 规律的正确姿势不要用浏览器开发者工具看 Network 面板里的静态 URL很多前端框架会对请求做二次封装URL 看着不直观。更稳妥的办法是找一个自己确定坐标的点按下面的公式算出某级瓦片行列号直接拼 URL 用 requests 拉一张看返回是否为有效图片。import requests def verify_tile_url(template: str, z: int, x: int, y: int) - bool: url template.format(zz, xx, yy) headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } resp requests.get(url, headersheaders, timeout10) return resp.status_code 200 and resp.headers.get(Content-Type, ).startswith(image/)逻辑说明requests 直接请求瓦片地址检查两件事——HTTP 状态码必须是 200响应头 Content-Type 必须是 image 开头。有些服务对错误的瓦片坐标会返回 200 一张空白占位图所以要穿透检查到 Content-Type 这一层。参数说明headers 里的 User-Agent 建议带上部分瓦片服务会拦截空 UA 的请求。timeout 参数同时是连接超时和读超时10 秒是个偏保守的值内网服务可以改成 5 秒公网弱网环境建议放宽到 15 秒。2.2 从经纬度边界算出瓦片范围公式与边界处理要下载某个矩形地理范围先要把经纬度边界换算成每一层级的瓦片行列号区间。这里用到的是 Web Mercator 投影下的标准换算公式import math def lonlat_to_tile(lon: float, lat: float, z: int): x int((lon 180.0) / 360.0 * (2 ** z)) y int((1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * (2 ** z)) return x, y逻辑说明x 的换算本质是把经度范围 [-180, 180] 线性映射到 [0, 2^z] 的整数网格y 的换算则因为 Web Mercator 是非线性投影纬度越高拉伸越大所以要先用反双曲正弦函数处理纬度再映射到网格。这套公式是通用标准所有遵循 XYZ 规范的服务都适用。参数说明lat 超过 85.05 度时 tan 值趋近无穷代码会计算出超出网格范围的行号。实际使用中要加一层 clamp 保护把 y 限制在 [0, 2^z - 1] 区间内。另外注意 Python 的 int() 是向下取整而不是四舍五入这一点和 JavaScript 的 Math.floor 行为一致写多语言版本时要保持统一。有了单点换算函数下载一个矩形范围就变成双层循环def tile_range_from_bounds(bounds: dict, z: int): west, south, east, north bounds[west], bounds[south], bounds[east], bounds[north] x_min, y_max lonlat_to_tile(west, north, z) x_max, y_min lonlat_to_tile(east, south, z) return x_min, x_max, y_min, y_max逻辑说明输入范围是西、南、东、北四个浮点数。西经和北纬算出来的是最小列号和最大行号东经和南纬算出来的是最大列号和最小行号。注意行号越大越靠南所以 y_min 来自 southy_max 来自 north方向不要搞反。参数说明bounds 用字典传四个值在实际工程里建议直接用配置文件读入。很多瓦片源不支持整张全球图只覆盖特定区域越界瓦片可能返回 404 或空白图下载前先查一下该源覆盖范围能省下大量的无效请求。3. 调度与续传设计不写清单文件的下载工具都是半成品3.1 分块任务队列为什么不能一把梭全塞进线程池如果一张瓦片对应一个下载任务一个 z 级 10 万张瓦片全塞进线程池内存里会同时挂着 10 万个任务对象线程调度开销巨大而且服务端会瞬时收到大量并发请求极易触发限流。我常用的做法是把任务拆成「批次队列」固定批次大小一批处理完再取下一批保证内存里同时只有几百个任务对象。from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass dataclass class TileTask: z: int x: int y: int def gen_tile_tasks(x_min, x_max, y_min, y_max, z): for x in range(x_min, x_max 1): for y in range(y_min, y_max 1): yield TileTask(zz, xx, yy) def run_batch(tasks, worker: int, worker_fn): with ThreadPoolExecutor(max_workersworker) as executor: future_map {executor.submit(worker_fn, t): t for t in tasks} for future in as_completed(future_map): task future_map[future] try: future.result() except Exception: # TODO: 记录失败任务下一轮重试 pass逻辑说明gen_tile_tasks 是生成器按行优先的顺序产出任务不一次性占满内存。run_batch 用 ThreadPoolExecutor 消费任务as_completed 会在每个任务完成时立即返回方便实时记录进度。这里把任务生成和任务执行解耦后续如果要换协程方案只需要替换 run_batch 内部实现。参数说明worker 参数是线程数后面避坑章节会细说。worker_fn 是下载单张瓦片的函数接收 TileTask 对象内部负责拼 URL、请求、落盘。异常处理先留空第 6 章会补上带重试和校验的完整版本。3.2 断点续传让跑到一半的任务能接着跑一个十万张瓦片的下载任务跑三四个小时很正常。中间网络波动、进程被杀、磁盘写满都会导致任务中断。如果不做断点续传下次只能从头再来这是最让人崩溃的体验。断点续传的实现思路不复杂用一个清单文件记录「哪些瓦片已经成功落盘」启动时先读清单跳过已完成的。import json, os class TileManifest: def __init__(self, path: str): self.path path self.done set() self._load() def _load(self): if os.path.exists(self.path): with open(self.path, r, encodingutf-8) as f: data json.load(f) self.done set(tuple(item) for item in data.get(done, [])) def mark_done(self, task: TileTask): self.done.add((task.z, task.x, task.y)) self._flush() def is_done(self, task: TileTask) - bool: return (task.z, task.x, task.y) in self.done def _flush(self): tmp self.path .tmp with open(tmp, w, encodingutf-8) as f: json.dump({done: list(self.done)}, f) os.replace(tmp, self.path)逻辑说明清单文件用 JSON 存储字段 done 是已完成瓦片的三元组列表。mark_done 每成功一张就追加记录并立即写盘。写盘时先写临时文件再用 os.replace 原子替换防止进程崩溃时把清单文件写坏。下次启动时只要遍历任务生成器跳过 is_done 返回 True 的任务即可。参数说明path 参数建议放在下载输出目录下文件名固定为 manifest.json。如果下载过程中换了 url 模板或改了 z 范围旧的清单文件要手动删除否则新旧任务会混在一起。批量标记时可以考虑攒够几十条再 flush减少磁盘写入次数但代价是进程崩溃时最多丢几十条记录取舍看你的场景。主循环里把任务生成器、批处理、清单串起来def download_area(template: str, bounds: dict, zoom_range: list, out_dir: str, worker: int, batch_size: int): manifest TileManifest(os.path.join(out_dir, manifest.json)) for z in zoom_range: x_min, x_max, y_min, y_max tile_range_from_bounds(bounds, z) pending [] for task in gen_tile_tasks(x_min, x_max, y_min, y_max, z): if not manifest.is_done(task): pending.append(task) if len(pending) batch_size: run_batch(pending, worker, lambda t: fetch_and_save(t, template, out_dir, manifest)) pending.clear() if pending: run_batch(pending, worker, lambda t: fetch_and_save(t, template, out_dir, manifest))逻辑说明外层循环按层级从低到高跑每个层级先算瓦片范围再过滤掉清单里已完成的任务。批次攒到 batch_size 就提交一次跑完清空继续攒。fetch_and_save 里下载成功后要调用 manifest.mark_done这个函数的完整实现在下一章拼接部分一起给。参数说明batch_size 建议设置在 200 到 500 之间。太小则频繁创建线程池有额外开销太大会让单批任务耗尽内存且中途失败不好定位。out_dir 目录要先创建好manifest.json 就放在这个目录里。4. 拼接成图把几万张瓦片合成一张 TIF 的完整套路4.1 用 Pillow 拼接大图的最简实现瓦片下载到本地后最常用的两个后续动作一是保持瓦片目录结构直接做离线瓦片服务二是把某一层级的瓦片拼成一张完整的大图。第二个场景在测绘和科研里更常见。拼接的核心逻辑是先算出整张图的总尺寸创建画布然后按每张瓦片的行列号计算它在画布中的偏移位置逐张贴上去。from PIL import Image import os def merge_tiles(tile_dir: str, z: int, x_min, x_max, y_min, y_max, output_path: str, tile_size: int 256): width (x_max - x_min 1) * tile_size height (y_max - y_min 1) * tile_size canvas Image.new(RGB, (width, height), (255, 255, 255)) for x in range(x_min, x_max 1): for y in range(y_min, y_max 1): tile_path os.path.join(tile_dir, str(z), str(x), f{y}.png) if not os.path.exists(tile_path): continue img Image.open(tile_path) canvas.paste(img, ((x - x_min) * tile_size, (y - y_min) * tile_size)) canvas.save(output_path, TIFF, compresstiff_deflate)逻辑说明画布左上角对应 x_min、y_min 那张瓦片所以任意瓦片 (x, y) 的贴图位置是 (x - x_min) 乘瓦片边长、 (y - y_min) 乘瓦片边长。如果某些瓦片缺失paste 会跳过最终画布上留下白底。拼完用 TIFF 格式输出压缩算法选 tiff_deflate 是无损压缩适合栅格影像保存。参数说明tile_size 必须和下载的瓦片实际像素一致不支持 256 就改成 512。如果下载时因为网络问题拿到了损坏的图片文件Image.open 会抛异常工程化版本里要包一层 try-except 并重新下载该瓦片。内存占用方面一张 5 万 × 5 万像素的 RGB 图光像素数据就要 7.5GB 内存超出物理内存时建议分块拼接。4.2 边界瓦片与空瓦片黑边、灰块和半透明图像的取舍拼接时最常见的三个现象边缘出现黑边、中间出现灰色块、整体颜色偏暗。黑边的来源是瓦片本身是 PNG 且带透明通道贴到 RGB 画布上透明部分会映射成黑色灰色块的来源是瓦片本身就是占位图即服务端缺少该层级的数据颜色偏暗则多半是 JPEG 的 EXIF 色彩配置和 TIFF 输出不兼容。def safe_load_tile(tile_path: str): img Image.open(tile_path) if img.mode RGBA: bg Image.new(RGB, img.size, (255, 255, 255)) bg.paste(img, maskimg.split()[-1]) return bg return img.convert(RGB)逻辑说明RGBA 模式的瓦片先用白色画布打底再用原图的 alpha 通道作为蒙版贴上去透明区域变成白色而不是黑色。这一步非常关键尤其是下载影像瓦片时服务端经常用透明表示无数据区域。convert(RGB) 则是把所有非 RGB 模式统一转换避免后续 paste 时报模式不一致的错。参数说明判断空瓦片不能只看 mode更实用的方式是在下载阶段就检查文件字节数——低于 2KB 的瓦片大概率是占位图可以在下载时直接标记为 missing 而不落盘。另一种情况是整个范围内的瓦片都是纯色块这时要检查该服务源在这个 z 级是否有数据覆盖。4.3 坐标系与分辨率直接拼出来的图能不能用拼接完成的大图只是像素阵列要落地使用必须知道它的地理坐标范围和分辨率。Web Mercator 投影下层级 z 的地面分辨率米/像素由赤道周长除以 256 再除以 2^z 决定。更精确的做法是写一个世界文件TFW让 GIS 软件能自动识别图片的地理位置。def write_tfw(output_path: str, bounds: dict, z: int, tile_size: int 256): west, south, east, north bounds[west], bounds[south], bounds[east], bounds[north] x_min, x_max, y_min, y_max tile_range_from_bounds(bounds, z) total_width_px (x_max - x_min 1) * tile_size total_height_px (y_max - y_min 1) * tile_size lon_per_px (east - west) / total_width_px lat_per_px (north - south) / total_height_px tfw_path output_path.replace(.tif, .tfw).replace(.png, .pgw) with open(tfw_path, w, encodingutf-8) as f: f.write(f{lon_per_px}\n0.0\n0.0\n{-lat_per_px}\n{west}\n{north}\n)逻辑说明TFW 文件是 ESRI 定义的世界文件格式六行数字分别代表像素宽度、旋转参数、旋转参数、像素高度、左上角经度、左上角纬度。因为 Web Mercator 是等角投影旋转参数直接填 0。像素高度取负值表示 y 轴朝下增长这是 GIS 软件读取图片时的标准约定。参数说明这个写法用经纬度直接近似像素比例严格来说 Web Mercator 下经纬度和像素不是线性关系但在小范围一个城市大小内误差可忽略。如果下载范围跨越了数十个纬度建议输出时把 EPSG:3857 坐标系的真实米制分辨率算出来写进 TFW否则高纬度地区的图会有可察觉的拉伸。需要投影转换时直接交给 GIS 软件做重投影不要在拼接阶段硬解。5. 避坑记录瓦片工具跑起来之后的 6 个意外5.1 线程数调到 30 反而比 8 个线程还慢现象线程从 8 加到 30任务完成速度没有变快反而变慢甚至出现大量超时失败。原因服务端对单 IP 的并发连接数有限制常见为 4 到 10。超过限制后新连接会排队等待或被直接拒绝客户端这边大量线程阻塞在等待响应加剧了整体延迟。另外线程数过高时本地网卡缓冲区和操作系统句柄也容易成为瓶颈。解决从 4 个线程起步逐步增加到 16 个为止观察每批完成的平均耗时曲线找到拐点附近的值。如果服务端支持 HTTP Keep-Alive用 requests.Session 复用连接能显著提升单线程吞吐量效果比盲目加线程好得多。5.2 断点续传跑了半天重启后清单文件是空的现象任务中断后重新启动程序把所有瓦片重新下载了一遍。原因清单文件只在任务全部成功退出时写盘或者写盘间隔太大。进程被 kill 时数据还在内存里磁盘上的清单文件是旧版本。解决每张瓦片下载成功后立即追加写入清单写入方式用临时文件加 os.replace 原子替换。如果担心频繁写盘影响性能可改为每 50 张写一次但要把「最多丢失 50 条记录」作为可接受的代价写进设计文档。5.3 拼出来的大图每隔 256 像素有一条白线现象瓦片之间出现细缝放大后非常明显。原因部分瓦片下载时被代理或 CDN 做了边缘像素压缩相邻瓦片的边界像素重叠了 1 到 2 个像素直接按行列号贴图时重叠区域盖住了彼此的边缘。另一个常见原因是拼接时相邻图片的缩放插值算法不一致。解决下载阶段对比瓦片实际尺寸和服务端声明的尺寸不一致的单独处理。拼接时不直接整张 paste而是把每张瓦片右边缘和底边缘裁剪掉 1 像素再贴消除重叠带。统一在读取时执行 convert(RGB)避免因为图片解码器差异产生色偏。5.4 高层级瓦片大面积 404低层级正常现象z 小于 14 的瓦片全部正常z 等于 15 和 16 时有一大片区域请求返回 404。原因很多瓦片服务对不同层级的数据覆盖范围不同。低层级是全局底图高层级的影像可能只覆盖主要城市。另外部分服务对最高层级做了访问控制普通请求拿不到瓦片。解决在启动任务前先对目标范围做抽样探测用三五个点试探该层级的响应状态确认覆盖后再批量启动。对于部分空白的层级可以把范围缩小到有数据的子区域避免无效请求堆积在错误日志里。5.5 下载了 2 万张后 IP 被临时限制访问现象前半程速度正常2 万张左右开始频繁返回 403 或 429重试也不管用。原因请求频率太高触发了服务端的反爬策略。常见阈值是单 IP 每分钟 300 到 600 次请求。客户端线程数越高撞墙越快。解决在调度层加入请求间隔控制每个线程完成一张瓦片后随机休眠 0.1 到 0.5 秒让请求分布更接近人工浏览行为。另一个有效手段是把下载顺序从行优先改成随机洗牌避免固定扫描轨迹被识别。真要大批量下载时严格控制并发数并增加间隔慢就是快。5.6 下载的瓦片有内容但拼完图后用 GIS 软件打开位置完全不对现象瓦片像素内容正确但叠加到 GIS 软件里整体偏移了几百公里。原因TFW 世界文件里的起点坐标和像素尺寸写错了。常见错误是直接用范围的最小经纬度当起点而实际拼接画布的左上角对应的是瓦片边界坐标而不是范围边界坐标另一个是把经纬度单位和米混用了。解决写 TFW 时用瓦片范围计算真实左上角经纬度也就是用 x_min 和 y_min 对应的经纬度而不是 west 和 north。多套坐标系的场景要在下载阶段就记录该瓦片服务的投影信息拼图输出时写入对应的投影描述文件GIS 软件才能正确叠加。6. 让工具再快一点的进阶技巧动态退避与结果校验大部分下载工具只做到「请求成功就存盘」但瓦片服务返回 200 不代表拿到了有效数据。我现在的习惯是把下载流程升级成三步循环请求 → 校验 → 登记。请求阶段先拼 URL校验阶段检查文件大小、图片文件头、像素内容是否为纯色占位图全部通过才在清单里登记。动态退避重试是提速的关键。固定间隔重试在服务端限流时没有任何作用反而会加剧拥堵。下面这个实现里每次失败后的等待时间按指数增长并加入随机抖动参数import random, time def fetch_with_retry(task: TileTask, template: str, session: requests.Session, max_retries: int 4): url template.format(ztask.z, xtask.x, ytask.y) for attempt in range(max_retries): resp session.get(url, timeout10) if resp.status_code 200 and len(resp.content) 2048: # 检查文件头是否为 PNG/JPEG if resp.content[:8] b\x89PNG\r\n\x1a\n or resp.content[:2] b\xff\xd8: return resp.content wait_time min(30, 2 ** attempt random.uniform(0, 1)) time.sleep(wait_time) raise RuntimeError(fdownload failed: {url})逻辑说明每次请求后同时验证状态码、文件字节数和文件头。小于 2KB 的内容直接视为占位图或错误响应不落盘。重试等待时间是 2 的指数次幂加一个 0 到 1 秒的随机抖动即 1 秒、2 秒、4 秒、8 秒上限封顶 30 秒。随机抖动是为了避免多个线程在同一时刻同时重试把服务端打崩。参数说明max_retries 设置为 4 到 5 次比较合理超过 5 次的重试基本都是在浪费时间。session 参数用 requests.Session 传入可以复用 TCP 连接比每次新建连接快一个数量级。如果服务端明确返回 404说明该瓦片本身不存在直接跳过不进入重试逻辑。另一个实测有效的做法是在正式批量下载前做一轮抽样探测。取目标范围内均匀分布的 10 个点位用当前配置的线程数分别下载一张瓦片统计平均耗时和失败率。如果平均耗时超过 2 秒或者失败率超过 5%说明要么线程数过高要么目标源状态不稳定这时候调整参数的成本远低于跑了一半再返工。我自己现在跑任何瓦片下载任务都会先写一条「dry-run 模式」的启动命令——只下载 20 张瓦片打印耗时分布和错误码分布确认无误后才放开全量任务。这个习惯帮我避免了至少三次因为 URL 模板拼错导致的十万级无效请求。地图瓦片下载工具这类工程真正比拼的不是谁的单线程代码写得精巧而是谁能在任务量放大一百倍之后依然稳定可控。希望帮到你。本文还有配套的精品资源点击获取