news 2026/10/7 15:43:09

遥感图像语义分割UNet实战:从数据标注到推理优化全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
遥感图像语义分割UNet实战:从数据标注到推理优化全流程

简介:面向遥感图像语义分割毕业设计的完整项目包,以UNet神经网络为核心,覆盖数据处理、模型搭建、训练预测与结果评估全流程,适合计算机视觉方向的学生参考或复用。压缩包含69个文件,约47MB,主体包括6份Python源码及对应的pyc文件、3个Jupyter Notebook交互式实验记录、LaTeX论文源文件与PDF报告,另有大量图像样本和结果图、TensorBoard与Jupyter启动脚本,结构清晰便于定位。项目还提供readme说明、Git忽略文件等工程化配置,可直接在本地复现训练过程。目前已有299人获取过这份资料,适合需要快速开启课题或借鉴完整方案的高年级本科生。随附的毕业论文完整展示研究思路与实验细节,能帮助理解遥感图像语义分割中的UNet结构、跳连设计、损失函数选择及IoU等评价指标。

1. 遥感图像语义分割为什么绕不开UNet,以及毕业设计用它到底值不值

如果你最近在准备遥感方向的毕业设计,十有八九会搜到“基于UNet的遥感图像语义分割”。这句话几乎成了这个方向的标配开场:把UNet用在遥感图像上,对建筑物、道路、水体、植被做像素级分类。UNet在医学图像分割上成名已久,但把同样的结构平移到遥感图上,表现并没那么理所当然。遥感图像的物体尺度跨度极大,一张512像素的切片里可能同时出现几十米宽的河流和几个像素宽的道路,这和细胞、肿瘤那种相对均匀的分布完全不同。许多人直接套用医学图像的标准UNet训练流程,结果跑出来的图里道路断裂、建筑边缘毛糙、小目标直接消失。

这篇笔记按我实际做过的方案,把数据准备、网络结构取舍、训练参数、推理拼接和常见翻车点一次讲清楚。适合三类人:正在做相关毕业设计的学生、第一次把UNet落到遥感数据上的工程师、以及想快速了解这套技术路线是否值得投入的团队。它解决的核心问题是:怎么用最少的时间,得到一份能过答辩、能拿去对比实验的结果。

2. 遥感图像标注与切片:先解决数据格式和标签错位的坑

2.1 标注工具选型与标签像素值约定

遥感语义分割的数据准备,第一步不是写模型,而是把标注格式统一好。常见的做法是用Labelme之类的开源工具在原图上画多边形,导出JSON格式,再转成逐像素的标签图。我一般会跳过JSON直接输出8位单通道PNG,因为训练框架几乎都默认读PNG、BMP这类栅格标签,而JSON还要二次转换,徒增复杂度。

这里有一个必须提前定死的约定:类别编号要从0开始,且0固定代表背景。假设你的数据有四类——背景、建筑、道路、水体,那像素值约定就是0背景、1建筑、2道路、3水体。很多初学者习惯拿标注软件里“第一个画的类别”当类别1,结果背景值变成1,模型一训练全乱套。

import numpy as np def json_to_mask(polygons, shape, class_map): """ polygons: list of dict, 每项含 'points' 坐标和 'label' shape: 图像高宽 (h, w) class_map: {'background': 0, 'building': 1, 'road': 2, 'water': 3} """ mask = np.zeros(shape, dtype=np.uint8) for poly in polygons: # 画出多边形区域,fill_value 取类别编号 # 这里省略具体绘图库调用,核心是保证 fill_value 来自 class_map ... return mask

这里最关键的是把class_map单独做成一个常量文件,后续所有数据加载、损失函数权重、评估脚本都从它读取,避免在多个脚本里各写一份。标注完成后,务必对每个切片做一次标签通道可视化。肉眼直接看原图很容易忽略错位,但把标签图单独拉伸显示,你立刻就能发现“道路跑到建筑上”之类的问题。

2.2 大图滑窗切片的窗口与重叠率

