简介:这是一款面向离线地图开发者与GIS应用人员的百度地图瓦片下载工具,用于批量获取指定区域、指定层级的地图瓦片,解决无网络环境下地图数据来源问题。压缩包共3个文件,约5.05MB,包含可执行jar主程序、properties配置文件和txt使用说明,分别承担下载逻辑、参数配置与操作指引,结构精简、开箱即用。工具支持百度坐标系转换、经纬度或行政区域范围选择、瓦片级别设定与批量下载,并可将零散瓦片整合为完整离线地图,适用于离线导航、地图应用开发、地理数据分析、教学科研及应急救援等场景。目前已有5544人学习下载,读者可借此掌握瓦片组织原理与坐标转换思路,快速搭建本地地图数据源,降低开发与部署成本。
1. 地图瓦片下载工具:从“手动一张张存”到“离线底图自由”
做 GIS 可视化、内网大屏或者离线地图应用的人,迟早会撞上一堵墙:底图从哪来。在线调用当然方便,可一旦部署环境没有外网、或者要求毫秒级响应不能等网络请求,就必须把地图瓦片提前落到本地磁盘。所谓瓦片,就是把地图按缩放级别切成一张张 256×256 或 512×512 的小图片,按z/x/y的目录结构存放。标题里的“百度地图瓦片下载工具”,本质就是解决“怎么把这些切片批量、可控、不重复地抓下来并组织成标准目录”这件事。它适合三类人:做离线地图的 GIS 工程师、给内网系统配底图的后端、以及需要固定底图做视觉比对或标注的算法同学。工具本身不复杂,但坐标系、并发、去重、断点续传这几个点,能把人折腾到怀疑人生。
2. 瓦片下载的底层逻辑:URL 规则、坐标系与并发模型
2.1 百度瓦片的 URL 拼接规则与坐标系陷阱
百度地图的瓦片服务走的是自己的协议,和常见的 Web 墨卡托(EPSG:3857)不是一套东西。它的瓦片 URL 大致长这样:
https://maponline{0-3}.bdimg.com/starpic/?qt=satepc&u=x={x};y={y};z={z};v=009&fm=46其中{0-3}是负载均衡子域,x、y、z分别是列号、行号、层级。这里第一个大坑就是坐标系:百度瓦片的x/y是基于 BD-09 的墨卡托投影,原点在经纬度 (0,0) 对应的投影位置,而且 y 轴方向和标准 TMS 相反。如果你拿标准 XYZ 的公式去算百度瓦片的编号,下载下来的图会整体偏移几百米到几公里,越到高层越明显。
常见做法是先把 WGS-84 经纬度转成 BD-09,再套百度的瓦片编号公式。转换分两步:WGS-84 → GCJ-02 → BD-09。网上有成熟的转换函数,但要注意精度损失,一般民用场景够用,测绘级需求得另找权威参数。
import math # WGS-84 转 GCJ-02(简化版,民用精度足够) def wgs84_to_gcj02(lng, lat): a = 6378245.0 ee = 0.00669342162296594323 dlat = _transform_lat(lng - 105.0, lat - 35.0) dlng = _transform_lng(lng - 105.0, lat - 35.0) radlat = lat / 180.0 * math.pi magic = math.sin(radlat) magic = 1 - ee * magic * magic sqrtmagic = math.sqrt(magic) dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * math.pi) dlng = (dlng * 180.0) / (a / sqrtmagic * math.cos(radlat) * math.pi) return lng + dlng, lat + dlat def _transform_lat(x, y): ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x)) ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0 ret += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0 ret += (160.0 * math.sin(y / 12.0 * math.pi) + 320 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0 return ret def _transform_lng(x, y): ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x)) ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0 ret += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0 ret += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0 return ret这段代码里_transform_lat和_transform_lng是偏移量计算,wgs84_to_gcj02把偏移叠加回原坐标。参数a是长半轴,ee是第一偏心率平方,都是 CGCS2000/WGS-84 椭球的标准值。实际用的时候,如果你拿到的原始坐标已经是 GCJ-02(比如从某些国内地图 SDK 直接取的),就跳过第一步,只做 GCJ-02 → BD-09。
2.2 瓦片编号计算:别拿 XYZ 公式硬套
BD-09 坐标算百度瓦片编号的公式和标准 XYZ 不一样。百度用的是中心点加偏移的算法,核心是先把经纬度转成墨卡托米制坐标,再除以瓦片分辨率。
def bd09_to_tile(lng, lat, zoom): # BD-09 墨卡托投影 x = lng * 20037508.34 / 180.0 y = math.log(math.tan((90.0 + lat) * math.pi / 360.0)) / (math.pi / 180.0) y = y * 20037508.34 / 180.0 # 百度瓦片分辨率:第 18 级为 0.5 米/像素左右,这里用通用公式 res = 2 ** (18 - zoom) tile_x = int((x + 20037508.34) / (256 * res)) tile_y = int((20037508.34 - y) / (256 * res)) return tile_x, tile_y注意res这个分辨率因子,不同层级的实际分辨率百度没有完全公开,常见做法是用 18 级作为基准反推。tile_x和tile_y算出来后,还要根据百度自己的编号规则做一次偏移,否则下载的图会错位。这个偏移量在不同资料里说法不一,稳妥的办法是拿一个已知地点的瓦片手动比对,校准一次后固定下来。
2.3 并发下载:线程池不是越大越好
瓦片数量动辄几万到几十万张,单线程下载等到天荒地老。但并发也不是无脑开大。百度瓦片服务对单 IP 有隐性的频率限制,开 200 个线程猛冲,大概率触发限流,返回 403 或者空图片。
我一般用concurrent.futures.ThreadPoolExecutor,线程数控制在 8 到 16 之间,配合每个请求之间的随机延迟。
import os import random import time import requests from concurrent.futures import ThreadPoolExecutor, as_completed HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://map.baidu.com/" } def download_tile(tile_x, tile_y, zoom, save_dir, session): url = f"https://maponline{random.randint(0,3)}.bdimg.com/starpic/?qt=satepc&u=x={tile_x};y={tile_y};z={zoom};v=009&fm=46" path = os.path.join(save_dir, str(zoom), str(tile_x), f"{tile_y}.png") if os.path.exists(path): return "skip" os.makedirs(os.path.dirname(path), exist_ok=True) try: resp = session.get(url, headers=HEADERS, timeout=10) if resp.status_code == 200 and len(resp.content) > 100: with open(path, "wb") as f: f.write(resp.content) return "ok" return "fail" except Exception: return "error" finally: time.sleep(random.uniform(0.05, 0.2)) # 随机延迟,降低被封风险 def batch_download(tile_list, save_dir, max_workers=12): session = requests.Session() stats = {"ok": 0, "skip": 0, "fail": 0, "error": 0} with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = [executor.submit(download_tile, tx, ty, z, save_dir, session) for tx, ty, z in tile_list] for future in as_completed(futures): stats[future.result()] += 1 return statsmax_workers=12是经验值,实测在多数网络环境下能跑满带宽又不触发限流。time.sleep放在finally里保证每个请求后都歇一下,random.uniform(0.05, 0.2)的区间是试出来的,太短没用,太长效率掉得厉害。session复用 TCP 连接,比每次新建连接快不少。len(resp.content) > 100这个判断是为了过滤掉百度返回的占位空图,那些图通常只有几十字节。
3. 从零跑通一个瓦片下载器:目录规划、断点续传与校验
3.1 目录结构设计:z/x/y 还是 z/y/x
瓦片存盘目录结构直接影响后续能不能被 Leaflet、OpenLayers、Mapbox 这些库直接读取。标准做法是{z}/{x}/{y}.png,这也是绝大多数前端地图库默认的模板。
tiles/ ├── 10/ │ ├── 123/ │ │ ├── 456.png │ │ └── 457.png │ └── 124/ │ └── 456.png ├── 11/ │ └── ...但百度瓦片的 y 轴方向和 TMS 相反,如果你打算把下载的瓦片直接喂给标准 XYZ 的库,存盘时得做一次 y 翻转:y_tms = 2^z - 1 - y_xyz。这个翻转放在下载阶段做,比后期批量重命名省事得多。
def flip_y(tile_y, zoom): return (2 ** zoom) - 1 - tile_y2 ** zoom是该层级的总行数,减一减tile_y就得到 TMS 编号。注意这个翻转只对标准 XYZ 库有意义,如果你用的就是百度自家的 JS API,那不用翻,保持原样。
3.2 断点续传:靠文件存在性判断就够了
瓦片下载最怕跑到一半断了,重新跑又怕重复下载。最简单的断点续传就是下载前检查目标文件是否存在,存在就跳过。上面download_tile里的if os.path.exists(path): return "skip"就是干这个的。
但这里有个细节:如果上次下载到一半,文件写了一半就断了,文件存在但内容是坏的。稳妥做法是下载到临时文件,写完再重命名。
def download_tile_safe(tile_x, tile_y, zoom, save_dir, session): url = f"https://maponline{random.randint(0,3)}.bdimg.com/starpic/?qt=satepc&u=x={tile_x};y={tile_y};z={zoom};v=009&fm=46" final_path = os.path.join(save_dir, str(zoom), str(tile_x), f"{tile_y}.png") tmp_path = final_path + ".tmp" if os.path.exists(final_path): return "skip" os.makedirs(os.path.dirname(final_path), exist_ok=True) try: resp = session.get(url, headers=HEADERS, timeout=10) if resp.status_code == 200 and len(resp.content) > 100: with open(tmp_path, "wb") as f: f.write(resp.content) os.replace(tmp_path, final_path) # 原子操作,避免半截文件 return "ok" return "fail" except Exception: if os.path.exists(tmp_path): os.remove(tmp_path) return "error" finally: time.sleep(random.uniform(0.05, 0.2))os.replace在大多数文件系统上是原子操作,要么旧文件被替换,要么新文件完整写入,不会出现半截状态。tmp文件在异常时主动清理,避免残留一堆垃圾。
3.3 下载后的完整性校验:别等前端报错才发现图是空的
批量下载完,最好跑一遍校验。校验分两层:文件大小和图片可解码性。文件大小低于阈值(比如 200 字节)的基本是空图或错误页;用 PIL 打开一次能过滤掉损坏文件。
from PIL import Image import os def validate_tiles(root_dir, min_size=200): bad = [] for dirpath, _, filenames in os.walk(root_dir): for name in filenames: if not name.endswith(".png"): continue fp = os.path.join(dirpath, name) if os.path.getsize(fp) < min_size: bad.append((fp, "too_small")) continue try: with Image.open(fp) as img: img.verify() except Exception as e: bad.append((fp, str(e))) return badimg.verify()只检查文件头,不真正解码像素,速度快。如果连文件头都过不了,说明下载时内容就是错的。min_size=200是经验值,正常瓦片至少几 KB,200 字节以下基本可以判定异常。
4. 避坑与排查:那些让我重跑整晚的坑
4.1 坑一:下载的瓦片整体偏移,前端叠加后对不上
现象:瓦片能下载,目录结构也对,但叠到地图上整体偏移几百米,放大后偏移更明显。
原因:坐标系没转,直接拿 WGS-84 的经纬度套了百度瓦片编号公式。百度瓦片基于 BD-09,和 WGS-84 差着两次非线性偏移。
解决:老老实实做 WGS-84 → GCJ-02 → BD-09 两步转换,转换后再算瓦片编号。如果原始数据已经是 GCJ-02,跳过第一步。校准方法:找一个地标建筑,手动算出它的瓦片编号,下载后和在线地图比对,确认对齐。
4.2 坑二:跑了几万张后突然全部返回 403
现象:前几千张正常,后面开始大量 403,换 IP 后恢复,过一会又 403。
原因:并发太高或请求间隔太短,触发了服务端的频率限制。百度瓦片服务对单 IP 的 QPS 有隐性阈值,具体数值没公开,但实测超过 20 QPS 就危险。
解决:把max_workers降到 8 到 12,每个请求后加random.uniform(0.05, 0.2)的延迟。如果还是被封,把延迟下限提到 0.3 秒,或者分批下载,每批之间歇几分钟。别用固定延迟,固定间隔的请求模式更容易被识别。
4.3 坑三:断点续传后部分瓦片是半截图
现象:中断后重跑,跳过已存在的文件,但前端加载时部分瓦片显示不全或花屏。
原因:上次中断时文件写了一半,os.path.exists判断为存在,直接跳过,坏文件被保留。
解决:用临时文件加os.replace的原子写入方案。如果已经产生了坏文件,跑一遍validate_tiles把异常文件删掉重下。校验时min_size别设太低,200 字节是底线,正常瓦片至少 1KB 以上。
4.4 坑四:高层级瓦片数量爆炸,磁盘写满
现象:下载到 17、18 级时,瓦片数量指数增长,磁盘很快满了。
原因:每升一级,瓦片数量翻四倍。一个城市 18 级的瓦片可能上百万张,单张按 10KB 算就是 10GB 起。
解决:下载前先算总量。公式是4^z乘以覆盖区域占比。如果只是局部区域,用边界框算出x、y的范围,只下范围内的瓦片。别全量下,没必要。另外,如果只是做离线底图,16 级通常够用,再高就是浪费。
4.5 坑五:下载的瓦片在 Leaflet 里不显示
现象:目录结构、文件内容都正常,但 Leaflet 加载后一片空白。
原因:y 轴方向没翻转。百度瓦片是 TMS 方向,Leaflet 默认是 XYZ 方向,两者 y 轴相反。
解决:存盘时做y_tms = 2^z - 1 - y_xyz翻转,或者在 Leaflet 的TileLayer里设置tms: true。推荐前者,因为翻转一次后面通用,不用每个前端都改配置。
5. 进阶技巧:用边界框精准控制下载范围与层级
5.1 从经纬度边界框算出瓦片编号范围
全量下载是新手最容易犯的错。实际项目里,你往往只需要某个城市、某个园区、甚至某条路两侧的瓦片。用边界框(bbox)算出瓦片编号范围,能把下载量砍掉九成以上。
def bbox_to_tile_range(min_lng, min_lat, max_lng, max_lat, zoom): # 先转 BD-09 min_lng_bd, min_lat_bd = wgs84_to_bd09(min_lng, min_lat) max_lng_bd, max_lat_bd = wgs84_to_bd09(max_lng, max_lat) # 算四个角的瓦片编号 x_min, y_max = bd09_to_tile(min_lng_bd, min_lat_bd, zoom) x_max, y_min = bd09_to_tile(max_lng_bd, max_lat_bd, zoom) return x_min, x_max, y_min, y_max def generate_tile_list(x_min, x_max, y_min, y_max, zoom): tiles = [] for x in range(x_min, x_max + 1): for y in range(y_min, y_max + 1): tiles.append((x, y, zoom)) return tileswgs84_to_bd09是前面两步转换的封装。注意y_max和y_min的对应关系:纬度越小,y 越大,所以min_lat对应y_max,max_lat对应y_min。这个容易搞反,搞反了范围就是空的。
5.2 层级选择:不是越高越好
层级选择取决于用途。做全国概览,5 到 8 级够用;做城市级离线地图,12 到 15 级;做园区级精细底图,16 到 18 级。每升一级,瓦片数量翻四倍,磁盘和下载时间都指数增长。
| 层级 | 适用场景 | 单城市瓦片量级 | 磁盘占用(约) |
|---|---|---|---|
| 5-8 | 全国/省概览 | 几百到几千 | 几十 MB |
| 9-12 | 城市级底图 | 几万到十几万 | 几百 MB 到 1 GB |
| 13-15 | 区县级精细底图 | 几十万 | 1-5 GB |
| 16-18 | 园区/街道级 | 上百万 | 5-20 GB |
这张表是经验值,具体数字取决于城市面积和边界框大小。下载前先用generate_tile_list算一下总数,心里有底再跑。
5.3 一个我常用的下载前检查习惯
跑批量下载前,我习惯先下 10 张样本,手动打开看三件事:图片内容对不对、坐标系有没有偏、文件大小是否正常。这三件事确认了再跑全量,能省掉整晚重跑的血泪教训。
def sample_check(tile_list, save_dir, n=10): import random samples = random.sample(tile_list, min(n, len(tile_list))) stats = batch_download(samples, save_dir, max_workers=4) print(f"样本下载结果: {stats}") # 手动打开 save_dir 下的图片确认内容 return statsrandom.sample从全量列表里随机抽,避免只抽到边缘瓦片。max_workers=4是样本阶段,慢一点无所谓,别触发限流。下载完手动打开几张图,确认是地图内容而不是错误页或空图。这个习惯帮我拦下过好几次坐标系配错的问题,比跑完几万张再发现要划算得多。
希望帮到你。
本文还有配套的精品资源,点击获取