
简介这是一个基于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输出同尺寸变化掩码 YY1 表示该位置发生了目标变化。难点不在“求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), dtypenp.float32) x2 np.array(Image.open(x2_path).convert(L), dtypenp.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], axis0) return inp.astype(np.float32)这段代码里最值得注意的参数是 eps。SAR图像的原始数据里经常有0值像素尤其是雷达阴影区或成像边缘直接 log(0) 会得到负无穷加一个1e-6是为了稳定计算如果数据本身范围在 0~65535建议把 eps 调大一点到 1e-3否则 log(x1e-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, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class SimpleUNet(nn.Module): def __init__(self, in_ch3, out_ch2): 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_ch3对应前面构造的三个通道log1、log2、ratio。out_ch2表示两类变化与不变。如果希望输出单通道概率图也可以设out_ch1并配合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_modecv2.MOTION_TRANSLATION): # im1是参考图im2是待配准图 # 统一尺寸为512x512ECC对差异大的图容易不收敛所以先缩放 im1 cv2.resize(im1, (512, 512), interpolationcv2.INTER_LINEAR) im2 cv2.resize(im2, (512, 512), interpolationcv2.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), flagscv2.INTER_LINEAR cv2.WARP_INVERSE_MAP) else: aligned cv2.warpAffine(im2, warp_matrix, (512, 512), flagscv2.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_size256, stride128): 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:ypatch_size, x:xpatch_size] p2 im2[y:ypatch_size, x:xpatch_size] lab label[y:ypatch_size, x:xpatch_size] patches.append((p1, p2, lab)) return patchesstride控制重叠度。stride128产生50%重叠让每个像素出现在多个patch中相当于数据加倍但会引入重复样本。如果内存吃紧把stride提高到等于patch_size即无重叠切块不过推理结果很容易出现拼缝。训练和推理对stride的要求不同训练时可以无重叠或重叠小一点推理时最好用重叠加平均来消除拼缝。3.2 训练样本怎么生成像素级标签与patch采样SAR变化检测的标签通常来自人工勾画或矢量图层栅格化。标签图与图像必须严格在同一坐标系、同一分辨率下否则后面训练时对不齐。类别不平衡是这个任务最典型的特征一片区域内可能变化像素只占1%甚至更低。如果按全图随机切patch大多数patch里没有变化标签模型会倾向于把所有像素预测成“不变”。我一般会在采样阶段做正负样本平衡。正样本中心从标签大于0的区域取负样本中心从全图随机取两者各占一部分。下面这段生成器代码可以直接塞进训练循环def balance_patch_sampling(im1, im2, label, patch_size256, positives_per_epoch20, randoms_per_epoch20): 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), sizepositives_per_epoch, replaceTrue) 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:cypatch_size, cx:cxpatch_size], im2[cy:cypatch_size, cx:cxpatch_size], label[cy:cypatch_size, cx:cxpatch_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_weight0.5): bce F.cross_entropy(pred_logits, target) # pred_logits shape: N,2,H,Wtarget为0/1 prob F.softmax(pred_logits, dim1)[:, 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(lr3e-4, weight_decay1e-4)学习率CosineAnnealingLR(T_maxepochs)batch size 8~16patch_size 为 256 时batch8 在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%即使模型全预测为不变po0.99但pe也接近0.98Kappa只有0.1左右这才能真实反映模型的可用性。4. 把模型包成web系统FastAPI在SAR图像变化检测里的落地姿势4.1 web系统要暴露哪些能力上传、推理、结果下载一个SAR变化检测web系统核心不是复杂页面而是两个能力让使用者不写代码地跑一次变化检测以及拿到可直接使用的结果图。所以接口设计上绕不开三个点上传两时相图像、后台执行模型推理、下载变化掩码和差异叠加图。因为SAR原始数据常是GeoTIFFweb端不要直接让浏览器解析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_locationcuda) 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, dim1)[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_typeimage/png)这里有个关键选择predict是CPU/GPU混合操作如果直接在一个async def里调用会阻塞FastAPI的事件循环其他请求全部排队。用run_in_threadpool把它丢给线程池才能保证并发。如果服务器没有GPU去掉map_locationcuda和.cuda()模型会在CPU上跑速度可能慢10倍以上但至少不崩。输入图像尺寸如果和训练时不一致全卷积网络能跑但输出尺寸会跟着变我给这个接口加一个隐式约定上传图会被cv2.resize到 256 的整数倍否则推理结果和原始坐标对不上。4.3 前端交互与后端任务队列别让请求卡死浏览器当图像是几万像素宽时一次推理可能要几十秒甚至几分钟。直接用同步接口前端会一直等响应浏览器很容易超时或卡死。常见做法是引入任务队列用户上传后得到task_id后端后台线程跑推理前端每隔几秒查询。最简单的任务状态管理可以直接用全局字典。单机、并发量不大时够用不需要上Redisimport 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(targetrun_detection_task, args(task_id, path1, path2), daemonTrue).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图像灰度动态范围极大网络对高亮度区域的速度跳变过度敏感。解决先看输出概率图如果概率图上的“变化”是均匀散布的椒盐点说明是噪声误检。用平衡采样重训并考虑在差异图上加一个小的中值滤波作为预处理。监控指标从准确率换成KappaKappa能明显反映这种“全图乱开枪”的糟糕状态。5.2 现象两张输入图配准不准导致检测结果成片伪变化原因亚像素级的错位在SAR图像中会被放大因为雷达目标的散射点在几个像素内就能从极强变成极弱。配准误差表现为道路边缘、房角、山脊线上的成片伪变化而不是散点。解决在推理时对第二张图做几个微小平移比如上下左右各移动1个像素分别推理后取概率平均这个技巧能明显削弱错位带来的边缘伪变化。更好的办法是训练时用Siamese网络或加入随机平移增强让模型对微小错位免疫。5.3 现象web上传大图内存爆掉原因几百MB的GeoTIFF读进来变成numpy数组float32单通道一张10000x10000就是400MB堆叠成3通道就是1.2GBweb服务并发两个请求直接OOM。再加上模型推理时的中间特征图内存轻松超过4GB。解决用rasterio按块读取推理时做分块滑窗最后再把结果拼起来。如果实在要用整图至少先把输入缩放到模型能接受的最大尺寸。对web系统来说上传时限制文件大小只是个补丁核心是后端要能处理超大图分块推理是绕不开的。5.4 现象预测结果出现条带状异常原因推理时把图像切成patchpatch之间没有重叠模型在边界处特征缺失拼接后就会出现明显的接缝。另一个常见原因是BatchNorm在推理时没有切换到eval模式导致每个patch的统计量不一致输出概率在patch边界发生跳变。解决推理滑窗必须带重叠比如patch_size256时设置stride224即重叠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系统一路踩过来的教训希望帮到你。本文还有配套的精品资源点击获取