FER2013数据集图片提取全流程:从CSV到PyTorch/TensorFlow可用图片
2026/9/1 2:22:43 网站建设 项目流程

简介:fer2013人脸表情识别数据集配套的Python提取代码与图片包,面向机器学习、深度学习中图像分类或表情识别的初学者与研究者。资源共约3.5万个文件,包括35887张由CSV解析出的jpg表情图、带7类情感标签的fer2013.csv原始文件、提取脚本py、readme说明及bib引用信息,压缩包约130MB。7类标签分别为高兴、中性、惊讶、悲伤、生气、害怕和厌恶,每张图片均有对应标注,适合直接用于卷积神经网络分类任务。Python代码覆盖CSV读取、标签编码、图像尺寸调整、训练集/验证集/测试集划分等关键预处理步骤,可配合Keras或TensorFlow快速构建并训练CNN模型。已有8191人浏览学习,对希望省去数据解析与整理时间、直接进入模型训练和实验对比的读者很实用。 做表情识别项目的人,几乎绕不开FER2013数据集,但也几乎都会在第一步卡一下:这个数据集里的图片需要自己写Python代码提取。FER2013是Kaggle在2013年举办的面部表情识别挑战赛推出的公开数据集,共包含35887张48x48像素的灰度人脸图片,覆盖生气、厌恶、恐惧、开心、悲伤、惊讶、中性7类表情。它最大的特点是所有图片不是以常见图片文件形式存放,而是被打平成像素字符串存进了一个CSV文件,你没法直接双击看,也没法直接扔进框架的自动加载接口里面。

这篇博文就把我从下载数据到最终生成图片文件夹的完整流程摊开讲,包括CSV结构分析、图片提取代码、目录规划、标签分布核对,以及我在实际处理过程中踩过的坑和排查思路。做完这一步,后面接PyTorch或TensorFlow做表情识别训练,或者拿这批图片做数据增强、可视化分析,都会顺手非常多。适合正在做人脸表情识别、刚入门深度学习、或者被CSV格式图片数据卡住的朋友直接参考。

1. 项目背景与整体设计思路

1.1 为什么需要图片提取这一步

直接读CSV把像素数组喂给模型,技术上确实可行,但提取成图片文件有三个实打实的优势。

第一是可视化方便。CSV里存的是一长串数字,单独看根本不知道是什么。提取成png之后可以直接打开看,也能拼成网格图观察整体分布,做数据清洗和异常检测的时候特别好用。

第二是方便对接现有工具链。多数深度学习框架和迁移学习模型都提供了按目录读取图片的入口,比如PyTorch的ImageFolder、TensorFlow的image_dataset_from_directory,都要求数据以图片文件形式按目录存放。把图片提取出来后,直接就能用这些高层接口,省掉一大截自定义Dataset的代码。

第三是方便做独立处理。提取成图片文件后,你可以在训练之外独立做清洗、去重、裁剪、旋转、加噪声等操作,处理完再统一喂给模型。尤其是做数据增强实验时,有些预处理需要反复调整参数,有实体图片后就不用每次都在CSV里捞数据。

不过提取图片并不是唯一解。如果你只想做训练,完全可以写一个自定义Dataset,从CSV里按索引实时解析像素,跳过多余的磁盘读写。但说实话,对于绝大多数刚接触FER2013的人,尤其是做可视化探索的阶段,我建议先把图片提出来,后面会舒服很多。

1.2 整体流程拆解

我的整体设计分成四个步骤:

  1. 下载并确认CSV文件结构,搞清楚emotion、pixels、Usage三个字段分别是什么。
  2. 解析pixels列,把空格分隔的像素字符串还原成48x48的像素矩阵。
  3. 按表情标签和数据集用途分类存放,生成规整的目录结构。
  4. 统计核对图片数量,确保每个类别的数量与原始CSV一致。

我当时特意没有把“提取”和“后续训练”混在一个脚本里,这样职责更清晰,排查问题也更方便。提取脚本只做一件事:从CSV生成标准目录结构的图片文件。后面不管是训练还是做别的处理,都从这批图片重新出发。

2. FER2013数据集的内部结构拆解

2.1 CSV文件的结构与字段含义

打开fer2013.csv,第一行是表头,后面每一行代表一张图片。去掉表头后共有35887行数据,对应35887张图片。每行有3个字段:

字段含义示例
emotion表情标签,0-6的整数0
pixels48x48=2304个像素值,用空格分隔的字符串98 105 110 ...
Usage数据用途,Training/PublicTest/PrivateTestTraining

emotion标签的映射关系为:0=Angry(生气)、1=Disgust(厌恶)、2=Fear(恐惧)、3=Happy(开心)、4=Sad(悲伤)、5=Surprise(惊讶)、6=Neutral(中性)。这个映射在提取图片、统计分布、做分类训练时都会反复用到,建议提前记住或者写进常量里。

pixels字段是整个提取过程的核心。它存储的是单张灰度图的像素值,取值在0到255之间,48x48的图片展开后正好是2304个数字。只要把这个字符串按空格切分,转成整数数组,再reshape成(48, 48)的二维矩阵,就能还原成一张完整的人脸灰度图。

