ARTICLE DETAIL

资讯详情

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

AffectNet预处理实战:人脸检测对齐与分层划分

AffectNet预处理实战:人脸检测对齐与分层划分 简介面向计算机视觉与人脸表情识别场景这套用Python编写的项目源码专门处理AffectNet公开数据集帮助算法工程师、研究者及学习者解决手动标注图像的筛选、面部裁剪、对齐与数据划分问题覆盖中性、开心、悲伤、惊讶、恐惧、厌恶和生气7类表情最终生成28万余张训练图及每类500张的3500张验证图并附带各类别数量统计。资源包共11个文件由7个Python脚本与4个Markdown说明文档组成整体仅10KB脚本分别承担标注解析、图像裁剪对齐、数据加载、类别统计与噪声标签生成等任务文档则说明运行流程与目录结构轻量且便于移植。从数据规模看已有409人学习下载。通过这套源码可省去手工整理AffectNet数据的大量时间直接获得可复用的处理管线与验证集划分方案尤其适用于需要按表情类别控制样本均衡、复现论文实验或构建自定义训练数据集的场景。1. 从 AffectNet 到可训练数据集这个源码包到底帮你省掉了哪些脏活做面部表情识别的人几乎绕不开 AffectNet。这个数据集规模大、维度全但下载完解压只是第一步真正折磨人的是后续的数据划分、面部裁剪和对齐。原始图片尺寸不统一、人脸位置五花八门、标签分布严重失衡直接把 CSV 丢给模型训练结果基本靠玄学。这个源码包解决的就是这个问题把 AffectNet 原始数据重构成一份可以直接喂给 CNN 的干净数据集包含 8:1:1 的分层划分、基于 MTCNN 的人脸检测裁剪、五点关键点仿射对齐全程可视、可改参数、可复现。适合两类人——手里已经有 AffectNet 但没时间写预处理代码的研究者以及被脏图和类别失衡坑过的工程从业者。新手可以照脚本跑通全流程熟手能快速改掉裁剪尺寸、对齐参考点和划分比例。2. AffectNet 数据基础目录结构、CSV 标签和必须预处理的三个理由2.1 目录与文件四十万张图背后的组织方式AffectNet 的官方压缩包解压后图片主体在Manually_Annotated目录下按train_set、validation_set、test_set三个大目录分放每张图是一个普通 JPG 文件。与之配套的annotations目录里放着三个 CSV 文件分别对应训练集、验证集和测试集的标注信息。这里要特别注意官方已经给了划分但很多研究场景需要重新划分比如按表情类别均衡后的 8:1:1或者按自定义比例切分这个源码包的核心功能之一就是覆盖这部分工作。CSV 里每一行对应一张图片关键列包括subDirectory_filePath图片相对路径、face_x、face_y、face_w、face_h人脸框坐标和宽高、facial_expression表情类别编号、valence愉悦度和arousal激活度。前四列是给做人脸检测的人省事的参考框后两列是连续维度的情感标注。facial_expression共 8 类对应关系如下表。编号类别英文标签0中性Neutral1高兴Happy2悲伤Sad3惊讶Surprise4恐惧Fear5厌恶Disgust6愤怒Anger7轻蔑Contempt2.2 CSV 双标签体系离散类别与连续维度要分开处理AffectNet 和 Fer2013 这类数据集最大的区别在于双标签结构。facial_expression是离散类别直接用于分类任务valence和arousal是连续值分别表示情感的正负程度和强烈程度适合回归任务或维度情感分析。处理 CSV 时我一般建议拆成两份数据流一份以facial_expression为标签做分类另一份以valence、arousal为回归目标做回归。源码包里对标签的处理默认走分类路线但你如果要做维度情感识别只需要改一下标签读取逻辑底层图片预处理流程完全复用。还有一个容易踩的点是 CSV 里的subDirectory_filePath在不同操作系统下表现不同。官方压缩包用的是/分隔但如果你在 Windows 下解压路径可能变成train_set\\xxx\\1024.jpg。pandas 读进来后如果不做路径统一后面 OpenCV 读取会直接报“图片不存在”。我习惯在读入后立刻执行df[subDirectory_filePath] df[subDirectory_filePath].str.replace(\\\\, /)把路径分隔符统一成/这一步放在所有逻辑之前。2.3 为什么必须预处理脏图、类别失衡与尺寸混乱AffectNet 之所以需要预处理三个理由足够说服我。第一是脏图多。网络爬取的图片里有大量遮挡、模糊、多人脸和夸张光照官方的人脸框偶尔会偏直接用框裁剪出来的区域可能只有半张脸。第二是类别严重失衡。Happy 类样本数量远超 Disgust、Contempt 等类别不做分层划分直接随机切分训练集和验证集的分布会差很多。第三是图片尺寸完全不统一从几百像素到上千像素都有必须做统一 resize否则 batch 都没法拼。这三点直接决定了预处理脚本的设计路线先读 CSV 做分层划分再按人脸框或检测器裁剪最后统一尺寸和对齐。整个过程不是可选项而是把 AffectNet 从“原始素材”变成“训练数据”的必要工序。如果你之前只是简单地cv2.resize然后开训那测试精度上不去时先检查预处理管线问题大概率出在这一环。3. 数据划分模块按 8:1:1 分层切分训练/验证/测试集3.1 划分脚本入口参数、路径与随机种子数据划分是整个预处理流程的第一步也是最容易被低估的一步。很多人的做法是直接把官方给的train_set、validation_set、test_set拿过来用但官方划分不一定是你的目标场景需要的比例而且官方验证集和测试集的图片数量相对较少类别分布也未必均衡。这个源码包的做法是重新划分读入全部 CSV按facial_expression做分层抽样以 8:1:1 的比例拆成训练、验证、测试三份。import pandas as pd from sklearn.model_selection import train_test_split df pd.read_csv(annotations/all_annotations.csv) df[subDirectory_filePath] df[subDirectory_filePath].str.replace(\\\\, /) train_df, tmp_df train_test_split( df, test_size0.2, random_state42, stratifydf[facial_expression] ) val_df, test_df train_test_split( tmp_df, test_size0.5, random_state42, stratifytmp_df[facial_expression] ) train_df.to_csv(split/train.csv, indexFalse) val_df.to_csv(split/val.csv, indexFalse) test_df.to_csv(split/test.csv, indexFalse)分段说明第一步读入全部标注 CSV并把路径分隔符统一成/避免 Windows 下反斜杠导致后续读取失败。第二步用train_test_split先切出 80% 作为训练集剩下 20% 留作临时集合。第三步把临时集合再对半切得到 10% 验证集和 10% 测试集。这里test_size0.5是对临时集而言的所以最终比例是 8:1:1。random_state42固定随机种子保证每次运行划分结果一致这一点在复现实验时极其重要。3.2 分层抽样的实现逻辑为什么 stratify 不能省如果忽略stratify参数代码也能跑通但划分结果可能让你在训练时吃大亏。AffectNet 的类别分布严重倾斜Happy 可能占到三成以上而 Disgust 可能只有几个百分点。随机切分时小类别样本数量本来就少万一在随机过程中全部流进训练集验证集里这个类的样本数就变成零模型对这个类的评估直接失真。stratifydf[facial_expression]的作用是让切分前后每个类别的占比保持一致这是数据划分里最不该省的一行。实际操作中我还会在划分后打一眼类别分布是否真的按比例走了。常见做法是用交叉表统计每个集合里各类别的样本数写成下面这样。for name, subset in [(train, train_df), (val, val_df), (test, test_df)]: dist subset[facial_expression].value_counts(normalizeTrue).sort_index() print(f{name}: {dist.values})打印结果应该能看到三个集合的每个类别占比基本一致差别在小数点后两位以内。如果某个类在验证集里的占比掉到 0.5% 以下说明分层没生效回去检查stratify传的是不是原始 DataFrame 的那一列而不是切分后的对象。3.3 划分结果检查保存清单后还要二次确认划分完成后源码包会输出三个 CSV 文件到split/目录里面记录的是图片路径和标签。这份清单就是后续裁剪、对齐和训练的索引。这里提醒一个细节重新划分之后官方目录结构可以保持不变因为 CSV 里记的是相对路径只要图片文件没挪动后续脚本按路径读取就能找到原图。我不建议把图片物理复制到新的train/、val/、test/目录因为几十万张图的复制耗时太长而 CSV 清单已经足够描述数据集的归属。二次确认这一步建议看两样东西一是每个集合的图片总数是否和预期一致二是抽查几条路径能否用 OpenCV 正常读入。常见的失败场景是 CSV 里混入了空路径或者文件确实缺失这类问题越早发现越好等裁剪跑到一半再报错返工成本很高。4. 人脸检测与面部裁剪MTCNN 选型、扩边策略与兜底逻辑4.1 为什么不用 OpenCV 默认检测器角度、遮挡和关键点AffectNet 里的图片是网络爬虫抓取的人脸角度千奇百怪有侧脸、抬头、低头还有戴眼镜、口罩遮挡的。用 OpenCV 自带的 Haar Cascade 检测器在这种数据上的表现非常不稳定侧脸直接漏检遮挡场景置信度极低。而且 Haar 只能给出矩形框给不出眼睛、鼻子、嘴角这些关键点后续做对齐没有关键点就得另想办法。这个源码包用的是 MTCNN三级网络结构先粗检再精修对角度和遮挡的容忍度高得多同时直接输出五个关键点坐标正好给第五章的对齐步骤复用。当然 MTCNN 不是万能的它的检测置信度在极端光照和重度遮挡下也会掉所以源码包里把置信度阈值暴露成了参数一般默认 0.9你可以按数据情况下调到 0.85 或 0.8。低于阈值的检测结果不采用宁缺毋滥。这里的原则是少一张训练图不致命但用错位的人脸框裁出半张脸模型会被带偏。4.2 裁剪流程扩边、正方形裁剪与边界收敛拿到 MTCNN 的人脸框后直接按框裁剪是不够的。MTCNN 返回的box是紧贴面部区域的矩形直接裁会把额头切掉一部分下巴也可能顶到边缘。常见做法是做扩边以人脸框中心为中心取宽高的最大值作为边长然后向外扩展一定比例最后裁出一个正方形区域再缩放到目标尺寸。这样裁剪出来的区域包含完整面部和少量背景对表情识别来说背景信息反而是有用的上下文。import cv2 from mtcnn import MTCNN detector MTCNN() def crop_face(image_path, margin_ratio0.2, target_size224): img cv2.imread(image_path) if img is None: return None, None h, w img.shape[:2] results detector.detect_faces(img) if not results: return None, None results.sort(keylambda r: r[confidence], reverseTrue) best results[0] x, y, box_w, box_h best[box] cx x box_w / 2.0 cy y box_h / 2.0 side max(box_w, box_h) * (1 2 * margin_ratio) x1 int(cx - side / 2) y1 int(cy - side / 2) x2 int(cx side / 2) y2 int(cy side / 2) x1 max(0, x1) y1 max(0, y1) x2 min(w, x2) y2 min(h, y2) face img[y1:y2, x1:x2] face cv2.resize(face, (target_size, target_size)) return face, best[keypoints]逻辑说明检测完成后按置信度降序排序取第一张脸避免多人脸图片裁错对象。side max(box_w, box_h) * (1 2 * margin_ratio)保证裁剪区域是正方形并且包含扩边margin_ratio0.2表示每侧额外扩出人脸框宽高的 20%。最后一步是边界收敛因为人脸接近图像边缘时扩边后的区域会超出原图不收敛会直接导致裁剪出来的数组形状不完整cv2.resize时报错。参数建议margin_ratio不要开太大超过 0.5 会把大量背景引入模型容易学到背景特征而不是表情特征太小的 0.1 又可能切到额头。0.2 到 0.3 是多数表情识别项目的习惯区间。4.3 检测失败样本的兜底策略别用官方框硬凑AffectNet 的 CSV 里自带face_x、face_y、face_w、face_h这是官方标注的人脸框。当 MTCNN 检测失败时一个直观的想法是“用官方框代替”。这个做法我劝你慎用。官方框是人工标注的质量多数可靠但坐标精度和 MTCNN 风格不一致混用会让模型在推理阶段表现出奇怪的敏感性。更稳妥的策略是检测失败就把这张图记入一个skip.txt后续训练直接排除不硬凑。def process_one(row): path row[subDirectory_filePath] face, keypoints crop_face(path) if face is None: return {path: path, status: skipped} # 后续裁剪、对齐、保存 return {path: path, status: ok}在批量处理时我习惯把每张图的状态记录下来最后统计跳过数量和跳过原因。这样你能快速判断是检测器阈值问题还是图片本身质量太差。如果跳过比例超过 5%先下调置信度阈值再看结果而不是默默忽略否则数据集变小了都不知道原因。5. 关键点对齐与输出五点仿射变换和归一化存储5.1 对齐基准五点坐标与参考布局裁剪只能解决“脸在哪里”的问题不能解决“脸正不正”的问题。AffectNet 里同一张表情图有人是正脸有人歪头有人微微侧脸。如果直接把这些图送进网络模型的卷积核需要额外学习不同姿态下的同一表情这对小模型来说负担很大。对齐的目标是把所有裁剪后的人脸变换到一个统一的姿态两只眼睛在水平线上脸朝正前方。MTCNN 输出的五个关键点正好用来做这件事。import numpy as np REFERENCE_POINTS np.array([ [0.315, 0.315], [0.685, 0.315], [0.500, 0.500], [0.350, 0.630], [0.650, 0.630] ], dtypenp.float32)这五个点依次是左眼中心、右眼中心、鼻尖、左嘴角、右嘴角。坐标是归一化值范围 0 到 1使用时乘以目标尺寸得到像素坐标。这个布局不是唯一标准但足够通用。FaceNet 和很多开源项目都采用类似的参考点实际效果稳定。如果你想改用只依赖两只眼睛的简化版对齐也可以少三个约束会让姿态矫正能力变弱侧面脸的矫正效果会差我不推荐在 AffectNet 上用两点法。5.2 仿射变换矩阵计算与 cv2.warpAffine 调用对齐的本质是求一个仿射变换矩阵把当前关键点映射到参考点。OpenCV 提供了cv2.estimateAffinePartial2D它能从两组对应点中拟合出一个带尺度和旋转的变换矩阵支持 RANSAC 剔除异常点。这里传入的src是 MTCNN 检测出的关键点dst是乘以目标尺寸后的参考点然后调用cv2.warpAffine完成变换。def align_face(face_img, keypoints, target_size224): src np.array([ keypoints[left_eye], keypoints[right_eye], keypoints[nose], keypoints[mouth_left], keypoints[mouth_right] ], dtypenp.float32).reshape(-1, 1, 2) dst (REFERENCE_POINTS * target_size).reshape(-1, 1, 2) transform, _ cv2.estimateAffinePartial2D(src, dst, methodcv2.RANSAC) aligned cv2.warpAffine(face_img, transform, (target_size, target_size)) return aligned参数说明reshape(-1, 1, 2)是必需的OpenCV 的estimateAffinePartial2D要求输入形状为(N, 1, 2)漏掉这一步会报维度错误。methodcv2.RANSAC表示用随机采样一致性拟合能自动忽略个别偏差较大的关键点。warpAffine的最后两个参数是输出尺寸这里直接输出(224, 224)保证后续送入网络时尺寸统一。对齐后的图片无论原图人脸是歪的还是侧过去的眼睛都会被拉到同一水平线附近。5.3 输出组织目录结构、文件名与归一化预处理完成的图片需要按合理的目录结构输出方便后续直接写 PyTorch 或 TensorFlow 的 Dataset。我常用的组织方式是按标签建子目录processed/train/0、processed/train/1这样每张图的文件名直接用原图 basename 加一个前缀标记比如aligned_1024.jpg。这种结构的好处是后续用torchvision.datasets.ImageFolder可以直接读取不需要再写自定义 Dataset 类。import os def save_aligned(row, face, aligned, out_root): label row[facial_expression] folder os.path.join(out_root, str(label)) os.makedirs(folder, exist_okTrue) basename os.path.basename(row[subDirectory_filePath]) out_path os.path.join(folder, faligned_{basename}) cv2.imwrite(out_path, aligned)关于归一化这里强调一个顺序问题像素值归一化应该放在数据读取阶段也就是模型训练时动态做而不是在预处理阶段把图片存成归一化后的 float 数组。原因很简单cv2.imwrite只能写 8 位整数图你硬存归一化数据反而损失精度。常见做法是预处理阶段只做几何变换输出标准尺寸的 8 位 RGB 图模型训练时再用transform做(img / 255.0 - mean) / std。这个源码包的输出也遵循这个约定你可以同时看到对齐结果图和原图方便抽查质量。6. 避坑与自查五个翻车现场加一个对齐质量检查脚本6.1 翻车现场一预处理速度慢到怀疑人生现象单进程循环跑了一小时才处理几千张图照这个速度跑完全部数据要一天一夜。原因detect_faces本身计算量大再加上串行读写磁盘CPU 没有吃满。解决用multiprocessing.Pool并行处理进程数设为 CPU 物理核心数图片读取和检测都放在子进程里。四核心机器起步就有三倍以上的加速。6.2 翻车现场二训练集读出来一半是黑图现象对齐后的图片约三分之一是全黑或边缘大面积黑边。原因裁剪框扩边时超出了原图边界边界收敛后区域尺寸小于预期直接 resize 导致拉伸变形或者收敛后的区域几乎没有图像内容。解决在裁剪函数里收敛边界后检查x2 - x1和y2 - y1是否小于side * 0.5太小说明原图边缘裁掉了大面积人脸这种情况直接跳过比生成黑图好。6.3 翻车现场三对齐后人脸歪向一边现象对齐后抽查图片眼睛不在水平线上脸反而比原图更歪。原因关键点顺序和参考点顺序不对应。MTCNN 返回的keypoints字典键名是left_eye、right_eye、nose、mouth_left、mouth_right如果你手写顺序时把左右眼调换变换矩阵会把人脸翻转角度。解决先打印一张图的keypoints肉眼确认字典键名和实际位置再拼 src 数组。6.4 翻车现场四划分后某类样本近乎消失现象训练集里 Disgust 类有几千张验证集里只有几十张。原因随机划分时没有分层小类别样本被随机冲散。解决train_test_split必须传stratifydf[facial_expression]并且划分后立刻用value_counts(normalizeTrue)核对各集合类别占比。6.5 翻车现场五CSV 列名在读取时悄悄变了现象脚本报KeyError: subDirectory_filePath打印列名发现变成了subDirectory_filePath带空格或路径是train_set\\xxx反斜杠。原因官方 CSV 某些行有首尾空格pandas 读入后列名保留原始字符Windows 解压又把路径分隔符改成了反斜杠。解决读入后统一执行df.columns df.columns.str.strip()和路径分隔符替换任何读取逻辑之前过一遍这两行。6.6 自查技巧九宫格抽查是最快的质检方式预处理完成后我会强制自己跑一个九宫格抽查脚本从对齐输出里每类随机抽三张拼成一张大图保存肉眼检查眼睛水平线、裁剪是否完整、有没有黑图。这个方法比任何指标都直观三分钟能发现八成问题。从那以后我每次跑新数据集都会把“划分检查、跳过率统计、九宫格抽查”固定成一条检查命令走一遍再开始训练。这套流程已经成了我的肌肉记忆希望能帮到你少踩几个我当年踩过的坑。本文还有配套的精品资源点击获取
返回列表