简介:面向离线地图开发者的百度地图瓦片下载工具,通过批量抓取指定级别与范围的瓦片图片,解决无网络环境下的地图数据获取难题。工具支持百度坐标系转换、经纬度范围选择与级别设定,可自动批量下载并整合离线地图,适用于离线导航、应急地图、数据分析等场景。压缩包共三个文件,大小约五兆,内含可运行的jar程序、用于参数配置的properties文件以及txt使用说明,无需复杂安装即可上手。该工具已有五千余人浏览学习,在同类离线地图工具中具备参考价值;借助jar工具与配置文件,开发者可快速完成坐标转换、批量获取瓦片,并可根据实际需求调整缩放级别以平衡地图细节与存储空间。附带的说明文档对参数配置和常见操作进行了梳理,即使初涉瓦片地图的开发者也能按图索骥,有效降低离线地图二次开发的成本。
1. 百度地图瓦片下载工具到底解决了什么事
内网机房部署一张业务大屏,数据全在本地,底图却死活出不来;野外勘测的平板进了山区,在线地图转半天只剩网格;要把摄像头点位图打印成 A0 纸质图,截图放大之后全是马赛克。这些场景的共同解,就是一个「百度地图瓦片下载工具」:按你划定的经纬度范围,把百度地图某一层级或连续几个层级切成 256 × 256 的小方块逐张抓下来,按 z/x/y 目录存好,最后打包成一个 ZIP,拷到任何一台不能上外网的机器上,本地加载器按同样的规则把瓦片拼回去。你拿到的这类工具 ZIP,解压后通常是一个 Python 脚本加一份参数说明,有的带简易界面,命令行版本更适合批量调度。本文不假装复刻某一个具体压缩包里的每一行代码,而是把这类工具从坐标换算、任务生成、并发下载到打包排错的全链路讲清楚,让你拿到任意一份同类工具都能快速上手改参数,甚至自己从零写一个。
2. 瓦片金字塔与坐标索引:先把地图切块这件事捋清楚
2.1 瓦片金字塔:不是一张大图切九宫格
在线地图从来不会把一张巨大的整图推给浏览器,而是把全球底图按缩放级别预先切成无数张 256 × 256 的小图,前端只请求当前视口能看见的那几片。这个结构叫瓦片金字塔:缩放级别 zoom 每增加 1,瓦片总数就变成上一级的 4 倍,所以级别越高,地图越精细,瓦片数量也越夸张。
具体到数量关系,在通用 Web 墨卡托方案中,级别 z 对应的全球瓦片矩阵是 2^z 行 × 2^z 列。z=10 时全球约 100 万张瓦片,到了 z=15 就变成约 10 亿张。所以下载工具的第一道数学题,就是算出你关心的地理范围在目标级别下到底横跨多少行、多少列,而不是真把全球都拽下来。
每个瓦片都有自己的坐标编号,一般写作 (x, y, z):z 是缩放级别,x 是列号,y 是行号。所有后续的下载、校验、拼接、加载都靠这三个数定位。离线加载器拿到 z/x/y 之后,能直接算出这张瓦片对应地球上的哪个矩形区域,从而精确地把它摆到画面正确的位置。
2.2 经纬度换算瓦片坐标:两个关键公式
要对一个经纬度矩形范围做下载,脚本得先把经纬度边界换算成瓦片行列号范围。也就是说,输入是经度 lon、纬度 lat、缩放级别 z,输出是瓦片 x 和 y。这个换算在通用 Web 墨卡托下有现成的公式:
import math def lonlat_to_tile(lon, lat, zoom): n = 2 ** zoom x = (lon + 180.0) / 360.0 * n lat_rad = math.radians(lat) y = (1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n return int(x), int(y)x 的公式很直白:把经度从 [-180, 180] 映射到 [0, 1],再乘以当前级别的总列数。y 这边之所以绕了一下,是因为墨卡托投影在高纬度地区会把地图拉长,纬度不能像经度那样线性映射,必须先用 tan 和反双曲正弦做一次投影变换,把纬度转成投影平面上的 y 值,再落到瓦片行号。
这里要特别记得 y 的方向是反的:纬度越高,y 数值越小,因为瓦片编号从左上角开始向下增长。很多人第一次写下载脚本,就是把 y 的上下边界写反了,结果下载出来的底图整体上下颠倒。
2.3 百度地图瓦片坐标和通用XY规则为什么对不上
如果直接用上面这套通用公式去拼百度地图的瓦片地址,会发现位置对不上,偏差从几十米到几百米都有。原因不在公式本身,而在坐标基准:通用公式基于 WGS-84 经纬度和标准 Web 墨卡托,而百度地图使用自己的坐标体系,业内常说的 BD09 坐标就是其中之一。GPS 拿到的原始坐标是 WGS-84,国内地图产品通常还要先经过一次加密偏移到 GCJ-02,百度再在这个基础上做二次偏移到 BD09。
所以务实的下载工具不会去背一套所谓的“百度官方瓦片公式”,而是在脚本里留一层坐标适配:
def translate_to_baidu(lon, lat): # 不同城市、不同偏移表的结果会有差异,正式使用前务必实测校准 return offset_table_apply(lon, lat)这层适配的常见实现有两种:一种是引入公开的 GCJ-02 坐标偏移算法,先做一次 WGS-84 到 GCJ-02 的转换,再根据目标区域微调;另一种更直接,选三五个已知地标,把百度地图在线页面里开发者工具抓到的瓦片编号和通用公式算出来的编号做对比,求出一组针对该城市和层级的偏移常数写回配置。
我的建议是:下载器的主流程不要依赖某个写死的偏移公式,而是把偏移量做成可配置参数,换一个城市或换一套底图样式时,只改配置不动代码。这个习惯能省掉大量重复调试时间。
3. 复刻一个能跑的下载脚本:参数、坐标函数与任务队列
3.1 命令行参数:范围、级别、并发三件套
一个下载工具好不好用,先看参数设计。最少得支持四件事:经纬度范围、缩放级别列表、并发线程数、输出文件名。我通常把范围用四个数字表示:west(最小经度)、south(最小纬度)、east(最大经度)、north(最大纬度),因为这是 GIS 工具里最通用的描述方式,也方便从地图页面上直接复制。
命令行用 argparse 解析,命令长这样:
python baidu_tile.py \ --west 116.28 --south 39.85 \ --east 116.48 --north 40.02 \ --zooms 10,11,12 \ --threads 8 \ --out bj_offline.zip这个例子以北京城区的近似范围为例,下载 10 到 12 三个层级,开 8 个线程。参数里有几个值得注意的点:zooms 用逗号分隔,方便一次跑连续多个级别;threads 不要默认开太大,后面讲并发的时候会解释原因;out 指定输出 ZIP 路径,脚本跑完直接产出离线底图包。
3.2 坐标转换:把经纬度矩形变成瓦片矩形
核心工具函数是把经纬度范围换算成瓦片范围,注意这里不能再像 2.2 节那样简单取整,而要处理边界归属问题。一个瓦片覆盖的是地球上一个矩形小块,边界上的点到底算哪张瓦片,取决于你取的向下取整规则。按瓦片编号的惯例,行号和列号都等于“投影坐标除以 256 后向下取整”,所以范围两端都要按这个规则算。
def bbox_to_tile_range(west, south, east, north, zoom): n = 2 ** zoom # x 方向:列号以向下取整为准,边框碰到瓦片线时优先多取一格 x_min = math.floor((west + 180.0) / 360.0 * n) x_max = math.floor((east + 180.0) / 360.0 * n) # 纬度太靠近极地时 tan 会发散,先做一次钳位 north = min(north, 89.99) south = max(south, -89.99) y_min = math.floor((1.0 - math.asinh(math.tan(math.radians(north))) / math.pi) / 2.0 * n) y_max = math.floor((1.0 - math.asinh(math.tan(math.radians(south))) / math.pi) / 2.0 * n) return x_min, y_min, x_max, y_max注意 y_min 是用 north 算的,y_max 用 south 算,因为纬度越高 y 越小。如果你发现下载区域上下颠倒或者整体平移了一整行,多半就是这里写反了。x 方向相对简单,哪个经度小哪个就是左边界。
3.3 任务队列:按行、列双层循环生成瓦片列表
有了瓦片范围,任务生成就是一个双层循环。这里我一般先把任务全部生成到一个列表里,再交给并发下载器,这样做的好处是能提前知道总任务数,方便打进度、做断点续传。
def build_tasks(west, south, east, north, zooms): tasks = [] for zoom in zooms: x_min, y_min, x_max, y_max = bbox_to_tile_range( west, south, east, north, zoom ) count = (x_max - x_min + 1) * (y_max - y_min + 1) print(f"zoom {zoom}: {count} tiles") for x in range(x_min, x_max + 1): for y in range(y_min, y_max + 1): tasks.append((x, y, zoom)) return tasks每次正式下载之前,先打印每个层级的瓦片数量,这对估算体积和下载时长特别有用。一个城市中心城区在 z=16 级别可能有几千到几万张瓦片不等,看到数量再决定要不要缩小范围,比闷头跑完发现包太大要划算得多。
4. 并发下载与ZIP打包:拿到能直接分发的离线底图包
4.1 请求头与重试:别让服务器一眼看穿你是脚本
瓦片地址本质上就是普通 HTTP 请求,但没带浏览器特征的请求很容易被拦。我见过太多新手下载下来一堆 200 响应、内容却是占位图的“空瓦片”,原因就是请求头里 User-Agent 暴露了脚本身份。
常见做法是把请求头伪装成正常浏览器访问,重点带上 User-Agent 和 Referer,Referer 填地图主站的域名,让服务端认为请求是从网页里发出来的。另外瓦片服务器通常会同时提供多个子域名做负载均衡,脚本里轮询使用,避免单域名请求过密:
import requests headers = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" ), "Referer": "https://map.baidu.com/", } server_list = ["online0", "online1", "online2", "online3"] session = requests.Session() session.headers.update(headers) def pick_server(seed): return f"https://{server_list[seed % len(server_list)]}.map.bdimg.com"这里把瓦片服务器域名放在一个列表里循环取用,seed 可以用 x + y + z 混合计算。换个城市跑的时候,如果发现某个域名完全不可达,只需要改这个列表,不用动下载逻辑。
4.2 多线程下载与限速:并发数不是越大越好
瓦片请求是高并发友好型任务,单张图几十 KB 到几百 KB,网络往返时间占比高,串行下载能把人急死。用 Python 的 ThreadPoolExecutor 做多线程下载是这类工具最常见的实现,代码简单,效果也够用。
import time from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_one(task, out_dir): x, y, z = task url = pick_server(x + y + z) + f"/tile/?qt=vtile&x={x}&y={y}&z={z}&styles=pl" save_path = out_dir / str(z) / str(x) / f"{y}.png" if save_path.exists(): return "skipped" for attempt in range(4): try: resp = session.get(url, timeout=10) if resp.status_code == 200 and len(resp.content) > 2048: save_path.parent.mkdir(parents=True, exist_ok=True) save_path.write_bytes(resp.content) return "ok" if resp.status_code in (403, 429): time.sleep(2 * (attempt + 1)) except requests.RequestException: time.sleep(1) return "failed" def run_download(tasks, out_dir, threads): out_dir = Path(out_dir) done, failed = 0, 0 with ThreadPoolExecutor(max_workers=threads) as pool: futures = [pool.submit(fetch_one, t, out_dir) for t in tasks] for fut in as_completed(futures): done += 1 if fut.result() == "failed": failed += 1 if done % 500 == 0: print(f"进度 {done}/{len(tasks)},失败 {failed}") return failed这段代码做了三件事:跳过已存在文件,实现断点续传;对 403/429 做退避重试;定期打印进度。并发数我一般建议在 4 到 16 之间,线程开太多并不会线性提速,反而更容易触发服务端限流。下载时长按小时计的场景,稳定比峰值速度重要。
4.3 目录结构与ZIP打包:把上千个文件收进一个文件
瓦片下载到本地后,目录结构必须和加载器约定一致。约定俗成的规则是z/x/y.png,也就是缩放级别作为一级目录、列号作为二级目录、行号作为文件名。Leaflet 等前端库的瓦片地址模板{z}/{x}/{y}.png就是按这个结构设计的。
打包成 ZIP 时,要注意压缩模式的选择。瓦片本身是 PNG 或 WebP,已经是压缩格式,再用 ZIP 的 DEFLATE 压缩一遍,体积几乎不会变小,反而徒增 CPU 消耗。所以这里用 ZIP_STORED 模式,只做打包不做压缩,速度快得多:
import zipfile from pathlib import Path def pack_tiles(root_dir, out_zip): root = Path(root_dir) count = 0 with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_STORED) as zf: for png in sorted(root.rglob("*.png")): arc_name = png.relative_to(root).as_posix() zf.write(png, arc_name) count += 1 return count写 ZIP 时用relative_to(root).as_posix()把路径分隔符统一转成斜杠,避免在 Windows 下打出反斜杠路径,否则换到 Linux 上的加载器会找不到文件。打包完成后,建议顺手打印一个文件总数和 ZIP 大小,方便和后面的校验结果对账。
5. 避坑排查:五个常见现象的定位路径
5.1 现象:请求全部返回 200,但图片是空白或灰底
这是下载器最容易踩的坑,表面看没有报错,实际上下载下来的瓦片全是无效图。原因多半是请求头不完整,或者被服务端判定为异常客户端后返回了占位图。另外,部分瓦片 URL 参数里带有时效性 token,直接用旧模板拼出来的地址,服务器也会返回统一占位内容。
解决方法是先单独用浏览器开一张瓦片地址验证,如果浏览器能打开、脚本打不开,就是请求头问题。把 User-Agent 和 Referer 补全,再用响应内容长度做一层过滤,小于 2048 字节的图片直接视为异常,触发重试而不是保存,这样可以避免大量垃圾文件落盘。
5.2 现象:矩形范围边缘缺了一溜瓦片
下载完成后拼出来,四周边缘少了一圈,或者某一侧缺了一条。这是因为边界瓦片的编号用的是向下取整,而经纬度边界压在瓦片边界线上时,取整结果可能把边缘那张瓦片排除在外。如果下载范围很小,比如只有几百米,边缘缺瓦非常明显。
解决方案是在生成瓦片范围时,左边界和上边界用向下取整,右边界和下边界再额外判断一次:如果经纬度坐标除以单瓦片跨度后不是整数,就多取一格。更省事的做法是把范围向外扩一个很小的值,比如经度减 0.001、纬度减 0.001,让边缘瓦片必然落入任务队列。
5.3 现象:瓦片能显示,但底图和坐标点位明显错位
这个现象在叠加轨迹、标注点的时候最容易暴露:底图是一条街,轨迹点却漂在隔壁街。原因基本可以断定是坐标系不一致。GPS 拿到的 WGS-84 坐标,和国内地图产品使用的加密坐标系之间存在固定偏移,百度地图还有自己的二次偏移,三层坐标混在一起不加转换,错位几十到几百米都很正常。
解决方法是先明确目标底图用的什么坐标系,然后在数据进入加载器之前统一做坐标转换。转换逻辑建议单独抽成一个模块,不要散落在下载脚本各处,方便针对不同城市更换偏移表参数。我不建议在下载器里强算全局坐标转换,因为国内不同区域偏移量并不完全一致,能做全市级校准就算很好了。
5.4 现象:下载到一半开始大量出现 403 或 429
前面几百张瓦片下得好好的,突然开始连环失败,重试也不管用。这是典型的请求频率过高触发了服务端限流。429 是明确的限流响应,403 则可能是封禁了当前的网络出口。线程开得越大,越容易触发这个问题。
解决方法是降并发、加重试退避、加随机延时。线程数从 16 降到 6 或 8,重试时用递增的等待时间,不要固定等 1 秒。另外把请求分布到多个瓦片子域名上,也能明显降低单域名的压力。真被封了 IP,除了等一段时间没有太好的即时解封办法,所以控制节奏才是关键。
5.5 现象:ZIP 解压后加载器黑屏或全是叉
黑屏一般不是瓦片文件的问题,而是目录层级和加载器期望的不匹配。有的加载器要求z/x/y.png,有的要求z/y/x.png,还有的要求每张瓦片放在zoom/x_y.png这种扁平结构里。如果你下载的脚本用的目录结构和加载器不一致,加载器按自己的模板找文件,自然什么都找不到。
解决方法是下载之前先确认加载器的瓦片地址模板,再决定文件组织方式。如果 ZIP 已经打包好了,可以在打包函数里加一个参数控制目录结构,或者解压后用脚本批量改名重排,避免重新下载一遍。另外要注意单个目录下文件过多时,某些老旧加载器会卡在索引阶段,这时候按瓦片行号建立二级目录会更稳。
6. 再往前一步:把瓦片校验当作交付前的必要动作
下载器和打包脚本都写完,不代表能直接交付。瓦片下载这类任务最大的问题就是“看着没报错,实际缺了货”,所以我现在的习惯是每次跑完批量任务之后,强制加一道校验流程:扫描目标目录里的瓦片文件,对照任务队列逐张检查大小,小于阈值的直接列入异常清单。
def verify_tiles(tasks, out_dir, min_size=2048): out_dir = Path(out_dir) missing, small = [], [] for x, y, z in tasks: p = out_dir / str(z) / str(x) / f"{y}.png" if not p.exists(): missing.append((x, y, z)) elif p.stat().st_size < min_size: small.append((x, y, z)) print(f"缺失 {len(missing)},异常 {len(small)}") return missing + small这道校验跑的很快,却能拦住大多数无效交付。缺瓦和异常瓦片清单出来后,直接把它们重新塞回下载任务跑一遍断点续传,就能补全大部分缺口。如果补完之后还有零星失败,再考虑是不是并发太高导致被限流,调低线程数重跑一次。
最后一个建议是交付前必须真机验证一次:用一个本地 HTML 页面加载这套瓦片,确认底图能拖动、能缩放、没有花屏。代码只需要一个最简单的瓦片图层实例,注意把 Leaflet 的 js/css 也放到本地目录,别依赖 CDN,因为离线环境根本没有外网。
<!doctype html> <html> <head> <meta charset="utf-8"/> <title>离线瓦片验证</title> <link rel="stylesheet" href="lib/leaflet.css"/> </head> <body> <div id="map" style="height: 100vh"></div> <script src="lib/leaflet.js"></script> <script> const map = L.map('map').setView([39.9, 116.4], 12); L.tileLayer('tiles/{z}/{x}/{y}.png').addTo(map); </script> </body> </html>这个验证页我每次都会留着,换一个项目、换一批瓦片,只改 setView 的中心点坐标和缩放级别就能复用。校验脚本加上验证页,两次确认都通过,这份瓦片包才算真正可以交出去。曾经有一次我省掉校验直接交付,结果对方解压后黑屏,找半天才发现是打包时把 y 目录写成了 x,从那以后我再也没跳过这道工序。希望帮到你。
本文还有配套的精品资源,点击获取