简介:面向遥感图像分类初学者的 PyTorch ResNet 实现,用于判断遥感影像是否包含湖泊,也适合用来熟悉从数据准备到模型训练、界面展示的完整流程。代码仅有 3 个 Python 文件,含逐行中文注释,即使是初学者也能对照理解;三个脚本分别负责生成数据集 txt、执行 CNN 训练、启动 PyQt 图形界面;另配有环境依赖 requirement.txt、类别示意 JPG 和说明文档。压缩包共 7 个文件,整体大小约 191KB,内容轻量清晰。资源不含数据集图片,但已预留“有湖泊/无湖泊”类别文件夹,用户收集图片放入对应目录即可训练,也可以自行增删类别扩展任务。说明文档对运行步骤和目录组织做了梳理,有助于快速跑通代码。已有 150 人学习下载,适合高校课程设计、个人练手或想要用轻量代码快速上手 ResNet 遥感二分类项目的读者。
1. 遥感“有无湖泊”,本质是一个二分类问题
拿到这个标题的第一反应,很多人会以为“遥感识别湖泊”就该上目标检测或者语义分割,把湖泊轮廓画出来。但仔细看标题——resnet模型、图像分类算法、有无湖泊识别,三件事合在一起,拍的其实是一个更务实的问题:给一张遥感切片,判断里面到底有没有湖。这正是图像分类算法最典型的二分类场景,输出不是一版边界,而是0或1。适合的读者也很明确:正在做遥感图像标注、想用深度学习跑通第一个遥感二分类任务、但又不想被分割网络的标注成本拖垮的研究生或GIS从业者。标题里“不含数据集图片”这一点尤其值得注意——意味着代码需要你自己补数据,但换来的好处是数据组织方式完全由自己掌控。
2. 用ResNet做湖泊二分类:选型理由与数据准备
2.1 为什么“有无湖泊”用ResNet而不是分割网络
遥感领域现在一提到地物识别,大家很容易被Segformer、U-Net这些分割模型带走,觉得“识别”就必须输出每个像素的类别。但对于“有无湖泊”这种判别式问题,ResNet反而是更稳的起点。你要做的不是画出湖的边界,而是让网络学会一种粗粒度的全局响应:这张图里水体占的区域是否足够多、足够典型。ResNet的Residual结构在层数加深时仍然能稳定传递梯度,layer4输出的特征图虽然空间分辨率低,但语义信息足够判定“有没有大面积水体”。这也是为什么ResNet FPN相关讨论里经常提到“粗粒度特征”和“细粒度特征”的配合——纯粹的湖泊有无二分,粗粒度特征就已经够用,细粒度特征反而是分割网络才需要较真的东西。
另一个现实理由是预训练权重。遥感影像和ImageNet自然图像存在域差异,但这不是Imagenet预训练失效的理由。常见做法是先用ImageNet预训练的ResNet初始化,然后在你自己的遥感tile上微调,只要遥感数据量不是太小,迁移效果通常远好于随机初始化。torchvision里ResNet18和ResNet50都有现成权重,生态遥感指数类工作中也大量沿用这一套预训练迁移路线,说明它经过了不少真实项目的验证。
选型上还要考虑一个“后悔药”问题:分割网络的标注成本是像素级的,二分类只需要给每一张tile一个类别,标注效率高出一个量级。如果你只是要快速判断一片区域“有湖还是没湖”,先跑通ResNet二分类,把数据流和训练pipeline搭好,后续再升级到实例分割,代码骨架也能复用,替换的只是模型和标注格式。别一上来就啃分割网络的硬骨头。
2.2 没有自带数据集:怎么用tile切图造出训练样本
标题写得很清楚,“不含数据集图片”,所以图片要自己准备。准备遥感数据的关键不是下载一张大影像直接丢给ResNet,而是把大影像切成固定尺寸的tile(切片)。常见做法是用rasterio读取GeoTIFF,按窗口滑动切图,同时跳过全是空值的黑边区域。
import os import rasterio from PIL import Image import numpy as np def split_tif_to_tiles(src_path, out_dir, tile_size=256, stride=256): os.makedirs(out_dir, exist_ok=True) with rasterio.open(src_path) as src: # 读取全波段数据,遥感影像一般是多波段,这里取前三个用于RGB img = src.read([1, 2, 3]) # shape: (3, height, width) img = np.transpose(img, (1, 2, 0)) # 变成 (height, width, 3) height, width = img.shape[:2] nodata = src.nodata tile_id = 0 for y in range(0, height - tile_size + 1, stride): for x in range(0, width - tile_size + 1, stride): tile = img[y:y + tile_size, x:x + tile_size] # 跳过包含nodata或全黑像素的tile if nodata is not None and np.any(tile == nodata): continue if np.all(tile < 10): continue tile_img = Image.fromarray(tile.astype(np.uint8)) tile_img.save(os.path.join(out_dir, f"tile_{y}_{x}.jpg")) tile_id += 1 print(f"共切出 {tile_id} 张tile,保存在 {out_dir}") # 参数说明:tile_size决定单张图的输入分辨率,遥感影像按米分辨率换算, # 例如2米分辨率的影像,256像素对应约512米x512米的地面范围 split_tif_to_tiles("landsat_area.tif", "./tiles")这里有几个参数要盯紧。stride如果不等于tile_size,相邻tile会有overlap,导致同一块水体出现在多张训练图中,验证集里一旦混入训练区域的重叠tile,评估指标会虚高,这是写着“验证集准确率95%”但业务上一塌糊涂的常见原因。所以我一般默认stride=tile_size,宁可多一点数据量,也不要空间重叠带来的数据泄漏。另外,切图之前要把影像投影和坐标范围记录好,后续分析模型在哪片区域翻车时,还需要靠坐标把tile映射回原始影像上定位。
2.3 标注与数据集划分:CSV清单是最省心的做法
图片切好后,接下来就是写标签。两张tile非常相似都是“有湖”,一个要标注成1,另一个标注成0。这时候最省心的是维护一个CSV清单,而不是用文件夹名称当label。原因是遥感tile数量多,改错一张图很难发现,而CSV里可以一眼看到文件名对应的标签,出错了用文本编辑器就能改。
filename,label tile_0_0.jpg,1 tile_0_256.jpg,1 tile_256_0.jpg,0 tile_256_256.jpg,0label的含义固定:1代表tile里有湖泊或大面积典型水体,0代表没有。标注完后,划分训练集和验证集时有一个容易忽略的点:遥感tile天然存在空间自相关,相邻tile内容高度相似。如果随机划分,可能出现训练集里某区域内容,验证集里紧挨着同样的地物,模型在验证集上表现好但换一片新区域又不行。更稳的做法是事先把所有tile按来源影像分块,以“影像”为单位划分而不是以“tile”为单位划分,保证同一景影像的tile全部落在训练集或验证集里。
3. 训练脚本的落地写法:从数据加载到保存最优权重
3.1 自定义Dataset读CSV,不依赖ImageFolder
PyTorch教程里最常见的图像分类demo是用ImageFolder,目录结构天然对应类别。但遥感tile场景下,我强烈建议改成自定义Dataset读CSV。原因有二:第一,遥感项目里同一批tile可能来自多个时相、多个区域,你需要在标签之外额外保存文件名和来源信息,方便排查;第二,train/val划分逻辑在CSV上做更透明,筛选哪些tile参与训练,一行pandas就能解决。
import pandas as pd import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as transforms class LandsatTileDataset(Dataset): def __init__(self, csv_path, data_dir, transform=None): self.df = pd.read_csv(csv_path) self.data_dir = data_dir self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img_path = os.path.join(self.data_dir, row["filename"]) img = Image.open(img_path).convert("RGB") label = int(row["label"]) if self.transform: img = self.transform(img) return img, label这个Dataset的逻辑很简单,核心是__getitem__里把CSV的一行读取成“图片+标签”对。注意convert("RGB")这行,遥感切出来的影像有些是RGBA四通道或灰度图,统一转成RGB三通道,ResNet18的第一层卷积是3通道输入,不做转换会直接报错。这是新手最容易卡住的地方。
3.2 用预训练ResNet18改头换面:冻结BN、替换全连接
模型部分最省事的写法是直接改torchvision里的ResNet18,替换最后一层全连接的输出维度。但这里有一个容易被忽略的细节:批量归一化层(BN)的统计量在model.train()模式下会持续更新。如果你的遥感数据集和ImageNet风格差异较大,且训练数据量少,BN层的running_mean和running_var会被少数样本带偏,导致验证集不稳定。常见做法是冻结BN层,让它使用预训练时累积的统计量,只更新卷积权重和最后一层全连接。
import torchvision.models as models def create_resnet18(num_classes=2, freeze_bn=True): # 新版torchvision用weights参数,老版本用pretrained=True try: model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) except TypeError: model = models.resnet18(pretrained=True) # 替换全连接层,二分类输出2个logits in_features = model.fc.in_features model.fc = torch.nn.Linear(in_features, num_classes) if freeze_bn: for module in model.modules(): if isinstance(module, torch.nn.BatchNorm2d): module.eval() # 冻结running_mean/running_var更新 return model这段代码值得留意的是module.eval()的时机,它放在训练循环之前设置,训练过程中每一轮都要调用,不能只在模型初始化时设置一次。我一般把冻结BN的开关暴露成参数,数据量大到几千张tile以上时就把freeze_bn=False,让BN跟着数据集重新估计统计量;数据量只有三五百张时则保持冻结,减少训练波动。
3.3 训练参数怎么设:学习率、批大小、早停与类别权重
训练循环本身是标准PyTorch流程,但遥感二分类任务有几个参数需要特别较真。第一个是损失函数的选择,两类直接用CrossEntropyLoss就够了,不需要在最后一层加Sigmoid。第二个是类别权重,遥感场景下“有湖”的tile数量通常远少于“无湖”,用torch.utils.data.WeightedRandomSampler或者给损失函数传weight参数都能缓解。
import torch import torch.nn as nn from torch.utils.data import DataLoader from sklearn.metrics import accuracy_score model = create_resnet18(num_classes=2, freeze_bn=True) model = model.cuda() criterion = nn.CrossEntropyLoss(weight=torch.tensor([0.5, 2.0]).cuda()) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4) best_val_acc = 0 patience = 5 no_improve = 0 for epoch in range(30): model.train() train_loss = 0.0 for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() train_loss += loss.item() # 每个epoch结束验证一次 model.eval() val_preds, val_labels = [], [] with torch.no_grad(): for images, labels in val_loader: images = images.cuda() outputs = model(images) _, preds = torch.max(outputs, 1) val_preds.extend(preds.cpu().tolist()) val_labels.extend(labels.tolist()) val_acc = accuracy_score(val_labels, val_preds) print(f"Epoch {epoch}: train_loss={train_loss/len(train_loader):.4f}, val_acc={val_acc:.4f}") if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), "best_resnet18_lake.pth") no_improve = 0 else: no_improve += 1 if no_improve >= patience: print("早停触发,停止训练") break参数设定上,学习率用1e-4是我比较稳的起点,比ImageNet分类常用的1e-3更低,因为遥感域差异大,学习率太大会破坏预训练权重里已有的边缘和纹理特征。批大小32在ResNet18上是兼顾显存和梯度的合理值。weighted交叉熵的两个权重数值怎么定?先统计训练集CSV里正负样本数,再按“负样本数量/正样本数量”的比例近似。比如负样本800张、正样本200张,权重就设成[0.25, 1.0]左右,不用极限拉满,给4倍左右即可。
4. 遥感湖泊二分类最常见的4个坑
4.1 现象:Loss在降,验证集却全预测成“有湖”
这个坑非常典型。Loss从0.69降到0.6左右就卡住不动了,验证集输出几乎全为正类。原因几乎总是类别失衡——如果训练集里“无湖”tile只占10%,模型学到的最优策略是“永远预测有湖”,因为能拿到90%的准确率,但这对业务没有任何意义。
解决分两步。第一步是修正损失权重,按正负样本比例调CrossEntropyLoss(weight=...)。第二步是检查验证集本身是否也失衡,如果验证集正样本占90%,那么“全预测为正”的准确率就是90%,模型根本没有学到“无湖”的特征。我一般会把验证集构造成均衡样本,正负各50%,这样准确率参考性才强,模型也必须真实区分两类才能拿到高分。
4.2 现象:训练集准确率99%,验证集掉到70%
这是典型的过拟合,而且遥感tile上表现得比自然图像更剧烈。原因不复杂:tile数量太少,通常只有几百张,模型直接背诵了训练图。但很多人忽略的一个因素是增强不够。遥感图像和自然图像不一样,湖水的形态对旋转不敏感,你可以放心大胆地做90度旋转、上下翻转、随机裁剪,这不会破坏语义。色彩抖动要谨慎,色相抖动过大会把水体颜色改得不像水体,反而学错特征。
解决时我一般把增强策略定义为:随机水平翻转+随机垂直翻转+随机90度旋转。这三个操作对遥感识别任务来说几乎无副作用,然后视过拟合程度决定是否加入轻微亮度对比度变化。另外数据集划分上,确保train里的区域和val不重叠,细节见前面章节。
4.3 现象:模型把山体阴影当成湖,误报率居高不下
这个现象在做山地区域遥感湖泊识别时尤其明显。单时相、单影像的情况下,深色山体阴影和水体的光谱特征非常相似,深度学习模型在这两类像素上学到的特征几乎一样。验证时你会发现错误样本集中在地形起伏大的区域。
我用过最有效的手段是增加一个NDWI判断前置。NDWI是归一化水体指数,公式为(Green - NIR) / (Green + NIR),水体在绿波段反射较高、近红外波段反射较低,山体阴影在两个波段上数值都偏低,二者在NDWI上区分度远好于RGB。具体做法:预处理阶段用NDWI先做一轮粗筛,把NDWI局部最大值低于阈值的tile直接标记为“无湖”,只把疑似含水的tile送入ResNet训练。这样模型从源头上少了一半误判压力。
4.4 现象:同样的训练代码跑两次,验证集AUC差5个百分点
这种情况最玄学,模型训练代码完全一样,随机种子没设,两次实验AUC从0.85漂到0.91。罪魁祸首是空间自相关下的随机划分和随机初始化双重影响。遥感tile相邻区域高度相似,随机划分一旦让某块湖泊密集区的tile落到验证集,这一次实验的指标就是虚高的。应对方法:第一,固定随机种子,PyTorch里设置torch.manual_seed(42)配合numpy.random.seed(42);第二,按影像来源分组划分数据集,不要全局随机切分。
这里还有一个容易被漏掉的细节点:DataLoader的num_workers参数在多进程下会引入额外的随机性,固定种子也不一定能完全复现,所以对比实验时保持num_workers一致,不要改一次跑一次。
5. 验证技巧与进阶:将阈值与Grad-CAM结合
训练结束并不能直接交付,一个只输出准确率的报告很难支撑决策。对遥感二分类来说,只看准确率是有欺骗性的,因为正负样本比例不均衡时,准确率指标会被多数类带跑。我习惯在验证阶段输出三个指标:精确率、召回率、AUC。AUC尤其关键,它不依赖阈值,直接反映模型在全部可能阈值下区分正负类的能力。
from sklearn.metrics import roc_auc_score, classification_report # 用softmax概率而不是argmax结果去算AUC prob_outputs = torch.softmax(outputs, dim=1)[:, 1].cpu().numpy() auc = roc_auc_score(val_labels, prob_outputs) print(f"AUC: {auc:.4f}") # 计算精确率和召回率,默认阈值0.5 print(classification_report(val_labels, val_preds, target_names=["无湖", "有湖"]))如果AUC高但业务上希望误报少一些,就需要调阈值。不要死守0.5,把阈值从0.5往下调到0.3,召回率会上升但误报也可能增加;往上调到0.7则相反。这个阈值选择本质上取决于项目的代价偏好,宁可漏报还是宁可误报,由业务场景决定的。
更进一步,我强烈建议用Grad-CAM看看模型到底在看什么。遥感tile里模型可能学的是“有深色区域就是湖”,如果Grad-CAM激活图落在了山体阴影上,说明模型并没有真正学到水体特征。这个检查往往能揭示单靠指标看不出来的模型缺陷,也直接影响后面的业务决策。我自己习惯是每个项目在训练完最后一批参数后,固定一批错误样本目录,来回扫一遍Grad-CAM热力图,再决定是否迭代数据。希望帮到你。
本文还有配套的精品资源,点击获取