
简介离线谷歌瓦片地图资源包面向网页前端开发者和地理信息系统初学者解决内网或弱网环境下无法调用在线地图、需要自建网页地图的问题。压缩包共1938个文件约16.52MB核心数据是1860张jpg瓦片另有43个js脚本负责瓦片拼接、经纬度坐标转换、缩放拖动与标注逻辑还包含示例页面、样式表、光标和标注图标等辅助文件目录结构清晰便于按用途取用。已有2322人学习下载。包内附带重庆地区实例打开示例页面即可在浏览器中直接体验无需后端服务开发时维护瓦片目录和索引即可快速迁移到其他区域。对希望理解瓦片索引、Mercator投影、图像预加载策略的开发者而言这是可运行的参考实现对只需离线展示地图的工程人员也可直接复用对应脚本和瓦片目录快速改造尤其适合在车载、内网或野外场景部署。 有一个场景我相信搞GIS或者做政企项目的朋友都遇到过系统部署在隔离网络里又必须有一张像样的地图底图。在线图源虽然好用但要么连不出去要么带宽和稳定性撑不住再加上数据安全的硬要求业务数据根本不能放到公网地图上。这时候“离线Google瓦片地图”就成了最实际、也最常用的一条路先把在线瓦片按区域和层级缓存到本地再通过内网自建的地图服务发布出来。这篇东西我会把瓦片坐标原理、采集工具、内网部署方案、Web端和Qt端的渲染方式以及我实际踩过的一些坑一次性讲清楚。适合正在做内网GIS、应急指挥、智慧园区这类项目的朋友参考。1. 为什么“离线瓦片”成了内网项目的刚需1.1 内网环境的三个限制先想清楚一个逻辑为什么不是直接把在线地图服务放到内网用因为大多数项目面临的不是“能不能用”的问题而是“能不能稳定、合规、低成本地用”。第一是网络隔离。很多项目部署在专网或内网与外网物理隔离。即便有政务外网访问公网地图服务的带宽和质量也不可控。地图底图动辄就是几百张大图加载慢不说还容易出现连接超时。第二是数据安全。业务系统的点位、轨迹、设备等数据通常不允许出内网。如果底图和业务图层都依赖在线地图服务业务数据的每一次请求都会经过第三方地图服务这在安全评审里基本过不了。第三是稳定性预算。在线瓦片服务一旦波动或接口调整前端页面显示就会出问题。离线之后整个地图通道变成自己管控的静态资源稳定性由自己说了算。这三点放在一起离线瓦片就不只是“备份”这么简单而是一种工程上的确定性保障。1.2 离线瓦片地图的三种交付形态我实际做过的项目里离线瓦片基本逃不出下面三种形态目录式瓦片按 z/x/y.png 的目录结构存放图片用 Nginx 或任意静态文件服务器发布。优点是实现简单、Web 端直接可用缺点是大量小文件对存储和备份不太友好。MBTiles 单文件把瓦片全部塞进一个 SQLite 数据库文件拷贝、分发、更新都方便配合 tileserver-gl 这类工具几秒钟就能起一个瓦片服务。内嵌式瓦片库把瓦片打包进桌面客户端或移动 App例如 Qt 应用内置部分区域瓦片满足无网环境下的基础底图显示。三种形态本质都是“瓦片金字塔”的落地方式。所谓金字塔就是同一块地理范围在不同缩放级别下被切成数量不等的方形小图。级别越低图片数量越少、覆盖范围越宏观级别越高图片数量指数增长细节越丰富。离线化的本质就是把这个金字塔提前完整地“搬”到你的基础设施里。2. 瓦片坐标体系为什么 Google 的规则是事实标准2.1 从 Web Mercator 开始地图瓦片能够离线化核心前提是“每一张瓦片都有唯一且确定的编号”。Google 瓦片的规则是所有在线地图服务里最通用的它把地球投影成一张正方形采用 Web Mercator 投影即将全球映射到一个边长近似地球周长的方形平面上。这个投影下经线是竖直的纬线是水平的方向不会偏适合做地图浏览。在这个方形平面上缩放级别 z 对应的行数和列数都是 2^z所以第 z 级会有 2^z × 2^z 张瓦片。每张瓦片用 (z, x, y) 三个整数唯一定位x 是列号从西到东递增y 是行号从北到南递增。注意地图服务的 y 轴方向是向下的和数学里常见的坐标系是反的很多新手在这里栽过跟头。给定经纬度求 x、y 的标准公式如下n 2^z x floor((lon 180) / 360 * n) latRad lat * PI / 180 y floor((1 - asinh(tan(latRad)) / PI) / 2 * n)这里的 asinh 是反双曲正弦函数。如果环境里不方便调用也可以用log(tan(π/4 latRad/2))替代两者等价。2.2 QuadKeyGoogle 瓦片的另一种“身份证”除了 (z, x, y) 三元组Google 在部分接口里还采用一种叫 QuadKey 的编码。它的算法很简单把 x、y 写成二进制从高位到低位交叉取位得到一串由 0、1、2、3 组成的字符串每个字符代表该瓦片在四叉树中的位置。def tile_to_quadkey(z, x, y): quadkey [] for i in range(z, 0, -1): digit 0 mask 1 (i - 1) if (x mask) ! 0: digit 1 if (y mask) ! 0: digit 2 quadkey.append(str(digit)) return .join(quadkey)反过来把 QuadKey 拆成 x、y 也很容易按位解析即可。你可能问既然已经有了 (z, x, y)为什么还要 QuadKey因为它在 URL 拼接、缓存键设计、还有某些在线瓦片服务的接口里都会出现。如果你只看懂了 XYZ 却认不出 QuadKey调试的时候会一头雾水比如某个瓦片服务返回的资源地址里有一串纯数字字符串那多半就是 QuadKey。2.3 别小看数据量全球全级别瓦片是个天文数字这里给一个粗略的数量级感受缩放级别全球瓦片数预估存储按平均20KB/张0-51,365约27MB0-101,398,101约27GB0-14357,913,941约6.7TB0-1827,487,790,694约512TB看到这个数字就明白了离线瓦片地图基本是“区域离线”不可能像全量下载互联网内容那样把全球高清瓦片都搬回内网。每次做方案的第一步就是跟业务方核实到底要哪个区域、哪几个级别、什么分辨率够用。这些参数直接决定存储预算、下载时长和部署方式。3. 瓦片采集实操圈范围、定级别、下回来3.1 把经纬度边界换算成瓦片范围下载瓦片之前第一件事是把业务方给的经纬度范围例如北京大概在 115.4E-117.5E、39.4N-41.6N换算成每个级别下的行列号范围。写个函数import math def deg_to_tile(lat, lon, z): n 2 ** z x int((lon 180.0) / 360.0 * n) lat_rad math.radians(lat) y int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) return x, y # 以北京大致范围为例 lon_min, lon_max 115.4, 117.5 lat_min, lat_max 39.4, 41.6 z 12 x_min, y_max deg_to_tile(lat_max, lon_min, z) # 北边界用 lat_max x_max, y_min deg_to_tile(lat_min, lon_max, z) # 南边界用 lat_min count (x_max - x_min 1) * (y_max - y_min 1) print(fz{z}: x {x_min}..{x_max}, y {y_min}..{y_max}, 瓦片数约 {count})这里最容易出错的点是 y 方向。因为 y 从北向南递增北纬边界lat_max对应的是较小的 y南纬边界lat_min对应的是较大的 y。初次写脚本时如果搞反下载回来的图就是上下颠倒或者覆盖范围对不上。建议先在一个小区域比如第 3 级验证一下行列号是否落在预期位置再放开跑全量。3.2 自己写下载器还是用现成软件下载瓦片通常有两条路。第一条是直接用现成的地图下载器软件例如 BIGEMAP、水经注这类工具。它们的优点是 UI 齐全、支持框选范围、按级别批量下载、甚至能直接导出成 MBTiles 或不同投影格式适合给非技术背景的同事使用也适合一次性交付需求。缺点是定制度低如果瓦片源 URL 规则复杂或者需要登录鉴权这类软件往往帮不上忙。第二条是写一个简单的 Python 下载脚本。可控性最强想怎么限速、怎么重试、怎么记录日志、怎么断点续传都可以自己定。核心逻辑就三步列出该范围内所有 (z, x, y)拼接瓦片 URL下载到本地对应路径。下面是一个并发下载的最小示例from concurrent.futures import ThreadPoolExecutor import requests, os, time TILE_URL https://your-tile-source/{z}/{x}/{y}.png OUTPUT tiles def fetch_one(item): z, x, y item path f{OUTPUT}/{z}/{x}/{y}.png if os.path.exists(path): return True for attempt in range(3): try: resp requests.get(TILE_URL.format(zz, xx, yy), timeout10) if resp.status_code 200: os.makedirs(os.path.dirname(path), exist_okTrue) with open(path, wb) as f: f.write(resp.content) return True except Exception: time.sleep(2 * (attempt 1)) return False tasks [(z, x, y) for ...] # 按范围展开 with ThreadPoolExecutor(max_workers8) as executor: results list(executor.map(fetch_one, tasks))为什么推荐 8 个线程而不是开到几十上百因为瓦片服务的 QPS 限制通常是隐性的并发太高很容易触发限流甚至封禁 IP。下载瓦片是一场持久战稳定比速度重要。实测中8 线程在大多数源站都能保持满速且不出事遇到 404 或者偶尔超时重试三次加上指数退避就够了。3.3 下完之后先别急着部署下载完一堆瓦片后我一般会做三件事。第一肉眼抽查几个级别下的瓦片确认范围是对的、没有整片的花屏或空图。第二统计每一级实际下载的文件数和理论瓦片数对比。如果差很多说明有大量请求失败被跳过了必须重试补齐。第三把同一区域不同级别的瓦片生成缩略对比图确认金字塔各级之间能逐级放大不会出现“这个级别有了、下个级别空白”的断层。另外瓦片图片格式建议统一。有的源返回 JPEG有的返回 PNG地图控件通常都能兼容但为了后续 MBTiles 打包或 Nginx 缓存配置统一还是先明确只用一种格式比较好。还有一个细节如果源站返回的空白瓦片纯白或纯透明也建议保留否则客户端请求缺失文件时会反复 404影响界面加载。4. 内网发布把瓦片变成标准地图服务4.1 目录式瓦片加 Nginx五分钟上线如果瓦片是 z/x/y.png 目录结构部署最省事的方式是直接交给 Nginx用 alias 指向瓦片根目录即可server { listen 8080; server_name localhost; location /tiles/ { alias /data/tiles/; add_header Access-Control-Allow-Origin *; expires 30d; access_log off; } }这里有两个细节要注意一是 alias 后面的路径必须以 / 结尾否则 Nginx 拼接路径时会报 404二是如果你准备在浏览器页面里直接调用必须加上Access-Control-Allow-Origin头否则前端会触发跨域拦截。如果瓦片文件是静态资源再加上expires缓存头客户端能缓存得很痛快内网访问速度会明显提升。4.2 MBTiles单文件分发更省心文件数量太多的时候单区域达到几十万张图片很常见文件系统遍历、备份、拷贝都会变慢。这时候可以把瓦片全部放进一个 MBTiles 文件里。MBTiles 本质上就是一个 SQLite 数据库里面一张表存放瓦片数据一张表存放元数据图层名称、格式、最大最小级别、坐标系等。有了 MBTiles 文件推荐用 tileserver-gl 或 mbtiles-server 快速起服务。用 Docker 的方式docker run -d -p 8080:80 -v /data/mbtiles:/data -e TILESERVER_CORS* maptiler/tileserver-gl然后浏览器访问 http://内网IP:8080 就能看到内置的瓦片查看器。前端调用时直接请求标准瓦片 URL 模板http://内网IP:8080/data/{z}/{x}/{y}.png。tileserver-gl 还支持把 MBTiles 发布成矢量瓦片如果内网后端需要做空间分析这一步能省不少事。选 MBTiles 还是目录模式主要看下游怎么用。如果是 Web 前端、Qt 桌面端或移动 App 通过 HTTP 加载两种方式在调用层没区别但如果需要经常增量更新我更倾向于目录式改文件既简单又直观如果需要分发给多个兄弟单位统一打包成 MBTiles 更合适一个文件全部带走不怕漏文件。4.3 增量更新该怎么设计离线不等于永远不变。行政边界调整、新建道路、影像更新都可能需要局部刷新。我的做法是把更新做成一个低成本的日常操作下载新区域瓦片后直接覆盖到对应目录或者把新瓦片写入 MBTiles 对应的记录中而不是全量重下。目录式部署的更新非常简单把新瓦片放到对应 z/x/y 路径覆盖即可客户端下次请求自然就是新图。如果是 MBTiles可以写个脚本按坐标范围把新瓦片插入数据库也可以直接把数据库文件重新生成一版再分发。另一个思路是让瓦片服务做“目录优先、MBTiles 兜底”的双层结构更新时只覆盖目录里的文件基础底图则留在 MBTiles 里。这个在 tileserver-gl 里可以通过配置多个源实现不过一般项目用不上这么复杂按需选择就好。5. 客户端渲染Web 端和 Qt 端怎么把瓦片拼出来5.1 Web 端Leaflet 两行代码接上本地瓦片Web 端接离线瓦片是最容易的。以 Leaflet 为例只要把瓦片地址指向内网服务const map L.map(map, { center: [39.9, 116.4], zoom: 12, minZoom: 5, maxZoom: 18 }); L.tileLayer(http://内网IP:8080/tiles/{z}/{x}/{y}.png, { maxZoom: 18, attribution: Offline Tiles }).addTo(map);如果你正在使用高德、百度或腾讯的 SDK 做应用开发要注意它们的 SDK 默认使用自己的在线瓦片服务离线场景不一定支持切换到私有源。这时候常见做法是用开源的 Leaflet、OpenLayers 或 MapLibre 作为前端控件把内网瓦片服务作为底图再叠加业务数据图层。这样既能完全控制图源又不依赖厂商 SDK 的在线鉴权。5.2 Qt 端QGraphicsView 实现瓦片加载桌面端的需求同样常见尤其是“qt使用qgraphicsview显示瓦片地图”这类场景。大体有两种写法一是直接使用 QML 里的地图控件比如 Esri 或 MapLibre 的 Qt 封装二是基于 QGraphicsView 自己拼瓦片。第二种虽然开发量大一点但完全不依赖第三方地图 SDK可控性最强。核心思路是重写 QGraphicsView 的 drawBackground在每次视图窗口变化时计算当前可见区域找出需要加载的瓦片行列号再逐块绘制void MapView::drawBackground(QPainter *painter, const QRectF rect) { QRectF sceneRect mapToScene(viewport()-rect()).boundingRect(); int xMin std::floor(sceneRect.left() / TILE_SIZE); int xMax std::floor(sceneRect.right() / TILE_SIZE); int yMin std::floor(sceneRect.top() / TILE_SIZE); int yMax std::floor(sceneRect.bottom() / TILE_SIZE); for (int x xMin; x xMax; x) { for (int y yMin; y yMax; y) { QPixmap pm tileCache-get(currentZoom, x, y); if (!pm.isNull()) { painter-drawPixmap(x * TILE_SIZE, y * TILE_SIZE, pm); } } } }这里有三个关键点瓦片图片的加载和解码不能放在 UI 线程里建议用 QNetworkAccessManager 异步下载瓦片数据下载完成后再通过信号通知主线程更新同时要做一个 LRU 缓存控制内存中缓存的瓦片数量否则地图一拖动内存就会涨得很快拖动过程中因为异步加载有延迟可以先画占位色块图片到了再刷新局部区域这样体验会顺畅很多。这一套做下来效果和在线地图几乎一致而且完全不受网络环境影响。5.3 高德、百度瓦片到底能不能直接复用这里给你提个醒。Google 瓦片遵循的是标准 Web Mercator 坐标系但国内的高德、腾讯、百度都有自己的坐标偏移体系。高德和腾讯使用 GCJ-02 坐标系百度使用 BD-09 坐标系它们和 WGS-84 之间都会有几米到几百米的偏差。如果在同一张底图上同时叠加在线 Google 瓦片和离线高德瓦片边界就会错位。想兼容多个图源有两个办法一是把所有源统一到同一坐标系下GCJ-02 和 BD-09 可以互相转换百度官方有接口说明二是不要让多个底图叠加而是把业务图层坐标转成底图坐标系保证相对位置不错位。这里的“底图坐标系”从设计瓦片服务那一刻起就应该定好项目后期再改代价非常大。6. 几个最容易被忽略的实战坑6.1 大量小文件会让磁盘先受不了一个中等城市在 z14 级别瓦片数量几乎都在百万片以上。百万级小文件在文件系统里会占用大量 inode拷贝、本文还有配套的精品资源点击获取