拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

DeepLabV3+语义分割实战:从VOC数据集到课设验收全流程指南

DeepLabV3+语义分割实战:从VOC数据集到课设验收全流程指南

简介:面向模式识别与机器学习课程设计及极市开发者平台打榜需求,是以开源DeepLabV3+为主干的项目包,实现对水体及漂浮物的像素级分割,并按面积阈值输出报警。适合具备Python和深度学习基础、需要完成结课项目或参与视觉分割竞赛的开发者。压缩包共258个文件,含120张png标注图、100张jpg真实场景图、26个py脚本,另有txt说明与sh辅助脚本,整体约29.77MB。图像多为河流、海洋等水域场景,py脚本覆盖数据预处理、模型训练与推理报警流程。已有223人浏览学习,可快速理解打榜项目的整体框架。实际可借助代码与样本复现完整分割流程,掌握面积阈值判断和报警输出设计;也可基于开源模型迁移至类似检测或分割任务,减少从零搭建成本。整体是课程设计与竞赛实践兼具的参考资源。

1. 一个开源模型撑起模式识别课设:DeepLabV3+到底能做什么

把DeepLabV3+用在模式识别与机器学习的课程结课项目里,是很多小组摸索之后发现的最稳路径。它做的是语义分割——不是给整张图一个类别,而是对每个像素做分类,输出和输入同尺寸的类别掩码。放到课设里,它能直观展示特征提取、上下文建模、类别决策这三件事,答辩时贴几张预测图,老师就能看懂你们做了什么。真正拉开分数差距的其实不在模型结构,而在数据准备、类别不平衡处理和mIoU验证这些环节上。这篇笔记就是一条按着顺序做就能走通的小组结课项目路线:数据、模型、训练、避坑、验收,一段一段说清楚。

2. 数据准备决定天花板:VOC数据集、Resize与标注对齐

语义分割课设和普通图像分类课设最大的差别,在于标签不是普通图片,而是索引图。很多小组第一行代码就栽在这里:用读RGB的方式读分割标签,模型训练出来的东西完全没法看。数据这一层没做好,后面换什么backbone都救不回来。

2.1 为什么先拿PASCAL VOC 2012打底

先跑通一个开源数据集,再决定要不要换自建数据,这是语义分割课设最常见的节奏。PASCAL VOC 2012一共20个物体类别加背景,共21类,数据规模在一万多张,既够训练一个像样的baseline,又不会像COCO那样一上来就是十几万张、类别80类,把小组的精力全耗在等待上。

如果你的课设题目已经限定在某个垂直场景,比如“航拍道路分割”“工业零件表面缺陷分割”,也建议先用VOC跑通整个流程,再加载自建数据做二次迁移。这个“先通用后专用”的路线本身就能在答辩时讲成一个小故事:你们用开源数据验证了DeepLabV3+的baseline能力,再迁移到自己的场景,逻辑上是完整的。不要一上来就自己标注几百张图,标注质量一旦出问题,模型训练全程都在跟脏数据搏斗,很难定位是模型的问题还是标签的问题。

2.2 目录结构与最小文件清单

VOC 2012的标准目录结构是这样的,拿到数据集后先按这个结构核对一遍,再写代码。

路径内容课设里怎么用
JPEGImages原始RGB图片,jpg格式模型输入
SegmentationClass与图片同名的png标签训练目标,像素值为类别索引
ImageSets/Segmentation/train.txt图片id列表,每行一个训练集划分
ImageSets/Segmentation/val.txt图片id列表,每行一个验证集划分

SegmentationClass里的png和普通真彩图不是一回事。它是一张索引图,每个像素的数值就是类别ID,0表示背景,1到20表示物体类别,255在VOC里通常作为边缘忽略区。你在看图软件里看到的各种颜色,是色板映射出来的视觉效果,不是模型要读的RGB通道。理解这一点,后面的读图代码才不会写错。

