如果你最近开始碰遥感影像分割,估计十有八九都会被推荐先跑一遍 LoveDA。可真正上手之后,你会发现这套数据集的英文网站要填申请表、下载回来还要折腾目录结构和标签,好不容易把数据塞进模型,训练时又可能因为标签值偏移和显存问题翻车。我前前后后在这个数据集上踩了不少坑,也帮几个师弟师妹排过问题,下面这份从下载到训练的完整流程,直接照着做就能少走弯路。
LoveDA 的全称是 Land-cOver Domain Adaptation,是武汉大学团队发布的高分辨率土地覆盖语义分割数据集。它不仅帮你解决“一个分割模型怎么搭”的问题,更核心的是它把城市与乡村两类差异巨大的场景放在了一起,天然就是为研究跨域语义分割准备的。对于想入门遥感影像语义分割、或者要做无监督域适应(UDA)的同学来说,这是目前最适合用来练手和发论文的数据集之一。
我默认你至少会一点 PyTorch,知道什么是 dataset、dataloader,不需要你精通迁移学习,但我会把每一步为什么要这么做讲清楚,方便你做选择。
1. 先看清 LoveDA 的底细:7 个类别、跨域设计和真正难点
1.1 数据规模、分辨率和 7 类语义标签
LoveDA 提供的是 0.3 米左右空间分辨率的高分遥感影像,图像以瓦片形式提供,单张尺寸大约 1024×1024 像素。整个数据集大约有 4778 张影像,其中训练集 2523 张、验证集 1287 张、测试集 968 张,覆盖了中国多个城市的多样化地物场景。
关注点是 7 类地物语义标签:背景(background)、建筑物(building)、道路(road)、水域(water)、草地(grass)、灌丛(shrub)和耕地(cropland)。
| 类别编号 | 类别名称 | 典型特征 |
|---|---|---|
| 1 | Background | 未定义区域、边缘区域或其他零星地类 |
| 2 | Building | 人工建筑屋顶、密集居民区建筑 |
| 3 | Road | 城市主路、乡村道路、裸土路肩 |
| 4 | Water | 河流、湖泊、池塘、水田积水区 |
| 5 | Grass | 公园草地、河岸草地 |
| 6 | Shrub | 低矮灌丛、林地过渡带 |
| 7 | Cropland | 规则田地、大棚、农田田块 |
单看类别数量,7 类确实不算多,和人造数据集几十上百类没法比。但遥感影像分割的难度从来不只在类别数量,而在于类间视觉差异小、类内差异巨大。
比如,草地和灌丛在高分影像上有时只是纹理疏密不同,耕地里面不同作物、不同生育期的颜色千差万别,道路穿过乡村土路之后又被两边耕地和裸土包围。这些都会让普通的语义分割模型很头痛,也正因如此,直接用 LoveDA 验证模型好坏是有说服力的。
1.2 为什么“域”设计是 LoveDA 最特别的地方
很多新手把 LoveDA 当成普通分割数据集,分好 train/val 就直接训练,这其实完全浪费了它最有价值的设计。LoveDA 特意把样本组织成城市(Urban)、乡村(Rural)和随机(Random)三种域,不同域之间地物形态差异非常大。
你在城市域里看到的建筑是密集的高楼和整齐街区,道路是柏油路面,但在乡村域里,建筑物变成了低矮村屋,道路可能还是未硬化的土路,耕地和灌丛占据了绝对主导地位。这种位移差异正好对应真实遥感业务的痛点:一个在上海训练好的模型,直接拿到四川农村大概率性能暴跌。
所以,LoveDA 的官方设定更适合做无监督域适应,比如用城市域样本作为 source,乡村域或随机域作为 target,考察模型在目标域上没有标注时的表现。你如果只是做监督训练,也要明白自己验证集上的分数会明显虚高,跨域评估才是更真实的水平。
1.3 最容易被高估的两个入门误区
误区一:“7 类太简单了,随便跑跑就能 90 mIoU”。实际上,LoveDA 上完全监督训练的主流模型 mIoU 一般也就在 55-65 这个区间。原因显而易见,全图 1024×1024 的标注图里类别像素数量极度不均衡,背景和耕地占了大头,建筑物和灌丛只占很小比例,mIoU 一平均就把分数拉下来了。
误区二:“只要把训练集吃透,测试集自然没问题”。由于 LoveDA 的测试集没有公开标注,而且它和训练集来自不同城市区域,模型在训练集上的过拟合会直接暴露在最终测试分数上。这也是为什么我强烈建议你在本地固定一个验证集,每轮都看全类别的 IoU,不要只盯着总 loss。
2. 下载这一步就有坑:申请、解压、目录结构和标签值确认
2.1 官方申请流程与邮箱选择
LoveDA 数据集需要在官方页面填写申请表,不是点个按钮就能直接下载的。这些表单项大概包括你的姓名、单位、邮箱和用途说明。我第一次申请时图省事,填了个临时邮箱,等了三天都没收到回复,后来换成机构邮箱,第二天就收到下载链接了。建议直接用学校或单位的正式邮箱,同时在用途说明里写清楚打算用来做什么,比如“用于遥感图像分割模型的研究与对比实验”,比光写“research”通过率高不少。
收到回复邮件后,里面一般会提供下载链接。数据文件整体在几个 GB 级别,网速一般的话需要一点时间。下载完不要急着解压到默认路径,先检查一下文件大小和压缩包完整性,我曾经遇到下载中断但压缩包还能解压出一部分文件的情况,训练到一半才发现样本数量不对,排查浪费了半天。
2.2 解压后的目录结构:必须核对的三件事
解压完成后,标准目录结构应该是这样:
LoveDA/ ├── Train/ │ ├── Image/ # 训练图像,000001.png │ └── Mask/ # 训练标签,000001_mask.png ├── Val/ │ ├── Image/ │ └── Mask/ └── Test/ └── Image/ # 只有图像,没有公开 Mask第一件要核对的事情是训练图像和标签的文件名是否一一对应。正常情况下,训练图像名为000001.png,对应的标签图名为000001_mask.png,我在用 glob 方式读取时建议直接基于图像文件名去生成 mask 文件名,同时做一次存在性检查,避免因为某一张图缺 label 导致运行时崩溃。
小组命名上没有做好对齐,常见做法是把Image路径里的.png替换成_mask.png,但这依赖路径结构。我更推荐用 pandas 或单纯的列表推导构建映射表,然后打印前 5 条确认,别想当然。
第二件要核对的是图片尺寸是否统一。LoveDA 名义上是 1024×1024,但为了保证边界完整,实际解压出来的图像可能有少量尺寸偏差。如果你打算直接喂给模型,务必在 dataset 里读取后做形状断言,或者统一 resize。否则训练时到了某一张图,batch 维度不一致,PyTorch 会直接报错。
第三件要核对的是测试集。测试集只有Test/Image文件夹,没有任何标注。所以本地验证泛化性能时,正确做法是从Train里分出一部分样本做验证集,或者用官方Val集。我建议直接用官方 Val,因为它和 Train 是不同位置的影像,更能反映真实泛化水平。
2.3 掩码的像素值不是 0 到 6,而是 1 到 7
这是 LoveDA 新手最容易踩的大坑。很多人在写数据加载代码时,会下意识认为七分类的 mask 像素值就是 0 到 6,结果训练出来的模型要么 loss 不降,要么可视化时发现预测结果整体错位。
LoveDA 官方把类别像素值从 1 标到了 7,其中 1 是背景,7 是耕地。像素值为 0 的区域主要是边界填充或无效区域,建议当作 ignore 处理。所以在训练前需要把像素值做一个映射。
2.4 掩码用看图软件打开黑乎乎一片
如果你解压之后直接用普通看图软件打开mask文件,会发现整张图几乎全黑,只有极少数亮斑。这不是文件坏了,而是像素值 1 到 7 在一张 8 bit 图像里,亮度差异太微弱。用 QGIS 或者 Python 里做一次 histogram 拉伸,才能看到有颜色的地物标注区域。
批量确认标签值分布可以用下面这段代码:
import numpy as np from PIL import Image mask = np.array(Image.open("/path/to/LoveDA/Train/Mask/000001_mask.png")) print("unique values:", np.unique(mask))我第一次检查时看到 unique values 是[0, 1, 2, 3, 4, 5, 6, 7],立刻就明白标签偏移的问题了。这一步不要跳,解压后第一时间跑一遍。
3. 训练前的冷启动:环境、模型选型和一份极简训练代码
3.1 选 MMSegmentation 还是自己写 DataLoader
面对 LoveDA 训练,你第一个要做的决定是用现成框架还是自己写一套小脚本。很多人迷信 MMSegmentation,一上来就配置文件调成 LoveDA,结果被版本依赖、config 继承、pipeline 定义这些复杂机制卡住了一个星期。
我当然承认 MMSegmentation 是好东西,它对很多公开数据集都提供了现成 config,复现经典模型效率极高。但只要 LoveDA 不在它内置数据集列表里,你就得自己写 dataset class 和 config,这个学习曲线对新手并不友好。而且 LoveDA 的标签偏移问题,在 MMSegmentation 里一般通过reduce_zero_label来配置,但这样会把像素值为 0 的区域当成 ignore,处理不好还会让背景类从原来的 1 变成 0,整个类别顺序乱掉。
我的建议很直接:第一阶段先用一个几十行的 PyTorch Dataset 子类把数据加载搞明白,模型用一个 U-Net 或 DeepLabV3+,把训练流程跑通,确认模型真的能学到东西;第二阶段再切换到 MMSegmentation 做消融对比或复现,那时候你已经知道每个参数在干什么,就不会被框架牵着鼻子走。
3.2 环境依赖:不需要花里胡哨
我推荐的环境组合是 Python 3.10、PyTorch 2.x、CUDA 11.8,配合opencv-python、albumentations、timm。如果是 NVIDIA 显卡,训练前先确认可用:
import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果 CUDA 不可用,不要开始任何训练,先把驱动和 PyTorch 版本对齐。
3.3 模型怎么选:先跑一个能收敛的,再谈 SOTA
我在 LoveDA 上最先跑通的模型是 U-Net,原因很简单:显存占用低、训练速度快、代码不容易出错。但它作为基础 encoder-decoder 架构,对大目标和大背景类别表现还行,遇到细碎的建筑物边缘和道路连接处就明显拉胯。之后换成 DeepLabV3+ 的 ResNet50 和 ResNet101,整体 mIoU 有 3-5 个点的提升。
| 模型 | 骨干网络 | 大致显存占用(512×512, batch 4) | 适合阶段 |
|---|---|---|---|
| U-Net | 无预训练 | 约 4-6 GB | 跑通流程、快速验证 |
| DeepLabV3+ | ResNet50 | 约 8-10 GB | 正式训练、效果提升 |
| SegFormer | MiT-B2 | 约 8-12 GB | 跟风试新模型 |
| MMSegmentation 全家桶 | 任选 | 取决于 config | 复现论文、大规模消融 |
如果你显卡只有 8 GB 显存,先上 U-Net 跑流程;如果有 16 GB 以上,直接上 DeepLabV3+ 不亏。不要拿一张入门卡上来就跑 SegFormer-B4,给自己找不痛快。
3.4 一份能直接跑的极简训练骨架
不要嫌代码简单,贪多嚼不烂。我的习惯是先写一个能 5 分钟跑通一个 batch 的脚本,确认输入输出维度没问题后,再逐步加正则化和各种 trick。
import torch import torch.nn as nn from torch.utils.data import DataLoader, Dataset from PIL import Image import numpy as np import os, glob class LoveDADataset(Dataset): def __init__(self, img_dir, mask_dir, img_size=512): self.img_paths = sorted(glob.glob(os.path.join(img_dir, "*.png"))) self.mask_paths = [p.replace(img_dir, mask_dir).replace(".png", "_mask.png") for p in self.img_paths] for p in self.mask_paths: assert os.path.exists(p), f"mask not found: {p}" self.img_size = img_size def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img = np.array(Image.open(self.img_paths[idx]).convert("RGB")) mask = np.array(Image.open(self.mask_paths[idx])) # resize 到统一尺寸,注意 mask 用最近邻 img = np.array(Image.fromarray(img).resize((self.img_size, self.img_size))) mask = np.array(Image.fromarray(mask).resize((self.img_size, self.img_size), resample=Image.NEAREST)) # 关键:1~7 映射到 0~6;0 作为 ignore mask = mask - 1 mask = np.where(mask < 0, 255, mask) img = img.astype(np.float32) / 255.0 img = torch.from_numpy(img).permute(2, 0, 1) mask = torch.from_numpy(mask).long() return img, mask if __name__ == "__main__": ds = LoveDADataset("/path/to/LoveDA/Train/Image", "/path/to/LoveDA/Train/Mask") dl = DataLoader(ds, batch_size=2, shuffle=True) for x, y in dl: print(x.shape, y.shape, torch.unique(y)) break这里有三个细节值得解释:
- mask 的 resize 必须用
NEAREST,不能做双线性插值,否则类别边界会出现不存在的浮点过渡值,产生灰色噪声标签。 mask - 1之后把负值替代为 255,这样在交叉熵损失里就能通过ignore_index=255把无效边界区域排除掉。- 图像归一化直接除以 255 是保底方案,如果你用 ImageNet 预训练权重,最好改用
mean=[0.485, 0.456, 0.406]、std=[0.229, 0.224, 0.225]的标准化方式,配合预训练主干效果更好。
4. 把数据喂进 DataLoader:掩码偏移、归一化、增强和显存控制
4.1 掩码偏移的两种处理流派
上一章节我给的代码是标准的mask = mask - 1再ignore_index=255。这样处理后,原来的背景类 1 变成了训练时的 0 类,耕地类 7 变成了 6 类,类别之间刚好连续。这种方式简单直接,绝大多数开源实现也是这么处理的。
另一种流派是把背景直接忽略,也就是删除 mask 中类别 1 的像素,当成 6 类的纯地物分割来做。这种做法在某些特定任务里可行,但我不推荐,因为背景类并非完全无意义,它代表的“不是七种地物之一”的区域,对边界判断是有帮助的。如果你用 MMSegmentation,又想用官方的 7 类设定,最好在 dataset 配置里把reduce_zero_label=False,并自己写 pipeline 里的Normalize前后的类别映射,不要偷懒。
4.2 要不要用 ImageNet 预训练归一化
这是一个非常容易被忽略但影响挺大的点。如果你用的骨干网络是 ImageNet 预训练权重,比如 ResNet50,那么输入图像的通道均值、方差一定要用 ImageNet 的统计值,否则骨干网络第一层卷积的特征分布会错乱,开始训练时 loss 可能抖动得厉害。
如果你用的是自己从零初始化的 U-Net,那么用img / 255.0的简单归一化就够了,没必要硬套 ImageNet 统计量。我见过不少同学代码里混着用,训练出来的模型在验证集上表现不稳定,最后排查下来就是归一化不匹配的问题。
4.3 数据增强:少而精,别把遥感图搞花
LoveDA 影像的语义信息对颜色和纹理很敏感,所以增强策略要克制。我实测下来,最有用的是:
- 随机水平翻转和垂直翻转:遥感图像没有绝对的“上下”语义,翻转让模型对方向不敏感,涨点有效。
- 随机裁剪到 512×512 或 640×640:解决大图显存压力,也相当于做数据扩充。
- 轻度随机缩放(0.75 到 1.5 倍):模拟不同成像高度和尺度。
- 颜色抖动,看情况加,强度不要太高,特别是耕地和草地的颜色容易因为色彩增强而混淆。
不要做无谓的旋转任意角度增强。遥感图像虽然不是完全“上北下南”,但任意角度旋转会让规则的建筑物边界和道路方向变得混乱,给分割模型增加大量无效学习负担。我建议只做 90 度倍数旋转,比如随机选 0、90、180、270。
4.4 两大显存爆炸场景与解决方案
场景一是全图直接进模型。1024×1024 的输入加上 8 的 batch,哪怕 ResNet50 也会让你直接 OOM。解决方案就是随机裁剪成 512×512,或者 640×640,然后配合梯度累积。
场景二是推理时整张预测。训练时你用 512 的裁剪,到测试推理时自然也想用同样的输入尺寸,但这样会丢失全图的上下文,尤其对大块耕地和背景区域不友好。可以做一个重叠滑窗推理,简单实现如下:
def sliding_window_infer(model, image, window=512, stride=256): h, w = image.shape[-2:] pred = torch.zeros((1, num_classes, h, w), device=image.device) count = torch.zeros((1, 1, h, w), device=image.device) for i in range(0, h, stride): for j in range(0, w, stride): patch = image[:, :, i:i+window, j:j+window] patch = F.pad(patch, (0, max(0, j+window-w), 0, max(0, i+window-h))) out = torch.softmax(model(patch), dim=1) pred[:, :, i:i+window, j:j+window] += out count[:, :, i:i+window, j:j+window] += 1 return pred / count滑窗重叠的目的是减少拼接接缝处的预测不一致。你如果只是先跑通训练流程,可以先不写这个,等做验证集评估时再用。
5. 第一次训练的超参与验证:不要一上来就对标 SOTA
5.1 类别不均衡:损失函数如何设置
直接套一个普通的CrossEntropyLoss也能跑,但 LoveDA 里背景和耕地面积很大,建筑物和灌丛面积较小,最后出来的 mIoU 会被小类别严重拉低。我会推荐在损失函数里加入类别权重,最简单的是按频率的倒数做归一化,当然不要用原始倒数,否则权重比过于极端。我习惯取weight = 1 / np.log(1.02 + freq),既放大小类别贡献,又不会让权重爆炸。
class_weights = torch.tensor([1.0, 1.2, 1.0, 1.0, 1.5, 2.0, 1.2]).cuda() criterion = nn.CrossEntropyLoss(ignore_index=255, weight=class_weights)你也可以在训练一个 epoch 后统计所有 batch 的类别分布,动态更新权重。但对第一次跑通训练来说,没必要,用一个相对合理的固定权重就好。
5.2 学习率、迭代轮次和优化器的选择
LoveDA 单张图较大,常规训练建议跑 40 到 60 个 epoch。优化器我一般用 AdamW,初始学习率 6e-5 到 1e-4,配合多项式学习率衰减,也就是lr = base_lr * (1 - iter / total_iter) ** 0.9。如果你用的是 U-Net 这类从零训练的模型,也可以用 SGD,momentum 0.9,初始学习率 0.01,但个人体验是 AdamW 更省心,对新手更友好。
batch size 不要一味求大。我实测在单张 RTX 3090 上,DeepLabV3+ 配 ResNet50 用 512×512 输入,batch 设 8 刚刚好,batch 18 到 16 虽然能塞进去但训练不稳定。小 batch 配合梯度累积两到三步,效果不比大 batch 差。
一个比较实用的训练脚本片段:
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.PolynomialLR(optimizer, total_iters=total_iters, power=0.9) scaler = torch.cuda.amp.GradScaler() for epoch in range(epochs): model.train() for img, mask in train_loader: img, mask = img.cuda(), mask.cuda() optimizer.zero_grad() with torch.cuda.amp.autocast(): logits = model(img) loss = criterion(logits, mask) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() scheduler.step()混合精度训练在 30 系及以上显卡上能明显提速,而且显存占用降低,让 batch 可以稍微调大一点。如果碰到 loss 出现 NaN,先关掉 AMP 试试,很多情况下是学习率太大导致梯度溢出。
5.3 验证不是只看 loss:逐类别 IoU 和可视化缺一不可
训练损耗下降只代表模型在训练集上拟合了,不代表它对每个类别都学得好。我强烈建议每 5 个 epoch 在验证集上计算一次逐类别 IoU 以及 mIoU,保存最新的最优权重。否则你可能训练到第 50 个 epoch 才发现模型把灌丛类完全学成了草地类,白白浪费时间。
验证阶段最简单也最有效的三行代码:
pred = logits.argmax(dim=1) iou_per_class = [] for cls in range(num_classes): intersection = ((pred == cls) & (target == cls)).sum() union = ((pred == cls) | (target == cls)).sum() iou_per_class.append((intersection + 1e-6) / (union + 1e-6)) miou = np.mean(iou_per_class)同时每次验证取 4 到 6 张验证图,把原图、标注、预测并排保存成图片,打开看一眼就知道模型是在哪些地方犯错。我发现 LoveDA 上最常见的错误是:
- 建筑物边缘被道路或背景吃掉;
- 草地和灌丛互相污染;
- 耕地在不同季节影像里颜色变化明显,模型会碎成很多小块。
6. 结果解读与避坑清单:再训练前最好看这里
6.1 监督训练结果意味着什么
我在这里先说个容易让人焦虑的现象。你真拿 LoveDA Train 去训练、Val 去验证,可能发现 mIoU 只有 50 多,换了一个稍微复杂的模型能到 60 多,但再往上走就非常吃力了。这不是你调参能力不行,而是 LoveDA 本身的域差异和类不均衡决定了它在监督学习下的上限就在这个区间。
看到这个结果不要灰心。你如果去看官方论文和其他以 LoveDA 为基准的论文,就会发现大家在完全监督设定下的 baseline 并不高,很多所谓 SOTA 其实是在无监督域适应设定下拿到的提升。所以第一次训练跑出 55 附近,是个非常正常的起点。
6.2 最容易踩的坑汇总表
我把这些坑按照“表现、原因和解决方式”整理成了表,训练前后都对照一遍。
| 表现 | 根本原因 | 解决方案 |
|---|---|---|
| mask 标签读出来全是 0 或 1 | 像素值偏移没处理 | 读取后打印 unique,训练前做 mask-1 |
| resize 后标签出现灰色噪点 | mask resize 用了双线性插值 | 改用Image.NEAREST |
| 训练时显存溢出 | 1024 全图加过大的 batch 直接进 GPU | 随机裁剪到 512 或使用 AMP |
| loss 下降慢或震荡 | 归一化与预训练权重不匹配 | 统一用 ImageNet mean/std,或换无预训练模型 |
| 类别数对不上,维度报错 | mask 没从 1-7 映射到 0-6 | 确认 mask 的 unique 值,做减法映射 |
| 验证 mIoU 低 | 验证集与训练集来自不同城市区域 | 正常现象,做跨域对比时不要慌 |
| 验证集上单类 IoU 为 0 | 该类别在训练集中样本极少 | 加入类别权重或采用更强的增强 |
| 用测试集时找不到 mask | 官方测试集不公开标签 | 本地分验证集或走官方评测流程 |
6.3 想再深入的话:LoveDA 的正确打开方式是域适应
训练过一次全流程之后,你就已经具备了继续往下走的基础。LoveDA 之所以值得深入,不是因为它能让你把 U-Net 调得很好,而是它给了一个天然的任务场景:如何在城市、乡村、随机三种域之间迁移。
我自己在复现过程中最喜欢的一步,是把训练集按域标签分离,用城市域训练模型,然后在乡村域验证集上评估,观察跨域性能下降。这个体验很直观地告诉你为什么现在遥感分割领域大家都在研究域适应:真实业务里,标注数据永远不够,而地物分布永远在变。
如果你想继续进阶,可以考虑三步:第一步,尝试在数据加载时把域标签也传进模型,辅助分割头;第二步,改用对抗式域适应框架,比如基于对抗的特征对齐;第三步,扩展到 MMSegmentation 生态,找 LoveDA 相关的开源配置文件,做更系统的实验。
我个人的经验是,做深度学习项目最怕的不是模型难调,而是数据集没吃透。LoveDA 的标签偏移、域划分、混淆类别这些问题,几乎在所有遥感分割数据集里都会遇到。你把这个数据集完整跑通一遍,之后切换到自己研究领域的数据集时,会发现大部分代码和思路都不用重写,只需要替换数据加载和类别定义部分。这份实战的价值,比单纯调高几个点 mIoU 要大得多。