ARTICLE DETAIL

资讯详情

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

双时空语义推理与BiSRNet:遥感变化检测的PyTorch实现

双时空语义推理与BiSRNet:遥感变化检测的PyTorch实现 简介基于论文复现的双时空语义推理Bi-SR变化检测模型实现面向具备机器学习基础、熟悉卷积神经网络的研发者与算法工程师解决高分辨率遥感影像中不同时相地表语义变化检测与追踪问题。资源包内仅1个docx文档约38KB却完整囊括了Python/PyTorch编码实现与逐段解释覆盖环境配置、SECOND数据集加载、SiamSRBlock与SSCDl等核心模块构建、训练与评价指标实现并延伸出超参数调整技巧、多GPU训练效率提升、模型融合策略等高级主题。全文以可运行代码加注释的方式呈现不仅帮助读者打通从数据处理到模型推理、再到精度评估的完整链路还穿插了代码管理、文档写作规范等工程实践建议可直接迁移到GIS土地覆盖监测、地表变化追踪等科研与工程项目中。当前已有190人学习是复现论文和落地应用的务实参考。1. 双时空语义推理为什么单时相模型解不了变化检测遥感影像语义变化检测这几年很尴尬单时相语义分割模型已经能跑到很高的 mIoU但真到了实际项目里业务要的不是“两张图各自分出来”而是“这两张图之间到底哪里变了、从什么变成了什么”。同一个地块从耕地变成建筑物如果分开预测前后两次的误差叠加变化区域往往被噪声淹没。双时空语义推理Bi-Temporal Semantic Reasoning的思路是让两个时相的特征在模型内部先互相“交换信息”再各自分类最后才做变化判别。这样一来变化检测不是两个独立分割结果的差值而是一个被语义一致性约束过的联合推理结果。BiSRNet 这类结构在 SECOND 数据集上的表现本质上是把三件事同时塞进一个网络T1 时相语义分割、T2 时相语义分割、两个时相之间的变化检测。这篇博文按“数据 → 核心模块 → 训练 → 评估”的路径把可运行的 PyTorch 代码拆开讲透重点放在 Siam-SR、Cot-SR 这些跨时相推理块的实现细节和调参经验上。适合已经能独立写分割模型、想往变化检测方向深入的工程师。2. SECOND 数据集的加载与预处理先解决“一对多”的数据组织问题2.1 文件路径的组织方式SECOND 数据集是西南交通大学发布的遥感变化检测基准包含 T1、T2 两个时相的高分辨率遥感影像以及对应的语义标签和变化标签。下载解压后每个样本实际上对应五个.npy文件而不是常规语义分割的一条图像一条标签。常见做法是先把训练集、测试集的路径整理成平行列表。这里的关键点是image_paths和label_paths的元素本身也是元组因为一个样本需要同时取出两个时相的影像和三个标签。train_image_paths [ (dataset/images_p1/A0001.jpg.npy, dataset/images_p2/A0001.jpg.npy), (dataset/images_p1/A0002.jpg.npy, dataset/images_p2/A0002.jpg.npy), ] train_label_paths [ (dataset/labels_p1/A0001.npy, dataset/labels_p2/A0001.npy, dataset/labels_change/A0001.npy), (dataset/labels_p1/A0002.npy, dataset/labels_p2/A0002.npy, dataset/labels_change/A0002.npy), ]不建议在__init__里直接写死路径更优雅的方式是把路径列表存成 JSON 或 CSV程序启动时读取。原因有两点一是换了机器或服务器后只需要改配置文件不用动代码二是 SECOND 官方发布的数据集目录结构在不同版本之间有差异硬编码路径会让后续扩展非常被动。2.2 Dataset 类实现注意张量维度和数据增强的分开处理数据读取的核心逻辑写在__getitem__里。一个容易踩的坑是.npy文件里图像的存储格式通常是H x W x C的uint8数组而 PyTorch 卷积层要求输入是C x H x W的浮点张量。permute(2, 0, 1)必须放在torch.tensor之后才符合HWC → CHW的正确语义。import torch import numpy as np from torch.utils.data import Dataset class SECONDataset(Dataset): def __init__(self, image_paths, label_paths, transformNone): self.image_paths image_paths self.label_paths label_paths self.transform transform def __len__(self): return len(self.image_paths) def __getitem__(self, idx): image1 np.load(self.image_paths[idx][0]) image2 np.load(self.image_paths[idx][1]) label1 np.load(self.label_paths[idx][0]) label2 np.load(self.label_paths[idx][1]) change_label np.load(self.label_paths[idx][2]) # HWC - CHW同时转为浮点型供 Conv2d 使用 image1 torch.tensor(image1, dtypetorch.float32).permute(2, 0, 1) image2 torch.tensor(image2, dtypetorch.float32).permute(2, 0, 1) # 标签保持 long 型供 CrossEntropyLoss 使用 label1 torch.tensor(label1, dtypetorch.long) label2 torch.tensor(label2, dtypetorch.long) change_label torch.tensor(change_label, dtypetorch.long) if self.transform: image1 self.transform(image1) image2 self.transform(image2) return image1, image2, label1, label2, change_label注意这里的transform只对影像做处理标签不参与。初学者常见的问题是直接把transforms.Normalize加到标签上导致标签值变成浮点小数torch.argmax和CrossEntropyLoss全部失效。另外一个细节是change_label虽然只有 0 和 1 两个值但不要提前转成float因为后面计算损失时BCEWithLogitsLoss会自己做 sigmoid 和类型转换。SECOND 数据集的增强策略需要额外小心T1 和 T2 必须使用完全相同的随机种子做几何变换否则两张图的尺寸、翻转方向不一致整个训练就是灾难。可以用如下方式保证同步def sync_transform(image1, image2, label1, label2, change_label, seed): torch.manual_seed(seed) # 这里放 RandomHorizontalFlip、RandomRotation 等几何变换 # 每次调用前固定 seed保证两个时相变换一致 return image1, image2, label1, label2, change_label # Dataset 内部使用时传入同一个 idx 作为种子 seed 42 idx这个同步机制在训练时必须打开在验证和测试时关掉。下面的表是实践中比较稳固的增强组合变换操作参数设置注意点RandomHorizontalFlipp0.5T1/T2 必须同翻转RandomVerticalFlipp0.5同上RandomRotationdegrees10旋转后的边缘填充值需一致ColorJitterbrightness0.2仅作用于影像不作用于标签3. BiSRNet 模型拆解Siam-SR、Cot-SR 与 SSCDl 的逐模块实现3.1 为什么要在分割网络里插入“语义推理”模块先看问题本质。标准的孪生网络结构是两个共享权重的编码器分别提取 T1 和 T2 的特征然后在某个位置拼接特征做变化检测。这种方式的问题在于T1 的特征提取过程完全不知道 T2 发生了什么变化两个分支只是在最后被强行拼在一起特征的语义对齐度不够。尤其是高分辨率遥感影像里同一个地物在 T1 是农田、在 T2 是建筑工地如果 T1 分支提取特征时没有 T2 侧的信息它会把“农田”的特征表达得非常彻底而忽视了这块区域未来要变成建筑物这一事实。Siam-SR单时相语义推理和 Cot-SR跨时相语义推理的组合就是要在特征层面先完成一次“语义联想”再做分割和变化判别。Siam-SR 负责把当前时相的语义信息用通道注意力强化Cot-SR 则用 T1 的特征去加权 T2 的特征同时用 T2 的特征去加权 T1 的特征形成双向语义引导。3.2 SiamSRBlock 与 CotSRBlock 的代码实现来看SiamSRBlock。它的本质是一个 SE-Net 风格的通道注意力模块先全局平均池化压缩空间信息再用两个全连接层生成通道权重最后把权重乘回原特征图。这里reduction参数控制压缩比例默认取 2意味着中间层的通道数是输入的 1/2可以控制计算量增幅。import torch import torch.nn as nn class SiamSRBlock(nn.Module): def __init__(self, in_channels, reduction2): super(SiamSRBlock, self).__init__() self.avg_pool nn.AdaptiveAvgPool2d(1) self.fc nn.Sequential( nn.Linear(in_channels, in_channels // reduction, biasFalse), nn.ReLU(inplaceTrue), nn.Linear(in_channels // reduction, in_channels, biasFalse), nn.Sigmoid() ) def forward(self, x): b, c, _, _ x.size() y self.avg_pool(x).view(b, c) y self.fc(y).view(b, c, 1, 1) return x * y.expand_as(x)这段代码有两点值得细看。第一view(b, c)将池化后的B x C x 1 x 1张量压成二维是为了送入全连接层第二expand_as并不复制内存而是返回一个广播视图所以不会带来额外的显存开销。reduction越大中间全连接层的参数越少但特征压缩得越狠信息损失也越大对于 SECOND 这种 128 通道的中等规模特征图取 2 是性价比最高的。CotSRBlock的实现思路是交叉特征重标定。它有两个独立的全连接分支分别处理 T1 和 T2 的池化特征但关键步骤在后面T1 的特征权重被乘到 T2 上T2 的权重被乘到 T1 上。这就在特征层面完成了跨时相的信息交换相当于让 T1 的每个通道知道 T2 在哪些通道上更显著。class CotSRBlock(nn.Module): def __init__(self, in_channels): super(CotSRBlock, self).__init__() self.avg_pool1 nn.AdaptiveAvgPool2d(1) self.avg_pool2 nn.AdaptiveAvgPool2d(1) self.fc1 nn.Sequential( nn.Linear(in_channels, in_channels // 2, biasFalse), nn.ReLU(inplaceTrue), nn.Linear(in_channels // 2, in_channels, biasFalse), nn.Sigmoid() ) self.fc2 nn.Sequential( nn.Linear(in_channels, in_channels // 2, biasFalse), nn.ReLU(inplaceTrue), nn.Linear(in_channels // 2, in_channels, biasFalse), nn.Sigmoid() ) def forward(self, x1, x2): b, c, _, _ x1.size() y1 self.avg_pool1(x1).view(b, c) y1 self.fc1(y1).view(b, c, 1, 1) y2 self.avg_pool2(x2).view(b, c) y2 self.fc2(y2).view(b, c, 1, 1) # 交叉相乘T1 特征用 T2 的权重校准 x1 x1 * y2.expand_as(x1) x2 x2 * y1.expand_as(x2) return x1, x2CotSRBlock没有引入复杂的注意力机制或图结构只是用两个全连接层实现了跨时相通道交互。对比 transformer-based 的时序建模这种设计的优势是轻量、稳定、对训练数据量要求低。SECOND 数据集的规模并不大强行上 Transformer 容易过拟合Cot-SR 的归纳偏置反而能帮模型收敛得更稳。3.3 SSCDl 与 BiSRNet 的完整组装SSCDl 的结构是一个典型的双编码器加双分类器设计。两个编码器不共享权重各自处理一个时相的影像。编码器输出 128 通道的特征图分别送入两个分类器做语义分割同时两个特征图在通道维度拼接成 256 通道经过一个 CD 单元输出变化图。这里 CD 单元最后用Conv2d(128, 1, kernel_size1)输出单通道 heatmap配合BCEWithLogitsLoss做二分类变化检测。class SSCDl(nn.Module): def __init__(self, num_classes): super(SSCDl, self).__init__() self.encoder1 nn.Sequential( nn.Conv2d(3, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(64, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2) ) self.encoder2 nn.Sequential( nn.Conv2d(3, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(64, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2) ) self.cd_unit nn.Sequential( nn.Conv2d(256, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(128, 1, kernel_size1) ) self.classifier1 nn.Conv2d(128, num_classes, kernel_size1) self.classifier2 nn.Conv2d(128, num_classes, kernel_size1) def forward(self, image1, image2): feature1 self.encoder1(image1) feature2 self.encoder2(image2) combined_feature torch.cat([feature1, feature2], dim1) change_map self.cd_unit(combined_feature) semantic_map1 self.classifier1(feature1) semantic_map2 self.classifier2(feature2) return semantic_map1, semantic_map2, change_map这个实现是论文的简化版。原论文的基础骨干网络更深通常用 ResNet18 或 ResNet34 替换这里的手写 encoder并把最大池化换成步长为 2 的卷积做下采样。如果你的显存足够建议直接把encoder1/encoder2替换为torchvision.models.resnet18去掉最后的全连接层前向速度会更快精度也会有小幅提升。替换时注意保持双分支结构不能两个时相共享同一个 encoder 实例因为 SECOND 的两个时相影像虽然尺寸一致但色调、光照都有差异不共享权重可以给每个时相更自由的适配空间。BiSRNet的完整前向过程分三条路径SSCDl 产出两个语义分割预测和一张变化图语义图分别经过 SiamSRBlock 增强增强后的特征再进入 CotSRBlock 做跨时相交互。交互后的特征其实没有直接参与分类它的作用是通过损失函数反传到 SSCDl 的编码器逼迫特征提取阶段就注意到跨时相差异。class BiSRNet(nn.Module): def __init__(self, num_classes): super(BiSRNet, self).__init__() self.sscdl SSCDl(num_classes) self.siam_sr1 SiamSRBlock(128) self.siam_sr2 SiamSRBlock(128) self.cot_sr CotSRBlock(128) self.semantic_loss nn.CrossEntropyLoss(ignore_index0) self.change_loss nn.BCEWithLogitsLoss() self.scloss nn.CosineEmbeddingLoss() def forward(self, image1, image2, label1, label2, change_label): semantic_map1, semantic_map2, change_map self.sscdl(image1, image2) feature1 self.siam_sr1(semantic_map1) feature2 self.siam_sr2(semantic_map2) feature1, feature2 self.cot_sr(feature1, feature2) sem_loss1 self.semantic_loss(semantic_map1, label1) sem_loss2 self.semantic_loss(semantic_map2, label2) change_loss self.change_loss(change_map.squeeze(1), change_label.float()) scloss_labels torch.where( change_label 1, -torch.ones_like(change_label), torch.ones_like(change_label) ) scloss self.scloss(semantic_map1, semantic_map2, scloss_labels) total_loss (sem_loss1 sem_loss2) / 2 change_loss scloss return total_loss这段代码包含三个关键设计。第一个是semantic_loss的ignore_index0SECOND 数据集的 0 类通常是“不变区域”或“未标注区域”这些像素不参与语义分割的梯度回传如果不去掉模型会被大量无意义的 0 类像素带偏。第二个是scloss_labels的构造对于变化区域标签为 1余弦嵌入损失的目标值是 -1意味着两个时相的语义特征要被推远对于不变区域目标值是 1要求两个时相的特征向量方向一致。这个对比学习式的约束是 BiSRNet 区别于普通孪生网络的核心。第三个是三个损失的等权相加实践下来的效果是语义分割收敛快变化检测收敛慢建议给change_loss乘一个 2x 的权重系数让模型更重视变化边界的拟合。4. 多阶段训练策略与优化器调参损失函数正确还不够4.1 训练循环中的维度陷阱先看训练代码。input 的五个张量分别是 T1 影像、T2 影像、T1 标签、T2 标签、变化标签。这里最大的坑是change_map.squeeze(1)之后如果 batch size 为 1squeeze 会把 batch 维度也一并去掉导致BCEWithLogitsLoss直接报错。安全做法是用change_map[:, 0, :, :]替代squeeze(1)。def train_model(model, train_loader, optimizer, epochs): model.train() for epoch in range(epochs): running_loss 0.0 for i, (image1, image2, label1, label2, change_label) in enumerate(train_loader): optimizer.zero_grad() loss model(image1, image2, label1, label2, change_label) loss.backward() optimizer.step() running_loss loss.item() avg_loss running_loss / len(train_loader) print(fEpoch {epoch 1}, Loss: {avg_loss:.4f})训练时要留意num_workers的设置。.npy文件是随机读取的如果num_workers设置得太高磁盘 IO 跟不上DataLoader 的加载时间会掩盖 GPU 的计算时间导致 GPU 利用率长期在 20% 以下。多尝试num_workers4和pin_memoryTrue会对训练速度有明显改善。4.2 优化器配置和损失函数的收敛特性主程序里的优化器用的是optim.SGD(model.parameters(), lr0.1, momentum0.9)。这个学习率对于 batch_size8 的场景偏大训练初期容易出现 loss 为 NaN 的问题。常见做法是先用 0.01 做 5 个 epoch 的 warmup然后切换到 cosine annealing 或者 StepLR 逐步衰减。参数项推荐值原因说明base_lr0.01比原文 0.1 保守适合 batch_size 较小的情况momentum0.9标准配置weight_decay5e-4防止全连接层过拟合lr_schedulerCosineAnnealingLR长训练周期收敛更平稳batch_size16显存允许时优先加大 batch_size损失收敛的表现有一个明显特点训练前 10 个 epochsem_loss下降速度远快于change_loss。这是因为CrossEntropyLoss的梯度幅度天然比BCEWithLogitsLoss大尤其在类别不平衡时。如果你观察 loss 曲线发现 change loss 纹丝不动检查一下change_label的正负样本比例。SECOND 的变化区域通常只占整张影像的 10% 左右正负样本严重不均建议在BCEWithLogitsLoss里加上pos_weight参数按负样本数除以正样本数手动校正。4.3 多 GPU 训练与 BatchNorm 的冲突如果有多张显卡直接套nn.DataParallel会碰到一个问题BiSRNet的 forward 需要五个输入DataParallel 默认按第一个维度切分张量但label1、label2、change_label不需要被切分也完全正确。实际上切分语义标签并不会改变结果torch.nn.DataParallel会自动把所有输入的第一个维度统一切分。真正要注意的是ignore_index0配合 BatchNorm 在单卡上的统计量差异多卡训练时每张卡的 batch 变小BatchNorm 的 running_mean 和 running_var 更新会偏差典型表现是验证集 mIoU 上下抖动的幅度超过 5%。解决方式是固定前几个 epoch 的 backbone 层不更新让 BatchNorm 统计量先稳定。model BiSRNet(num_classes7) # 冻结 encoder 前两层先让分类器和推理模块收敛 for name, param in model.named_parameters(): if encoder1.0 in name or encoder1.2 in name: param.requires_grad False if encoder2.0 in name or encoder2.2 in name: param.requires_grad False这样做的好处是训练前期变化检测任务可以更快建立稳定的梯度信号等冻结层解冻后再以较小的学习率对全模型做微调。5. 评估指标的精确复算与复现排错5.1 FSCD 和 SeK 指标才是论文的亮点calculate_oa和calculate_miou是标准操作不再赘述。真正需要认真对待的是 FSCDF-measure for Semantic Change Detection和 SeK 指标。FSCD 的输出不是单一数值而是“变化区域正确检测”与“语义类别正确识别”的联合衡量它在 SCD 任务里扮演着综合精度指标的角色负责同步评估变化检测和语义分割的质量公式上通常同时考虑变化区域预测和语义归属是否正确。def calculate_fscd(semantic_map1, semantic_map2, label1, label2, change_label): 简化版 FSCD 核心思路只对预测为变化的像素计算语义一致性 pred1 torch.argmax(semantic_map1, dim1) pred2 torch.argmax(semantic_map2, dim1) # 变化区域真实标签 change_gt change_label 1 # 变化区域同时要求前后语义类别预测和标签都对应正确 correct1 (pred1 label1) change_gt correct2 (pred2 label2) change_gt # 变化区域预测为正的像素 change_pred ((pred1 ! pred2)) change_gt union change_gt.sum().item() change_pred.sum().item() intersection (correct1 correct2).sum().item() fscd (2 * intersection) / union if union 0 else 0.0 return fscd注意change_pred语义预测变化才视为正样本这个细节是 FSCD 与普通变化检测 F1 的核心区别只有变化一张图预测正确不算数两边的语义也要都对。SeK 指标在论文里的定义更加繁琐原代码给出的“相似度”示例是不可用的占位实现。复现时建议去精确阅读论文的公式部分如果只是为了对比实验性能可以先用 OA 和 mIoU 做主要指标。5.2 常见复现失败点和检查步骤训练时 loss 出现 NaN最常见的原因是影像数据没有归一化到[0, 1]区间。.npy文件的像素值如果是uint8那么torch.tensor(..., dtypetorch.float32)并不会自动缩放到[0, 1]必须手动除以 255 或使用transforms.Normalize。transform transforms.Compose([ transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])如果漏了ToTensor或归一化Conv2d的输入范围会达到 0-255第一层卷积输出的特征图数值爆炸BatchNorm 的统计量也随之失常。显存不足时的处理顺序是先降低 batch_size再考虑换用更小的输入尺寸原图512x512可以先用256x256验证流程实在不行再用 AMP 混合精度训练。在 SECOND 上实测混合精度不影响最终精度训练速度提升约 30%-40%显存占用下降约 20%。模型保存直接沿用torch.save(model.state_dict(), path)即可加载时务必先实例化BiSRNet(num_classes)再load_state_dict同时将模型切换到 eval 模式再评估否则 BatchNorm 的 dropout 统计量直接污染推理结果。本文还有配套的精品资源点击获取
返回列表