一句话总结

将 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 应用,这个方向值得深入。