2.3 加载数据集:VOC格式的正确读法

下面是一个可以直接放进项目里的数据集类,按VOC标准格式读取图片和标签。

import os import torch import numpy as np from torch.utils.data import Dataset from PIL import Image class VOCSegDataset(Dataset): def __init__(self, root, split="train", img_size=512): self.root = root self.split = split self.img_size = img_size self.img_dir = os.path.join(root, "JPEGImages") self.mask_dir = os.path.join(root, "SegmentationClass") id_file = os.path.join(root, "ImageSets", "Segmentation", split + ".txt") with open(id_file, "r") as f: self.ids = [line.strip() for line in f.readlines()] def __len__(self): return len(self.ids) def __getitem__(self, idx): img_id = self.ids[idx] # 图片按RGB读 img = Image.open(os.path.join(self.img_dir, img_id + ".jpg")).convert("RGB") # 标签按P模式读,保留像素值即类别索引 mask = Image.open(os.path.join(self.mask_dir, img_id + ".png")) # 训练集做随机水平翻转,验证集不做增强 if self.split == "train": img = img.transpose(Image.FLIP_LEFT_RIGHT) mask = mask.transpose(Image.FLIP_LEFT_RIGHT) # 图片用双线性缩放到固定尺寸,标签用最近邻缩放 img = img.resize((self.img_size, self.img_size), Image.BILINEAR) mask = mask.resize((self.img_size, self.img_size), Image.NEAREST) # 归一化到[-1, 1]附近的区间,使用ImageNet统计量 img = np.asarray(img, dtype=np.float32) / 255.0 mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) img = (img - mean) / std img = torch.from_numpy(img).permute(2, 0, 1) # 标签直接读成int64,像素值本身就是类别号 mask = np.asarray(mask, dtype=np.int64) mask = torch.from_numpy(mask).long() return img, mask

这里有两个参数必须注意。第一,标签的resize只能用Image.NEAREST,不能用双线性插值。双线性会插出0.5、1.6这类非整数数值,交叉熵损失拿到这样的目标值会直接算错。第二,mask最终是(H, W)的long型张量,不是(C, H, W)的one-hot形式。PyTorch的CrossEntropyLoss接收的target本来就是(N, H, W)的类别索引,不需要自己做one-hot转换。

这个数据集类把图片缩放到固定大小,所以多个样本可以直接用DataLoader默认的collate_fn堆成batch,不会因为尺寸不齐报错。如果以后换成不resize的方案,就得自己写collate_fn按最大边padding,那是另一个复杂度,课设阶段没必要碰。

2.4 自建小数据集时最容易踩的标注坑

如果小组决定自建数据集,常见工具是LabelMe,画多边形后导出成VOC格式。导出后不要急着训练,先写一个检查脚本,统计每张标签图里出现的像素值。

from PIL import Image import numpy as np def check_mask(path): mask = np.array(Image.open(path)) classes = np.unique(mask) print(path, "包含类别编号:", classes) # 正常情况:0 背景,1~N 对应自定义类别 # 如果混入255或预期外的编号,说明标注导出色板有问题

这个脚本能快速暴露两类问题。一是导出时类别编号错位,比如背景变成了1、第一个物体类别变成了2,所有类别整体偏移;二是混入了255或随机噪声像素,这通常来自标注工具保存时的压缩抖动。VOC原本用255表示边缘忽略区,但自建数据里如果像素值只是零星噪点,把它当ignore_index处理反而会教模型忽略那些区域。更稳的做法是标注时避开边界,导出后检查确认只有0到N-1这些连续值。

注意:背景0是正常类别,在交叉熵里要参与训练。self建数据集时不要因为“背景不重要”就把0从损失里排除,那样网络会连背景都分不清,可视化时整个图都是花的。

3. 模型搭建与backbone选型:ASPP、Decoder与跑得动的配置

