简介:这份资源围绕 Cesium 与克里金插值展开,面向需要在三维地球场景中呈现连续温度场的 WebGIS 开发者与地理信息专业学习者。它解决的核心问题是:如何把离散、不规则分布的温度观测点,通过变异函数分析插值成平滑连续的温度表面,并叠加到 Cesium 的交互式三维地球上,适用于气象可视化、环境监测与空间分析类项目。压缩包共 585 个文件,约 7.27MB,以 321 个 js 脚本、141 个 png 与 53 个 jpg 图像、29 个 json 配置、27 个 css 样式为主,另含少量 svg、wasm、html 等,基本覆盖 Cesium 运行所需的脚本、贴图与界面样式。目前已有 167 人学习下载。读者可从中获得克里金插值在 Cesium 中的落地思路、温度数据渲染的完整工程结构,以及脚本、样式与图像资源的组织方式,便于对照搭建自己的三维温度场可视化页面。
1. 从一堆散点温度到一张热力图:Cesium Kriging 到底在算什么
手头有一批气象站传来的离散温度点,几十个到几百个不等,每个点只有经纬度和一个摄氏度数值。直接把这些点标在地图上,看到的是一堆孤立图标,站点之间的空白区域完全靠脑补。业务方要的是一张连续的温度分布色带图,能看出冷暖空气的走向、城区热岛的范围、山区温度随海拔的梯度变化。这时候就需要空间插值,而克里金(Kriging)是地统计学里最常用的一种。
Cesium 本身不提供插值能力,它负责的是三维地球渲染、影像图层、几何体绘制。Kriging 插值需要另外实现,把散点算成规则网格上的连续值,再交给 Cesium 以图片或纹理的形式贴到地球上。这套组合在气象可视化、环境监测、农业估产、地质勘探的场景里反复出现。适合谁看:手里有带坐标的采样数据、需要在三维地球上做连续场渲染的前端或 GIS 工程师。如果你只是想把几个点标出来,那用不上 Kriging;但只要涉及“面”的展示,插值就是绕不开的一步。
2. 克里金插值的数学骨架与 Cesium 的对接方式
2.1 半变异函数:克里金区别于反距离加权的核心
反距离加权(IDW)的思路很直白:离得近的点权重大,离得远的权重小,权重是距离的倒数。它简单、快,但有个硬伤——它假设空间相关性只由距离决定,而且权重函数是人为指定的。克里金不一样,它先从数据本身估计空间相关性,再用这个相关性去定权重。
这个“从数据估计”的工具叫半变异函数。对任意一对采样点,计算它们距离 h 和数值差的平方的一半,把所有点对按距离分组,得到实验半变异函数。然后拟合一个理论模型(球状、指数、高斯是三种最常见的),模型有三个关键参数:块金值(nugget,距离趋近零时的方差,反映测量误差和微尺度变异)、基台值(sill,距离足够大时变异函数趋于的稳定值)、变程(range,超过这个距离后空间相关性消失)。
变程决定了插值的“影响半径”。如果两个点距离超过变程,它们的权重趋近于零,插值结果会回落到全局均值附近。这就是为什么克里金在数据稀疏区域不会像 IDW 那样产生明显的“牛眼”伪影——它会诚实地告诉你“这里没有信息,我给个均值”。
2.2 普通克里金、简单克里金与泛克里金的选择
普通克里金(Ordinary Kriging)假设数据没有全局趋势,局部均值未知但恒定。这是最常用的默认选项,适合温度、降水这类在局部区域内相对平稳的变量。简单克里金(Simple Kriging)假设全局均值已知且恒定,实际中很少用,因为均值往往不知道。泛克里金(Universal Kriging)假设存在一个确定性趋势(比如温度随纬度线性变化),先拟合趋势再对残差做克里金。如果你的温度数据跨越很大纬度范围,泛克里金更合适;如果只是城市尺度,普通克里金足够。
在 Cesium 场景里,我一般先用普通克里金跑通流程,观察残差图。如果残差呈现明显的方向性梯度,再换泛克里金。不要一上来就上泛克里金,它的参数更多、更容易过拟合。
2.3 从插值网格到 Cesium 纹理的完整链路
Cesium 不能直接渲染一个二维数值数组。你需要把插值结果转成图片,再作为材质贴到一个矩形几何体或影像图层上。链路是这样的:
- 确定插值范围(bounding box)和网格分辨率(比如 256×256 或 512×512)。
- 对每个网格点,用克里金算出预测值。
- 把预测值归一化到 0-255,映射到色带,生成 RGBA 像素数据。
- 用 Canvas 或 OffscreenCanvas 把像素数据画成图片。
- 在 Cesium 里创建一个 Rectangle 几何体,材质用 ImageMaterialProperty 指向这张图片。
这里有个容易翻车的点:经纬度是地理坐标,但插值计算通常在投影平面或直接用经纬度做欧氏距离。如果范围不大(比如一个城市),直接用经纬度算距离误差可以接受;如果跨越几个纬度,必须先把经纬度转成投影坐标(如 Web Mercator)再算距离,否则南北方向和东西方向的距离尺度不一致,插值结果会扭曲。
3. 手写一个可复现的 Cesium Kriging 插值流程
3.1 数据准备与半变异函数拟合
假设你有一份 JSON 数据,每个元素是{lon, lat, value}。先做半变异函数拟合。下面是一段 Python 代码,用scipy做拟合,输出模型参数供前端使用。
import numpy as np from scipy.optimize import curve_fit # 球状模型 def spherical_model(h, nugget, sill, range_): # 避免 range_ 为 0 导致除零 range_ = max(range_, 1e-6) # 当 h < range_ 时按球状公式,否则取基台值 return np.where( h < range_, nugget + (sill - nugget) * (1.5 * h / range_ - 0.5 * (h / range_) ** 3), sill ) def fit_variogram(coords, values, n_bins=15): # coords: Nx2 数组,values: N 数组 n = len(values) dists = [] semivars = [] # 计算所有点对的距离和半变异值 for i in range(n): for j in range(i + 1, n): d = np.sqrt((coords[i, 0] - coords[j, 0]) ** 2 + (coords[i, 1] - coords[j, 1]) ** 2) semivars.append(0.5 * (values[i] - values[j]) ** 2) dists.append(d) dists = np.array(dists) semivars = np.array(semivars) # 按距离分箱取平均 bins = np.linspace(0, dists.max(), n_bins + 1) bin_centers = (bins[:-1] + bins[1:]) / 2 bin_means = np.array([ semivars[(dists >= bins[k]) & (dists < bins[k + 1])].mean() if len(semivars[(dists >= bins[k]) & (dists < bins[k + 1])]) > 0 else np.nan for k in range(n_bins) ]) # 去掉空箱 valid = ~np.isnan(bin_means) bin_centers = bin_centers[valid] bin_means = bin_means[valid] # 拟合球状模型,初始猜测:块金=0,基台=方差,变程=最大距离的一半 p0 = [0, np.var(values), dists.max() / 2] popt, _ = curve_fit(spherical_model, bin_centers, bin_means, p0=p0, maxfev=5000) return popt # [nugget, sill, range]这段代码的逻辑:先暴力计算所有点对的距离和半变异值,然后按距离分箱取平均得到实验半变异函数,最后用非线性最小二乘拟合球状模型。参数说明:n_bins控制分箱数量,太少则曲线粗糙,太多则每个箱内点对太少、噪声大,一般 10-20 之间。p0是初始猜测,块金值初始设为 0,基台值初始设为数据方差,变程初始设为最大距离的一半。如果拟合不收敛,可以尝试换指数模型或高斯模型,或者手动调整初始值。
3.2 构建克里金方程组并求解权重
得到模型参数后,对每个待插值点,构建克里金方程组。普通克里金的方程组是 (n+1)×(n+1) 的矩阵,其中 n 是采样点数量。矩阵元素由半变异函数值构成,最后一行和最后一列是 1,右下角是 0。右边向量是待插值点与各采样点的半变异函数值,最后加一个 1。
// 前端 JavaScript 实现,假设 variogramParams = [nugget, sill, range] function spherical(h, nugget, sill, range) { if (h >= range) return sill; const hr = h / range; return nugget + (sill - nugget) * (1.5 * hr - 0.5 * hr * hr * hr); } function krigingWeights(sampleCoords, sampleValues, targetCoord, params) { const n = sampleCoords.length; const [nugget, sill, range] = params; // 构建矩阵 A (n+1)x(n+1) const A = []; for (let i = 0; i < n; i++) { const row = []; for (let j = 0; j < n; j++) { const dx = sampleCoords[i][0] - sampleCoords[j][0]; const dy = sampleCoords[i][1] - sampleCoords[j][1]; const d = Math.sqrt(dx * dx + dy * dy); row.push(spherical(d, nugget, sill, range)); } row.push(1); // 拉格朗日乘子列 A.push(row); } const lastRow = new Array(n).fill(1); lastRow.push(0); A.push(lastRow); // 构建右边向量 b const b = []; for (let i = 0; i < n; i++) { const dx = sampleCoords[i][0] - targetCoord[0]; const dy = sampleCoords[i][1] - targetCoord[1]; const d = Math.sqrt(dx * dx + dy * dy); b.push(spherical(d, nugget, sill, range)); } b.push(1); // 解线性方程组 A * w = b,这里用高斯消元 const w = solveLinearSystem(A, b); return w; // 前 n 个是权重,最后一个是拉格朗日乘子 } function solveLinearSystem(A, b) { const n = A.length; // 构造增广矩阵 const M = A.map((row, i) => [...row, b[i]]); for (let col = 0; col < n; col++) { // 选主元 let maxRow = col; for (let r = col + 1; r < n; r++) { if (Math.abs(M[r][col]) > Math.abs(M[maxRow][col])) maxRow = r; } [M[col], M[maxRow]] = [M[maxRow], M[col]]; // 消元 for (let r = col + 1; r < n; r++) { const factor = M[r][col] / M[col][col]; for (let c = col; c <= n; c++) { M[r][c] -= factor * M[col][c]; } } } // 回代 const x = new Array(n).fill(0); for (let i = n - 1; i >= 0; i--) { let sum = M[i][n]; for (let j = i + 1; j < n; j++) { sum -= M[i][j] * x[j]; } x[i] = sum / M[i][i]; } return x; }逻辑说明:矩阵 A 的前 n 行 n 列是采样点之间的半变异函数值,第 n+1 行和列是拉格朗日乘子约束(保证权重和为 1)。右边向量 b 的前 n 个是待插值点与采样点的半变异函数值,最后一个是 1。解出权重后,预测值就是sum(weight[i] * sampleValues[i])。参数说明:sampleCoords是采样点坐标数组,targetCoord是待插值网格点坐标,params是半变异函数参数。注意:如果采样点数量超过几百,这个 O(n³) 的求解会变慢,实际项目中可以用 kd-tree 先筛选最近的几十个点参与计算,或者用数值稳定的 Cholesky 分解替代高斯消元。
3.3 生成纹理并挂到 Cesium 矩形上
插值完整个网格后,把数值映射到色带。下面代码把网格数据画到 Canvas,再创建 Cesium 矩形。
function renderKrigingToCesium(viewer, gridData, bounds, colorStops) { // gridData: 二维数组 [rows][cols],bounds: {west, south, east, north} const rows = gridData.length; const cols = gridData[0].length; const canvas = document.createElement('canvas'); canvas.width = cols; canvas.height = rows; const ctx = canvas.getContext('2d'); const imageData = ctx.createImageData(cols, rows); // 找最大最小值用于归一化 let minVal = Infinity, maxVal = -Infinity; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const v = gridData[r][c]; if (v < minVal) minVal = v; if (v > maxVal) maxVal = v; } } for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const v = gridData[r][c]; const t = (v - minVal) / (maxVal - minVal + 1e-9); const color = interpolateColor(colorStops, t); const idx = (r * cols + c) * 4; imageData.data[idx] = color[0]; imageData.data[idx + 1] = color[1]; imageData.data[idx + 2] = color[2]; imageData.data[idx + 3] = 180; // 半透明 } } ctx.putImageData(imageData, 0, 0); const dataUrl = canvas.toDataURL(); // 创建 Cesium 矩形 viewer.entities.add({ rectangle: { coordinates: Cesium.Rectangle.fromDegrees( bounds.west, bounds.south, bounds.east, bounds.north ), material: new Cesium.ImageMaterialProperty({ image: dataUrl, transparent: true }) } }); } function interpolateColor(stops, t) { // stops: [[pos, [r,g,b]], ...] 按 pos 升序 for (let i = 0; i < stops.length - 1; i++) { if (t >= stops[i][0] && t <= stops[i + 1][0]) { const localT = (t - stops[i][0]) / (stops[i + 1][0] - stops[i][0]); return [ Math.round(stops[i][1][0] + localT * (stops[i + 1][1][0] - stops[i][1][0])), Math.round(stops[i][1][1] + localT * (stops[i + 1][1][1] - stops[i][1][1])), Math.round(stops[i][1][2] + localT * (stops[i + 1][1][2] - stops[i][1][2])) ]; } } return stops[stops.length - 1][1]; }逻辑说明:先把网格数据归一化到 0-1,然后按色带插值得到 RGB,写入 ImageData,画到 Canvas,转成 dataURL,最后作为 ImageMaterialProperty 贴到矩形上。参数说明:colorStops是色带定义,比如[[0, [0,0,255]], [0.5, [0,255,0]], [1, [255,0,0]]]表示蓝-绿-红。alpha设为 180 是为了让底图影像还能透出来,具体值按视觉效果调。注意:Canvas 的尺寸就是网格分辨率,如果网格是 512×512,Canvas 也是 512×512,贴到地球上时 Cesium 会自动拉伸,分辨率不够会糊。
4. 避坑与排查:克里金插值在 Cesium 里翻车的五个典型场景
4.1 插值结果出现规则条纹或方块
现象:渲染出来的温度图有明显的网格状条纹,或者色块边界生硬。原因:网格分辨率太低,或者色带映射时用了最近邻插值而不是线性插值。解决:把网格分辨率提高到至少 256×256,色带插值用线性过渡。另外检查 Canvas 的imageSmoothingEnabled是否被意外关闭。
4.2 边缘区域出现异常高值或低值
现象:插值范围边缘的温度值突然飙高或骤降,和内部趋势不连贯。原因:边缘网格点距离最近的采样点很远,克里金权重趋近于零,预测值回落到全局均值,但如果全局均值本身偏离局部趋势,就会显得突兀。解决:把插值范围收缩到采样点凸包内部,或者用泛克里金引入趋势项。另一个办法是在边缘增加虚拟采样点,值取最近的真实点值。
4.3 半变异函数拟合不收敛或参数离谱
现象:拟合出的变程比最大距离还大,或者块金值为负数。原因:数据量太少、分布太集中,或者初始猜测值不合理。解决:先画实验半变异函数散点图,肉眼判断大概的变程和基台值,手动设置初始值。如果数据点少于 30 个,考虑改用 IDW 或增加数据。块金值为负说明模型选择不当,换指数模型试试。
4.4 Cesium 矩形贴图在三维球面上变形
现象:矩形贴图在高纬度地区被拉伸,温度分布看起来被压扁。原因:经纬度矩形在 Cesium 的默认投影下,高纬度地区的东西方向被拉伸。解决:如果研究区域在高纬度,考虑用投影坐标做插值,然后把结果重投影回经纬度。或者接受一定变形,因为温度场本身在大尺度上也有纬度梯度。
4.5 大量采样点时页面卡死
现象:采样点超过 500 个,浏览器主线程卡住几秒甚至崩溃。原因:克里金方程组的 O(n³) 求解在 n=500 时已经需要上亿次运算。解决:用 kd-tree 或网格索引先筛选每个待插值点最近的 30-50 个采样点,只对这些点构建方程组。这样每个网格点的计算量从 O(n³) 降到 O(k³),k 是近邻数量。另外可以把插值计算放到 Web Worker 里,避免阻塞 UI。
5. 进阶技巧:用 Web Worker 加速与色带动态调整
当采样点上千、网格分辨率 512×512 时,主线程插值会卡到无法交互。我一般会把整个插值流程塞进 Web Worker,主线程只负责发数据和收结果。Worker 里用 kd-tree 做近邻筛选,每个网格点只取最近的 40 个采样点参与克里金。实测下来,1000 个采样点、512×512 网格,在普通笔记本上大约 3-5 秒完成,主线程全程不卡。
色带动态调整是另一个实用技巧。温度数据的合理色带范围随季节变化,夏天可能 20-40 度,冬天可能 -10-10 度。如果色带固定,冬天整张图都是蓝色,看不出细节。我一般会在 UI 上放两个滑块控制色带的最小值和最大值,用户拖动时重新映射像素颜色,但不需要重新插值——因为插值结果是数值网格,色带只是数值到颜色的映射。把数值网格缓存在内存里,色带变化时只重绘 Canvas,响应时间在 50ms 以内。
还有一个验证插值质量的方法:留一交叉验证。每次去掉一个采样点,用剩余点插值出该点的预测值,计算预测值和真实值的误差。如果平均误差超过数据标准差的 30%,说明半变异函数模型不合适,需要重新拟合。这个验证在 Python 端做一次就行,不用放到前端。
从那以后我每次做克里金插值,都强制先跑一遍留一交叉验证,再检查半变异函数拟合图,最后才挂到 Cesium 上。这三步走完,基本不会出现“图好看但数值离谱”的情况。希望帮到你。
本文还有配套的精品资源,点击获取