ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Cesium 三维温度场可视化:Kriging 插值实战与避坑指南

Cesium 三维温度场可视化:Kriging 插值实战与避坑指南

简介:这份资源面向需要在 Web 三维场景中呈现连续温度场的前端与 GIS 开发者,围绕 Cesium 与克里金插值展开,解决离散温度测点难以平滑表达的问题。包内共 585 个文件,以 321 个 js 脚本、141 个 png 与 53 个 jpg 图像、29 个 json 配置、27 个 css 样式为主,另有 svg、wasm、html 等辅助文件,压缩包约 7.27MB,整体为可直接运行的 Cesium 工程结构。资源将克里金插值用于把不规则分布的温度测量值转换为连续温度场,使三维空间中的温度变化得以平滑展现,读者可据此理解变异函数分析与插值流程,并参考工程组织方式完成地形渲染、影像叠加与矢量展示等模块的整合。已有 167 人学习,适合希望掌握空间插值可视化实现思路的开发者参考。

1. 从一张“温度热力图”说起:Cesium 里怎么把离散气象站数据铺成连续色带

去年冬天帮一个做区域气象可视化的团队看代码,需求很朴素:地图上散着几十个自动站,每个站有实时温度,要在 Cesium 的三维地球上画出一张平滑的、随缩放跟着走的温度分布图。他们一开始用的是 IDW 反距离加权,结果城区站点密集的地方还行,山区站点稀疏的地方直接糊成一片,等值线歪得没法看。后来换成克里金插值(Kriging),效果立刻不一样了——它不只是按距离加权,还考虑了空间自相关性,能给出更符合地理规律的连续曲面。这个项目做的就是这件事:把 Cesium 的三维渲染能力和 Kriging 插值算法接起来,让离散的温度点变成一张可交互的温度场。适合谁看?手里有站点数据、想在三维地图上做连续场渲染的前端或 GIS 开发,尤其是被 IDW 效果坑过、想上克里金但不知道从哪下手的人。

2. 技术选型与整体架构:为什么是 Cesium + Kriging,而不是别的组合

2.1 渲染层选 Cesium 的硬理由

三维地球场景里,能同时处理地形、影像、矢量、自定义图元,并且开源可商用的,Cesium 基本是默认答案。这个项目需要温度场贴着地形起伏走,还要能随相机缩放、旋转实时更新,用二维 Canvas 叠上去会穿帮。Cesium 的Entity、Primitive、ImageryLayer三套体系里,温度场这种“整片区域连续着色”的需求,最稳的落法是生成一张栅格图,挂成ImageryLayer或者贴到Rectangle图元上。这样既吃到了 Cesium 的瓦片调度,又不用自己写 WebGL 着色器去逐像素算插值——插值在 CPU 端算一次,出图,交给 GPU 贴。

2.2 插值层选 Kriging 的取舍

Kriging 家族里常见的有普通克里金(Ordinary Kriging)、泛克里金、协同克里金。温度场这种没有明显全局趋势、局部平稳的变量,普通克里金够用,计算量也可控。它的核心是半变异函数(semivariogram):先根据样本点两两之间的距离和温度差拟合出一个模型(球状、指数、高斯),再用这个模型给每个待插值像素算权重。相比 IDW 的“距离越近权重越大”,Kriging 多了一步“空间结构建模”,这就是它在稀疏区不糊的关键。

技术栈上,前端是原生 JavaScript + Cesium,插值算法用成熟的 kriging.js(一个轻量的 JS 克里金实现),数据用 JSON 或 GeoJSON 喂进来。没有上框架,是因为这类可视化项目往往要嵌进已有的 GIS 平台,保持轻量反而好集成。

2.3 一次渲染的完整链路

把链路拆开看,从数据到屏幕是这么走的:

// 1. 准备样本:站点经纬度 + 温度值 const samples = [ { lng: 116.40, lat: 39.90, value: 3.2 }, { lng: 116.52, lat: 39.95, value: 2.8 }, // ... 几十个站点 ]; // 2. 转成 kriging.js 需要的格式:[x, y, value] const values = samples.map(s => [s.lng, s.lat, s.value]); // 3. 训练变差函数模型(球状模型,参数由 fit 自动拟合) const model = 'spherical'; const sigma2 = 0; // 块金效应,先给 0 让 fit 自己估 const alpha = 100; // 变程,单位是度,按区域尺度调 const krigingModel = kriging.train(values, 0, 0, model, sigma2, alpha); // 4. 在网格上逐点预测,生成栅格 const gridWidth = 256, gridHeight = 256; const canvas = document.createElement('canvas'); canvas.width = gridWidth; canvas.height = gridHeight; const ctx = canvas.getContext('2d'); const imageData = ctx.createImageData(gridWidth, gridHeight); for (let y = 0; y < gridHeight; y++) { for (let x = 0; x < gridWidth; x++) { // 把像素坐标映射回经纬度 const lng = minLng + (maxLng - minLng) * (x / gridWidth); const lat = minLat + (maxLat - minLat) * (y / gridHeight); const t = kriging.predict(lng, lat, krigingModel); // 温度映射到色带,写入 RGBA const [r, g, b] = tempToColor(t); const idx = (y * gridWidth + x) * 4; imageData.data[idx] = r; imageData.data[idx + 1] = g; imageData.data[idx + 2] = b; imageData.data[idx + 3] = 200; // 半透明,露出底图 } } ctx.putImageData(imageData, 0, 0); // 5. 挂到 Cesium 上,贴到指定矩形范围 viewer.imageryLayers.addImageryProvider( new Cesium.SingleTileImageryProvider({ url: canvas.toDataURL(), rectangle: Cesium.Rectangle.fromDegrees(minLng, minLat, maxLng, maxLat), }) );

这段代码是整个项目的骨架。kriging.train的四个关键参数值得说清楚:model决定半变异函数形状,温度场一般用spherical或exponential;sigma2是块金值,表示微观尺度上的随机误差,给 0 让算法自己拟合通常更稳;alpha是变程,意思是“超过这个距离,两点就基本不相关了”,单位跟你的坐标单位一致——这里用经纬度,所以是度,如果换成投影坐标就是米,这个单位搞错是新手最常见的翻车点。kriging.predict返回的是该点的预测值,逐像素算完写进ImageData,最后用SingleTileImageryProvider贴图,Cesium 会自动处理投影和缩放。

2.4 目录结构长什么样

这类项目不需要复杂工程化,一个能直接打开的静态目录就够:

cesium-kriging-temp/ ├── index.html # 入口,挂 Cesium viewer ├── js/ │ ├── main.js # 初始化场景、加载数据、触发渲染 │ ├── kriging.js # 克里金算法库(第三方,可替换) │ ├── interpolate.js # 封装 train/predict/出图逻辑 │ └── colorScale.js # 温度到颜色的映射 ├── data/ │ └── stations.json # 站点样本数据 └── lib/ └── Cesium/ # Cesium 静态资源

interpolate.js是二次开发最常动的地方,把“训练模型—网格预测—生成 canvas”三步封成一个函数,main.js只负责调它。colorScale.js单独拆出来,是因为色带方案经常要按业务换(比如零下用蓝、零上用红),拆开后改色不用碰插值逻辑。

3. 核心模块拆解:从样本训练到栅格出图的每一步

3.1 半变异函数拟合:Kriging 的“黑匣子”在这里

很多人用 kriging.js 只会调train,不知道里面发生了什么,结果参数一错就出鬼图。train做的事是:把所有样本点两两配对,算出距离h和温度差的半方差γ(h) = 0.5 * (z_i - z_j)^2,按距离分箱求平均,得到一张经验半变异函数散点图,再用选定的模型去拟合这条曲线。拟合出来的三个参数——块金、基台、变程——决定了后续预测的权重分配。

// kriging.js 内部拟合的简化逻辑(帮助理解,非源码逐行) // 经验半变异函数:按距离分箱 const bins = {}; for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { const h = distance(values[i], values[j]); const gamma = 0.5 * Math.pow(values[i][2] - values[j][2], 2); const bin = Math.round(h / binSize); (bins[bin] = bins[bin] || []).push(gamma); } } // 每个 bin 取平均,得到 (h, γ) 点对,再对模型做最小二乘拟合

这段逻辑说明一件事:样本点太少(比如少于 10 个)或者分布极不均匀时,经验半变异函数根本拟合不出可靠曲线,预测结果会剧烈抖动。我一般会在训练前先做个检查,样本数低于 15 就退回 IDW 兜底,别硬上 Kriging。

