☰
基于CNN的Landsat遥感影像地物分类Python源码深度解析
2026/10/3 9:34:02 网站建设 项目流程

简介:基于CNN的遥感Landsat影像地物分类完整Python项目,面向计算机视觉、遥感科学与人工智能相关专业学生,可作为毕业设计、课程设计或算法练手项目。整包共10个文件,包含三个核心脚本(影像切片预处理、七类地物CNN模型训练、新数据批量预测),配套h5预训练权重(可直接加载推理)、示例tif影像、tfw坐标配准文件及README文档,约14.88MB。使用流程清晰:先裁剪原始影像生成训练图块,再训练模型,最后预测输出分类标签;xml配置与README辅助读者理解参数设置和代码路径。目前已有470人学习下载,适合二次开发,对理解遥感影像语义分割与CNN端到端流程有直接借鉴价值。

1. 用CNN给Landsat地物分类:为什么这类源码值得自己跑一遍

把Landsat遥感影像喂给CNN做地物分类,是遥感入门到深度学习最顺的一条落地路径:数据公开、标注能自己画、模型结构不复杂、结果能直观看到。标题里的“基于CNN深度学习的遥感landsat影像地物分类算法python完整源码”,核心就是一套从Landsat影像读取、预处理、滑窗采样、CNN模型训练到分类结果输出的工程代码,不是一篇论文复现,也不是论文源码。

它能解决的是今天很多人卡住的那类问题:Landsat导出来是16位DN值,怎么转成模型能吃的输入?样本标签怎么从矢量转成栅格?CNN的输入通道到底该给三个波段还是七个波段?以及模型跑完怎么把预测结果写回GeoTIFF。这套源码给的是通用骨架,适合正在做遥感影像分类、土地覆盖制图、或者是拿Landsat时间序列做变化检测但缺一个基线模型的人。新手拿来能跑通,熟手拿来能替换成自己的数据和类别体系。

2. Landsat影像数据准备:从下载到制作训练样本的完整链路

2.1 先明确分辨率与波段:Landsat不是一张普通照片

Landsat 8/9的OLI传感器有11个波段,其中多光谱波段分辨率是30米,全色波段是15米,热红外波段是100米重采样到30米。做CNN地物分类时,大多数方案不使用全色波段和热红外,因为全色波段缺光谱信息,热红外不是每个地物类别都有稳定区分度。常用组合是蓝、绿、红、近红外、短波红外1、短波红外2这六个波段。

在这个源码里,默认的输入通道是可配置的,常见设置是取六个波段或者其中任意几个。如果你只用了RGB三个波段,分类效果会明显差一个档,因为植被和水体主要靠近红外和短波红外区分。我一般会要求源码至少支持自定义波段索引,否则就要改数据加载部分的通道切片。

import rasterio import numpy as np # 以Landsat 8 Collection 2 Level-2产品为例,波段文件命名规则 band_files = [ "LC08_L2SP_xxx_20200101_20200115_02_T1_SR_B2.TIF", # 蓝 "LC08_L2SP_xxx_20200101_20200115_02_T1_SR_B3.TIF", # 绿 "LC08_L2SP_xxx_20200101_20200115_02_T1_SR_B4.TIF", # 红 "LC08_L2SP_xxx_20200101_20200115_02_T1_SR_B5.TIF", # 近红外 "LC08_L2SP_xxx_20200101_20200115_02_T1_SR_B6.TIF", # 短波红外1 "LC08_L2SP_xxx_20200101_20200115_02_T1_SR_B7.TIF", # 短波红外2 ] def read_landsat_bands(band_files): bands = [] for path in band_files: with rasterio.open(path) as src: bands.append(src.read(1)) # 每个波段文件只有一个band return np.stack(bands, axis=-1) # 得到 (height, width, 6)

这段代码看起来简单,但有一个值得注意的参数:Collection 2 Level-2产品已经是表面反射率,不需要再做辐射校正。如果你拿到的是Level-1的DN值产品,必须先把DN转成反射率再进模型,否则模型学到的会是传感器增益差异而不是地物光谱差异。这个坑后面会单独展开。

