从卫星影像到法线贴图:为任意区域一键生成真实地形材质

1. 引言

在数字孪生、三维地图可视化、智慧城市等项目中,有一个非常常见的需求:给定一个行政区划(省/市/县/区)或任意自定义区域的边界数据,生成该区域的顶面覆盖贴图------比如卫星影像纹理和地形法线贴图,以便在三维场景中将它们"披"在地形模型上,呈现出真实的地貌效果。

通常情况下,我们手头只有一份 GeoJSON 格式的区域边界数据(包含经纬度坐标的封闭多边形),而缺乏该区域的现成贴图。手动去截图、拼接、处理不仅效率低下,且难以覆盖大量区域。

为此我们搭建了一套全自动管线:给定一个 GeoJSON 边界文件,自动下载卫星瓦片并合成高清影像,同时从全球高程数据中生成法线贴图,最后根据边界裁剪输出,全程无需人工介入。

点此进入易知微孪生资产库

2. 基础知识铺垫

在深入技术细节之前,我们先快速了解几个本文会反复出现的基础概念。

2.1. GeoJSON ------ 用 JSON 画地图

GeoJSON 是一种基于 JSON 的地理空间数据交换格式,可以描述点(Point)、线(LineString)、多边形(Polygon)等几何对象。你可以把它理解成"地图界的 SVG"------只不过 SVG 的坐标是屏幕像素,而 GeoJSON 的坐标是真实世界的经纬度。

例如,一个描述浙江省边界的 GeoJSON 文件结构大致如下:

text 复制代码
{
  "type": "FeatureCollection",
  "features": [{
    "type": "Feature",
    "properties": { "name": "浙江省", "adcode": "330000" },
    "geometry": {
      "type": "Polygon",
      "coordinates": [[[118.0, 27.0], [118.5, 28.0], ...]]
    }
  }]
}

本文的核心输入就是这个多边形坐标列表,我们用它来确定"需要处理哪片区域"以及"最终裁剪到什么形状"。

2.2. 瓦片地图 ------ 地球被切成无数小方块

在线地图服务(谷歌、高德、OpenStreetMap 等)不是把一整张世界地图的图片发给你,而是将地球表面按照不同缩放层级切分成无数个 256×256 像素 的小方块,称为瓦片(Tile)

瓦片的编号体系使用三个整数 (x, y, z)

  • z(Zoom Level):缩放层级,取值通常在 0~18 之间。z=0 时整个地球只有 1 张瓦片;z 每增加 1,瓦片数量翻 4 倍。
  • x, y:该层级下瓦片的列号和行号。

这个概念对于游戏/图形开发者来说非常熟悉------类似纹理的 Mipmap 或游戏的 LOD(Level of Detail) 机制:远看用低精度,近看用高精度。

2.3. 墨卡托投影 ------ 把球面"摊平"

地球是球形的,但地图是平面的。把球面坐标(经纬度)映射到平面坐标(像素)的过程叫投影 。绝大多数 Web 地图(包括高德、谷歌、OpenStreetMap)使用的标准投影是 Web 墨卡托投影(Web Mercator)

墨卡托投影的核心特点是:

  • 保持方向和形状:在地图上,北就是上,正方形还是正方形(局部保形)。
  • 高纬度区域拉伸:格陵兰岛(北极圈附近)在地图上看起来比非洲还大,但实际上非洲的面积是格陵兰的 14 倍。
  • 南北纬约 85° 以上的极地无法显示(数学上的奇点)。

对于我们的方案来说,必须正确处理墨卡托投影的坐标转换,才能从瓦片服务中精准下载对应区域的图片,并在法线图生成时按等面积方式采样高程数据。

2.4. DEM ------ 用数字矩阵记录地形

数字高程模型(DEM, Digital Elevation Model) 是以栅格形式记录地表海拔高度的数据集------你可以把它理解成一张"灰度图",每个像素的值代表该位置的海拔高度(单位:米)。

本文使用的是 SRTM(Shuttle Radar Topography Mission) 数据------由 NASA 的航天飞机在 2000 年通过雷达干涉测绘获得,覆盖全球北纬 60° 至南纬 56° 的陆地区域。SRTM 数据的分辨率约为 30 米(美国本土)到 90 米(全球其他区域),作为公开数据可以合法用于研究和学术目的

