☰
基于PyTorch的SegNet图像分割入门与避坑指南
2026/10/10 8:10:44 网站建设 项目流程

简介:一套基于 PyTorch 实现的 SegNet 图像分割项目源码,面向计算机专业学生完成期末大作业、课程设计,也适合需要图像分割实战参考的开发者,内容涵盖数据准备、网络搭建、训练评估与推理可视化等环节。项目经导师指导并获 98 分评价,整体结构清晰、代码注释完整,可作为高分课程设计与期末大作业的完成模板。压缩包共 119 个文件,约 27.19MB;内含 14 个 Python 脚本、77 张 PNG 样例与结果图、1 个模型权重文件,以及 Dockerfile、shell 脚本、日志和说明文档,覆盖环境配置到运行复现的完整链路。文件按功能组织,配套说明文档写明使用方式,日志记录训练过程,便于对照调参,已有 176 人学习浏览。通过这份资料,读者可掌握 SegNet 编码-解码结构、PyTorch 数据集加载、损失函数、评估与模型保存加载,并借助附带配置复现实验,提升图像分割任务的动手能力。

1. 基于PyTorch的SegNet图像分割:为什么大作业选它更稳

临近课程设计提交截止,手里只有一块8G显存的老显卡,试了U-Net直接显存溢出,FCN的预测结果又糊成一片。把SegNet调通并跑出能写进报告的mIoU,是我当时性价比最高的选择。SegNet是图像分割领域的经典网络,核心思路是编码器记录池化索引,解码器用索引做上采样,省掉整套学习上采样的参数,显存占用小、训练稳定,很适合当深度学习大作业的骨干网络。这篇笔记写给正在做图像分割作业的学生,也写给想快速验证分割思路的开发者,从模型搭建、数据管线、训练评估到踩坑记录,一条线走完。

2. 从论文到PyTorch:编码器、解码器与池化索引的落地写法

2.1 SegNet解码器为什么不做上采样学习:池化索引是核心

图像分割任务里,FCN和U-Net的解码器一般用转置卷积或双线性插值来扩大分辨率。SegNet另走了一条路:编码过程中,max pooling会把每个窗口里最大值的位置记下来;解码时用这个索引把特征值放回原来的位置,空出来的地方补零,再交给后续卷积恢复细节。这个设计意味着解码器的上采样部分没有可学习参数,省下的参数量在输入分辨率稍大时体现得非常明显。

对比U-Net的跳连接,SegNet没有把编码器的特征图直接接到解码器,只传递了索引。代价是无法恢复高频细节,SegNet对小目标的精度整体不如U-Net,但训练更稳、显存更省。对大作业场景来说,这恰恰是最合适的取舍;写报告时,这个设计决策也容易讲明白。

2.2 搭出Encoder与Decoder:pool和unpool的成对写法

先定义最基础的卷积块和两个子模块。卷积块是Conv2d、BatchNorm2d加ReLU的组合,SegNet原论文里每组卷积后面都带BN,这也是它比FCN更容易收敛的原因之一。

import torch import torch.nn as nn def conv_block(in_channels, out_channels, kernel_size=3, padding=1): return nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=kernel_size, padding=padding), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) class SegNetEncoder(nn.Module): def __init__(self, in_channels, out_channels, num_layers=2): super().__init__() layers = [] for i in range(num_layers): cin = in_channels if i == 0 else out_channels layers.append(conv_block(cin, out_channels)) self.layers = nn.Sequential(*layers) self.pool = nn.MaxPool2d(kernel_size=2, stride=2, return_indices=True) def forward(self, x): x = self.layers(x) x, indices = self.pool(x) return x, indices

这里的num_layers对应原论文每组卷积的层数:前两组是2层,中间三组是3层。kernel_size=2, stride=2是SegNet的标准池化配置;return_indices=True是整条链路的关键,少写这个参数,后面取不到索引。forward里先卷积再池化,池化同时返回缩小后的特征图和索引。

