☰
CelebA人脸数据集PyTorch加载实战:自定义Dataset与DataLoader避坑指南
2026/10/14 4:39:24 网站建设 项目流程

搞AI的,尤其是做人脸方向,迟早都会撞上CelebA这个数据集。人脸属性识别、人脸编辑、人脸生成,几乎所有公开论文都在它上面跑实验,一度成了人脸方向的事实标准。我自己第一次用的时候,以为难点在模型怎么做,结果下载完数据,卡在“把图片喂给PyTorch”这一步就折腾了两天:ImageFolder套上去直接给我返回0个图片,属性文件的标签读出来全是-1和1,Windows上DataLoader多进程一开就崩,还有一次训练到一半突然报坏图……这个过程说实话比调网络结构还磨人。这篇文章我把踩过的坑整理成了一份完整笔记,打算把这个数据集从目录结构、标注格式到自定义Dataset、数据加载加速一次性讲清楚。后面要跑CelebA的同学,可以直接照着抄,少走点弯路。

1. CelebA到底是什么,做哪些实验会用到它

1.1 数据规模与基本特性

CelebA是公开的人脸属性数据集,图片数量大约20万张,每张都是已经对齐裁剪过的人脸特写,原始尺寸统一为178x218像素,RGB三通道,整体数据量解压后大约1.5GB左右。这20万张图来自一万多个名人身份,涵盖各种肤色、年龄段、姿态、表情、光照条件,因此用它训练出来的模型泛化性会比较有保障。

数据集最核心的卖点在于“标注全”。每一张图片都附带40个二值属性标注,例如:男性、女性、微笑、高颧骨、卷发、直发、戴眼镜、刘海、双下巴、肤色深浅、年龄感等等。同一个样本身上会同时打上一串标签,标注数量多,任务延展性就很强。而且在属性之外,数据集还额外提供了人脸框坐标、五官关键点坐标、身份标识,以及官方的训练集/验证集/测试集划分,这些都是做很多实验要用到的“配件”。

1.2 它通常被用在什么任务里

根据标签的类型,常见的用途大概可以分成四类,我用一个表格来表示它们和对应标注之间的关系:

任务类型用到的标注典型做法
人脸属性分类40个属性多标签二分类,模型输出40个概率
属性编辑/生成40个属性以属性向量为条件,输入GAN生成人脸
人脸识别/检索身份标识学习人脸特征表示,计算相似度
人脸对齐/关键点五官关键点预测眼睛、鼻子、嘴角坐标

我最初接触它是想做单属性“微笑检测”,后来扩展成40个属性一起预测,变成了一个多标签分类任务。做GAN的朋友则更喜欢拿属性向量做条件输入,生成指定性别、指定发型的人脸。无论做哪个方向,第一步一定要先把数据加载这块搞定,不然模型写得再漂亮也无济于事。

2. 下载解压之后,先把目录和标注文件吃透

2.1 解压后的目录结构

我建议拿到数据后不要直接开写代码,先花十分钟把文件结构摸一遍。CelebA整理之后的目录大概长这样:

celeba/ ├── img_align_celeba/ # 图片文件夹,所有jpg平铺在一起 ├── list_attr_celeba.txt # 40个属性标注 ├── list_bbox_celeba.txt # 人脸框坐标 ├── list_landmarks_align_celeba.txt # 五官关键点坐标 ├── list_identity_celeba.txt # 身份ID └── eval_partition.txt # 官网划分:训练/验证/测试

需要注意,img_align_celeba这个目录里的图片全部是平铺的,文件名形如000001.jpg、000002.jpg,没有任何按类别分的子目录。这和很多PyTorch教程里展示的“flowers/train/class1/xxx.jpg”结构完全不同,这是后面ImageFolder用不了的根本原因。

2.2 属性标注文件的真实格式

list_attr_celeba.txt这个文件是重中之重。它的内容不是规规矩矩的CSV,而是一个带特殊头部的空格分隔文本。用文本编辑器打开,前几行长这样:

202599 5_o_Clock_Shadow Arched_Eyebrows Attractive Bags_Under_Eyes Bald ... 000001.jpg -1 1 1 -1 -1 ... 000002.jpg 1 -1 -1 -1 1 ...

第一行是图片总数,也就是202599;第二行是40个属性名称;从第三行开始,每行是“图片名 + 40个属性值”。属性值只有两个:1表示具备该属性,-1表示不具备。

这个格式看着简单,实际用pandas读取时特别容易踩坑。直接pd.read_csv('list_attr_celeba.txt', sep='\s+'),第一行“202599”会被当成数据行,而且整列会缺一块,图片名和属性名会错位。正确做法是skiprows=2跳过前两行,再把第二行的属性名提取出来作为列名。我后面会给出完整版代码。