2.2 云掩膜处理:用QA波段把云和阴影踢出训练集

Landsat分类里最影响精度的不是模型结构,而是云。Landsat的Level-2产品带一个QA_PIXEL波段,每个像素用位标志记录是否云、是否阴影、是否水体等。训练前必须按位运算取出云和阴影掩膜,在制作样本时只保留有效区域。

def get_cloud_mask(qa_path): with rasterio.open(qa_path) as src: qa = src.read(1) # Collection 2 QA_PIXEL位定义 cloud_conf = (qa >> 4) & 0b11 # 云置信度 shadow_conf = (qa >> 8) & 0b11 # 云阴影置信度 cirrus_conf = (qa >> 12) & 0b11 # 卷云置信度 # 置信度大于1(中等或高)视为云/阴影 cloud_mask = cloud_conf > 1 shadow_mask = shadow_conf > 1 cirrus_mask = cirrus_conf > 1 return cloud_mask | shadow_mask | cirrus_mask

这里有个关键参数:置信度阈值。如果你取大于0,训练样本里会混入大量薄云边缘的像素,这些像素的光谱特征是云和地物的混合,模型容易学到“云边带”的伪特征。取大于1会去掉中等和高置信度的云,保留低置信度的薄云区域,是相对平衡的做法。

实际制作训练样本时,掩膜区域会直接标记为无效,在滑窗采样时跳过这些窗口,而不是把它们当成一个单独的类别。原因后面避坑章会说。

2.3 矢量标注转栅格标签:用rasterio把shp文件烧到栅格上

样本标签的常见来源是人工解译的矢量文件,或者已有的土地覆盖产品。训练前需要把矢量转成与影像对齐的栅格,且坐标系、分辨率、范围必须完全一致。大多数源码会直接依赖geopandas和rasterio的rasterize函数。

import geopandas as gpd from rasterio import features def vector_to_raster(shapefile, reference_tif, output_path): # 读取参考影像获取transform和shape with rasterio.open(reference_tif) as src: transform = src.transform out_shape = (src.height, src.width) crs = src.crs # 读取矢量并在目标crs下进行几何操作 gdf = gpd.read_file(shapefile) if gdf.crs != crs: gdf = gdf.to_crs(crs) # 字段里存类别整数值,比如1=水体, 2=建筑, 3=植被 shapes = [(geom, value) for geom, value in zip(gdf.geometry, gdf['class_id'])] # rasterize: 所有没有矢量的像素填充为0(背景/未知) labels = features.rasterize(shapes, out_shape=out_shape, transform=transform, fill=0, dtype='uint8') # 写入输出 with rasterio.open(output_path, 'w', driver='GTiff', height=out_shape[0], width=out_shape[1], count=1, dtype='uint8', crs=crs, transform=transform) as dst: dst.write(labels, 1)

这个步骤最容易出问题的是坐标系转换。如果你的矢量是经纬度坐标,影像是UTM投影,直接rasterize会得到一张全空或者错位的标签图。rasterio的rasterize不会帮你做坐标转换,必须先对矢量做to_crs。

还有一个隐蔽问题:矢量的几何范围和影像边界不完全一致时,靠近边界的像素会被写入多个值,rasterize的默认行为是后面的覆盖前面的,也就是说最后一个要素会覆盖同位置的较早要素。如果你的标注区域有重叠,需要检查class_id的写入顺序。

2.4 滑窗裁剪与数据增强:小patch才是CNN的输入

整景Landsat影像太大,8000x8000像素的影像直接进CNN不现实,常见做法是滑窗裁剪成固定大小的patch。patch尺寸一般取32、64或128。尺寸越大,模型能看到的空间上下文越丰富,但代价是同一数量样本下可裁剪的窗口数变少,训练耗时变长。

