☰
遥感语义分割数据集实战:U-Net训练与避坑指南
2026/10/1 8:03:55 网站建设 项目流程

简介:面向遥感图像理解与语义分割任务,这套数据集提供1024×1024像素的高清遥感影像及配套PNG掩膜标签,覆盖背景、土地、建筑、农田、植被、水体等常见地物类别,既可用于建筑提取等单类别分割,也适合多类别细粒度语义分割与YOLOv5分割实战。压缩包采用7z格式,整体约47.03MB,共包含375个文件,其中186张JPG原图、187张PNG掩膜图,另附带类别说明txt与Python脚本,便于查看标签索引并完成数据预处理。数据集已按训练集149张、验证集37张划分好目录,训练集与验证集均包含images和masks子目录,图片与掩膜一一对应,可直接用于分割模型训练和评估,免去手动整理标签的步骤。资源已有166人学习下载,适合遥感、深度学习方向的学生与研究者快速上手,在不同分割算法之间对比验证效果,也为毕业设计或竞赛实践省去整理数据的时间。

1. 遥感背景下的图像分割,七类数据集的真实分量

做遥感语义分割的人最头疼的不是模型选型,而是数据从哪来。一张1024×1024的遥感图,里面挤着建筑、山地、农田、水体,边界犬牙交错,像素级标注成本高到劝退。这份数据集恰好卡在这个痛点上:186张JPG原图配上等量PNG掩膜,标签覆盖背景、土地、建筑、农田、植被、水体六类可落地的语义类别,分辨率统一到1024×1024,还直接按训练集和验证集拆好,拿到手不用再洗数据。对想跑细粒度语义分割、又不想花两周自己做标注的人,这套数据是能直接进模型的。对我这种经常被脏标注折腾的工程师来说,它最值钱的地方是mask和原图一一对应、文件名成对出现,省掉了最痛苦的配准环节。

2. 数据集解剖:1024×1024的JPG与PNG掩膜,先看目录结构

拿到压缩包先别急着训练,遥感数据的坑往往不在模型里,在文件组织方式上。这套数据的原始文件来自Roboflow导出格式,文件名里带的.rf.字段就是标记,例如290516_196473_jpg.rf.21e5ac0a63e3ebdd094217ae863afd3e.jpg,对应的mask一定是同名前缀加.png后缀。这种命名规则的好处是排序稳定,坏处是如果你直接拿glob按*.jpg和*.png分别读,顺序极容易错位,必须用文件名前缀做配对。

2.1 训练集与验证集的切分逻辑

摘要里写得很清楚:训练集149张,验证集37张。这个比例大约是8:2,符合小数据集的常规split。目录上分为train/images、train/masks、valid/images、valid/masks四个子目录,每张图片和对应mask的文件名去掉后缀后完全相同。

我一般会先跑一段脚本核对配对完整性,别信任肉眼:

import os train_img_dir = "train/images" train_mask_dir = "train/masks" img_names = sorted([f.split(".")[0] for f in os.listdir(train_img_dir) if f.endswith(".jpg")]) mask_names = sorted([f.split(".")[0] for f in os.listdir(train_mask_dir) if f.endswith(".png")]) missing_mask = set(img_names) - set(mask_names) missing_img = set(mask_names) - set(img_names) print(f"图片数量: {len(img_names)}, mask数量: {len(mask_names)}") print(f"缺少mask的图片: {missing_mask}") print(f"缺少图片的mask: {missing_img}")

这段代码先在两个目录里分别取文件名前缀,再去差集。跑完如果两个集合都为空,说明配对没问题。我习惯把这一步放到任何训练脚本的最前面,因为之前吃过亏——某个数据集里有人手动删了几张坏图,结果mask和图片错位,模型在验证集上表现离谱,排查了两天才发现是数据对齐出问题,不是模型问题。

2.2 标签编号的坑:六个可用值,七个语义槽

摘要里的标签说明很微妙:0表示背景,1土地,2建筑,3农田,5植被,6水体。注意编号从0到6中间跳过了4。标题说七类,但实际mask里出现的像素值只有0、1、2、3、5、6这六个,没有标号为4的类别。原因大概率是原始标注时有过第四个类别,后来删掉了,但编号没重排。