3.2 网格分辨率与性能的平衡

gridWidth和gridHeight直接决定计算量:256×256 就是 65536 次predict调用,每次 predict 要遍历所有样本算权重,复杂度是 O(像素数 × 样本数)。几十个样本、256 网格,现代浏览器一两秒能出图;但如果网格拉到 1024,样本上百,就会卡到主线程冻结。

常见做法是把网格控制在 256 到 512 之间,视觉上已经足够平滑,因为 Cesium 贴图本身会做双线性插值放大。真要更高精度,就把预测挪到 Web Worker 里,主线程只负责收结果贴图:

// worker 里跑插值,避免阻塞渲染 const worker = new Worker('js/interpolate.worker.js'); worker.postMessage({ samples, bounds, gridSize: 512 }); worker.onmessage = (e) => { const { imageData } = e.data; // 主线程拿到 ImageData,转 dataURL 贴图 const canvas = document.createElement('canvas'); canvas.width = imageData.width; canvas.height = imageData.height; canvas.getContext('2d').putImageData(imageData, 0, 0); addTemperatureLayer(canvas.toDataURL()); };

参数上,bounds是插值区域的外包矩形[minLng, minLat, maxLng, maxLat],必须比样本范围略大一圈,否则边缘站点外侧会出现一条没有数据的硬边。这个“外扩”我一般取样本跨度的 5% 到 10%。

3.3 色带映射:温度场好不好看,一半靠它

插值出的是数值,人眼要看的是颜色。colorScale.js里做的是分段线性映射,把温度区间切成若干档,每档对应一个 RGB:

// 温度色带:-10℃ 深蓝 → 0℃ 青 → 10℃ 黄 → 30℃ 红 const stops = [ { t: -10, color: [30, 60, 180] }, { t: 0, color: [80, 180, 220] }, { t: 10, color: [250, 220, 90] }, { t: 30, color: [220, 60, 40] }, ]; function tempToColor(t) { // 夹到区间内,防止极端值越界 t = Math.max(stops[0].t, Math.min(stops[stops.length - 1].t, t)); for (let i = 0; i < stops.length - 1; i++) { const a = stops[i], b = stops[i + 1]; if (t >= a.t && t <= b.t) { const k = (t - a.t) / (b.t - a.t); return [ Math.round(a.color[0] + (b.color[0] - a.color[0]) * k), Math.round(a.color[1] + (b.color[1] - a.color[1]) * k), Math.round(a.color[2] + (b.color[2] - a.color[2]) * k), ]; } } return stops[stops.length - 1].color; }

这里有个容易忽略的点:alpha 通道别给满。温度场是覆盖层,全不透明会把底图地形和路网全盖死,给 180 到 210 之间,既看得清色带又能透出地理信息。另外色带断点最好跟业务对齐,比如农业场景关心 0℃ 霜冻线,那 0℃ 那一档就要卡得准,别用等间距硬切。

3.4 贴图挂载与范围对齐

最后一步是把 canvas 变成 Cesium 能认的图层。SingleTileImageryProvider适合单张整图,rectangle参数必须和插值时用的bounds完全一致,否则色带会跟站点位置错位——这是排查时最隐蔽的 bug,图看着没问题,但某个站点的颜色跟它的实际温度对不上,八成就是这里差了一点。

const provider = new Cesium.SingleTileImageryProvider({ url: canvas.toDataURL('image/png'), rectangle: Cesium.Rectangle.fromDegrees( bounds[0], bounds[1], bounds[2], bounds[3] ), tileWidth: 512, tileHeight: 512, }); viewer.imageryLayers.addImageryProvider(provider);

tileWidth/tileHeight设成和 canvas 一致,避免 Cesium 内部再做一次重采样导致模糊。如果温度场要随时间刷新(比如逐小时数据),记得在加新图层前把旧图层 remove 掉,不然图层会越叠越多,内存一路涨。

4. 避坑与排查:这几个坑我踩过,你别再踩

4.1 现象:整张图一片纯色,没有任何渐变

原因通常是alpha变程参数给得离谱。如果坐标用经纬度(度),变程给成 100,意味着“100 度以内都强相关”,整个区域被当成一个点,预测值全趋近样本均值,自然一片纯色。解决:变程按实际区域尺度给,几十公里量级的区域,经纬度变程大约 0.5 到 2 度,先用样本跨度的 1/3 试,再微调。

