简介:基于DeepLabv3+的Python高分辨率航拍图像语义分割项目,面向计算机视觉方向毕业设计、遥感图像分析入门及语义分割方法对比研究场景。项目以DeepLabv3+为整体框架,同时整合ResNet、HRNet、Swin、Twins、BiSeNetV2、BEiT等多种backbone实现,能够直接对比不同特征提取网络在高分辨率航拍影像上的分割精度与效果,也便于针对特定场景替换或改进网络结构。压缩包共184个文件,以95个Python脚本和84个pyc编译文件为主体,另含3个Jupyter Notebook实验记录、1个说明文档和1个README,整体仅477KB,文件结构简洁,适合直接阅读源码并快速复现。notebook区分离线输出与在线运行两种流程,可辅助理解模型在不同条件下的调试方法;代码按backbone模块化划分,便于在此基础上扩展训练流程、接入自有航拍数据集或进行消融实验。目前已有204人学习下载,对于需要以DeepLabv3+为基线完成毕业设计课题、撰写技术文档或开展遥感语义分割对比实验的读者,是一份轻量而完整的参考资料。
1. 航拍图像语义分割这块硬骨头,DeepLabv3+ 为什么还能打
做高分辨率航拍图像的语义分割,最烦的不是模型跑不起来,而是跑起来了效果一塌糊涂:小目标糊成一团、地物边缘锯齿严重、显存动不动就爆。这个基于 Python 的 DeepLabv3+ 毕业设计项目,把编码器-解码器结构和空洞卷积的思路完整落到了代码里,我拆完第一感觉是——它把「高分辨率输入」这个最磨人的约束直接当成了设计前提来处理。项目自带基线训练 Notebook、在线/离线推理脚本和六个骨干网络定义文件,适合两类人:一是拿它做毕业设计、需要完整跑通训练到评估全流程的同学,二是做遥感影像分析、想快速对比不同 backbone 效果的从业者。整份资源不是那种只给个 ipynb 糊弄事的,README 里把数据准备、训练入口和常见报错都交代了,属于能照着复现的那一类。
2. DeepLabv3+ 架构拆解:ASPP 与编码器-解码器的设计边界
2.1 编码器-解码器结构到底解决了什么问题
DeepLabv3+ 在语义分割里是个「老但稳」的选项,尤其适合航拍这类目标尺度跨度大的场景。先看它的骨架:编码器部分用带空洞卷积的骨干网络提取多尺度特征,解码器部分把低层细节特征和高层语义特征融合,从而恢复目标边缘。航拍图像里一栋楼的屋顶可能占几百个像素,一辆车可能只占十几个像素,这种尺度差异单靠普通卷积堆叠很难同时照顾到。
项目里deeplabv3plus_baseline_offline_out.ipynb这个 Notebook 就是标准的编码器-解码器实现,我在里面看到它对低层特征的处理方式比较到位:没有直接把 stride=16 的特征图上采样回原尺寸,而是先做 1x1 卷积降通道,再与上采样后的高层特征 concat。这一步很关键,因为低层特征通道数通常很大(ResNet 里是 256 起步),直接 concat 会带来大量冗余计算和显存开销。
对应到代码层面,最核心的 ASPP 模块长这样:
import torch import torch.nn as nn import torch.nn.functional as F class ASPP(nn.Module): def __init__(self, in_channels, out_channels=256, rates=(6, 12, 18)): super(ASPP, self).__init__() # 1x1 卷积分支:保持原尺度感受野 self.conv1 = nn.Conv2d(in_channels, out_channels, 1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) # 三个不同空洞率的 3x3 卷积分支 self.convs = nn.ModuleList() for rate in rates: conv = nn.Conv2d(in_channels, out_channels, 3, padding=rate, dilation=rate, bias=False) bn = nn.BatchNorm2d(out_channels) self.convs.append(nn.Sequential(conv, bn)) # 全局平均池化分支:捕获全局上下文 self.gap = nn.AdaptiveAvgPool2d(1) self.gap_conv = nn.Conv2d(in_channels, out_channels, 1, bias=False) self.gap_bn = nn.BatchNorm2d(out_channels) # 汇合后的 1x1 卷积 self.fuse = nn.Sequential( nn.Conv2d(out_channels * (2 + len(rates)), out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) def forward(self, x): h, w = x.size(2), x.size(3) # 各分支独立计算 feat = [self.bn1(self.conv1(x))] for conv_bn in self.convs: feat.append(conv_bn(x)) # 全局分支需要上采样回原尺寸 gap = self.gap_bn(self.gap_conv(self.gap(x))) gap = F.interpolate(gap, size=(h, w), mode='bilinear', align_corners=False) feat.append(gap) # 所有分支 concat 后融合 x = torch.cat(feat, dim=1) return self.fuse(x)这段代码里rates=(6, 12, 18)是三个空洞卷积的膨胀率,航拍图建议按输入分辨率调整:如果输入是 512x512,这三个值够用;如果输入拉到 768 或 1024,我一般会把 rates 换成(12, 24, 36),否则感受野覆盖不到大尺度地物。align_corners=False这个参数在航拍分割里尤其重要,因为航拍图的地物边界不是规整网格,对齐方式选错会导致边缘错位半像素,后处理时很别扭。
2.2 解码器里的细节:为什么低层特征要降维再融合
解码器部分有一个经常被忽略的设计细节。DeepLabv3+ 原论文里对低层特征先做 1x1 卷积把通道数压到 48,再与高层特征 concat。这个「48」不是拍脑袋定的,而是实验出来的平衡点:通道压得太低会丢掉边缘纹理信息,压得不够又会让高层语义特征被低层噪声淹没。
项目里的骨干网络文件如resnet.py、hrnet.py都保留了完整的 stage 输出,方便在解码器里取不同层级的特征。我拆hrnet.py的时候注意到它输出的多分辨率特征比 ResNet 更适合航拍场景,因为 HRNet 始终保持高分辨率分支,对小目标的响应更敏感。如果你的显卡显存足够,建议优先试 HRNet 做编码器;显存吃紧就退回 ResNet50。
解码器融合的常见写法是:
class DeepLabV3PlusDecoder(nn.Module): def __init__(self, low_level_channels=256, high_level_channels=256, out_channels=256): super(DeepLabV3PlusDecoder, self).__init__() # 低层特征降维到 48 通道,保留细节同时控制显存 self.low_level_proj = nn.Sequential( nn.Conv2d(low_level_channels, 48, 1, bias=False), nn.BatchNorm2d(48), nn.ReLU(inplace=True) ) self.conv1 = nn.Sequential( nn.Conv2d(48 + high_level_channels, out_channels, 3, padding=1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) self.conv2 = nn.Sequential( nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) def forward(self, low_level_feat, high_level_feat): # high_level_feat 已经是 stride=16 的特征 high = F.interpolate(high_level_feat, size=low_level_feat.size()[2:], mode='bilinear', align_corners=False) low = self.low_level_proj(low_level_feat) x = torch.cat([low, high], dim=1) x = self.conv1(x) return self.conv2(x)航拍数据里常见的地物边界(比如道路边缘和建筑轮廓)非常依赖低层特征,这里把低层通道压到 48 而不是 128 或 256,目的就是让高层语义信息在融合时占据主导地位。实际操作里我遇到过一个问题:如果骨干网络输出通道数和代码里预设不一致(比如把 ResNet 换成 Swin Transformer 后通道数变成 96 或 192),会直接报维度不匹配的错。解决方式是在代码里加一层 1x1 卷积做通道适配,或者干脆改low_level_channels的预设值。
3. 六个骨干网络文件怎么选:从 ResNet 到 Swin 的替换套路
3.1 各骨干网络的定位与适用边界
项目里提供了resnet.py、hrnet.py、swin.py、twins.py、bisenetv2.py、beit.py六个骨干网络定义文件,这个设计很聪明——同一个 DeepLabv3+ 解码器,换编码器就是一组对比实验,正好满足毕业设计里「对比不同模型性能」的常见需求。
我先逐个说清楚定位:
| 骨干网络 | 核心特点 | 航拍场景适配度 | 显存占用 |
|---|---|---|---|
| ResNet | 经典残差结构,预训练权重最好找 | 中规中矩,适合做 baseline | 低 |
| HRNet | 全程保持高分辨率特征 | 对小目标友好,边缘细节好 | 中高 |
| Swin Transformer | 窗口自注意力,全局建模强 | 大尺度地物效果好,但训练收敛慢 | 高 |
| Twins | 局部-全局注意力混合 | 速度与精度平衡 | 中 |
| BiSeNetV2 | 双向通道设计,主打实时 | 推理速度快,适合大图快速预测 | 低 |
| BEiT | 预训练特征强,迁移效果好 | 数据量少时表现好 | 高 |
选型逻辑很简单:显存 8G 以下无脑 ResNet50 起步;显存 12G 以上优先试 HRNet 或 Swin;如果要做大图推理且对速度有要求,BiSeNetV2 是唯一能在单张 1080Ti 上跑 1024x1024 不爆显存的选择。这个对比本身就可以写进论文的实验章节,省得自己再造轮子。
3.2 骨干网络替换的实操套路
替换骨干网络最怕的是改了编码器、解码器没跟着改,导致通道数对不上。项目里这几个文件命名很规范,每个文件都暴露了标准的输出接口——返回一个 dict,包含low_level和out两个特征。我拆swin.py的时候发现它对 torch 版本有要求,torch.einsum的调用方式在 1.12 和 2.0 之间行为略有差异,建议直接用 Python 3.8 + torch 1.13 或 2.0 的常见组合。
骨干网络替换的核心代码逻辑是这样:
def build_backbone(name, pretrained=True): """ 按名称构建骨干网络,返回统一格式的特征接口 """ if name == 'resnet50': from resnet import resnet50 backbone = resnet50(pretrained=pretrained) backbone.out_channels = 2048 # stride=16 输出通道 backbone.low_level_channels = 256 # stride=4 输出通道 elif name == 'hrnet': from hrnet import get_hrnet backbone = get_hrnet(pretrained=pretrained) backbone.out_channels = 720 backbone.low_level_channels = 256 elif name == 'swin': from swin import SwinTransformer backbone = SwinTransformer(embed_dim=96, depths=[2, 2, 6, 2]) backbone.out_channels = 768 # stage4 输出通道 backbone.low_level_channels = 192 # stage2 输出通道 return backbone这里最关键的是最后两行属性赋值。DeepLabv3+ 解码器在构建时需要知道低层特征和高层特征的通道数,而不同骨干网络的通道数差异很大——ResNet 的低层是 256、高层是 2048,Swin 的低层是 192、高层是 768。我见过不少人直接换 backbone 后报size mismatch错误,十有八九是没在build_backbone里同步更新这两个值。
另外注意swin.py里的depths参数,它控制每个 stage 的 Transformer block 数量。[2, 2, 6, 2]是 Swin-Tiny 的配置,显存紧张就改成[2, 2, 2, 2],代价是精度会掉 1-2 个点。我一般会先在 ResNet 上把整个流程调通,再切 Swin 做精度实验,这样排错范围小很多。
4. 把 Notebook 跑通:训练、断点续训与离线推理的完整流程
4.1 数据准备与训练入口:别在 Dataset 上省时间
README.md里写的训练流程比较精简,我补一个自己常用的做法。航拍数据集最常见的格式是原始大图 + 对应标签图,每个图可能 5000x5000 甚至更大,直接整图送进网络没有任何显卡吃得消。常见做法是先做滑窗裁剪,把大图切成 512x512 或 768x768 的小块,再按 8:1:1 划分训练、验证、测试集。
切图这一步我建议注意重叠率。如果切 512x512 的图,步长用 256 而不是 512,这样相邻 patch 之间有 50% 重叠,能有效避免目标正好被切在 patch 边界上导致标签丢失。代价是训练样本量增加约一倍,训练时间变长,但对于航拍分割来说这钱花得值。
训练入口在deeplabv3plus_baseline_offline_out.ipynb里,核心训练循环可以提炼成这样:
# 训练循环核心逻辑(简化版) model.train() for epoch in range(start_epoch, epochs): for images, masks in train_loader: images = images.cuda() masks = masks.long().cuda() outputs = model(images) loss = criterion(outputs, masks) # 默认用 CrossEntropyLoss,但要配 ignore_index optimizer.zero_grad() loss.backward() optimizer.step() # 每 50 个 iteration 打印一次 loss 和 lr if step % 50 == 0: cur_lr = optimizer.param_groups[0]['lr'] print(f"Epoch {epoch} Step {step} | Loss {loss.item():.4f} | LR {cur_lr:.1e}")criterion这里有个容易被忽略的细节:航拍标签图里通常有255这个值代表 ignore 区域(比如图像拼接缝、云遮挡),必须设置CrossEntropyLoss(ignore_index=255),否则 loss 会被这些无意义像素带偏。我见过有人用默认的 CrossEntropyLoss 训了一晚上,mIoU 死活上不去,最后发现是 ignore 没设——这个坑在第 5 章详细说。
4.2 评估脚本与离线推理:两个 Notebook 的定位差异
项目里deeplabv3plus_baseline_offline_out.ipynb和sam_arch_offline_out.ipynb看起来都是离线推理,但定位不同。前者是单模型推理,加载训练好的权重对测试集做预测并计算指标;后者是架构对比实验用的,可以一次加载多个不同 backbone 的模型,输出同一张图的对比分割结果。
评估指标建议至少算三个:mIoU(像素级交并比均值)、F1 Score(尤其关注前景类)、OA(整体精度)。mIoU 是语义分割论文里的标配,但航拍场景里地物类别极不平衡——比如一个数据集中植被占 60%、建筑占 25%、道路占 10%、车辆占 5%,mIoU 会被植被这类大类拉高,看不出小类效果。所以我一般额外打印每个类别的 IoU 表格,看看到底是哪几类拉了后腿。
评估代码的核心逻辑:
# 评估 mIoU 的简化实现 def compute_miou(pred_masks, gt_masks, num_classes=8): ious = [] for cls_id in range(num_classes): pred_cls = (pred_masks == cls_id) gt_cls = (gt_masks == cls_id) intersection = (pred_cls & gt_cls).sum().item() union = (pred_cls | gt_cls).sum().item() if union == 0: ious.append(float('nan')) # 该类别在 GT 中不存在 else: ious.append(intersection / union) return ious # 推理时注意:模型输出是 logits,需要 argmax 转为类别 ID with torch.no_grad(): logits = model(batched_imgs) pred = torch.argmax(logits, dim=1).cpu().numpy()num_classes=8这个参数要看你的数据集而定,航拍常见的数据集类别数从 6 到 15 不等。union 为 0 时返回 nan 是常见做法,但最后统计平均 mIoU 时要用np.nanmean跳过这些类,否则平均值会被算成 nan。另一个坑是预测结果和标签的类别 ID 顺序必须严格一一对应,如果训练时改了类别顺序,评估时没改回来,mIoU 会直接掉 20 个点以上。
5. 语义分割避坑清单:高分辨率航拍影像最容易翻车的五个地方
5.1 显存溢出:换小输入不如换策略
现象:训练刚开始没几个 step 就报CUDA out of memory,显存直接吃满。
原因:航拍原图分辨率太高,直接整图输入必然爆显存;或者切图后 batch size 设太大,backbone 输出特征图叠加了 ASPP 的多分支,显存占用比想象中大得多。
解决:优先把输入尺寸从 768x768 降到 512x512,batch size 降到 4 甚至 2;其次是开梯度累积,每 4 个 batch 更新一次参数;再不够就换轻量骨干如 BiSeNetV2。我自己的习惯是先用 512x512 + batch 4 跑通,确认 loss 下降趋势正常后再加大输入尺寸。
5.2 loss 不下降但看着代码没问题:ignore_index 缺失
现象:训练 loss 一直在 3.4 附近震荡,降不下去;或者 loss 在降但验证 mIoU 纹丝不动。
原因:标签图里大量255像素被当成普通类参与 loss 计算,模型在疯狂学习预测这些无效区域;还有一种可能是标签类别从 1 开始编号而不是从 0 开始,导致类别 ID 错位。
解决:训练前先统计标签图的像素类别分布,确认255的比例;在CrossEntropyLoss里显式设置ignore_index=255。如果类别是从 1 开始,需要先把所有标签减 1,让类别 ID 从 0 开始。这一步检查只需两行代码,但能省一整晚。
5.3 验证 mIoU 高但查看预测图边缘破碎:解码器融合细节出了问题
现象:mIoU 看着有 78%,但把预测图拉大看,建筑边缘锯齿严重,道路断成一截截。
原因:解码器里低层特征和高层特征上采样对齐方式不对;或者骨干网络的stride输出与解码器假设不一致(比如用了 stride=8 的 backbone,但解码器默认 stride=16)。
解决:统一用align_corners=False做所有上采样;打印 backbone 输出特征图的尺寸,确认和decoder.forward的预期一致——如果 backbone 输出是输入尺寸的 1/8,那解码器里的 interpolate 目标尺寸也要跟着调整,不能死等 1/16。这问题我在换 HRNet 时踩过,HRNet 的输出 stride 和 ResNet 不一样,不改解码器就是锯齿。
5.4 加载预训练权重报错:key 名称不匹配
现象:load_state_dict报Missing key(s) and unexpected key(s),权重加载失败。
原因:项目里的骨干网络文件可能和官方 torchvision 预训练权重的命名不完全一致,常见的是conv1.weight对应不上的情况,还有分类头fc.weight是多余的。
解决:加载时设strict=False,把不匹配层打印出来人工核对;如果只是分类头不匹配,直接忽略即可。我每次换骨干网络都要先跑一次load_state_dict检查,确认 backbone 主干层全部加载成功再开始训练,避免带着随机初始化的 backbone 训半天还不自知。
5.5 数据增强把标签搞坏了:几何变换要同步
现象:训练集 mIoU 高到离谱,验证集 mIoU 奇低,明显是过拟合但增强也加了。
原因:用的数据增强库对图像和标签分开处理——比如随机旋转时图像转了 30 度,标签转了 45 度;或者随机翻转只翻了图像没翻标签。
解决:用专门支持「同步变换」的增强库,或者在自定义 Dataset 里把图像和标签拼成一个四通道数组一起变换,变换完再拆开。项目里如果自己写了train_transforms,务必检查每个 transform 是否同时接收image和mask两个参数。
6. 高分辨率航拍推理的验证技巧:滑窗预测与 TTA
毕业设计答辩的时候,光交一个 mIoU 数字是不够的,评委会看可视化结果——预测图叠在原图上是否边界对齐、小目标是否清晰。这一节分享一个我最常用的高分辨率推理验证技巧:滑窗重叠预测 + 多尺度 TTA,两者叠加能稳定提升 1-3 个 mIoU 点,而且代码只要几十行。
滑窗预测的核心思路是:推理时用 512x512 的窗口在大图上滑动,步长取 256 让相邻窗口有重叠,每个像素的预测结果取多个窗口的平均概率再 argmax。这样做的好处是窗口边缘的预测不再"孤零零",重叠区域的概率被平均后更稳定。配合多尺度 TTA——把每张测试图缩放成 0.75、1.0、1.25 三个尺度分别预测,再把概率图插值回原尺寸取平均——基本能达到单模型 + 简单后处理的精度上限。
def slide_predict(model, image, window=512, stride=256, scales=(0.75, 1.0), num_classes=8): """ 滑窗 + 多尺度预测,返回类别 ID 图 """ h, w = image.shape[:2] # 概率累积器 prob_acc = np.zeros((h, w, num_classes), dtype=np.float32) cnt = np.zeros((h, w, 1), dtype=np.float32) for scale in scales: img_scaled = cv2.resize(image, (int(w * scale), int(h * scale))) H, W = img_scaled.shape[:2] for y in range(0, H - window + 1, stride): for x in range(0, W - window + 1, stride): patch = img_scaled[y:y + window, x:x + window] # 归一化并转成 tensor tensor = torch.from_numpy(patch).permute(2, 0, 1).float().cuda().unsqueeze(0) with torch.no_grad(): # 原始概率和窗口坐标对应在原图的位置 prob = torch.softmax(model(tensor), dim=1).cpu().numpy() # (1, C, 512, 512) prob = prob[0].transpose(1, 2, 0) # (512, 512, C) # 将概率写回原图坐标(除以 scale 映射回去) x0, y0 = int(round(x / scale)), int(round(y / scale)) x1, y1 = x0 + window, y0 + window prob_acc[y0:y1, x0:x1] += prob[:min(window, h - y0), :min(window, w - x0)] cnt[y0:y1, x0:x1] += 1 prob_acc /= np.maximum(cnt, 1) # 被覆盖次数至少为 1 return np.argmax(prob_acc, axis=2)这段代码里有三个参数值得注意:stride=256决定了重叠率,越大精度越高但推理次数也越多;scales的尺度列表会影响 TTA 效果,航拍图像我用(0.75, 1.0, 1.25)比(0.5, 1.0, 1.5)效果好,因为后者的最小尺度放得太大,小目标直接糊没了;cnt计数器的存在是为了处理图像边缘处窗口覆盖不足的情况,没有它边缘像素的概率会被低估。
这个技巧在答辩时很加分——你可以直接展示同一张航拍原图,对比普通整图预测和滑窗+TTA 预测的结果,边界清晰度和目标完整性差距一眼可见。我从那以后每次做航拍分割推理都强制走一遍滑窗流程,宁可推理慢个几分钟也不直接整图上模型。真心希望这个项目能帮你把毕业设计的实验部分撑起来,把该踩的坑提前避开。
本文还有配套的精品资源,点击获取