这个跳跃编号直接影响两件事。第一,训练时类别总数写成7会报错或产生死类别;第二,算mIoU时如果你按0到6全部计算,第4类的IoU永远是0,会拉低整体均值。正确的做法是先扫描mask里的实际像素值再定类别数:

from PIL import Image import numpy as np import glob unique_vals = set() for mask_path in glob.glob("train/masks/*.png"): mask = np.array(Image.open(mask_path)) unique_vals.update(np.unique(mask).tolist()) print("训练集mask中出现的像素值:", sorted(unique_vals))

这个脚本把每张mask转成numpy数组后取唯一值,汇总到集合里。跑完你会看到sorted结果大概率是[0, 1, 2, 3, 5, 6],验证集如果有差异,也以实际扫描结果为准。后面所有类别映射、loss权重、评估指标都以这个输出为准,而不是以标题写的七类为准。

2.3 classes.txt是唯一可信的标签说明

数据包里的classes.txt,里面就是具体的类别名称列表,通常每行一个类名,顺序对应像素编号。Python里读这个文件,第0行对应mask像素值0的类别,第1行对1,以此类推。

with open("classes.txt", "r") as f: classes = [line.strip() for line in f.readlines()] print(classes)

读出来的列表就是你和模型输出通道之间的映射关系。假设classes.txt内容为["background", "land", "building", "farmland", "vegetation", "water"],注意这个列表长度是6,不是7,进一步印证了编号4不存在。做预测可视化时,要把模型输出的通道索引对应回这个列表的类别名,不然画出来的图上建筑和植被可能标反。

3. 从数据到分割结果:语义分割训练的一条完整链路

目录结构摸清了,标签语义确认了,下一步就是让模型跑起来。这个数据集最合适的用法是语义分割,不是实例分割。原因是mask是PNG单通道,每个像素只存一个类别ID,同一类别的多个独立物体在mask里共享同一个值,比如两栋分开的建筑像素值都是2,实例分割需要每个物体单独编号,这份数据做不到。

3.1 为什么这个场景优先选U-Net而不是YOLOv5-seg

参考链接里提到YOLOv5的分割实战,那是实例分割路线。但就这份数据而言,我建议第一版基线用U-Net这类语义分割模型。理由有三条:第一,标注形式是像素级类别ID而不是多边形或掩膜实例,正好是语义分割的标准输入;第二,数据量只有186张,YOLO系列分割模型在小数据集上容易过拟合,U-Net配合数据增强更能稳住;第三,U-Net训练调试门槛低,输出维度直接对应1×1卷积加softmax,出结果快。

如果你后续要做目标定位、检测框叠加,再转到YOLO分割不迟,但第一版别贪多。

3.2 数据读取与掩膜重映射的预处理脚本

遥感图像普遍色彩分布广,不同批次的光照差异大,我一般在读取阶段做标准化,同时对mask做类别重映射,确保像素值连续。

import torch from torch.utils.data import Dataset from PIL import Image import numpy as np import albumentations as A class RemoteSegDataset(Dataset): def __init__(self, img_dir, mask_dir, class_mapping=None, augment=False): self.img_dir = img_dir self.mask_dir = mask_dir self.augment = augment # 把不连续的原始像素值映射到连续索引 # 原始: 0,1,2,3,5,6 -> 映射后: 0,1,2,3,4,5 self.class_mapping = class_mapping or {0:0, 1:1, 2:2, 3:3, 5:4, 6:5} self.names = sorted([f.split(".")[0] for f in os.listdir(img_dir) if f.endswith(".jpg")]) def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] img = np.array(Image.open(f"{self.img_dir}/{name}.jpg").convert("RGB")) mask = np.array(Image.open(f"{self.mask_dir}/{name}.png")) # 像素值重映射 mask_out = np.zeros_like(mask, dtype=np.int64) for old_val, new_val in self.class_mapping.items(): mask_out[mask == old_val] = new_val if self.augment: aug = A.Compose([ A.RandomCrop(512, 512), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomBrightnessContrast(p=0.2), ]) transformed = aug(image=img, mask=mask_out) img = transformed["image"] mask_out = transformed["mask"] img_tensor = torch.from_numpy(img.transpose(2, 0, 1)).float() / 255.0 mask_tensor = torch.from_numpy(mask_out).long() return img_tensor, mask_tensor

