
简介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。foldnovel测试类别VOC 类别 idfold-0aeroplane, bicycle, bird, boat, bottle1–5fold-1bus, car, cat, chair, cow6–10fold-2diningtable, dog, horse, motorbike, person11–15fold-3pottedplant, sheep, sofa, train, tvmonitor16–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 模式图像像素索引直接等于类别 id0 是背景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 train1464训练原版标注SBD trainaug10582训练常用增强集VOC2012 val1449测试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, splittrain, shot1, size(473, 473), episodes_per_epoch1000): 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, ffold{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, modeL) 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, interpolationImage.BILINEAR) for i in images] masks [TF.resize(m, self.size, interpolationImage.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(fclass {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, sizefeat.shape[-2:], modenearest) num (feat * mask).sum(dim(2, 3)) # [K, C] den mask.sum(dim(2, 3)).clamp(min1e-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, dim0) qry_feat F.normalize(qry_feat.squeeze(0), dim0) # [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(min1e-6)防止某个 support 里前景为空导致除零。mean(0)对 K 个 support 取平均这就是 K-shot 的自然扩展。einsum那一步是逐像素点积等价于余弦相似度因为两边都归一化了。参数上特征图的h, w通常是原图的 1/8 或 1/16score最后要插值回原图尺寸再算 loss。4.2 loss 与边界屏蔽二值分割用带 logits 的 BCE 就够问题在于 ignore 区域怎么处理。前面留的ignore掩码在这里派上用场import torch.nn.functional as F def seg_loss(score, qry_mask, ignoreNone): score F.interpolate(score, sizeqry_mask.shape[-2:], modebilinear, align_cornersFalse) loss F.binary_cross_entropy_with_logits(score, qry_mask, reductionnone) if ignore is not None: w (1.0 - ignore).float() loss (loss * w).sum() / w.sum().clamp(min1.0) else: loss loss.mean() return loss逻辑说明先上采样到标注尺寸再逐像素算 BCE得到未归约的 loss 图ignore为 1 的位置权重置 0最后用有效像素数归一化。参数上modebilinear用于 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, fold0, splittrain, shot1, size(473, 473)) loader DataLoader(ds, batch_size1, shuffleFalse, num_workers4) optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-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_size1是因为每个 episode 的类别不同拼 batch 会引入类别混杂shuffleFalse对自定义长度的 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 1K5 时特别容易触发因为有些 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, threshold0.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(min1) return iou.mean().item(), iou[1].item() # (mIoU, 前景 IoU)逻辑说明累加而不是逐 episode 求平均这是 FB-IoU 的定义想换成 per-episode mIoU就把中间结果存成列表最后平均。threshold默认 0.5有些实现用 argmax 等价形式二分类下结果一致。参数上union.clamp(min1)防除零测试集里可能出现某个类整场都没预测到前景的情况。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 之间。三项都通过才认为数据管道是干净的再往下调模型。希望帮到你。本文还有配套的精品资源点击获取