简介北京航空航天大学学报2023年论文《基于小波变换与平行注意力的多源遥感图像分类》的配套源码面向遥感图像分类研究者与机器学习实践者。代码完整复现论文提出的分类框架涵盖小波变换工具、平行注意力网络、多种融合网络模型如FusatNet、Fusion-HCT、DFINet等以及训练、测试、可视化等Python脚本可直接用于土地利用分类、环境监测、灾害预警等场景。资源共56个文件以Python源码与编译文件为主另有YAML环境配置、论文PDF、许可证、Markdown说明与文本资料压缩包约2.97MB。代码采用模块化设计便于读者对比不同融合网络的效果并针对特定数据集进行调整已有160人学习使用适合希望深入理解小波与注意力机制在遥感分类中应用并快速开展实验的研究者与工程师。1. 多源遥感分类绕不开小波变换和平行注意力先看这个设计在解决什么做过多源遥感图像分类的人大多有过这种体验把光学影像、SAR 影像和 DEM 数据按通道拼在一起丢进 ResNet验证集精度却迟迟上不去。问题常常不在网络深度而在于三种数据的特征分布完全不在一个坐标系里——SAR 的斑点噪声、光学影像的纹理细节、DEM 的高程突变被同一个下采样卷积核处理高频信息在逐层池化里被抹掉了。基于小波变换与平行注意力的多源遥感图像分类设计源码核心思路就是在网络入口先用离散小波变换把图像拆成低频轮廓和高频细节再用两个并行的注意力分支分别筛选“哪些通道重要”和“哪些位置重要”最后融合分类。这套方案适合做地物分类、土地利用识别这类任务的算法工程师和研究生也适合想拿一套可改动源码做实验的人。它不是万能涨点插件但能把多源数据里最容易被浪费的信息重新利用起来。2. 小波变换做前端二维离散小波分解的选型、代码与特征图组织2.1 为什么多源遥感场景下先做频域分解从卷积下采样说起普通 CNN 的分类头之前会经历多次 stride2 的卷积或池化每一层都在丢失高频细节。对自然图像来说这无伤大雅因为物体的语义信息主要集中在中低频轮廓但多源遥感影像里SAR 图像的纹理细节、光学影像的道路边缘、DEM 的山脊线恰恰是区分地类的关键高频特征。如果入口不做处理这些信息在第三四个 stage 之后基本就消失了。小波变换的好处是它把图像分解成四个子带LL 是低频近似LH 保留水平高频HL 保留垂直高频HH 保留对角高频。网络既可以只对 LL 做常规卷积也可以让高频子带走一条浅层分支。更关键的是小波分解是可逆的这意味着它不会像普通下采样那样不可挽回地丢掉信息——这正是小波变换图像增强、小波去噪这些经典做法能用在同一套框架里的原因。小波基的选择有三个常见选项haar、db2 和 sym4。haar 滤波器结构最简单只有 2x2 的核计算量小梯度反传稳定适合做分类任务的前端db2 和 sym4 的频域局部性更好分解出的高频子带更干净但边界效应也更明显图像尺寸小时会引入伪纹理。我的经验是分类任务优先用 haar重建或分割任务才考虑更长的滤波器。2.2 用 PyTorch 实现二维离散小波变换不需要第三方库很多人以为小波变换必须装 PyWavelets其实分类模型的训练管线里有一种更干净的做法用固定权重的卷积层实现二维 DWT。haar 小波的四个滤波器本身就对应四个 2x2 卷积核stride 设为 2 就能完成一次分解。import torch import torch.nn as nn import torch.nn.functional as F class DWT2D(nn.Module): 二维离散小波分解固定权重不参与训练 def __init__(self): super().__init__() # haar 小波的低通/高通滤波器 self.register_buffer(ll, torch.tensor( [[0.5, 0.5], [0.5, 0.5]]).reshape(1, 1, 2, 2)) self.register_buffer(lh, torch.tensor( [[0.5, 0.5], [-0.5, -0.5]]).reshape(1, 1, 2, 2)) self.register_buffer(hl, torch.tensor( [[0.5, -0.5], [0.5, -0.5]]).reshape(1, 1, 2, 2)) self.register_buffer(hh, torch.tensor( [[0.5, -0.5], [-0.5, 0.5]]).reshape(1, 1, 2, 2)) def forward(self, x): # x: [B, C, H, W]H/W 必须为偶数 ll F.conv2d(x, self.ll, stride2) lh F.conv2d(x, self.lh, stride2) hl F.conv2d(x, self.hl, stride2) hh F.conv2d(x, self.hh, stride2) return ll, lh, hl, hh这段代码的逻辑是用四个固定卷积核分别对输入 x 做 stride2 的卷积得到四个尺寸减半的子带。用 register_buffer 而不是普通 tensor是为了让滤波器随模型一起迁移到 GPU 和保存 checkpoint又不会被优化器更新。要特别注意 stride2 的卷积在 H 或 W 为奇数时会向下取整导致四个子带尺寸不一致。最稳的做法是在数据预处理阶段统一把影像 resize 到 256x256 这样的偶数尺寸而不是在模型里做 padding 补救。逆变换不是分类任务必需的前向计算但可视化时经常用到。对应实现是用 F.conv_transpose2d 把四个子带上采样后求平均注意 haar 正变换里乘了 0.5逆变换里要恢复成 1.0 的增益否则重建图像整体变暗。2.3 特征图怎么重新组织子带通道拼接与第二层分解一次 DWT 之后得到的是四个 [B, C, H/2, W/2] 的子带。如果直接把 LL、LH、HL、HH 各自送进不同的分支模型会变得很复杂。常见做法是把四个子带沿通道维度拼回去得到一个 [B, 4C, H/2, W/2] 的特征张量后续网络把它当成一个普通的 4C 通道输入处理。def dwt_pack(x, dwt): DWT 分解后沿通道拼接方便接入常规卷积 ll, lh, hl, hh dwt(x) # 拼接顺序不影响精度但会决定后续 channel 索引 return torch.cat([ll, lh, hl, hh], dim1) # [B, 4C, H/2, W/2]这里有一个容易被忽略的细节拼接顺序虽然不影响理论精度但会影响注意力模块对通道维度的感知。如果项目里后续要复用预训练权重建议把 LL 放在前四个通道因为低频分量和原图的语义最接近这样即使去掉高频分支模型也能靠 LL 的预训练特征兜底。第二层分解只对 LL 继续做得到 LL2、LH2、HL2、HH2然后把第二层的三个高频子带和第一层的三个高频子带放进同一个融合模块。这个设计能提升几个点的精度但代价是特征图尺寸从 H/2 降到 H/4后续两个分支要合并时坐标对齐容易出问题。我一般只在输入分辨率充足大于 256 像素时才用两层分解否则单层分解加通道拼接已经足够。3. 平行注意力模块为什么并行比串联稳PyTorch 实现与可视化3.1 串联注意力的信息瓶颈通道和空间不能二次加权主流注意力机制有两种通道注意力SENet 路线和空间注意力。很多实现会把它们串联起来比如先做通道注意力再用空间注意力。这个做法在单源图像上效果不错但在多源遥感里有个隐患第一次加权会改变特征图的数值分布第二次加权在已经缩放过的特征上再算权重两次权重的乘积会叠加出不必要的衰减一些原本有用的高频通道被连续乘上两个小系数直接失去表达力。平行注意力的思路是通道分支和空间分支共享输入但互不依赖各自输出一个权重张量最后把两个分支的结果加回去。这样做的好处是每个分支只负责学习一个维度的特征重要性不会互相干扰。在多源场景下光学影像的通道边缘重要但空间范围分散SAR 影像则往往是特定区域内有强响应平行结构天然适配这种“各看各的重点”的需求。3.2 平行注意力模块的完整实现通道分支与空间分支并行代码实现分三个部分通道注意力子模块、空间注意力子模块、平行融合模块。通道分支用全局平均池化压缩空间信息再通过两个 1x1 卷积生成通道权重空间分支直接用 1x1 卷积把通道维度压缩成一张权重图不做全局池化以保留位置信息。import torch import torch.nn as nn class ChannelAtt(nn.Module): 通道注意力压缩空间逐通道加权 def __init__(self, in_c, r4): super().__init__() self.mlp nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_c, in_c // r, 1), nn.ReLU(inplaceTrue), nn.Conv2d(in_c // r, in_c, 1), nn.Sigmoid(), ) def forward(self, x): return x * self.mlp(x) class SpatialAtt(nn.Module): 空间注意力保留位置逐像素加权 def __init__(self, in_c): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_c, in_c // 4, 1), nn.ReLU(inplaceTrue), nn.Conv2d(in_c // 4, 1, 1), nn.Sigmoid(), ) def forward(self, x): return x * self.conv(x) class ParallelAttention(nn.Module): 平行注意力两个分支独立计算结果相加并接残差 def __init__(self, in_c): super().__init__() self.ca ChannelAtt(in_c) self.sa SpatialAtt(in_c) def forward(self, x): return self.ca(x) self.sa(x) x通道分支和空间分支为什么要输出之后再加上原始 x这是残差思想训练初期注意力权重还没有收敛如果直接用加权结果替代原特征等于让网络一开始就面对被削弱的输入加上恒等路径后模型可以选择性地依赖注意力输出稳定性强很多。两个分支用加法融合而不是乘法也避免了二次缩放的数值问题。3.3 多源输入下平行注意力怎么接每个源各配一个模块多源遥感分类里光学、SAR、DEM 三个输入的特征差异巨大不能共享同一个注意力模块。常见做法是每个源的特征先过各自的平行注意力再送入融合层。这里给出一个多源融合模块。class MultiSourceFusion(nn.Module): 光学/SAR/DEM 各自过注意力再拼接融合 def __init__(self, src_channels, out_channels): super().__init__() self.attns nn.ModuleList( [ParallelAttention(c) for c in src_channels] ) self.fuse nn.Conv2d(sum(src_channels), out_channels, 1) def forward(self, feats): # feats: [opt_feat, sar_feat, dem_feat] attned [attn(f) for attn, f in zip(self.attns, feats)] return self.fuse(torch.cat(attned, dim1))这里每个 ParallelAttention 的输入通道数可以不同因为光学影像可能是 4 波段、SAR 是 1 通道、DEM 是 1 通道。融合层用 1x1 卷积目的是把三种特征投影到同一语义空间而不做空间下采样。特别注意如果某个源的通道数太少比如 SAR 只有 1 个通道通道注意力的 MLP 会把输入压到 0 维需要把中间隐藏层维度设为至少 1。常见的解决办法是先用一个 3x3 卷积把每个源扩张到相同通道数再接注意力卷积的参数不会太多但稳定性好很多。3.4 注意力权重可视化第一次跑通后先看这张图平行注意力模块训练完第一步不是看精度而是看空间注意力图是否合理。把 SpatialAtt 中最后一个 Sigmoid 层的输出取出来它就是一张与特征图同尺寸的权重图反应模型“盯着图像哪个区域看”。def visualize_attn(model, batch): model.eval() with torch.no_grad(): x batch[sar] # 以 SAR 源为例 sa_map model.fusion.attns[1].sa.conv(x) sa_map torch.sigmoid(sa_map) # [B, 1, H, W] sa_map F.interpolate(sa_map, sizex.shape[-2:], modebilinear) return sa_map如果训练正常SAR 的空间注意力图应该高亮在地物边界和纹理丰富的区域如果整张图一片均匀的 0.5 左右说明注意力分支没有学到有效信息需要检查是不是归一化出了问题。这个可视化脚本建议写进训练仓库里每轮验证时顺便存一次比只看 loss 曲线直观得多。4. 多源遥感数据进模型前配准、独立归一化与 Dataset 装载4.1 多源数据为什么不能直接拼成一个张量三个层面的不匹配把多光谱影像、SAR 影像和 DEM 直接沿通道拼接是新手最容易踩的坑。第二层问题来自三个层面首先是几何配准光学影像和 SAR 影像的成像几何不同同一像素坐标对应的地面位置可能有几个像素的偏移其次是分辨率不匹配光学影像往往是米级DEM 可能是几十米重采样出来的直接拼接等于让模型自己去学一个荒谬的映射最后是辐射尺度差异光学反射率范围在 0 到 1 之间SAR 后向散射系数可能是线性幅度也可能是 dB 单位DEM 高程的数值范围更是从几十到几千如果不做归一化数值大的源会主导整个网络的学习。这里的处理原则是几何配准在数据生产阶段解决模型代码里只做像素级对齐的校验分辨率不同的源统一上采样到目标尺寸每种源独立做归一化绝不共用一套均值和标准差。4.2 Dataset 类的三件必做事独立归一化、同步裁剪、多源增强下面这个 Dataset 实现覆盖了多源遥感分类数据装载的核心逻辑。以光学、SAR、DEM 三源输入为例每个样本是一个元组包含三个文件路径和一个类别标签。import torch import random import numpy as np from torch.utils.data import Dataset class MultiSourceRSDataset(Dataset): 多源遥感分类 Dataset独立归一化 同步裁剪 def __init__(self, samples, src_stats, size256, augFalse): self.samples samples # [(opt_path, sar_path, dem_path, label)] self.src_stats src_stats # {opt: {mean: [...], std: [...]}, ...} self.size size self.aug aug def _read_tif(self, path): # 实际项目用 GDAL/rasterio 读取 return np.load(path) # 示例npy 文件 def _norm(self, arr, key): mean np.array(self.src_stats[key][mean], dtypenp.float32) std np.array(self.src_stats[key][std], dtypenp.float32) # 独立归一化每个源用自己的统计量 return (arr - mean) / (std 1e-6) def __len__(self): return len(self.samples) def __getitem__(self, idx): opt_path, sar_path, dem_path, label self.samples[idx] opt self._read_tif(opt_path).astype(np.float32) sar self._read_tif(sar_path).astype(np.float32) dem self._read_tif(dem_path).astype(np.float32) # 每个源独立归一化不要混用统计量 opt self._norm(opt, opt) sar self._norm(sar, sar) dem self._norm(dem, dem) # 同步随机裁剪所有源用同一个起点 if self.aug: x0 random.randint(0, opt.shape[1] - self.size) y0 random.randint(0, opt.shape[2] - self.size) opt opt[:, x0:x0self.size, y0:y0self.size] sar sar[:, x0:x0self.size, y0:y0self.size] dem dem[:, x0:x0self.size, y0:y0self.size] if random.random() 0.5: # 水平翻转 opt np.flip(opt, axis2).copy() sar np.flip(sar, axis2).copy() dem np.flip(dem, axis2).copy() opt torch.from_numpy(opt) sar torch.from_numpy(sar) dem torch.from_numpy(dem) return {opt: opt, sar: sar, dem: dem, label: label}这段代码有三个关键决策。第一独立归一化不是锦上添花而是必须做的SAR 影像如果有离群强反射点直接用全局 min-max 会把绝大多数像素压缩到很窄的区间里空间注意力模块的 Sigmoid 饱和后基本失效。第二同步裁剪必须基于同一个随机起点三个源要裁就裁同一个窗口否则模型学到的是错位的特征。第三翻转增强必须对三个源同时做且副本要.copy()否则 numpy 的翻转视图与后续 torch.from_numpy 共享内存可能在 DataLoader 多进程下产生诡异的数值问题。4.3 样本不均衡与多源数据装载的另一个隐藏问题IO多源遥感数据集的类别不均衡比自然图像严重得多比如水体样本可能是林地样本的十分之一。如果按文件顺序取 batch很多轮迭代里模型根本看不到少数类这时候注意力模块学出来的权重也会偏向多数类。常见做法是用 torch.utils.data.WeightedRandomSampler按类别频率的反比计算每个样本的采样概率。这个采样器要在 DataLoader 里传入而不是在 Dataset 里做重采样否则没法配合 shuffle 使用。数据装载还有一个隐藏瓶颈是磁盘 IO。每个样本要读三个文件如果 tif 很大且没有做瓦片化训练时 CPU 会一直处在读盘状态GPU 利用率上不去。建议在预处理阶段把影像切成 256x256 的瓦片存成 npy 或内存映射格式Dataset 每次只需要读取一个固定大小的数组。这个改动往往比调整模型结构带来的加速更明显。5. 模型装配与避坑训练参数、消融开关和 5 个高发问题排查5.1 整体装配小波前端、双分支编码器、平行注意力、分类头完整模型的装配顺序是先做 DWT 分解并沿通道拼接得到一个 4C 通道的特征图然后分别送进两个编码器分支——主分支处理低频为主的信息辅助分支处理高频细节两个分支的特征在倒数第二层通过通道注意力或简单的 1x1 卷积融合。平行注意力模块插在融合之前而不是融合之后这样才能让每个源的特征先自己做重要性筛选。训练参数的常见配置是优化器用 AdamW初始学习率 3e-4weight decay 0.01Batch Size 16 到 32 之间。分类任务的损失函数用 CrossEntropyLoss如果样本不均衡严重给每个类别设置与样本频率反比的权重比用 Focal Loss 稳定。学习率用余弦退火而不是固定值遥感分类任务通常 20 到 40 个 epoch 就能收敛余弦退火能让后期精度再涨零点几个点。5.2 高发问题一小波分解后特征图尺寸对不上现象两层 DWT 分解后高频子带分支的尺寸是 H/4而另一个分支的特征图是 H/2torch.cat 时直接报维度不匹配。原因DWT 的分层分解天然产生不同尺寸的子带如果网络设计时没考虑多尺度对齐拼接必然出错。另一类情况是输入尺寸不是 2 的幂次数比如 512x512 没问题但 513x513 在第一次分解时向下取整变成 256第二次变成 128每次取整的误差会积累。解决最省事的方法是输入尺寸固定为 2 的幂次256 或 512。如果不想改数据可以在 DWT 之后加一层 F.interpolate 把高频子带上采样回主分支尺寸但会有额外的计算开销。我的习惯是只做一层 DWT四个子带沿通道拼接成 4C 通道特征后续全用常规卷积处理这种设计不容易出错提升几个点的精度对大多数任务已经够用。5.3 高发问题二把小波逆变换加入训练后 loss 震荡现象有人在重构分支里加了小波逆变换用于图像重建辅助损失训练到十几个 epoch 时 loss 突然变成 nan或者来回剧烈震荡。原因逆变换的转置卷积与正变换的滤波器是固定系数梯度通过 stride2 的上采样链路回传时会被放大尤其是边缘像素被多次累加。如果重建分支在总损失里占比很小问题不明显但占比超过 0.1 时梯度不稳定就会暴露出来。解决如果分类是主任务重建分支不是必要组件直接去掉是最省事的选择。如果一定要保留重建辅助损失给逆变换的输出特征接一层 LayerNorm并且把重建损失的学习率单独拉低到主损失的五分之一。不要用梯度裁剪它只解决爆炸不解决震荡。5.4 高发问题三多源归一化不一致导致注意力图整体失效现象平行注意力模块训练完空间注意力图几乎全亮或全暗可视化结果看不出任何有意义的区域分布。原因各源的数据范围差异太大。比如 DEM 高程数值从 0 到 800直接用全局均值/方差归一化后大部分像素落在 -0.5 到 0.5 之间空间注意力的 1x1 卷积输出经过 Sigmoid 后大约在 0.5 附近整张图看起来一片灰。SAR 影像同理强反射点会把标准差拉得很大。解决归一化改用分位数统计而不是均值和标准差。比如把 2% 到 98% 分位数之间的数据线性拉伸到 0 到 1超过范围的像素截断。这一步要在数据预处理阶段完成并把统计结果保存下来推理时用同一份参数。如果源数据来自不同传感器建议在归一化后额外做一次标准差缩放让每个源的特征尺度大致相当。5.5 高发问题四加了平行注意力反而掉点的排查顺序现象 baseline 精度 88%加上平行注意力后变成 86%而且训练过程中 attention 分支的 loss 下降缓慢。原因训练初期注意力权重未收敛错误的加权信号干扰了主分支或者注意力模块放在模型过深的位置特征经过多层卷积后空间分辨率太低空间注意力已经失去了分辨位置信息的能力。解决按顺序排查三件事。第一把注意力模块的输出改为与残差相加而不是直接替换确保恒等路径存在。第二检查注意力模块放置位置通道注意力可以放深空间注意力要放在分辨率不低于 16x16 的位置低于这个尺度位置信息基本消失。第三训练的前 5 个 epoch 不启用注意力模块让主干网络先稳定下来再逐步加入注意力分支。这个开关建议写成一个带步数的 schedule而不是简单的 epoch 判断。5.6 高发问题五多源样本不均衡导致小类召回率低现象总体准确率看起来不错但每类报告的召回率一查水体和裸地这类样本占比小的类别几乎全被模型忽略。原因DataLoader 按顺序取批量样本样本占比小的类别在整个 epoch 里只出现很少的轮次模型对它们的梯度贡献被淹没了。解决用 WeightedRandomSampler 按样本比例的倒数做采样让每个类别在每个 epoch 里出现的次数大致相当。要注意的是采样器会让少数类样本被重复抽取所以增强策略必须打开否则模型会把少数类的重复样本原样记住过拟合到个别样本上。另一种辅助手段是在 Loss 里给少数类更高的权重但要控制权重倍数一般不超过 5 倍太高会干扰多类别的整体精度。6. 进阶验证技巧注意力热力图导出与低频占比消融分析6.1 把空间注意力热力图叠加到原始影像导出 PNG训练完成后除了精度指标还需要确认注意力模块学到了物理上合理的特征。做法是把空间注意力图的输出插值回原始影像尺寸与合成 RGB 影像按权重叠加渲染成 PNG这样能直观看到模型在做决策时重点关注了哪些区域。import matplotlib matplotlib.use(Agg) import matplotlib.pyplot as plt import torch.nn.functional as F def export_heatmap(model, batch, rgb, out_path): model.eval() with torch.no_grad(): sar_feat model.encoder(batch[sar]) attn_map model.fusion.attns[1].sa.conv(sar_feat) attn_map torch.sigmoid(attn_map).mean(dim1, keepdimTrue) attn_map F.interpolate(attn_map, sizergb.shape[-2:], modebilinear) heat attn_map[0, 0].cpu().numpy() fig, ax plt.subplots(1, 2, figsize(8, 4)) ax[0].imshow(rgb) ax[1].imshow(rgb) ax[1].imshow(heat, alpha0.5, cmapjet) plt.savefig(out_path, dpi150, bbox_inchestight) plt.close()这个脚本里值得注意的两个细节Sigmoid 之后用 mean 把空间注意力图的通道维度压缩成单通道因为 sar_feat 经过编码器后通道数已经扩展到几十维直接可视化会无法解释叠加时 alpha 用 0.5既能看到热力分布又不遮挡原始影像。如果热力图总是集中在图像的固定位置而不是随地物分布变化大概率是数据归一化或者配准出了问题而不是注意力模块的问题。6.2 用低频能量占比判断小波前端是否真的起作用这是一个我每次做消融都会用的量化指标。对经过 DWT 分解的特征图计算低频能量占比LL 子带的平方和除以四个子带平方和的总和。这个指标越高说明图像能量越集中在低频轮廓高频细节的贡献越小。def low_freq_ratio(ll, lh, hl, hh): 低频能量占比衡量小波前端是否有效利用高频信息 energy_ll (ll ** 2).sum() energy_total energy_ll (lh ** 2).sum() (hl ** 2).sum() (hh ** 2).sum() return (energy_ll / (energy_total 1e-8)).item()如果低频能量占比高到 0.9 以上而分类精度也没有明显提升说明网络实际上在忽略高频子带此时需要给高频分支更大的通道数或者更强的空间注意力权重。如果低频占比偏低说明输入影像本身纹理复杂比如城区高分影像这时候小波前端的作用更明显。这个指标建议写进训练日志每轮记录一次配合精度曲线一起分析。做实验时我现在的习惯是把小波基、分解层数、注意力启用的起始步数全部写进实验记录模型训练前先打印这三个超参避免三天后自己都说不清跑的是哪组配置——这个习惯帮我省下过不少重跑实验的时间。希望帮到你。本文还有配套的精品资源点击获取
