简介基于孪生自注意力网络的高光谱图像变化检测系统提供完整Python源码与配套实验数据面向遥感、计算机视觉方向的学生、研究者与开发者可用于毕业设计、课程设计、大作业及算法学习入门。压缩包共138个文件约174.68MB核心为30个Python源码与54个pyc编译文件另含pth权重、npy/mat数据、xml配置及说明文档等便于直接运行、调试和按模块二次开发。方案聚焦双时相高光谱影像的变化检测任务借助孪生结构提取特征差异同时利用自注意力机制建模光谱与空间关系能够帮助读者理解前沿模型的训练、验证和评估流程。目前已有176人学习下载代码与项目配置说明完整目录结构清晰便于按模块检索、复现与扩展改动适合作为算法入门与论文复现的实用参考。1. 高光谱变化检测为什么需要孪生自注意力网络同样一块地面隔几个月再看多了几栋房子、少了一片林子高光谱影像上每个像素都变成了几十上百个波段的曲线怎么把这些变化干净利落地找出来就是高光谱图像变化检测要解决的事。传统的做法——两幅影像直接做差、算比值或者扔进一个普通卷积网络里比较——在光谱维度和成像条件上都容易翻车大气、光照、传感器起伏造成的伪变化常常比真实变化还醒目。而标题里这个“孪生自注意力网络”用共享权重的双分支把两时相影像分别编码到同一个特征空间再靠自注意力去建模波段与波段之间的长程关系恰好把这几年变化检测里两个最核心的痛点的解法放进了同一个系统。这篇文会把这套网络从原理、数据切分讲到模型搭建和训练踩坑你拿到的这套Python源码和配套数据照着跑能还原一个完整的变化检测训练与推理流程。2. 先拆结构再说选型孪生分支和自注意力各解决什么问题2.1 孪生结构处理的是“同位置两时相”不是简单拼接变化检测的输入有两个视角时相一和时相二同一块地面、同一个位置。最直觉的建模方式是先各自提特征再比较。但这里有个容易犯的错如果两时相各走一个独立的卷积网络两个分支学到的特征分布并不对齐你拿两个“语言体系不同”的特征去做差数值上有多大差异根本不代表地物有多大变化。孪生网络Siamese Network的解法是让两幅影像走同一个权值共享的分支同一个编码器、同一套参数把两时相投影到一个共同的嵌入空间这样特征差异才真正反映“影像内容”的差异而不是“提取器偏好”的差异。除了特征对齐共享权重还有两个实打实的好处。第一是参数量直接减半高光谱影像波段多第一层卷积的参数量按波段数放大两个独立编码器的话显存压力很大而孪生结构只需保存一份主干参数第二是天然具备对称性f(x1, x2) 和 f(x2, x1) 应该给出相同的变化判断共享权重的双分支结构本身就满足这个对称性约束后面接任何差异度量都不会出现方向偏置。我见过不少初版实现为省事把两时相直接 concat 成一个双通道输入扔进网络最终模型基本都退化成了“记住两幅图的绝对亮度差”在不同成像条件下的测试数据上一碰就碎。差异融合层的选择也值得较真。常见的做法是直接做差或者取绝对值差更讲究一点的会把两分支输出做差之后再过一层 1x1 卷积让网络自己学该保留哪些差异通道。我一般会先试绝对差因为它比原始差值更稳定——原始差值里的负号在反向传播时会让梯度方向变得复杂而绝对值差表达“变化强度”更直接。下面是孪生分支共享权重的最小骨架负责让你先看到结构长什么样完整的可训练版本放到第 4 章import torch.nn as nn class SharedEncoder(nn.Module): 共享编码器两时相影像各过一遍权值完全相同 def __init__(self, in_bands, hidden32): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_bands, hidden, 3, padding1, biasFalse), nn.BatchNorm2d(hidden), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) encoder SharedEncoder(in_bands100) f1 encoder(x1) # 时相一 f2 encoder(x2) # 时相二同一套权重 diff (f1 - f2).abs()这段代码里最关键的就是“同一个 encoder 实例被调用两次”PyTorch 里模型实例复用自然就是权值共享不需要额外操作。如果手滑写成 encoder1 和 encoder2 两个实例两套初始化权重不同反向传播后就变成了非共享的双流网络特征对齐的优势就没了。hidden 参数决定中间特征图的通道数高光谱波段数 100 左右时取 32 比较均衡太小特征容量不够太大显存涨得快。2.2 自注意力在高光谱上抓的是波段间的长程依赖高光谱影像的维度困境在于一个像素有几十上百个波段相邻波段高度相关而真正能区分地物变化的特征往往藏在相隔较远的波段组合里。常规卷积核在光谱维的感受野是局部的要靠堆叠很多层才能间接看到远端波段而且卷积核权重是内容无关的同一组系数作用在植被曲线和建筑曲线上灵活性有限。自注意力不一样它在做的是“内容相关的特征交互”每个像素的波段向量先去查其他波段向量跟自己有多相关再按相关性加权聚合信息。注意力权重是随输入变化的天然适合光谱形态差异很大的高光谱数据。另外一个工程上的考量是迁移性。CNN 在高光谱上的第一层卷积参数量随波段数线性增长换一个传感器、波段数从 100 变到 200网络得从头训而自注意力里 q/k/v 的线性投影通常先做降维波段数变化时只需要改输入维度结构不用动。这就是为什么现在遥感变化检测的主流结构越来越多地把自注意力块加进来而不是继续往下堆卷积。下面这个是我在多个高光谱项目里反复用的光谱维自注意力模块注意力矩阵的大小由波段数决定、与图像面积无关这是它适合高光谱的关键import torch import torch.nn as nn class SpectralSelfAttention(nn.Module): 光谱维自注意力每个像素的波段向量作为 token注意力矩阵 C x C 计算量由波段数决定与 patch 面积无关。 def __init__(self, bands, reduction4): super().__init__() hidden max(bands // reduction, 8) self.q nn.Linear(bands, hidden, biasFalse) self.k nn.Linear(bands, hidden, biasFalse) self.v nn.Linear(bands, bands, biasFalse) self.scale hidden ** -0.5 def forward(self, x): B, C, H, W x.shape x x.permute(0, 2, 3, 1) # B, H, W, C q self.q(x) # B, H, W, hidden k self.k(x) # B, H, W, hidden v self.v(x) # B, H, W, C attn torch.einsum(bhwd,bhwe-bhde, q, k) * self.scale # B,H,W,C,C attn torch.softmax(attn, dim-1) out torch.einsum(bhde,bhwe-bhwd, attn, v) return out.permute(0, 3, 1, 2)这个模块的注意力矩阵是 C×C也就是波段数和波段数之间的相关性矩阵每个空间位置的像素独立做一次波段间注意力。scale 用的是 hidden 维度的 -0.5 次方作用是防止 q 和 k 内积结果太大把 softmax 推到饱和区。reduction 是 q/k 投影的降维倍数设为 4 时能把注意力计算量压到原来的 1/4代价是相关性建模的粒度变粗波段数超过 150 时我一般会额外做一步主成分压缩而不是单纯把 reduction 调大因为降维太多 q/k 的区分度会明显下降。2.3 三种结构的选型对比为什么最终落在孪生加自注意力上把常见的三种做法摆在一起看选型逻辑会更清楚。双流 CNN 不共享权重各自负责一个时相优点是灵活、每个分支可以专门适配自己的成像条件但这个灵活性在变化检测里反而是负担两时相特征空间不对齐差异度量的数值含义模糊孪生 CNN 共享权重大幅改善了这个问题但光谱维的长程依赖还是要靠堆深度来间接建模孪生加上自注意力则是把“特征对齐”和“波段长程关系”两个问题分给了不同的模块每个模块的任务都足够简单直接。结构两时相特征可比性光谱长程依赖建模参数量对小变化目标友好度双流 CNN不共享弱特征空间不对齐靠堆叠卷积间接2 倍主干参数依赖卷积局部感受野孪生 CNN好共享嵌入空间靠堆叠卷积间接1 倍主干参数同上孪生 自注意力好显式建模任意两波段关系1 倍主干 轻量注意力块光谱判别强空间细节保留这个对比里我要额外强调最后一行。自注意力加进去之后小变化目标会不会被“注意力平均化”吞掉是很多人担心的问题。实际测试中光谱维自注意力把波段关系建模清楚之后3x3 卷积提取局部空间特征patch 又只有 11x11 左右小目标的响应反而比纯 CNN 更干净因为光谱域的假变化被注意力机制压制了。3. 从 zip 里的原始数据到训练样本patch 提取、标准化与数据集划分3.1 高光谱数据进模型前要先过这三关打开你手头这份源码加数据的 zip里面大概率是 .mat 或 .tif 格式的高光谱影像外加一份像素级标注图。高光谱影像最常见的存储方式是“高 × 宽 × 波段数”的三维数组标注图是和影像同尺寸的单通道图变化像素标 1、未变化标 0。拿到数据后的第一件事不是写网络而是先检查三个问题两时相影像是否严格配准两时相的亮度分布差异有多大标注图里变化像素的占比有多低。这三个问题任何一个没处理好后面模型训练得再精心指标都会很难看。配准问题最隐蔽。变化检测的样本是 x1 和 x2 在同一位置的 patch如果两时相影像在实际空间中偏移了两三个像素那 patch 里的地物根本没对上模型只能学到“配准误差导致的伪变化”。检查方法很简单任选一个地物边缘明显的区域把两时相的灰度图叠加或者用滑动互相关看一眼峰值是否在中心位置。误差在两个像素以内的靠训练时随机抖动 x2 几个像素能缓解误差再大就要用控制点做几何校正这不是模型能扛的事。标准化是第二关也是最容易被写错的一环。高光谱每个波段的量纲和动态范围都不同有些波段反射率集中在 0.1 附近有些波段能到 0.9直接喂给模型会把注意力全部集中在幅值大的波段上。常见做法是对每个波段独立做 z-score 标准化也就是减均值除以标准差。但这里有个关键约束标准化用的均值和标准差只能从训练集影像上统计验证集和测试集复用同一套参数不能全图一起算完再切分。否则验证集的信息已经泄漏进训练流程最后的指标会虚高到了新数据上立刻现原形。import numpy as np def zscore_by_band(img, meanNone, stdNone): 按波段做标准化。mean/std 为 None 时从当前影像统计否则复用传入值。 if mean is None or std is None: mean img.mean(axis(0, 1), keepdimsTrue) std img.std(axis(0, 1), keepdimsTrue) std[std 1e-8] 1.0 # 空波段保护防止除零 return (img - mean) / std # 训练时 img1_train_norm zscore_by_band(img1_train) img2_train_norm zscore_by_band(img2_train) mean1, std1 img1_train.mean(axis(0,1)), img1_train.std(axis(0,1)) mean2, std2 img2_train.mean(axis(0,1)), img2_train.std(axis(0,1)) # 验证/测试时复用训练集的统计量 img1_val_norm zscore_by_band(img1_val, mean1, std1) img2_val_norm zscore_by_band(img2_val, mean2, std2)注意上面对两时相的标准化参数是分开统计的没有强制统一。很多人会觉得变化检测应该把两时相拉到完全相同的亮度分布但实际操作中成像时间不同、太阳高度角不同同一样地物的辐射值本来就会有系统性偏移。如果强制用全局统一参数硬压反而会把真实的辐射变化也抹掉一部分。正确姿势是各自独立标准化把每幅影像内部的波段分布拉齐保留两时相之间的相对差异这个相对差异才是变化检测的信号来源。3.2 滑窗取 patch中心像素标签与重叠策略深度变化检测几乎都是 patch 级输入。原因很直接整幅高光谱影像动辄上千乘上千乘上百直接整图过网络先不说显存单是边界 padding 就够让人头疼。滑窗取块的逻辑是以每个要预测的像素为中心裁一个 11×11 或 15×15 的小方块把两时相的小方块作为网络输入输出中心像素的变化类别。窗口大小不是越大越好patch 太大时中心像素对应的地物只占小部分周围地物的光谱会把中心信息“稀释”掉太小则上下文不够变化边界附近的像素很难分清楚。8×8 到 15×15 之间是我常用的区间。这里有个一直有人踩的坑标签到底怎么定。如果裁出来的 patch 内所有像素都恰好是同一类地物那整块区域的标签一致训练起来很省事但高光谱影像的实际标注图里变化边界往往是像素级锯齿状的一个大 patch 里常常既包含变化像素又包含非变化像素。比较规范的做法是只用中心像素的标签来监督该 patchpatch 只负责提供上下文信息。另一个选择是整个 patch 逐像素都算损失但这样相邻重叠 patch 同一个像素会被重复计算很多次梯度被重复叠加训练动态会偏掉。def extract_patches(img1, img2, gt, patch11, stride5): 滑窗提取 patch 对标签取窗口中心像素的标注。 返回数组可用 h5py 或 np.memmap 落盘避免全量驻留内存。 H, W, C img1.shape p1_list, p2_list, label_list [], [], [] for y in range(0, H - patch 1, stride): for x in range(0, W - patch 1, stride): p1_list.append(img1[y:y patch, x:x patch, :]) p2_list.append(img2[y:y patch, x:x patch, :]) label_list.append(gt[y patch // 2, x patch // 2]) return (np.stack(p1_list), np.stack(p2_list), np.array(label_list))stride 参数决定相邻 patch 的重叠程度。stride patch 时样本之间无重叠、样本量最小stride 取 patch 的一半甚至更小样本量近似翻四五倍但代价是相邻样本高度相似训练时会看到大量重复信息。我一般取 stride patch - 1既保证每个中心像素都能被覆盖到又不至于让样本量爆炸。落到文件这步建议用 np.memmap 或 h5py 写盘高光谱 patch 堆叠起来动辄几十 GB直接 list 内存大概率会 OOM。3.3 训练集、验证集和测试集怎么切才不算作弊变化检测数据集切分是个容易被忽视但后果很严重的问题。如果直接在全图上随机打乱 patch 再划分训练验证相邻 patch 之间的内容大面积重叠验证集里会有大量和训练集窗口几乎一样的样本。模型在验证集上跑出 99% 的准确率一换到新区域立刻掉到 80%这就是典型的空间数据泄漏很多初版源码翻车都翻在这里。正确的划分方式是按空间位置划分把整幅影像的坐标范围划分成若干网格按网格所属区域切分保证训练集和验证集覆盖的是地面上的不同区块而不是同一块地的一堆重叠窗口。def split_by_spatial_block(H, W, val_ratio0.15, test_ratio0.15): 按空间网格划分训练/验证/测试坐标块避免样本重叠导致的泄漏 grid_h, grid_w 8, 8 block_h, block_w H // grid_h, W // grid_w val_blocks set() test_blocks set() # 随机抽几个网格作为验证和测试剩下的都是训练 np.random.seed(42) all_blocks [(i, j) for i in range(grid_h) for j in range(grid_w)] np.random.shuffle(all_blocks) val_size max(1, int(len(all_blocks) * val_ratio)) test_size max(1, int(len(all_blocks) * test_ratio)) val_blocks.update(all_blocks[:val_size]) test_blocks.update(all_blocks[val_size:val_size test_size]) def sample_label(y, x): if (y // block_h, x // block_w) in val_blocks: return val if (y // block_h, x // block_w) in test_blocks: return test return train return sample_label网格分的块数不是固定的影像越大、网格分得越细每个块的空间独立性越强、切分越安全影像只有一两百行的话分 4×4 就够了。这里还要留意验证集里变化像素的占比。变化检测天然是极度不平衡的二分类问题常见数据集里变化像素占比可能不到 5%如果切分时恰好把变化像素大部分都划到训练集验证集就会变成一个几乎全是不变像素的“虚胖集”验证损失青蛙跳水一样往下掉但真实变化检出能力一点都没验证到。切完之后跑一行统计代码看每个集合里正负样本比这个习惯能省掉后面大量返工。4. 搭建孪生自注意力变化检测网络最小可复现的 Python 训练脚本4.1 完整网络定义光谱自注意力加共享编码器加变化头前面第 2 章给了模块级骨架这里把整个网络串起来做成一个可以直接吃 DataLoader 输出的端到端模型。整个网络的结构是两时相 patch 分别过光谱自注意力模块 → 共享 3x3 卷积编码器 → 绝对值差融合 → 1x1 卷积变化头。自注意力先做光谱维的全局关系建模把波段间的长程依赖揉进特征3x3 卷积再从局部空间上提取纹理细节差值融合负责度量两时相特征差异最后的变化头把差异张量映射成“变化 / 未变化”两个类别的 logits。import torch import torch.nn as nn class SiameseSelfAttentionNet(nn.Module): def __init__(self, bands, patch_size, hidden32, num_classes2): super().__init__() self.spectral_attn SpectralSelfAttention(bands, reduction4) self.encoder nn.Sequential( nn.Conv2d(bands, hidden, 3, padding1, biasFalse), nn.BatchNorm2d(hidden), nn.ReLU(inplaceTrue), nn.Conv2d(hidden, hidden, 3, padding1, biasFalse), nn.BatchNorm2d(hidden), nn.ReLU(inplaceTrue) ) self.change_head nn.Conv2d(hidden, num_classes, 1) def forward(self, x1, x2): # 共享的光谱自注意力 a1 self.spectral_attn(x1) a2 self.spectral_attn(x2) # 共享的卷积编码器 f1 self.encoder(a1) f2 self.encoder(a2) # 绝对差融合 diff (f1 - f2).abs() return self.change_head(diff)forward 里最需要理解的是“共享”这两个字。spectral_attn 和 encoder 在 x1、x2 上各被调用了一次但用的是同一套权重。PyTorch 的 Module 是层级的同一个实例调用两次共享权重这是孪生网络的实现基础。我见过有人图省事把 forward 写成 self.encoder(a1) 和 self.encoder(a2)看起来也是共享实际也是共享——但如果你在 init 里定义了两个 encoder 实例就变成了各学各的非共享结构特征对齐的优势全部丢失。这个网络里两个卷积层的感受野加起来还是局部的刻意没有做池化想让空间分辨率保持到最后一层。变化检测的目标是逐像素输出任何一次 stride2 的卷积或池化都会让变化边界模糊一截这个取舍做对比实验时会有明显感知。4.2 训练循环关键参数损失权重、学习率与 batch size损失函数方面变化检测二分类问题最直接的起点是交叉熵但必须处理类别不平衡。拿一份高光谱城市变化数据变化像素通常占全图 1% 到 8%如果不加权模型的最优策略是“全部预测为未变化”那也能刷出 95% 以上的正确率可这个指标没有任何意义。常见的做法是给交叉熵的类别权重加一个正样本权重取值一般是变化像素占比的倒数再折中一下比如变化像素占 2%正样本权重取 8 到 15。图省事的直接按“负样本数 / 正样本数”也算得过去但要注意权重过大会让模型走向另一个极端——把大量未变化像素误报成变化。import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset BANDS 100 PATCH 11 model SiameseSelfAttentionNet(bandsBANDS, patch_sizePATCH, hidden32) optimizer optim.Adam(model.parameters(), lr3e-4, weight_decay1e-5) # 变化像素占比 2% 时正样本权重取 10 左右 criterion nn.CrossEntropyLoss(weighttorch.tensor([1.0, 10.0])) # 假设 x1_patches, x2_patches, y_center 来自第 3 章的 extract_patches dataset TensorDataset( torch.from_numpy(x1_patches).permute(0, 3, 1, 2).float(), torch.from_numpy(x2_patches).permute(0, 3, 1, 2).float(), torch.from_numpy(y_center).long() ) loader DataLoader(dataset, batch_size128, shuffleTrue, num_workers4) for epoch in range(30): model.train() total_loss, total_cnt 0.0, 0 for x1, x2, y in loader: optimizer.zero_grad() logits model(x1, x2) # B, 2, PATCH, PATCH # 只取中心像素位置的 logits 计算损失 logits_center logits[:, :, PATCH // 2, PATCH // 2] # B, 2 loss criterion(logits_center, y) loss.backward() optimizer.step() total_loss loss.item() * y.numel() total_cnt y.numel() print(fepoch {epoch:02d} loss {total_loss / total_cnt:.4f})注意这里取 logits 中心像素再算损失的写法对应第 3 章“中心像素标签”的数据准备方式。如果你手里这份源码的标签是整块 patch 标注的那就把 y 的形状换成 B×PATCH×PATCHloss 直接吃整块 logits两种监督方式都能跑通但不要在同一个系统里混用否则训练目标和评价方式对不上。学习率 3e-4 是 Adam 在高光谱变化检测这类中等规模任务上比较稳的起点band 数多、patch 大时降到 1e-4 更安全batch size 在 11×11 的小 patch 上可以开到 128 甚至 256但如果显存有限优先保证 batch size 不低于 32太小的话 BatchNorm 的统计量会抖得厉害。4.3 推理阶段把滑窗输出拼回整幅变化概率图训练完成后验证阶段要做的是把模型输出的 patch 级预测拼回和原图尺寸一致的变化图。推理时有两个常见选择一是继续用训练时的 stride 滑窗每个中心像素得到一次预测直接填到对应坐标二是用更小的 stride 甚至 stride1 密集滑窗得到多个重叠预测后取平均。第二种方式更稳边界像素的预测更平滑但推理成本翻了好几倍高光谱影像上做全图推理时要先算好耗时能不能接受。def infer_full_map(model, img1_norm, img2_norm, patch11, stride5, devicecuda): 整图滑窗推理返回与输入同尺寸的变化概率图 model.eval() H, W, _ img1_norm.shape prob_map np.zeros((H, W), dtypenp.float32) count_map np.zeros((H, W), dtypenp.float32) with torch.no_grad(): for y in range(0, H - patch 1, stride): for x in range(0, W - patch 1, stride): p1 torch.from_numpy( img1_norm[y:y patch, x:x patch].transpose(2, 0, 1) ).unsqueeze(0).float().to(device) p2 torch.from_numpy( img2_norm[y:y patch, x:x patch].transpose(2, 0, 1) ).unsqueeze(0).float().to(device) logits model(p1, p2) prob torch.softmax(logits, dim1)[0, 1].cpu().numpy() # 变化类概率 prob_map[y:y patch, x:x patch] prob count_map[y:y patch, x:x patch] 1 return prob_map / np.maximum(count_map, 1)这段推理代码把 patch 级概率叠加回原图对应区域最后除以计数矩阵得到重叠平均。count_map 用了 np.maximum(count_map, 1) 防止除零同时让影像最右、最下那些取不满窗口的边界区域得到一个有效值而不是 NaN。边界几行几列的覆盖次数少概率值噪声会大一些做定量评估时可以把边界区域排除掉只统计有效覆盖区域内的像素指标这样更公平。5. 高光谱变化检测的五个高频踩坑现象、原因与对策5.1 变化像素占比不到 3%模型全给你预测成“未变化”现象训练没几个 epoch验证准确率就冲到 95% 以上但打开变化图一看全图几乎全黑真实变化区域一个都没标出来。损失在降、指标在涨输出却完全没用。原因很直接二分类交叉熵默认类别是均衡的而变化检测天然极度不平衡未变化像素占绝对多数模型只要无脑预测“未变化”就能把损失压得很低。解决给正样本加权重或者换用 Focal Loss、Dice Loss 这类为不平衡设计的损失函数。我倾向先用类别权重交叉熵它只加一个参数、最可控Focal Loss 多两个超参数要调适合正样本占比特别极端低于 1%的场景。5.2 两时相辐射差异过大真实变化被伪变化淹没现象变化概率图上亮成一片的区域实地核对后发现那里什么都没有变只是两时相拍摄时的大气条件不同。原因两时相影像的辐射值存在系统性偏移模型学的不是“地物变了”而是“亮度值变了”。解决按第 3 章的写法对两时相分别做波段级标准化把每幅影像内部的光谱分布拉齐。如果做了标准化还是压不住可以考虑用直方图匹配把两时相的整体亮度分布对齐但要注意别把真实辐射变化也一起抹掉。这个度比较难拿捏经验是先做 z-score等基础模型跑通再试直方图匹配的增量收益。5.3 随机切 patch 导致空间数据泄漏验证指标虚高现象训练验证都在同一条数据上做验证 Kappa 高达 0.9把模型往另一块同场景的影像上一扔指标直接掉 20 个点。原因全图随机滑窗切分后验证 patch 和训练 patch 高度重叠模型相当于直接看见了验证样本周围的内容。解决按 3.3 节的网格切分思路按空间区块划分训练验证测试保证验证集对应地面上的独立区域。这个坑最容易在公开数据集上踩因为数据集本身往往只有一个场景的几幅影像一旦切分不仔细Kappa 漂亮得能发论文部署到新数据就翻车。5.4 边缘与细小变化目标检测不出来现象面积小的变化目标比如一两栋新建房子在变化图上被抹成一团模糊或者整个丢失变化区域的边界轮廓像被毛玻璃擦过。原因两层 3x3 卷积虽然保留了分辨率但 patch 中心像素的上下文占比有限小目标的光谱特征被周围大背景淹没真实标签的边界又是像素级锯齿变化头很难做到像素级精确。解决推理时使用重叠滑窗取平均把多次预测结果综合训练数据里可以做针对变化区域的过采样保证每个 batch 里变化样本不低于一定比例损失函数改用 Dice Loss 能明显改善边界区域的梯度让模型更关注边界像素。5.5 光谱自注意力在波段数大时显存吃不消现象把 SpectralSelfAttention 接到 200 波段的影像上batch size 128 直接 OOM甚至 batch size 32 都不稳定。原因注意力矩阵是 C×C波段数从 100 涨到 200注意力矩阵的尺寸涨到原来的 4 倍按像素数量平摊后内存开销成倍放大。解决先做主成分分析把波段压到 50 到 100 个主成分再喂网络这在没做大规模预训练的前提下几乎不损失变化检测精度或者把自注意力模块改成按波段分组组内做完整注意力、组间用 1x1 卷积融合显存能降一大截。实在不想动结构就把 batch size 降到 16、用梯度累积模拟大步长也能顶过去。6. 用评估指标和消融实验给系统验收验证它是否真的在“变化检测”训练出来的模型不能只靠看一眼变化图就说“效果不错”要上指标。变化检测最常用的定量指标是整体精度OA、Kappa 系数和逐类别的 F1其中 Kappa 尤其值得关注它扣除了随机一致性的影响在正负样本极度不平衡时比 OA 诚实得多。一套严格的验证要做三件事第一统计测试集上变化类别的 F1而不是只看 OA第二输出变化概率图做目视检查重点看变化目标的形态是否完整、边界是否锐利第三做消融实验把光谱自注意力模块换成等参数量的卷积块、把共享权重改成非共享分别对比 Kappa 和 F1。我给一个简单可复用的评估脚本骨架把 GT 和预测的概率图放进来就能跑from sklearn.metrics import confusion_matrix, cohen_kappa_score, f1_score def evaluate_change_map(prob_map, gt_map, threshold0.5): pred (prob_map threshold).astype(int).ravel() gt (gt_map 0).astype(int).ravel() cm confusion_matrix(gt, pred, labels[0, 1]) oa (cm[0, 0] cm[1, 1]) / cm.sum() kappa cohen_kappa_score(gt, pred) f1 f1_score(gt, pred, averagebinary) return {OA: oa, Kappa: kappa, F1: f1, confusion_matrix: cm}threshold 默认取 0.5实际使用中变化概率图往往双峰分布不明显0.3 到 0.7 之间扫一遍选 Kappa 最高的阈值是常见做法。另外我习惯把模型输出的概率图直接关掉阈值看成连续变量和 GT 算 AUC这个指标不依赖阈值选择对比不同网络设计时更稳定。我自己的使用习惯是每次改完结构先跑一遍消融用 Kappa 和变化类 F1 决定留哪个版本不看 OA——OA 在类别不平衡时基本就是“未变化类的正确率”极其误导人。这套脚本配合你手里 zip 里的数据和标注足够把所有关键决策都验证一遍希望能帮到你把这个系统的每个模块都测到明白为止。本文还有配套的精品资源点击获取