对应的Decoder要做三件事:用MaxUnpool2d按索引把特征图放大回原尺寸,再走同样数量的卷积层恢复信息。MaxUnpool2d在PyTorch里必须显式传入output_size,原因是unpool的输出尺寸不能从索引直接推断,尤其当输入尺寸不是偶数倍数时容易踩坑。

class SegNetDecoder(nn.Module): def __init__(self, in_channels, out_channels, num_layers=2): super().__init__() self.unpool = nn.MaxUnpool2d(kernel_size=2, stride=2) layers = [] for i in range(num_layers): cin = in_channels if i == 0 else out_channels layers.append(conv_block(cin, out_channels)) self.layers = nn.Sequential(*layers) def forward(self, x, indices, output_size): x = self.unpool(x, indices, output_size=output_size) x = self.layers(x) return x

decoder的in_channels是上一层传来的通道数,第一层是512,最后一层输出通道数要等于分类类别数。卷积层数与左侧encoder保持一致,整体就是一个对称编解码结构。

2.3 组装完整SegNet:通道数与输入尺寸的约束

把五个encoder和五个decoder串起来,需要记录每个encoder池化前的尺寸,以及每次池化返回的索引。池化前的尺寸要提前存,否则最后一步unpool会报尺寸对不上。

阶段输出通道数卷积层数池化后空间尺寸
第1组642H/2, W/2
第2组1282H/4, W/4
第3组2563H/8, W/8
第4组5123H/16, W/16
第5组5123H/32, W/32
class SegNet(nn.Module): def __init__(self, in_channels=3, num_classes=21): super().__init__() self.encoders = nn.ModuleList([ SegNetEncoder(in_channels, 64, 2), SegNetEncoder(64, 128, 2), SegNetEncoder(128, 256, 3), SegNetEncoder(256, 512, 3), SegNetEncoder(512, 512, 3), ]) self.decoders = nn.ModuleList([ SegNetDecoder(512, 512, 3), SegNetDecoder(512, 256, 3), SegNetDecoder(256, 128, 2), SegNetDecoder(128, 64, 2), SegNetDecoder(64, num_classes, 2), ]) def forward(self, x): pool_indices = [] feat_sizes = [] for enc in self.encoders: feat_sizes.append((x.shape[2], x.shape[3])) x, indices = enc(x) pool_indices.append(indices) for i, dec in enumerate(self.decoders): dim_idx = len(self.decoders) - 1 - i x = dec(x, pool_indices[dim_idx], output_size=feat_sizes[dim_idx]) return x

forward里第一个循环做编码,第二个循环做解码。output_size填的是池化前的原始高宽;如果填成池化后的尺寸,unpool出来的图会小两倍,最终与标签对不上。

提示:输入图像必须保证能被2整除5次,也就是长宽是32的倍数。实际数据里很少有天然满足的图,我一般在Dataset里统一resize成256x256或360x480,省得后面处理尺寸错位。

3. 数据处理与Dataset实现:让SegNet吃上对齐的图和掩码

3.1 标签格式:彩色标注图必须先转成索引图

很多开源分割数据集给的是彩色标注而不是索引图。比如常用的CamVid和VOC,每类对应一种RGB颜色,直接拿去算CrossEntropy会出问题。loss计算期望的label是0到N-1的整数,不是三维的RGB。转换时要注意:背景通常是0,前景类别从1开始。

import numpy as np from PIL import Image import os def rgb_mask_to_index(rgb_path, class_colors, output_path): img = np.array(Image.open(rgb_path).convert('RGB')) index_map = np.zeros((img.shape[0], img.shape[1]), dtype=np.int64) for cls_id, color in enumerate(class_colors): mask = np.all(img == color, axis=-1) index_map[mask] = cls_id Image.fromarray(index_map.astype(np.uint8)).save(output_path)

