简介:一份基于Python神经网络学习的SAR图像变化检测系统完整项目包,面向遥感图像处理、深度学习和计算机视觉方向的研究者、学生与开发者。系统利用多时相SAR图像自动识别地表变化,适用于自然灾害监测、城市规划等领域。项目采用深度学习建模思路,覆盖SAR图像预处理、神经网络构建、模型训练、变化图生成与结果展示全链路。包内共195个文件,约3.07MB,主要包含Python源码(.py)、PyTorch模型权重(.pt)、多组SAR测试图像(.bmp/jpg)、配置与说明文档(.json/yml/md),以及基于Vue/JS的前端可视化模块(*.vue/js/html),目录组织清晰,便于按模块阅读与二次开发。已有104人学习/浏览。借助该资源,可完整掌握从数据加载、特征提取到变化区域输出的实现细节,理解CNN在SAR变化检测中的实际应用,同时参考项目结构快速搭建属于自己的检测系统,适合作为课程设计、毕业设计或科研起步的参考模板。
1. SAR图像变化检测系统需要回答的三个问题
SAR(合成孔径雷达)图像不受云雨和光照影响,能全天候拍下同一片地面的状态,这让它成为地质灾害监测、农业估产和城市违建巡查的常用数据源。但把同一地区两个时相的SAR图叠在一起做差值,你会发现噪声比变化还要刺眼——SAR固有的相干斑点噪声会让同一条马路在两个时相里差出几个灰度级,直接做代数运算是分不出“地面塌了”和“雷达角度偏了0.1度”的。这个基于Python神经网络学习的SAR图像变化检测系统,本质上是把“哪些像素变了”从“哪个像素在噪声里闪了”这个问题里剥离出来:它用神经网络学习SAR图像的噪声分布规律,把真实的土地覆盖变化以掩膜的形式标出来,再用Web界面把前后时相和变化区域叠在一张图上呈现。适用对象很明确:遥感算法工程师拿它做原型验证,GIS开发人员拿它搭变化检测服务,研究生拿它复现实验。正式开始前,先统一一下技术栈:图像处理用OpenCV,神经网络用PyTorch,模型推理封装用Flask,前端只承担上传、展示和下载结果。
2. 神经网络模型选型与SAR数据预处理
2.1 为什么从U-Net和Siamese结构里选
SAR图像变化检测本质上是一个逐像素的二分类问题:预测结果是“变化”或“未变化”。但它和普通语义分割有一个关键差异——输入永远是两张图,输出永远是一张掩膜。常见做法是三种:两时相图像拼接后送入单分支网络,两时相图像分别过共享权重的双分支网络再融合,或者先做像素级差异图再输入网络。前者实现简单但把配对关系留给了网络自己学,后者更符合SAR变化检测任务的直觉。
U-Net的优势在于它保留了编码器的空间细节,解码器的每一层都拼接了对应尺度的特征图,这对变化区域的边缘恢复特别有利——SAR图像里的变化区域边缘往往就是建筑轮廓或滑坡边界,像素级判对比区域级判对更有意义。Siamese结构的价值在于它强制两个时相使用同一套权重的特征提取器,变化检测只关心“同一个位置的属性是否改变”,不关心两次成像的绝对辐射值,共享权重正好把绝对差异从特征层面压掉。
实际落地时我会选择两者结合:Siamese双分支共享权重的编码器,加一个带拼接融合的U-Net解码器。这个组合的推理阶段能接受任意尺寸输入(模型内部做了padding),训练时又能利用ImageNet上预训练权重做初始化。
import torch import torch.nn as nn class SiameseUNetBlock(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)这个块贯穿整个编码器链,两个时相的图像输入时都调用同一个SiameseUNetBlock实例,梯度在反向传播时自然共享。padding=1保证特征图尺寸不变,后面写跳连接时不用做裁剪对齐。
2.2 SAR图像预处理:斑点噪声滤波、对数变换与灰度窗
SAR图像和光学图像最大的差别是灰度范围。光学图像的16位DN值经过辐射定标后能直接当浮点数喂给网络,SAR图像则要先取对数压缩动态范围,再做强度归一化。常见做法是先做多视处理或Lee滤波抑制相干斑点噪声,再做对数变换,最后按百分位截断拉伸到[0,1]。
Lee滤波是SAR领域的老牌噪声抑制方案。它在同质区域做均值滤波,在边缘区域保留原值,参数上只需要关心窗口大小和等效视数ENL。窗口设5x5通常能在保边缘和降噪之间取一个平衡,ENL根据图像的等效视数来填,没有标定值时就填默认的1。
import cv2 import numpy as np def sar_preprocess(image_path, log_clip=(0.02, 0.98)): """SAR单时相图像预处理""" img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) # 对数变换压缩动态范围,避免高反射目标主导loss img = np.log1p(img.astype(np.float64)) # 百分位截断,去掉极亮和极暗的离群辐射点 lo, hi = np.percentile(img, [log_clip[0]*100, log_clip[1]*100]) img = np.clip((img - lo) / (hi - lo + 1e-8), 0, 1) return (img * 255).astype(np.uint8) def lee_filter(img, win_size=5, enl=1.0): """Lee滤波主函数""" img_f = img.astype(np.float64) kernel = np.ones((win_size, win_size), dtype=np.float64) / (win_size * win_size) mu = cv2.filter2D(img_f, -1, kernel) mu_sq = cv2.filter2D(img_f * img_f, -1, kernel) var = mu_sq - mu * mu var = np.clip(var, 0, None) # 等效视数越小,降噪强度越高 cu = np.sqrt(1.0 / enl) cu_max = np.sqrt(2.0 / enl) cuv = np.sqrt(var) / (mu + 1e-8) weight = np.exp(-(cuv - cu) / (cu_max - cu + 1e-8)) weight = np.clip(weight, 0, 1) result = mu + weight * (img_f - mu) return np.clip(result, 0, 255).astype(np.uint8)预处理顺序不能乱:先滤波再对数变换。先取对数会把噪声的乘性特征转成加性,但滤波窗口的统计假设是基于原始乘性噪声模型推导的,顺序反过来效果会变差。log_clip的截断比例直接决定网络看到的对比度,SAR大范围实验中我一般保持0.02/0.98不动,只有在海岸带含强反射舰船时才调到0.01/0.99。
2.3 构建图像对样本:配准、切片与标注策略
网络训练需要的是“前一时相图、后一时相图、变化掩膜”的三元组。SAR图像变化检测公开数据集不多,常见的有Bern、Ottawa和California等经典场景,这些数据集的单张图像通常在300x300到500x500左右,直接整图训练会导致batch里样本数太少。常见做法是滑窗切片:用256x256或128x128的窗口、步长取窗口的一半做重叠切片,把切片后的图像对和掩膜对应存下来。
要强调的是,SAR图像变化检测训练对配准精度要求比光学图像高很多。两时相图像哪怕只有像素级的偏移,边缘区域就会被网络学成“伪变化”。我一般会在切片之前用cv2的ECC算法或ORB特征点匹配先做一次刚体配准:
def align_images(img1, img2, warp_mode=cv2.MOTION_EUCLIDEAN): """以img1为基准,对img2做刚体配准""" # 转为float32并归一化,ECC算法要求 im1 = img1.astype(np.float32) / 255.0 im2 = img2.astype(np.float32) / 255.0 # 初始化为单位变换矩阵 warp_matrix = np.eye(2, 3, dtype=np.float32) criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 5000, 1e-8) try: _, warp_matrix = cv2.findTransformECC(im1, im2, warp_matrix, warp_mode, criteria) except cv2.error: # 收敛失败时退回原始图像 return img2 h, w = img2.shape aligned = cv2.warpAffine(img2, warp_matrix, (w, h), borderMode=cv2.BORDER_REPLICATE) return aligned切片策略的具体参数我放在下表里,这是训练效果和显存占用折中后比较稳的一组值:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 切片尺寸 | 256x256 | 感受野足够覆盖典型建筑/滑坡体 |
| 切片步长 | 128 | 重叠50%,样本量翻倍且不破坏边缘连续性 |
| 归一化方式 | 每张切片独立归一化 | 应对切片间辐射差异 |
| 数据增强 | 随机翻转/旋转90度 | 不能做颜色抖动和随机光照 |
| 正负样本比例 | 变化像素占比<10%时做重采样 | 防止模型退化到全预测为未变化 |
标签掩膜生成方式按数据来源分两条路:公开数据集自带人工标注的ground truth,直接切就行;如果是实测数据,就先用传统变化检测方法(如对数比值法+Otsu阈值)生成伪标签,再由人工修正。后者的人工工作量大约每景图像2小时到半天不等,取决于变化区域复杂度。
3. 用PyTorch搭建变化检测网络
3.1 双时相输入结构设计与前向传播
上一章的SiameseUNetBlock是编码器的基础单元,整个网络结构需要把它串起来。标准做法是四层编码器加四层解码器:编码器每层输出接一个池化,解码器用转置卷积恢复分辨率,每一层的解码器输入都拼接编码器对应层的输出。Siamese体现在前两时相图像在编码器阶段各走一遍,权重共享,到最底层特征图用通道维度拼接后再进解码器。
class ChangeDetectionNet(nn.Module): """双时相SAR图像变化检测网络""" def __init__(self, in_ch=1, base_ch=32): super().__init__() # 共享编码器 self.enc1 = SiameseUNetBlock(in_ch, base_ch) self.enc2 = SiameseUNetBlock(base_ch, base_ch * 2) self.enc3 = SiameseUNetBlock(base_ch * 2, base_ch * 4) self.enc4 = SiameseUNetBlock(base_ch * 4, base_ch * 8) self.pool = nn.MaxPool2d(2) # 融合两个时相特征的解码器 self.dec4 = nn.ConvTranspose2d(base_ch * 16, base_ch * 8, 2, stride=2) self.dec3 = nn.ConvTranspose2d(base_ch * 16, base_ch * 4, 2, stride=2) self.dec2 = nn.ConvTranspose2d(base_ch * 8, base_ch * 2, 2, stride=2) self.dec1 = nn.ConvTranspose2d(base_ch * 4, base_ch, 2, stride=2) self.final = nn.Conv2d(base_ch, 1, 1) def forward(self, t1, t2): # 两个时相走同一个编码器,权重完全共享 e1_1 = self.enc1(t1); e1_2 = self.enc1(t2) p1_1 = self.pool(e1_1); p1_2 = self.pool(e1_2) e2_1 = self.enc2(p1_1); e2_2 = self.enc2(p1_2) p2_1 = self.pool(e2_1); p2_2 = self.pool(e2_2) e3_1 = self.enc3(p2_1); e3_2 = self.enc3(p2_2) p3_1 = self.pool(e3_1); p3_2 = self.pool(e3_2) e4_1 = self.enc4(p3_1); e4_2 = self.enc4(p3_2) p4_1 = self.pool(e4_1); p4_2 = self.pool(e4_2) # 通道维度拼接,特征通道翻倍 fused = torch.cat([p4_1, p4_2], dim=1) d4 = torch.cat([self.dec4(fused), e4_1 + e4_2], dim=1) d3 = torch.cat([self.dec3(d4), e3_1 + e3_2], dim=1) d2 = torch.cat([self.dec2(d3), e2_1 + e2_2], dim=1) d1 = torch.cat([self.dec1(d2), e1_1 + e1_2], dim=1) return torch.sigmoid(self.final(d1))这里有个容易忽略的细节:解码器跳接时我用的是e1_1 + e1_2而不是torch.cat。原因是两个编码器的特征图语义一致、数值范围相同,相加能保持特征通道数不再翻倍,减少解码器计算量。这个改动在变化检测任务里几乎不损失精度,推理速度能提升10%以上。如果追求极限精度,可以把加法换成cat,同时把对应解码器的输入通道数翻倍。
3.2 手写数据加载器:从切片文件到训练管线
数据加载器要同时从三个目录读文件:t1/、t2/、label/,文件名保持一致前缀。SAR图像的切片文件是8位灰度PNG,标签是0(未变化)和255(变化)的二值图。加载器在__getitem__里把三个文件读进来,做数据增强,转成Tensor。
from torch.utils.data import Dataset, DataLoader class SARDataset(Dataset): def __init__(self, t1_dir, t2_dir, label_dir, crop_size=256, augment=False): self.t1_dir = t1_dir self.t2_dir = t2_dir self.label_dir = label_dir self.crop_size = crop_size self.augment = augment self.names = [p.stem for p in Path(t1_dir).glob('*.png')] def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] t1 = cv2.imread(f'{self.t1_dir}/{name}.png', cv2.IMREAD_GRAYSCALE) t2 = cv2.imread(f'{self.t2_dir}/{name}.png', cv2.IMREAD_GRAYSCALE) label = cv2.imread(f'{self.label_dir}/{name}.png', cv2.IMREAD_GRAYSCALE) # 随机裁剪到crop_size,保证同一位置的图像和标签一起裁 h, w = t1.shape y0 = np.random.randint(0, h - self.crop_size + 1) if h > self.crop_size else 0 x0 = np.random.randint(0, w - self.crop_size + 1) if w > self.crop_size else 0 t1 = t1[y0:y0+self.crop_size, x0:x0+self.crop_size] t2 = t2[y0:y0+self.crop_size, x0:x0+self.crop_size] label = label[y0:y0+self.crop_size, x0:x0+self.crop_size] if self.augment: if np.random.rand() > 0.5: t1, t2, label = t1[:, ::-1], t2[:, ::-1], label[:, ::-1] if np.random.rand() > 0.5: t1, t2, label = t1[::-1], t2[::-1], label[::-1] # 转Tensor并归一化到[0,1] t1 = torch.from_numpy(t1).float().unsqueeze(0) / 255.0 t2 = torch.from_numpy(t2).float().unsqueeze(0) / 255.0 label = torch.from_numpy(label).float().unsqueeze(0) / 255.0 return t1, t2, label随机裁剪的位置三个文件必须共用一套坐标,这是多输入任务里最常见的数据集bug来源。还有一种更隐蔽的情况是OpenCV读图后numpy数组是HxW格式,而PyTorch的卷积层期望NxCxHxW,unsqueeze(0)在最后一维做会让通道维度变到错误的轴上。上面代码里unsqueeze(0)在from_numpy之后立刻做,保证是1xHxW,顺序不能反。
3.3 损失函数对比与训练循环的关键参数
变化检测的训练目标函数通常在这两种里选:二元交叉熵(BCE)和Dice Loss。BCE收敛稳定,但SAR变化检测里变化区域往往只占整幅图像的2%到8%,BCE会让模型陷入“全预测为未变化”的局部最优。Dice Loss直接优化区域重叠度,对小目标更友好,但单独用Dice Loss在训练初期梯度不稳定。常见做法是两者加权相加。
import torch.nn.functional as F def bce_dice_loss(pred, target, bce_weight=0.5): """组合损失:BCE稳定梯度,Dice聚焦小目标""" pred = pred.squeeze(1).squeeze(1) target = target.squeeze(1).squeeze(1) bce = F.binary_cross_entropy(pred, target) smooth = 1.0 pred_flat = pred.reshape(-1) target_flat = target.reshape(-1) intersection = (pred_flat * target_flat).sum() dice = 1.0 - (2.0 * intersection + smooth) / ( pred_flat.sum() + target_flat.sum() + smooth ) return bce_weight * bce + (1.0 - bce_weight) * dice配合下表的超参数组合,能在不调学习率调度器的情况下把训练流程稳定跑通:
| 超参数 | 推荐值 | 调参方向说明 |
|---|---|---|
| 优化器 | AdamW | 比Adam权重衰减更规范,SAR大batch下更稳 |
| 学习率 | 1e-4 | 若loss震荡则降至5e-5,若收敛过慢则提至3e-4 |
| batch大小 | 8(256x256切片) | 显存不够时优先降batch,其次降切片尺寸 |
| 训练轮数 | 50~80 | 用验证集Dice判定早停 |
| 权重初始化 | ImageNet预训练(把输入复制成3通道) | SAR单通道预训练权重要做通道广播 |
| 梯度裁剪 | max_norm=1.0 | 防止对数比值图的极端像素值放大梯度 |
训练循环的标准写法是先model.train()再遍历batch,注意一点:torch.sigmoid(self.final(d1))已经在前向里做了,损失函数里不能再对pred做sigmoid。上面代码里的bce_dice_loss直接拿pred和target做交叉熵,因为pred已经是[0,1]区间的概率值。
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.5) scaler = torch.cuda.amp.GradScaler() # 混合精度,SAR大图推理和训练都快很多 for epoch in range(80): model.train() running_loss = 0.0 for t1, t2, label in train_loader: t1, t2, label = t1.cuda(), t2.cuda(), label.cuda() optimizer.zero_grad() with torch.cuda.amp.autocast(): pred = model(t1, t2) loss = bce_dice_loss(pred, label) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() running_loss += loss.item() scheduler.step() # 每个epoch后评估验证集Dice,代码略混合精度在这里不是可选项而是性能选项。SAR图像切片喂进网络的是256x256单通道小图,半精度误差对最终变化掩膜几乎没有影响,但显存占用可以直接降一半,batch size可以翻倍。需要注意的是GradScaler和clip_grad_norm_的调用顺序,必须先scaler.unscale_(optimizer)再裁剪,否则梯度是缩放过的,裁剪阈值就失效了。
4. 将模型封装为Web系统
4.1 项目结构设计与推理模型导出
模型训练完,Web端要解决的是“输入两张图、输出一张掩膜”的链路问题。整套系统我按工具化思路分四个目录组织:
sar-change-detection/ ├── app.py # Flask入口 ├── models/ │ └── checkpoint.pth # 训练好的权重 ├── utils/ │ ├── preprocess.py # 预处理函数 │ └── inference.py # 推理封装 ├── static/ │ └── upload/ # 用户上传图像临时存储 └── templates/ └── index.html推理阶段要把模型切到eval()模式并关闭梯度计算。PyTorch的torch.no_grad()块在推理张量较大时能省下可观的峰值内存。模型导出时还有一个易错点:训练时模型输入是Nx1xHxW,Web端接收到的图像尺寸往往不是256的倍数,推理模块需要把输入图padding到256的倍数,预测完再裁剪回原始尺寸。
# utils/inference.py import torch import numpy as np def pad_to_multiple(img, multiple=256): h, w = img.shape pad_h = (multiple - h % multiple) % multiple pad_w = (multiple - w % multiple) % multiple padded = np.pad(img, ((0, pad_h), (0, pad_w)), mode='edge') return padded, h, w def predict_change(model, t1_img, t2_img, device='cuda'): """输入两张预处理后的灰度图,输出变化概率图和二值掩膜""" model.eval() t1_pad, h, w = pad_to_multiple(t1_img) t2_pad, _, _ = pad_to_multiple(t2_img) t1_tensor = torch.from_numpy(t1_pad).float().unsqueeze(0).unsqueeze(0).to(device) / 255.0 t2_tensor = torch.from_numpy(t2_pad).float().unsqueeze(0).unsqueeze(0).to(device) / 255.0 with torch.no_grad(): prob_map = model(t1_tensor, t2_tensor).squeeze().cpu().numpy() prob_map = prob_map[:h, :w] return prob_mappadding方式用mode='edge'而不是默认的constant填充0,是因为SAR图像边缘像素的辐射值和远景区域差异巨大,填0会在预测掩膜边缘制造一圈伪变化。edge模式用图像本身的边界值向外扩展,对滑动窗口式的卷积网络更友好。
4.2 Flask API接收上传图像并返回结果
Flask端提供两个接口:GET /渲染Web页面,POST /predict接收两张图像并返回处理结果。API设计上我倾向于传入两个multipart/form-data字段,而不是传一个打包的zip压缩包——省去后端解压的时间,前端也更容易处理错误情况。
# app.py from flask import Flask, request, jsonify, render_template import os import uuid import cv2 from utils.preprocess import sar_preprocess, lee_filter from utils.inference import predict_change app = Flask(__name__) app.config['MAX_CONTENT_LENGTH'] = 32 * 1024 * 1024 # 单张最大32MB # 模型加载只做一次,避免每个请求都加载权重 model = load_model('models/checkpoint.pth') device = 'cuda' if torch.cuda.is_available() else 'cpu' @app.route('/', methods=['GET']) def index(): return render_template('index.html') @app.route('/predict', methods=['POST']) def predict(): file1 = request.files.get('t1') file2 = request.files.get('t2') if not file1 or not file2: return jsonify({'error': '请同时上传两个时相的SAR图像'}), 400 uid = uuid.uuid4().hex path1 = f'static/upload/{uid}_t1.png' path2 = f'static/upload/{uid}_t2.png' file1.save(path1) file2.save(path2) # 预处理:滤波、对齐、对数变换 img1_raw = cv2.imread(path1, cv2.IMREAD_GRAYSCALE) img2_raw = cv2.align = align_images(img1_raw, img2_raw) img1 = sar_preprocess(path1) img2 = sar_preprocess_from_array(img2_aligned) # 推理 prob_map = predict_change(model, img1, img2, device) # 保存掩膜到工作目录 mask_path = f'static/upload/{uid}_mask.png' cv2.imwrite(mask_path, (prob_map * 255).astype(np.uint8)) return jsonify({ 'mask_url': f'/{mask_path}', 'changed_ratio': float((prob_map > 0.5).mean()) })接口返回的changed_ratio是变化像素占比,前端可以直接拿它做初步判断。需要明确的是,Flask开发服务器app.run()在多线程并发下性能有限,8并发以内够用,正式部署时接gunicorn或uWSGI,命令大概长这样:
gunicorn -w 4 -b 0.0.0.0:5000 app:app --timeout 1204个worker对应4个独立进程,注意每个worker都会加载一份模型到内存,模型大小乘以worker数就是Web服务的基础内存占用。
4.3 前端交互:上传、预览与结果叠加渲染
前端用原生HTML加少量JavaScript就够,不需要引入React或Vue。核心交互是:选择两张图像后立即预览,点“开始检测”后把图像POST到后端,返回掩膜图后叠加到前时相图像上。叠加渲染有两种方案:后端用OpenCV的addWeighted把掩膜红色通道叠加到前时相图上生成一张新图返回,前端用canvas按像素透明度叠加。我推荐后者——后端输出原始概率图,前端控制透明度滑块可以交互式调阈值。
<!-- templates/index.html 片段 --> <div> <label>前时相图像:</label> <input type="file" id="t1" accept="image/png,image/jpg"> <img id="preview_t1" width="400"> </div> <div> <label>后时相图像:</label> <input type="file" id="t2" accept="image/png,image/jpg"> <img id="preview_t2" width="400"> </div> <div> <label>变化阈值:</label> <input type="range" id="threshold" min="0" max="100" value="50"> <span id="th_val">0.5</span> </div> <button onclick="runDetection()">开始检测</button> <canvas id="overlay" width="400" height="400"></canvas>runDetection函数用FormData对象构造multipart请求,这里有一个JavaScript的坑:FormData.append的第三个参数不传文件名时,某些浏览器会用blob作为文件名,后端拿不到.png后缀导致OpenCV读取失败。显式传入t1_prev.png和t2_prev.png可以彻底规避。
前端加载掩膜后叠加渲染时,canvas的globalAlpha用阈值滑块的当前值,代码如下:
async function runDetection() { const form = new FormData(); form.append('t1', document.getElementById('t1').files[0], 't1_prev.png'); form.append('t2', document.getElementById('t2').files[0], 't2_prev.png'); const resp = await fetch('/predict', { method: 'POST', body: form }); const data = await resp.json(); const maskImg = new Image(); maskImg.src = data.mask_url; maskImg.onload = () => { const canvas = document.getElementById('overlay'); const ctx = canvas.getContext('2d'); const t1Img = document.getElementById('preview_t1'); ctx.drawImage(t1Img, 0, 0, 400, 400); ctx.globalAlpha = document.getElementById('threshold').value / 100; ctx.drawImage(maskImg, 0, 0, 400, 400); ctx.globalAlpha = 1.0; }; }这个交互设计把“阈值”这个概念从后端参数变成了前端实时调节的滑块,不需要重新请求接口就能观察不同置信度下变化区域的变化。实际使用中用户会习惯性把阈值往下拉到0.4以下,因为SAR变化检测的预测概率普遍偏低,这是模型输出的概率校准问题,不是Web端bug。
5. 验证与调优的实操落点
一套系统跑通后,最容易被忽略但最值得花时间的是输出结果的定量验证。SAR图像变化检测有三个指标几乎绕不开:查准率(Precision)、查全率(Recall)和总体精度(Overall Accuracy)。查准率衡量预测为变化的像素里有多少是真实变化,查全率衡量真实变化的像素里有多少被找出来,这两个指标天然互相制约。
def evaluate_metrics(pred_mask, gt_mask): """pred_mask和gt_mask都是0/1的numpy数组""" pred_mask = (pred_mask > 0.5).astype(np.uint8) gt_mask = (gt_mask > 0.5).astype(np.uint8) tp = np.logical_and(pred_mask == 1, gt_mask == 1).sum() fp = np.logical_and(pred_mask == 1, gt_mask == 0).sum() fn = np.logical_and(pred_mask == 0, gt_mask == 1).sum() precision = tp / (tp + fp + 1e-8) recall = tp / (tp + fn + 1e-8) f1 = 2 * precision * recall / (precision + recall + 1e-8) return {'precision': precision, 'recall': recall, 'f1': f1}F1分数是实际部署时最常用的单值评估指标。当你发现F1上不去,先不要动模型,检查两件事:第一,ground truth的标注有没有把“由于雷达入射角差异造成的鬼影”标为变化,这属于标注噪声;第二,预处理的对齐步骤是否引入亚像素偏移,ECC配准失败时直接返回原始图像,会让大量边缘像素变成伪变化。定位这两个问题后,常见优化路线是:
调整推理阈值来平衡查准率和查全率。模型输出的概率图直接以0.5为阈值二值化,往往导致变化区域偏小(因为SAR变化区域的概率响应是分布式的,峰值常在0.6左右但边缘剪切带只有0.3)。在验证集上遍历阈值0.3到0.7、步长0.05,选F1最高的值作为生产环境默认阈值。
小目标变化区域的增强手段:形态学开运算去掉掩膜里的孤立噪点,闭运算填充变化区域的细小空洞。注意结构元素不要超过3x3,SAR噪声的颗粒度决定了更大卷积核会抹掉真实的小面积变化。
模型推理性能的提升空间通常不在网络结构,而在输入尺寸和数据通路。把pad_to_multiple的multiple从256改成128,推理速度能快一倍,代价是模型在跨patch边界处可能出现拼接痕迹。另一个容易忽略的点是Web端配准很贵:findTransformECC在两景512x512灰度图上的耗时可能超过模型推理本身,业务上如果两时相图像已经经过标准地理配准,这个步骤应当做成可配置项而不是强制流程。
上面这套验证和调优做完,系统的精度和性能基本就稳定在一个可用状态了。它不需要再堆模型复杂度,也不需要拼推理框架,日常维护只需要盯着验证集F1和报警数据这两个信号就够了。
本文还有配套的精品资源,点击获取