DeepLabv3+高分辨率航拍图像语义分割实战:从数据切块到GeoTIFF输出
2026/9/15 3:40:23 网站建设 项目流程

简介:面向计算机视觉方向毕业设计、高分辨率航拍影像分割研究及算法复现者的项目包,围绕DeepLabv3+语义分割模型提供从数据准备、模型训练到评估预测的完整实践。包内含DeepLabv3+基线实现以及ResNet、HRNet、Swin、Twins、BiSeNetV2、BEiT等多种骨干网络定义,可用于对比不同编码器在高分辨率航拍数据上的分割效果,适合遥感地物分类、城市规划等应用场景。压缩包内共184个文件,95个Python源码负责模型搭建、训练与推理,84个pyc为编译产物,3个Jupyter Notebook呈现分阶段实验记录,另有Markdown和txt文档说明项目结构与部署要点。整包仅477KB,轻量易用。目前已有203人学习下载,用户可参考实验流程快速复现DeepLabv3+在航拍影像中的语义分割,并将其作为毕业设计的基线代码或扩展比较实验,有效节省从零搭建模型与调试环境的时间。

1. 高分辨率航拍图像语义分割,为什么绕不开 DeepLabv3+

拿到一张覆盖几平方公里的航拍图,地面物体的尺度差异会立刻把常规分割模型打回原形:屋顶可能不到十米宽,道路是细长的流线结构,农田地块却横跨上千像素。U-Net 擅长捕捉局部纹理,但感受野有限,在航拍场景下容易把小目标切开、把大目标边缘修补得支离破碎。DeepLabv3+ 的核心价值在于 ASPP 模块通过不同膨胀率的并行卷积,让同一层网络能同时看到 3 像素和 30 像素尺度的上下文,这正是航拍图像语义分割最需要的多尺度建模能力。

这篇文章以 Python 为工具链,从数据切块、模型搭建、训练调参到整张大图的滑窗推理输出,完整走一遍基于 DeepLabv3+ 的高分辨率航拍图像语义分割落地路径。无论你是准备毕业设计答辩,还是第一次在遥感影像上做分割实验,都可以照着下面的步骤跑通,并且能在答辩或汇报时解释清楚每个参数为什么这么设。

2. 先解决高分辨率数据怎么切片、怎么喂给 DeepLabv3+

2.1 数据源与标签体系:先定类别,再定分辨率

航拍图像语义分割的常见公开数据集有 OpenEarthMap、DeepGlobe 等,类别通常覆盖地面建筑、道路、水体、低矮植被、树木、裸地等。如果你是自己的数据,常见做法是准备一张大尺寸 RGB 影像和一张与之像素对齐的标签图,标签图每个像素值为类别编号,比如 0 表示背景、1 表示道路、2 表示建筑。

这里要提醒一个关键点:航拍图像的分辨率不是越高越好,而是要与标签精度匹配。原始影像分辨率只有 0.3 米每像素,但标签是手工勾绘的,边界本身有几米误差,这时候硬把训练图像放大到 2048×2048 只会放大标注噪声。我一般会先做一次直方图统计,确认各类别像素占比。如果“道路”只占全图 2%,就属于严重类别不平衡,要在损失函数阶段处理,而不是靠调学习率硬扛。

2.2 切块尺寸与重叠率的定量选择

显卡显存决定了 DeepLabv3+ 能一次吃进多大的图。以 512×512 输入、ResNet50 骨架为例,训练时 batch size 为 8 大约需要 12 GB 显存。因此常见做法是先把大图切成固定大小的 chip,再进入 DataLoader。

切块参数可以按下面的表格来定:

参数常用范围说明
chip_size512 或 1024芯片尺寸,兼顾显存与上下文视野
stridechip_size 或 chip_size / 2步长,等于 chip_size 时不重叠,减半时重叠采样
通道顺序RGB,float32 归一化到 [0, 1]不要直接用 uint8 输入 BN 层
训练集/验证集8:2 或 7:3按影像切块而不是按像素切块,避免跨图泄漏

不重叠切块的问题在于,位于 chip 边缘的目标会被切断,模型在训练时看不到完整的建筑轮廓。我的做法是在训练阶段使用重叠采样,stride 取 chip 的 1/2,使同一个目标在多个 chip 中完整出现一次;验证集则用不重叠切块,保证评估指标与推理一致。

