☰
基于U-Net的遥感图像道路提取Python实战:从数据裁剪到后处理
2026/10/11 13:27:57 网站建设 项目流程

简介:这是一套面向遥感图像处理与课程设计场景的Python完整实现方案,适合高校学生作为毕业设计、综合实践或期末大作业的参考范本。项目围绕道路提取核心任务,包含特征提取、聚类分析、检测策略、结果可视化等完整模块,代码附有详尽注释,便于初学者理解算法逻辑与工程实现细节。资源共64个文件,以Python源文件为主,辅以备份文件、编译配置及测试图像,整体压缩包仅3.97MB,结构清晰便于按模块查阅。目前已有30人学习浏览。项目涵盖灰度共生矩阵、K均值聚类、多种道路检测策略等关键技术点,并提供了面向对象设计的路口检测上下文与参数配置机制;代码经过系统化测试调试,曾获得优异的课程考核成绩,具备较高的工程实用性与教学参考价值,可作为课设任务拆解、算法复现和代码排错的重要参考。

1. 遥感图像道路提取这套Python源码:为什么选了U-Net而不是全连接

做课程设计拿到遥感图像道路提取这个题目,大多数人第一反应是用OpenCV做边缘检测加形态学处理,跑通容易,但精度和泛化能力都拿不出手。这份基于Python的遥感图像道路提取算法课程设计源码,核心是U-Net语义分割,把道路提取当成像素二分类问题来做:输入遥感影像,输出同尺寸的概率图,再通过后处理得到干净的道路矢量。它能解决的不只是“跑个demo交差”,而是从数据准备、模型训练到结果评估的一整条链路,适合正在做期末大作业、又不想只交传统方法糊弄过去的同学。第1章先交代清楚:这套源码用什么、为什么能拿到高分、你拿到手后需要准备什么环境。

2. 把TIF影像切成模型能吃的样子:GDAL裁剪、标签对齐与训练集划分

2.1 先弄清楚原始数据和标签到底是什么格式

遥感影像和普通自然图像最大的区别在于:它通常是多波段TIF,带地理坐标信息,而且一张图动辄几千乘几千像素。直接拿整张图去训练,显存肯定爆掉,所以第一步一定是裁剪成小切片。拿到源码后不要上来就跑训练脚本,先打开数据目录看一眼。常见的组织方式是images/放原始影像,labels/放二值标签,标签里道路像素为255或1,背景为0。如果你手上的标签是白底黑线,那后处理时候要留意反色问题,这是后面踩坑的重灾区的起点。

另一个容易被忽略的点是TIF的波段数。有些高分影像有4个波段(RGBN),有些是3波段,少数还有8波段。源码里的数据加载部分一般会写死波段数,如果你的数据是4波段,U-Net的输入通道就要改成4。我之前帮人调过一份代码,加载时用了img[:, :, :3]硬切,结果近红外波段的信息直接被丢掉了,道路边缘分割效果肉眼可见地变差。拿到源码先确认数据格式和加载代码是否匹配,这一步花不了五分钟,但能省掉后面一整天的排错时间。

2.2 裁剪时保证特征图和标签严格对齐

裁剪是遥感分割里最“细”的环节。源码里提供的是滑动窗口裁剪,核心参数是tile_size和stride。tile_size决定每个切片的尺寸,一般取256或512;stride是窗口每次移动的步长,如果stride < tile_size,就产生了重叠区。重叠区域的作用有两个:一是增加训练样本数量,二是避免道路正好被切在边界上导致标签不完整。这里给出一个标准的裁剪实现,用rasterio读取影像,同时裁特征和标签,保证二者坐标严格对齐:

import rasterio from rasterio.windows import Window import numpy as np def crop_tiles(image_path, label_path, save_dir, tile_size=512, stride=256): with rasterio.open(image_path) as src_img, rasterio.open(label_path) as src_lab: height, width = src_img.height, src_img.width for y in range(0, height - tile_size, stride): for x in range(0, width - tile_size, stride): window = Window(x, y, tile_size, tile_size) img_data = src_img.read(window=window) # shape: (C, H, W) lab_data = src_lab.read(window=window) # shape: (1, H, W) # 转成 (H, W, C) 的排列,方便后续输入模型 img_data = np.transpose(img_data, (1, 2, 0)) lab_data = np.transpose(lab_data[0], (0, 1)) np.save(f"{save_dir}/img_{y}_{x}.npy", img_data) np.save(f"{save_dir}/lab_{y}_{x}.npy", lab_data)

