☰
遥感图像分割数据集构建指南:7类地物标注、标签制作与避坑
2026/10/7 5:51:43 网站建设 项目流程

简介:面向遥感图像语义分割任务的高质量数据集,覆盖建筑、土地、植被、农田、水体等多类地物,标注为7类语义标签,适合用于深度学习分割模型训练、细粒度地物提取以及与YOLOv5等实例分割框架的实战调试。包内含375个文件,主体为186张JPEG原图与187张PNG标签掩膜(mask),并附1个类别说明文本和1个Python辅助脚本,压缩包整体约47.03MB,数据组织清晰,便于直接划分训练与验证。数据集已分好训练集149对图像与掩膜、验证集37对,每张图像分辨率达1024×1024,为建筑提取、遥感地物分类等场景提供了可直接落地的样本基础。目前已有166人下载学习,适合计算机视觉方向的研究者、遥感算法工程师及高校学生快速开展语义分割实验与模型评测。

1. 遥感图像分割数据集到底解决了什么问题:给建筑、山地、植被等7类地物做一张像素级地图

做高精度遥感落地的人,迟早会卡在同一个地方:模型选好了,训练代码写好了,却没有一套能让自己信服的标签数据。遥感影像语义分割数据集,核心就是给一景影像里的每一个像素打上类别标签,比如建筑、道路、水体、植被、山地、农田,外加一个背景类,凑成标题里说的7类。它的直接价值不是让你“跑通demo”,而是让语义分割算法在真实地理场景下可验证、可对比、可迭代。适合谁?做土地覆盖制图、城市规划、生态监测、灾损评估的工程师和研究生——你需要的是一个带像素级标签文件的遥感数据集,而不是一堆散装的影像文件。

2. 7类语义分割数据集怎么定义:类别方案、标签格式与目录结构

拿到遥感影像先别急着标注。第一件事不是打开软件画多边形,而是把“7类”这回事钉死。类别定义含糊,后面所有标签都会跟着含糊,模型训练出来也是糊涂账。

2.1 先把7类定死:类别层级与不可见类别的处理

我一般会先建一张类别表,把每个类别的包含对象、边界情况、容易混淆的对象写清楚。别嫌这一步啰嗦,后面每一块标签的准确性都依赖它。一个比较稳妥的7类方案是这样的:

Class ID类别名包含对象容易混淆对象
0背景/其他未标注区域、不确定地物阴影中的未知地物
1建筑房屋、厂房、温室大棚大型桥梁、白色车辆
2道路高速公路、城市道路、乡村硬化路裸地、停机坪
3水体河流、湖泊、水库、鱼塘阴影、深色湿地
4植被乔木、灌木、草地、茂盛农田山地阴坡的林区
5山地/裸地裸岩、裸露土壤、采石场干燥农田、建筑工地
6农田各类农作物地块草地、休耕裸地

注意两个容易翻车的地方。第一,背景/其他类必须有,而且占的比例最好控制在10%到20%之间,不要为了凑数把所有边缘缝隙都归进去,不然模型会偷懒,把所有不确定像素都丢给背景类。第二,如果影像里有大片云、云影,我建议在标注规范里明确“云和云影属于背景/其他”,而不是强行归到山地或水体——这是遥感分割最容易犯的错误,后面避坑章节我会专门展开。

2.2 标签文件的三种落地格式:RGB索引色、单通道灰度与RLE编码

确定了类别,接下来要定标签文件的存储格式。遥感标签文件常见的有三种:

  • 单通道灰度PNG,像素值直接等于类别ID,例如像素值3就代表水体。训练时最方便,不需要任何转换,直接当target喂给模型。
  • RGB索引色PNG,每个类别对应一个固定RGB颜色,人眼看着直观,分享给别人时友好,但训练前必须做一次颜色到类别ID的映射。
  • RLE(Run-Length Encoding)掩码,常见于COCO风格的实例分割数据集,语义分割用得相对少,适合对象边界复杂的场景。

