ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

百度地图瓦片下载与离线地图制作:从行列号计算到坐标纠偏实战

百度地图瓦片下载与离线地图制作:从行列号计算到坐标纠偏实战

简介:一份面向地图开发者和离线应用设计者的百度地图瓦片批量下载工具,解决在无网络或内网环境下获取指定区域地图数据的痛点。工具基于Java封装,内置坐标转换、范围选择、级别设定、批量下载及数据整合等模块,可自动完成百度坐标与瓦片坐标的换算,按经纬度范围和缩放等级抓取对应图片,适合离线导航、地理信息可视化、应急预置底图等场景。资源包共3个文件,包括可执行jar包、properties配置文件和使用说明txt,整体仅有5.05MB,轻量易部署;通过修改配置文件即可指定下载区域与级别,无需复杂安装。目前已有5543人学习/下载。下载后可直接获得对应级别的瓦片图片集,为后续拼接离线地图、叠加自定义标注提供数据基础,可显著减少手工切图工作量,适合需要快速搭建本地地图底图并进一步开发个性化地图应用的工程师。

1. 百度地图瓦片下载工具:把在线底图转成可离线复用的瓦片目录

百度地图瓦片下载工具,最直白的用法是把指定城市或区域的在线底图拆成一张张 256×256 的 PNG 小图,按缩放级别和行列号存进本地目录,这套小图矩阵就是地图客户端真正加载的东西。手动画不来,也不该拿截图去凑,下载工具的价值是把本来只存在于在线服务里的瓦片资源,变成你能离线调度、拼图、挂接的数据文件。对正在做离线地图、户外采集、GIS 二次开发或应急通信展示的开发者来说,它是把在线地图资产化的必经环节。需要强调的是,地图瓦片有自己严格的坐标系和编号规则,先把这两件事弄明白,跑任务才不翻车。

2. 瓦片编号背后的坐标系:行列号、缩放级别与范围选择逻辑

2.1 百度地图瓦片为什么不能直接套用普通 Web Mercator 行列号

很多人的第一反应是:地图瓦片不都是按 Web Mercator 网格来切的吗?左上角是 0,0,x 向右递增,y 向下递增,最多是 y 轴方向和 TMS 相反。这个说法对大部分国际地图服务成立,但对百度地图要打一个折扣。百度地图在线上渲染时使用的是一套经过偏移处理的地理坐标系,通常称为 BD-09,它基于国测局加密坐标再做了二次偏移;投影方式也不是标准 EPSG:3857 的直接展开,而是与之近似的百度墨卡托。简单说,你拿 WGS84 经纬度按标准公式算出瓦片行列号,在这个工具里可能在大部分区域能对上,但到了某些城市边缘或大比例尺下,会差出半个瓦片甚至一整块。

下载工具会替你做的是“请求参数层面的坐标转换”,也就是把输入范围换算成百度瓦片服务器认的那套行列号,而不是让你自己写投影公式。但你在使用成果时要清楚,下载下来的瓦片默认带有百度坐标系的偏移,不可能和 GPS 原始轨迹直接叠加。我一般会在下载前先想清楚最终要给谁用:如果只做离线浏览,直接存原始瓦片即可;如果后面要叠加 GPS 轨迹或者和标准 Web Mercator 数据混用,就得在下载参数里开启“坐标纠偏”或在拼接阶段处理。

2.2 从经纬度换算瓦片行列号:给你一套可验证的手算路径

虽然工具内部会换算,但建议你至少能手算一次。原因很简单:你只有知道行列号范围,才能预估瓦片数量、检查下载结果是否缺行缺列,也才能在拼接阶段判断边界是否有问题。标准 Web Mercator 瓦片的行列号公式在多数参考书上都能找到,写成 Python 是这样:

import math def lnglat_to_tile(lng, lat, zoom): """ 根据经纬度和缩放级别计算瓦片行列号 返回 (x, y),即列号和行号 """ n = 2 ** zoom x = (lng + 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) # 以某个城市中心点为例,计算 15 级瓦片坐标 print(lnglat_to_tile(116.404, 39.915, 15))

