ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于随机森林的冰湖遥感提取全流程实战

基于随机森林的冰湖遥感提取全流程实战

这几年做高海拔冰川区的遥感蚀变监测,我前前后后试过不少水体提取方案,最后发现最稳、最适合直接拿来做业务的,还是“随机森林分类提取冰湖”这一套。它不像传统阈值法那样依赖某个经验参数,也不会因为一条山体阴影就把整个结果带偏,只要你把特征工程和样本质量控制好,落地的精度和稳定性都很能打。这篇文章把我从数据准备、特征搭配、模型参数到整景影像预测和后处理的完整流水线都写出来,全部用 Python 实现,代码可以直接改路径跑,适合正在做冰湖提取、冰川洪水风险评价或者相关遥感分类的同学参考。

1. 冰湖提取为什么不能只靠“水体指数”

1.1 水体指数原理与冰湖的特殊性

讲冰湖提取,很多人的第一反应是用 NDWI(归一化差异水体指数)或者 MNDWI(改进型归一化差异水体指数)做个阈值分割。这个思路本身没有错,核心逻辑是利用水体在绿光波段反射较高、在近红外或短波红外波段强烈吸收的特性,构造一个比值来放大水体和陆地的差异。公式不复杂:

NDWI = (Green - NIR) / (Green + NIR) MNDWI = (Green - SWIR1) / (Green + SWIR1)

在普通平原湖泊、水库这类场景下,这两个指数的区分效果确实不错,水体接近 0.2 以上,植被和裸土普遍在 0 以下,随便卡一个 0 或者 0.1 的阈值就能拿到像模像样的水体边界。

但冰湖不一样。冰湖所处的地表环境极其“嘈杂”,融水、冰碛物、基岩、季节性积雪、山体阴影在同一景影像里密集共存。冰湖本身也因为悬浮泥沙、冰川粉的注入,光谱特征从深蓝到乳白都有,光谱曲线非常不稳定。换句话说,在冰湖场景里,“水”不是一个光谱上均匀的类别,而是一个分布范围很宽的类别。

1.2 阈值法在冰湖场景里的典型翻车现场

我自己做过一组对比实验,用同一景影像对某研究区分别跑 MNDWI 阈值法和随机森林分类。阈值法为了把所有浑浊湖都包含进来,只能把阈值压低,结果一压低,大量冰川融水湿裸地、冰碛物内的高湿度碎屑全被当成水体。而如果把阈值抬高,又能明显看到部分乳白色冰湖被漏掉,尤其是湖体边缘水浅、悬沙浓度高的过渡带,直接和周围的冰碛混成一片。

更致命的是山体阴影。高海拔地区地形起伏大,沟谷里的阴影区域在蓝绿波段的表现和水体极度相似。MNDWI 里这些阴影也会出现接近水的指数值,阈值法没有任何手段去区分“这是阴影还是水”,只能靠后期人工修。季度性积雪同理,尤其是季节雪刚化完、地面还没完全干燥的时候,湿润的砂砾层会给出一个典型的“伪 NDWI”信号。

1.3 为什么随机森林适合做这件事

随机森林处理的不是一个单一指数,而是一个多维特征向量。每个像元在它眼里是“蓝、绿、红、近红外、短波红外、海拔、坡度、各种指数值”的组合体,模型会在训练样本的指导下,自动学习出“什么样的特征组合更像水、什么样的更像冰碛物”。它不需要你手工调一个阈值,而是通过大量决策树投票的方式,把特征空间里的决策边界拟合出来。

这个性质对冰湖提取尤其关键,因为冰湖的光谱表现不是一个固定的值,是一个复杂的区域。随机森林天然支持多模态分布,同样被识别为水体的像元,既可以来自深蓝色的清洁湖,也可以来自乳白色的悬沙湖,只要它们在特征空间里能形成相对聚集的分布。你甚至可以额外加入 DEM 派生特征,让模型学会“海拔超过某个高度的水体更可能是冰湖”这种业务逻辑。

2. 特征工程:算法上限的决定因素

2.1 影像源与波段组合选择

