遥感城市语义分割数据集:8类标签与UNet/SwinUNet训练实战
简介这是一份面向遥感图像语义分割任务的高质量数据集资源适用于需要快速开展8类别城市地物分割实验的深度学习开发者与研究人员。数据集已预先完成训练集与验证集划分训练集约800张图片及对应掩膜验证集约300张图片及对应掩膜图片与标签一一对应、已处理完毕可直接投入常见分割网络训练省去手动标注与格式转换环节。同时附带类别说明文件和一个图像分割可视化脚本脚本会随机抽取一张样本将原始图片、标注真值、真值在原图上的蒙板效果一并展示并保存到当前目录方便直观核验标注质量与模型预测参考可视化输出结果也可用于构建训练前后对比图。压缩包整体42.05MB共2000个文件以jpg原图和png标签图为主辅以txt类别定义与py工具脚本结构清晰、体量轻便适合快速下载与实验。目前已有130人下载学习可作为U-Net、SwinU-Net等分割网络改进与验证的入门数据。1. 遥感城市语义分割数据集拿来即用的8类基准数据做遥感城市图像的语义分割真正让人上头的不是网络结构而是数据本身——一张原图配一张mask每个像素ID代表什么类别、图片和标签有没有对齐、类别分布偏不偏这些不先确认训练起来就像在黑匣子里调参跑出来的mIoU都不知道该算模型的错还是数据的错。这份约1000张的遥感城市图像语义分割数据集800张左右训练、300张左右验证已经按train/val目录划分好mask也处理成可直接训练的标签格式0是背景、1是建筑其余类别定义写在classes.txt里。它帮你跳过了标注采集和格式转换这两个最耗时环节直接进到模型验证阶段。适合刚接触遥感分割的学生、从通用CV转遥感方向的工程师以及想在UNet、SwinUNet这类网络上验证改进点的算法同学。2. 数据目录与8类标签体系先看结构再写第一行训练代码拿到数据集第一步不是开train.py而是把目录结构和标签体系摸清楚。这套数据最有价值的点之一就是划分已经是成品train和val两个目录各自带着images与masks子目录文件名一一对应不需要再花时间整理拆分。我一般先把目录树打出来再抽查两三张mask确认类别ID分布这两步做完才敢写训练代码。2.1 目录结构与文件名对应规则dataset/ ├── train/ │ ├── images/ # 800张左右 JPG 原图 │ └── masks/ # 与原图同名的 mask 文件 ├── val/ │ ├── images/ # 300张左右 原图 │ └── masks/ └── classes.txt # 0背景、1建筑等类别定义目录结构本身不复杂但「同名对应」这个规则要记牢images里的776.jpg在masks里一定能找到主名同为776的mask文件只是后缀可能不同。做数据加载前我用一段小脚本对账把「有图没标签」和「有标签没图」的孤儿文件都揪出来import os image_dir dataset/train/images mask_dir dataset/train/masks images sorted(os.listdir(image_dir)) masks sorted(os.listdir(mask_dir)) # 去掉后缀只比较主文件名 image_names {os.path.splitext(f)[0] for f in images} mask_names {os.path.splitext(f)[0] for f in masks} orphan_images image_names - mask_names orphan_masks mask_names - image_names print(有图无标签:, orphan_images) print(有标签无图:, orphan_masks)这段逻辑是用两个集合做差集set是Python的去重集合类型差集正好反映文件缺失。splitext是标准库os.path里拆后缀的函数比手动split(.)稳妥因为文件名可能带多个点。跑完发现集合非空就说明数据链路没对齐需要回到源文件检查输出两个空集合才能进到下一步。数据包在服务器之间转存、用压缩工具解压时偶尔会丢文件这种问题肉眼很难发现全靠这段对账脚本兜底。2.2 类别ID与classes.txt读取方式分割标签的约定和分类任务不一样mask图片用灰度模式读出来每个像素点的值就是类别ID而不是「像素的颜色」。0代表背景、1代表建筑这两个锚点是数据集作者明确写好的剩下6类在不同遥感数据集里定义会有差异比如道路、树木、草地、水体、裸地、农田这类组合具体以classes.txt为准别拿通用城市分割数据集的类别定义去硬套。with open(dataset/classes.txt, r, encodingutf-8) as f: class_names [line.strip() for line in f.readlines() if line.strip()] print(类别总数:, len(class_names)) for idx, name in enumerate(class_names): print(f {idx}: {name})这里有个隐含约定大多数学术分割数据集的classes.txt行号就是类别ID第0行对应背景第1行对应建筑以此类推。enumerate从0开始编号正好对齐。strip是为了去掉行尾换行符和多余空格encodingutf-8避免在Windows下读中文标注出现乱码。如果classes.txt里的格式是「0:background」这种带冒号的就把每行按冒号拆开再取后半段逻辑不变解析时多一步split而已。2.3 单张mask类别分布检查看完类别定义再抽查一张mask的像素统计确认ID范围和标注质量import cv2 import numpy as np mask_path dataset/train/masks/776.png mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) values, counts np.unique(mask, return_countsTrue) for v, c in zip(values, counts): print(f类别 {v}: {c} 像素, 占比 {c / mask.size:.2%})np.unique会把mask里出现的所有像素值去重后返回配合return_countsTrue拿到每个值出现的次数。这段能快速回答三个问题ID最大值是不是78类就是0~7、有没有超出范围的异常值、背景和前景的比例是否离谱。如果mask最大值超过7说明标签文件里混入了非预期ID训练时交叉熵会因为类别数不匹配直接报错如果只有0和1两个值说明数据集可能是二值标注而不是8类需要回到classes.txt重新确认。我遇到过标注软件残留的255像素统计一跑就暴露了处理方式是把255这种异常值统一映射成背景或者在Dataset里做一个ignore_index。3. 训练管线实战数据加载、归一化参数与UNet起步配置目录确认没问题之后训练的第一步是写数据加载。遥感分割的Dataset实现差别主要在三个地方怎么对齐原图和mask文件名、mask的resize用什么插值、归一化用哪组统计量。这三处有一处不对训练出来的mIoU都可能差好几个点。3.1 一个可直接用的PyTorch Dataset实现下面这个Dataset类按这套数据的组织方式写好文件对齐、灰度读标签、插值方式都包含在内import os import cv2 import numpy as np import torch from torch.utils.data import Dataset class RemoteSensingSegDataset(Dataset): def __init__(self, image_dir, mask_dir, classes_file, image_size(512, 512), augmentFalse): self.image_dir image_dir self.mask_dir mask_dir self.image_size image_size self.augment augment self.images sorted([f for f in os.listdir(image_dir) if f.endswith((.jpg, .png))]) with open(classes_file, r, encodingutf-8) as f: self.classes [line.strip() for line in f.readlines() if line.strip()] self.num_classes len(self.classes) def __len__(self): return len(self.images) def __getitem__(self, idx): img_name self.images[idx] stem os.path.splitext(img_name)[0] image cv2.imread(os.path.join(self.image_dir, img_name)) image cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # mask 按同名文件读取后缀如果和实际不一致要改这里 mask cv2.imread(os.path.join(self.mask_dir, stem .png), cv2.IMREAD_GRAYSCALE) # 统一尺寸原图线性插值mask 必须最近邻 image cv2.resize(image, self.image_size, interpolationcv2.INTER_LINEAR) mask cv2.resize(mask, self.image_size, interpolationcv2.INTER_NEAREST) if self.augment and np.random.rand() 0.5: image cv2.flip(image, 1) mask cv2.flip(mask, 1) image image.astype(np.float32) / 255.0 mask mask.astype(np.int64) image torch.from_numpy(image).permute(2, 0, 1) image (image - torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)) / \ torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1) return image, torch.from_numpy(mask)这段代码有三个关键点。第一mask用cv2.IMREAD_GRAYSCALE读取保证拿到的是单通道ID而不是三通道颜色如果mask文件实际是彩色编码图这里读出来的灰度值并不是类别ID需要额外建颜色映射表。第二image的resize用INTER_LINEAR但mask必须用INTER_NEAREST因为mask里是0~7的整数双线性插值会在边界插出2.5、3.7这种小数直接送进交叉熵要么报错要么产生伪类别最近邻插值把边界归到最近的整数ID边缘会有一点点锯齿但对训练影响很小。第三归一化暂时用ImageNet的mean/std这套数据是自然遥感影像分布和常规图像比较接近先跑起来再说。提示如果解压后发现mask后缀是.jpg而不是.png把__getitem__里mask_path那行的后缀改成.jpg即可其余逻辑不用动。3.2 训练超参设置与UNet起步配置数据量在1000张左右时网络不需要一上来就上最重的模型。我一般先用UNet做基线encoder用ResNet-34加ImageNet预训练权重跑通流程后再考虑SwinUNet这类改进结构。超参起点可以参考这张表超参数推荐起点说明image_size512×512 或 768×768遥感原图通常几千×几千先切patch再训练batch_size8~16512×512输入下8比较稳显存紧张就降到4optimizerAdamWweight_decay0.01分割任务比SGD稳初始学习率3e-4~1e-3配poly学习率策略尾段衰减更平滑lossCrossEntropy Dice交叉熵主导Dice缓解小类丢失epochs60~100这个量级的数据50轮左右能看到mIoU收敛训练循环的简化版本如下重点在于outputs和masks的shape关系loss_fn torch.nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lr3e-4, weight_decay0.01) for epoch in range(epochs): model.train() running_loss 0.0 for images, masks in train_loader: images images.to(device) masks masks.to(device) outputs model(images) # (B, C, H, W) loss loss_fn(outputs, masks) # masks: (B, H, W)整数ID optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item()outputs的shape是B×C×H×WC等于8对应8个类别masks直接是B×H×W的整数ID张量。PyTorch的CrossEntropyLoss内部会做one-hot展开不需要手动把mask转成8通道。这里有个工程细节如果数据增强里有随机裁剪原图和mask要传同一个随机种子保证裁剪位置一致很多翻车案例就是图像裁了左上角、标签裁了右下角loss震荡但mIoU死活不涨。3.3 这份数据不需要二次清洗但要确认一件事「已处理完可以直接训练」的意思是mask已经是像素ID语义的灰度标签不需要自己写脚本把多边形转成mask也不需要做颜色到ID的映射。你只需要确认一遍mask读出来是0~7的整数ID且和原图位置对齐前面2.3的像素统计就是在做这件事。如果你的环境里发现mask是三通道彩色图那就不能直接训练最耗时间的往往就是那一步转换这套数据帮你省了。4. 可视化脚本解读原始图、GT与叠加图是怎么合成一张的配套的可视化脚本是这套数据里很有价值的一个附件。它从数据集中随机抽一张图把原始图、GT mask、GT在原图上的蒙板叠加图拼接成一张三联图保存下来。训练前跑一遍确认标签有没有错位训练中跑一遍对比预测和GT的差别给别人展示实验效果时这张三联图也是最直观的素材。4.1 三图并排的输出逻辑脚本核心逻辑可以浓缩成下面这个函数import os import cv2 import numpy as np import matplotlib.pyplot as plt def visualize(image_path, mask_path, save_path): image cv2.imread(image_path) image_rgb cv2.cvtColor(image, cv2.COLOR_BGR2RGB) mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 蒙板叠加前景区域半透明染色 overlay image_rgb.copy() overlay[mask 0] (0.5 * overlay[mask 0].astype(np.float32) 0.5 * np.array([255, 200, 0], dtypenp.float32)) overlay overlay.astype(np.uint8) fig, axes plt.subplots(1, 3, figsize(15, 5)) axes[0].imshow(image_rgb) axes[0].set_title(Original Image) axes[1].imshow(mask, cmaptab20) axes[1].set_title(GT Mask) axes[2].imshow(overlay) axes[2].set_title(Overlay) for ax in axes: ax.axis(off) plt.tight_layout() plt.savefig(save_path, dpi150, bbox_inchestight) plt.close()叠加那一步用的是最简单的alpha混合overlay 0.5×原图 0.5×颜色。代码里用mask 0做布尔索引把前景区域挑出来叠成半透明橙色背景保持原样。0.5这个系数是透明度想突出前景可以改成0.6、0.7颜色向量也可以换成红色或蓝色。注意overlay先转成float32再运算最后要转回uint8否则保存时会溢出或者出现花屏。imshow的cmaptab20是matplotlib里适合离散类别的颜色映射8个类别能区分开比默认灰度好看得多。plt.close()务必调用否则脚本在循环里跑多了figure对象堆在内存里进程会越来越慢直到崩掉。提示训练完把预测结果用同一个叠加函数画出来和GT叠加图并排对比视觉口径一致哪里边界糊、哪个类别漏一眼就能看出来。4.2 只看某个类别的叠加技巧mask 0把整个前景当作一体叠加但在排查某个具体类别时更常用的是只叠加某一个类。比如想确认建筑这一类边界有没有对齐把布尔条件改成mask 1即可想看水体这类小目标用类别ID变量控制building_id 1 # 建筑在 classes.txt 中的 ID target (mask building_id) overlay image_rgb.copy() overlay[target] (0.5 * overlay[target].astype(np.float32) 0.5 * np.array([255, 0, 0], dtypenp.float32))我这段在做类别抽样检查时会把每个类别的ID整理成一个字典比如{background: 0, building: 1, ...}然后循环跑一遍每个类别的单独叠加图。这样能快速看到每个类在整张图里的覆盖区域是否和视觉直觉一致——建筑类叠出来的区域应该对应屋顶和墙面水体类应该落在河道和湖面。如果某一类的叠加结果明显不对几乎可以肯定是mask读取方式或classes.txt行号没对齐。4.3 批量可视化抽查为了保持一致性我通常固定随机种子抽10张图批量生成import random, os images sorted(os.listdir(dataset/train/images)) random.seed(2024) # 固定种子换机器也能复现同一批样本 sample random.sample(images, 10) os.makedirs(vis_out, exist_okTrue) for name in sample: stem os.path.splitext(name)[0] img_path os.path.join(dataset/train/images, name) mask_path os.path.join(dataset/train/masks, stem .png) save_path os.path.join(vis_out, stem .png) visualize(img_path, mask_path, save_path)random.seed(2024)的作用是把随机序列定住这样无论跑多少次抽出来的都是同一批图方便复现和对比。sample从images列表里取10个不重复的元素seed不同抽到的图就不同。批量生成之后我习惯随机打开三四张检查重点看GT边界是否锐利、有没有缺标注区域。这一步不是走流程而是给后续所有训练结果建立视觉参照系——训练完把预测结果用同一个函数叠出来和GT叠加图并排看往往一眼就能看出模型在哪个类别上翻车。5. 语义分割训练常见问题排查五个高频坑与现场处理记录这一章把在类似遥感分割数据上踩过的五个高频坑整理出来每条按「现象→原因→解决」写训练中遇到类似问题可以按图索骥。5.1 mask全黑或全0三通道彩色标签当灰度读现象训练能跑loss也在下降但预测结果所有像素都是背景类输出接近全黑。原因mask文件实际是三通道彩色编码图每个类用一种RGB颜色表示而不是直接用像素值存类别ID。用IMREAD_GRAYSCALE去读得到的是彩色图转灰度后的亮度值和真实类别ID对不上模型学到的是「灰度值到类别」的错误映射。解决先拿到BGR三通道再用颜色到ID的映射表转换例如绿色对应2、蓝色对应3就把BGR匹配到的像素统一写成2、3。作者通常会在README或classes.txt里附颜色对照如果没附从图里取几个典型像素的RGB值反推也能建表。这套数据已经处理成灰度ID理论上不会再遇到但从其它渠道拿遥感标注时很容易踩。5.2 验证mIoU不涨类别采样和loss权重失衡现象训练loss正常下降但验证集mIoU卡在某个值不动尤其是小类别几乎预测不出来。原因城市遥感场景里建筑、道路占了大半像素水体、裸地这类小目标占比可能不到5%交叉熵按像素平均被大类别主导小类即使全错对loss的贡献也有限。解决给交叉熵加类别权重常见做法是统计每个类在整个训练集里的像素占比取反比作为权重from collections import Counter import cv2, os mask_dir dataset/train/masks counter Counter() for m in os.listdir(mask_dir): mask cv2.imread(os.path.join(mask_dir, m), cv2.IMREAD_GRAYSCALE) counter.update(mask.flatten().tolist()) total sum(counter.values()) weights [total / (8 * max(counter[i], 1)) for i in range(8)] print(weights)这是median frequency balancing的简化版total是全部像素数8是类别数counter[i]是第i类的像素数。max(counter[i], 1)防止某个类在训练集里完全没有出现时除零。算出的weights直接传给CrossEntropyLoss的weight参数小类因为像素少权重自然变大。这里有个细节权重只在训练集上统计不要在验证集上统计否则验证时的mIoU是被加权抬过的和实际部署效果对不上。5.3 显存不够几千像素大图整图进网络现象程序跑到第一个batch就报CUDA out of memory或者只能把batch_size调到1才能勉强跑。原因遥感影像原图动辄2000×2000甚至更高直接整图forwardUNet的中间特征图跟着放大显存占用是成倍增长的。解决训练时用随机裁剪把输入限制在512×512或768×768推理时用滑窗切patch预测再把结果拼接回原图。裁剪时注意原图和mask用同一个窗口坐标h, w image.shape[:2] top np.random.randint(0, h - crop_size) left np.random.randint(0, w - crop_size) image_crop image[top:topcrop_size, left:leftcrop_size] mask_crop mask[top:topcrop_size, left:leftcrop_size]top和left是窗口左上角坐标np.random.randint从0到h-crop_size之间取值保证窗口不出界。两行切片坐标完全一致这是数据增强里最容易写错的一行写错了图像和标签错位训练损耗完全反映不了真实情况验证集mIoU会很差且找不到原因。5.4 边界处预测糊成一团交叉熵对小目标惩罚不够现象mIoU数值看着可以但放大叠加图建筑物边界、道路边缘总是糊轮廓细节全丢。原因交叉熵逐像素独立对边缘像素和内部像素一视同仁但边缘像素数量很少在总loss里占比小模型学不到锐利边界。解决交叉熵基础上加Dice损失分支Dice衡量预测和GT的区域重叠度对前景小区域更敏感。实现时把输出softmax后取前景类和GT的one-hot做Dice系数组合损失用0.7×CE0.3×Dice这类比例。推理阶段还可以做多尺度预测同一张图在512和768两个尺度各跑一次取平均再argmax边界平滑度会明显提升。5.5 验证集mIoU高但实际效果差数据划分与可视化核对现象本地验证集mIoU有0.7以上拿到新遥感图上预测肉眼可见地差一截。原因最常见的是训练和验证划分没有保证地理分布一致比如训练集集中在某个城区验证集又有其它城区的地貌另一种情况是验证集里混入了和训练集相似的图指标虚高。解决先确认这套数据的train/val划分是随机分配还是按地理位置分。随机分配大概率存在同情景重复问题实验记录里标注「随机划分基准」即可别当成「跨区域泛化基准」宣传。更实用的做法是前文说的把验证集预测结果逐张叠回原图肉眼核对边界和类别。指标只能反映整体具体哪个类别、哪片区域出了问题还是可视化最直接。6. 进阶把UNet换成SwinUNet这份数据上的对比实验路径当你想在这套数据上验证改进网络比如SwinUNet或TransUNet这套数据已经具备「干净基准」的作用。关键在于换网络不是唯一的变量训练配置、数据加载、评估脚本都要保持一致对比才有意义。6.1 固定基线记录每类IoU先用UNet跑完一轮完整训练把mIoU和8个类别的单独IoU记录下来这一步相当于给改进实验定了参照系。把evaluate函数单独写成一个文件输入是model和val_loader输出mIoU和每类IoU后面换任何模型都只换model实例models {unet: unet_model, swinunet: swinunet_model} for name, model in models.items(): model.to(device) miou, per_class_iou evaluate(model, val_loader) print(f{name}: mIoU{miou:.4f}) for cls_id, iou in enumerate(per_class_iou): print(f cls {cls_id}: {iou:.4f})6.2 换网络时哪些参数不能动输入尺寸、batch_size、optimizer、loss、epochs、数据增强、评估脚本保持完全一致能动的只有encoder预训练权重、学习率策略和网络结构本身。SwinUNet的Swin Transformer在小数据集上不容易收敛1000张左右的数据量必须靠预训练权重提供先验。对比项UNetbaselineSwinUNetencoderResNet-34Swin-T / Swin-B预训练ImageNetImageNet-22K 或自监督显存需求8~10GB12GB 起收敛速度快略慢边界质量一般更干净我第一次拿遥感数据换新模型时忘了固定随机种子同一个SwinUNet跑了两次mIoU差出1.8个点后来才发现是数据加载做了shuffle又没设种子。从那以后我每次在分割数据上做网络对比都会把seed、image_size、loss三项写进一个config文件训练和评估强制走同一份配置再截图留档。数据集的基准性建立在流程可复现上希望帮到你。本文还有配套的精品资源点击获取