ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AffectNet表情识别预处理:数据划分、裁剪与对齐避坑指南

AffectNet表情识别预处理:数据划分、裁剪与对齐避坑指南 简介面向面部表情识别研究和AffectNet数据集预处理需求这份仅10KB的Python项目源码提供了完整的数据划分、人脸裁剪与对齐方案。包内共11个文件由7个.py脚本和4个.md文档组成脚本覆盖图像裁剪对齐、每类表情数量统计、标注文件生成、数据加载以及噪声标签生成等关键操作文档则说明了各模块用法和目录结构。项目基于手动标注的7类表情划分出训练集283901张图像其中开心表情134415张、中性74874张、悲伤25459张、惊讶14090张、生气24882张、恐惧6378张、厌恶3803张验证集3500张每类表情各500张。这样的划分结果可直接用于表情识别模型训练与评估免去自行处理原始数据的繁琐流程。已有409人学习该资源适合需要在AffectNet上快速开展实验的计算机视觉研究者与学生也可按需调整脚本以适配自定义数据集或进一步扩展类别。1. AffectNet 预处理为什么表情识别项目一半时间都耗在数据划分、裁剪和对齐上拿到 AffectNet 原始包的那一刻大部分人的第一个冲动是直接写个 DataLoader 开训。结果往往是 loss 曲线上下乱跳、验证集永远不上涨、训练到一半还报坐标越界。真正的问题不是模型而是数据没规整AffectNet 超过四十万张图像手动标注和自动标注混在一起bbox 有的贴着人脸有的框进整个上半身landmark 顺序在不同版本里有出入一句话这就是一个需要 Python 实现数据划分、面部裁剪和对齐等预处理操作的表情识别挑战。本篇文章就按照数据划分、面部裁剪、面部对齐这条实际处理链路展开附带可直接复制的源码和参数后边再讲那些不跑一遍根本猜不到的坑。适合准备在 AffectNet 上做表情识别、人脸属性分析或者多模态情感研究的从业者看完能少踩一半的坑。2. 数据划分用 stratify 把 AffectNet 切成 train / val / test2.1 AffectNet 目录结构与三种划分策略AffectNet 的官方发布结构里Manually_Annotated是人工标注子集标注质量高是表情识别实验的主力Automatically_Annotated是自动标注的庞然大物量大但噪声多。每个子集里又有Images、Annotations等目录标注信息分散在 CSV 文件里包括图像路径、表达式标签expression0 到 7 共八类、面部 bbox、40 个人脸关键点等。官方其实已经给了 train / val / test 的划分文件为什么还要自己再划分一次因为我实际做项目时有三个刚需一是官方划分里的类别分布并不均衡某些小类别在 val 里只有几十张评估结果波动极大二是做增量学习、半监督或者跨域实验时需要按自己的时间或图像来源重切三是想把自动标注子集按比例混入训练集这时候官方划分文件根本不够用必须自己写脚本。常见的三种划分策略最省事的是随机划分但它会把类别分布打散小类可能全部掉进训练集或测试集模型在测试阶段直接翻车第二种是按表情类别做分层抽样也就是 stratify保证每一类在三个集合中的占比和全量一致第三种是按身份去重划分AffectNet 没有公开身份 ID要做的话只能靠人脸特征聚类成本高表情识别任务不推荐一上来就搞。我一般用的是第二种代码简单可复现性也强。2.2 数据划分脚本固定随机种子、分层抽样、输出三类索引文件直接上脚本。这里假设你已经把标注 CSV 整理成包含image_path和expression两列的格式image_path是相对于数据根目录的路径。# split_affectnet.py import csv import os import random from collections import Counter from sklearn.model_selection import train_test_split random.seed(42) # 固定种子保证任何人复跑得到完全一致的划分 IMG_ROOT /data/AffectNet SRC_CSV affectnet_annotations.csv rows [] with open(SRC_CSV, newline, encodingutf-8) as f: reader csv.DictReader(f) for rec in reader: img_path rec[image_path] # 例如 Manually_Annotated/Images/0/xxx.jpg label int(rec[expression]) # 0-7 对应八类表情 # 原数据里有个别 jpg 缺失先过滤再做划分避免后续读取时崩 if not os.path.exists(os.path.join(IMG_ROOT, img_path)): continue rows.append((img_path, label)) print(f有效样本数: {len(rows)}) print(f全量标签分布: {Counter(l for _, l in rows)}) train_val, test train_test_split( rows, test_size0.10, random_state42, stratify[label for _, label in rows], # 第一刀分出 10% 测试集 ) # 第二刀从剩余 90% 里再分出约 10% 作为验证集 val_size len(test) train, val train_test_split( train_val, test_sizeval_size, random_state42, stratify[label for _, label in train_val], )逻辑说明第一刀分出 10% 作为测试集第二刀从剩余 90% 中拿出约总体的 10% 作为验证集最终比例约 80 / 10 / 10。两次都用stratify按标签分层保证每类表情在三个集合中的占比与全量一致小类不会被随机过程丢掉。random_state固定为 42这是为了复现实验如果你要跟别人协作种子必须写成常量写进 README否则每个人跑出来的划分都不一样后面对结果没法对齐。参数说明test_size是浮点数时表示比例实际落地时我更倾向于先把总量算清楚用绝对数量控制验证集和测试集规模。如果数据量特别大比如要把自动标注子集也切进来可以先把官方 CSV 按子集拆分再分层不要让Automatically_Annotated里的噪声样本污染验证集。for name, part in [(train, train), (val, val), (test, test)]: with open(faffectnet_{name}.txt, w, encodingutf-8) as f: for img_path, label in part: f.write(f{img_path}\t{label}\n) print(f{name}: {len(part)} 张)逻辑说明这里把划分结果写成affectnet_train.txt / affectnet_val.txt / affectnet_test.txt每行是图像路径加标签用制表符隔开。这个格式是给后续 PyTorch Dataset 用的torchvision.datasets.ImageFolder也能直接消费只要目录结构对应。为什么不直接移动图片因为移动 30 万张文件在机械硬盘上要跑几个小时而写索引文件是毫秒级的训练时按索引读路径就行这才是工程上常见做法。2.3 划分阶段就该过滤坏的样本别留着后面炸划分脚本里提前过滤了缺失图片但真正的 AffectNet 里还有两类脏数据label 不是 0-7 的异常行和 bbox / landmark 字段为空的记录。如果这些脏数据进了训练集后面裁剪对齐脚本一跑就爆回头排查时你根本分不清是划分的锅还是处理的锅。所以我建议划分的同时就做三件事检查 label 合法性检查 bbox 坐标是否存在且宽高为正抽样 20 张图把 landmark 画上去人工确认坐标点顺序。画 landmark 时注意AffectNet 官方给的 40 个点在不同发布版本里的索引含义不完全一致有的版本第 16、17 号是左右眼中心有的版本顺序不同。这个不亲眼确认后面对齐脚本里的索引全是错的。我踩过一次很惨跑完一万张对齐图才发现眼睛坐标取反了整批数据重来血泪经验。3. 面部裁剪bbox 加 margin 的正确打开方式与越界处理3.1 直接裁标注框会得到什么AffectNet bbox 的松紧差异AffectNet 的 bbox 是矩形框x, y, w, h标注质量并不稳定。手动标注子集里有些框紧贴脸部轮廓下巴和额头几乎不露自动标注子集里有些框把头发、肩膀甚至背景都包了进去。直接把x, y, w, h裁出来喂给模型会出现一个严重问题同一类表情的图像里人脸占比从 0.6 到 1.0 都有模型学到的是“背景 半张脸”的上下文而不是表情本身。正确做法是给 bbox 加 margin。具体加多少没有标准答案我一般取宽高的 10% 到 15%。margin 太小脸贴边卷积核在边缘采样的感受野是不完整的等价于引入一种奇怪的裁剪噪声margin 太大背景信息过多模型会去学发色和肩膀轮廓。如果你想让人脸占比尽量接近一个固定值可以先算原图里人脸面积和整图面积的比例再按目标比例反推扩展系数。但对表情识别来说15% 是个很稳的起点不用过度调优后面做对齐时会统一下采样。3.2 裁剪脚本中心扩展、clip 边界、统一 resize这里给一个可直接落地的裁剪函数。注意一个工程细节不要直接用img[y : y h, x : x w]去切而是以 bbox 中心为基准向外扩保证扩展后的人脸还在图像中心附近这样即使 margin 不对称裁剪结果也不至于偏到一边。# crop_face_affectnet.py import csv import cv2 from pathlib import Path IMG_ROOT Path(/data/AffectNet) OUT_ROOT Path(/data/AffectNet_cropped) OUT_ROOT.mkdir(parentsTrue, exist_okTrue) MARGIN_RATIO 0.15 # bbox 宽高各向外扩展 15%背景留一点但不喧宾夺主 OUT_SIZE (224, 224) # 统一输出尺寸和模型输入对齐 def crop_face_with_margin(img, x, y, w, h): 以 bbox 中心为基准向外扩展越界处 clip 到图像边界返回裁剪 resize 后的人脸。 h_img, w_img img.shape[:2] cx, cy x w / 2.0, y h / 2.0 half_w w * (1 MARGIN_RATIO) / 2.0 half_h h * (1 MARGIN_RATIO) / 2.0 x0 max(0, int(cx - half_w)) y0 max(0, int(cy - half_h)) x1 min(w_img, int(cx half_w)) y1 min(h_img, int(cy half_h)) if x1 x0 or y1 y0: return None # 说明 bbox 本身就越界严重直接标记废样本 face img[y0:y1, x0:x1] return cv2.resize(face, OUT_SIZE, interpolationcv2.INTER_CUBIC)逻辑说明函数先算出 bbox 中心cx, cy再按扩展后的半宽半高算左上角和右下角。max/min是为了把越界部分 clip 回图像范围内。之所以先算中心再 clip而不是直接对x, y, w, h做膨胀是因为人脸靠近图像边缘时clip 会截掉一边按中心扩展至少能保证脸不偏出画面。若 clip 后宽高为零或负数直接返回None由外层调用者跳过并记录不要抛异常打断批量处理。参数说明MARGIN_RATIO 0.15表示上下左右各扩 15%不是总面积扩 15%INTER_CUBIC适合放大如果缩小为主可以换INTER_AREA。这里用 224x224 是因为 ResNet 系列的标准输入如果你用 112 或者 96直接改OUT_SIZE即可。外层循环写起来很简单但要注意保留原始相对目录结构with open(affectnet_train.txt, encodingutf-8) as f: lines [line.strip().split(\t) for line in f] for rel_path, label in lines: src_img IMG_ROOT / rel_path img cv2.imread(str(src_img)) if img is None: continue x, y, w, h get_bbox(rel_path) # 从标注 CSV 里查 bbox按 rel_path 关联 face crop_face_with_margin(img, x, y, w, h) if face is None: continue out_path OUT_ROOT / rel_path out_path.parent.mkdir(parentsTrue, exist_okTrue) cv2.imwrite(str(out_path), face)逻辑说明get_bbox是从标注文件里按图像路径查x, y, w, h的函数我这里只写调用不展开因为实现取决于你解析 CSV 的方式。保存时沿用原本的目录层级好处是排查时可以快速把原图和裁剪图对照起来看坏处是目录多但目录数无所谓文件系统处理小文件慢是后话缓存方案在最后一章讲。3.3 先裁剪还是先对齐顺序决定坐标是否错乱很多人把裁剪和对齐写成两步先按 bbox 裁出人脸再对裁剪结果做对齐这会在坐标换算上埋坑。landmark 标注的是原图坐标系里的坐标一旦裁剪landmark 必须减去裁剪偏移量才能继续用。偏移量本身不难算但 AffecNet 的 bbox 标注有的地方跟 landmark 边缘不一致减完偏移后 landmark 要么飞出图外要么挤在边界上。推荐的顺序是先对齐后裁剪。也就是在原始图上用 landmark 做相似变换得到一张脸已经被摆正的大图再在这张大图上按对齐后的目标区域裁剪成统一尺寸。这样做的第二个好处是旋转后的图像中人脸自然水平后续任何基于裁剪图的再处理都免去了坐标旋转的麻烦。我在自己的项目里把对齐和裁剪写进同一个函数一步出结果避免中间文件不一致。4. 面部对齐用 estimateAffinePartial2D 做相似变换不扭曲表情4.1 为什么用相似变换而不是仿射表情识别里不能让脸变形谈到对齐最常见的面孔是对齐到标准坐标操作是眼连线水平化。OpenCV 的getAffineTransform可以任意挪动三个点但它是自由仿射会把一个正脸拉成歪脸或大小眼warpPerspective是射影变换自由度更高更不适合。表情识别对脸的形变极其敏感同样一个微笑宽度被拉伸 5%模型可能把它当成大笑甚至恐惧。所以正确选择是相似变换数学上只包含旋转、等比例缩放和平移不改变人脸的真实形状。OpenCV 里的estimateAffinePartial2D就是干这个的它能从两组对应点里估计出一个带 4 个自由度的 2x3 矩阵完美符合我们的需求。AffectNet 自带 40 个 landmark但两点就够支撑相似变换就是左眼中心和右眼中心。4.2 对齐脚本模板点、估计矩阵、warpAffine 三件套下面是对齐函数直接可跑。注意这里用了占位索引跑之前务必按 2.3 节的方法可视化确认左右眼中心在你手里的 landmark 文件中是第几个点。# align_face_affectnet.py import cv2 import numpy as np ALIGN_SIZE (112, 112) # 对齐输出尺寸112 是轻量级模型常用输入 EYE_TARGET_DIST 40 # 对齐后左右眼中心目标距离控制脸在画面中的占比 LEFT_EYE_INDEX 16 # 换成你确认过的左眼中心索引 RIGHT_EYE_INDEX 17 # 换成你确认过的右眼中心索引 def build_template(size112, eye_dist40): 构造标准脸模板两眼连线水平且中点落在图像中轴略偏上位置。 cx size // 2 cy int(size * 0.42) # 偏上一点给下巴留空间 return np.float32([ [cx - eye_dist / 2, cy], [cx eye_dist / 2, cy], ]) def align_face_affectnet(img, landmarks): dst build_template(ALIGN_SIZE, EYE_TARGET_DIST) src np.float32([ [landmarks[LEFT_EYE_INDEX][0], landmarks[LEFT_EYE_INDEX][1]], [landmarks[RIGHT_EYE_INDEX][0], landmarks[RIGHT_EYE_INDEX][1]], ]) # 相似变换仅估计旋转、缩放、平移不引入剪切变形 M, _ cv2.estimateAffinePartial2D(src, dst, methodcv2.LMEDS) if M is None: return None aligned cv2.warpAffine( img, M, ALIGN_SIZE, flagscv2.INTER_CUBIC, borderModecv2.BORDER_CONSTANT, # 边界填充黑色不要用镜像或环绕 ) return aligned逻辑说明build_template在 112x112 的画布上定了两点左右眼中心水平距离 40 像素中心点纵坐标在112 * 0.42的位置。estimateAffinePartial2D接收原始 landmark 的两眼点和模板点返回一个 2x3 矩阵M这个矩阵把原图旋转缩放到让两眼落点的位置。warpAffine按M重采样像素得到摆正的人脸。LMEDS是稳健估计方法如果传入的两点中有一个明显偏离它能扛住部分离群点不会像最小二乘那样被拖走。参数说明EYE_TARGET_DIST 40直接决定脸占画面的比例40 对应 112 尺寸下脸宽约占 36%接近常规识别任务的经验值。如果你用 224 输出可以按比例改成 80但我更推荐 112 或 96表情识别不需要太高分辨率高分辨率只会拖慢训练。BORDER_CONSTANT生成的黑色填充区不会伪造纹理这点很重要如果用BORDER_REFLECT脸周围会出现镜像的假皮肤纹理模型很容易学到错误特征。4.3 对齐后的坐标系后续标注必须跟着同一个矩阵走对齐不只是视觉上的摆正它实际上改变了原图的像素坐标。裁剪、旋转过之后再想叠加遮挡 mask、注意力图或者嘴部区域标注就不能直接用原图坐标了必须把 mask 或 box 也套上M做一次warpAffine。常见做法是对齐整图后把二值 mask 也用同一个M变换到 112x112再做nearest插值保持 mask 不出现模糊值。AffectNet 本身不提供遮挡 mask 或语义分割标注但很多下游研究会引入外部标注比如嘴部运动单元检测这时坐标一致性就成了玄学问题看起来对齐了实际 mask 全错位。我的做法是每次对齐都顺手把M矩阵存成一个.npy文件和图像同路径不同后缀后边做任何涉及坐标的运算直接读矩阵换算不再从图上重新估计。这个习惯帮我躲过好几次数据事故。5. AffectNet 预处理避坑清单现象、原因、解决一条条说清5.1 裁剪后大量黑边人脸跑到角落现象裁剪出的图片里有的是大面积黑色补边人脸偏在角落表情已经变形。原因bbox 越界后 clip 把一边截没了人脸的相对位置被剪掉了或者MARGIN_RATIO取得太大背景占了主要面积。解决先按中心扩展再 clip代码见 3.2 节MARGIN_RATIO不要超过 0.2对 clip 后宽高比小于原 bbox 70% 的图直接跳过并把这部分记录为坏样本输出到一个bad_bbox.txt方便后续决定重标还是删除。5.2 对齐后眼睛不在水平线整批脸歪现象跑完一万张对齐图抽查发现 5% 左右的图两只眼睛连线明显倾斜有的上下差 20 像素。原因AffectNet 的 40 个 landmark 索引在部分版本里不一样你以为是左眼中心实际那个点是眉毛外侧或内眼角。解决批量对齐前先抽 20 张图把 landmark 逐帧可视化到原图上截图检查确认索引后再跑全量。如果不想依赖索引可以换成自动人脸关键点检测比如face_alignment库重新提取 68 点再以 68 点里的 36、45 号作为左右眼中心虽然慢一点但索引不会错。我的经验是手工标注的 landmark 本身质量很好不值得为少数版本差异引入额外检测模型。5.3 stratify 分层时报错小类样本数小于分层数现象调用train_test_split时直接抛错提示最小类别样本数不足以完成分层。原因AffectNet 中contempt轻蔑和disgust厌恶样本数量远少于neutral和happy当test_size设得过大或同时切三份时小类剩下不足一个批次。解决有两种做法一是把test_size调小比如先切成 5% 测试集再切 5% 验证集二是对小类采用过采样先复制少量样本再分层但这种做法会改变原始分布报告结果时要说明。我一般建议直接沿用官方验证集只在训练集内部做二次划分这样既不打破对外评估基准也能拿到稳定指标。5.4 读到 CSV 里的 nan 坐标程序全崩现象批量处理时某张图一裁就报错定位后发现标注行里x是nan一转换就抛异常。原因AffectNet 的 bbox 和 landmark 标注并不是每条记录都完整部分自动标注图像缺失关键点信息。解决读取 CSV 后用pd.isnull()或math.isnan()做一次全字段检查把这些记录过滤到bad_annotations.txt。不要在循环里 try except批量处理几十万行时 try except 的开销和排查成本都不可控前置过滤最省心。5.5 自动标注子集混入后验证集指标虚高现象训练完模型验证集 accuracy 比论文还高但换成真实场景的摄像头画面效果差得离谱。原因自动标注子集里同一张脸、同一表情的相似图像非常多划分时按文件随机切这些近似图同时进了训练集和验证集造成了时间戳式的泄漏评估结果虚高。解决先按图像哈希做相似去重再划分。AffectNet 没有提供专门的去重工具可以用imagededup这类库对巨大子集先聚类或者干脆只在手动标注子集上做实验确保验证集干净。这也是我在 2.1 里建议优先用手动标注集的原因。6. 验证预处理效果可视化检查、缓存加速和训练对比预处理做完不算完还要验证它对模型到底帮了多少。最直接的方法是做一次三组对照原图、只裁剪、裁剪加对齐用同一个 ResNet18 在同样的训练配置下各跑 50 个 epoch看验证集 accuracy 曲线。表情识别里对齐带来的提升往往在 2 到 5 个点之间而只裁剪不对齐有时候反而比原图还差因为人脸没有摆正卷积核浪费在背景上。# 验证对齐质量随机抽 100 张左右眼连线倾角统计 import numpy as np angles [] for landmarks in sampled_landmarks: dx landmarks[RIGHT_EYE][0] - landmarks[LEFT_EYE][0] dy landmarks[RIGHT_EYE][1] - landmarks[LEFT_EYE][1] angles.append(np.degrees(np.arctan2(dy, dx))) print(f平均眼睛倾角: {np.mean(angles):.2f} 度) print(f倾角标准差: {np.std(angles):.2f} 度)逻辑说明倾角均值越接近 0说明系统性的旋转偏差越小标准差反映对齐的稳定性大于 5 度就意味着部分图没有对齐需要回去查 landmark 索引。这个检查跑一遍只要几秒钟是性价比最高的一步。缓存部分是容易被忽视的性能瓶颈。预处理完几十万张 112x112 的小图零散存在磁盘上训练时随机读取慢到怀疑人生。常见做法是把所有对齐图像写入一个 LMDB 或 HDF5 文件数据量控制在几个 GB训练时顺序读入内存再随机 shuffle。我一般会在预处理脚本最后加一个build_cache.py把 train/val/test 三个分片分别打包这样后面训练时不需要目录遍历IO 速度提升一个数量级。最后的经验是AffectNet 预处理这套链路真正花时间的不是代码本身而是确认坐标系、确认标注索引、确认划分不泄漏这三件事。我现在的习惯是每次拿到新数据集先花半天做可视化检查和划分验证再写批量处理脚本虽然看起来慢了但后期几乎不会再为数据问题返工希望帮到你。本文还有配套的精品资源点击获取
返回列表