这段代码用了 Python 3 内置的asinh函数,避免了老式写法里log(tan + 1/cos)在极点附近的溢出问题。zoom就是缩放级别,百度地图常用 1 到 19 级,15 级对应城区街道尺度。算出来的 x 和 y 是浮点数,向下取整后就是这块瓦片在网格中的位置。要注意的是,这条公式对应的是标准 Web Mercator,百度瓦片实际请求时还要加偏移,所以它更适合用来估算范围和数量,不适合直接拼 URL。

估算数量时用到的公式更直观:如果范围跨了 x 方向 120 列、y 方向 80 行,总瓦片数就是 120×80=9600 张。按每张 30 到 80 KB 估算,单层结果大约 300 到 700 MB。这个数量会影响磁盘规划、并发设置和下载时长。我习惯在每次任务开始前先写个脚本把范围换算成行列号,再输出预计数量和体积,确认没问题再启动下载,这个习惯帮我避免过好几次把磁盘塞满的尴尬。

2.3 工具的三种范围模式和产物形态

这类下载工具通常支持三种指定范围的方式:矩形对角框选、多边形边界框选、按中心点和半径画圆。矩形模式适合城市或区县,多边形适合沿河流、道路或自定义行政区边界,圆形多用于基站覆盖、应急保障这类场景。范围越规则,生成的瓦片矩阵越整齐,后续拼接越容易;多边形边缘会出现半块瓦片,一般工具会补全整块,但边缘会有超出范围的内容,这是正常现象。

产物目录结构大同小异,常见组织方式是:

tiles/ 15/ 51320/ 20135.png

其中15是缩放级别,51320是 x 列号,20135.png是 y 行号。这种 z/x/y 结构是目前瓦片服务事实上的通用标准,后续用 Nginx、MinIO 或自建瓦片服务直接指到这个目录就能用。工具一般还会在旁边生成一个task.json,记录本次任务的坐标系、范围、级别和源类型,这个文件别删,它是后续拼接和追溯的依据。

3. 工具实操:从配置文件到批量落盘,完整跑通一次任务

3.1 用配置文件锁定范围、层级与输出目录

图形界面是这类工具的标配,但批量使用和管理时会遇到一个问题:手工填参数不可重现,下次再下载同样范围还要重新点一遍。我习惯在有命令行入口的工具上把参数写成 JSON 配置文件,让任务有痕迹、可迭代。一份典型的配置长这样:

{ "task_id": "demo_area_z15", "source": "baidu_vector", "style": "pl", "zoom_levels": [12, 13, 14, 15], "bounds": { "min_lng": 116.35, "min_lat": 39.85, "max_lng": 116.45, "max_lat": 39.92 }, "output": "./tiles/demo_area", "concurrency": 8, "retry_times": 3, "resume": true, "skip_existing": true }

source指定数据源类型,常见值是baidu_vector和baidu_satellite,分别对应矢量底图和卫星影像。style是地图样式,矢量底图一般用pl,卫星影像有纯影像和带标注两种,带标注的适合做现场对照。zoom_levels用数组而不是单值,下载器会自动从低级别往高级别逐级爬。bounds是经纬度范围,注意这里用的是 WGS84 坐标,工具内部会再做偏移换算。concurrency控制同时请求的瓦片数量,retry_times是失败重试次数,resume和skip_existing组合使用可以实现断点续传。

参数里最容易被忽略的是style。同一个范围下,矢量底图和卫星影像的瓦片大小差很多,卫星影像单张可达 100 KB 以上,矢量底图只有 20 到 50 KB。如果混在一起下载,磁盘容量估算会偏差很大。我一般只会为同一个区域同时保留矢量底图和卫星影像,标注图只在需要现场比对时下。

3.2 缩放级别与并发数怎么选:不是越高越好

缩放级别选择是新手最容易踩坑的地方。级别越低,整幅图越宏观,瓦片总数越少;级别越高,画面越细,瓦片数量按 4 的幂次增长。同一个城市范围,13 级可能只有几千张,到 17 级就是几十万张,18 级破百万也很正常。工具默认会把 1 到 19 级全部勾上,如果直接跑全量,下载时长和磁盘占用会非常可观。

