GEE位运算去云全解析:从QA波段到Landsat/Sentinel-2实战
2026/9/16 10:47:48 网站建设 项目流程

1. 为什么去云在GEE里绕不开位运算:GEE遥感影像的“数据组织结构”

很多刚接触GEE(Google Earth Engine)的人,第一次被劝退往往不是JavaScript语法,而是去云处理时遇到的那一串让人头皮发麻的数字和位运算符。打开别人的代码,总能看到qualityBandcloudBitMaskbitsrightShift这些词混在一起,代码能跑通,但自己完全看不懂它在干什么。这篇博文就是专门来拆解这个事的。文章面向所有在GEE里做影像处理的人,尤其是刚学完基础API、开始碰真实影像筛选和合成的人。我会从GEE影像波段背后的“数据组织逻辑”讲起,把位运算去云这个操作彻底拆开揉碎。

先给一个最核心的结论,也是理解全文的基础:在Landsat 8/9和Sentinel-2这些影像里,不只是像素值是打包存储的,连“哪些像素有云、哪些像素是云影、哪些像素是雪”这些质量信息,也是以二进制位(bit)的形式压缩在一个整数波段里的。一个16位的整数里,可以塞进好几层独立的“是与否”标记。这种设计大大节省了存储空间,但代价就是——你想读某个标记,就必须使用位运算去“解包”。

这就解释了为什么去云处理几乎绕不开位运算。而位运算本身又是一个在普通JavaScript开发里很少用到、但在GEE里天天见的东西。所以这篇文章会实际回答三个问题:GEE的质量波段到底是怎么组织的?位运算去云代码每一行在干什么?为什么不位运算就用不了这些去云逻辑?

在展开之前,先说明一下阅读前提。如果你完全没有接触过二进制,也不用怕。我会先用一个生活化类比把二进制位这个概念建起来,然后再逐步落到Landsat和Sentinel-2的真实代码上。换句话说,这篇不是泛泛讲概念,而是直接把代码贴在下面,逐行解释它如何从“整数数字”里“抠出”云和云影位置的。

2. 二进制、比特位和QA波段:先花10分钟把基础概念焊死

2.1 二进制位到底是什么:一个路灯开关的比喻

二进制这东西,听起来学术,但本质特别简单。想想你楼道里的声控灯,它只有两个状态:亮或者灭。我们用数字表示,亮是1,灭是0。如果给你一排八个这样的灯,每个灯都能独立控制亮灭,那么这一排灯就可以表示“八位”信息。每个“灯”就是一个二进制位,也就是“比特”(bit)。

而在计算机里,一个8位的数长这样:10101100。从右往左数,最右边那个位叫第0位,再往左是第1位、第2位……以此类推。每一位的“权重”是2的n次方。也就是说,第0位的权重是1,第1位的权重是2,第2位的权重是4,第3位是8,第4位是16,以此类推。

所以10101100如果换算成十进制数,就是:第2、3、5、7位是1,对应权重4、8、32、128,加起来等于172。 这就是位运算的底层语言。它不关心这个数字在人眼里是“一百七十二”,它关心的是这个数字在二进制层面,哪些“灯”亮着。

而GEE的质量波段,恰恰就是利用了这一点。它把一个数字的每一个比特位,规定为一种质量标志。第0位亮表示“这颗像素是有效的”,第1位亮表示“这颗像素有云的辐射干扰”,第2位亮表示“这颗像素被云影遮挡”。这些标志全塞在同一个整数里,读的时候用位运算取出来,写的时候用位运算塞进去。

这就像你口袋里有十把钥匙,都串在一个钥匙环上。质量波段就是这个钥匙环,位运算就是从里面挑出某一把特定钥匙的手段。

2.2 QA_PIXEL波段:Landsat和Sentinel-2的质量档案

在Landsat Collection 2以前,去云主要看CFMask算法生成的QC波段,不同版本之间的比特位定义不统一,写过Landsat 5和Landsat 7的人应该都吃过这个亏。从Collection 2开始,一切变得标准化了,所有Landsat影像(包括8号和9号)都提供了一个叫QA_PIXEL的波段。这个波段是UInt16类型的,里面每一个bit的定义在官方文档里有一张表,这就是去云代码的“宪法”。

