ISDANet:交互式与监督双模式注意力的遥感变化检测

张开发
2026/5/3 9:00:52 15 分钟阅读
ISDANet:交互式与监督双模式注意力的遥感变化检测
前言遥感变化检测就是给定同一地区在两个时间点拍摄的遥感图像判断哪里发生了变化。比如一块空地几年后变成了建筑群或者道路扩建了或者植被发生了明显变化。这类任务在城市规划、灾害评估、生态监测里都很重要。但问题是这件事远没有把两张图做差那么简单。论文地址Interactive and Supervised Dual-Mode Attention Network for Remote Sensing Image Change Detection代码仓库RenHongjin6/ISDANet: Codes for ISDANet我创建了一个用于复现变化检测模型的仓库https://github.com/Auorui/CDLab真正的差异变化遥感图像变化检测的核心难点不在于提取特征本身而在于如何让模型真正理解“两时相图像之间的关系”并且把注意力集中在真实变化区域而不是季节、光照、阴影、纹理差异这些伪变化上。两张遥感图像拍摄于不同时间往往会带来很多干扰因素比如季节变化导致植被颜色不同光照角度变化导致阴影不同云层、雾气、传感器噪声带来的伪差异以及地表纹理在不同成像条件下表现不一致。面对上面这种情况模型如果只是比较像素的差异或者是分别提取各自特征再相减很容易造成误判。今天我们要学习的ISDANet就是要建模两个时间点特征之间更深层的交互关系。论文里面也对已有的方法进行了评判CNN方法擅长局部但却不擅长全局关系看的见局部不同但不一定知道这个不同是不是处于一个真正变化的语义区域里面Transformer等方法做到比较浅层比如简单通道堆叠、通道对齐、只交互query的单向交互没有深入联系解码阶段对于小目标和边界不太友好特别是建筑边缘、小尺度目标细碎变化区域。针对上面的这些问题提出了ISDANet整个模型主要由三个核心模块组成NFAM用于多尺度特征融合IAEM进行双时相深度交互SAM带监督的注意力进行解码。邻域特征聚合模块NFAM在许多的变化检测方法当中有一个很常见的默认前提是backbone 提出来的多尺度特征已经足够好了可以直接拿去做变化检测。作者这里认为这一步其实还能再优化以mobileNetV2为例论文当中用的是它作为backbone我们知道低层的特征细节丰富但噪声多而高层特征语义信息枪但细节又丢失的多这些特征相互独立没有交互融合。NFAM的作用就是在双时相交互之前现在单个时间点内部把不同尺度的特征融合起来如下图模型图所示它将相邻层的特征进行融合起到让低层特征补语义高层特征补细节并把相邻层特征融合得到更完整的多尺度表示。而NFAM的具体构建图如下所示整个过程十分清晰下面这么多是因为计算了backbone的四个阶段最上面一条支路经过 MaxPool 降采样再卷积提取低层局部信息同时减少尺寸匹配问题。中间一条支路将低层和相邻高层特征拼接形成邻近特征融合后对拼接后的特征进行卷积融合生成融合特征最下面一条支路仅做了1x1卷积处理相当有是调整了一下通道数将这三条支路通过相加的方式融合。给我的感觉很像QKV的那种感觉但这个本质上是属于局部的特征融合。class NFAM(nn.Module): def __init__(self, in_dNone, out_d64): super(NFAM, self).__init__() if in_d is None: in_d [16, 24, 32, 96, 320] self.in_d in_d self.mid_d out_d // 2 self.out_d out_d # scale 1 self.conv_scale1_c1 nn.Sequential( nn.MaxPool2d(kernel_size2, stride2), nn.Conv2d(self.in_d[0], 32, kernel_size3, stride1, padding1,), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue) ) self.conv_scale1_c2 nn.Sequential( nn.Conv2d(self.in_d[1], 32, kernel_size1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.Conv2d(32, 32, kernel_size3, stride1, padding1, ), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue) ) self.conv_s1 nn.Sequential( nn.Conv2d(64, 32, kernel_size1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.Conv2d(32, 32, kernel_size3, stride1, padding1, ), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue) ) # scale 2 self.conv_scale2_c2 nn.Sequential( nn.MaxPool2d(kernel_size2, stride2), nn.Conv2d(self.in_d[1], 64, kernel_size3, stride1, padding1,), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue) ) self.conv_scale2_c3 nn.Sequential( nn.Conv2d(self.in_d[2], 64, kernel_size1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, kernel_size3, stride1, padding1, ), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue) ) self.conv_s2 nn.Sequential( nn.Conv2d(128, 64, kernel_size1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, kernel_size3, stride1, padding1, ), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue) ) # scale 3 self.conv_scale3_c3 nn.Sequential( nn.MaxPool2d(kernel_size2, stride2), nn.Conv2d(self.in_d[2], 128, kernel_size3, stride1, padding1,), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue) ) self.conv_scale3_c4 nn.Sequential( nn.Conv2d(self.in_d[3], 128, kernel_size1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.Conv2d(128,128, kernel_size3, stride1, padding1, ), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue) ) self.conv_s3 nn.Sequential( nn.Conv2d(256, 128, kernel_size1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.Conv2d(128,128,kernel_size3,padding1,stride1,), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), ) # scale 4 self.conv_scale4_c4 nn.Sequential( nn.MaxPool2d(kernel_size2, stride2), nn.Conv2d(self.in_d[3], 256, kernel_size3, stride1, padding1,), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue) ) self.conv_scale4_c5 nn.Sequential( nn.Conv2d(self.in_d[4], 256, kernel_size1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Conv2d(256, 256, kernel_size3, stride1, padding1, ), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue) ) self.conv_s4 nn.Sequential( nn.Conv2d(512, 256, kernel_size1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Conv2d(256, 256, kernel_size3, padding1, stride1, ), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), ) self.relu nn.ReLU(inplaceTrue) self.conv1 nn.Sequential(nn.Conv2d(24,32,kernel_size1)) self.conv2 nn.Sequential(nn.Conv2d(32,64,kernel_size1)) self.conv3 nn.Sequential(nn.Conv2d(96,128, kernel_size1)) self.conv4 nn.Sequential(nn.Conv2d(320,256, kernel_size1)) def forward(self,c1, c2, c3, c4, c5): # scale 1 c1_s1 self.conv_scale1_c1(c1) # [4,32,64,64] c2_s1 self.conv_scale1_c2(c2) # [4,32,64,64] c1_c2 torch.cat([c1_s1,c2_s1],dim1) s1 self.conv_s1(c1_c2) #[4,32,64,64] c2_conv self.conv1(c2) # [4,32,64,64] s1 self.relu(s1c1_s1c2_conv) # [4,32,64,64] # scale 2 c2_s2 self.conv_scale2_c2(c2) # [4,64,32,32] c3_s2 self.conv_scale2_c3(c3) # [4,64,32,32] c2_c3 torch.cat([c2_s2,c3_s2],dim1) s2 self.conv_s2(c2_c3) #[4,64,32,32] c3_conv self.conv2(c3) # [4,64,32,32] s2 self.relu(s2c2_s2c3_conv) # [4,64,32,32] # scale 3 c3_s3 self.conv_scale3_c3(c3) #[4,128,16,16] c4_s3 self.conv_scale3_c4(c4) #[4,128,16,16] c3_c4 torch.cat([c3_s3,c4_s3],dim1) s3 self.conv_s3(c3_c4) #[4,128,16,16] c4_conv self.conv3(c4) #[41281616] s3 self.relu(s3c3_s3c4_conv) #[4,128,16,16] # scale 4 c4_s4 self.conv_scale4_c4(c4) #[4,256,8,8] c5_s4 self.conv_scale4_c5(c5) # [4,256,8,8] c4_c5 torch.cat([c4_s4,c5_s4],dim1) s4 self.conv_s4(c4_c5) #[4,256,8,8] c5_conv self.conv4(c5) #[4,256,8,8] s4 self.relu(s4c4_s4c5_conv) #[4,256,8,8] return s1, s2, s3, s4 # [4,32,64,64] # [4,64,32,32] # [4,128,16,16] # [4,256,8,8]交互注意力增强模块IAEM在很多的变化检测方法当中会做一些差分的方法例如特征相减、拼接后卷积、差异图提取但这些方法都是默认了一个前提就是我只要将两个时间点的特征提取出来再进行一比较就能知道变化在哪里。我们只要看过双时相图像的数据就知道这是不对的正确的做法应该是让 T1 和 T2 的特征先彼此感知、彼此引导、彼此校正然后再判断变化。IAEM就是把 self-attention 和 cross-attention 更紧密地统一起来形成一种双向、互补的交互机制。意思是它不是只让 T1 去看 T2也不是只让 T2 去看 T1而是让两者在 query 和 key 的层面做更充分的双向互动。这样做的原因是在真实变化检测里判断变化往往不是单边完成的。例如一片区域在 T2 看起来像新建筑但只有结合 T1 中它原来的状态模型才能确认这是真变化而不是纹理误判。相当于是对两个时间点的特征共同参与注意力建模互相提供参照。其整体结构如下图所示模块接收两路输入T1和T2特征分别计算双向的交叉注意力与自注意力最终各自输出增强后的特征。对于每一路输入IAEM 分别提取两组投影query投影C/8 维度q1 self.query1(input1) # [B, C/8, H, W] q2 self.query2(input2) # [B, C/8, H, W]Key 投影C/4 维度k1_ self.key1_(input1) # [B, C/4, H, W] k2_ self.key2_(input2) # [B, C/4, H, W]值得注意的是Query 和 Key 都采用了逐点卷积 → 深度可分离卷积 → 逐点卷积的三段式结构即代码中的 Conv1x1→DWConv→Conv1x1这样的设计一方面降低了计算量另一方面能够在通道压缩的同时保留空间上的局部感受野信息。然后将 T1 和 T2 的 Query 在通道维度拼接后形成联合 Query 矩阵然后分别与 T1 和 T2 各自的 Key 做矩阵乘法计算注意力图# 联合 Query 拼接 q torch.cat([q1, q2], 1).view(batch_size, -1, height * width).permute(0, 2, 1) # [B, N, C/4] # 交叉注意力图 attn_matrix1_k self.softmax(torch.bmm(q, k1_)) # T1 的注意力图 attn_matrix2_k self.softmax(torch.bmm(q, k2_)) # T2 的注意力图这里的关键在于联合 Query 同时包含了 T1 和 T2 的语义信息因此它与 k1_ 做注意力时T2 的信息也参与了对 T1 的位置响应建模同理对 k2_ 也是如此。这就实现了真正意义上的双向互参。除了上述的Q→K路径IAEM 还设计了一条对称的K→Q路径进一步丰富注意力的建模方式# 联合 Key 拼接 k torch.cat([k1, k2], 1).view(...).permute(0, 2, 1) # [B, N, C/4] # 反向注意力图 attn_matrix1_q self.softmax(torch.bmm(k, q1_)) # [B, N, N] attn_matrix2_q self.softmax(torch.bmm(k, q2_)) # [B, N, N]这条路径的含义是以联合 Key 作为参照以各自的 Query 作为探针从另一个角度再次建模两路特征之间的关联关系。两条注意力路径相互补充使得模块能够捕捉到更丰富、更全面的跨时相依赖。得到四个注意力图后IAEM 利用各自的 Value 向量完成特征聚合v1 self.value1(input1).view(batch_size, -1, height * width) # [B, C, N] v2 self.value1(input2).view(batch_size, -1, height * width) # [B, C, N] # T1 特征的两路聚合结果 out1_k torch.bmm(v1, attn_matrix1_k.permute(0, 2, 1)) # 跨时相注意力增强 out1_q torch.bmm(v1, attn_matrix1_q.permute(0, 2, 1)) # 自适应补充注意力 # 拼接后 1x1 卷积融合并残差连接 out1 self.conv1x1(torch.cat([out1_k, out1_q], 1)) input1两路注意力结果在通道维度拼接后通过 1x1 卷积压缩回原始通道数最后加上输入的残差连接确保原始特征不会被破坏。T2 分支的处理流程与 T1 完全对称。通过这样的设计IAEM 不仅让 T1 能够感知 T2 的变化信息也让 T2 同步感知 T1 的历史状态从而在做特征差分之前双方的表达已经经过了充分的对齐与校正为后续的差分检测提供了更可靠的特征基础。监督注意力模块SAM在前面两个模块NFAM IAEM的配合下模型已经完成了多尺度特征融合、双时相深度交互以及差分特征提取。但这个时候得到的差分特征还比较粗糙还有边界不清晰小目标容易丢失高层语义和低层细节融合不顺滑。这些都是SAM需要解决的问题在解码阶段通过带监督信号的注意力机制逐层精细化差分特征。这里一条支路经过深度可分离卷积DSConv提取局部细节再注意力另一条支路直接进行注意力两条支路进行拼接。最终返回了两个值out正常的输出特征继续参与解码deep_flow监督分支直接接分类头输出辅助损失这就是所谓的深监督不只让最终输出层学习而是让中间层也直接接受梯度监督让注意力模块学得更稳定、更准确。class SupervisionAttentionModule(nn.Module): def __init__(self, in_channels): super(SupervisionAttentionModule, self).__init__() self.SA_Block CoordChannelAtt(in_channels) self.conv1 nn.Sequential( nn.Conv2d(in_channels, in_channels, kernel_size1), nn.BatchNorm2d(in_channels), nn.ReLU(inplaceTrue) ) self.conv1_ nn.Sequential( nn.Conv2d(2*in_channels, in_channels, kernel_size1), nn.BatchNorm2d(in_channels), nn.ReLU(inplaceTrue) ) self.dwconv nn.Sequential( nn.Conv2d(in_channels, in_channels, kernel_size3, stride1, padding1, groupsin_channels), nn.BatchNorm2d(in_channels), nn.ReLU(inplaceTrue), nn.Conv2d(in_channels, in_channels, kernel_size1), nn.BatchNorm2d(in_channels), nn.ReLU(inplaceTrue)) def forward(self, x): mask_a self.conv1(x) mask_a1 self.dwconv(mask_a) mask_a_ self.SA_Block(mask_a) mask_b_ self.SA_Block(mask_a1) mask torch.cat([mask_a_, mask_b_],1) mask_ self.conv1_(mask) mask_b1 self.dwconv(mask_) mask_b2 mask_a mask_b1 deep_flow mask_b2 out self.conv1(mask_b2) return out, deep_flow这里面还有一个关键之处CCA即是CoordChannelAtt这部分是SAM的核心注意力一部分是通道注意力Channel Attention另外一部分是坐标注意力Coordinate Attention应该是来源于https://arxiv.org/abs/2103.02907它的特别之处在于同时建模水平W和垂直H方向的空间信息然后经过卷积压缩、激活、分割最后生成水平和垂直两个方向的注意力图class CoordChannelAtt(nn.Module): def __init__(self, inp, reduction4): super(CoordChannelAtt, self).__init__() self.cha Channel_Attention(inp, reductionreduction) self.pool_h nn.AdaptiveAvgPool2d((None, 1)) self.pool_w nn.AdaptiveAvgPool2d((1, None)) mip max(8, inp // reduction) self.conv1 nn.Conv2d(inp, mip, kernel_size1, stride1, padding0) self.conv1_ nn.Conv2d(mip, inp, kernel_size1, stride1, padding0) self.conv nn.Conv2d(inp, inp, kernel_size1, stride1, padding0) self.bn1 nn.BatchNorm2d(mip) self.act h_swish() self.sigmoid nn.Sigmoid() def forward(self, x): identity x cha self.cha(x) n, c, h, w x.size() x_h self.pool_h(x) x_w self.pool_w(x).permute(0, 1, 3, 2) y torch.cat([x_h, x_w], dim2) y self.conv1(y) y self.bn1(y) y self.act(y) y self.conv1_(y) x_h, x_w torch.split(y, [h, w], dim2) x_w x_w.permute(0, 1, 3, 2) a_h self.sigmoid(x_h) # CxHx1 a_w self.sigmoid(x_w) # Cx1xW out identity * a_w * a_h out out cha return out这里的作用就是能够影响通道水平/垂直空间位置信息使其对边界的检测更友好。结论作者抓住变化不是静态特征提取问题而是双时相关系理解问题。围绕这个判断作者构建了一条很完整的技术路线先用 NFAM 先增强多尺度特征表达用 IAEM 深化双时相特征交互再用 SAM 在解码阶段通过监督式注意力恢复边界和细节。先让特征更完整再让时相之间真正互动最后让注意力在监督下把变化画清楚。

更多文章