简介:针对遥感背景下建筑、山地、植被等典型地物的语义分割需求,压缩包内提供了一套七类像素级标注数据。图像分辨率统一为1024×1024,原图为jpg格式,掩膜标签为png格式,标注类别涉及背景、土地、建筑、农田、植被、水体等,具体标签说明可在随包的txt类别文件中查看。数据集已划分为训练集与验证集,其中训练集149张图片及对应掩膜,验证集37张图片及对应掩膜,结构清晰,免去自行标注与整理的环节,可直接用于语义分割模型的训练和效果对比。压缩包共375个文件,除图片和标签外,还包含类别定义txt与辅助脚本,整体约47.03MB,下载解压即可使用。目前已有166人学习,适合需要快速获取遥感分割数据、开展算法实验或项目演示的研究者与开发者,便于快速上手。
1. 遥感图像分割数据集:为什么标签文件才是 7 类分割项目的分水岭
遥感图像分割数据集这个方向,最容易被低估的是标签文件的价值——模型结构可以复现,训练参数可以公开,但一份类别边界干净、覆盖建筑、山地、植被等 7 类的标注文件,才是决定分割模型上线精度的真正分水岭。下面围绕一套遥感背景下的 7 类语义分割数据集,从类别体系设计、标签文件格式选型,到基于 SegFormer 的训练落地,再到标注质检与踩坑记录,按一条完整可复现的路径展开。适合正在做遥感图像语义分割、需要自制数据集或打算用现成数据训练分割模型的研究生、算法工程师和 GIS 开发。
2. 7 类语义分割的类别体系与标签文件格式:定标准是训练前最重要的事
2.1 七个类别怎么定:建筑、山地、植被之外的类别取舍原则
遥感影像里地物种类远不止 7 类,真正做数据集时不能凭感觉堆类别,而是按“互斥、完备、可标注”三个原则来筛。最常见的做法是把 7 类定成:背景、建筑、山地、植被、水体、道路、农田。其中建筑、山地、植被是标题里点名的三个显性类,剩下四类的取舍才是数据集质量的关键。
先看建筑和山地、植被之间的边界怎么切。建筑在遥感影像里指有屋顶和有墙体的构筑物,包括住宅、厂房、大型公共建筑,但集装箱、活动板房、温室大棚经常和建筑长一个样,必须在标注雷同里写明“温室大棚归农田,工地临时板房归背景”。山地的定义要避开植被覆盖的语义交叉:我一般把“自然地形抬升且植被覆盖度低于 40%”划归山地,植被覆盖度高于 40% 的坡面归植被。裸岩、碎石坡、稀疏灌木都算山地。这套规则不写清楚,两个标注员会在同一片山坡上给出完全不同的标签,模型训出来边界一定乱。
再看剩下四个类的取舍。水体和道路在遥感数据集中几乎必选,因为它们是建筑和植被之外最容易识别、也最有应用价值的对象。水体包括河流、湖泊、水库和养殖塘,静止水面按水体处理;道路指硬化路面,含街道和公路,但建筑墙脚紧贴的硬化地面要跟建筑归并,不能一个像素的路面就单独切开。农田和背景是两个“兜底”类:农田覆盖水稻田、旱地、果园、大棚,有明显耕作边界;背景放广场、裸地、云影、无法归类的杂地。
这 7 类之间的边界规则建议直接写进一份label_rule.md,并配三张典型示例图。类别冲突重灾区是建筑与道路夹角、山地与植被过渡带、水体与阴影交界处,这三处占标注返工量的八成。把规则前置到标注阶段,省下来的时间远超训练时调模型的成本。对最终做语义分割数据集的人来说,类别定义不是论文里的一句话,而是每一条可执行的像素级判定标准。
2.2 标签文件格式选型:PNG 掩码、GeoJSON 矢量与 RLE 编码的对比
语义分割数据集的标签文件在存储上有三种主流形态:单波段 PNG 掩码、GeoJSON 矢量、RLE 游程编码。同一个 7 类数据集,标签文件格式直接决定训练代码怎么写、二次修正怎么改、以及出问题后能不能“后悔”。
| 格式 | 物理形态 | 训练友好度 | 二次编辑 | 典型问题 |
|---|---|---|---|---|
| PNG 掩码 | 每张 mask 一张 PNG,宽高与影像一致,像素值为 0~6 整数 | 直接进 DataLoader,最快 | 只能用像素画笔逐格改 | 文件数量大,不带地理坐标,改一笔要重出 |
| GeoJSON 矢量 | 一个 JSON 或 SHP,存多边形顶点与类别属性 | 训练前需要栅格化一次 | QGIS 里随时改多边形 | 栅格化参数不对会错位 |
| RLE 编码 | 每张图一串压缩字符串 | 需要解码还原成二维 mask | 极难手工改 | 调试时眼睛完全看不出形状 |
在遥感背景的数据集里,我一般用“矢量存底、PNG 出训练”的双轨策略:标注阶段全都存 GeoJSON,标注员在 QGIS 里勾多边形,属性表里写类别编号;发布训练集时用脚本把 GeoJSON 栅格化成与影像严格对齐的 PNG 掩码。GeoJSON 相当于整个数据集的后悔药——训练发现某类边界画错了,回 QGIS 改几个多边形重新栅格化就行,不用推倒重来。
配套还需要一份class_dict.json,把类别索引、类别名和 RGB 颜色对应起来。下面这段代码生成的标准 class_dict 可以作为数据集发布时的公共参考:
# build_class_dict.py import json class_names = [ "background", "building", "mountain", "vegetation", "water", "road", "farmland" ] # 颜色尽量选色相差大、且避免纯白纯黑的 colors = [ (0, 0, 0), # background (255, 0, 0), # building (0, 180, 0), # mountain (0, 255, 0), # vegetation (0, 0, 255), # water (255, 255, 0), # road (128, 64, 0), # farmland ] class_dict = { str(i): {"name": name, "color": color} for i, (name, color) in enumerate(zip(class_names, colors)) } with open("class_dict.json", "w", encoding="utf-8") as f: json.dump(class_dict, f, ensure_ascii=False, indent=2) print("class_dict.json generated,", len(class_names), "classes")这段代码的作用是把类别定义固化成机器可读的 JSON,后续数据增强、可视化、评估脚本都从这一份文件读取类别名和颜色,避免在多个脚本里各写一份硬编码。注意三个参数:颜色表不能用 (255,255,255) 纯白和 (0,0,0) 纯黑作前景,因为遥感影像里的高光屋顶和阴影区频繁出现这两种像素值,质检时人眼容易看混;类别名建议统一小写英文,中文名放进一个附加字段,避免编码问题;索引从 0 开始连续编号,背景固定为 0,这样和 PyTorch 的 CrossEntropyLoss 默认映射一致。
PNG 掩码本身建议存单波段、像素值直接等于类别索引(0~6),不要为了“看得见”去存 RGB 彩色图。原因很简单:训练代码里 mask 读进来直接是LongTensor,省去一次 RGB 到索引的映射;而且 RGB 彩色图有 255 种组合,转回类别索引时一旦出现压缩伪色,标签就坏了。
3. 用 SegFormer 训练遥感 7 类分割:数据划分、训练代码与指标解读
3.1 遥感影像裁剪为训练瓦片:目录结构、滑窗与重叠参数
遥感影像动辄上万像素宽,直接整图训练显存放不下,常规做法是滑窗裁成 512×512 或 256×256 的瓦片。裁剪本身不是无脑切,overlap(重叠)参数必须设置,否则跨越瓦片边界的建筑物会被拦腰截断,模型训练时永远看不到完整轮廓,推理时边界处会出现锯齿。
推荐的目录结构如下:
dataset/ ├── images/ │ ├── train/ # 训练影像瓦片,GeoTIFF │ └── val/ # 验证影像瓦片,GeoTIFF ├── masks/ │ ├── train/ # 训练标签,单波段PNG,像素值0~6 │ └── val/ # 验证标签 ├── class_dict.json └── splits/ ├── train.txt └── val.txttrain.txt和val.txt里每行一个文件名(不带扩展名),DataLoader 按这个清单读取影像和 mask。目录与清单分离的好处是换划分方案时不用移动文件。
用 GDAL 写裁剪脚本是遥感数据处理的基本操作,代码如下:
# tiff2tiles.py from osgeo import gdal import os def tile_image(src_path, dst_img_dir, dst_mask_dir, tile_size=512, overlap=64): ds = gdal.Open(src_path) width, height = ds.RasterXSize, ds.RasterYSize band_count = ds.RasterCount step = tile_size - overlap # 滑窗步长 = 瓦片尺寸 - 重叠 count = 0 for y in range(0, height - tile_size + 1, step): for x in range(0, width - tile_size + 1, step): count += 1 tile_name = f"{os.path.basename(src_path)[:-4]}_{y}_{x}" driver = gdal.GetDriverByName("GTiff") dst_ds = driver.Create( os.path.join(dst_img_dir, f"{tile_name}.tif"), tile_size, tile_size, band_count, gdal.GDT_Byte, ) # 保留地理变换与投影,后续做标签对齐要用 dst_ds.SetGeoTransform(ds.GetGeoTransform()) dst_ds.SetProjection(ds.GetProjection()) for band in range(1, band_count + 1): data = ds.GetRasterBand(band).ReadAsArray(x, y, tile_size, tile_size) dst_ds.GetRasterBand(band).WriteArray(data) dst_ds = None print(f"{count} tiles generated from {src_path}") tile_image("raw/area1.tif", "dataset/images/train", "dataset/masks/train")裁剪脚本的逻辑是先用gdal.Open读原图宽高,再按step = tile_size - overlap从左到右、从上到下移动窗口。SetGeoTransform和SetProjection这两行不能省,它们把地理坐标写到瓦片上,后面做掩码栅格化和标签对齐时全靠这组信息。overlap=64 在实际项目中是一个比较稳的默认值:512 瓦片配 64 重叠,约 14% 的像素会重复出现在相邻瓦片里,这给模型提供了跨边界的上下文。
如果原影像波段数多于 3(比如含近红外),裁剪脚本会自动把所有波段都写进瓦片。但训练时通常只取 RGB 三个波段,多波段场景我一般先做波段选择再裁剪,避免把数据量白白放大。
3.2 训练脚本与关键参数:以 SegFormer b0 为例
SegFormer 是遥感分割里目前用得最多的模型之一,在中低分辨率的多类别场景下对边界保持比 U-Net 好,且预训练权重成熟,拿来训练 7 类数据集很省事。训练脚本分两部分:Dataset 类和训练循环。
Dataset 类负责把瓦片和 mask 读进来、做数据增强、转 Tensor:
# dataset.py import os import numpy as np from PIL import Image import torch from torch.utils.data import Dataset import albumentations as A class RemoteSegDataset(Dataset): def __init__(self, img_dir, mask_dir, split_file, img_size=512, train=True): self.img_dir = img_dir self.mask_dir = mask_dir self.img_size = img_size self.train = train with open(split_file, "r", encoding="utf-8") as f: self.samples = [line.strip() for line in f if line.strip()] # 训练增强:随机裁剪+翻转+亮度对比,增强方向不变性 self.train_transform = A.Compose([ A.RandomResizedCrop(img_size, img_size, scale=(0.5, 1.0), p=1.0), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.3), ]) self.val_transform = A.Compose([ A.CenterCrop(img_size, img_size), ]) def __len__(self): return len(self.samples) def __getitem__(self, idx): name = self.samples[idx] img = np.array(Image.open(os.path.join(self.img_dir, name + ".tif")).convert("RGB")) mask = np.array(Image.open(os.path.join(self.mask_dir, name + ".png")), dtype=np.int64) mask = np.clip(mask, 0, 6) # 防止标签里偶然出现255等异常值 if self.train: augmented = self.train_transform(image=img, mask=mask) else: augmented = self.val_transform(image=img, mask=mask) img_t = torch.from_numpy(augmented["image"].transpose(2, 0, 1)).float() / 255.0 mask_t = torch.from_numpy(augmented["mask"]).long() return img_t, mask_t这个 Dataset 里值得注意的参数有三个:RandomResizedCrop的 scale 范围设成 (0.5, 1.0),这意味着每张图会随机裁取原图 50% 到 100% 的区域并缩放到 512×512,等于白送一层尺度增强,对山区、农田这种尺度变化大的地物非常有效;np.clip(mask, 0, 6)是为了拦截标签里可能出现的脏像素值,防止训练时出现类别索引越界;convert("RGB")把多波段 TIF 前三个波段取出来,忽略近红外。
训练循环用 Hugging Face Transformers 的 SegFormer 接口:
# train_segformer_remote.py import torch from torch import nn from torch.utils.data import DataLoader from transformers import SegformerForSemanticSegmentation from dataset import RemoteSegDataset NUM_CLASSES = 7 LR = 6e-5 EPOCHS = 60 BATCH_SIZE = 8 DEVICE = "cuda" if torch.cuda.is_available() else "cpu" model = SegformerForSemanticSegmentation.from_pretrained( "nvidia/mit-b0", num_labels=NUM_CLASSES, ignore_mismatched_sizes=True, ) model.to(DEVICE) train_ds = RemoteSegDataset( "dataset/images/train", "dataset/masks/train", "splits/train.txt", train=True, ) val_ds = RemoteSegDataset( "dataset/images/val", "dataset/masks/val", "splits/val.txt", train=False, ) optimizer = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=0.01) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS) criterion = nn.CrossEntropyLoss(ignore_index=255) for epoch in range(EPOCHS): model.train() total_loss = 0.0 for imgs, masks in DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True): imgs, masks = imgs.to(DEVICE), masks.to(DEVICE) outputs = model(pixel_values=imgs) loss = criterion(outputs.logits, masks) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() scheduler.step() avg_loss = total_loss / len(train_ds) print(f"epoch {epoch+1}/{EPOCHS} loss {avg_loss:.4f}") torch.save(model.state_dict(), f"checkpoints/segformer_b0_ep{epoch+1}.pth")训练脚本里四个关键配置直接影响收敛质量。ignore_mismatched_sizes=True必须打开,否则预训练分类头是 1000 类、换了 7 类后会报尺寸不匹配。ignore_index=255配合第 5 章要讲的“云影区域标成 255”策略,让模型完全忽略这些区域的梯度。学习率 6e-5 对 SegFormer b0 是一个实测稳妥的起点,大于 1e-4 通常会在前 5 轮出现 loss 震荡。CosineAnnealingLR在遥感分割里几乎必用,它能让学习率在后半段平滑下降,避免 loss 在收敛点附近来回弹。
3.3 mIoU、Kappa 与逐类 IoU:遥感分割指标怎么读
训练完只看 loss 没意义,遥感分割的验收指标是逐类 IoU 和 mIoU,Kappa 系数作为辅助参考。评估代码按混淆矩阵计算,这样既能得到总体指标,又能定位到具体哪个类别拖后腿:
# evaluate_remote_seg.py import numpy as np import torch from torch.utils.data import DataLoader from tqdm import tqdm def compute_metrics(hist): eps = 1e-6 iou_per_class = np.diag(hist) / ( hist.sum(axis=1) + hist.sum(axis=0) - np.diag(hist) + eps ) miou = np.nanmean(iou_per_class) total = hist.sum() p0 = np.diag(hist).sum() / total pe = (hist.sum(axis=0) * hist.sum(axis=1)).sum() / (total * total) kappa = (p0 - pe) / (1 - pe + eps) return miou, iou_per_class, kappa def evaluate(model, val_loader, num_classes=7): model.eval() hist = np.zeros((num_classes, num_classes), dtype=np.int64) with torch.no_grad(): for imgs, masks in tqdm(val_loader): imgs = imgs.to(DEVICE) logits = model(pixel_values=imgs).logits preds = logits.argmax(dim=1).cpu().numpy() masks = masks.numpy() for p, m in zip(preds, masks): valid = m != 255 p = p[valid] m = m[valid] hist += np.bincount( m * num_classes + p, minlength=num_classes * num_classes, ).reshape(num_classes, num_classes) miou, ious, kappa = compute_metrics(hist) class_names = ["background", "building", "mountain", "vegetation", "water", "road", "farmland"] for i, name in enumerate(class_names): print(f"{name:12s} IoU = {ious[i]:.4f}") print(f"mIoU = {miou:.4f}, Kappa = {kappa:.4f}")代码核心是混淆矩阵的累加方式:m * num_classes + p把“真实类别—预测类别”组合编码成一维索引,bincount统计后 reshape 成 7×7 矩阵,这一招比双重循环快一个量级。valid = m != 255是配合 ignore_index 策略,把云影区域的像素从评估里剔除,否则这些纯噪声像素会同时压低真阳率和假阳率,让 mIoU 失真。
读指标时有个常见误区:mIoU 总体高不代表模型可用。比如山地和植被占了大头,这两个类 IoU 高能把 mIoU 拉到 0.85,但建筑 IoU 可能只有 0.35。所以在遥感项目里一定要打印逐类 IoU,哪一类低于 0.5 就回到 5.2 的去处理类别不平衡。Kappa 对类别不平衡更敏感,如果 Kappa 比 mIoU 低 0.1 以上,说明多数类虚高、少数类崩盘,这时候加权损失要马上跟上。
4. 从原始遥感影像到 7 类标签文件:数据制作的全流程
4.1 影像预处理:分辨率统一、波段合成与投影对齐
数据集的影像来源五花八门,有公开遥感影像、无人机正射影像、还有从地图服务里抓的静态图。预处理不做齐,后面每走一步都是坑。
第一件事是统一分辨率。0.5 米分辨率的无人机影像和 10 米分辨率的卫星影像混在一起,同是“建筑”,前者边缘轮廓清晰锐利,后者糊成一片。模型会学到“清晰的是建筑,模糊的是山地”这种完全错误的隐含关联。常见的处理方式是把所有影像重采样到同一个 GSD(地面采样距离),比如统一到 1 米或 2 米,语义分割对分辨率没有检测那么敏感,但 5 倍以上的分辨率差一定出问题。
第二件事是波段合成。多数开源模型吃 RGB 三通道,遥感多光谱影像通常选 R、G、B 三个波段直接合成 RGB;如果后续要解决水体与阴影的混淆,可以把近红外波段作为额外通道,让模型在 4 通道输入下学习“水体在近红外强吸收”的特性。但这要求模型第一层接受 4 通道输入,SegFormer 预训练权重是 3 通道,改通道等于放弃预训练,常见的做法仍是训练 RGB 三通道,把 NIR 留给专门训练的模型。
第三件事是投影与坐标对齐。不同来源的影像如果不在同一投影坐标系下,做标签栅格化时点位会整体偏移。我的习惯是全部统一到目标区域的 UTM 分区投影,用 GDAL 的gdal.Warp批量重投影到同一坐标系和分辨率,然后把投影信息写进瓦片。这一步不做,第 5 章要讲的标签错位问题会以最隐蔽的方式出现。
4.2 标注工具选型与像素级质检流程
7 类语义分割数据集的标注工作量取决于影像面积。几十张小图用 Labelme 逐张勾多边形可以胜任;上千张瓦片就必须用支持多人协作的标注工具,否则时间成本直接失控。常见做法是 QGIS 手工勾绘配合 CVAT 或 Supervisely 做半自动标注:先用分割模型预测一版粗结果,标注员在粗结果上修正边界,效率比从零画多边形高 3 倍以上。
标注规则里最容易出问题的是类别判定歧义。下表是一个可以直接抄走的标注判定表:
| 类别 | 判定要点 | 最容易混淆的邻类 |
|---|---|---|
| 建筑 | 有屋顶的构筑物,含厂房屋顶 | 道路、温室大棚 |
| 山地 | 自然抬升、植被覆盖度低于 40% | 裸地、植被 |
| 植被 | 乔木、灌木、草地等自然绿色覆盖 | 农田、山地 |
| 水体 | 河流、湖泊、水库、养殖塘 | 阴影、低反射屋顶 |
| 道路 | 硬化路面,含街道、公路 | 建筑墙脚、广场 |
| 农田 | 耕地、大棚作物区、果园 | 植被、裸地 |
| 背景 | 广场、裸地、云影、无法归类的区域 | 山地、道路 |
质检流程必须独立于标注流程。我一般抽 10% 的瓦片做人工复查,把预测 mask 以半透明色块叠加在原图上,放大到 200% 检查边界。边界容差允许 1~2 像素,如果同一批数据平均边界误差超过 3 像素,整批退回重标。另一个容易被忽略的步骤是“交叉标定”:让第二个标注员独立标注 5% 的影像,算一次两人标注结果间的平均 IoU,低于 0.9 说明标注规则本身有歧义,先修改规则再继续批量标注。
4.3 类别不平衡与数据增强:哪些手段真的有效
遥感 7 类分割里,山地和植被常常占了总像素的 60% 以上,建筑、道路加一起可能不到 10%。类别不平衡不解决,模型会倾向于把所有像素预测成植被和山地,mIoU 虚高但建筑类完全不可用。
数据增强方面,对遥感分割真正有效的是随机翻转、90° 旋转和随机裁剪缩放。遥感影像没有“上下”概念,水平垂直翻转和旋转 90° 不损失语义,这是比其他视觉任务更强的增强自由度。亮度对比度和高斯噪声只建议加一个,加多了会让模型在真实推理时的色调偏移上失控。CutMix 或 Mosaic 这类混合增强在遥感多类别分割里效果不稳定,容易把被剪切地物的边角打乱,常见做法是用类别加权损失代替这类激进增强。
类别不平衡的根治手段有两个。第一个是少数类过采样,训练时对包含建筑、道路像素的瓦片提高采样概率,比如统计每个训练瓦片的类别直方图,含有小类的瓦片重复进 batch。第二个是类别加权损失,把 CrossEntropy 的权重设成1 / sqrt(freq),频率越低权重越高,简单有效。这两个手段要在训练脚本里同时用,单靠一个撑不住建筑这类极少数类别。
5. 遥感语义分割常见问题排查:7 类数据里的 5 个翻车现场
5.1 训练 loss 不降:标签文件读进来为什么全是背景
现象:训练几个 epoch,loss 一直在高位徘徊不下降,验证时所有预测结果都是背景类。
原因:最常见的是标签读取环节出了黑匣子错误。PNG 掩码如果是 16 位单波段 TIF,用 PIL 的Image.open读进来默认当成 8 位处理,高 8 位全丢,像素值全部变成 0;或者 GeoJSON 栅格化时窗口没对齐,生成的 mask 整片是背景。
解决:在 Dataset 的__getitem__里加一条校验断言,统计 mask 的类别直方图,如果逐类像素数与预期不符直接报错。我习惯在每个 epoch 结束时打印一次训练集的类别分布表,能立刻发现标签文件是否被错误读取。
5.2 建筑 IoU 不到 0.5:类别不平衡把梯度全吃掉了
现象:mIoU 有 0.8,但逐类 IoU 打印出来,建筑只有 0.3,道路 0.4,山地植被都超过 0.9。
原因:建筑和道路在整幅影像里占比太小,交叉熵损失把绝大多数梯度贡献给了占有像素最多的植被和山地,模型学不到小类的区分特征。
解决:先按 4.3 做类别加权损失和少数类过采样,再来看指标。如果加了权重后建筑仍然上不去,就要检查标注本身——建筑屋顶颜色和道路材质在可见光下本来接近,如果标注边界又粗糙,模型学到的特征会被噪声淹没。调试时单独取一批建筑密集的瓦片做可视化,看预测边界是偏大还是偏碎。
5.3 验证集很好、新影像全错:同图泄漏与时相差异
现象:训练和验证集上的 mIoU 都很高,但把模型部署到另一块区域、另一时相的影像上,精度断崖式下跌。
原因:遥感影像存在强烈的空间自相关,训练和验证如果来自同一景影像,模型其实在记忆“这片区域的纹理”,而不是在学“建筑长什么样”。另一种原因是时相差异——训练集全是夏季影像,推理时给的是冬季影像,植被、农田的外观差异会骗过模型。
解决:划分 train/val 时按“景”切而不是按像素切,一景影像要么全在训练、要么全在验证,保证验证集与训练集没有任何空间重叠。数据集要尽量覆盖 3 个以上不同时相、不同区域的影像,让模型学到跨场景的泛化能力,而不是记住某一块地的纹理。
5.4 预测边界整体偏移:投影不一致导致标签错位
现象:模型整体形状预测得不错,但所有目标物的轮廓像被推了一格,建筑边缘整体偏移十几个像素。这个偏移在不同瓦片上方向一致,而且大小固定。
原因:影像瓦片和 mask 栅格化的投影基准不一致。比如影像在 Web Mercator 投影下载,GeoJSON 用 UTM 坐标栅格化,栅格化后 mask 与影像的像素网格没有对齐,整个标签图发生了平移。
解决:在栅格化代码里严格使用与影像相同的地理变换参数。核对方法是取三到五个地面控制点——道路交叉口、建筑物角点——在影像和 mask 上对比像素坐标,误差超过 2 个像素就要回查投影和重采样参数。这个问题在训练代码里几乎无法发现,因为指标看起来只是整体偏低,不会提示错位。
5.5 阴影把水体带偏:深色像素的类别歧义怎么解
现象:预测图在山区背光面出现大片水体,实际上那是山体阴影;反之,部分深色水体被预测成植被或阴影。
原因:RGB 影像里阴影和水体在灰度上高度相似,模型学到的是“深色像素=水体”的简单规则。类别定义里没有“阴影”这个类,所有深色像素被迫塞进水体和背景里,模型只能在这两个类之间做没有意义的区分。
解决:两种路径。第一种是在标注阶段把山体阴影单独标记为忽略区,mask 像素值设为 255,训练时ignore_index=255,让模型不再被迫学阴影的归属。第二种是引入近红外波段,水体在近红外波段吸收强烈而阴影区反射较强,这个物理特征能在颜色之外把两者分开,但需要模型支持多通道输入。对大多数 RGB 训练场景,第一种方案是性价比最高的。
6. 把 7 类遥感分割模型做到可交付:可视化、矢量化与选型边界
6.1 分割结果叠加原图:用典型难例替代指标作为验收标准
指标只是数字,真正交付前要看图。我的习惯是每次训练完固定跑一组“硬样本”——建筑密集区、山地阴影区、水体边界、窄道路各选十张,把预测 mask 叠加在原图上存档,训练前后对比。下面是叠加可视化代码:
# visualize_overlay.py import cv2 import numpy as np def overlay_mask(img, pred, alpha=0.5): color_map = np.array([ [0, 0, 0], [255, 0, 0], [0, 180, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [128, 64, 0], ], dtype=np.uint8) colored = color_map[pred] return cv2.addWeighted(img, 1 - alpha, colored, alpha, 0)看可视化时重点看三处:建筑物墙角是否被抹圆、窄道路是否有断点、山地植被过渡带是否出现椒盐噪声。这三类问题 mIoU 指标反映不出来,但 GIS 交付时一眼就能看到。生态遥感指数计算这类下游任务,也依赖分割结果里植被、水体的边界是完整连通的,而不是破碎的。
6.2 mask 转矢量:连通域过滤与多边形抽稀参数
GIS 交付通常需要把掩码转成矢量面。直接调用cv2.findContours会产出大量碎多边形,必须做两个后处理:连通域面积过滤和多边形抽稀。
# mask2vector.py import cv2 import numpy as np def mask_to_polygons(label_mask, class_id, min_area=50, epsilon_factor=0.0005): binary = (label_mask == class_id).astype(np.uint8) contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) polygons = [] for cnt in contours: area = cv2.contourArea(cnt) if area < min_area: continue # 过滤碎块,min_area按分辨率折算 epsilon = epsilon_factor * cv2.arcLength(cnt, True) approx = cv2.approxPolyDP(cnt, epsilon, True) polygons.append(approx.squeeze().tolist()) return polygonsmin_area=50在 0.5 米分辨率下大约是 12.5 平方米,对应一个小杂物间,低于这个面积的连通域基本是预测噪声。epsilon_factor=0.0005控制抽稀强度,值越大轮廓越平滑但细小凸起丢失越多,0.0005 是保留建筑直角特征的安全值。这两个参数必须按实际分辨率和交付精度要求重新折算,不能照搬。
6.3 语义分割与实例分割的选型边界
热搜里经常看到 yolo26 中实例分割与语义分割的区别这种讨论,在遥感项目里这两条路线要早做决定。本文这套 7 类方案是语义分割,每个像素一个类别标签,适合土地利用分类、面积统计、生态遥感指数计算这类“算面积、看分布”的任务。如果需求是“把每栋建筑单独框出来、数清楚有多少栋”,语义分割给不了独立目标编号,必须走实例分割路线,比如 Mask R-CNN 或 YOLOv8-seg 这类模型。遥感语义分割数据集和实例分割数据集在标签形态上就是两类东西——一个是像素级掩码,一个是逐目标的独立掩码和框,做数据集之前先想清楚下游要什么,能省掉一整轮返工。
最后说一个这几年反复踩过的教训:数据集里最容易出问题的不是模型,而是标签文件与影像之间那层看不见的错位。每次跑完训练,第一件事是把预测结果叠加到原图上,用眼睛扫三分钟,比任何指标都管用。这个检查习惯帮我挡掉过很多次边界偏移、类别混淆和过拟合假象。希望帮到你。
本文还有配套的精品资源,点击获取