Landsat Collection 2 的QA_PIXEL比特位定义,常用部分如下:

比特位是否填充值含义
位0影像填充(如果该位为1,说明像素是填充值,无实际观测)
位1地物云检测(1=云或阴影遮挡)
位2云影置信度(1=云影存在)
位3冰雪置信度(1=存在冰雪)
位4云置信度(1=存在云)
位5云影 Dilated(膨胀后的云影)
位6云 Dilated(膨胀后的云)
位7地物云置信度高

不同数据产品会有些差异,但核心逻辑一致:一个bit点是1还是0,决定了像素是不是云。比如“位4是云置信度”,意思是这个bit位的值是1时,影像算法认为那颗像素大概率有云。

Sentinel-2这边更绝。它不但在MSK_CLDPRB这类波段里直接给出了0到100的云概率百分比,还在QA60波段里用比特位记录了云和卷云的掩膜。QA60是UInt16,常用的是第10位(云)和第11位(卷云)。位10为1,说明像素被判为云;位11为1,说明像素被判为卷云。

所以无论是Landsat还是Sentinel-2,去云的本质都一样:把质量波段里的“云标志位”读出来,然后把对应位置上的像素屏蔽掉。下面一节里,我要讲的就是读这个标志位所依赖的位运算工具。

2.3 去云代码中的两个位运算主角:bitwiseAnd与rightShift

GEE里位运算的主要操作,一个是bitwiseAnd,也就是按位与。另一个是rightShift,右移。这两个函数但凡去云代码,必出现。

rightShift做的事情是:把整个二进制数往右移动n位,最右边的n位直接丢弃。比如数字172,二进制是10101100。如果把它右移4位,得到的是00001010,也就是十进制10。注意,右移4位的意思相当于是“我想看原来从第4位开始往上的信息”。原来的第4位变成了新数字的第0位,原来的第5位变成了新数字的第1位。这样一来,我们就把“藏在数字第4位里的标志”,挪到了现在整个数字最低的位置上。

bitwiseAnd做的事情是:把两个二进制数逐位比较,只有两个数的对应位都是1时,结果的对应位才是1,否则为0。它的一个重要用途是“遮罩”。比如你想知道一个数的第0位是不是1,就让它和1做按位与。因为1的二进制是00000001,除了第0位是1,其他位全是0,按位与的结果会把其他位全部清零。最终结果如果是0,说明原数的第0位是0;如果是1,说明原数的第0位是1。

这两个函数配合起来,去云的读法就变成了标准动作:先右移,把目标位挪到最低位,再和1做按位与,把其他位全部清零,最终只留下目标位的0或1

GEE里这段标准写法,几乎所有教程都会看到,但很少有人会解释它为什么要写成“右移x位再与上1”而不是别的写法。原因很简单:因为位标志不是孤立存在的。如果你想判断位4是否为1,既可以通过“右移4位再与1”做到,也可以通过“直接和16按位与判断结果是否为0”做到。前者看到的标志位是0或1,可读性更强;后者虽然计算上更简洁,但“16”这个魔法数字让人摸不着头脑。所以社区的主流写法基本都是右移加按位与。

有了这些基础,下面就可以直接落代码了。

3. 逐行拆解Landsat去云代码:从maskL8SR到哨兵二号的QA60

3.1 Landsat 8/9 Collection 2的去云函数:一段经典代码的完整解读

先看最常见的Landsat 8/9去云函数。这段代码在CSDN、知乎和GitHub里被反复张贴,但大量人只是复制粘贴,不理解里头的位运算逻辑。我把注释写得极为详细,再逐行解释。