合法合规提示:SRTM 数据由 NASA 和 USGS 公开发布。其他高分辨率 DEM 数据(如 ALOS AW3D30、ASTER GDEM)各有自身的许可条款,使用前请查阅相应数据提供方的使用协议。

3. 核心流程总览

整体方案包含两条独立且并行的 Pipeline

text 复制代码
卫星贴图生成流程:
GeoJSON 边界 → 边界框计算 → 自适应 Zoom → 瓦片下载 → 图片拼接 → 按边界裁剪 → 卫星贴图

法线贴图生成流程:
GeoJSON 边界 → 边界框计算 → DEM 高程采样 → 空洞填充 → 梯度计算 → 法线合成 → 法线贴图

两条 Pipeline 共用同一个输入(GeoJSON 区域边界数据),但后续处理链路完全独立,可以并行执行。

下面分别展开两条 Pipeline 的技术细节。

4. 卫星贴图生成详解

卫星贴图生成的本质是:根据区域边界框下载覆盖该区域的所有瓦片图片,拼成一张大图,再按边界精确裁剪

4.1. 输入:区域边界解析

第一步是读取 GeoJSON 文件,提取其中的多边形(Polygon)或多多边形(MultiPolygon)几何体,然后遍历所有顶点坐标,计算出覆盖该区域的最小边界框(Bounding Box):

text 复制代码
BBox = (min_lon, min_lat, max_lon, max_lat)

注意:GeoJSON 中一个 Feature 可能是 MultiPolygon 类型(如包含岛屿的沿海省份),需要处理所有子多边形。

4.2. 自适应 Zoom 层级选择

这是整个方案中一个关键的工程决策:选多大的缩放层级?

  • Zoom 太大(如 z=18):单个瓦片覆盖范围极小,覆盖一个省可能需要上万张瓦片------下载极慢、内存爆炸
  • Zoom 太小(如 z=8):瓦片数量虽少,但分辨率太低,最终图片模糊不清

我们的策略是 "反向估算" :对于每个候选的 Zoom 层级(从 z=18 到 z=5 依次扫描),计算如果采用该层级需要下载多少张瓦片,选择使瓦片数量落在 50~500 张 范围内的最高层级。

这一策略确保了:

  • 小面积区域(如一个区县)自动匹配较高 Zoom,获得充足细节
  • 大面积区域(如一个省份)自动降级 Zoom,控制下载量在可接受范围

可以通俗地理解为:"根据你的区域大小自动选择最佳分辨率,类似视频网站的自适应码率"

4.3. 经纬度 → 瓦片坐标转换

这是 Web 地图技术中最核心的坐标转换步骤。给定一个经纬度点 (lon, lat) 和 Zoom 层级 z,需要计算它落在哪张瓦片上。

核心公式(简化版):

text 复制代码
n = 2^z                                   // 该层级下瓦片总数(每行/列 = n)

// 经纬度 → 瓦片坐标
tile_x = (lon + 180) / 360 * n            // 经度线性映射到 [0, n)
lat_rad = radians(lat)
tile_y = (1 - asinh(tan(lat_rad)) / π) / 2 * n  // 纬度经墨卡托投影后映射

// 取整得到瓦片行号、列号
x = floor(tile_x)
y = floor(tile_y)

反向转换(瓦片坐标 → 经纬度)同样重要,用于计算拼接后大图的实际地理跨度:

text 复制代码
lon = tile_x / n * 360 - 180
lat = atan(sinh(π * (1 - 2 * tile_y / n)))

这里的 asinh(反双曲正弦)和 atan(sinh(...)) 就是墨卡托投影的数学内核。 tan(lat_rad) 把球面坐标拉伸到投影面, asinh 再把它变成适合线性分块的尺度。

4.4. 并发瓦片下载

确定瓦片范围后,生成所有需要下载的 (x, y, z) 元组列表。使用线程池并发下载,大幅提升效率。

对于省级区域(约 200~400 张瓦片),并发下载可在 30 秒内完成。