更合理的做法是按下发场景倒推级别:做城市宏观展示,选 10 到 13 级;做街道级业务,选 14 到 16 级;做现场设备定位或施工图底图,选 17 到 19 级。所谓“下载范围外扩”,指的是在目标边界外增加一圈瓦片,通常外扩 1 到 2 个瓦片宽度就够了,这样拼接后的图边缘不会露出难看的白边或半截内容。

并发数也同理。并发太高容易触发服务端限流,导致大量空文件和零星 503;并发太低,几十万张瓦片要跑好几个小时。我的经验是:内网环境下并发 16 到 32 都试过,公共服务下稳定跑 8 比较安全。工具一般也提供请求间隔参数,单位为毫秒,设 50 到 200 可以明显降低被断连的概率。

3.3 启动任务和读取日志

配置文件准备好之后,命令行调用方式一般是这样:

./tile_downloader --config ./config/demo_area.json --log-dir ./logs/tile_demo

--config指定配置路径,--log-dir指定日志目录。启动后终端会滚动输出每批瓦片的下载进度、失败数量和重试状态。任务结束或中断后,日志目录里会保留每个级别的统计文件,包括已下载数、失败数、重试次数。判断任务有没有真正跑完,不要只看进度条,要看最后一行有没有输出任务结束标记和总耗时。

日志里有几个字段要特别留意:total表示当前级别计划下载的瓦片总数,fetched是实际成功的数量,failed是最终失败且重试无效的数量,empty代表 HTTP 200 但内容为空文件的异常瓦片。如果empty超过总量的 1%,就要停下来调整并发或请求间隔,而不是继续让任务空转。

我在命令行前通常会先加一条检查命令,确认输出目录是空的或者符合预期,防止skip_existing把上一次残留的旧瓦片当成新成果:

find ./tiles/demo_area -name "*.png" | wc -l

如果数量明显不对,先清理目录再跑新任务,省得后续做数据完整性分析时被脏数据干扰。

4. 下载后的三步处理:巡检、拼接、配准

4.1 先做瓦片目录巡检:数量、体积、空洞一次看清

下载任务显示“完成”并不代表成果一定可用。瓦片服务在高并发下有时会返回一个 200 状态码的空文件,或者层级目录缺了一部分。工具一般不会把所有异常都标成失败,因为它只确认“请求完成”,不负责验证“文件有效”。所以我拿到结果后的第一步永远是巡检。

写一个简单的 Python 脚本统计目录状态:

import os def scan_tiles(root, zoom): """ 统计某个缩放级别下的瓦片总数、空文件和异常小文件 """ total = 0 empty = 0 small = 0 base = os.path.join(root, str(zoom)) for x_name in os.listdir(base): x_path = os.path.join(base, x_name) if not os.path.isdir(x_path): continue for y_name in os.listdir(x_path): path = os.path.join(x_path, y_name) total += 1 size = os.path.getsize(path) if size == 0: empty += 1 elif size < 200: small += 1 return {"total": total, "empty": empty, "small": small} print(scan_tiles("./tiles/demo_area", 15))

这里以 200 字节作为异常阈值是一个经验值:正常瓦片压缩后至少几十 KB,只有极少部分纯色块可能压到几 KB 以下,但如果大量文件都在 200 字节以下,基本可以断定下载过程出了问题。巡检发现空洞时,不要重新全量下载,工具一般支持按行列号范围补下,把缺的范围单独列出来跑一次即可。补下后的目录和原目录合并,用skip_existing=true可以跳过已有文件。

4.2 把瓦片拼接成一张大图

有些业务场景不需要保留瓦片目录,而是要一张完整的大图,比如做打印出图或汇报演示。拼接的思路是把某级别下的所有瓦片按照行列号排列到一张大画布上,行列号不连续的位置留白或跳过。直接上代码:

from PIL import Image import os def merge_tiles(tile_root, zoom, x_range, y_range, out_png): """ tile_root: 瓦片根目录, 内部按 zoom/x/y.png 存放 x_range, y_range: 需要拼接的行列号范围 """ tile_size = 256 x_min, x_max = x_range y_min, y_max = y_range width = (x_max - x_min + 1) * tile_size height = (y_max - y_min + 1) * tile_size canvas = Image.new("RGB", (width, height), "white") for x in range(x_min, x_max + 1): for y in range(y_min, y_max + 1): path = os.path.join(tile_root, str(zoom), str(x), f"{y}.png") if not os.path.exists(path): continue tile = Image.open(path).convert("RGB") canvas.paste(tile, ((x - x_min) * tile_size, (y - y_min) * tile_size)) canvas.save(out_png) print(f"已保存 {out_png},尺寸 {canvas.size}")