function maskL8sr(image) { // 选出QA_PIXEL波段 var qa = image.select('QA_PIXEL'); // 1. 判断"影像填充"标志(位0) var dilatedCloud = qa.bitwiseAnd(1 << 0).neq(0); // 2. 判断"云"标志(位4)和"云影"标志(位3)——不同代码选择不同 var cloudBit = qa.bitwiseAnd(1 << 4).neq(0); var cloudShadowBit = qa.bitwiseAnd(1 << 3).neq(0); // 3. 判断"冰雪"标志(位5)——有的代码不处理冰雪 var snowBit = qa.bitwiseAnd(1 << 5).neq(0); // 或者使用右移写法 var cloudBit2 = qa.rightShift(4).bitwiseAnd(1).neq(0); // 综合起来,生成一个"需要被遮掉"的掩膜 var mask = dilatedCloud.or(cloudBit).or(cloudShadowBit).or(snowBit); // 把掩膜应用到影像上:更新影像里的像素为0 return image.updateMask(mask.not()); }

这段代码里有几个地方,初学者特别容易卡住。

第一个卡点是1 << 4。在JavaScript中,<<是左移运算符。1 << 4表示把数字1的二进制00000001左移4位,变成00010000,也就是十进制16。“1左移4位”其实是在动态构造一个“只有第4位是1、其他位都是0”的二进制数。这比直接写16可读性好得多,因为它明确告诉你,我关心的是位4。同样,1 << 0就是1本身,表示位0。

第二个卡点是neq(0)。GEE的bitwiseAnd返回一个影像,里面的值是按位与的结果。bitwiseAnd(1 << 4)会把所有其他位清零,只剩下位4的值。但位4如果为0,结果就是0;如果为1,结果就是16。很多人认为“结果应该是0或1”,其实不是,结果是非0即0。所以我用neq(0)把这个结果转成真正的布尔掩膜:结果是0就是false,结果非0就是true。这样生成的掩膜就可以用于updateMask了。

第三个值得注意的点是云影和冰雪的比特位定义。不同的数据集版本里,位3到底是云影还是冰雪,其实有一点点区别。在Collection 2的Landsat 8/9里,位3是云影,位4是云,位5是冰雪。但在某些旧代码里,筛选用的是qa.bitwiseAnd(1 << 3)表示云,qa.bitwiseAnd(1 << 5)表示云影。这种不一致是无数踩坑的根源。所以拿到一段代码,第一件事不是跑,而是去查你所用数据集的QA_PIXEL波段定义,对照文档确认每一位的含义。

有一个更稳健的写法,不用记每一位的比特位,而是使用GEE官方提供的SRTMGL1_003风格不适用,但我们有官方封装的image.mask或者直接依赖Landsat的质量算法。实际上在GEE里还有一个简化方式,就是使用自带云概率波段或者MCD43A4那样直接提供质量波段的产品,但Landsat本身没有直接的“云概率”波段,必须位运算。

3.2 右侧「右移+与1」写法和左侧「左移+与」写法的区别

我在上节代码里同时写了两种格式:qa.bitwiseAnd(1 << 4).neq(0)qa.rightShift(4).bitwiseAnd(1).neq(0)。两者效果基本等价,但适用场景略有不同。

先说右移写法。qa.rightShift(4)把整个QA_PIXEL的二进制数右移4位,原来的位4变成了新的位0,原来的位0到位3全部被丢弃。这个时候再和1做bitwiseAnd,就能精确提出原来位4的值,并且结果就是0或1。这段的语义非常干净:你就是在说“把第4位取出来”。

左移写法其实是用1 << 4生成一个掩码,再用bitwiseAnd把其他位清零。结果如果是0,说明位4不是1;如果是16,说明位4是1。为了让结果变成0或1,还需要额外做一步neq(0)。所以左移写法更侧重于“判断”,右移写法更侧重于“取值”。在GEE里,两种都常见,我个人的习惯是优先用右移写法,因为它的结果更直观,而且减少了一个对neq(0)的记忆负担。

但有个性能细节要注意:GEE是对影像逐像素运算的,每次rightShiftbitwiseAnd都会产生一个中间影像。一个QA_PIXEL波段会被反复读取好几次。如果你在一个大区域的影像集合上做去云,这种位运算其实有轻微的计算开销。虽然一般体感不明显,但如果你处理的是全国范围的时间序列,建议把质量掩膜的计算合并成一次波段运算,或者尽量减少重复image.select次数。后面我会给出一个批量处理时的优化思路。

3.3 Sentinel-2 QA60实战:位10和位11的卷云与云

Sentinel-2的去云,社区里最常见的写法之一是这样:

function maskS2clouds(image) { var qa = image.select('QA60'); var cloudBitMask = 1 << 10; var cirrusBitMask = 1 << 11; var mask = qa.bitwiseAnd(cloudBitMask).eq(0) .and(qa.bitwiseAnd(cirrusBitMask).eq(0)); return image.updateMask(mask); }

注意这里的逻辑和Landsat版本的差异。在Landsat版本里,我用的是neq(0),意思是“如果这个位的值是1,就认为它是云,需要去掉”。但在Sentinel-2版本里,用的却是eq(0),意思是“如果这个位的值是0,就认为它是好像素,保留”。一个是保留非云,一个是剔除云,方向相反,但本质一样。

QA60的bit10是云,bit11是卷云。把cloudBitMask设为1 << 10,也就是1024,把cirrusBitMask设为1 << 11,也就是2048。然后分别用bitwiseAnd去检查这两位上是否为0。如果都为0,说明像素既不是云也不是卷云,保留。

这里有一个容易被忽略的点:Sentinel-2的QA60是UInt16,但L2A产品的QA60里,位10和位11是“云掩膜”和“卷云掩膜”的置信度标志,而不是具体的云概率。所以如果我们还想利用MSK_CLDPRB的云概率来做更精细的控制,那就要把位运算和阈值法结合起来。很多生产级代码里都是先做QA60位运算,再做云概率阈值筛选,最后再做云影检测,三层叠加。

另外,处理Sentinel-2时有个小坑:L1C产品和L2A产品的QA60定义并不完全相同。L1C的QA60里有时缺少卷云判断。所以你在写去云函数前,最好先确认你用的影像集合是COPERNICUS/S2_SR(L2A地表反射率)还是COPERNICUS/S2(L1C大气表观反射率)。两者的云掩膜质量差距很大。用L1C做地表反射率分析,去云效果通常不尽人意。

4. 位运算去云背后容易被忽略的五个细节

4.1 按位与不等于逻辑与:GEE的eq、neq、and、or都是逐像素操作

位运算里最容易混淆的就是“按位与”(bitwiseAnd)和“逻辑与”(and)。很多新手以为bitwiseAnd返回的是一个布尔值,但实际上它返回的是一个逐像素的整数影像。在GEE里,and才是逻辑与操作,它接受两个布尔影像,逐像素做逻辑与。

举一个经典的错误写法:有人想把云和云影的掩膜合并,直接写mask = cloudBit && shadowBit,这在JavaScript里只能对单个数值生效,对GEE影像完全无效。必须写成cloudBit.and(shadowBit)或者cloudBit.or(shadowBit)。同样的,要判断位是否为0,不能说if (cloudBit === 0),因为GEE的影像对象没法在客户端直接被比较。你必须使用eq(0)生成一个掩膜影像,然后再做后续操作。

这其实是GEE里所有波段运算的共性,不只是位运算。但由于位运算经常和neq(0)eq(0)andor混用,新手特别容易在这里卡壳。记住一句话:GEE的运算大多数是“影像函数”,不是“JavaScript运算符”。

4.2 位定义在不同数据集之间存在差异:千万不要复制代码不查表

我在前面已经反复提到了位定义的差异。这里用一张表把常见数据集的常用去云位整理出来,方便你查证,但这张表不能替代官方文档。尤其是当GEE更新数据产品版本时,层面的比特定义可能保持不变,但某些新增的比特位可能会影响你已有的掩膜。

数据集质量波段云影雪/冰卷云
Landsat 8/9 C2 SRQA_PIXEL位4位3位5无单独卷云位
Landsat 7 C2 SRQA_PIXEL位4位3位5
Landsat 5 C2 SRQA_PIXEL位4位3位5
Sentinel-2 L2AQA60位10见SLC相关波段位11
Sentinel-2 L1CQA60位10见SLC相关波段位11

上表只是最常见的几个。如果你用的是MOD09GA或MOD09GQ那类产品,质量波段叫state_1km,里面的定义又是另一套。总之,去云前先查官方文档,是比位运算本身更重要的一步。

4.3 位运算做的是“逐像素判定”,不是“影像级筛选”

另一个常见误解是认为“去云代码”会把带云的整幅影像删除。实际上不是。GEE的updateMask操作是把影像中某些像素标记为无效,而不是把整幅影像去掉。所以在做影像合成(比如median()mean())时,那些被掩膜掉的像素就不会参与统计。但如果你只是想“踢掉某张云量大于10%的影像”,那是另一回事,需要用到filter(ee.Filter.lt('CLOUD_COVER', 10))

这两者的关系是:你先用CLOUD_COVER在影像集合层面做粗筛,删掉那些整体云量太大的影像;然后在像素层面用QA_PIXEL位运算做细筛,把单颗像素里的云、云影、雪标记出来。两个步骤配合使用,才能得到干净的合成结果。

很多人只做像素级去云,不做影像级筛选,结果在一个多云区域反复合成,输出依然坑坑洼洼。反过来,如果只做影像级筛选,整景影像里依然会有大片残云。正确流程是先粗筛再细筛。

4.4 云影检测比云检测麻烦得多:为什么很多代码干脆只去云不去云影

你有没有发现,很多GEE去云教程里只有云掩膜,没有云影掩膜?不是因为云影不重要,而是因为云影检测本身是个复杂的空间推理问题。QA_PIXEL里的云影位是由CFMask算法预先算好的,但它的准确性在不同地形和不同太阳角度下波动非常大。

尤其是在山区,云影经常被误判为“暗地表”,或者真正的云影没被标出来。所以生产级的去云流程里,除了用QA_PIXEL的云影位做第一层剔除,有些人还会结合多时相变化检测或光谱特征再做第二层云影去除。但那些都超出了位运算本身的范围。这篇文章里讲到位运算的云影提取,是你在GEE里能做的最快速、最不依赖额外数据的方案。

4.5 更新掩膜之后,还要考虑波段数值范围的变化

updateMask去云后,影像里被掩膜掉的像素在可视化时是透明的,在统计时是缺失的。但有些波段操作,比如计算NDVI,会因为你没有先updateMask而导致云区域的光谱值参与计算并产生怪异值。所以,如果你在计算指数之前做去云,就应该把updateMask应用在原始影像上,然后再normalizedDifference。顺序错了,结果就不对。

此外,Landsat的SR波段在Collection 2里已经做了缩放,去云并不会改变缩放系数。但如果你用了老版本的Collection 1数据,SR波段的DN值范围是0到10000,和C2有差异。去云函数本身不受影响,但在统计和图表里,你会看到数值差异。建议一切新项目都用Collection 2。

5. 从单影像去云到影像集合批量合成:完整可跑的GEE代码

5.1 以Landsat 8/9为例的月度无云合成代码

下面给出一段可以直接在GEE编辑器里跑的代码。它会把指定区域和指定时间范围内的Landsat 8/9影像,先做粗筛,再做位运算像素级去云,最后按月合成中值影像。

// 定义研究区域 var roi = ee.Geometry.Rectangle([116.0, 39.5, 117.5, 41.0]); // 加载Landsat 8/9 Collection 2 SR数据 var collection = ee.ImageCollection('LANDSAT/LC09/C02/T1_L2') .merge(ee.ImageCollection('LANDSAT/LC08/C02/T1_L2')) .filterBounds(roi) .filterDate('2023-01-01', '2023-12-31') .filter(ee.Filter.lt('CLOUD_COVER', 30)); // 去云函数(采用右移+与1的写法) function maskL89sr(image) { var qa = image.select('QA_PIXEL'); var fillBit = qa.rightShift(0).bitwiseAnd(1).eq(0); var cloudBit = qa.rightShift(4).bitwiseAnd(1).eq(0); var shadowBit = qa.rightShift(3).bitwiseAnd(1).eq(0); var snowBit = qa.rightShift(5).bitwiseAnd(1).eq(0); var mask = fillBit.and(cloudBit).and(shadowBit).and(snowBit); return image.updateMask(mask); } // 应用去云 var masked = collection.map(maskL89sr); // 按月份分组合成 var months = ee.List.sequence(1, 12); var monthly = ee.ImageCollection.fromImages( months.map(function (m) { var start = ee.Date.fromYMD(2023, m, 1); var end = start.advance(1, 'month'); var comp = masked.filterDate(start, end).select(['SR_B2','SR_B3','SR_B4','SR_B5','SR_B6','SR_B7']).median(); return comp.set('month', m).set('system:time_start', start); }) ); // 可视化参数 var vis = {bands: ['SR_B4', 'SR_B3', 'SR_B2'], min: 7000, max: 12000}; Map.centerObject(roi, 10); Map.addLayer(masked.filterDate('2023-06-01', '2023-07-01').median().select(['SR_B4', 'SR_B3', 'SR_B2']).clip(roi), vis, 'June composite'); Map.addLayer(monthly.filter(ee.Filter.eq('month', 6)).first().clip(roi), vis, 'June median monthly');

这段代码里有一个操作值得特别说明:我把fillBit也加了进来,并且用的是eq(0)。它的意思是:如果位0为0,说明这个像素不是填充值;如果位0为1,说明这个像素是填充值。对去云来说,填充值像素本来就不该参与后续计算,所以把它们一并排除掉。

这里选区用的经纬度只是示例,你可以换成自己的研究区。CLOUD_COVER阈值30是影像级粗筛,一般建议在20到40之间,太严格会导致影像数量过少,太宽松会留下很多边缘云。实际使用时,根据研究区多云程度调整。

5.2 以Sentinel-2为例的合成代码:QA60与云概率双阈值

再来一段Sentinel-2的去云合成。这个函数会额外使用MSK_CLDPRB波段做一个概率筛选。MSK_CLDPRB是0到100的云概率值,你可以把60%作为阈值,高于60%的像素视为云。

function maskS2L2A(image) { var qa = image.select('QA60'); var cloudMask = qa.bitwiseAnd(1 << 10).eq(0); var cirrusMask = qa.bitwiseAnd(1 << 11).eq(0); var probMask = image.select('MSK_CLDPRB').lte(60); var mask = cloudMask.and(cirrusMask).and(probMask); return image.updateMask(mask); } var s2 = ee.ImageCollection('COPERNICUS/S2_SR') .filterBounds(roi) .filterDate('2023-05-01', '2023-09-30') .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 20)) .map(maskS2L2A); var visS2 = {bands: ['B4', 'B3', 'B2'], min: 0, max: 3000}; Map.addLayer(s2.median().clip(roi), visS2, 'S2 composite');

