ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于PyTorch的高分遥感语义分割工程实践与避坑指南

基于PyTorch的高分遥感语义分割工程实践与避坑指南 简介面向遥感图像处理与深度学习交叉领域这套用PyTorch实现的高分遥感语义分割项目聚焦地物分类任务适合有一定卷积神经网络基础、希望上手遥感影像分割的开发者与研究者。工程内共858个文件主体为819张PNG图片用于训练样本、结果可视化与前后对比另有35个Python源码文件覆盖模型搭建、训练推理与后处理流程并配有CSV数据清单和Markdown说明文档压缩包约548MB。项目实现了膨胀预测、后处理优化以及基于伪标签的半监督方法同时集成tensorboardX实时可视化整个训练过程便于观察模型收敛与分割效果。已有3041人学习下载完整工程可直接修改数据路径运行也可作为课程设计或算法复现的起点帮助理解遥感地物分类从数据准备到结果输出的全链路代码结构清晰方便按需调整。1. 高分遥感语义分割不是普通分割这套 PyTorch 工程到底要解决什么拿到一张高分二号 GF2_PMS2 的米级分辨率影像你不会想用整图直接喂进语义分割网络——几千乘几千像素、建筑、田块、水体交织在一起类别又碎又不均衡直接训练轻则显存爆炸重则模型学成一片色块。这套人工智能项目实践解决的就是这件事用 PyTorch 把高分遥感影像切成瓦片、训练语义分割模型完成地物分类并把膨胀预测、后处理、半监督伪标签、tensorboardX 可视化这些真实工程才会拆开讲的环节串成一条从数据到成图的完整链路。资源里自带样本瓦片、类别表和预测输出图适合手里有遥感数据、想快速复现一套地物分类流程的人也适合正在做语义分割大作业、毕业设计、需要一套可运行代码打底的同学。2. 把 GF2 瓦片和类别表对齐数据准备阶段的三个关键动作2.1 读懂 classes CSV它是整条链路的调色板这套工程里所有可视化输出包括 predict.png、blend.png、img_gt_pre.png颜色都来自同一个源头——和 GF2 影像同名的那个 CSV 文件。文件名里的 E116.0_N39.1 是影像中心经纬度20170302 是拍摄日期MSS2 指多光谱传感器 2 号。CSV 每一行对应一个地物类别通常包含类别名、类别 id、显示用 RGB 三列classes_sample.jpg 就是这张表的可视化图例。训练开始前先把 CSV 和 sample 图并排对照一遍确认颜色映射没对错位这一步能省掉后面大量排查时间。下面给了这类的典型表结构字段含义示例class_name类别名称building / farmland / waterclass_id训练用的整数标签0,1,2,…r,g,b标注图和输出图的显示色255,0,0读表建映射的代码我一般这样写import pandas as pd import numpy as np # 项目里的 CSV每行一个地物类别 df pd.read_csv(GF2_PMS2_E116.0_N39.1_20170302_L1A0002214760-MSS2.csv, encodingutf-8-sig) color2idx {} for row in df.itertuples(indexFalse): color2idx[(int(row.r), int(row.g), int(row.b))] int(row.class_id) def rgb2mask(rgb_img, color2idx): 把 RGB 标注图转成单通道类别索引图HxWx3 - HxW h, w rgb_img.shape[:2] mask np.zeros((h, w), dtypenp.uint8) for color, idx in color2idx.items(): mask[(rgb_img np.array(color)).all(axis-1)] idx return mask逻辑不复杂逐像素比对 RGB 值命中哪个类别的颜色就写哪个 id。三个参数值得注意encoding 用 utf-8-sig 是为了兼容 Windows 导出的带 BOM 的 CSVnp.uint8 足够覆盖绝大多数地物分类类别数如果 CSV 里有 ignore、unlabeled、void 这类占位类class_id 统一设为 255后面 loss 用 ignore_index255 跳过别把它当正常类参与训练。还有一条隐蔽坑cv2.imread 读出来是 BGRPIL 是 RGBCSV 里颜色按 RGB 定义用 cv2 读图又不转码整个颜色映射会错位blend 图里红的当绿的排查起来非常折磨。2.2 切片策略训练切片和预测切片不是一回事高分影像不能整图进网络切片绕不开。项目里 380、381、142、382 这类编号的瓦片就是把大场景切出来的局部。切片尺寸怎么定我一般看两个东西一是显存512×512 输入在 8G12G 显存的卡上跑 UNet 类网络比较稳二是地物尺度GF2 多光谱原始分辨率 4 米融合重采样后常在 1 米上下房子、田块在 512 像素窗口里能占到足够样本窗口再小到 256大块农田和连续水体就学成碎渣。训练切片的 overlap 不必大3264 像素足够目的是轻微扩样本量。真正影响成图质量的是预测阶段那部分 overlap 放到第 4 章细说。这里有一条必须记住的原则带 overlap 的切片不能随机分 train/val。同一个地理位置重叠出来的切片长相高度相似分到两边等于数据泄漏验证 IoU 虚高得离谱模型一换场景就露馅。正确做法是按所属原始场景、按瓦片号划分保证训练集和验证集没有地理重叠。数据增强方面左右翻转、上下翻转、90 度旋转这类几何增强对遥感安全且有效颜色抖动要克制。地物分类很大程度靠光谱响应区分把亮度、对比度、饱和度拉得太狠等于让模型别信颜色那还分什么地物。RGB 影像上我只保留很轻微的亮度扰动多光谱场景干脆不加颜色类增强。2.3 类别均衡先 bincount 再看 loss地物分类天然的毛病是类别不平衡一片影像里农田、裸地可能占六成面积建筑、水体只占零星几个点。第 3 章会提加权 CE但权重必须回到数据本身来算。预处理阶段我会对全部训练标签做一次像素统计classes np.arange(len(df)) counts np.bincount(mask_train.ravel(), minlengthlen(classes)) freq counts / counts.sum() class_weight 1.0 / np.log(1.02 freq) # 对数放缩防止小类权重爆炸class_weight 直接传给 CrossEntropyLoss 的 weight 参数。这里用 1/log(1.02freq) 而不是简单逆频率是为了防止占比 0.1% 的类别权重被放得过大训练初期把 loss 带偏。顺便说一句如果统计结果里某个类别在所有切片中占比不到 1%先怀疑标注漏了而不是急着调 loss——这是数据问题不是模型问题。3. 训练管线落地网络结构、loss 设计与 tensorboardX 监控3.1 为什么这类工程普遍选编码器-解码器结构语义分割落到高分遥感网络选型有个现实约束地物小、边界细下采样不能太狠。FCN 一路下采样到 1/32 再上采样对 VOC 那种大物体合适对遥感里几米宽的道路、小建筑就太粗边界早就磨没了。所以这套项目以及大部分同类工程走的都是编码器-解码器加跳连的路线UNet、UNet、DeepLabV3 是出现频率最高的三兄弟。跳连的意义在于把编码器高分辨率的浅层特征直接送到解码器浅层特征保边界深层特征保语义两边一拼边界糊的问题缓解一大半。backbone 我用 ResNet18 或 ResNet34 起步遥感地物分类类别少不需要 ImageNet 那套 50 层往上的深度。编码器输出分辨率控制在原图 1/8 左右就够了再往下采几米宽的道路在特征图里就剩一两个像素真就是神仙也难救。3.2 训练循环的核心写法与参数含义训练入口长什么样核心循环节选如下import torch from torch import nn from tensorboardX import SummaryWriter model UNet(in_channels3, num_classesNUM_CLASSES) criterion nn.CrossEntropyLoss(ignore_index255, weightclass_weight) optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max80) writer SummaryWriter(logdirruns/gf2_exp) for epoch in range(EPOCHS): model.train() for imgs, masks in train_loader: imgs, masks imgs.cuda(), masks.cuda() logits model(imgs) loss criterion(logits, masks) optimizer.zero_grad() loss.backward() optimizer.step() writer.add_scalar(loss/train, loss.item(), global_step) if epoch % 5 0: model.eval() writer.add_image(val/img, tensor_to_rgb(img0), epoch, dataformatsHWC) writer.add_image(val/pred, pred_to_pseudo_color(pred0), epoch, dataformatsHWC) writer.close()几个参数按经验说lr 从 1e-4 起别一上来就 1e-3高分遥感样本量通常不大大学习率前期 loss 必震荡。AdamW 比 Adam 好使weight_decay 给 1e-4 量级正则太重会把细小地物磨平。ignore_index255 配合第 2 章的 mask 约定边界留白和不感兴趣的类都放 255。CosineAnnealing 在 80 epoch 内把学习率降到接近 0比固定 lr 的分割结果稳定这是反复对比过的不是玄学。3.3 tensorboardX 的监控不止是曲线项目单独把 tensorboardX 拎出来说明作者吃过没可视化的亏。我的用法是三条loss 曲线必看但不止看它每 5 个 epoch 把验证集的输入图、argmax 后的预测伪彩色图、真值三张并排 add_image 上去。只看曲线最容易出现的假象是 loss 在降、预测图却是一片同色——类别不平衡时大类把 loss 压住了曲线很漂亮小类实际全军覆没。预测伪彩色图能第一时间暴露这个问题。add_image 有个容易翻车的点tensorboardX 默认把输入当 CHW值域要归一到 [0,1]或者给 dataformatsHWC 且数值范围 0255。很多人图不显示、显示成马赛克就是值域没归一化、通道顺序又错。多进程 dataloader 训练时只在主进程创建 writer 并写事件否则多个进程抢同一个事件文件曲线会跳来跳去。4. 滑窗预测、膨胀预测与后处理把概率图变成干净的分类图4.1 滑窗预测的基本参数tile 与 stride训练切片用 512预测时一般也保持 512步长 stride 要小于 tile让相邻窗口有重叠。overlap 的作用是让同一位置被多个窗口各预测一次最后取平均重叠区域越大接缝越不明显代价是计算量线性上涨。通常取 stride tile/2也就是 50% overlap效果和开销最平衡显存不够再退到 stride tile*3/4。还有和训练切片相反的细节预测 overlap 不做随机裁剪位置固定每个窗口的预测结果按原坐标累加到一个整景等大的概率图上同时维护一个 count 图记录每个像素被累加了几次最后概率除以 count 得到平均。这套累加逻辑是后面膨胀预测的地基。4.2 膨胀预测让每个输出像素都有完整上下文滑窗预测有个隐藏问题窗口边缘的像素因为 padding 补零和感受野被截断预测质量明显比窗口中心差。直接拼接这些差像素就凑成了接缝。膨胀预测就是针对这个问题设计的预测时取比输出块大的输入块网络输出只保留中间与目标位置对应的区域。比如要输出 512×512就取 640×640 的输入在更大的输入块上预测只把中心 512 当有效结果。输入比输出膨胀了一圈所以叫膨胀预测。import torch import numpy as np def infer_with_dilation(model, img, tile_out512, expand64, stride256): 对整景影像做滑窗膨胀预测返回平均概率图 h, w img.shape[-2:] probs np.zeros((NUM_CLASSES, h, w), dtypenp.float32) cnt np.zeros((h, w), dtypenp.float32) for y in range(0, h, stride): for x in range(0, w, stride): # 取外扩 expand 后的输入块边缘 clamp y0, y1 max(0, y - expand), min(h, y tile_out expand) x0, x1 max(0, x - expand), min(w, x tile_out expand) crop img[:, y0:y1, x0:x1].cuda().unsqueeze(0) with torch.no_grad(): logits model(crop) prob torch.softmax(logits, dim1)[0].cpu().numpy() # 只保留中心有效区对应原始 tile_out 区域 dy0, dx0 y - y0, x - x0 valid prob[:, dy0:dy0 tile_out, dx0:dx0 tile_out] probs[:, y:y tile_out, x:x tile_out] valid cnt[y:y tile_out, x:x tile_out] 1 return probs / np.maximum(cnt, 1)逻辑说明每个滑窗位置先向外扩 expand 再预测取回中心有效区累加cnt 归一化用来处理影像边缘位置有效区不满的情况。两个参数影响最大expand 取网络感受野的一半就够UNet 这类结构 64128 是常见区间再大只是浪费算力边缘 clamp 后有效区会缩水所以用 cnt 平均而不是直接覆盖否则影像四周会出现明显低概率异常带。注意expand 超过感受野一半以后涨的基本只有计算量接缝改善趋近于零这是可以用曲线验证的规律。4.3 后处理小目标剔除与连通域清理拿到 argmax 的分类图最后一步是后处理。高分影像分割结果最常见的瑕疵两类散点噪声——某个像素孤零零被分成别的类空洞——大块地物中间有几个像素的异类像水体中间的零星陆地。项目里后处理主要做连通域层面的清理from scipy import ndimage def clean_seg(seg, min_area16): 移除小于 min_area 像素的连通域改为周围最频繁的类别 out seg.copy() for cls in np.unique(seg): lab, n ndimage.label(seg cls) if n 0: continue sizes ndimage.sum(seg cls, lab, range(1, n 1)) for i, area in enumerate(sizes, 1): if area min_area: out[lab i] mode_of_neighborhood(seg, lab i) return outmin_area 直接按像素算米级分辨率下 16 像素大约是十几平方米的一小块属于典型单体噪声尺度。注意别用大核中值滤波做这件事——滤波去噪的同时会把道路、田埂这类细线状地物磨断这是血泪经验。后处理的定位是补救不是主力接缝和边缘问题在膨胀预测阶段就该解决后处理只负责把最后那点小碎块收拾干净。真把后处理当主力用结果就是预测图看起来很干净实际精度不升反降。5. 半监督伪标签与高频翻车点一份避坑实战清单5.1 伪标签半监督什么时候用、怎么迭代遥感最贵的是标注。一个常见做法是半监督伪标签先用少量已标注切片把模型训到收敛再用它对未标注切片预测把高置信度的预测结果当作标签补进训练集迭代训练。项目里把伪标签列为专门一环说明作者确实在标注不足时靠它涨过点数。流程我一般这样跑第一步只用真实标签训 3050 epoch直到验证 IoU 不再明显上升第二步对未标注切片滑窗预测得到 softmax 概率第三步按阈值筛选softmax 最大值超过阈值才作为伪标签低于阈值的像素设 255 交给 ignore_index第四步混合原标注和伪标签再训 1020 epoch然后重新预测、更新伪标签重复两三轮就停。阈值从 0.9 起步类别差异大就按类设——水体、建筑这类边界清晰的类 0.85 可用农田和裸地这类容易混淆的类要 0.95 以上。伪标签不是越迭代越多越好三轮以后增益递减继续加轮次风险大于收益。还有个小习惯训练时给伪标签区域的 loss 乘 0.30.5 权重让真实标注始终占主导伪标签只负责带路不能带偏。提示伪标签阈值宁可高不要低。0.9 起步守住标签干净比追求伪标签数量重要得多。5.2 五个高频翻车点按现象→原因→解决给你现象训练 loss 一直降验证 IoU 不动预测图大片只输出一个类。原因类别不平衡时纯 CrossEntropy 被大类主导小类梯度被淹没模型学成全预测大类也能把 loss 压得很低。解决用第 2 章 bincount 统计结果算 class_weight把小类权重拉起或者换 Focal Loss让难样本占比升上去。先改这个再看别的八成问题在这。现象拼接出来的整景图有棋盘格瓦片边界出现条纹或重复错分线。原因滑窗边界预测不可靠overlap 太小甚至没有边界差像素直接拼一起。解决按 4.2 上膨胀预测取中心有效区同时把 stride 降到 tile 一半用 cnt 归一化做重叠平均。这两步做完接缝问题基本消失。现象伪标签迭代第二轮验证精度不升反降而且降得很快。原因阈值设太低错误预测当硬标签噪声累积后被模型当真理学进去越练越歪。解决阈值提到 0.9 以上按类分别设伪标签区域 loss 降权每轮重新预测伪标签不要用上一轮缓存。现象建筑边缘糊一圈道路断线细小地物整个消失。原因下采样倍数太高或网络没有跳连浅层边界信息传不到解码器后处理大核滤波也会加重。解决换带 skip connection 的结构下采样控制到 1/8 以内膨胀预测 expand 给足上下文后处理只做小面积连通域清理别开大核滤波。现象tensorboardX 曲线不刷新或者 add_image 显示的图是花的、颜色对不上。原因版本不兼容writer 没 flush/closeadd_image 值域和通道顺序不对。解决tensorboardX 和 torch 版本对齐循环末尾 writer.flush()训练结束 writer.close()add_image 前把张量转成 HWC、数值归一到 0255RGB 通道顺序和 CSV 颜色定义保持一致。这类 PyTorch 环境问题README 里一般会写明依赖版本照着装别自由发挥。6. 三面板自检与精度验证让训练结果真正可信的小习惯项目里带了 predict.png、blend.png 和 img_gt_pre.png 三张输出图这其实是作者留给你的验证工具。img_gt_pre.png 从名字看就是三面板原始影像、真值、预测并排。第一件必须做的事是拿这张图和 classes_sample.jpg 对颜色——如果预测图的类别颜色和样本图模板不一致说明第 2 章的颜色映射表在某个环节错位了最常见的元凶是 cv2 读图的 BGR 和 CSV 里 RGB 不一致。颜色错位会骗过你的眼睛模型可能白训。blend.png 是影像和预测半透明叠加专门用来看错分位置。遥感分割里错分集中在两类位置是正常的一是阴影区和水体边缘二是建筑和裸地接壤带这些像素本来就从光谱上难以区分。如果错分出现在大片农田中间那才是真问题往回检查训练切片和标签质量别急着调模型。验证我还惯用一个脚本def iou_per_class(pred, gt, valid_classes): scores {} for c in valid_classes: inter ((pred c) (gt c)).sum() union ((pred c) | (gt c)).sum() scores[c] float(inter) / max(float(union), 1.0) return scores重点看小类别的 IoU别只看总 IoU——大类会抬平均值小类惨状被盖住。从那以后我每次拿到新影像都强制先走一遍三面板对色、逐类 IoU 两件事再谈调参。调参是优化问题数据链路错了是方向问题方向错了调参全是白费。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进