☰
Landsat遥感影像CNN地物分类实战:从数据预处理到PyTorch训练全流程
2026/10/8 11:10:44 网站建设 项目流程

简介:CNN深度学习遥感影像地物分类项目专注于Landsat数据的处理与地物分类,基于PyTorch框架实现,面向人工智能、遥感、通信等专业的高校学生、教师与科研人员,既适合课程设计与毕业设计,也可用于项目初期演示或深度学习进阶练习。压缩包大小约14.88MB,共10个文件,包括3个Python脚本(分别对应影像切片生成、CNN模型训练与预测新数据)、1个预训练模型文件(.h5)、2个TIFF影像示例及配套地理配准/辅助文件(.tfw与2个xml),另附说明文档,整体结构简洁、分类清晰。项目代码经过严格测试,可直接运行,已有88人学习下载。通过完整代码可系统掌握遥感影像地物分类全流程,涵盖Landsat数据预处理、训练样本制作、模型构建训练与未知影像预测,代码注释与目录安排便于在此基础上修改扩展,是深度学习与遥感交叉方向值得参考的实践范例。

1. CNN深度学习遥感影像地物分类到底是什么活

做 CNN 深度学习遥感影像地物分类,最容易被低估的不是网络结构,而是输入数据。Landsat 一景影像动辄 7000 乘 7000 像元,覆盖几十公里范围,你要在里面分出水体、植被、建筑、耕地,模型再深,只要波段没定标、标签和影像错位一个像元,精度就卡在 70% 上下不去。这篇笔记围绕标题里的 Python 源码组织方式展开,从 Landsat 数据预处理写到 CNN 训练、评估成图,把每个环节的常用参数和踩坑点都摆出来。适合做土地利用变化分析、生态监测、农业估产的从业者和研究生,也适合第一次想用 CNN 处理遥感影像、不想从零开始折腾的人。

2. Landsat 数据预处理:把原始影像变成能喂给 CNN 的张量

拿到标题对应的源码包,不管打包成什么样,主体躲不开四块:Landsat 预处理、样本制作、CNN 训练、评估输出。先别急着打开训练脚本,第一步把数据处理好。下面代码基于 rasterio、geopandas、numpy、torch,建议用 conda 新建 Python 3.9 环境并装好这些库;在 Windows 上装 geopandas 报底层库错是常事,用conda install geopandas而不是 pip,这是深度学习环境配置里比较常见的坑。默认你已经下载好一景 Landsat 影像和对应的标注数据,接下来按三步走。

2.1 波段组合与辐射定标:为什么不能直接用原始 DN 值

Landsat 8/9 OLI 有 11 个波段,地物分类常用的就六个:蓝、绿、红、近红外、两个短波红外。如果拿到的是 Level-1 产品,像元值叫 DN 值,既不是地表反射率,也受大气、太阳高度角和传感器增益影响,不同期影像数值范围不一致。常见做法是直接用 Collection 2 Level-2 地表反射率产品,它已经做过大气校正,只要按元数据缩放参数转成浮点即可;只有 Level-1 时就得先做辐射定标和大气校正,Python 里可以调 py6s,工程上更多人会在 QGIS 的 SCP 插件里先把这一步做完再导出。

import numpy as np import rasterio # 波段文件命名为 LC08_*_B2.TIF ... B7.TIF,顺序固定为蓝绿红近红外短波红外1短波红外2 band_paths = { "blue": ".../LC08_..._B2.TIF", "green": ".../LC08_..._B3.TIF", "red": ".../LC08_..._B4.TIF", "nir": ".../LC08_..._B5.TIF", "swir1": ".../LC08_..._B6.TIF", "swir2": ".../LC08_..._B7.TIF", } order = ["blue", "green", "red", "nir", "swir1", "swir2"] bands = [] for name in order: with rasterio.open(band_paths[name]) as src: arr = src.read(1).astype("float32") arr[arr <= 0] = np.nan # 填充值/无效值统一置 NaN bands.append(arr) stack = np.stack(bands, axis=-1) # H × W × 6 scale = 0.0000275 # Collection 2 Level-2 反射率缩放 offset = -0.2 reflectance = stack * scale + offset np.save("landsat_reflectance.npy", reflectance) print(reflectance.shape, reflectance.dtype)

