做遥感的人应该都有这种经历:在GEE里兴冲冲加载出一景影像,放大一看,研究区正中间盖着一大片云;要么就是算NDVI时跑出来一堆几千上万的值,阈值分割根本没法下手。这个标题里的三件事——影像加载、去云、波段系数转换,拆开看都不难,但一旦想串成一条批处理流程,就有一堆细节容易翻车。这篇东西就沿着"加载→去云→系数转换"这条链路,把每个环节的原理、代码、验证方法和坑一次讲透,适合刚接触GEE、或者流程能跑通但对掩膜逻辑和数值口径还有疑惑的读者。
1. 先弄清楚:加载、去云、系数转换到底是什么关系
1.1 一张遥感影像的"存储态"和"物理态"
很多人在GEE里第一次加载影像时,会习惯性地以为看到的数值就是反射率。实际上,绝大多数光学卫星产品在分发时,为了压缩存储空间和避免浮点误差,会把物理量(反射率、辐射亮度)线性换算成16位整数保存。比如Landsat 8 Collection 2 Level-2的地表反射率产品,原始DN值范围通常是0到65535,真实反射率却是0到0.4左右的小数,中间差了好几个数量级。
这意味着你在GEE控制台里随便点开一个像素,看到的值大概率是几千这种量级。如果直接拿这个数去做NDVI、去设阈值、去训练分类器,结果一定离谱。所以"波段系数转换"不是锦上添花的细节,而是所有定量分析的前置条件。
去云则是另一道绕不开的工序。光学遥感依赖太阳反射信号,云层会把地表信息完全遮挡,云影还会让周边的像元值异常偏低。不把云和云影剔除,后面的指数计算、分类、变化检测全都会被污染。更麻烦的是,云污染不是全局性的,一景影像可能只有20%的面积被云盖住,但如果你不做逐像元处理,这20%的坏像元就会混在结果里,像一颗老鼠屎坏了一锅汤。
1.2 云是光学影像最大的脏数据来源
说云是光学遥感最大的脏数据源,一点不夸张。NASA的统计里,全球平均云覆盖率常年过半,很多地区想找到一景云量低于10%的影像都费劲。正因为这样,GEE里几乎所有光学影像集合都自带了云量评估元数据,像Landsat的CLOUDY_PIXEL_PERCENTAGE、Sentinel-2的CLOUDY_PIXEL_PERCENTAGE,目的就是让你在加载阶段就能把多云影像筛掉。
但要注意,这个元数据给的是整景影像的云量,不是针对你的研究区。举个例子,你的ROI只有山脚下一小块,影像全局云量20%,但云恰好全压在你的ROI上面,这时候按云量筛选照样会选中一张"全域不算多云、局部全是云"的废数据。所以真正的去云一定要落到像元级别——用QA波段、SCL波段或云概率波段生成掩膜,把每个云像元精确剔掉。
1.3 GEE的延迟计算逻辑逼你写"处理函数"
GEE和传统GIS软件最大的区别,是它不会在你加载数据时把影像拉到本地,而是把一系列计算逻辑推到云端执行。你在Code Editor里写的filter、map、reduce,本质上都是在构建一个计算图,只有到最后Map.addLayer、Export或者getInfo触发时,云端才会真正跑计算。
这个机制带来的直接后果是:你不能像ArcGIS那样拿着一景影像慢慢试,你得把整个处理流程写成可复用的函数,然后map到影像集合上批量执行。加载、去云、系数转换这三件事,因此不是三个独立步骤,而是要打包进同一个预处理函数里的一次性操作。我在实际项目里习惯把它们合成一个preprocess函数,后面所有分析都从预处理后的集合开始,这样既省事又不容易漏步骤。
2. 影像加载的正确打开方式:集合筛选而不是单景拉取
2.1 GEE的数据结构是ImageCollection,不是文件夹
初学GEE的人最容易犯的错,是想直接加载"一张影像"。但GEE里绝大多数业务数据都以ImageCollection(影像集合)为组织单位,你面对的不是单景遥感影像,而是一个按时间排列、按轨道拼接的影像序列。比如Landsat 8的Surface Reflectance产品,一个数据集的ID是LANDSAT/LC08/C02/T1_L2,里面包含了成千上万景经过几何校正、大气校正的影像。
正确的加载方式,是先建立或选择一个ImageCollection,再用一系列filter把它缩小到你需要的时空范围。单景加载虽然也支持(直接用ee.Image('LANDSAT/LC08/C02/T1_L2/LC08_xxx')),但在实际项目中几乎用不到,因为你做去云也好、做合成也好,面对的都是一个时间窗口里的多期影像,不可能只盯着一景。
2.2 时间、空间、云量三个筛选条件怎么组合
影像集合筛选最核心的三个维度就是时间、空间、云量。时间用filterDate('2023-01-01', '2023-12-31'),空间用filterBounds(roi),云量用filterMetadata('CLOUDY_PIXEL_PERCENTAGE', 'less_than', 10)。
这里有几个细节值得注意。第一,filterBounds的roi可以是ee.Geometry点、线、面,也可以是ee.FeatureCollection,GEE会自动计算其包围盒做交集筛选,所以不用担心几何形状复杂。第二,日期格式严格要求是YYYY-MM-DD,传'2023-1-1'这种不带前导零的格式容易报错。第三,云量阈值没有固定标准,我习惯先设10,如果筛选后影像太少再放宽到20,这是一个需要根据研究区和季节反复试的参数。
还有一个常用的组合技巧:按云量排序后取第一景。GEE里可以这样写——collection.sort('CLOUDY_PIXEL_PERCENTAGE').first()。sort默认升序排列,云量最小的排在最前面,first()直接取出最少云的一景。这在快速预览某个区域时可太方便了,不需要手动翻列表。
2.3 我常用的筛选模板和排序取景技巧
下面是我在Code Editor里最常用的一个筛选模板,直接可跑:
var roi = ee.Geometry.Point([115.5, 32.6]).buffer(10000); var l8 = ee.ImageCollection('LANDSAT/LC08/C02/T1_L2') .filterBounds(roi) .filterDate('2023-01-01', '2023-12-31') .filterMetadata('CLOUDY_PIXEL_PERCENTAGE', 'less_than', 20); print('筛完还剩几景:', l8.size()); var leastCloud = l8.sort('CLOUDY_PIXEL_PERCENTAGE').first(); print('最干净的一景,云量:', leastCloud.get('CLOUDY_PIXEL_PERCENTAGE')); Map.centerObject(roi, 9);把坐标换成你自己的研究区就能用。这里我用了buffer(10000)把点变成10km半径的圆,方便预览。打印size()这一步非常关键,因为有时你会发现筛选条件太苛刻,直接把你需要的影像全过滤没了,不打印出来根本不知道。
3. 去云的核心机制:QA波段、位运算与updateMask
3.1 为什么要逐像元去云:云量百分比只代表整景
前面提到过,CLOUDY_PIXEL_PERCENTAGE只是整景的宏观云量,真正想拿到一块干净的研究区,必须做逐像元掩膜。GEE里大部分光学数据产品都内置了质量评估波段,Landsat叫QA_PIXEL,Sentinel-2叫SCL和MSK_CLDPRB。这些波段记录了每个像元的质量标记,比如是否是云、是否是云影、是否被雪覆盖。
逐像元去云的逻辑就是:读QA波段,判断某个像元是否命中"云"或"云影"标记,如果命中,就把这个像元在影像中的数值设为无效。这里涉及的函数是updateMask,它是GEE遥感处理里最核心的掩膜工具,值为1的位置保留原数据,值为0的位置变成空值(Nodata),后续计算会直接跳过这些位置。
3.2 Landsat QA_PIXEL的bit位怎么读,代码怎么写得稳
Landsat Collection 2的QA_PIXEL波段是16位整数,每一位都代表一个质量标记。查阅产品说明后会知道,Bit 1表示云是否扩张,Bit 3表示云,Bit 4表示云影,Bit 5表示雪,Bit 6表示晴空。在JavaScript里用左移运算符和位与运算来提取这些位:
function maskL8sr(image) { var qa = image.select('QA_PIXEL'); var cloudBit = 1 << 3; var shadowBit = 1 << 4; var snowBit = 1 << 5; var mask = qa.bitwiseAnd(cloudBit).eq(0) .and(qa.bitwiseAnd(shadowBit).eq(0)) .and(qa.bitwiseAnd(snowBit).eq(0)); return image.updateMask(mask); }这段代码的意图是:只要像元在Bit 3、Bit 4、Bit 5中有一个不为0,就认为它命中了云、云影或雪,掩膜值设为0,剔掉。为什么要连雪一起掩膜?因为在时间序列分析中,雪和云一样会掩盖地表信号,且雪的光谱特征在某些区域与云高度相似,不一起处理后期会出各种妖蛾子。
位运算看起来有点吓人,其实逻辑很简单:1 << 3就是二进制1000,bitwiseAnd就是逐位求交集,eq(0)判断该位是否为0,即"没有云"。
3.3 Sentinel-2的SCL波段和云概率阈值选哪个
如果你用的是Sentinel-2,去云思路就完全不同了。Sentinel-2 Level-2A产品自带SCL(Scene Classification)波段,它对每个像元做了地物分类,数值和含义的对应关系大致是:0无数据、1饱和/缺陷、2暗部、3云影、4植被、5非植被、6水体、7低云/未分类、8中概率云、9高概率云、10薄卷云、11雪。
最稳的SCL去云写法,是把3、8、9、10这几个云相关类别剔除:
var scl = image.select('SCL'); var cloudMask = scl.neq(3).and(scl.neq(8)) .and(scl.neq(9)).and(scl.neq(10)); return image.updateMask(cloudMask);另外一种常见做法是使用MSK_CLDPRB波段,也就是云概率图。这个波段的值从0到100,代表每个像元是云的概率。很多人习惯用50作为阈值,把云概率高于50%的像元全部剔除。我实测下来,SCL方法的结果更符合视觉判断,因为它是基于场景分类的,而云概率阈值法对薄云的敏感性不太稳定,调到30能误杀很多边缘像元,调到60又容易漏掉高山薄云。所以我的习惯是优先用SCL,只有做批量生产、需要统一口径时才会用固定云概率阈值。
3.4 updateMask之后为什么出现黑边,怎么收尾
updateMask之后影像会出现黑色或透明的区域,这是完全正常的,因为被掩膜掉的像元变成了空值。问题在于,如果你打算对单景影像做可视化,黑边会很难看;如果你打算导出,空值区域会被写成特定填充值,后期还得处理。
我习惯的收尾方式是:不直接用单景做分析,而是把预处理函数map到整个集合上,然后用median()做多时相合成。中值合成的好处是,同一位置只要有几期影像覆盖,云掩膜留下的空洞就能被其他时相的干净像元填补。用完median()之后你会发现,大量的黑边自然消失了,只剩下那些在所有影像里都无效的顽固区域。
4. 波段系数转换:把整数DN值还原成真实反射率
4.1 scale factor从哪里来,各产品都是多少
波段系数转换的核心是scale factor,也就是缩放系数。不同数据集、不同级别的产品,系数完全不同,用错了结果直接废掉。我把常用数据集整理成了表格,方便对照:
| 数据集 | 典型波段 | scale factor | offset |
|---|---|---|---|
| Landsat 8 Collection 2 SR | SR_B2-SR_B7 | 0.0000275 | -0.2 |
| Landsat 9 Collection 2 SR | SR_B2-SR_B7 | 0.0000275 | -0.2 |
| Sentinel-2 Level-2A SR | B2-B12 | 0.0001 | 0 |
| MODIS反射率产品(典型) | Band 1-7 | 0.0001 | 0 |
看到Landsat那行的offset是-0.2了吗?这是Collection 2产品加的一个固定偏移,很多人只记得乘以0.0000275,忘了减0.2,结果反射率整体偏高0.2。换算公式很简单:真实反射率 = DN值 × scale + offset。
需要注意,不同批次、不同采集级别的产品系数会变。最稳妥的办法是别死记硬背,直接在GEE里打印波段元数据:print(image.select('SR_B4')),控制台会列出该波段的scale和offset属性,以产品文档为准。
4.2 转换必须放在指数计算之前,顺序错一步全盘错
波段系数转换这件事,最大的坑不在转换本身,而在转换的时机。很多人加载完影像,随手就调用normalizedDifference算NDVI,完全没想过自己用的还是DN值。NDVI的公式是(NIR-RED)/(NIR+RED),它本身是个比值,理论上可以消除一定的大气影响,但前提是两个波段的值域是匹配的。DN值和反射率之间的缩放关系虽然对每个波段是线性的,可一旦RED和NIR的缩放系数不一致,比值就会严重失真。
我帮朋友排查过不少这类问题,症状高度统一:NDVI计算结果不是-1到1之间,而是跑到几百甚至几万,然后他还奇怪为什么阈值分割出来的东西全是噪点。解决办法只有一个——先乘系数,再算指数,顺序不能变。
正确的GEE写法是:
var sr = image.select(['SR_B2','SR_B3','SR_B4','SR_B5','SR_B6','SR_B7']) .multiply(0.0000275) .add(-0.2);这样得到的SR_B4、SR_B5就是真实反射率了,后续再算NDVI,结果才会落在合理区间。
4.3 从系数转换到波段运算:NDVI其实就是一种波段系数组合
既然提到了波段系数转换,就不得不延伸一下:所谓"波段运算",本质上就是把不同波段乘以不同系数再加加减减、乘乘除除。NDVI也好,EVI也好,甚至各种水体指数、建筑指数,本质上都是波段系数组合成新波段的过程。
GEE里可以用expression把公式写出来,也可以直接用内置的normalizedDifference(['SR_B5', 'SR_B4'])。我更推荐后者,因为它在底层处理了浮点和边缘情况,代码也干净。但有个前提,就是你传给它的波段必须是已经做过系数转换的反射率波段,否则一切免谈。
理解这一点之后,你会发现"波段系数转换"远远不只是乘以一个数字那么简单,它代表的是"从原始数据到物理量、再从物理量到专题指数"的完整数据链路。这条链路走通了,后面的分析才有意义。
5. 完整示例:一次写完加载、去云、缩放、NDVI和可视化
5.1 可直接运行的Code Editor代码
把前面所有内容串起来,下面这段代码是我在实际项目中反复使用的一个完整模板,覆盖了标题里说的全部操作:
var roi = ee.Geometry.Point([115.5, 32.6]).buffer(10000); // 第一步:加载影像集合,按时间、空间、云量筛选 var l8 = ee.ImageCollection('LANDSAT/LC08/C02/T1_L2') .filterBounds(roi) .filterDate('2023-01-01', '2023-12-31') .filterMetadata('CLOUDY_PIXEL_PERCENTAGE', 'less_than', 20); // 第二步:定义预处理函数,去云+波段系数转换 function preprocess(image) { // QA_PIXEL去云 var qa = image.select('QA_PIXEL'); var cloudBit = 1 << 3; var shadowBit = 1 << 4; var snowBit = 1 << 5; var mask = qa.bitwiseAnd(cloudBit).eq(0) .and(qa.bitwiseAnd(shadowBit).eq(0)) .and(qa.bitwiseAnd(snowBit).eq(0)); // 波段系数转换 var sr = image.select(['SR_B2','SR_B3','SR_B4','SR_B5','SR_B6','SR_B7']) .multiply(0.0000275) .add(-0.2); return sr.updateMask(mask); } // 第三步:对所有影像执行预处理,再用median合成 var processed = l8.map(preprocess); var composite = processed.median(); // 第四步:计算NDVI var ndvi = composite.normalizedDifference(['SR_B5', 'SR_B4']).rename('NDVI'); // 第五步:可视化 Map.centerObject(roi, 10); Map.addLayer(composite, {bands: ['SR_B4', 'SR_B3', 'SR_B2'], min: 0, max: 0.3}, 'True color'); Map.addLayer(ndvi, {bands: ['NDVI'], min: -1, max: 1, palette: ['blue', 'white', 'green']}, 'NDVI');这段代码虽然短,但已经是一个完整可用的生产级预处理链路:筛选已知云量低的影像,逐景做QA掩膜和反射率还原,多时相中值合成消除残余云影,最后输出NDVI。
5.2 运行结果解读:为什么min、max要设成这些值
代码里的真彩色图层拉伸参数是min: 0, max: 0.3,NDVI是min: -1, max: 1。这两个参数绝对不是随便填的。真彩色波段经过系数转换后,蓝绿红的反射率一般都在0到0.3之间,植被区域绿色波段和近红外波段稍高,水体区域整体偏低。设成0到0.3,刚好能覆盖绝大多数地表反射率区间,图像对比度合适。
很多人做完系数转换后一可视化发现全黑或全白,原因就是忘了改拉伸范围。如果不乘以0.0000275,DN值的范围是0到几千,你用默认的拉伸自然什么都看不清;乘以系数后反射率落在0到0.3,再用旧的拉伸参数一样什么都看不清。所以参数和数据处理是配套的,这一步最能检验你前面的转换有没有做对。
NDVI的合理范围就是-1到1,水体为负,裸土接近0,植被集中在0.2到0.8之间。如果跑出来的结果超出了这个区间,不用怀疑,一定是前面某个环节没做对。
5.3 从单景走向时间序列合成
上面代码里我用的是median()合成,这在GEE里是最经典的多时相去云策略。它的逻辑是:每个像元位置取这段时间内所有有效值的中位数,因为云像元已经被掩膜成了空值,不参与统计,所以最后的结果基本上没有云和云影,比单景干净得多。
如果不想合成,想看某一景单景的处理效果,把processed.first()拿来做可视化就行。如果想进一步做月合成、季合成,可以在filterDate上加循环,或者在集合上先按月份分组再合成。掌握了前面这个预处理函数,这些扩展都是在它基础上加一层map操作而已,不会增加多少代码量。
6. 我踩过的三个坑,以及最后沉淀的固定套路
6.1 bit位记反:把云影当成了云,研究区出现片状空洞
第一次写Landsat去云代码时,我把Bit 3当成云影、Bit 4当成云,搞反了。当时看控制台掩膜图,感觉也有东西被剔掉,但没细想。后来做时间序列分析时发现,某些年份的研究区总出现不规则的片状空洞,排查了半天才发现是云影掩膜和云掩膜用反了。实际上Bit 3是云,Bit 4才是云影,如果反过来,有些亮度高但并非云的像元被误删,而真正的云影却漏掉了。
这个坑的教训是:写位运算之前先查数据产品文档,不要凭感觉。验证方法也很简单,找一景你肉眼能明显看到云和云影的影像,把掩膜结果在Map上叠加显示,看剔除的区域是不是和视觉判断一致。
6.2 云量筛选过了还是有云,因为没做逐像元掩膜
另一个常见问题是,有人只用CLOUDY_PIXEL_PERCENTAGE筛选出低云影像,就认为"去云完成了",结果导出影像里依然有云。原因前面说过:整景云量是全局指标,不代表你的ROI区域干净。更隐蔽的是,有些薄云在CLOUDY_PIXEL_PERCENTAGE里根本不被统计,因为算法把它判断成了晴空。
所以我现在的固定套路是"双保险":先用云量元数据筛掉明显多云影像,减少计算量;再用QA波段或SCL波段做逐像元掩膜,彻底清除残余云和云影。两道工序各管一段,谁也不能替代谁。
6.3 没缩放就做NDVI,数值上万还不自知
这个坑最隐蔽,因为GEE的NDVI图层在默认拉伸下看起来居然还挺像模像样,红红绿绿的。但如果用Inspector点一个植被像元,你发现问题了:NDVI值不是0.5,而是5000多。这就是因为忘了做波段系数转换。
为什么会这样?因为DN值范围是0到65535,NDVI公式虽然是个比值,但NIR和RED波段如果数值量级太大,归一化后依然会得到一个大数,而且这个数还会随着光照条件、传感器增益变化,完全没有物理意义。从那以后我把波段系数转换固定写在预处理函数里,和去云同步完成,再也没有漏过。
6.4 沉淀下来的固定处理顺序
踩过这么多坑之后,我最终沉淀出了一套固定套路,现在每一次跑GEE批处理都按这个顺序来:
- 选数据集,明确需要哪些波段,查清scale和offset;
- 按研究区、时间、云量元数据做初级筛选;
- 定义预处理函数,里面依次做QA掩膜、波段系数转换;
- 用map把预处理应用到整个集合;
- 根据需求选择单景展示或多时相合成;
- 合成之后再做指数计算和可视化。
这个顺序可能看起来很啰嗦,但每一步都有它存在的理由。跳过任何一步,后续的返工成本都会指数级上升。我见过太多人在最后导出阶段发现数据不对,再回头排查,一查就是半天,那次排查的成本远比一开始按顺序处理好要高得多。
最后分享一个小习惯:我在GEE里做任何预处理,都会顺手把中间结果打印一次,比如用print(composite.bandNames())看看波段名、用print(ndvi.reduceRegion(...))看看数值范围。这种"每个阶段留个证据"的做法,帮我省下了无数排查时间。希望这篇东西能让你在GEE的加载、去云和系数转换上少走几步弯路。