def extract_patches(image, label, patch_size=64, stride=32, valid_mask=None): h, w = image.shape[:2] patches_img = [] patches_lab = [] for y in range(0, h - patch_size + 1, stride): for x in range(0, w - patch_size + 1, stride): # 滑窗内标签的有效像素占比检查 label_win = label[y:y+patch_size, x:x+patch_size] if valid_mask is not None: valid_win = valid_mask[y:y+patch_size, x:x+patch_size] # 云掩膜覆盖超30%的窗口丢弃 if valid_win.mean() < 0.7: continue # 丢弃标签中背景(0)占比过高的窗口 if (label_win == 0).mean() > 0.5: continue img_win = image[y:y+patch_size, x:x+patch_size] patches_img.append(img_win) patches_lab.append(label_win) return np.array(patches_img), np.array(patches_lab)

这句valid_mask.mean() < 0.7是经验值。云覆盖超过三分之一的窗口即便半透明,也会让标签和光谱不匹配,模型会把这些样本当成噪声学习。阈值设得太高会丢掉大量可用样本,太低则让噪声进训练集,一般0.7到0.8之间比较稳。

数据增强在遥感影像上常用的是随机翻转、旋转90度倍数、随机亮度扰动。要注意的是,随机亮度扰动不要作用于多光谱波段太强的幅度,因为不同波段的反射率物理范围不同。亮度扰动幅度在0.8到1.2之间是比较安全的范围,再大就会把植被和水体的光谱边界打乱。

3. CNN模型设计与源码结构:该用分类头还是分割头

3.1 Patch分类与像素分割的取舍:标题没有明说,默认从分类头开始

这套源码既然叫地物分类,大多数实现会把每个patch预测成一个类别,也就是patch classification。实现上是CNN backbone接一个全连接层,最后softmax输出类别概率。这样好处是训练简单、类别特征好提取、Patch尺寸内空间信息充分利用。

另一种做法是语义分割,每个像素输出一个类别,适合精细边界要求的应用。但分割网络对标注质量要求更高,需要逐像素真值,训练耗时也更长。标题里的“源码”如果只有一个模型文件,通常指的patch分类。你在看源码时先确认模型最后的输出维度,如果是(batch, num_classes)就是分类头,如果是(batch, h, w, num_classes)就是分割头,后面改代码时不要搞混。

from tensorflow import keras from tensorflow.keras import layers def build_cnn_classifier(input_shape=(64, 64, 6), num_classes=5): inputs = keras.Input(shape=input_shape) # Block 1: 32个3x3卷积核 x = layers.Conv2D(32, 3, padding='same', activation='relu')(inputs) x = layers.BatchNormalization()(x) x = layers.MaxPooling2D(pool_size=2)(x) # Block 2: 64个3x3卷积核 x = layers.Conv2D(64, 3, padding='same', activation='relu')(x) x = layers.BatchNormalization()(x) x = layers.MaxPooling2D(pool_size=2)(x) # Block 3: 128个3x3卷积核 x = layers.Conv2D(128, 3, padding='same', activation='relu')(x) x = layers.BatchNormalization()(x) x = layers.MaxPooling2D(pool_size=2)(x) # 分类头 x = layers.GlobalAveragePooling2D()(x) x = layers.Dense(256, activation='relu')(x) x = layers.Dropout(0.5)(x) outputs = layers.Dense(num_classes, activation='softmax')(x) model = keras.Model(inputs, outputs) return model

网络只有三个卷积块、两到三个全连接层,参数很小,在CPU上都能完成一次训练。input_shape里的第三维是6,和你前面波段读取的数量对应。注意BatchNormalization在遥感影像分类里不是可选项,Landsat不同时相的影像反射率值域分布不同,BN能显著缩小这个分布偏移。

Dropout放在最后一个全连接层之前是惯例,速率0.5对这个小网络不会欠拟合。如果训练数据只有几千个patch,dropout还能防过拟合;如果数据上了十万级别,这个rate可以降到0.3。

3.2 让源码支持多光谱波段:通道数不要写死

很多网上找的CNN分类源码是从ImageNet分类改的,输入层写死shape=(224, 224, 3),送到你手里根本跑不了Landsat。改法是设一个n_bands变量,由数据加载阶段自动获取波段数。