逻辑说明:先把六个波段按固定顺序堆叠成 H×W×6 的数组,通道顺序直接影响后续模型训练,前后必须保持一致。无效值置成 NaN 而不是 0,是因为 0 是真实存在的低反射率,水面在近红外波段就接近 0,用 0 填充会让模型以为“无效”是一种地物。

参数说明:scale 和 offset 的具体取值以你这景影像的 MTL 元数据里的 reflectance_mult/add 为准,不要凭记忆手填。做完这一步建议顺手把六个波段写成 6 波段的 GeoTIFF,复制原单波段 profile、把 count 改成 6 再逐波段写入,后面裁剪、重采样、切片只用这一个文件,能避免六个 TIF 的 transform 各自漂移。

2.2 裁剪、重采样与标签栅格化:所有数据必须压在同一个网格上

Landsat 一景范围太大,训练通常只在研究区内做。第一步按矢量边界裁剪影像,第二步把标注好的地物 shp 栅格化成与影像完全对齐的标签掩膜。这两步出的问题绝大多数是坐标系和网格不一致:影像在 WGS84 经纬度下像元不是正方形,越往高纬度变形越大,直接切 patch 会让不同位置的窗口物理范围不一致。常见做法是先统一投影到 UTM 对应分带,再做裁剪和栅格化。

import geopandas as gpd import rasterio from rasterio.mask import mask as rio_mask study = gpd.read_file("study_area.shp") with rasterio.open("landsat_6bands.tif") as src: img, out_transform = rio_mask( src, study.geometry.values, crop=True, nodata=0) profile = src.profile.copy() profile.update(count=img.shape[0], height=img.shape[1], width=img.shape[2], transform=out_transform) with rasterio.open("landsat_cropped.tif", "w", **profile) as dst: dst.write(img)

逻辑说明:rio_mask会按矢量边界计算裁剪窗口,crop=True表示把范围收紧到研究区外接矩形,nodata=0给裁剪后边界外的区域填 0。关键在于更新 profile 时一定要用返回的out_transform,而不是复制原文件的 transform,否则坐标直接错位。

参数说明:img.shape对多波段影像返回(count, height, width),所以 profile 里 count 必须同步更新。如果研究区跨多景影像,先gdal.Warp拼接或重投影到同一 UTM 分带,再做这一步。

import geopandas as gpd import numpy as np import rasterio from rasterio.features import rasterize with rasterio.open("landsat_cropped.tif") as src: height, width = src.height, src.width transform = src.transform labels = gpd.read_file("labels.shp") shapes = [(geom, val) for geom, val in zip(labels.geometry, labels["class_id"])] label_mask = rasterize( shapes, out_shape=(height, width), transform=transform, fill=0, dtype="uint8", ) np.save("label_mask.npy", label_mask)

逻辑说明:栅格化标签的核心是transform和out_shape直接取自裁剪后的影像,不能自己重新指定分辨率或行列数。shapes 是(geometry, class_id)的迭代对象,class_id 就是标注时给的类别编码,0 表示没有标注的背景区域。

参数说明:dtype="uint8"够用,类别超过 255 才需要换uint16。标签重采样时必须用 nearest,不能用双线性;如果用双线性,地物边界会被糊成中间值,训练时模型学到一堆不存在的过渡类别。裁完建议做一次冒烟检查:在 QGIS 里叠上影像和 label_mask 转出的 GeoTIFF,透明度调到 50%,肉眼扫一遍有没有半个像元级别的错位,这一步别省。