遥感原始影像动辄几千乘几千像素,直接整图进UNet不现实。标准做法是用滑窗切成固定尺寸的patch。patch的尺寸一般取256或512。256在显存吃紧时比较稳,512对中等尺度地物更友好,像建筑物这类目标需要一定的上下文才能分辨边界。

def sliding_window_crop(image, mask, tile_size=512, stride=448): """ 对原始影像和标签做同步切片。 stride < tile_size 时切片之间有重叠,能缓解目标被切断的问题。 """ tiles = [] h, w = image.shape[:2] for y in range(0, h - tile_size + 1, stride): for x in range(0, w - tile_size + 1, stride): img_tile = image[y:y + tile_size, x:x + tile_size] msk_tile = mask[y:y + tile_size, x:x + tile_size] tiles.append((img_tile, msk_tile)) # 最后一行/列往往够不到整块,需要单独补一块边缘对齐的切片 if y + tile_size < h: ... return tiles

重叠率不是越高越好。重叠意味着同样的像素被训练多次,相当于一种隐式数据增强。但重叠率太高会让模型对目标边缘过度平滑,推理时也很容易在拼接处留下模糊带。我一般会把stride设为 tile_size 的 75% 到 87.5%,也就是重叠 12.5% 到 25%,这个区间在信息利用和过拟合之间比较均衡。如果地物破碎、小目标多,重叠多一点;如果地物是大块连续区域,重叠少一点。

2.3 数据增强:颜色抖动要克制,几何增强放得开

遥感语义分割的增强策略和自然图像分类差别很大。平移翻转这类几何增强随便用,因为地物方向不敏感,旋转90度、水平翻转、垂直翻转都能做。但颜色抖动要非常克制——你不能把蓝色水体的色相随机调成泛红,那样模型会把水体学成裸土。遥感数据的物候差异和光照差异本来就大,再人为制造辐射失真,等于给模型制造无谓的类内方差。

我常用的增强组合是随机水平翻转、随机垂直翻转、随机旋转90度、外加轻微的高斯模糊。高斯模糊对建筑边界有帮助,因为它能模拟不同分辨率传感器对边缘的平滑。

# 用 imgaug 或 albumentations 都行,关键看是否支持 mask 同步变换 import albumentations as A train_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomRotate90(p=0.5), A.GaussianBlur(blur_limit=(3, 7), p=0.2) ])

这里的要点是A.Compose同时接收 image 和 mask,保证原图和标签做相同的空间几何变换。如果只用图像增强库而忘了传 mask,模型会在完全错位的标签上训练,等于白跑。颜色类增强我一律不加,因为RGB遥感数据受季节、天气、传感器型号影响,颜色特征本身就是不稳定的信息源,加了反而破坏模型对纹理和几何结构的依赖。

3. UNet结构细节与损失函数:通道数、跳跃连接和类别不平衡

3.1 标准UNet的双卷积和跳连接在遥感数据上的表现

UNet的核心是编码器-解码器结构加跳跃连接。编码器不断下采样提取语义信息,分辨率从512降到16甚至8,这时候模型看到的是大范围上下文;解码器再逐步上采样恢复空间分辨率。跳跃连接把编码器同尺度的特征图直接拼到解码器对应层,相当于给上采样过程补充高分辨率细节信息。这个设计对遥感分割尤其合适:建筑物边界需要高频细节,而道路连续性需要全局上下文。

但标准UNet有一个隐患——模型没有预训练权重。医学图像数据集普遍只有几十到几百张图,必须从头训练,UNet的小参数量正好适配。遥感数据集稍微大一点,从头训练也能收敛,但收敛速度和对纹理特征的捕捉能力都不如带预训练编码器的版本。

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)

这是UNet最基本的双卷积块。注意遥感图像的BatchNorm行为。如果batch size小到2或4,BatchNorm统计量会很不稳定。显存有限时,要么用GroupNorm替换,要么把batch size提升到8以上再配合梯度累积。我在实验中发现同样的UNet结构,batch size从4提到8,验证集mIoU一般能涨一到两个点,这个提升不来自模型本身,纯粹是BatchNorm的统计更稳定了。