2.2.1 一个可直接改用的切块脚本
import numpy as np from PIL import Image def tiff_to_chips(image_path, label_path, out_dir, chip_size=512, stride=256): image = np.array(Image.open(image_path)) label = np.array(Image.open(label_path)) h, w = image.shape[:2] idx = 0 for y in range(0, h - chip_size + 1, stride): for x in range(0, w - chip_size + 1, stride): chip_img = image[y:y + chip_size, x:x + chip_size, :] chip_lbl = label[y:y + chip_size, x:x + chip_size] np.save(f"{out_dir}/{idx:06d}_img.npy", chip_img) np.save(f"{out_dir}/{idx:06d}_lbl.npy", chip_lbl) idx += 1 print(f"total chips: {idx}") tiff_to_chips("area_01.tif", "area_01_label.tif", "./chips/train", chip_size=512, stride=256)

这段脚本的核心逻辑是按步长在图像上滑动窗口,把大图裁剪成固定尺寸的数组并保存为 npy 文件。chip_size决定了每个训练样本的空间范围,stride必须小于或等于chip_size,否则部分区域不会被任何 chip 覆盖。当stride = chip_size / 2时,重叠率约为 50%,样本数量约为不重叠切块的 4 倍,训练时间也会随之增加。

注意保存 npy 文件时不要顺手转成 JPEG,JPEG 有损压缩会在建筑边缘产生伪影,语义分割对这类高频信息非常敏感。另外,如果图像尺寸不是 chip_size 的整数倍,脚本会丢弃底部和右侧的多余像素,建议在切块前先做 padding 或者直接自定义边界补齐逻辑。

2.3 数据增强与归一化:航拍图的方向无关性

航拍图像没有“上下颠倒”的概念,因此随机旋转 90 度、随机水平和垂直翻转都是安全的增强方式,不会改变语义标签。但航拍图像的像素分布受光照和传感器影响很大,常见的做法是先对每个 channel 做均值方差归一化,再做 ColorJitter 模拟不同时段的光照变化。

还有一个容易被忽略的环节:如果训练集中同时存在 0.3 米和 1 米分辨率的影像,一定要按分辨率分组或者统一缩放。直接混着训练,DeepLabv3+ 会被迫在同一组权重里适配两种尺度,最终结果通常是小目标丢失或大目标边缘粗糙。我会在数据集划分阶段按 tif 文件名前缀区分影像来源,确保同一张影像的 chip 不会同时出现在训练集和验证集中,这也叫“按图切分”而非“按 chip 随机切分”,能有效避免验证集分数虚高。

3. DeepLabv3+ 结构拆解与 PyTorch 快速搭建

3.1 ASPP 到底是做什么的,为什么对航拍图有效

ASPP 是 Atrous Spatial Pyramid Pooling 的缩写,核心思想是并行的多个空洞卷积,每个分支使用不同的 dilation rate。空洞卷积通过在不同位置插入间隔,在不降低特征图分辨率的情况下扩大感受野。以 dilation rate 为 6、12、18 的三个 3×3 卷积为例,它们在 512×512 特征图上分别能看到约 13、25、37 像素范围的上下文,最终将多尺度特征拼接融合。

航拍场景里,道路宽度可能只有几个像素,而农田地块占据几百像素。普通卷积要么被小目标干扰,要么对大目标缺乏全局建模能力。ASPP 分支设计的巧妙之处在于,dilation 较小的分支保留局部细节,dilation 较大的分支捕捉全局语义,最后由一个 1×1 卷积融合。这也是 DeepLabv3+ 在遥感分割领域比 U-Net 更受青睐的结构原因。

3.1.1 一个轻量 ASPP 实现
import torch import torch.nn as nn class ASPP(nn.Module): def __init__(self, in_channels=2048, out_channels=256, rates=(6, 12, 18)): super().__init__() self.branches = nn.ModuleList() # 1x1 卷积分支,等价于 dilation rate = 1 self.branches.append( nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), ) ) for r in rates: self.branches.append( nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, padding=r, dilation=r, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), ) ) self.project = nn.Sequential( nn.Conv2d(out_channels * (len(rates) + 1), out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), ) def forward(self, x): feats = [branch(x) for branch in self.branches] return self.project(torch.cat(feats, dim=1))