数据准备好之后,第二个关键决策就是模型怎么搭。DeepLabV3+不是只能照抄别人的训练脚本,它的结构其实很清晰:一个backbone做特征提取,然后接ASPP模块做多尺度特征融合,最后用一个Decoder把低层特征和高层特征拼起来恢复边界细节。把这个结构讲明白,答辩时不虚。

3.1 DeepLabV3+的两个关键设计:ASPP和Decoder

ASPP的全称是Atrous Spatial Pyramid Pooling,空洞空间金字塔池化。它用多个不同空洞率的卷积并行处理同一份特征,相当于用不同大小的感受野去看同一个区域。小感受野抓局部纹理,大感受野抓全局上下文,拼在一起再投影,让网络同时拥有“看清细节”和“看懂场景”的能力。

decoder部分则是DeepLabV3+区别于DeepLabV3的核心。DeepLabV3直接把ASPP输出上采样到原始分辨率,边界的锯齿感很强。DeepLabV3+的做法是把backbone里靠近输入的低层特征也拿出来,经过一层1x1卷积压缩通道后,与ASPP输出上采样4倍的结果做拼接,再卷积融合。低层特征保留了更多空间位置信息,正好弥补空洞卷积和连续下采样丢失的边界细节。这个Decoder对课设这种小数据量任务提升很明显,也是老师最喜欢问的点。

3.2 backbone选型:显存和精度的博弈

backbone决定了整个模型的显存占用和训练速度,是课设选型里最不能拍脑袋决定的一环。

backbone显存压力精度典型表现训练速度课设适配度
MobileNetV2低中等快首选,普通笔记本也能跑
ResNet50中较好中有独立显卡时可选
ResNet101高最好慢不推荐,课设时间耗不起

课设环境大多只有一张显卡,甚至有些人用的是学校的云GPU,显存8G到12G. 512x512的输入加上ResNet50已经是比较吃紧的组合。我的建议是:如果之前没跑过语义分割,直接用MobileNetV2先拿到一版完整结果,再决定要不要换ResNet50。先保证“有结果”,再谈“结果更好”。

3.3 最小可跑的ASPP实现

课程设计不一定要从零手写整个DeepLabV3+,最省力的方式是直接用mmsegmentation这类开源训练库,选deeplabv3plus系列配置,把num_classes改成自己的类别数就能跑。但答辩时如果只知道调库、说不清结构,容易丢分,所以至少要能把ASPP写明白。下面这个ASPP模块可以直接放在自己的model.py里用于理解结构。

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().__init__() self.branches = nn.ModuleList() # 1x1卷积分支,相当于用感受野最小的方式提取特征 self.branches.append(nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) )) # 三个不同空洞率的3x3卷积分支,覆盖多尺度上下文 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.branches.append(nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) )) # 所有分支拼起来后做一次投影,控制通道数 self.project = nn.Sequential( nn.Conv2d(out_channels * (len(rates) + 2), out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) def forward(self, x): size = x.shape[-2:] outs = [] for branch in self.branches: out = branch(x) # 池化分支输出是1x1,要插值回当前特征图尺寸 if out.shape[-2:] != size: out = F.interpolate(out, size=size, mode="bilinear", align_corners=True) outs.append(out) return self.project(torch.cat(outs, dim=1))

代码里的rates=(6, 12, 18)是DeepLab系列常用的一组空洞率,对应的感受野从7到37不等,覆盖了从近处细节到远处上下文的范围。in_channels由backbone输出通道决定,MobileNetV2最后一层输出是1280,ResNet50最后一层输出是2048,使用时按实际情况传入。

3.4 迁移学习:加载预训练权重的关键一步

语义分割模型直接随机初始化训练,效果通常很差。常见做法是加载在ImageNet上预训练过的backbone权重,这属于迁移学习的范畴,也是模式识别与机器学习课设里最容易讲出亮点的部分。

# 加载预训练权重时,如果类别数不一致,分类头会被跳过 model = DeepLabV3Plus(num_classes=6) state_dict = torch.load("pretrained_backbone.pth", map_location="cpu") model.load_state_dict(state_dict, strict=False)