3.2 用预训练ResNet编码器替换UNet的encoder

结构上可以让解码器保持UNet原样,把纯卷积编码器换成torchvision里带ImageNet预训练权重的ResNet34。ResNet34的层数比原始UNet Encoder深,它在前几层已经学到了边缘、纹理类的基础特征,迁移到遥感图像上,开局损失就比随机初始化低不少。

import torchvision.models as models resnet = models.resnet34(pretrained=True) encoder = nn.Sequential(*list(resnet.children())[:-2])

这里把ResNet34的最后两层去掉,保留到layer4的输出作为最深层特征。要注意的是,ResNet的layer4输出分辨率是输入的1/32,对512输入就是16x16。UNet原版的深层是1/16分辨率,所以网络结构里需要调整解码器第一层的输入通道数,并在最深特征后补一个额外的下采样/上采样路径,具体取决于你的特征图对齐策略。做毕业设计的话,我建议直接用带预训练的版本,单卡训练时损失下降速度肉眼可见地快,省下的时间足够多跑几组对比实验。

3.3 混合损失函数:CrossEntropy + DiceLoss 的权重配比

遥感分割最大的训练障碍是类别不平衡。在一个城市切片里,建筑可能占40%,道路占15%,车辆占不到1%。如果只用交叉熵,模型会倾向于把车辆学成背景。DiceLoss对小类别更敏感,因为它计算的是预测和标签的重叠度,但纯DiceLoss在小目标上梯度波动大,训练不稳定。常见做法是两者相加,让交叉熵提供稳定的梯度,让Dice约束整体重叠度。

class CombinedLoss(nn.Module): def __init__(self, ce_weight=0.6, dice_weight=0.4): super().__init__() self.ce_weight = ce_weight self.dice_weight = dice_weight def forward(self, pred, mask): ce = F.cross_entropy(pred, mask) dice = dice_loss(pred, mask) # 自行实现或从第三方库引入 return self.ce_weight * ce + self.dice_weight * dice

权重配比取决于任务。如果你的类别分布整体均匀,CE可以给到0.7到0.8;如果小目标类别特别稀缺,Dice权重提到0.5甚至0.6。但Dice权重过高会导致训练震荡,我有一个阶段配到0.7,loss曲线像心电图,验证集分数反而下降。另一个务实做法是给稀缺类别买一个加权采样器,让每个batch里都能看到足够多的稀有类别样本,这比分担损失权重更直接。

4. 训练全流程:学习率调度、EMA和验证指标mIoU的计算口径

4.1 优化器选AdamW,学习率用warmup加余弦退火

UNet做遥感分割,优化器选AdamW几乎是默认best practice。SGD在充分调参后可能略胜一点点,但它对学习率极其敏感,遥感数据本身噪声大,梯度方向不稳定,SGD前期容易迷路。AdamW的权重衰减对UNet这种中等规模网络也友好,不容易过拟合到背景。学习率方面,先用几轮warmup把学习率从极小值逐步抬高,再走余弦退火到接近零,这种组合在语义分割任务上尤其稳。

optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=epochs - warmup_epochs, eta_min=1e-6 )

warmup一般占总epoch数的10%左右。如果你总共训80轮,前8轮做warmup。学习率基线可以设在1e-4,显存小导致batch size只有4左右时,学习率建议降到5e-5。余弦退火的好处是后期学习率自动变小,帮助模型在验证集附近更精细地收敛,不用手动调低学习率分阶段降。

4.2 梯度累积解决单卡显存不足的问题

遥感数据标注图是8位单通道,输入图像是三通道RGB,显存压力主要来自分辨率。如果你只有6GB或8GB显存,batch size可能连4都跑不起来。解决方法是用梯度累积——以小batch前向反向几次,把梯度攒起来再更新参数。这样等效的batch size等于单次batch乘累积步数。

