ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Python+Unet实现遥感图像道路提取:从数据到后处理全流程

Python+Unet实现遥感图像道路提取:从数据到后处理全流程 简介基于Python的遥感图像道路提取算法完整源码包面向高校学生、编程初学者及相关方向的研究者适用于课程设计、期末大作业或毕业设计参考。系统实现了从遥感影像数据读取、特征提取、聚类分割到检测策略与结果输出的完整链路并采用模块化设计便于理解与二次开发。资源共包含64个文件以35个Python脚本为核心另配有测试图像、编译模块、说明文档及备份文件整体约3.97MB目录按功能模块划分检索方便。目前已有30人学习下载。代码全程附带详尽注释并提供了可视化操作界面能够帮助快速掌握道路识别算法的实现思路项目曾获优异成绩工程稳定性较好适合作为课程综合实践或毕业设计的参考范本具有较高的教学与工程参考价值。1. 遥感图像道路提取为什么Python与Unet成了课程设计的默认答案在高校地理信息、测绘和遥感专业的课程设计里“基于Python的遥感图像道路提取算法”几乎年年出现。很多第一次接触这个题目的同学会把它当成一个图像分割任务——事实上它就是。道路在遥感影像里呈现为连续的、有明显几何特征的区域但实际做起来会发现单靠NDVI阈值或边缘检测这类传统方法到了城市中心就全线崩溃密集的建筑阴影把道路切断成碎块树冠遮挡让线段凭空消失。这个课题的核心技术路线其实非常收敛用Python的深度学习框架PyTorch或TensorFlow搭一个Unet把遥感影像作为输入让模型逐像素判断“是不是路”再用形态学后处理把预测结果拉通。适合的人群也很明确有基本Python语法基础、想在一个学期内跑通一个完整CV项目的学生以及把遥感图像分割作为入门方向、想落到实际代码和数据上的从业者。本文讲的不是“原理幻灯片”而是一套能实际训练、调参、出图的方案。2. 准备数据与构建数据集遥感图像标注的格式之争与裁切策略2.1 DeepGlobe / Massachusetts Roads公开数据集的格式差异与选择逻辑道路提取领域有两个被用得最多的公开数据集DeepGlobe Road Extraction和Massachusetts Roads Dataset。DeepGlobe的影像来自DigitalGlobe卫星分辨率大概在0.5米左右图像尺寸为1024×1024标签是黑白掩膜道路像素为白色255背景为黑色0。Massachusetts Roads覆盖的是波士顿周边区域影像尺寸更大但同样是单通道的掩膜标签。实践里我一般优先选DeepGlobe理由是图像尺寸统一、标签质量相对干净省去了一堆坐标对齐的麻烦。Massachusetts Roads的图幅更大但部分区域标签存在错位需要额外人工检查。选数据集的核心判断标准有两个第一标签里的道路宽度是否与你要检测的道路级别一致DeepGlobe主要是城市和乡村道路比较符合“道路提取”这个题目第二影像是否为RGB三通道因为Unet的输入通常就是RGB如果用了多光谱数据就得做通道选择。2.2 遥感图像标注的格式掩膜与GeoJSON/Shapefile怎么转成训练标签如果你的课程设计需要用自己拿到的遥感影像就要面对标注问题。遥感图像标注的常见工具是LabelMe或QGIS标注结果有两种形式一是直接画出多边形、导出为GeoJSON或Shapefile矢量文件二是直接在像素层面生成掩膜。对于道路这种条带状目标绝大多数人用多边形勾路中心线或路面轮廓。把GeoJSON/Shapefile转成掩膜的常见做法是栅格化用GDAL/OGR读矢量创建一个与影像同尺寸的空白单波段栅格把多边形写进去。这里有个必踩的坑矢量坐标是地理坐标系WGS84等栅格是像素坐标系必须先做坐标系变换否则画出来全错。# 用geopandasrasterio把矢量道路线转成掩膜 import geopandas as gpd import rasterio import numpy as np with rasterio.open(satellite.tif) as src: rgb src.read([1,2,3]).transpose(1,2,0) # 读取矢量确保坐标系和影像一致 rows gpd.read_file(roads.geojson) if rows.crs ! src.crs: rows rows.to_crs(src.crs) # 用rasterio.features.rasterize把矢量烧录成掩膜 from rasterio.features import rasterize shapes [(geom, 1) for geom in rows.geometry] mask rasterize(shapes, out_shape(src.height, src.width), transformsrc.transform, fill0, dtypenp.uint8) np.save(road_mask.npy, mask) # 保存成npy训练时直接加载这个转换脚本有三个要点一是坐标系校验因为很多公开的标签文件都是WGS84而Unet训练用的是像素坐标不做to_crs的就是黑匣子错图二是rasterize里的transform参数必须来自影像本身别自己算像素分辨率三是掩膜保存成npy或uint8的PNG都可以但千万别保存成JPEGJPEG有损压缩会让标签边缘糊掉。3. 搭建Unet提取道路从编码器到跳跃连接的核心实现3.1 用PyTorch实现Unet主干的参数讲解Unet结构在道路提取上的优势在于跳跃连接编码器逐步下采样获得语义特征解码器逐步上采样恢复空间分辨率跳到同尺度的编码器特征拼在一起让“路在哪里”的位置信息不至于在多层池化后丢失。道路是连续、线状的目标一旦下采样太深细道路会被“磨”没跳跃连接就是针对这个问题的后悔药。常见做法是用预训练的主干如ResNet34作为编码器降采样倍数控制在16倍而不是32倍。下面是一个精简版Unet不用外部预训练权重适合课程设计跑通import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.conv(x) class Unet(nn.Module): def __init__(self, in_ch3, out_ch1): super().__init__() # 编码器四个stage通道数指数增长 self.e1 DoubleConv(in_ch, 64) self.e2 DoubleConv(64, 128) self.e3 DoubleConv(128, 256) self.e4 DoubleConv(256, 512) self.pool nn.MaxPool2d(2) self.bridge DoubleConv(512, 1024) # 解码器上采样后与跳跃连接concat self.up3 nn.ConvTranspose2d(1024, 512, 2, stride2) self.d3 DoubleConv(1024, 512) self.up2 nn.ConvTranspose2d(512, 256, 2, stride2) self.d2 DoubleConv(512, 256) self.up1 nn.ConvTranspose2d(256, 128, 2, stride2) self.d1 DoubleConv(256, 128) self.out nn.Conv2d(128, out_ch, 1) def forward(self, x): e1 self.e1(x) e2 self.e2(self.pool(e1)) e3 self.e3(self.pool(e2)) e4 self.e4(self.pool(e3)) b self.bridge(self.pool(e4)) b self.d3(torch.cat([self.up3(b), e4], dim1)) b self.d2(torch.cat([self.up2(b), e3], dim1)) b self.d1(torch.cat([self.up1(b), e2], dim1)) return self.out(b)关键参数说明in_ch3对应RGB输入out_ch1表示二分类输出logits卷积核都用3×3padding1保证尺寸不变只有池化层改变分辨率。编码器的通道数按64、128、256、512递增解码器在每次上采样后把通道数减半最后用1×1卷积映射到单通道。注意解码器的concat操作跳跃连接必须把编码器同层输出e2、e3、e4传下来通道拼接后用DoubleConv融合。3.2 数据加载与训练主循环的正确写法数据加载是另一个容易翻车的地方。遥感影像原始尺寸很大1024×1024以上课程设计的显存通常不大所以主流做法是随机裁切到256×256或512×512。注意裁切时影像和标签必须用同一个随机种子否则标签和图像错位模型学到的就是“噪声到噪声”的映射。# Dataset的随机裁切与归一化 class RoadDataset(torch.utils.data.Dataset): def __init__(self, img_dir, mask_dir, crop_size256): self.images sorted(list(img_dir.glob(*.png))) self.masks sorted(list(mask_dir.glob(*.png))) self.crop_size crop_size def __getitem__(self, idx): img np.array(Image.open(self.images[idx])) # H,W,3 mask np.array(Image.open(self.masks[idx])) # H,W # 随机裁切保证影像与标签同步 h, w img.shape[:2] y np.random.randint(0, h - self.crop_size) x np.random.randint(0, w - self.crop_size) img img[y:yself.crop_size, x:xself.crop_size] mask mask[y:yself.crop_size, x:xself.crop_size] # 归一化到[0,1]标签二值化 img img.astype(np.float32) / 255.0 mask (mask 127).astype(np.float32) img torch.from_numpy(img).permute(2,0,1) mask torch.from_numpy(mask).unsqueeze(0) return img, mask这里的两个参数值得注意Windows下多进程DataLoader要在__getitem__里避免顶层文件句柄归一化建议用除以255的简单方式很多预训练模型用ImageNet的mean和std但遥感影像的RGB分布与自然图像不同手动统计整个数据集均值更稳。permute(2,0,1)是把h,w,c转成c,h,w通道维必须在最前面PyTorch不会替你检查。4. 训练道路提取模型损失函数与超参数的调参玄学4.1 损失函数BCEWithLogits与Dice Loss的选择与组合道路提取的二分类存在严重类别不均衡DeepGlobe里道路像素占整张图的比例通常只有5%~10%。如果直接用BCEWithLogits模型会倾向于把所有像素预测为背景因为这条路能让损失更低。常见的解决方法是Dice Loss或Focal Loss。Dice Loss的公式是2|X∩Y|/(|X||Y|)它对正负样本不敏感专门用来处理这种“目标稀疏”的分割任务。实操里我一般把BCE和Dice混合起来用loss 0.5 * bce dice。只用Dice训练容易在早期震荡混合Loss收敛更稳。下面是一个可以直接替换的损失函数import torch.nn.functional as F def dice_loss(pred, target, smooth1.0): # pred是logitstarget是0/1掩膜 pred torch.sigmoid(pred) pred_flat pred.reshape(pred.size(0), -1) target_flat target.reshape(target.size(0), -1) intersection (pred_flat * target_flat).sum(dim1) union pred_flat.sum(dim1) target_flat.sum(dim1) dice (2.0 * intersection smooth) / (union smooth) return 1 - dice.mean() def mixed_loss(pred, target): bce F.binary_cross_entropy_with_logits(pred, target) return 0.5 * bce dice_loss(pred, target)smooth参数的作用是防止分子分母都为0导致除零一般取1.0。对道路这种细长目标Dice系数比IoU对“细线断裂”更敏感因为它的分母是并集而非单独的预测区域。训练时要盯着Dice曲线看模型学到后期Dice涨得很慢但断裂改善非常明显——这种“分数不涨但视觉变好”的情况很常见。4.2 超参设置学习率、Batch Size、训练轮数怎么定才不玄学我的默认参数方案是学习率1e-4Batch Size设为8如果显存只有8G输入裁切到256×256训练100个epoch用ReduceLROnPlateau在验证Dice停滞时把学习率除以5。AdamW相对Adam更好因为解耦的权重衰减让模型泛化能力稍强。# 优化器、调度器与训练循环 optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-5) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.5, patience5, verboseTrue) for epoch in range(100): model.train() train_loss [] for img, mask in train_loader: img, mask img.cuda(), mask.cuda() optimizer.zero_grad() pred model(img) loss mixed_loss(pred, mask) loss.backward() optimizer.step() train_loss.append(loss.item()) # 每个epoch结束后在验证集上算Dice val_dice evaluate(model, val_loader) scheduler.step(val_dice)训练里最容易翻车的是“验证Dice不涨反而掉”。先看去训练loss是否下降如果loss下降而Dice不动典型的梯度消失或类别不均衡换Dice Loss、调高Dice权重。如果loss和Dice都在震荡多半是学习率太大。如果loss不降先查数据标签是否错位——把一批影像和掩膜拼在一起可视化这能筛掉80%的翻车。5. 道路提取避坑与排查从标签错位到结果断裂的常错点5.1 标签与影像错位图上对得上训练全白费现象loss降到0.3左右就再也不动把预测结果画出来道路边缘整体偏移几个像素。原因最常见的是栅格化时transform参数写错或者裁切时影像和标签用了不同的随机种子还有一种是数据集本身就带错位Massachusetts Roads的部分区域就是这样。解决训练前把10对影像和标签做叠加可视化用半透明混合直接检查。程序上写一个自动对齐函数比较影像和标签的尺寸是否一致不一致就resize或报错。# 快速检查影像与标签是否对齐叠加显示 import matplotlib.pyplot as plt img plt.imread(val_img.png) mask plt.imread(val_mask.png) fig, ax plt.subplots(1, 2, figsize(10, 5)) ax[0].imshow(img) ax[1].imshow(img, alpha0.5) ax[1].imshow(mask, alpha0.5, cmapReds) plt.savefig(check_overlay.png)注意不要肉眼只看两张图就觉得“差不多”。叠加检查的意义在于看道路轮廓是否套在影像的道路上偏移超过2个像素就应排查标定流程。5.2 道路预测断裂细道路为什么在Unet里总是断现象预测出来的道路是虚线主干道连续城中村的窄路断裂成几十段。原因下采样倍数太高比如16倍或32倍窄路在特征图上只有不到一个像素更常见的尤其是直接用预训练模型时编码器会丢失高分辨率细节特别是桥下阴影和树木遮挡。解决把下采样倍数控制在8倍编码器用4个stage不是5个引入更多图片增强特别是不改变道路连续性的水平翻转和随机亮度扰动后处理里加形态学闭运算把断口接上。还有一个偏门经验训练的时候把输入裁切尺寸从256提高到384窄路的上下文更长模型能看到“这条路通向哪里”断裂会显著改善。代价是显存占用高用梯度累积模拟大Batch。5.3 道路提取结果有大量孤立像素模型学到了“噪声”现象预测掩膜布满白色噪点看起来跟遥感影像上的道路无关。原因这类情况多数发生在训练数据太少、且没有做数据增强的时候。模型把少量道路样本背下来了无法泛化到影像的其他区域。也可能标签里包含了非道路的高亮地物。解决增加随机翻转、随机亮度模拟不同太阳高度角、随机裁剪的增强强度把增强写进Dataset。然后检查训练集和验证集的来源如果验证集和训练集来自同一景影像那评估分数偏高换一个区域的影像测试。# 一个包含增强的Dataset扩展 class AugmentedRoadDataset(RoadDataset): def __getitem__(self, idx): img, mask super().__getitem__(idx) # 水平翻转影像和标签必须同步 if np.random.rand() 0.5: img torch.flip(img, dims[2]) mask torch.flip(mask, dims[2]) # 随机亮度模拟光照差异 brightness 0.8 0.4 * np.random.rand() img img * brightness return img, mask特别注意torch.flip的维度dims[2]对CHW格式来说翻转的是宽方向。亮度扰动只作用在影像上标签不能跟着变否则道路类别含义就变了。5.4 验证集的Dice很高但实际区域效果差评估集选择的坑现象验证Dice上0.85放到另外一片区域的遥感影像上预测效果崩到没法看。原因训练集、验证集来自同一景/同一时相影像模型学到了该影像的光谱特征而非道路的通用特征。遥感影像不同时相的光照、植被状态差异很大。解决按区域划分数据集训练用A区域验证用B区域测试用C区域。这是做遥感图像深度学习最值得注意的一个边界问题。如果课程设计的题目只给了单景影像唯一可行的方法是把影像切成不同块且确保测试块和训练块不重叠。6. 进阶让提取结果从上交版变成能用的版本6.1 后处理形态学闭运算、骨架化与矢量化Unet的预测输出是逐像素的概率图直接二值化使用会有一堆毛毛糙糙的边缘。常见做法是先以0.5阈值二值化再用OpenCV的形态学闭运算填充断口。道路是线状结构闭运算的核别太大3×3就够太大会把相邻建筑物连成路。import cv2 import numpy as np # 概率图 - 二值掩膜 - 形态学闭运算 prob torch.sigmoid(pred).cpu().numpy().squeeze() binary (prob 0.5).astype(np.uint8) kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3,3)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations2) # 去掉太小的连通域面积小于100像素的通常是噪点 num, labels, stats, _ cv2.connectedComponentsWithStats(closed, connectivity8) filtered np.zeros_like(closed) for i in range(1, num): if stats[i, cv2.CC_STAT_AREA] 100: filtered[labels i] 1两个参数值得记录MORPH_CLOSE是先膨胀后腐蚀能把细小的断裂补上connectedComponentsWithStats的connectivity8对于道路这种八连通目标很关键4连通常常会把斜向道路断得更碎。6.2 网格推理大影像怎么在显存限制下推理遥感影像基本都是几千乘几千像素不能直接整图喂给Unet。常见做法是滑窗推理把大图裁成512×512的块预测后再拼接。块与块之间要重叠一些我一般设64像素的重叠重叠区域取两个预测的均值避免接缝处出现“撕裂”感。def sliding_predict(model, full_img, crop512, stride448): model.eval() h, w full_img.shape[:2] output np.zeros((h, w), dtypenp.float32) count np.zeros((h, w), dtypenp.float32) for y in range(0, h - crop 1, stride): for x in range(0, w - crop 1, stride): patch full_img[y:ycrop, x:xcrop].astype(np.float32) / 255.0 patch torch.from_numpy(patch).permute(2,0,1).unsqueeze(0).cuda() with torch.no_grad(): p torch.sigmoid(model(patch)).cpu().numpy().squeeze() output[y:ycrop, x:xstride] p[:stride, :stride] # 只累加核心区域 count[y:ystride, x:xstride] 1 # 未覆盖区域最后一块不足crop单独处理或用边缘填充 output np.divide(output, count 1e-6, outoutput, wherecount 0) return output这块代码里最容易写错的是边缘处理如果影像宽高不是crop的整数倍最后一行/列会漏采。解决方法是先把影像padding到crop的整数倍推理完再裁掉。另一个经验是stride越小越平滑但越慢常用stridecrop//4重叠区域比较多接缝感最弱。6.3 课程设计评审怎么看不只是把Dice刷高如果这是高分课程设计多数评审老师关心的三件事能不能复现、有没有对比实验、后处理是否完整。我会把实验做成三组对比纯Unet、Unet增强、Unet增强后处理然后在验证集上计算IoU、F1、Precision、Recall四张表。报告的图要把“原图、标签、预测、叠加”四联图放一起让老师一眼看出模型在哪些地方断、哪些地方过分割。另外给一个实用技巧预测结果加一个“平滑细线”的形态学骨架化把带状的预测结果提取成单像素道路中心线。这会直接拉高评审印象分因为这个操作证明你不是只会跑通代码而是懂道路提取应用端的形态学处理逻辑。# 骨架化提取道路中心线 from skimage.morphology import skeletonize binary filtered 0 skeleton skeletonize(binary).astype(np.uint8)我自己的教训是花在数据检查的时间永远值得超过花在调模型参数的时间。一次对齐错误的标签会让一周的训练白费。竞赛和课程设计里先花一晚上检查标签、切分数据集、跑一个5个epoch的冒烟测试再上全量训练这个习惯帮我躲过了大部分翻车。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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