rates是膨胀率元组,膨胀率不能超过特征图尺寸,否则卷积核的有效区域会完全超出图像边界。project层的作用是压缩通道数,把 4 个分支拼接出的 1024 通道降为 256 通道,方便后续解码器融合。若输入特征图是 ResNet 输出的 2048 维,这一层也能显著减少计算量。

3.2 Encoder-Decoder 结构与 backbone 选择

DeepLabv3+ 在 DeepLabv3 基础上增加了解码器,把编码器侧的低层特征(通常是 ResNet 第一个 block 的输出,通道数较少但空间分辨率较高)与 ASPP 输出上采样后的高层特征拼接,再经过几个 3×3 卷积恢复细节。这样做的原因是 ASPP 输出虽然语义抽象,但上采样 8 倍后边界会很模糊,低层特征能提供锐利的边缘信息。

backbone 的选择要结合显存和精度目标。ResNet50 是均衡选择,预训练权重大,微调难度低;ResNet101 精度更高,但训练时间近乎翻倍。追求推理速度时,可以将 backbone 换成 MobileNetV3,航拍语义分割对实时性要求不高,我一般优先 ResNet50。

3.2.1 用 torchvision 快速构建完整模型
import torch.nn as nn from torchvision.models.segmentation import deeplabv3_resnet50 def build_model(num_classes=6, pretrained=True): model = deeplabv3_resnet50(weights="DEFAULT" if pretrained else None) # 替换分类头,把默认的 21 类改为自己的类别数 in_channels = model.classifier[4].in_channels model.classifier[4] = nn.Conv2d(in_channels, num_classes, kernel_size=1) return model

classifier[4]是最后一层卷积,输入通道由主干网络决定,无需手动计算。替换分类头时必须保留前面的 ASPP 层,因为它的预训练语义信息对新任务仍有迁移价值。如果你的 torchvision 版本提示weights参数不可用,改成pretrained=pretrained,或者在离线环境下只加载 backbone 权重。

3.3 输入尺寸与 backbone 冻结策略

航拍图像的原始尺寸远大于分类任务,DeepLabv3+ 的编码器是步长为 16 或 8 的下采样结构,输入 512×512 的图,ASPP 输入特征图是 32×32(步长 16 时),在 ImageNet 预训练设定下完全适用。如果直接输入 1024×1024,也能跑,但显存占用会翻数倍。

迁移学习阶段的常见策略是先冻结所有参数,只训练分类头和 ASPP 中的批归一化层,然后用较大学习率训练几个 epoch 观察 loss 是否下降;之后再解冻 backbone,统一用较小的学习率微调。冻结的作用是避免预训练权重在早期被随机初始化的分类头反向传播冲乱,这在数据量不足时尤其明显。判断依据是训练集与验证集 loss 的差距,若训练 loss 下降但验证 loss 停滞,说明模型过拟合,此时要增加数据增强而不是继续增大模型容量。

4. 训练配置、损失函数与超参数调整

4.1 类别不平衡时,单纯的交叉熵不够用

航拍分割中“道路”和“建筑”通常只占几个百分点,交叉熵损失会被占比最高的类别主导,模型最终倾向把所有像素都预测为背景。辅助损失函数能够有效抑制这个问题。

这里推荐组合损失函数,结构是交叉熵与 Dice Loss 的线性加权。Dice Loss 通过计算预测与标签的交并比来优化区域整体重叠度,对类别不平衡比像素级交叉熵更稳。组合公式为:

L = 0.6 * CE + 0.4 * Dice

先保证交叉熵提供足够的梯度稳定性,再用 Dice 拉高困难类的分割质量。简单实现如下:

import torch import torch.nn.functional as F class ComboLoss(nn.Module): def __init__(self, num_classes, ce_weight=1.0, dice_weight=1.0, smooth=1e-5): super().__init__() self.num_classes = num_classes self.ce_weight = ce_weight self.dice_weight = dice_weight self.smooth = smooth def forward(self, pred, target): ce = F.cross_entropy(pred, target) pred_soft = F.softmax(pred, dim=1) target_onehot = F.one_hot(target, self.num_classes).permute(0, 3, 1, 2).float() inter = (pred_soft * target_onehot).sum(dim=(2, 3)) union = pred_soft.sum(dim=(2, 3)) + target_onehot.sum(dim=(2, 3)) dice = (2 * inter + self.smooth) / (union + self.smooth) diceloss = 1 - dice.mean() return self.ce_weight * ce + self.dice_weight * diceloss

F.cross_entropy内部自带 Softmax,不要在它之前再对 logits 进行 Softmax,否则梯度会出现双重 Softmax 造成的饱和。target必须是 LongTensor 类型且取值小于类别数,否则one_hot会报错或产生全零向量,这一点在自定义数据集时非常容易踩坑。

4.2 学习率策略与混合精度训练

语义分割任务常用 poly 学习率衰减策略,即学习率随训练进度按幂指数衰减,公式为lr = base_lr * (1 - iter / total_iter) ^ power,power 常取 0.9。poly 衰减比每 N 个 epoch 折半衰减更平滑,因为航拍数据集的类别分布不均匀,后期更需要精细调整。

另一个实用的做法是 warmup:训练头几个 epoch 让学习率从极小值线性增长到基准学习率。选用 AdamW 优化器时,基础学习率1e-4是安全起点,同时设weight_decay=1e-4防止 ASPP 分支参数过大。完整配置如下:

超参数推荐值说明
optimizerAdamW比 SGD 收敛平稳,对 BN 层友好
base_lr1e-4使用预训练 backbone 时不需要高学习率
weight_decay1e-4 到 5e-4防止 ASPP 多分支过拟合
batch size8 或 16由显存决定,尽量保持奇数层 BN 稳定
epochs60 到 100航拍数据量小,过多会过拟合
AMP开启显存占用降低约 40%,几乎无损

混合精度训练的核心是用 FP16 做前向和反向计算,用 FP32 保存参数副本。PyTorch 新版本推荐用torch.autocastGradScaler配合使用。

model = build_model(num_classes=NUM_CLASSES) model = model.cuda() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lambda ep: (1 - ep / 60) ** 0.9) scaler = torch.cuda.amp.GradScaler() for epoch in range(60): model.train() for imgs, masks in dataloader: imgs = imgs.cuda() masks = masks.long().cuda() optimizer.zero_grad() with torch.autocast(device_type="cuda", dtype=torch.float16): out = model(imgs)["out"] loss = ComboLoss(num_classes, ce_weight=1.0, dice_weight=0.4)(out, masks) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() scheduler.step()

注意model(...)["out"]直接取主输出,不要使用aux辅助分支,辅助分支只在 3 倍上采样后的边缘监督中使用,需要单独配置aux_loss=True,会增加显存和训练时间。autocastGradScaler必须成对出现,单独使用会造成 loss 溢出变成 NaN,这在训练第一天就会让你怀疑人生。

4.3 训练可视化与早停判断

训练时的评判指标不能只看 loss,因为交并比损失下降时 loss 可能已经触底。我会在每个 epoch 结束后计算验证集 mIoU 和各类别的 pixel accuracy,并记录到一个 CSV 文件。若连续 15 个 epoch mIoU 不再提升,就提前终止训练并保存表现最好的模型权重。

航拍图像的验证集通常有几张完整大图,逐图计算 IoU 再取平均值,而不是在切块级别取平均。原因是切块重叠率较高时,同一个目标的多块预测高度相关,按 chip 平均会低估误差,按图平均更接近实际部署效果。

5. 大图滑窗推理、结果拼接与 GeoTIFF 输出

5.1 滑窗推理的代码骨架与重叠区处理

推理时不再切块训练,而是把一整张数千像素的航拍图交给模型,显存无法容纳。常见的解决方案是滑窗推理,即按固定步长切块预测后拼回原图。但在边界处,模型对 chip 边缘的预测置信度更低,直接拼接会出现“棋盘格”效应。

以下是我常用的滑窗推理逻辑,它对每个像素位置累积所有覆盖它的预测分数,最后取平均,而不是简单的占位覆盖。

