前言
最近碰到了一个需求,需要通过 cesium 直接加载 geotiff 影像图。
咋一听,这个需求好像蛮奇怪,cesium 本身本来就支持加载 tile 影像图,也就是所谓的切片地图。原理其实就是,通过 geoserver 等工具,按照一定的规则和坐标系规则,切好对应的切片。
而 cesium 里面,加载瓦片地图也很简单,想要显示哪个区域的地图,就根据对应的规则,去 geoserver 里请求对应的切片。这些逻辑在 cesium 里面,也已经封装好了,直接调用就好了。
但是如果不想发布到 geoserver,想直接通过 cesium,加载 geotiff 影像文件,来预览影像图呢?
说实话,刚开始碰到这个需求,内心也是没底的,毕竟翻遍了 cesium 的 api,也没有发现,其能支持这种加载方式。
而且,geotiff 影像图的格式,对于我来说,也是一片未知的领域。要不是去年开始接触 cesium 和 geoserver,我根本不知道它的存在。
当然,碰到问题,还是得发挥一个程序员的 geek 精神,先搜索下,看有没有人碰到同样的烦恼。
虽然这种方式不常见,但是,还是有同道中人的,但是结果多不理想,甚至有人直接回复,说不支持这种加载方式。
就在我一度想要放弃的时候,忽然有了灵感。
几个小问题
既然 geotiff 本质上是一张图片,文件不太大的图,甚至直接用一些常见的看图软件就能打开,那么想要贴在 cesium 的 globe 上,又有何难呢?
现在摆在面前的有几个问题:
- 如果 cesium 支持贴 tif 后缀的图,那么皆大欢喜,只要想方设法解析到 geotiff 的坐标范围信息,然后调用 cesium 提供的加载单张图作为图层的 api,再传入范围信息,即可正常的加载该 geotiff 图。
- 如果很不幸,cesium 不支持贴 tif 后缀的图,那么我们就得先解析 geotiff 文件,想办法获取到相关的地理信息和像素信息,拿到像素信息和地理信息以后,像第一种情形一样处理,无非就是多了一步将像素信息处理成 cesium 可以支持的图像信息而已。
- 我们该如何解析出 geotiff 内部的信息呢?
接下来,就让我们对提出的问题,一个个尝试解决方案,如果能够迎刃而解,那么用 cesium 加载影像图,不是如同探囊取物么!
尝试寻找解决方案
我们先来找找,看能否找到前端解析 geotiff 的解决方案。
我们知道,如果用桌面软件,查看 geotiff 图像,很多常见的软件都能支持,大到像 arcgis,小到像 windows 看图,都能查看。
但是前端,是否有现成的工具,可以用来解析 geotiff 图像呢?
带着这样的疑问,开始了我们的探寻之旅。
经过一番尝试以后,发现前端有个开源的库—— geotiff.js ,可以用来解析 .tif 格式的文件。
具体 geotiff.js 的 api,在这里就不做过多的介绍了,有兴趣了解的,可以去看下官方提供的 readme 文件,上面有用法的详细说明。
原始影像图
假设现在有个 geotiff 文件,用 IrfanView 文件打开,是这个样子的:
解析 geotiff 文件
geotiff.js 提供了几种写入读取 二进制文件的方式,为了方便使用,我们就尝试采用 fromBlob 的方式。
我们先调用 geotiff.js 提供的 api,将文件读取成 js 对象,再通过对象提供的 getImage api,获取到图像的相关信息。
const tiff = await fromBlob(blob);
let image = await tiff.getImage();
let [west, south, east, north] = image.getBoundingBox();
const code =
image.geoKeys.ProjectedCSTypeGeoKey ||
image.geoKeys.GeographicTypeGeoKey;
为了准确的把图贴到 cesium 的球面上去,我们必须要先获取到图像的范围,并且要获取到图像采用的是哪种坐标系。
我们测试的这张图,打印出上述信息,发现采用的是 4527 坐标系,范围如图示:
转换坐标的方法
现在问题是,cesium 目前已知的,只支持月球、标准的球体和 WGS84 体系的坐标体系,不支持我们的 CGCS2000 坐标系。
怎么办呢?我们必须能找到一个换算的方式,将我们的坐标换算成 WGS84 坐标体系里的点。
可是,由于本身对 gis 专业相关的基础知识的匮乏,对于坐标体系转换,毫无经验,根本不知道怎么转换该如何是好?
虽然,怎么转化,论文里都有,但是等学会那些,再来解决这个问题,都不知道要等到猴年马月去呀。
不过不要着急,我发现了一个网站支持这种服务,提供了这种转换的接口。
不用自己写转换坐标的算法,岂不是很舒服!
首页长这样:
点击进入这个 transform coordinates 页面:
我们试着输入一个坐标:
返现返回了我们想要的结果,点进去看下位置:
现在问题是,虽然我们能在页面上获取转换结果,但是总不能每次都打开页面,输入地址,来获取转换后的坐标吧?
无妨,我们打开控制台看一下,转换的过程到底经历了写什么。
我们点一下 transform,发现页面发了一个 ajax 请求,里面包含了一些相关的信息
而返回的结果,正是在 4326 体系下,的经纬度坐标信息:
既然有了转换方式,可以转换坐标,那么接下来要做的就很简单了。
通过接口,获取该影像图所表示的地理区域的范围:
let { x: w, y: n } = await (
await fetch(
`//epsg.io/trans?x=${west}&y=${north}&s_srs=${code}&t_srs=4326`
)
).json();
let { x: e, y: s } = await (
await fetch(
`//epsg.io/trans?x=${east}&y=${south}&s_srs=${code}&t_srs=4326`
)
).json();
将 geotiff 像素信息写入 canvas
按理说,走到了这一步后,如果 cesium 支持直接加载 geotiff 图为静态的 图层,是最理想的状态,可惜的是,它并不支持。
既然它不支持,我们就要想办法另辟蹊径了。
// 读取像素信息
const [red = [], green = [], blue = []] = await image.readRasters();
// 将像素信息写入canvas
const canvas = document.createElement("canvas");
let width = image.getWidth();
let height = image.getHeight();
canvas.width = width;
canvas.height = height;
let ctx = canvas.getContext("2d");
let imageData = ctx.createImageData(width, height);
console.time("写入像素");
for (var i = 0; i < imageData.data.length / 4; i += 1) {
imageData.data[i * 4 + 0] = red[i];
imageData.data[i * 4 + 1] = green[i] || 0;
imageData.data[i * 4 + 2] = blue[i] || 0;
imageData.data[i * 4 + 3] = red[i] === 0 ? 0 : 255;
}
ctx.putImageData(imageData, 0, 0);
console.timeEnd("写入像素");
我们可以通过 image 对象提供的 readRasters 接口,将像素信息读取出来,然后写入 canvas,形成一张前端可以操控的图。
在 cesium 中加载
遗憾的是,cesium 的 SingleTileImageryProvider 接口,并不支持对 canvas 的直接载入,需要转换成图片才能进行操作。
我们可以调用 canvas 自带的 toDataURL 将 canvas 转换成图片,然后传进去即可。
let rectangle = Cesium.Rectangle.fromDegrees(w, s, e, n);
let du = canvas.toDataURL();
viewer.imageryLayers.addImageryProvider(
new Cesium.SingleTileImageryProvider({
url: du,
rectangle,
})
);
viewer.camera.setView({
destination: rectangle,
});
这样,我们就成功的将该 geotiff 影像图,直接加载到 cesium 里面去了。
调整颜色
到了这一步,我们要做的差不多就结束了。
但是细心的同学可能会发现,加载到 cesium 里的影像图的颜色跟我们前面用软件打开的时候不太一样。
这是为什么呢?
要理解这个问题,可能需要童鞋们去了解下颜色的构成方式。
这里我们采用的是 rgb 的表示方法。
当我们运行代码的时候,进入调试模式,你会发现,
默认这个影像里面,只存储了 R 的信息,G、B 的信息并没有。
那么怎么处理呢?
其实很简单,只需要改一行代码即可:
const [red = [], green = red, blue = red] = await image.readRasters();
将 green 和 blue 均赋值一个初始值,等于 red 即可。
然后,我们再次尝试运行一下代码,就会得到下图所示的场景了:
此刻,细心的童鞋就会发现,这与我们之前打开的图一般无二了。
后记
当然,有一些情况,我们这里并没有考虑到,有兴趣的同学可以自己研究下:
- 一般情况下,geotiff 影像图都非常的大,我们的示例并未考虑到影像图的大小对系统的影响。
- 我们这里只考虑了单文件的情况,有时候,geotiff 的表示形式,存在多文件的情况。
- 可以尝试对配色进行修改,从而调出不同的风格的影像图,这是个很酷的功能。