注意:所有参与训练的影像、标签、掩膜,必须是同一个 CRS、同一个分辨率、同一个网格起点。这是整条流水线里最贵的坑,错一个像元后面全白干。

3. CNN 模型选型与训练数据组织:先想清楚逐像素还是窗口分类

预处理做完,接下来决定怎么把影像切成模型能吃的样子。很多人一上来就堆 U-Net、DeepLabv3+,结果标注样本不够,训练十个 epoch 就开始过拟合。标题只说了 CNN,没锁死网络,那按最常见的可靠方案来:先从 Patch 分类起步,跑通整条链路,再视样本量决定要不要升级成全卷积网络。

3.1 逐像素分类还是 Patch 分类:一张表看明白

方案输入形式适合场景样本量要求边界表现
Patch CNN 分类以目标像元为中心的 N×N 窗口训练样本少、地物斑块大每类几百个窗口可起步分类图锯齿明显
全卷积/语义分割整景或瓦片样本充足、需要边界连贯需要像素级标注支撑边界更连续平滑

Landsat 30m 分辨率下,一栋建筑、一块水塘往往有几十上百个像元,空间上下文非常强,3×3 的窗口几乎看不出地物形状,32×32 是常用的起步值。先做 Patch 分类还有一个好处:样本是按“窗口”组织的,每类凑几百个就能跑,不用一上来就标几万像素。等 Patch 分类的评估结果能到 85% 以上,再考虑换全卷积网络去啃边界,这是比较省时间的路线。

3.2 用 PyTorch 写 PatchDataset 和轻量 CNN

PatchDataset 的核心是滑动窗口切片,同时只保留中心像元有标注的窗口。这样尽量避免一个窗口跨两类地物时,标签不知道该算哪一类。

