GEE遥感去云实战:QA波段位运算与云掩膜完全指南
发布时间:2026/9/16 1:53:25 作者:尧图编辑部 阅读量:1,286

做遥感的尤其经常用 GEE 处理光学影像的同学基本都绕不开同一个问题——去云。Landsat 也好Sentinel-2 也好只要不是沙漠地区你一拉影像列表总有一大堆场景被云和云影盖得严严实实。GEE 里最常见的去云手段不是训练什么深度学习模型而是靠影像自带的 QA 波段通过位运算bitwise operation去做掩膜。很多教程会直接甩给你一段 maskL8sr 这样的函数告诉你“复制粘贴就能用”但真到了你自己换传感器、换数据版本、或者碰上 QA 波段结构变了的时候这段代码怎么改、为什么这样写、哪里容易踩坑就全靠理解了。这篇文章我就拿几段真实在用的去云代码段把 GEE 里的位运算逻辑彻底讲透。不是给你一堆“拿来主义”而是让你看完之后哪怕给你一个新的传感器你也能自己翻文档、自己写掩膜函数。适合刚接触 GEE 的初学者也适合那些用了很久但一直对 QA 波段“知其然不知其所以然”的老朋友。1. 内容整体设计与思路拆解1.1 为什么去云偏偏要用“位运算”先说个最朴素的问题影像里有一堆像元每个像元除了反射率之外还附带一堆质量信息比如这个像元是不是云、是不是云影、是不是水体、是不是冰雪、置信度有多高。你当然可以用“云概率大于 50% 就删掉”这种阈值方法但 GEE 里官方推荐的做法更接近一种“二进制开关”的思路——每个属性占一个或多个比特位0 和 1 表示这个属性“有”还是“无”。而位运算就是用来精准读取这些开关状态的。打个生活化的比方你有一排电灯开关每个开关控制一个房间的灯。你想知道“客厅的灯是不是亮着”你不会把所有房间的灯都看一遍而是直接看客厅对应的那个开关。QA 波段的每一个 bit 就是这样一个开关位运算就是让你能单独看某个开关的“手电筒”。GEE 选择位运算而不是普通比较运算最核心的原因是效率和信息密度。一个 16 位整数可以同时存储 16 个不同属性的开关状态你只需要一个波段就能携带大量质量信息。如果每个属性都单独存一个波段数据量翻好几倍不说处理时 IO 压力也大。位运算在底层就是几条 CPU 指令速度极快尤其适合 GEE 这种在海量影像上做逐像元运算的场景。1.2 GEE 里常用的三个位运算函数GEE 的ee.Image对象给我们提供了几个位操作相关的函数去云用得最多的就三个bitwiseAnd()按位与操作对应位都是 1 时结果才为 1否则为 0。这是最常见的“位读取”方法。bitwiseOr()按位或操作对应位只要有一个是 1结果就是 1。偶尔用于合并多个掩膜条件。rightShift()/leftShift()右移 / 左移操作把整个二进制数值按位移动相当于“平移开关面板”。去云的核心逻辑基本就是“用 bitwiseAnd 配合一个位掩码把它对应的位单独拎出来看”。举个例子假设某个属性位于第 3 位从右往左数从 0 开始计数你想知道这个属性是否为 1就用qa.bitwiseAnd(8)8 的二进制是 1000只有第 3 位是 1得到的结果如果等于 8说明这一位确实是 1如果等于 0说明这一位是 0。这里其实藏着一个小坑很多新手直接写qa.bitwiseAnd(8)然后就拿去 updateMask结果发现该滤掉的云根本没滤掉。原因在于你拿到的结果不是简单的 0 或 1而是“只保留第 3 位、其他位全清零”后的完整整数。只有当第 3 位恰好是 1 时结果才是 8如果不是结果可能是 0也可能是其他数假如你还顺便保留了别的位。所以正确的做法通常要配合eq(0)或eq(8)来转成布尔掩膜这一点后文会反复强调。2. 核心细节解析与实操要点2.1 Landsat Collection 2 的 QA_PIXEL 波段结构现在 GEE 里的 Landsat 主流数据是 Collection 2 Level 2它使用的质量波段叫QA_PIXEL。很多网上老教程还在讲BQA波段那是 Collection 1 时代的遗留物了如果你现在拿着老代码去跑新数据大概率什么都掩不掉。QA_PIXEL是一个 16 位无符号整数官方文档里给了一张位定义表。常用到的几个位是位序号属性十进制掩码值含义Bit 0Fill1填充像元无数据区域Bit 1Dilated Cloud2扩展云 / 云边缘Bit 2Cirrus4卷云Bit 3Cloud8云Bit 4Cloud Shadow16云影Bit 5Snow32雪Bit 6Clear64晴空Bit 7Water128水体Bit 8-9Cloud Confidence256/512云置信度Bit 10-11Cloud Shadow Confidence1024/2048云影置信度注意这里你不需要把整张表背下来只需要学会“查表 翻译成位掩码”即可。比如要处理“云”就先找到 Cloud 对应 Bit 3然后计算1 3 8这个 8 就是我们的位掩码。同理云影是 Bit 4掩码是1 4 16。卷云是 Bit 2掩码是 4。还有个很容易被忽略的细节Cloud Confidence占两个 bit它的取值组合表示不同置信度等级。00 表示未设置01 表示低置信度10 表示中置信度11 表示高置信度。如果你只想掩掉“高置信度”的云光判断 Bit 3 还不够还要去读 Bit 8-9 的组合值。实际业务里如果场景内云很多通常建议直接把 Cloud 位为 1 的像元全部滤掉不要省这一步否则漏云会让你后面处理植被指数时出现大量异常值。2.2 Sentinel-2 的 QA60 波段与 SCL 分类图Sentinel-2 在 GEE 里的去云方式稍微有点不同。Level-1C 产品用的是QA60波段它是一个 10 位或 12 位的位编码波段其中 Bit 10 表示云Bit 11 表示卷云。判断逻辑和 Landsat 一模一样qa60.bitwiseAnd(1 10)得到非 0 就说明是云bitwiseAnd(1 11)得到非 0 就说明是卷云。不过实测下来QA60的云检测会比较激进经常把一些高亮地表比如盐碱地、雪、部分建筑物屋顶误判成云。如果你做的是精细分类或长时间序列分析我更推荐使用SCL波段Scene Classification Layer。SCL 是像元级的分类标签里面把地物分成了阴影、云、雪、水体、植被等十几个类别。去云逻辑从“读取比特位”变成了“查类别编号”var scl image.select(SCL); var cloudMask scl.eq(3) // Cloud shadow .or(scl.eq(8)) // Cloud medium probability .or(scl.eq(9)) // Cloud high probability .or(scl.eq(10)); // Cirrus严格来说这已经不是位运算了但它能很好地和位运算方案做互补。我的个人习惯是处理单景影像、快速预览用QA60位运算方案真正要做时间序列、批量建 composite 的时候优先SCL它的漏判率和误判率在大部分地区优于QA60。2.3 代码段逐行拆解Landsat 8/9 云掩膜函数下面这段代码是 GEE 官方推荐的 Landsat 8/9 Collection 2 SR 云掩膜函数有可能是你搜索“GEE 去云”时见到次数最多的代码段之一。我一行一行拆给你看function maskL8sr(image) { var qa image.select(QA_PIXEL); var cloudBitMask 1 3; var cloudShadowBitMask 1 4; var cirrusBitMask 1 2; var mask qa.bitwiseAnd(cloudBitMask).eq(0) .and(qa.bitwiseAnd(cloudShadowBitMask).eq(0)) .and(qa.bitwiseAnd(cirrusBitMask).eq(0)); return image.updateMask(mask); }第 1 行image.select(QA_PIXEL)从原始影像里把质量波段拎出来。注意这里选的是QA_PIXEL不是老版本的BQA。如果你的image是经过波段筛选的比如你之前select([B2,B3,B4,B5])那QA_PIXEL可能已经被丢掉了这时候select会报错。处理顺序永远应该是先掩膜再选波段。第 2 到 4 行定义三个位掩码。1 3表示把十进制 1 左移 3 位二进制从0001变成1000也就是十进制 8。对应的就是QA_PIXEL表里的 Bit 3 Cloud。同样1 4是 16Bit 4 Cloud Shadow1 2是 4Bit 2 Cirrus。这个写法比直接写8、16、4要更“自解释”——你一看代码就知道它在处理哪一位。第 5 行是核心qa.bitwiseAnd(cloudBitMask)把 QA 值里除了第 3 位之外的所有位清零结果要么是0第 3 位原本是 0要么是8第 3 位原本是 1。后面接.eq(0)意思就是“如果第 3 位是 0那么这个像元不是云掩膜值为 true”。这里的.eq(0)返回的是一个布尔型影像true 保留false 去掉。后面用.and()把“非云”“非云影”“非卷云”三个条件全部连接起来三个条件同时满足这个像元才留下。最后一行image.updateMask(mask)把布尔掩膜应用回原始影像。掩膜为 true 的像元保留原值为 false 的像元在后续所有计算中都会被当成无效值忽略。这一步之后你再去做均值合成、最大值合成、植被指数计算就不会被云污染了。3. 实操过程与核心环节实现3.1 用代码实现自己的通用去云函数理论说完了我来给你一套可以直接上手的完整流程这段代码我在实际项目中跑过很多次踩过一些坑之后调整成了现在的版本。它比官方版多做了两件事一是把填充像元也过滤掉二是顺手把雪也掩掉。function maskLandToaClouds(image) { var qa image.select(QA_PIXEL); var fillBitMask 1 0; var dilatedCloudBitMask 1 1; var cloudBitMask 1 3; var cloudShadowBitMask 1 4; var snowBitMask 1 5; var mask qa.bitwiseAnd(fillBitMask).eq(0) .and(qa.bitwiseAnd(dilatedCloudBitMask).eq(0)) .and(qa.bitwiseAnd(cloudBitMask).eq(0)) .and(qa.bitwiseAnd(cloudShadowBitMask).eq(0)) .and(qa.bitwiseAnd(snowBitMask).eq(0)); return image.updateMask(mask); }为什么我要多处理Dilated Cloud和Fill因为Fill位为 1 的像元大多是传感器扫描带边缘的无效数据不去掉会让合成结果出现条纹状噪声Dilated Cloud是云的边缘膨胀区域这些地方虽然不一定是浓云中心但往往是薄云、半透明云光谱信号已经被污染了留着弊大于利。雪为什么要掩如果你做植被分析或地表温度反演雪像元的光谱特征和云非常像会让 NDVI 直接变成负值。调用方式很简单。如果你要做一个 2023 年 6 月到 8 月、目标区域有少量云污染的合成影像var collection ee.ImageCollection(LANDSAT/LC08/C02/T1_L2) .filterBounds(region) .filterDate(2023-06-01, 2023-08-31) .map(maskLandToaClouds); var composite collection.median();注意map(maskLandToaClouds)这一步是在整个影像集合的每一景影像上执行掩膜函数。掩膜之后再做median()中间那段时间如果有云但没被掩膜完全覆盖中位数会比平均值更稳健因为中位数不受个别极端像元的影响。3.2 自己写掩膜函数时的参数计算技巧如果你面对的是一个新型号传感器或者官方文档给的是位序号而不是十进制值怎么快速算出掩码方法非常简单记住一个公式掩码值 2 的“位序号”次方或者用位移写法1 位序号。比如官方文档告诉你“Bit 4 是云影”那掩码就是1 4 16。告诉你“Bit 2 是卷云”掩码就是1 2 4。不要自己心算 2 的次方写代码时统一用位移表达式可读性高得多。如果你需要同时判断多个位比如“Bit 3 和 Bit 4 任意一个是 1”可以这样写var cloudOrShadowMask qa.bitwiseAnd(8).or(qa.bitwiseAnd(16));这里一定要记得bitwiseAnd得到的是影像.or()也是影像方法它代表逐像元的逻辑或运算。每一像元如果“第 3 位是 1”或者“第 4 位是 1”则结果像元值为 true。还有一种更高效的做法直接把两个掩码合并成 24然后一次位运算判断。因为 8 的二进制是0100016 的二进制是10000取或得到11000就是 24。qa.bitwiseAnd(24)非 0 就说明至少有一位是 1。不过这种写法可读性较差我更推荐读者在真正需要性能优化时再考虑。3.3 调试位运算代码的三种方法位运算最让人头疼的地方在于它不像普通加减法错了你能一眼看出来。我调试这类代码一般用三个手段。第一先在小范围、单景影像上跑再加到集合里批量处理。你要是直接在几千景影像上执行一个没验证过的掩膜函数万一位掩码写错了整个合成结果都会报废而且你还很难定位问题到底出在哪一步。第二把中间结果可视化。用Map.addLayer(qa, {}, QA)看原始 QA 波段再用Map.addLayer(mask, {}, Mask)看掩膜结果如果掩膜在云区全黑或在晴空区全白那就说明逻辑写反了。注意掩膜中 true 在可视化里通常显示为白色false 为黑色或透明。第三统计掩膜前后的有效像元数量。这一步最有说服力var beforeCount image.reduceRegion({ reducer: ee.Reducer.count(), geometry: region, scale: 30, maxPixels: 1e10 }); var masked image.updateMask(mask); var afterCount masked.reduceRegion({ reducer: ee.Reducer.count(), geometry: region, scale: 30, maxPixels: 1e10 }); print(Before, beforeCount, After, afterCount);如果 afterCount 是 0说明你的掩膜把所有像元都干掉了最常见的原因是位掩码写错或者.eq(0)写成了.eq(1)。4. 常见问题与排查技巧实录4.1 掩膜后全是黑的影像一片空白这个情况十个新手九个遇到过。原因多半是你在updateMask(mask)里传入的掩膜非 0 即 1但你的原始影像像元值本身可能就是 0 或者负数。当你用真彩色显示时0 值像元显示为黑色而 mask 为 true 的像元如果原始值也是 0你看到的就是一片黑。实际上这不是掩膜把影像删光了而是你能看到的像元恰好都是 0。解决办法不要只看真彩色先通过Map.addLayer(masked, {min: 0, max: 3000}, masked)调整显示范围或者用一个统计函数看掩膜后像元值的分布。如果是 Sentinel-2原始 SR 值会有负值请把显示范围调整到合理区间比如{min: 0, max: 3000}否则总是感觉影像偏黑。4.2 为什么我用了官方代码云还是没去掉一个很隐蔽的原因你用的影像集合和数据版本不对。QA_PIXEL是 Collection 2 的波段如果你还在用LANDSAT/LC08/C01/T1_SR那个数据集用的是BQA波段位定义差别很大直接在旧数据上套新代码结果一定是无效的。另一个高频原因是你没有在map之前筛选影像有些景本身全是云QA 波段里 Cloud 位确实标记为 1但你的区域太小云没覆盖到你的 ROI你想看的那块地是晴空这不算代码问题是“区域无有效观测”的问题。再有一个很常见的坑我见过有人把cloudBitMask写成1 3却把.eq(0)写成了.eq(1)。这个逻辑错误很隐蔽因为影像还能显示出来只是云区反而被保留、晴空区被去除。视觉上看起来“好像有点怪”但你不对比原图很难发现。所以我强烈建议你每次写完掩膜函数先找一块有云有晴空的对比较明显的区域叠加显示原图、QA、Mask 三层确认逻辑正确再批量跑。4.3 位运算结果出现小数或者非 0/1 的奇怪值这可能是因为你选择的 QA 波段本身是浮点型或者你的image里已经混入了一些非整数波段。位运算是按整数处理的如果输入影像的数据类型是 Float 或 Double结果会让人摸不着头脑。解决方案是在做位运算之前显式转换成整数var qa image.select(QA_PIXEL).toInt();另外一点如果QA_PIXEL某些像元本身是 NoData位运算后得到的也是 NoDataeq(0)的结果会是 false这会导致边缘出现额外的空洞。在数据质量比较差的区域建议先对 QA 影像做一次unmask(0)把 NoData 填成 0再去做位运算判断这样掩膜会更平滑。4.4 一个补充多源影像统一去云时的掩膜组织实际项目中经常需要同时用 Landsat 8、Landsat 9、Sentinel-2 做长时序分析这三者的 QA 波段结构完全不同不能共用一个掩膜函数。我个人的做法是分别写好各自的掩膜函数在map里根据image.get(system:index)或影像 id 去判断调用哪个函数或者干脆先分别处理成一个去云后的ImageCollection最后用ee.ImageCollection.merge合并。如果你用的是Landsat 9记得它的数据集 id 是LANDSAT/LC09/C02/T1_L2位定义和 8 基本一致可以直接复用maskL8sr。另外median()合成确实能进一步压制残余云噪声但要注意如果你的区域在特定季节长期被云覆盖哪怕已经做过去云某些像元在所有可用影像中依然全是云合成结果会出现空洞。这时要考虑时间维度的插值或者引入其他数据源如 MODIS 辅助产品做填补。这部分展开讲又是一篇文章的体量这里就不多写了。5. 关于性能与批量处理的一些建议5.1 map 与 for 循环的选择很多从其他语言转过来的用户一开始会尝试写一个for循环逐景去云。这在 GEE 里是效率极差的做法正确姿势永远是ImageCollection.map(function)。因为map操作在 GEE 后端是并行执行的每一景影像的掩膜计算互不依赖能同时跑而for循环是串行请求等于是让服务器一个一个处理遇到几百景影像的时候速度会慢到怀疑人生。如果你需要把去云后的影像集合导出也请不要一景一景导出而是先做composite或者mosaic然后在 ReduceRegion 或 Export.image 时设置好scale和crs一次性导出。导出的影像如果被 mask 掉了大片区域可以使用unmask(0)填充背景值否则在 GIS 软件里打开全是黑边。5.2 避免“掩膜过度”导致的样本缺失这个坑我在做土地利用分类时踩得很深。一开始我以为云掩膜越激进越好于是把 Cloud、Cirrus、Dilated Cloud、Cloud Shadow 全部滤掉再加上更高的置信度要求结果训练样本区域里有一大半像元变成了 NoData分类精度反而下降。原因是你不可能要求一个地区的每一像元都同时满足“非云非云影非卷云非扩展云”尤其是在多云区域这些条件叠加后有效像元所剩无几。后来我调整了策略把掩膜分为“严格模式”和“宽松模式”。严格模式用于最终成果合成宽松模式只滤掉 Cloud 和 Cloud Shadow不管 Cirrus 和 Dilated Cloud用于样本采集和特征提取。这样既保证了样本数量又不会让明显的云污染混进模型。在写掩膜函数时我建议你也把每个位单独拆成一个布尔变量方便按需组合var cloudFree qa.bitwiseAnd(8).eq(0); var shadowFree qa.bitwiseAnd(16).eq(0); var cirrusFree qa.bitwiseAnd(4).eq(0); var transformBitMask cloudFree.and(shadowFree); // 宽松版以后想调整策略直接重排组合即可。根据我个人经验GEE 里去云这件事代码本身只占三分之一的分量剩下三分之二取决于你对数据产品文档的理解程度。新版数据换一个波段或者位定义表微调几个 bit都会让网上抄来的旧代码当场失效。每次开始一个新项目我建议你先花半小时把当前使用数据集的 Quality Assessment 文档从头到尾读一遍把位定义表截图或者抄下来再动手写掩膜。多花这半小时能帮你少踩一周的坑。最后再分享一个小技巧当你判断是否需要把 Cirrus 也掩掉时可以先做一个 NDVI 直方图对比实验。保留 Cirrus 和掩掉 Cirrus 各出一版 NDVI如果两版直方图形状差异不大说明这个区域卷云影响轻可以放宽如果差异明显那就要严格处理。这种以结果为导向的方式往往比死记硬背 QA 表更能帮你做决策。