注意这里MSK_CLDPRB是L2A产品自带的云概率波段,不涉及位运算。而CLOUDY_PIXEL_PERCENTAGE是影像集合级的云量属性,也不涉及位运算。真正用到位运算的只有QA60。理解了这一点,你对“去云里有哪几层操作”就有了完整概念。

5.3 一个性能优化技巧:用addBands把掩膜提前算好

如果你需要对一个大区域、多年、几十个时相的影像做去云,重复在每次map时计算掩膜会让任务运行时间变长。一个常见的优化思路是:在影像集合进入map之前,先给每一景影像增加一个预设的掩膜波段。这样后续不需要反复读取QA波段做位运算。

function addCloudMask(image) { var qa = image.select('QA_PIXEL'); var cloud = qa.rightShift(4).bitwiseAnd(1).eq(0); var shadow = qa.rightShift(3).bitwiseAnd(1).eq(0); var mask = cloud.and(shadow); return image.addBands(mask.rename('cloudMask')); } var withMask = collection.map(addCloudMask); // 后续使用时直接 select('cloudMask'),而不再对QA做bit运算

这个技巧在数据量巨大时才比较有意义。小区域测试时,写不写都行,但养成这个习惯,对以后做生产级批处理有好处。

6. 去云结果的验证与坑位排查:为什么掩膜有时看起来“漏云”或“误杀”