2.2 标签分布与数据划分

用pandas快速统计一下各标签数量,会发现数据分布其实很不均衡。我手里的这份FER2013统计结果大致如下:

标签情绪样本数
0Angry4953
1Disgust547
2Fear5121
3Happy8989
4Sad6077
5Surprise4002
6Neutral6198

Disgust(厌恶)类别明显是少数样本,只有547张,不到Happy的十分之一。这说明如果你直接用这个数据集做训练,模型对Disgust的识别效果大概率会差,这是数据集本身分布决定的,不是模型或代码的问题。遇到这种情况,常见的应对思路是过采样少数类、做数据增强,或者改用带类别权重的损失函数。

数据用途方面,官方把数据划分成了三份:Training共28709张,PublicTest共3589张,PrivateTest共3589张。PublicTest和PrivateTest数量一样,区别在于Kaggle当年的比赛规则中,PublicTest用于公开排行榜展示,PrivateTest作为最终判定结果,两者不重叠。提取图片时,我建议把这三个子集分开保存,方便后续分别评估。

2.3 图片目录结构怎么规划

我建议用“数据集用途 > 标签名”两级目录来组织图片,这也是PyTorch的ImageFolder默认约定的结构:

fer2013_images/ ├── Training/ │ ├── Angry/ │ ├── Disgust/ │ ├── Fear/ │ ├── Happy/ │ ├── Sad/ │ ├── Surprise/ │ └── Neutral/ ├── PublicTest/ │ └── ... └── PrivateTest/ └── ...

这样规划的好处是,后面用框架加载数据时一行代码就能完成,不需要手写复杂的路径映射,同时每个类别文件夹直接数文件个数就能核对提取结果是否和原CSV分布一致。如果你不需要区分Usage,只想把所有图片按标签放一起,去掉中间那层目录即可。

3. 提取图片的Python代码实现

3.1 环境准备与依赖安装

先列一下我用到的依赖:

  • Python 3.8+
  • pandas
  • numpy
  • opencv-python(或者Pillow)
  • tqdm(可选,用于显示进度)

安装命令很简单:

pip install pandas numpy opencv-python tqdm

如果你机器上已经有了,直接跳过。核心其实只需要pandas加numpy,保存图片用cv2.imwrite或者PIL的Image.save都可以。我习惯用OpenCV,因为后面做人脸对齐、图像预处理时大概率也会用到它,提前装好省事。

这里说一个细节:保存图片时建议用png格式。原始像素值是0-255的灰度值,png是无损压缩,保存后不会丢信息。用jpg在低质量参数下会引入压缩伪影,虽然不一定影响训练,但没必要自找麻烦。

3.2 完整提取脚本

直接给出完整代码,并逐段说明。

import os import pandas as pd import numpy as np import cv2 from tqdm import tqdm def load_fer2013(csv_path): df = pd.read_csv(csv_path) print(f"[INFO] 总样本数: {len(df)}") print(f"[INFO] Usage分布:\n{df['Usage'].value_counts()}") return df def parse_pixels(pixels_str): return np.array(pixels_str.split(), dtype=np.uint8).reshape(48, 48) def save_images_from_csv(csv_path, output_root="fer2013_images"): df = load_fer2013(csv_path) emotion_map = { 0: "Angry", 1: "Disgust", 2: "Fear", 3: "Happy", 4: "Sad", 5: "Surprise", 6: "Neutral" } for idx, row in tqdm(df.iterrows(), total=len(df), desc="Extracting images"): emotion = int(row["emotion"]) usage = str(row["Usage"]) label_name = emotion_map[emotion] dest_dir = os.path.join(output_root, usage, label_name) os.makedirs(dest_dir, exist_ok=True) img = parse_pixels(row["pixels"]) filename = f"{idx:06d}.png" save_path = os.path.join(dest_dir, filename) cv2.imwrite(save_path, img) print("[INFO] 提取完成") if __name__ == "__main__": save_images_from_csv("fer2013.csv")

运行后会在当前目录生成fer2013_images文件夹,里面按Training / PublicTest / PrivateTest以及具体表情标签分好了类。如果你不需要细分Usage,就去掉usage那一层目录,直接按标签存放。

3.3 代码里几个关键细节

parse_pixels这步是核心。pixels列是类似“98 105 110 ...”的空格分隔字符串,split()之后得到2304个字符串,np.array(..., dtype=np.uint8)之后是2304维向量,再reshape成48x48的二维灰度图。

这里有一个容易踩的坑:如果用np.int32或np.float64读取再保存,后续处理还要多一步类型转换。直接用uint8既能节省内存,又能保证和原数据一致,而且cv2.imwrite默认就接受uint8的单通道图像,不需要额外处理。

另一个细节是文件名用idx的6位编号,比如000000.png、000001.png。沿用CSV的行号做文件名,好处是后面如果发现某张图有异常,可以快速定位到原始CSV的对应行,排查起来非常方便。如果文件名乱起,追溯起来就是一场灾难。

3.4 提取后校验:图片数量要对上

