简介:基于Transformer的遥感影像变化检测算法完整项目源码,面向遥感分析、深度学习方向的研究者与工程师,用于高效捕捉影像间的全局空间依赖与变化特征,弥补传统手工特征法在复杂场景下检测精度不足、泛化能力弱的短板,可支撑城市规划、灾害监测、资源管理等应用场景。压缩包共61个文件,以Python源码(20个py)和遥感影像样本(36个png)为主体,另含Shell运行脚本、模型权重pt及Markdown说明文档,整体大小57.55MB。已有225人学习。项目源码包含数据预处理、Transformer模型构建、损失函数定义、训练调参与结果评估等完整模块,并内置典型数据集配置与预训练权重,能够一键复现实验流程;README文档详细说明目录结构与运行步骤,便于快速上手。此外,样本图像与可视化脚本可帮助直观比对变化检测效果,适合作为算法研究、论文复现和工程落地的参考基座。
1. 为什么遥感变化检测要用 Transformer:双时相影像差异计算的一线答案
两期同一区域的遥感影像摆在你面前,靠人工对比找变化,眼睛看花是小事,漏检和误检才是真正要命的损失。遥感影像变化检测要解决的,就是自动找出“这段时间里地面到底哪里变了”——房子有没有新建、农田有没有被占用、河道有没有改道。基于 Transformer 的变化检测模型,用自注意力机制同时建模两期影像的全局上下文,比传统影像差分和纯卷积网络更擅长捕捉微小但关键的变化区域。这份源码包是完整的可运行项目,从数据裁剪、模型构建到训练评估、推理可视化都能直接跑通,适合做遥感算法、毕业设计以及工程落地的实际需要。
2. 源码包结构与核心思路:双时相输入、Swin Transformer 编码与文件布局
2.1 先看目录:源码包的文件布局与运行入口
拿到压缩包解压后,第一步不是看论文、不是调参数,而是把目录结构摸清楚。我按这套源码的实际组织方式整理成一张表,跑通之前先对照一下,避免后面连文件放哪都找不到:
| 文件/目录 | 作用 |
|---|---|
| data/ | 训练数据存放目录,按 train / val / test 划分,内部再分 t1、t2、label |
| models/ | 模型定义目录,编码器、解码器、主干网络构建都在这里面 |
| utils/ | 工具函数目录,数据加载、指标计算、可视化脚本都在这 |
| train.py | 训练入口,负责数据加载、优化器配置、训练循环、模型保存 |
| predict.py | 推理入口,加载权重,输入一对影像直接输出变化图 |
| requirements.txt | Python 依赖列表,环境不对跑不起来,先按这个装 |
| README.md | 运行说明,里面通常有完整命令 |
运行入口只有两个:train.py 和 predict.py。其余 models、utils 都是被这两个入口调用的模块。我建议第一次复现时严格按 README 里的命令先跑通一次,再动参数,不要一上来就改网络结构——先建立“能跑的基线”,比直接追求“更好的效果”重要得多。
2.2 为什么是 Transformer:从双时相差异计算到自注意力机制
变化检测的标准输入是同一区域、不同时相的两幅遥感影像,记作 T1 和 T2,配套的标签是一张同样尺寸的二值图,像素值为 1 表示“这里变了”。这个任务的核心,是找到 T1 有而 T2 没有、或者 T1 没有而 T2 有的位置。传统做法靠影像差分或者植被指数差值,思路直白,但对光照变化、配准误差极其敏感,两期影像只要亮度稍有差异,差分结果就全是噪声。
Transformer 进入这个领域后,处理两期影像的主流方式有三种,我在实际拆代码时把这三种都对比过:
通道拼接(Concat)。把 T1 和 T2 沿通道方向拼起来,形成 6 通道输入,网络自己在深层特征里学会“做差”。实现最简单,但两期的信息混在一起,模型很难显式对齐两幅影像的空间位置和语义,收敛偏慢。
Siamese 双分支。两个共享权重的编码器分别提取 T1 和 T2 的特征,在解码器之前把两套特征做差,得到差异特征再上采样回原分辨率。这套源码采用的就是这种结构。它最大的好处是两期影像的特征尺度一致,模型不需要隐式地对齐,参数量也小,是三种方案里工程上最稳的。
差分注入。在 Transformer 每一层注意力计算之前,把两期特征的差显式拼进序列,相当于让每个注意力头都“带着差异信息”去计算。对细节变化更敏感,但显存开销大,代码复杂度也高,属于进阶改造。
把模型输入设计成双时相结构之后,注意力机制的价值才真正体现出来。自注意力让每个位置都能看到全图其他位置的信息,建筑物变化、农田边界这类需要上下文判断的目标,比局部感受野的卷积网络更容易被捕获。
2.3 为什么不直接上 ViT:Swin Transformer 与位置编码
既然要用 Transformer,第一个跳进脑子里的往往是 ViT。但我实际对比后,劝你在这个场景里优先考虑 Swin Transformer,理由有两个,都很工程化。
第一个是位置编码。原始 ViT 使用绝对位置编码,长度和输入尺寸绑死。训练时用 224×224,推理时换成遥感大图,位置编码只能插值,这里就有信息损耗。而 Swin Transformer 用的窗口自注意力加相对位置偏置,编码的是窗口内 patch 之间的相对位置关系,天然支持任意尺寸输入。遥感影像动辄上千像素,这个特性是刚需。Swin-Tiny 的典型配置是 patch size 为 4、窗口大小为 7、embed_dim 为 96,这四个数字决定了特征图的分辨率和感受野,改动任何一个是会直接反应在显存和精度上的。
第二个是预训练权重。Swin 在 ImageNet 上训练得很充分,迁移到遥感影像上,把前几层冻结、后面层微调,就能在小数据集上获得相对不错的结果。解码器这边,源码里用的是 FPN 风格的上采样路径,把编码器各层输出逐级融合,浅层细节和深层语义一起参与最终预测。相比简单的单层上采样,这种方式对小目标的定位能力有明显提升。
提示:如果你手头的数据量只有几百对影像,不要从零训练 Swin,直接加载官方预训练权重再微调,效果和收敛速度都差一个量级。
3. 数据管线:从影像裁剪、归一化到训练集/验证集划分
3.1 数据目录组织与影像配对
遥感变化检测最忌讳数据组织混乱。两期影像必须精确配准,文件名一一对应,标签必须是单通道的 0/1 掩膜。我习惯把数据整理成下面的结构,这套源码也是按这个约定设计的:
data/ train/ t1/ 0001.png t2/ 0001.png label/ 0001.png val/ t1/ ... t2/ ... label/ ...每一批文件的文件名前缀相同,t1、t2、label 三个子目录里的同名文件构成一个训练样本。标签图必须是单通道灰度图,像素值为 255 的是变化区域,0 是不变区域,读取时按阈值二值化。很多翻车案例出在标签存成了三通道、或者像素值既不是 0 也不是 255——模型训练时 loss 能降,但预测结果完全没法看。
3.2 裁剪与归一化:把 1024×1024 切成 256×256
公开遥感数据集的原始影像大多是 1024×1024 甚至更大,整图直接进模型,显存压力大,训练也慢。常见做法是先裁剪成 256×256 的图块,再在“影像级”层面划分训练集和验证集,确保同一张影像的图块不会同时出现在训练集和验证集里,否则验证指标会虚高,换到新影像上立刻现原形。
import cv2 import numpy as np from pathlib import Path def crop_triplet(t1_path, t2_path, label_path, out_dir, crop_size=256): t1 = cv2.imread(t1_path) # BGR 三通道 t2 = cv2.imread(t2_path) label = cv2.imread(label_path, cv2.IMREAD_GRAYSCALE) # 单通道灰度 h, w = t1.shape[:2] out_dir = Path(out_dir) for y in range(0, h, crop_size): for x in range(0, w, crop_size): c1 = t1[y:y + crop_size, x:x + crop_size] c2 = t2[y:y + crop_size, x:x + crop_size] cl = label[y:y + crop_size, x:x + crop_size] if c1.shape[0] < crop_size or c1.shape[1] < crop_size: continue stem = f"{Path(t1_path).stem}_{y}_{x}" cv2.imwrite(str(out_dir / "t1" / f"{stem}.png"), c1) cv2.imwrite(str(out_dir / "t2" / f"{stem}.png"), c2) cv2.imwrite(str(out_dir / "label" / f"{stem}.png"), cl)crop_size 设成 256,步长也按 256 走,这是不重叠裁剪,正好把一张 1024×1024 图切成 16 块。步长改成 crop_size // 2 就是重叠裁剪,样本量接近翻倍,但同一块地面会出现在多个图块里,验证集必须错开,不然模型容易把重复区域的特征背下来。
裁剪之后是归一化。Swin 编码器加载的是 ImageNet 预训练权重,输入分布必须对齐预训练时的统计值:
def normalize(image, mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)): image = image.astype(np.float32) / 255.0 image = (image - np.array(mean)) / np.array(std) return image.astype(np.float32)mean 和 std 沿用 ImageNet 统计值的原因只有一个:预训练权重的输入层是针对这个分布学出来的。如果你的数据是多光谱卫片,通道数不是 3,那这套预训练权重就用不上了,得从头训或者换编码器。真遇到这种情况,我一般先在训练集上把每个通道的 mean 和 std 算出来,再用自己的统计值归一化。
3.3 Dataset 加载器与数据增强
数据整理完,接下来要把它变成 PyTorch 能吃的东西。Dataset 类负责把文件路径变成张量,同时把数据增强挂进去:
import torch from torch.utils.data import Dataset class ChangeDetectionDataset(Dataset): def __init__(self, root, split="train", crop_size=256): self.t1_dir = root / split / "t1" self.t2_dir = root / split / "t2" self.label_dir = root / split / "label" self.names = sorted(p.name for p in self.t1_dir.glob("*.png")) def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] t1 = cv2.imread(str(self.t1_dir / name)) t2 = cv2.imread(str(self.t2_dir / name)) label = cv2.imread(str(self.label_dir / name), cv2.IMREAD_GRAYSCALE) # 以相同的随机种子处理三张图,保证空间位置对应 seed = torch.randint(0, 100000, (1,)).item() for img in (t1, t2, label): torch.manual_seed(seed) if img.ndim == 3: img[:] = img t1 = torch.from_numpy(normalize(t1).transpose(2, 0, 1)) t2 = torch.from_numpy(normalize(t2).transpose(2, 0, 1)) label = torch.from_numpy((label > 127).astype(np.float32)) return {"t1": t1, "t2": t2, "mask": label}这个类返回的是一个字典,包含 t1、t2、mask 三个张量,训练时直接按 key 取。标签阈值设在 127,也就是原始灰度值大于 127 才认为是变化区域。数据增强我建议只做简单的几何变换:水平翻转、垂直翻转、90 度旋转,变化检测任务对几何增强比较友好,翻转不会破坏“哪里变了”这个语义。颜色抖动要谨慎,遥感影像的颜色本身就代表地物信息,瞎调亮度色相容易让模型学歪。
4. 训练与评估:损失函数搭配、学习率设置与指标解读
4.1 损失函数:BCE 与 Dice 的组合
逐像素二分类,第一反应是二值交叉熵。但遥感影像里“变化”和“未变化”严重不平衡,变化区域往往只占整张图的 5% 不到,单独用 BCE,模型很快学会全预测成“无变化”,loss 表面在降,F1 却在原地踏步。常见的解法是把 BCE 和 Dice loss 组合起来用:
import torch import torch.nn as nn class ChangeLoss(nn.Module): def __init__(self, bce_weight=0.5, dice_weight=0.5, smooth=1.0): super().__init__() self.bce = nn.BCEWithLogitsLoss() self.bce_weight = bce_weight self.dice_weight = dice_weight self.smooth = smooth def forward(self, logits, mask): bce = self.bce(logits, mask) prob = torch.sigmoid(logits) inter = (prob * mask).sum(dim=(1, 2, 3)) dice = 1.0 - (2.0 * inter + self.smooth) / ( prob.sum(dim=(1, 2, 3)) + mask.sum(dim=(1, 2, 3)) + self.smooth ) return self.bce_weight * bce + self.dice_weight * dice.mean()forward 里第一个操作是拿 logits 直接和 mask 算 BCE,不用先过 sigmoid,因为 BCEWithLogitsLoss 内部已经做了数值稳定的 sigmoid 计算。Dice 部分则要先过 sigmoid,因为它是基于“预测概率与标签的相似度”来算的。smooth 参数防止分母为 0,训练初期预测全零时不会除零崩溃。
两个权重怎么调?默认 0.5 对 0.5 起步。如果变化区域特别小,把 dice_weight 提到 0.7,模型会把注意力往变化区域倾斜。但别一次加太狠,Dice 权重过高会让 loss 曲面变陡,训练后期容易在验证集上抖动。
4.2 训练主循环与优化器参数
训练循环的骨架很标准,但有几个参数值得按经验值设好,我整理成一张表,照着设基本不会出大问题:
| 参数 | 建议值 | 说明 |
|---|---|---|
| batch_size | 4 ~ 8 | 256×256 输入下,batch=8 大约需要 14~16G 显存 |
| learning_rate | 1e-4 ~ 2e-4 | 加载预训练权重时从 1e-4 起步比较稳 |
| epochs | 50 ~ 100 | 数据量小 50 轮能收敛,复杂场景加长到 100 轮 |
| crop_size | 256 | 显存和精度的中间值,512 可试但容易 OOM |
| optimizer | AdamW | weight_decay 默认 1e-4 即可 |
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) for epoch in range(epochs): model.train() for batch in train_loader: t1 = batch["t1"].cuda() t2 = batch["t2"].cuda() mask = batch["mask"].cuda() optimizer.zero_grad() with autocast(): logits = model(t1, t2) loss = criterion(logits, mask) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()autocast 和 GradScaler 是 PyTorch 混合精度训练的标准组合,半精度计算能明显压低显存占用和训练时间,尤其对 Swin 这种参数体量的模型,开着能省 30% 以上的显存,值得养成默认开启的习惯。学习率调度上,我前 5 个 epoch 会用线性 warmup 把学习率从 0 升到目标值,Transformer 对学习率比卷积敏感得多,直接上大学习率,loss 常常在第一个 epoch 就冲飞,后面很难拉回来。
4.3 评估指标:F1、IoU 与 OA 的统计口径
训练过程中每个 epoch 结束要在验证集上算指标,放行的依据是验证集 F1,不是训练 loss。指标代码本身不复杂,但统计口径必须统一,我见过有人把 precision 写成分母是 tp + fp + fn,F1 直接算成 0.5,还拿去出报告,这就是没核对公式。
def calc_metrics(pred_mask, gt_mask, eps=1e-6): pred = pred_mask > 0.5 gt = gt_mask > 0.5 tp = (pred & gt).sum().float() fp = (pred & ~gt).sum().float() fn = (~pred & gt).sum().float() precision = tp / (tp + fp + eps) recall = tp / (tp + fn + eps) f1 = 2 * precision * recall / (precision + recall + eps) iou = tp / (tp + fp + fn + eps) oa = (pred == gt).sum().float() / gt.numel() return { "prec": precision.item(), "recall": recall.item(), "f1": f1.item(), "iou": iou.item(), "oa": oa.item(), }四个指标里,OA(整体精度)最容易虚高,因为未变化像素占大多数,全猜“没变”都有 90% 以上精度。F1 和 IoU 才是判断模型有没有用的关键,尤其是 IoU,它同时对漏检和误检做惩罚,IOU 0.7 以上的模型才接近能用的水平。
4.4 训练过程的产物:权重保存与变化概率图预览
训练流程里最容易忽略的是可视化。模型输出的是 logits,转成 sigmoid 概率之后,把概率图叠加到 T2 影像上,红色区域就是模型认为“变了”的地方。每 5 个 epoch 存几张预览图到磁盘上,肉眼扫一眼,比单看数字更能发现问题:
def save_overlay(t2_image, prob_map, save_path, threshold=0.5): overlay = t2_image.copy() overlay[prob_map > threshold] = (0, 0, 255) # BGR 红色 cv2.imwrite(save_path, overlay)从这几张预览图能看到三件事:模型有没有把整片区域都预测成变化,是不是只捡大块变化区域、小目标全丢了,以及变化区域的边缘是不是糊成一团。这些信息在 F1 数字里看不出来,必须看图。
5. 实战避坑:Transformer 变化检测的五个常见翻车点
5.1 训练 loss 震荡不收敛
现象:loss 在 0.6 附近来回跳,20 个 epoch 过去了一点下降的意思都没有,验证集 F1 始终贴着 0。
原因:三选一。没加载预训练权重,Swin 从头学遥感特征极其慢;学习率开得太大,Transformer 对学习率的敏感度远高于 CNN;以及没有 warmup,训练一开局就遭到大学习率冲击。
解决:先做一个 smoke test,拿 8 对图块、batch_size=2 跑几十步,确认 loss 能稳定降下来再上全量数据。然后检查预训练权重是否真的加载成功——打印模型的 stem 层参数,数值跟官方权重一致才算加载。最后把学习率收到 1e-4,加 5 个 epoch 的线性 warmup,loss 曲线会立刻变得能看懂。
5.2 GPU 显存溢出 OOM
现象:程序启动后第一个 batch 就报 CUDA out of memory,或者训练到中途半路崩掉。
原因:crop_size 和 batch_size 的组合超过了显存上限。Swin 的窗口注意力虽然省显存,但 FPN 解码器在高分辨率特征图上采样时显存消耗并不小。
解决:先把 batch_size 降到 4,crop_size 降到 256,这两步能解决九成 OOM。还不行就在编码器层开梯度检查点(gradient checkpointing),用少量训练时间换显存空间。混精度训练也要开,autocast 在显存紧张时是刚需,不是可选项。
5.3 小目标变化漏检严重
现象:训练出来 F1 在 0.8 以上,可视化一看,体积小的变化区域全被模型当成噪声滤掉了,只有成片的大变化能看到。
原因:网络经过多级下采样,8 倍下采样后一个 16×16 像素的小目标在特征图里只剩 2×2 像素,语义信息几乎消失。加上 BCE 权重偏高,小目标对 loss 的贡献被大面积未变化像素淹没。
解决:把 dice_weight 提到 0.7,让损失函数更关注类别不平衡;推理时不用整图直出,改成重叠滑窗,stride 设成 crop_size 的一半,相邻窗口重叠区域取平均,小目标被多个窗口重复覆盖,漏检率明显下降。
5.4 训练和推理预处理不一致
现象:训练时验证集的 F1 有 0.8,一上 predict.py,输出的变化图要么全黑,要么满是噪点。
原因:推理时的预处理和训练时不一致。常见的是忘了 normalize,直接把 0~255 的像素喂给模型;或者忘了把单张图扩成 4D 张量,模型拿到的 shape 是 (C,H,W),而训练时是 (B,C,H,W);还有 PIL 读图是 RGB,OpenCV 读图是 BGR,通道顺序对不上导致颜色语义完全错乱。
解决:把 normalize 和转张量封装成同一个函数,训练循环和推理脚本都调用它,不许各写一份。伸手改之前先打印输入张量的 mean、std、shape,对照训练时的值,确认一致再推理。这条我栽过一次,从那以后推理脚本的第一行一定是断言“shape 是 4D,数值范围在 -2 到 2 之间”。
5.5 验证集与真实场景落差太大
现象:验证集 F1 0.85,自己找了一块新区域的影像一测,F1 直接跌到 0.4,模型完全不敢用。
原因:训练集、验证集来自同一片区域,两期影像之间存在很强的空间自相关。模型学的可能不是“建筑怎么变化”,而是“这个地区的建筑长什么样”。换一个城市、换一个传感器,立刻露馅。
解决:验证集和测试集尽量跟训练集在空间上分开,比如训练用 A 市,验证用 B 市。训练时增加几何增强,弱化模型对特定区域纹理的依赖。推理结果出来之后做一次形态学后处理,先开运算去掉零星误检,再闭运算把断裂的变化区域连起来,这一步对最终视觉效果提升非常明显。
6. 推理脚本与进阶:滑窗推理、模型轻量化与开放词汇变化检测
推理阶段的代码在 predict.py 里,核心就是加载训练完的权重,把一对影像输出成一张变化图。这里我强烈建议用重叠滑窗而不是整图直出,尤其是当推理影像比训练时的 crop_size 大很多的时候:
def sliding_window_infer(model, t1, t2, crop_size=256, stride=128): model.eval() h, w = t1.shape[2], t1.shape[3] prob = torch.zeros((1, 1, h, w), device=t1.device) weight = torch.zeros_like(prob) with torch.no_grad(): for y in range(0, h - crop_size + 1, stride): for x in range(0, w - crop_size + 1, stride): p1 = t1[:, :, y:y + crop_size, x:x + crop_size] p2 = t2[:, :, y:y + crop_size, x:x + crop_size] logit = model(p1, p2) prob[:, :, y:y + crop_size, x:x + crop_size] += torch.sigmoid(logit) weight[:, :, y:y + crop_size, x:x + crop_size] += 1 prob = prob / weight.clamp(min=1) return (prob > 0.5).float()stride 设成 128,意思是相邻两个窗口有一半是重叠区域。重叠区域被多次预测,最后取平均,边缘伪影被显著削弱。这个技巧对提升变化边界质量比换模型更直接,先试推理参数,不满意再动网络结构。
这套源码往上走的方向,最值得说的是开放词汇变化检测。当前输出是二值图,“变了”还是“没变”就结束了。实际工程里往往要追问“发生了什么变化”——是新建房屋、森林砍伐、还是水体变化。做法是在解码器侧加一个语义头,把变化检测从二分类扩展成多分类;更进一步,把文本提示像 CLIP 那样作为条件输入,用“房子”“道路”“水体”等自然语言描述来控制检测目标,这是目前比单纯二分类实用得多的方向。
另一个方向是模型轻量化。Swin-Tiny 在推理时对显存和延迟仍然有压力,如果要做嵌入式或实时监测,可以把编码器换成更轻的骨干网络,再用当前模型做蒸馏,把变化检测能力迁移到小模型上。两期变多期也是自然扩展思路:把时间维当作 Transformer 的序列维,多期影像连续监测违法建设、非法开采这类场景,比单对单的检测更符合真实业务节奏。
最后讲讲我自己的习惯。从那以后,我每次拿到一套新的变化检测源码,都会先花半小时把预处理函数、训练和推理路径对齐一遍再做任何调参,这个习惯帮我躲掉了绝大多数“玄学”问题。下载这份源码后,也建议你先跑通整套流程,再考虑上自己的数据集。希望帮到你。
本文还有配套的精品资源,点击获取