这里的read(window=window)是按地理坐标窗口读取,只要两个文件的投影一致,切出来的图就是像素级对齐的。注意height - tile_size这个写法是防止窗口越界,如果影像尺寸不是tile_size的整数倍,最后一行一列会被丢弃。我一般会在做之前先打印影像尺寸,确认大图的宽高,如果边缘丢弃的区域太大,就把stride改成tile_size // 2,或者把影像先做一次镜像填充再切。参数这块可以这样调:tile_size=512适合显存不太够的情况,tile_size=256训练速度快但上下文信息少,道路这种狭长目标更容易断;重叠率stride=tile_size//2是验证下来最稳的选择。

2.3 训练集划分与正负样本比例

裁剪完之后就是划分训练集和验证集。这里容易犯的错误是直接随机划分,但同一张大图切出来的相邻切片高度相似,训练集和验证集信息重叠,验证指标虚高。我自己的习惯是先把大图按区块分组,比如一张5000×5000的图切成约20×20=400个切片,按切片中心坐标的块号来划分,保证同一地理位置只出现在一个集合里。源码里如果只提供了简单的train_test_split,拿到手后最好改成这种按来源划分的方式。正负样本比例也要留意,遥感影像里道路通常只占5%~10%,背景占绝大多数,如果训练时直接把原始切片全丢进去,模型会学成“全都预测为背景”。常见做法是算一下每个切片里道路像素占比,把全是背景的切片和含道路的切片按1:3或1:4的比例混合训练,或者在后处理阶段用pos_weight调整二分类交叉熵的权重。

3. U-Net训练参数与评价指标:loss怎么设、IoU怎么算、不收敛看哪里

3.1 模型结构选型:标准U-Net还是轻量版

源码里用的是标准U-Net,编码器部分可以换ResNet34或EfficientNet预训练权重,解码器保持经典的跳跃连接结构。U-Net在道路提取这个任务上的优势很直观:跳跃连接让浅层细节和深层语义融合,对细长拓扑结构比较友好。作为课程设计,标准U-Net完全够用,而且好解释,答辩时可以把每个模块的功能讲得很清楚。如果你想把效果再往上拉,可以参考DeepLabV3+的ASPP模块,但对显存和训练时间的要求会明显提高。一份课程设计源码里给你的是标准U-Net,这就是最稳妥的起点,别一上来就换大模型,先跑通再优化。模型核心代码大概是这样的结构:

import torch.nn as nn class UNet(nn.Module): def __init__(self, in_channels=3, out_channels=1, features=[64, 128, 256, 512]): super().__init__() self.encoder = nn.ModuleList() self.decoder = nn.ModuleList() self.pool = nn.MaxPool2d(2) for f in features: self.encoder.append(self._conv_block(in_channels, f)) in_channels = f self.bottleneck = self._conv_block(features[-1], features[-1] * 2) for f in reversed(features): self.decoder.append(nn.ConvTranspose2d(f * 2, f, kernel_size=2, stride=2)) self.decoder.append(self._conv_block(f * 2, f)) def _conv_block(self, in_ch, out_ch): return 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): skip_connections = [] for enc in self.encoder: x = enc(x) skip_connections.append(x) x = self.pool(x) x = self.bottleneck(x) skip_connections = skip_connections[::-1] for idx in range(0, len(self.decoder), 2): x = self.decoder[idx](x) skip = skip_connections[idx // 2] if x.shape != skip.shape: x = nn.functional.interpolate(x, size=skip.shape[2:]) x = torch.cat((skip, x), dim=1) x = self.decoder[idx + 1](x) return torch.sigmoid(x)

这段代码里features=[64, 128, 256, 512]控制编码器各层通道数,显存不够可以把数值减半。ConvTranspose2d上采样后有个对齐处理,interpolate到跳跃连接的特征图尺寸,这是防止因输入尺寸非整数倍导致张量形状对不上的关键动作。训练时in_channels要和你数据的波段数一致,RGB就是3,四波段就改成4。最后输出接sigmoid激活,得到每个像素是道路的概率,取值范围0到1。

3.2 损失函数与训练超参:BCE和Dice一起用

道路提取的二分类问题里,纯用BCE(二元交叉熵)容易让模型偏向背景占比大的情况,损失值降得挺快但道路像素全被忽略。常见的做法是BCE加Dice Loss组合,Dice系数直接衡量预测和真实标注的重叠程度,训练时两者加权相加,一般权重各取0.5。下面这段是训练循环里的关键片段,你可以直接对照源码里的训练脚本看看有没有这部分:

import torch import torch.nn.functional as F def dice_loss(pred, target, smooth=1.0): pred = pred.reshape(-1) target = target.reshape(-1) intersection = (pred * target).sum() return 1 - (2.0 * intersection + smooth) / (pred.sum() + target.sum() + smooth) def combined_loss(pred, target): bce = F.binary_cross_entropy(pred, target) dice = dice_loss(pred, target) return 0.5 * bce + 0.5 * dice

combined_loss里smooth是防止分母为0的平滑项,一般设1.0。训练时我一般用Adam优化器,学习率1e-4,batch_size根据显存调,8或16比较常见。还有一个隐藏的关键点:标签从npy加载后要除以255,把像素值从0~255缩放到0~1,否则BCE的梯度方向一开始就是错的。源码里如果没做这一步,训练loss会卡在0.4上下死活不降。学习率的调整策略可以用ReduceLROnPlateau,验证集IoU连续5个epoch不涨就把学习率乘0.5,这是最省事的调度方式。

3.3 评价指标:先跑通IoU,再谈可视化

课程设计答辩时老师必问的问题是“你精度多少”,这里精度不能只报像素准确率,因为背景占比大,全预测成背景也有95%以上的准确率,骗得了分数骗不了答辩。必须算IoU(交并比),道路类IoU才是真正反映提取效果的指标。计算方式如下:

import numpy as np def compute_iou(pred_mask, true_mask): pred = pred_mask > 0.5 true = true_mask > 0.5 intersection = np.logical_and(pred, true).sum() union = np.logical_or(pred, true).sum() iou = intersection / (union + 1e-6) return iou

这段代码里pred_mask是模型输出的概率图,>0.5是二值化阈值,true_mask是真实标签,union加了个1e-6防止除零。除了IoU,还可以补一个F1-Score,公式是2 * precision * recall / (precision + recall),更能体现细道路的断裂情况。源码里应该已经封装了评估脚本,如果没有,就把这段算IoU的代码补进去,跑验证集时逐张算然后取平均。记住一个经验值:道路类别IoU在0.55以上就已经是可见效果较好的结果,能到0.65以上在课程设计里就很能打了。

4. 分割结果的后处理:形态学过滤、连通域筛选与GeoJSON导出

4.1 为什么模型输出不能直接交作业

模型输出的概率图直接转成二值图,里面会有一堆“盐粒”噪声:单独亮起的像素、短小的碎线、建筑物边缘的误检。这些东西在交作业的图上非常显眼,一眼就被看出是没做后处理的半成品。最基础的后处理包括三步:中值滤波去噪、形态学开闭运算、小连通域剔除。这三步的目的都是让道路保持连续、平滑、拓扑完整。另外模型输出的是栅格,如果课程设计要求展示在真实遥感底图上,最好转成矢量线划图叠加上去,这一步在后处理里一起解决。

4.2 形态学开闭运算的核怎么选

形态学开运算(先腐蚀再膨胀)可以去除细小的白色噪点,闭运算(先膨胀再腐蚀)可以填补道路中间的细小断裂。核的大小直接影响道路的完整性:核太小起不到滤波作用,核太大会把相邻的平行道路融成一片。以512像素分辨率的影像为例,我一般先试3×3的核,看结果里碎点还剩多少,道路有没有断,再决定要不要升到5×5。后处理代码里直接调OpenCV就行:

import cv2 import numpy as np def postprocess(prob_map, threshold=0.5, kernel_size=5, min_area=50): # 概率图转二值图 binary = (prob_map > threshold).astype(np.uint8) * 255 # 开运算去噪点,闭运算补断线 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (kernel_size, kernel_size)) opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) closed = cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel) # 连通域分析,去掉面积小于阈值的小块 num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(closed, connectivity=8) filtered = np.zeros_like(closed) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] >= min_area: filtered[labels == i] = 255 return filtered

threshold控制二值化灵敏度,调低一点道路更完整但噪声更多。kernel_size=5和min_area=50是经验起始值,具体要看你的切片像素分辨率;如果你的影像是0.5米分辨率,路面宽度可能有几十个像素,min_area可以设到200。connectivity=8表示8邻域连通,道路这种斜向目标必须用8连通,4连通会把斜路断成好几段。做好这几步之后,提交的图像上碎点和断线都会明显减少。

4.3 把栅格转成矢量GeoJSON:用rasterio的features模块

有的课程设计项目要求输出带坐标的矢量文件,这时候要用到rasterio.features.shapes把栅格像素转成矢量多边形。源码里如果只是输出掩膜PNG,这一步可以自己补上,答辩时能多一个展示的点:遥感影像叠加半透明道路矢量,一眼看去就是高完成度。实现方式如下:

import json import rasterio from rasterio import features from shapely.geometry import shape from shapely.geometry import mapping def mask_to_geojson(mask_array, transform, output_path): results = [] for geom, value in features.shapes(mask_array.astype('uint8'), transform=transform): if value == 1: results.append(mapping(shape(geom))) geojson = {"type": "FeatureCollection", "features": results} with open(output_path, "w") as f: json.dump(geojson, f)

transform参数是原始TIF的地理仿射变换矩阵,直接从rasterio.open()返回的src.transform拿。mask_array是0/1掩膜而不是255/0,因为features.shapes会把非零值都当作前景。转换出来的是多边形面,如果你要的是道路中心线,还得做一次骨架提取,用skimage.morphology.skeletonize,但作为课程设计而言输出面矢量已经足够完整。

5. 课程设计最容易翻车的五个坑:现象、原因与改动建议

5.1 训练Loss不降反升,验证集压根不收敛

现象:跑了十几个epoch,训练loss在0.7附近震荡,验证IoU始终为零附近。

原因:最常见的是标签没归一化。标签图读进来是0到255,模型输出是0到1,BCE的梯度数值完全错位。还有一个常见原因是标签和影像没有对齐,整个标签图偏移了十几个像素,模型学到的特征全被错位样本干扰。

解决:在数据加载代码里强制加一行label = label / 255.0,再打印一对img和label的shape以及像素值范围确认一下。对齐问题的话,先把标签和影像同一位置切片可视化检查一次,看道路线是不是正好压在影像的道路位置上。

5.2 训练Loss很低但预测全图全是白的

现象:验证loss看起来正常,IoU也还行,但拿全图一预测,输出几乎整片都是道路。

原因:这大概率是因为训练样本里只有那些道路占比高的切片,模型没见过大量干净背景。还有可能是后处理时二值化阈值太低,预测概率0.3也被当成道路。

解决:回看训练集切片中道路像素占比的分布,把纯背景切片和含道路切片混合。预测时把阈值从0.5往上调,试0.6或0.7,同时观察道路是否还连续。一般来说概率图里道路的峰值和背景的峰值应该分得比较开,如果阈值调到0.8道路还是很大面积存在,那就是训练样本分布的问题。

5.3 道路断成一段一段的,细小路几乎全丢

现象:预测结果里主干道清楚,但乡村小路、次干道断裂严重。

原因:一是训练切片尺寸太小,512的切片对于8米宽的道路来说还行,但对3米宽的乡村路,切出来可能只有四五个像素宽,下采样后细节直接消失。二是形态学开运算的核太大,把细路当成噪声腐蚀掉了。

解决:训练时把tile_size从512提高到768或1024,让上下文更大。后处理的小连通域面积阈值min_area要降低,因为长而窄的道路像素块面积并不大。建议先不加后处理直接看模型原始输出,确认断裂是模型问题还是形态学参数问题,再对症下药。

5.4 模型在训练集上效果很好,验证集上一塌糊涂

现象:训练切片逐张看预测结果非常漂亮,但切到另一块区域的验证切片,道路提取明显退化。

原因:这是典型的过拟合,根源是训练样本太单一,比如只来自两张高分影像。遥感影像的色调变化极大,不同区域的道路材质、屋顶颜色、植被覆盖差异都会让模型失效。

解决:用数据增强去扛,对切片做随机翻转、随机旋转90度、色彩抖动和亮度对比度调整。源码里如果只有水平翻转,建议补上ColorJitter和RandomRotation。另外就是增加训练数据的来源,哪怕只多一张完全不同色调的影像,泛化能力也会有可见提升。

5.5 GPU显存溢出,batch_size根本调不上去

现象:CUDA out of memory,调整batch_size到2照样爆显存。

原因:显存不足的核心往往不是batch_size,而是切片的尺寸。512×512的输入加U-Net在6GB显存下跑batch_size=8比较吃力,加上自动求导的中间变量,很容易爆掉。

解决:优先把tile_size改到256,观察道路小的特征是否还能保住;再把batch_size降到4,配合梯度累积。另一个做法是在训练脚本里加torch.cuda.empty_cache()。如果显存实在不够,把特征features从64改到32,模型参数量能少四倍,分割效果下降有限。

6. 用TTA和多尺度推理把路拼完整:从单张预测到全图成图验证

6.1 推理时不要直接拿整张大图塞进模型

训练完成之后,预测阶段的核心是滑窗推理加拼接。把测试大图按训练时的tile_size和stride切块,逐块预测,再把结果按坐标拼回全图。拼接时重叠区域不能简单取平均值,否则重叠区和非重叠区之间会有明显的亮度跳变。我常用的做法是给每张切片预测结果加一个三角窗权重,重叠区域按权重叠加,最终除以权重和,拼出来的图过渡自然。核心逻辑可以写成这样:

def sliding_predict(model, full_img, tile_size=512, stride=256): h, w = full_img.shape[:2] weight_map = np.zeros((h, w), dtype=np.float32) prob_map = np.zeros((h, w), dtype=np.float32) for y in range(0, h - tile_size, stride): for x in range(0, w - tile_size, stride): tile = full_img[y:y+tile_size, x:x+tile_size] tile_tensor = transform(tile).unsqueeze(0) with torch.no_grad(): pred = model(tile_tensor).squeeze().numpy() prob_map[y:y+tile_size, x:x+tile_size] += pred weight_map[y:y+tile_size, x:x+tile_size] += 1 prob_map = prob_map / np.maximum(weight_map, 1) return prob_map

weight_map里的1表示每个像素被覆盖的次数,最后除以覆盖次数就是平均值。如果想让重叠区融合更平滑,把weight_map换成距离中心越远权重越低的三角窗,但课程设计里平均融合已经完全够用。预测前记得把模型切到eval()模式,关闭dropout和batchnorm的动态更新,否则推理结果会有随机性。

6.2 TTA:测试时增强就是白捡的精度

预测阶段还有一个几乎不花成本的提分手段,就是TTA。把每张切片做一次水平翻转和垂直翻转,分别预测,再把三次预测的概率图翻转回原始方向后取平均。这个方法专门对付模型对特定方向过拟合的问题。道路分割里,U-Net对上下方向往往比对左右方向敏感,TTA之后IoU一般能涨0.02到0.03。代码实现上就是在sliding_predict里多写几个翻转分支:

preds = [] for aug_img in [tile, np.fliplr(tile), np.flipud(tile)]: with torch.no_grad(): p = model(transform(aug_img).unsqueeze(0)).squeeze().numpy() preds.append(p) preds[1] = np.fliplr(preds[1]) preds[2] = np.flipud(preds[2]) pred = np.mean(preds, axis=0)

这段代码就是TTA的核心,翻转三次预测再平均,模型实际推理时间增加了三倍,但换来的是更平滑的概率边界。如果你时间充裕,还可以加一个多尺度分支,把切片缩放到0.75和1.25倍分别预测再resize回原尺寸,效果又不一样。

预测完成后,拿拼好的全图概率图做一次可视化检查:把预测道路轮廓叠加到原始遥感影像上,透明度调到0.4,人工看一眼道路是否连通、有没有跑到屋顶上去。这一步是课程设计里最出效果的地方,也是答辩时最能说明问题的一张图。从那以后,我每次交遥感分割作业,都会强制走一遍“切片可视化+重叠区拼接+TTA平均”的完整流程,确认全图没有黑洞也没有断层才提交,这套习惯救过我不少次。希望帮到你,这套工程包确实值得花一个晚上把它跑通,再按你自己的数据调一遍参数。

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

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

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

立即咨询