我常用的数据源是哨兵二号(Sentinel-2)L2A 级产品,因为它自带大气校正,10 米空间分辨率在中小型冰湖识别上有明显优势,而且 20 米分辨率的短波红外波段对水体识别非常关键。如果你手头只有 Landsat 系列,也可以跑同一套流程,只是重采样和波段命名调整一下。

波段选择上,核心是蓝、绿、红、近红外、短波红外一、短波红外二这六个波段。蓝绿波段负责捕捉水体反射峰,近红外和短波红外负责“压”植被和干燥裸地,红波段对浑浊水有一定响应。不建议只用前四个波段而丢掉短波红外,冰湖场景里短波红外对“含水但不完全是水”的地物(如湿裸地、冰川冰)有很好的区分力。

如果影像存在云和云影,务必先做掩膜,否则云影一样会被模型误判为水。我自己习惯的做法是先用 SCL 波段(场景分类波段)把云、云影、雪盖像元提前排除,只对干净像元执行分类,这样能显著降低后续人工修图的量。

2.2 指数特征:不是越多越好,但要有针对性

除了原始波段,我会计算三个指数作为额外特征:NDWI、MNDWI 和 NDVI(归一化差异植被指数)。这里多说一句,很多教程把指数当成“必杀技”狂堆几十个特征,实际上对随机森林来说,信息冗余反而会稀释强特征的权重。我实测下来,三到四个指数就够用,重点是让每个指数代表一个物理意义明确的维度。

在冰湖场景里,MNDWI 的贡献通常大于 NDWI。原因很简单,MNDWI 用短波红外替代了近红外,对“湿润土壤”“融水薄层”这类含水地物的抑制效果更好,能把湖体和周边湿地的混淆度压下去。而 NDVI 的作用是辅助剔除洼地周边发育的茂密草甸,虽然冰川区植被覆盖度普遍不高,但河谷漫滩里的低矮灌丛经常被误认为水,有了 NDVI 这个维度,模型能少犯很多低级错误。

2.3 地形特征的引入

这是冰湖提取区别于普通水体提取的最重要一步。冰湖在绝对地理意义上必须和冰川活动产生关联,所以将海拔(DEM)、坡度(Slope)和地形位置指数(TPI)加入特征栈,等于让模型获得了“高度上下文”。

实际操作中,将 DEM 重采样到与影像一致的分辨率,然后计算坡度和 TPI。坡度对水体的意义在于:水的表面是水平的,真实的冰湖湖面坡度理论上接近于 0,而山体阴影和融水径流一般都分布在明显坡面上,这个物理差异是非常强的判别信号。TPI 则用来表达“该像元是处于谷底、坡面还是山脊”,因为冰湖一定发育在洼地或谷地中,一个位于山脊上的“高 NDWI 像元”几乎可以确定是积雪或阴影而不是湖。

我习惯把这三个地形特征全部转成 float32 后和光谱特征一起堆叠。这里要提醒一下:如果你只想提取某一类特定区域的冰湖,比如只有冰川前沿的冰湖,可以在后处理时用“距冰川矢量一定缓冲区内的水体”来做空间约束,比把约束强塞进特征里更可控。

2.4 训练样本设计与标签规范

训练样本是整套流程里最决定成败的环节,没有之一。我自己的标签方案分三类:水体(含清洁湖、浑浊湖、湖滨浅水)、非水体(冰碛物、基岩、裸土、草地)、以及特殊情况(雪、阴影、云雾),后面这一类单独做标签的原因是它们正是最容易和水体混淆的“高危类”。

样本采集我推荐直接在影像上按多边形方式数字化,然后栅格化处理,而不是逐像元打点。因为冰湖提取本质上是像元分类,多边形生成的密集像元样本会让模型充分学习到湖心、湖岸过渡带的内部变异性。数量上,每类至少 2000 到 3000 个像元,如果你特征里包含地形要素,最好让样本分布覆盖不同海拔梯度和不同坡向,避免模型对地形过拟合到某一条具体沟谷里。

还有一点容易被忽略:训练样本和验证样本必须来自不同区域。我见过很多时候模型精度评估虚高,就是因为训练和验证取的是同一片湖区的样本。正确的做法是空间上分块采样,比如用上半区和下半区做交叉验证,或者干脆把其中一个冰湖整体留出来做验证。

3. 随机森林原理与参数设置