提示:属性文件里图片名的顺序和img_align_celeba目录里的实际文件顺序是一致的,都是按文件名排序。但不要依赖这个“一致”,最好总是通过图片名去关联,避免手工拼错。

2.3 其他标注文件的格式

eval_partition.txt 是官方划分,格式就两列:

000001.jpg 0 000002.jpg 1 000003.jpg 0 ...

第二列0代表训练集,1代表验证集,2代表测试集。做实验时建议直接用这个划分,方便和论文里的结果对比,不要自己重新随机切割。

list_bbox_celeba.txt 和 list_landmarks_align_celeba.txt 分别保存人脸框和五官关键点,格式类似,第一行是数量,第二行是列名,第三行开始是具体数值。做属性分类或生成任务时一般用不到它们,但如果做关键点回归或者自定义裁剪,就能派上用场。

3. 为什么ImageFolder直接扑街,正确做法是自定义Dataset

3.1 ImageFolder的使用前提

torchvision自带的ImageFolder接口非常方便,适合那种“图片已经按类别放进子目录”的数据集。它的逻辑是:扫描根目录下的所有子文件夹,每个子文件夹的名字自动成为类别标签,子文件夹里的图片归属于这个类。

它的目录要求完全固定:

root/ ├── class_0/ │ ├── a.jpg │ └── b.jpg └── class_1/ ├── c.jpg └── d.jpg

一旦root下面没有子目录,而是直接放了一堆jpg文件,ImageFolder就会认为这个数据集里没有任何类别。实际操作时,如果直接把它的root指向img_align_celeba,加载后得到的数据集会显示__len__为0,训练循环一跑就直接空转,更严重的还会在DataLoader内部抛异常。

我当初为了绕开这个问题,想过一个笨办法:把图片按照“是否微笑”复制到两个子文件夹里,正样本放一个目录,负样本放另一个目录。这样确实能让ImageFolder跑起来,但两个问题扑面而来。一是浪费磁盘空间,本来1.5GB的数据复制扩展后直接翻倍;二是如果后面想从单属性扩展到40个属性的多标签任务,ImageFolder的“一图一类别”机制根本无法表达多标签关系。

3.2 自定义Dataset的正确写法

正确做法是继承PyTorch的Dataset类,自己控制图片读入、标签解析和transform。核心其实就__init__里解析标注文件、__len__返回样本数、__getitem__按索引读图返回(图片, 标签)对。下面这份代码可以直接用,注释我尽量写清楚:

import os import torch from PIL import Image from torch.utils.data import Dataset class CelebADataset(Dataset): def __init__(self, root_dir, attr_path, partition_path=None, split='train', attr_names=None, transform=None): """ root_dir: img_align_celeba 文件夹的实际路径 attr_path: list_attr_celeba.txt 的路径 partition_path: eval_partition.txt 的路径,None则不划分 split: train / val / test attr_names: 需要哪些属性,比如 ['Smiling'];None表示使用全部40个属性 transform: torchvision 的 transforms """ self.root_dir = root_dir self.transform = transform # 1. 解析属性文件 with open(attr_path, 'r') as f: lines = f.readlines() # 第一行是数量,第二行是属性名列,剩余是数据 header = lines[1].strip().split() self.attr_names_all = header # 用列表解析收集图片名和标签 file_names = [] attrs = [] for line in lines[2:]: parts = line.strip().split() file_names.append(parts[0]) # 标签本来是 -1/1,这里顺便换成 0/1 attr_one_hot = [1 if int(v) == 1 else 0 for v in parts[1:]] attrs.append(attr_one_hot) self.file_names = file_names self.attrs = torch.tensor(attrs, dtype=torch.float32) # 2. 按官方划分过滤数据 if partition_path is not None: part_map = {'train': 0, 'val': 1, 'test': 2} split_value = part_map[split] with open(partition_path, 'r') as f: partition_lines = f.readlines() keep_flags = [] for i, fn in enumerate(self.file_names): # partition_lines 和 attr_lines 顺序是一致的,直接按文件名匹配更稳 keep = False for pline in partition_lines: pfn, pval = pline.strip().split() if pfn == fn: if int(pval) == split_value: keep = True break keep_flags.append(keep) idx = [i for i, keep in enumerate(keep_flags) if keep] self.file_names = [self.file_names[i] for i in idx] self.attrs = self.attrs[idx] # 3. 如果只需要部分属性 if attr_names is not None: if isinstance(attr_names, str): attr_names = [attr_names] attr_idx = [self.attr_names_all.index(name) for name in attr_names] self.attrs = self.attrs[:, attr_idx] def __len__(self): return len(self.file_names) def __getitem__(self, idx): img_path = os.path.join(self.root_dir, self.file_names[idx]) image = Image.open(img_path).convert('RGB') if self.transform: image = self.transform(image) label = self.attrs[idx] return image, label

