地理空间 3D 数据实战:Shapefile 解析、坐标变换与地理参考模型构建
一句话总结
将 2D 地理矢量数据(Shapefile)与 3D 几何结合,构建可导入 Google Earth、Cesium 等平台的地理参考 3D 城市场景——同时理解为什么坐标系是这个领域最大的坑。
为什么这个问题重要?
Google Earth 近期新增了对 Shapefile 和 3D 模型直接导入的支持。这看起来只是一个 UI 功能,背后却涉及一个严肃的工程问题:如何将 2D 地理矢量数据正确地”立体化”并精确放置在地球上?
应用场景
- 城市数字孪生:建筑轮廓(Shapefile)+ 楼层属性 → 3D 城市模型
- 基础设施管理:管线、道路的 3D 可视化分析
- 应急响应:叠加地形、建筑和实时传感器数据的态势感知
现有方案的痛点
| 问题 | 实际影响 |
|---|---|
| 坐标系混乱(EPSG:4326 vs EPSG:3857) | 数据叠加错位数百米 |
| 2D Shapefile 缺乏高度信息 | 需要额外数据源补充 |
| 百万级要素无法实时渲染 | 必须做 LOD 和瓦片化 |
| 3D 模型缺少地理坐标 | 无法与地图数据对齐 |
背景知识
Shapefile 格式剖析
Shapefile 是 ESRI 设计的地理矢量格式,本质上是一组文件的集合:
buildings.shp ← 几何形状(顶点坐标序列)
buildings.shx ← 形状索引(支持随机访问)
buildings.dbf ← 属性表(dBASE IV 格式)
buildings.prj ← 坐标参考系(WKT 描述)
buildings.cpg ← 字符编码声明(防止中文乱码)
几何类型:Point、Polyline、Polygon,以及带高度的 PolygonZ/PolylineZ。
坐标参考系(CRS)——这是最大的坑
- EPSG:4326(WGS84):经纬度,单位为度,Google Earth 原生格式
- EPSG:3857(Web Mercator):单位为米,Google Maps/Web 地图使用
- EPSG:326xx(UTM 带):局部高精度,适合距离和面积计算
核心原则:数据处理用投影坐标系(米为单位),最终输出转回地理坐标系(经纬度)。
核心方法
直觉解释
Shapefile(2D轮廓 + 属性表)
↓
坐标系统一(→ WGS84)
↓
局部米制坐标变换(→ ENU)
↓
高度属性提取 + 无效几何修复
↓
2D Polygon → 3D 拉伸网格
↓
KMZ / glTF 导出 → Google Earth / Cesium
核心几何操作:将平面多边形沿 Z 轴拉伸 $h$ 米,生成封闭的棱柱体网格(Extrusion)。
数学细节
设建筑物地面轮廓顶点为 ${p_i} \in \mathbb{R}^2$,拉伸高度为 $h$。
底面顶点:$v_i^{b} = (p_i^x,\ p_i^y,\ 0)$,顶面顶点:$v_i^{t} = (p_i^x,\ p_i^y,\ h)$
侧面三角化(对每条边 $(i,\ i+1)$):
\[\text{Face}_i = \{(v_i^b,\ v_{i+1}^b,\ v_i^t),\quad (v_{i+1}^b,\ v_{i+1}^t,\ v_i^t)\}\]WGS84 → 局部 ENU 坐标变换(以参考点 $\mathbf{o}$ 为原点):
\[\mathbf{p}_{ENU} = R_{ECEF \to ENU}(\mathbf{o}) \cdot \left(\mathbf{p}_{ECEF} - \mathbf{o}_{ECEF}\right)\]在城市级别场景(<50km),用方位角等距投影(AEQD)近似此变换,精度误差 <0.1%。
实现
环境配置
pip install geopandas fiona pyproj shapely trimesh simplekml open3d
Shapefile 读取与高度属性提取
import geopandas as gpd
import pandas as pd
from shapely.validation import make_valid
def load_buildings(shp_path: str) -> gpd.GeoDataFrame:
"""加载建筑物 Shapefile,统一坐标系并提取高度"""
gdf = gpd.read_file(shp_path)
# 转换为 WGS84(Google Earth 要求)
gdf_wgs84 = gdf.to_crs("EPSG:4326")
# 修复无效几何(OSM 数据中约 5-10% 存在自相交问题)
gdf_wgs84["geometry"] = gdf_wgs84.geometry.apply(
lambda g: make_valid(g) if not g.is_valid else g
)
# 过滤面积过小的噪声要素(测量误差产生的碎片)
utm_crs = gdf_wgs84.estimate_utm_crs()
area_m2 = gdf_wgs84.to_crs(utm_crs).geometry.area
gdf_wgs84 = gdf_wgs84[area_m2 > 1.0].copy()
# 尝试多种常见高度字段名
height_candidates = ["height", "HEIGHT", "HOEHE", "building:height"]
floor_candidates = ["floors", "FLOORS", "building:levels"]
height_col = next((c for c in height_candidates if c in gdf_wgs84.columns), None)
floor_col = next((c for c in floor_candidates if c in gdf_wgs84.columns), None)
if height_col:
gdf_wgs84["height_m"] = pd.to_numeric(gdf_wgs84[height_col], errors="coerce")
elif floor_col:
gdf_wgs84["height_m"] = pd.to_numeric(gdf_wgs84[floor_col], errors="coerce") * 3.0
# 缺失高度用 10m 填充(城市平均单层建筑高度)
gdf_wgs84["height_m"] = gdf_wgs84.get("height_m", pd.Series(dtype=float)).fillna(10.0)
gdf_wgs84["height_m"] = gdf_wgs84["height_m"].clip(lower=1.0, upper=500.0)
print(f"加载完成: {len(gdf_wgs84)} 个建筑物, "
f"高度范围 {gdf_wgs84['height_m'].min():.0f}m - {gdf_wgs84['height_m'].max():.0f}m")
return gdf_wgs84
WGS84 → 局部 ENU 坐标变换
import pyproj
def to_local_enu(gdf_wgs84: gpd.GeoDataFrame) -> tuple[gpd.GeoDataFrame, tuple]:
"""
将 WGS84 坐标转为以研究区重心为原点的局部米制坐标系
返回: (局部坐标系GeoDataFrame, (lon0, lat0) 原点坐标)
使用 AEQD(方位角等距投影):在原点处几何误差最小,
适合 <50km 的城市级别场景
"""
centroid = gdf_wgs84.unary_union.centroid
lon0, lat0 = centroid.x, centroid.y
local_crs = pyproj.CRS.from_proj4(
f"+proj=aeqd +lat_0={lat0} +lon_0={lon0} +units=m +datum=WGS84"
)
return gdf_wgs84.to_crs(local_crs), (lon0, lat0)
2D Polygon → 3D 拉伸网格
import trimesh
from shapely.geometry import Polygon
def batch_extrude_buildings(gdf_local: gpd.GeoDataFrame) -> trimesh.Trimesh:
"""
批量将建筑物多边形拉伸为 3D 网格
依赖 trimesh.creation.extrude_polygon 内置功能
"""
meshes = []
skipped = 0
for _, row in gdf_local.iterrows():
geom = row.geometry
height = float(row.get("height_m", 10.0))
polys = list(geom.geoms) if geom.geom_type == "MultiPolygon" else [geom]
for poly in polys:
if not isinstance(poly, Polygon) or not poly.is_valid or poly.area < 1.0:
skipped += 1
continue
try:
# trimesh 直接接受 Shapely Polygon,沿 Z 轴拉伸 height 米
mesh = trimesh.creation.extrude_polygon(poly, height=height)
meshes.append(mesh)
except Exception:
skipped += 1
print(f"成功拉伸: {len(meshes)} 个, 跳过无效: {skipped} 个")
combined = trimesh.util.concatenate(meshes)
print(f"总三角面数: {len(combined.faces):,}")
return combined
KMZ 导出(Google Earth 格式)
import simplekml, zipfile, pathlib
def export_kmz(gdf_wgs84: gpd.GeoDataFrame, output: str):
"""
导出为 KMZ 格式,使用 KML 的 <extrude> + relativeToGround 模式
Google Earth 会自动将多边形从地面拉伸到指定高度
"""
kml = simplekml.Kml()
folder = kml.newfolder(name="3D Buildings")
for idx, row in gdf_wgs84.iterrows():
geom = row.geometry
height = float(row.get("height_m", 10.0))
if geom.geom_type != "Polygon":
continue
pol = folder.newpolygon(name=str(row.get("name", f"bldg_{idx}")))
# KML 坐标顺序:(经度, 纬度, 高度)
pol.outerboundaryis = [(c[0], c[1], height) for c in geom.exterior.coords]
pol.extrude = 1 # 拉伸到地面
pol.altitudemode = simplekml.AltitudeMode.relativetoground # 相对地面高度
pol.style.polystyle.color = simplekml.Color.changealpha("88", simplekml.Color.steelblue)
pol.style.linestyle.width = 0.5
tmp_kml = output.replace(".kmz", "_tmp.kml")
kml.save(tmp_kml)
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as kmz:
kmz.write(tmp_kml, "doc.kml")
pathlib.Path(tmp_kml).unlink()
print(f"KMZ 已保存: {output} ({pathlib.Path(output).stat().st_size / 1e6:.1f} MB)")
3D 可视化(Open3D)
import open3d as o3d
import numpy as np
def visualize_city(mesh: trimesh.Trimesh):
"""按建筑高度着色,在 Open3D 中可视化城市 3D 模型"""
o3d_mesh = o3d.geometry.TriangleMesh(
vertices=o3d.utility.Vector3dVector(mesh.vertices),
triangles=o3d.utility.Vector3iVector(mesh.faces)
)
o3d_mesh.compute_vertex_normals()
# 按 Z(高度)着色:低层蓝色 → 高层橙色
z = np.asarray(mesh.vertices)[:, 2]
t = (z - z.min()) / (z.ptp() + 1e-8)
colors = np.column_stack([t, 0.4 * (1 - t), 1 - t]) # RGB 渐变
o3d_mesh.vertex_colors = o3d.utility.Vector3dVector(colors)
axes = o3d.geometry.TriangleMesh.create_coordinate_frame(size=50)
o3d.visualization.draw_geometries(
[o3d_mesh, axes],
window_name="City 3D Model",
width=1280, height=720,
zoom=0.4, front=[0.6, -0.7, 0.4], up=[0, 0, 1]
)
# 完整流程调用
if __name__ == "__main__":
gdf = load_buildings("buildings.shp")
gdf_local, origin = to_local_enu(gdf)
city_mesh = batch_extrude_buildings(gdf_local)
export_kmz(gdf, "city_3d.kmz")
visualize_city(city_mesh)
实验
推荐数据集
| 数据集 | 来源 | 高度数据 | 适合入门? |
|---|---|---|---|
| NYC Building Footprints | NYC Open Data | ✅ 楼层数完整 | ✅ |
| OpenStreetMap(Geofabrik) | geofabrik.de | ⚠️ 约 40% 覆盖 | ✅ |
| LoD2 Building Models | 德国各州开放数据 | ✅ 包含屋顶结构 | ❌ 格式复杂 |
NYC 建筑数据是最佳入门选择:100 万+ 建筑轮廓,属性完整,下载免费。
格式性能对比
| 格式 | 10k 建筑文件大小 | Google Earth 加载时间 | 几何精度 |
|---|---|---|---|
| KMZ(KML 拉伸) | ~15 MB | 3-8s | 中(无屋顶细节) |
| KMZ(COLLADA 网格) | ~45 MB | 8-15s | 高 |
| glTF/GLB | ~8 MB | 1-2s | 高 |
结论:glTF 是未来方向——压缩比更好,Three.js/Cesium/Google Earth 生态均已支持。
工程实践
实际部署考虑
- 数据量上限:10k 建筑物可实时渲染,100k+ 必须实现 LOD + 空间瓦片化(参考 Cesium 3D Tiles 规范)
- 内存估算:每个建筑物约 50 个三角形,100k 建筑 ≈ 200MB 网格数据,超出 GPU 显存需流式加载
- 精度 vs 规模:NeRF/3DGS 可达照片级真实感,但 1km² 场景就需要数 TB 存储;Shapefile 拉伸可覆盖整个国家
常见坑
坑 1:坐标轴顺序(XY vs YX)
部分 WKT 标准和旧版 GDAL 输出 lat, lon 而不是 lon, lat,数据会出现在赤道和南极附近:
# 快速检查:经度范围应在 [-180, 180],纬度在 [-90, 90]
bounds = gdf.total_bounds # [minx, miny, maxx, maxy]
if abs(bounds[0]) > 180 or abs(bounds[1]) > 90:
from shapely.ops import transform
gdf["geometry"] = gdf.geometry.map(lambda g: transform(lambda x, y: (y, x), g))
坑 2:无效几何导致拉伸崩溃
OSM 数据中约 5-10% 的多边形存在自相交(butterfly polygon),trimesh.creation.extrude_polygon 会抛出异常:
from shapely.validation import make_valid, explain_validity
# 批量修复(已集成在前面的 load_buildings 函数中)
invalid_count = (~gdf.geometry.is_valid).sum()
print(f"无效几何: {invalid_count} 个")
gdf["geometry"] = gdf.geometry.apply(make_valid)
坑 3:大规模数据分块读取
import fiona
# Fiona 支持切片读取,避免一次性加载数 GB 的 Shapefile
with fiona.open("large_city.shp") as src:
for i in range(0, len(src), 1000):
chunk = gpd.GeoDataFrame.from_features(
[src[j] for j in range(i, min(i + 1000, len(src)))]
)
process_and_export_chunk(chunk) # 分块处理导出
什么时候用 / 不用?
| 适用场景 | 不适用场景 |
|---|---|
| 城市建筑轮廓 3D 可视化 | 需要厘米级精度的工程测量 |
| 基础设施资产管理地图 | 需要真实纹理和光照(用 LoD2/CityGML) |
| 快速原型验证 3D 效果 | 密集植被或复杂地形场景 |
| 规划方案的体量展示 | 动态变化场景(每日更新建筑状态) |
与其他方法对比
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| KML 拉伸多边形(本文) | 数据易获取,可编程批量生产 | 几何简化,无屋顶细节 | 城市规模概览 |
| CityGML LoD2/3 | 完整建筑结构 | 数据获取成本极高 | 城市规划仿真 |
| NeRF / 3DGS 重建 | 照片级真实感 | 计算量大,无语义属性 | 精细场景渲染 |
| Cesium 3D Tiles | 流式加载,工业级稳定 | 系统复杂度高 | 生产环境部署 |
我的观点
Google Earth 支持 Shapefile 和 3D 模型直接导入,标志着专业 GIS 工具和消费级地图平台之间的壁垒正在消融。但有几个现实需要清醒面对:
数据质量是真正的瓶颈:全球 60%+ 的建筑物没有可靠的高度数据,而 LiDAR 扫描数据在大多数城市根本不存在或未开放。拿到轮廓 Shapefile 容易,拿到准确高度难。
语义才是核心价值:3D 几何本身价值有限,真正有用的是几何 + 属性(这栋楼是住宅还是商业?建于何年?容积率是多少?)。Shapefile 的属性表加上 3D 几何,才是城市数字孪生的基础数据格式——不是 NeRF 生成的好看点云。
glTF 正在成为 3D 地理数据的通用格式,类似 Shapefile 在 2D 时代的地位。结合 3D Tiles 规范、CesiumJS 和 Deck.gl 生态,这是目前最成熟的大规模 3D 地图技术栈。如果你在做城市级别的 3D 应用,这个方向值得深入。
Comments