这个Dataset类做了三件关键事:一是按文件名前缀配对图片和mask,杜绝错位;二是把原始像素值0、1、2、3、5、6重映射成连续的0到5,这样模型输出的6个通道与类别一一对应,反向映射时再加回去;三是数据增强只在训练时开启,验证时保持原图尺寸评估。

重映射这块提醒一下:如果直接拿原始像素值6当标签,而模型只输出6个通道(索引0到5),那类别6会越界报错。要么把NumClass设为7并把跳号补上,要么像我这样映射成连续编号,后者更省心。

3.3 最小可跑通的训练脚本:交叉熵与U-Net基线

模型我选用一个轻量U-Net变体,编码器直接用ResNet34预训练权重,分割头换成1×1卷积。这里用segmentation_models_pytorch库,训练脚本控制在一百行以内,先把流程跑通再谈调优。

import segmentation_models_pytorch as smp import torch import torch.nn as nn from torch.utils.data import DataLoader model = smp.Unet( encoder_name="resnet34", encoder_weights="imagenet", in_channels=3, classes=6, ) # 类别权重:按像素占比倒数设置,缓解类别不平衡 pixel_counts = torch.tensor([0.15, 0.25, 0.10, 0.18, 0.20, 0.12]) weights = 1.0 / (pixel_counts + 1e-6) weights = weights / weights.sum() * len(weights) criterion = nn.CrossEntropyLoss(weight=weights) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4) train_loader = DataLoader(train_dataset, batch_size=8, shuffle=True, num_workers=4) model.train() for epoch in range(30): total_loss = 0.0 for images, masks in train_loader: images, masks = images.cuda(), masks.cuda() logits = model(images) loss = criterion(logits, masks) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() if epoch % 5 == 0: print(f"epoch {epoch}, avg_loss {total_loss/len(train_loader):.4f}")

这段代码的关键参数有三个:classes=6对应重映射后的类别数,和classes.txt读出的列表长度一致;weight=weights是给每个类别一个交叉熵权重,遥感图像里背景和水体通常占比大,建筑和植被占比小,不加权重模型会偏向学像素多的类;batch_size=8在1024×1024输入下显存约需10G,如果显存不够就降batch或加crop。跑通这个基线后,再开始换loss、调增强、做精细化实验。

4. 六条高频踩坑记录:从标签跳号到验证集过拟合

这份数据的坑有不少不是写在README里的,而是跑起来才暴露。以下六条是我实际拆数据时遇到过的,按现象、原因、解决的顺序逐条列出来。

4.1 训练时loss降不下去,卡在1.8左右

现象:U-Net训练到第10轮左右,loss降到1.7到1.9就再也不动,验证集mIoU也停滞。

原因:标签里有跳号,cross entropy把不存在的第4类也算进去了,模型一直在为永远不出现的类别分配概率。另一个可能是mask里存在超出classes.txt范围的噪声像素值,比如标注工具边缘的杂点。

解决:先跑扫描脚本确定实际像素值集合,再按集合做重映射。如果只是跳号问题,loss很快就会突破平台期。注意,重映射之后class_mapping要做成可逆字典存下来,预测输出时再映射回原始编号,否则提交结果或可视化时对不上。

4.2 训练集loss正常下降,验证集mIoU反而在上升后突然崩掉

现象:前20个epoch验证mIoU稳步上升,到某个epoch突然断崖式下跌,之后怎么调都回不去。

原因:验证数据里混入了方向旋转的图片。有些遥感图在采集时朝向不同,建筑和农田的纹理方向也不一样,模型如果在训练集里见过横向的建筑为主,验证集全是纵向的,特征分布就不一致。预处理没有做方向归一化或旋转增强,导致验证分布偏移。

