简介:基于Python神经网络学习的SAR图像变化检测系统以zip压缩包形式发布,面向遥感图像处理、深度学习应用及SAR变化检测研究的开发者或学习者。系统采用深度学习模型对多时相SAR图像进行地表变化识别,覆盖数据预处理、模型训练、检测流程与前端可视化,可用于自然灾害监测、城市规划等场景。压缩包共195个文件,约3.07MB,主要包含Python脚本、PyTorch模型权重、Vue/JavaScript前端组件、SAR示例图像及Markdown/JSON文档,其中脚本与权重构成核心算法,前端文件提供可视化界面,示例图像便于直接测试。目前已有104人学习下载,附带的模型权重与测试数据,配合源码和说明文档,可快速复现变化检测流程,理解CNN在SAR图像应用中的实现思路。项目目录组织清晰,适合在此基础上进行参数调优或二次开发。
1. SAR图像变化检测为什么要用神经网络,这个 web.zip 能不能直接跑起来
拿到一个名为“基于python神经网络学习的SAR图像变化检测系统-web.zip”的压缩包,第一反应别急着双击解压。这类工程把“同一区域不同时间的两幅SAR图像,交给Python和神经网络模型去学习差异特征,最后在Web页面上输出变化区域”这件事串成了一条流水线,但它不是开箱即用的商业软件,而是一套带训练代码和Web演示脚手架的课题项目。你真正要先确认的是:SAR图像变化检测为什么不能继续用阈值分割,神经网络负责哪一段,以及这个Web系统到底是展示层还是生产工具。想清楚这三点,后面解压、跑通、再改造才不容易翻车。
2. 拆解架构与算法选型:双时相SAR影像到变化图的完整链路
无论zip包里的代码用哪种框架组织,SAR图像变化检测系统的处理主线基本固定:两幅配准后的影像进入预处理模块,模型对每个像素判断是否发生变化,再经后处理拿到最终变化掩膜,最后落到结果图和统计报表里。先把这个链路拆开,你会发现所谓“神经网络学习”只占其中一小段,但恰恰是决定精度的核心。
2.1 变化检测到底在检测什么:不是分割,是逐像素二分类
SAR图像变化检测,输入是同一地理区域两个时相的后向散射影像,通常记为图一和图二。输出是一张变化掩膜,每个像素只有0或1两个值:0表示没有变化,1表示发生了变化。它和常规图像分割最大的区别在于,分割任务需要识别目标类别,而变化检测只需要回答“这里变没变”,至于变成什么,很多系统不关心。
正因为只做二分类,类别不平衡问题被推到眼前。农田、森林、水域大范围区域可能几乎不变,变化像素往往只占整幅图的0.5%到5%。如果直接用像素准确率作为训练目标,模型只要把所有像素预测为“无变化”,准确率也能到95%以上。这是基于深度学习的变化检测项目最常见的翻车点,后面第三章会专门用损失函数来压它。
2.2 为什么用神经网络而不是阈值分割:相干斑噪声的锅
传统SAR变化检测有一套成熟方法:先对两时相影像做对数比或均值比,生成一张差异图,再对差异图做阈值分割。问题在于SAR影像是相干成像系统,带有大量颗粒状的相干斑噪声,同一个地物在不同时刻的灰度波动可能比真实变化还要大。用像素级灰度差判断变化,很容易把噪声点当成变化区域,也会漏掉整体灰度缓慢变化的真实目标。
神经网络之所以适合这个任务,是因为卷积结构天然利用局部邻域上下文。卷积神经网络是典型的前馈神经网络,上一层输出当作下一层输入,权重在空间上共享。对变化检测来说,卷积核相当于告诉模型“结合周围一个像素范围内的灰度关系来判断中心像素是否变化”,这比逐像素比较更有抗噪能力。U-Net这一类模型还会通过下采样获得更大的感受野,能够把“整片区域从农田变成建筑工地”这种空间一致的变化识别出来。
2.3 模型怎么选:双通道U-Net、孪生网络还是简单CNN
这类系统的模型选型一般有三种常见做法。
| 模型结构 | 输入方式 | 优点 | 典型问题 |
|---|---|---|---|
| 差异图 + U-Net | 单通道差异图 | 训练快、显存占用小 | 差异算子的信息丢失不可逆 |
| 双时相拼接 + U-Net | 两时相堆叠成双通道 | 端到端可学习,结构简单 | 对配准错误敏感 |
| 孪生 U-Net | 两时相分别过共享权重编码器 | 特征提取一致性更好 | 显存和耗时翻倍 |
如果zip包里的训练代码不是U-Net,只是一个简单的三层卷积分类器,我的建议是优先换成“双时相拼接+U-Net”。这个方案的输入通道数是2,把两幅图当作图像的两个波段,模型在forward第一层时就会同时看到两幅图中心像素和周围邻域,变化信息可以提前在底层卷积里融合。
2.4 拿到 web.zip 先看四个文件:目录结构决定了你从哪一步接手
一套标准的变化检测系统web压缩包,通常会包含这些部分:
- data或dataset目录:两时相SAR影像样本、标签掩膜
- train.py或train.sh:模型训练入口
- web目录:Flask或Django应用,包含app.py、templates和static
- requirements.txt或environment.yml:Python依赖清单
- model.pth或weights目录:预训练权重文件,不一定存在
拿到包后不要直接运行train.py,先花五分钟确认三件事。第一,requirements.txt里的torch和flask版本和你本机是否兼容,尤其注意torch的CUDA版本;第二,data目录下有没有实际测试数据,没有数据的话就必须先准备Sentinel-1的切片;第三,web入口是否指向已经训练好的模型权重,如果模型文件缺失,web界面只能展示一个空框架。
实际操作时,我一般会先在项目根目录执行一行命令,确认是否有权重文件:
find . -name "*.pth" -o -name "*.pt"如果输出为空,说明需要从训练阶段开始复现,而不是直接启动web。这不是坏消息,至少你能看清楚训练和推理的全过程。
3. 把模型跑起来:数据准备、PyTorch训练和Flask部署的可复现代码
一套能跑通的最小闭环包括四步:预处理、模型定义、训练、预测和web预览。下面所有代码用PyTorch和Flask写,这也是这类工程最常用的组合。为了避免代码和服务器环境互相影响,建议先建一个干净的conda环境:
conda create -n sar_change python=3.10 conda activate sar_change pip install torch torchvision flask rasterio scipy numpy跑不动GPU也没关系,U-Net在CPU上也能训练,只是迭代会慢,后面把训练图片裁剪到256像素即可。
3.1 数据预处理:配准、裁剪、分位数归一化和标签掩膜
SAR影像预处理的第一原则是:两幅图必须在同一地理网格上。如果两幅影像的投影或像素尺寸不一致,直接送入网络会产生大量边缘假变化。常见的做法是用rasterio读取后先检查transform和crs,再统一重采样。下面这个函数处理的是已经完成配准的情况:
import rasterio import numpy as np def load_and_normalize(path1, path2, target_size=(256, 256)): with rasterio.open(path1) as src1, rasterio.open(path2) as src2: if (src1.crs != src2.crs) or (src1.transform != src2.transform): raise RuntimeError("两幅影像的投影或范围不一致,请先配准") img1 = src1.read(1).astype(np.float32)[:target_size[0], :target_size[1]] img2 = src2.read(1).astype(np.float32)[:target_size[0], :target_size[1]] def normalize_sar(img): p1, p99 = np.percentile(img, 1), np.percentile(img, 99) return np.clip((img - p1) / (p99 - p1 + 1e-6), 0, 1) return normalize_sar(img1), normalize_sar(img2)逻辑说明:这里用分位数归一化而不是最小最大值归一化,因为SAR图像里经常存在角反射器、金属建筑等极亮目标,如果按min-max归一化,正常地表灰度会被压缩到很窄的区间,模型学不到细节。取1%和99%分位数相当于先把极值截断,再拉伸到0到1。如果两幅影像的投影不匹配,不要直接resize,应该用rasterio.warp.reproject或者gdal.Warp把影像重投影到同一网格,否则任何训练都白费。
标签掩膜不用归一化,直接保持0和1的uint8值即可,但裁剪位置必须和影像一致。常见的错误是影像和标签来自不同范围,尤其从不同网站上截取的片段。
3.2 定义神经网络:用PyTorch搭一个最小U-Net
U-Net结构在这个任务里非常合适,它一边下采样提取上下文,另一边上采样保留细节,中间用跳连接把浅层特征拼回来,能兼顾大范围变化和细碎边界。以下是一个最小可训练版本:
import torch import torch.nn as nn class SimpleUNet(nn.Module): def __init__(self, in_channels=2, base=32): super().__init__() self.enc1 = self._block(in_channels, base) self.pool1 = nn.MaxPool2d(2) self.enc2 = self._block(base, base * 2) self.pool2 = nn.MaxPool2d(2) self.enc3 = self._block(base * 2, base * 4) self.pool3 = nn.MaxPool2d(2) self.bridge = self._block(base * 4, base * 8) self.up2 = nn.ConvTranspose2d(base * 8, base * 4, kernel_size=2, stride=2) self.dec2 = self._block(base * 8, base * 4) self.up1 = nn.ConvTranspose2d(base * 4, base * 2, kernel_size=2, stride=2) self.dec1 = self._block(base * 4, base * 2) self.up0 = nn.ConvTranspose2d(base * 2, base, kernel_size=2, stride=2) self.dec0 = self._block(base * 2, base) self.out = nn.Conv2d(base, 1, kernel_size=1) def _block(self, in_c, out_c): return nn.Sequential( nn.Conv2d(in_c, out_c, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(out_c, out_c, 3, padding=1), nn.ReLU(inplace=True), ) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool1(e1)) e3 = self.enc3(self.pool2(e2)) b = self.bridge(self.pool3(e3)) d2 = self.dec2(torch.cat([self.up2(b), e3], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e2], dim=1)) d0 = self.dec0(torch.cat([self.up0(d1), e1], dim=1)) return self.out(d0)参数说明:in_channels取2,输入Tensor形状是(B, 2, H, W),两时相影像的灰度值各占一个通道。base从32开始,显存不够就降到16。这里所有卷积都用kernel_size=3、padding=1,特征图尺寸不会变;只有池化和转置卷积改变空间尺寸。注意forward里解码部分用了torch.cat把上采样结果和对应编码器输出拼在通道维度上,这是U-Net保留高频细节的关键。
3.3 训练与评估:BCE+Dice损失、AdamW和验证指标的取舍
训练阶段要做三件事:定义数据集、选损失函数、设优化器。变化检测任务里正负样本严重不平衡,所以不能单独用二值交叉熵。下面代码同时计算BCE和Dice损失:
class SarDataset(torch.utils.data.Dataset): def __init__(self, img1_list, img2_list, mask_list): self.img1_list, self.img2_list, self.mask_list = img1_list, img2_list, mask_list def __len__(self): return len(self.mask_list) def __getitem__(self, idx): x = torch.stack([self.img1_list[idx], self.img2_list[idx]], dim=0) y = torch.from_numpy(self.mask_list[idx]).float().unsqueeze(0) return x, y def dice_loss(pred, target, smooth=1.0): prob = torch.sigmoid(pred) inter = (prob * target).sum() return 1 - (2 * inter + smooth) / (prob.sum() + target.sum() + smooth) criterion = torch.nn.BCEWithLogitsLoss(pos_weight=torch.tensor([10.0])) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) for epoch in range(100): for x, y in dataloader: pred = model(x) loss = criterion(pred, y) + dice_loss(pred, y) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 10 == 0: print(f"epoch {epoch}, loss {loss.item():.4f}")这里BCEWithLogitsLoss的pos_weight取10,意味着出现“变化”这一正类时,损失贡献放大约10倍。如果正负样本比更悬殊,比如正类只有0.5%,可以把pos_weight提高到20,但要观察是否带来大量误检。AdamW比Adam更容易稳定收敛,学习率先用1e-4,如果loss震荡就降到3e-5。训练过程中不要只盯loss,还要在单独划分的验证集上计算IoU和F1,准确率在这里没有参考价值。
评估指标可以用下面这段代码实现:
def cal_metrics(prob, mask, threshold=0.5): pred = (prob > threshold).astype(np.uint8) mask = (mask > 0).astype(np.uint8) inter = (pred & mask).sum() union = (pred | mask).sum() iou = inter / (union + 1e-6) tp = (pred & mask).sum() fp = (pred & ~mask).sum() fn = (~pred & mask).sum() precision = tp / (tp + fp + 1e-6) recall = tp / (tp + fn + 1e-6) f1 = 2 * precision * recall / (precision + recall + 1e-6) return iou, f1注意每轮训练都保留验证集上的F1最高的权重,不要用最后一个epoch的权重。模型训练完保存成model.pth,web服务加载时就靠这个文件。
3.4 模型预测与后处理:从概率图到变化图斑
推理阶段更简单,把两幅影像打包成(B,2,H,W)送入模型,输出的是未经过sigmoid的logits,需要先转成概率:
model.eval() with torch.no_grad(): logits = model(x) prob = torch.sigmoid(logits)[0, 0].numpy() change_map = (prob > 0.5).astype(np.uint8)直接得到的二值图通常有很多孤立噪点,我一般会加一个形态学后处理:
from scipy import ndimage change_map = ndimage.binary_opening(change_map, iterations=1).astype(np.uint8) change_map = ndimage.binary_closing(change_map, iterations=1).astype(np.uint8)逻辑说明:开运算先腐蚀后膨胀,用来去掉小于卷积核的孤立点;闭运算先膨胀后腐蚀,用来填充变化区域内部的小孔。如果最终变化区域很碎,还可以用ndimage.label统计连通域,删除面积小于20像素的块。阈值0.5是起步值,实际使用时应该在验证集上从0.3到0.7以步长0.05搜索,选择F1最高点作为最终阈值。
3.5 Web端接入:Flask上传两张图并返回变化结果
系统标题里带了web.zip,所以Web这一段不能少。最常见的做法是用Flask写一个单页接口,接收两张上传的PNG或TIF,调用模型得到变化图后直接返回图片:
from flask import Flask, request, send_file import io import numpy as np import torch from PIL import Image app = Flask(__name__) model = SimpleUNet(in_channels=2) model.load_state_dict(torch.load("model.pth", map_location="cpu")) model.eval() @app.route("/predict", methods=["POST"]) def predict(): f1 = request.files["img1"] f2 = request.files["img2"] img1 = np.array(Image.open(f1).convert("L"), dtype=np.float32) / 255.0 img2 = np.array(Image.open(f2).convert("L"), dtype=np.float32) / 255.0 x = torch.from_numpy(np.stack([img1, img2])).unsqueeze(0) with torch.no_grad(): prob = torch.sigmoid(model(x))[0, 0].numpy() change_map = (prob > 0.5).astype(np.uint8) * 255 buf = io.BytesIO() Image.fromarray(change_map).save(buf, format="PNG") buf.seek(0) return send_file(buf, mimetype="image/png")这个接口有三个地方要注意。第一,如果上传的是TIF而不是PNG,要用rasterio打开而不是PIL,TIF里可能有多波段,读取前先确认波段顺序。第二,上传的影像尺寸如果和训练不一致,应先在接口里裁剪或重置,否则U-Net会因尺寸不匹配报错。第三,send_file返回的PNG没有地理参考信息,只适合浏览器预览,如果要做变化图套合到地图上,必须单独保存GeoTIFF并传给前端。
提示:如果只是想快速验证流程,先把两张图手动裁剪成256x256的PNG再上传,能省掉好多预处理排错。
4. 让模型在真实SAR数据上可靠:五个必调参数、三类边界场景和一条偷懒建议
训练脚本跑通只是起点,真实SAR数据和竞赛数据集差得很远。分辨率、轨道方向、季节物候都会让同一个模型从9成F1掉到不足5成。这一章把影响最大的参数和场景边界讲清楚。
4.1 五个必调参数:概率阈值、patch大小、学习率、损失权重、最小连通域
| 参数 | 常见值 | 调高/调低影响 | 踩坑信号 |
|---|---|---|---|
| 概率阈值 | 0.5 | 调高漏报增加,调低误检增加 | 验证集F1长期偏低 |
| patch大小 | 256 | 过小没有上下文,过大显存溢出 | 小目标变化被漏掉 |
| 学习率 | 1e-4 | 过大约束崩溃,过小收敛极慢 | loss在前面几轮不降 |
| 损失权重 | 10 | 过大误检,过小漏检 | 预测结果全为0 |
| 最小连通域 | 20像素 | 过小保留噪声,过大删除真实小块 | 零散变化被删光 |
概率阈值是最容易获得收益的参数。常见做法是保存验证概率和标签后,用下面这段脚本搜索最佳阈值:
best_thr, best_f1 = 0.5, 0 for thr in np.arange(0.3, 0.75, 0.05): pred = prob > thr f1 = cal_metrics(prob, label, thr)[1] # 用前面定义的指标 if f1 > best_f1: best_f1, best_thr = f1, thrpatch大小影响的是视野。SAR变化检测中有些地物很大,比如整片森林砍伐,patch太小只能看到纹理,感受不到区域变化。我一般先用256x256跑通,之后在显存允许时试512x512,两者F1之差通常在5个百分点以上。
训练时还需要把学习率从固定值改成余弦退火,避免后期在损失平面来回震荡:
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-5)注意eta_min不要设成0,SAR数据噪声大,学习率降到0会让模型丧失微调能力。
损失权重是针对类别不平衡的终极手段。除了pos_weight=10,也可以在自定义dataset里对每个像素生成权重图,变化像素权重设为10、非变化像素权重设为1,效果更直接。但要注意权重过大会让网络把所有边界都判成变化,导致误检,所以权重一般不超过20。
4.2 三类边界场景:分辨率、入射角、季节变更
第一类是传感器分辨率不一致。Sentinel-1的影像重采样到10米,与高分三号5米级影像在纹理上差异很大。训练集用的是10米数据,直接在1米数据上推理时,模型会看到完全陌生的局部分布。解决方法是把测试影像重采样到训练时的地面采样距离,而不是简单缩放像素尺寸。
第二类是入射角和轨道方向。同一颗卫星升降轨影像的入射角可能相差20度以上,让同一片未变化区域的灰度差异看起来接近真实变化。如果训练数据来自降轨,测试却用升轨,误报会明显上升。常见做法是按轨道方向拆数据集,或者把入射角作为第三个通道传给网络。对web系统来说,最简单的是在数据准备阶段就过滤掉轨道方向不一致的影像对。
第三类是季节变化。农田从播种到收获,雷达后向散射特性会发生明显变化,但这种变化对灾害监测、建筑变更监测来说往往是“不想要的变化”。模型无法自动区分真实变化和季节差异,只能靠训练标签的分布约束。如果业务目标是城市变化,训练样本应尽量挑选同一季节的两个时相,或者把季节变化当作背景噪声加入训练集,而不是完全排除。
4.3 训练数据不足:借壳开放词汇变化检测的思路,先把伪标签跑起来
标注SAR影像变化非常费劲,动辄一个区域几千个像素级标签。最近这个领域常提到“开放词汇变化检测”,就是想用图文预训练模型把变化类别从固定集合扩展到开放集合,不再要求每个新增场景都重新标注。对SAR来说,直接套用开放词汇方案还很牵强,因为SAR是单通道灰度图,和日常照片分布差异太大,但我从中借鉴到一条偷懒建议:先用已有模型或者阈值法生成一版粗变化图,人工只修正明显的错误边界,再把这批“伪标签”作为训练数据加入迭代。
具体操作是,找一块目标区域的历史影像和近期影像,用一个训练好的U-Net跑出概率图,用较高阈值得到保守变化区域。然后把它叠加在影像上过一遍,把明显漏掉的大块变化手动画几笔,把明显误检的孤立点删掉,得到初版标签。再用它和原有标签混合训练,一般迭代两轮就能把你的系统从“只在原数据集上有效”拉到目标区域可用。
注意伪标签会放大模型自身的错误,所以每次迭代后要在真实标签上做验证,不能只看伪标签上的F1。
5. 避坑/常见问题/排查:从训练到Web部署的五条真实踩坑记录
这里每一节都是常见的工程翻车点,我按“现象、原因、解决”三条展开。
5.1 现象:训练loss不降,F1长期为零
你跑完20个epoch,打印出来的loss还在0.7左右,验证集F1始终是0,说明模型根本没办法把变化区域和背景分开。最常见的原因是影像和标签没有对齐,像素偏移2到10个点。SAR图像经过不同轨道和重采样后,文件范围经常出现半个像素到几个像素的平移,肉眼看不出来,损失函数却很敏感。
解决方法是先检查训练样本,把影像和标签按0.3透明度叠加,确认边缘位置是否一致。如果发现偏移,用相位相关法估计平移量:
from skimage.registration import phase_cross_correlation from scipy import ndimage # img1 和 label 是同一地区的二维数组 shift, error, phasediff = phase_cross_correlation(img1, mask) img1_aligned = ndimage.shift(img1, shift, order=1)再用ndimage.shift把影像或标签移动到位。注意不推荐用resize来对对边界,因为会引入插值误差。
5.2 现象:训练集准确率99%,但预测结果全是背景
这种情况几乎可以确定是类别不平衡导致模型收敛到局部最优,把所有像素都预测成多数类。准确率这个指标在变化检测里是骗人的,正样本只占1%时,全预测为0也能拿到99%。
解决方法是按照第三章的写法,启用pos_weight或像素级权重,并将验证指标换成F1和IoU。同时可以每个batch打印当前batch中变化像素的占比,如果某个batch没有变化样本,训练信号等于零,应考虑在采样时让每个batch包含至少一张变化明显的样本。
5.3 现象:换一个地区后,模型输出一团黑
训练数据来自A区域,测试数据来自B区域,模型在A上F1有0.8,到B上几乎全黑或全白。根本原因是SAR影像的灰度分布受地形、湿度和传感器标定影响,A区域样本学到的统计特征在B区域不成立。这个问题不是简单调阈值能解决的。
解决方法是给系统做一层“归一化适配”:在推理前先统计当前影像的1%和99%分位数,如果和训练时的统计值相差太大,说明测试分布漂移,要用几个B区域的样本对模型进行微调,通常只需训练10个epoch、学习率降到1e-5。如果完全没有B区域的标签,也可以用无监督域适应,但效果不稳定,不如手动采几个小patch标一下。
5.4 现象:Web上传大图超时,浏览器转圈卡住
很多用户直接上传一张一万像素见方的大影像,Flask接收文件后如果直接把整张图送进模型,模型推理会占满内存和CPU,浏览器等几十秒得不到响应,前端认为超时。这是web系统性工程问题,不是模型问题。
解决方法是切块推理,再把结果拼接回去。下面是一个简化的切块循环:
H, W = full_img.shape patch_size, stride = 256, 192 result = np.zeros((H, W), dtype=np.float32) count = np.zeros((H, W), dtype=np.uint8) for i in range(0, H, stride): for j in range(0, W, stride): patch = full_img[i:i+patch_size, j:j+patch_size] # patch 比 patch_size 小就先填充,推理后裁掉 x = torch.from_numpy(np.stack([patch, patch2])).unsqueeze(0) with torch.no_grad(): prob_patch = torch.sigmoid(model(x))[0, 0].numpy() result[i:i+patch_size, j:j+patch_size] += prob_patch count[i:i+patch_size, j:j+patch_size] += 1 prob = result / np.maximum(count, 1)这里stride小于patch_size,会让相邻块有重叠,拼接处更平滑。count记录每个像素被覆盖的次数,最终除以次数得到平均概率,避免拼接边缘有缝隙。web前端同时要限制上传文件大小,并在接口里增加异步提示,别让用户以为系统死了。
5.5 现象:变化图锯齿严重,面积统计总偏大
模型输出的概率图直接二值化后,边界表现为一个个方块,尤其是下采样次数多的小网络更明显。另一个表现是变化区域内部有一堆小孔,或者边缘碎块很多。如果后面要统计变化面积,这种结果是不可用的。
解决方法是做后处理,而不是只调模型。除了第三章提到的开闭运算,还可以做一次连通域面积过滤:
from scipy import ndimage labeled, num = ndimage.label(change_map, structure=np.ones((3,3))) sizes = ndimage.sum(change_map, labeled, range(1, num+1)) mask = sizes > 20 cleaned = mask[labeled - 1].astype(np.uint8) if num > 0 else change_map核心思路是删除面积小于20像素的孤立变化块。若边界仍粗糙,可以使用CRF或简单的高斯边缘平滑,但在SAR应用里要注意别为了好看把真实的小型变化抹掉。
6. 进阶:给变化图算混淆矩阵,并让它可导出而不是停留在网页截图
很多演示web系统只输出一张PNG,但真实业务需要的是一个可以量化和追溯的结果。我的习惯是给系统加一个独立评估脚本,每次训练结束自动把验证集所有结果和标签保存为npy,然后计算多阈值下的IoU和F1,而不是只展示loss曲线。
# evaluate.py 片段 thresholds = np.linspace(0.1, 0.9, 17) records = [] for t in thresholds: pred = prob > t iou, f1 = cal_metrics(prob, label, t) records.append((t, iou, f1))最终在web页面的结果区域旁边显示“当前阈值0.5,IoU 0.62,F1 0.74”三个数字,而不是用漂亮的颜色掩盖模型性能。这个习惯能帮你判断一个模型到底能不能上线。如果阈值从0.3到0.7变化时F1波动超过0.15,说明模型输出不稳定,需要先修训练数据。
变化图还需要保地理参考。如果web系统返回的都是一张PNG,换到GIS里就无法和矢量边界叠加。正确做法是推理时记录原影像的transform和crs,在生成结果后调用rasterio写回GeoTIFF:
with rasterio.open(result_path, 'w', driver='GTiff', height=prob.shape[0], width=prob.shape[1], count=1, dtype='uint8', crs=crs, transform=transform) as dst: dst.write((prob > best_thr).astype(np.uint8), 1)最后一步,把系统里“概率阈值”“后处理最小面积”做成可调节参数,每次预测返回JSON时附带一个version字段。这样你下一次改了模型或参数,才能追溯某张结果图是哪个版本生成的,不至于过两天对着变化图说不出来龙去脉。
我第一次做这类变化检测系统时,为了展示效果把概率阈值故意调低,演示看起来变化区域很全,结果到验收阶段发现误报率高出两倍。从那以后我要求系统里必须有验证指标和版本记录,模型不行就不拿出来演示。这条习惯帮我避开了很多后续麻烦,希望帮到你。
本文还有配套的精品资源,点击获取