☰
Python+Unet实现遥感图像道路提取:从数据到后处理全流程
2026/9/27 1:34:06 网站建设 项目流程

简介:基于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等),栅格是像素坐标系,必须先做坐标系变换,否则画出来全错。

# 用geopandas+rasterio把矢量道路线转成掩膜 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), transform=src.transform, fill=0, dtype=np.uint8) np.save('road_mask.npy', mask) # 保存成npy,训练时直接加载

这个转换脚本有三个要点:一是坐标系校验,因为很多公开的标签文件都是WGS84,而Unet训练用的是像素坐标,不做to_crs的就是黑匣子错图;二是rasterize里的transform参数必须来自影像本身,别自己算像素分辨率;三是掩膜保存成npy或uint8的PNG都可以,但千万别保存成JPEG,JPEG有损压缩会让标签边缘糊掉。

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, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.conv(x) class Unet(nn.Module): def __init__(self, in_ch=3, out_ch=1): 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, stride=2) self.d3 = DoubleConv(1024, 512) self.up2 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.d2 = DoubleConv(512, 256) self.up1 = nn.ConvTranspose2d(256, 128, 2, stride=2) 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], dim=1)) b = self.d2(torch.cat([self.up2(b), e3], dim=1)) b = self.d1(torch.cat([self.up1(b), e2], dim=1)) return self.out(b)

关键参数说明:in_ch=3对应RGB输入;out_ch=1表示二分类,输出logits;卷积核都用3×3,padding=1保证尺寸不变,只有池化层改变分辨率。编码器的通道数按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_size=256): 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:y+self.crop_size, x:x+self.crop_size] mask = mask[y:y+self.crop_size, x:x+self.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, smooth=1.0): # pred是logits,target是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(dim=1) union = pred_flat.sum(dim=1) + target_flat.sum(dim=1) 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-4,Batch Size设为8(如果显存只有8G,输入裁切到256×256),训练100个epoch,用ReduceLROnPlateau在验证Dice停滞时把学习率除以5。AdamW相对Adam更好,因为解耦的权重衰减让模型泛化能力稍强。

# 优化器、调度器与训练循环 optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='max', factor=0.5, patience=5, verbose=True) 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, alpha=0.5) ax[1].imshow(mask, alpha=0.5, cmap='Reds') 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, iterations=2) # 去掉太小的连通域(面积小于100像素的通常是噪点) num, labels, stats, _ = cv2.connectedComponentsWithStats(closed, connectivity=8) 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的connectivity=8对于道路这种八连通目标很关键,4连通常常会把斜向道路断得更碎。

6.2 网格推理:大影像怎么在显存限制下推理

遥感影像基本都是几千乘几千像素,不能直接整图喂给Unet。常见做法是滑窗推理:把大图裁成512×512的块,预测后再拼接。块与块之间要重叠一些,我一般设64像素的重叠,重叠区域取两个预测的均值,避免接缝处出现“撕裂”感。

def sliding_predict(model, full_img, crop=512, stride=448): model.eval() h, w = full_img.shape[:2] output = np.zeros((h, w), dtype=np.float32) count = np.zeros((h, w), dtype=np.float32) for y in range(0, h - crop + 1, stride): for x in range(0, w - crop + 1, stride): patch = full_img[y:y+crop, x:x+crop].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:y+crop, x:x+stride] += p[:stride, :stride] # 只累加核心区域 count[y:y+stride, x:x+stride] += 1 # 未覆盖区域(最后一块不足crop)单独处理,或用边缘填充 output = np.divide(output, count + 1e-6, out=output, where=count > 0) return output

这块代码里最容易写错的是边缘处理:如果影像宽高不是crop的整数倍,最后一行/列会漏采。解决方法是先把影像padding到crop的整数倍,推理完再裁掉。另一个经验是stride越小越平滑但越慢,常用stride=crop//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的冒烟测试,再上全量训练,这个习惯帮我躲过了大部分翻车。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询