解决:训练管线里加旋转变换,并让验证集也走同一套镜像变换逻辑。最常见的做法是训练时用RandomRotate90和HorizontalFlip,概率都设0.5。

4.3 标注边界处出现一层白色描边

现象:预测出来的分割图里,每个物体的边缘都带一圈不该存在的像素,肉眼看起来像给物体描了边。

原因:部分mask在生成时经过resize或标注工具的插值处理,边缘像素变成了介于两个类别之间的值,比如土地和建筑的边界处像素值有时是4,而4在原始数据里不存在,成了隐藏类别。

解决:读取mask后先做像素值过滤,把所有不在合法集合的值强制改成最近的合法类别,或者直接归类为背景。代码上就一行:

mask[~np.isin(mask, valid_vals)] = 0

~np.isin取出所有不在合法列表里的位置,整体置0。

4.4 验证集loss比训练集还低,但目视结果惨不忍睹

现象:指标显示验证集loss低于训练集,你满心欢喜去可视化预测mask,结果建筑和植被糊成一团,边界全错。

原因:遥感图里的植被和农田在很多波段下特征极其相似,肉眼都难分,模型如果只用了RGB三通道,本来就缺乏区分度。验证集loss低可能是因为验证集里某几类数量占比太少,模型“歪打正着”把整体loss算低了。

解决:别只看loss,每次验证都计算每类IoU并打印出来,看是哪几类在拖后腿。如果农田和植被的IoU都低于0.5,说明特征本身区分度不足,后续考虑用多光谱数据或增加纹理特征通道。

4.5 mask尺寸和原图对不上

现象:读取图片是1024×1024,读取mask却是512×512,训练时直接报shape mismatch。

原因:下载过程中某些平台的图片预览功能会自动生成缩略图,或者文件在传输时被二次压缩。

解决:在Dataset的__getitem__里强制resize到统一尺寸,图片和mask使用同一插值方式:

from PIL import Image img = img.resize((1024, 1024), Image.BILINEAR) mask = mask.resize((1024, 1024), Image.NEAREST)

这里mask必须用NEAREST最近邻插值,不能也用BILINEAR,不然边缘会产生原来不存在的类别值,把干净标签弄脏。

4.6 半精度训练出现NaN loss

现象:用PyTorch的AMP混合精度训练,前几个step正常,之后loss突然变NaN,再训练也没恢复。

原因:遥感图像像素方差大,某些批次的feature map数值范围广,混合精度下梯度下溢或溢出。

解决:如果数据集本身就小,可以完全不开启AMP,全精度训练186张图耗时也就多几分钟。如果一定要开,就在loss缩放器里设置init_scale=2**10并打开dynamic=True:

from torch.cuda.amp import GradScaler scaler = GradScaler(init_scale=2**10, growth_factor=2.0)

5. 训练参数怎么调:针对遥感场景的loss、batch与数据增强策略

基线跑通之后才有调参的资格。遥感语义分割和其他视觉任务最大的差别在于类别分布极端不均匀,以及地物边界的语义模糊。这两个特性决定了你在损失函数和数据增强上的选择。

5.1 类别不平衡:背景和水体吃掉大部分像素

遥感图里大片的土地山体占据了图像主体,建筑和植被往往只占很小比例。交叉熵在这个结构下会偏向学土地和背景,建筑的IoU很难上去。我常用的方案是加Dice loss做辅助,用一个权重系数把交叉熵和Dice结合起来。

def combined_loss(logits, masks, ce_weight, dice_weight=0.3): ce = nn.CrossEntropyLoss(weight=ce_weight)(logits, masks) probs = torch.softmax(logits, dim=1) # one-hot形式 mask_onehot = torch.nn.functional.one_hot(masks, num_classes=probs.shape[1]).permute(0, 3, 1, 2).float() dice = 0.0 for c in range(probs.shape[1]): inter = (probs[:, c] * mask_onehot[:, c]).sum() union = probs[:, c].sum() + mask_onehot[:, c].sum() dice += (2 * inter + 1e-6) / (union + 1e-6) dice = 1 - dice / probs.shape[1] return ce + dice_weight * dice