def build_model(n_bands, patch_size=64, num_classes=5): input_shape = (patch_size, patch_size, n_bands) return build_cnn_classifier(input_shape, num_classes)

这里要注意一个点:patch_size和n_bands必须和预处理输出的尺寸严格一致。如果你在预处理阶段做了归一化,要确认归一化的统计量是全局统计还是逐景影像统计。逐景统计的均值方差归一化在单景影像上效果好,但在多景训练数据混合时会造成波段统计分布不一致,建议改用全局统计量,固定保存一份mean和std文件。

如果源码里有归一化代码,你务必去看它是(x - mean) / std还是x / 65535。x / 65535会把16位DN缩放到0到1之间,速度最快,但在植被和水体的光谱差异较小的波段上会损失区分度。(x - mean) / std对每个波段独立标准化,保留的区分度更多,推荐使用。

3.3 不一定要直接跑大型模型:在CNN和ResNet之间做一次成本测算

标题里写了CNN,不等于只能用最朴素的卷积堆叠。如果你的数据量超过十万个patch,可以考虑把主干换成残差结构,或者直接换成现成的ResNet18。但在遥感地物分类上,大规模预训练权重带来的收益并不明显,因为Landsat多光谱波段和ImageNet三通道RGB的自然图像分布差异很大,预训练权重几乎不能迁移。

# 如果想升级为残差结构,避免自己造轮子,可以直接用keras自带模型 from tensorflow.keras.applications import ResNet50 def build_resnet_classifier(n_bands, num_classes): # 注意: ResNet50默认输入是224x224x3, 需要改输入通道 inputs = keras.Input(shape=(224, 224, n_bands)) # 用第一层卷积将多光谱映射到3通道 x = layers.Conv2D(64, 7, strides=2, padding='same', activation='relu')(inputs) x = layers.BatchNormalization()(x) x = layers.MaxPooling2D(pool_size=3, strides=2, padding='same')(x) # 后续接ResNet50的block是不现实的, 实际做法是换用支持多通道输入的框架 return model

在实际项目中,我不会硬把多光谱输入塞进预训练模型。更常见的做法是保持简单CNN结构,通过增加数据量来提升精度。想提升模型容量时,把卷积核数量翻倍或者增加一个卷积块,效果比换模型架构更可预期。这也符合这套源码“完整可跑”的定位。

4. 模型训练与精度评估:从TensorBoard到混淆矩阵的完整闭环

4.1 数据划分:按影像划分而不是按patch随机划分

训练集、验证集、测试集的划分方式是遥感分类里最容易出错的一步。如果你把同一景影像的所有patch随机打乱再划分,训练集和验证集来自同一空间区域,模型相当于已经记住了这批数据的光谱特征,验证集分数虚高。等模型用到另一时相或另一区域的影像上,精度会明显下降。

正确做法是按影像文件划分:假设你有三景不同日期的Landsat影像,就用两景做训练,一景做验证。如果只有一景影像,就按空间块划分,左边区域训练、右边区域验证。

from sklearn.model_selection import GroupShuffleSplit # patches包含每个patch所属的影像文件ID group_split = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(group_split.split(patches, groups=scene_ids)) train_patches = patches[train_idx] val_patches = patches[val_idx]

这里random_state=42也是经验值:遥感数据的空间自相关性很强,换一个划分种子可能得到5个点的精度浮动,如果做实验对比必须固定所有随机种子。

另一个关键点是:同一patch的关联样本不要同时出现在训练和验证里。如果相邻patch重叠(stride小于patch_size),两个patch有重叠区域,随机划分会让验证集泄漏训练信息。解决办法是把相同位置的patch合并成一个group,或者干脆stride取等于patch_size,不重叠。

4.2 损失函数与类别权重:不均衡类别才是常态

地物分类里,植被往往占了一半以上像素,水体可能只占1%,建筑也可能只占5%。默认的交叉熵损失会偏向大类别,模型会把所有不确定像素都预测成植被。

常见做法是给损失函数加类别权重,权重等于各类样本数的倒数归一化。另一个做法是用Focal Loss,它对难分类样本给更高权重,对易分类的大类别自动降权。两种都可以在源码里替换。