6.1 漏云的常见原因:分辨率、薄云与QA算法的保守策略

很多用户做完去云后,发现合成结果里仍有淡淡的云影或薄云。这不一定是位运算写错了。CFMask算法本身对薄云和高空卷云的检测能力有限。薄云往往在高亮地表上难以被识别,所以QA_PIXEL里没有标记它。遇到这种情况,单纯靠QA_PIXEL去不掉,你需要额外的手段:

  • 使用多个时相的合成策略(比如median()而不是first()),因为薄云在多个时相里不太可能总在同一位置。
  • 结合MSK_CLDPRB云概率波段(仅Sentinel-2),把阈值往下调。
  • 使用可见光和近红外波段的亮度差做二次过滤。这个方法不通用,但可以对特定场景补偿。

我第一次做Landsat去云时,就栽在薄云上。当时想着位运算都做对了,为什么合成出来还是模糊。后来把去云前的影像调出来看,发现那几天的云都是典型的卷云,QA_PIXEL根本没标出来。加了多时相合成和雪/卷云掩膜后,效果明显改善。

6.2 误杀的常见原因:把明亮地表认成云,把云影认成水体

误杀的意思是,本来没有云的地方,掩膜却把像素抹掉了。在沙漠、盐湖、积雪区域,QA_PIXEL经常把高反射地表误认为云。而在山谷阴影和暗色水体里,它又容易把真实阴影或水体误认为云影。