3.1 从决策树到森林:Bagging 与随机特征选择

随机森林本质上是很多棵决策树的集成。单棵决策树的问题是容易过拟合,稍微换一点输入数据,树的结构就可能大变样,而随机森林通过两个策略来解决这个问题。

第一个策略是“行采样”,也就是 Bagging,每棵树训练时随机抽取原始样本的一部分,不同树看到的数据略有不同,每棵树都“偏科”一点,但投票时大家的偏见互相抵消。第二个策略是“列采样”,每次做节点分裂时不是考虑全部特征,而是随机挑一部分特征候选,这就保证了树与树之间的多样性,避免所有树都只盯着某个最强特征,导致整体模型变成那个特征的一言堂。

把这两点合起来你就明白了:随机森林的预测结果不是“最强分类器”的输出,而是“一群互补分类器”的共识。这种机制让它对噪声和异常值不那么敏感,在 Sentinel-2 这种包含大气残余误差、地形效应的高噪声遥感数据上尤其稳。

3.2 核心参数的经验取值

参数这块我直接给出我自己跑冰湖场景比较稳的一组起点值,然后解释每个参数背后的逻辑。

参数推荐初始值调整逻辑
n_estimators300树越多越稳,但超过 300 后边际收益递减,训练时间却线性增长
max_depth12限制单树深度,防止对样本噪声死记硬背
max_featuressqrt分类通常用平方根,减少特征间相关性带来的偏向性
min_samples_leaf5叶子节点至少 5 个样本,减少零碎分类斑块
class_weightbalanced自动根据类别频率加权,缓解水体样本不足的问题
n_jobs-1全核并行,训练快很多

max_depth 是决定模型容错能力的关键参数。冰湖影像的像元级别标签本身带噪声,有些像元光谱上明明是水但样本点恰好点在了浪花白沫上,深度太深就会把这层噪声也学进去。min_samples_leaf 的作用类似,它强制叶子节点必须有一定规模的样本支持,相当于给预测结果做了一个最小置信度过滤,细分碎分类斑块这种方式比单纯调深度更柔和。

3.3 类别不平衡的处理

冰湖提取任务里最典型的类别不平衡是:整个影像中“非水体”占了绝大多数,水体虽然面积在湖区不可忽视,但相比整个流域可能只占百分之几。如果随机森林面对 9 比 1 甚至更悬殊的类别分布,模型会倾向于把边界情况都判成多数类,因为这样整体准确率更高。

我的处理步骤有三个:一是用 class_weight='balanced',让 sklearn 在损失函数层面自动补偿少数类;二是训练样本采样时避免“一对一复制”式上采样,而要尽量多采集不同状态的湖体样本,让少数类的真实分布更完整;三是如果某类样本实在不够,可以用 SMOTE 做合成样本,但只对光谱特征做、不对地形特征做,因为合成海拔值没有物理意义。最后评估不要只看准确率,要看 F1-score、召回率和 Kappa 系数,其中召回率对冰湖提取的意义最高——漏掉一个湖比多画一块地面更危险。

4. Python 实现全流程

4.1 环境与依赖

整套流程我用到的 Python 库包括:rasterio 做栅格读写,numpy 做矩阵运算,scikit-learn 做随机森林建模,geopandas 读样本矢量,scipy.ndimage 做后处理。如果这几样还没装,可以用下面的命令一次性装齐:

pip install rasterio geopandas scikit-learn scipy numpy

建议在虚拟环境里装,避免和系统 Python 环境打架。rasterio 对 GDAL 有依赖,Windows 下如果安装报错,优先找预编译的 wheel 包,不要折腾源码编译。

4.2 读取影像并构建特征栈

假设你已经准备好了六个波段 TIF 和一份同分辨率的 DEM TIF。第一步是把它们全部读入内存并统一为 float32,然后计算三个指数,压成一个特征栈。完整代码如下:

import numpy as np import rasterio bands = {} band_names = ["B2", "B3", "B4", "B8", "B11", "B12"] base_path = "data/sentinel2/" for name in band_names: with rasterio.open(f"{base_path}{name}.tif") as src: bands[name] = src.read(1).astype(np.float32) profile = src.profile # 提取核心波段 green = bands["B3"] nir = bands["B8"] red = bands["B4"] swir1 = bands["B11"] swir2 = bands["B12"] blue = bands["B2"] # 计算指数 ndwi = (green - nir) / (green + nir + 1e-10) mndwi = (green - swir1) / (green + swir1 + 1e-10) ndvi = (nir - red) / (nir + red + 1e-10) # 读取 DEM,并重采样到与影像一致(这里假设已经重采样完成) with rasterio.open("data/dem_10m.tif") as src: dem = src.read(1).astype(np.float32) # 计算坡度和地形位置指数 from scipy.ndimage import sobel, uniform_filter slope_x = sobel(dem, axis=0) slope_y = sobel(dem, axis=1) slope = np.arctan(np.sqrt(slope_x**2 + slope_y**2)) tpi = dem - uniform_filter(dem, size=11) # 堆叠所有特征 feature_stack = np.stack( [blue, green, red, nir, swir1, swir2, ndwi, mndwi, ndvi, dem, slope, tpi], axis=-1 ) feature_names = ["B2", "B3", "B4", "B8", "B11", "B12", "NDWI", "MNDWI", "NDVI", "DEM", "Slope", "TPI"]

这里有一个细节要解释一下,加1e-10是为了防止分母为零,在高海拔裸地和冰面上偶尔会出现这种极端情况,不加会让 numpy 产出 NaN,进而污染整个训练矩阵。还有一个经验:先转 float32 再计算指数,因为 uint16 整型直接相减会被截断成负数,结果完全不对。

4.3 从样本矢量到训练矩阵

用一个样本点矢量文件lake_samples.shp,属性字段label,取值 0 表示非水体,1 表示水体。读取样本坐标并从特征栈中提取对应像元值。

import geopandas as gpd points = gpd.read_file("label/ice_samples.shp") coords = [(geom.x, geom.y) for geom in points.geometry] labels = points["label"].values def extract_features(stack, coords, transform, local_window=1): feats = [] for x, y in coords: row, col = rasterio.transform.rowcol(transform, x, y) row = min(max(row, 0), stack.shape[0] - 1) col = min(max(col, 0), stack.shape[1] - 1) feats.append(stack[row, col, :]) return np.array(feats) X = extract_features(feature_stack, coords, profile["transform"]) y = labels.copy()

坐标提值的时候有一类坑我碰到过好几次:样本点在影像边缘之外,rowcol会被整成负数,所以上面的代码做了边界裁剪。样本文件与影像坐标系不一致也会导致提取结果全是垃圾,建议在 GGIS/QGIS 里确认两边的坐标参考系统完全一致。

4.4 模型训练与评估

训练集和验证集按空间分层方式划分,这里用简单的随机划分演示,正式项目里建议分区域做独立验证。

from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report, confusion_matrix X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) clf = RandomForestClassifier( n_estimators=300, max_depth=12, max_features="sqrt", min_samples_leaf=5, class_weight="balanced", n_jobs=-1, random_state=42, ) clf.fit(X_train, y_train) y_pred = clf.predict(X_test) print(classification_report(y_test, y_pred, target_names=["non-water", "water"])) print(confusion_matrix(y_test, y_pred))

分类报告里的 recall 值是我最关注的一个指标,它表示“真实水体样本中百分之多少被正确找回来”,冰湖提取漏检的代价远比虚检高。

4.5 整景影像预测与栅格输出

模型验证通过之后,可以对整景影像做预测。注意直接对全图做 predict 会非常快,但如果你希望后续做面积统计,最好输出概率而不是直接把 0/1 写死。因为冰湖边的像元经常是“50% 水、50% 岸”的混合像元,通过概率阈值来控制最终边界比单一分类结果更灵活。

H, W = green.shape flat_features = feature_stack.reshape(H * W, -1) prob = clf.predict_proba(flat_features)[:, 1].reshape(H, W) lake_mask = (prob >= 0.5).astype(np.uint8) out_profile = profile.copy() out_profile.update(count=1, dtype=np.uint8, compress="lzw") with rasterio.open("output/lake_mask.tif", "w", **out_profile) as dst: dst.write(lake_mask, 1)