def weighted_crossentropy(y_true, y_pred, class_weights): # 将类别权重转成与y_pred相同形状的权重矩阵 weights = tf.gather(class_weights, tf.argmax(y_true, axis=-1)) loss = keras.losses.categorical_crossentropy(y_true, y_pred) return tf.reduce_mean(loss * weights) # 计算类别权重 def compute_class_weights(labels): # labels: (n_patches, patch_size, patch_size) 的整数标签 unique, counts = np.unique(labels[labels > 0], return_counts=True) total = counts.sum() weights = total / (len(unique) * counts) # 转成float32张量, 顺序与类别ID一一对应 weight_dict = {cls: w for cls, w in zip(unique, weights)} return np.array([weight_dict.get(i, 1.0) for i in range(1, len(unique)+1)])

这里一个隐蔽问题是labels[labels > 0]排除了背景类0。如果你的标签里0是“未标注区域”,它不应该参与权重计算,因为推理时也不会对背景做预测。如果你把0当成一个类别,那权重计算要包含它。

训练时的batch size和patch size也会影响不均衡程度。如果batch size太小,一个batch里可能完全没有水体样本,梯度方向被植被主导,训练loss波动很大。一般batch size取32或64,patch size取64时,一个batch里包含的像素数足够覆盖多数类别。如果类别的空间分布非常分散,可以用采样器让每个batch包含固定比例的小类别样本。

4.3 评估指标:不要只盯着准确率

地物分类的常见场景里,准确率会骗人:如果水体只占1%,模型把所有像素都预测成植被,准确率也有99%,但水体完全没被识别出来。评估时至少看F1值或IoU。IoT对每个类别计算IoU,再取平均,对小类别更敏感。

from sklearn.metrics import confusion_matrix, classification_report # 把patch级的softmax输出转成像素级预测 y_true = val_labels.flatten() y_pred = np.argmax(model.predict(val_patches), axis=-1).flatten() # 过滤背景类0 valid_idx = y_true > 0 y_true = y_true[valid_idx] y_pred = y_pred[valid_idx] # 输出每个类别的precision, recall, f1 print(classification_report(y_true, y_pred, target_names=['水体', '建筑', '植被', '裸地', '农田'])) # 计算每个类别的IoU cm = confusion_matrix(y_true, y_pred) iou = np.diag(cm) / (cm.sum(axis=1) + cm.sum(axis=0) - np.diag(cm) + 1e-6) print("IoU per class:", dict(zip(['水体', '建筑', '植被', '裸地', '农田'], iou)))

混淆矩阵的价值在于告诉你错误方向:比如模型总把建筑误判成裸地,那说明使用的波段组合里建筑和裸地的光谱可分性差,需要加入更多空间特征或改用更大patch_size,而不只是调学习率。

评估完如果F1整体不高,优先检查样本数量分布和标注质量,其次才调整模型结构。遥感影像分类里,标注边界不准是系统性的精度上限,往往比模型结构的影响大得多。这也是源码完整跑通之后最需要投入的地方。

5. 避坑:Landsat分类源码跑不通的常见原因与排查路径

5.1 影像单位不一致:DN值直接当反射率用

现象:模型训练和验证都正常,但推理结果严重偏暗、很多类别分不出来。尤其在不同日期的Landsat影像间迁移时精度断崖式下跌。

原因:Landsat Level-1产品是DN值,Level-2产品是表面反射率,两者的数值范围不一样。如果训练时用的是Level-2,推理时用了Level-1,同一地物的光谱值域不同,模型学到的权重完全对不上。还有更隐蔽的:就算是Level-2,不同波段之间存在增益差异,各波段的反射率范围并不相同。

解决:统一用Level-2表面反射率产品,并在预处理里使用同样的一套归一化统计量。读取每个波段前先打印最小值、最大值、均值和标准差,如果某个波段的数值范围和其他波段明显不在一个量级,检查是否漏了缩放因子。Landsat Collection 2的反射率波段自带scale_factor=0.0000275,有的产品需要乘这个因子,有的产品源码里已经处理过,确认不要重复缩放。