这段代码的关键点是Image.new用RGB模式而不是默认的RGBA。很多地图瓦片本身带透明通道,直接贴到透明画布上会出现边缘黑线或花屏,白底可以避免大部分问题。tile_size固定为 256,这是绝大多数在线瓦片的标准尺寸。如果下载工具支持 128 或 512 尺寸,记得改这里。

拼接后的图片已经是一张完整栅格图,但此时它还没有任何地理参考,只是一个长方形图片文件,不能直接进 GIS 软件和真实坐标对齐。

4.3 把拼接结果挂上地理参考,转成 GeoTIFF

如果要把成果导入 GIS 工具或叠加其他空间数据,需要把拼接图写成带地理参考的 TIFF。这里不能简单地把百度瓦片当标准 Web Mercator 出图,因为瓦片本身带偏移。常见做法是:先用工具在下载时开启“坐标纠偏”得到 WGS84 偏向的瓦片,再以 EPSG:3857 为输出投影做一次重采样。

用 GDAL 完成这一步比较直接:

from osgeo import gdal, osr def add_georeference(merged_png, out_tif, origin_x, origin_y, res): """ merged_png: 拼接好的大图 origin_x, origin_y: 左上角像素对应的投影坐标 res: 每个像素对应的地面分辨率 """ ds = gdal.Open(merged_png, gdal.GA_ReadOnly) width = ds.RasterXSize height = ds.RasterYSize target_ds = gdal.GetDriverByName("GTiff").Create( out_tif, width, height, ds.RasterCount, ds.GetRasterBand(1).DataType ) target_ds.SetGeoTransform([origin_x, res, 0, origin_y, 0, -res]) srs = osr.SpatialReference() srs.ImportFromEPSG(3857) target_ds.SetProjection(srs.ExportToWkt()) for band_idx in range(1, ds.RasterCount + 1): band = ds.GetRasterBand(band_idx) data = band.ReadAsArray() target_ds.GetRasterBand(band_idx).WriteArray(data) target_ds = None print(f"已生成带坐标的 GeoTIFF: {out_tif}")

origin_x和origin_y来自拼接图左上角那块瓦片左上角像素的真实坐标,计算办法是把边缘瓦片的行列号换算回百度墨卡托坐标。如果你下载时开了纠偏,就直接用 EPSG:3857 的坐标范围;如果没有,需要先做一次偏移纠正,否则叠加到标准底图上会差几百米。这也是很多人下载百度瓦片后“总觉得位置不对”的根本原因。

做完以上三步,一套可交付的离线地图成果才算真正完成。后面第五部分我会专门讲几个容易反复踩的坑。

5. 避坑:瓦片下载里最容易翻车的五个地方

5.1 拼接后出现黑色边缘和花屏

现象:单独打开任意一张瓦片都正常,但拼接完成后,相邻瓦片的接缝处出现黑线、黑边,或者整块泛花。

原因:瓦片本身是带透明通道的 PNG,白色道路和地块是半透明叠加,透明区域在直接粘贴到黑色背景或透明画布时,会被渲染成黑色。

解决:拼接时把画布固定为白色底,读取瓦片后先执行convert("RGB"),把 alpha 通道丢掉。另一点是不要用系统自带看图工具做最终验证,很多看图工具对透明通道处理不规范,最好用 GIS 软件或 Python 重新打开检查。

5.2 大量文件大小为 0 或不足 200 字节

现象:下载任务显示成功,但目录统计发现几百个文件是 0 字节,或者大小只有几十字节。

原因:并发请求过高时触发服务端限流,服务端返回了 200 状态码但没有图片内容;也存在代理服务器在连接中断时生成了截断文件。

