资讯详情

全卷积网络FCN语义分割实战:PyTorch实现行人分割

📅 2026/10/20 10:27:54 | 华诺云谱 👁 阅读
全卷积网络FCN语义分割实战:PyTorch实现行人分割
简介全卷积网络FCN是图像分割领域的里程碑式模型Penn-Fudan Database由宾夕法尼亚大学与复旦大学联合构建专门用于行人检测与分割研究。面向深度学习与计算机视觉学习者这份资源将两者结合提供了一整套基于PyTorch的FCN训练与评估代码以及街景图像、分割掩模和对应的标注文件便于从数据加载到模型训练完整复现实验。资源共533个文件以340张PNG图像、172个TXT说明或日志、6个Python训练脚本为主另有NPY格式的评估指标文件和XML配置等压缩包约184.68MB目录按数据、代码和结果分类便于快速定位。目前已有504人学习使用适合希望深入理解FCN上采样、跳跃连接等原理并动手训练分割模型的读者可直接基于脚本调整批量大小、学习率等超参数并根据输出的平均交并比meanIU评估效果。1. 全卷积网络一张街头照片如何被逐像素抠出行人语义分割最直观的落地场景是拿一张街头照片让程序像PS一样把行人轮廓逐像素抠出来。全卷积网络Fully Convolutional Network正是这类任务的经典基线——把VGG这种分类网络里的全连接层全部丢掉改成卷积层让网络能接受任意尺寸输入并输出同分辨率的分割图。这份源码基于Penn-Fudan行人分割数据集实现了一条从数据加载、模型训练到预测可视化的完整pipeline而且FCN-8s的跳跃连接部分写得相当精简、易读。它适合两类人刚接触分割任务、想找一个最小可运行工程跑通全流程的入门者以及需要快速把单类别分割改成多类别、或者把骨干换成轻量网络做部署的开发者。接下来按模型、数据、代码、避坑的顺序拆开每一步都落到可执行的位置。2. 网络结构与设计从VGG16到FCN-8s的语义分割改造2.1 为什么要“全卷积”去掉全连接层的真正原因经典VGG16接收224×224的固定输入经过五个卷积块和三层全连接最终输出1000类概率。全连接层吃掉了绝大部分参数量同时带来两个问题输入尺寸必须固定特征一旦展平就丢掉了像素间的空间位置关系。分割任务恰恰是逐像素做预测空间位置就是命根子。FCN的做法非常直接——把全连接层换成卷积层让网络用滑窗的方式保留位置信息输入尺寸也不再受限。用conv6和conv7去替代VGG原来的全连接头是社区里的标准做法原第一个全连接层接收7×7×512的特征等价于一个7×7卷积第二个等价于1×1卷积。因此FCN里常见的是kernel7、padding3的conv6加kernel1的conv7把分类头转成密集预测头。这样改造后网络最终输出特征图的空间分辨率由最后一次池化决定语义分割的第一块地基就稳了。2.2 FCN-32s/16s/8s跳跃连接的三种配方与取舍池化层让特征图逐级缩水VGG16到pool5时已经缩到原图的1/32。如果直接把pool5的输出上采样32倍得到的是FCN-32s——最平滑也最粗糙的版本。高层特征语义强但几何细节丢得厉害直接放大到原图就像把一张缩略图强行拉伸轮廓糊成一团。FCN的改进点在“跳跃连接”把深层语义和浅层细节相加逐步找回边界。三条路径的差异用一个表就能看明白变体融合路径最终上采样倍率边界质量FCN-32s无融合32x轮廓模糊FCN-16spool5特征2x pool416x中等FCN-8s上一步2x pool38x最锐利FCN-16s把pool5特征上采样2倍与1×1卷积投影后的pool4特征逐元素相加再整体上采样16倍。FCN-8s在此基础上继续把融合后的1/16特征再上采样2倍与pool3特征相加最后上采样8倍回原图。这份源码默认实现FCN-8s但forward里每条路径都独立成层改成FCN-32s或16s只需要裁剪两行加法这为后续做消融实验留了很大余地。2.3 二值分割的损失设计与输出头选择Penn-Fudan的标注是像素级掩码背景为0、行人为1天然是二分类。输出头用1×1卷积把融合特征投影成单通道预测图配合Sigmoid和BCEWithLogitsLoss完成训练。这里有个非常常见的理解误区BCEWithLogitsLoss内部已经内置了Sigmoid计算输出层只需要给原始logits。如果自己在输出层又接一个Sigmoid训练初期loss会卡在log2附近不降预测值始终围绕0.5打转。换到多类别场景才需要不同设计输出通道数改成类别数损失换成CrossEntropyLoss训练目标从“每个像素是否前景”变成“每个像素属于哪个类”。2.4 预训练初始化与分层学习率VGG主干直接加载ImageNet预训练权重是分割项目拉开训练速度的关键。torchvision里一行vgg16(pretrainedTrue)就能拿到权重而FCN自己新增的conv6、conv7以及跳跃连接里的1×1投影层没有预训练权重必须随机初始化。如果共用同一个学习率随机初始化层会拖慢主干的微调节奏。我习惯给新增层配2倍学习率主干用基础学习率在Penn-Fudan这种百张量级的小数据集上甚至可以冻结pool3之前的全部参数只微调后面几层。等换到更大数据集再放开全量微调也不迟。3. 数据集处理Penn-Fudan行人分割标注的加载与增强3.1 标注格式与可视化检查Penn-Fudan的数据目录很干净PNGImages放原图Masks放同名掩码图。掩码是灰度PNG行人区域为白色255、背景为黑色0每个行人在掩码里直接以亮色区域呈现。初学者最容易忽略的一点是灰度掩码不会自动变成二值标签直接除以255也只是得到0到1之间的浮点标签阈值判断时会出问题。加载时应该用(mask 0).to(torch.long)把像素值规整到0和1这一步决定了后续损失函数能否正确收敛属于血泪经验级别的关键细节。拿到一份新分割数据集我强烈建议先随机抽三到五个样本把原图和掩码叠加显示出来确认标注轮廓与图片内容对齐而不是错位或者反相。经典数据集的掩码质量通常有保障但自己收集的数据经常出现掩码灰度值不统一、前景标签被误标成背景这类问题。视觉检查花不了五分钟却能避免训练两小时后才发现标签根本不配对的时间浪费。3.2 Dataset实现与增强策略PyTorch的Dataset类要保证__getitem__返回固定的元组原图、掩码、图像路径。原始图片用PIL读取掩码统一转成灰度模式再做尺寸预处理。关键决策在插值方式原图用双线性插值掩码必须使用最近邻插值。原因是掩码是离散类别标签双线性插值会在背景和前景之间生成0.5这种不存在的类别值最近邻插值才能保持标签纯净。这个取舍在工程里经常被忽视等到可视化预测结果时才发现“样本里多了一堆灰色边缘”。数据增强我采用随机水平翻转加小幅随机缩放再用RandomCrop固定到统一尺寸。分割任务的增强要特别留意几何变换的一致性原图水平翻转时掩码必须以相同概率同步翻转缩放时掩码插值方式仍然固定为最近邻。常见做法是把整套变换封装成一个类对image和mask共用同一组随机参数这样才能避免“原图翻了、掩码没翻”的错位问题。3.3 训练脚本的文件结构与DataLoader组装项目的文件划分很克制五个文件就能撑起完整流程文件职责model.pyFCN8s模型定义与上采样层初始化dataset.pyPennFudanDataset类与数据增强逻辑train.py训练循环、优化器、学习率调度predict.py加载权重、单图推理与可视化叠加utils.py像素准确率与IoU计算DataLoader组装时有个隐蔽问题Penn-Fudan原始图像尺寸并不是完全一致的直接按batch堆叠会在维度上翻车。常见解法有两种一是在Dataset里统一resize到固定尺寸模型输入和掩码尺寸都好控制二是保留原始尺寸在collate_fn里对同batch内的图片做padding补到最大宽高。前者简单稳定适合快速跑通后者能避免缩放带来的标注偏移适合追求精度的实验。这份代码里用的是统一resize方案想改第二种只需替换collate_fn一个函数。4. 核心代码拆解模型定义、训练循环与评估指标4.1 FCN8s模型定义VGG骨干与三层跳跃融合import torch import torch.nn as nn import torch.nn.functional as F from torchvision import models class FCN8s(nn.Module): def __init__(self, num_classes1, pretrainedTrue): super().__init__() vgg models.vgg16(pretrainedpretrained) features list(vgg.features.children()) # 按VGG的池化位置切出五个特征段 self.pool1 nn.Sequential(*features[:4]) # conv1_x pool1 self.pool2 nn.Sequential(*features[4:9]) # conv2_x pool2 self.pool3 nn.Sequential(*features[9:16]) # conv3_x pool3 self.pool4 nn.Sequential(*features[16:23]) # conv4_x pool4 self.pool5 nn.Sequential(*features[23:30]) # conv5_x pool5 # 用卷积层模拟VGG原来的全连接层 self.conv6 nn.Conv2d(512, 4096, kernel_size7, padding3) self.relu6 nn.ReLU(inplaceTrue) self.drop6 nn.Dropout2d(0.5) self.conv7 nn.Conv2d(4096, 4096, kernel_size1) self.relu7 nn.ReLU(inplaceTrue) self.drop7 nn.Dropout2d(0.5) # 三类预测头主输出 两路跳跃特征 self.score_final nn.Conv2d(4096, num_classes, kernel_size1) self.score_pool4 nn.Conv2d(512, num_classes, kernel_size1) self.score_pool3 nn.Conv2d(256, num_classes, kernel_size1) # 反卷积上采样倍率由kernel、stride、padding共同决定 self.upsample2x_1 nn.ConvTranspose2d(num_classes, num_classes, kernel_size4, stride2, padding1) self.upsample2x_2 nn.ConvTranspose2d(num_classes, num_classes, kernel_size4, stride2, padding1) self.upsample8x nn.ConvTranspose2d(num_classes, num_classes, kernel_size16, stride8, padding4) def forward(self, x): pool1 self.pool1(x) pool2 self.pool2(pool1) pool3 self.pool3(pool2) pool4 self.pool4(pool3) pool5 self.pool5(pool4) conv6 self.drop6(self.relu6(self.conv6(pool5))) conv7 self.drop7(self.relu7(self.conv7(conv6))) score_final self.score_final(conv7) score_pool4 self.score_pool4(pool4) score_pool3 self.score_pool3(pool3) # 第一路融合主分支上采样2倍后与pool4相加 out self.upsample2x_1(score_final) score_pool4 # 第二路融合上一步再上采样2倍后与pool3相加 out self.upsample2x_2(out) score_pool3 # 最终上采样8倍回到输入分辨率 out self.upsample8x(out) return out逻辑说明forward里的两个加法操作就是FCN-8s的跳跃连接核心。score_pool4和score_pool3先用1×1卷积把通道压到与主输出相同的num_classes这样逐元素相加时通道数天然对齐。转置卷积的kernel、stride、padding组合决定了上采样倍率2倍上采样用kernel4、stride2、padding18倍上采样用kernel16、stride8、padding4这组参数是PyTorch里转置卷积输出尺寸计算的标准配方。参数说明num_classes1对应二分类前景/背景改成多类别时这里是类别总数同时需要把损失函数从BCE换成CrossEntropyLoss前面2.3节已经提过。4.2 训练循环BCE损失、AdamW与学习率调度def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0.0 for images, masks in loader: images images.to(device) masks masks.to(device).float().unsqueeze(1) # [B, 1, H, W] optimizer.zero_grad() logits model(images) # [B, 1, H, W] loss criterion(logits, masks) # BCEWithLogitsLoss loss.backward() optimizer.step() total_loss loss.item() * images.size(0) return total_loss / len(loader.dataset)逻辑说明掩码原始形状是[B, H, W]而logits是[B, 1, H, W]必须用unsqueeze(1)在通道维上补一个维度否则BCEWithLogitsLoss在计算时维度广播会对不上。训练时标签转成float是BCE损失的要求CrossEntropyLoss则要求标签保持long类型。优化器使用AdamW初始学习率1e-4每20个epoch乘以0.9做阶梯式衰减。对这种百张量级的小数据集40到60个epoch足够让前景IoU稳定下来。为什么不用SGD加momentumAdam在调参不充分时更稳SGD对学习率和momentum的组合更敏感项目默认AdamW是在降低跑通门槛追求更高精度时再切SGD不迟。4.3 预测流程与概率图可视化def predict(model, image_path, device): model.eval() img Image.open(image_path).convert(RGB) w, h img.size img_t transform(img).unsqueeze(0).to(device) # [1, 3, H, W] with torch.no_grad(): logits model(img_t) probs torch.sigmoid(logits) mask (probs 0.5).squeeze(0).squeeze(0).cpu().numpy() # [H, W] # 原图上叠加半透明红色蒙层 overlay np.zeros((h, w, 4), dtypenp.uint8) overlay[mask] (255, 0, 0, 160) return Image.alpha_composite(img.convert(RGBA), Image.fromarray(overlay))逻辑说明推理时把模型切到eval模式关闭梯度计算。sigmoid把logits转换成0到1的概率值阈值0.5是二分类的默认选择。如果前景区域比较稀疏、模型输出概率整体偏低可以把阈值降到0.3再观察效果这属于玄学调参范畴最终以肉眼视觉效果为准。注意mask经过两次squeeze之后从[B,1,H,W]变成[H,W]的二维数组正好对应原图的像素网格。4.4 评估指标像素准确率与mIoU的落地实现def compute_iou(pred_mask, true_mask): pred_mask pred_mask 0 true_mask true_mask 0 intersection (pred_mask true_mask).sum() union (pred_mask | true_mask).sum() if union 0: return 1.0 if intersection 0 else 0.0 return intersection / union def evaluate(model, loader, device): model.eval() iou_list [] correct, total 0, 0 with torch.no_grad(): for images, masks in loader: logits model(images.to(device)) preds logits 0 masks masks.bool() correct (preds masks).sum().item() total masks.numel() for p, t in zip(preds.cpu(), masks.cpu()): iou_list.append(compute_iou(p, t)) pa correct / total miou float(np.mean(iou_list)) return pa, miou逻辑说明IoU计算里union0的分支特别重要一张图完全没有前景时网络也预测全背景此时两者完全一致按定义应当计为1.0而不是除零报错。像素准确率PA在背景占比极高的图片上会虚高——一张图99%是背景模型全预测背景也能拿99%的准确率。因此分割任务更关注mIoU它对类别不平衡更敏感。评估时把预测和标签都转成bool再比较能直接利用PyTorch的张量广播机制避免写多层for循环。5. 常见避坑训练分割模型最容易踩的五个坑5.1 损失函数不下降Sigmoid与BCEWithLogitsLoss被重复叠加现象训练前几步loss卡在0.693附近预测输出全是0.5左右的中间值几个epoch过去纹丝不动。原因输出层加了nn.Sigmoid()损失函数又选了BCEWithLogitsLossSigmoid被算了两遍。解决输出层只保留原始logits推理时再用torch.sigmoid做概率转换。在代码里检查的方法是打印logits的数值范围如果最大值不超过1多半就是叠加了Sigmoid的典型症状。5.2 掩码标签没归一化预测结果全黑或全白现象训练loss正常下降但可视化分割结果要么全黑要么全白跟原图像素没有对应关系。原因掩码是0到255的灰度图有人直接除以255得到0到1之间的浮点标签模型把“0.5”当成了一个独立类别或者前景被设成255验证时掩码和预测二值化的阈值对不上。解决在Dataset加载阶段统一执行(mask 0).to(torch.long)只做一次规整后续所有逻辑都基于0/1标签能少掉一半的调试时间。5.3 上采样特征图尺寸对不上forward直接报错现象训练跑到第一个step就报shape mismatch报错集中在torch.add操作上。原因VGG的maxpool在偶数尺寸下可以整除但输入宽高一旦是奇数pool后的特征经过转置卷积无法恢复成整数倍尺寸pool5和pool4特征差出一个像素。解决最简单的办法是训练时把输入统一resize到16的倍数一劳永逸如果想保留任意输入尺寸把固定倍率的转置卷积换成F.interpolate(sizepool4.shape[2:])做动态对齐牺牲一点性能换取鲁棒性。5.4 显存不足训练中断Batch Size降到1还是不够现象训练到第二步OOM退出改小Batch Size之后仍然训练很慢甚至继续OOM。原因VGG16的conv7有4096个通道在1/32分辨率上依然占大量显存转置卷积的梯度回传又额外翻了一倍显存开销。解决先把Batch Size降到2验证显存是否够用再把Dropout关掉或者把conv7通道数从4096改成1024这一步能显著压缩显存占用。对这部分代码还可以用torch.cuda.amp混合精度训练在支持自动混合精度的显卡上能省接近一半显存且精度损失通常可以忽略。5.5 mIoU指标好看但边缘全是锯齿可视化效果与数据对不上现象mIoU做到0.7以上放大预测图边缘却全是毛刺明显比标注粗糙。原因交叉熵损失独立处理每个像素没有显式惩罚边界处的几何结构模型在类别主体上得分高、在边界像素上摇摆不定。解决在损失函数里加一个Dice loss分支或者对边缘像素手动加权。这是分割任务里最典型的“指标好看、实际效果难看”的案例以后凡是评估模型效果我都会把可视化结果和指标一起看两者缺一不可。6. 二次开发从二值分割扩展到多类别与轻量部署6.1 多类别分割的三处核心改动把二值分割扩成多类别只需要改三个地方。模型层把num_classes从1改成类别总数比如8车道场景就是FCN8s(num_classes8)损失函数把BCEWithLogitsLoss换成CrossEntropyLoss默认的ignore_index可以设成255跳过标注中的不确定区域标签处理不能再走mask 0而是保留mask的类别索引训练时标签保持long类型。评估逻辑也要同步改IoU从“前景对背景”变成每个类别逐一计算再取平均。model FCN8s(num_classes8) criterion nn.CrossEntropyLoss(ignore_index255) # 标签shape从[B,1,H,W]变成[B,H,W]保持long类型这组改动里最容易翻车的点是通道顺序logits的通道维输出[B,8,H,W]而CrossEntropyLoss期望的目标是[B,H,W]的索引图不用刻意做one-hot编码PyTorch会自动在内部完成比对。6.2 更换轻量骨干与ONNX部署显存紧张或推理速度敏感时把VGG骨干换成MobileNetV3是常见路径。替换时要同步调整跳跃连接的特征通道数VGG的pool4是512通道、pool3是256通道MobileNetV3末端通道数完全不同score_pool4和score_pool3的输入通道必须跟着改。另一个部署技巧是把训练好的模型导出成ONNX格式导出时把输入尺寸固定成[1,3,512,512]再用ONNX Runtime做推理可以绕开PyTorch的依赖环境。我一般会在导出前用torch.onnx.export跑一遍单张图片确认输出shape与原模型一致再交出去。6.3 改造后的快速验证套路每次改完结构我会用同一张小测试图做三件事打印logits的shape确认输出分辨率与输入一致检查预测类别的数值范围确认没有NaN或者全零输出叠加可视化原图观察三类以上目标的边界是否粘连。这三个动作加起来不到两分钟却能拦住大多数结构改动引入的回归问题。从那以后我每次调整模型结构都强制走一遍这套验证流程宁可多跑几秒钟也不把不确定的模型扔进训练循环。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