简介:这是一个基于Python神经网络学习的SAR图像变化检测系统完整工程包,面向遥感、深度学习和图像处理方向的开发者,适合通过实际代码理解卷积神经网络在多时相SAR影像中识别地表变化的方法,也可支撑灾害监测、城市变化分析等应用场景。压缩包共195个文件,大小约3.07MB,文件类型较丰富:Python脚本承担核心算法,Vue/JS组件构成可视化交互界面,预训练模型权重可直接加载,BMP图像提供成对实验样本,Markdown文档则说明项目结构与依赖配置。工程代码按功能模块划分,覆盖数据预处理、模型定义、变化检测逻辑与前端展示,示例数据中包含ottawa_1、ottawa_2及before/after成对影像,可快速跑通完整检测流程并直观对比地物变化;同时便于在此基础上替换数据、调整参数做二次试验。已有104人学习下载,适合希望系统掌握SAR图像变化检测原理、动手复现完整项目的开发者参考。
1. SAR图像变化检测:为什么传统方法总在噪声面前翻车,而神经网络能救回来
很多人在拿到一套基于Python神经网络学习的SAR图像变化检测系统时,第一反应还是“两时相图像相减、比值,再找个阈值”——结果在真实项目里几乎必翻车。SAR(合成孔径雷达)图像自带乘性斑点噪声,连同一片农田在不同角度拍出来灰度都能差一大截,阈值一卡就是满屏雪花点。把卷积神经网络请进来,让模型从数据里学什么样的差异才算“变化”,再用web系统把推断过程暴露出去,是目前落地最稳的路线。这篇笔记就按这个方向把数据预处理、模型训练、web打包和排查经验一条条讲透,适合遥感从业者、算法工程师和学生照着复现。
2. 从像素级到特征级:把SAR变化检测拆成神经网络能学的任务
SAR图像变化检测在数学上是这样一件事:给定同一区域两时相图像 X1、X2,求一个映射 F,输出同尺寸变化掩码 Y,Y=1 表示该位置发生了目标变化。难点不在“求F”这个形式,而在SAR图像的噪声特性让“差异”变得不可信。
2.1 变化检测的本质:两时相图像怎么做差异建模
最直观的差异建模是像素级图像相减。设在像素点 p 上,X1(p) 与 X2(p) 分别表示两时相的灰度值,差值法定义 D(p)=|X1(p)-X2(p)|,比值法定义 R(p)=X2(p)/X1(p)。由于SAR图像通常呈乘性噪声特性,直接用差值法会把高亮度区域的小幅波动也当成强变化;比值法在数学上更接近乘性噪声假设。实际项目里更常用的是对数比值法,即 D(p)=|ln X2(p)-ln X1(p)|,它把乘性关系变成加性关系,输出范围更稳定,这也是很多经典SAR变化检测的起点。
但无论是差值还是比值,得到的只是一个“差异强度图”。差异强度高不一定是目标变化,也可能是斑点噪声在同一个均匀区域的随机波动。神经网络的变化检测,本质是把“从差异图到变化图”这一步从手工阈值换成一个可学习的非线性映射。输入既可以是原始两时相堆叠,也可以是对数比图,甚至可以把三个都堆进去。我实验下来的经验是:把两时相图像各自做对数变换后沿通道堆叠,再附上一个对数比图,三个通道作为网络输入,比只喂差异图更容易收敛,因为网络可以自己决定哪些信息有用。
在代码层面,这个输入构造很简单,常见做法是先把两时相图像用numpy读进来、转成float32、做对数变换:
import numpy as np from PIL import Image def build_input(x1_path, x2_path): # 读入两时相SAR幅度图,均为单通道灰度 x1 = np.array(Image.open(x1_path).convert('L'), dtype=np.float32) x2 = np.array(Image.open(x2_path).convert('L'), dtype=np.float32) # 加一个极小值防止log(0),这是SAR图像里常有的黑边 eps = 1e-6 log1 = np.log(x1 + eps) log2 = np.log(x2 + eps) # 对数比值图,同样是防止除零 ratio = log2 - log1 # 堆叠成3通道:HxW 变 3xHxW,符合PyTorch的CHW顺序 inp = np.stack([log1, log2, ratio], axis=0) return inp.astype(np.float32)这段代码里最值得注意的参数是 eps。SAR图像的原始数据里经常有0值像素,尤其是雷达阴影区或成像边缘,直接 log(0) 会得到负无穷;加一个1e-6是为了稳定计算,如果数据本身范围在 0~65535,建议把 eps 调大一点到 1e-3,否则 log(x+1e-6) 对低灰度区的区分度太强,反而放大阴影区噪声。上面按3xHxW堆叠,是 PyTorch 默认的N,C,H,W布局,后面喂给CNN时不用再转维。
2.2 为什么选神经网络而不是经典比值法、阈值法
经典变化检测的完整流程通常是:构造差异图 → 估计差异图的分布 → 用阈值(可能是全局阈值或局部阈值)把像素分成变化/不变。这套流程在光学图像上还能跑,在SAR图像上则有两个绕不开的坑。
第一个坑是斑点噪声的分布并不固定。比值法的理论假设是两块强度同分布的SAR图像比值服从固定方差的分布,但真实数据里均匀地物的散射强度、入射角、成像质量都会变,经验分布和理论对不上,阈值就很难定。Otsu或大津法虽然不需要人工设阈值,但它是假设差异图呈双峰分布,SAR噪声差异图往往单峰长尾,Otsu会把大量噪声点划进变化类。
第二个坑是经典方法没有空间上下文。一个孤立像素的灰度跳变,很可能就是噪声;但是一片连续的、形状规则的区域灰度跳变,通常才是真实变化。传统阈值法一个一个像素独立判断,天然就吃亏。神经网络通过卷积核、池化和感受野,把像素周围的空间模式纳入决策,这才是它能在SAR变化检测上跑赢传统方法的核心原因。
还有一个实际选型理由:当数据有标注时,训练一个全卷积网络可以端到端地优化“像素分类”的目标,不需要手工设计特征。SAR图像没有彩色纹理,传统手工特征(如GLCM纹理特征)对噪声极其敏感,而神经网络会自己从训练数据里学哪种特征在噪声背景下有效。当然,这不意味着不需要特征工程,前面提到的对数变换和配准反而比网络结构更影响上限。
我一般会把这个问题分成两个阶段:如果只有几十个像素级别标注,直接用U-Net硬训容易过拟合,我会退而求其次,先用经典方法生成伪标签,再让神经网络在伪标签上做精细学习,这算是一个现实可行的混合路线。不过,只要标注量在几千个样本以上(patch采样),神经网络是明显更稳的选择。
2.3 网络结构选型:从CNN到Siamese网络,再到UNet变体
选网络结构时,核心问题不是“哪个网络最fancy”,而是“哪个结构对录入误差和噪声最鲁棒、最好训练”。最容易上手的是双通道输入的CNN分类器,滑动窗口取每个像素周围的一个patch,比如5x5 或 7x7,输入是两个时相各一个通道,输出当前像素是否变化。结构简单、训练快,但逐像素滑窗效率很低,而且patch之间没有共享特征,预测结果容易出现不连贯的盐噪点。
更符合图像分割思路的是U-Net,输入整幅或裁剪大图,输出等尺寸的变化概率图。它在SAR变化检测里表现稳定的原因有两层:编码器不断下采样,让模型能看到更大范围的空间上下文;解码器跳连恢复细节,让变化边界不至于被磨掉。U-Net及其变体是当前SAR变化检测的默认基线,升级方向是加注意力模块或残差连接,但不要一开始就上Transformer——SAR训练集通常不大,Transformer在小样本下容易欠拟合。
如果想把“两时相对比”这个先验信息显式建模,就选Siamese网络结构。两个分支共享权重,分别提取X1和X2的特征,然后计算特征差或特征拼接送入分割头。共享权重保证两个时相的特征分布一致,比直接堆叠更能容忍一定程度的配准误差。代价是显存翻倍、训练更慢。在真实项目里,如果两时相图像来自同一传感器、成像几何接近,配准质量较高,直接双通道U-Net就够用;如果时相跨度大、图像有几何形变,Siamese结构的效果优势就体现出来了。
画个简洁的PyTorch示意,说明双通道U-Net骨架的输入输出关系:
import torch import torch.nn as nn class DoubleConv(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 SimpleUNet(nn.Module): def __init__(self, in_ch=3, out_ch=2): super().__init__() self.enc1 = DoubleConv(in_ch, 32) self.enc2 = DoubleConv(32, 64) self.pool = nn.MaxPool2d(2) self.enc3 = DoubleConv(64, 128) self.dec3 = DoubleConv(128, 64) self.dec2 = DoubleConv(64, 32) self.dec1 = DoubleConv(32, 32) self.final = nn.Conv2d(32, out_ch, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) d3 = self.dec3(e3) d2 = self.dec2(d3 + e2) d1 = self.dec1(d2 + e1) return self.final(d1)这里in_ch=3对应前面构造的三个通道(log1、log2、ratio)。out_ch=2表示两类:变化与不变。如果希望输出单通道概率图,也可以设out_ch=1并配合BCEWithLogitsLoss。这个示意图跳过了UNet的上下采样对齐细节,实际落地时要仔细处理每层的空间尺寸,否则特征相加时维度对不上。网络宽度上,初始通道数从32开始是一个兼顾显存和表达力的值;如果图像分辨率很高(比如10000×10000),我建议初始通道数降到16,否则第一个卷积的显存开销就非常大。
3. 用Python搭建SAR变化检测系统:数据预处理、模型训练与评估指标
3.1 数据准备:SAR图像对数变换、配准与裁剪
凡是做SAR变化检测,配准是绕不过去的第一道工序。两张时相图如果错了两三个像素,边缘和强散射点就会被模型识别成“变化”。常见做法是用OpenCV的findTransformECC做估计,但它对初始值敏感,SAR图像噪声多,直接全尺寸跑很容易不收敛。我的习惯是先把两时相图像缩放到同一尺寸,比如512x512,做一次粗略配准,再在降采样后的变换矩阵基础上做精细配准。
import cv2 import numpy as np def align_images(im1, im2, warp_mode=cv2.MOTION_TRANSLATION): # im1是参考图,im2是待配准图 # 统一尺寸为512x512,ECC对差异大的图容易不收敛,所以先缩放 im1 = cv2.resize(im1, (512, 512), interpolation=cv2.INTER_LINEAR) im2 = cv2.resize(im2, (512, 512), interpolation=cv2.INTER_LINEAR) if warp_mode == cv2.MOTION_HOMOGRAPHY: warp_matrix = np.eye(3) else: warp_matrix = np.eye(2) criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 5000, 1e-10) try: (cc, warp_matrix) = cv2.findTransformECC( im1, im2, warp_matrix, warp_mode, criteria, None, 5) except cv2.error: # 简单降级:相位相关只处理平移 shift, _ = cv2.phaseCorrelate(np.float32(im1), np.float32(im2)) warp_matrix = np.float32([[1, 0, shift[0]], [0, 1, shift[1]]]) if warp_mode == cv2.MOTION_HOMOGRAPHY: aligned = cv2.warpPerspective(im2, warp_matrix, (512, 512), flags=cv2.INTER_LINEAR + cv2.WARP_INVERSE_MAP) else: aligned = cv2.warpAffine(im2, warp_matrix, (512, 512), flags=cv2.INTER_LINEAR + cv2.WARP_INVERSE_MAP) return aligned这个配准代码有几个关键参数。warp_mode用cv2.MOTION_TRANSLATION最容易收敛,适合同一卫星重复观测、只有微小平移的数据;如果两时相存在旋转,可以换cv2.MOTION_EUCLIDEAN;像元尺寸差异大或视角变化大时再用cv2.MOTION_HOMOGRAPHY,但此时ECC跑失败的几率很高。findTransformECC的最后一个参数是高斯滤波的核尺寸,设为5可以在一定程度上压掉斑点噪声,提高配准稳定性。降级分支里的phaseCorrelate只适合纯平移,而且要求两图灰度分布接近;实际上SAR图像灰度动态范围大,用之前最好先做对数变换。
配准完成后,接下来是裁剪。SAR原图动辄上万像素,直接整幅送入网络会撑爆显存,常见的做法是切patch。切patch时需要注意重叠和标签对齐:
def make_patches(im1, im2, label, patch_size=256, stride=128): h, w = im1.shape patches = [] for y in range(0, h - patch_size + 1, stride): for x in range(0, w - patch_size + 1, stride): p1 = im1[y:y+patch_size, x:x+patch_size] p2 = im2[y:y+patch_size, x:x+patch_size] lab = label[y:y+patch_size, x:x+patch_size] patches.append((p1, p2, lab)) return patchesstride控制重叠度。stride=128产生50%重叠,让每个像素出现在多个patch中,相当于数据加倍,但会引入重复样本。如果内存吃紧,把stride提高到等于patch_size,即无重叠切块,不过推理结果很容易出现拼缝。训练和推理对stride的要求不同:训练时可以无重叠或重叠小一点,推理时最好用重叠加平均来消除拼缝。
3.2 训练样本怎么生成:像素级标签与patch采样
SAR变化检测的标签通常来自人工勾画或矢量图层栅格化。标签图与图像必须严格在同一坐标系、同一分辨率下,否则后面训练时对不齐。类别不平衡是这个任务最典型的特征:一片区域内可能变化像素只占1%甚至更低。如果按全图随机切patch,大多数patch里没有变化标签,模型会倾向于把所有像素预测成“不变”。
我一般会在采样阶段做正负样本平衡。正样本中心从标签大于0的区域取,负样本中心从全图随机取,两者各占一部分。下面这段生成器代码可以直接塞进训练循环:
def balance_patch_sampling(im1, im2, label, patch_size=256, positives_per_epoch=20, randoms_per_epoch=20): h, w = label.shape rows_y, cols_x = np.where(label > 0) centers = [] if len(rows_y) > 0: idx = np.random.choice(len(rows_y), size=positives_per_epoch, replace=True) for i in idx: cy, cx = rows_y[i], cols_x[i] cy = min(max(cy - patch_size//2, 0), h - patch_size) cx = min(max(cx - patch_size//2, 0), w - patch_size) # 随机扰动中心,避免总切同一个区域 cy += np.random.randint(-20, 20) cx += np.random.randint(-20, 20) centers.append((min(max(cy, 0), h - patch_size), min(max(cx, 0), w - patch_size))) for _ in range(randoms_per_epoch): cy = np.random.randint(0, h - patch_size) cx = np.random.randint(0, w - patch_size) centers.append((cy, cx)) for cy, cx in centers: yield (im1[cy:cy+patch_size, cx:cx+patch_size], im2[cy:cy+patch_size, cx:cx+patch_size], label[cy:cy+patch_size, cx:cx+patch_size])positives_per_epoch和randoms_per_epoch的比例需要自己调。变化区域很小时,正样本太多会让模型过度拟合那几个现场,泛化差;正样本太少又学不到变化特征。我一般先按1:1跑一个epoch看Dice,如果Dice没超过0.5,就把正负比例调到3:2再看。这里的随机扰动np.random.randint(-20, 20)是一种免费的增强,让模型看到变化目标周围的更多上下文,而不是死记变化目标的固定位置。
3.3 模型训练:损失函数、学习率与数据增强参数
损失函数我几乎不用纯交叉熵,因为SAR变化检测的类不平衡问题太严重。常见做法是把BCE + Dice混合,Dice项对前景占比小的类别更敏感,能让模型在变化像素很少时也学到有效的梯度信号。
import torch import torch.nn.functional as F def mixed_loss(pred_logits, target, dice_weight=0.5): bce = F.cross_entropy(pred_logits, target) # pred_logits shape: N,2,H,W,target为0/1 prob = F.softmax(pred_logits, dim=1)[:, 1, :, :] smooth = 1.0 dice = 1 - (2.0 * (prob * target).sum() + smooth) / \ (prob.sum() + target.sum() + smooth) return bce + dice_weight * dicedice_weight是一个必调参数。从0.5开始比较稳,如果训练曲线震荡得厉害,降到0.2;如果发现模型预测的变化区域偏保守、漏检多,就提到0.8。smooth项是为了防止除零,尤其是当某个patch里完全没有变化像素时,target.sum()可能是0。更稳妥的做法是在调用时先用torch.any(target == 1)检查,过滤掉纯背景patch,否则Dice loss会出现异常值。
优化器和学习率方面,我常用的配置是:
AdamW(lr=3e-4, weight_decay=1e-4)- 学习率
CosineAnnealingLR(T_max=epochs) - batch size 8~16,patch_size 为 256 时,batch=8 在12GB显存上比较合适
- 混合精度训练可以用
torch.cuda.amp,但要注意BatchNorm在低精度下的稳定性,必要时保留FP32
数据增强不要照搬光学图像那套。随机翻转、旋转90度都可以用,乘性噪声增强x *= (1 + sigma * randn)对SAR很有效,sigma取0.1~0.2,模拟同一地物在不同成像条件下的灰度波动。但不要用亮度/对比度增强,SAR灰度本身代表后向散射强度,做全局亮度拉伸会破坏物理意义。
训练时我建议每个epoch都保存一个best_kappa.pt,而不是只存loss最低的模型。因为Kappa对类别不平衡更严格,很多loss低的模型其实在预测时变成了“全图不变”。Kappa的计算基于混淆矩阵:
def kappa_from_cm(cm): total = cm.sum() pe = (cm[0, :].sum() * cm[:, 0].sum() + cm[1, :].sum() * cm[:, 1].sum()) / (total * total) po = np.trace(cm) / total return (po - pe) / (1 - pe + 1e-12)这个公式很简单,但注意pe的计算要包含两类,不能只算变化类。如果测试集中不变像素占99%,即使模型全预测为不变,po=0.99,但pe也接近0.98,Kappa只有0.1左右,这才能真实反映模型的可用性。
4. 把模型包成web系统:FastAPI在SAR图像变化检测里的落地姿势
4.1 web系统要暴露哪些能力:上传、推理、结果下载
一个SAR变化检测web系统,核心不是复杂页面,而是两个能力:让使用者不写代码地跑一次变化检测,以及拿到可直接使用的结果图。所以接口设计上绕不开三个点:上传两时相图像、后台执行模型推理、下载变化掩码和差异叠加图。
因为SAR原始数据常是GeoTIFF,web端不要直接让浏览器解析GeoTIFF,而是后端负责读取,返回PNG给前端预览。如果原始图太大,后端应自动降采样或按范围切片。这个系统的“web”不是给普通用户看花哨界面的,而是给分析人员做数据处理的工具,所以把处理能力做稳比做炫更重要。
4.2 用FastAPI写一个最小可用的变化检测服务接口
我一般用FastAPI而不是Flask,原因是FastAPI自带数据校验和异步支持,而且响应处理天然适合这类模型服务。模型按单例加载到GPU,常驻内存,避免每次请求都加载权重。
一个最小服务:
from fastapi import FastAPI, UploadFile, File from fastapi.responses import FileResponse from starlette.concurrency import run_in_threadpool import numpy as np, cv2, uuid, torch app = FastAPI() # 全局加载模型,只加载一次 model = torch.load("sar_unet.pt", map_location="cuda") model.eval() def predict(im1_path, im2_path): # 做与训练一致的前处理 im1 = cv2.imread(im1_path, cv2.IMREAD_GRAYSCALE) im2 = cv2.imread(im2_path, cv2.IMREAD_GRAYSCALE) inp = build_input(im1, im2) # 复用前面的函数 inp = torch.from_numpy(inp).unsqueeze(0).cuda() with torch.no_grad(): out = model(inp) prob = torch.softmax(out, dim=1)[0, 1].cpu().numpy() mask = (prob > 0.5).astype(np.uint8) * 255 return mask, prob @app.post("/detect") async def detect(file1: UploadFile = File(...), file2: UploadFile = File(...)): task_id = str(uuid.uuid4()) path1 = f"/tmp/{task_id}_1.tif" path2 = f"/tmp/{task_id}_2.tif" with open(path1, "wb") as f: f.write(await file1.read()) with open(path2, "wb") as f: f.write(await file2.read()) # 模型推理放到线程池,避免阻塞事件循环 mask, prob = await run_in_threadpool(predict, path1, path2) mask_path = f"/tmp/{task_id}_mask.png" cv2.imwrite(mask_path, mask) return FileResponse(mask_path, media_type="image/png")这里有个关键选择:predict是CPU/GPU混合操作,如果直接在一个async def里调用,会阻塞FastAPI的事件循环,其他请求全部排队。用run_in_threadpool把它丢给线程池,才能保证并发。如果服务器没有GPU,去掉map_location="cuda"和.cuda(),模型会在CPU上跑,速度可能慢10倍以上,但至少不崩。输入图像尺寸如果和训练时不一致,全卷积网络能跑,但输出尺寸会跟着变;我给这个接口加一个隐式约定:上传图会被cv2.resize到 256 的整数倍,否则推理结果和原始坐标对不上。
4.3 前端交互与后端任务队列:别让请求卡死浏览器
当图像是几万像素宽时,一次推理可能要几十秒甚至几分钟。直接用同步接口,前端会一直等响应,浏览器很容易超时或卡死。常见做法是引入任务队列:用户上传后得到task_id,后端后台线程跑推理,前端每隔几秒查询。
最简单的任务状态管理可以直接用全局字典。单机、并发量不大时够用,不需要上Redis:
import threading task_status = {} @app.post("/detect/async") async def detect_async(file1: UploadFile = File(...), file2: UploadFile = File(...)): task_id = str(uuid.uuid4()) task_status[task_id] = {"state": "running"} # 保存文件略,同上一节 threading.Thread(target=run_detection_task, args=(task_id, path1, path2), daemon=True).start() return {"task_id": task_id} @app.get("/task/{task_id}") async def get_task(task_id: str): return task_status.get(task_id, {"state": "not_found"})这里有一个容易踩的坑:后台线程推理时,如果模型是在主线程里加载到GPU的,最好让所有推理任务都使用同一个全局线程池,而不是每次threading.Thread创建新线程。原因在于CUDA上下文和线程绑定在某些驱动版本下会出问题,表现为CUDA error: initialization error。稳一点的做法是模型加载和推理都发生在同一个线程池内,或者干脆用FastAPI的同步def端点让它自动跑在线程池里,这样代码路径最干净。前端页面只需要一个index.html,用FormData上传两个文件,拿到task_id后轮询/task/{task_id},状态变成done就显示下载按钮。
5. 避坑与排查:SAR图像变化检测系统开发的5个高频故障
5.1 现象:训练loss下降但检测图全是噪声
原因:模型把斑点噪声当成了变化。最常见的是正负样本不平衡,变化像素太少,loss被不变像素主导,模型学到的决策边界把所有轻微波动都判成“可能变化”。另一个原因是输入没做对数变换,SAR图像灰度动态范围极大,网络对高亮度区域的速度跳变过度敏感。
解决:先看输出概率图,如果概率图上的“变化”是均匀散布的椒盐点,说明是噪声误检。用平衡采样重训,并考虑在差异图上加一个小的中值滤波作为预处理。监控指标从准确率换成Kappa,Kappa能明显反映这种“全图乱开枪”的糟糕状态。
5.2 现象:两张输入图配准不准导致检测结果成片伪变化
原因:亚像素级的错位在SAR图像中会被放大,因为雷达目标的散射点在几个像素内就能从极强变成极弱。配准误差表现为道路边缘、房角、山脊线上的成片伪变化,而不是散点。
解决:在推理时对第二张图做几个微小平移,比如上下左右各移动1个像素,分别推理后取概率平均,这个技巧能明显削弱错位带来的边缘伪变化。更好的办法是训练时用Siamese网络或加入随机平移增强,让模型对微小错位免疫。
5.3 现象:web上传大图内存爆掉
原因:几百MB的GeoTIFF读进来变成numpy数组,float32单通道一张10000x10000就是400MB,堆叠成3通道就是1.2GB,web服务并发两个请求直接OOM。再加上模型推理时的中间特征图,内存轻松超过4GB。
解决:用rasterio按块读取,推理时做分块滑窗,最后再把结果拼起来。如果实在要用整图,至少先把输入缩放到模型能接受的最大尺寸。对web系统来说,上传时限制文件大小只是个补丁,核心是后端要能处理超大图,分块推理是绕不开的。
5.4 现象:预测结果出现条带状异常
原因:推理时把图像切成patch,patch之间没有重叠,模型在边界处特征缺失,拼接后就会出现明显的接缝。另一个常见原因是BatchNorm在推理时没有切换到eval模式,导致每个patch的统计量不一致,输出概率在patch边界发生跳变。
解决:推理滑窗必须带重叠,比如patch_size=256时设置stride=224(即重叠32像素),拼接时对重叠区做线性加权平均。在模型导出和推理时一定要调用model.eval(),并关闭dropout。如果用了BatchNorm,单张patch推理时batch size为1,统计量会非常不稳定,最好用全局统计模式或把网络固定为全卷积推理。
5.5 现象:验证精度高但实际场景泛化差
原因:训练和验证数据来自同一张图、同一地区,模型把该地区特定地物的散射特征背下来了,换了传感器、入射角或季节就失效。SAR变化检测最容易出现这种“高分低能”的情况,因为它本质上是对两时相的相对变化建模,但模型很容易把绝对强度特征一并学了去。
解决:训练数据按区域划分,不要所有patch都来自同一幅大图;要保证验证集和训练集不重叠。增强层面,加入乘性噪声模拟不同成像条件,并用不同传感器的数据做跨域验证。如果实际场景确实和训练域差异太大,一个务实的做法是先对两时相图像做辐射归一化,再送进网络,让模型更关注相对变化而不是绝对灰度。
6. 把检测结果做精:多尺度推理、阈值扫描与工程化保存
模型训好后,别急着直接上线。每次推理前我习惯跑一个多尺度推理:把输入图像按0.75、1.0、1.25三档缩放,分别用模型预测,将输出概率图上采样回原始尺寸,再对三张概率图取平均。这样能显著减少单尺度下对特定目标尺寸的偏好,尤其适合SAR图像里尺度变化大的地物变化。多尺度推理的代价是推理时间乘以3,但对离线的变化检测场景通常可接受;如果是实时web系统,可以只在后台批量任务里开启。
另一个必做动作是阈值扫描。模型输出的是变化概率图,但用户最终要的是一张二值掩码。0.5这个默认阈值几乎不是最优的。我一般会保存概率图,然后在验证集上用0.2到0.8、步长0.05扫描一遍,分别计算Kappa和F1,取最优。这个步骤代码很短,但能把Kappa从0.5拉到0.7以上,算是性价比最高的调优手段。如果系统给用户开放了阈值参数,就把扫描结果里的“推荐值”默认填进去,而不是硬编码0.5。
最后是工程化保存。如果输入是带地理坐标的GeoTIFF,输出掩码也应该保留地理坐标。用rasterio读取原图的仿射变换和投影,把掩码写成新的GeoTIFF,这样后续可以直接进GIS或专业平台叠加分析。只存PNG会丢掉坐标,等于白做。我现在的习惯是:无论web系统多轻量,都必须保留概率图GeoTIFF和掩码GeoTIFF两个产物,前者方便事后调阈值,后者方便业务直接使用。这也算是我从一个只能出PNG的demo系统一路踩过来的教训,希望帮到你。
本文还有配套的精品资源,点击获取