解决:先把并发数降到 4 到 8,增加请求间隔。然后启用工具的失败重试和空文件重试机制。如果仍然出现,可以在巡检脚本里加上“文件小于 200 字节自动删除并记录”,再针对这些坐标单独补一次小范围下载。

5.3 下载成果和 GPS 轨迹叠加后整体偏移

现象:瓦片底图显示正常,但把 GPS 轨迹或标准 Web Mercator 数据叠加上去后,整体偏移几百米,有时偏到相邻街区。

原因:百度瓦片使用 BD-09 坐标系,本质上是加密偏移后的坐标;直接按 WGS84 或 EPSG:3857 处理当然对不上。

解决:下载参数里开启坐标纠偏,或者对拼接后的 GeoTIFF 做一次从百度坐标到 WGS84 的转换。需要注意,坐标纠偏不是简单的常数平移,不同城市偏移量不同,必须用算法处理,不要手动选一个偏移量套用全部区域。

5.4 磁盘空间预判不足,任务中途失败

现象:下载到一半磁盘写满,任务中断,日志里大量写入失败记录。

原因:忽略了卫星影像的单张体积,或者同时勾选了十几个缩放级别,实际下载量比预期高出一个数量级。比如一个城区在 17 级往往超过 10 万张瓦片,每张 50KB 就是 5GB 以上,如果范围再大一点很容易爆盘。

解决:下载前先用行列号范围公式估算数量,按每张瓦片最大 100KB 做容量预算。任务配置里限制级别列表,不要全选。给输出目录预留两倍预算空间,因为中间文件可能还要再做拼接。

5.5 断点续传失效,重启任务后从头开始

现象:任务跑到一半手动中断,再次启动后本应跳过已有文件,结果又开始全量扫描或重新下载。

原因:配置里漏开resume和skip_existing,或者输出目录换了新路径,工具识别不到旧文件。

解决:启动前确认配置中resume: true且skip_existing: true,关键是输出目录不要随意更改。工具一般通过输出目录下的元数据文件识别任务,删除或改名都会让它当成全新任务处理。再补充一个习惯:任务中断后不要急着重启,先看日志确认已完成的瓦片目录结构正常,再决定是续跑还是补下。

6. 进阶用法:把瓦片工具接进自动化巡检脚本,交付前跑一次全量自检

工具本身能完成下载,但离“可交付的离线地图包”还差一道质检。我把下载、拼接、巡检三个动作串成一条流水线,每次交付前跑一次自检脚本,专门验证瓦片目录有没有空洞、层级是否齐全、拼接图大小是否符合预期。这步做完再交付,基本能避免“现场打不开、拼接缺块、坐标对不上”这三类事后返工。

巡检脚本核心逻辑是遍历每个缩放级别下的 x 目录,确认每个 x 目录下都有对应的 y 文件:

import os def validate_package(root, zoom_list, expect_ranges): """ zoom_list: 需要校验的级别 expect_ranges: 每个级别预期的行列号范围,和配置文件中一致 """ report = [] for zoom in zoom_list: base = os.path.join(root, str(zoom)) x_min, x_max, y_min, y_max = expect_ranges[zoom] lacking = [] for x in range(x_min, x_max + 1): x_dir = os.path.join(base, str(x)) if not os.path.isdir(x_dir): lacking.append((x, "missing_dir")) continue for y in range(y_min, y_max + 1): if not os.path.exists(os.path.join(x_dir, f"{y}.png")): lacking.append((x, y)) report.append({"zoom": zoom, "lacking_count": len(lacking)}) return report print(validate_package("./tiles/demo_area", [12, 13, 14, 15], {12: (100, 110, 50, 60), 13: (200, 220, 100, 120)}))

这个脚本的价值在于把“主观觉得没问题”变成“数据上确认没问题”。每次调度新任务前,我也会把配置文件和这个脚本一起存到任务目录里,形成一套可追溯的记录。

瓦片下载这类工作,最怕的不是下载失败,而是下载成功但成果不能用。从那以后我每次交付瓦片资源,都强制自己先跑一遍小范围自检,再把巡检脚本挂进任务流程,确认数量、大小、坐标都正常后才做全量。这个习惯救回过不止一次上线前夕的返工,也希望它能帮到你少踩几个坑。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进