简介这份资源围绕全卷积网络FCN在Penn-Fudan Database上的行人检测与分割实践展开面向具备一定深度学习基础、希望掌握像素级语义分割的开发者与研究者。内容涵盖FCN的核心原理——以卷积层替代全连接层、通过上采样与跳跃连接恢复空间分辨率并结合Penn-Fudan数据集的70张精细标注街景图像完成从数据加载、模型训练到分割预测的完整流程对智能监控、自动驾驶等场景有直接参考价值。资源包共533个文件以340个png图像与分割掩模、172个txt标注文件为主另含6个Python脚本、4个npy指标文件及4个xml配置压缩包约184.68MB目录结构便于按模块查阅。目前已有504人学习下载适合作为图像分割入门与实验复现的实操素材。1. 全卷积网络跑 Penn-Fudan Database行人分割这套资源到底能解决什么行人分割这个任务说穿了就是把图里每个人用像素级掩码抠出来。很多做目标检测的同行一开始觉得框住人就够了直到业务方要求「把行人轮廓描出来做换背景、做密度估计、做遮挡分析」才发现检测框根本不够用。Penn-Fudan Database 就是为这类需求准备的小型行人分割数据集170 张图、345 个行人实例每张图带像素级标注场景集中在校园步道遮挡、尺度变化、多人重叠都有覆盖。它体量小、标注干净特别适合拿来验证全卷积网络FCN这类语义/实例分割模型能不能跑通、能不能收敛、掩码边界够不够细。这份资源的价值不在「数据量大」而在「闭环完整」——从数据加载、模型搭建、训练到掩码可视化一条链路能在一台普通显卡机器上几小时内走完。适合刚接触分割的新手建立直觉也适合熟手拿它当调试分割 pipeline 的沙盒避免一上来就啃 COCO 那种几十 G 的庞然大物。2. 把 Penn-Fudan 读进内存目录结构、标注解析与 Dataset 封装2.1 先看清数据集长什么样Penn-Fudan Database 的原始发布形态是每张图配一个.txt标注文件命名规则是FudanPed00001.png对应FudanPed00001.txt。标注文件里每个行人一个块格式大致是# 第一个行人 PennFudanPed/00001.png 200 300 450 620 1 ...第一行是图片相对路径第二行是边界框xmin ymin xmax ymax第三行是实例数量后面跟的是该实例的像素级掩码行。很多人第一次读会懵因为掩码不是直接给 PNG而是用文本行描述。常见做法是写一个解析函数把每个实例的掩码还原成二值矩阵。这里有个容易翻车的点不同镜像站下载的版本标注文件里路径前缀可能带PennFudanPed/也可能不带解析时要做兼容否则Image.open直接报文件找不到。我一般会先跑一段脚本确认目录结构和标注可读性再动手写 Dataset。下面这段是解析单个标注文件并还原掩码的核心逻辑import os import numpy as np from PIL import Image def parse_annotation(txt_path, img_dir): 解析 Penn-Fudan 单个标注文件返回 boxes 和 masks with open(txt_path, r) as f: lines [l.strip() for l in f if l.strip()] # 第一行是图片路径取文件名 img_name os.path.basename(lines[0]) img_path os.path.join(img_dir, img_name) # 第二行是 bbox xmin, ymin, xmax, ymax map(int, lines[1].split()) # 第三行是实例数 num_objs int(lines[2]) # 后面每 2 行描述一个实例的掩码 masks [] idx 3 for _ in range(num_objs): # 掩码用 RLE 风格的行描述这里按行读取并还原 mask_rows [] while idx len(lines) and not lines[idx].startswith(PennFudanPed): mask_rows.append(lines[idx]) idx 1 # 实际项目中这里要根据官方格式把行描述转成 HxW 矩阵 # 简化示意假设每行是 y x1 x2 形式的连续段 h, w Image.open(img_path).size[1], Image.open(img_path).size[0] mask np.zeros((h, w), dtypenp.uint8) for row in mask_rows: parts list(map(int, row.split())) y, x_start, x_end parts[0], parts[1], parts[2] mask[y, x_start:x_end] 1 masks.append(mask) return img_path, [xmin, ymin, xmax, ymax], masks这段代码的关键在于先定位图片路径再按行解析 bbox 和实例数最后逐实例还原掩码。参数上img_dir要指向图片实际所在目录txt_path是标注文件路径。实际跑的时候掩码行格式可能因版本不同有差异建议先打印几行原始内容确认别直接套。还原出来的掩码是uint8的 0/1 矩阵后续转 tensor 时再乘 255 或直接当二分类标签用。2.2 封装成 PyTorch Dataset解析通了之后封装 Dataset 就是标准动作。核心是__getitem__返回 image tensor、target 字典含 boxes、labels、masks。这里有个细节Penn-Fudan 只有「行人」一个类别label 统一给 1背景算 0。masks 要转成float32且形状为[N, H, W]因为 PyTorch 的检测/分割模型通常要求这样。import torch from torch.utils.data import Dataset from torchvision.transforms import functional as F class PennFudanDataset(Dataset): def __init__(self, root, transformsNone): self.root root self.transforms transforms # 收集所有图片名按编号排序保证可复现 self.imgs sorted([f for f in os.listdir(os.path.join(root, PennFudanPed)) if f.endswith(.png)]) def __getitem__(self, idx): img_name self.imgs[idx] img_path os.path.join(self.root, PennFudanPed, img_name) txt_path img_path.replace(.png, .txt) img Image.open(img_path).convert(RGB) _, bbox, masks parse_annotation(txt_path, os.path.join(self.root, PennFudanPed)) num_objs len(masks) boxes torch.as_tensor([bbox], dtypetorch.float32) labels torch.ones((num_objs,), dtypetorch.int64) masks torch.as_tensor(np.stack(masks), dtypetorch.uint8) target {boxes: boxes, labels: labels, masks: masks} if self.transforms: img, target self.transforms(img, target) return img, target def __len__(self): return len(self.imgs)参数说明root是数据集根目录下面要有PennFudanPed子目录transforms建议用 torchvision 的检测变换注意要同时处理 image 和 target。boxes这里只放了一个框实际如果一张图多个行人要把所有框都塞进去别只取第一个。masks转 tensor 时保持uint8训练时再转 float。这个 Dataset 跑通后DataLoader的collate_fn要自定义因为每张图实例数不同默认拼接会报错。3. 全卷积网络搭起来从骨干到掩码输出的关键参数3.1 为什么选 FCN 而不是直接上 Mask R-CNNPenn-Fudan 只有 170 张图实例数 345这个量级下直接上 Mask R-CNN 这种两阶段大模型过拟合风险很高而且训练时间不划算。全卷积网络FCN的思路更直接用卷积层替换全连接层输出和输入同分辨率的掩码图对每个像素做分类。它的优势是结构简单、参数量可控、训练快适合小数据集快速验证。常见做法是用一个预训练骨干比如 ResNet-50 去掉最后的全连接做特征提取再接几层上采样把特征图恢复到原图尺寸最后1x1卷积输出类别数通道。Penn-Fudan 只有行人和背景两类所以输出通道设 2 就够。选型上要注意FCN 原版是做语义分割的不区分实例。如果你要的是「每个人单独一个掩码」得在 FCN 基础上加实例区分逻辑或者干脆用 Mask R-CNN。但 Penn-Fudan 的场景里很多行人互相不重叠语义分割的掩码已经够用。我一般会先跑语义分割版本看效果如果重叠严重再换实例分割。这个判断能省不少时间。3.2 骨干网络与上采样层的参数配置搭 FCN 时骨干用torchvision.models.resnet50(pretrainedTrue)去掉fc层保留前面的卷积。上采样部分用ConvTranspose2d逐级放大每次放大 2 倍直到恢复到输入尺寸。关键参数是上采样层的kernel_size和stride一般设kernel_size2, stride2最稳不会出现棋盘格伪影。输出层用Conv2d(in_channels64, out_channels2, kernel_size1)。import torch.nn as nn import torchvision.models as models class FCNPennFudan(nn.Module): def __init__(self, num_classes2): super().__init__() backbone models.resnet50(pretrainedTrue) # 去掉最后的全连接和平均池化 self.layer0 nn.Sequential( backbone.conv1, backbone.bn1, backbone.relu, backbone.maxpool) self.layer1 backbone.layer1 self.layer2 backbone.layer2 self.layer3 backbone.layer3 self.layer4 backbone.layer4 # 上采样从 1/32 恢复到原图 self.upsample1 nn.ConvTranspose2d(2048, 512, 2, stride2) self.upsample2 nn.ConvTranspose2d(512, 256, 2, stride2) self.upsample3 nn.ConvTranspose2d(256, 128, 2, stride2) self.upsample4 nn.ConvTranspose2d(128, 64, 2, stride2) self.upsample5 nn.ConvTranspose2d(64, 32, 2, stride2) self.classifier nn.Conv2d(32, num_classes, 1) def forward(self, x): x self.layer0(x) x self.layer1(x) x self.layer2(x) x self.layer3(x) x self.layer4(x) x self.upsample1(x) x self.upsample2(x) x self.upsample3(x) x self.upsample4(x) x self.upsample5(x) return self.classifier(x)参数说明num_classes2对应行人和背景每个ConvTranspose2d的输入通道要跟上一层输出对齐别写错stride2保证每次放大一倍。实际训练时输入图片要 resize 到 32 的倍数否则上采样后尺寸对不上。我一般统一 resize 到512x512既保留细节又不至于爆显存。损失函数用CrossEntropyLoss忽略背景像素的权重可以调低因为背景占多数。3.3 训练循环与学习率调度训练循环里优化器用Adam学习率1e-4起步跑 20 个 epoch 基本能看到掩码收敛。每个 epoch 后打印 loss 和 IoUIoU 低于 0.5 说明模型还没学到东西检查标注解析是否正确。学习率调度用StepLR每 10 个 epoch 乘 0.1。import torch.optim as optim from torch.optim.lr_scheduler import StepLR device torch.device(cuda if torch.cuda.is_available() else cpu) model FCNPennFudan(num_classes2).to(device) optimizer optim.Adam(model.parameters(), lr1e-4) scheduler StepLR(optimizer, step_size10, gamma0.1) criterion nn.CrossEntropyLoss() for epoch in range(20): model.train() total_loss 0 for imgs, targets in dataloader: imgs imgs.to(device) # 把 masks 转成 [B, H, W] 的 long 类型标签 masks torch.stack([t[masks][0] for t in targets]).to(device).long() optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, masks) loss.backward() optimizer.step() total_loss loss.item() scheduler.step() print(fEpoch {epoch1}, Loss: {total_loss/len(dataloader):.4f})这段代码里masks的取法假设每张图只有一个实例多实例时要先合并或做实例映射。CrossEntropyLoss要求 target 是long类型且值在[0, num_classes-1]别传成 float。训练完保存state_dict后面推理直接加载。4. 避坑与排查Penn-Fudan 跑 FCN 时最容易翻车的五件事4.1 标注文件路径不匹配导致图片读不到现象Image.open报FileNotFoundError提示的路径里多了一层PennFudanPed/或少了一层。原因不同来源的 Penn-Fudan 压缩包解压后目录层级不一致有的把图片放在PennFudanPed/PennFudanPed/下有的直接放PennFudanPed/。解决先os.listdir打印实际目录确认图片和标注的相对位置再在代码里用os.path.join动态拼接别硬编码路径。4.2 掩码解析错位导致标签全黑现象训练 loss 一直不降可视化掩码全是黑色。原因标注文件里掩码行的格式没吃透把 bbox 行或实例数行误当成掩码行解析导致掩码矩阵全零。解决打印前 10 行原始标注对照官方格式确认每行含义解析后随机抽一张图可视化掩码确认有白色区域再继续。4.3 输入尺寸不是 32 的倍数导致上采样对不齐现象forward时报RuntimeError: size mismatch或者输出掩码尺寸和原图差几个像素。原因FCN 经过 5 次下采样再 5 次上采样输入尺寸必须是 32 的倍数否则中间特征图尺寸出现奇数上采样后对不上。解决在 Dataset 的 transform 里统一 resize 到512x512或480x640确保能被 32 整除。4.4 DataLoader 默认 collate 报错现象DataLoader迭代时抛RuntimeError: stack expects each tensor to be equal size。原因每张图的行人实例数不同masks的N维度不一致默认collate_fn无法拼接。解决自定义collate_fn把每张图的 target 单独放列表返回不强行 stack或者只做语义分割把多实例掩码合并成单通道二值图。4.5 学习率过大导致 loss 震荡不收敛现象loss 在前几个 epoch 上下跳IoU 始终低于 0.3。原因Adam 配1e-3对 FCN 这种小模型偏大梯度更新太猛。解决学习率降到1e-4或5e-5加StepLR衰减如果还震荡检查输入归一化是否做了Penn-Fudan 图片要按 ImageNet 均值方差归一化。5. 进阶技巧用预训练权重和掩码后处理把 IoU 再提一截5.1 加载预训练骨干并冻结底层Penn-Fudan 数据量小从头训 FCN 容易过拟合。我一般会加载 ImageNet 预训练的 ResNet-50 权重然后冻结layer0和layer1只训后面的层。这样底层特征不用重新学收敛快且泛化好。冻结的代码很简单for param in model.layer0.parameters(): param.requires_grad False for param in model.layer1.parameters(): param.requires_grad False冻结后优化器只更新剩余参数学习率可以稍微调大一点到2e-4。实测在 Penn-Fudan 上冻结底层比不冻结的 IoU 高 5 到 8 个百分点训练时间也短。5.2 掩码后处理阈值与连通域过滤模型输出的掩码是每个像素的类别概率直接argmax会有很多噪点。常见做法是先对行人通道做sigmoid再设阈值 0.5 二值化然后用cv2.connectedComponents去掉面积小于 50 像素的连通域。这一步能明显减少背景误判。参数上阈值可以按验证集 IoU 调0.4 到 0.6 之间试面积阈值根据图片分辨率定512 尺寸下 50 比较合适。5.3 验证 IoU 的正确算法算 IoU 时别直接拿预测掩码和标注掩码逐像素比要先确认两者都是二值且对齐。公式是intersection / union交集是两者都为 1 的像素数并集是至少一个为 1 的像素数。如果一张图多个行人要按实例分别算再平均别把所有行人混在一起算否则重叠区域会拉低分数。我习惯每个 epoch 在验证集上跑一遍记录平均 IoU低于 0.6 就回去查数据解析和归一化。从那以后我每次跑新的分割数据集都强制先可视化三张原图加掩码确认标注解析没问题再开训。这个习惯帮我省了至少两次通宵排查的功夫。希望帮到你。本文还有配套的精品资源点击获取