简介:Pascal-5i 数据集面向小样本学习的研究者与学习者,基于 PASCAL VOC 的 20 个类别构建,提供 5-way 1-shot 与 5-way 5-shot 场景,便于在少量样本条件下训练和评估模型对新类别的识别与泛化能力。压缩包共 6 个文件,约 151KB,以 Python 读取脚本、Jupyter Notebook 示例、Markdown 说明为主,并配有 requirements、license 及配置文件,可用于快速了解数据组织、加载流程和实验入口。已有 1445 人学习下载,适合课程实践、算法复现与论文实验前的环境熟悉。读者可借助示例代码和说明文档理清基础类别与新类别的划分逻辑,完成数据读取、任务采样与基线评估,并为原型学习、元学习、知识迁移等方法提供可操作的实验起点。
1. PASCAL-5i 不是新数据集:它把 VOC 的 20 类切成了 4 组考卷
做小样本分割的人,第一步几乎都会遇到同一个问题:训练集里见过的类再多,测试时还是要面对从没标注过的新类。PASCAL-5i 就是为小样本学习场景总结出来的标准 benchmark,它不生成新图像,而是把 PASCAL VOC 2012 的 20 个前景类重新分组,切成 4 个 fold,每个 fold 固定 5 类做测试、15 类做训练,再用 1-shot / 5-shot 的 episode 采样喂给模型。下载这个数据集的人,多数是想复现 FSS 论文里的 baseline,或者给自己的分割网络找一个可比的基准。适合已经跑通过普通语义分割、想切入 few-shot 分割的工程师,也适合要做课程设计、需要一套结构清晰又能讲出指标的学生。难点不在数据量,而在划分文件、掩码格式和评估指标这三处细节上;这三处各错一次,指标就会明显异常。
2. 四个 fold 的分组规则:base/novel 划分与目录结构对齐
2.1 为什么按 VOC 类别 id 连续切五刀
PASCAL VOC 2012 的前景类一共 20 个,官方类别 id 从 1 排到 20。PASCAL-5i 的做法很直接:按 id 顺序每 5 个一组,切出四个 fold。这种切法不是经过优化的结果,而是早期小样本分割工作为了固定实验条件硬编码下来的,后来的实现基本都沿用它,所以你几乎不会看到有人重新组合 fold。
| fold | novel(测试)类别 | VOC 类别 id |
|---|---|---|
| fold-0 | aeroplane, bicycle, bird, boat, bottle | 1–5 |
| fold-1 | bus, car, cat, chair, cow | 6–10 |
| fold-2 | diningtable, dog, horse, motorbike, person | 11–15 |
| fold-3 | pottedplant, sheep, sofa, train, tvmonitor | 16–20 |
每个 fold 里,这 5 类是 novel,另外 15 类是 base。训练时只碰 base 类的标注,测试时只碰 novel 类,两者的图像来源都是同一批 VOC 图像,区别只在类别划分和划分文件。
这里有一个很容易被忽略的口径问题:背景怎么算。常见做法有三种。第一种是把背景当作第 21 类,前景 20 类各自成类,训练时 base 类和背景一起学;第二种是每个 episode 只做前景和背景的二分类,novel 类、base 类里非当前目标的像素统统归为背景;第三种是只在测试时保留背景类不参与平均。三种做法算出来的 mIoU 能差好几个点,复现别人的数之前,先确认对方是哪种。我一般会在自己的评估脚本里同时输出前景 mIoU 和 FB-IoU,就是被这个差异坑过之后养成的习惯。
2.2 目录结构与调色板掩码
一份能直接跑的实现,目录通常长这样:
PASCAL-5i/ ├── VOC2012/ │ ├── JPEGImages/ # 原图,jpg │ ├── SegmentationClass/ # 标注,8 位调色板 png │ └── ImageSets/Segmentation/ │ ├── train.txt │ └── val.txt └── splits/ ├── fold0/ │ ├── train.txt # base 类样本 │ └── val.txt # novel 类样本 ├── fold1/ ... └── fold3/ ...SegmentationClass下的 png 不是普通灰度图,而是调色板(P 模式)图像,像素索引直接等于类别 id,0 是背景,1–20 是前景类,255 表示 void,也就是标注边界和难以判定的区域。用 PIL 读出来再转 numpy,拿到的就是这张索引表,不需要自己做颜色映射。这一点先用几行代码验证,比后面 debug 半天划算:
import numpy as np from PIL import Image p = 'PASCAL-5i/VOC2012/SegmentationClass/2007_000032.png' im = Image.open(p) print(im.mode) # 期望 'P',如果是 'RGB' 说明拿错文件了 m = np.array(im) print(m.dtype, m.shape) # uint8, (H, W) print(np.unique(m)) # 期望是 0..20 的子集,可能夹着 255参数和逻辑说明:im.mode用来确认是不是调色板格式;np.array(im)不做任何转换,直接取索引;np.unique是检查标注是否干净最快的办法——如果出现 21 以上的非 255 值,说明这张图被别的工具重新映射过,后面二值化必然出错。shape 也要确认,个别图像的标注尺寸和原图不一致,需要单独列出来处理。
2.3 划分文件没有统一标准,得自己核对
PASCAL-5i 的划分文件不像 VOC 官方 ImageSets 那样有唯一版本,不同仓库给出的splits/foldX/train.txt格式差别很大:有的每行是image_id,类别从目录名推;有的每行是image_id class_id;还有的干脆用 json 存成{class: [ids]}。格式本身无所谓,关键是内容要和 fold 表对得上。
我核对的做法是写个统计脚本,按类别数一遍训练和测试集的图像数,再和 VOC 官方的类分布对一下。如果某个 fold 的 novel 类在训练文件里出现了,或者某类图像数为 0,基本就是划分文件拿错了版本。另外注意训练集来源:VOC 2012 原版训练集只有 1464 张,而 SBD 增强标注(常被称作 trainaug)有 10582 张,多数小样本分割论文的训练用的是后者。只下原版就跑,指标偏低是必然的,不是模型的问题。
| 数据来源 | 图像数 | 典型用途 | 备注 |
|---|---|---|---|
| VOC2012 train | 1464 | 训练 | 原版标注 |
| SBD trainaug | 10582 | 训练 | 常用增强集 |
| VOC2012 val | 1449 | 测试 | novel 类评估 |
提示:划分文件里的类别字段最好用 VOC 的数值 id 而不是类别名字符串,字符串在跨平台换行符和大小写上的问题足够让你多排查一小时。
3. 写一个能跑的 Episode Dataset:从调色板 PNG 到 support/query 张量
3.1 二值化的正确写法与 255 的去向
小样本分割的 episode 是「K 张 support + 1 张 query」的套餐,每张图只关心当前目标类。所以读标注后要做的第一件事是二值化:mask == cls_id为前景,其余为背景。这里有个细节——255 的边界像素在执行== cls_id时自然变成 0,也就是被当成背景。这个行为本身不算错,但如果你的 loss 不额外屏蔽这些像素,模型会把大量边界学成背景,预测结果边缘会明显变细、变得向内收缩。是否屏蔽取决于你复现的方法,但至少要知道它存在。
import numpy as np from PIL import Image def load_binary_mask(mask_path, cls_id): m = np.array(Image.open(mask_path)) fg = (m == cls_id).astype(np.uint8) ignore = (m == 255).astype(np.uint8) # 单独留一份,供 loss 屏蔽 return fg, ignore逻辑说明:fg是 0/1 的前景掩码,ignore标出哪些像素不该参与 loss。参数上,cls_id必须来自划分文件里记录的数值 id,不能拿类别名去比对;mask_path用图像 id 拼.png,别用.jpg,两者后缀不一致的情况在手工整理的数据里很常见。
3.2 Episode Dataset 的完整实现
下面这个类负责把一个 fold 的划分文件变成可迭代的 episode。设计上故意保持简单:一个__getitem__返回一个 1-way K-shot episode,长度由episodes_per_epoch控制,不依赖固定文件列表,这样每个 epoch 的采样组合都不同。
import os import random import numpy as np from PIL import Image import torch from torch.utils.data import Dataset import torchvision.transforms.functional as TF class Pascal5iEpisode(Dataset): def __init__(self, root, fold, split='train', shot=1, size=(473, 473), episodes_per_epoch=1000): self.root = root self.shot = shot self.size = size self.episodes_per_epoch = episodes_per_epoch self.img_dir = os.path.join(root, 'VOC2012', 'JPEGImages') self.mask_dir = os.path.join(root, 'VOC2012', 'SegmentationClass') split_file = os.path.join(root, 'splits', f'fold{fold}', f'{split}.txt') self.records = self._read_split(split_file) # {cls_id: [img_id, ...]} def _read_split(self, path): rec = {} with open(path) as f: for line in f: line = line.strip() if not line: continue img_id, cls_id = line.split() rec.setdefault(int(cls_id), []).append(img_id) return rec def __len__(self): return self.episodes_per_epoch def _load_one(self, img_id, cls_id): img = Image.open(os.path.join(self.img_dir, img_id + '.jpg')).convert('RGB') raw = np.array(Image.open(os.path.join(self.mask_dir, img_id + '.png'))) fg = (raw == cls_id).astype(np.uint8) * 255 return img, Image.fromarray(fg, mode='L') def _sync_aug(self, images, masks): # support 与 query 必须共享同一组几何变换,否则空间对不上 if random.random() < 0.5: images = [TF.hflip(i) for i in images] masks = [TF.hflip(m) for m in masks] images = [TF.resize(i, self.size, interpolation=Image.BILINEAR) for i in images] masks = [TF.resize(m, self.size, interpolation=Image.NEAREST) for m in masks] return images, masks def __getitem__(self, idx): cls_id = random.choice(list(self.records.keys())) candidates = self.records[cls_id] if len(candidates) < self.shot + 1: raise ValueError(f'class {cls_id} only has {len(candidates)} images') picked = random.sample(candidates, self.shot + 1) images, masks = [], [] for img_id in picked: img, m = self._load_one(img_id, cls_id) images.append(img) masks.append(m) images, masks = self._sync_aug(images, masks) sup_img = torch.stack([TF.to_tensor(i) for i in images[:self.shot]]) sup_mask = torch.stack([TF.to_tensor(m) for m in masks[:self.shot]]).float() sup_mask = (sup_mask > 0.5).float() qry_img = TF.to_tensor(images[self.shot]) # [3, H, W] qry_mask = TF.to_tensor(masks[self.shot]).float() # [1, H, W] qry_mask = (qry_mask > 0.5).float() return sup_img, sup_mask, qry_img, qry_mask, cls_id逻辑说明:_read_split把线性文件转成「类别到图像列表」的字典,采样时先随机抽类,再抽图,这样每个类的被选中概率相近,避免大类主导。_sync_aug是关键函数,它把 support 和 query 的图像、掩码放在同一个循环里做同样的翻转和缩放,掩码用NEAREST保证不产生中间灰度。to_tensor对图像做 0–1 归一化,对掩码只做类型转换,所以后续要再做一次> 0.5的二值化。
参数说明:shot就是 K,常用的 1 和 5 都支持;size设成 473 是不少小样本分割实现的输入尺寸,也可以用 321 或 512,但训练和测试必须一致;episodes_per_epoch决定一个 epoch 有多少次参数更新,1-shot 场景下我一般设 1000 到 2000,太小会让 loss 曲线抖动得没法看。
3.3 transform 的三个一致性要求
几何一致只是其中一条,另外两条同样重要。一是归一化一致:support 和 query 必须用同一套均值和方差,很多人只在 query 分支加标准化,support 忘了加,模型学出来的匹配关系会两套分布,收敛很慢。二是尺寸一致:support 掩码在送进模型前要降到和特征图同样的空间分辨率,这步下采样只能用最近邻,否则二值掩码会出现 0.3、0.7 这样的值,masked average pooling的结果会被稀释。三是色彩空间一致:所有图像都要convert('RGB'),VOC 里有少量灰度 jpg,不转换的话它会在torch.stack时直接报维度错误。
import torchvision.transforms as T # support 和 query 共用同一个 transform 对象 norm = T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])这段不需要多解释,但一定要共用同一个对象,别一边写一套参数——我见过有人在 query 分支用 ImageNet 统计量、support 分支用 0.5 的简化值,结果模型在训练集上过拟合得一塌糊涂。
4. 训练循环里的对齐问题:support 掩码怎么拼、loss 怎么屏蔽
4.1 三种 support/query 融合方式与选择理由
拿到 episode 之后,模型结构的关键决策是 support 信息怎么进网络。主流做法有三种,选哪种取决于你复现的论文和算力。
第一种是通道拼接:把 support 图像(3 通道)和 support 掩码(1 通道)拼成 4 通道,query 图像 3 通道,各走一个共享 encoder 后拼接特征。实现简单,但 K-shot 时通道数随 K 增长,5-shot 时输入变成 3 + 5×4 通道,显存涨得快。
第二种是 prototype:对 support 特征做带掩码的全局平均池化,得到每类一个特征向量,再与 query 特征逐像素算余弦相似度。参数量最小,1-shot 和 5-shot 都能直接扩展(多个 support 取平均),是复现论文时最常用的基线。
第三种是特征相关:把 support 特征和 query 特征做 4D 相关运算,保留空间信息。指标通常更好,但显存和实现复杂度都上一个台阶。
我一般先用 prototype 跑通全流程,确认数据管道没问题,再换复杂结构。prototype 的实现也就十来行:
import torch import torch.nn.functional as F def masked_avg_pool(feat, mask): # feat: [K, C, h, w] mask: [K, 1, H, W] mask = F.interpolate(mask, size=feat.shape[-2:], mode='nearest') num = (feat * mask).sum(dim=(2, 3)) # [K, C] den = mask.sum(dim=(2, 3)).clamp(min=1e-6) # [K, 1] return num / den def prototype_score(sup_feat, sup_mask, qry_feat): proto = masked_avg_pool(sup_feat, sup_mask).mean(0) # [C] proto = F.normalize(proto, dim=0) qry_feat = F.normalize(qry_feat.squeeze(0), dim=0) # [C, h, w] score = torch.einsum('c,chw->hw', proto, qry_feat) # [h, w] return score.unsqueeze(0).unsqueeze(0)逻辑说明:masked_avg_pool先把 support 掩码降到特征图分辨率,再做加权求和,clamp(min=1e-6)防止某个 support 里前景为空导致除零。mean(0)对 K 个 support 取平均,这就是 K-shot 的自然扩展。einsum那一步是逐像素点积,等价于余弦相似度,因为两边都归一化了。参数上,特征图的h, w通常是原图的 1/8 或 1/16,score最后要插值回原图尺寸再算 loss。
4.2 loss 与边界屏蔽
二值分割用带 logits 的 BCE 就够,问题在于 ignore 区域怎么处理。前面留的ignore掩码在这里派上用场:
import torch.nn.functional as F def seg_loss(score, qry_mask, ignore=None): score = F.interpolate(score, size=qry_mask.shape[-2:], mode='bilinear', align_corners=False) loss = F.binary_cross_entropy_with_logits(score, qry_mask, reduction='none') if ignore is not None: w = (1.0 - ignore).float() loss = (loss * w).sum() / w.sum().clamp(min=1.0) else: loss = loss.mean() return loss逻辑说明:先上采样到标注尺寸,再逐像素算 BCE,得到未归约的 loss 图;ignore为 1 的位置权重置 0,最后用有效像素数归一化。参数上,mode='bilinear'用于 logits 没问题,但标注侧不能插值;权重归一化一定要除以w.sum()而不是loss.numel(),否则 ignore 越多 loss 越小,等于变相降低了学习率。
注意:如果你的实现里
qry_mask已经是 0/1 的二值图,就不要再做一次阈值化,(x > 0.5).float()写两次不会报错,但会把「目标类边界被忽略」这个信息彻底抹掉,训练曲线看着正常,测试指标就是上不去。
4.3 训练循环骨架
import torch from torch.utils.data import DataLoader ds = Pascal5iEpisode('PASCAL-5i', fold=0, split='train', shot=1, size=(473, 473)) loader = DataLoader(ds, batch_size=1, shuffle=False, num_workers=4) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) for ep in range(50): model.train() for step, (sup_img, sup_mask, qry_img, qry_mask, cls_id) in enumerate(loader): sup_feat, qry_feat = model(sup_img, qry_img) score = prototype_score(sup_feat, sup_mask, qry_feat) loss = seg_loss(score, qry_mask) optimizer.zero_grad() loss.backward() optimizer.step()说明:batch_size=1是因为每个 episode 的类别不同,拼 batch 会引入类别混杂;shuffle=False对自定义长度的 Dataset 没影响,episode 本身是随机的。num_workers设 4 左右即可,再高会被磁盘 IO 卡住,收益不大。训练轮数按论文设置走,常见是 50 到 200 个 epoch,配合 1000 步每 epoch。
5. 五个常见问题复盘:PASCAL-5i 数据管道的排查清单
5.1 现象:训练 loss 平稳下降,测试 mIoU 只有 0.05 上下
原因:novel 类的划分文件里类别 id 没对上,二值化后整张掩码全是 0。比如划分文件写的是类别名,代码按整数解析失败被跳过,或者 fold 索引和类别 id 差了 1。这时 loss 会稳定在一个接近背景比例的数值上,看起来「正常」,其实模型什么都没学到。
解决:在每个 episode 取样后打印一次qry_mask.mean(),前景占比应该在 1% 到 30% 之间。如果是 0,直接回去看划分文件的类别字段;如果接近 1,说明二值化方向反了。
5.2 现象:掩码缩放后出现大片 128 灰度,模型边缘预测糊成一团
原因:掩码用了双线性插值。调色板索引和标签做插值会造出根本不存在的类别值,二值化后边缘变成半前景。
解决:所有掩码的 resize、旋转、裁剪统一使用最近邻。这个错误在数据增强库的默认参数里非常常见,尤其是那种一套 transform 同时处理图像和掩码的封装。
5.3 现象:1-shot 指标比论文低 5 到 8 个点,反复调参也没变化
原因:训练集用了 VOC2012 原版 1464 张,论文用的是 SBD trainaug 10582 张。base 类的样本量差 7 倍,模型对前景特征的学习明显不足。
解决:确认数据源,替换训练划分文件,或者在自己的实验记录里写清楚用的是哪一套。复现别人的数之前,先对齐这一项,比调学习率有效得多。
5.4 现象:采样时报ValueError: Sample larger than population
原因:某个类的图像数小于shot + 1,K=5 时特别容易触发,因为有些 novel 类在 val 集里只有五六张图。
解决:两道保险。采样前过滤掉样本数不足的类,或者允许random.choices有放回采样。我一般两个都加上,先过滤,再用有放回兜底,日志里记一笔哪些类被降级。
5.5 现象:FileNotFoundError或图像与掩码尺寸对不上
原因:SBD 增强集里有部分图像 id 和 VOC 原版不一致,混合两个来源时文件名对不上;另外少量 VOC 图像的标注尺寸和原图差一个像素,直接 stack 会报错。
解决:写一个校验脚本,遍历划分文件里所有 id,检查.jpg和.png是否都存在、尺寸是否一致,把异常条目打印成清单一次性修完,不要一条一条试错。
6. 指标对齐与复现:mIoU、FB-IoU 算错的三种典型姿势
6.1 两个指标的口径差别
小样本分割论文里最常见的是 mIoU 和 FB-IoU。mIoU 是每个 episode 分别算前景、背景的 IoU,再对所有 episode 和类别求平均;FB-IoU 是把整个测试集的前景像素合并成一个集合算一次 IoU,背景同理。后者对类别不平衡不敏感,数字通常更高,所以在做对比时,看到「比 baseline 高 3 个点」先确认两边用的是不是一个指标。还有一个细节:很多论文报的是前景 IoU 的均值,不包含背景,要是你的脚本把背景也算进去,数字会被人为拉高或拉低,看背景占比。
6.2 一个不容易算错的评估函数
import torch @torch.no_grad() def evaluate(model, loader, threshold=0.5): inter = torch.zeros(2) union = torch.zeros(2) for sup_img, sup_mask, qry_img, qry_mask, _ in loader: score = model.predict(sup_img, sup_mask, qry_img) pred = (torch.sigmoid(score) > threshold).long().squeeze() gt = qry_mask.long().squeeze() for c in (0, 1): inter[c] += ((pred == c) & (gt == c)).sum() union[c] += ((pred == c) | (gt == c)).sum() iou = inter / union.clamp(min=1) return iou.mean().item(), iou[1].item() # (mIoU, 前景 IoU)逻辑说明:累加而不是逐 episode 求平均,这是 FB-IoU 的定义;想换成 per-episode mIoU,就把中间结果存成列表最后平均。threshold默认 0.5,有些实现用 argmax 等价形式,二分类下结果一致。参数上,union.clamp(min=1)防除零,测试集里可能出现某个类整场都没预测到前景的情况。
6.3 复现对齐的三个固定动作
| 动作 | 具体做法 | 影响 |
|---|---|---|
| 固定测试 episode 列表 | 用固定 seed 生成 600 或 1000 个 episode 并存盘 | 换 seed 波动可达 2–3 个点 |
| 多次取均值 | 至少 5 次随机种子,报均值和标准差 | 单次结果不足以说明优劣 |
| 明确是否含背景 | 在实验记录里写清指标口径 | 直接影响可比性 |
测试 episode 一定要固定下来,我吃过这个亏:用随机采样评估,同一份权重两次跑出来的 mIoU 差了 2.6 个点,一度以为是模型不稳定,查了半天才发现是采样不同。后来每次评估都先把 episode 列表落盘,权重换了几版都能直接对比。
最后一个习惯是,任何新拿到的 PASCAL-5i 副本,我都会先跑一遍三项检查:按类别统计样本数、随机抽 10 个 episode 可视化 support 和 query 的掩码叠加、用随机初始化的模型算一次指标,确认结果落在接近随机猜测的区间(大约 0.3 到 0.5 之间)。三项都通过,才认为数据管道是干净的,再往下调模型。希望帮到你。
本文还有配套的精品资源,点击获取