关于输出坐标信息,这里直接复用了原始影像的 profile,保证了输出 TIF 的 transform 和 CRS 与原图完全一致,后处理拿到 ArcGIS 或者 QGIS 里可以直接和影像叠加。

4.6 后处理:去小碎块与连通性分析

随机森林的像元级分类结果和所有像元级分类器一样,必然存在椒盐噪声。后处理是我的固定流程,主要包括两步:一是用形态学开闭运算平滑细小毛刺,二是用连通域分析剔除面积过小的孤立“湖块”。

from scipy.ndimage import binary_opening, binary_closing, label # 先腐蚀后膨胀,去掉孤立噪点并平滑边缘 cleaned = binary_closing(binary_opening(lake_mask, iterations=2), iterations=2) # 连通域分析,保留面积大于设定阈值的区域(这里以像元数计) structure = np.ones((3, 3), dtype=np.int32) labeled_array, num_features = label(cleaned, structure=structure) min_pixels = 100 # 根据你的影像分辨率调整,10米分辨率下100像元约1公顷 final_mask = np.where(labeled_array > 0, 0, 0).astype(np.uint8) for region_id in range(1, num_features + 1): region_size = np.sum(labeled_array == region_id) if region_size >= min_pixels: final_mask[labeled_array == region_id] = 1 with rasterio.open("output/lake_mask_clean.tif", "w", **out_profile) as dst: dst.write(final_mask, 1)

阈值min_pixels的设置逻辑是这样:10 米分辨率下,一个像元对应 0.01 公顷,100 个像元就是 1 公顷,而绝大多数有研究意义的冰湖面积不会小于这个规模。如果你要提取的是非常小的融水塘,可以把阈值降到 30 甚至更低,但这种小目标本身更容易和冰面融水坑混淆,需要结合影像实际情况权衡。

5. 实测对比与典型误检分析

5.1 与 MNDWI 阈值法的对比实验

我用同一景包含典型冰湖、冰川、冰碛物和大量阴影的研究区影像做了对比。阈值法用 MNDWI 值 0.1 作为分割阈值,随机森林用上面这套默认参数。从结果看,随机森林的 Kappa 系数比阈值法高出约 0.2 到 0.3,F1-score 提升在 15 个百分点上下。

阈值法在两个地方非常吃亏:一是要被阴影污染,MNDWI 无法区分山谷阴影和水体;二是对浑浊湖的漏检,悬沙浓度高的湖水 MNDWI 值会跌到阈值以下,白花花一片湖就被漏掉了。随机森林因为有地形特征的参与,阴影问题基本被解决;而因为训练样本里故意加了很多浑浊湖水样本,模型对这类“非典型水体”也保持住了识别能力。

5.2 三大经典混淆场景剖析

第一类最常出现的误检是“融水湿裸地”。冰川前沿的融水会在冰碛物表面形成一层薄薄的水膜,光谱上它和水非常接近,但它不构成稳定的湖泊水面。模型仅靠光谱有时候区分不了,这时候坡度特征起作用了——即使表面看起来平缓,薄层径流所在区域的微地形坡度和开阔湖面仍有可量化的差异。如果实际场景区分度还不够,我会增加一个“影像时段”特征,选择融水退去后的枯水期影像做提取,湿裸地问题自然弱化。

第二类是“山体阴影”,在高差大的深切河谷里最常见。阴影不是没有救,关键在于冰湖和阴影最容易混淆的区域是蓝色到暗绿色调区域,而随机森林可以同时参考短波红外和地形位置指数。阴影区通常位于高坡度坡面上,TPI 往往为正值或不明显的负值,而冰湖所在位置 TPI 通常是明显负值——它位于谷底、洼地中央。这个组合判别能力是任何单波段指数都给不了的。

第三类是“冰川冰/积雪的残留边缘”。冰川表层冰和积雪的反射率很高,但它们在短波红外波段和液态水有根本差异,液态水强烈吸收短波红外,而雪和冰在这个波段保持高反射。这也是我坚持在特征里保留 SWIR1、SWIR2 波段的原因,单单一个 NDWI 会把新雪和薄云都误认成水,但有了短波红外的约束,雪和冰基本上不可能混进湖体。

5.3 特征重要性告诉你什么