我自己的项目习惯是:对外交付用RGB索引色PNG,因为对方能直接打开看,检查标签更直观;自己训练时统一转成单通道灰度PNG,因为省去每次forward都做映射的开销。标题里的“标签文件”如果是你从网上下到的,大概率是RGB索引色PNG,拿到手第一件事就是确认它是不是真的0到6的连续整数。

2.3 干净的数据集目录:train/valid/images/labels怎么组织

目录结构不影响模型精度,但影响你三个月后能不能找回自己放哪了。我一般按这个结构组织:

dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ ├── labels/ │ ├── train/ │ ├── val/ │ └── test/ ├── meta/ │ ├── classes.json │ └── palette.json └── tiles/ ├── scene_01/ ├── scene_02/ └── scene_03/

images和labels下每个子目录的文件一一同名,比如images/train/scene01_001.png对应labels/train/scene01_001.png。meta目录里放类别名和调色板的映射文件,python代码和标注人员都从这里读,而不是把类别信息硬编码在脚本里。tiles目录放切好的原始瓦片,等划分完成后可以删,但训练阶段保留着方便追溯。

类别定义好后,我建议第一时间跑一个统计脚本,看看每个类别的像素占比。这一步直接决定后面要不要做类别权重。

from pathlib import Path import numpy as np from PIL import Image label_dir = Path("dataset/labels/train") class_names = ["background", "building", "road", "water", "vegetation", "mountain", "farmland"] counts = np.zeros(len(class_names), dtype=np.int64) for p in sorted(label_dir.glob("*.png")): arr = np.array(Image.open(p).convert("L")) # 标签像素值必须在 0~6 之间,越界直接报错 if arr.min() < 0 or arr.max() > 6: raise ValueError(f"{p.name} 标签越界,当前最大值为 {arr.max()}") for c in range(len(class_names)): counts[c] += int((arr == c).sum()) total = counts.sum() for c, name in enumerate(class_names): ratio = counts[c] / total print(f"{name:<10s} {counts[c]:>12d} {ratio:>7.4f}")

这段代码做的事情很简单:遍历所有训练标签,逐类统计像素总数,然后输出每个类别的占比。越界检查放在循环里,不要等到训练时突然报“class index out of range”再回来查,那时候定位成本会翻好几倍。参数说明:convert("L")是必要的,因为PNG标签可能是RGB三通道,直接np.array()会得到一个(H, W, 3)的数组,和后面的arr == c逻辑完全对不上。类别名数组和类别ID必须和标注时的规范严格一致,顺序错一位,统计结果就是灾难。

跑完这个脚本,如果某些类别占比低于1%,就要警惕了——不是不能训练,而是val集和test集里这些类别的样本本来就少,算出来的IoU会有很大的方差,波动十个点是很正常的事。

3. 从原始影像到标签文件:标注流程与RGB转单通道预处理

类别表和目录都定了,接下来是最耗时也最需要细心的一步:把原始遥感影像变成“影像+标签文件”的成对数据。这一步做得扎实,后面的训练和评估才有底气。

3.1 标注工具选型与底图准备:QGIS和LabelMe各自边界在哪

标注遥感分割数据集,最常见的方案是QGIS配合栅格化,也有一些人在用LabelMe。两种我都用过,给一点真实感受。

QGIS适合大图幅、多边形为主的标注。你先加载遥感影像,新建矢量图层,每个类别建一个图层,然后用“创建多边形”功能沿着地物边界画。画完以后用“栅格化”工具把矢量转成栅格标签。优势是能在原始分辨率上精确贴合边界,劣势是学习成本高,而且大量重复画多边形非常费手。

LabelMe更适合中小图、轮廓复杂的目标。你把影像切块后逐个标注,导出JSON文件再转成掩码。优势是上手快、有Web界面方便多人协作,劣势是图幅大了以后效率很低,多边形节点一多就卡。

