孪生自注意力网络实现高光谱变化检测:原理与代码实践 简介孪生自注意力网络驱动的高光谱图像变化检测系统以完整可运行的Python源码加配套数据形式打包面向遥感领域从事变化检测研究的学生和开发人员尤其适合毕业设计、课程作业及算法复现初期的参照。项目中嵌入SSTFormer核心结构配合训练生成的权重文件和numpy数据可直接启动检测流程并支持在此基础上调整参数与结构。压缩包共收录138个文件体积约174.68MB主体包括30个Python脚本、54个编译生成的pyc文件、10个mat格式样本地物数据、配置文件及说明文档等组织方式便于定位训练、测试与可视化模块。目前已有176人学习下载作者确认项目经过稳定性验证。若希望快速切入高光谱变化检测领域这套资源能帮助节约环境搭建与调参时间同时可作为二次开发的原型尤其对后续算法优化和实验对比有实际参考意义。1. 高光谱图像变化检测不是普通图像比对为什么值得用孪生自注意力网络拿两幅 RGB 影像做差像素值有明显差异的区域基本就是变化区域这个逻辑放到高光谱影像上并不成立。高光谱图像动辄上百个波段两个时相之间哪怕只是光照条件变化、大气校正残差或者传感器噪声都会在某些波段上出现明显的数值波动简单的逐像素差值会产生大量“伪变化”变化检测结果满是椒盐噪声和虚警。这个资源给你的是基于 IEEE TGRS 论文 SSTFormer 思路实现的一套完整 Python 高光谱图像变化检测系统核心是利用孪生自注意力网络对两个时相的高光谱影像做特征级对比而不是像素级相减。它天然适合高光谱场景下的双时相变化检测任务也能直接当毕设、课程设计或者论文复现的底子对刚接触 Transformer 和遥感图像应用的从业者来说也是一份结构完整、能跑通的参考工程。2. 孪生网络与 SSTFormer 的原理拆解双时相高光谱影像为什么不能直接做差2.1 高光谱变化检测的三个深层难点第一波段冗余严重。高光谱影像相邻波段之间的相关性极高几十个波段可能都在描述相似的地物光谱信息。如果直接把这些波段全部喂给网络模型会花大量参数去拟合冗余特征不但训练变慢还容易过拟合。第二光谱漂移和噪声干扰。同一个地物在两个时相上呈现出不完全一致的光谱曲线这是大气条件、观测角度、土壤湿度等多种因素共同造成的。这种漂移在数值上可能掩盖真实的土地利用变化导致检测结果出现漏检。第三标注数据极其稀疏。高光谱变化检测的像素级标注通常靠人工目视解译加实地调查来做一份双时相影像的参考变化图可能要标注很久。因此模型训练很容易遇到类别不平衡变化类像素往往只占整幅影像的很小比例。这三个难点共同指向一个结论变化检测不能停留在光谱向量之间的绝对值比较上而应该在更高层的语义特征空间里做比较。Transformer 架构天然适合这个目标因为它能把整幅影像切分成 token在 token 的语义级别建立长距离依赖关系。2.2 孪生结构的价值权值共享与特征空间对齐孪生网络的核心是两条分支共享同一套权重。输入是同一地区两个时相的高光谱影像分别经过同一个特征提取网络得到两个特征图再对特征图做差异判断。这里的关键在于“共享权重”而不是“两个独立网络”。如果两个时相分别用不同的网络提取特征那么两个分支学习到的特征分布很可能不一致比较特征图就失去了意义。共享权重可以保证两个时相的影像被映射到同一个特征空间网络提取到的边缘、纹理、光谱特征在三者之间具备可比性。SSTFormer 正是在这个思路上进一步引入 Transformer 结构让两条分支内部都具备建模空间上下文的能力。2.3 SSTFormer 的三个阶段语义 Tokenizer、主特征提取器与目标 Token 聚合SSTFormer 的整体处理流程大致可以拆成三个阶段阶段作用输入输出语义 Tokenizer把双时相特征图转换成语义 token 序列输入为高光谱特征图输出为多尺度语义 token主特征提取器在 token 序列上执行自注意力计算输入为成对语义 token输出为增强后的 token 特征目标 Token 聚合把 token 恢复到原始空间分辨率输入为增强 token输出为逐像素变化概率图第一阶段的语义 Tokenizer 不是简单地把特征图拉平成一串 patch embedding而是将局部相似的光谱特征聚合到一起形成带语义含义的 token。这样做的直接好处是后续自注意力计算量大幅降低因为 token 数量远小于逐像素数量。第二阶段的主特征提取器是真正的核心计算体它接收两个时相各自产生的语义 token通过自注意力机制建模全局关系。需要说明的是自注意力在双时相任务里同时承担两个职能一方面增强单个时相内部的空间上下文另一方面建立两个时相 token 之间的对应关系。第三阶段的目标 Token 聚合将增强后的 token 重新映射回高光谱影像的空间尺寸输出逐像素的变化检测结果。整个过程保证网络输出分辨率与输入影像一致无需额外的上采样对齐。2.4 损失函数为什么要组合使用变化检测本质上是一个二分类问题但是类别极度不平衡“变”和“不变”的比例可能达到 1:99 甚至更低。如果只用交叉熵损失网络很容易把所有像素都判为“不变”整体准确率看似很高但变化区域的召回率接近 0。SSTFormer 一类方法通常会组合两种损失交叉熵损失负责逐像素分类另外引入特征层面的约束损失不少实现里用的是中心损失或对比损失让同类别的特征在内聚的同时拉大不同类别之间的距离。训练时两个损失按权重相加我习惯先把特征约束损失的权重调小等网络能基本收敛了再慢慢加大否则前期梯度会被特征约束主导分类分支反而学不扎实。3. 工程落地依赖、目录结构与数据组织方式3.1 项目目录结构先读懂再动手解压后第一件事不是急着跑训练而是先梳理目录。这份资源里的核心模块大致遵循下面的组织方式HSI_change_detection/ ├── data/ │ ├── train/ │ │ ├── T1/ # 时相1影像 │ │ ├── T2/ # 时相2影像 │ │ └── label/ # 变化标签 │ ├── val/ │ └── test/ ├── models/ │ ├── sstformer.py # 主模型结构 │ ├── backbone.py # 特征提取骨干 │ └── loss.py # 损失函数定义 ├── utils/ │ ├── dataset.py # 数据加载 │ ├── metrics.py # 精度评估 │ └── vis.py # 可视化 ├── configs/ │ └── config.yaml # 训练配置 ├── train.py # 训练入口 ├── test.py # 推理入口 └── eval.py # 评估入口注意 .iml 文件是 IntelliJ 系列 IDE 的模块描述文件说明作者当时用 PyCharm 打开过这个工程qd.html 大概率是工程辅助生成的说明页对核心训练流程没有影响不用把精力花在读它上面。3.2 Python 依赖环境安装这套代码基于 PyTorch 实现。高光谱数据量相对较大建议直接用 conda 建独立环境避免和系统 Python 环境相互污染conda create -n hsi python3.8 conda activate hsi pip install torch1.13.1 torchvision0.14.1 --extra-index-url https://download.pytorch.org/whl/cu117 pip install numpy scipy scikit-learn matplotlib h5py tqdm pyyaml这段命令里有一个值得单独说明的点torch 和 torchvision 我特意指定了版本而不是直接pip install torch。高光谱变化检测代码往往依赖特定的 tensor 操作接口如果安装最新版 PyTorch部分 API 可能变化导致源码报错。1.13.1 是目前兼容性非常稳的版本绝大多数 SSTFormer 复现工程在这个版本下都不会出问题。3.3 数据准备双时相影像怎么切成训练样本高光谱影像不能整幅直接送进网络显存放不下也没有必要。常见做法是把双时相影像按照固定窗口大小切块同时带上对应的标签块。我在这个项目里一般用 7×7 的 patch 大小理由是高光谱影像空间分辨率普遍偏低7×7 窗口既能捕捉局部空间上下文又不会把不同类别的边界过度混合。# utils/dataset.py 中的核心切块逻辑 def crop_patches(t1, t2, label, patch_size7, stride7): t1/t2: (C, H, W) 的高光谱影像 label: (H, W) 的变化标签1代表变化0代表不变 stride 等于 patch_size 时表示不重叠切块 c, h, w t1.shape patches_t1, patches_t2, patches_label [], [], [] for i in range(0, h - patch_size 1, stride): for j in range(0, w - patch_size 1, stride): patch_t1 t1[:, i:ipatch_size, j:jpatch_size] patch_t2 t2[:, i:ipatch_size, j:jpatch_size] patch_label label[i:ipatch_size, j:jpatch_size] # 标签块中心像素作为该样本的标签 center_label patch_label[patch_size // 2, patch_size // 2] patches_t1.append(patch_t1) patches_t2.append(patch_t2) patches_label.append(center_label) return np.stack(patches_t1), np.stack(patches_t2), np.array(patches_label)这里有个容易忽略的细节每个样本的标签取的是中心像素而不是整个 patch 的众数。原因是边界像素在 patch 边缘受邻近地物影响较大中心像素最能代表这个局部区域的实际类别。如果你想让样本更丰富可以把 stride 改成小于 patch_size用重叠切块做数据增广但训练样本数量会翻倍显存消耗也跟着上涨。3.4 训练配置与启动方式配置文件通常以 YAML 形式集中管理超参数# configs/config.yaml model: name: sstformer in_channels: 102 # 根据实际波段数修改 patch_size: 7 embed_dim: 64 num_heads: 4 depth: 4 tokenizer_scale: [1, 2, 4] train: batch_size: 16 lr: 0.001 epochs: 200 loss_weights: cross_entropy: 1.0 center_loss: 0.1 scheduler: cosine data: data_root: ./data/train val_ratio: 0.2in_channels 是最需要留意的字段。不同高光谱传感器的波段数差别很大AVIRIS 通常是 224 波段Hyperion 是 242 波段预处理后常用到的可能是 102 或 103 个波段。如果你的数据和原作者用的波段数不一致改这个字段即可不需要动模型结构。启动训练一条命令就够了python train.py --config configs/config.yaml --gpu 0训练过程中建议关注两个指标一个是训练集上的 loss 下降曲线另一个是验证集上的 Kappa 系数。Kappa 比准确率更能反映不平衡样本下的真实性能如果 Kappa 一直徘徊在 0.2 以下基本可以认为是标注或者数据预处理出了问题。3.5 评估指标怎么选高光谱变化检测领域最常用四个指标指标公式含义关注点OA正确分类像素占总像素比例大类主导不平衡时虚高AA各类别准确率的平均值对每个类别一视同仁Kappa消除随机一致性的分类一致性系数类别不平衡时的可靠参考F1精确率与召回率的调和平均重点关注变化类的查全查准代码包里通常会提供 eval.py 直接计算这些指标但我建议你额外打印出变化类的召回率。在实际项目中“漏变化”往往比“多报变化”代价更高一个变化区域没检测出来后续分析会漏掉真实目标。4. 实操用源码做一次完整推理并输出变化图4.1 加载预训练权重工程跑通后第一步先做推理验证确认模型输出符合预期再回头研究训练细节。推理脚本的加载逻辑通常长这样# test.py 中的权重加载核心代码 import torch from models.sstformer import SSTFormer device torch.device(cuda if torch.cuda.is_available() else cpu) # 模型实例化参数必须和训练时一致 model SSTFormer( in_channels102, patch_size7, embed_dim64, num_heads4, depth4, tokenizer_scale[1, 2, 4] ).to(device) checkpoint torch.load(checkpoints/best_model.pth, map_locationdevice) model.load_state_dict(checkpoint[model_state_dict]) model.eval()这里有一个非常容易翻车的点model.load_state_dict要求当前实例化的模型结构和训练时的完全一致。embed_dim、num_heads、depth 这些参数差一个数字加载时就会报 key 缺失或形状不匹配。如果你手头只有权重文件而没有训练时的配置打印一下 checkpoint 里model_state_dict的 key 列表对照每个 tensor 的 shape 反推参数是最快的解决办法。4.2 前向推理生成变化概率图模型推理阶段和训练阶段有一个行为差异推理不需要计算梯度所以必须把整段代码包在torch.no_grad()里面否则显存会被中间变量的梯度信息撑爆import numpy as np import torch from utils.dataset import crop_patches t1 np.load(data/test/T1.npy) # (C, H, W) t2 np.load(data/test/T2.npy) # 和前文一样切块 patches_t1, patches_t2, _ crop_patches(t1, t2, np.zeros((t1.shape[1], t1.shape[2]))) # 转成 tensor 并分批推理 batch_size 32 probs [] with torch.no_grad(): for i in range(0, len(patches_t1), batch_size): batch_t1 torch.from_numpy(patches_t1[i:ibatch_size]).float().to(device) batch_t2 torch.from_numpy(patches_t2[i:ibatch_size]).float().to(device) logits model(batch_t1, batch_t2) probs.append(torch.softmax(logits, dim1).cpu().numpy()) probs np.concatenate(probs, axis0) # 变化类概率通道重塑回影像尺寸 change_prob probs[:, 1, 0, 0].reshape(t1.shape[1], t1.shape[2])后半段有一个容易看懵的操作probs[:, 1, 0, 0]取的是每个 patch 中心像素的变化类概率然后把一维序列 reshape 回二维影像尺寸。这是因为切块时每一个 patch 对应一个中心标签推理输出也是逐 patch 的。如果你切块时用了重叠这一步的恢复逻辑也要相应调整最简单的方案是保持 stride 等于 patch_size避免重叠。4.3 计算评估指标并输出变化检测图得到变化概率图之后通常需要在 0 到 1 之间选一个阈值来二值化默认取 0.5但实际场景下 0.5 未必最优。可以用验证集做一次阈值扫描from sklearn.metrics import accuracy_score, cohen_kappa_score, f1_score def find_best_threshold(prob_map, gt_label, thresholdsnp.arange(0.3, 0.8, 0.05)): 通过遍历阈值寻找 F1 最优的二值化阈值 best_f1, best_th 0, 0.5 for th in thresholds: pred (prob_map th).astype(int) f1 f1_score(gt_label.flatten(), pred.flatten(), pos_label1) if f1 best_f1: best_f1, best_th f1, th return best_th, best_f1 best_th, best_f1 find_best_threshold(change_prob, gt_label) print(fbest threshold: {best_th:.2f}, best F1: {best_f1:.4f})二值化后再用 matplotlib 保存结果图颜色选取建议直接用纯黑白变化区域用白色、不变区域用黑色这样在论文里排版时最清晰审稿人不会因为颜色映射产生歧义。4.4 后处理去除孤立噪点无论用哪种网络变化检测结果里总会残留一些面积很小的孤立斑块它们在形态学上属于典型的椒盐噪声。用形态学开运算可以去掉闭运算可以填补内部空洞from scipy import ndimage # 先开运算去孤立点再闭运算填洞 struct ndimage.generate_binary_structure(2, 2) pred_clean ndimage.binary_opening(pred, structurestruct, iterations1) pred_clean ndimage.binary_closing(pred_clean, structurestruct, iterations1)开闭运算的迭代次数不建议超过两次迭代太强会把真实的小变化区域也抹掉。如果你检测的目标本来就是小目标这一步宁可不用。5. 避坑自查高光谱变化检测代码运行的常见问题与排查5.1 中文路径导致的文件读取错误现象程序在np.load或h5py.File读取数据时直接报错提示文件不存在但路径明明没错有时训练到一半才报错报错位置毫无规律。原因项目说明里特意强调了解压路径不能带中文。PyTorch 和部分图像库对非 ASCII 路径支持不完善中文路径在编码转换时出现不一致尤其是 Windows 平台上问题最集中。解决无论压缩包下载到哪个目录解压后立刻重命名为纯英文路径。我的习惯是直接放到D:\projects\hsi_sstformer这类路径下文件夹层级不超过三层层的绝对路径太长也可能触发 Windows 系统的路径长度限制。5.2 训练时 CUDA OOM现象batch_size 设置为 16 能跑提到 32 就报CUDA out of memory有时候训练中途也报甚至 8G 显存测试阶段都撑不住。原因Transformer 比卷积网络更吃显存。自注意力机制需要保存 token 之间的注意力矩阵其显存占用随 token 数量近似平方增长。语义 Tokenizer 产生的 token 数量虽然远小于像素数但对 7×7 的 patch 而言仍不可忽视。解决优先减小 batch_size而不是换更小的模型。高光谱数据在 batch 维度上的冗余度非常高batch_size 从 32 降到 8显存占用差不多降一半精度损失通常不明显。再不行就把 patch_size 从 7 改成 5token 数量减少将近一半。同时检查训练代码里是否每个 batch 结束都调用了optimizer.zero_grad()漏了这一句会导致梯度累积显存持续增长。5.3 波段数与模型 in_channels 不匹配现象模型加载权重时报 shape 不匹配报错信息类似size mismatch for backbone.conv1.weight。原因原始训练数据是 102 波段而你换的数据集是 200 波段或者只剩几十波段。conv1 层的输入通道数变了权重矩阵形状自然对不上。解决第一重新统计自己数据集的波段数把配置文件的in_channels改成实际值第二如果使用了预训练权重最简单的方式是丢掉第一层卷积的权重只加载骨干网络剩余部分微调时让第一层重新学习。我在实际项目中通常直接把加载预训练权重的函数改成strictFalse这样 PyTorch 会忽略不匹配的层。5.4 训练过拟合验证集 Kappa 低但训练集准确率接近 100%现象训练 loss 持续下降训练集最终准确率 99% 以上但验证集上 Kappa 不到 0.3变化区检测结果几乎全是噪声。原因高光谱变化检测的标注样本本来就少切块之后大量 patch 属于“不变”类模型学到的实际上是对背景的拟合。Transformer 参数量大在样本不足时过拟合速度很快。解决最有效的手段不是加 dropout而是增加数据多样性。高光谱影像常见的数据增广包括随机光谱扰动在每个波段上叠加高斯噪声、随机翻转和旋转、随机裁剪。如果训练集实在太小退一步用随机采样平衡“变化”和“不变” patch 的比例保证每个 batch 里变化类样本不低于 30%。5.5 双时相影像没有严格配准现象推理结果里变化区域都出现在地物边缘上比如道路边界、房屋轮廓一圈全是变化像素但中心区域反而没被检测为变化。原因两个时相的影像存在亚像素级配准误差。网络本身不具备图像配准能力它只能检测输入特征之间的差异几何偏差被当作光谱变化学进去了。解决训练之前先把双时相影像用 ENVI 或 GDAL 做一次自动配准控制均方根误差在一个像素以内。如果你处理的区域地物边缘密集配准误差容忍度还要收紧。5.6 预训练权重文件损坏或下载不完整现象加载 checkpoint 报unexpected EOF或Ran out of input文件大小明显小于预期。原因网络下载断点续传失败或者传输过程丢包导致 pickle 文件损坏。解决不要用右键另存下载权重文件用命令行下载工具并加校验wget -c https://example.com/checkpoints/best_model.pth md5sum best_model.pth下载后先核对 MD5再load能省下大量排查时间。6. 进阶技巧把自己数据集接入这套系统的完整流程6.1 自建数据集的组织建议把别人的数据和模型跑通只是第一步。要真正把这个系统用到自己的研究区域数据组织方式直接决定后面能不能顺利训练。推荐一份最简单的目录组织方式mydataset/ ├── T1/ │ └── scene.npy # 时相1高光谱影像 ├── T2/ │ └── scene.npy # 时相2高光谱影像 └── label/ └── change.npy # 手动标注的变化图radar 影像或高光谱影像的原始格式可能是 .tif 或 .h5建议先用 GDAL 统一转换为 .npy。这里面的原因是 .npy 加载速度远快于 .tif训练循环里的数据读取时间会显著缩短。转换时保留原始数据类型不要随意归一化到 0-255高光谱的辐射分辨率往往在 12 位以上归一化会丢信息。6.2 推理封装成一个可复用函数为了让测试代码在多个数据集上反复使用我把推理逻辑封装成一个函数输入双时相影像路径输出变化概率图和二值图def predict_change(model, t1_path, t2_path, device, batch_size32): 输入双时相高光谱影像路径返回变化概率图和二值图 t1 np.load(t1_path) t2 np.load(t2_path) patches_t1, patches_t2, _ crop_patches(t1, t2, np.zeros((t1.shape[1], t1.shape[2]))) probs [] with torch.no_grad(): for i in range(0, len(patches_t1), batch_size): b1 torch.from_numpy(patches_t1[i:ibatch_size]).float().to(device) b2 torch.from_numpy(patches_t2[i:ibatch_size]).float().to(device) logits model(b1, b2) probs.append(torch.softmax(logits, dim1).cpu().numpy()) probs np.concatenate(probs) prob_map probs[:, 1, 0, 0].reshape(t1.shape[1], t1.shape[2]) return prob_map, (prob_map 0.5).astype(np.uint8)封装好之后验证不同数据集的效果只需要一行调用。我建议每次在新数据集上测试时先跑一次推理并输出变化图用目视检查和真实标注做对比再谈调参。盲调超参数是浪费时间。6.3 验证方法的一点点提醒很多爱好者复现时会把注意力全部放在训练上却忽略了最关键的验证环节。建议在训练完成后做三件事第一在验证集上计算混淆矩阵重点看变化类像素的错分和漏分分布第二把变化概率图叠加在时相 1 影像上人工检查检测区域和真实地物边界是否吻合第三做一次阈值敏感性分析看 F1 指标是否在 0.3 到 0.7 的阈值范围内都保持平稳。有一件事我想特别多说一句这个项目里 .iml 文件和 .gitignore 文件往往被初学者忽略但它们能透露工程的来源和组织习惯。.gitignore 里哪些目录被排除说明那些目录可能是数据缓存或临时文件。从那以后我每次接手这类代码包都会先看一遍 .gitignore 和目录结构再跑通第一个命令。拿不准的配置先按默认值跑通一遍再逐项调整不要一开始就追求最优超参数。希望这些内容能帮你在自己的高光谱变化检测项目里少走几条弯路顺利把源码跑起来。本文还有配套的精品资源点击获取