accum_steps = 4 scaler = torch.cuda.amp.GradScaler() for it, (imgs, masks) in enumerate(train_loader): imgs, masks = imgs.cuda(), masks.cuda() with torch.cuda.amp.autocast(): preds = model(imgs) loss = loss_fn(preds, masks) / accum_steps scaler.scale(loss).backward() if (it + 1) % accum_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()

使用混合精度还能额外节约显存,代价是BatchNorm统计量稍有漂移。如果显存实在紧张,把输入从512降到384,或者把UNet基础通道数从64降到48,效果比强行上大patch更划算。注意梯度累积时机器的有效batch size变大后,学习率可以对应上调,但一般别超过原来的1.5倍。

4.3 EMA权重平滑和mIoU的计算方式

训练过程里保存模型权重,如果直接保存每个epoch结束时的权重,验证集分数往往波动。因为sgd或adam在训练后期仍然会在最优解附近震荡。一个简单有效的缓解方案是EMA——维护一份权重的滑动平均副本用于验证,训练权重负责大步前进,平均权重负责给出稳定预测。

ema_model = copy.deepcopy(model) ema_decay = 0.99 def ema_update(ema_model, model, decay=ema_decay): with torch.no_grad(): for ema_p, p in zip(ema_model.parameters(), model.parameters()): ema_p.data.mul_(decay).add_(p.data, alpha=1 - decay)

验证指标的mIoU定义要保持一致,否则实验结果没有可比性。mIoU的标准计算方法是每个类别算预测和标签的交并比,然后对所有类别取平均。注意“所有类别”包括背景。如果数据里背景占比极高,mIoU会被背景拉高,掩盖真实类别上的失败,所以在记录mIoU的同时,一定要单独看每个类别的IoU,尤其关注道路、车辆这类细长小目标。

def compute_miou(preds, masks, num_classes): ious = [] for cls in range(num_classes): pred_cls = (preds == cls) mask_cls = (masks == cls) inter = (pred_cls & mask_cls).sum().item() union = (pred_cls | mask_cls).sum().item() iou = inter / union if union > 0 else 0.0 ious.append(iou) return sum(ious) / len(ious)

这个函数看起来简单,但它在逐像素计算时,大图必须展平后整体算,不能按batch分开算再平均,那样会让每张图的背景占比权重不同,得到的总mIoU失真。正确口径是把整个验证集所有像素汇总,再算类别的并集和交集。

5. 遥感语义分割典型翻车现场:5个小目标、边界和拼接的坑

5.1 标签类别编号错位,模型在错误标签上训练了一整夜

现象:训练loss正常下降,验证mIoU卡在某个低值不动,可视化预测结果发现建筑预测成了道路,道路预测成了背景。

原因:标注工具导出的标签顺序和训练代码里的class_map不一致,最常见的是背景类别没有置0,而是从1开始,导致所有类别偏移一位。

解决:训练前写一个脚本读取某张标签图,打印它的唯一像素值集合,和class_map里的键值对一一核对。不要用肉眼看原图或标签的颜色显示,直接统计值是最可靠的。

5.2 小目标类别消失,车辆在预测图上直接蒸发

现象:车辆类别的IoU在2%到5%之间徘徊,个别验证图上车辆完全没被预测出来。

原因:车辆类别的像素占比可能不足1%,交叉熵损失几乎不受它影响。再加上下采样到16x16特征图时,一辆20像素宽的车在深层特征里已经只剩一两个像素的信息。

解决:训练阶段给车辆类别的交叉熵权重提高两到三倍,同时把输入分辨率从512提升到640。如果显存不够,只对包含车辆标注的样本做均匀下采样,让每个batch都有车。推理阶段也可以对包含车辆区域的原图做二次局部放大预测。

5.3 混合精度训练时验证集Nan,损失值突然变成inf

现象:前20个epoch正常,第25个epoch开始loss变成nan,之后所有分数归零。