我一般会用QGIS。但不管用哪个工具,有一个前提必须满足:标注底图的合成方式要先定好。自然彩色影像(红/绿/蓝波段)人眼看着舒服,但水体、裸地、建筑之间的光谱差异经常不够大。我通常会把波段重组成“近红外-红-绿”假彩色合成,因为近红外对植被响应非常敏感,植被在图上显示为亮色调,和建筑、裸地一眼就能区分,这样标注的边界质量会明显提升。另外,如果影像分辨率低于2米,建议先重采样到统一分辨率再标注,避免不同图幅的标注精度不一致。

3.2 RGB标签转单通道:重映射脚本与掩膜清洗

QGIS栅格化出来的标签通常是RGB格式,比如你给建筑定义了(255, 0, 0),栅格化输出里建筑就是这个红色。这种标签不能直接用来训练,需要转成单通道灰度,像素值等于类别ID。

import numpy as np def rgb_to_index(rgb_arr, palette): """把RGB标签图转成单通道索引图 rgb_arr: 形状为 (H, W, 3) 的标签数组 palette: dict,格式为 {类别名: (r, g, b)} """ h, w = rgb_arr.shape[:2] idx = np.zeros((h, w), dtype=np.uint8) for class_id, (name, color) in enumerate(palette.items()): mask = (rgb_arr == color).all(axis=-1) idx[mask] = class_id return idx palette = { "background": (0, 0, 0), "building": (255, 0, 0), "road": (255, 255, 0), "water": (0, 0, 255), "vegetation": (0, 255, 0), "mountain": (128, 128, 128), "farmland": (0, 128, 0), } rgb = np.array(Image.open("label_rgb.png")) label = rgb_to_index(rgb, palette) Image.fromarray(label).save("label_idx.png")

这段代码的核心逻辑是:遍历调色板里的每个类别颜色,用(rgb_arr == color).all(axis=-1)找出所有像素值完全等于该颜色的位置,然后写上对应的类别ID。all(axis=-1)表示在最后一个维度(RGB三通道)上全部匹配才算命中,不能只比对单通道,否则红色和橙色会被混淆。

这里有几个容易翻车的细节。第一,调色板的类别顺序必须固定,不要中途改,否则之前生成的标签全部作废。第二,栅格化输出里常常会带一条背景黑边或白边,看情况归到背景类;如果不确定那片区域到底属于什么,不要试图硬分类。第三,转换前先用np.unique看一眼RGB标签里实际出现了哪些颜色,如果出现了调色板之外的颜色,说明标注时不小心用了别的颜色,先修复标注而不是硬转换。

3.3 多波段影像和标签分辨率对齐:重投影的坑位

遥感影像和标签文件分辨率不对齐,是另一个高频翻车点。你拿到的原始影像可能是多波段TIF,分辨率是0.8米;但你在QGIS里画的矢量边界如果参考的是另一套影像,或者栅格化时分辨率设错了,生成的标签可能在边缘有半个像素的错位。不要小看这一个像素,在建筑轮廓这种高频边界区,错位会让模型在训练时不断看到“同一个纹理,两套标签”,最后学出来的边界就是模糊一片。

我的习惯是:标注前把所有原始影像切到同一个坐标系、同一个分辨率,再开始画。如果实在没有统一的条件,那栅格化时把分辨率设成和影像完全一致,标签和影像输入网络的尺寸保持一致。另外,如果标签是大图幅栅格,训练前降采样切瓦片时,标签的重采样方法要选“众数”或者“最近邻”,绝对不能用双线性插值——双线性插值会在类别边界处产生小数,比如3.0到4.0之间出现3.6,这既不是水体也不是植被,模型看到这种target基本就傻眼了。

4. 标签合法性检查与数据集划分:训练前必须过的两关

标签文件生成之后,直接开始训练?建议先忍一忍。高质量数据集和“能跑的demo”之间,差一次彻底的合法性检查和一次合理的划分。这两件事做不好,后续所有指标都是带病作业。

4.1 标签合法性检查:越界值、未标注区、重叠区三件套

第一层检查是像素值范围。

