简介:本资源为基于CNN深度学习的遥感Landsat影像地物分类Python项目源码包,面向计算机、人工智能、遥感与电子信息等相关专业学生及企业开发者,可用于课程设计、毕业设计、大作业或初期项目立项演示,帮助读者掌握卷积神经网络在遥感影像分类中的完整实现流程。压缩包共10个文件,约14.89MB,包含Python脚本、XML与TFW地理配准文件、TIF影像数据、HDF5模型权重及Markdown说明文档,覆盖数据切片、模型训练与影像预测等关键环节。项目代码经测试可正常运行,读者可据此理解从原始Landsat影像到分类结果输出的技术链路,并借鉴其目录组织与模型保存方式。目前已有970人学习浏览,适合希望快速上手遥感深度学习实战的初学者与进阶开发者参考。
1. 遥感影像分类这件事,为什么CNN一上来就比传统方法能打
做遥感地物分类的人大多经历过这个阶段:拿到一景 Landsat 影像,先算 NDVI、NDWI 一堆指数,再喂给随机森林或者 SVM,调参调到怀疑人生,最后分类图上一片椒盐噪声,水体里冒出零星的建筑像元,耕地和林地边界糊成一团。问题不在分类器本身,而在于手工设计的特征根本表达不了复杂的地物空间结构。CNN 深度学习的介入改变了这个局面——它不需要你告诉它什么是纹理、什么是边缘,卷积核在训练中自己学。这个标题对应的就是一套完整的落地链路:用 Python 把 Landsat 影像读进来,做波段组合和样本标注,搭一个 CNN 模型训练,最后输出分类图和精度评价。适合谁?有 Python 基础、做过遥感预处理、想从传统机器学习切换到深度学习的从业者。下面按我实际跑通的顺序拆开讲。
2. 从 Landsat 原始波段到 CNN 可吃的样本:数据准备全流程
2.1 为什么选 Landsat 而不是高分影像
Landsat 系列(尤其是 8/9 的 OLI 传感器)有几个对 CNN 训练特别友好的特性。第一,免费且时间跨度长,Landsat 8 从 2013 年到现在积累了海量存档,做时序分类或者迁移学习都不缺数据。第二,波段设计合理:30 米空间分辨率下,蓝、绿、红、近红外、短波红外这几个波段对植被、水体、裸土、建筑的区分度足够。第三,辐射定标和大气校正的流程成熟,用开源工具就能完成。
但要注意一个现实问题:30 米分辨率意味着单个像元覆盖 900 平方米,城市内部的道路和建筑混杂严重,分类精度天然受限。如果你的应用场景需要区分到建筑物级别,Landsat 不是最优选。但对于大范围的土地覆盖分类——水体、林地、耕地、草地、建设用地、裸地这六类——Landsat 的性价比无可替代。
2.2 波段选择与特征工程
CNN 虽然能自动提取特征,但输入哪些波段直接决定了模型能学到什么。我一般用 6 个波段作为基础输入:Blue(B2)、Green(B3)、Red(B4)、NIR(B5)、SWIR1(B6)、SWIR2(B7)。这 6 个波段覆盖了可见光到短波红外,对大多数地物类型都有区分能力。
有人会问要不要加 NDVI、NDWI 这些指数作为额外通道。我的经验是:加了不亏,但别指望有质变。CNN 在训练中能从原始波段组合里学到类似指数的特征,额外加指数通道相当于给模型一个先验提示,收敛会快一些。具体做法是把指数归一化到 0-1 后作为独立通道拼上去。
import numpy as np import rasterio def load_landsat_bands(img_path, band_indices=[2, 3, 4, 5, 6, 7]): """ 读取 Landsat 8 指定波段并堆叠为多通道数组 band_indices: 对应 B2-B7,注意 rasterio 读取时波段从 1 开始 """ with rasterio.open(img_path) as src: bands = [] for idx in band_indices: band = src.read(idx).astype(np.float32) # 辐射定标:DN 值转反射率(简化系数,实际需用元数据中的乘数和偏移) band = band * 0.0000275 - 0.2 bands.append(band) # 堆叠为 (H, W, C) 格式 stacked = np.stack(bands, axis=-1) # 裁剪到合理范围,避免异常值干扰 stacked = np.clip(stacked, 0, 1) return stacked def add_spectral_indices(data): """在 6 波段基础上追加 NDVI 和 NDWI 两个通道""" nir = data[:, :, 3] red = data[:, :, 2] green = data[:, :, 1] # 避免除零 eps = 1e-8 ndvi = (nir - red) / (nir + red + eps) ndwi = (green - nir) / (green + nir + eps) # 归一化到 0-1 ndvi = (ndvi + 1) / 2 ndwi = (ndwi + 1) / 2 return np.concatenate([data, ndvi[:, :, np.newaxis], ndwi[:, :, np.newaxis]], axis=-1)这段代码做了三件事:读取指定波段、辐射定标转反射率、追加光谱指数。参数说明:band_indices默认取 B2 到 B7,如果你的研究区水体占比大,可以额外加 B1 海岸波段;辐射定标的乘数和偏移系数建议从影像的 MTL 元数据文件里读取,不要硬编码,不同日期的影像系数可能不同。
2.3 样本标注与数据集划分
样本质量决定模型上限,这句话在遥感分类里是铁律。我见过太多人模型结构调了半天,最后发现是训练样本里混了大量标签错误的像元。
标注方式有两种常见做法。一是基于已有土地利用产品做交叉验证后采样,比如用某公开的全球土地覆盖产品作为参考,在置信度高的区域随机撒点。二是人工目视解译,在影像上画多边形。前者效率高但有系统性偏差,后者准确但费时。我一般混合使用:先用公开产品撒一批候选点,再人工检查修正。
每类地物的样本数量建议不少于 500 个像元,类别不平衡时用加权损失或者过采样处理。数据集划分按 7:2:1 分训练、验证、测试。注意空间自相关的问题——相邻像元高度相似,如果随机划分,测试集里的像元可能在训练集里有空间邻居,导致精度虚高。更严谨的做法是按空间块划分,把研究区分成若干网格,整块划入训练或测试。
from sklearn.model_selection import train_test_split def prepare_dataset(image, labels, patch_size=32, samples_per_class=500): """ 从影像中提取以标注点为中心的 patch 作为 CNN 输入 image: (H, W, C) 多波段影像 labels: (H, W) 标签图,0 表示未标注 patch_size: 每个样本的空间窗口大小 """ half = patch_size // 2 patches, targets = [], [] for cls in np.unique(labels): if cls == 0: continue rows, cols = np.where(labels == cls) # 随机采样,避免样本过多导致训练慢 idx = np.random.choice(len(rows), min(samples_per_class, len(rows)), replace=False) for i in idx: r, c = rows[i], cols[i] # 边界检查 if r < half or r >= image.shape[0] - half or c < half or c >= image.shape[1] - half: continue patch = image[r-half:r+half, c-half:c+half, :] patches.append(patch) targets.append(cls) X = np.array(patches, dtype=np.float32) y = np.array(targets, dtype=np.int64) # 按 7:2:1 划分 X_train, X_tmp, y_train, y_tmp = train_test_split(X, y, test_size=0.3, stratify=y, random_state=42) X_val, X_test, y_val, y_test = train_test_split(X_tmp, y_tmp, test_size=1/3, stratify=y_tmp, random_state=42) return X_train, X_val, X_test, y_train, y_val, y_test参数说明:patch_size取 32 是经验值,太小感受野不够,太大显存吃紧且引入过多无关背景。samples_per_class控制每类采样上限,防止某类样本过多导致训练偏斜。边界检查那一步不能省,否则数组切片会静默出错。
3. CNN 模型怎么搭:从三层卷积到残差结构的选型逻辑
3.1 遥感分类网络的输入尺寸与感受野
遥感影像分类和自然图像分类有一个本质区别:地物的空间尺度差异极大。一栋建筑可能只占几个像元,一片连续林地可能覆盖上千个像元。这意味着网络的感受野需要足够大,才能同时捕捉小地物的细节和大地物的上下文。
我一般用 32×32 的输入窗口配合 5 层卷积,每层用 3×3 卷积核加池化,最终感受野能覆盖到 100 像元以上。如果研究区地物破碎度高,可以缩小到 16×16 并减少池化层数;如果地物连片分布,可以放大到 64×64。
3.2 一个可复现的 CNN 结构
下面这个结构是我在多个 Landsat 分类任务中反复用过的基线,不算新,但稳定。
import torch import torch.nn as nn class RemoteSensingCNN(nn.Module): def __init__(self, in_channels=8, num_classes=6): super().__init__() self.features = nn.Sequential( # Block 1: 32x32 -> 16x16 nn.Conv2d(in_channels, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.Conv2d(32, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # Block 2: 16x16 -> 8x8 nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # Block 3: 8x8 -> 4x4 nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): x = self.features(x) return self.classifier(x)逻辑说明:三个卷积块逐步把空间尺寸从 32 降到 4,通道数从 32 升到 128。每个卷积块内用两个 3×3 卷积替代一个 5×5 卷积,参数量更少且非线性表达能力更强。AdaptiveAvgPool2d(1)把每个通道压缩成一个值,避免全连接层参数爆炸。Dropout(0.5)是防过拟合的标配。
参数说明:in_channels要和输入数据的通道数一致,前面用了 6 波段加 2 个指数就是 8。num_classes根据你的分类体系定,六类地物就是 6。学习率初始设 1e-3,用余弦退火调度,训练 50 到 100 个 epoch。
3.3 训练循环与精度评价
训练部分没什么玄学,但有几个细节容易翻车。第一,Landsat 影像的像元值范围差异大,务必在输入网络前做标准化,按波段计算均值和标准差。第二,验证集上的精度波动大时,不要急着调模型,先检查样本标签有没有错。第三,保存验证集精度最高的模型权重,而不是最后一个 epoch 的。
from torch.utils.data import DataLoader, TensorDataset from sklearn.metrics import cohen_kappa_score, confusion_matrix def train_model(model, X_train, y_train, X_val, y_val, epochs=60, lr=1e-3, batch_size=64): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) criterion = nn.CrossEntropyLoss() train_ds = TensorDataset(torch.from_numpy(X_train).permute(0, 3, 1, 2), torch.from_numpy(y_train)) val_ds = TensorDataset(torch.from_numpy(X_val).permute(0, 3, 1, 2), torch.from_numpy(y_val)) train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True) val_loader = DataLoader(val_ds, batch_size=batch_size) best_kappa, best_state = 0, None for epoch in range(epochs): model.train() for xb, yb in train_loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() loss = criterion(model(xb), yb) loss.backward() optimizer.step() scheduler.step() # 验证 model.eval() preds, gts = [], [] with torch.no_grad(): for xb, yb in val_loader: out = model(xb.to(device)).argmax(dim=1).cpu().numpy() preds.extend(out) gts.extend(yb.numpy()) kappa = cohen_kappa_score(gts, preds) if kappa > best_kappa: best_kappa = kappa best_state = {k: v.clone() for k, v in model.state_dict().items()} model.load_state_dict(best_state) return model, best_kappa参数说明:weight_decay=1e-4是 L2 正则,防止权重过大。CosineAnnealingLR让学习率从初始值平滑降到接近零,比固定学习率收敛更稳。Kappa 系数比总体精度更能反映分类一致性,低于 0.7 基本不可用,0.8 以上算合格。
4. 避坑指南:Landsat 分类里翻车最多的五个地方
4.1 坑一:辐射定标系数用错导致波段值全乱
现象:训练 loss 从一开始就震荡不降,或者模型把所有像元都预测成同一类。
原因:Landsat 8 的 DN 值转反射率需要乘数和偏移两个系数,这两个值在 MTL 元数据文件里。不同日期的影像系数不同,硬编码某一个日期的系数会导致其他影像的反射率计算错误。
解决:每次读取影像时从 MTL 文件解析REFLECTANCE_MULT_BAND_x和REFLECTANCE_ADD_BAND_x,动态计算。如果嫌麻烦,至少要把系数写成可配置的参数,不要写死在函数里。
4.2 坑二:训练样本空间自相关导致精度虚高
现象:验证集 Kappa 0.95,但把分类图叠加到原影像上一看,错误一片一片的。
原因:随机划分样本时,相邻像元被分到训练集和测试集,模型记住了空间位置而不是地物特征。
解决:按空间块划分。把研究区切成 500×500 像元的网格,随机选 70% 的网格做训练,剩下的做验证和测试。这样测试集里的像元和训练集在空间上完全隔离,精度更可信。
4.3 坑三:类别不平衡导致小类被吞
现象:水体分类精度 0.98,但裸地精度只有 0.3,混淆矩阵里裸地大部分被分成了建设用地。
原因:训练样本里水体有 5000 个,裸地只有 300 个,损失函数被大类主导。
解决:两个办法。一是过采样小类,用数据增强(旋转、翻转、加噪声)把样本数补到和其他类相当。二是在 CrossEntropyLoss 里传weight参数,按类别频率的倒数设权重。我一般两个一起用。
4.4 坑四:patch 边界像元被丢弃
现象:分类图边缘有一圈空白,或者某些区域完全没有分类结果。
原因:提取 patch 时做了边界检查,靠近影像边缘的像元因为窗口越界被跳过了。
解决:对影像做镜像填充(reflect padding),填充宽度等于 patch_size 的一半。这样边缘像元也能提取到完整的窗口,分类图不会缺边。
4.5 坑五:推理阶段显存溢出
现象:训练时 batch_size 64 跑得好好的,推理时整景影像一进去就 OOM。
原因:整景 Landsat 影像可能有 7000×8000 像元,一次性滑窗提取所有 patch 再送进网络,显存扛不住。
解决:分块推理。把影像切成不重叠的大块(比如 512×512),逐块滑窗预测,最后拼接。块与块之间留 patch_size 的重叠区,拼接时取中心区域的结果,避免边界效应。
5. 让分类图真正能用的三个后处理技巧
模型输出的分类图直接出图,大概率会有椒盐噪声和孤立小斑块。后处理不是可有可无的步骤,它决定了你的结果能不能拿给业务方看。
第一个技巧是众数滤波。用一个 3×3 或 5×5 的滑动窗口,把中心像元的类别替换为窗口内出现次数最多的类别。这个操作对消除孤立错分像元非常有效,窗口越大越平滑,但太大也会抹掉真实的小地物。我一般用 3×3,对 Landsat 30 米分辨率来说刚好。
from scipy.ndimage import generic_filter def majority_filter(classified_map, window_size=3): """众数滤波:窗口内出现最多的类别替换中心像元""" def _mode(arr): vals, counts = np.unique(arr, return_counts=True) return vals[np.argmax(counts)] return generic_filter(classified_map, _mode, size=window_size)第二个技巧是最小斑块去除。设定一个面积阈值(比如 5 个像元),把小于这个阈值的连通区域合并到相邻的最大类别中。这一步用scipy.ndimage.label做连通域标记,再遍历处理。
第三个技巧是概率图输出。不要只保存 argmax 后的硬分类结果,把 softmax 的概率图也存下来。概率图有两个用途:一是可以做置信度阈值过滤,低置信度的区域标记为待核实;二是后续如果要做变化检测,概率图的连续值比硬分类结果信息量大得多。
最后说一个我自己的习惯:每次跑完分类,不要只看 Kappa 系数,一定要把分类图叠加到原始影像上目视检查。重点看三类边界区域——水体与阴影的边界、耕地与草地的边界、建设用地与裸地的边界。这三个地方是错分高发区,Kappa 系数再高,边界糊了就是不能用。希望帮到你。
本文还有配套的精品资源,点击获取