这里有个细节经常被忽略:strict=False会让加载器打印一堆“missing key”和“unexpected key”的警告。看到缺失的key集中在最后的分类卷积上,是正常的,因为你的类别数跟预训练权重不一致。但如果你发现缺失的key出现在backbone或者ASPP里,说明预训练权重和你的网络结构对不上,要回头检查backbone的输出维度是否匹配,而不是硬着头皮往下训。

另外要如实说清楚:torchvision自带的是deeplabv3_resnet101,它跟DeepLabV3+不是同一个结构,少了一个Decoder。如果课设题目明确要求DeepLabV3+,别拿torchvision那个模型改名顶替,答辩老师一眼就能看穿。要么自己照着mmsegmentation实现Decoder,要么就用mmsegmentation的deeplabv3plus配置来训练,这两种方式都站得住。

4. 训练参数实战:损失函数、学习率与类别不平衡怎么配

模型结构定了之后,训练参数就成了决定整个课设命运的部分。同样是DeepLabV3+,有人跑出来mIoU能到0.6,有人跑出来只有0.1,差别主要不在显卡,而在损失函数怎么配、学习率怎么降、类别不平衡怎么处理。

4.1 损失函数:CrossEntropy是基线,什么时候加Dice Loss

逐像素分类任务的默认损失就是CrossEntropyLoss,PyTorch里一行代码就能用。VOC有21类,直接对每个像素算交叉熵,整个batch取平均。但背景像素在大多数图中占比很高,普通交叉熵会让网络过度关注背景,损失数值降得很漂亮,前景却一塌糊涂。

常见的做法是对类别做加权,让样本少的类别获得更大的损失权重。

import torch # class_counts 是每个类别在整个训练集里出现的像素总数 # 用根号倒数软化权重,避免背景占比过大导致前景权重爆炸 weights = 1.0 / torch.sqrt(class_counts + 1e-6) weights = weights / weights.sum() * len(class_counts) criterion = torch.nn.CrossEntropyLoss(weight=weights, ignore_index=255)

torch.sqrt是一种常用的软化技巧。如果直接用像素占比的倒数,背景占90%时前景权重会非常高,损失值动不动就上千,学习率完全没法调。加了根号之后,权重压缩到一个温和的范围内,模型训练稳定得多。ignore_index=255必须带上,否则VOC标签里那些边缘忽略区域会被当成类别255参与反向传播,训练过程会非常诡异。

Dice Loss在医学分割里很流行,它直接优化区域重叠度,对类别不平衡更敏感,但梯度不平滑,多分类场景下一开始就上Dice很容易把训练带崩。课设的合理用法是先跑一版交叉熵作为baseline,如果某些小类别IoU确实上不去,再考虑把Dice Loss按一定权重加进来对比。

4.2 学习率策略:poly衰减是DeepLab系列的加分项

语义分割模型的学习率策略和普通分类不太一样。DeepLab系列最经典的做法是poly衰减,让学习率在整个训练过程中按多项式曲线平滑下降,既在前段保持足够的探索能力,又能在后段收敛到稳定区域。

def poly_lr(epoch, max_epoch, base_lr=0.01, power=0.9): return base_lr * (1 - epoch / max_epoch) ** power

严格按iteration计算更准确,但课设阶段按epoch算也够用。如果你只训练30到50个epoch,poly衰减和固定学习率差距不大,可以直接用固定学习率省事。优化器方面,语义分割最稳的组合是SGD加momentum。

优化器常用初始学习率适用情况
SGD0.01到0.02配合momentum=0.9,weight_decay=1e-4,课设最稳
AdamW0.001到0.002batch很小或SGD不收敛时更稳,学习率要调低

