简介:面向遥感与深度学习初学者的SAR图像变化检测实战项目,基于Python与神经网络(如CNN)完成多时相SAR图像的地表变化识别,可应用于地质灾害监测、城市扩展分析等场景。资源共195个文件,压缩包仅3.07MB,包含Python源码(.py)、PyTorch模型权重(.pt)、Vue前端组件、JavaScript逻辑、JSON配置、BMP/JPG样本图像及Markdown文档,目录覆盖模型定义、数据预处理、变化检测逻辑与训练配置,便于按模块研读和二次开发。包内附Ottawa地区实测图像样本与说明文档,可直观理解SAR数据特点及检测流程。已有104人学习下载,适合具备Python基础、希望将深度学习落地于遥感图像处理的研究者。整体包体虽小,但串联了数据、算法、界面与文档,是一份可运行的完整工程样例。
1. 为什么把 SAR 变化检测从传统算法换成神经网络
接手这个项目时,压缩包里就是一对 Ottawa 地区的 Radarsat-1 时序影像,一张洪水前、一张洪水后,外加一个 README 备份和几个前端资源文件。用传统差值法跑出来的变化图噪声大得没法看,河流两侧的细微地物变化和传感器噪声混在一起,阈值怎么调都顾此失彼。换成神经网络方案之后,最直观的变化是误检率明显下降——网络自动学习到的特征能区分“真实地表变化”和“噪声/配准残差”,这正是这套系统的核心价值。
整套方案适合两类人:一是做遥感时序分析的工程师,需要一个能快速复现的变化检测 baseline;二是做毕设或课程设计的同学,需要一个能讲清楚“数据预处理→模型训练→Web 展示”完整链路的参考实现。下面从数据处理开始,完整走一遍这个系统的搭建思路。
2. SAR 图像预处理的四个关键步骤
2.1 多时相影像对的读取与检查
压缩包里的ottawa_1.bmp和ottawa_2.bmp是同一地区不同时刻的 SAR 影像,before.bmp和after.bmp是另一组样本。实际做变化检测之前,先要确认两幅影像的空间范围和分辨率是否一致。SAR 影像和光学影像不同,它的几何畸变来自侧视成像,所以同一个地物在两幅图上的位置可能偏移几个像素,这会直接影响后续逐像素比较的准确性。
第一步是把影像读进来并检查 shape:
import cv2 import numpy as np img1 = cv2.imread("ottawa_1.bmp", cv2.IMREAD_GRAYSCALE) img2 = cv2.imread("ottawa_2.bmp", cv2.IMREAD_GRAYSCALE) print("img1 shape:", img1.shape, "dtype:", img1.dtype) print("img2 shape:", img2.shape, "dtype:", img2.dtype)IMREAD_GRAYSCALE直接读成单通道灰度图,这是 SAR 影像的标准处理方式——SAR 本身是单极化或双极化的强度数据,RGB 三个通道反而会引入无效信息。检查 shape 是否一致,不一致就得先做配准或裁剪。Ottawa 这组数据是公开数据集里处理好的,一般尺寸相同,但实际项目里经常碰到分辨率不同、范围对不齐的情况,这时候可以用cv2.matchTemplate或 ORB 特征点做配准,这里不展开。
2.2 相干斑噪声抑制:为什么不用高斯滤波
SAR 图像的固有问题是相干斑噪声(speckle),它源自雷达回波的相干叠加。高斯滤波会模糊边缘,而边缘恰好是变化检测最关心的信息。常见做法是用 Lee 滤波或增强 Lee 滤波,它会根据局部方差自适应调整滤波强度:平坦区域多平滑,边缘区域少平滑。
from skimage.restoration import denoise_bilateral # 用双边滤波近似保持边缘的滤波效果 img1_denoised = denoise_bilateral(img1, sigma_color=25, sigma_spatial=3) img2_denoised = denoise_bilateral(img2, sigma_color=25, sigma_spatial=3) # 或者直接用均值滤波做简易版 # img1_denoised = cv2.blur(img1, (5, 5))sigma_color控制灰度值差多少算作“不同区域”,数值越大,边缘保留得越模糊;sigma_spatial控制空间邻域大小。SAR 影像一般sigma_color取 20~30,sigma_spatial取 3 左右比较稳。如果用了 5x5 的均值滤波,效果也不算错,只是边缘处的变化检测结果会偏粗。工程上我一般建议先跑均值滤波看整体效果,再换双边滤波对比。
2.3 对数变换与差分图构造
SAR 强度数据的动态范围很大,直接做差值会被几个高亮目标主导。传统 SAR 变化检测里,最经典的操作是对数比(log-ratio):
# 避免除零 eps = 1e-6 log_ratio = np.abs(np.log((img2_denoised + eps) / (img1_denoised + eps))) # 归一化到 0~255 log_ratio_norm = cv2.normalize(log_ratio, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)用np.log做的那个比值,把乘法性的斑点噪声变成了加法性噪声,正态分布假设更容易成立,同时自然地压缩了动态范围。eps = 1e-6是防止某些像素灰度为 0 导致除零错误,这个细节在真实数据里几乎一定会碰到。cv2.normalize把结果拉伸到 0~255 方便后续处理和可视化,但这步只是显示用,训练神经网络时通常不做这个归一化,直接用浮点数。
2.4 分块策略:让训练样本数量可控
整幅图直接丢进神经网络不现实,内存和算力都不够。标准做法是切成 patch,每一对 patch 作为一条训练样本:
patch_size = 32 stride = 16 patches1 = [] patches2 = [] h, w = img1.shape[:2] for y in range(0, h - patch_size, stride): for x in range(0, w - patch_size, stride): p1 = img1_denoised[y:y+patch_size, x:x+patch_size] p2 = img2_denoised[y:y+patch_size, x:x+patch_size] patches1.append(p1.reshape(-1)) patches2.append(p2.reshape(-1))patch_size=32是经验值,太小了感受野不够,学不到区域级特征;太大了边界效应明显,样本量也减少。stride=16是 50% 重叠,既能扩大样本量,又不会让相邻样本完全重复。提取出来的 patch 数量大概是((308-32)//16+1)^2 ≈ 19*19=361对,够一个简单网络训练用。如果数据不够,可以增加重叠比例或对原图做翻转/旋转增强。
3. 神经网络模型选型与训练实现
3.1 为什么最终选 Siamese 结构的 CNN
刚才把两幅图直接拼接输入网络,也能训练,但效果一般。原因是:SAR 变化检测的本质是判断“同一位置在不同时刻是否发生了变化”,这天然是一个双输入问题。Siamese 网络(孪生网络)的结构恰好契合这个场景——两个分支共享权重,分别提取两幅影像的特征,然后在高层做差异度量。共享权重意味着两幅图用同样的规则提取特征,不会偏向某一时刻。
也有更简单的方案:构造差分图后放进普通 CNN 分类器。这种做法的缺点是差分图丢掉了原始纹理信息,遇到辐射差异大的数据对时误检率高。Siamese 网络直接吃原始 patch,让网络自己学“什么是变化”的度量方式,鲁棒性明显好。值得注意的是,这个思路和当前遥感领域主流的“时相编码 + 差异注意力”框架相比,实现成本低,但已经能覆盖大部分工程场景。
3.2 网络结构定义
import torch import torch.nn as nn import torch.nn.functional as F class SiameseCNN(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 16, 3, padding=1) self.bn1 = nn.BatchNorm2d(16) self.conv2 = nn.Conv2d(16, 32, 3, padding=1) self.bn2 = nn.BatchNorm2d(32) self.conv3 = nn.Conv2d(32, 64, 3, padding=1) self.bn3 = nn.BatchNorm2d(64) self.pool = nn.MaxPool2d(2) self.fc = nn.Linear(64 * 4 * 4, 2) def forward_one(self, x): x = F.relu(self.bn1(self.conv1(x))) x = self.pool(x) x = F.relu(self.bn2(self.conv2(x))) x = self.pool(x) x = F.relu(self.bn3(self.conv3(x))) x = self.pool(x) return x.view(x.size(0), -1) def forward(self, x1, x2): f1 = self.forward_one(x1) f2 = self.forward_one(x2) diff = torch.abs(f1 - f2) out = self.fc(diff) return out三个卷积层逐步把通道数从 1 扩到 64,每层后面跟 BatchNorm 和 ReLU,过两次 MaxPool 下采样。forward_one分别提取两幅图的特征向量,forward里用torch.abs(f1 - f2)计算特征差异,再送进全连接层做二分类(变化/未变化)。用绝对差而不是拼接,是因为绝对差直接表达了“特征之间的偏离程度”,对变化检测更直观,也有利于网络收敛。
3.3 训练数据自动标注策略
训练神经网络需要标签,也就是“哪些 patch 是变化的”,但这份数据里没有标注图。常见做法是用传统方法预先生成伪标签:先用 2.3 节得到的对数比图做阈值分割,或者用 K-Means 聚类分两类,把聚类的输出当作弱标签。虽然不完美,但有足够样本量时可以训练出一个比传统方法更鲁棒的模型。
from sklearn.cluster import KMeans pixels = log_ratio_norm.reshape(-1, 1).astype(np.float32) kmeans = KMeans(n_clusters=2, random_state=0, n_init=10).fit(pixels) labels = kmeans.labels_.reshape(log_ratio_norm.shape) # 阈值分割的替代方案 # _, labels_simple = cv2.threshold(log_ratio_norm, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)K-Means 在这里的物理含义是:把对数比图的像素强度自动分成“低差异”(未变化)和“高差异”(变化)两类。n_init=10表示用 10 次随机初始化选最优结果,避免陷入局部最优。Otsu 阈值法运行更快,但遇到灰度分布重叠严重的数据对时,分割结果不如 K-Means 稳定。
3.4 训练配置与超参数说明
训练时把 pair 和伪标签组装成 TensorDataset,使用交叉熵损失:
import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset # 假设 X1, X2, Y 已经是 numpy 数组 X1_t = torch.FloatTensor(X1).unsqueeze(1) X2_t = torch.FloatTensor(X2).unsqueeze(1) Y_t = torch.LongTensor(Y) dataset = TensorDataset(X1_t, X2_t, Y_t) loader = DataLoader(dataset, batch_size=64, shuffle=True) model = SiameseCNN() optimizer = optim.Adam(model.parameters(), lr=1e-4, weight_decay=1e-5) criterion = nn.CrossEntropyLoss() for epoch in range(80): for batch_x1, batch_x2, batch_y in loader: optimizer.zero_grad() output = model(batch_x1, batch_x2) loss = criterion(output, batch_y) loss.backward() optimizer.step() if (epoch + 1) % 20 == 0: print(f"Epoch {epoch+1}, Loss: {loss.item():.4f}")几个参数的取值逻辑:lr=1e-4是 CNN 微调常用的起点,太大会导致伪标签中的噪声被放大,太小则收敛慢。weight_decay=1e-5是轻量 L2 正则,防止网络在标签不准时过拟合到噪声上。batch 取 64 是因为 patch 是 32x32 的小图,显存占用不大,batch 再大梯度方向不稳定,再小训练太慢。80 轮在 CPU 上跑大约 10~20 分钟,加 BatchNorm 后不需要特别多轮次就能看到效果。
4. 从概率图到变化图的后处理与 Web 集成
4.1 全图推理与概率图生成
训练好的模型要应用到整幅影像上,仍然用滑动窗口切 patch,逐 patch 预测,再把预测概率拼回原图尺寸:
model.eval() prob_map = np.zeros((h, w), dtype=np.float32) count_map = np.zeros((h, w), dtype=np.float32) with torch.no_grad(): for y in range(0, h - patch_size, stride): for x in range(0, w - patch_size, stride): p1 = img1_denoised[y:y+patch_size, x:x+patch_size] p2 = img2_denoised[y:y+patch_size, x:x+patch_size] t1 = torch.FloatTensor(p1).unsqueeze(0).unsqueeze(0) t2 = torch.FloatTensor(p2).unsqueeze(0).unsqueeze(0) prob = F.softmax(model(t1, t2), dim=1)[0, 1].item() prob_map[y:y+patch_size, x:x+patch_size] += prob count_map[y:y+patch_size, x:x+patch_size] += 1 # 重叠区域取平均 prob_map = prob_map / np.maximum(count_map, 1)这里用F.softmax取出“变化”类别的概率,而不是直接用 argmax 取类别。概率图保留了置信度信息,后续可以灵活调整阈值。重叠区域(因为 stride < patch_size)用累加求平均的方式融合,避免了拼接边界出现明显的条带效应。
4.2 形态学后处理与精度评估指标
原始概率图转成二值图后,会有大量孤立的小噪声区域,这是因为 SAR 斑点噪声即使经过滤波也残留部分高响应。常见做法是先用阈值 binarize,再做形态学开运算,最后用连通域面积筛选:
_, binary = cv2.threshold(prob_map, 0.55, 1, cv2.THRESH_BINARY) binary = binary.astype(np.uint8) # 开运算:先腐蚀后膨胀,去除细小噪声 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=2) # 连通域面积过滤 num_labels, labels_im, stats, _ = cv2.connectedComponentsWithStats(opened, 8) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] < 50: opened[labels_im == i] = 0阈值0.55比默认的 0.5 略高,因为伪标签训练会让模型输出偏向“变化”类,稍微提高阈值可以压掉部分假阳性。开运算的内核大小 3x3,迭代 2 次,对 Ottawa 数据来说够用;如果影像分辨率更高,内核要相应放大。connectedComponentsWithStats后面那个< 50是面积阈值,低于 50 像素的区域直接视为噪声。这个阈值需要根据影像尺寸调整——影像越大,真实变化区域越大,阈值也应相应增加。
效果评估方面,如果测试区域有 ground truth 标注图,用这三项就够:
| 指标 | 公式 | 含义 |
|---|---|---|
| OA | (TP+TN)/(TP+TN+FP+FN) | 总体精度 |
| Kappa | (OA - pe)/(1 - pe) | 排除随机一致性的精度 |
| F1 | 2PR/(P+R) | 兼顾误检和漏检 |
在公开的 Ottawa 数据集上,网络方案一般能到 94% 左右的 OA 和 0.80 以上的 Kappa。如果没有标注图,就只能目视对比颜色差异和形状完整性。
4.3 Web 端如何集成:FastAPI 与 axios 的配合
压缩包里的axios.cjs、index.d.cts说明前端展示层是通过 HTTP 调用后端接口的。整个 Web 端的逻辑是:用户在页面上传两张 SAR 影像,后端调已训练好的模型完成预处理、推理、后处理,返回变化检测结果图和统计数据。
后端用 FastAPI 暴露一个接口:
# app.py from fastapi import FastAPI, UploadFile, File from fastapi.responses import JSONResponse, FileResponse import numpy as np import cv2 app = FastAPI() @app.post("/detect") async def detect(file1: UploadFile = File(...), file2: UploadFile = File(...)): # 读入上传影像 data1 = await file1.read() data2 = await file2.read() img1_arr = np.frombuffer(data1, np.uint8) img2_arr = np.frombuffer(data2, np.uint8) img1 = cv2.imdecode(img1_arr, cv2.IMREAD_GRAYSCALE) img2 = cv2.imdecode(img2_arr, cv2.IMREAD_GRAYSCALE) # 此处调用 2~4 节中的预处理、模型推理、后处理流程 # result_map 为最终二值图 cv2.imwrite("result.png", result_map * 255) return JSONResponse(content={"status": "ok", "result": "/static/result.png"})UploadFile是 FastAPI 处理文件上传的标准类型,cv2.imdecode能把前端传过来的二进制流直接解码成图像数组,省去了先落盘再读盘的 IO 开销。返回的 JSON 里给前端一个图片 URL,前端拿到后直接更新<img>标签即可。
前端用 axios 上传:
// detect.js const formData = new FormData(); formData.append("file1", fileInput1.files[0]); formData.append("file2", fileInput2.files[0]); axios.post("http://127.0.0.1:8000/detect", formData, { headers: { "Content-Type": "multipart/form-data" } }) .then(res => { document.getElementById("result").src = res.data.result; }) .catch(err => console.error(err));FormData传文件时前端不需要手写multipart/form-data边界,浏览器会自动处理。Content-Type理论上也可以不写,但显式声明更保险。axios在压缩包里出现.cjs结尾的文件,说明前端可能是 Electron 或 CommonJS 模块环境,注意require("axios")和import axios from "axios"的差异即可。
5. 迁移学习与模型检查的三个进阶技巧
5.1 换一块数据时,别从头训
Ottawa 数据只有几十 MB,训练一个模型很轻松,但换成 Sentinel-1 的大幅影像时,从头训练的时间和标注成本都会上升。常见做法是把已经训好的权重当作预训练模型,在新数据上做微调:
# 加载已有权重 model.load_state_dict(torch.load("sar_change_det.pth")) # 只微调最后一层,前几层冻结 for name, param in model.named_parameters(): if "fc" not in name: param.requires_grad = False optimizer = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=5e-5)冻结策略的逻辑是:浅层卷积学到的是边缘、纹理这类通用特征,不同数据集之间可以共享;只有最后的全连接层需要适配新数据的“差异度量方式”。学习率降到5e-5,因为只有少量参数更新,步长太大会震荡。
5.2 模型输出检查:可视化特征而不是只看准确率
训练完模型后,把forward_one输出的特征图可视化,能直观判断网络有没有学到有意义的东西:
def visualize_feature(model, img_patch): model.eval() x = torch.FloatTensor(img_patch).unsqueeze(0).unsqueeze(0) with torch.no_grad(): feat = model.conv1(x) feat_map = feat[0].detach().numpy() return feat_map # feat_map shape: (16, 32, 32),挑几个通道画出来即可如果特征图里有清晰的边缘轮廓,说明网络学到了结构信息;如果全是噪声或全零,可能是 BatchNorm 没有收敛或者数据归一化有问题。这一步排查效率比盯着 loss 曲线高得多。
5.3 单类别的误检分析技巧
变化检测最容易出现的问题是河流区域的大面积误检——水位变化导致后向散射系数变化,算法把它识别成了地表变化。解决思路是给输出加一层地理先验掩码:
# 假设 water_mask 是河流区域掩码(手工标注或从原图阈值得到) # 把河流区域的变化结果直接置为 0 result_final = opened.copy() result_final[water_mask == 1] = 0更通用的做法是记录误差集中出现在哪些类型的地物上。分析时把检测结果和原始影像叠成半透明伪彩色图,用 OpenCV 的addWeighted叠加,快速定位误检区域的空间分布模式。这类分析做多了,会对模型的盲区形成直觉判断:SAR 变化检测的漏检往往集中在低后向散射区域(如平静水面),误检则集中在高异质区域(如城区边缘)。
整个系统的链路到这里就闭环了:数据预处理 → 神经网络训练 → 后处理生成变化图 → Web 接口展示。如果要把这套方案用到更大的数据上,优先考虑把 Siamese 主干换成 ResNet 预训练版本,并在模型输出后加一层 CRF 做空间平滑,效果提升会非常明显。
本文还有配套的精品资源,点击获取