import numpy as np from PIL import Image from pathlib import Path label_dir = Path("dataset/labels/train") for p in sorted(label_dir.glob("*.png")): arr = np.array(Image.open(p).convert("L")) uniq = np.unique(arr) if len(uniq) == 0: raise ValueError(f"{p.name}: 标签为空") if uniq.min() < 0 or uniq.max() > 6: raise ValueError(f"{p.name}: 标签越界,像素值 {uniq}") if 255 in uniq: raise ValueError(f"{p.name}: 含nodata值 255,需要先清理") print(f"{p.name}: OK -> {uniq}")

这段循环检查两件事:像素值是否在0到6范围内,以及有没有出现255。这两个问题分别来自不同的原因:像素值越界通常是调色板映射出错,255则是QGIS或GDAL栅格化时输出nodata导致。

第二层检查是未标注区域。如果标签里某一块大面积的像素值恒为0,你要判断它到底是真正的“背景”还是“漏标注”。怎么判断?用一个简单办法:把影像和标签叠加显示,如果某一区域影像纹理很明确——比如明显是房子——但标签里全是0,那就是漏标了。相比之下,边缘带或图幅交接处全是0,可能是原始影像本身就是黑边,问题不大。

第三层检查是重叠区域。如果你把多张矢量图层一起栅格化,某些多边形之间可能有重叠区域,栅格化时后画的图层会覆盖先画的,结果就是标签和实际地物不一致。检查方法是:在QGIS里用“拓扑检查”工具查看重叠多边形,或者在转成标签后对比重叠区域的像素是否与任何一方吻合。这类问题往往只影响局部,训练时毫不知情,但最后可视化输出时会出现“同一个位置一会儿是建筑一会儿是农田”的诡异现象。

4.2 按图幅切瓦片:滑动窗口采样而不是全图硬塞

原始遥感影像往往是几千乘几千的大图幅,显存有限,不能整图输入网络。常规做法是滑动窗口切瓦片。

def sliding_window_crop(image_path, label_path, out_root, window=512, stride=256): img = np.array(Image.open(image_path)) lab = np.array(Image.open(label_path).convert("L")) h, w = lab.shape k = 0 for y in range(0, h - window + 1, stride): for x in range(0, w - window + 1, stride): img_crop = img[y:y + window, x:x + window] lab_crop = lab[y:y + window, x:x + window] # 过滤纯色瓦片,减轻类别不均衡 first = lab_crop.reshape(-1)[0] if (lab_crop == first).mean() > 0.95: continue out_img_dir = Path(out_root) / f"images" out_lab_dir = Path(out_root) / f"labels" out_img_dir.mkdir(parents=True, exist_ok=True) out_lab_dir.mkdir(parents=True, exist_ok=True) Image.fromarray(img_crop).save(out_img_dir / f"{k:05d}.png") Image.fromarray(lab_crop).save(out_lab_dir / f"{k:05d}.png") k += 1 sliding_window_crop("scene01.tif", "scene01_label.png", "tiles", 512, 256)

关于窗口尺寸、步长和过滤策略,给一组参数参考。窗口512或256都行,如果显卡是8G显存,256更稳;如果显存充足且场景中地物边界很大,512的上下文更够用。步长一般取窗口的1/2,也就是256对应128,512对应256,这样相邻瓦片有重叠,训练样本量加大,相当于一种轻度的数据增强。注意这里的纯色瓦片过滤:如果一个瓦片95%以上都是同一个类别,这种样本对于分割网络来说几乎不提供信息,还会加剧类别不平衡,建议扔掉。但不要把所有纯背景瓦片都扔掉,否则验证集里没有背景样本,模型在这类区域的表现无法被量化。

4.3 训练/验证/测试划分:时间分离而不是随机洗牌

这是遥感分割数据集构建里最容易被忽视、影响也最隐蔽的一步。如果你把切好的瓦片随机打乱,然后按比例划分train/val/test,很可能出现的情况是:属于同一幢建筑、同一条道路的两块相邻瓦片,一块在train里,另一块在test里。模型在训练时见过这条路,测试时再遇到,IoU自然很好看,但换一景全新影像就全面翻车。