实操时有个血泪经验:batch size如果只有4,BatchNorm的统计量会非常不稳,损失曲线来回震荡。这时候不要盲目加学习率,先把batch调到8或16。显卡不够就用梯度累计,等效放大batch,效果比调学习率更直接。

4.3 类别不平衡:模型把整张图都预测成背景怎么办

这件事值得单独拿出来说。VOC数据里背景像素占比经常超过70%,如果数据过滤不严格,模型很容易收敛到“所有像素都预测背景”的局部最优解。损失数值看着在降,可视化出来全图一片黑,mIoU趋近于零。

除了加权损失,还有一个非常管用的前置步骤:过滤掉那些完全没有前景物体的训练图片。VOC本身大多数图片都含目标,但自建数据里大量“只有背景”的图片非常常见。

import numpy as np from PIL import Image def filter_fg_ids(root, split_file): keep = [] with open(split_file) as f: for line in f: img_id = line.strip() mask = np.array(Image.open(f"{root}/SegmentationClass/{img_id}.png")) # 像素值种类大于1说明至少含一个前景类别 if len(np.unique(mask)) > 1: keep.append(img_id) return keep

这个过滤步骤放在数据集切分阶段执行一次,不影响DataLoader的加载速度。它的作用不是让模型不学背景,而是避免训练batch里出现整批全是背景图的情况,让每一次梯度更新都对区分前景有实际帮助。配合加权损失,全背景预测的问题基本能解决。

5. 避坑与常见问题排查:小组课设里最容易翻车的5个环节

这部分是几个小组项目里反复出现的问题,按“现象、原因、解决”的顺序整理,按图索骥就行。

5.1 训练loss正常下降,但验证集所有输出都是背景

现象:训练损失从1.0降到0.3,曲线很好看,验证集mIoU却只有0.01,预测图全黑,一张物体轮廓都看不到。

原因:类别不平衡环境下,网络发现“全预测背景”就能拿到很低的交叉熵损失。这个局部最优非常强,尤其是batch里经常出现全背景图片时。损失下降不代表模型在学前景,它只是在把背景像素的置信度越调越高。

解决:先做上一节提到的前景过滤,保证每个batch至少有几张带物体的图;然后给CrossEntropyLoss加类别权重;最后别用最后一个epoch的权重做验收,保存验证集mIoU最高的checkpoint,训练后期模型经常在最后几个epoch跑偏。

5.2 显存不足:ResNet50在普通笔记本上直接OOM

现象:训练脚本启动后没多久就报CUDA out of memory,学生笔记本上的GTX系列显卡尤其常见。

原因:512x512输入加ResNet50,加上ASPP里多分支空洞卷积,中间特征占用非常大。batch设成2都可能撑不住,而batch太小又会导致BatchNorm不收敛。

解决:backbone换成MobileNetV2,显存占用能降一半以上;输入尺寸从512降到384甚至320,速度提升明显且精度损失不大;batch保持在8以上,不够时用梯度累计。另外,很多人忽略的是DataLoader的num_workers开太多也会抢显存,如果OOM发生在数据加载阶段,先把num_workers设成0试一次。

5.3 mIoU很高但可视化翻车:指标欺骗了你们

现象:验证集mIoU有0.68,看起来很体面,随机挑几张训练样本图一看,小物体全丢了,边缘全是锯齿,只有大块类别看得清。

原因:mIoU是所有类别IoU的平均值。VOC里大类别样本多,IoU高,小类别如bottle、pottedplant样本少,IoU很低。这个低值被平均到整体数值里之后,被大类别的高分掩盖了。

解决:训练结束后输出逐类IoU表格,不要只看总mIoU。如果某个小类别IoU只有0.1,单独标记出来,在报告里说明原因并提出改进方向。这比硬撑着说“我们mIoU达到了0.68”更有说服力。

5.4 验证集也做随机翻转,指标虚高几个点

现象:验证集结果比自己预期好很多,但拍成视频或跑新场景时表现明显偏差。