遇到这种情况,你应该检查两件事。第一,看QA_PIXEL里云影位的阈值是不是过高。由于我们直接信任了CFMask的结果,等于把误判也一起接收了。如果你发现云影掩膜太激进,可以只保留云掩膜而不使用云影掩膜,代价是部分真正的云影会漏掉。第二,看合成时是否有足够多的影像参与中值运算。中值本身对单时相的误杀不太敏感,所以只要集合里的影像数量足够多,个别错标像素通常会被其他时相的正确像素补齐。

6.3 用Map视图快速确认掩膜是否合理

调试去云掩膜时,我通常会写一段辅助代码,把掩膜叠加到原始影像上,快速目视检测:

var testImage = ee.Image(collection.first()); var testMasked = maskL89sr(testImage); var vis = {bands: ['SR_B4', 'SR_B3', 'SR_B2'], min: 7000, max: 12000}; Map.addLayer(testImage.clip(roi), vis, 'Original first'); Map.addLayer(testMasked.clip(roi), vis, 'Masked first'); Map.addLayer(testImage.select('QA_PIXEL').clip(roi), {min: 0, max: 65535}, 'QA PIXEL raw');

这种目视对比非常有用。如果第一景影像的云被正确去掉,且地表细节完好,基本可以确认位运算没问题。如果地表出现大量黑色孔洞,大概率是掩膜太激进。如果云区域还留着,大概率是位定义或eq/neq逻辑方向弄反了。