class_colors是一个列表,第一位必须是背景RGB元组。例如某数据集背景是(0,0,0),道路是(128,0,0),建筑是(0,128,0)。逐类比较全图得到布尔mask再写入类别id,效率比逐像素循环高。保存格式用PNG不用JPEG,JPEG压缩会污染颜色值,导致映射错位。

这里要特别提醒:如果数据集的调色板本身很多,比如VOC有21类、256种颜色,逐类循环也没问题。若某些像素颜色不在class_colors里,它们会保持0,也就是被当成背景,你需要确认数据集里有没有未定义类别。常规模确定义时,建议把未定义颜色映射到255,并在损失函数里当作ignore_index跳过。

3.2 自定义Dataset与同步预处理:resize、翻转、归一化

图像和mask必须做相同的几何变换,否则输入和标签错位。torchvision里有一部分transform不能同时作用于两张图,比如RandomResizedCrop,直接在图像和掩码上分开用,裁剪位置不一样,结果就是错位的。我一般用PIL的resize加水平翻转组合,控制插值方式。

import torch import random from torch.utils.data import Dataset from PIL import Image class SegDataset(Dataset): def __init__(self, img_dir, mask_dir, size=(256, 256), train=True): self.img_dir = img_dir self.mask_dir = mask_dir self.img_list = sorted(os.listdir(img_dir)) self.mask_list = sorted(os.listdir(mask_dir)) self.size = size self.train = train def __len__(self): return len(self.img_list) def __getitem__(self, idx): img = Image.open(os.path.join(self.img_dir, self.img_list[idx])).convert('RGB') mask = Image.open(os.path.join(self.mask_dir, self.mask_list[idx])) img = img.resize(self.size, Image.BILINEAR) mask = mask.resize(self.size, Image.NEAREST) if self.train and random.random() < 0.5: img = img.transpose(Image.FLIP_LEFT_RIGHT) mask = mask.transpose(Image.FLIP_LEFT_RIGHT) img = torch.from_numpy(np.array(img)).permute(2, 0, 1).float() / 255.0 mask = torch.from_numpy(np.array(mask)).long() return img, mask

mask的resize必须用NEAREST,因为mask是离散类别编号,BILINEAR会把0和1插值成0.5,后续损失函数直接报错或者引入错误类别。img用BILINEAR是因为连续值需要平滑。翻转条件随机,只在训练时做增强,验证时不做。如果需要归一化,在返回前加:

mean = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1) std = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1) img = (img - mean) / std

归一化参数用ImageNet统计量即可。别在作业这种小数据集上自己重新统计均值方差,容易把图像分布拉偏,导致训练初期loss异常。

3.3 数据划分比例与验证集的固定

数据量不大时,我习惯按7:2:1划分训练、验证、测试,并且随机划分前先固定随机种子。分割任务的样本之间有空间相关性,尤其是视频帧抽取的数据集,不能直接随机打散,否则相邻帧可能同时出现在训练和验证里,mIoU虚高。正确做法是先按序列分组,再整组划分。

ids = list(range(len(all_samples))) random.seed(42) random.shuffle(ids) train_ids = ids[:int(0.7 * len(ids))] val_ids = ids[int(0.7 * len(ids)):int(0.9 * len(ids))] test_ids = ids[int(0.9 * len(ids)):]

这段划分逻辑在作业报告里也算一个可写的实验细节。如果数据集本身已经给了train/val/test目录,就不要再自己乱分,直接沿用官方划分,方便和别人论文里的baseline数字对比。

4. 训练与评估:CrossEntropy、Adam和mIoU的正确配合

4.1 损失函数与优化器:默认组合为什么够用

多分类分割任务用nn.CrossEntropyLoss,它内部已经带了softmax,所以模型最后一层不需要额外加激活函数。CrossEntropy支持ignore_index参数,适合把未定义区域的255类别跳过。如果背景占比过高,可以传入weight参数给低频类别更大权重。

criterion = nn.CrossEntropyLoss(ignore_index=255) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.5)

