简介:面向本科毕业设计的城市建筑物高分辨率遥感影像变化检测项目,基于改进的U-Net++算法实现,适合计算机视觉、遥感或深度学习方向的毕设学习者参考。资源包含完整Python源码、详细说明文档及注释,共11个文件,涵盖6个Python脚本、2个LaTeX文档及程序压缩包等,核心代码与实验记录分层存放。方案在模型选择、编码器架构上做了优化,引入SE注意力模块、嵌套U-Net的损失计算与剪枝技术,并针对训练阶段的CUDA显存溢出问题给出缓解思路;部分实验采用加权FocalLoss,兼顾类别不均衡场景。资源体积约21KB,已有207人学习,适合希望快速理解改进分割网络在遥感变化检测中落地流程的读者。
1. 高分辨率建筑物变化检测:为什么大家都在改进U-Net++
遥感变化检测这几年已经不是学术圈自嗨了,城市扩张监测、违建排查、拆迁审计这些业务场景都在要「能直接出结果」的模型。这张图里最难的不是语义分割本身,而是同一栋楼在不同时相的影像里,光照、角度、阴影全不一样,模型很容易把「没变」当成「变了」。这个项目切入得很直接:以U-Net++为骨架,把编码器换掉、嵌入SE模块、加上嵌套损失和剪枝,再配一版加权FocalLoss来处理变化区域和背景区域极度不平衡的问题。它解决的问题很明确——在高分辨率遥感影像上,把建筑物的新增和拆除区域从像素级别抠出来,交付给下游做统计和分析。适合正在做本科毕设、或者刚接手遥感分割任务想找一份能跑通参考实现的人,源码、说明文档和注释都是齐的,可以直接对着改。
2. 从U-Net到嵌套U-Net++:SE模块与剪枝技术到底改了什么
2.1 为什么变化检测要选U-Net++而不是普通U-Net
普通U-Net的结构是一条对称的编码器-解码器通道,浅层特征和深层特征只在最底层完成一次跳跃连接。这个设计在医学分割里效果很好,因为医学影像的语义相对单一、结构相对固定。但遥感建筑物变化检测不一样,输入是两期影像(或者两期影像拼接成多通道输入),变化区域往往在边缘细节上和未变化区域极其相似,单次跳跃连接带来的特征融合粒度不够。
U-Net++做的事情是把跳跃连接从一条线变成一个嵌套的密集结构:每一层解码器不再只接收来自对应编码器层的特征,而是接收前面所有子模块输出的特征叠加。用大白话说,普通U-Net是「每个楼层只跟正下方那层沟通」,U-Net++是「每个楼层把下面所有楼层的消息都汇总一遍」。这个设计对遥感变化检测的价值在于,不同尺度的建筑物轮廓信息可以在训练中被反复融合,小到单栋民房的屋顶变化、大到成片厂区的拆除,都能在同一套参数里获得比较均衡的感知。
2.2 编码器换成VGG19:预训练权重带来的收益和代价
项目里把编码器从原始U-Net++论文里的普通卷积堆叠换成了VGG19的encoder部分,这是一个很务实的决定。VGG19虽然是老架构,但它的ImageNet预训练权重在全世界各种任务里被验证过泛化性,遥感影像虽然和自然图像分布不同,但底层纹理、边缘、角点这些低级特征是可以迁移的。训练初期损失下降的速度会明显比随机初始化快,这对算力有限的本科毕设场景尤其重要——你大概率没有一张24G显存的卡去从头训一个深层分割网络。
代价有三个。第一,VGG19的前五层stage下采样倍数分别是1、2、4、8、16,输入尺寸必须能整除以32,否则特征图尺寸对不上。第二,VGG19的encoder部分在stage3之后通道数直接去到512,嵌套U-Net++里每个子U-Net都要在这些高维特征上做卷积,显存占用会明显上升。第三,预训练权重是RGB三通道的,如果你的输入是两期影像拼接成的六通道,要么把两期影像分别过encoder再在高层融合,要么直接砍掉预训练的第一层卷积权重重新初始化。项目的做法是把两期影像分别过权重共享的encoder,在解码器阶段做差异融合,这也是大多数遥感变化检测模型的标准操作。
2.3 SE模块嵌入的位置和理由
SE模块(Squeeze-and-Excitation)的核心操作就两步:先通过全局平均池化把每个通道的空间信息压缩成一个数值,再用两个全连接层学习出每个通道的重要性权重,最后把权重乘回原来的特征图。本质上是给模型加了一个「注意力开关」,让它对有变化响应的特征通道更敏感。
在这个项目里SE模块不是简单地堆在编码器后面,而是嵌在嵌套U-Net++的每个子U-Net模块的卷积块之间。我拆过代码之后发现它主要嵌在两个位置:一是encoder每个stage的最后一层卷积之后,二是每个子U-Net内部上采样之后。前者的作用是让编码阶段就抑制掉光照差异带来的冗余通道,后者是让解码阶段更聚焦在变化区域而非背景纹理上。
2.4 嵌套损失和剪枝:训练时用全套,推理时删冗余
嵌套损失是这个项目一个比较有意思的设计。U-Net++本身在训练时会对每个子U-Net的输出都计算损失,最后加权求和,这个叫deep supervision。项目的创新点在于把这个嵌套计算和SE模块的输出结合了起来——每个子模块的损失里除了分割损失,还叠加了一个对SE权重分布的正则项,让注意力分布更稀疏。
剪枝技术则对应推理加速。训练好的嵌套U-Net++里存在大量冗余的子路径,项目里提供了一个剪枝脚本,把贡献度低于阈值的子模块直接裁掉。剪完之后模型在推理时退化成一条接近U-Net的瘦结构,但精度损失控制在可接受范围内。这样做的好处很实际:训练时用大模型保证精度上限,部署时用小模型保证推理速度,一套代码两种形态。
3. 工程落地第一步:代码包结构与数据组织
3.1 拿到压缩包后的初始清单
解压之后先别急着跑训练,把文件分一下类。train脚本负责训练主流程,valid脚本负责验证,LineGraph.py负责把训练和验证的损失、指标曲线画出来,说明文档.7z里是完整的撰写文档和论文相关的tex文件。还有一个VGG19_encoder.tex,里面是编码器结构图和对应的参数量表格,写论文方法部分时可以对照着用。
项目不是纯单文件脚本,train脚本里import了同目录下的网络结构模块、数据加载模块和损失函数模块。所以解压之后第一件事是把整个目录结构保持原样,不要单独把某个.py文件拷出来放到别的路径下。我一般会先跑一遍导入检查,确认没有缺库:
cd project_code_upload python -c "from train_utils import *; print('import ok')"正常情况下看到import ok说明依赖基本齐了。如果报错,绝大多数情况是缺了torchvision或者timm,这两个库在编码器加载预训练权重时会用到。第一个参数是训练epoch数,第二个是batch size,先用小值跑到第一个epoch结束确认全流程能走通,再动正式参数。
3.2 数据目录长什么样:两期影像、标签与切块
遥感变化检测的训练数据组织方式跟普通分割不一样。每个样本包含三张图:t1时期的影像、t2时期的影像、以及对应的变化标签图。标签图里像素值0代表未变化,255代表建筑物变化。
项目默认的数据读取逻辑要求你把数据按固定目录结构放好:
data/ train/ A/ # t1 影像 B/ # t2 影像 label/ val/ A/ B/ label/高分遥感影像的原始尺寸动辄几千乘几千像素,不可能整张图直接喂给网络,所以数据加载模块里内置了一个随机切块的逻辑。每次迭代从原图上随机裁出一块固定尺寸的patch,同时做随机的水平翻转、垂直翻转和旋转增强。这个设计的好处是显存占用可控,缺点是如果原图里有大量空白区域,随机切块可能会裁到大量无变化区域,训练会被背景样本主导。我一般会先统计一下标签图里变化像素的占比,如果占比低于1%,建议在数据读取里加一个采样逻辑——以变化像素为中心优先切块。
3.3 训练脚本关键参数解读
train脚本开头的配置区集中定义了所有超参数,尽量别直接改网络结构里的硬编码,而是在这个配置区调整。下面这段是从项目里摘出来的核心参数逻辑:
# train.py 中的关键配置示例 input_size = 512 # 切块尺寸,显存不够时优先降这里 batch_size = 8 # batch size,OOM时先降到4或2 base_lr = 1e-4 # 初始学习率,换编码器后建议从5e-5起步 num_epochs = 100 # 训练总轮数 loss_mode = 'focal' # 'ce' 或 'focal',focal对变化区域更友好 use_se = True # 是否启用SE模块 deep_supervision = True # 训练时开启嵌套损失 accumulate_steps = 2 # 梯度累积步数,显存不够时用这里重点说三个参数。input_size降到384甚至256是最有效的显存缓解手段,代价是建筑物轮廓会变粗糙,如果目标以小民房为主建议保持512。base_lr在换用VGG19预训练编码器后建议调低到5e-5,因为预训练权重已经处在一个较优的局部区域,学习率太大会把低层特征直接冲毁。loss_mode切到focal之后要对gamma和alpha做调参,这个在后面避坑章节细说。
3.4 验证阶段和训练阶段的结构差异
验证脚本和训练脚本共享同一套网络结构定义,但有两个重要区别。第一,验证阶段会强制关闭嵌套损失中的辅助损失分支,只保留主分支的输出,这样得到的结果更接近实际部署时的推理结构。第二,验证阶段不会做随机增强,而是用滑窗的方式把整张测试图切成patch逐个推理,再把patch的预测结果拼回原图尺寸。这里有一个容易踩的坑:滑窗的窗口重叠区域如果直接覆盖写,边界处会出现明显的接缝伪影。项目里默认的处理是重叠区域的预测取平均值,这个逻辑在验证脚本里已经有了,但如果你自己写滑窗推理,一定要记得加。
4. 核心代码拆解:嵌套损失、加权FocalLoss与两阶段训练
4.1 编码器部分的实现与预训练权重加载
编码器是理解这个项目结构的钥匙。项目在构造网络时,把VGG19的features部分按stage切分成五段,每一段输出一组不同分辨率的特征图。下面这段代码展示了编码器构造的核心逻辑:
# model.py 中的 VGG19 encoder 构造逻辑 import torch.nn as nn from torchvision import models class VGG19Encoder(nn.Module): def __init__(self, pretrained=True, output_levels=(1, 2, 3, 4, 5)): super().__init__() vgg = models.vgg19(pretrained=pretrained) features = list(vgg.features.children()) self.stages = nn.ModuleList() stage = [] for layer in features: if isinstance(layer, nn.MaxPool2d): self.stages.append(nn.Sequential(*stage)) stage = [] else: stage.append(layer) self.stages.append(nn.Sequential(*stage)) # 只保留前5个stage self.stages = self.stages[:5] def forward(self, x): feats = [] for stage in self.stages: x = stage(x) feats.append(x) return feats这里最关键的细节在于MaxPool2d触发stage切分。VGG19的original特征提取部分有五个maxpool层,对应五次空间分辨率减半,正好与U-Net++的五层深度对齐。forward函数返回的是一个特征图列表,列表长度等于5,每个元素的尺寸依次是输入的1、1/2、1/4、1/8、1/16。decoder部分拿到这个列表之后,从最深层开始逐层向上融合。
预训练权重的加载用的是torchvision自带的vgg19预训练参数,不需要手动下载文件,但第一次运行时会联网拉取权重,如果你的机器没有外网或者被防火墙拦了,会卡在这一步。本地跑的时候如果遇到这个情况,需要单独下载权重文件放到torchvision缓存目录里,或者直接设置pretrained=False随机初始化。
4.2 SE模块实现细节:降维比例与小输入尺寸的适配
SE模块在项目实现里并不是直接照搬原论文的写法,而是做了两个调整。第一,降维比例reduction取的是8而不是原论文的16,原因是遥感影像的特征图通道数在浅层只有64,如果用16降维,中间层只有4个通道,信息损失太严重。第二,两个全连接层之间用了LeakyReLU激活而不是原版的ReLU,目的是避免负半轴的梯度直接归零。
# module.py 中的 SE 模块实现 import torch.nn as nn class SEBlock(nn.Module): def __init__(self, channels, reduction=8): super().__init__() self.squeeze = nn.AdaptiveAvgPool2d(1) self.excitation = nn.Sequential( nn.Conv2d(channels, channels // reduction, kernel_size=1), nn.LeakyReLU(0.1, inplace=True), nn.Conv2d(channels // reduction, channels, kernel_size=1), nn.Sigmoid() ) def forward(self, x): w = self.squeeze(x) w = self.excitation(w) return x * w这里的实际效果是,每个通道的特征图先被压缩成一个标量描述全局响应,再用两个1x1卷积学习通道间的非线性关系,最后通过Sigmoid输出一个0到1之间的权重,乘回原特征图。1x1卷积在这里替代全连接层,好处是不要求输入特征图必须保持某个固定尺寸,任意分辨率都能跑。
4.3 嵌套损失与deep supervision:训练时的多层约束
嵌套损失是这个项目训练阶段的核心机制。在U-Net++里,存在多个子U-Net模块,每个子模块都输出一张预测图。项目在训练时会对所有子模块的输出分别计算损失,再加权求和,权重按深度的增加而递减。浅层子模块分辨率高但语义弱,权重设低一些;深层子模块分辨率低但语义强,权重设高一些。
从实际效果来说,deep supervision相当于给网络加了多个「进度检查点」。从不同深度的特征图都去逼近标签图,梯度可以从多个位置回传,避免了梯度只从最深层一条路径回传导致的浅层特征更新缓慢。训练初期,浅层子模块的损失帮助网络快速学会基本的边缘定位;训练后期,深层子模块的损失主导网络学会全局语义一致性。
嵌套损失和SE模块的交互,项目里做了个定制——每个子模块的损失在传回主loss之前会乘以当前子模块内部SE权重的稀疏度打分。这个设计在训练时引导SE模块产生更稀疏的注意力分布。稀疏注意力分布有两个好处:一是通道冗余降低,模型对不相关纹理的响应更少;二是剪枝时更容易判断哪些通道可以裁掉。
4.4 加权FocalLoss:缓解变化区域与背景的极端不均衡
遥感变化检测里最常见的问题不是网络结构不够深,而是标签数据极度不平衡。一个典型的场景,标签图里变化区域可能只占整张图的0.5%,其余全是未变化背景。如果直接用交叉熵损失,网络只要把整张图都预测为未变化就能拿到99.5%的正确率,梯度信号被背景完全淹没。
项目采用的加权FocalLoss在标准FocalLoss基础上加了一个类别权重项,针对性解决变化像素太少的问题:
# loss.py 中的加权 FocalLoss 实现 import torch import torch.nn.functional as F class WeightedFocalLoss(nn.Module): def __init__(self, alpha=0.75, gamma=2.0, weight=10.0): super().__init__() self.alpha = alpha # 正样本权重系数 self.gamma = gamma # 难易样本调节系数 self.weight = weight # 变化类别整体缩放权重 def forward(self, logits, targets): ce_loss = F.binary_cross_entropy_with_logits( logits, targets, reduction='none' ) p_t = torch.sigmoid(logits) * targets + (1 - torch.sigmoid(logits)) * (1 - targets) modulating = torch.pow(1 - p_t, self.gamma) alpha_t = self.alpha * targets + (1 - self.alpha) * (1 - targets) focal = modulating * alpha_t * ce_loss # 对变化区域整体放大损失权重 focal = focal * (1 + self.weight * targets) return focal.mean()这段代码的思路是把损失拆成几个维度分别调节。alpha参数控制正负样本的基础权重比例,gamma参数让难分类样本(p_t接近0.5的那些像素)获得更大梯度,最后的weight项直接对变化像素的损失做整体放大。项目处在「部分实验已完成」的状态,意思是这个损失函数在部分数据集上做过验证,但还没有在大规模数据上系统调过alpha和gamma的最优组合。
4.5 梯度累积与训练稳定性:小显存跑大模型的土办法
训练和验证阶段解决CUDA Out of memory的手段,项目里主要做了三件事。第一是梯度累积,把一次大batch的更新拆成多次小batch分别前向反向、累加梯度后统一更新参数。第二是关闭不需要的梯度计算,验证阶段显式包裹torch.no_grad(),减少显存占用。第三是在混合精度可用时开启自动混合精度,让部分计算以float16执行。
这三招的优先级是有讲究的。如果显存只是稍微超了一些,先开混合精度,几乎不损失精度就能省约30%显存。如果显存还很紧张,再上梯度累积,注意累积步数乘以batch size等于等效batch size,学习率也要按比例微调。最后才是降输入尺寸或者降batch size,因为这两个改动会直接影响模型效果。
5. 避坑记录:OOM、损失震荡与剪枝掉点的四个实操问题
5.1 训练没几分钟就CUDA Out of Memory
现象:batch size设成8、输入尺寸512,训练脚本跑到第一个iteration直接报CUDA out of memory。
原因:嵌套U-Net++本身就比普通U-Net的显存占用高出一截。每个子U-Net都要保留前向过程的中间特征图用于梯度回传,deep supervision开启时还要额外保存多个辅助输出头。项目里同时开启SE模块和嵌套损失之后,显存占用比同等深度的普通分割网络高出约60%到80%。
解决:按顺序做三件事。第一,把train脚本里的混合精度开关打开;第二,设置梯度累积步数为2甚至4,等效batch不变但峰值显存减半;第三,如果还超,把input_size从512降到448或384。输入尺寸最好是32的倍数,保证VGG19各层特征图尺寸对齐。
5.2 FocalLoss训练早期损失不降反升
现象:把损失切换成FocalLoss之后,前几个epoch的loss值比同配置下的交叉熵损失高出一大截,而且下降速度肉眼可见地慢。
原因:FocalLoss的初始设计目标是让模型关注难分类样本,但训练早期模型对所有像素都处于「难分类」状态,alpha和gamma的联合作用把整体loss放大了。gamma取2.0、alpha取0.75的这个组合适用于训练中期和后期的微调,不适合从零开始训练。
解决:我一般会分两阶段走。前20个epoch用普通交叉熵训练,让网络先学会基本的分割结构和大致的轮廓定位;第20个epoch之后切换到FocalLoss,此时大部分背景像素已经能被正确分类,FocalLoss的梯度才会真正集中到变化区域的难样本上。gamma从1.0起步,观察3到5个epoch的验证指标,稳定之后再往上加到2.0。
5.3 剪枝之后模型精度断崖式下跌
现象:按脚本默认阈值剪枝后,验证集上的IoU从0.72直接跌到0.58,下跌幅度远超预期。
原因:默认的剪枝策略是直接去掉贡献度低于阈值的子模块和对应通道,但嵌套U-Net++里不同子模块之间存在共享的特征依赖。暴力裁剪会把一部分主路径也在依赖的中间特征一并剪掉,相当于把承重墙拆了。
解决:把剪枝阈值调低一个量级,先剪掉那些输出权重占比低于1%的子模块,保留所有主路径上的模块。剪完之后不要直接拿去推理,把学习率调到原来的1/10,用训练集再跑5到10个epoch做一次微调。微调不需要跑满全程,主要是让被裁分支的剩余通道重新适应新的特征分布。
5.4 滑窗验证出现明显的接缝伪影
现象:推理阶段用滑窗拼接结果时,预测图里出现一条条规则的竖线和横线,位置和滑窗的重叠边界完全重合。
原因:验证脚本里滑窗步长小于窗口尺寸,重叠区域的预测结果如果直接取最后一次覆盖写入,边界处的预测会出现跳变。特别是变化区域正好跨越边界时,两个窗口对同一位置的变化概率估计不一致,拼接处就产生了伪影。
解决:项目验证脚本里已经包含了重叠区域取平均的逻辑,但如果自行修改过推理流程,需要确认两点:一是重叠区域每个像素的预测值做叠加而不是覆写,二是记录每个像素被覆盖的次数,最后做除法取平均而不是最值。
6. 验证与进阶:让训练曲线和指标真正指导你调参
训练一套变化检测模型,最怕的不是acc上不去,而是你压根不知道它为什么上不去。LineGraph.py这个脚本画出来的训练曲线,很多人只是看一眼loss有没有下降就关了,其实里面信息量很大。训练loss稳步下降、验证loss先降后升,这是典型的过拟合信号,此时应该加增强、加权重衰减或者降低学习率,而不是继续闷头跑。训练loss和验证loss都下降缓慢、但验证指标里查全率明显偏低,说明模型对变化区域不敏感,需要检查FocalLoss的alpha权重是不是太小,或者变化类别的采样率不够。两个loss都在下降,但misclassification rate里把变化区域错误分类为背景的比例很高,这是建筑物边缘像素的定位不够精细,考虑把输入尺寸调回512,或者在切片逻辑里增加变化像素为中心的采样比例。
验证脚本输出的指标里,重点关注IoU和F1就行,准确率可以忽略——在变化区域占比只有1%的数据集上,准确率永远是99%以上,区分不出模型的任何好坏。IoU反映的是变化区域预测框和真实区域的重合程度,F1反映的是查准和查全的平衡。建筑物变化检测的项目通常更看重查全率,因为漏掉一栋新增建筑比误报一栋更严重。如果F1里precision很高但recall偏低,后处理时可以把概率阈值从0.5往下调到0.4或者0.35,变化区域通常会有概率偏低的边缘像素,阈值调低一点能明显提升召回。
进阶操作层面,我建议你拿到数据集后先做一次像素分布统计,计算变化像素占全图比例。如果低于1%,训练时对标签图做一个膨胀操作,把变化区域的边界往外扩3到5个像素,相当于给模型增加了正样本数量。训练完成之后,推理结果还要做一次形态学后处理,先开运算去掉零散的小噪点,再闭运算填补变化区域内部的空洞。这两个操作的卷积核大小用3x3就够了,对大尺寸目标可以尝试5x5。
另外,把验证脚本的滑窗步长从窗口尺寸的50%改成75%,重叠区域增多之后预测结果的接缝伪影会更少,代价是推理时间增加约40%。如果你赶时间则用50%步长,如果指标就差最后一点,用75%步长做最终提交结果通常能再涨一个点。从那以后我每跑完一组实验,都会强制自己去LineGraph出来的曲线文件里看一遍「验证集IoU和FocalLoss的分量变化」,只有两期影像的变化区域被真正激活出来、而不是靠背景占多数刷高指标,我才敢把这个权重文件用于后续的交付。希望帮到你。
本文还有配套的精品资源,点击获取