这里的partition匹配部分我是用循环逐行比对实现的,思路直观,但每次初始化都会全量扫描一遍划分文件,20万行虽然琐碎但还在接受范围内。想快一点可以用二分索引或直接把partition_lines存成字典,这里不展开。

3.3 用eval_partition做训练集、验证集、测试集

上面的类已经支持按官方划分过滤数据。初始化的时候这样用:

from torchvision import transforms from torch.utils.data import DataLoader transform = transforms.Compose([ transforms.Resize(64), transforms.CenterCrop(64), transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), ]) train_dataset = CelebADataset( root_dir='img_align_celeba', attr_path='list_attr_celeba.txt', partition_path='eval_partition.txt', split='train', attr_names=['Smiling'], # 这里改成你需要的属性 transform=transform, ) train_loader = DataLoader( train_dataset, batch_size=128, shuffle=True, num_workers=2, )

如果attr_names传None,每个样本的标签会变成40维的tensor,正好用于多标签分类。如果只想要单一属性,比如“Smiling”,标签就是1维的0/1值。

4. 实操中的五个高频坑,逐个击破

4.1 坑一:Windows下DataLoader一开多进程就崩

很多同学第一次跑上面代码,num_workers设成4,在Windows上直接报BrokenPipeError,或者进程反复重启,屏幕疯狂刷错,最后整个内核死掉。这是因为Windows创建多进程用的是spawn方式,子进程会重新执行你的脚本顶层代码,如果没有if __name__ == '__main__':保护,脚本就会在子进程里无限递归加载DataLoader、无限开新进程,最终触发BrokenPipeError。

解决办法有两个:

  • 把训练主逻辑全部放进函数里,在脚本末尾加if __name__ == '__main__':再执行。
  • 如果是Jupyter环境,上述办法不生效,那就把num_workers直接设成0。
if __name__ == '__main__': train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=2) for images, labels in train_loader: # 训练... pass

注意:Linux下一般不会碰到这个问题,macOS和Windows用户要格外小心。如果不想总纠结这个,短期调试时num_workers=0是最省心的。

4.2 坑二:标签是-1/1,损失函数报dtype错

list_attr_celeba.txt里存的是-1和1,而PyTorch的交叉熵损失和BCE损失通常操作的是0/1标签。如果不转换,训练时会出现两种典型报错:

  • 用CrossEntropyLoss,标签为负数,直接报“Target is out of bounds"或者期望long类型,结果来了float。
  • 用BCEWithLogitsLoss,标签值只有-1和1,损失会永远不收敛,因为负样本的标签从0变成了-1,模型会被拉向错误方向。

我的习惯是在Dataset解析时就把标签全部转成0/1,并且统一转成torch.float32。如果做多标签分类,模型最后一层不加sigmoid,直接配合BCEWithLogitsLoss,标签保持float32;如果做单标签二分类,模型最终输出两个logits,配合CrossEntropyLoss,标签需要转成torch.long。

训练循环的典型写法:

import torch.nn as nn model = MyFaceModel() # 假设输出维度是 N x 40 或 N x 1 # 多标签任务 criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) for images, labels in train_loader: images = images.to(device) labels = labels.to(device).float() # 标签保持float logits = model(images) loss = criterion(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step()

4.3 坑三:数据集太大,一次性预加载把内存挤爆

20万张图,每张178x218x3的RGB数组大约是116KB,算下来全部转成numpy或torch tensor放进内存,需要20G以上的RAM。

我见过有人图省事,把全部图片一次性用Image.open读进列表,再逐张转成RGB数组。如果机器只有16G内存,加载到一半进程直接被系统杀掉。

所以一定要用延迟加载机制,也就是在__getitem__里按需读取图片。每次训练取一个batch时才真正访问磁盘,内存占用只有当前batch和缓存池那么大。代价是磁盘IO会成为速度瓶颈,解决办法是提高num_workers让多个进程并行读图,或者把数据集放到SSD上,能明显缓解。

4.4 坑四:训练到一半碰到损坏图片,程序直接退出

CelebA这个数据集虽然整体质量高,但下载过程网络中断、解压不完整,或者个别图片本来就有点问题,都会导致PIL打不开文件。在__getitem__里直接Image.open会抛UnidentifiedImageError,整个训练流程停下来,前面几个小时的训练全白费。

稳妥做法是给图片读取加一层保护,一旦读不到就返回同批次的替代样本:

def __getitem__(self, idx): img_path = os.path.join(self.root_dir, self.file_names[idx]) try: image = Image.open(img_path).convert('RGB') except Exception: # 读到坏图,就拿前一张顶替,或者返回一张纯黑图 fallback_idx = (idx - 1) % len(self.file_names) img_path = os.path.join(self.root_dir, self.file_names[fallback_idx]) image = Image.open(img_path).convert('RGB') if self.transform: image = self.transform(image) label = self.attrs[idx] return image, label

