资讯详情

GEE实战:Sentinel-2 Level1C指定地点NDVI计算与云掩膜全流程

📅 2026/10/15 5:27:47 | 华诺云谱 👁 阅读
GEE实战:Sentinel-2 Level1C指定地点NDVI计算与云掩膜全流程
1. 项目缘起与整体设计思路第一次接触遥感植被指数计算的朋友十有八九是从NDVI入门的。原因很简单归一化植被指数Normalized Difference Vegetation Index的公式就一行近红外波段减去红光波段再除以两者之和。但真正落到工程上尤其是要针对某个指定地点、用Sentinel-2 Level1C数据、在云端批量跑出来中间的门道远比公式本身多得多。我这次要拆解的项目核心目标很明确在Google Earth Engine后面统一简称GEE平台上对用户指定的一个地理坐标或区域调用Sentinel-2 Level1C影像完成NDVI的计算与可视化输出。听起来像是一个教学Demo但实际做下来它涉及影像筛选、云掩膜、大气校正的取舍、时间窗口设定、坐标系与分辨率对齐、结果导出等一系列环节。任何一个环节处理不当最后出来的NDVI要么被云污染得没法看要么数值范围异常要么导出后跟GIS软件里对不上。这个内容适合谁如果你是遥感、地理信息、生态监测、农业估产方向的在校学生或刚入行的工程师手头有GEE账号但还没系统跑通过一个完整流程那这篇就是写给你的。如果你已经用过GEE但一直搞不清楚Level1C和Level2A到底该选哪个、云掩膜为什么总是不干净那也能从里面找到答案。我会把每一步的“为什么这么做”讲透而不是只丢一段代码让你复制。整体设计上我采用的思路是“先定区域、再定时间、后定处理链”。很多人习惯一上来就写ee.ImageCollection筛选结果区域边界没定清楚后面裁剪、导出全乱套。正确的顺序应该是先把研究区ROI确定下来可以是一个点加缓冲区也可以是一个矢量边界然后根据研究目标确定时间窗口比如要监测某年生长季就锁定6到9月接着才是影像集合的筛选、云处理、指数计算最后是可视化参数设定和导出。这个顺序不是拍脑袋定的而是因为GEE是惰性计算lazy evaluation的你写的每一行只是构建计算图真正触发计算是在getInfo、Export或者地图渲染的时候。顺序清晰调试起来才不痛苦。还有一个关键决策用Level1C还是Level2A。Sentinel-2的Level1C是经过几何精校正的大气顶层反射率TOA而Level2A是经过大气校正的地表反射率BOA。理论上做NDVI应该用Level2A因为它去掉了大气散射和吸收的影响数值更接近真实地表。但Level2A在GEE里的覆盖时间比Level1C晚早期数据可能没有而且有些区域Level2A会有条带或缺失。所以标题里指定Level1C我理解是有意为之——要么是教学演示简化流程要么是目标区域Level2A不可用。我的处理策略是以Level1C为主但在文中会说明如果Level2A可用如何切换以及两者NDVI的差异大概在什么量级。2. 核心概念与关键细节解析2.1 Sentinel-2波段与NDVI的对应关系Sentinel-2 MSI传感器有13个波段做NDVI只需要两个B4红光中心波长约665nm和B8近红外中心波长约842nm。在GEE的COPERNICUS/S2数据集中这两个波段的名称就是B4和B8。注意GEE里还有COPERNICUS/S2_SR那是Level2A的地表反射率产品波段命名一样但数值已经过大气校正。NDVI的计算公式是NDVI (NIR - RED) / (NIR RED)代入Sentinel-2就是NDVI (B8 - B4) / (B8 B4)这个公式的输出范围理论上是-1到1。健康茂密植被的NDVI通常在0.6到0.9之间稀疏植被在0.2到0.5水体、云、雪通常是负值。这里有个容易踩的坑Level1C的TOA反射率数值范围是0到10000被放大了10000倍存储为整数而Level2A的BOA反射率范围是0到10000甚至更大。如果你直接拿原始DN值算NDVI公式本身是比值放大倍数会约掉所以结果不受影响。但如果你要做阈值判断比如NDVI大于0.3算植被那就要确保你算出来的确实是归一化后的值而不是被缩放过的。2.2 云掩膜NDVI质量的生命线云是光学遥感的天敌。一朵云飘过去NDVI直接从0.8掉到-0.5如果你不做云掩膜时间序列曲线会像心电图一样乱跳。Sentinel-2 Level1C自带一个QA60波段里面用位掩码标记了不透明云和卷云。GEE官方示例里常用的云掩膜函数就是基于QA60的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).divide(10000); }这段代码的逻辑是QA60是一个16位整数第10位表示不透明云第11位表示卷云。1 10就是1024bitwiseAnd之后如果结果不为0说明该位被置1即有云。eq(0)就是取没有云的位置。最后divide(10000)是把DN值转成反射率方便后续可视化。但这里有个大坑QA60的云检测并不完美尤其是薄云和云边缘经常漏检。我实测下来在南方多云多雨地区光靠QA60掩膜生长季能用的影像可能只剩两三成。这时候可以考虑结合COPERNICUS/S2_CLOUD_PROBABILITY数据集做更精细的云概率掩膜或者干脆用Level2A自带的SCL场景分类波段里面直接标了云、云影、雪、水等类别。不过标题指定Level1C我们就以QA60为主在注意事项里会提替代方案。2.3 时间窗口与合成策略指定地点做NDVI通常有两种需求一种是看某一景影像的瞬时NDVI另一种是看一段时间内的合成NDVI。前者简单筛选出单景影像直接算后者需要做时间合成比如取一个月内所有可用影像的中值median或最大值max。中值合成能有效抑制云和异常值最大值合成则倾向于保留植被最茂盛的状态常用于生长季峰值监测。时间窗口的设定要看研究目标。如果是农业监测一般按旬或按月合成如果是物候分析可能需要逐日或逐周。这里没有标准答案但有一个原则窗口内至少要有3到5景可用影像否则合成结果不稳定。我一般会先打印出筛选后的影像数量如果太少就放宽时间范围或者扩大区域。2.4 坐标系与分辨率Sentinel-2的空间分辨率是10米B4和B8都是10米GEE默认使用WGS84地理坐标系EPSG:4326进行显示和计算。但如果你要导出到本地做面积统计最好投影到UTM或者Albers等面积守恒的投影否则在高纬度地区面积会失真。GEE的Export.image.toDrive可以指定crs和scale参数我通常会用crs: EPSG:32650对应UTM 50N或者直接用image.projection()继承原影像投影。还有一个细节GEE里reduceRegion做区域统计时默认的scale是影像的原始分辨率但如果你不指定它可能会用最大分辨率比如10米在大区域上计算量巨大甚至超时。所以做区域NDVI均值统计时我一般会把scale设成30或100米牺牲一点精度换速度。3. 完整实操流程与代码实现3.1 研究区定义点加缓冲区还是矢量边界第一步是定义ROI。最简单的方式是定义一个点然后加一个缓冲区var point ee.Geometry.Point([116.391, 39.907]); // 示例坐标 var roi point.buffer(5000); // 5公里缓冲区如果你有矢量边界比如Shapefile或GeoJSON可以先上传到GEE的Assets然后ee.FeatureCollection(users/yourname/yourasset)加载。注意GEE的buffer默认单位是米但如果你在地理坐标系下操作缓冲区会按球面计算精度足够日常使用。提示缓冲区大小要结合研究目标。做城市绿地监测1到2公里够了做流域尺度可能几十公里。缓冲区太大计算量指数上升而且会混入不同地类NDVI均值意义不大。3.2 影像集合筛选与云掩膜接下来筛选Sentinel-2 Level1C影像var s2 ee.ImageCollection(COPERNICUS/S2) .filterBounds(roi) .filterDate(2023-06-01, 2023-09-30) .filter(ee.Filter.lt(CLOUDY_PIXEL_PERCENTAGE, 20)) .map(maskS2clouds);这里我加了CLOUDY_PIXEL_PERCENTAGE小于20的过滤条件这是影像级别的云量元数据能快速剔除云量过高的整景影像。但注意这个百分比是整个影像的不是ROI内的。如果ROI很小整景云量20%可能意味着ROI内全是云。所以更稳妥的做法是先按云量粗筛再在ROI内做像素级云掩膜最后统计ROI内有效像素比例。云掩膜函数前面已经给了这里再补充一个细节divide(10000)之后影像的反射率范围是0到1左右。但有些像素可能因为云边缘或地形阴影出现异常高值我一般会再加一个updateMask把反射率大于1的像素去掉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).divide(10000) .updateMask(image.select(B8).lt(1)); }3.3 NDVI计算与合成有了清洗后的影像集合就可以逐景计算NDVI然后做合成var addNDVI function(image) { var ndvi image.normalizedDifference([B8, B4]).rename(NDVI); return image.addBands(ndvi); }; var withNDVI s2.map(addNDVI); var ndviComposite withNDVI.select(NDVI).median().clip(roi);normalizedDifference是GEE内置的归一化差值函数等价于(B8-B4)/(B8B4)但更简洁且自动处理了除零问题。median()是中值合成你也可以换成max()、mean()或qualityMosaic(NDVI)。qualityMosaic会按NDVI值从高到低排序取每个像素上NDVI最高的那景影像的值适合做生长季峰值合成但会偏向于选择植被最茂盛的日期可能掩盖物候变化。注意clip(roi)一定要加否则后续导出或统计会包含ROI外的区域浪费计算资源。3.4 可视化参数设定NDVI的可视化调色板很关键。我常用的是从棕色到深绿的渐变var ndviVis { min: -0.2, max: 0.8, palette: [#a50026, #d73027, #f46d43, #fdae61, #fee08b, #d9ef8b, #a6d96a, #66bd63, #1a9850, #006837] }; Map.centerObject(roi, 12); Map.addLayer(ndviComposite, ndviVis, NDVI Median);min和max的设定会影响视觉效果。如果你把min设成-1max设成1那大部分植被区域的NDVI在0.3到0.8之间颜色对比就不够明显。我一般会根据研究区实际情况调整比如城市区域min设-0.1max设0.6农田区域min设0max设0.9。3.5 区域统计与导出如果你要统计ROI内的NDVI均值、最大值、标准差var stats ndviComposite.reduceRegion({ reducer: ee.Reducer.mean().combine({ reducer2: ee.Reducer.max(), sharedInputs: true }).combine({ reducer2: ee.Reducer.stdDev(), sharedInputs: true }), geometry: roi, scale: 30, maxPixels: 1e9, bestEffort: true }); print(NDVI Stats:, stats);scale: 30是为了加速maxPixels: 1e9防止像素数超限bestEffort: true让GEE自动调整分辨率。如果你要导出NDVI栅格到Google DriveExport.image.toDrive({ image: ndviComposite, description: NDVI_Export, folder: GEE_NDVI, region: roi, scale: 10, crs: EPSG:32650, maxPixels: 1e9 });导出时scale: 10保持原始分辨率crs指定投影。导出任务不会立即执行需要到GEE的Tasks面板手动点击Run。4. 常见问题与排查技巧实录4.1 NDVI值异常全为负或全为1这是新手最常遇到的问题。原因通常有三个一是波段选错了比如把B4和B8写反了或者用了B3和B4二是云掩膜把有效像素全掩掉了导致median()返回空值三是divide(10000)没加DN值直接算NDVI虽然比值不变但如果后续做了阈值判断就会出错。排查方法先打印影像集合的大小print(s2.size())如果是0说明筛选条件太严再打印单景影像的波段列表print(s2.first().bandNames())确认B4和B8存在最后用Map.addLayer分别加载B4和B8看看数值范围是否正常。4.2 云掩膜后影像数量骤减在南方地区生长季云量高QA60掩膜后可能只剩几景。这时候可以考虑放宽CLOUDY_PIXEL_PERCENTAGE到30或40但配合更精细的像素级掩膜或者改用Level2A的SCL波段它把云影也标出来了掩膜更彻底再或者用Sentinel-1的SAR数据做辅助但那是另一个话题了。4.3 导出后与GIS软件对不上常见原因是投影和分辨率不一致。GEE默认导出可能是WGS84地理坐标系而你在ArcGIS或QGIS里用的是UTM投影叠加时会有偏移。解决办法是在导出时明确指定crs和scale并确保GIS软件里也设置相同的投影。另外GEE导出的GeoTIFF可能带有noData值在GIS里要设置透明或忽略。4.4 计算超时或内存不足大区域、长时间序列、高分辨率三者叠加很容易触发GEE的计算限制。我的经验是先用小区域和短时间窗口测试代码逻辑跑通后再逐步扩大区域统计时把scale设大一点导出时用maxPixels: 1e9和bestEffort: true如果还是不行就分块导出比如按月份或按子区域拆分。4.5 Level1C与Level2A的NDVI差异我做过对比同一地点同一天Level1C的NDVI通常比Level2A高0.02到0.05因为TOA反射率包含了大气散射的贡献尤其是在蓝光波段但NDVI只用红光和近红外大气影响相对小一些。在植被茂密区两者差异不大在稀疏植被或水体边缘差异会明显一些。如果你的研究对精度要求高建议用Level2A如果只是做趋势分析或教学演示Level1C够用。问题现象可能原因排查方法解决方案NDVI全为负波段写反或云掩膜过度打印波段列表检查掩膜后像素数修正波段顺序放宽云掩膜影像集合为空时间窗口太窄或云量阈值太低打印size()逐步放宽条件扩大时间范围提高云量阈值导出影像有黑边noData值未处理在GIS中查看栅格属性导出时设noDataGIS中设透明区域统计超时区域太大或分辨率太高查看Tasks面板错误信息增大scale分块统计NDVI时间序列跳变云污染或合成方法不当逐景查看NDVI影像改用中值合成加强云掩膜5. 进阶技巧与个人实操心得5.1 用qualityMosaic做生长季峰值合成如果你要提取一个区域在生长季的NDVI峰值qualityMosaic(NDVI)比max()更好用。max()是逐像素取最大值但不同像素的最大值可能来自不同日期合成结果在空间上不连续。qualityMosaic是先按NDVI排序然后取每个像素上NDVI最高的那景影像的完整波段这样合成结果在时间上是一致的适合做物候分析。var ndviPeak withNDVI.qualityMosaic(NDVI).select(NDVI).clip(roi);5.2 加入时间序列图表GEE的ui.Chart可以快速画出ROI内NDVI的时间序列var chart ui.Chart.image.series({ imageCollection: withNDVI.select(NDVI), region: roi, reducer: ee.Reducer.mean(), scale: 30 }).setOptions({ title: NDVI Time Series, vAxis: {title: NDVI}, hAxis: {title: Date} }); print(chart);这个图表能直观看出植被的物候变化比如返青期、峰值期、枯黄期。如果曲线有异常跳变说明云掩膜没做好需要回头检查。5.3 批量导出多个月份的NDVI如果你要按月导出NDVI可以用循环var months ee.List.sequence(6, 9); months.getInfo().forEach(function(m) { var start ee.Date.fromYMD(2023, m, 1); var end start.advance(1, month); var monthly s2.filterDate(start, end).map(addNDVI) .select(NDVI).median().clip(roi); Export.image.toDrive({ image: monthly, description: NDVI_2023_ m, folder: GEE_NDVI, region: roi, scale: 10, crs: EPSG:32650, maxPixels: 1e9 }); });注意getInfo()是同步操作会阻塞直到返回结果。如果月份很多建议用ee.List的map配合Export但导出任务本身是异步的需要在Tasks面板手动运行。5.4 个人踩坑记录第一个坑早期我用filterBounds(roi)之后直接median()忘了clip(roi)结果导出的影像比ROI大一圈在GIS里叠加时才发现。第二个坑QA60的位掩码我一开始写成了1 10和1 11但实际测试发现有些影像的QA60波段是空的后来查文档才知道Sentinel-2 Level1C从2022年1月25日之后QA60波段被移除了改用MSK_CLDPRB和MSK_SNWPRB。所以如果你的时间窗口在2022年之后云掩膜函数要改成基于MSK_CLDPRB的function maskS2clouds(image) { var cldProb image.select(MSK_CLDPRB); var mask cldProb.lt(20); // 云概率小于20%视为无云 return image.updateMask(mask).divide(10000); }这个变化很多人不知道网上很多老教程还在用QA60跑2023年数据就会报错。我建议你拿到数据后先print(s2.first().bandNames())看一眼确认有哪些波段可用。第三个坑reduceRegion的scale参数。我一开始没设默认用了10米结果一个5公里缓冲区的统计跑了十几分钟还超时。后来改成30米几秒钟就出结果。精度损失可以接受因为NDVI均值对分辨率不敏感。5.5 关于Level1C的再思考标题指定Level1C我猜测可能是教学场景或者目标区域Level2A缺失。但如果你在实际项目中我强烈建议优先用Level2ACOPERNICUS/S2_SR因为大气校正后的NDVI更准确尤其是在气溶胶含量高的地区。切换方法很简单把COPERNICUS/S2换成COPERNICUS/S2_SR云掩膜改用SCL波段function maskS2sr(image) { var scl image.select(SCL); var mask scl.neq(3).and(scl.neq(8)).and(scl.neq(9)).and(scl.neq(10)); return image.updateMask(mask).divide(10000); }SCL里3是云影8是中概率云9是高概率云10是卷云。这样掩膜比QA60干净得多。最后再分享一个小技巧如果你要对比不同年份的NDVI一定要用相同的合成方法和时间窗口否则物候差异会混入年际变化。我一般会固定用生长季6到9月的中值合成这样年际可比性最好。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