5.2 云掩膜没有正确应用,模型把云识别成了类

现象:分类结果图上,云区域和云影区域被模型分成固定类别,地物边界和云边界重合,或者模型把云识别成“裸地”或“建筑”。

原因:训练时没有引用QA波段做掩膜,或者掩膜阈值设得过于宽松,导致包含云和阴影的patch被当成了正常样本。模型学到的是“高反射率区域=某类”,而不是真正的光谱特征。

解决:回到源码里的数据加载部分,确认滑窗提取函数里确实调用了掩膜。掩膜要作用于两个地方:一是参与样本筛选,云覆盖率超过阈值的patch直接丢弃;二是训练时把标签中对应云区域的像素置为0,让损失函数忽略这些位置。很多源码只做了第一点,没有做第二点,两条腿必须同时有。

5.3 类别数量不均衡导致验证集F1高但实际效果差

现象:模型验证集上总体F1达到0.85以上,但把概率图落到实际影像上,水体区域成片漏检,或者把阴影全部预测成水体。再看混淆矩阵,发现水体类的recall只有0.3。

原因:交叉熵损失被主要类别主导,小类别虽然精度看起来不低,但实际能召回的样本很少。验证集里水体样本本身就不多,一旦预测错了几个patch,表现在recall上差异不大,表面看分数还能接受。

解决:首选加类别权重,其次在训练时使用分层采样,保证每个batch里都有小类别样本。还有一个实用技巧:对小类别做空间二分类验证,单独训练一个“水体检测”的辅助模型,在地物分类不确定区域做投票,比单纯调主模型的有效率高得多。

5.4 显存或内存溢出:patch太大或者batch太大

现象:程序跑起来几秒钟后报OOM,或者Python进程直接被杀掉,没有任何报错输出。

原因:patch_size取128、batch_size取64时,单个batch的输入张量是(64, 128, 128, 6),float32占用的内存约合64乘以128乘以128乘以6乘以4字节,约等于25MB,这只是输入。中间卷积层的feature map会远大于这个数,显存不够是必然的。滑窗提取代码如果同时把所有patch都加载到内存,也会直接把RAM吃满。

解决:先把patch_size降到64,batch_size降到16,确认能跑通后逐步调大。数据加载改成生成器或者tf.data.Dataset,不要在一开始就一次性把所有patch读进内存。源码如果是这样写的,建议改造成分批读取。排查时先用nvidia-smi看显存占用,TensorFlow分配显存是默认占满的,要在代码里加gpu_memory_growth配置。

import tensorflow as tf gpus = tf.config.experimental.list_physical_devices('GPU') if gpus: try: for gpu in gpus: tf.config.experimental.set_memory_growth(gpu, True) except RuntimeError as e: print(e)

这个配置不会让你显存变大,但能让你在训练完成前之间做推理测试,不至于程序一启动显存就被占光。

5.5 推理时坐标错位:输出GeoTIFF和底图对不上

现象:分类结果图单独打开正常,但叠加到底图上偏移了几十米到几百米,或者整个图被翻转和旋转。

原因:预测时numpy数组的shape是(height, width),写GeoTIFF时没有带正确的投影和transform。滑窗裁剪时stride没考虑影像宽高和patch_size的整除关系,推理拼图时丢掉了一部分行列。

解决:推理阶段要用和训练阶段相同的滑窗逻辑,并且记录每个patch的位置坐标。把预测结果写回时直接用原始影像的transform和crs。

def write_prediction_geotiff(prediction, reference_tif, output_path): with rasterio.open(reference_tif) as src: profile = src.profile.copy() profile.update(count=1, dtype='uint8', compress='lzw') with rasterio.open(output_path, 'w', **profile) as dst: dst.write(prediction.astype('uint8'), 1)

这里一个容易被忽略的细节:Landsat影像通常是以左上角为原点存储的,numpy数组第0维是行方向,第1维是列方向,写GeoTIFF时行列正好对得上。但如果你的预处理阶段用到了旋转或翻转来做数据增强,推理时不要对最终结果做同样的变换,推理阶段只保留原始方向。

