
简介面向图像分割入门与进阶人群的 FCN 实战资料包围绕语义分割中像素级分类这一核心任务覆盖数据准备、模型训练、验证预测到结果评估的完整链路。资源共 2000 个文件压缩包约 335.39MB主体为 xml 标注文件与 png/jpg 图像数据配合 py 训练与推理脚本、json 类别映射及少量说明文档可支撑数据集组织、类别配置、模型调参与实验复现。已有 1533 人下载学习适合希望通过全卷积网络快速上手语义分割项目、并深入理解反卷积上采样与逐像素预测原理的读者。包内工程脚本经过拆分为独立模块可直接改造后接入自有数据也能对照 PASCAL VOC 类别配置快速验证训练效果降低从理论到实践的落地门槛。无论是课程设计、算法对比还是工程落地前的实验验证都能从中找到可复用的起点。1. 语义分割的痛点从框出来到像素级跨越第一次把FCN跑通的时候我看着输出的分割图愣了好几秒512×512的原图进去出来的还是512×512的像素级标签图每一栋房子、每一条路面边缘都贴得严丝合缝。在此之前我做的图像分割都是先检测目标框再往里填类别框永远比物体大一圈边缘全靠后期裁剪遇到不规则地物就翻车。FCN解决的就是这个核心问题——把分类网络末尾的全连接层全部换成卷积层再用反卷积把低分辨率特征图恢复到原图尺寸从而实现逐像素分类。这份实战项目包含了从数据读取、训练、验证到预测的完整脚本配合PASCAL VOC数据集可以端到端跑通语义分割流程。它适合已经会跑分类网络、想迈入分割方向并需要一份能直接改代码进行实践的工程师。2. FCN架构拆解全卷积替代全连接空间信息为什么没丢2.1 把全连接层拧成卷积层任意尺寸输入从哪来经典的图像分类网络比如VGG16结构上是卷积层提取特征 全连接层输出类别概率。全连接层有一个硬性约束输入特征数量必须固定。这就导致分类网络在进入全连接层之前必须通过全局池化或固定裁剪把任意尺寸的特征图压成一个固定长度的向量。这个操作本身没有错但它把空间位置关系抹掉了——你只知道图里有一只猫却不知道猫在哪。FCN的核心改动是把全连接层替换成卷积层。从数学上讲全连接层本质上是输入特征图与权重矩阵之间的矩阵乘法而卷积层在kernel size恰好等于特征图尺寸时二者等价。用卷积替代之后网络从头到尾没有固定长度的约束任意尺寸的图像输入都只是让特征图在空间维度缩放不会改变通道结构。# 分类网络中的全连接层VGG16风格 self.classifier nn.Sequential( nn.Linear(512 * 7 * 7, 4096), nn.ReLU(inplaceTrue), nn.Dropout(), nn.Linear(4096, 1000) ) # FCN中把全连接层替换为卷积层 self.classifier nn.Sequential( nn.Conv2d(512, 4096, kernel_size7), nn.ReLU(inplaceTrue), nn.Dropout2d(), nn.Conv2d(4096, 4096, kernel_size1), nn.ReLU(inplaceTrue), nn.Dropout2d(), nn.Conv2d(4096, num_classes, kernel_size1) )这里第一个卷积层用kernel_size7匹配VGG最后一个池化层输出的7×7特征图相当于把原来的nn.Linear(512*7*7, 4096)搬移成卷积操作。后两个kernel_size1的卷积相当于全连接层在空间维度上的逐点映射。这样替换之后网络的前向传播不再关心输入尺寸测试时可以直接喂入整张大图。我一般会在推理时利用这个特性直接输入原始分辨率图像而不是先裁剪到训练尺寸。对遥感影像这种动辄几千像素宽的数据来说这意味着不需要做分块重叠拼接也就不用处理拼接缝处的预测不一致问题。当然显存是另一个话题后面坑的部分再展开。2.2 反卷积上采样把16×16特征图恢复成512×512卷积主干里有5个池化层每层让分辨率减半最终特征图只有输入的1/32。以512×512输入为例主干输出的特征图只有16×16。要对每个像素做分类必须把16×16放大回512×512。可选的放大方式有双线性插值和转置卷积。双线性插值速度快但权重固定学不到语义信息转置卷积的参数参与训练网络能自动决定每个位置的填充值这是FCN能获得精细分割结果的关键。PyTorch里对应的层是nn.ConvTranspose2dFCN-32s的上采样模块长这样self.deconv nn.ConvTranspose2d( in_channelsnum_classes, out_channelsnum_classes, kernel_size64, stride32, padding16, biasFalse )转置卷积的输出尺寸公式是(H-1)×stride kernel_size - 2×padding。代入输入H16、stride32、kernel64、padding16(16-1)×32 64 - 2×16 480 64 - 32 512。刚好把16×16恢复到512×512。这里stride32必须和主干的下采样倍数严格对应换一个结构不同的骨干网络时这个数字要一起改否则输出尺寸跟标签对不上损失函数直接报shape不匹配。提示反卷积有时会产生棋盘格伪影当kernel_size不能被stride整除时尤其明显。FCN原配置里64/32整除一般看不出问题如果你自己缩小kernel_size就要检查输出的视觉质量。2.3 跳跃连接FCN-32s、FCN-16s、FCN-8s如何选择只从最后一层特征图反卷积到原尺寸这是FCN-32s。实现简单但第5次池化时大量细节已经丢失上采样出来的边缘很粗糙。FCN论文给出的改进思路是跳跃连接——把前面对应位置的池化层特征图与上采样结果融合再做一次上采样。FCN-16s的具体做法是把pool4输出原图1/16分辨率与主路上采样2倍的特征图逐像素相加再用stride16的反卷积恢复。FCN-8s则再融合pool3原图1/8分辨率最后用stride8的反卷积。每次融合都引入更高分辨率的低级特征边缘定位精度逐级提升。结构融合特征最终上采样倍数相对mIoU显存开销FCN-32s无32倍基准最低FCN-16spool416倍3~4中等FCN-8spool3 pool48倍4~6最高实践里我的选择标准很简单目标是车辆、行人这类小物体或者项目要求边缘贴合度高直接上FCN-8s。只做大面积地物分类如水体、农田FCN-32s训练快、显存占用低后处理反而更省时间。PASCAL VOC的默认配置在工程里是FCN-8s结构这也是复现时最容易得到高分的选择。3. 工程代码跑通从数据准备到训练验证闭环3.1 my_dataset.py与transforms.py数据读取和预处理的正确姿势my_dataset.py负责把PASCAL VOC数据组织成PyTorch的Dataset接口。__getitem__返回三个元素图像张量、标签张量、文件名。训练流程取前两个预测流程用文件名来拼接保存路径。def __getitem__(self, index): img_path self.images[index] label_path self.labels[index] image Image.open(img_path).convert(RGB) label Image.open(label_path) image, label self.transforms(image, label) return image, label, img_pathtransforms.py里有一个关键细节图像和标签同时做尺寸变换但插值方式必须不同。图像用双线性保持平滑标签用最近邻避免引入新值。如果对标签也做双线性插值类别边界会出现0到20之间不存在的中间值训练时模型会被这些幽灵标签干扰。image image.resize((512, 512), Image.BILINEAR) label label.resize((512, 512), Image.NEAREST)注意分割任务的Resize永远是一对一对地写不能只对图像做变换而放过标签也不能用同一个插值方式处理两个输入。Normalize标准化用的均值和标准差是ImageNet预训练对应的数值0.485, 0.456, 0.406如果想换骨干网络需要同步换成该网络预训练时的统计量否则迁移效果会打折。3.2 train.py启动参数与多卡配置每个参数对应什么实际选择train.py是训练入口命令行启动方式是python train.py --data-path ./VOCdevkit --epochs 100 --batch-size 16 --lr 0.001 --num-classes 21--data-path指向放置VOC数据集的根目录要求内部包含JPEGImages、SegmentationClass、ImageSets子目录。--num-classes必须与pascal_voc_classes.json里的类别数一致VOC是21类20个目标类别1个背景换自己的数据集时这个数字要同步修改。distributed_utils.py提供了多卡训练支持多卡启动方式python -m torch.distributed.launch --nproc_per_node2 train.py --data-path ./VOCdevkit --distributed--nproc_per_node是每台机器上使用的GPU数量。多卡训练时batch_size指的是单卡上的数值显存小的卡可以先减半再从学习率上找补。--lr的常见误区是沿用分类任务的0.01FCN这类密集预测任务一般用0.001打底学习率太高时第一个epoch的loss会直接冲上几十并且不容易回落。参数上最值得留意的是batch_size和输入分辨率之间的平衡。512×512输入下单卡16的batch在8GB显存上已经接近临界值显存不足时优先降分辨率到384×384显存占用会按平方关系下降比一路调小batch_size有效得多对精度的损失也相对可控。3.3 train_and_eval.py与validation.py用mIoU而不是loss判断模型好坏train_and_eval.py是训练和验证交替执行的脚本每隔指定epoch调用一次validation.py。验证集上计算的核心指标是mIoU平均交并比也就是对每个类别计算预测区域和真实区域交集与并集的比值再对所有类别取平均。这个指标对语义分割的意义远大于loss因为它直接衡量空间重叠程度。ious [] for cls in range(num_classes): intersection np.logical_and(pred cls, target cls).sum() union np.logical_or(pred cls, target cls).sum() ious.append(intersection / (union 1e-6)) miou np.mean(ious)看验证输出时我习惯逐类查看IoU而不只看平均值。VOC里面person这类大目标出现频率高IoU能做到80以上但bottleplant这类小目标可能只有30。平均值被大目标拉得很高小目标的问题会被掩盖。如果项目对特定类别有精度要求训练时应该用validation.py打印的per-class IoU表来判断模型是否达标而不是只看全局mIoU。4. 标签与颜色映射决定训练正确性的两个JSON文件4.1 pascal_voc_classes.json与palette.json类别索引和RGB的配对关系pascal_voc_classes.json记录类别索引与名称的映射palette.json记录每个索引对应的RGB颜色值。这两个文件看起来像配置实际决定了两个关键问题训练时标签像素值对应哪个类别可视化时颜色是否正确。{ 0: background, 1: aeroplane, 2: bicycle, 3: bird, 4: boat, 5: bottle }对应的palette.json存储一个列表每个类别占三个连续数值代表RGB例如索引1对应[128, 0, 0]表示暗红色。用下面的方式把索引转成可视化图import json import numpy as np from PIL import Image with open(palette.json, r) as f: palette json.load(f) pred np.array(Image.open(pred_label.png)) # 单通道索引图 vis np.zeros((pred.shape[0], pred.shape[1], 3), dtypenp.uint8) for cls_id in range(21): vis[pred cls_id] palette[cls_id * 3 : cls_id * 3 3] Image.fromarray(vis).save(pred_vis.png)一个最容易发生的错误是把palette.json里的顺序搞错或者从网上复制一份类别顺序不一致的映射表到工程里。表现是模型预测完全正确但输出的彩色图里车是红色的、树是蓝色的看起来像分割失败实际上是可视化映射错误。排查方法很简单拿一张VOC训练集的标签图用上述脚本分别渲染成彩色图和原图中物体的肉眼颜色做三张对比逐个类别核对。4.2 忽略像素、调色板模式与灰度索引三个容易被带偏的细节VOC标签是调色板PNG每个像素存的是灰度索引0到255其中0到20是有效类别。标注时难以判定的边缘区域和difficult样本像素值往往被标成255。工程里my_dataset.py的标准做法是把这些无效值映射成忽略索引训练计算损失时跳过这些像素。读取标签图的方式有个隐蔽的坑。用Image.open()默认返回的是调色板模式对象直接用np.array()取出的是索引值正确。但如果先做了.convert(RGB)再转数组得到的是三通道颜色值跟类别索引完全不是一回事。分类任务里没人会犯这个错分割任务里新手经常在这里翻车。正确做法是保持调色板模式转成索引数组。label Image.open(label_path) label_arr np.array(label) # 此时取到的是索引矩阵不是RGB label_arr[label_arr 20] 255 # 无效区域置为255训练时忽略4.3 Image_del.py给标注数据做一次体检Image_del.py做的事情是扫描整个数据目录删除那些尺寸异常、文件损坏、图像与标签尺寸不匹配的样本。听起来简单实际作用很大——分割任务里每像素都是监督信号一张标签和原图配不上的坏样本意味着在几万个位置上同时喂入错误标签对训练稳定性的破坏比分类任务大一个量级。脚本内部的核心判断逻辑是这样for img_file in os.listdir(images_dir): img_path os.path.join(images_dir, img_file) label_path os.path.join(labels_dir, img_file.replace(.jpg, .png)) if not os.path.exists(label_path): print(f[删除] 缺少标签: {img_file}) os.remove(img_path) continue img Image.open(img_path) label Image.open(label_path) if img.size ! label.size: print(f[删除] 尺寸不一致: {img_file}) os.remove(img_path) os.remove(label_path)我一般会在每次拿到新数据集后先跑一遍这个脚本再开始训练。有一次从网上爬了一批道路分割数据跑完检查发现3%的样本图像和标签尺寸不一致来源是原始站点在缩放图片时没有同步缩放标注。如果这些样本直接进入训练集第二个epoch就会看到loss出现异常抖动浪费大量排查时间。5. 避坑指南FCN训练与预测中我实际踩过的五个坑5.1 显存溢出batch_size减到2仍然Out of Memory现象训练启动后没几个step就报CUDA out of memory把batch_size从16一路降到2问题依旧。原因FCN保留了大量中间特征图用于反卷积的梯度回传加上512×512输入本身占空间显存主要被激活值耗尽。只调batch_size而不动输入分辨率等于在水量不变的情况下换一个更小的杯子接水。解决把输入分辨率从512降到384显存占用大约按平方关系下降效果立竿见影。再不够就用梯度累积用多次小batch的梯度累加模拟大batch效果accumulation_steps 4 loss loss / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()这样可以把等效batch_size做到64而不让显存翻倍但训练时间会相应拉长。配合torch.cuda.empty_cache()在每次验证前释放缓存基本能控制住。5.2 训练loss在降验证mIoU纹丝不动现象每个epoch打印的loss持续下降看起来模型在正常收敛但验证mIoU停在30%上下不增长。原因两个最多见的情况。第一是标签读取方式出了问题彩色图被当作索引图喂进模型loss表面在降模型实际在学颜色到类别的随机映射。第二是类别极度不均衡背景像素占比超过90%模型把所有像素预测为背景就能把loss压得很低但mIoU会因为前景类别交并比为零而无法提升。解决先打印一个batch的标签张量检查像素值分布是否只有0到20这些合法索引。再用torch.nn.CrossEntropyLoss的默认权重观察如果确认是类别不均衡给前景类别人为加大权重。我处理VOC时会把person这类数量多的类别权重调低bottle这类占比小的调高mIoU会有肉眼可见的改善。5.3 预测图尺寸与原图对不上插值方法惹的祸现象用predict.py跑单张图输出的分割图比原图小了一圈贴回原图时整体错位。原因模型内部把输入resize到512×512推理输出也是512×512但脚本没有在保存前把预测结果映射回原图尺寸。很多工程里的py脚本只写了前向推理没处理后处理阶段的尺寸还原。解决在predict.py里保存原图宽高推理完成后用最近邻插值还原orig_w, orig_h original_image.size pred pred_tensor.squeeze(0).argmax(dim1).numpy() # (512, 512) pred cv2.resize( pred.astype(np.uint8), (orig_w, orig_h), interpolationcv2.INTER_NEAREST )这里不能用INTER_LINEAR或INTER_CUBIC线性插值会在类别边界制造出不存在的类别值保存成索引图后整张图都变成噪点。5.4 小目标完全消失FCN的架构局限还是参数没调好现象分割结果里大面积类别效果尚可但行人、路灯这类小而稀疏的目标基本被无视偶尔出现也是一小团噪声。原因FCN在多次下采样后小目标在低分辨率特征图上只占几个像素信息已经丢失反卷积无法无中生有。这是FCN的架构性局限和超参关系不大。解决两个方向。一是提高输入分辨率把训练和推理都改成1024×1024小目标像素数量按平方增加效果改善明显但显存和训练时间同时上涨。二是从FCN-32s换成FCN-8s融合更高分辨率的pool层特征小目标边缘和召回率都会提升。如果这两招之后小目标仍然被吞说明FCN这条路本身就吃力需要考虑U-Net或DeepLab这类带多尺度机制的模型那是另一个话题了。5.5 验证mIoU大幅波动混淆矩阵的累积方式问题现象验证集mIoU分数上下跳动超过5个百分点隔几个epoch来回横跳看起来没有规律。原因验证集样本量小的时候如果按batch逐次计算IoU再取平均每个batch内的类别分布差异会把最终分数搅乱。比如一个batch里碰巧全是天空下一个batch全是道路两者单独算IoU再平均和合并起来算的结果差很多。解决把所有batch的预测结果和标签统一收集到数组里最后一次性生成混淆矩阵再基于汇总后的矩阵计算逐类IoU和mIoU。同样一批数据两种计算方式得出的数值差异能达5到10个百分点后者可信度要高得多。6. predict.py实操一张图从模型加载到彩色分割图的完整链路6.1 权重加载与结构对应关系predict.py的入口逻辑简单但有一个前提必须先保证训练时用的模型结构必须与预测脚本中定义的结构完全一致。工程里使用的是torchvision.models.segmentation.fcn_resnet50加载方式import torch import torchvision model torchvision.models.segmentation.fcn_resnet50( weightsNone, num_classes21, aux_lossFalse ) model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) model.eval()这里最容易遇到的是state_dict键名不匹配多半是训练时用了aux_lossTrue辅助损失分支而预测时设成了False。aux_loss会额外多出一层卷积的参数加载时那部分键名对不上。解决方法是训练和预测时保持aux_loss设置一致或者在预测时用下面的方式过滤掉带aux前缀的键。state_dict torch.load(best_model.pth, map_locationcpu) state_dict {k: v for k, v in state_dict.items() if aux not in k} model.load_state_dict(state_dict, strictFalse)6.2 预处理-推理-后处理三环节predict.py的流程分三段每一段都有对应参数需要注意。预处理阶段将图像resize到512×512并做标准化推理阶段用torch.no_grad()包住前向传播避免梯度和激活值驻留显存后处理阶段把输出logits经argmax转成类别索引图再映射成彩色图保存。import torch from PIL import Image import torchvision.transforms as transforms img Image.open(test.jpg).convert(RGB) orig_w, orig_h img.size transform transforms.Compose([ transforms.Resize((512, 512)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) input_tensor transform(img).unsqueeze(0) with torch.no_grad(): output model(input_tensor) # FCN返回dictout键是logits pred output[out].squeeze(0).argmax(dim0).numpy()拿到pred矩阵后的第一件事是做一个合法性检查——确认预测值只落在0到20之间。如果出现大量预测值是21或255这样的越界数字说明模型输出的通道数与num_classes对不上最常见的来源是加载了不同类别数的预训练权重。6.3 一个沿用至今的验证习惯每次跑完预测我会直接把pred矩阵和输入图重叠保存成半透明效果而不是只看单独的彩色标签图。这样做的目的是用肉眼快速确认边界贴不贴合有没有大面积错位。重叠图的实现不复杂把两个图像矩阵按比例混合保存即可。overlay np.clip(0.5 * img_np 0.5 * vis_np, 0, 255).astype(np.uint8) Image.fromarray(overlay).save(overlay_result.png)从那以后我每次换数据集都强制自己走一遍原图-标签-可视化三连对比确认类别索引和颜色映射对上了才开始训练这套流程帮我省掉了至少两次跑完几十个epoch才发现标签解码错误的无效劳动。希望这个习惯也对你有用。本文还有配套的精品资源点击获取