合法合规使用第三方地图服务的注意事项
在线瓦片地图服务(高德、谷歌、OSM 等)为开发者提供了便捷的地图数据获取途径,但在使用时需要特别注意:

  1. 遵守服务条款:各平台对数据获取方式、使用场景(商业/非商业)有明确约定,请在接入前仔细阅读。
  2. 控制请求频率:高频并发请求可能对服务器造成负担,建议使用合理的并发数和重试策略,并在请求间加入适当间隔。
  3. 商业产品需走正规渠道:如果你的产品面向商业客户,建议通过官方 API(如高德开放平台、Google Maps Platform)注册 API Key,获取稳定且有 SLA 保障的服务。
  4. 本文方案的技术定位:本文分享的技术方案仅用于技术研究和学习目的,实际产品化时应根据具体场景评估合规性和选择合适的服务商。

4.5. 瓦片拼接(Image Stitching)

将所有成功下载的 256×256 瓦片按行列位置"贴"到一张大图上:

text 复制代码
合成图 = Image.new(width=cols*256, height=rows*256)

for row in range(rows):
    for col in range(cols):
        if (start_x+col, start_y+row) in 已下载瓦片:
            大图.paste(瓦片图片, (col*256, row*256))

拼接后的像素尺寸取决于瓦片行列数,例如覆盖一个省可能需要拼接出 5000×4000 像素级别的大图。

4.6. 按 GeoJSON 边界精确裁剪

这是最后一步,也是最"GIS 味"的一步------把拼接好的矩形大图按 GeoJSON 多边形的形状裁剪:

  1. 多边形坐标映射 :将 GeoJSON 中的每个经纬度点 (lon, lat) 通过 4.3 节的反向公式映射到拼接大图中的像素坐标 (px, py)
  2. 创建遮罩(Mask) :用 PIL 的 ImageDraw.polygon() 在空画布上绘制多边形------多边形内部填充白色(255,=保留),外部黑色(0,=丢弃)
  3. 裁剪边界框:计算多边形在图片中占据的像素边界框并裁出
  4. 缩放至目标尺寸:保持宽高比,将有效内容贴边缩放至 2048×2048 像素

最终输出:一张正方形 (2048×2048) 的卫星贴图,区域恰好覆盖 GeoJSON 边界内的所有内容。

5. 法线贴图生成详解

有了卫星贴图只是"画皮"------没有光影变化的地形看起来是平面的。**法线贴图(Normal Map)**就是用来增强地形光影真实感的关键贴图。

5.1. 什么是法线贴图?------ 给像素"指方向"

在 3D 渲染中,每个表面都有一个"朝向"属性------叫法线(Normal),它是一个指向表面外侧的单位向量(长度为 1)。光照计算(如漫反射、镜面反射)必须知道法线才能算出表面该多亮多暗。

但 3D 模型的面数是有限的------一个地形模型不可能把每个小石头和微小起伏都建模出来。法线贴图 就是解决这个问题的方案:

法线贴图是一张 RGB 图片,每个像素的值不表示"颜色",而是编码了该位置表面的"朝向"。渲染时,GPU 读取这个"假朝向"来计算光照,让平面看起来有凹凸------这就是计算机图形学中的"凹凸映射(Bump Mapping)"技术。

编码规则很简单:

|------|----------|------------------|
| 通道 | 编码内容 | 通俗解释 |
| R(红) | 法线的 X 分量 | 表面"向左倾斜"还是"向右倾斜" |
| G(绿) | 法线的 Y 分量 | 表面"向前倾斜"还是"向后倾斜" |
| B(蓝) | 法线的 Z 分量 | 表面"直直地朝向我们"的程度 |

当一个像素的颜色是 (128, 128, 255) ------偏蓝色------表示该表面的法线是 (0, 0, 1),即"直直向上",是完全平坦的地面。这也是大多数法线贴图的基色调为淡蓝色的原因。

在三维地形引擎(如 CesiumJS、Unity3D)中,给地形模型贴上法线贴图后,山脊的向阳面会变亮,背光面会变暗------就像真实光照打在真实地面上一样。

5.2. DEM 高程数据采样

法线贴图的生成流程可以概括为三步:采样高程 → 计算梯度 → 合成法线

首先从 SRTM 数据中采样目标区域的高程。有一个重要的采样策略:按墨卡托投影像素等间距采样,而非按经纬度等间距采样

