从零构建高分二号遥感数据集:预处理、标注与PyTorch实战

发布时间:2026/8/24 4:45:08
从零构建高分二号遥感数据集:预处理、标注与PyTorch实战 1. 项目缘起为什么从零开始制作遥感数据集在遥感图像处理领域无论是做土地覆盖分类、建筑物提取还是灾害监测一个绕不开的核心环节就是数据集。你可能经常看到各种论文和开源项目里大家用着现成的ISPRS Vaihingen、DeepGlobe或者LoveDA数据集模型跑得飞起精度一个比一个高。但当你真正想把自己的想法落地比如想用高分二号影像去识别家乡的农田变化或者监测某个工业园区的建设进度时往往会发现一个尴尬的现实没有现成的、完全贴合你需求的标注数据。这就是我决定动手从原始高分二号影像开始完整走一遍数据集制作流程的原因。市面上的公开数据集要么区域不匹配要么类别定义不符合你的业务需求比如公开数据集可能只分“植被”而你需要细分“水稻”、“玉米”、“林地”。依赖公开数据你的模型永远是在别人的场景里“打工”。而自己制作数据集虽然前期投入大但意味着你真正掌握了从数据源头到模型输出的完整链条模型的性能上限和业务贴合度完全由你自己定义。这个过程远不止是“用labelme画几个多边形”那么简单。它涉及到遥感影像的预处理、地理坐标的考量、标注规范的制定、类别不平衡的处理以及最终生成适配PyTorch或TensorFlow框架的数据格式。本文将基于国产高分二号GF-2卫星影像手把手带你走通这条从原始.tif文件到训练-ready数据集的完整路径其中会穿插大量我实际标注和训练过程中踩过的坑和总结的经验。2. 高分二号影像初探与预处理高分二号是我国高分辨率对地观测系统的重要成员它提供了0.8米全色和3.2米多光谱的融合数据非常适合进行精细化的地物识别。当你拿到原始数据通常是一个包含多个文件的文件夹比如GF2_PMS1_E116.5_N39.8_20230415_L1A0001000000这种命名格式。里面会有PAN.tif全色波段、MUL.tif多光谱波段通常含蓝、绿、红、近红外四个波段以及一堆元数据文件。2.1 数据预处理的核心辐射定标与大气校正很多新手会直接打开MUL.tif就用这是第一个大坑。原始卫星影像的像素值DN值受到传感器本身、太阳高度角、大气散射等多重影响不能直接反映地物的真实反射率。为了后续标注的一致性以及模型训练的稳定性我们必须进行预处理。辐射定标是将DN值转换为大气顶层反射率或辐射亮度值的过程。你需要从元数据文件通常是*.xml中找到定标系数。一个典型的操作是使用GDAL库或ENVI等专业软件。这里以Python的rasterio和numpy为例展示一个简化的思路import rasterio import numpy as np # 假设你已经从xml中提取了增益(gain)和偏移(bias) gain 0.001 # 示例值实际请查阅元数据 bias 0.0 # 示例值 with rasterio.open(MUL.tif) as src: dn_data src.read() # 读取所有波段 # 辐射定标公式: 反射率 DN * gain bias reflectance_data dn_data * gain bias # 更新元数据并写入新文件 profile src.profile profile.update(dtyperasterio.float32) with rasterio.open(MUL_reflectance.tif, w, **profile) as dst: dst.write(reflectance_data.astype(np.float32))大气校正则更进一步旨在消除大气分子和气溶胶的影响获得地表真实反射率。这对于跨时相分析或使用对反射率敏感的指数如NDVI至关重要。对于严肃的项目建议使用6S、FLAASHENVI内置或Sen2Cor针对哨兵但思路可借鉴等模型。如果项目对绝对反射率要求不高或者数据来自同一时相、天气条件相似有时可以省略此步但心里要清楚这可能会引入噪声。2.2 全色与多光谱图像融合Pan-sharpening高分二号的优势在于高空间分辨率。为了获得兼具高空间分辨率0.8米和多光谱信息3.2米的图像我们需要进行融合。常见的算法有Brovey变换、PCA主成分分析和Gram-SchmidtGS变换等。我个人的经验是GS变换在保持光谱信息方面通常表现更稳定。你可以使用gdal_pansharpen.pyGDAL工具或者rasterio结合scikit-image等库实现。这里的关键是融合后的图像将作为我们标注的底图其质量直接影响标注的准确性和难易度。务必检查融合结果是否有明显的色彩失真或空间扭曲。2.3 图像切片将大图变成可管理的“小图”一幅高分二号影像可能覆盖上百平方公里直接用于标注和训练是不现实的。我们需要将其切割成固定大小如512x512或1024x1024的小图块。这里有几个关键决策点切片大小512x512是常见选择兼顾GPU内存和上下文信息。如果你想检测大型地物如机场跑道可能需要更大的尺寸如1024x1024。重叠率为了防止地物在切图时被切断切片之间需要设置重叠如128像素。在后续制作数据集时重叠部分需要特殊处理如在训练时作为随机裁剪区域或在预测时使用滑动窗口并融合。背景剔除遥感影像边缘常有大量无效区域黑色填充。在切片时可以设置一个阈值如果切片内有效像素非零值比例过低如低于20%则丢弃该切片避免数据集充斥无意义的“背景”图块。切片代码示例如下from PIL import Image import numpy as np def slice_image(image_path, output_dir, tile_size512, overlap128): img Image.open(image_path) img_array np.array(img) height, width, _ img_array.shape stride tile_size - overlap count 0 for y in range(0, height - tile_size 1, stride): for x in range(0, width - tile_size 1, stride): tile img_array[y:ytile_size, x:xtile_size, :] # 简单背景剔除计算非纯黑像素比例 if np.mean(tile) 10: # 阈值可根据实际情况调整 tile_img Image.fromarray(tile) tile_img.save(f{output_dir}/tile_{count:04d}.png) # 同时需要记录切片的位置信息(x, y)用于后续可能的拼接 save_position_info(count, x, y) count 1 print(f共生成 {count} 个有效切片。)完成以上三步我们才得到了真正可以投入标注的“干净”图像。预处理阶段花的时间会在后续标注一致性和模型性能上加倍回报回来。3. 定义标注规范与使用LabelMe进行标注拿到预处理好的切片图像后下一步就是定义“我们要分什么类”以及“怎么标”。这是整个项目中最需要人工智慧与领域知识结合的环节。3.1 制定一份清晰的标注规范文档千万不要边标边想否则后期类别混乱、同物异标会让你痛不欲生。规范文档应包括类别列表与定义例如1: Building- 所有人造建筑物屋顶包括住宅、厂房、棚屋。注意建筑阴影属于Shadow类。2: Road- 铺装道路包括高速公路、城市道路、乡村硬化路。宽度大于3个像素约2.4米才标。标注几何类型语义分割通常使用多边形Polygon标注物体的轮廓。对于线状地物如细道路、田埂有时也会用线条Line但最终需要转换为具有宽度的掩膜这增加了复杂度建议初期统一用多边形。边界处理原则两个物体紧挨着如相邻的建筑物边界像素如何归属通常规定“左属右上属下”或按视觉主体划分必须统一。模糊区域处理施工中的地块、半毁坏的建筑、云层遮挡区域是标为“未知/忽略”还是强制归入某一类需要在文档中明确。我建议类别数不宜过多起步时5-8个核心类别为宜例如背景、建筑、道路、水体、植被、农田。过于精细的类别如不同树种不仅标注成本激增模型也难以学习。3.2 LabelMe的安装与高效标注技巧LabelMe是一个优秀的开源图像标注工具特别适合多边形标注。安装非常简单# 使用pip安装 pip install labelme # 或者使用conda conda install -c conda-forge labelme安装后在终端输入labelme即可打开图形界面。高效标注的核心技巧“滚轮流”标注法不要用鼠标一个个点。按住Ctrl键用鼠标滚轮可以快速放大缩小图像按住空格键用鼠标拖拽可以平移画布。这能极大提升标注边界时的精度和速度。多用快捷键Ctrl Z: 撤销上一个点。Esc: 取消当前多边形的绘制。Ctrl S: 保存当前标注。D: 下一张图像。A: 上一张图像。熟练掌握这些快捷键标注效率能提升50%以上。先轮廓后精修对于复杂物体先用较稀疏的点勾勒出大致轮廓双击完成多边形然后双击该多边形进入编辑模式在需要的地方添加点在边上单击拖动点进行微调。这比一开始就追求像素级精度要快得多。利用复制粘贴对于大量重复、形状相似的物体如整齐的农田垄、成排的树可以标好一个后使用Edit - Copy Shape和Paste Shape然后移动和微调能节省大量时间。标注完成后每张图片会生成一个同名的.json文件里面以JSON格式存储了所有多边形的顶点坐标和类别标签。LabelMe的标注界面直观友好但它的输出格式需要经过转换才能被大多数深度学习框架直接使用。4. 从LabelMe JSON到标准分割掩码这是将人工标注转化为机器可读数据的关键一步。LabelMe生成的.json文件不是图像我们需要将其转换为与原始图像尺寸相同的单通道掩码图Mask其中每个像素的值代表其类别ID如0为背景1为建筑2为道路...。4.1 理解LabelMe JSON结构一个典型的LabelMe JSON文件包含以下核心部分{ version: 5.1.1, flags: {}, shapes: [ { label: Building, points: [[x1, y1], [x2, y2], ...], // 多边形顶点坐标 group_id: null, shape_type: polygon, flags: {} }, // ... 更多多边形 ], imagePath: tile_0001.png, imageData: ... // 可选的Base64编码的图像数据 }我们的任务就是读取shapes列表根据每个多边形的label和points在空白画布上“绘制”出对应的区域。4.2 使用labelme2voc.py脚本进行批量转换LabelMe官方提供了一个实用的转换脚本labelme2voc.py。你可以通过以下方式使用# 假设你的图片和json文件都在 ./images 目录下 labelme2voc.py ./images ./dataset_output --labels labels.txt你需要提前创建一个labels.txt文件第一行固定是__ignore__第二行是_background_之后每行是你的类别名例如__ignore__ _background_ Building Road Water Vegetation Farmland运行后会在./dataset_output下生成SegmentationClass彩色掩码图和SegmentationClassVisualization可视化彩色图等文件夹。但是这个脚本生成的掩码是彩色的P模式PNG每个类别对应一种颜色。而大多数语义分割模型如U-Net, DeepLab需要的是单通道、像素值为类别索引的掩码图L模式。因此我们通常需要自己编写更灵活的转换脚本。4.3 自定义转换脚本生成单通道索引掩码下面是一个更实用的自定义转换脚本核心部分import json import os import numpy as np from PIL import Image, ImageDraw # 定义类别名到索引的映射0通常为背景 label_to_id { _background_: 0, Building: 1, Road: 2, Water: 3, Vegetation: 4, Farmland: 5 } def json_to_mask(json_path, img_width, img_height): 将单个LabelMe JSON文件转换为单通道索引掩码。 with open(json_path, r) as f: data json.load(f) # 创建一个全零背景的掩码图 mask np.zeros((img_height, img_width), dtypenp.uint8) for shape in data[shapes]: label shape[label] points shape[points] # 将浮点坐标转换为整数 polygon [(int(x), int(y)) for x, y in points] # 获取该标签对应的ID class_id label_to_id.get(label, 0) # 未定义的标签默认为背景 # 使用PIL绘制多边形填充 if len(polygon) 2: # 确保是多边形 img Image.new(L, (img_width, img_height), 0) draw ImageDraw.Draw(img) draw.polygon(polygon, outlineclass_id, fillclass_id) # 将绘制的结果叠加到mask上注意后标注的会覆盖先标注的 mask np.maximum(mask, np.array(img)) return mask # 批量处理 image_dir ./sliced_images json_dir ./labelme_annotations output_mask_dir ./masks os.makedirs(output_mask_dir, exist_okTrue) for json_name in os.listdir(json_dir): if json_name.endswith(.json): json_path os.path.join(json_dir, json_name) # 假设图片名与json名相同扩展名不同 img_name os.path.splitext(json_name)[0] .png img_path os.path.join(image_dir, img_name) # 获取图像尺寸 with Image.open(img_path) as img: width, height img.size mask_array json_to_mask(json_path, width, height) mask_img Image.fromarray(mask_array, modeL) output_path os.path.join(output_mask_dir, img_name) mask_img.save(output_path) print(fConverted: {json_name} - {img_name})这个脚本给了你完全的控制权。你可以轻松处理重叠标注后标覆盖先标或按特定优先级也可以处理一些特殊情况比如为某些类别添加“忽略”区域像素值设为255在训练时会被忽略。5. 构建PyTorch Dataset与处理类别不平衡现在我们有了配对的图像文件夹./images和掩码文件夹./masks。下一步就是将其包装成深度学习框架能读取的Dataset。5.1 创建自定义PyTorch Dataset类这是一个标准的做法它允许你轻松集成数据增强。import torch from torch.utils.data import Dataset, DataLoader from PIL import Image import torchvision.transforms as T import os import numpy as np class RemoteSensingDataset(Dataset): def __init__(self, image_dir, mask_dir, transformNone, image_size(512, 512)): self.image_dir image_dir self.mask_dir mask_dir self.transform transform self.image_size image_size # 获取所有图像文件名假设图像和掩码同名 self.image_names [f for f in os.listdir(image_dir) if f.endswith((.png, .jpg, .tif))] # 基础转换调整大小和转为Tensor self.to_tensor T.Compose([ T.Resize(image_size), # 统一尺寸 T.ToTensor(), ]) self.mask_to_tensor T.Compose([ T.Resize(image_size, interpolationImage.NEAREST), # 掩码用最近邻插值防止产生无效类别 T.ToTensor(), ]) def __len__(self): return len(self.image_names) def __getitem__(self, idx): img_name self.image_names[idx] img_path os.path.join(self.image_dir, img_name) mask_path os.path.join(self.mask_dir, img_name) # 假设同名 image Image.open(img_path).convert(RGB) mask Image.open(mask_path).convert(L) # 单通道灰度图 # 应用基础转换 image self.to_tensor(image) mask self.mask_to_tensor(mask).squeeze(0) # 去掉通道维变成[H, W] mask mask.long() # 确保是长整型用于计算损失 # 应用额外的增强如果提供 if self.transform: # 注意对图像和掩码应用相同的随机变换如翻转、旋转 seed torch.randint(0, 2**32, (1,)).item() torch.manual_seed(seed) image self.transform(image) torch.manual_seed(seed) mask self.transform(mask.unsqueeze(0)).squeeze(0) # 先加通道维增强后再去掉 return image, mask5.2 应对遥感场景中的类别不平衡类别不平衡是遥感语义分割的老大难问题。在一张城市影像中“建筑”和“道路”的像素可能占30%而“水体”可能只占1%。如果直接训练模型会倾向于忽略小类别。1. 损失函数加权最常用且有效在交叉熵损失中为每个类别赋予不同的权重。权重通常与类别频率成反比。def calculate_class_weights(mask_dir, num_classes): 计算每个类别的权重逆频率 class_pixel_counts torch.zeros(num_classes) total_pixels 0 for mask_file in os.listdir(mask_dir): mask_path os.path.join(mask_dir, mask_file) mask np.array(Image.open(mask_path)) for i in range(num_classes): class_pixel_counts[i] np.sum(mask i) total_pixels mask.size class_freq class_pixel_counts / total_pixels # 逆频率加权加平滑项避免除零 weights 1.0 / (class_freq 1e-6) # 归一化使权重之和等于类别数可选但常见 weights weights / weights.sum() * num_classes return weights # 假设有6个类别0-5 class_weights calculate_class_weights(./masks, num_classes6) criterion torch.nn.CrossEntropyLoss(weightclass_weights)2. 在线难例挖掘OHEM不是所有像素的难度都一样。OHEM的思路是在每张图中只对那些损失最大的像素即模型最难预测的像素进行梯度回传。这迫使模型去关注那些它目前还分不好的区域往往是小目标或边界区域。PyTorch中可以通过自定义损失函数或采样策略实现。3. 数据级方法过采样与欠采样过采样在训练时让包含稀有类别的图像有更高的概率被抽到。可以在DataLoader的sampler参数中实现一个加权随机采样器。欠采样随机丢弃一些只包含常见类别如大块植被的图像切片。但这可能会减少数据总量。我的经验是组合拳效果最好。使用加权损失函数作为基线如果小类别如“车辆”仍然表现很差可以尝试过采样那些包含该类别的小图或者在数据增强时针对性地对包含小类别的区域进行随机裁剪增加其在批次中的出现频率。6. 数据增强策略针对遥感图像的独特技巧数据增强是提升模型泛化能力、防止过拟合的利器。对于遥感图像除了通用的翻转、旋转、缩放外还有一些针对性的技巧。6.1 光谱增强与颜色抖动多光谱/高光谱影像的光谱信息至关重要。我们可以对不同的波段进行轻微的、独立的亮度、对比度调整来模拟不同光照条件、季节变化或传感器差异。import torchvision.transforms.functional as F import random class SpectralJitter: 对多波段图像进行逐波段或分组抖动 def __init__(self, brightness0.1, contrast0.1): self.brightness brightness self.contrast contrast def __call__(self, img_tensor): # img_tensor: [C, H, W] c, h, w img_tensor.shape for i in range(c): # 对每个波段独立应用亮度对比度变化 img_tensor[i] F.adjust_brightness(img_tensor[i], random.uniform(1-self.brightness, 1self.brightness)) img_tensor[i] F.adjust_contrast(img_tensor[i], random.uniform(1-self.contrast, 1self.contrast)) return img_tensor对于RGB真彩色合成图像则可以使用标准的ColorJitter。6.2 模拟云层与阴影遮挡遥感影像常受云和阴影干扰。我们可以随机在图像上叠加一些半透明的灰色或白色多边形来模拟云层遮挡叠加深色多边形模拟阴影。这能极大地提升模型在非理想条件下的鲁棒性。def add_cloud_shadow(img_tensor, max_num_patches3): 随机添加模拟云或阴影的遮挡块 c, h, w img_tensor.shape for _ in range(random.randint(1, max_num_patches)): # 随机生成一个椭圆或多边形区域 top random.randint(0, h//2) left random.randint(0, w//2) bottom top random.randint(h//6, h//3) right left random.randint(w//6, w//3) # 随机决定是云亮还是阴影暗 if random.random() 0.5: # 云叠加白色透明度随机 cloud torch.ones((c, bottom-top, right-left)) * random.uniform(0.7, 1.0) alpha random.uniform(0.3, 0.7) # 透明度 else: # 阴影叠加黑色透明度随机 cloud torch.zeros((c, bottom-top, right-left)) alpha random.uniform(0.4, 0.8) # 将遮挡块混合到原图 img_tensor[:, top:bottom, left:right] \ (1 - alpha) * img_tensor[:, top:bottom, left:right] alpha * cloud return img_tensor6.3 几何增强的注意事项翻转和旋转在遥感中是安全的因为地物没有绝对的“上下”之分。但需要注意大角度旋转旋转后图像角落会出现黑色填充。一种策略是使用“反射”填充模式但更常见的做法是在旋转的同时进行随机裁剪只保留中心有效区域。尺度缩放过度的缩小可能会让细小地物如电线杆在像素层面消失放大则可能引入模糊。建议缩放范围控制在[0.75, 1.25]之间。一个综合的数据增强管道可以这样定义from torchvision import transforms train_transform transforms.Compose([ transforms.RandomHorizontalFlip(p0.5), transforms.RandomVerticalFlip(p0.5), transforms.RandomRotation(degrees30, fill0), # 用0黑色填充旋转边缘 transforms.RandomResizedCrop(size(512, 512), scale(0.75, 1.25)), # 结合缩放和裁剪 # 自定义增强 SpectralJitter(brightness0.05, contrast0.05), # 注意ToTensor()和Normalize()通常在Dataset基类里做这里不需要 ])将这个transform传入之前定义的RemoteSensingDataset即可。7. 数据集划分、组织与最终检查在投入训练前最后一步是合理地组织你的数据。7.1 训练集、验证集、测试集划分千万不要用所有数据训练然后挑几张图看看效果就完事。必须严格分离。训练集用于模型参数更新。验证集用于在训练过程中监控模型表现调整超参数如学习率以及进行早停Early Stopping。验证集上的表现是选择最终模型的关键依据。测试集在模型训练和调参完全结束后用于最终、客观地评估模型的泛化能力。测试集在训练过程中绝对不能被使用或看到。划分比例通常为70%训练15%验证15%测试。如果数据量很大验证和测试的比例可以更小。关键原则是确保验证集和测试集在空间分布上具有代表性。不要简单随机划分因为相邻的切片在空间和内容上高度相关会导致数据泄露。应该按大区域块来划分。例如将整景影像的左上区域作为训练集中间区域作为验证集右下区域作为测试集。7.2 最终目录结构一个清晰的结构有助于项目管理。我推荐的目录树如下GF2_Segmentation_Dataset/ ├── images/ # 所有原始切片图像 │ ├── tile_0001.png │ ├── tile_0002.png │ └── ... ├── masks/ # 所有单通道索引掩码 │ ├── tile_0001.png │ ├── tile_0002.png │ └── ... ├── splits/ # 划分文件列表 │ ├── train.txt # 每行一个图像文件名不含路径 │ ├── val.txt │ └── test.txt ├── labelme_jsons/ # 原始的LabelMe标注文件备份 │ └── ... ├── class_names.txt # 类别名列表与索引对应 └── README.md # 数据集说明文档class_names.txt内容示例background Building Road Water Vegetation Farmland7.3 质量检查清单在开始训练前请务必进行以下检查图像-掩码对齐随机抽查几对图像和掩码用可视化工具叠加查看确保标注轮廓与图像地物完全吻合没有偏移。类别索引正确性打开几张掩码图用图像查看器检查其像素值是否严格在[0, num_classes-1]的整数范围内。可以使用np.unique(mask)来查看。类别平衡性分析计算并打印每个类别在训练集中的像素占比。如果某个类别占比低于1%你需要认真考虑第5节提到的类别不平衡策略。数据增强可视化对同一张图像和掩码应用你的增强管道多次观察增强后的效果是否合理掩码是否随图像同步变换是否有不合理的畸变。Dataset加载测试创建一个DataLoader迭代一个批次检查返回的图像和掩码张量的形状[B, C, H, W]和[B, H, W]、数据类型float32和int64以及值范围图像是否已归一化到[0,1]或[-1,1]掩码是否是正确的整数标签。完成所有这些步骤你就拥有了一个为自己任务量身定制的、高质量的遥感图像语义分割数据集。这个过程虽然繁琐但当你看到模型在你的数据上准确识别出你关心的地物时那种成就感是使用现成数据集无法比拟的。更重要的是你掌握了从数据源头解决问题的核心能力这对于应对未来更复杂、更专业的遥感应用场景是无比宝贵的经验。