正确做法是按原始图幅划分。比如你有10景不同地区或不同时相的影像,先把这10景按区域随机分成8个train景、1个val景、1个test景,再在每景内部切瓦片。如果按时间采集的影像,train取较早的时相,test取较晚的时相,这样能模拟“用旧数据训练,预测新数据”的真实场景。

如果你手里只有一景影像,没法按图幅划分,那么至少按空间位置划分:把整景影像分成几个区块,不同区块进不同数据集,确保train和test在空间上不重叠。这一条是遥感分割的底线。

5. 遥感分割数据集避坑指南:我踩过的5个坑

这个部分写的都是我实际做遥感分割数据集时踩过的坑。每一条都按“现象、原因、解决”展开,希望你少走弯路。

5.1 坑一:loss在2.1左右不降,像没在学习

现象:训练一开始,loss降到2.0附近就上不去了,感觉模型根本没有学到任何东西。

原因:大多数自然影像分割数据集类别相对均衡,但遥感场景里植被和农田经常占掉80%以上的像素,建筑和道路加起来不到5%。普通交叉熵损失在这种极度不平衡的数据集上,模型会学出一个“全都预测为植被”的局部最优解,因为这样loss也不会太高。

解决:给损失函数加类别权重,权重取每个类别像素占比的倒数,再做一次平滑处理,防止占比极低的类别权重过大导致训练震荡。另外一个常配合的手段是改用Focal Loss,让模型把注意力放在难分类的少数类别上。我的建议是先用类别权重这一招,改动最小、效果最稳。

5.2 坑二:mIoU看着挺高,可视化时建筑却全被吃掉了

现象:训练结束,mIoU超过70%,保存可视化结果一看,建筑区域几乎都是漏分割,跟背景混在一起。

原因:mIoU是所有类别IoU的平均值。如果背景、植被、农田占了大头,这几类的IoU很高,平均值被拉上去,建筑这一类的IoU只有30%也能得到看似不错的整体指标。这是指标选择不当导致的误判,不是模型真的“够好了”。

解决:评估时不仅要看mIoU,还要按类别打印IoU。标准做法是输出一个7x7的混淆矩阵,逐类看精度和召回率。如果建筑IoU明显低于其他类,重点排查建筑标签本身是否准确,再看是否需要对训练样本做类别均衡采样。

5.3 坑三:标签里出现255或254

现象:训练时突然报错“target中的类别索引超出范围”,打开标签图一看,某块区域像素值是255。

原因:QGIS栅格化通常把背景输出为0,但GDAL或者某些标注工具会输出255作为nodata标记;如果你转换单通道时忘了处理这部分像素,255就会混进训练target。

解决:在标签预处理脚本里,加一条强制约定:训练前把所有大于等于254的像素值映射到0(背景),前提是你确定这片区域确实是未标注区域。如果不确定,回到原始标注里人工复核。这一步不要嫌麻烦,越早做成本越低。

5.4 坑四:随机切瓦片后,同一栋楼被切进train和test

现象:train IoU很高,test IoU掉20个点,而且无论怎么调模型都追不回来。

原因:切瓦片时没有按原始图幅区分,同一个大的地面目标被切成了两三块瓦片,随机分配后一部分进了train,一部分进了test。模型相当于开卷考试。

解决:按图幅或按空间区块划分数据集,确保test里的任何建筑物、道路段都没有在train里出现过。这是遥感分割数据集构建最基础的要求,也是最容易被跳过的。

5.5 坑五:水体识别指标虚高,换个地区就翻车

现象:在本地测试集上水体的IoU超过90%,但拿到另一个季节或另一个区域的新影像测试,水体分割效果一塌糊涂。

原因:数据集里的水体样本和云影样本在光谱特征上太像了,模型学到的是“深色就是水”。特别是检测水体时,厚云阴影下和山体阴影下的深色区域几乎都被错分为水体。

