DeepLabv3+高分辨率航拍图像语义分割实战:从数据切块到GeoTIFF输出

DeepLabv3+高分辨率航拍图像语义分割实战:从数据切块到GeoTIFF输出 简介面向计算机视觉方向毕业设计、高分辨率航拍影像分割研究及算法复现者的项目包围绕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. 先解决高分辨率数据怎么切片、怎么喂给 DeepLabv32.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 时不重叠减半时重叠采样通道顺序RGBfloat32 归一化到 [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_size512, stride256): 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(ftotal chips: {idx}) tiff_to_chips(area_01.tif, area_01_label.tif, ./chips/train, chip_size512, stride256)这段脚本的核心逻辑是按步长在图像上滑动窗口把大图裁剪成固定尺寸的数组并保存为 npy 文件。chip_size决定了每个训练样本的空间范围stride必须小于或等于chip_size否则部分区域不会被任何 chip 覆盖。当stride chip_size / 2时重叠率约为 50%样本数量约为不重叠切块的 4 倍训练时间也会随之增加。注意保存 npy 文件时不要顺手转成 JPEGJPEG 有损压缩会在建筑边缘产生伪影语义分割对这类高频信息非常敏感。另外如果图像尺寸不是 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_channels2048, out_channels256, 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, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), ) ) for r in rates: self.branches.append( nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, paddingr, dilationr, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), ) ) self.project nn.Sequential( nn.Conv2d(out_channels * (len(rates) 1), out_channels, 1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), ) def forward(self, x): feats [branch(x) for branch in self.branches] return self.project(torch.cat(feats, dim1))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_classes6, pretrainedTrue): model deeplabv3_resnet50(weightsDEFAULT if pretrained else None) # 替换分类头把默认的 21 类改为自己的类别数 in_channels model.classifier[4].in_channels model.classifier[4] nn.Conv2d(in_channels, num_classes, kernel_size1) return modelclassifier[4]是最后一层卷积输入通道由主干网络决定无需手动计算。替换分类头时必须保留前面的 ASPP 层因为它的预训练语义信息对新任务仍有迁移价值。如果你的 torchvision 版本提示weights参数不可用改成pretrainedpretrained或者在离线环境下只加载 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_weight1.0, dice_weight1.0, smooth1e-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, dim1) 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 * dicelossF.cross_entropy内部自带 Softmax不要在它之前再对 logits 进行 Softmax否则梯度会出现双重 Softmax 造成的饱和。target必须是 LongTensor 类型且取值小于类别数否则one_hot会报错或产生全零向量这一点在自定义数据集时非常容易踩坑。4.2 学习率策略与混合精度训练语义分割任务常用 poly 学习率衰减策略即学习率随训练进度按幂指数衰减公式为lr base_lr * (1 - iter / total_iter) ^ powerpower 常取 0.9。poly 衰减比每 N 个 epoch 折半衰减更平滑因为航拍数据集的类别分布不均匀后期更需要精细调整。另一个实用的做法是 warmup训练头几个 epoch 让学习率从极小值线性增长到基准学习率。选用 AdamW 优化器时基础学习率1e-4是安全起点同时设weight_decay1e-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.autocast和GradScaler配合使用。model build_model(num_classesNUM_CLASSES) model model.cuda() optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambdalambda 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_typecuda, dtypetorch.float16): out model(imgs)[out] loss ComboLoss(num_classes, ce_weight1.0, dice_weight0.4)(out, masks) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() scheduler.step()注意model(...)[out]直接取主输出不要使用aux辅助分支辅助分支只在 3 倍上采样后的边缘监督中使用需要单独配置aux_lossTrue会增加显存和训练时间。autocast和GradScaler必须成对出现单独使用会造成 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_size512, stride256, num_classes6): model.eval() h, w image.shape[:2] score_map np.zeros((num_classes, h, w), dtypenp.float32) count_map np.zeros((h, w), dtypenp.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)), modereflect) 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, dim1).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, axis0)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(count1, dtypeuint8, driverGTiff) with rasterio.open(output_path, w, **profile) as dst: dst.write(result.astype(uint8), 1)profile.copy()保留了原影像的坐标系、仿射变换、像素尺寸和影像尺寸。如果推理结果尺寸和原图不一致必须重新设置height、width和transform否则输出的 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 和推理耗时这份自动化表格比任何口头描述都有说服力。本文还有配套的精品资源点击获取