ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

遥感影像湖泊有无识别:基于ResNet的图像分类二分类实践

遥感影像湖泊有无识别:基于ResNet的图像分类二分类实践 简介面向遥感图像分类初学者的 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_size256, stride256): os.makedirs(out_dir, exist_okTrue) 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, ftile_{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%”但业务上一塌糊涂的常见原因。所以我一般默认stridetile_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不依赖ImageFolderPyTorch教程里最常见的图像分类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, transformNone): 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_classes2, freeze_bnTrue): # 新版torchvision用weights参数老版本用pretrainedTrue try: model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) except TypeError: model models.resnet18(pretrainedTrue) # 替换全连接层二分类输出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_bnFalse让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_classes2, freeze_bnTrue) model model.cuda() criterion nn.CrossEntropyLoss(weighttorch.tensor([0.5, 2.0]).cuda()) optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4) 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(fEpoch {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, dim1)[:, 1].cpu().numpy() auc roc_auc_score(val_labels, prob_outputs) print(fAUC: {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热力图再决定是否迭代数据。希望帮到你。本文还有配套的精品资源点击获取
返回列表