简介:地图瓦片下载工具面向需要离线地图数据、开展GIS分析或搭建本地瓦片服务的用户,核心为AZMap 4.9程序,可在设置地图源、层级范围与输出格式后按区域批量获取瓦片,便于后续制图、数据备份与空间分析。压缩包共841个文件,约47.15MB,包含可执行程序与动态库、JavaScript与CSS前端资源、PNG/GIF图标与瓦片示例、CSV/WKT/PRJ等地理数据与投影文件,以及配置、任务索引和数据库文件,整体覆盖运行、配置与示例数据多个层面。已有1214人学习或下载。资源的价值在于提供一套可直接运行的地图瓦片采集环境,并附带区域示例数据与任务配置,方便读者理解瓦片组织方式、下载参数设置和成果管理;对需要定制高分辨率地图、准备离线底图或进行地理数据处理的用户,具有较高的参考与复用价值。
1. 地图瓦片下载工具:先算清瓦片数,再谈下载
很多人第一次做地图瓦片下载,都是打开一个在线底图,看着挺清楚,随手写个循环把 z=18 全下下来,结果磁盘两个小时就红了一半。问题不在代码,在于没算账:全球范围 z=18 是 687 亿张瓦片,就算每张只要 20 KB,也是一个天文数字。地图瓦片下载工具真正要解决的不是“怎么发请求”,而是把一个连续的地理范围,换算成有限、可控、能续传、能拼回图的瓦片集合。它适合三类人:要做离线底图的产品开发者、要给算法喂带地理坐标底图的工程同学、以及需要把某片区域固化下来做长期对比的分析人员。工具本身不复杂,复杂的是坐标、层级、并发和那堆看起来都正常、拼起来就错位的坑。
2. 瓦片金字塔与 XYZ 编号:差一级,瓦片数差四倍
2.1 金字塔切分逻辑与地面分辨率换算
瓦片地图的本质是一棵四叉树。z=0 时整个世界是一张 256×256 的图,每升一级,横竖各切一刀,父瓦片变成四个子瓦片,所以总瓦片数是 4 的 z 次方。这个关系决定了所有容量规划:z 加 1,瓦片数乘 4,下载时间大致也乘 4。我一般会先让使用者把目标层级定死,再往下谈并发和存储,否则后面全是白算。
地面分辨率有个现成公式,基于 Web Mercator 投影,单位是米/像素:
resolution = 156543.03392 * cos(lat) / 2^z156543.03392 是赤道处 z=0 时一个像素对应的地面长度,随着纬度升高,同样的瓦片覆盖的地面范围在东西方向上按 cos(lat) 收窄。所以在中纬度地区,z=16 大约是 2 米级,z=18 落到 0.5 米级,z=19 以后多数公开服务就不给了。选层级不要凭感觉,先问一句“我要看清多大的东西”:看清一栋楼轮廓,z=17 够用;看清一条车道线,起码 z=18~19;只是做区域分布图,z=12~14 就完全够,还能把瓦片数压到几千张量级。
这里有个容易被忽略的点:分辨率是按纬度变化的,同一个 z 在低纬和高纬的实际清晰度不一样。做跨纬度的大范围拼接时,如果统一用最高纬度的层级去覆盖,低纬部分会白白多下很多瓦片。常规做法是按纬度带分段定级,或者干脆按“目标分辨率”反推每一段该用哪一级,代码里多写十几行,能省掉一半以上的请求量。
2.2 用代码把经纬度范围换算成瓦片行列号
真正干活的第一步是把 bbox 换成 (z, x_min, x_max, y_min, y_max)。公式不复杂,但边界处理是重灾区。下面这段是我常用的实现,不依赖第三方瓦片库,直接算:
import math def lonlat_to_tile(lon, lat, z): """经纬度 -> XYZ 瓦片行列号,Web Mercator 投影""" n = 2 ** z # 纬度裁到 Web Mercator 的有效范围,避免 tan/log 溢出 lat = max(min(lat, 85.05112878), -85.05112878) 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(max(x, 0), n - 1), min(max(y, 0), n - 1) def bbox_to_tiles(lon_min, lat_min, lon_max, lat_max, z): """范围 -> 瓦片矩形,右下角减一个极小量,防止刚好压线多算一行""" eps = 1e-9 x0, y0 = lonlat_to_tile(lon_min, lat_max, z) # 左上角 x1, y1 = lonlat_to_tile(lon_max - eps, lat_min + eps, z) # 右下角 return x0, y0, x1, y1逻辑上有两个关键点。第一,经纬度到瓦片的映射里,纬度越大 y 越小,也就是 y 轴向南递增,这跟屏幕坐标一致,但跟 TMS 规范相反,后面第 4 章会专门说这个事。第二,右下角必须减 epsilon,因为int()是向下取整,如果右上角刚好落在瓦片边界上,不减这个极小量就会多算一整列和一整行,范围一大就是成千上万次多余请求,而且这些多出来的瓦片往往还正好是服务的边界外,返回 404 或者空白图。
拿到范围后,先算数量再决定要不要下:
def tile_count(x0, y0, x1, y1): cols = x1 - x0 + 1 rows = y1 - y0 + 1 return cols * rows, cols, rows # 示例:约 1 度 × 1.5 度的范围,取 z=16 n, c, r = tile_count(*bbox_to_tiles(100.0, 20.0, 101.0, 21.5, 16)) print(n, c, r) # 先看这个数字,再决定要不要往下走我一般会把这段打印做成硬性门槛:超过预设阈值就抛异常退出,而不是“先下着看”。血泪经验是,一旦开始下,人就不太愿意中止,最后往往是磁盘先喊停。
2.3 层级选择的三个硬约束
层级不是想定多高就定多高,有三条线卡着。第一条是服务端给不给:多数公开瓦片服务最高到 z=18 或 z=19,超过直接返回空白或 404,写代码时把max_zoom写成配置项,别写死在循环里。
第二条是单次请求的并发容忍度。瓦片服务普遍对单 IP 有 QPS 限制,超了就是 429,再超就是短暂封禁。这不是玄学,是服务方明写在条款里的。我通常把并发压到 4~8,再加一个全局令牌桶限速,宁可慢一点跑一晚上,也别中途被封了重来。
第三条是存储和 inode。一张 256×256 的 PNG 在城区大约 15~40 KB,郊区可能只有 3~8 KB。按 25 KB 中位数估,100 万张就是 25 GB。更要命的是文件数:Linux 的 ext4 在单目录文件数超过十万后,ls和目录遍历会明显变慢。所以目录结构必须提前规划,别等跑到一半再迁移。
提示:只下载你有权访问的瓦片服务,并遵守服务方的使用条款与频率限制。企业内部底图、自建瓦片服务、明确授权的数据集,才是这类工具该用的地方。
3. 下载器实现:并发、断点续传与请求头
3.1 目录结构与文件命名
我把目录结构分成两派:{z}/{x}/{y}.png和扁平{z}_{x}_{y}.png。前者适合边下边拼,因为按 x 分目录之后,同一列的瓦片在一个文件夹里,按行流式拼接时读盘局部性好;后者适合单层文件系统或者对象存储,键值查找更快,但瓦片数一多,目录遍历就难受。
我的默认选择是三级目录,并且在瓦片总数预估超过 20 万时加一层分片,变成{z}/{x//1000}/{x}/{y}.png。这样单个目录里的文件数被压在千级,find、rsync、备份都不至于卡死。
命名上要注意一件事:扩展名不要写死 png。服务端返回的可能是 jpg 或 webp,你按 png 存,拼图时用 Pillow 打开会直接抛异常,而且错误信息很含糊,只告诉你“cannot identify image file”。正确做法是读响应头的Content-Type,映射成对应后缀,或者干脆统一转码成 png 存盘,代价是多一次解码编码,好处是后续流程完全一致。
import os EXT_MAP = { "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", } def tile_path(root, z, x, y, ext=".png"): """三级目录 + 按 x 分片,控制单目录文件数""" return os.path.join(root, str(z), str(x // 1000), str(x), f"{y}{ext}")这段看着简单,但它决定了后面所有增量更新、校验、打包能不能做。目录规范一旦定错,几百万个文件重命名一遍是灾难级的操作。
3.2 带限速与重试的并发下载
并发模型我选线程池,不选 asyncio。原因是这类任务 99% 的时间在等 IO,线程池足够,而且requests生态成熟,出错栈也好看。协程虽然更省资源,但一旦某个第三方库阻塞,整个事件循环就僵住,排查成本高。
核心是三件套:连接复用、全局限速、指数退避重试。
import time, random, threading import requests from concurrent.futures import ThreadPoolExecutor, as_completed class RateLimiter: """简单令牌桶,控制全局 QPS""" def __init__(self, qps): self.interval = 1.0 / qps self.lock = threading.Lock() self.next_time = time.monotonic() def acquire(self): with self.lock: now = time.monotonic() wait = max(0.0, self.next_time - now) self.next_time = max(now, self.next_time) + self.interval if wait > 0: time.sleep(wait) def fetch_tile(session, url, headers, limiter, retries=3): """单张瓦片下载:限速 + 退避重试""" for attempt in range(retries): limiter.acquire() try: resp = session.get(url, headers=headers, timeout=15) if resp.status_code == 200 and resp.content: return resp.content, resp.headers.get("Content-Type", "") if resp.status_code in (429, 503): # 明确被限流,退避时间拉长 time.sleep(2 ** attempt + random.random()) continue if resp.status_code == 404: return None, "404" # 该层级无瓦片,不重试 except requests.RequestException: time.sleep(1.5 ** attempt) return None, "failed"RateLimiter用的是“预约下一个可用时刻”的思路,多线程下每次只锁一小段计算,睡眠在锁外,所以既精确又不会把线程全堵在锁上。fetch_tile里对状态码分了类:200 但内容为空,视为失败;429/503 是限流信号,必须退避;404 是无瓦片,不能重试,否则永远下不完。这个分类如果搞混,最常见的结果就是线程池被一堆 404 占满,看起来并发很高,实际吞吐为零。
参数上,timeout=15是我踩过坑之后的保守值,瓦片小,正常响应在 200 ms 内,超过 15 秒基本就是连接卡死,早断早重试。并发数我一般设 6,QPS 设 10,这两个值要根据服务方条款和你自己的网络出口调整,没有一个通用数字。
3.3 用 SQLite 做任务清单与断点续传
断点续传不要靠“文件存在就跳过”来判断。原因有两个:一是下载中断会产生 0 字节或半截文件,二是有的服务对无数据区域返回一张纯色占位图,文件存在但内容没意义。我的做法是用 SQLite 存一张任务表,状态机管理。
CREATE TABLE IF NOT EXISTS tiles ( z INTEGER NOT NULL, x INTEGER NOT NULL, y INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', -- pending / ok / empty / failed ext TEXT, bytes INTEGER, sha1 TEXT, etag TEXT, updated_at INTEGER, PRIMARY KEY (z, x, y) ); CREATE INDEX IF NOT EXISTS idx_status ON tiles(status); -- 取下一批待下载任务 SELECT z, x, y FROM tiles WHERE status IN ('pending','failed') LIMIT 500;主键设成(z, x, y)天然去重,重复入队不会产生脏数据。状态分四档:pending待下,ok成功,empty确认无数据(比如 404 或纯色占位),failed重试耗尽。empty单独一档很重要,它能防止下一轮增量时反复去请求那些本来就没有瓦片的格子,省下大量无效流量。
批量写入不要一条一条 commit,用事务包起来,每 500 条提交一次:
def flush(conn, rows): conn.executemany( "INSERT INTO tiles(z,x,y,status,ext,bytes,sha1,updated_at) VALUES(?,?,?,?,?,?,?,?) " "ON CONFLICT(z,x,y) DO UPDATE SET status=excluded.status, ext=excluded.ext, " "bytes=excluded.bytes, sha1=excluded.sha1, updated_at=excluded.updated_at", rows) conn.commit()ON CONFLICT DO UPDATE让续传和重跑都安全,同一个任务跑两遍不会报错,只会覆盖成最新状态。
3.4 请求头配置与合规边界
请求头不是随便糊一个就行。User-Agent至少要能标识你的应用,空 UA 在很多服务端会被直接拒掉;Accept写image/png,image/jpeg,image/webp能让服务端返回它支持的格式,比默认的*/*更可控;Accept-Encoding: gzip对图片基本没用,开着反而增加一点解压开销,我一般关掉。
Referer这个字段要谨慎。它本质上是告诉服务端“我从哪来”,如果你填一个不属于你的来源,那就是在伪造身份,既不必要也不合适。正确做法是:用自建或已授权的瓦片服务,Referer 留空或填写你自己的应用地址。
比请求头更重要的是节奏控制。我给自己定的规矩是:单服务单 IP 并发不超过 8,全天请求量不超过服务方条款允许的范围,遇到 429 立即降速而不是加大重试。这套规矩不是道德表演,是实打实的工程经验——被封一次,恢复时间比你省下来的那点时间多得多。
4. 拼接与坐标校正:散瓦片怎么变成一张能用的图
4.1 按行流式拼接控制内存
瓦片下完,拼接是最容易 OOM 的一步。假设你下了 20 万张 256×256 的瓦片,如果全部读进内存再拼,光是 RGB 三通道就要 20 万 × 256 × 256 × 3 ≈ 39 GB,机器直接趴窝。
正确姿势是按行流式处理:一次只加载一行瓦片的宽度,把所有 x 拼成一条长图,再把它贴到最终画布上。内存峰值约等于 画布宽度 × 256 × 3,可控。
from PIL import Image def mosaic(root, z, x0, y0, x1, y1, ext=".png", out="mosaic.png"): """按行拼接,内存峰值只有一行的高度""" cols, rows = x1 - x0 + 1, y1 - y0 + 1 canvas = Image.new("RGB", (cols * 256, rows * 256), (255, 255, 255)) for j, y in enumerate(range(y0, y1 + 1)): strip = Image.new("RGB", (cols * 256, 256), (255, 255, 255)) for i, x in enumerate(range(x0, x1 + 1)): p = tile_path(root, z, x, y, ext) try: img = Image.open(p).convert("RGB") strip.paste(img, (i * 256, 0)) except FileNotFoundError: continue # 缺瓦片留白,不要中断整条拼接 canvas.paste(strip, (0, j * 256)) strip.close() canvas.save(out)两个细节。第一,缺瓦片用留白,不要抛异常,因为大范围下载难免有几张失败,为了几张图丢掉整幅结果不值得。第二,strip.close()必须写,Pillow 的 Image 对象不会自动释放,长循环里不关会慢慢吃内存,跑到几千行就开始换页。
如果范围大到画布本身都放不下(比如跨十几个省),就得换方案:按块输出多个文件,或者直接走分块写出到磁盘的库,别硬拼一张巨图。
4.2 写入地理坐标:让 GeoTIFF 能被 GIS 认出来
拼接出来的 PNG 是“哑图”,没有坐标信息,拖进 GIS 软件里对不上任何东西。要变成能用的成果,需要写 GeoTIFF,关键是两个参数:投影和仿射变换。
拼接图左上角对应的地理坐标,可以用反算公式从 (z, x0, y0) 推出来:
import math def tile_bounds(x, y, z): """瓦片左上角经纬度与地面范围(度)""" n = 2 ** z lon_left = x / n * 360.0 - 180.0 lon_right = (x + 1) / n * 360.0 - 180.0 lat_top = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n)))) lat_bottom = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n)))) return lon_left, lat_bottom, lon_right, lat_top拿到整幅图的范围后,用 GDAL 命令行补上地理参考:
# 把拼好的 PNG 转成带 EPSG:3857 参考的 GeoTIFF # -a_ullr 依次是 左上经度 左上纬度 右下经度 右下纬度(投影坐标,单位米) gdal_translate -of GTiff \ -a_srs EPSG:3857 \ -a_ullr 11131949.08 2504688.54 11248465.30 2340129.72 \ mosaic.png mosaic_geo.tif这里有个硬性要求:-a_ullr用的必须是投影坐标(米),不是经纬度。Web Mercator 下 x = lon / 180 × 20037508.34,y 要用ln(tan(...))那套公式换算。直接填经纬度进去,图会缩成一个小点,而且不报错,这是最坑的地方之一。如果嫌换算麻烦,就统一先转成 EPSG:3857 的坐标再传参。
4.3 TMS 与 XYZ 的 Y 轴翻转
这个问题每次都要坑一批人。XYZ 规范里 y 从北往南递增,y=0 是最北一行;TMS 规范里 y 从南往北递增,y=0 是最南一行。两者关系是:
y_tms = 2^z - 1 - y_xyz很多瓦片服务在文档里只写“标准 TMS”,但实际返回的是 XYZ 编号,或者反过来。判断方法很直接:随便挑一张你确认地理位置的瓦片,看内容对不对得上。如果整幅图上下颠倒,就是这里错了;如果只是整体偏移半个瓦片,那是 4.1 里的行列顺序问题,两码事。
我通常会在下载配置里加一个scheme字段,取值xyz或tms,在生成 URL 和计算存储路径时统一按scheme转换一次,避免在代码各处散落翻转逻辑。翻转只做一次,比到处打补丁可靠得多。
5. 避坑与排查:五条血泪记录
5.1 现象:地图一片白,但文件大小正常
现象:拼接出来的图大片空白,检查目录发现瓦片文件都在,大小也有几 KB。
原因:服务端对超出数据范围或未授权区域返回 HTTP 200 加一张纯色占位图,内容长度不为零,任何“文件存在就算成功”的判断都会放行。
解决:入库时算 sha1 并记录,对相同哈希的瓦片统计出现频次,频次异常高的(比如某个哈希占全部瓦片的 10% 以上)标记为占位图,状态改成empty。更稳的做法是同时看Content-Type和像素方差,纯色图方差接近 0。
import hashlib from PIL import Image import numpy as np def tile_fingerprint(content): """返回 sha1 与像素方差,用于识别占位图""" digest = hashlib.sha1(content).hexdigest() buf = Image.open(io.BytesIO(content)).convert("L") arr = np.asarray(buf, dtype=np.float32) return digest, float(arr.var())方差低于 1.0 的基本可以判定是纯色块,直接标empty,不要浪费后续的拼接内存。
5.2 现象:拼出来的图整体错位半张
现象:对比原图,拼好的结果整体往左上或右下偏了半个瓦片,边界也切得不齐。
原因:拼接时把x和y的顺序写反,或者 bbox 的左上/右下角取错了。最常见的是把“左上角用 lat_max,右下角用 lat_min”写成了反过来,导致 y 方向整体偏移。
解决:固定用(lon_min, lat_max)取左上,(lon_max, lat_min)取右下,并在代码里加断言:x0 <= x1 and y0 <= y1,不成立就直接报错。另外拼接时贴图坐标是(i*256, j*256),i 对应 x 方向,j 对应 y 方向,别在循环里混用。
5.3 现象:跑到一半开始大量 429,重试也没用
现象:前几千张很顺,之后大量 429,退避重试成功率极低,最后整批failed。
原因:并发数或 QPS 超过了服务端限制,触发按 IP 的速率控制,退避时间太短,请求还在窗口内就被再次拒绝。
解决:立刻把并发降到 2~4,QPS 降到 3 以下,退避时间从 2 秒起步按指数增长。同时检查是否有多个进程同时跑同一个任务,重复计数会提前触顶。恢复后不要马上把参数调回去,宁可用低速跑完。
5.4 现象:磁盘写满,但文件总量对不上预期
现象:du显示目录才 10 GB,df却说磁盘满了,ls也卡得厉害。
原因:瓦片是小文件,每个默认占 4 KB 块,几百万个文件的实际占用远大于内容总和;同时单目录文件数过多导致元数据膨胀和遍历变慢。
解决:目录按{z}/{x//1000}/{x}/分片;控制单次任务瓦片数;长期存储考虑打包成 MBTiles(一个 SQLite 文件),文件数从百万级降到 1,备份和传输都快得多。
5.5 现象:图片能打开,但拼图时报无法识别
现象:单张瓦片用看图软件能开,脚本里Image.open报 cannot identify image file。
原因:保存时扩展名固定写成了.png,实际内容是 jpg 或 webp;或者下载被中断,文件是半截的。
解决:按Content-Type决定扩展名,落盘前先校验尾部字节(PNG 以IEND结尾,JPEG 以FFD9结尾);校验失败的直接删掉重下。
def is_complete(content, ctype): if ctype == "image/png": return content.endswith(b"IEND\xaeB`\x82") if ctype == "image/jpeg": return content.endswith(b"\xff\xd9") return True # webp 等格式省略,交给解码器判断这五条里,5.1 和 5.4 是最容易被低估的,因为它们不报错,只是安静地把你的成果变成一堆废数据。
6. 增量更新与校验:让瓦片库能反复用
一次性下载只是开始。底图会更新,范围会扩大,你迟早要面对“哪些瓦片该重下”这个问题。我现在的做法是给瓦片库存一份 sha1 清单,重跑时先拉响应头比对ETag或Last-Modified,一致就跳过,不一致才下正文。
def need_update(session, url, headers, old_etag): """只发 HEAD,用 ETag 判断是否要重下""" resp = session.head(url, headers=headers, timeout=10, allow_redirects=True) new_etag = resp.headers.get("ETag") if not new_etag or not old_etag: return True # 拿不到校验值,保守重下 return new_etag.strip('"') != old_etag.strip('"')用 HEAD 的好处是流量几乎为零,几万张瓦片跑一遍也就几分钟。但要注意,不是所有服务都支持 HEAD,也不一定返回 ETag,这时候就退化成“按时间窗口全量重下”,比如只重下最近 30 天更新过的区域。
定期校验我按季度做一次,抽样 1% 的瓦片重新下载并比对 sha1,不一致的直接覆盖。抽样不是偷懒,是因为全量校验的时间和流量成本接近重下一遍,收益却不匹配。下面这张表是我自己用的校验项清单:
| 校验项 | 方法 | 异常处理 |
|---|---|---|
| 文件完整性 | 校验尾部字节 | 删除后重下 |
| 内容一致性 | sha1 抽样比对 | 覆盖并记录 |
| 占位图比例 | 哈希频次统计 | 标 empty,排除拼接 |
| 目录文件数 | 单目录计数 | 超阈值触发分片迁移 |
| 坐标正确性 | 抽点反查 | 修正 scheme 配置 |
从那以后,我每次启动一个新的瓦片下载任务,都会强制走一遍“先算瓦片数、再打印预估存储、最后确认层级”的流程,哪怕只是下一个小范围。这个习惯救过我很多次,因为真正让你返工的不是代码写错,而是层级多选了一级。希望帮到你。
本文还有配套的精品资源,点击获取