为什么要这样做?回忆 2.3 节墨卡托投影的特点:高纬度区域被拉伸。如果按经纬度等差采样(如每隔 0.01° 取一个点),在哈尔滨(北纬 45°)的单位面积内采样点会比在海口(北纬 20°)同一单位面积内的采样点多------导致高纬度区域过度采样、法线密度不均匀。

正确的做法是:

  1. 将区域的经纬度范围映射到墨卡托投影像素坐标范围
  2. 在墨卡托投影空间中等距采样(如均分 200×200 个采样点)
  3. 再将每个采样点的墨卡托坐标反算回经纬度,查询 SRTM 高程值

这样保证了每个采样点在地图上覆盖的实际面积是一致的。

5.3. 数据预处理:空洞填充

SRTM 数据在某些区域可能返回无效值------水体区域(如湖泊、大型河流)在雷达测绘中无法获取有效高程,可能返回 None 或极小值。

需要识别并填充这些空洞。我们借助 SciPy 的 distance_transform_edt(欧氏距离变换)实现最近邻插值:对每个无效像素,找到离它最近的有效像素的值,以此填充。这确保了整个高程矩阵是完整且连续的。

5.4. 高度归一化策略

采样完成后,需要将所有高度值映射到 0, 1 区间,才能参与后续的梯度计算。但这里有个关键设计选择:线性归一化 还是 对数归一化?

|------------|--------------|---------------------------|
| 情况 | 策略 | 理由 |
| 高差 ≤ 100m | 线性归一化 | 高度范围窄,线性映射足够保留细节 |
| 高差 > 100m | 对数归一化(log1p) | 压缩极高的山峰,为中低海拔区域留出更多"灰度空间" |

以实际地形为例:一个区域包含海平面(0m)和一座 3000m 的高峰。如果用线性归一化,海平面到 1000m 的丘陵地带只占整个灰度范围的 1/3------大量的地形成分被压缩到少数几个灰度级里,细节丢失殆尽。

对数归一化通过 log1p(height) 压缩高值:

  • 0m → log1p(0) ≈ 0
  • 1000m → log1p(1000) ≈ 6.9
  • 3000m → log1p(3000) ≈ 8.0

可以看到低海拔区域的相对差异被拉大了,丘陵的起伏在最终的归一化结果中得到了更好的体现。

5.5. Sobel 算子计算梯度

接下来是最核心的步骤------用 Sobel 算子 计算归一化高程矩阵中每个点的梯度(即"高度在水平和垂直方向上的变化率")。

Sobel 是一种经典的 3×3 卷积核,广泛用于图像边缘检测。这里用它来"感知"地形的陡峭程度:

Sobel X 核(检测水平方向的高度变化):

text 复制代码
[-1,  0,  1]
[-2,  0,  2]
[-1,  0,  1]

Sobel Y 核(检测垂直方向的高度变化):

text 复制代码
[-1, -2, -1]
[ 0,  0,  0]
[ 1,  2,  1]

将这两个核分别与归一化高程矩阵做二维卷积(scipy.ndimage.convolve),得到:

  • dx:每个点水平方向的高度变化率------正值表示"右边比左边高"(东高西低),负值表示"左边比右边高"(西高东低)
  • dy:每个点垂直方向的高度变化率------正值表示"上边比下边高"(北高南低),负值表示"下边比上边高"(南高北低)

如果你熟悉图像处理,可以把它理解为"在高度图上跑边缘检测,突出的边缘就是山脊和陡坡"。

5.6. 法线向量合成

有了梯度(dx, dy),就可以合成每个像素的法线向量了:

text 复制代码
// 法线 = 梯度的"反向"(因为梯度指向"升高"方向,法线需要指向"表面朝向")
normal_x = -dx × strength × scale_factor
normal_y = -dy × strength × scale_factor
normal_z = 0.3   // 保持基本向上的 Z 分量,避免法线过于"平躺"

然后将法线向量归一化(除以向量长度,使其成为单位向量),最后映射到 RGB 空间:

text 复制代码
R = (normal_x × 0.5 + 0.5) × 255   // 将 [-1, 1] 映射到 [0, 255]
G = (normal_y × 0.5 + 0.5) × 255
B = (normal_z × 0.5 + 0.5) × 255

