FCN语义分割实战:从PASCAL VOC数据管线到PyTorch训练与调参避坑
发布时间:2026/10/1 8:12:54来源:尧图网络
简介面向图像分割入门与进阶开发者的全卷积网络FCN实战资源基于FCN完成像素级语义分割任务与经典CNN末层全连接输出方式不同可直接输入任意尺寸图像通过反卷积上采样恢复至原始分辨率逐像素预测并保留空间信息。压缩包共两千个文件主体为XML标注文件与PNG/JPG图像另含Python脚本、TXT说明及JSON配置总大小约三百三十五兆字节。目前已有一千五百三十二人学习并下载。包内覆盖数据加载、图像增强、训练、验证与预测全流程支持自定义数据集与分布式训练并附Pascal VOC类别定义和调色板JSON便于直接解压运行配合包内图像与标注文件可从数据准备到分割结果可视化完整复现实验。对于想理解FCN反卷积上采样与逐像素分类细节的读者这套资源提供了清晰且可操作的参考实现。1. 全卷积网络FCN为什么语义分割必须抛弃全连接层拿到一份标注好物体轮廓的街景图你想让模型告诉每个像素是道路、行人还是汽车。传统CNN做了卷积之后接一个全连接层最后输出的是「这张图里有一个行人」这种整体标签遇到像素级分类就卡住了——位置信息被全连接层压成了一维向量空间细节全丢。FCN的做法很直接把全连接层全部换成卷积层让网络从头到尾只做卷积和下采样最后用反卷积把特征图放大回原图尺寸这样每个像素都能拿到一个分类预测。第一次跑通FCN的预测结果时我盯着那个轮廓完整的彩色mask看了很久终于明白什么叫「像素级的分类」。这份资源是一个基于PyTorch的FCN语义分割工程文件里包含从数据读取、训练验证到预测可视化的全套脚本。如果你正打算接触语义分割或者想把FCN用在无人机图像、工业质检之类的场景上先拿这套代码复现一遍比看十篇原理讲都管用。工程用到的数据集是Pascal VOC格式代码里也做好了类别映射和颜色映射新手可以端到端跑通熟手能直接替换成自己的数据集做微调。2. 数据管线拆解my_dataset.py、transforms.py 与 PASCAL VOC2.1 my_dataset.py读取图像和分割掩膜的底层逻辑语义分割的训练数据和图像分类不一样每张图必须配一张尺寸相同的mask图mask里每个像素的值是这个像素的类别索引。这份工程里的 my_dataset.py 就是负责把图像和mask配对读进来的模块。常见的写法是继承 torch.utils.data.Dataset然后在getitem里同时读取两张图片注意两张图的路径要对应好。# my_dataset.py 的核心结构 import torch from PIL import Image import os class VOCSegmentation(torch.utils.data.Dataset): def __init__(self, root, img_list, mask_list, transformNone): self.root root self.img_list img_list # 图像相对路径列表 self.mask_list mask_list # mask相对路径列表 self.transform transform def __len__(self): return len(self.img_list) def __getitem__(self, idx): img_path os.path.join(self.root, self.img_list[idx]) mask_path os.path.join(self.root, self.mask_list[idx]) img Image.open(img_path).convert(RGB) mask Image.open(mask_path).convert(L) # 单通道像素值即类别索引 if self.transform is not None: img, mask self.transform(img, mask) return img, mask这段代码的关键在于 mask 用convert(L)打开转成单通道灰度图。Pascal VOC 的 mask 是调色板类型的PNG直接读出来是P模式转成L模式后每个像素值就直接对应类别索引了不需要再做额外解析。如果你用的是自己的数据集一定要先确认mask的像素值是从0开始连续的整数否则交叉熵损失算出来的类别数对不上。2.2 transforms.py尺寸缩放和归一化的选择FCN理论上可以接受任意尺寸输入但实际训练时为了让batch里的数据形状一致还是要固定尺寸。这份资源里的 transforms.py 处理了几个关键点图像和mask必须使用同样的随机缩放参数不能一个裁了另一个没裁归一化操作只对图像做mask不能做最后把图像从HWC转成CHW张量。很多新手翻车就翻在改了图像尺寸忘了同步改mask训练时损失直接爆掉。# transforms.py 中自定义的双输入变换 import random import torch from torchvision import transforms from PIL import Image class Compose: def __init__(self, transforms): self.transforms transforms def __call__(self, img, mask): for t in self.transforms: img, mask t(img, mask) return img, mask class RandomResize: def __init__(self, min_size320, max_size512): self.min_size min_size self.max_size max_size def __call__(self, img, mask): size random.randint(self.min_size, self.max_size) w, h img.size if w ! size or h ! size: img img.resize((size, size), Image.BILINEAR) mask mask.resize((size, size), Image.NEAREST) return img, maskmask 缩放必须用Image.NEAREST不能用BILINEAR。因为mask里面存的是离散的类别索引双线性插值会插出2.7这种小数类别就乱了。图像缩放用BILINEAR可以保留细节mask用NEAREST保证类别不变这是语义分割数据增强的铁律。训练时输入尺寸定在320到512之间随机变化为了让模型对尺度不那么敏感预测时再统一缩放到一个固定尺寸。2.3 palette.json 和 pascal_voc_classes.json类别映射和颜色映射这份工程里有两个JSON文件pascal_voc_classes.json 存的是类别名称列表palette.json 存的是每个类别对应的RGB颜色。Pascal VOC一共20类物体加一个背景共21类。类别名称用来在日志里显示当前类的名字颜色用在可视化上——预测出来的mask是单通道索引图直接看是黑的必须通过调色板映射成彩色才直观。// pascal_voc_classes.json 的格式 { 0: background, 1: aeroplane, 2: bicycle, ... 20: tvmonitor }// palette.json 的格式 { 0: [0, 0, 0], 1: [128, 0, 0], 2: [0, 128, 0], ... 20: [64, 192, 0] }这个颜色映射不是随便定的Pascal VOC官方就给了这么一套调色板不同类别的训练数据也是按这套颜色标注的。你替换成自己数据集的时候记得把palette.json改成自己的颜色否则预测可视化时所有像素都会变成黑色或错色。Image_del.py 是辅助脚本用于清理数据集里标注损坏或尺寸异常的图片跑之前先扫一遍数据避免训练到一半崩掉。3. 训练脚本实战train.py 与 train_and_eval.py3.1 模型构建backbone选择与aux参数这份工程的 train.py 里模型部分借鉴了TorchVision的实现方式backbone可以选择MobileNetV2或者ResNet50。FCN不自己造特征提取网络一般是拿分类backbone的前半段用后半段的全连接层去掉再接上卷积和上采样。MobileNetV2适合显存小的场景ResNet50精度更高但吃显存。工程里还有个 aux 参数控制要不要开启辅助分类器也就是在backbone中间层额外分出一路损失辅助训练。# train.py 中构建模型的逻辑 import torchvision def create_model(auxFalse, num_classes21, pretrainTrue): # 使用 torchvision 自带的 fcn_resnet50 结构 model torchvision.models.segmentation.fcn_resnet50( pretrainedpretrain, num_classesnum_classes, aux_lossaux ) return modelpretrainTrue的意思是加载在ImageNet上分类预训练的权重不是直接加载分割权重。这样做的好处是backbone已经学到了丰富的特征微调几轮就能收敛。如果你的数据集类别不是21类一定把num_classes改成自己的类数类别数量是包含背景的千万别少算。aux_lossTrue时训练损失是主损失加辅助损失辅助损失权重要低一些在这份工程里是主流损失加0.4倍的辅助损失。3.2 训练参数学习率、batch size、损失函数语义分割的内存占用比分类大得多一张512x512的图送进ResNet50的FCN显存轻松超过3G。工程里默认batch size是4学习率设置为0.0001这里的经验是分割任务用大批次很难堆起来小批次下学习率也一定要调小否则loss直接飞掉。损失函数用的是交叉熵没有加类别权重但工程里写了 ignore_index255 的选项用来忽略标注中白色的模糊区域。# train.py 中的训练循环关键代码 criterion torch.nn.CrossEntropyLoss(ignore_index255) optimizer torch.optim.SGD(model.parameters(), lr0.0001, momentum0.9, weight_decay1e-4) for images, masks in dataloader: images images.to(device) masks masks.to(device).long() # mask 转成 long 型交叉熵要求 output model(images) if isinstance(output, dict): loss criterion(output[out], masks) aux_out output.get(aux, None) if aux_out is not None: aux_loss criterion(aux_out, masks) loss loss 0.4 * aux_loss else: loss criterion(output, masks) optimizer.zero_grad() loss.backward() optimizer.step()masks.to(device).long()这一步很关键。PyTorch 的交叉熵损失期望输入是浮点型预测logits和长整型类别索引。如果mask是从图像直接读出来的默认是uint8不转成long会直接报类型错误或者静默计算成错误结果。ignore_index255是Pascal VOC数据集的规则标注里像素值255表示这个区域不参与损失计算设置了这个参数后那些模糊难分的边界像素就不会干扰梯度。3.3 分布式训练distributed_utils.py 在单机多卡下的用法训练脚本里带了 distributed_utils.py这是太阳花小组常用的分布式训练工具包内容包含init_distributed_mode初始化分布式环境、save_on_master只在主进程保存模型、is_main_process判断当前进程以及reduce_value把多卡指标归约到主进程。使用单机多卡训练时不需要把代码里所有逻辑都改掉只需要调用它的初始化函数。# 训练前初始化分布式环境 import distributed_utils as utils utils.init_distributed_mode(args) # 如果当前进程不是主进程关闭打印日志 if not utils.is_main_process(): torch.distributed.disable_print_logs() # 多卡训练时用 DistributedDataParallel 包裹模型 if torch.distributed.is_available() and torch.distributed.get_rank() ! -1: model torch.nn.SyncBatchNorm.convert_sync_batchnorm(model) # 把BN同步 model torch.nn.parallel.DistributedDataParallel( model, device_ids[args.local_rank] )convert_sync_batchnorm在语义分割里很有用它把每个GPU单独计算的BN统计量变成全局同步相当于扩大了batch size来计算均值和方差。分割任务中batch size本来就小每卡4张图batch统计噪声很大同步BN能明显提升精度代价是多卡通信变多、训练速度略微下降。如果你没有多卡环境直接在单卡上跑 train.py分布式初始化会被绕过不影响训练。4. 预测与可视化predict.py 让分割结果变成彩色图4.1 预测流程Resize 与反归一化的顺序训练完的模型在 predict.py 里做推理。它和训练时的数据预处理保持一致的逻辑但有几个顺序问题要注意。第一步读图后做归一化然后resize到模型输入尺寸把图像从HWC转成CHW推理完得到的张量尺寸是[C, H, W]C是类别数要对第一个维度取argmax得到索引图最后把索引图resize回原图的大小。这个resize必须用最近邻插值。# predict.py 预测一张图片的核心逻辑 import torch from PIL import Image import numpy as np from transforms import Normalize, ToTensor def predict_one_image(model, image_path, device): img Image.open(image_path).convert(RGB) orig_w, orig_h img.size # 归一化 转张量 img_tensor ToTensor()(img).unsqueeze(0).to(device) model.eval() with torch.no_grad(): output model(img_tensor) if isinstance(output, dict): logits output[out] else: logits output pred logits.argmax(dim1).squeeze(0).cpu().numpy() # [H, W]每个像素是类别索引 # 恢复原始尺寸使用最近邻插值 pred_img Image.fromarray(pred.astype(np.uint8)) pred_img pred_img.resize((orig_w, orig_h), Image.NEAREST) return pred_img这里有个常见错误是先去argmax再resize顺序是对的但resize时忘了用NEAREST导致生成的索引图出现毛毛糙糙的插值色块。因为索引图是离散值插值算法会生成不存在的类序号后面查颜色表直接报key错误。预测时模型输入尺寸一般用训练时的最大尺寸比如512这样小目标不容易丢失但是推理会变慢如果追求速度也可以用320推理精度会掉两三个点。4.2 palette.json 的用法颜色映射表预测出来的索引图上每一个像素值0、1、2代表的是类别编号直接保存成灰色图几乎看不清。predict.py 里会把调色板读进来把索引图转换成真正的RGB彩色图。Pascal VOC的调色板不是简单的按索引递增而是16位颜色值必须依赖palette.json。# 用调色板生成彩色分割图 import json def apply_palette(pred_img, palette_path): with open(palette_path, r) as f: palette json.load(f) # 把 {类别: [R,G,B]} 转成PIL需要的调色板列表 flat_palette [] for i in range(len(palette)): r, g, b palette[str(i)] flat_palette [r, g, b] pred_img.putpalette(flat_palette) return pred_img.convert(RGB)这里要注意putpalette接受的列表长度必须是256*3不足的部分PIL会用0填充。如果你的类别数小于256直接把前面21类填进去剩下的都是黑色。彩色化之后还可以把原图和分割结果做个半透明叠加方便查看边界对齐情况。如果你想保存调色板文件就把这个RGB列表序列化到JSON下次直接加载即可。5. 避坑指南FCN 训练中的五类典型翻车现场5.1 loss变成NaN原因在于mask像素值和类别数对不上最开始跑这份工程时第二十个epoch后loss突然变成NaN。日志里精度还在涨loss值却变成了nan。排查时发现是mask里有些像素值是255但我没在CrossEntropyLoss里设置ignore_index。255是很大的边缘值经过softmax后梯度爆炸。解决方法是给损失函数加ignore_index255或者干脆转换数据时把255像素改成0背景不过更推荐保留255忽略因为标注里那些区域本来就该被忽略。5.2 训练loss下降但验证mIoU一直很低loss一直降验证集的mIoU卡在60%上不来。把预测结果可视化后发现所有物体边缘都有一圈错开的彩色边。这是数据增强时图像用了BILINEAR缩放、mask用了NEAREST但两者缩放的尺寸不一样导致的——我用的随机裁剪是分别对img和mask取中心结果图像裁了一个区域、mask裁了另一个。解决方法是确认Compose类里的每个变换都对img和mask使用同一个随机种子比如随机裁剪的坐标参数必须基于同一组值不能各自独立随机。5.3 预测结果整体错位一个尺寸宽用predict.py推理时分割图比原图大了一圈目标位置完全对不上。原因是我在把输出resize回原尺寸时把orig_w, orig_h当成了(height, width)来用。PIL里img.size的返回值顺序是(width, height)而我写resize时用(height, width)导致宽高互换。后来统一写成(orig_w, orig_h)这种命名方式并在resize后打印一下尺寸验证再也没翻过车。5.4 辅助损失0.4倍导致主损失曲线看起来不正常设置aux_lossTrue后训练日志里loss曲线的下降速度看起来很怪前几十个epoch主损失还在2.0左右总损失却已经到0.8了。因为辅助损失分支的收敛速度比主分支快加权重后总损失曲线不反映主任务进展。解决方法是训练日志里分别打印主损失和辅助损失不要把两个值混在一起看。我当时就是没分开打印白白困惑了两天。5.5 多卡训练结果跟单卡不一致使用分布式训练后每个epoch的验证mIoU比单卡低了几个百分点。查了资料才发现多卡训练时各个GPU上的BN统计量独立计算batch太小均值方差不稳定。解决方法是加了SyncBatchNorm.convert_sync_batchnorm同时把验证时的batch size调成单卡时一致的数值。从那以后我跑多卡分割任务前都会先看一眼backbone里是不是全卷积结构有没有BN层有的话必须同步。6. 效果验证与调参技巧mIoU、学习率策略与上采样细节6.1 mIoU的计算与阈值validation.py 里实现了mIoU的计算mIoU是全类别IoU的平均值。计算方式很简单但容易写错每个类别先算出预测正确像素数除以该类别真实像素数、预测像素数的并集然后再把所有类别的IoU取平均。多数实现用混淆矩阵来算矩阵中的第i行第j列表示真实类别i被预测成了类别j的像素数。# 基于混淆矩阵计算 mIoU def compute_miou(preds, masks, num_classes): miou 0.0 for pred, mask in zip(preds, masks): pred pred.argmax(dim0) for c in range(num_classes): i (mask c) (pred c) # 交集 u (mask c) | (pred c) # 并集 if u.sum() 0: miou i.sum() / u.sum() miou / len(preds) * num_classes return miou注意这个写法在某个类完全没有出现时u.sum()0不能把该类的IoU算成0直接加入求和否则会误伤整体指标。更稳的做法是维护一个混淆矩阵最后逐类计算。验证时建议每类单独打印IoU背景类IoU往往很高但路的边缘和细长杆类才反映模型真实水平。6.2 学习率与batch_size的配合这个工程里默认SGD学习率0.0001如果你把batch size从4改成8学习率应该上调到0.0002左右如果改成2学习率降到0.00005。线性缩放规则是经验值不是绝对公式但分割任务里batch size变大后BN统计更稳定确实允许更高的学习率。我用过余弦退火策略从0.0003衰减到0.0000005最后一轮的mIoU比固定学习率高了1.5个点建议工程里也把这个调度器加上。# 一个稳定的余弦退火调度 lr_scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max50, eta_min1e-6 )6.3 上采样方式双线性插值还是转置卷积FCN最后的上采样层可以用转置卷积也可以用双线性插值。转置卷积是带学习参数的理论上能学到更好的上采样映射但训练不稳定容易在分割边界出现棋盘格噪声。更稳的做法是在最后一层输出跟输入尺寸相同分辨率的特征图比如用双线性插值直接放大8倍到原尺寸然后接1x1卷积输出类别分数。预测时这个双线性插值不参与学习所以又快又稳。我一般会把backbone的最后两层的stride改成1再把输出分辨率从原图的1/32提升到1/8最后用一次双线性插值放大8倍。这样做比全反卷积少一半参数量也不容易产生网格状伪影。从那以后我每次跑FCN或DeepLab都会先写个几行脚本把预测结果和原图叠加起来看边界确认mask和原图对齐然后再去看mIoU数字。这一步能救回很多因为resize顺序、插值方式导致的隐性错位问题。希望这份工程能帮你省下我之前踩坑花的两周时间照着文档把流程跑通再按自己的数据去调细节语义分割的入门路会顺很多。本文还有配套的精品资源点击获取
网站建设高端定制企业官网