6. 把源码变成可投入使用的工具:全图推理与精度落地技巧

6.1 从patch分类到全图推理:滑窗策略决定边界质量

训练完成后,面对一整景完整的Landsat影像,需要做全图推理。固定步长滑窗预测时,相邻patch重叠区域会有多次预测结果,需要把重复区域的预测值融合。最简单的做法是每个窗口只取中心区域,边缘丢弃,重叠区域取平均概率或投票。

def predict_full_image(model, image, patch_size=64, stride=32, num_classes=5): h, w = image.shape[:2] # 概率图累积器 prob_map = np.zeros((h, w, num_classes), dtype=np.float32) count_map = np.zeros((h, w, 1), dtype=np.float32) for y in range(0, h - patch_size + 1, stride): for x in range(0, w - patch_size + 1, stride): patch = image[y:y+patch_size, x:x+patch_size] # 添加batch维度 patch_batch = patch[np.newaxis, ...] prob = model.predict(patch_batch, verbose=0)[0] # 累加到概率图 prob_map[y:y+patch_size, x:x+patch_size] += prob count_map[y:y+patch_size, x:x+patch_size] += 1 # 取平均概率并argmax prob_map /= np.maximum(count_map, 1) prediction = np.argmax(prob_map, axis=-1).astype('uint8') return prediction, prob_map

这个实现的代价是逐patch推理的速度非常慢,一景8000x8000影像要在GPU上跑几十分钟。一个高效技巧是把patch组成batch再预测,一次喂32个patch,吞吐量提升明显。源码如果写的是单patch循环,建议改成批处理模式。

重叠策略上,stride = patch_size / 2是最常用的组合,兼顾推理速度和边缘精度。如果对边界要求很高,可以stride再减半,但推理时间会翻四倍,实际项目中不划算。

6.2 用未参与训练的时相影像做时间泛化验证

模型训练完只评估同一景影像会有很强的自欺性。地物分类模型的真正价值在于对不同时相、不同季节的影像保持稳定。最好留出一景完全没参与任何训练和验证的影像,作为独立测试集,验证模型的时间泛化能力。

如果模型在新时相影像上精度暴跌,最常见的两个原因是季节变化引起的植被光谱差异,以及太阳角度变化引起的阴影差异。应对手段有两个:训练数据里加入不同季节的样本,或者做影像归一化。后者在Landsat Level-2产品里已经相对稳定,但太阳高度角的影响依然能明显改变近红外波段的反射率幅值。我会在训练数据里刻意加入不同月份的影像,比调损失函数更见效。

6.3 分类结果后处理:多数滤波和小图斑消除

深度学习模型的逐像素分类结果通常带有椒盐噪声,也就是孤立的小图斑。地物分类的应用场景里,这些图斑往往是错误预测。常见的后处理是多数滤波,也就是用一个固定窗口统计类别众数,替换中心像素。

from scipy.ndimage import median_filter def postprocess_smooth(prediction, size=5): # 对每个类别做多数投票的近似方法: 中值滤波 smoothed = median_filter(prediction, size=size, mode='nearest') return smoothed

中值滤波的窗口大小要控制好,5x5适合30米分辨率的Landsat影像,改成更大的窗口会把细小的水体边界抹掉。如果做面积统计,滤波后还要检查类别最小图斑面积阈值,把小于若干像元的图斑归类到周围多数类别,这是土地利用分类图的标准后处理流程。

我在做这类项目时的一个习惯是:每次调参后都会把预测结果转成概率图看一下不确定性分布,高置信度区域往往对应大范围均质区域,低置信度点集中在边界和阴影区域。这比盯着一张最终分类图更容易发现哪些类别互相混淆,也更容易定位是模型问题还是数据问题。希望这套方法论本身能帮到你,拿到源码的第一件事,先跑通整条链路,再决定演进的优先级。

本文还有配套的精品资源,点击获取

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

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

立即咨询