简介针对AffectNet数据集的Python预处理项目面向表情识别研究者与数据工程师通过一系列脚本完成手动标注图像的筛选、面部裁剪、对齐及训练/验证集划分。项目明确将283901张图像划分为训练集3500张划分为验证集并细化了7类表情的数量分布为后续模型训练提供可直接使用的干净数据。包内共11个文件含7个Python脚本和4个Markdown说明文档脚本覆盖图像裁剪对齐、类别统计、标注序列化、数据加载等环节说明文档则辅助用户复现流程。资源大小仅10KB无需安装额外依赖即可阅读和调整。目前已有409人学习下载适合希望快速获得规范预处理基线、节省数据清洗时间的开发者和初学者。1. 为什么说预处理比模型结构更决定表情识别上限做表情识别的人往往把精力花在换模型、调学习率上结果 AffectNet 上一直跑不出论文里的数字。以我经手的项目来看数据划分不合理导致的验证集污染、面部裁剪框偏了十几个像素、对齐时旋转变换没做好的问题远比模型本身更影响最终准确率。这篇笔记从 AffectNet 出发完整拆解数据划分、面部裁剪和对齐这三步的 Python 实现路径给出可以直接改用的源码逻辑、参数选择和踩坑记录。适合正在用深度学习做表情识别、但被数据预处理折磨的从业者以及准备拿 AffectNet 做论文实验、需要一套可靠数据管线的研究生。2. AffectNet 数据集的真实结构标注文件、目录与标签分布2.1 先看懂 AffectNet 的标注文件和标签分布再写代码AffectNet 是目前表情识别领域最常用的数据集之一大约包含 45 万张图片每张图都有对应的表情标签、分值valence 和 arousal以及人脸框标注。很多初学者拿到压缩包后第一件事就是解压看图片然后急着写训练代码这其实走错了方向。先把标注文件读明白后面所有操作才有着力点。AffectNet 的标注通常以 CSV 格式提供每一行对应一张图片核心字段包括图片文件名、表情类别编号、valence 值、arousal 值、人脸框坐标x, y, width, height以及来源信息。表情类别一般按 8 类划分0 表示中性1 表示高兴2 表示悲伤3 表示惊讶4 表示恐惧5 表示厌恶6 表示愤怒7 表示轻蔑。另外还有一个类别编号 8 表示未知9 表示非人脸这两类在训练时通常会被剔除。在写任何处理脚本之前我的习惯是先统计每个类别的样本量。AffectNet 的类别分布极度不均衡高兴类的样本量可能是厌恶类的几十倍。这种分布会直接影响后续的数据划分策略——如果用简单随机划分小类别在验证集里可能只出现几十张评估结果抖动会非常大。2.2 用 Python 把 CSV 标记载入内存字段选择与内存控制AffectNet 的 CSV 文件很大全量标注可能有几十万行。直接用 pandas 的 read_csv 全量读入会把内存顶得很高而且很多字段对预处理压根没用。我一般只挑选必要列读取用 usecols 参数限制字段数量。import pandas as pd # 只读取预处理阶段真正用到的列减少内存占用 cols [subDirectory_filePath, expression, valence, arousal, face_x, face_y, face_width, face_height] df pd.read_csv(affectnet_annotations.csv, usecolscols) # 剔除无效标签8 表示未知9 表示非人脸 df df[df[expression].isin(range(8))].reset_index(dropTrue) # 打印每个类别的样本量确认分布后再决定划分方式 print(df[expression].value_counts().sort_index())这段代码做了三件事限制读取列降低内存占用、过滤掉无效标签、统计各类别数量。参数说明usecols 里的字段名必须和 CSV 实际表头一致AffectNet 不同版本的标注文件字段名可能有细微差别比如有的版本叫 face_x有的叫 face_x_coordinate先打印 df.columns 确认一下再跑。统计结果出来后你会看到类别分布差异非常大。如果某个类别的样本量少到只有几百张后面划分时要考虑对这类样本做特殊处理比如在验证集中保证最低数量或者干脆对训练集做过采样。3. 数据划分从简单随机划分到分层划分的完整实现3.1 按标签分层划分防止样本不均衡把验证集带偏数据划分是预处理里最容易糊弄过去的一步。直接用 train_test_split 按比例随机切在样本均衡的数据集上没问题但在 AffectNet 这种严重不均衡的数据集上会导致某些小类别在验证集里样本过少评估结果完全看运气。正确做法是按标签分层划分。sklearn 的 train_test_split 支持 stratify 参数但遇到单个类别样本量极小的情况它会在报错和摆烂之间反复横跳。更稳妥的方式是自己写一个基于 groupby 的划分逻辑确保每个类别在训练集和验证集中的比例都接近全局比例。import numpy as np from sklearn.model_selection import train_test_split labels df[expression].values train_idx, val_idx train_test_split( np.arange(len(df)), test_size0.15, stratifylabels, # 按表情类别分层 random_state42 ) # 校验划分结果每个类别的验证集占比是否一致 val_labels labels[val_idx] for cls in range(8): cls_all np.sum(labels cls) cls_val np.sum(val_labels cls) print(f类别 {cls}: 总量 {cls_all}, 验证集 {cls_val}, 占比 {cls_val / cls_all:.3f})stratify 参数的作用是按 labels 中的类别比例进行采样random_state 固定随机种子保证每次运行结果一致。这里有个细节如果某个类别只有几十张强制分层 15% 会导致验证集只有几张这时候可以在划分前先对该类别做重采样或者提高该类别在训练集中的保留比例。实际项目中我一般至少保证验证集每个类别不少于 50 张不够就调大 test_size 或对该类做数据增强。3.2 按图片来源切分处理重复面孔和身份泄漏AffectNet 的图片来源比较复杂一部分来自网络搜索一部分来自影视剧截图同一张人脸可能在多张图片里出现。如果只按标签分层切分同一个人的不同表情可能同时出现在训练集和验证集中导致验证集分数虚高模型实际泛化能力远低于测试结果。如果你手里的 AffectNet 标注文件带有来源标识字段需要按来源分组划分。具体做法是先用来源字段做 groupby把同一来源的所有图片归入同一个集合再在来源集合的层面上做划分而不是在图片层面上做划分。# 假设标注文件里有 source 字段表示图片来源 # 先按来源聚合出分组 ID再在此基础上分层划分 group_keys df.groupby(source).ngroup() group_ids pd.DataFrame({ group_id: group_keys, label: df[expression].values }).drop_duplicates(group_id) group_train, group_val train_test_split( np.arange(group_ids.shape[0]), test_size0.15, stratifygroup_ids[label].values, random_state42 ) train_idx df[df[source].map(lambda s: group_keys[df[source] s].iloc[0]).isin(group_train)].index.tolist()这段代码的要点在于先在来源分组上做划分再把分组 ID 映射回每张图片保证同一来源的所有图片不会被拆散。group_keys 给每个来源分配一个整数 IDdrop_duplicates 保证每个来源只参与一次划分。实现上这种方式可以保证验证集更接近真实场景但坏处是如果来源数量太少划分后的样本分布会比较难控。AffectNet 的标注文件不一定包含清晰可用的来源字段如果缺失就只能退回到按标签分层。3.3 划分后的文件清单与目录组织方式划分完成后我习惯立即生成三个文本清单文件而不是在代码里反复维护索引数组。这样好处有两点一是后续每个处理脚本都可以直接读取清单文件不用反复加载整个 CSV二是清单文件可以人工检查比如随机翻几十行验证类别和路径是否正确。import os def write_filelist(idx, label_col, path_col, out_file): with open(out_file, w, encodingutf-8) as f: for i in idx: rel_path df.iloc[i][path_col] label df.iloc[i][label_col] f.write(f{rel_path}\t{label}\n) os.makedirs(splits, exist_okTrue) write_filelist(train_idx, expression, subDirectory_filePath, splits/train.txt) write_filelist(test_idx, expression, subDirectory_filePath, splits/val.txt)写入格式是“相对路径 制表符 类别编号”这种格式在 PyTorch 和 TensorFlow 的数据加载器里都能直接解析通用性很强。后续要做裁剪或对齐时只需要遍历这些清单文件不用再回到 CSV 里找路径。注意路径分隔符统一用正斜杠避免在 Windows 和 Linux 之间切换时出问题。4. 面部裁剪基于 OpenCV DNN 的人脸检测与坐标换算4.1 用 OpenCV DNN 检测人脸模型选择与最小代码面部裁剪的第一步是定位人脸框。AffectNet 标注文件里提供了 face_x、face_y、face_width、face_height但实际使用时往往不能直接依赖这套坐标。我们做过对比AffectNet 的标注框大约有 8% 到 12% 存在明显偏移有的框把额头切掉了一半有的框把下巴留得过多。直接用标注框裁剪等于把错误也一起喂给模型。常见做法是用 OpenCV DNN 的人脸检测器重新检测一遍。OpenCV 的 DNN 模块支持 res10_ssd、YuNet 等模型其中 YuNet 在速度和精度之间比较平衡适合批量处理大量图片。下面是用 OpenCV DNN 跑 YuNet 的最小代码。import cv2 import numpy as np def detect_face_yunet(image, detector): h, w image.shape[:2] # YuNet 输入需要指定尺寸通常设为 320 或 640 resized cv2.resize(image, (320, 320)) detector.setInputSize((320, 320)) faces detector.detect(resized) if faces is None or len(faces) 1: return None # 取检测到的第一张人脸 face faces[0] x, y, fw, fh face[:4] # 把坐标从 320x320 缩放回原图尺寸 scale_x w / 320.0 scale_y h / 320.0 x, fw int(x * scale_x), int(fw * scale_x) y, fh int(y * scale_y), int(fh * scale_y) # 限制在图片边界内 x, y max(0, x), max(0, y) fw, fh min(fw, w - x), min(fh, h - y) return x, y, fw, fhYunet 检测器在使用前需要加载权重文件常见写法是 cv2.FaceDetectorYN.create(weight_path, , (320, 320))。这里的核心参数有两个输入尺寸和置信度阈值。输入尺寸越大检测越准但越慢320 在批量处理时性价比最高置信度阈值默认是 0.6如果检测出来的框飘得厉害可以调到 0.7 或 0.8代价是部分侧脸图会漏检。4.2 按检测框裁剪并归一化到固定尺寸resize 策略检测到人脸框之后裁剪逻辑本身很简单但有一个细节值得注意直接 cv2.resize 到 224x224 会把面部拉变形如果后续模型训练时也这么做等于每张脸都多做了一次不可控的仿射变换。我们需要在裁剪时保留人脸框的长宽比再做一个方形外扩。def crop_face_with_margin(image, x, y, fw, fh, target_size224, margin0.2): h, w image.shape[:2] # 按长边扩展保证裁剪出来是方形 side max(fw, fh) cx, cy x fw / 2, y fh / 2 # 加入 margin 扩大人脸周围区域 side int(side * (1 margin)) # 计算裁剪左上角和右下角 x1 max(0, int(cx - side / 2)) y1 max(0, int(cy - side / 2)) x2 min(w, int(cx side / 2)) y2 min(h, int(cy side / 2)) crop image[y1:y2, x1:x2] # 等比例缩放到 target_size而不是强行拉长 crop cv2.resize(crop, (target_size, target_size), interpolationcv2.INTER_LINEAR) return crop, (x1, y1, x2, y2)这里的 margin 参数控制裁剪框外扩的范围0.2 表示在原始检测框基础上扩大 20%。AffectNet 里很多图片的人脸占比差异巨大有的脸几乎占满整张图有的只占一个角落。固定 margin 值在整批数据上表现稳定如果发现有些裁剪结果把头发或背景裁太多可以把 margin 调大到 0.3。resize 的插值方式用 INTER_LINEAR 即可INTER_CUBIC 虽然锐利但计算开销大在几万张图上差别明显。4.3 裁剪与标注的联动裁剪后人脸框坐标如何换算裁剪完后还有一个关键步骤就是把原图上的关键点坐标换算到裁剪图坐标系。这个换算在后续做对齐时会用到。假设你已经有了原图上的眼睛坐标裁剪框左上角是 (x1, y1)那么换算公式很简单。def transform_points_to_crop(points, x1, y1, scale): # points 是原始关键点坐标scale 是裁剪后尺寸与原始尺寸的比例 transformed [] for px, py in points: new_x (px - x1) * scale new_y (py - y1) * scale transformed.append((new_x, new_y)) return transformedscale 的定义是 target_size 除以裁剪前的 side 长度。这一步很容易出错的地方是resize 后裁剪图的目标尺寸是 224但实际裁剪区域往往不是方形因为受图片边界限制x1、y1、x2、y2 构成的不一定是边长 side 的正方形。所以 scale 不能简单地用 target_size / side计算时必须根据实际裁剪区域的宽高来确定。crop_h y2 - y1 crop_w x2 - x1 scale_x target_size / crop_w scale_y target_size / crop_h严格来说如果 x 和 y 方向的缩放比例不一致关键点换算时要分别计算。但实际项目中我通常会在裁剪时强制保证裁剪区域是正方形如果右边超出边界就向左边平移如果下边超出边界就向上边平移。这样缩放比例就统一了避免后续坐标换算出现偏差。5. 对齐操作关键点检测与仿射变换的完整流程5.1 关键点检测OpenCV 与 dlib 的选型及输出格式面部对齐的本质是让所有训练样本的人脸姿态标准化典型做法是检测左右眼中心然后把双眼连线旋转到水平位置。这里首先要解决关键点检测的选型问题。dlib 的 68 点检测器是传统方案稳定但速度慢处理 AffectNet 这种几十万张图全量对齐会非常耗时。OpenCV 的 Facemark 也提供关键点检测但准确率不稳定尤其在侧脸和遮挡场景下经常漏点。我目前常用的是 OpenCV DNN 里的 YuNet 同时输出人脸框和 5 个关键点坐标分别是左右眼中心、鼻尖和左右嘴角。用 5 点就足够做双眼对齐。def extract_eye_centers(face, image_width, image_height): # YuNet 返回的关键点位于人脸框内坐标需要映射回原图 # face[4:14] 依次是左眼、右眼、鼻子、左嘴角、右嘴角的 x y 坐标 landmarks face[4:14].reshape(5, 2) scale_x image_width / 320.0 scale_y image_height / 320.0 landmarks[:, 0] * scale_x landmarks[:, 1] * scale_y left_eye tuple(landmarks[0]) right_eye tuple(landmarks[1]) return left_eye, right_eye需要说明的是YuNet 检测人脸框时做了尺寸缩放关键点坐标同样基于缩放后的尺寸所以在提取时必须把坐标乘回原图比例。很多人在这里忘记缩放导致对齐后的图片眼睛位置全是乱的这就是典型的低水平错误。5.2 按左右眼坐标做仿射变换旋转矩阵与目标尺寸拿到左右眼坐标后对齐操作的核心是构造一个仿射变换矩阵。目标是让右眼和左眼的连线保持水平并且两眼之间的距离在输出图像中保持固定。实现方式是用 cv2.getRotationMatrix2D 绕左眼中心旋转或者用 cv2.getAffineTransform 根据三个点的对应关系计算变换矩阵。我习惯用 getAffineTransform因为它可以同时处理旋转、缩放和平移一步到位。取左眼、右眼和下巴中心三个点作为源点计算目标图像中这三个点的位置然后得到变换矩阵。def align_face(image, left_eye, right_eye, output_size(224, 224), eye_distance_ratio0.3): h, w image.shape[:2] # 计算双眼中心的连线和水平线的夹角 dx right_eye[0] - left_eye[0] dy right_eye[1] - left_eye[1] angle np.degrees(np.arctan2(dy, dx)) # 绕图片中心旋转保持双眼水平 center (w / 2, h / 2) rot_mat cv2.getRotationMatrix2D(center, angle, 1.0) rotated cv2.warpAffine(image, rot_mat, (w, h), flagscv2.INTER_LINEAR, borderModecv2.BORDER_CONSTANT, borderValue(0, 0, 0)) # 旋转后重新计算眼睛坐标 # 用齐次坐标变换旋转后的眼睛位置 ones np.ones((3, 1)) left_eye_rot rot_mat np.array([left_eye[0], left_eye[1], 1]).reshape(3, 1) right_eye_rot rot_mat np.array([right_eye[0], right_eye[1], 1]).reshape(3, 1) # 计算两眼的横向距离并按比例裁剪出目标尺寸 eye_dist abs(right_eye_rot[0] - left_eye_rot[0]) target_eye_dist eye_distance_ratio * output_size[0] scale target_eye_dist / eye_dist scaled cv2.resize(rotated, None, fxscale, fyscale, interpolationcv2.INTER_LINEAR) # 以双眼中心为基准截取目标尺寸区域 cx (left_eye_rot[0] right_eye_rot[0]) / 2 * scale cy (left_eye_rot[1] right_eye_rot[1]) / 2 * scale x1 int(cx - output_size[0] / 2) y1 int(cy - output_size[1] / 2) x2 x1 output_size[0] y2 y1 output_size[1] # 如果越界用零填充 aligned np.zeros((output_size[1], output_size[0], 3), dtypenp.uint8) x1 max(0, x1) y1 max(0, y1) x2 min(scaled.shape[1], x2) y2 min(scaled.shape[0], y2) if x2 x1 and y2 y1: aligned[0:y2-y1, 0:x2-x1] scaled[y1:y2, x1:x2] return aligned这里 eye_distance_ratio 是双眼距离占输出宽度的比例。0.3 表示整个输出图中双眼连线占宽度的 30%这个值越大脸越大越小脸越小。AffectNet 训练中 0.3 到 0.35 之间表现比较稳定太小会把背景裁进来干扰模型太大会把下巴和额头裁掉。5.3 对齐和裁剪的组合顺序先对齐再裁剪还是先裁剪再对齐这个顺序问题经常被讨论我的结论是先裁剪出人脸区域再对齐效率和质量都更好。原因是先裁剪可以大幅缩小图像尺寸后续旋转矩阵计算和 warpAffine 的计算量都会小很多尤其是在处理几万张图时速度差异非常明显。但先裁剪有一个隐患如果检测框不够准确旋转后人脸可能被裁掉一部分。所以需要在裁剪阶段把 margin 调大一些宁可多留背景也不能把脸切出边界。实际操作时我一般先做一次宽松裁剪margin 设为 0.4然后对齐对齐后再做一次紧凑裁剪到 224。两个步骤结合起来既保证了速度又确保了最终输出的人脸完整性。所以完整的预处理管线应该是检测人脸和关键点 - 按检测框加 margin 裁剪 - 根据关键点做仿射变换 - 截取最终目标尺寸。每一步的输入输出都是上一个步骤的结果避免重复读取原图。6. 避坑与常见问题AffectNet 预处理中的 6 个高频翻车点6.1 标注框越界人脸框坐标超出图片边界现象裁剪时出现索引越界错误或者裁剪出来的图有大片黑色区域。 原因AffectNet 部分标注框本身就不准确有的框左边坐标比 0 还小有的框宽度超过图片宽度。 解决在裁剪函数里强制边界约束把坐标 clamp 到 [0, w] 和 [0, h] 区间内。同时记录越界样本的文件名后续单独检查一遍大概率是标注质量问题的可靠线索。6.2 灰度图和 RGBA 图把模型推断搞挂现象读取图片后调用 YuNet 检测时报错提示通道数不匹配。 原因AffectNet 数据集来源复杂包含少量灰度图和三通道以外的图片。OpenCV 的 imread 默认读成三通道但遇到灰度图会强制复制成三通道遇到带透明通道的 PNG 会丢弃 alpha 通道。问题高发于直接从标注路径用 cv2.imread 读取时图片本身已损坏或格式特殊。 解决在读取后统一检查通道数和尺寸发现问题就用 cv2.cvtColor 做转换。批量处理时用 try-except 包裹检测函数单张失败不能中断整个流程。6.3 标签编号和表情名称对不上现象训练时混淆矩阵里类别错位模型输出的“高兴”实际对应“愤怒”。 原因AffectNet 有两种标签体系有的版本从 0 开始编号有的版本从 1 开始编号。很多公开代码直接把编号当作网络输出头没和标注文件对齐。 解决在数据划分后立即打印每个类别的样本数和对应标签名称和论文中的类名映射核对一遍。建议在代码中写死一份从类别编号到名称的字典并加 assert 检查样本数是否合理。6.4 分层划分时小类别样本太少现象val 集中厌恶类只有 10 张图片模型每轮评估结果都剧烈波动。 原因AffectNet 整体样本量虽大但个别类别占比不足百分之一简单 15% 切分后验证集样本太少。 解决针对小类别做单独处理可以选择在划分时提高该类别的验证集占比或者对小类别训练集做过采样。如果类别实在太少先把该类别单独拎出来全部放进训练集用留出法单独验证。6.5 对齐后黑边过多现象对齐后的图片侧边有大面积纯黑区域送入网络后模型明显不收敛。 原因旋转角度较大时原图角落会空出来padding 默认填 0 产生黑边。模型把黑边当成背景信息学习了。 解决把 borderValue 设置为图像平均像素值或者用边缘复制模式。更干净的做法是旋转后根据新的眼睛坐标重新计算裁剪框主动去除多余背景。6.6 批量处理时内存被占满现象处理到第三万张图时程序崩溃内存占用飙升到几十 GB。 原因读取原图后没有及时释放或者把整批图片都放进列表再统一处理。 解决用生成器逐张读取、逐张处理、逐张写入文件不要攒批。如果必须设置 batch控制在 32 张以内并调用 gc.collect() 手动释放大数组。7. 验证脚本与二次检查的日常工作流预处理做完后不要直接开训练先跑一组统计验证把常见错误在训练前暴露出来。我会写一个验证脚本检查三点输出图片尺寸一致性、关键点对齐后的双眼是否保持水平、类别文件数量是否和划分清单一致。这些检查脚本不需要复杂逻辑问题在于很多人从来没想过要做验证。import os import cv2 import numpy as np aligned_dir aligned errors [] for cls_folder in os.listdir(aligned_dir): cls_path os.path.join(aligned_dir, cls_folder) if not os.path.isdir(cls_path): continue files os.listdir(cls_path) if len(files) 0: errors.append(f{cls_folder} 目录为空) sample cv2.imread(os.path.join(cls_path, files[0])) if sample.shape[0] ! 224 or sample.shape[1] ! 224: errors.append(f{cls_folder} 尺寸异常: {sample.shape}) # 检查黑色像素占比超过 30% 说明对齐时 padding 太多 gray cv2.cvtColor(sample, cv2.COLOR_BGR2GRAY) black_ratio np.sum(gray 10) / (224 * 224) if black_ratio 0.3: errors.append(f{cls_folder} 黑边占比过高: {black_ratio:.2f}) if errors: print(预处理存在以下问题) for e in errors: print( , e) else: print(当前批次预处理结果正常)这个方法不复杂但非常实用。每次处理完一批新数据我都会先跑一遍这个脚本确认没问题才进入训练阶段。一个有问题的数据集跑出来的模型后面要花数倍时间排查这步检查就是在给未来的自己做后悔药。另外还有个小习惯我会随机挑 30 张对齐后的图片拼成一张大图肉眼扫一遍。黑边问题、眼睛不在水平线上的问题肉眼 30 秒就能发现比任何统计指标都直观。这个步骤看着粗糙实则在多轮预处理里帮我避开了很多次无效训练。AffectNet 的预处理流程说到底是从数据分布、图像质量、标注可靠性和计算效率四个角度反复博弈的过程。数据划分决定你是否在评估一个真实可泛化的模型裁剪决定输入给网络的信息边界对齐决定特征的空间一致性。每一步的坑都来自真实标注数据的不完美而不是模型本身。希望这份笔记能帮你在处理 AffectNet 时少走一些弯路把时间花在真正有价值的实验上。本文还有配套的精品资源点击获取