提取完不要急着走,先核对数量,确认没漏图、没多图。可以用下面的脚本统计:

stats = df.groupby(["Usage", "emotion"]).size().reset_index(name="count") emotion_map = { 0: "Angry", 1: "Disgust", 2: "Fear", 3: "Happy", 4: "Sad", 5: "Surprise", 6: "Neutral" } for _, row in stats.iterrows(): folder = os.path.join("fer2013_images", row["Usage"], emotion_map[row["emotion"]]) actual = len(os.listdir(folder)) expected = row["count"] status = "OK" if actual == expected else "MISMATCH" print(f"{row['Usage']} - {emotion_map[row['emotion']]}: {actual}/{expected} {status}")

输出结果应当每个都是OK。我之前跑完遇到过MISMATCH的情况,原因基本是提取一半中断导致重新生成时部分文件被覆盖,或者命名规则冲突。用带前导零的行号命名可以很好规避这类问题。

3.5 不用OpenCV,PIL版本怎么写

有些精简环境里没装OpenCV,用Pillow也能完成同样的事。代码差异不大:

from PIL import Image def save_image_pil(img_array, path): img = Image.fromarray(img_array, mode="L") img.save(path)

注意一定要指定mode="L",表示单通道灰度图。如果不指定,Pillow对二维数组的模式推断偶尔会出问题,可能生成索引颜色模式或通道混乱的图像。这个是我实际踩过的小坑,单独拎出来说。

4. 常见问题与排查技巧实录

4.1 像素点数和48x48对不上怎么办

有时候split之后的长度不是2304,多半是数据源格式有变化。可以先打印出来看看:

pixels_str = row["pixels"] length = len(pixels_str.split()) if length != 2304: print(f"异常行 {idx}: 像素长度 = {length}")

如果长度不等于2304,要么是分隔符不是空格而是逗号或分号,要么是文件被二次处理过。FER2013官方版本一般不会出现这个问题,但你从网上下载的第三方版本就不好说了。

4.2 图片保存出来是全黑或严重失真

这种情况我遇到两种原因。一种是数组没 reshape,把2304的向量当成单行图像保存,出来的图当然不是人脸。另一种是像素值已经被缩放到0-1的浮点数,直接按uint8保存后大部分像素变成了0,图片几乎全黑。第二个问题需要在保存前先乘255,再转uint8。拿到数据后第一时间确认像素范围是0-255还是0-1,可以省不少排查时间。

4.3 图片看起来有波纹或变形

多半是reshape顺序搞错了。FER2013官方说明是按行展开的,所以直接用默认的C-order(行优先)reshape,即np.array(...).reshape(48, 48),顺序不会错。如果图像左右上下错位,说明原始数据可能是按列展开的,这时候需要先reshape成(48, 48)再转置,或者调整split的读取顺序。判断方法很简单,打印前几个像素值,对照一张已知人脸图的左上角,基本就能看出来。

4.4 遍历3万多行慢不慢

df.iterrows()虽然方便,但性能不算好。如果觉得提取太慢,可以把pixels列一次性解析成大数组,再循环保存:

pixels_matrix = [] for seq in df["pixels"]: pixels_matrix.append(np.array(seq.split(), dtype=np.uint8)) pixels_matrix = np.vstack(pixels_matrix).reshape(-1, 48, 48)

实测下来,解析部分耗时占比很低,大部分时间在文件I/O上。所以日常提取3万多张图,迭代方式也就一两分钟,不需要过度优化。

4.5 CSV文件读取时编码报错

个别情况下用pandas读取会出现UnicodeDecodeError,大多因为文件编码不是utf-8而是latin-1或cp1252。解决方案:

df = pd.read_csv(csv_path, encoding="latin-1")

现在网上流传的FER2013版本不少,有的加了额外字段,有的把Usage字段去掉或改名。遇到这类变体版本,先用df.columns确认列名,再调整代码,不要拿着旧逻辑硬套。

5. 最后再说两句个人经验

提取FER2013图片这件事,技术上不算复杂,但它是整个表情识别项目里绕不开的起点。我一开始也觉得直接读CSV训练就行,没必要提取,后来真正开始做迁移学习时才发现,把图片提取出来的价值不止是省几行代码,更重要的是它能让你用上框架自带的数据加载接口、能肉眼审查数据质量、能自由组合增强策略,甚至能配合OpenCV做人脸对齐等额外处理。

最后一个小建议:提取脚本和后续训练脚本分开写,目录命名加上版本号。这样当你换了一版数据或者改了一种存放方式时,不会把之前的成果搞乱。我自己在这个数据集上反复折腾的过程中体会到,越早把数据整理规整,后面做起实验来越省心。

如果你打算继续往下走,可以考虑几个扩展方向:一是构建一个直接从CSV读取的自定义Dataloader,跳过图片文件,适合做分布式训练时减少IO;二是针对Disgust这类少数类别做平衡采样,改善模型在类别不均衡下的表现;三是把FER2013和后续的FER2013+等版本做对比实验,看看噪声标签对模型的影响。这些方向都很有意思,之后有机会我再单独整理分享。

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

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

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

立即咨询