import numpy as np import torch def sliding_predict(model, image, chip_size=512, stride=256, num_classes=6): model.eval() h, w = image.shape[:2] score_map = np.zeros((num_classes, h, w), dtype=np.float32) count_map = np.zeros((h, w), dtype=np.float32) for y in range(0, h, stride): for x in range(0, w, stride): y1, y2 = y, min(y + chip_size, h) x1, x2 = x, min(x + chip_size, w) chip = image[y1:y2, x1:x2] # 边界不足时用边缘填充,保持尺寸一致 chip = np.pad(chip, ((0, chip_size - (y2 - y1)), (0, chip_size - (x2 - x1)), (0, 0)), mode="reflect") chip_tensor = torch.from_numpy(chip.transpose(2, 0, 1)).unsqueeze(0).float().cuda() with torch.no_grad(): logits = model(chip_tensor)["out"] # 1, C, H, W prob = torch.softmax(logits, dim=1).squeeze(0).cpu().numpy() prob = prob[:, :y2 - y1, :x2 - x1] score_map[:, y1:y2, x1:x2] += prob count_map[y1:y2, x1:x2] += 1 # 防止边界像素被漏统计 count_map[count_map < 1] = 1 score_map /= count_map return np.argmax(score_map, axis=0)

stride越小,每个像素被预测的次数越多,拼接越平滑,但推理时间按比例增长。stride = chip_size / 2时每个像素约被预测 4 次,效果与耗时的平衡点。边界填充用reflect而不是zeros,能避免填充区域与真实图像差异过大导致特征偏移。最终结果通过argmax得到每个像素的类别编号,后续可直接用于统计分析或矢量转换。

5.2 输出带地理参考的 GeoTIFF 文件

航拍影像的专业之处在于它有地理坐标系(CRS)和仿射变换参数。语义分割结果如果不带坐标,在 GIS 软件里就只是一个普通图片,无法叠加原图使用。正确做法是用rasterio读取原影像的空间参考信息,然后写入推理结果。

import rasterio from rasterio.transform import from_origin def write_geotiff(result, reference_path, output_path): with rasterio.open(reference_path) as src: profile = src.profile.copy() profile.update(count=1, dtype="uint8", driver="GTiff") with rasterio.open(output_path, "w", **profile) as dst: dst.write(result.astype("uint8"), 1)

profile.copy()保留了原影像的坐标系、仿射变换、像素尺寸和影像尺寸。如果推理结果尺寸和原图不一致,必须重新设置heightwidthtransform,否则输出的 GeoTIFF 会和原图错位。验证是否配准正确,可以在 QGIS 中叠加显示,或者用rasterio.warp提取其中一个地物的经纬度与真实坐标对照。

5.3 用 mIoU 验证结果是否达到毕业设计标准

语义分割项目最核心的量化指标是 mIoU,即每个类别的 IoU 取平均。IoU 是预测区域与真实区域交集除以并集,由于并集通常大于交集,这个分数介于 0 到 1 之间。航拍分割数据集中,mIoU 达到 0.6 以上已具备基本可用性,0.7 以上属于优秀水平。

直接调用现成指标库不可靠,因为不同库对边界像素的处理方式不同。建议在验证脚本里手写计算逻辑,用混淆矩阵逐类别统计。

def compute_iou(pred, target, num_classes): iou_list = [] for cls in range(num_classes): p = (pred == cls) t = (target == cls) inter = (p & t).sum() union = (p | t).sum() iou_list.append(inter / union if union > 0 else float("nan")) return np.nanmean(np.array(iou_list))

验证脚本固定使用未参与训练的一张大图,先调用sliding_predict得到完整预测,再与真实标签计算 IoU。如果某一类别在整张图中完全没有出现,union为 0,直接跳过比记为 0 合理。毕业设计答辩时,除了报告整体 mIoU,还要按类别列出 IoU,并解释道路这类小目标 IoU 偏低的原因,比如标注稀疏、类别边界宽。把滑窗推理和 IoU 计算合成为一个evaluate.py,每次训练结束后跑一遍,输出逐类 IoU 和推理耗时,这份自动化表格比任何口头描述都有说服力。

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

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

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

立即咨询