Adam在作业规模的数据集上收敛快,对学习率的容错高,建议先跑通再考虑换SGD。StepLR每20个epoch把学习率乘0.5,给损失曲线一个平台期。weight_decay不能太大,1e-4比较常规,太大会把BN的参数也压坏,出现训练loss不降反升的怪现象。

4.2 训练主循环与checkpoint:中断恢复的后悔药

训练循环结构很固定:取batch、前向、算loss、反向、裁剪梯度、更新。clip_grad_norm_不是必需,但建议加上,防止某个异常样本让梯度爆掉,导致loss突然变成NaN,全部训练作废。

def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss = 0.0 for images, masks in loader: images, masks = images.to(device), masks.to(device) optimizer.zero_grad() outputs = model(images) # (B, num_classes, H, W) loss = criterion(outputs, masks) # masks 是 (B, H, W),值为 0~N-1 loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=10.0) optimizer.step() total_loss += loss.item() * images.size(0) return total_loss / len(loader.dataset)

保存模型时别只存state_dict,把epoch和best_miou一起存,这样训练中断可以从最近checkpoint继续,不需要从头跑。

def save_checkpoint(model, optimizer, epoch, best_miou, path='segnet_checkpoint.pth'): torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'best_miou': best_miou }, path)

恢复时先把模型和优化器放到相同设备上,再load_state_dict,否则会报device mismatch。恢复之后手动设置optimizer.state_dict里的lr,scheduler的step从恢复的epoch接着走,避免学习率突然跳回初始值。

4.3 mIoU计算:报告里最常要求的主指标

像素准确率在类别不均衡时水分很大。背景占90%时,模型什么都不预测也能有0.9的准确率。mIoU按类别求交集除以并集,再取平均,每个类别都算数,是分割任务报告通常的主指标。

def compute_miou(outputs, masks, num_classes): preds = outputs.argmax(dim=1) miou_list = [] for cls in range(num_classes): pred_mask = (preds == cls) true_mask = (masks == cls) intersection = (pred_mask & true_mask).sum().float() union = (pred_mask | true_mask).sum().float() if union > 0: miou_list.append((intersection / union).item()) else: miou_list.append(0.0) return sum(miou_list) / len(miou_list)

这段代码对每个类别单独计算,避免直接在张量上算全局IoU的错误。如果某一类在验证集里完全没出现,union为0,此时按0计入或跳过都可以,但在报告里要写明,否则mIoU会被这些空类拉低很多。一般跑完每个epoch算一次验证集mIoU,保存最高值对应的模型。

5. SegNet训练避坑清单:四个高频翻车点与排查顺序

5.1 显存溢出:降低分辨率比调小batch更有效

现象:输入360x480,batch_size设成8,第二个epoch直接CUDA out of memory。

原因:SegNet解码器的unpool没有可学习参数,但encoder的中间特征图仍然要保留,解码器逐层还原时,每一层都要写一份完整分辨率的张量。峰值显存出现在多层特征同时驻留内存的时候,模型参数量小不等于显存占用小。

解决:先降分辨率,再降batch_size。把输入统一resize到256x256,batch_size调到4,能解决大部分溢出。如果还不够,启用混合精度:

scaler = torch.cuda.amp.GradScaler() for images, masks in loader: images, masks = images.to(device), masks.to(device) optimizer.zero_grad() with torch.autocast(device_type='cuda', dtype=torch.float16): outputs = model(images) loss = criterion(outputs, masks) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()

混合精度把激活值占用减半,实测在SegNet上能多塞近一倍的batch。GradScaler必须在优化器更新前调用,否则fp16梯度会下溢。

5.2 训练时loss正常,预测结果却全黑或全白

现象:epoch跑20个,loss从1.8降到0.3,但可视化预测图全是0或全是255。