原因:数据加载代码复用了训练分支,验证时也执行了随机翻转、随机裁剪。验证评估变成了“开卷考试”,同一张图翻过来再预测一次,模型相当于在宽松条件下作答,指标自然偏高。

解决:数据增强只加在训练集分支,验证集只做resize和归一化。这个判断标准很简单:验证流程里出现随机函数,不管有没有random关键词,都算开卷。把训练和验证的数据处理拆成两个逻辑,分开维护。

5.5 小组多人环境不一致,队友的代码跑不起来

现象:负责训练的队友交出一个训练好的模型文件,其他人加载时发现缺算子、报版本错误,重训又复现不出原来的精度。

原因:PyTorch、mmsegmentation、PIL、numpy四个库只要有一个小版本不同,行为就会有差异。小组各跑各的环境,最后合代码时全是地狱。

解决:项目根目录放一份requirements.txt,用pip freeze > requirements.txt生成,精确到小版本。更稳的做法是整个小组共用同一台服务器或同一个conda环境,不要每人一台机器各装各的。训练时统一随机种子,虽然不能完全消除差异,但大部分问题是环境引起的,环境统一了问题就少一大半。

6. 验收与答辩前做冲刺:mIoU怎么算、结果图怎么出彩

模型训练完,距离提交还有一道验收关卡。不要拿训练日志里的loss去答辩,那只能说明模型在训练集上拟合了。老师要看的是验证集上的mIoU和逐类指标。

6.1 写一个独立于训练流程的评估脚本

训练代码和评估代码混在一起,容易在加载权重时出差错。单独写一个eval.py,输入模型和验证集,输出逐类IoU。

import numpy as np import torch def compute_miou(preds, masks, num_classes=21, ignore_index=255): # preds和masks都是(N, H, W)的long型张量 preds = preds.reshape(-1) masks = masks.reshape(-1) valid = masks != ignore_index preds, masks = preds[valid], masks[valid] ious = [] for c in range(num_classes): inter = ((preds == c) & (masks == c)).sum().item() union = ((preds == c) | (masks == c)).sum().item() if union == 0: ious.append(float("nan")) # 该类别未出现在当前验证集 else: ious.append(inter / union) return ious # 平均时用nanmean,跳过验证集里不存在的类别 ious = compute_miou(preds, masks) print("mIoU:", np.nanmean(ious))

计算时用(preds == c) & (masks == c)求交集,用(preds == c) | (masks == c)求并集。漏掉ignore_index过滤是常见错误,VOC的255边界区域一旦混入,IoU会略微失真。输出逐类IoU后,按类别排序,小类别垫底是正常的,关键是能解释原因。

6.2 可视化输出做三列对比

答辩时最直观的武器就是对比图。用matplotlib把原图、真实标签、预测标签横着排成三列,每张图下面标注文件名。这个比花哨的交互式demo更能说明问题——老师能一眼看到模型在哪里错、错成什么样。

可视化时注意标签图要用色板着色,不要直接显示灰度索引图。灰度图在投影仪上几乎看不清。没时间写着色函数的话,可以直接用VOC官方提供的voc12_colormap,网上到处都是这份标准色板,照着转就行。

6.3 答辩前至少跑一次小消融

准备一张简单的对比表,固定住epoch和输入尺寸,只改一个变量。表格不需要复杂,三到四行就够。

配置mIoU备注
MobileNetV2 + CrossEntropy0.58baseline
MobileNetV2 + 加权CrossEntropy0.64类别不平衡改进
ResNet50 + 加权CrossEntropy0.67换backbone提升

这张表回答了两个答辩必问题:“你们为什么选这个模型”和“你们的工作比baseline好在哪里”。我一般会把对应类别的IoU明细也打印出来贴在报告附录里,被追问时直接翻到那一页,比自己现场回忆靠谱得多。这个小习惯救过我很多次,也算是在这个课设方向上最值得留给自己的一份“后悔药”。希望帮到你。

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

返回列表