Python实现天地图瓦片坐标与经纬度高效转换实战
1. 天地图瓦片坐标转换:从入门到精通
如果你正在用Python处理地图数据,尤其是想从天地图这类在线地图服务上获取卫星影像或电子地图,那你肯定绕不开一个核心问题:如何把现实世界中的经纬度坐标,转换成地图服务器能听懂的“瓦片行列号”。这就像你想在图书馆找一本书,只知道书名(经纬度)不行,你得知道它在哪个房间、哪个书架、哪一层(瓦片坐标),管理员才能帮你取出来。
我刚开始接触这块的时候,也是一头雾水。网上资料要么太理论,满篇的墨卡托投影公式;要么代码太零碎,跑起来一堆问题。后来在几个实际的地理信息系统项目中摸爬滚打,才把这套转换逻辑彻底搞明白。今天,我就把自己踩过的坑和总结出的高效方法,用最直白的话分享给你。你会发现,天地图的瓦片坐标转换,核心就是几个简单的数学公式,用Python实现起来非常优雅。无论是想批量下载某个区域的卫星图,还是想在地图上精准叠加自己的数据,掌握这个技能都是第一步。
简单来说,这个过程就是**“翻译”**。我们把人类熟悉的经纬度(比如北京天安门:116.3975, 39.9087),翻译成计算机和地图服务器约定的瓦片网格系统中的行号和列号。天地图、谷歌地图、高德地图等主流在线地图,都采用类似的瓦片金字塔模型。地图被按照不同的缩放级别(Zoom Level)切割成无数个256x256像素的小图片,也就是“瓦片”。你的任务,就是根据给定的经纬度和缩放级别,快速算出这个点落在哪个瓦片上,或者一个区域覆盖了哪些瓦片。接下来,我们就手把手实现这个“翻译官”。
2. 核心原理:瓦片地图系统与转换公式拆解
在写代码之前,我们得先花几分钟搞懂背后的游戏规则。如果你完全没概念,可以想象一下地球仪和方格本。
2.1 瓦片金字塔模型是什么?
想象一下,你有一张世界地图。在缩放级别为0时,整个世界只用一张256x256像素的图片来表示,这就是最顶层的一张大瓦片。当你把地图放大一倍(缩放级别变为1),这张世界地图就被切成了2行×2列,总共4张瓦片,每张还是256x256像素,但能显示更多细节。继续放大到级别2,就变成了4行×4列,16张瓦片……以此类推。
缩放级别(Zoom Level,常记为z) 每增加1,横向和纵向的瓦片数量就翻一倍。所以,在某个缩放级别z下,整个地图被切分成 2^z 行 × 2^z 列瓦片。天地图的最大级别通常是18,这意味着在最高清的情况下,全球被分成了超过26万行、26万列,总共近700亿个瓦片!当然,海洋和无人区很多瓦片是空白的或者纯色的。
2.2 经纬度如何映射到瓦片坐标?
这是最关键的一步。地图是平的(屏幕),但地球是球体。需要一种方法把球面上的点“投影”到平面上。在线地图最常用的是 Web墨卡托投影。天地图使用的正是这种投影。
对于Web墨卡托投影,有一个非常经典的公式,可以将经纬度直接换算为瓦片坐标系统中的“像素坐标”,再进一步换算为瓦片行列号。我们一步步来:
-
将经度转换为像素X坐标:这个相对简单。经度范围是[-180, 180]。我们把它映射到[0, 256 * 2^z]这个像素范围。
pixelX = ((longitude + 180) / 360) * (256 * 2^z) -
将纬度转换为像素Y坐标:纬度范围是[-85.0511, 85.0511](Web墨卡托的有效范围,两极被截断了)。转换公式涉及三角函数,因为它不是线性关系:
lat_rad = latitude * pi / 180// 纬度转弧度pixelY = (1 - log(tan(lat_rad) + 1/cos(lat_rad)) / pi) / 2 * (256 * 2^z) -
从像素坐标到瓦片行列号:知道了像素坐标,除以每个瓦片的边长(256像素),再取整,就得到了瓦片坐标(列号x和行号y)。
tileX = floor(pixelX / 256)tileY = floor(pixelY / 256)
这里的 tileX 和 tileY 就是我们要的瓦片行列号,从0开始计数。
2.3 天地图的“分辨率”字典法
你可能在网上看到过另一种天地图专用的方法,就像原始文章里给出的那个 resolution 字典。它没有直接使用 2^z 计算,而是预先定义好了每个层级下,地图上1像素代表多少度(分辨率)。这种方法本质上和上述公式是等价的,只是换了一种计算形式。
它的逻辑是:在某个层级z,地图的宽度(360度)被均分成了 (256 * 2^z) 个像素。那么每个像素代表的经度(即分辨率)就是 360 / (256 * 2^z)。文章里的 resolution 字典就是预先算好了这个值。这样,转换公式就变成了: tileX = floor((经度 + 180) / (resolution * 256)) tileY = floor((90 - 纬度) / (resolution * 256)) // 注意这里是90-纬度,因为瓦片行号是从上往下数的。
这两种方法结果完全一致。字典法的好处是省去了每次计算 2^z 的步骤,对于固定层级的批量计算,直接查表可能稍微快一丁点。下面我们用代码把这两种方法都实现一下,你可以对比看看。
3. 实战:两种Python转换方法代码实现
理论说再多不如跑行代码。我们创建一个Python文件,比如叫 tile_converter.py,开始动手。
3.1 方法一:使用标准公式(通用性强)
这是最通用、最推荐的方法,适用于任何遵循Slippy Map规范(即OpenStreetMap、谷歌、天地图等使用的标准)的地图服务。
import math
def latlon_to_tile_standard(lat, lon, zoom):
"""
使用标准公式将经纬度转换为瓦片行列号。
适用于大多数在线地图(天地图、OSM、谷歌等)。
Args:
lat (float): 纬度,范围[-85.0511, 85.0511]
lon (float): 经度,范围[-180, 180]
zoom (int): 缩放级别,通常0-18
Returns:
tuple: (tile_x, tile_y) 瓦片的列号和行号
"""
# 将经度转换为瓦片列号x
n = 2.0 ** zoom
tile_x = int((lon + 180.0) / 360.0 * n)
# 将纬度转换为瓦片行号y(公式稍复杂)
lat_rad = math.radians(lat)
tile_y = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n)
return tile_x, tile_y
# 我们来测试一下天安门广场的坐标(WGS84坐标系)
beijing_lat, beijing_lon = 39.9087, 116.3975
zoom_level = 15
tile_x, tile_y = latlon_to_tile_standard(beijing_lat, beijing_lon, zoom_level)
print(f"方法一(标准公式)计算结果:")
print(f" 缩放级别 {zoom_level} 下,经纬度 ({beijing_lat}, {beijing_lon})")
print(f" 对应的瓦片坐标是: x={tile_x}, y={tile_y}")
运行这段代码,你会得到类似 x=26796, y=12462 这样的结果。这个坐标就是天地图服务器上存储对应瓦片的唯一标识。
3.2 方法二:使用天地图分辨率字典(直接高效)
这是原始文章中使用的方法,更直接地针对天地图的参数。我们把它优化成一个更健壮的函数。
def latlon_to_tile_tianditu(lat, lon, zoom):
"""
使用天地图分辨率字典,将经纬度转换为瓦片行列号。
此方法直接对应天地图的切片规则。
Args:
lat (float): 纬度
lon (float): 经度
zoom (int): 缩放级别 (1-18)
Returns:
tuple: (tile_x, tile_y)
"""
# 天地图各级别分辨率(度/像素),与原始文章一致
resolution_dict = {
1: 0.703125,
2: 0.3515625,
3: 0.17578125,
4: 0.087890625,
5: 0.0439453125,
6: 0.02197265625,
7: 0.010986328125,
8: 0.0054931640625,
9: 0.00274658203125,
10: 0.001373291015625,
11: 0.0006866455078125,
12: 0.00034332275390625,
13: 0.000171661376953125,
14: 8.58306884765629E-05,
15: 4.29153442382814E-05,
16: 2.1457672119140625E-05,
17: 1.07288360595703E-05,
18: 5.36441802978515E-06
}
if zoom not in resolution_dict:
raise ValueError(f"不支持的缩放级别 {zoom},天地图通常支持1-18级。")
res = resolution_dict[zoom]
# 计算瓦片列号x
tile_x = int(math.floor((lon + 180.0) / (res * 256)))
# 计算瓦片行号y。注意:瓦片坐标系原点在左上角,纬度越大行号越小。
tile_y = int(math.floor((90.0 - lat) / (res * 256)))
return tile_x, tile_y
# 用同样的坐标测试
tile_x_tdt, tile_y_tdt = latlon_to_tile_tianditu(beijing_lat, beijing_lon, zoom_level)
print(f"\n方法二(天地图字典)计算结果:")
print(f" 缩放级别 {zoom_level} 下,经纬度 ({beijing_lat}, {beijing_lon})")
print(f" 对应的瓦片坐标是: x={tile_x_tdt}, y={tile_y_tdt}")
# 验证两种方法结果是否一致
print(f"\n两种方法结果是否一致? {tile_x == tile_x_tdt and tile_y == tile_y_tdt}")
运行后,你会发现两种方法计算出的 x 和 y 是完全相同的。这证明了它们本质是一回事。字典法里的 (90.0 - lat) 其实就是标准公式中三角函数计算结果的另一种表达,因为天地图瓦片行号是从北向南(从上到下)递增的。
3.3 处理一个矩形区域
实际项目中,我们很少只下载一个点,更多的是下载一个矩形区域(比如一个行政区划)的所有瓦片。这就需要我们计算这个区域覆盖的瓦片范围。
def get_tile_range(lat1, lon1, lat2, lon2, zoom):
"""
计算给定矩形区域(由左上角和右下角经纬度定义)在指定层级下覆盖的瓦片行列号范围。
Args:
lat1, lon1 (float): 矩形区域左上角(西北角)的纬度和经度
lat2, lon2 (float): 矩形区域右下角(东南角)的纬度和经度
zoom (int): 缩放级别
Returns:
dict: 包含最小/最大列号、行号以及总瓦片数的字典
"""
# 获取左上角和右下角坐标对应的瓦片
tile_top_left = latlon_to_tile_standard(lat1, lon1, zoom)
tile_bottom_right = latlon_to_tile_standard(lat2, lon2, zoom)
# 确定行列号的范围(注意:行号y,从上到下增加,所以左上角的y值更小)
min_x = min(tile_top_left[0], tile_bottom_right[0])
max_x = max(tile_top_left[0], tile_bottom_right[0])
min_y = min(tile_top_left[1], tile_bottom_right[1])
max_y = max(tile_top_left[1], tile_bottom_right[1])
total_tiles = (max_x - min_x + 1) * (max_y - min_y + 1)
return {
'min_x': min_x,
'max_x': max_x,
'min_y': min_y,
'max_y': max_y,
'total_tiles': total_tiles,
'tiles_wide': max_x - min_x + 1,
'tiles_high': max_y - min_y + 1
}
# 测试:计算故宫博物院大致范围的瓦片(一个较小的矩形)
# 左上角(西北角): 39.923, 116.390
# 右下角(东南角): 39.915, 116.405
palace_range = get_tile_range(39.923, 116.390, 39.915, 116.405, 16)
print(f"\n故宫区域在级别16下的瓦片覆盖情况:")
print(f" 列号范围: {palace_range['min_x']} 到 {palace_range['max_x']}")
print(f" 行号范围: {palace_range['min_y']} 到 {palace_range['max_y']}")
print(f" 总共需要下载 {palace_range['total_tiles']} 张瓦片 ({palace_range['tiles_wide']}列 x {palace_range['tiles_high']}行)")
这个函数非常实用,它能告诉你下载某个区域需要请求多少张瓦片图片,为后续的批量下载任务提供了精确的循环边界。
4. 逆向操作:从瓦片坐标反推经纬度
有来有回才完整。有时候我们拿到一张瓦片,想知道它覆盖的地理范围是什么,或者根据瓦片坐标反推其左上角的经纬度。这个逆向过程同样重要。
4.1 单张瓦片的地理范围计算
每张瓦片都是一个256x256像素的正方形,对应地球上的一块矩形区域。我们可以根据瓦片坐标和缩放级别,计算出这块区域的四至(左上角和右下角的经纬度)。
def tile_to_latlon(tile_x, tile_y, zoom):
"""
根据瓦片行列号和缩放级别,计算该瓦片左上角(西北角)的经纬度。
Args:
tile_x (int): 瓦片列号
tile_y (int): 瓦片行号
zoom (int): 缩放级别
Returns:
tuple: (latitude, longitude) 瓦片左上角的纬度和经度
"""
n = 2.0 ** zoom
# 计算经度
lon_deg = tile_x / n * 360.0 - 180.0
# 计算纬度(需要反解那个三角函数方程)
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * tile_y / n)))
lat_deg = math.degrees(lat_rad)
return lat_deg, lon_deg
def tile_bounds(tile_x, tile_y, zoom):
"""
计算给定瓦片覆盖的完整地理范围(四至)。
Args:
tile_x, tile_y, zoom: 同上
Returns:
dict: 包含西北角、东北角、西南角、东南角经纬度的字典
"""
# 左上角(西北角)
nw_lat, nw_lon = tile_to_latlon(tile_x, tile_y, zoom)
# 右下角(东南角)是下一张瓦片的左上角
se_lat, se_lon = tile_to_latlon(tile_x + 1, tile_y + 1, zoom)
return {
'northwest': (nw_lat, nw_lon),
'northeast': (nw_lat, se_lon), # 经度用右下角的,纬度用左上角的
'southwest': (se_lat, nw_lon), # 纬度用右下角的,经度用左上角的
'southeast': (se_lat, se_lon),
'center': ((nw_lat + se_lat) / 2, (nw_lon + se_lon) / 2)
}
# 测试:反推我们之前计算出的天安门瓦片的地理范围
test_tile_x, test_tile_y = tile_x, tile_y # 沿用之前计算的结果
bounds = tile_bounds(test_tile_x, test_tile_y, zoom_level)
print(f"\n瓦片 ({test_tile_x}, {test_tile_y}) 在级别 {zoom_level} 下的地理范围:")
print(f" 西北角(左上): {bounds['northwest'][0]:.6f}, {bounds['northwest'][1]:.6f}")
print(f" 东南角(右下): {bounds['southeast'][0]:.6f}, {bounds['southeast'][1]:.6f}")
print(f" 中心点: {bounds['center'][0]:.6f}, {bounds['center'][1]:.6f}")
运行后,你会看到这个瓦片覆盖的经纬度范围非常小,大概只有百分之几度。这也解释了为什么高清地图需要那么多瓦片——每个瓦片只负责显示地球表面极小的一块区域。
4.2 验证闭环:正向转换再逆向回来
一个好的转换函数应该是可逆的。我们可以做一个简单的验证:把一个经纬度转换成瓦片坐标,再把这个瓦片坐标转换回其左上角的经纬度,看看这个经纬度是否落在原始经纬度所在的瓦片范围内。
def verify_conversion_roundtrip(lat, lon, zoom):
"""验证正向转换和逆向转换的一致性。"""
print(f"\n--- 转换闭环验证 (级别 {zoom}) ---")
print(f"原始坐标: ({lat}, {lon})")
# 正向:经纬度 -> 瓦片
tx, ty = latlon_to_tile_standard(lat, lon, zoom)
print(f"所在瓦片: ({tx}, {ty})")
# 逆向:瓦片 -> 该瓦片左上角经纬度
lat_topleft, lon_topleft = tile_to_latlon(tx, ty, zoom)
print(f"瓦片左上角: ({lat_topleft:.6f}, {lon_topleft:.6f})")
# 再正向:左上角经纬度 -> 瓦片(应该得到同一个瓦片)
tx2, ty2 = latlon_to_tile_standard(lat_topleft, lon_topleft, zoom)
print(f"左上角坐标所在瓦片: ({tx2}, {ty2})")
print(f"瓦片坐标是否一致? {tx == tx2 and ty == ty2}")
# 计算原始点相对于瓦片左上角的像素偏移(可选,用于精确定位)
n = 2.0 ** zoom
pixel_x = ((lon + 180.0) / 360.0 * n) * 256
pixel_y = ((1.0 - math.log(math.tan(math.radians(lat)) + 1/math.cos(math.radians(lat))) / math.pi) / 2.0 * n) * 256
offset_x = pixel_x - tx * 256
offset_y = pixel_y - ty * 256
print(f"点在瓦片内的像素位置: ({offset_x:.1f}, {offset_y:.1f})")
# 执行验证
verify_conversion_roundtrip(beijing_lat, beijing_lon, zoom_level)
这个验证能帮你深刻理解转换过程的准确性。你会发现,原始坐标转换得到的瓦片,其左上角坐标再转换回来,确实落在同一个瓦片内。而计算出的像素偏移,则告诉你这个点在这张256x256图片中的具体位置。
5. 性能优化与实战技巧
当你要处理成千上万个瓦片时,效率就变得很重要了。这里分享几个我实战中总结的优化技巧和注意事项。
5.1 避免重复计算,使用缓存或向量化
如果你需要频繁地为同一缩放级别计算不同经纬度的瓦片,那么预先计算 2 ** zoom 或者使用分辨率字典能避免重复的幂运算。对于批量转换,强烈建议使用NumPy进行向量化计算,这比用for循环快成百上千倍。
import numpy as np
def batch_latlon_to_tile(lats, lons, zoom):
"""
批量将经纬度数组转换为瓦片坐标(使用NumPy向量化计算,极快)。
lats, lons 可以是列表或NumPy数组。
"""
lats = np.array(lats, dtype=np.float64)
lons = np.array(lons, dtype=np.float64)
n = 2.0 ** zoom
# 计算瓦片列号x
tile_xs = np.floor((lons + 180.0) / 360.0 * n).astype(int)
# 计算瓦片行号y
lat_rad = np.radians(lats)
tile_ys = np.floor((1.0 - np.log(np.tan(lat_rad) + 1.0 / np.cos(lat_rad)) / np.pi) / 2.0 * n).astype(int)
return tile_xs, tile_ys
# 示例:批量转换10000个随机点
np.random.seed(42)
num_points = 10000
random_lats = np.random.uniform(-85, 85, num_points) # 在有效纬度范围内
random_lons = np.random.uniform(-180, 180, num_points)
zoom = 10
import time
start = time.time()
batch_x, batch_y = batch_latlon_to_tile(random_lats, random_lons, zoom)
end = time.time()
print(f"\n向量化批量转换 {num_points} 个点,耗时: { (end-start)*1000:.2f} 毫秒")
print(f"前5个结果示例:")
for i in range(5):
print(f" ({random_lats[i]:.2f}, {random_lons[i]:.2f}) -> ({batch_x[i]}, {batch_y[i]})")
5.2 处理边界情况与坐标纠偏
边界情况:当经纬度正好落在瓦片边界上时,floor 取整函数的行为是确定的。但如果你要下载一个矩形区域的所有瓦片,记得使用 floor 计算左上角瓦片,使用 ceil 计算右下角瓦片(就像原始文章里那样),以确保完全覆盖该区域。
坐标纠偏:这是一个非常重要的点!国内的地图服务,如高德、百度,出于合规考虑,使用的是 GCJ-02坐标系(火星坐标系),它是在WGS84坐标系(GPS标准坐标系)基础上加入非线性偏移的。而天地图使用的是标准的WGS84坐标系。这意味着:
- 如果你用手机GPS采集的坐标(WGS84)去请求天地图瓦片,位置是准的。
- 但如果你用高德或百度地图API获取的坐标(GCJ-02)直接去请求天地图瓦片,会发现位置有几百米的偏移。
所以,在混合使用不同数据源时,务必进行坐标转换。网上有开源的WGS84与GCJ-02互转的库(如 coordtransform),但使用时请注意相关合规要求。本文所有示例均基于WGS84坐标系,这也是天地图所采用的。
5.3 构建完整的瓦片下载URL
计算出瓦片坐标后,最终目的是为了从服务器获取图片。天地图提供了不同类型的瓦片服务,URL模板如下:
- 影像瓦片(卫星图):
http://t[0-7].tianditu.gov.cn/DataServer?T=img_w&x={x}&y={y}&l={z}&tk=您的密钥 - 矢量瓦片(电子地图):
http://t[0-7].tianditu.gov.cn/DataServer?T=vec_w&x={x}&y={y}&l={z}&tk=您的密钥 - 地形瓦片:
http://t[0-7].tianditu.gov.cn/DataServer?T=ter_w&x={x}&y={y}&l={z}&tk=您的密钥
注意:
t0到t7是负载均衡服务器,可以随机选用或轮流使用。{x},{y},{z}分别对应我们计算出的列号、行号、缩放级别。tk参数需要替换为你自己在天地图官网申请的服务密钥(Token)。没有密钥的话,请求次数会受限。
下面是一个简单的下载单张瓦片的函数示例:
import requests
from PIL import Image
import io
def download_tianditu_tile(tile_x, tile_y, zoom, tile_type='img_w', token='你的天地图令牌'):
"""
下载指定的一张天地图瓦片。
Args:
tile_x, tile_y, zoom: 瓦片坐标和级别
tile_type: 瓦片类型,'img_w'(影像),'vec_w'(矢量),'ter_w'(地形)
token: 天地图API访问令牌
Returns:
PIL.Image.Image: 瓦片图像对象
"""
# 随机选择一个服务器,平衡负载
import random
server_num = random.randint(0, 7)
url_template = f"http://t{server_num}.tianditu.gov.cn/DataServer?T={tile_type}&x={tile_x}&y={tile_y}&l={zoom}&tk={token}"
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
try:
response = requests.get(url_template, headers=headers, timeout=10)
response.raise_for_status() # 检查请求是否成功
image_data = io.BytesIO(response.content)
img = Image.open(image_data)
return img
except requests.exceptions.RequestException as e:
print(f"下载瓦片 ({tile_x}, {tile_y}) 失败: {e}")
return None
# 注意:运行前请将 '你的天地图令牌' 替换为真实有效的令牌
# img = download_tianditu_tile(tile_x, tile_y, zoom_level, token='your_real_token_here')
# if img:
# img.show() # 显示图片
# img.save(f'tile_{zoom_level}_{tile_x}_{tile_y}.jpg')
5.4 多线程异步下载与拼接
下载一个区域的瓦片往往是IO密集型任务(等待网络响应)。使用多线程或异步IO可以极大提升下载速度。你可以使用Python的 concurrent.futures.ThreadPoolExecutor 来并发下载。
下载完成后,你需要根据瓦片的行列号,将它们拼接成一张完整的大图。这需要你知道每个瓦片在其所属网格中的位置。通常的做法是:按行下载所有瓦片,将一行内的瓦片水平拼接(np.hstack 或 PIL.Image 的 paste),然后将所有行再垂直拼接(np.vstack)。原始文章中的 Write_image 函数就演示了这个过程,但其中使用了递归重试,在实际大量下载时,建议增加更完善的错误处理和重试机制,并注意控制并发数,避免对服务器造成过大压力。
我在实际项目里,会先把需要下载的所有瓦片的URL生成到一个列表里,然后用一个固定线程池(比如20个线程)去并发下载,并保存到本地以 z_x_y.jpg 的格式命名。拼接时,再根据文件名中的x, y信息排序和定位。这样即使中途失败,也容易断点续传。
更多推荐


所有评论(0)