简介:这套基于Python神经网络学习的SAR图像变化检测系统,以Web应用形式封装,面向遥感图像处理、深度学习入门及SAR变化检测应用开发者。项目融合神经网络模型、前后端交互与图像处理流程,可用于灾害监测、城市规划、环境研究等场景的快速验证与初步实践。压缩包共195个文件,大小仅3.07MB,主要包含4个py源码、3个pt模型权重、若干bmp/jpg/png样本图像,以及vue/js/ts前端页面、md/json说明配置等,结构上覆盖数据、模型、配置与文档。已有104人浏览学习。通过解压可直接查看工程组织方式,理解数据预处理、模型训练与变化检测的调用链路;文档与配置文件有助于快速搭建环境,代码结构较为清晰,适合作为完整小项目进行代码阅读与实操复现。
1. 基于Python神经网络学习的SAR图像变化检测系统-web.zip:到底解决什么问题
如果你手上只有一张去年某山区的SAR影像和一张今年同时相的SAR影像,想快速知道哪片林子被砍了、哪片水域扩大了,你会怎么做?像素相减、阈值分割,三分钟能出图,但结果往往被相干斑噪声掩盖得看不清细节。这套基于Python神经网络学习的SAR图像变化检测系统-web.zip,本质上是把「两幅SAR影像输入 → 神经网络推断 → 输出变化区域标注」这条链路做成了开箱即用的Web服务,适用于遥感变化发现、违建巡查辅助、灾害应急评估等场景。适合谁?适合已经能跑通深度学习基础代码、但想把模型落成可交付Web工具的算法工程师和GIS开发。它不解决算法之外的业务合规问题,但能把你的模型从Jupyter Notebook里搬出来,变成一个真正有人使用的系统。
2. 从双时相影像到训练样本:SAR数据预处理为什么决定检测上限
2.1 SAR成像特征对变化检测的直接影响
SAR(合成孔径雷达)是主动式微波成像,不受云雨和光照影响,但代价是成像机制带来了严重的相干斑噪声。和光学影像不同,SAR影像中同一块均匀地物在相邻像素上的回波强度会有明显随机起伏,这让「同一位置不同时相的像素差」天然存在大量假变化。如果直接用两期影像做逐像素相减,结果图打开后往往是一片密密麻麻的椒盐噪声。
变化检测的核心矛盾就在这:真实变化信号和相干斑噪声在统计特性上高度混淆。早期常用的均值比、对数比差异图方法,本质是手工设计特征去压低噪声、突出真实变化区域,但手工特征对地物类型多样、噪声水平不同、配准误差不一致的实际情况适应力有限。神经网络的介入方式,其实是把「如何区分噪声与真实变化」这个问题交给模型去隐式学习,前提是你提供的训练样本质量足够高。
提示:SAR影像变化检测的预处理环节重要性高于模型结构本身。公开数据集尺寸普遍偏小,预处理做不好,换任何网络都容易过拟合。
2.2 数据来源与样本构造的常见做法
公开的SAR变化检测数据集很小,常见的有Bern等城市区域的单场景双时相数据,通常只有几百到一两千像素的尺寸,训练一个UNet都很勉强。我一般会优先找ERS-1/2、Sentinel-1的公开存档切片来构造训练数据。Sentinel-1的Ground Range Detected产品经过多视处理,相干斑相对轻,适合做预训练;高分辨率SAR(如TerraSAR-X级)数据集少,通常以迁移学习为主。
没有公开标注数据时,工程上常用的做法是构造伪样本:取同一地区的不同日期影像做配准后,人工把若干区域做灰度扰动或局部纹理替换,模拟「变化区域」,并保留大量未扰动区域做负样本。这种数据构造方式是很多落地项目实际采用的手段,虽然带标签的数据不完美,但足以让网络学到「该关注什么样的局部差异」。
2.3 预处理流程与代码实现
拿到原始双时相影像后,典型预处理流程要经过这几个环节:辐射定标、地形校正(可选)、对数变换压缩动态范围、滤波降斑、归一化到0-1区间、切块。下面这一段是核心预处理代码,用Python实现:
import numpy as np import cv2 from scipy.ndimage import uniform_filter import albumentations as A def preprocess_sar_pair(img_t1, img_t2, filter_size=5): # 1. 对数变换:压缩SAR数据动态范围,消除乘性噪声影响 t1_log = np.log1p(np.clip(img_t1, 0, None)).astype(np.float32) t2_log = np.log1p(np.clip(img_t2, 0, None)).astype(np.float32) # 2. Boxcar均值滤波去除相干斑,窗口尺寸需要根据分辨率调整 t1_f = uniform_filter(t1_log, size=filter_size) t2_f = uniform_filter(t2_log, size=filter_size) # 3. 逐像素min-max归一化,保持双时相尺度一致 vmin = min(t1_f.min(), t2_f.min()) vmax = max(t1_f.max(), t2_f.max()) t1_n = (t1_f - vmin) / (vmax - vmin + 1e-8) t2_n = (t2_f - vmin) / (vmax - vmin + 1e-8) # 4. 构造双通道输入 [H, W, 2] pair = np.stack([t1_n, t2_n], axis=-1).astype(np.float32) return pair # 训练时的几何增强(注意:亮度扰动在这里通常不适用) train_aug = A.Compose([ A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.25), A.RandomRotate90(p=0.5), ])这段代码里有几个容易忽略的点。对数变换在SAR处理里几乎是必做的,因为SAR原始信号是乘性噪声模型,取对数后噪声变成加性,更利于后续滤波和网络学习。滤波窗口选用Boxcar是因为它计算快、对不同地物类型适应性尚可;如果噪声特别重,可以换成Lee滤波或者增强Lee滤波,但那样会引入额外超参数。
为什么第4步要把双时相堆叠成双通道而不是各自单通道?因为后续网络如果走差异图路线,可以直接从两通道学习差异特征;如果走双时相直接输入路线,这也是最基础的输入组织形式。归一化时取双时相的全局min/max是保持辐射一致性,很多新手犯错在于分图归一化后,同一地物在两期影像中的灰度变了,等于人为制造了假变化。
注意:这一节里故意没有加入亮度对比度类数据增强。SAR变化检测中,双时相的辐射一致性是最宝贵的先验,任何会破坏像素对应关系的强增强(包括随机伽马校正、高斯噪声)都会降低验证集上的准确率,属于需要长期踩坑才会有的血泪经验。
3. 差异图路线还是双时相直接输入:网络选型与训练参数设定
3.1 两条技术路线的对比与选择
基于深度学习做SAR变化检测,当前主流有两类路线:第一类是先构造差异图(如对数比值图),再把单通道差异图送入卷积神经网络做二分类分割;第二类是直接把双时相影像堆叠或通过双分支网络输入,让网络隐式学习变化表征。差异图路线的优点是目标任务简化了,网络只需要专注学「差异图里什么样的模式是真实变化」,训练稳定;缺点是把原始信息的筛选提前到人工特征设计阶段,如果差异图构造方式不匹配数据,上限受限。
第二类路线即双时相直接输入,这是实际落地中我越来越倾向的选型。原因有三:一是SAR噪声模式复杂,一个好的特征提取器完全能自己学会比手工比值图更鲁棒的差异表征;二是可以方便复用光学遥感分割领域的预训练权重做迁移学习;三是推理时不用额外计算差异图,整个流程更简洁。代价是网络结构更复杂,对小数据集更容易过拟合。
结构上我推荐共享权重的Siamese编码器加解码器结构,也就是编码部分两个时相共用一套卷积核。共享权重有两个实际价值:一是参数减半,在数据量小的时候显著降低过拟合风险;二是强制模型对两个时相做完全一致的特征提取,避免学到「时相1的纹理特征」这类混淆信号。
3.2 UNet剪裁版模型定义与参数说明
下面给出一个可直接跑的轻量UNet剪裁版,输入双通道影像,输出单通道变化概率图,适合在单卡GPU或CPU上训练入门:
import torch import torch.nn as nn import torch.nn.functional as F class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.conv(x) class SiameseUNet(nn.Module): def __init__(self, in_channels=1, base_ch=32, num_classes=1): super().__init__() # 共享编码器:输入单时相单通道 self.enc1 = ConvBlock(in_channels, base_ch) # 32通道 self.enc2 = ConvBlock(base_ch, base_ch * 2) # 64通道 self.enc3 = ConvBlock(base_ch * 2, base_ch * 4) # 128通道 self.pool = nn.MaxPool2d(2) # 解码器(两分支特征拼接后送入) self.up3 = nn.ConvTranspose2d(base_ch * 4, base_ch * 2, 2, stride=2) self.dec3 = ConvBlock(base_ch * 4, base_ch * 2) # 128+64 -> 64 self.up2 = nn.ConvTranspose2d(base_ch * 2, base_ch, 2, stride=2) self.dec2 = ConvBlock(base_ch * 2, base_ch) # 64+32 -> 32 self.head = nn.Conv2d(base_ch, num_classes, 1) def encode(self, x): f1 = self.enc1(x) f2 = self.enc2(self.pool(f1)) f3 = self.enc3(self.pool(f2)) return f1, f2, f3 def forward(self, t1, t2): # 共享权重编码两期影像 e1_1, e1_2, e1_3 = self.encode(t1) e2_1, e2_2, e2_3 = self.encode(t2) # 特征拼接融合 x = torch.cat([e1_3, e2_3], dim=1) x = self.up3(x) + torch.cat([e1_2, e2_2], dim=1) x = self.dec3(x) x = self.up2(x) + torch.cat([e1_1, e2_1], dim=1) x = self.dec2(x) return torch.sigmoid(self.head(x))这段代码的关键设计点有三处。一是共享权重编码器,PyTorch里同一套模块对t1和t2各跑一次前向,梯度会同时更新两路,这是Siamese结构的核心;二是跨层拼接时用了加法而非concat,配合转置卷积的上采样,能减少参数量的同时保持梯度传播稳定;三是最后用sigmoid输出概率图,而不是直接输出类别标签,这会直接影响后面阈值选择的灵活性。
训练参数方面,我常用的基础配置是:AdamW优化器,初始学习率1e-4,权重衰减1e-4,BatchSize在网络能放下显存的前提下取16或32;损失函数用BCEWithLogitsLoss加DiceLoss的加权组合(BCE权重0.6,Dice权重0.4)。类别不平衡在变化检测里很突出,变化区域通常只占全图5%以下,因此BCE需要设置pos_weight为负正样本比的估计值,通常在10到30之间。
训练轮数建议控制在60到80轮,并配合学习率余弦退火。SAR变化检测数据集小,模型通常在30轮左右开始过拟合,这时需要靠验证集的F1分数决定是否early stopping。怎么判断过拟合不是玄学?观察训练集F1和验证集F1的差值,差值超过0.1就该停了。
3.3 推理阶段的增强细节
训练完成后,推理阶段有一个常被忽视的提升手段:测试时增强。因为SAR影像的几何畸变和斑点噪声分布是随机的,对输入做水平翻转、垂直翻转和旋转90度后分别推理,再对概率图取平均,能有效压低噪声带来的误检。实测对验证集F1的提升大约在0.02到0.05之间,成本只是推理时间翻几倍。
def predict_tta(model, t1_tensor, t2_tensor): model.eval() probs = [] with torch.no_grad(): for t1_a, t2_a in [(t1_tensor, t2_tensor), (torch.flip(t1_tensor, [3]), torch.flip(t2_tensor, [3])), (torch.flip(t1_tensor, [2]), torch.flip(t2_tensor, [2])), (torch.rot90(t1_tensor, 1, [2, 3]), torch.rot90(t2_tensor, 1, [2, 3]))]: p = model(t1_a, t2_a) # 逆变换还原 p = torch.rot90(p, -1, [2, 3]) if p.shape[3] != t1_tensor.shape[3] or p.shape[2] != t1_tensor.shape[2] else p p = torch.flip(p, [3]) if t1_a.shape[3] != t1_tensor.shape[3] else p probs.append(p) return torch.stack(probs).mean(dim=0)注意翻转和旋转的逆变换要对齐,Flask推理接口里最容易翻车的点就在这里:翻转了输入却忘了翻转输出,概率图就会和原始影像错位。建议在本地先用一对小尺寸影像验证TTA输出的shapes和空间对齐关系,再集成到Web服务。
4. Web封装不是简单套壳:Flask推理接口、切块与结果输出
4.1 为什么用Flask做模型服务层
整个系统名为web.zip,说明最终交付物是Web形态。常见的Python Web框架有三档选择:Django偏重量级,带全套ORM和后台管理;FastAPI自带异步支持和OpenAPI文档,适合高并发纯API服务;Flask轻量、灵活、中间件生态成熟,最适合把单个模型包装成带上传和可视化功能的页面。
我选Flask的原因很朴素:模型推理本身是计算密集型任务,瓶颈在GPU和数据处理,不在Web框架的并发能力;Flask能把「上传图片 → 预处理 → 模型推理 → 返回结果图」整条链路写在同一个文件里,对后续维护最友好。多用户并发场景下再在前端加一层Nginx做静态资源代理即可,模型服务本身用单进程就够了。
4.2 模型加载、切块推理与接口约定
一个容易出的低级错误是每来一次请求就重新加载一次模型权重。模型加载后应该放在全局变量里,进程启动时加载一次,推理接口只做数据张量化、前向传播和还原。下面是完整的模型服务化代码框架:
from flask import Flask, request, jsonify, send_file import torch import numpy as np import io from PIL import Image import base64 app = Flask(__name__) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 全局加载模型,避免每次请求重复加载 model = SiameseUNet(in_channels=1, base_ch=32) model.load_state_dict(torch.load("sar_cd_model.pth", map_location=device)) model.to(device).eval() INPUT_SIZE = 256 STRIDE = 192 # 重叠步长,减少切块边缘伪影 def reconstruct_from_patches(patches, img_h, img_w): """将滑窗推理的概率图拼回原图尺寸,重叠区域取平均""" prob_map = np.zeros((img_h, img_w), dtype=np.float32) weight_map = np.zeros((img_h, img_w), dtype=np.float32) idx = 0 for y in range(0, img_h - INPUT_SIZE + 1, STRIDE): for x in range(0, img_w - INPUT_SIZE + 1, STRIDE): prob_map[y:y+INPUT_SIZE, x:x+INPUT_SIZE] += patches[idx] weight_map[y:y+INPUT_SIZE, x:x+INPUT_SIZE] += 1.0 idx += 1 return prob_map / np.maximum(weight_map, 1e-6) @app.route("/predict", methods=["POST"]) def predict(): t1_file = request.files["t1_image"] t2_file = request.files["t2_image"] # 读取并预处理为双通道浮点数组 t1_arr = np.array(Image.open(t1_file).convert("L"), dtype=np.float32) t2_arr = np.array(Image.open(t2_file).convert("L"), dtype=np.float32) assert t1_arr.shape == t2_arr.shape, "两期影像尺寸不一致" pair = preprocess_sar_pair(t1_arr, t2_arr) # 切块推理 img_h, img_w = pair.shape[:2] patches = [] for y in range(0, img_h - INPUT_SIZE + 1, STRIDE): for x in range(0, img_w - INPUT_SIZE + 1, STRIDE): t1_patch = pair[y:y+INPUT_SIZE, x:x+INPUT_SIZE, 0] t2_patch = pair[y:y+INPUT_SIZE, x:x+INPUT_SIZE, 1] t1_t = torch.from_numpy(t1_patch).unsqueeze(0).unsqueeze(0).to(device) t2_t = torch.from_numpy(t2_patch).unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): p = model(t1_t, t2_t).squeeze().cpu().numpy() patches.append(p) prob_map = reconstruct_from_patches(patches, img_h, img_w) # 默认阈值0.5,可在请求参数中覆盖 threshold = float(request.form.get("threshold", 0.5)) change_mask = (prob_map > threshold).astype(np.uint8) * 255 # 返回缩略图和数据统计 mask_pil = Image.fromarray(change_mask) buf = io.BytesIO() mask_pil.save(buf, format="PNG") buf.seek(0) change_ratio = float((change_mask > 0).mean()) return jsonify({ "change_ratio": round(change_ratio, 4), "changed_pixels": int((change_mask > 0).sum()), "mask_base64": base64.b64encode(buf.getvalue()).decode("utf-8"), }) app.run(host="0.0.0.0", port=5000)这段代码里切块推理是Web落地的关键。为什么不能整图直接推理?因为SAR影像动辄上万像素宽,直接送进网络会OOM,即便显存放得下,也超过网络感受野能有效编码的范围,产出大量无意义的全局上下文干扰。切块尺寸256和步长192组合的含义是相邻两个patch有64像素重叠,重叠区概率取平均,这样拼图接缝处的预测不会出现明显的条带。
4.3 前端交互与参数可调性设计
Web界面不需要复杂框架,一个单页HTML配原生JavaScript就够用。核心交互是:上传两期影像 → 点击检测 → 等待推理 → 显示变化区域叠加图和变化面积百分比。前端调用接口的代码很简单,但有一个值得注意的细节是过期请求处理。用户可能连续点击多次「检测」,前面请求还未完成。这时候要给请求加上递增序号,只接受最后一次请求的响应,避免旧结果覆盖新结果。
参数可调性方面,我把threshold做成前端滑块控件,用户可以在0.2到0.8之间拖动。这在实际使用里很重要:不同区域的变化检测,最优阈值差异极大。城市扩展区域的变化边界清晰,阈值0.4即可;农田变化边界模糊,往往要把阈值调到0.25才不至于漏检。提供这个旋钮,比让用户每次改代码重新部署实际得多。
5. 避坑:SAR变化检测系统最常见的 5 个翻车现场
5.1 结果图出现密集椒盐状伪变化
现象:推理出的变化区域呈现密集离散点状分布,看起来像整幅图都被撒了一层盐。
原因:SAR相干斑噪声在预处理阶段没有被充分抑制,或者双时相影像配准误差较大导致地物边缘错位。深层原因是训练数据里没有包含与推理数据噪声水平相当的负样本。
解决:先用更大幅度的滤波窗口做预处理测试,将filter_size从5调整到9或11,观察伪变化是否显著下降。如果有效,说明噪声是主因;接着在训练数据中多裁剪无变化区域样本,让网络见过足够多的「同地物不同时相的轻微灰度波动」模式。
5.2 训练loss正常下降但验证F1上不去
现象:训练集F1能到0.9,验证集F1一直徘徊在0.6附近,模型没有崩溃,但也学不到真实变化特征。
原因:训练数据与验证数据之间存在分布偏移。最常见的是训练样本大多来自城市区域,验证集却包含大片农田;或是训练时切块尺寸和验证时不一致,导致感受野覆盖的地物范围不同。
解决:检查训练和验证数据的来源是否足够分散;把切块尺寸统一固定;在验证集上分别统计不同地物类别的F1,定位是哪类地物拖了后腿。如果农田区域指标极低,就补充农田样本或对该类样本做过采样。这类问题没有捷径,得靠逐类分析定位。
5.3 Web服务第一次请求响应极慢,之后正常
现象:服务启动后第一次调用/predict接口等了十几秒,第二次就恢复到几百毫秒,排查发现GPU显存占用正常,CPU也没有跑满。
原因:这是典型的冷启动问题。TORCH和CUDA运行时的初始化、cuDNN的自动调优算法选择都发生在第一个请求触发时;另外模型第一次前向传播会触发CUDA kernel编译和显存分配。
解决:在模型加载完成后主动执行一次空前向传入一个全零张量,把CUDA初始化提前完成。代码里在app.run之前调用model(torch.zeros(1,1,256,256), torch.zeros(1,1,256,256))即可。这个小改动会让Web服务首请求响应时间缩短数秒,属于上线前必须做的一个细节。
5.4 切块边缘出现规律性条带伪影
现象:拼图结果里出现X方向和Y方向间距相等的细线状假变化,位置恰好对应切块边界。
原因:重建时重叠区域平均权重没有正确累加,或者步长设置导致某些像素仅被一个patch覆盖。另一种可能是切块尺寸不是网络下采样倍数的整数倍,导致边界处的BatchNorm统计量异常。
解决:先检查reconstruct_from_patches的权重累加是否正确;再确认INPUT_SIZE能被网络stride整除。更稳妥的做法是在推理时对每个patch的边缘做一定像素数的裁剪,只保留patch中心区域的有效预测,这样拼图完全依赖重叠区平均,条带会明显减弱。
5.5 变化面积统计结果被业务方质疑
现象:系统输出的变化面积百分比是8%,但业务人员人工目视估计不足3%,要求解释偏差。
原因:阈值设置偏低且未做后处理,把大块连续变化区域周围的零星噪声点也算进去了。
解决:在输出mask后增加最小连通域面积过滤,比如变化区域面积少于50个像素的连通域直接置0。同时给前端提供两个统计口径:原始概率图平均值和过滤后变化面积。让业务方看到阈值调节对面积统计的影响曲线,比单给一个数值更有说服力。
6. 进阶验证:从「能出图」到「能交付」的最后一公里
系统开发完成后,别急着交付。给你一套我每次做SAR变化检测项目都要做的验证清单,确保系统在不同区域、不同时相数据上的表现稳定,而不是只在测试集上好看。
首先是逐类混淆矩阵分析。SAR变化检测输出的是变化/不变二分类,但失败模式并不对称:漏检一块真实变化区域(例如新增建筑)和误检一块噪声斑点,对业务方的代价完全不一样。所以在验证阶段要把变化区域细分,比如把「新增建筑」「植被砍伐」「水体扩展」分开统计各自的检出率和误检率,再决定整体阈值偏向。这项工作需要业务方参与标注验证样本,不要自己埋头做。
其次是时间泛化验证。SAR变化检测模型最容易被质疑的地方是换了时间段就失灵。做法是准备三个时间段的双时相样本:训练期内的、训练期刚结束的、训练期半年后的,分别统计F1。如果半年后指标明显下降,说明模型学到了特定天气或轨道条件下SAR成像特征的短期模式,而非真正的地物变化特征。遇到这种情况,先把预处理阶段的滤波参数调得更平滑,再看是否需要在损失函数中引入对相干斑噪声更鲁棒的训练策略。
最后是滑窗投票的必要性验证。对比单次整图推理和TTA投票推理在同一批验证影像上的不一致区域占比,如果差异超过5%,说明模型预测不稳定,应优先排查输入归一化是否受单张影像局部灰度分布影响。我在一个项目中遇到过输入影像含大面积水域导致全图灰度直方图偏移,归一化后陆地纹理被压缩的问题,最终是把归一化方式从全图min-max改为分位数截断,效果立竿见影。
做这套系统的过程中还有个习惯影响了我的技术选型:每次改动预处理或模型结构后,所有对比结果都保留同一组固定验证影像和固定随机种子,确保可复现。这个做法在变化检测这类数据量小的场景下尤其重要,因为数据本身分布就不稳定,如果实验之间的随机因素控制不好,你甚至分不清一个改动是真正有效还是纯属巧合。
希望帮到你。
本文还有配套的精品资源,点击获取