可以在训练前先扫描一遍全部图片,把打不开的文件名列出来,从dataset里过滤掉。虽然会多花几分钟,但后续训练能省心很多。

4.5 坑五:transform缩放直接变形,人脸被拉扁

CelebA图片是178x218,宽高比不是1:1。如果transforms.Resize((64, 64))直接硬缩放,原本正常的正脸会被纵向压扁,训练出来的模型泛化效果一言难尽,尤其在做人脸生成或者关键点任务时影响更大。

正确做法是先按等比例resize,再裁剪到正方形。最常用的组合:

transform = transforms.Compose([ transforms.Resize(64), # 短边缩放到64,长边保持比例 transforms.CenterCrop((64, 64)), transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), ])

Resize(64)传入单个数字时,会按短边固定为64、长边等比缩放。于是178x218变成64x78左右,再利用CenterCrop切出中心64x64区域,人脸主体仍然在中间且比例不变。

4.6 常见问题速查表

症状原因解决办法
DataLoader报BrokenPipeErrorWindows多进程递归创建进程加if __name__保护,或num_workers=0
训练loss不收敛标签还是-1/1在Dataset里把-1/1转成0/1
报dtype错,期望long给了float标签类型与损失函数不匹配单标签用long,多标签BCE用float
内存暴涨,进程被杀整个数据集一次性读入内存改延迟加载,__getitem__按需读图
图片缺角/训练中断下载不完整导致坏图过滤坏图,__getitem__加异常回退
人脸严重变形直接强制Resize成正方形先等比Resize,再CenterCrop

5. 加载效率优化的几个实用经验

5.1 属性文件解析结果可以缓存起来

上面Dataset代码每次初始化都要重新解析一遍list_attr_celeba.txt,20万行数据读一遍也要一两秒。如果模型调参频繁、反复创建数据集,累积的时间也很可观。

我先在代码里解析完,把图片名列表和标签矩阵保存成.pt文件,之后再初始化Dataset时直接load,能省下不少时间:

# 第一次运行后,执行一次缓存 torch.save({ 'file_names': train_dataset.file_names, 'attrs': train_dataset.attrs, 'attr_names': train_dataset.attr_names_all, }, 'celeba_meta.pt') # 以后直接用缓存 meta = torch.load('celeba_meta.pt') train_dataset.file_names = meta['file_names'] train_dataset.attrs = meta['attrs']

真正需要写在__init__里的,其实只有文件名的索引和标签矩阵,路径拼接这类逻辑放在__getitem__里按需做就行。

5.2 先跑一个子集验证模型,再全量训练

20万张图片的全量训练,哪怕网络很小,一个epoch也要跑很久。我习惯在正式训练前抽一个1万张的子集,把模型、loss、优化器和训练循环全部跑通,确认没有隐藏bug后,再切回全量数据。

子集不用另写代码,直接在Dataset里加一个max_samples参数,初始化后截断前N个样本即可:

class CelebADataset(Dataset): def __init__(self, ..., max_samples=None): ... if max_samples is not None: self.file_names = self.file_names[:max_samples] self.attrs = self.attrs[:max_samples]

这样能快速排查数据读取、标签维度、设备分配等问题,不用每次都在20万张数据上试错。

5.3 多标签训练时的标签形状

多标签任务里,每个样本的标签是40维向量。DataLoader默认会把这批40维标签堆叠成(batch_size, 40)的tensor,配合BCEWithLogitsLoss正好用。但如果只想预测单属性,attr_names传入['Smiling'],标签维度是(batch_size, 1),这时用CrossEntropyLoss每个样本必须是一个类别索引,需要先把标签改成一维(batch_size,):

if single_attr: labels = labels.squeeze(1).long()

这个细节很容易被忽略,因为直接从多标签代码改到单标签时,模型维度对不上就会报错。

6. 最后的经验

跑完一遍CelebA加载的完整流程,我才真正理解为什么那么多教程都推荐自己写Dataset。表面上只是多写一个类,实际上这个类承载了数据集结构解析、标签格式转换、数据划分过滤、异常图片兜底这么多职责。从工程角度来说,数据加载永远不是最出彩的部分,却决定了训练能不能稳定跑下去。我觉得最值得养成的习惯就是:拿到任何新数据集,第一步一定亲自打开标注文件看几行,只靠猜和框架默认行为迟早翻车。最后我自己的一个小习惯,如果只是做快速验证,把数据集的图片数量前1000张跑一轮,确认所有环节流畅后再放开全量,这样能把试错周期从小时级缩短到分钟级。后面的路就好走多了。

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

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

立即咨询