原因:混合精度训练把权重部分换成半精度浮点数,遇到异常梯度就溢出成inf。常见触发点是学习率过高导致梯度范数爆炸,或者BatchNorm在半精度下统计不稳定。

解决:先用梯度裁剪把最大梯度范数限制在3.0以内,再把损失计算的缩放因子加上。如果梯度裁剪无效,说明问题是数值不稳定,可以把整个网络的关键层强制FP32,或者从混合精度切换回全精度验证问题是否消失。很多情况下,全精度训练虽然慢一些,但对UNet这种网络反而省心。

5.4 验证集分数高,但整图推理结果出现接缝和纹理条带

现象:单patch验证mIoU达到85%,把整张遥感图切成patch预测再拼回原图时,patch边界处出现明显的接缝,建筑边界在接缝处错位。

原因:滑窗推理时patch之间没有重叠,模型在patch边缘的预测质量天然偏低。因为边界处的像素看到的上下文不够,预测置信度整体下降,拼接后就在边界处形成灰度带。

解决:推理时使用比训练更大的重叠率。训练时重叠加到25%,推理时把stride设成patch大小的一半,也就是重叠50%。每个像素取它在多个patch中的预测结果平均,或者只取该像素位于patch中心区域时的预测。前者效果更稳定,后者速度快一些。大多数框架里用前者更简单可靠。

6. 推理阶段TTA、CRF后处理与概率输出:最后三件能提分的小事

训练收敛后,从验证集到最终成图,还有三个小技巧值得做。第一个是测试时增强(TTA)。推理时把原始图像做水平翻转、垂直翻转和旋转180度,分别跑一次预测,把四个结果的概率图相加取平均,最后再取argmax作为类别。这个操作对几何类错误能稳定提升0.5到1.5个mIoU点,几乎不用改动代码:

def predict_with_tta(model, img, flip_h=True, flip_v=True): probs = [] x = torch.from_numpy(img).float().unsqueeze(0).cuda() p0 = torch.softmax(model(x), dim=1) probs.append(p0) if flip_h: ph = torch.softmax(model(torch.flip(x, dims=[3])), dim=1) probs.append(torch.flip(ph, dims=[3])) # 垂直翻转同理 return torch.mean(torch.stack(probs), dim=0)

第二个是CRF后处理。DenseCRF能利用像素间的颜色相似性把预测图的碎块和毛刺抹平一点,对道路、建筑这类有明确边界的类别效果比较明显,对小碎物体反而可能直接抹没。我一般只在最终输出时对整图跑一遍,参数里sxy_gaussian设在 2 到 3,sxy_bilateral设在 80 到 120,迭代三轮就够。跑太多次会过度平滑,边界细节反而丢失。

第三个技巧是输出概率图而不是直接输出argmax。保存每个像素的类别概率做成的npy文件,答辩或调阈值时能随时换不同的阈值重新出图,不用重跑模型。这个习惯帮我避免了无数次“阈值调了得重新预测”的返工。

这一套走下来,其实整个毕业设计真正花费时间的是数据标注和切片整理,模型训练反而相对机械。我自己在那时会犯的一个血泪错误是:急着把网络跑起来,忘了先可视化标签,结果一整晚训练都喂给了带偏差的数据。希望你开工前先花半小时做一次数据完整性自检。希望这套流程能帮你省下同样的一晚。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 15:43:09

ROS2 + Gazebo阿克曼小车搭载Livox MID-360雷达仿真搭建指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 15:42:54

显示驱动调试工具全解析:从内核日志到总线信号的分层实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 15:42:39

基于YOLOv5的道路交通标识识别:从数据集到训练避坑全指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 15:41:29

DRV8301无刷电机驱动板设计全流程:原理图、PCB布局与STM32代码实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 15:40:31

InternVL端侧多模态推理:高通QNN部署实战与性能优化路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 15:39:59

PADS Layout实战:元件摆放与地线处理的10个关键技巧

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华