import numpy as np import torch from torch.utils.data import Dataset class LandsatPatchDataset(Dataset): """从 HxWxC 影像和 HxW 标签里按窗口滑切 patch。""" def __init__(self, image, label, patch_size=32, stride=16, class_ids=(1, 2, 3, 4, 5)): self.patches = [] self.labels = [] h, w = image.shape[:2] for row in range(0, h - patch_size + 1, stride): for col in range(0, w - patch_size + 1, stride): center = label[row + patch_size // 2, col + patch_size // 2] if center not in class_ids: continue self.patches.append( image[row:row + patch_size, col:col + patch_size]) self.labels.append(center - 1) # 转成0基索引 def __len__(self): return len(self.patches) def __getitem__(self, idx): patch = torch.from_numpy(self.patches[idx]).float().permute(2, 0, 1) label = torch.tensor(self.labels[idx], dtype=torch.long) return patch, label

逻辑说明:stride=16让相邻窗口有 50% 重叠,等于给模型做了平移增广,对小样本训练很有帮助;stride=patch_size则不重叠,窗口数量少、样本不充分时不要这么干。标签减 1 是因为 label_mask 里类别从 1 编码,而 CrossEntropyLoss 要求类别从 0 开始。

参数说明:patch_size=32在 30m 分辨率下对应 960m 范围,对耕地、水体、林地这类大斑块足够;如果你要分的是房屋、道路这种细碎地物,把 patch_size 降到 16 试试。用中心像元的标签,是因为卷积会引入边缘效应,窗口中心的感受野最完整,边缘像元本来就学不准。

模型这部分不追求花哨,一个三层卷积的小网络就够了:

import torch.nn as nn class LandsatCNN(nn.Module): """输入 6 波段任意尺寸 patch,输出 num_classes 分类。""" def __init__(self, in_channels=6, num_classes=5): super().__init__() self.features = nn.Sequential( nn.Conv2d(in_channels, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.AdaptiveAvgPool2d(1), ) self.classifier = nn.Linear(128, num_classes) def forward(self, x): x = self.features(x) return self.classifier(x.flatten(1))

逻辑说明:两层 MaxPool 把 32×32 输入压到 8×8,最后用 AdaptiveAvgPool2d 把特征图压成 128 维向量,这样模型推理时能吃更大尺寸的窗口,不用重新训练。BatchNorm 放在卷积和 ReLU 之间,能缓解 Landsat 不同期影像反射率分布漂移带来的影响。

参数说明:第一层卷积核从 32 个起步,对 6 波段输入够了,别一上来就 128 个,遥感样本量通常撑不起太宽的网络。如果你刚入门,建议先照着《动手深度学习》的 CNN 章节把卷积层输出尺寸的换算算熟,再回来调这个模型,不然调 padding 和 kernel 全靠猜。CNN 有时候像个黑匣子,但参数规模能不能被样本量撑住是可以算清楚的。

3.3 类别不平衡:先改损失函数,比换网络结构见效快

Landsat 分类里水体、植被、耕地往往占掉大半景影像,建筑、裸地只有零星几块。如果不处理不平衡,模型损失会被多数类压住,少数类的 F1 惨不忍睹。常见做法是先给 CrossEntropyLoss 加类别权重,不够再上 focal loss。

import numpy as np import torch.nn as nn class_counts = np.bincount(label_mask[label_mask > 0])[1:] # 丢弃0背景 class_weights = class_counts.sum() / ( len(class_counts) * class_counts + 1e-6) weights = torch.from_numpy(class_weights.astype("float32")) criterion = nn.CrossEntropyLoss(weight=weights)

逻辑说明:权重公式是“总样本数 / (类别数 × 每类样本数)”,少数类权重自动变大,多数类被压低。[1:]是为了去掉 bincount 结果里 0 类背景的位置,和 Dataset 里标签减 1 的约定对齐。

参数说明:如果类别数特别多且分布极端,加权交叉熵可能会出现少数类权重过大、训练震荡的问题,这时可以加一个平滑系数,比如把权重上限截到 10。另一个常用做法是在 Dataset 里按类别等量采样,让每个 batch 里每类窗口数量接近,这个对收敛的帮助比改模型结构直接得多。

4. 避坑排查:Landsat 与 CNN 训练中最容易翻车的四个环节

这一章写的都是实际跑数据时常遇到的问题,每一条都是先现象、再原因、后解决,照着排查能少走很多弯路。

4.1 标签和影像错位一个像元,精度就到不了 85

现象:训练 loss 收敛正常,验证混淆矩阵里每类都有一定精度,但 OA 卡在 75% 左右,分类图上的地物边界和影像明显对不齐。原因:多半是标签栅格化时用了自己指定的行列数或分辨率,而不是影像自带的 transform;也可能是在 GIS 里手动导出标注范围时带了半个像元的偏移,重投影时标签用了双线性插值。解决:回到 2.2 节的栅格化方式,强制让标签的 transform 来自裁剪后的影像;把标签转成 GeoTIFF 叠在影像上半透明检查,偏移超过 1 个像元就重做。这个错位极其隐蔽,因为整体精度还是能到 70% 以上,不做边界对比根本发现不了。

4.2 loss 掉得很漂亮,验证 OA 只有 70

现象:训练 loss 一路下探,但验证集 OA 只有 70%,看混淆矩阵发现建筑、裸地几乎全被分成了植被和耕地。原因:类别不平衡时,加权没加对,或者权重加在了背景类别上;还有一种可能是验证集和训练集来自同一景影像的相邻区域,模型记住了空间位置而不是地物特征。解决:先确认 CrossEntropyLoss 的 weight 没有把 0 背景算进去,再用按类别等量采样重新做 Dataset。评估时别只看 OA,要同时看每类的 F1 和 Kappa,OA 对多数类极度偏袒,少数类全错也能有 80% 的 OA。

4.3 整景切 patch 内存爆掉,先从瓦片入手

现象:16G 内存的机器,一执行 patch 提取就 MemoryError,或者训练到一半进程被杀。原因:把整景 7000×7000×6 的数组一次性滑动窗口展开成几百万个 patch,内存直接撑爆。解决:不要提前把所有 patch 都存进内存,Dataset 里按需读取;更稳的做法是np.load(..., mmap_mode="r")用内存映射加载影像,再把整景拆成 512×512 的瓦片逐块切 patch。瓦片之间留几行重叠,避免边缘像元被切掉。这条是纯工程问题,和模型无关,但很多人卡在这里以为是自己代码写得不对。

4.4 Landsat7 条带、云和阴影被模型当成地物学进去

现象:分类图上出现和 Landsat7 SLC-off 条带完全重合的假地物边界,或者大片阴影被分成了水体。原因:训练数据把无效像元也当成真实地物了。Landsat7 的条带区域、云和云影在地表反射率产品里经常是 0 或异常值,如果不做掩膜,模型会学到“有条纹的地方是某种地物”。解决:用产品自带的 QA 波段生成有效像元掩膜,训练时只采样有效区域。QA 波段是按 bit 位编码的,云、云影、冰雪各占不同位,最省事的写法是cloud_mask = ((qa >> 1) & 1) == 1,具体 bit 定义要看 USGS 文档,不同 Collection 版本有差异。千万不要用影像全图 min-max 归一化后把无效像元当成 0 去训练,那等于给模型灌了一类假地物。

5. 分类图验收:交叉验证、预测成图与输出 GeoTIFF

模型训练好之后,最后一件事是让结果能“交差”:出精度指标、出分类图、保留坐标信息。精度评估用 scikit-learn 一条命令就能算:classification_report看每类 F1,cohen_kappa_score看 Kappa,混淆矩阵自己画一张,别只报一个 OA,审稿人和甲方都会追问每类精度。

预测整景影像时,还是用滑窗方式,把每个窗口中心像元的预测结果写回,窗口重叠时取中心位置而不是取平均,这样分类图不会出现两个预测抢一个像元的情况:

model.eval() pred_map = np.zeros((h, w), dtype="uint8") batch, coords = [], [] with torch.no_grad(): for row in range(0, h - patch_size + 1, stride): for col in range(0, w - patch_size + 1, stride): patch = img[row:row + patch_size, col:col + patch_size] batch.append(torch.from_numpy(patch).float().permute(2, 0, 1)) coords.append((row + patch_size // 2, col + patch_size // 2)) if len(batch) == 64: out = model(torch.stack(batch)) for i, (r, c) in enumerate(coords): pred_map[r, c] = out[i].argmax().item() batch, coords = [], []

逻辑说明:batch 攒到 64 再推理,是显存和速度的折中;预测时不需要算梯度,必须包在torch.no_grad()里,否则显存会翻倍。推理步长和训练时保持一致,分类图才连续。

预测完用 rasterio 写成 GeoTIFF,直接复用裁剪后影像的 profile,坐标信息就不会丢:

with rasterio.open("prediction.tif", "w", driver="GTiff", height=h, width=w, count=1, dtype="uint8", crs=profile["crs"], transform=profile["transform"]) as dst: dst.write(pred_map, 1)

参数说明:分类图必须用 uint8 整数写,类别编码和 label_mask 保持一致;如果写了浮点,后面 GIS 软件做统计时还得自己转。如果你想更进一步,在输出地物类别的同时再输出边界,那就是多任务深度学习,两个 loss 不能直接相加,要按各自梯度量级缩放后再加权,这是后话。

我自己的习惯是每次训练前,先把 label_mask 的类别分布打印出来存成 txt,训练完跑一轮评估后对比一次,能省掉很多调参的玄学时间。预测图出来后再叠到原始影像上看一眼边界,确认没有规则条纹或条带状的假分类,再交付数据。希望帮到你。

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

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

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

立即咨询