每次训练完,我习惯把clf.feature_importances_打印出来看一眼。在大多数冰湖场景里,排序前几的通常是 MNDWI、SWIR1、DEM、Slope。这个排序本身就是一个信号:模型的分类逻辑没有跑偏,它在用“水的光谱特征”和“湖的地形位置”双重证据来判断。

如果发现某个意想不到的特征排到最前面,比如 NDVI 成了第一重要特征,那大概率是训练样本出了问题,可能草地样本和湖体样本的光谱没有充分区分,导致模型只能靠植被指数兜底。这时候不要急着调参数,先回去检查样本质量。

importance_df = pd.DataFrame({ "feature": feature_names, "importance": clf.feature_importances_ }).sort_values("importance", ascending=False) print(importance_df)

6. 调参与工程化经验

6.1 参数敏感性实测

我系统性跑过几组参数敏感性实验,简单说一下结论。n_estimators从 100 加到 300,Kappa 提升大约 2 到 3 个百分点;从 300 加到 600,提升不到 0.5 个百分点,所以 300 是性价比拐点。max_depth从 8 加到 12,提升显著;继续往 20 走,训练集精度上去了,验证集精度反而往下掉,这就是过拟合特征了。max_features='sqrt'和'log2'差别不大,但比用全部特征要稳。

一个容易被忽视的点是min_samples_leaf。我一开始设成 1,结果分类结果里到处都是孤立的小蓝点,后来拉到 5 到 8,结果干净了很多,模型整体精度不降反升,因为那些小碎块本来就不是真实水体,而是噪声响应。

6.2 样本质量比数量重要

如果只让我给一条建议,就是:高质量样本 > 海量样本。我在某测试区域做过实验,用 1200 个精细数字化样本训练出的模型,效果比用 5000 个粗糙的半自动样本好很多。粗糙样本的典型问题包括:多边形画到了湖岸滩地上、标签边界比实际水体宽了几个像元、漏掉了部分浑浊湖区。

为了提高样本质量,我习惯在数字化时把影像放大到 1:5000 左右,并且对照多个波段合成方案(如 RGB 真彩色、假彩色红外)交替目视判读。湖岸线两边都保留一段“缓冲过渡区”,这些过渡像元不要强塞进水体或非水体的标签里,宁可删掉也不给模型错误教条。

6.3 物候期与影像选择策略

影像时相选择对冰湖提取的影响比很多人以为的要大得多。同一个湖,在 6 月(融水高峰期)拍摄,湖面可能浑浊不堪且面积暴涨,部分冰川融水漫到冰碛物上形成大片湿区;在 9 月(融水消退后)拍摄,湖面清透、边界稳定,周边湿区已经干透。我通常优先选择 8 到 9 月的无云影像,这时候湖体面积最接近常年稳定值,周边干扰最少。

如果只有多时相影像而缺少理想季节的云下影像,还有一个折中方案:拿多期分类结果取“最小水体范围”,即每个像元必须在所有时相都被分类为水体才最终保留。这样能极大降低季节性融水塘和临时湿地的干扰。

6.4 批量生产时的性能优化

当你的研究范围从一景影像扩展到整个流域几百景影像时,像元级预测的耗时就会凸显出来。我的经验是三条线同时优化:一是特征预处理用矩阵运算向量化,避免逐像元 Python 循环;二是整景预测时可以把影像分块预测再拼接,降低内存峰值的压力;三是如果影像特别多,先对全区域做一次 MNDWI 快速预筛,只对指数值落在 [-0.1, 0.5] 这个可疑区间内的像元跑随机森林分类,其余直接标为非水体。

预筛策略能省掉至少一半的计算量,而且几乎不损失精度,因为真正的水体像元 MNDWI 值一般都在这个区间内。这也是我在做大范围制图任务时的首选加速手段。

最后再分享一个小技巧:随机森林模型训练完成后,用joblib.dump把模型保存下来,下次遇到同一地区不同年份的影像时,只要影像获取季节类似、特征栈构建方式一致,可以直接加载模型做预测,不用重新打样本。我经常准备一批覆盖不同湖区状态的“通用模型”,先跑一遍快速摸底,再针对重点冰湖区域补样本精修,效率比每次都从零开始高很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进