☰
全卷积网络实战:Penn-Fudan行人分割数据集解析与训练
2026/10/10 18:07:51 网站建设 项目流程

简介:这份资源围绕全卷积网络(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), dtype=np.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, transforms=None): 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], dtype=torch.float32) labels = torch.ones((num_objs,), dtype=torch.int64) masks = torch.as_tensor(np.stack(masks), dtype=torch.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-CNN

Penn-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(pretrained=True),去掉fc层,保留前面的卷积。上采样部分用ConvTranspose2d逐级放大,每次放大 2 倍,直到恢复到输入尺寸。关键参数是上采样层的kernel_size和stride,一般设kernel_size=2, stride=2最稳,不会出现棋盘格伪影。输出层用Conv2d(in_channels=64, out_channels=2, kernel_size=1)。

import torch.nn as nn import torchvision.models as models class FCNPennFudan(nn.Module): def __init__(self, num_classes=2): super().__init__() backbone = models.resnet50(pretrained=True) # 去掉最后的全连接和平均池化 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, stride=2) self.upsample2 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.upsample3 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.upsample4 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.upsample5 = nn.ConvTranspose2d(64, 32, 2, stride=2) 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_classes=2对应行人和背景;每个ConvTranspose2d的输入通道要跟上一层输出对齐,别写错;stride=2保证每次放大一倍。实际训练时,输入图片要 resize 到 32 的倍数,否则上采样后尺寸对不上。我一般统一 resize 到512x512,既保留细节又不至于爆显存。损失函数用CrossEntropyLoss,忽略背景像素的权重可以调低,因为背景占多数。

3.3 训练循环与学习率调度

训练循环里,优化器用Adam,学习率1e-4起步,跑 20 个 epoch 基本能看到掩码收敛。每个 epoch 后打印 loss 和 IoU,IoU 低于 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_classes=2).to(device) optimizer = optim.Adam(model.parameters(), lr=1e-4) scheduler = StepLR(optimizer, step_size=10, gamma=0.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(f"Epoch {epoch+1}, 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 就回去查数据解析和归一化。

从那以后我每次跑新的分割数据集,都强制先可视化三张原图加掩码,确认标注解析没问题再开训。这个习惯帮我省了至少两次通宵排查的功夫。希望帮到你。

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

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

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

立即咨询