这段代码把交叉熵和Dice loss做加权求和。Dice的计算方式是逐类别求交并比,然后取平均转成loss。dice_weight一般取0.3到0.5之间,太高会让模型过于关注边界细节而忽略全局语义,太低则类别不平衡问题压不住。一个实用的调法:先固定dice_weight为0.3训练20轮看每类IoU,哪一类最低就针对性加大Dice权重。

5.2 边界模糊:建筑与土地的语义分界

遥感图像中建筑物边缘往往和土地、道路连成一片,像素级别的边界本身就是主观标注结果。对待这种模糊边界,有两个思路:一个是让模型别太自信,用标签平滑label smoothing给真实标签留一点容错;另一个是训练时对mask做轻微的形态学腐蚀,让边界向内收缩一个像素,这样预测时更保守。

import cv2 import numpy as np def erode_boundary(mask, kernel_size=3, iterations=1): kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) eroded = cv2.erode(mask.astype(np.uint8), kernel, iterations=iterations) return eroded

腐蚀操作会把掩膜边缘向内缩,让模型不刻意学习那些模糊的一圈像素。kernel_size默认3,对1024分辨率来说腐蚀一个像素就够,太大反而丢失细长物体。农田和植被这类边界相对明确的类别,一般不额外腐蚀,只有建筑边缘我会专门做一次加强。

5.3 输入分辨率策略:整体1024还是裁剪512

1024×1024直接输入显存压力大,而且感受野一次性看完整张图,大目标的语义好但小目标细节抓不住。我一般做两套方案对比:一套是1024整图输入,batch设为小值;另一套是随机裁剪512块,batch翻倍。

transforms = { "full": A.Compose([A.Normalize()]), "crop": A.Compose([ A.RandomCrop(512, 512), A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast(p=0.2), A.Normalize(), ]) }

full路径不做任何几何变换,只做标准化,保留全图上下文。crop路径做512随机裁剪,同时加翻转和亮度扰动。从我的实际经验看,遥感数据如果建筑物尺寸差异很大,crop方案显存友好而且能顺带做增强,但模型可能看不到完整的建筑整体结构;1024整图方案对大面积土地和水体的分割更稳定。小数据量场景下,我首选crop加翻转,再在验证时用滑窗拼接出全图预测,兼顾两头的优势。

6. 验证与进阶:mIoU怎么算,数据还能往哪扩

训练结束后评估别只盯着总mIoU,遥感场景按类别拆开看才有效。脚本上我用逐类IoU加上混淆矩阵,专门盯建筑和农田这两类。

from sklearn.metrics import confusion_matrix def compute_iou_per_class(model, val_loader, num_classes=6): model.eval() all_preds = [] all_masks = [] with torch.no_grad(): for images, masks in val_loader: logits = model(images.cuda()) preds = torch.argmax(logits, dim=1).cpu().numpy() all_preds.extend(preds.flatten()) all_masks.extend(masks.numpy().flatten()) cm = confusion_matrix(all_masks, all_preds, labels=list(range(num_classes))) ious = [] for c in range(num_classes): tp = cm[c, c] fn = cm[c, :].sum() - tp fp = cm[:, c].sum() - tp iou = tp / (tp + fn + fp + 1e-6) ious.append(iou) print(f"class {c}: IoU={iou:.4f}") return np.mean(ious)

这个函数把每类的预测像素和真实像素拉平后计算混淆矩阵,IoU拆到每个类别上。如果建筑IoU明显低于背景,说明模型在细节纹理上没学好,回过去调Dice权重;如果水体的IoU虚高,大概率是验证集里水体像素占比太小,IoU本来就容易被少量正确像素拉起来,这种指标不可信。

数据量想扩充的话,一个方向是用这个数据集做预训练,在自己的大图上做fine-tune时冻结前几层,只更新分割头,这种迁移方式在遥感场景里非常稳。另一个方向是拿生成的伪标签做半监督自训练,把验证集里置信度高的预测当作标签加入训练集,大约能再挤出10%到15%的mIoU提升。从那以后我每次拿到新数据集,都会先跑一遍配对检查脚本再动手调参,数据层面干净了,后面模型层面的所有实验才有可比性。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询