原因:最常见的是标签数据本身是彩色RGB,直接把每个像素的三维值当成类别编号来读;其次是可视化时直接显示单通道索引图,索引0的背景画出来是黑色,索引1的类别画出来接近黑色,视觉上像全黑。

解决:训练前先把彩色图转成索引图,参考第3.1节。可视化时把索引图映射回RGB再显示:

def index_to_rgb(index_map, class_colors): h, w = index_map.shape rgb = np.zeros((h, w, 3), dtype=np.uint8) for cls_id, color in enumerate(class_colors): rgb[index_map == cls_id] = color return rgb

如果结果全白,大概率是255的ignore_index类别,或者索引越界后强转成uint8溢出成255。

5.3 mIoU低但loss不错:类别不均衡让背景主导了梯度

现象:训练loss一路下降,验证像素准确率很高,但mIoU只有0.25。看混淆矩阵发现绝大部分错分在少数前景类。

原因:分割数据集里的车辆、行人这类像素很少,背景和道路占大头。CrossEntropy在没有权重的情况下被背景主导,模型学到最后输出几乎全是背景。

解决:给CrossEntropy加weight参数,数值反比于类别像素频率:

class_weight = torch.tensor([0.1, 1.0, 2.0, 5.0, 10.0]).to(device) criterion = nn.CrossEntropyLoss(weight=class_weight, ignore_index=255)

具体权重可以用np.bincount统计训练集每个类别的像素数量,取倒数再归一化。数值建议限制在0.1到10之间,避免某些类别权重过大,把loss带偏。

5.4 unpool尺寸对不上,forward直接崩溃

现象:运行forward时报RuntimeError,信息里有size mismatch,位置在MaxUnpool2d或后面的卷积层。

原因:输入图片尺寸不是32的倍数。MaxUnpool2d在output_size参数给的尺寸和实际索引不匹配时会报错,常见原因是Dataset里只resize了短边,导致长边不规则。

解决:在Dataset构造时统一resize成固定(256, 256),或者pad到32的倍数。如果自己改过模型分支结构,记得同步更新feat_sizes的记录逻辑,只有所有encoder尺寸都记录正确时,unpool才不会出错。

6. 答辩进阶:三个让SegNet大作业加分的改动

6.1 用转置卷积替换MaxUnpool做对比实验

把Decoder里的unpool替换成转置卷积,模型参数量立刻增加,但mIoU通常小幅提升。改动只有一行:

self.unpool = nn.ConvTranspose2d( in_channels, in_channels, kernel_size=2, stride=2 ) x = self.unpool(x)

去掉indices和output_size相关逻辑后,原来的feat_sizes记录也可以删掉。用相同数据跑两版模型,对比参数量和mIoU,这一组对比能直接变成答辩PPT里的核心图表,比只贴一张分割结果图有说服力。

6.2 用调色板生成答辩可视化图

验证集挑5张图,把原图、真实标签、预测结果拼成一行保存。用第5.2节的index_to_rgb把索引图转成彩色图,再拼接:

result = np.concatenate([img, gt_rgb, pred_rgb], axis=1) Image.fromarray(result).save(f'epoch_{epoch}_sample_{i}.png')

每个epoch结束保存一次,答辩时可以展示训练过程中分割效果的演化,比只给最终结果更直观。

6.3 固定随机种子的复现习惯

PyTorch某些算子默认不确定,固定随机种子后训练结果才可复现。这是报告里“实验设置”部分最容易扣分、也最容易补上的细节。

def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)

在加载数据、创建模型之前调用一次,后续训练、划分数据集都基于这个随机状态。加上这一段,自己的实验也能重复跑出同一组数字,调参时不会分不清效果来自模型改动还是随机波动。

我自己做这个作业时,最懊恼的不是模型调不动,而是没在第一天就把彩色标签转成索引图,白白等了大半个训练循环才发现结果全黑,一晚上时间就这么没了。希望你少走这个弯路,也希望这份笔记里的方法和坑位对你有实际帮助。

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

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

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

立即咨询