ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

人脸关键点检测实战:从数据预处理到CNN回归模型全解析

人脸关键点检测实战:从数据预处理到CNN回归模型全解析 简介面向CVND计算机视觉纳米学位学习者及人脸关键点检测入门者的完整项目包围绕面部关键点检测搭建了从数据加载、模型训练到实际检测的完整流程。项目拆解为四个Jupyter Notebook内容包括加载与可视化关键点数据、定义并训练卷积神经网络预测关键点、利用Haar级联完成端到端检测以及趣味滤镜应用适合需要动手实践CNN和OpenCV的初中级开发者。压缩包约330MB共2000个文件以大量jpg训练测试图片和ipynb笔记为主另有xml级联文件、csv标注、py工具脚本等均可按模块独立使用或复盘。已有200人学习内含全部起始代码与配套资源可直接对照运行能帮助你快速理解面部关键点检测从模型训练到项目落地的完整技术路线。1. 人脸关键点检测这是 CVND 第一个项目里最值钱的一课如果你正在走计算机视觉纳米学位CVND的第一段路程大概率会在 JupyterNotebook 里遇到 P1_Facial_Keypoints 这个项目。说直白一点它要求你从一张 96x96 的灰度人脸图中回归出 68 个关键点的坐标x、y 加起来一共 136 个数值。听上去像是小菜一碟实际上它把「图像输入 → 特征提取 → 结构化输出 → 可视化验证」这条完整链路一次性甩到你面前初学者最容易在这里翻车也最能在这里把基本功打牢。这个项目适合刚学完 CNN 基础、想做第一个完整实战的人也适合想搞清楚回归任务和分类任务到底差在哪的从业者。别急着找答案先把数据摸透后面你就明白为什么 80% 的人会挂在同一个坑上。2. 数据加载与预处理先读懂 CSV 里的 136 列和那些缺失值2.1 数据格式一行样本就是一个人的全脸标注这个项目用的公开数据集把训练样本放在一个 CSV 文件里每一行代表一张人脸图。前 136 列是 68 个关键点的坐标排布规律是 x_0, y_0, x_1, y_1一直到 x_67, y_67。最后一列是 Image存放着这张图的像素数据。像素值不是直接展开的数组而是以空格分隔的字符串形式存在读取的时候要用 split 再加 astype 转成 uint8。import pandas as pd import numpy as np df pd.read_csv(data/training_frames_keypoints.csv) img_row df.iloc[0] pixels np.array(img_row[Image].split(), dtypenp.uint8).reshape(96, 96) print(pixels.shape) # (96, 96)逻辑说明pandas 读进来后Image 列拿到的是字符串必须手动按空格拆分成列表再转数值最后 reshape 成 96x96 的矩阵。关键点坐标在原始 CSV 里是 0~95 的像素坐标后面做模型输出时要记得做归一化。如果直接用原始像素坐标作为回归目标模型输出范围很大训练时 loss 会抖得厉害。参数说明reshape 的 96x96 是数据集的原始尺寸不要随意改因为关键点坐标也是在这个坐标系下标注的。dtypeuint8 可以省内存逐个样本转成 float32 后再进模型即可。数据里最值得留意的坑是缺失值。68 个关键点并不是每一行都完整很多样本部分关键点没有标注CSV 里直接留空。第一次跑的时候我用 pandas 默认的 dropna 把所有含空值的行全删了结果训练集直接少了一半模型怎么训都不准。常见的做法是把缺失行过滤掉或者用有效关键点的均值兜底但对于第一个项目来说直接丢弃标注不完整的行更省心。df df.dropna().reset_index(dropTrue) keypoints df.iloc[:, :136].values.astype(float32) images df[Image].apply(lambda x: np.array(x.split(), dtypenp.uint8).reshape(96, 96))逻辑说明dropna 之后重新 reset_index 是防止后续索引错位。keypoints 取前 136 列转成 float32做好归一化前的准备。images 用 apply 把每行字符串转成图像矩阵。2.2 归一化策略把关键点拉到 [-1, 1] 区间图像归一化大家都很熟减去均值除以标准差就行。关键点坐标同样需要处理。常见做法是把 x 和 y 分别减去 48 再除以 48这样所有坐标落在 [-1, 1] 区间和模型输出的激活范围对齐。我习惯把归一化写进 Dataset 类里而不是在训练循环里单独处理这样代码更干净。from torch.utils.data import Dataset class FacialKeypointsDataset(Dataset): def __init__(self, df, transformNone): self.df df.reset_index(dropTrue) self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] image np.array(row[Image].split(), dtypenp.float32).reshape(96, 96) / 255.0 keypoints row.iloc[:136].values.astype(float32) keypoints (keypoints - 48.0) / 48.0 sample {image: image, keypoints: keypoints} if self.transform: sample self.transform(sample) return sample逻辑说明在getitem里把图片像素除以 255 归一化到 [0,1]关键点用 (x-48)/48 映射到 [-1,1]。这样设计的好处是模型输出的范围是确定的MSE 或者 SmoothL1 的梯度更稳定。参数说明48 是 96 的一半刚好把坐标原点挪到图像中心。如果数据集尺寸变了这个值要同步换成 width/2 和 height/2。transform 参数位留给后面的数据增强用现在先传 None。几乎每个人都会在这里直觉地认为「回归问题的输出层是不是需要 sigmoid 或者 tanh」其实不需要。CNN 回归任务的输出层就是纯线性层直接输出 136 个实数。加上激活函数反而会限制输出范围导致关键点被截断。训练前期我吃过这个亏硬是加了 tanh结果眼神位置死活收敛不到标注点上。3. CNN 回归模型设计为什么输出 136 维而不是 68 维3.1 模型结构卷积提特征全连接出坐标分类模型输出的是类别概率回归模型输出的是连续数值。人脸关键点检测要的是精确坐标所以这是一个典型的回归任务。模型结构我沿用了一个经过验证的轻量 CNN四个卷积块再加两个全连接层。输入是 96x96 的灰度图通道数为 1输出是 136 维向量。import torch.nn as nn class KeypointModel(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(128, 256, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.regressor nn.Sequential( nn.Flatten(), nn.Linear(256 * 6 * 6, 512), nn.ReLU(inplaceTrue), nn.Dropout(0.2), nn.Linear(512, 136) ) def forward(self, x): return self.regressor(self.features(x))逻辑说明四个卷积块每块都带 MaxPool2d(2)特征图尺寸从 96 一路降到 6。最后的全连接输入维度是 256x6x6因为 96 经过四次池化正好是 6这个数字要手算确认写错的话模型直接跑不起来。Dropout 放在第二个全连接前做一点轻量正则防止训练集太小导致过拟合。参数说明kernel_size 全部用 3padding1 保持尺寸不变这是最常见的配置。第一层卷积核数量从 32 开始逐层翻倍到 256 封顶整体参数量在百万级别CPU 也能勉强跑但推荐用 GPU。一个小经验不要把这个模型的参数改得太大比如把通道数翻倍到 512训练时间会拉长好几倍而精度提升非常有限。对于 96x96 的小图和几千张训练数据来说这个规模已经够了。3.2 损失函数SmoothL1 比 MSE 稳在哪回归任务最常用的损失是 MSE但实际跑下来我发现 SmoothL1 Loss 更舒服。MSE 对误差大的样本会给出很大的梯度如果标注里有噪声一个离群点就能把整个模型带偏。SmoothL1 在误差小时表现为 L2误差大时表现为 L1梯度有上限训练更稳。criterion nn.SmoothL1Loss()逻辑说明SmoothL1 也被称为 Huber Loss它有两个阶段的特性。误差绝对值小于 1 时梯度是线性的大于 1 时梯度被限制在常数不容易被极端离群点带崩。人脸关键点数据里偶发标注偏移这个损失函数本质上是在给标注噪声做缓冲。在 136 个输出值中x 和 y 是交替排列的但损失函数不需要区分它们因为本质上都是坐标回归。很多初学者会试图把 x 和 y 分开计算损失再相加完全没必要。SmoothL1Loss 会把这个 136 维向量看成一个整体效果是等价的。参数说明PyTorch 的 nn.SmoothL1Loss 默认 beta1.0这个值表示 L1 和 L2 的切换阈值。如果训练中发现 loss 很难降可以试着把 beta 调大一点到 2.0让更多误差走 L2 梯度收敛会快一些但对离群点更敏感。我通常先用默认值不轻易动。4. 训练流程与超参数怎么跑出一个能用的关键点模型4.1 训练循环记录 loss 并在每个 epoch 后可视化训练循环本身并不复杂但第一次写的时候容易漏掉两件事一是模型要切到 train 模式二是在每个 epoch 结束后做一次可视化验证。只盯着 loss 数字像在开黑匣子关键点检测这种任务肉眼看着点是不是落在眉毛、眼睛上比任何指标都直接。import torch.optim as optim from torch.utils.data import DataLoader dataset FacialKeypointsDataset(df) loader DataLoader(dataset, batch_size64, shuffleTrue, num_workers0) model KeypointModel() optimizer optim.Adam(model.parameters(), lr1e-3) criterion nn.SmoothL1Loss() for epoch in range(30): model.train() running_loss 0.0 for batch in loader: images batch[image].unsqueeze(1) keypoints batch[keypoints] optimizer.zero_grad() outputs model(images) loss criterion(outputs, keypoints) loss.backward() optimizer.step() running_loss loss.item() print(fEpoch {epoch1}: loss {running_loss / len(loader):.4f})逻辑说明images 读进来的时候形状是 (batch, 96, 96)没有通道维度所以要 unsqueeze(1) 变成 (batch, 1, 96, 96)。outputs 和 keypoints 都是 (batch, 136) 的浮点坐标直接算损失不需要额外的后处理。每个 epoch 打印平均 loss方便观察收敛趋势。参数说明batch_size64 是一个比较均衡的选择太小训练慢太大显存容易爆如果你的 GPU 显存只有 4G降到 32。学习率 lr1e-3 是 Adam 的常用起点后续如果 loss 平台期太明显可以降到 1e-4 再跑一轮。epoch 设 30 是参考值实际要结合 loss 曲线和可视化结果来定。训练到第 8 个 epoch 左右loss 会在 0.08 附近变得很平这不代表模型已经最优了。我第一次跑的时候看到 loss 不降就停了后来发现是学习率太大导致在局部振荡把学习率降到 1e-4 再训 20 个 epoch关键点位置明显更贴合眼睑轮廓。4.2 在 JupyterNotebook 里做预测可视化JupyterNotebook 最适合做这件事每个 epoch 结束之后抽取几张验证图把预测点用散点图叠在图像上。代码可以直接写在同一个 notebook 里节省来回切换的上下文开销。import matplotlib.pyplot as plt def visualize_predictions(model, dataset, n4, devicecpu): model.eval() indices np.random.choice(len(dataset), n, replaceFalse) fig, axes plt.subplots(2, n // 2, figsize(12, 6)) for ax, idx in zip(axes.flatten(), indices): sample dataset[idx] img sample[image] gt sample[keypoints] with torch.no_grad(): pred model(torch.tensor(img).unsqueeze(0).unsqueeze(0)) pred pred.numpy().reshape(-1, 2) * 48.0 48.0 gt gt.numpy().reshape(-1, 2) * 48.0 48.0 ax.imshow(img, cmapgray) ax.scatter(gt[:, 0], gt[:, 1], cg, s4, labelgt) ax.scatter(pred[:, 0], pred[:, 1], cr, s4, labelpred) ax.axis(off) plt.tight_layout() plt.show()逻辑说明预测出来的坐标是归一化值展示前要逆变换回 [0,95] 的像素坐标。pred 先 reshape 成 (68, 2)这样每一行正好是一个点的 (x, y)。gamma色点做 ground truth红色点做预测一眼就能看出哪些点偏了。参数说明n4 表示随机抽 4 张图展示这个值不要设太大否则图太密看不清。用 np.random.choice 随机抽取意味着每轮看到的图都不一样避免一直盯着同一批样本自欺欺人。4.3 超参数速查表不同配置下的表现差异配置学习率损失函数收敛速度最终效果基础版1e-3MSE快但波动大轮廓可辨眼睑贴合一般推荐版1e-3 降 1e-4SmoothL1前快后稳眉毛、眼睛、嘴贴合好激进版5e-3SmoothL1极易发散不可用保守版5e-4SmoothL1很慢稳定但耗时加倍这张表里的「激进版」我亲手踩过。Adam 对学习率没有那么敏感但在 96x96 的小图上5e-3 会导致 loss 在第一个 epoch 直接跳到 8 以上后面再也回不来。如果你看到 loss 爆增第一反应不是改模型而是把学习率降到 1e-4 重新跑。5. 避坑与常见问题排查人脸关键点项目最容易翻车的四个地方5.1 水平翻转增强后关键点没有做镜像变换现象加了 RandomHorizontalFlip 数据增强之后loss 不降反升可视化里预测点完全错乱。原因图像水平翻转后左眼和右眼的位置互换了但关键点坐标没有对应翻转等于监督信号是错的。翻转一张图片x 坐标要变成 width - 1 - xy 坐标不变。很多人只翻了图像忘了处理关键点。解决class RandomHorizontalFlip: def __call__(self, sample): if np.random.random() 0.5: image np.fliplr(sample[image]).copy() keypoints sample[keypoints].reshape(-1, 2).copy() keypoints[:, 0] -keypoints[:, 0] sample {image: image, keypoints: keypoints.flatten()} return sample逻辑说明这里因为关键点已经归一化到 [-1,1]所以翻转后的 x 坐标直接取相反数不用再换算回像素坐标。注意 fliplr 之后要 .copy()否则是只读视图后面没法正常操作。参数说明翻转概率设 0.5 最常用低于 0.3 增强效果不明显高于 0.7 会让模型看到太多镜像图分布偏移。y 坐标保持不变因为水平翻转不改变垂直位置。5.2 部分关键点缺失导致损失出现 NaN现象训练到中间某一步loss 突然变成 nan然后永远回不来。原因CSV 里部分样本的关键点存在空值dropna 之后如果还有漏网之鱼空值被转成 NaN损失函数一算就会把梯度污染掉。还有一个隐蔽场景是在数据增强时对缺失行做了插值把 NaN 引入了 tensor。解决在 Dataset 的init里强制过滤一次并且用 np.isfinite 检查所有坐标值self.df self.df[np.isfinite(self.df.iloc[:, :136].values).all(axis1)].reset_index(dropTrue)逻辑说明np.isfinite 加 all(axis1) 保证这一行的 136 个坐标全部是有限数值任何一个是 NaN 或 inf 都会被过滤。这样不管 CSV 里藏了什么脏数据进模型之前就已经清理干净。参数说明这个过滤逻辑的位置比 dropna 更靠前因为它覆盖了 dropna 管不到的边界情况。数据量如果足够大过滤后基本无损如果过滤后所剩无几那就要考虑用均值填充而不是硬删。5.3 DataLoader 的 num_workers 在 Windows 上卡死现象JupyterNotebook 里运行 DataLoader 时界面卡住不动或者报出和进程相关的错误。原因Windows 下 num_workers 大于 0 时会使用多进程加载数据而 JupyterNotebook 的进程模型和 Python 脚本不同容易产生死锁或者重复加载。解决把 num_workers 设为 0让数据加载在主进程里完成。虽然会慢一点但对这个数据集的大小来说完全够用。数据量只有几千张瓶颈不在 IO而在 GPU 计算。参数说明如果你在 Linux 服务器上跑可以试着设 num_workers2 或 4速度会提升。但第一个项目阶段不要花时间优化这个等数据集规模上万再考虑。5.4 Dropout 在验证时忘了关现象训练完的模型在验证集上画图关键点飘得非常厉害同一个点每次预测都不一样。原因模型没有调用 eval() 模式Dropout 层在推理时仍然随机丢弃神经元导致每次前向传播的结果都不同。解决在可视化函数或者验证代码里推理前一定加 model.eval()。如果是 PyTorch 2.x还要注意 model.eval() 只管 Dropout 和 BatchNorm不代表输入不需要转成 tensor。逻辑说明这个坑和模型本身无关纯粹是使用姿势的问题。新手容易在验证时忘记切换模式熟手也偶尔会在写快速测试脚本时漏掉这一行。6. 进阶验证用 NME 指标代替肉眼看图可视化里点画得再漂亮也要有一个量化指标来度量模型好坏。人脸关键点领域最常用的指标是 NMENormalized Mean Error归一化平均误差。计算方式是每个关键点的欧氏距离总和除以归一化因子这个因子通常取双眼中心之间的距离或者脸部尺寸。双眼距离可以排除人脸大小和远近的影响让误差在不同图片之间有可比性。def nme_score(pred, gt, eye_dist): pred pred.reshape(-1, 2) * 48.0 gt gt.reshape(-1, 2) * 48.0 dists np.linalg.norm(pred - gt, axis1) return np.mean(dists) / eye_dist逻辑说明pred 和 gt 都是归一化坐标先乘 48 还原到像素尺度。逐点算欧氏距离取平均再除以双眼距离。双眼距离怎么来可以直接用第 36 和第 45 个关键点坐标求距离这两个点在国际标准 68 点标记里正好是左右眼外眼角。如果你忘了这个标准排布就看数据里哪两个点的距离接近稳定值取一个就行。参数说明NME 的值通常以百分比表示。一个粗略的基准是NME 小于 5% 说明模型贴合得很好5% 到 10% 属于可用水平超过 10% 就需要回去检查数据增强或者模型容量。跑这个项目的训练数据量不大能稳定在 6% 左右已经算合格。把 NME 写进训练循环里之后,训练就不再是盲目调参了。从那以后我每次跑新的视觉回归任务都会先确认评估指标再动手做模型而不是等训练完了才想怎么验证希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进