有个特别容易踩的逻辑反向问题:eq(0)neq(0)写反。我在一次给团队写代码时就犯过这个错。本来是“云位为0就保留”,结果写成了“云位为0就去掉”,导致所有无云像素被抹掉,只剩云体,看起来像“反色”效果。排查方法很简单:点击地图上的云和地表像素,看QA_PIXEL的数值,然后手算它的二进制位是否与预期一致。

6.4 二进制手算与GEE控制台:快速验证位标志

在GEE控制台里,你可以直接打印一个QA_PIXEL像元的数值,然后在浏览器控制台或者本地用JavaScript手算。比如从一个Landsat影像中取一个云像素的值:

var sampleValue = testImage.select('QA_PIXEL').reduceRegion({ reducer: ee.Reducer.first(), geometry: cloudPoint, scale: 30 }); print(sampleValue);

然后在JavaScript控制台里输入(值 >>> 4) & 1,如果结果是1,说明位4为1,确实是云。这个方法可以帮你快速判断是数据问题还是代码逻辑问题。

提示:>>>是无符号右移,因为QA_PIXEL是UInt16,不会出现负数,用>>也行。但在GEE服务端,永远使用rightShift方法,不要用JavaScript运算符处理影像对象。

7. 从去云到位运算思维:这套思路还能用在哪里