4.2 现象:边缘出现锯齿状硬边或空白条

原因是插值bounds和贴图rectangle不一致,或者 bounds 没外扩。样本最外圈站点之外没有数据支撑,预测值会快速衰减。解决:bounds 在样本外包矩形基础上外扩 5% 到 10%,并且保证插值和贴图用的是同一个 bounds 变量,别在两处各写一遍。

4.3 现象:页面卡死几秒,缩放都卡

原因是主线程同步跑了高分辨率网格的逐点预测。解决:网格降到 256 起步,或者把插值搬进 Web Worker;另外predict内部每次都要重算样本权重矩阵,样本多时可以在训练后缓存模型,别每次刷新都重新train。

4.4 现象:温度值明显异常,比如出现 -999 或远超合理范围

原因是样本数据里混进了缺测标记(气象数据常见 -999、9999 这类哨兵值),直接喂进train会把半变异函数带偏。解决:训练前做一次清洗,过滤掉超出物理合理区间的值,缺测站点直接剔除,别用 0 填充——0℃ 在温度场里是有意义的值,填充会污染插值。

4.5 现象:切换数据源后色带对不上,同一温度颜色变了

原因是色带的 min/max 写死了,换区域后温度区间变了,映射关系就错位。解决:色带断点要么按业务固定(如霜冻线),要么每次根据当前样本的 min/max 动态生成,但动态生成时要把图例一起更新,否则用户看到的颜色没有参照。

5. 二次开发:把温度场换成任意标量场,以及几个实用改造

这套代码的插值部分其实跟“温度”无关,任何带经纬度的标量数据都能套:PM2.5 浓度、土壤湿度、噪声分贝、降雨量,改的只是colorScale.js的断点和单位。我一般会先把interpolate.js抽成一个不依赖 Cesium 的纯函数模块,输入samples + bounds + gridSize,输出ImageData,这样同一套插值逻辑既能给 Cesium 用,也能给二维地图或导出图片用,复用性立刻上来。

第二个改造点是时间序列。气象数据往往是逐小时的,与其每次重新train,不如把模型训练和网格预测拆开:模型在数据更新时训练一次缓存起来,预测按需触发。下面这个结构我用了很多次,稳:

// 模型缓存:同一批样本只训练一次 let cachedModel = null; let cachedSampleHash = ''; function getModel(samples) { const hash = JSON.stringify(samples.map(s => [s.lng, s.lat, s.value])); if (hash === cachedSampleHash && cachedModel) return cachedModel; const values = samples.map(s => [s.lng, s.lat, s.value]); cachedModel = kriging.train(values, 0, 0, 'spherical', 0, estimateAlpha(samples)); cachedSampleHash = hash; return cachedModel; } // 变程按样本跨度自适应,省去手调 function estimateAlpha(samples) { const lngs = samples.map(s => s.lng); const lats = samples.map(s => s.lat); const span = Math.max( Math.max(...lngs) - Math.min(...lngs), Math.max(...lats) - Math.min(...lats) ); return span / 3; }

estimateAlpha这个自适应变程是我被手调参数折磨之后加的,样本跨度除以 3 是个经验值,大多数区域气象场景直接能用,省掉一轮试错。验证方法也简单:拿几个没参与训练的站点做留一交叉验证,看预测值和实测值的平均绝对误差,误差明显偏大就说明变程或模型选得不对,换exponential再试一轮。

还有一个容易被忽略的改造:图例。温度场贴上去之后,用户不知道颜色对应多少度,得在角落放一个渐变色条加刻度。这个用普通 DOM 叠在 Cesium 容器上就行,别用 Cesium 的 HTML 覆盖物去折腾,简单直接。图例的断点必须和colorScale.js里的stops同源,我一般让colorScale.js导出一个getLegendStops(),图例和渲染共用一份配置,改一处两边同步。

最后说个血泪经验:这类插值可视化,最耗时间的从来不是算法本身,而是坐标系和范围对齐。经纬度、投影坐标、Cesium 的弧度,三套单位混着用,稍不留神就错位。从那以后我每次接新数据源,都强制先把样本点用 Cesium 的PointPrimitive打在地图上,肉眼确认位置对了,再开插值——这一步花两分钟,能省掉后面两小时的排查。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表