GeoTIFF 高程数据可视化:从不可见到可感知

GeoTIFF 高程数据可视化:从不可见到可感知

前言

一张 GeoTIFF 高程图片,每个像素存储的不是颜色,而是一个浮点数------代表该点的海拔高度。这样的图片无法直接展示给人眼,甚至很多 TIFF 都只有单通道。那么,如何让这些「看不见」的数据变成「看得懂」的地形图呢?

本文将介绍两个核心技术:山体阴影(Hillshade)色带映射(Color Ramp) ,并结合 WebGL2 GPU 加速,实现高程数据的实时可视化。


1. 问题:为什么 TIFF 高程图无法直接展示?

普通图片的每个像素是 RGB 颜色值(0~255),人眼可以直接辨识。而高程 TIFF 中的像素值可能是:

css 复制代码
像素[0][0] = 342.7    (海拔342.7米)
像素[0][1] = 343.1
像素[1][0] = 341.9
...

这些浮点数没有颜色含义,且数据范围远超 0~255。即使强制显示为灰度图,你看到的只是一片近乎均匀的灰色------因为相邻像素的高程差异太小,人眼根本分辨不出。

我们需要两种手段来让高程信息「可见」:

  • 山体阴影:通过模拟光照产生明暗对比,让地形起伏变得可感知
  • 色带映射:将高程值映射到一组有意义的颜色上,让海拔高低一目了然

2. 山体阴影(Hillshade)的原理

山体阴影的核心思路是:如果知道地表每一点的朝向(法线),就能计算出阳光照射到该点时的明暗程度。

兰伯特余弦定理Lambert's cosine law

一个表面接收的光照能量与 法线 n 和 光照方向 l 的余弦相关。见下图。

我们现在只有一张图片,里面的每个像素表示一个高程。那么如何获取地表的法线呢?

2.1 从高程到梯度:Sobel 卷积

要知道地表的朝向,首先需要知道地形在 x 和 y 方向上的变化率(梯度)。这通过 Sobel 算子 对高程数据做 3×3 邻域卷积来实现。

对于当前像素 C,采样其周围的 8 个像素:

css 复制代码
tl  tp  tr
 l  [C]  r
bl   b  br

Sobel 算子对 x 方向和 y 方向分别定义了权重矩阵:

css 复制代码
Gx = [[-1, 0, 1],     Gy = [[ 1,  2,  1],
      [-2, 0, 2],           [ 0,  0,  0],
      [-1, 0, 1]]           [-1, -2, -1]]

对一个像素周围的所有的像素取出,获取它们的高程值,在通过上述权重矩阵进行加权平均。

计算梯度代码如下:

scss 复制代码
dz/dx = ((tr + 2r + br) - (tl + 2l + bl)) / (8 × cellSizeX)
dz/dy = ((tl + 2tp + tr) - (bl + 2b + br)) / (8 × cellSizeY)

dz/dx 表示高程对x轴的变化速度 dz/dy 表示高程对y轴的变化速度

其中 cellSizeXcellSizeY 是每个像素在实际地理空间中代表的距离(单位:米),这样梯度的物理意义就是真实坡度(米/米)

2.2 从梯度到法线

有了 x/y 方向的梯度后,地表法线向量可以直接构造:

scss 复制代码
normal = normalize(-dz/dx × zFactor,  -dz/dy × zFactor,  1.0)
  • zFactor 是高程夸张系数,值越大,山体起伏在视觉上越明显
  • 法线的 z 分量为 1.0,因为梯度已经是真实坡度(米/米),无需再做缩放
  • 取负号是因为梯度方向指向高程增大的方向,而法线应该指向地表的「外侧」

2.3 Lambert 漫反射光照

有了法线和光线方向,就可以用 Lambert 余弦定理 计算照明强度:

ini 复制代码
shade = max(dot(normal, lightDir), 0.0) × intensity
  • lightDir 是归一化后的光线方向向量
  • dot(normal, lightDir) 即两个向量的点积,等于 cos(θ),θ 为法线与光线的夹角
  • 当面朝光线时 cos(θ) ≈ 1,完全背光时 cos(θ) ≤ 0(取 0)
  • intensity 控制光照强度

这是最经典的漫反射模型:法线越朝向光源越亮,越背离光源越暗

最终输出为灰度图:亮处代表阳面,暗处代表阴面,人眼就能直观地感受到山体的立体起伏。


3. 色带映射(Color Ramp)

山体阴影解决了「分辨起伏」的问题,但无法区分海拔高低。色带映射通过将高程值映射到一组预定义颜色来解决这一问题。

3.1 高程归一化

首先将高程值归一化到 [0, 1] 区间:

ini 复制代码
t = (elevation - minElevation) / (maxElevation - minElevation)

3.2 色带控制点

定义一组控制点,每个控制点指定一个归一化位置和对应的 RGBA 颜色:

less 复制代码
const TERRAIN_COLOR_RAMP: ColorStop[] = [  { offset: 0.0,  color: [0, 97, 0, 128] },     // 深绿 --- 低海拔
  { offset: 0.15, color: [16, 122, 0, 128] },    // 绿
  { offset: 0.3,  color: [132, 173, 54, 128] },  // 黄绿
  { offset: 0.45, color: [202, 204, 68, 128] },  // 黄
  { offset: 0.6,  color: [185, 152, 90, 128] },  // 棕黄
  { offset: 0.75, color: [148, 107, 62, 128] },  // 棕
  { offset: 0.9,  color: [178, 178, 178, 128] }, // 灰
  { offset: 1.0,  color: [255, 255, 255, 128] }, // 白 --- 高海拔/雪线
];

这是经典的 hypsometric tints(等高着色)方案:低处绿色(植被),中部棕黄(裸岩),高处灰白(雪线)。

3.3 插值生成 1D 纹理

将控制点之间进行线性插值,生成一条固定宽度(如 256 像素)的颜色带:

ini 复制代码
// 对每个纹素位置 t ∈ [0, 1]
// 找到 t 所在的两个相邻控制点 lower 和 upper
// 在两者之间做线性插值
factor = (t - lower.offset) / (upper.offset - lower.offset)
color = lower.color + (upper.color - lower.color) × factor

这个颜色带作为 1D 查找纹理上传到 GPU,片元着色器中只需一次 texture() 采样即可查到对应颜色。


4. WebGL2 GPU 加速实现

上述算法如果在 CPU 上逐像素计算,对于大尺寸的 TIFF 会非常慢。通过 WebGL2,我们将整个计算过程交给 GPU 并行执行。

4.1 整体流水线

javascript 复制代码
高程数据 (Float32Array)
    │
    ▼
┌──────────────────────────────────────────┐
│  GPU 纹理上传                              │
│  ┌──────────────┐  ┌──────────────────┐  │
│  │ 高程纹理 R32F │  │ 色带纹理 RGBA8   │  │
│  │ 纹理单元 0    │  │ 纹理单元 1       │  │
│  └──────┬───────┘  └────────┬─────────┘  │
│         │                   │            │
│         ▼                   ▼            │
│  ┌──────────────────────────────────┐    │
│  │ 片元着色器(每像素并行执行)        │    │
│  │                                  │    │
│  │  1. NoData 检测 → 透明输出        │    │
│  │  2. 高程归一化 → t ∈ [0, 1]      │    │
│  │  3. 色带纹理采样 → RGBA 颜色      │    │
│  │  4. Sobel 3×3 → 梯度 dz/dx, dy  │    │
│  │  5. 梯度 → 地表法线              │    │
│  │  6. Lambert 光照 → shade 值      │    │
│  │  7. 环境光混合                    │    │
│  │  8. 色带颜色 × 阴影亮度 → 输出    │    │
│  └──────────────┬───────────────────┘    │
│                 │                        │
│                 ▼                        │
│  ┌──────────────────────────────────┐    │
│  │  readPixels → Uint8Array → ImageData  │
│  └──────────────────────────────────┘    │
└──────────────────────────────────────────┘

4.2 关键实现细节

高程纹理使用 R32F 格式

高程值是浮点数,使用 gl.R32F(单通道 32 位浮点)格式存储,采样方式设为 NEAREST,避免高程值被纹理插值篡改:

arduino 复制代码
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, width, height, 0,
              gl.RED, gl.FLOAT, heightData);

色带纹理使用 LINEAR 采样

色带纹理使用 LINEAR 采样,这样即使纹理分辨率不高,颜色过渡也是平滑的:

ini 复制代码
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);

cellSize:像素到真实距离的换算

地理坐标系中,经度方向的实际距离随纬度变化。取影像中心纬度进行近似:

ini 复制代码
const metersPerDegLat = 111320;
const metersPerDegLng = 111320 * Math.cos(centerLat × π / 180);
​
const cellSizeX = (经度跨度 × metersPerDegLng) / 图像宽度;
const cellSizeY = (纬度跨度 × metersPerDegLat) / 图像高度;

这保证了 Sobel 梯度的物理单位正确,山体阴影不会因为像素比例失真。

环境光(Ambient Light)防止全黑

纯 Lambert 模型下,完全背光的区域会变成纯黑。加入环境光的最低亮度:

ini 复制代码
float finalShade = mix(u_ambientLight, 1.0, shade);

shade = 0(完全背光)时,输出为 ambientLight(如 0.15),而非 0。

4.3 片元着色器核心代码

