ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

地图瓦片爬取工具实战:瓦片原理、坐标系与离线地图下载全攻略

地图瓦片爬取工具实战:瓦片原理、坐标系与离线地图下载全攻略 1. 这个工具到底是干嘛的地图瓦片爬取工具说白了就是把在线地图切成的小图片批量下载到本地。在线地图高德、百度、天地图、OpenStreetMap这些在网页或者App上显示的时候不是一次性给你加载一整张大地图而是把全球地图按照不同缩放级别切成无数张256x256像素的小方块这些方块就是瓦片。你需要哪个区域浏览器就按需请求哪个区域的瓦片拼起来就是一张完整地图。这工具解决的核心问题是在没有网络或者内网隔离的环境下怎么还能用上带地图像底图的业务系统。比如我手上有一个工厂安防平台的项目整个监控中心在物理隔离的内网里外面什么网都连不上但业务上又必须展示厂区周边两公里范围的卫星影像和路网信息。这时候唯一靠谱的路子就是在能上外网的电脑上先把需要的瓦片按区域和层级全部抓下来拷进内网让内网的GIS系统直接读本地文件。这个项目适合谁参考如果你是做GIS开发、可视化大屏、内网部署项目、离线地图应用或者只是想给自己的Web地图加个离线底图这篇内容都适用。我会把从瓦片原理、工具选型、代码实现到踩坑经验整个链路都讲清楚。2. 动手前你必须理解的瓦片机制2.1 瓦片是怎么切出来的在线地图的瓦片体系核心是金字塔模型。最顶层Zoom级别最低通常只有一张或者少数几张瓦片覆盖全球越往下级别越高瓦片数量呈4的倍数增长。每个级别的地图图片都是上一级别把一张瓦片四等分得来的这样上一级的1张瓦片对应下一级的4张瓦片。以Web Mercator投影EPSG:3857为例全球范围是一个正方形在Zoom级别z下横向和纵向各有2^z张瓦片。所以z0时全球是1张瓦片z1时是4张z10时就是1024x1024约104万张z18时每边262144张全部下载下来那就是天文数字了。所以爬取瓦片从来不是把全世界下载下来而是把某个经纬度范围内的某几个层级下载下来。这个范围通常是一个矩形边界对应下来就是一组x编号从xmin到xmax、y编号从ymin到ymax的瓦片集合。2.2 经纬度与瓦片编号的换算公式要准确计算需要下载哪些瓦片必须掌握经纬度WGS84坐标和瓦片xyz编号之间的换算逻辑。这里直接给出我验证过的公式以Web Mercator投影为标准给定经纬度(lon, lat)和缩放级别zn 2^z x floor((lon 180) / 360 * n) y floor((1 - ln(tan(lat_rad) 1/cos(lat_rad)) / π) / 2 * n)其中lat_rad是纬度的弧度值。逆运算由瓦片编号推经纬度范围lon_min x / n * 360 - 180 lon_max (x 1) / n * 360 - 180 lat_max atan(sinh(π * (1 - 2 * y / n))) * 180 / π lat_min atan(sinh(π * (1 - 2 * (y 1) / n))) * 180 / π这个公式在任何一本讲瓦片地图的书里都能找到但实际写代码时我建议不要自己重复造轮子直接用现成的库。我用Python的话会引入mercantile这个库它把所有换算封装好了import mercantile # 根据经纬度范围计算需要的瓦片范围 tiles mercantile.tiles( west116.0, south39.5, east117.0, north40.5, zooms[10, 11, 12] ) for tile in tiles: print(tile.x, tile.y, tile.z)这个库返回的是一个瓦片编号生成器不需要先把所有编号存内存里遍历的时候边算边下载就行千万级别数量也能扛住。2.3 高德、百度、谷歌的瓦片坐标差异不同地图厂商虽然都叫xyz编号但规则并不完全相同这是新手最容易踩坑的地方。高德地图和谷歌地图在Web Mercator投影下原点在左上角x轴从左往右递增y轴从上往下递增这个就是标准的TMSTile Map Service变体通常叫XYZ规范可以直接套公式。高德的瓦片地址格式是https://webrd0X.is.autonavi.com/appmaptile?langzh_cnsize1scale1style8x{x}y{y}z{z}其中style8是路网图style6是卫星图。百度地图就特殊了它的投影不是Web Mercator而是自己搞的百度Mercator且瓦片的原点在经纬度(0, 0)点y轴向上为正。所以百度的瓦片编号不能直接套上面的公式需要先做坐标偏移转换再单独换算。百度的瓦片URL格式https://maponline0{ak}.baidu.com/onlinelite/03/{z}/{x}/{y}.png这里的x和y已经经过百度的加密偏移直接按标准公式算出来是错的。还有天地图它的规则类似高德但需要在请求里带上tokentk参数而且它对并发请求的限制比较严格频繁请求会直接封IP。这里特别提醒一点高德和百度都用了GCJ-02坐标偏移俗称火星坐标系也就是你拿GPS设备采集到的WGS84经纬度如果不做偏移纠偏直接套公式下载下来的瓦片放在地图上会有几百米的偏移比如说你定位一个点结果标到了旁边的街区。所以如果你用的原始坐标是GPS采集的WGS84在高德和百度地图上做瓦片爬取或者做位置标注必须先做坐标系转换WGS84转GCJ-02否则后面所有操作都会带着这个偏移误差。天地图和谷歌地球用的则是WGS84或者加偏后的坐标体系规则又不一样。后面3.3节我还会细说坐标系问题这里你先知道有这回事。3. 工具选型与技术方案3.1 自研还是用现成工具地图瓦片爬取这个需求有不少现成工具比如Mobile Atlas CreatorMOBAC、QMetaTiles、GMapCatcher甚至有的GIS软件自带出图功能。但我实测几轮下来遇到特殊需求时现成工具很难灵活适配典型场景包括需要把瓦片文件按特定目录结构存储与自研的加载程序对接需要指定多级缩放范围并发下载还要断点续传需要给瓦片加自定义的命名规则或做批量拼接需要在下载过程中对瓦片内容做校验比如排除纯色空瓦片我在项目中直接选了自己写Python脚本原因很直接requests库处理HTTP下载足够成熟concurrent.futures做并发也方便加上mercanti le做坐标换算整套方案一两百行代码就能跑通后续要改哪里直接改代码就行。工具选型的逻辑是如果只是临时用一次下载小范围瓦片直接用MOBAC这种图形工具就够了如果要集成到项目里、要自动化、要定制那就自研脚本省事可控。3.2 自研方案的整体架构我设计这个工具时分了四个模块各管一块参数配置模块读取下载范围经纬度或行列号范围、级别数组、并发数、存储路径、瓦片源URL模板瓦片编号计算模块把经纬度范围换算成各级别的瓦片行列号范围生成所有待下载瓦片的坐标列表下载执行模块用线程池并发下载瓦片支持失败重试、超时控制、断点续传校验输出模块检查文件大小、内容类型生成目录索引或统计报告这里有个关键决策千万别把待下载的瓦片坐标一次性全部生成到内存列表里。比如范围覆盖20个级别时瓦片数量可能上亿光把坐标存内存就能撑爆。我用的方案是:存成迭代器或分批生成每处理完一批再生成下一批。3.3 坐标系的坑先在这里说清楚这个问题值得单独拿出来讲。国内地图厂商出于合规要求对公开地图数据做了坐标加偏处理高德和百度用的是GCJ-02坐标系火星坐标百度在GCJ-02基础上还做了一次二次偏移变成了BD-09坐标系。而GPS设备直接输出的坐标通常是WGS84。这意味着什么你拿着手机GPS定位得到经纬度如果直接在高德瓦片上标注位置会发现位置偏移了几十米到几百米不等这是一个必然结果不是程序写错了。同样道理如果你拿高德地图上采集的坐标点套用标准WGS84公式去做下载爬下来的瓦片能看到但叠加自己的业务数据比如设备点位时点位和底图会错位。解决方法是下载前先明确你的业务坐标是什么体系业务坐标是GCJ-02比如从高德JS API里拿到的坐标那你直接按这个经纬度下载高德瓦片完全没问题业务坐标是WGS84比如GPS原始数据那要么下载天地图/谷歌的WGS84瓦片要么就把坐标转成GCJ-02再下高德瓦片WGS84转GCJ-02的算法是公开的网上有标准的Python实现核心是一个基于经纬度偏移的近似计算函数。我不建议自己记公式直接用现成库比如coord-convert这个包一行代码就能完成转换。from coord_convert.transform import wgs2gcj gcj_lng, gcj_lat wgs2gcj(lng, lat)这个坑如果你提前知道能省下大量排查时间。我第一次做的时候没注意结果内网地图上几百个设备点位全部偏移排查了两天才发现是坐标系不一致导致的。4. 核心代码实现与参数解析4.1 下载脚本主流程直接上我在项目里用的代码框架Python 3.8以上版本都可以跑。依赖库只有requests和mercantile并发用的concurrent.futures。import os import math import time import requests from concurrent.futures import ThreadPoolExecutor, as_completed import mercantile class TileDownloader: def __init__(self, url_template, output_dir, concurrency8): :param url_template: 瓦片URL模板用{x}/{y}/{z}占位 :param output_dir: 瓦片保存根目录 :param concurrency: 并发线程数 self.url_template url_template self.output_dir output_dir self.concurrency concurrency self.session requests.Session() self.session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 }) self.success_count 0 self.fail_count 0 self.fail_list [] def download_one(self, tile): 下载单个瓦片 x, y, z tile.x, tile.y, tile.z # 目录层级output_dir/z/x/y.png dir_path os.path.join(self.output_dir, str(z), str(x)) os.makedirs(dir_path, exist_okTrue) file_path os.path.join(dir_path, f{y}.png) # 断点续传文件已存在且大小大于0跳过 if os.path.exists(file_path) and os.path.getsize(file_path) 0: return True url self.url_template.format(xx, yy, zz) for attempt in range(3): try: resp self.session.get(url, timeout10) if resp.status_code 200 and len(resp.content) 100: with open(file_path, wb) as f: f.write(resp.content) return True else: # 空瓦片或异常响应记录一下 self.fail_list.append((x, y, z, resp.status_code)) return False except requests.RequestException as e: if attempt 2: self.fail_list.append((x, y, z, str(e))) return False time.sleep(1 * (attempt 1)) return False def run(self, tiles_iter): 批量下载 with ThreadPoolExecutor(max_workersself.concurrency) as executor: future_map {executor.submit(self.download_one, t): t for t in tiles_iter} for future in as_completed(future_map): tile future_map[future] try: ok future.result() if ok: self.success_count 1 else: self.fail_count 1 except Exception as e: self.fail_count 1 self.fail_list.append((tile.x, tile.y, tile.z, str(e)))这里有几个设计细节值得说一下。首先是把瓦片保存为{output_dir}/{z}/{x}/{y}.png这种目录结构这个不是随便定的Leaflet、OpenLayers、Cesium、QGIS这些主流地图框架在加载本地瓦片时默认找的路径就是这个规则。如果你用别的目录结构后面加载还得写额外的映射逻辑纯属给自己加戏。其次是断点续传的逻辑判断文件存在且大小大于100字节就跳过。为什么要判断大小因为有时候服务器返回的是200状态码但内容是空白的或者是一个错误提示页面这种文件存下来也是坏的。加一个文件大小下限判断能过滤掉大量无效文件。注意这里按100字节判断是考虑到最极端的单色瓦片PNG压缩后也可能比较小但一般不会小于100字节如果你下载高德瓦片时发现大量几KB的文件那基本是正常的。另一个关键点是会话复用用requests.Session而不是每次请求都新建连接。瓦片下载动辄几万几十万次请求如果每次都走完整的TCP握手和TLS握手耗时至少多一倍。Session会自动维护连接池在并发下载时性能提升非常明显。4.2 并发数怎么定并发数是个需要调参的参数不是越大越好。我实测过同一台机器下载高德瓦片的数据并发数下载1万张瓦片耗时是否触发限流4约22分钟否8约12分钟否16约7分钟偶尔出现40332约5分钟频繁403高德的服务端有QPS每秒请求数限制并发太高会直接拒绝服务返回403或者验证码页面。百度稍微宽松一点但也不建议超过16。天地图的限制比较严格官方文档建议每秒不超过2次请求实际用4并发也比较稳。我的经验参数高德和百度用8~12并发天地图用4。如果你是下载谷歌地图的瓦片限制更狠建议先用4并发试探稳定了再慢慢加。4.3 下载范围怎么算在实际项目中用户给你的通常是一块区域的经纬度范围。这里说的经纬度范围通常是从需求文档里抄出来的比如厂区周边5公里。具体怎么把经纬度范围变成瓦片编号范围我在2.2节已经给了换算逻辑。def gen_tiles(west, south, east, north, zooms): 根据经纬度范围和各层级生成瓦片迭代器 for z in zooms: tiles mercantile.tiles(west, south, east, north, [z]) for tile in tiles: yield tile需要注意mercantile.tiles这个函数本身返回的是生成器我不建议在这个基础上再嵌套列表推导式否则内存会出问题。比如一个经纬度范围在z16级别可能对应上千张瓦片z18级别可能上万张看起来不多但如果范围扩大到整个地级市z18级别的瓦片数量就是几百万级全部加载进内存直接卡死。正确的做法是保持生成器链边遍历边下载。我上面4.1里的run方法接收的就是这个生成器forEach循环推进一个下载一个内存占用始终恒定。4.4 瓦片源URL的选择在线瓦片源常见的有高德、百度、天地图、谷歌、OSM这几类。它们的URL各有格式区别高德路网图https://webrd0{1-4}.is.autonavi.com/appmaptile?langzh_cnsize1scale1style8x{x}y{y}z{z}高德卫星图https://webst0{1-4}.is.autonavi.com/appmaptile?style6x{x}y{y}z{z}百度路网图https://maponline0{0-3}.baidu.com/onlinelite/03/{z}/{x}/{y}.png天地图需要网站申请tkhttp://t{0-6}.tianditu.gov.cn/DataServer?Tvec_wX{x}Y{y}L{z}tk你的keyOSM标准地图https://tile.openstreetmap.org/{z}/{x}/{y}.png注意高德URL里的webrd0{1-4}这个子域名是可以轮询的用多个子域名分发请求能有效降低单域名并发压力。你可以把URL模板里直接写死webrd01也可以自己在代码里随机选择子域名实测下来轮询子域名能减少大概20%的限流概率。5. 实操过程全记录5.1 一次完整的下载任务我以下载某个工业园区周边范围的高德路网图为例完整走一遍流程。场景数据范围是经度116.35~116.55纬度39.85~40.05需要在z10到z18共9个级别下全部下载预计瓦片数量约50万张。第一步写配置文件# config.py DOWNLOAD_CONFIG { url_template: https://webrd0{}.is.autonavi.com/appmaptile?langzh_cnsize1scale1style8x{{x}}y{{y}}z{{z}}, subdomains: [1, 2, 3, 4], west: 116.35, south: 39.85, east: 116.55, north: 40.05, zooms: [10, 11, 12, 13, 14, 15, 16, 17, 18], concurrency: 10, output_dir: ./tiles }第二步初始化下载器。from config import DOWNLOAD_CONFIG as cfg from tile_downloader import TileDownloader, gen_tiles downloader TileDownloader( url_templatecfg[url_template], output_dircfg[output_dir], concurrencycfg[concurrency] ) tiles_iter gen_tiles( westcfg[west], southcfg[south], eastcfg[east], northcfg[north], zoomscfg[zooms] ) downloader.run(tiles_iter) print(f成功: {downloader.success_count}, 失败: {downloader.fail_count})第三步等任务跑完。实测50万张瓦片在10并发下大约跑了6个多小时平均每秒下载约23张。跑完之后检查目录结构tiles/ 10/ 808/ 341.png 342.png ... 11/ 1617/ 683.png ...中途有断网或者限流导致的失败瓦片都记在self.fail_list里。脚本跑完后遍历fail_list重新发起下载就行。如果失败数量太大比如超过总数10%不要直接重下先分析失败原因看看是不是被限流了降并发再试。5.2 怎么校验下载结果瓦片下载完了不能直接用先做几项校验文件大小检查。单张瓦片正常在几KB到几十KB之间如果大量瓦片只有几百字节大概率是空瓦片或者错误占位图。写个脚本统计一下基础文件大小分布。文件夹完整性检查。每个z目录下的x子目录数量是否一致每个x子目录下的y文件是否连续从ymin到ymax。如果有缺口说明有漏下的瓦片。抽样目视检查。随便挑几个文件打开看一眼确认图片内容确实是地图不是验证码或者错误页。这个很基础但真有人漏看结果内网加载全是一片空白或者全是请勿频繁访问的提示图。5.3 用Python脚本快速校验瓦片范围用下面的脚本把一个范围内的期望瓦片数和实际文件数对比就能靠谱地判断是否缺瓦片import os import mercantile # 期望范围和级别 ZOOM_LEVELS [10, 11, 12, 13, 14, 15, 16, 17, 18] WEST, SOUTH, EAST, NORTH 116.35, 39.85, 116.55, 40.05 BASE_DIR ./tiles for z in ZOOM_LEVELS: tiles list(mercantile.tiles(WEST, SOUTH, EAST, NORTH, [z])) expect_count len(tiles) actual_count 0 missing [] for tile in tiles: file_path os.path.join(BASE_DIR, str(z), str(tile.x), f{tile.y}.png) if os.path.exists(file_path) and os.path.getsize(file_path) 100: actual_count 1 else: missing.append((tile.x, tile.y)) print(fz{z}: 期望 {expect_count}, 实际 {actual_count}, 缺口 {len(missing)}) if missing[:5]: print(f 前5个缺失: {missing[:5]})这个脚本建议保留每次下载完跑一遍能直观看到哪个级别缺了瓦片。真的缺了也不怕重下时断点续传逻辑会自动跳过已有文件只补缺的。6. 常见问题与排查技巧实录6.1 下载到一半被限流了怎么办高德和百度在检测到单IP请求频率过高时会直接拒绝请求返回403。表现就是下载脚本一开始正常跑了几千张之后大量失败。排查步骤查看失败状态码如果全是403基本确认限流降低并发数比如从16降到8或4在请求之间加随机延时比如time.sleep(random.uniform(0.1, 0.3))切换子域名轮询分散请求如果限流还是没有缓解最稳妥的办法是分段下载。把大范围切成多个小范围分时间段跑每次下载间隔几个小时。比如我今天下西半部分明天再下东半部分这样基本不会被封。6.2 瓦片加载出来地图缺块内网加载时地图上出现棋盘格一样的空白或灰块多半是瓦片文件缺失或者文件损坏。这种情况优先检查失败列表里缺了哪些瓦片然后补下。另外也可能是瓦片源本身在这个级别没有数据比如高德的卫星影像在z18级别的某些区域就是没有高清影像返回的是纯色空瓦片这种下载下来文件大小也很小加载端如果做了空瓦片过滤就不会显示异常。还有一个容易忽略的问题文件名后缀不对。有些瓦片源返回的是jpg格式你保存成了png虽然图片内容能看但某些严格的环境下解析会失败。下载前先确认Content-Type再决定保存的后缀。6.3 下载速度很慢瓦片下载慢的原因主要有三个一是单线程下载这个最简单加并发就能解决。二是网络链路问题比如你连的某个云服务器到瓦片源之间的链路质量差可以考虑换一个网络环境或者用国内服务器跑下载任务。三是瓦片源服务器本身响应慢这种情况换备用源即可。另外提一点如果你下载的地图范围大建议把下载脚本放在离地图源机房近的服务器上跑。比如下载高德瓦片放一台国内阿里云或腾讯云的机器速度比本机快很多因为地理位置近网络延迟低。6.4 瓦片数量和大小超过文件系统限制这个坑真的很隐蔽。当你下载一个省级甚至国家级范围时瓦片文件数量可能达到几千万。而Ext4文件系统默认的inode数量是有限制的如果inode耗尽即使磁盘空间没满也不能再创建新文件了。解决办法下载前用df -i检查inode剩余量用大容量磁盘或把输出目录放到专门的分区如果单目录文件太多适当拆分目录层级另外小文件过多会导致加载和拷贝速度很慢几百万个小文件在机械硬盘上拷贝可能要一整天。有条件的话最好把瓦片打包成专用格式比如MBTilesSQLite数据库格式这样只有一个文件拷贝加载都方便。常见框架都支持直接读MBTiles。6.5 下载的瓦片在Leaflet/Cesium/QT里显示偏移这个问题的根源大概率是坐标系问题。如果你的底图是GCJ-02高德/百度但你用WGS84的坐标系统去加载就会出现偏移。解决办法是确认加载端的坐标系配置是否和瓦片源的坐标系一致不一致就得做转换。比如在Leaflet里加载高德瓦片要设置crs: L.CRS.EPSG3857这个本身没问题关键是你瓦片源的坐标系必须和瓦片地址公式匹配。高德的瓦片地址本身用的就是GCJ-02加偏后的坐标算出来的所以只要你按高德的公式请求瓦片和瓦片之间是自洽的不会错位。但如果你的业务点位用的是WGS84坐标叠加在上面则会偏移这个要在业务数据层面处理比如把点位坐标转成GCJ-02再展示。6.6 内网部署时怎么让加载性能更好离线瓦片图片文件数量庞大几百万个小文件内网Web服务器要高效地提供这些文件有几个优化方向一是开启HTTP缓存对于瓦片这种不变资源缓存命中率极高能显著降低IO压力。二是预压成WebP格式。同质量下WebP比PNG小30%~50%但需要加载端支持或者你在导出时直接转格式。不过老实说这个优先度不高除非你对带宽和存储空间极其敏感。三是用nginx/Apache的gzip压缩响应对PNG收益有限但对SVG和其他文本类瓦片数据有用。四是最关键的加载端尽量做缓存策略用户浏览过的瓦片不再二次请求。Leaflet/OpenLayers默认都有瓦片缓存只要磁盘空间够不用额外调。7. 扩展玩法从静态瓦片到更多应用场景下载瓦片只是第一步这个工具的思路可以派生出一堆衍生用途。比如你可以自己做地图标注平台把下载好的瓦片作为底图在上面叠加业务图层。我在一个项目中就是在离线瓦片基础上叠加了管线、阀门、摄像头等设备的SVG标注配合自研的H5页面做交互效果接近在线地图体验。再比如你想做历史影像对比。有的瓦片源提供了历史影像你可以把同一区域不同时间的瓦片都下载下来做滑动对比或者分屏对比这个在很多城市规划项目中非常实用。还可以把瓦片拼接成一张大的完整地图图片用于打印或离线查看。用PIL库把相邻瓦片按照行列号拼成大图配合2.3节的逆运算公式计算图片对应的经纬度范围输出成GeoTIFF还能在CAD或者ArcGIS里直接叠加。编程能力稍强的朋友还可以把瓦片下载做成服务接口输入一个经纬度范围和级别后台自动异步下载完成后发通知。这样前端只需要一个简单页面填参数就能自助申请瓦片数据省去每次人工跑脚本。8. 实操心得总结最后分享一些我这几年跑瓦片下载项目攒下来的实操体会。第一下载之前先想清楚坐标系这是整个项目里最便宜但回报最高的一步。花10分钟确认你的业务坐标是什么体系、瓦片源是什么体系、需不需要转换能省下后面几个通宵排查的时间。第二目录结构一定要按{z}/{x}/{y}.png来组织。几乎所有主流地图库都认这个结构就算你暂时不需要加载瓦片将来要接也方便。第三不要一上来就追求大并发。默认8并发加子域名轮询足够应对绝大多数场景。真的遇到大范围下载被限流的情况宁可分批分时段跑也别把IP搞封了一旦封禁可能要等几小时甚至一天才能恢复更耽误事。第四下载完成后一定要校验校验脚本必须保留。很多人下载完直接拷贝进内网结果内网环境里加载一看缺了十几万张瓦片那时候再想补下外网和内网的物理通道早就拆了只能重新来。第五量大的瓦片任务一定要考虑后续使用方的加载性能文件碎片化严重虽然能用但体验差优先考虑打成MBTiles等单文件格式。这个工具看起来简单但里面的门道不少。希望这篇文章能帮你少踩几个坑如果后续你在实际使用中有其他问题或者发现了更好的瓦片源欢迎随时交流。
返回列表