其中 scale_factor 是一个自适应系数:min(实际高差 / 500, 8.0)。它的含义是:

  • 高差越大 → 法线起伏越剧烈 → 山脊的明暗对比越强,视觉冲击力更足
  • 上限 8.0 防止在极端高差(如青藏高原)下法线过于扭曲失实

strength 是一个可调参数(默认 1.5),允许人工微调法线的表现力强度。

5.7. 输出

与卫星贴图类似,最终的法线图也需要缩放到 2048×2048。处理方式:

  1. 保持原始高程矩阵的宽高比贴边放置
  2. 空白区域填充基准色 (128, 128, 255) ------即"平坦地面"的颜色

6. 实际效果与对比

将同一区域的卫星贴图和法线贴图同时应用到三维地形模型上,与"仅贴卫星图"的效果对比:

|-------|-------------------|-------------------|
| 对比维度 | 仅有卫星贴图 | 卫星贴图 + 法线贴图 |
| 光照感 | 平面光,无论地形高低亮度一致 | 动态光照,向阳面亮、背光面暗 |
| 地形辨识度 | 靠颜色猜地形(绿=平原,棕=山地) | 靠光影感知隆起,山谷与山脊一清二楚 |
| 视觉真实感 | 像"打印在地面上的照片" | 像"真实阳光照在真实地面上" |

特别是在山区、丘陵地带,配合法线贴图后的效果提升极为显著------原本模糊的山脊线变得清晰可辨,即使缩小到远景视角也能感受到地形起伏。

7. 真实案例

点此获取更多真实案例

易知微基于多年在数字孪生及数据可视化领域丰富实践,沉淀了诸多经验成果,欢迎大家互相交流学习:《数字孪生+AI:行业最佳实践白皮书》下载地址:https://easyv.cloud/references/detail/135.html/?t=yzwsm

《数字孪生视觉语言白皮书》下载地址:https://easyv.cloud/references/detail/127.html/?t=yzwsm

《数字孪生世界白皮书》下载地址:https://easyv.cloud/references/detail/51.html/?t=yzwsm

《数字孪生行业方案白皮书》下载地址:https://easyv.cloud/references/detail/120.html/?t=yzwsm

《港口数智化解决方案》下载地址:https://easyv.cloud/references/detail/121.html/?t=yzwsm

想申请易知微产品免费试用的客户,欢迎点击易知微官网申请试用:https://easyv.cloud/?t=yzwsm

相关推荐
mobility5 小时前
免费AI视频生成器:我如何用零成本做出带旁白字幕的多场景AI视频
ai·vibe coding
doiito9 小时前
【Agent Harness】Gliding Horse 给 Agent OS 装上双曲空间引擎与默克尔树边云同步
ai·rust·架构设计·系统设计·ai agent
knqiufan12 小时前
从 Python 到 TypeScript,用 GLM-5.2 跑通 PowerMem SDK 的长程任务工程
ai·memory·agentic·powermem
小白跃升坊1 天前
Codex 增强部署:基于 Codex++ 接入 DeepSeek
ai·ai编程·codex·deepseek·ai coding·codex++
AlfredZhao1 天前
GPT 省钱,不是别用最新模型,而是别浪费缓存
gpt·ai
doiito1 天前
【Agent Harness】Gliding Horse 本体论系统设计:给 AI Agent 装上“语义大脑”
ai·rust·架构设计·系统设计·ai agent
小七-七牛开发者2 天前
周一上线 | SpaceX 收购 Cursor、支付宝进入 AI 时代、DeepSeek 完成 500 亿元融资
ai·agent·token·glm·智谱·claudecode·ai coding·周一上线
doiito2 天前
【Agent Harness】为什么我把 JSON‑LD “编译成 DAG” 后,整个 Agent 平台立刻聪明了
ai·rust·架构设计·系统设计·ai agent
xiezhr3 天前
折腾半小时,终于让AI 能直接帮我写飞书文档了
ai·飞书·ai agent·飞书cli·飞书文档
岳小哥AI3 天前
Claude Fable和Claude Mythos 5同时发布:注意力机制下愈加强大的AI大模型
ai·ai基础