简介:这是一份面向遥感目标识别与深度学习分割任务的大分辨率道路图像分割数据集,适用于入门至进阶的图像分割模型训练与验证。资源内含约6000张图片与对应二值标签,划分为4300张左右的训练集和1800张左右的测试集,背景与道路两类标注清晰,并附有类别说明文件。压缩包共2000个文件,以PNG图像为主,辅以txt标注说明与py可视化脚本,可随机抽取样本,将原图、GT与叠加蒙板效果一并展示,便于快速检查标签质量与训练效果。包体大小约359.11MB,结构简洁,适合直接接入Unet、Swin-Unet等分割网络进行实验。目前已有104人学习。对于需要大规模遥感道路数据或希望对比不同分割网络效果的读者,这套数据与配套脚本能节省不少数据整理时间,并提供直观的验证流程。
1. 遥感道路 2 值分割:6000 张大图能训练一个能落地的分割模型吗
手里有 6000 张高分辨率遥感道路图像,想做一个 2 值分割模型,第一反应是直接开训。这份遥感道路图像分割数据集合了训练集约 4300 张、测试集约 1800 张,每一张都带同尺寸的单通道 mask 标签,类别只有背景和道路两类。跟自然图像分割不一样,遥感影像分辨率大、道路细长、背景占比高,直接读全图进模型大概率显存爆掉,loss 曲线像过山车。这套资源适合两类人:一类是刚接触语义分割,需要一个能快速跑通 UNet 流程的干净数据集;另一类是做遥感道路提取或目标检测,想用现成标注做迁移预训练。下面从目录结构讲起,把切图、训练、验证和我知道的坑一次说清楚。
2. 拿到压缩包先别训练:目录结构、标签规范与可视化脚本
2.1 目录结构与文件名对应规则
解压后第一件事,先看清楚目录结构。这份遥感道路分割数据集的文件组织比很多学术数据集更简单,核心目录就是 train 和 test 两个:
dataset/ ├── classes.txt # 类别文件:背景 道路 ├── vis.py # 可视化脚本,随机抽一张图并保存 ├── train/ │ ├── images/ # 约 4300 张遥感原图 │ └── masks/ # 与 images 同名的二值标签 └── test/ ├── images/ # 约 1800 张遥感原图 └── masks/ # 与 images 同名的二值标签拿到任何数据集,我一般先做两件事:一是用 os.listdir 数一下两个目录的文件数是否与描述一致,二是确认原图文件名和 mask 文件名能不能一一对应上。这份数据的命名规则很直白,原图是 509171.png,mask 就是 509171_mask.png,前缀完全一致。这意味着写训练代码时,mask 路径只需要在原图路径上做一次字符串替换,不需要维护额外的 JSON 或 txt 索引文件,逻辑比 COCO 那种 json 标注省事得多。
train 和 test 是已经划好的两个独立目录,原样用就行。我见过不少人拿到数据后把两边混在一起重新切分,结果测试集和训练集出现同源图片,指标虚高,后面避坑章会专门说这个数据泄漏问题。
2.2 标签值域先确认:是 0/255 还是 0/1
看到 masks 目录下全是 PNG,别急着直接往网络里塞。二值分割数据集的 mask 有两种常见存储方式:一种是背景为 0、前景为 255,保存时为了肉眼看起来更直观;另一种是已经处理成 0 和 1,加载后可以直接当标签用。这两种差了一个 255 的缩放,训练出来的结果天差地别。
解压后第一件事,随便挑一张 mask 用 OpenCV 读成灰度图,看一眼它的值域:
import cv2 import numpy as np mask = cv2.imread("dataset/train/masks/509171_mask.png", cv2.IMREAD_GRAYSCALE) print("shape:", mask.shape) print("unique:", np.unique(mask))如果输出的是 [0 255],说明 mask 停留在可视化友好的 0/255 形式,需要在 Dataset 里用 mask > 0 转成布尔再转 float;如果输出是 [0 1],直接转 float32 就行。classes.txt 一般会把两个类别按顺序列出来,背景、道路,对应 0、1,训练时读进来当类别映射参考即可。这里多花两分钟,比训练完发现 loss 不降再回头排查要省太多时间。
2.3 自带的可视化脚本:先看数据再动手
这份资源里附带了一个可视化脚本,逻辑是随机从训练集抽一张图,把原始图像、GT 图像、GT 叠加到原图上的效果并排显示,然后保存到当前目录。它解决的是“标注到底长什么样”这个基础问题。我自己拿到数据也会先写同样一段脚本:
import os import random import cv2 import numpy as np import matplotlib.pyplot as plt images_dir = "dataset/train/images" masks_dir = "dataset/train/masks" name = random.choice(os.listdir(images_dir)) img = cv2.imread(os.path.join(images_dir, name)) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask = cv2.imread(os.path.join(masks_dir, name.replace(".png", "_mask.png")), cv2.IMREAD_GRAYSCALE) overlay = img.copy() overlay[mask > 0] = (255, 0, 0) # 道路区域标红 mixed = cv2.addWeighted(img, 0.7, overlay, 0.3, 0) fig, axes = plt.subplots(1, 3, figsize=(15, 5)) axes[0].imshow(img); axes[0].set_title("image") axes[1].imshow(mask, cmap="gray"); axes[1].set_title("mask") axes[2].imshow(mixed); axes[2].set_title("overlay") plt.savefig("vis_example.png", bbox_inches="tight")这段代码的关键在于 mask 的读取方式:cv2.imread 的第二个参数传了 IMREAD_GRAYSCALE,确保拿到单通道灰度图,而不是被 OpenCV 默认读成三通道 BGR。叠加时用 addWeighted 做半透明合成,原图权重 0.7、标注权重 0.3,这样能同时看清道路纹理和标签边界,比直接贴一张纯色蒙版更容易发现标注偏差。跑完这段脚本,你会对这个数据集里的道路是宽是窄、有没有断头路、背景里有哪些干扰目标有直观认识,再决定后续切图尺寸和增强策略。
3. 从大图到训练样本:切图、增强与 Dataset 封装
3.1 为什么大分辨率必须切图:显存不是唯一原因
遥感影像的“大分辨率”不是形容词,是具体数字。通常一张原始图的边长在几千像素级别,三通道 RGB 数组动辄几十上百 MB,直接按整图塞进 UNet,显存会在第一个 batch 直接爆炸。更麻烦的是,很多语义分割网络的 bottleneck 是 16 倍或 32 倍下采样,输入尺寸不够规整时,最后的上采样倍数会被打乱,输出尺寸和标签对不上。
所以切图不是可选项,而是必选项。这里的核心决策是切多大:256 太小,道路这种长条形目标会被切断,模型学到的是碎片化结构;512 是性价比比较高的选择,既能装下够长的道路片段,又不会让 batch size 缩水太多;如果显存只有 8G,可以退到 384 或者 256 配合梯度累积。还有一个隐性原因:遥感影像不同区域的亮度、阴影差异很大,切成小块后相当于天然做了一次随机裁剪增强,模型能看到更多样的场景分布,泛化能力比整图训练要稳。
3.2 带 overlap 的滑窗切图:坐标对齐别偷懒
切图时最容易犯的错是只切原图,mask 按原图坐标硬切,结果两张图边缘错位。正确做法是用完全相同的滑窗起点去切原图和 mask,并且让窗口带一部分重叠,避免一条道路恰好被切在窗口边界上而导致标签断裂。
import os import cv2 in_img_dir = "dataset/train/images" in_mask_dir = "dataset/train/masks" out_img_dir = "dataset/train_crop/images" out_mask_dir = "dataset/train_crop/masks" os.makedirs(out_img_dir, exist_ok=True) os.makedirs(out_mask_dir, exist_ok=True) crop_size = 512 overlap = 64 step = crop_size - overlap for name in os.listdir(in_img_dir): if not name.endswith(".png"): continue img = cv2.imread(os.path.join(in_img_dir, name)) mask = cv2.imread(os.path.join(in_mask_dir, name.replace(".png", "_mask.png")), cv2.IMREAD_GRAYSCALE) h, w = img.shape[:2] for y in range(0, h - crop_size + 1, step): for x in range(0, w - crop_size + 1, step): crop_img = img[y:y + crop_size, x:x + crop_size] crop_mask = mask[y:y + crop_size, x:x + crop_size] base = name.replace(".png", f"_{y}_{x}.png") cv2.imwrite(os.path.join(out_img_dir, base), crop_img) cv2.imwrite(os.path.join(out_mask_dir, base), crop_mask)注意 range 的步长是 step,所以相邻两个窗口会重叠 overlap 个像素。为什么要有重叠:分割模型的预测置信度在窗口边缘明显低于中心,推理阶段对重叠区域做平均可以显著改善接缝问题;训练阶段重叠相当于过了数据增强,样本量也上来了。crop_size 和 overlap 的取值不是玄学,我一般先按 512/64 跑一版,如果道路特别长、预测图断得厉害,就把 overlap 加到 128,代价是同样的原图会切出更多图块,训练时间上涨。
还有一个细节:这个脚本没有处理右侧和下侧不足 crop_size 的残留边缘。如果整张原图恰好是 1000×800,滑窗会漏掉最后 488×288 的带状区域。常见做法是先把原图边缘用 cv2.copyMakeBorder 镜像填充到能被 step 整除,或最后补切一刀不完整图块。训练时少量不完整图块问题不大,但推理时最好先 pad 再切,后面避坑章会专门说这个接缝问题。
3.3 数据增强必须同步:image 与 mask 共用同一套变换
切图完之后,数据集规模大概是原来的几倍,但遥感场景重复度仍然高,数据增强是必要的。增强最大的坑是两个输入各做各的变换,最常见的就是用 torchvision.transforms.RandomHorizontalFlip 单独处理 image,又单独处理 mask,结果是图像左右翻转了,标签还是原方向,训练出的模型会把道路学成背景。
解决方式是用支持多输入的增强库,albumentations 在这个场景下几乎是标配,因为它的 transform 接口天然接收 image 和 mask 两个键:
import albumentations as A transform = A.Compose([ A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomRotate90(p=0.5), A.RandomBrightnessContrast(p=0.3), ])这里 HorizontalFlip 和 VerticalFlip 是几何变换,作用于 mask 不会改变标签语义;RandomBrightnessContrast 是像素变换,只作用于 image,所以需要把 mask 排除在外。albumentations 的 Compose 会自动判断:传入的是 dict 就同时处理 image 和 mask,传入的是单张图就只处理图像。如果坚持用 torchvision 那套,就得在 transform 前后手动控制随机种子,非常容易出错,我建议直接用 albumentations 省掉这个心智负担。
翻转概率设 0.5 就够了,旋转 90 度对遥感图是个很实用的增强,因为道路方向并不总是水平或垂直。亮度对比度增强的幅度不要太大,遥感图的颜色分布通常比较稳,调 0.2-0.3 的强度已经足够让模型不那么敏感了。
3.4 写一个 RoadDataset:值域归一化与通道设定
切好的图和 mask 还是散落在磁盘上,接下来写一个标准的 PyTorch Dataset,把读取、归一化、增强串起来:
import os import cv2 import numpy as np import torch from torch.utils.data import Dataset import albumentations as A class RoadDataset(Dataset): def __init__(self, img_dir, mask_dir, transform=None): self.img_dir = img_dir self.mask_dir = mask_dir self.names = [f for f in os.listdir(img_dir) if f.endswith(".png")] self.transform = transform def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] img = cv2.imread(os.path.join(self.img_dir, name)) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask = cv2.imread(os.path.join(self.mask_dir, name), cv2.IMREAD_GRAYSCALE) mask = (mask > 0).astype(np.float32) # 关键:0/255 -> 0/1 if self.transform is not None: aug = self.transform(image=img, mask=mask) img, mask = aug["image"], aug["mask"] img = img / 255.0 img = torch.from_numpy(img).permute(2, 0, 1).float() mask = torch.from_numpy(mask).unsqueeze(0).float() return img, mask几处关键逻辑:mask 在这里用 mask > 0 统一转成 0/1,不管你原始值是 0/255 还是 0/1,都能归一;img 除以 255 后,从 HWC 布局 permute 成 CHW,这是 PyTorch 卷积输入的默认格式;mask 最后 unsqueeze(0) 变成 1×H×W,配合后面 BCEWithLogitsLoss 使用。如果打算用 CrossEntropyLoss,就要输出两个通道,mask 改成 LongTensor 而不是 float,这一步的通道设置和 loss 选择是绑定的,先想清楚。
到这里,数据管线已经通了。下一步是选网络和损失函数,把训练跑起来。
4. 用 UNet 跑通第一次训练:损失函数、优化器与训练曲线判读
4.1 模型选型:UNet 是起点,SegFormer 是后手
道路分割这个任务,UNet 是公认的起点模型。它的 U 型结构保证了浅层高分辨率特征和深层语义特征能通过 skip connection 融合,道路这种既需要边界细节又需要连续性的目标,正好吃这一套。如果之前没接触过这个数据集,我一般建议先用 UNet 出一个 baseline,把数据流程验证通了,再考虑换结构。
用 segmentation_models_pytorch(smp)库,UNet 只需要一行:
import segmentation_models_pytorch as smp model = smp.Unet( encoder_name="resnet34", encoder_weights="imagenet", in_channels=3, classes=1, )encoder_name 控制主干,resnet34 是预训练权重大小和精度比较平衡的选择;encoder_weights="imagenet" 加载 ImageNet 预训练,能显著加速收敛,遥感图像虽然不是自然图像,但低层纹理特征是可迁移的;classes=1 意味着输出单通道,经过 sigmoid 后就是道路概率图。如果想提点,可以换 efficientnet-b3 当 encoder,或者直接上 segformer 这一类的 transformer 分割模型。配套资料里的 CSDN 专栏也整理了 unet、swinUnet、transUnet 的改进思路,等 baseline 跑通了再去照着改,会少走很多弯路。
4.2 损失函数:BCE 还是 Dice,还是两个都要
道路分割的典型问题是类别不平衡:一张遥感图里道路面积占比可能只有 5%-15%,剩下全是背景。如果只用 BCE,模型很容易发现全预测成背景也能拿到不高的 loss,收敛出来的结果大概率是道路细线断成一截一截的。
Dice Loss 对前景占比不敏感,直接优化预测和标签的重合度,对这类细长目标更友好,但在训练初期梯度容易不稳定。常见做法是把两者拼起来:L = BCE + Dice,兼顾像素级收敛路径和区域级优化目标。
import torch.nn.functional as F def bce_dice_loss(logits, mask): bce = F.binary_cross_entropy_with_logits(logits, mask) pred = torch.sigmoid(logits).flatten() target = mask.flatten() inter = (pred * target).sum() union = pred.sum() + target.sum() + 1e-6 dice = 1 - 2 * inter / union return bce + diceunion 里加 1e-6 是为了防止目标区域太小导致除零。两个 loss 系数默认 1:1,实操里如果发现预测偏保守,可以把 dice 的权重调到 1.2,如果收敛太慢就把 bce 权重调大。这个函数可以直接塞进训练循环,不需要太复杂的工程化封装。
4.3 训练循环与参数:一版能跑的配置
数据集和 loss 定了,训练脚本就很好写了。下面是一版能直接跑的循环骨架,train_loader 用第 3 章的 RoadDataset 配合 torch.utils.data.DataLoader 生成,batch_size 设 8、shuffle=True:
import torch import torch.optim as optim model = smp.Unet("resnet34", encoder_weights="imagenet", in_channels=3, classes=1).cuda() criterion = bce_dice_loss optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) for epoch in range(60): model.train() total_loss = 0.0 for img, mask in train_loader: img, mask = img.cuda(), mask.cuda() logits = model(img) loss = criterion(logits, mask) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() print(f"epoch {epoch:02d} loss {total_loss / len(train_loader):.4f}")几个参数是配合着来的:
| 参数 | 建议值 | 说明 |
|---|---|---|
| input_size | 512×512 | 兼顾细节与显存 |
| batch_size | 8 | 大约占 10-12G 显存 |
| lr | 1e-4 | 预训练主干不宜太大 |
| epochs | 60 | 每 5 轮做一次验证 |
| weight_decay | 1e-4 | 缓解过拟合 |
如果显卡是 8G,就把 batch_size 降到 4,或退到 384 输入;lr 用 1e-4,预训练权重对学习率敏感,再大容易把权重冲掉。epoch 数不是固定的,遥感道路分割一般 40-80 epoch 才能看到 mIoU 明显增长,我的习惯是先跑 60 个 epoch,同时每 5 个 epoch 存一次 checkpoint,方便回滚。
提示:训练循环里强烈建议加一个简单的验证函数,每 5 个 epoch 在测试集上算一次 mIoU。如果只盯着训练 loss,很难知道模型是记住了训练集还是学到了泛化特征。
4.4 训练曲线怎么判读:不是 loss 掉了就算数
训练 loss 从 0.7 缓慢掉到 0.3,看着挺顺,但一上测试集就露馅,这是分割训练最常见的情况。判读曲线我一般看三个信号:第一,训练 loss 是否下降;第二,验证 mIoU 是否同步上升;第三,两者差值是否越拉越大。
如果训练 loss 持续下降但验证 mIoU 停滞,大概率是过拟合,回退到前面某个 checkpoint,同时加重增强或加大 weight_decay。如果训练 loss 和验证 mIoU 一起卡住不动,先检查 mask 是否归一化成 0/1,再检查学习率是不是太大。还有一个容易忽略的信号:loss 卡在某个值附近长期不动,比如 BCE 项一直在 0.6 以上,多半是标签没喂对,回到第 2 章去重新看 mask 的值域。
5. 遥感分割避坑:四个最容易翻车的地方
5.1 数据加载阶段的坑
坑 1:mask 全黑,或者训练 loss 曲线是一条水平线。现象:训练几轮后 loss 几乎不动,预测图全是背景,道路区域完全没有被激活。原因:最常见的两个,一个是 mask 原值是 0/255 而没归一化,标签数值变成 255 后 loss 被撑爆,梯度过大导致模型震荡;另一个是用 cv2.imread 默认彩色模式读 mask,读出三通道后和单通道输出直接算 loss,维度都对不上。解决:Dataset 里统一用 mask > 0 转成 0/1,同时在训练循环开头打印一个 batch 的 mask 唯一值和 shape,确认是 [0,1] 且尺寸与图像一致。这个检查我已经养成习惯,几十秒的时间能省几小时的 debug。
坑 2:感觉训练集和测试集表现都很好,但换个区域就崩。现象:在测试集上 mIoU 能到 0.85 以上,把模型丢到另一批遥感影像上效果很差。原因:先切图、后划分训练集和测试集,导致同一张大图的切片同时出现在两边,模型已经看过测试区域,指标虚高。这份数据本身给了 train 和 test 目录,我一般不会再自己去重编划分。解决:训练集和测试集的文件名前缀做一次交集检查,有重复就说明泄漏了。如果是自己从原始大图切图,务必先把大图分成两份,再做滑窗切图。
5.2 训练与推理阶段的坑
坑 3:推理时直接整图塞进模型,显存溢出。现象:训练时一切正常,跑推理脚本时 CUDA out of memory。原因:训练用了滑窗,推理时却把整张遥感图直接 forward,全卷积网络输入多大显存就吃多大。解决:推理时用和训练相同的滑窗切图,每块单独 forward,再把结果拼接回原图尺寸。我在推理脚本里会把 crop_size、overlap 和训练时保持一致,不额外动参数。
坑 4:拼接出来的预测图有明显棋盘格接缝。现象:每 512×512 块的边界处,道路预测断线或者有浅色条纹。原因:窗口边缘的预测置信度天然比中心低,直接硬拼接会把低置信度区域留在边界上。解决:推理时把滑窗步长设小,让相邻窗口有重叠,每个像素点被多次预测,最后取平均再阈值化。逻辑不复杂,维护两个数组就行:
import numpy as np acc = np.zeros((H, W), dtype=np.float32) # 累加预测概率 cnt = np.zeros((H, W), dtype=np.int32) # 每个位置被预测次数 for x, y in slide_windows: pred = sigmoid(model(crop)) acc[y:y + crop_h, x:x + crop_w] += pred cnt[y:y + crop_h, x:x + crop_w] += 1 final_mask = (acc / np.maximum(cnt, 1)) > 0.5这里 H、W 是原图尺寸,crop_h、crop_w 是切图尺寸,slide_windows 是滑窗坐标序列。最后 acc 除以每个位置被预测的次数,得到平均概率,再阈值化成 0/1。这个后处理对最终 mIoU 的影响往往有 1-2 个点,值得写进推理脚本。
坑 5:道路细线被丢了,模型输出像虚线。现象:主干道预测完整,但窄路、小巷路段频繁断开。原因:crop_size 过大导致窄路段在整个图块里占比过低,Dice Loss 对细长目标的敏感度下降;另一个原因是下采样倍数过大,道路宽度只有 1-2 个像素时被池化层吞掉了。解决:训练时切图尺寸降到 384,overlap 加大;后处理时对预测概率图做一次形态学闭运算,用 3×3 或 5×5 的核把断点连起来。我一般会在验证阶段测试不同后处理核大小,找到最稳定的配置再写进推理脚本。
6. 训练后的验证三板斧:mIoU、叠加可视化与坏样本定位
6.1 第一板斧:mIoU 怎么算才算对
mIoU 的公式不复杂,但实现细节决定结果可信度。按整张图所有像素一起统计,还是按每个图块单独统计再平均,得到的数字完全不一样。我习惯按像素累加交并集,再在最后统一算比值:
def compute_miou(pred, gt, eps=1e-6): pred = (pred > 0.5).float() gt = gt.float() inter = (pred * gt).sum() union = (pred + gt).clamp(0, 1).sum() return inter / (union + eps)单张验证图调用这个函数,全数据集再对所有像素的 inter 和 union 各自求和,最后算一次全局 mIoU。这样不会被那些道路占比很小的空图块拉低平均值。
6.2 第二板斧:预测叠加到原图
指标只能告诉你好不好,不能告诉你哪里不好。把预测结果叠加到原图上,是最快定位问题的可视化手段:
pred_mask = ((pred > 0.5).astype(np.uint8)) * 255 overlay = img.copy() overlay[pred_mask > 0] = (0, 0, 255) vis = cv2.addWeighted(img, 0.7, overlay, 0.3, 0) cv2.imwrite("vis_pred.png", vis)红色叠加在灰色遥感底图上,道路的贯通性、误检区域一眼可见。这一步不用做得太精细,重点是把训练曲线和真实预测效果对应起来。
6.3 第三板斧:按误差排序找坏样本
我拿到测试集预测结果,会先把每张图的 mIoU 全部算出来,按升序排,取最差的第一批图专门核查:
ious = [compute_miou(preds[i], gts[i]) for i in range(len(preds))] bad_idx = np.argsort(ious)[:10]翻翻这些坏样本集中在地形起伏大的阴影区、道路交叉口,还是大片裸土被误判成道路的地方。搞清楚坏样本的分布,再决定下一步是补训练数据,还是调后处理,还是直接换更大的模型。指标是结果,坏样本定位才是起点。
那次之后,我每次拿到一个新的分割数据集,都会强制走一遍这三板斧:先算 mIoU,再做叠加可视化,最后按误差排序翻车样本。顺序不能乱,因为漏掉坏样本定位,前两个数字再好看,模型也可能在新区域上崩给你看。希望帮到你。
本文还有配套的精品资源,点击获取