ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

遥感道路分割实战:大分辨率影像切块、模型训练与后处理全流程

遥感道路分割实战:大分辨率影像切块、模型训练与后处理全流程 简介面向深度学习语义分割任务这一来自DeepGlobe Road的高分辨率遥感道路分割数据集已预先切分为训练集与测试集每张原始图像均配备像素级前景标注掩码标注质量高可直接用于分割网络训练、验证或作为测试基准。压缩包共包含2000个文件其中1999个为JPEG格式的遥感图与对应mask另附1个Python可视化脚本整体压缩后约336.79MB、解压后约513MB训练集与测试集目录下分别设置images和masks子目录结构干净省去自行划分时间。脚本会随机抽取一张图片将原图、像素级标注Ground Truth以及半透明蒙版叠加效果同屏输出并保存到当前目录非常适合快速检查数据标注质量、调试模型输入。目前已有977人学习下载尤其适合研究遥感图像分割、道路提取的学生与算法工程师对需要高分辨率道路标注数据与可视化验证环节的项目尤为省时实用。1. 遥感道路分割数据集大分辨率是优势也是第一道坎标题里这个「大分辨率」三个字拿到手才知道是双刃剑遥感影像单景动辄上万像素道路这种目标又细又长占画面比例常常不足5%。好处是细节清楚坏处是整张图根本塞不进GPU——模型不是直接「看」整张图而是先切块。这篇文章从数据组织、滑窗切块、模型训练、指标评估到常见翻车现场把拿到这种已划分train/test的遥感影像道路图像分割数据集后的一整套落地路径讲透。适合正在做遥感解译、路网提取或者准备把语义分割模型迁移到高分影像上的工程师和研究生。这套做法在类似数据集上可以直接复用下面直接说怎么用。2. 读懂数据集标注格式、目录结构与大分辨率影像切块2.1 道路分割和通用语义分割到底差在哪遥感影像里道路是典型的线状目标和建筑物、农田、水体这些面状目标完全不一样。一段在0.5米分辨率影像上约20米宽的主干道横向只有约40个像素纵向可能连续几千个像素。这种形态带来两个直接影响。第一类别极不平衡。在一个切块里道路像素占比通常不到5%把整幅图全部预测为背景准确率都有95%以上。如果直接拿交叉熵去训练模型很容易学成「全背景都输出背景」的偷懒模式loss虽然在下降但路网什么都没学到。第二像素级指标苛刻。预测结果和标注只要错开1~2个像素IoU就会明显下降但人眼并不会觉得路画错了。这两个特点决定了后面模型选型、损失函数、评估方式都不能照搬通用物体分割的做法。在真实遥感场景里道路还会和很多东西混淆行道树挡住部分路面建筑物阴影盖在路面上桥面和水体边界相似这些都会直接影响数据标注质量和模型的判别难度。这也是为什么要反复检查数据集的标注和划分。2.2 已划分train/test的数据集长什么样拿到这种已划分的数据集第一步不是训练而是先摸清目录结构和标签格式。常见做法是train和test两个平级目录下面各放images和masks两个子目录。文件命名的规律也值得先看一眼有的数据集按影像块编号有的按原始影像名加后缀搞错对应关系后面会很痛苦。用一张表列一下常见的组织方式要素常见形式检查要点影像格式.tif / .png / .jpg是否带地理坐标GeoTIFF会含RPC或坐标信息影像通道RGB三通道 / 多光谱多光谱不能直接套ImageNet归一化参数mask格式单通道灰度 / RGB三通道单通道直接读灰度RGB要先转换道路像素值0/1 或 0/255统一成0/1再进网络划分方式按影像划分 / 按切块划分按影像划分才可信按切块划分有泄漏一个典型的目录长这样road_dataset/ train/ images/ tile_001.tif tile_002.tif tile_003.tif masks/ tile_001.png tile_002.png tile_003.png test/ images/ tile_101.tif tile_102.tif masks/ tile_101.png tile_102.png先数一遍文件数量确认train里每张影像都有对应masktest也一样。我习惯写一个几行的检查脚本把「影像名集合」和「mask名集合」做差集一次性确认没有漏标、重名。这一步花5分钟能避免训练到中途才发现数据缺失。from pathlib import Path def check_pairs(image_dir, mask_dir): imgs {p.stem for p in Path(image_dir).glob(*.tif)} masks {p.stem for p in Path(mask_dir).glob(*.png)} print(缺mask的影像:, imgs - masks) print(缺影像的mask:, masks - imgs)这段代码的逻辑是用集合差集找出两边对不上的文件名。参数上需要保证image_dir和mask_dir的目录路径正确且文件后缀一致如果mask是.jpg或.tif把glob里的后缀改掉就行。跑完看到两个集合都是空再开始下一步。2.3 大分辨率影像怎么喂进模型滑窗切块与重建大分辨率是这类数据集的核心卖点也是模型训练第一道坎。一张5000×5000的遥感影像直接resize成512×512道路就只剩一两个像素宽信息全丢了直接整图进模型显存立刻爆掉。常见做法是滑窗切块把大图切成固定尺寸的小块喂给模型推理完再把小块预测拼回原图。下面的函数完成切块和重建两件事。import numpy as np def sliding_window_crop(image, window_size512, stride448): image: (H, W) 或 (H, W, C) 的 numpy 数组 返回: (crops, coords)coords 记录每个块在原图左上角坐标 h, w image.shape[:2] crops, coords [], [] for y in range(0, h - window_size 1, stride): for x in range(0, w - window_size 1, stride): crop image[y:y window_size, x:x window_size] crops.append(crop) coords.append((y, x)) return crops, coords def sliding_window_reconstruct(crops, coords, full_size, window_size512): 把切块的预测结果拼回原图 crops: 每个块是 (H, W) 的概率图或类别图 h, w full_size[:2] acc np.zeros((h, w), dtypenp.float32) count np.zeros((h, w), dtypenp.float32) for crop, (y, x) in zip(crops, coords): acc[y:y window_size, x:x window_size] crop count[y:y window_size, x:x window_size] 1 acc / np.clip(count, 1, None) return acc这里把stride设置为window_size - 64 448也就是说每两个相邻块在上下左右各自重叠64个像素。重叠的意义在于道路很容易横跨切块边界模型在边界处看不到足够上下文预测容易断裂。重叠区域用累加平均做平滑边界预测比硬拼接自然得多。window_size一般取512或256。512对U-Net这类下采样4~5倍的模型来说是有充分上下文的最小值之一如果显存紧张就降到256但道路跨块的风险会变大。切块还有两个容易忽略的边界问题如果影像尺寸不是window_size的整数倍边缘会剩一条没有切到的区域需要pad成整数倍如果要跟GIS叠加建议把每个切块的左上角原图坐标一并存下来上面代码里的coords就是干这个的。重建时如果只是拼接二值结果重叠区可以取多数投票如果用的是网络输出的概率图累加平均更平滑。3. 用分割网络跑通遥感道路提取模型选型与训练流程3.1 模型选型为什么常用DeepLabV3和U-Net选模型之前先问自己两个问题有多少标注数据有多少显存。遥感道路分割不是上车新架构的地方常见做法是在两个成熟框架里选U-Net和DeepLabV3。U-Net的编码-解码结构配合跳跃连接能把编码器浅层的边缘细节直接送到解码器这对细长的道路边界特别重要。参数少、训练快数据量在几千张切块这个规模时不容易过拟合。DeepLabV3引入了ASPP模块用不同空洞率并行提取多尺度上下文适合道路宽度差异大的场景——高速路、主干道、乡道在同一个切块里宽度可以差一个量级。它的缺点是显存占用和训练时间都更高。我一般先跑U-Net把流程和代码调通再换DeepLabV3对比几个epoch看验证集指标有没有明显提升。这样做的好处是出问题时分得清是数据问题还是模型问题。下面这段是U-Net里最基础的卷积块它的结构直接影响训练稳定性。import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv1 nn.Conv2d(in_ch, out_ch, kernel_size3, padding1) self.bn1 nn.BatchNorm2d(out_ch) self.conv2 nn.Conv2d(out_ch, out_ch, kernel_size3, padding1) self.bn2 nn.BatchNorm2d(out_ch) def forward(self, x): x torch.relu(self.bn1(self.conv1(x))) x torch.relu(self.bn2(self.conv2(x))) return x这个双卷积加BN的结构是U-Net的基本积木连续两个3×3卷积比单个卷积感受野更大、非线性更强每个卷积后面接BN能让深层网络在训练时梯度更稳。不要小看这几行遥感影像的像素分布和自然照片差别很大BN的均值方差会被影像亮度分布影响如果发现loss抖动剧烈优先检查BN和归一化。3.2 加载切块数据从mask到训练张量的完整流程有了数据集和模型下一步是把切块数据装进训练流程。这里有一个数据集层容易踩的坑mask读的方式不对后面全白练。常见做法是像下面这样写一个Dataset类。import cv2 import numpy as np import torch from torch.utils.data import Dataset from pathlib import Path class RoadDataset(Dataset): def __init__(self, image_dir, mask_dir, window_size512, trainTrue): self.image_dir Path(image_dir) self.mask_dir Path(mask_dir) self.window_size window_size self.train train self.files sorted([p.stem for p in self.image_dir.glob(*.tif)]) def __len__(self): return len(self.files) def __getitem__(self, idx): name self.files[idx] img cv2.imread(str(self.image_dir / f{name}.tif)) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 统一成RGB mask cv2.imread(str(self.mask_dir / f{name}.png), cv2.IMREAD_GRAYSCALE) mask (mask 0).astype(np.float32) # 不管255还是1统一成0/1 h, w img.shape[:2] if self.train: y np.random.randint(0, max(h - self.window_size, 0) 1) x np.random.randint(0, max(w - self.window_size, 0) 1) else: y, x 0, 0 # 测试时用固定起始位置 img img[y:y self.window_size, x:x self.window_size] mask mask[y:y self.window_size, x:x self.window_size] img img.astype(np.float32) / 255.0 img (img - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) img torch.from_numpy(img).permute(2, 0, 1).float() mask torch.from_numpy(mask).unsqueeze(0).float() return img, mask逻辑说明训练模式下的随机切块位置让模型每个epoch看到同一张影像的不同局部相当于低成本数据增强测试模式则从左上角固定位置裁剪保证结果可复现。mask用灰度模式读取再统一成0/1这样mask原始值是255还是1都不用关心。参数层面的几个选择值得说。归一化用的是ImageNet的均值方差这对三通道真彩色遥感影像成立如果数据集里有多光谱波段要单独统计每个波段的mean/std或者干脆按样本做z-score归一化。window_size设为512和2.3的切块参数保持一致。如果每张原图是几千乘几千训练时每次都实时裁512×512的patch数据加载会成为瓶颈常见做法是离线切好小块另存为独立的png或npyDataLoader只管读小块省掉原图IO开销。3.3 损失函数与类别不平衡道路只占几个像素怎么训练交叉熵在这个任务上不顶用原因是类别不平衡太严重。道路占1%到5%是很常见的比例交叉熵对每个像素平权模型发现把所有像素预测为背景loss已经很低梯度里几乎没有道路类的有效信号。两个常见做法是Dice Loss和Focal Loss我一般用Dice Loss配合BCE组合使用。Dice Loss的计算逻辑是把预测概率和标签都展平成一维计算两集合的交集和并集然后直接优化「预测和标签的Dice系数」。下面这段是它的标准实现。def dice_loss(pred, target, smooth1.0): pred: 经过sigmoid的概率图, shape (N, 1, H, W) target: 二值标签, shape (N, 1, H, W) pred pred.view(pred.size(0), -1) target target.view(target.size(0), -1) intersection (pred * target).sum(dim1) dice (2.0 * intersection smooth) / ( pred.sum(dim1) target.sum(dim1) smooth ) return 1.0 - dice.mean()smooth参数取1.0是常用值防止交集和并集都为0时产生除零。它不显式区分背景和道路的像素数量直接面向「道路类交集/并集」优化免疫背景像素占多数的问题。训练里常用总损失是0.5 * BCE 0.5 * dice_lossBCE保证每个像素的梯度仍然存在dice保证目标类被充分优化。训练循环里要注意pred是logitsdice_loss里先经过sigmoid转成概率BCE用binary_cross_entropy_with_logits内部自带sigmoid这样配合在数值稳定性上更合理。for epoch in range(epochs): model.train() total_loss 0.0 for img, mask in train_loader: img, mask img.to(device), mask.to(device) pred model(img) loss 0.5 * nn.functional.binary_cross_entropy_with_logits(pred, mask) \ 0.5 * dice_loss(torch.sigmoid(pred), mask) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item()如果试过Dice之后发现道路预测的误检多可以换Focal Loss把gamma调到2.0alpha按道路像素占比调整实际做下来损失函数不是越复杂越好先能把道路从背景里分出来再谈精细优化。4. 训练集/测试集划分之后评估指标与结果验证4.1 IoU之外道路分割还要看什么测试集存在的意义不是报一个漂亮数字而是告诉你这套方案上线行不行。道路分割常用三个指标IoU、F1、召回率。IoU是图像分割的标准指标计算预测与标注的交集除以并集。问题是它按像素平均道路细长一两像素的偏移就会被放大。F1是查准率和召回率的调和平均和IoU有固定换算关系但心理上更容易理解把它当作「道路类到底分得多好」的直接读数。召回率能告诉你漏掉了多少真值路网对道路这种线状目标漏检比误检更致命。代码实现如下。import numpy as np from sklearn.metrics import f1_score def segmentation_scores(pred, gt, threshold0.5): pred_bin (pred threshold).astype(np.uint8).ravel() gt_bin (gt 0).astype(np.uint8).ravel() intersection np.logical_and(pred_bin, gt_bin).sum() union np.logical_or(pred_bin, gt_bin).sum() iou intersection / max(union, 1) recall np.logical_and(gt_bin, pred_bin).sum() / max(gt_bin.sum(), 1) f1 f1_score(gt_bin, pred_bin) return {IoU: iou, F1: f1, Recall: recall}threshold在预测输出为概率图时用来二值化取0.5在道路分割里常常偏保守有时0.3效果更好这依赖模型校准程度上线前值得扫一遍阈值。如果只看IoU你无法判断漏检和误检哪个更严重建议在测试集上累计混淆矩阵的TP、FP、FN、TN四个值再看漏检FN和误检FP的比例。道路分割里最常见的毛病是FN远大于FP出现这种情况优先上调召回比如降低预测阈值、加强正样本增强反过来FP过多说明模型把裸土、水体边界的纹理误认成道路优先清理训练数据里的相似标注。4.2 用可视化检查预测mask指标只是压缩信息可视化才能暴露问题。我习惯每次都把影像、GT、预测三个图并排输出确认道路形态和断裂情况。import matplotlib.pyplot as plt def visualize_prediction(image, mask, pred, save_path): fig, axes plt.subplots(1, 3, figsize(18, 6)) axes[0].imshow(image) axes[0].set_title(Image) axes[1].imshow(mask, cmapgray) axes[1].set_title(Ground Truth) axes[2].imshow(pred, cmapgray) axes[2].set_title(Prediction) for ax in axes: ax.set_xticks([]) ax.set_yticks([]) fig.tight_layout() fig.savefig(save_path, dpi150) plt.close(fig)预测图是概率图时用gray的imshow直接看灰度深浅比二值图更能看出模型的置信度分布。通常一个batch抽4到8张图放到一张大图里扫一眼就够不必每个文件都存。可视化是检验「指标」和「实际效果」差距的最快手段不要只看一个数字。4.3 指标和可视化对不上的原因最打击信心的一幕是验证集IoU接近0.8打开可视化路网碎成雪花只有基础设施的主干道是完整的。原因在于像素级指标被大面积目标主导主干道占了大半的IoU贡献支路和小路即使模型一条没画出来IoU也只掉零点零几。道路分割里这种「像素数是够的但拓扑完全不连贯」的情形很常见所以第6章的后处理就专门来解决连通性。另一个值得做的验证是按场景分组统计。把测试集影像按地貌粗分几类比如城区、山区、农田区分别计算IoU。很多时候总指标看着没事其实是城区道路太好分割拉高了平均分山区小路全军覆没。按组看指标才能暴露模型真正的短板。如果发现train和test数据分布不一致尤其test里出现了train没见过的影像类型优先怀疑划分策略而不是急着调模型。5. 遥感道路分割避坑指南5个高频翻车现场5.1 切块把道路拦腰切断现象推理拼出来的整景路网在切块边界处整齐地断掉断层对齐得像网格。原因滑窗切块时模型在块边界处没有足够的上下文。道路沿水平或垂直方向穿过边界时块内的可见部分太短模型倾向于把「没看到延续」的像素判为背景。解决推理时用重叠滑窗stride比window_size小64到128像素重叠区域的概率用累加平均而不是硬取一个块的结果。我在2.3里给的代码默认就是重叠64像素多数情况下已经够用如果断裂严重把重叠加到128。第二个补救是在形态学后处理里做闭运算把差距在几个像素内的断口接上。这两招配合断裂问题基本能压下去。5.2 训练集和测试集来自同一景影像现象训练损失正常下降测试IoU看起来很高但把模型拿到另一批影像上泛化效果明显变差。原因切块后随机划分train/test时同一景大影像的相邻切块之间高度相关——它们共享同一片道路、同一片地表覆盖相当于测试集见过训练内容。这种泄漏会让指标虚高在上线前骗你一次。解决划分以「影像」为单位不是以「切块」为单位。先按大影像文件分train/test再在训练影像内部切块。如果数据集官方已经给了train/test划分要手动检查test里是不是混入了来自同一景影像的块如果来源相同建议重新按影像划分否则报告里的指标没有统计意义。提示按影像划分train/test才有统计意义按切块划分的指标只能当参考不适合用来做上线决策。5.3 mask是255还是1标签读取错误现象模型从第一步开始就预测全背景loss卡在一个很高的值下不去或者训练曲线看着正常但预测永远没有道路。原因数据集的mask有的把道路标成255、背景0有的标成1。如果代码里用了mask 1来生成标签而数据集里实际是255标签就全成了0模型只能学全背景。这类问题隐蔽在「能跑起来、loss也在降」但结果全错。解决拿到数据先写一行检查代码打印np.unique(mask)确认标注值域。加载时统一用(mask 0)转成0/1而不是直接比较等于某个数值。我在3.2的Dataset里就是这种写法从源头避免。如果再发现loss异常第一时间回去检查GT长什么样不要先怀疑网络结构。注意mask读出来后先用np.unique看一眼值域再写训练代码这一步能省出一整天的排查时间。5.4 大分辨率影像导致显存溢出现象训练时设置window_size512正常切块加载训练也没问题但一到整图推理就报CUDA out of memory。原因把一张5000×5000的影像直接送给模型中间特征图会把显存撑爆。训练时因为数据已经切成小块所以不爆推理时如果没走滑窗直接整图forward就会翻车。解决推理阶段同样走滑窗把预测概率逐块拼回原图。window_size在推理时可以比训练小一点比如训练用512推理用448再加重叠显存占用更可控。另一个常见做法是单batch推理不要为了省事把多张切块叠成一个大batch。注意滑窗推理时重叠区域的概率要累加平均直接取一个块的输出会导致网格痕迹5.1里的断裂和这里其实是同一个问题在训练、推理两个阶段的两种表现。5.5 验证集IoU很高但预测路网断成碎片现象IoU有0.75甚至F1也有0.7可视化里道路却断成一段一段连通性很差。原因IoU和F1都是像素级指标对连通性没有感知一条断成三段的道路和一条完整的道路在像素交并比上可能完全相同。道路作为线状目标连通性是应用层面的第一要求。解决评估时增加基于连通性的指标比如计算预测路网的连通域数量、最大连通域像素占比修路网时用第6章的后处理把断口接上、把孤立小岛滤掉。指标是给论文看的连通性是给地图用的。两条路分开验证才能避免被一个好看的数字骗过去。我自己的习惯是每次改完参数先抽几张大图做整景可视化确认路网连得上再回来整理指标报表。6. 从像素mask到可用路网连通域分析与拓扑修复前面所有工作的产出是每个像素的预测概率但应用方要的是路网线划图。最常见做法是先二值化再做形态学闭运算最后做连通域分析滤除噪声。顺序不要反闭运算要在连通域分析之前不然断口接不上、孤立点也滤不干净。import cv2 import numpy as np def post_process(pred, threshold0.4, min_area50, close_kernel7): bw (pred threshold).astype(np.uint8) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (close_kernel, close_kernel)) closed cv2.morphologyEx(bw, cv2.MORPH_CLOSE, kernel) num_labels, labels, stats, _ cv2.connectedComponentsWithStats(closed, connectivity8) cleaned np.zeros_like(closed) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] min_area: cleaned[labels i] 1 return cleaned参数的经验值threshold取0.4到0.45之间比0.5更激进一点宁可多找几个候选让后处理去滤也不要漏检min_area按影像分辨率换算0.5米分辨率下50像素大约12.5平方米基本就是噪声块close_kernel取5到7太大会把两条并行道路错误粘连太小接不上断口。connectivity要用8道路对角方向的断点在4连通里连不上。后处理之后怎么验证修得好不好用连通域数量做前后对比修完的mask连通域数量应明显减少同时道路总长度不要大幅缩水。如果要做中心线用cv2.ximgproc.thinning提取骨架再统计骨架的总长度和分支数量这比用像素统计更能反映路网拓扑。我的习惯是把原图、GT、原始预测、后处理结果四个图放同一张画布输出每次跑完一轮就看一遍肉眼确认断裂和粘连的分布位置。这类数据集到落地环节真正拉开差距的往往不是模型结构而是后处理和评估口径。我自己踩过好几轮「训练指标很好路网却像被切碎的面条」的坑后来养成的习惯是每改一次参数先看三张图再谈指标。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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