
简介这是一套基于CNN深度学习的Landsat遥感影像地物分类完整工程面向遥感研究者、GIS开发人员及深度学习初学者。压缩包内共10个文件包含3个Python脚本影像切片、模型训练、新数据预测、1个训练好的HDF5格式CNN模型h5、2个TIF影像及配套xml/tfw地理信息文件另有README说明文档整体14.49MB结构清晰。已有185人学习下载适合直接运行或二次开发。程序采用TensorFlow/PyTorch等框架构建可自动提取影像纹理、形状与上下文特征实现水体、植被、建筑区等地表覆盖类型的高精度分类训练好的模型免去从头训练步骤用户加载h5文件即可快速验证。这套代码对理解CNN在遥感地物分类中的应用、完成课程设计或实际项目原型验证均有实用价值。1. 遥感地物分类为什么值得自己动手做一套 CNN 方案遥感地物分类是整个遥感应用里最常被问起、也最容易把新手劝退的方向。手头有 Landsat 影像想把它分成水体、植被、建筑、农田这类可用的地物类别用传统的监督分类也能出图但一到复杂场景就露馅阴影被当成水体、裸地和建设用地互相混淆、山体起伏带来大片错分。很多做 GIS 或土地调查的从业者后来都转向了 CNN 深度学习这条路——类别特征让网络自己学泛化能力确实比手工特征硬。Python 在这个方向几乎是唯一的选择语言生态里既有 rasterio/GDAL 处理遥感栅格又有 PyTorch 这类深度学习框架能直接建 CNN 模型再加上一份能跑的源代码和一套训练好的权重落地门槛就只剩下「把数据准备好」这一件事。这篇笔记就是围绕这样一个交付物来展开影像怎么预处理、样本怎么制作、CNN 结构怎么选、训练时哪些参数不能乱动、模型怎么用起来。内容按「数据 → 网络 → 训练 → 部署」的递进关系组织前四章每章都能直接照做第五章集中写避坑最后一章把模型落地成能用工具。适合三类人刚接触遥感深度学习的学生、需要在业务里做地物分类图的生产人员、想验证 CNN 在遥感影像上效果的算法工程师。2. Landsat 影像预处理从原始 DN 值到可训练的样本张量2.1 Landsat 数据的基本认知波段、分辨率与 DN 值Landsat 影像不是「一张照片」而是一组多光谱栅格文件。以 Landsat 8/9 OLI 传感器为例常见交付格式是 GeoTIFF每个波段是独立文件空间分辨率 30 米波段顺序从沿海蓝到热红外。做地物分类最常用的是波段 2蓝、3绿、4红、5近红外、6短波红外 1、7短波红外 2这六个波段能覆盖植被、水体、建筑、土壤的主要光谱差异。新手最容易在「DN 值」上翻车Landsat Level-1 产品里像素值是无量纲的数字通常在 0~65535 之间这个数值不是地表反射率受太阳高度角、大气散射、传感器增益多因素影响。如果拿 DN 值直接喂给 CNN模型会把传感器状态当成地物特征学进去——同一片水在不同时间拍出来数值差别很大模型自然就乱了。常见做法是转成地表反射率或至少做辐射定标把 DN 值缩放到合理的浮点范围。实操中如果不想引入大气校正的复杂性至少也得让样本制作时的数据分布稳定下来。我一般会先把 Landsat 数据转成反射率并做归一化到 0~1 区间背后的理由很简单CNN 对输入分布是敏感的BN 层能缓解内部协变量偏移但输入尺度差异过大会让前几层卷积核的梯度不稳定。先把输入统一到相同尺度后面的训练会省掉很多不确定的麻烦。2.2 预处理管线落地裁剪、合成、归一化一个标准的预处理脚本长这样基于 rasterio 读取波段裁剪到研究区范围叠加成多通道数组再做百分比截断归一化。百分比截断比最小最大归一化更抗异常值——Landsat 影像里像云、像雪这种极端亮目标会拉高最大值把有用的地物细节压缩到很窄的数值区间里。import rasterio import numpy as np def load_landsat_bands(band_paths: dict, windowNone): bands [] for bname in [B2, B3, B4, B5, B6, B7]: with rasterio.open(band_paths[bname]) as src: if window is None: data src.read(1) else: data src.read(1, windowwindow) bands.append(data.astype(np.float32)) return np.stack(bands, axis0) # shape: (6, H, W) def to_reflectance_and_clip(image: np.ndarray, percentile2): # 反射率缩放Landsat 8 的 RADIANCE_MULT 与 ADD 因子近似用 2e-5 和 -0.1 refl image * 2e-5 - 0.1 refl np.clip(refl, 0, 1) # 对每个波段按百分位截断压缩异常亮目标的影响 for i in range(refl.shape[0]): lo, hi np.percentile(refl[i], [percentile, 100 - percentile]) refl[i] np.clip((refl[i] - lo) / max(hi - lo, 1e-6), 0, 1) return refl这个脚本里有几个参数值得说清楚。band_paths是把波段序号映射到文件路径的字典顺序必须固定你选了什么波段组合训练时就必须用什么波段组合比如训练用了 2-3-4-5-6-7到推理阶段换成 1-2-3-4-5-6 就会出问题。percentile控制截断强度设成 2 意味着上下各 2% 的极亮极暗像素被压缩掉这个值在云量较少时很稳如果研究区云多建议提到 5。窗口裁剪在影像特别大时很有用先按窗口把数据切成块处理避免一次性把整景影像读进内存。2.3 样本块制作滑窗切块加重叠采样有了预处理后的影像下一步是把影像切成训练样本。这里有个关键选择地物分类到底做「像素级分类」还是「图块级分类」常见做法是折中——以某个像素为中心取一块邻域比如 28×28 或 32×32 的窗口窗口里的多波段数据作为输入中心像素的类别作为标签训练一个 CNN 做 N 分类。窗口里的空间上下文给了网络更多判别线索。这种策略对硬件要求低、代码逻辑简单是标题这种「源代码模型」交付物最常见的实现路径。def sample_blocks(feature: np.ndarray, label: np.ndarray, block_size28, num_samples10000): h, w feature.shape[1], feature.shape[2] half block_size // 2 samples_x, samples_y [], [] # 从每类标签中均匀采样保证类别平衡 for cls in np.unique(label): ys, xs np.where(label cls) mask (ys half) (ys h - half) (xs half) (xs w - half) ys, xs ys[mask], xs[mask] if len(ys) 0: continue idx np.random.choice(len(ys), sizemin(num_samples // len(np.unique(label)), len(ys)), replaceFalse) samples_y.extend(ys[idx].tolist()) samples_x.extend(xs[idx].tolist()) blocks [] labels [] for cy, cx in zip(samples_y, samples_x): block feature[:, cy - half:cy half, cx - half:cx half] blocks.append(block) labels.append(label[cy, cx]) return np.array(blocks), np.array(labels)这里block_size的选择是有讲究的28 对应的是 MNIST 习惯尺寸窗口里大约覆盖 840 米×840 米的地面范围对建筑、水体的判别足够但对大面积农田来说可能不够看。如果研究区里地物分布较破碎可以放到 32 或 48窗口越大空间语义越丰富但计算量和内存占用同步上升。num_samples是总样本数注意逻辑是「按类别均分采样」避免某类地物面积太大导致样本严重不平衡。3. CNN 分类网络设计写给遥感影像的小型卷积结构3.1 为什么遥感分类适合小型 CNN而不是一上来就上 ResNet刚接触深度学习的遥感从业者常问一个问题是不是网络越深效果越好其实对于 Landsat 这种 30 米分辨率、六七个波段输入的数据一个小型 CNN 往往够用且更好用。原因有两点一是遥感样本的标注成本高手工解译地物类别非常耗时数据量通常只有几千到几万张样本块深层网络像 ResNet-50 在这种数据规模下很容易过拟合二是 Landsat 多光谱波段之间的相关性较高浅层网络已经能提取到足够的纹理和光谱特征深层网络带来的收益被过拟合风险抵消。我自己做这类任务的默认起点是一个「三层卷积两层全连接」的简单结构。第一层卷积学习光谱组合特征第二层学习局部纹理第三层扩大感受野学习空间上下文。全连接层接在全局池化后面参数量控制在几十万级别单卡训练几分钟就能看到初步结果。比直接调 ImageNet 预训练模型要快得多也少了很多「权重要不要冻结」的纠结。3.2 网络结构实现PyTorch 下的可运行版本下面是这个网络的具体实现PyTorch 代码输入是(batch, 6, 28, 28)的张量输出 5 类地物的 logits。结构上加了两个处理要点BN 层跟上卷积层避免梯度消失全局平均池化替代 Flatten大幅度降低全连接层的参数量。import torch.nn as nn class LandsatCNN(nn.Module): def __init__(self, in_channels6, num_classes5): super().__init__() self.features nn.Sequential( nn.Conv2d(in_channels, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 14x14 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 7x7 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool2d(1) # 1x1 ) self.classifier nn.Sequential( nn.Linear(128, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) return self.classifier(x)几个参数的设置思路要说明一下。in_channels必须和预处理阶段的波段数保持一致前面选了 6 个波段这里就得是 6。第一层卷积核数量从 32 起步数据量大可以翻倍到 64但不要一上来就 128否则小数据集很容易过拟合。两个 MaxPool 把 28×28 的空间尺寸压到 7×7第三个卷积层后面接 AdaptiveAvgPool2d(1) 把特征图压到 1×1这一步替代了 Flatten 加全连接的做法让最后一层卷积的每个通道对应一个抽象特征响应网络参数量大幅减少训练和推理都更稳定。Dropout 放在全连接前抑制过拟合遥感样本之间本来就存在一定空间自相关Dropout 在这类数据上的收益比自然图像更明显。3.3 类别数设计与输出层用交叉熵配合类别权重标题里的「地物分类」没有指定类别体系实际做的时候需要自己定。最常见的五类方案是0水体、1植被、2建设用地、3裸地、4农田。如果你的研究区有特殊地物比如湿地、积雪或养殖池塘可以扩到六类七类但每多一类标注工作量就多一截混淆概率也升一截。输出层直接使用nn.CrossEntropyLoss()。这个损失函数内部整合了 Softmax 和 NLLLoss输入 logits 而不是概率这是 PyTorch 中正确用法。有一点容易被忽视不同地物类别在影像上天然不平衡——建设用地和裸地面积通常远大于水体。就算前面「按类均匀采样」缓解了样本不平衡推理时若某类别特别难分可以给损失函数加类别权重# 类别权重示例水体更难分类给它更高权重 class_weights torch.tensor([1.5, 1.0, 1.0, 1.0, 1.2], devicedevice) criterion nn.CrossEntropyLoss(weightclass_weights)这个权重怎么定一个简洁的方法是先跑一版不加权的结果看混淆矩阵里哪类精度低就调高它的权重。通常是水体这类面积小、边缘复杂的类别需要更高权重。权重的绝对值不用死抠0.2~0.5 的调整幅度足够明显。4. 模型训练与参数调优从损失下降到收敛判断4.1 训练脚本的主干逻辑与 checkpoint 策略训练阶段的代码要解决三个问题数据怎么喂、参数怎么更新、模型怎么存。数据加载用 PyTorch 的 DataLoader 配合 TensorDataset把前面生成的样本块数组做成 dataset 对象。下面这份训练脚本是完整可跑的epoch 设为 50初始学习率 0.001使用 Adam 优化器按验证集准确率保存最佳权重。import torch from torch.utils.data import TensorDataset, DataLoader train_ds TensorDataset(torch.from_numpy(train_x), torch.from_numpy(train_y).long()) val_ds TensorDataset(torch.from_numpy(val_x), torch.from_numpy(val_y).long()) train_loader DataLoader(train_ds, batch_size64, shuffleTrue) val_loader DataLoader(val_ds, batch_size64, shuffleFalse) device cuda if torch.cuda.is_available() else cpu model LandsatCNN().to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size20, gamma0.1) criterion nn.CrossEntropyLoss() best_acc 0.0 for epoch in range(50): model.train() running_loss 0.0 for imgs, labels in train_loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * imgs.size(0) # 每轮结束验证一次 model.eval() correct, total 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels imgs.to(device), labels.to(device) outputs model(imgs) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) acc correct / total scheduler.step() # 保存最优模型 if acc best_acc: best_acc acc torch.save({ epoch: epoch, model_state_dict: model.state_dict(), best_acc: best_acc, num_classes: 5, block_size: 28, }, best_model.pth) print(fEpoch {epoch:02d} | Loss {running_loss/len(train_ds):.4f} | Val Acc {acc:.4f})这段代码里三个细节是经验之谈。scheduler.step()放在验证之后而不是每个 batch 之后因为 StepLR 按 epoch 调整学习率放在前面会导致每个 batch 都在改学习率行为不符合预期。model.eval()和torch.no_grad()必须成对出现否则 BN 层在验证时还会用 batch 统计量推理结果时好时坏。保存 checkpoint 时我把block_size和num_classes一起存进去是因为推理时要读取这些元数据来重建输入维度和预测类别数单独存一个模型文件不带这些参数换环境时容易忘记。4.2 学习率策略与数据划分比例学习率是遥感分类训练里最「玄学」也最关键的超参数。0.001 是 Adam 的默认推荐值配合 CNN 小网络通常开局收敛正常。但有两个情况需要调低到 0.0005 或 0.0003一是样本量只有几千张、网络还没有足够数据支撑梯度稳定下降时二是 Loss 曲线在早期就开始震荡说明步长过大在损失曲面两侧反复横跳。用 StepLR 每 20 轮降为原来的 0.1相当于前 20 轮做粗调后 20 轮做精调对遥感数据这种「全局是大致平滑、局部有尖锐边界」的损失面比较合适。数据划分建议 7:2:1 而不是常见的 8:2。差出来的 10% 做训练过程的最终验证集其实是为了对抗一个风险样本块之间因为空间相邻存在相关性验证集和训练集距离太近时验证准确率会虚高。如果你有足够大的标注面积最好按「空间分块」划分数据比如东半幅做训练、西半幅做验证比随机划分更能反映真实泛化能力。随机划分的验证集精度通常在 96% 以上但空间划分可能只有 88%——后者才接近你拿去分类一张新影像时的真实表现。4.3 训练过程诊断看什么指标怎么判断收敛训练时不要只盯验证集准确率至少要把三样东西记录下来训练 Loss、验证 Loss、验证准确率。Loss 比准确率更能反映模型是否真的在收敛因为准确率只看 argmax 结果early 阶段的微小改进不会体现。我通常会画一张损失曲线图放在手边训练 Loss 和验证 Loss 都在下降且差距不大健康状态按计划训练。训练 Loss 降但验证 Loss 在回升过拟合信号需要提前停止或加大 Dropout。两个 Loss 都在高位震荡学习率偏大或者网络结构有 bug先调低学习率看反应。有一种情况经常让新手摸不着头脑验证 Loss 下降但验证准确率也下降通常发生在类别不平衡严重的时候。模型发现把所有样本判成大类能压低整体 Loss但小类被牺牲了。这时要么按混淆矩阵检查逐类精度要么回到第 3 节的类别权重方案手动调权重。训练好的模型质量不能只看总体准确率逐类用户精度和制图精度才是遥感业务关心的指标。5. 遥感分类落地避坑六个最容易翻车的环节5.1 影像预处理不一致训练时归一化、推理时不归一化现象训练集精度很高但拿到一张新影像去预测时完全不收敛出图像噪声。原因预处理管线没有封装成统一函数训练时做了百分位截断归一化推理时直接读原始 DN 值当成反射率数输入网络。CNN 对输入数据分布敏感训练数据和推理数据分布不同前几层卷积的响应完全跑偏。解决把预处理逻辑封装成一个preprocess_landsat(path) - np.ndarray函数训练和推理共用同一份代码。推理之前先打印输入数组的均值、方差和训练样本对比是否在同一量级这是最直接的检查手段。5.2 波段顺序错乱B5 和 B4 位置互换导致的「植被消失」现象模型对植被区域的预测结果特别差或者把大范围森林识别成建设用地。原因GeoTIFF 的波段文件是按文件名读取的如果代码里用字典顺序去读B4排在B5后面但构建数组时习惯先读近红外导致波段顺序错位。植被在高分辨率影像里主要靠近红外波段和红波段的比值区分一旦顺序错了网络学到的是错误光谱组合。解决在load_landsat_bands函数里硬编码波段列表[B2, B3, B4, B5, B6, B7]不要用文件名自然排序或os.listdir()的返回结果。在模型训练前用一小块已知区域做 sanity check打印出植被样本块在 B4 和 B5 波段的均值B5 应明显高于 B4如果反了就是波段顺序问题。5.3 类别标签不对齐样本块中心像素与标签栅格偏差现象训练 Loss 无法收敛到理想水平混淆矩阵里每一行都有零散错误分布。原因制作样本块时截取窗口的坐标是从np.where(label cls)得到的行列号但标签栅格和影像栅格的空间范围没有对齐错开几个像素学习时输入与标签错位。这种情况在边界区域尤其严重标签是建筑像素输入窗口却主要覆盖道路。解决严格用同一份 GeoTransform 读取影像和标签使用rasterio.windows.from_bounds把地理坐标换算成像素坐标不让两者使用独立坐标索引。如果标签是外部标注转成的栅格先用rasterio.warp.reproject对齐到影像的分辨率和范围。5.4 训练集类别不平衡导致大类主导现象总体准确率 94%但水体的用户精度只有 40%大量水体像素被判成植被。原因按面积随机取样时水体面积占比太小样本数量远低于建设用地没有在采样阶段加类别约束。CNN 学到的是「尽量把不确定像素判为大类」的局部最优策略。解决改用第 2 节的按类均匀采样函数限制每类样本数趋于一致。如果均匀采样后水体样本不够使用数据增强扩充——对水体样本做水平翻转、旋转 90 度、随机噪声增强水体样本多样性而不是靠重复采样来凑数。5.5 推理时显存溢出或内存不足现象预测一张完整 Landsat 影像时报 CUDA out of memory或者 CPU 推理慢到无法接受。原因推理策略是直接用整景影像作为网络输入全图前向传播占用显存巨大。Landsat 单景影像尺寸约 7000×7000 像素6 个波段 float32 数组就占 1.2GB 内存输入 CNN 后中间特征图更是爆炸。解决跑滑窗推理把影像切成与训练时一致的块大小28×28逐块预测再用stride14做重叠投票边缘部分的预测取多数投票结果既节省显存又减少边界伪影。5.6 模型文件跨平台加载失败现象在 GPU 机器上训练的模型拷贝到 CPU 机器上加载时报错或者预测结果全 0。原因torch.load默认使用保存时的设备映射。GPU 保存的 checkpoint 在纯 CPU 环境加载会把张量映射到 CUDA 设备轻则报错重则返回 NaN 权重。解决加载时显式指定map_locationcpu并在加载后检查权重的device属性。这个参数只影响加载过程不影响后续推理计算所以在 CPU 机器上部署一般都用它。checkpoint torch.load(best_model.pth, map_locationcpu) model.load_state_dict(checkpoint[model_state_dict])6. 把训练好的模型用起来新增影像批量预测与制图输出模型训练完、验证集精度满意后下一步通常是对一批新的 Landsat 影像做批量预测然后输出为可用于 GIS 制图的 GeoTIFF。这里最大的问题不是预测本身而是把 NumPy 数组写出带地理坐标的栅格文件。用rasterio可以优雅地解决复制参考影像的投影信息和变换矩阵写出的分类结果可以在 ArcGIS 或 QGIS 里直接打开。import numpy as np import rasterio from rasterio.transform import from_origin def predict_image(model, img_array: np.ndarray, block_size28, stride14): model.eval() h, w img_array.shape[1], img_array.shape[2] preds np.zeros((h, w), dtypenp.uint8) count np.zeros((h, w), dtypenp.uint8) half block_size // 2 with torch.no_grad(): for y in range(half, h - half, stride): for x in range(half, w - half, stride): block img_array[:, y - half:y half, x - half:x half] block_tensor torch.from_numpy(block).unsqueeze(0).float().to(device) output model(block_tensor) pred output.argmax(dim1).item() preds[y - half:y half, x - half:x half] pred count[y - half:y half, x - half:x half] 1 return preds def save_classification(result: np.ndarray, ref_path: str, out_path: str): with rasterio.open(ref_path) as src: profile src.profile.copy() profile.update(dtyperasterio.uint8, count1, compresslzw) with rasterio.open(out_path, w, **profile) as dst: dst.write(result, 1)stride14是重叠投票常用参数把移动步长设为窗口的一半让每个像素至少被预测 4 次不同窗口位置覆盖取众数作为最终类别。这个设计能显著减少分类结果里的椒盐噪声但代价是推理时间约为滑窗不重叠时的 4 倍。如果对出图速度有要求可以先用不重叠滑窗快速看效果最终出图再切到重叠投票。批量预测的完整流程就是遍历影像文件夹、逐景预处理、逐景预测、逐景保存。这里有一个我自己的习惯每一景影像预测完后随手统计各类别占比并打印出来和已知研究区的先验知识对比。比如研究区明明以农田为主预测结果显示建筑占了一半不用等出图也能判断出一定是预处理或模型加载出了问题。这种「用输出合理性反推管线问题」的习惯帮我挡下了很多低级错误。真正投入生产前建议用你最熟的一块小区域做端到端验证裁剪一个 500×500 像素的子区跑完整个管线把输出的地物分类图和原始影像叠加在 GIS 里看边界吻合程度。若吻合良好再全量跑若不理想利用小区域快速迭代参数代价远低于在整景影像上反复试错。希望这套从数据预处理到模型落地的路径能帮你少走几个月的弯路。本文还有配套的精品资源点击获取