scss 复制代码
void main() {
    float elev = getHeight(v_texCoord);
​
    // NoData 检测:无效像素输出全透明
    if (u_hasNoData > 0.5 && elev == u_noDataValue) {
        fragColor = vec4(0.0);
        return;
    }
​
    // 高程归一化 → 色带查找
    float t = clamp((elev - u_minElevation) / elevRange, 0.0, 1.0);
    vec4 rampColor = texture(u_colorRamp, vec2(t, 0.5));
​
    // Sobel 3×3 采样邻域
    float tl = getHeight(v_texCoord + vec2(-texelSize.x,  texelSize.y));
    // ... 其余 7 个方向 ...
​
    // 计算梯度(除以真实像素距离)
    float dzdx = ((tr + 2.0*r + br) - (tl + 2.0*l + bl)) / (8.0 * u_cellSize.x);
    float dzdy = ((tl + 2.0*tp + tr) - (bl + 2.0*b + br)) / (8.0 * u_cellSize.y);
​
    // 构建法线 → Lambert 光照
    vec3 normal = normalize(vec3(-dzdx * u_zFactor, -dzdy * u_zFactor, 1.0));
    float shade = max(dot(normal, u_lightDir), 0.0) * u_intensity;
​
    // 环境光混合
    float finalShade = mix(u_ambientLight, 1.0, shade);
​
    // 最终输出:色带颜色 × 阴影亮度
    fragColor = vec4(rampColor.rgb * finalShade, rampColor.a);
}

一次 Pass,所有计算在片元着色器中同时完成------无需多趟渲染或中间缓冲区。

我们通过 webgl的readPixels 获取图片像素数据,再把它贴图到地图上,最终效果如下所示:

观察到地形的细节还是不错的。


5. 完整数据流总结

arduino 复制代码
                    GeoTIFF 文件
                        │
                        ▼
              ┌─────────────────────┐
              │  geotiff.js 解码     │
              │  → 宽、高、通道数     │
              │  → Float32 栅格数据   │
              └─────────┬───────────┘
                        │
              ┌─────────┴───────────┐
              │                     │
              ▼                     ▼
     ┌────────────────┐    ┌────────────────┐
     │ 元数据统计       │    │ 色带控制点       │
     │ min/max 高程    │    │ 线性插值         │
     │ NoData 处理     │    │ → 1D RGBA 纹理  │
     └───────┬────────┘    └───────┬────────┘
             │                     │
             ▼                     ▼
     ┌─────────────────────────────────────┐
     │         WebGL2 单 Pass 渲染           │
     │                                     │
     │  高程纹理(R32F) + 色带纹理(RGBA8)     │
     │         ↓                            │
     │  片元着色器:                          │
     │    NoData → Sobel → 法线 → Lambert   │
     │    → 色带采样 → 混合 → 输出            │
     └──────────────┬──────────────────────┘
                    │
                    ▼
              ┌──────────┐
              │ ImageData │
              │ RGBA 可视化│
              └──────────┘

6. 效果调参指南

参数 作用 建议值
lightSource.direction 光线方向向量 (x, y, z) [-1, -1, 2] 模拟西北方向阳光
lightSource.intensity 光照强度 0.8 ~ 1.5
zFactor 高程夸张系数 平原地区 25,山区 12
ambientLight 环境光最低亮度 0.1 ~ 0.2
colorRamp 自定义色带 按需定义,支持任意 RGBA 控制点

总结

GeoTIFF 高程可视化的本质是两个映射的叠加:

  1. 高程 → 颜色(色带映射):让人一眼区分海拔高低
  2. 高程 → 明暗(山体阴影):让人感知地形的立体起伏

两者结合------色带颜色乘以阴影亮度------就得到了既有色彩信息又有立体感的地形可视化图像。借助 WebGL2 的并行计算能力,整个过程在一次 GPU Pass 中完成,即使面对大尺寸高程数据也能高效处理。

相关推荐
花满楼zxc6 小时前
使用GSAP轻松实现元素做曲线运动
数据可视化
叶智辽3 天前
Three.js多视口渲染:如何在一个屏幕上同时展示三个视角
webgl·three.js·数据可视化
mCell5 天前
分享一个常用的文生图提示词
人工智能·llm·数据可视化
玄魂6 天前
Coze+ VisActor Skill:智能图表,触手可及
ai编程·数据可视化·coze
柳杉9 天前
使用Ai从零开发智慧水利态势感知大屏(开源)
前端·javascript·数据可视化
ANQH11 天前
Manim三大核心类详解
数据可视化
柳杉14 天前
使用AI从零打造炫酷医疗数据可视化大屏,源码免费拿!
前端·javascript·数据可视化
Highcharts.js14 天前
Highcharts热力图(Heatmap)完全指南:从基础配置到地理热力图,一文学会颜色轴数据可视化
信息可视化·数据可视化·热力图·heatmap·highcharts·地理热力图
凌云拓界15 天前
TypeWell全攻略(二):热力图渲染引擎,让键盘发光
前端·后端·python·计算机外设·交互·pyqt·数据可视化