解决:从数据源头处理。标注时不要指望模型自己学会区分水体和阴影,要么把云影、山体阴影单独定义为一个类别,训练完再做类别合并;要么在标注规范里对含阴影的区域做明确标注,并尽量多收集不同光照条件下的影像。另一个技术手段是引入近红外波段作为额外输入通道,水体和阴影在近红外波段上有明显差异,但前提是你的标签和训练管线都支持多通道输入。

6. 用基准训练验证数据集:从U-Net到SegFormer的最小评估闭环

数据集构建完成,别急着上大模型。先用一个标准的U-Net跑通训练闭环,确认标签本身没有系统性错误,再考虑SegFormer这类更复杂的语义分割模型。很多人跳过这一步直接上大模型,结果训练很久才发现标签有问题,浪费时间不说,还很难定位问题出在数据还是模型。

6.1 类别权重与损失函数配置:一份可直接修改的PyTorch核心配置

import torch import torch.nn as nn # 类别像素占比来自第 2 章的统计脚本 class_ratios = torch.tensor([0.08, 0.05, 0.06, 0.12, 0.45, 0.18, 0.06]) class_weight = 1.0 / torch.sqrt(class_ratios + 1e-6) class_weight = class_weight / class_weight.sum() * len(class_ratios) criterion = nn.CrossEntropyLoss(weight=class_weight.to("cuda")) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)

这里最关键的是class_weight的构造。直接用类别占比的倒数会给占比极低的类别一个非常大的权重,比如0.5%的类别权重会是200,训练时一步梯度更新就能把整个特征表示撞歪。所以我用1 / sqrt(ratio),压一下极端权重,再用最后一行归一化,保证权重之和等于类别数,训练初期的loss量级和常规交叉熵一致。

损失函数用带权重的交叉熵就够了,不需要一上来就上Focal Loss。跑完收敛后,如果少数类效果还是不行,再换Focal Loss不迟。

6.2 用混淆矩阵和每类IoU来定位标签问题

写一个简短的评估函数,训练结束后在val集上打印混淆矩阵和每类IoU。

from sklearn.metrics import confusion_matrix def evaluate(model, val_loader): model.eval() gt_all, pred_all = [], [] with torch.no_grad(): for x, y in val_loader: logits = model(x.cuda()) pred = logits.argmax(dim=1).cpu().numpy().reshape(-1) gt_all.extend(y.numpy().reshape(-1)) pred_all.extend(pred) conf = confusion_matrix(gt_all, pred_all, labels=list(range(7))) iou = [] for c in range(7): tp = conf[c, c] fn = conf[c, :].sum() - tp fp = conf[:, c].sum() - tp iou.append(tp / (tp + fp + fn + 1e-6)) class_names = ["background", "building", "road", "water", "vegetation", "mountain", "farmland"] for c, name in enumerate(class_names): print(f"{name:<10s} IoU = {iou[c]:.4f}") print(f"mIoU = {np.mean(iou):.4f}")

这个评估函数的作用是用混淆矩阵找到标签错误的高发区。如果建筑的IoU明显偏低,而混淆矩阵显示建筑大量被预测成道路,那可能是标注时把深色屋顶画成了道路;如果山地被预测成植被,那大概率是假彩色合成下的光谱混淆,而不是模型能力问题。

我自己的习惯是:训练前先对一小批瓦片做5轮过拟合测试,如果loss能降到很低,说明模型能吃住数据,接下来再谈泛化;loss下不去,先怀疑数据和标签,不要急着调模型结构。第一次做遥感分割数据集的时候,我因为云影没有单独处理,导致水体IoU虚高了一个月,后来靠混淆矩阵才发现模型用的是“深色即水体”这条懒惰路径。现在每次交付数据,我都会先跑一遍这套最小训练闭环,再谈其他。这个习惯帮我省掉了大量“模型调不通”的假问题,也推荐你试一试。希望帮到你。

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

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

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

立即咨询