7.1 Land Surface Temperature、水深反演中的质量波段应用

位运算不只是为了去云。在GEE里,很多数据集都提供了位打包的质量信息。比如MODIS的温度产品MOD11A1,它的QC波段里也有一堆位标志,用来表示数据质量、云污染、平均发射率误差等。你想筛选“质量好、无云、误差小”的像素,一样得用bitwiseAnd。所以一旦掌握了QA_PIXEL去云里的位运算逻辑,你再去看任何QC波段的文档,都会觉得似曾相识。

7.2 土地利用分类前的严格质控:DN值编码与数据筛选

在做土地利用分类时,训练样本的纯净度直接影响分类精度。如果训练样本里混入了云、云影、雪,分类器会把它们当成一种“地类”,从而污染结果。所以分类前也应该用位运算掩膜把不良像素清掉。另外,很多土地覆盖产品本身也用bit编码来标识分类精度或变化置信度,比如MCD12Q1的QC波段。你要提取“高置信度变化”的区域,还是位运算。

7.3 自定义二值掩膜:为什么位运算在批量处理中更省空间

在自定义掩膜时,位运算依然有帮助。假如你想在一个波段里同时记录“云”“云影”“雪”“水体”四个标志,不用开四个波段,只用一个整数波段,分别用位0到位3记录。这样导出的GeoTIFF体积更小,而且后续查询效率更高。这种编码思想在很多遥感产品里被广泛使用,理解了QA_PIXEL,你就理解了为什么产品设计者会选择这种看似“反人类”的存储方式。

8. 踩坑总结与快速自查清单

我在不同项目里摸爬滚打,总结了一份去云代码的快速自查清单,分享给你。

  1. 先查你用的数据产品是哪个版本,Landsat Collection 2还是Collection 1,Sentinel-2 L1C还是L2A。不同版本的QA波段、比特位定义可能有差异。
  2. 确认你选对质量波段。Landsat用QA_PIXEL,不用QA_RADSAT;Sentinel-2用QA60,不用场景分类SCL波段(除非你要精细到10米级的分类信息)。
  3. 确认方向是eq(0)还是neq(0)。如果你想“保留好像素”,就用eq(0);如果你想“标记坏像素”,就用neq(0)
  4. 确认你用的是bitwiseAndrightShift方法,不是在GEE服务端写了&|>>这类JavaScript运算符。GEE里的这些运算符不适用于影像对象。
  5. 有条件的话,用真实像素的QA值做一次手算验证。比如打印几个云和地表像元的值,手动转换成二进制,对照比特位定义表确认。
  6. 在批处理之前,先在单景影像上测试去云函数,目视对比原始影像和掩膜后影像。
  7. 留意云影掩膜的误杀风险。如果研究区多山、多水体,可以考虑只保留云掩膜,或者结合其他方法二次去云影。
  8. 在最终合成时,尽量使用median()而非mean(),因为中值对少数残留的异常像素更鲁棒。
  9. 不要把CLOUD_COVER属性筛选和像素级掩膜混为一谈。前者用于影像集合筛选,后者用于逐像素处理,二者必须组合使用。

我做遥感这么些年,最大的体会是:GEE里大部分去云代码本质上都是同一个套路,只是数据源不同导致比特位定义不同。你把QA_PIXEL的位运算逻辑真正吃透了,换到MODIS、换到Sentinel-3、换到国产卫星,也就只是查表换参数的事。而“查表换参数”这五个字,背后最核心的那道门槛,其实就是二进制位的读取能力。希望这篇文章能帮你跨过这道门槛。

如果你在跑代码时遇到什么奇怪的现象,欢迎把报错或地图截图发在评论区,我尽量帮你看。但记得先自查清单里的前三条,很多时候问题不在代码逻辑,而在数据版本和比特位定义表。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询