做过土地利用分类的人基本都体验过这种崩溃:分类器跑完了,打开结果图一看,原本应该整齐的耕地像打翻的调色盘,河流边界全是锯齿,连片的林地被切成几百个碎块,整个研究区看起来像是撒了一地芝麻。一开始我也以为是样本点没选好、分类器参数不对,反复调特征、换算法,折腾了好几天,结果改善有限。后来才意识到,问题不在分类本身,而在分类之后少了一道关键的工序——Landcover Cleanup。这个标题里的Cleanup不是简单“去掉小斑块”那么轻描淡写,它是把像素级分类结果从“能用”提升到“能看、能出图、能入库”的必经步骤。这篇文章就来把GEE里做土地利用分类清理的核心方法、代码逻辑和调试经验完整梳理一遍,适合已经会用GEE做基础分类、但苦于成果图面质量的同学参考。
1. 分类结果的“半成品”长什么样:三类典型噪声的成因分析
很多教程讲到随机森林分完类就结束了,没人告诉你后面还有一大段活要干。先搞清楚分类结果为什么“脏”,才能理解Cleanup为什么这么设计。
1.1 椒盐噪声:像元级独立决策的必然副产物
GEE里最常用的分类流程,是给每个像元单独提取波段特征、指数特征,然后扔进分类器,分类器对每个像元独立地给出一个类别标签。问题就出在“独立”这两个字上——分类器并不知道旁边那个像元是什么类别,也不会因为自己的结果和周围不一致就主动修正。
举个例子,一片成熟玉米地在Landsat影像上,因为含水量、长势、土壤背景的微小差异,总会有零星几个像元的反射率和周边不太一样。分类器只看这几个像元的数值,就可能把它们判成草地甚至建设用地。这种误判在空间上表现为一个一个孤立的小点,椒盐噪声就这么来了。Landsat 30米分辨率本身就有混合像元问题,一个像元里可能一半是耕地一半是道路,光谱特征落在两个类别的模糊区间,分类器只能硬着头皮选一个。
换句话说,椒盐噪声不是分类器“笨”,而是像素级分类的结构性局限。要解决它,必须引入空间信息——让分类图“参考邻居的意见”。
1.2 锯齿边界与碎斑:空间语义缺失的代价
另一种常见问题比椒盐噪声更难处理,就是地物边界的锯齿状和同类地物的碎化。
耕地、林地、水体这类地物在自然界里往往是大片连续分布的,但分类器没有“大片连续”这个概念。它只关心光谱值,于是同一块林地里,树冠阴影重的像元可能被判成水体,灌木丛稀疏的区域可能被判成草地,整片林地就碎成了无数小图斑。边界部分更不用说,栅格数据本身是方形像元网格,分类时对连续地物的边缘做离散化,地块边界自然呈现锯齿状,这是任何像素级分类都无法避免的。
细碎斑块在面积统计时尤其麻烦。我见过一个研究区的分类结果,光斑块数量就有十多万个,其中一大半都是面积不到一个像元的小碎片。这种结果拿去算面积、做变化检测、出专题图,数据量又大又不准,根本没法用。
搞清楚这些成因之后,清理思路就很清晰了:需要一种能知道“哪个像元和哪个像元属于同一个连续区域”的方法,然后在区域层面做决策。这就引出了GEE里最重要的一个工具——连通域分析。
2. 从像元视角切换到斑块视角:连通域分析是Cleanup的基石
Cleanup的第一个关键动作,是把处理单元从“单个像元”升级为“连通斑块”。GEE提供了两个层面的工具,分别适合不同的场景。
2.1 connectedPixelCount:快速获取每个斑块的像元数量
connectedPixelCount是GEE里做分类图清理时出镜率最高的函数之一。它做的事情很直观:给每个像元赋值一个数字,表示它所在的连通斑块总共包含多少个像元。
// 假设 classImg 是分类结果,类别取值为 1-6 的整数 var pixelCount = classImg.connectedPixelCount({ maximumNeighbors: 256, eightConnected: true }).rename('pixelCount');这里的maximumNeighbors表示最多向外搜索多少个相邻像元,eightConnected: true表示使用8邻域连通(上下左右加四个对角方向)。8邻域比4邻域对斜向连接的斑块更友好,建议默认设为true。
拿到pixelCount之后,就可以在斑块层面做筛选了。比如去掉所有像元数小于5的斑块,再和原分类图叠加:
var cleaned = classImg.updateMask(pixelCount.gte(5));updateMask会把不满足条件的像元设为掩膜(无效值),只保留面积达到要求的斑块。这个操作看起来简单,但它把空间连续性这个维度正式引入了清理流程。
这里有个值得注意的点:connectedPixelCount直接返回的是像元数量,不是实际面积。如果想知道斑块的平方米面积,乘以ee.Image.pixelArea()即可:
var areaImg = pixelCount.multiply(ee.Image.pixelArea()).rename('area_m2');30米分辨率的Landsat,一个像元约900平方米,面积阈值5个像元就是4500平方米。不同研究目的对最小图斑面积的要求差异很大,后面会专门讲阈值怎么选。
2.2 connectedComponents与reduceConnectedComponents:真正的对象级统计
如果你觉得只需要“数一数斑块有多大”就够了,那没问题。但有些情况下,我们需要对斑块做更复杂的统计——比如计算每个斑块内的平均NDVI,或者找出每个斑块的主导类别。这个时候就需要connectedComponents和reduceConnectedComponents了。
connectedComponents会给每个像元分配一个唯一的斑块ID:
var objectId = classImg.connectedComponents({ connectedness: ee.Kernel.plus(1), maxSize: 256 });输出图像有一个labels波段,同一个连通区域内的所有像元共享同一个ID值。拿到ID之后,就可以把分类图和一个需要统计的波段合并,对每个斑块做区域统计:
// 给每个斑块计算内部像元数量 var objCount = objectId.select('labels') .addBands(pixelCount) .reduceConnectedComponents({ reducer: ee.Reducer.count(), maxSize: 256 });它的逻辑其实和ArcGIS里的Region Group很相似。ArcGIS里你选中一个栅格图层,用Region Group生成连通区编码,再配合Zonal Statistics统计每个区内的像元数、均值、最大值等信息。GEE里的这两个函数就是等价的云端实现。
不过在实际做Cleanup时,我90%的场景用connectedPixelCount就够了,因为它够快、够直接。只有需要做“斑块内部类别再判断”时,才会用到reduceConnectedComponents。比如你想把面积大于某阈值、但内部类别占比低于另一个阈值的斑块强制重归类,这就必须用到斑块ID和分区统计的组合。
2.3 桌面软件与GEE的操作对比
如果你之前习惯用桌面遥感软件做分类后处理,理解GEE这套工具会非常快。桌面软件里,你通常先做聚类/分割,再手动设置最小图斑面积、合并规则,每一步都有可视化界面。GEE里没有按钮,只有函数,但它把这个流程压缩成了一条链:连通域分析 → 斑块属性计算 → 条件筛选 → 结果回填。
这种“函数式”的写法的好处是,整套处理流程是可复现的、可批量的。你换一个研究区、换一期影像,只要改一下参数,跑一遍就能得到同样规范的结果,不用再手动点来点去。对于需要处理多期影像做土地利用变化分析的人来说,这是桌面软件很难比的效率优势。
3. 三招清理细碎图斑:类别合并、众数滤波与面积阈值筛选
有了连通域这个工具,接下来就是具体的清理操作。我一般会按顺序做三件事:先合并类别,再众数滤波,最后按斑块面积筛选。每一步都有它的必要性,顺序不建议乱。
3.1 第一招:类别重编码,先把“语义噪声”降下来
很多时候分类图的“脏”不是地类分错了,而是分类体系定得太细。常见的情况是把森林细分成了常绿林、落叶林、混交林,但不同森林类型在光谱上高度重叠,分类器硬分出来的结果就是一片斑驳。
这时候做一个类别合并,效果立竿见影。GEE里用remap重编码:
// 原始类别:1常绿林 2落叶林 3混交林 4灌木 5草地 6农田 // 合并后:1林地 2灌木 3草地 4农田 var fromList = [1, 2, 3]; var toList = [1, 1, 1]; var classMerged = classImg.remap(fromList, toList);为什么要先做这一步而不是先滤波?因为类别越细,类别之间的光谱边界越模糊,椒盐噪声越严重。先把容易混淆的子类合并成有明显光谱差异的大类,噪声自然减少,后面的滤波压力也小了。就像你先把一堆颜色相近的豆子分成几大类,再挑里面的沙子,比直接在一堆豆子里挑沙子要容易得多。
3.2 第二招:众数滤波,用邻域信息压制椒盐噪声
合并类别之后,再用邻域统计对图像做一次平滑。对分类图来说,最合适的平滑不是均值或中值,而是众数——取邻域内出现次数最多的类别作为中心像元的新类别。
// 3x3核的众数滤波 var smoothed = classMerged.focalMode({ radius: 3, kernelType: 'square', units: 'pixels', iterations: 1 });这招对消除孤立点特别管用:一个落在一大片农田里的孤立“建设用地”像元,它的3x3邻域里大概率有5个以上农田像元,众数滤波会把它刷成农田。
这里有一个参数选择的细节:核大小用3x3够不够?我的经验是,30米分辨率的Landsat数据做常规土地利用分类,3x3的核就够了。用5x5甚至7x7的核确实更平滑,但代价是会把一些窄条状地物吃掉——比如三四米宽的乡村道路,在Landsat影像上本来就只占一个像元左右,核稍微大一点,路就没有了。分类图里的线性地物本来就珍贵,别让滤波把它们清掉。
还有一个容易被忽略的细节:focalMode处理的是整数值类别标签,操作过程中不会产生小数,比focalMedian更适合分类图。如果你习惯用focalMedian,记得对结果取整,否则类别标签里会出现2.5这种不存在的类别。
3.3 第三招:连通域面积阈值,按斑块而非像元做取舍
经过前面两步,椒盐噪声基本没了,但细碎图斑还在:一片林地里可能有几个连成一小块的误分类区域,面积有三四个像元,既不是孤立点,也不是大面积真实地物。它们不会轻易被众数滤波消除,必须用连通域分析按斑块面积筛掉。
// 计算连通像元数 var patchCount = smoothed.connectedPixelCount({ maximumNeighbors: 256, eightConnected: true }); // 筛选最小图斑面积(这里设定至少6个像元) var cleaned = smoothed.updateMask(patchCount.gte(6));这一步的操作逻辑是:如果一个斑块的像元数小于阈值,整个斑块都被视为不可靠结果,整体掩膜掉。注意,和众数滤波只改中心像元不同,这里是对整个小斑块做切除,两者结合才能既去掉孤立点又去掉小碎块。
筛掉的小斑块后续怎么处理?这取决于你的用途。如果只是出图展示,掩膜掉就够了;但如果你需要完整的、没有空洞的分类图去算面积,就要对掩膜掉的位置做填充。最简单的填充方式是再做一次众数滤波,把空洞填成周围的主导类别:
var filled = cleaned.unmask(0) .focalMode({radius: 5, kernelType: 'square', units: 'pixels'});注意unmask(0)把掩膜区域临时填成0,再用大一点的核做众数填充,填完再把0类别的地方重新掩膜。这一步会让最终的分类图在边缘更“圆润”,视觉上自然很多。
3.4 三招的顺序为什么不能乱
我一开始也试过先做面积筛选、再做类别合并,结果发现筛选完之后,很多小斑块本来属于某个类别,合并类别后这些斑块被重新归并成了更大的连通域,之前的面积筛选就白做了,还得再筛一遍。
反过来,先合并类别再滤波再筛选,每一步都在前一步的基础上处理,逻辑上是清晰的:先减少类别间的光谱混淆,再消除孤立点,最后切除小碎块。如果顺序反了,每一步处理可能创造新的问题,最终的多轮调试会非常痛苦。所以这三步的顺序不是个人偏好,而是从数据问题的成因到最后处理的自然逻辑顺序。
4. 清理不是滤镜,是决策:面积阈值、类别语义与精度检验
很多人把Cleanup当成一个“美化”过程,觉得只是让图看起来更干净。这种理解会带来一个隐患:清理其实是在做决策——你决定留下哪些图斑、去掉哪些图斑,而这个决策会直接影响面积统计和分类精度。所以清理的每一步都应该有依据。
4.1 阈值不该拍脑袋:按类别分别设阈值
最小图斑面积设多少?这是Cleanup里最常见的问题。我的建议是,不要对所有类别设置同一个阈值。
不同地类的空间尺度差异很大:一片建设用地可能只有两三个像元,但它是有意义的;而一块农田如果只有两三个像元,大概率是分类噪声。所以在实际项目里,我会把常用类别按空间特征分个类:
| 类别 | 空间特征 | 建议最小图斑面积(Landsat 30m) |
|---|---|---|
| 水体 | 往往连续、形态完整 | 5-10个像元(小水塘需放宽到3个) |
| 建设用地 | 城市内部破碎,但单体较大 | 10-20个像元 |
| 农田 | 地块大且规则 | 10-30个像元 |
| 林地 | 大范围连续 | 10-20个像元 |
| 道路 | 线性窄条 | 1-3个像元(不建议过滤) |
实现按类别设置不同阈值的方法是,先对每个类别分别提取掩膜,再分别做连通域筛选,最后把结果合并:
// 对水体类别单独筛选,阈值设为3个像元 var waterMask = smoothed.eq(1); // 假设类别1是水体 var waterCleaned = smoothed .updateMask(waterMask) .updateMask(waterMask.connectedPixelCount({ maximumNeighbors: 256, eightConnected: true }).gte(3));这种做法比全局一个阈值更贴近实际需求,特别是当研究区里既有大型水体又有零散小水塘时,全局阈值会直接抹掉小水塘,造成面积统计的明显偏差。
4.2 清理前后精度如何比对:混淆矩阵与面积趋势
清理之后,怎么判断效果好不好?肉眼看着顺眼是一个维度,但更应该用定量指标验证。
最常用的是混淆矩阵。在GEE里,先对清理后的分类图做分层随机采样,再结合高分辨率影像人工解译样本点,用errorMatrix计算总体精度、用户精度和生产者精度:
var sample = cleaned.stratifiedSample({ numPoints: 300, classBand: 'classification', region: roi, scale: 30, geometries: true }); // 假设在样本点上人工解译得到 reference 字段,取值0-5 var confusionMatrix = sample.errorMatrix({ actual: 'reference', predicted: 'classification' }); print('总体精度', confusionMatrix.accuracy()); print('Kappa系数', confusionMatrix.kappa());这里有个经验:做清理前后的对比验证时,样本点要完全一致,这样两个混淆矩阵才有可比性。我见过有人清理前后各抽一套样本,最后精度变化其实是抽样误差造成的,根本不说明问题。
另外,我还习惯对清理前后的地类面积做一个对比趋势表。正常情况下,清理后细碎小斑块被归并,各地类总面积变化不会特别剧烈,一般不超过5%。如果某个类别面积变化超过10%,说明阈值设得太激进,把真实地物也一起清掉了,需要调参。
4.3 一个容易被忽略的问题:清理的代价是面积偏移
最后必须提醒一点:清理操作本质上是在改变分类结果,它不会无损地提高精度。每抹掉一个小斑块,都会有一部分真实地物的面积被“转移”到其他类别。
比如一片真实的灌木丛,因为面积小而零散,被面积阈值整体掩膜掉了,随后在填充阶段被周围的草地类别覆盖,那这片灌木的面积就从统计表里消失了,而草地的面积变大了。在做面积统计和后续的变化检测时,这种偏移会被放大,影响结论。
所以我对Cleanup的态度一向是:能用轻度处理解决问题,就不要用重度处理。能设5个像元阈值解决碎斑,就绝不设20个像元。清理的目的是去掉明显不可靠的分类噪声,而不是把分类图“修”得过于干净,后者往往会牺牲真实性。
5. 可复现的完整流程:从训练样本到Cleanup成果导出
讲完了原理和单个步骤,下面给出一段可以直接参考运行的完整流程。为了方便说明,我假设已经在某个研究区内准备好了训练样本点,且样本点带有类别属性class。
5.1 基础分类流程回顾
首先是影像加载与特征构建。这里以Landsat 8 Collection 2 Level-2数据为例,附加三个光谱指数作为分类特征:
var roi = ee.FeatureCollection('your_roi_asset'); // 替换为自己的研究区 var samples = ee.FeatureCollection('your_samples_asset'); // 替换为训练样本 var image = ee.ImageCollection('LANDSAT/LC08/C02/T1_L2') .filterBounds(roi) .filterDate('2023-01-01', '2023-12-31') .filter(ee.Filter.lt('CLOUD_COVER', 10)) .median() .clip(roi); // 添加常用光谱指数 var imageWithIndices = image .addBands(image.normalizedDifference(['SR_B5', 'SR_B4']).rename('NDVI')) .addBands(image.normalizedDifference(['SR_B3', 'SR_B5']).rename('NDWI')) .addBands(image.normalizedDifference(['SR_B4', 'SR_B6']).rename('MNDWI')); // 训练随机森林分类器 var training = samples.map(function(f) { return f.select(['NDVI', 'NDWI', 'MNDWI', 'SR_B2', 'SR_B3', 'SR_B4', 'SR_B5', 'SR_B6', 'SR_B7']); }); var classifier = ee.Classifier.smileRandomForest(100).train({ features: training, classProperty: 'class', inputProperties: ['NDVI', 'NDWI', 'MNDWI', 'SR_B2', 'SR_B3', 'SR_B4', 'SR_B5', 'SR_B6', 'SR_B7'] }); // 得到初始分类结果 var classImg = imageWithIndices .select(['NDVI', 'NDWI', 'MNDWI', 'SR_B2', 'SR_B3', 'SR_B4', 'SR_B5', 'SR_B6', 'SR_B7']) .classify(classifier) .rename('classification');这段代码里,样本点的质量直接决定分类结果的基调。如果样本数量少、解译不准确,后面再怎么做Cleanup都是徒劳。我通常每个类别至少准备50-80个样本点,且尽量均匀分布在整个研究区,避免集中在某一个角落导致分类器对空间位置产生隐性依赖。
5.2 Cleanup完整代码段
接下来进入本文的核心,把前面讲的三个步骤整合成一段完整的Cleanup处理链:
// ===== 第一步:类别合并 ===== // 以原始类别1-6为例,分为水体、林地、灌木、草地、农田、建设用地 // 这里将林地类(1、2、3子类)合并为一个大类 var fromList = [1, 2, 3]; var toList = [1, 1, 1]; var classMerged = classImg.remap(fromList, toList); // ===== 第二步:众数滤波 ===== var smoothed = classMerged.focalMode({ radius: 3, kernelType: 'square', units: 'pixels', iterations: 1 }); // ===== 第三步:连通域面积筛选 ===== var patchCount = smoothed.connectedPixelCount({ maximumNeighbors: 256, eightConnected: true }); var cleaned = smoothed.updateMask(patchCount.gte(6)); // ===== 第四步:掩膜空洞填充 ===== var finalImg = cleaned.unmask(0).focalMode({ radius: 5, kernelType: 'square', units: 'pixels' }).updateMask(cleaned.unmask(0).select(0).neq(0)); // 输出清理后的分类图 Map.addLayer(finalImg, {min: 0, max: 5, palette: ['0000ff', '00aa00', 'ffaa00', 'ffff00', 'ff0000']}, 'Final Landcover');这段代码的运行逻辑是:先合并类别,把分类体系简化;再通过众数滤波,把孤立像元“洗”成周围的主导类别;随后以6个像元为最小面积阈值,把过小的连通斑块整个剔除;最后对剔除区域做一个5x5邻域的众数填充,保证分类图没有空洞。整个处理过程都在GEE服务器端完成,导出时不用额外担心计算内存。
5.3 导出与可视化建议
清理完成后,导出时要注意两个细节:一是确认投影和分辨率,Export.image.toDrive里scale设置为30,crs建议设置为研究区对应的UTM投影,避免默认的EPSG:4326导致面积变形;二是导出前最好做一次reproject,用固定的投影和尺度把分类图重采样,防止后续ArcGIS或QGIS打开时出现莫名其妙的偏移:
Export.image.toDrive({ image: finalImg, description: 'landcover_cleanup_result', folder: 'GEE_Export', region: roi, scale: 30, crs: 'EPSG:32650', // 根据研究区所在纬度带替换 maxPixels: 1e13 });如果你只是在GEE里做后续分析,不打算导出,那不需要做reproject,GEE会按照计算时的默认投影自动处理。
6. 我的调试踩坑记录与参数心得
文章最后,整理几个我在实践中反复踩过的坑。这些问题在官方文档里多半没有直接答案,但遇到的时候非常耽误时间。
6.1 大范围计算内存溢出的规避
GEE的内存限制是新手最容易撞上的墙。对一个大范围研究区做connectedPixelCount,尤其是maximumNeighbors设得比较大(比如512),经常会在计算时报“User memory limit exceeded”。
这个问题的根源在于,连通域分析需要把每个像元的连通关系记录在内存里,范围越大、连通域越大,消耗的内存就越高。我的做法是,先用clip(roi)把影像范围限定在研究区内,不要带着影像的原始全球边界算;其次,如果研究区实在太大,按网格分块导出再拼接,避免一次性计算。还有一种折中方案:先用reduceResolution把分类图粗化到90米或120米,在低分辨率下做一次大尺度的斑块筛选,再把结果重采样回30米,用于剔除超大范围上的孤岛斑块。
6.2 updateMask导致边缘黑块怎么处理
用updateMask(patchCount.gte(6))筛掉小斑块后,分类图边缘会出现一圈无数据的黑块,尤其是研究区边界裁剪处,原本被裁成碎片的斑块因为面积不足被整体掩膜了。这个问题在视觉上很突兀,也会影响后续的面积计算。
我的解决方案是,在面积筛选前,先对研究区做一次缓冲处理,或者在筛选后对空值区域做邻域类别填充。前面代码里的unmask(0)加focalMode填充就是应对这个问题的。填完之后,边缘黑块会变成周边类别的延续,视觉效果和统计结果都正常得多。
6.3 别对线性地物下重手:道路与沟渠的清理教训
最后一条经验来自一次真实教训。某个项目里,研究区包含大量乡村道路和林间防火道,这类地物在30米Landsat影像上往往只有1-2个像元宽,连通性却很强。我当时为了提高图面整洁度,把最小面积阈值设到了15个像元,结果道路被整体切碎,然后被邻域类别填充抹掉了。后续做道路缓冲区和生态廊道分析时,数据里完全找不到道路网络,只能重做。
从那以后,我对线性地物一律“特殊照顾”:要么不做面积筛选,要么单独设置1-3个像元的低阈值,要么在分类体系里把道路和建设用地分开建模,避免被建设用地的大面积阈值牵连。清理的度,真的得根据研究目标和地物特征来定,任何参数都不能无脑套用。
做土地利用分类越久越觉得,分类器负责的是“猜”,Cleanup负责的是“判断”。前者把每个像元放进最可能的类别,后者决定哪些像元组合有资格成为一个地理实体。把Cleanup这一步做好了,分类成果的质量会有一个肉眼可见的跃升,后续的面积统计和变化分析也才站得住脚。希望这篇整理能帮你少走一点我在调试过程中绕过的弯路。