
简介基于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_shapeout_shape, transformtransform, fill0, dtypeuint8) # 写入输出 with rasterio.open(output_path, w, driverGTiff, heightout_shape[0], widthout_shape[1], count1, dtypeuint8, crscrs, transformtransform) 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_size64, stride32, valid_maskNone): 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:ypatch_size, x:xpatch_size] if valid_mask is not None: valid_win valid_mask[y:ypatch_size, x:xpatch_size] # 云掩膜覆盖超30%的窗口丢弃 if valid_win.mean() 0.7: continue # 丢弃标签中背景(0)占比过高的窗口 if (label_win 0).mean() 0.5: continue img_win image[y:ypatch_size, x:xpatch_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_classes5): inputs keras.Input(shapeinput_shape) # Block 1: 32个3x3卷积核 x layers.Conv2D(32, 3, paddingsame, activationrelu)(inputs) x layers.BatchNormalization()(x) x layers.MaxPooling2D(pool_size2)(x) # Block 2: 64个3x3卷积核 x layers.Conv2D(64, 3, paddingsame, activationrelu)(x) x layers.BatchNormalization()(x) x layers.MaxPooling2D(pool_size2)(x) # Block 3: 128个3x3卷积核 x layers.Conv2D(128, 3, paddingsame, activationrelu)(x) x layers.BatchNormalization()(x) x layers.MaxPooling2D(pool_size2)(x) # 分类头 x layers.GlobalAveragePooling2D()(x) x layers.Dense(256, activationrelu)(x) x layers.Dropout(0.5)(x) outputs layers.Dense(num_classes, activationsoftmax)(x) model keras.Model(inputs, outputs) return model网络只有三个卷积块、两到三个全连接层参数很小在CPU上都能完成一次训练。input_shape里的第三维是6和你前面波段读取的数量对应。注意BatchNormalization在遥感影像分类里不是可选项Landsat不同时相的影像反射率值域分布不同BN能显著缩小这个分布偏移。Dropout放在最后一个全连接层之前是惯例速率0.5对这个小网络不会欠拟合。如果训练数据只有几千个patchdropout还能防过拟合如果数据上了十万级别这个rate可以降到0.3。3.2 让源码支持多光谱波段通道数不要写死很多网上找的CNN分类源码是从ImageNet分类改的输入层写死shape(224, 224, 3)送到你手里根本跑不了Landsat。改法是设一个n_bands变量由数据加载阶段自动获取波段数。def build_model(n_bands, patch_size64, num_classes5): 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, strides2, paddingsame, activationrelu)(inputs) x layers.BatchNormalization()(x) x layers.MaxPooling2D(pool_size3, strides2, paddingsame)(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_splits1, test_size0.2, random_state42) train_idx, val_idx next(group_split.split(patches, groupsscene_ids)) train_patches patches[train_idx] val_patches patches[val_idx]这里random_state42也是经验值遥感数据的空间自相关性很强换一个划分种子可能得到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_countsTrue) 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或64patch 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(axis1) cm.sum(axis0) - 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_factor0.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降到64batch_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(count1, dtypeuint8, compresslzw) 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_size64, stride32, num_classes5): h, w image.shape[:2] # 概率图累积器 prob_map np.zeros((h, w, num_classes), dtypenp.float32) count_map np.zeros((h, w, 1), dtypenp.float32) for y in range(0, h - patch_size 1, stride): for x in range(0, w - patch_size 1, stride): patch image[y:ypatch_size, x:xpatch_size] # 添加batch维度 patch_batch patch[np.newaxis, ...] prob model.predict(patch_batch, verbose0)[0] # 累加到概率图 prob_map[y:ypatch_size, x:xpatch_size] prob count_map[y:ypatch_size, x:xpatch_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, size5): # 对每个类别做多数投票的近似方法: 中值滤波 smoothed median_filter(prediction, sizesize, modenearest) return smoothed中值滤波的窗口大小要控制好5x5适合30米分辨率的Landsat影像改成更大的窗口会把细小的水体边界抹掉。如果做面积统计滤波后还要检查类别最小图斑面积阈值把小于若干像元的图斑归类到周围多数类别这是土地利用分类图的标准后处理流程。我在做这类项目时的一个习惯是每次调参后都会把预测结果转成概率图看一下不确定性分布高置信度区域往往对应大范围均质区域低置信度点集中在边界和阴影区域。这比盯着一张最终分类图更容易发现哪些类别互相混淆也更容易定位是模型问题还是数据问题。希望这套方法论本身能帮到你拿到源码的第一件事先跑通整条链路再决定演进的优先级。本文还有配套的精品资源点击获取