ARTICLE DETAIL

资讯详情

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

PyTorch人脸关键点检测:CNN回归68坐标全流程指南

PyTorch人脸关键点检测:CNN回归68坐标全流程指南 简介P1_Facial_Keypoints 是 Udacity 计算机视觉纳米学位CVND中的第一个实战项目面向正在学习深度学习和计算机视觉的开发者。该项目以面部关键点检测为主线覆盖从数据加载与可视化、卷积神经网络CNN的定义与训练到基于 Haar 级联的完整检测流程并延伸出情感识别、趣味滤镜等应用场景。资源共 2000 个文件压缩包约 330.12MB核心内容分为四个 Jupyter Notebook 实验模块第一模块讲解关键点数据的加载和可视化第二模块完成 CNN 的网络架构定义与训练第三模块将 Haar 级联与训练好的模型结合实现真实图像中的面部关键点检测第四模块展示关键点的趣味玩法。文件类型以 jpg 样例图片居多同时包含 ipynb 实验笔记、xml 级联分类器、csv 标注文件和 py 辅助脚本目录结构清晰适合按步骤对照实践。已有 200 人学习下载。配套的完整代码与实现思路可帮助读者快速搭建设计好的面部关键点检测系统理解图像预处理、模型训练与级联检测的协作方式。1. 用坐标回归给68个点定位Facial_Keypoints 到底在训什么模型人脸关键点检测是很多人脸应用的前置步骤而 CVND计算机视觉纳米学位的第一个项目 P1_Facial_Keypoints本质上是让你用 CNN 从一张人脸图里回归出 68 个关键点的 (x, y) 坐标。项目本身不复杂输入是一张灰度人脸图输出是 136 个浮点数——但正是这个“看起来很简单”的回归任务把图像预处理、标签归一化、卷积网络设计和训练稳定性全部串在了一起适合作为第一次完整跑通视觉项目的练手。它能解决的实际问题很直接给你任意一张人脸照片模型能标出眼睛、鼻子、嘴巴、下颌线的位置这也是后续做表情识别、人脸对齐、面具特效的底座。适合的人群是刚学完 CNN 基础、想动手训练一个端到端模型的初学者也适合想复习回归任务细节的在职工程师。2. 把数据喂进 CNN 之前关键点坐标归一化为什么能决定模型能不能收敛2.1 标签构造先手工对齐再让网络学残差别直接扔原始坐标原始数据集的标签是 CSV 格式每行对应一张图片列名类似left_eye_center_x、nose_tip_y这种未标注的位置填的是 NaN。第一步不是急着写网络而是把标签整理成模型能吃的张量。我一般会先把缺失值处理掉对于训练集直接丢弃缺失超过一定比例的行对于测试集因为提交时不能丢样本常见做法是用该列均值填充或者用相邻点插值。然后把 68 个点的 x 和 y 顺序交错排列变成一个长度为 136 的向量——前 68 个是 x 坐标后 68 个是 y 坐标或者按(x0, y0, x1, y1, ...)交替排都行但要保证和网络输出的排列方式一致。关键的一步是坐标归一化。假设图片尺寸是 96x96原始坐标范围是 0 到 96直接把这个值丢给网络做回归损失函数会被大的数值主导模型训练初期极其不稳定。我的做法是把坐标除以图片宽高压到 0 到 1 区间# 假设 keypoints 是形状为 (N, 136) 的原始坐标矩阵img_size 96 # 原始坐标顺序前68列为x后68列为y keypoints[:, :68] keypoints[:, :68] / img_size # x 归一化到 [0,1] keypoints[:, 68:] keypoints[:, 68:] / img_size # y 归一化到 [0,1]这段代码的逻辑是保持 x 和 y 分开处理分别除以图片边长。img_size是你在预处理阶段统一缩放后的尺寸这个值必须和训练时输入图片的尺寸严格一致。归一化之后网络输出的 136 个值天然就在 0 到 1 范围内配合 Sigmoid 输出层或者对输出做裁剪可以有效避免预测出“跑到图像外面”的坐标。2.2 灰度图、缩放和边框裁剪PyTorch 里一套可复用的数据预处理管线Facial_Keypoints 数据集的原始图片尺寸不一有 96x96 的也有更大或更小的而且部分图片人脸只占画面的一小块。如果不做统一缩放数据加载器根本没法凑成一个 batch。我习惯用一个transforms.Compose把预处理串起来from torchvision import transforms from PIL import Image import torch import numpy as np train_transforms transforms.Compose([ transforms.Resize((96, 96)), # 统一尺寸 transforms.Grayscale(num_output_channels1), # 转单通道灰度图 transforms.ToTensor(), # HWC - CHW像素值缩放到 [0,1] transforms.Normalize(mean[0.5], std[0.5]) # 将 [0,1] 映射到 [-1,1] ])这里Resize是唯一会改变坐标映射关系的操作——图像缩放后关键点坐标必须跟着等比例缩放所以上面这段代码只在加载图片时用标签的缩放要在 DataLoader 的__getitem__里单独做不能依赖 torchvision 的 transform。Normalize的参数mean0.5, std0.5是把像素从 [0,1] 映射到 [-1,1]这是很多预训练模型默认的输入分布自己从零训练时用不用影响不大但加上之后损失曲线的数值范围会更稳定。有一个容易忽略的细节数据集中部分图片带有边框或黑边直接Resize会把人脸压扁。我的处理方式是在预处理阶段先做一次人脸检测把 bounding box 裁出来再缩放。常见做法是用 OpenCV 的CascadeClassifier检测人脸区域然后按检测框裁剪裁剪后的图片再进入上面的 transform 流程。这样能显著减少背景干扰模型学到的特征更集中于人脸本身。2.3 数据增强的操作清单与参数边界随机旋转、水平翻转和它的坐标陷阱数据增强是这个小项目里性价比最高的技巧但因为标签是坐标数值增强操作必须同步作用在坐标上不能只对图片做。两条最常用的增强是随机水平翻转和随机小角度旋转。水平翻转的坐标变换逻辑是新 x 1 - 原 xy 不变。但要注意翻转后左右眼的坐标会互换如果项目后续要做左右眼相关的分析需要同步交换标签索引。旋转更麻烦一点坐标绕图片中心旋转后可能会超出 0 到 1 的边界所以旋转角度不宜过大我一般控制在 ±15 度以内旋转后对坐标做裁剪超出边界的点直接丢弃或钳制到边界import random import torch def random_flip(image, keypoints, p0.5): image: tensor shape (1, H, W)已归一化 keypoints: tensor shape (136,)顺序为 [x0..x67, y0..y67]值域 [0,1] 返回翻转后的图片和对应坐标 if random.random() p: image torch.flip(image, dims[2]) # 水平翻转W 维度 # 关键点128个点前半是x后半是y n keypoints.shape[0] // 2 x keypoints[:n] y keypoints[n:] x 1.0 - x # 翻转后 x 坐标映射 keypoints torch.cat([x, y]) return image, keypoints实现里最容易翻车的地方是torch.flip的dims参数。image的 shape 是(C, H, W)水平翻转要翻转宽那一维所以dims[2]。如果你写成了dims[1]图像变成上下翻转但坐标只做了水平变换那训练出来的模型预测的点位会全部错位而且这种错误不会引起训练崩溃损失会正常下降直到可视化时才暴露——这是最容易踩的隐形坑。3. 从全连接到卷积回归适合 CVND 入门的一版模型结构3.1 设计思路全连接打底还是小 CNN显存和效果怎么权衡在刚开始做这个项目时我犯过一个典型的初学者错误上来就用三层全连接网络把 96x96 的图拍平成一维向量直接回归。结果训练 Loss 确实在掉但预测出来的关键点经常成片地偏移——因为全连接层没有空间不变性眼睛稍微换个位置网络就认不出来了。换成卷积网络之后情况立刻不同。卷积层天然具备局部感受野能捕捉人脸的局部纹理特征比如眼睛周围的梯度变化、鼻梁的明暗过渡这些特征对关键点定位更关键。但也不是说卷积层越多越好。这个数据集本身不大训练集大概 2000 多张测试集几百张深层网络很容易过拟合。我试过 ResNet50在训练集上 Loss 能降到非常低但验证集表现反而更差。所以对于这种规模的数据一个 4 到 6 层的轻量 CNN 是最务实的方案参数少、训练快、效果不差。CVND 这个项目的定位本来就是让你掌握“从数据到模型再到评估”的完整流程而不是比谁的网络更深。3.2 用 PyTorch 写一个 5 层 CNN 回归器输出 136 维坐标下面是一个我在这个项目里反复调整后比较稳定的结构。它由 3 个卷积块和 2 个全连接层构成输入 96x96 单通道图片输出 136 维向量import torch.nn as nn import torch.nn.functional as F class KeypointCNN(nn.Module): def __init__(self): super().__init__() # 第一个卷积块1 - 32 通道空间尺寸 96 - 48 self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.bn1 nn.BatchNorm2d(32) self.pool1 nn.MaxPool2d(2) # 第二个卷积块32 - 64 通道空间尺寸 48 - 24 self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.bn2 nn.BatchNorm2d(64) self.pool2 nn.MaxPool2d(2) # 第三个卷积块64 - 128 通道空间尺寸 24 - 12 self.conv3 nn.Conv2d(64, 128, kernel_size3, padding1) self.bn3 nn.BatchNorm2d(128) self.pool3 nn.MaxPool2d(2) # 展平后的特征维度128 * 12 * 12 18432 self.fc1 nn.Linear(128 * 12 * 12, 512) self.dropout nn.Dropout(0.3) self.fc2 nn.Linear(512, 136) def forward(self, x): x self.pool1(F.relu(self.bn1(self.conv1(x)))) x self.pool2(F.relu(self.bn2(self.conv2(x)))) x self.pool3(F.relu(self.bn3(self.conv3(x)))) x x.view(x.size(0), -1) x F.relu(self.fc1(x)) x self.dropout(x) x self.fc2(x) # 输出 136 维坐标不加激活函数 return x结构上有几个关键选择值得说明。第一每个卷积层后面都接了BatchNorm2d这个项目的数据量不大BatchNorm 能显著加速收敛也能缓解梯度消失——实测不加 BN 时相同 epoch 下 Loss 高出不少。第二全连接层之间加了Dropout(0.3)这是针对小数据集过拟合的必要手段也是 CVND 项目评估中常被考察的知识点。第三最后的输出层没有加 Sigmoid 或 Tanh而是直接线性输出——因为我们在训练时用归一化坐标配合损失函数约束推理时再做一次 clamp 把输出裁剪到 [0,1]如果输出层加了 Sigmoid虽然值域天然落在 [0,1]但梯度在饱和区会消失影响训练后期精度。全连接层的输入维度是128 * 12 * 12这个数字是怎么来的输入 96x96经过三次MaxPool2d(2)每次尺寸减半96 - 48 - 24 - 12。如果你的输入尺寸变了这里必须跟着重算否则view那一步会直接报错。3.3 损失函数与优化器SmoothL1Loss 比 MSE 好在哪关键点检测是个回归任务最常见的损失选择是 MSE均方误差但我在实践里更推荐SmoothL1Loss也就是 Huber Loss。它在误差绝对值小于 1 时表现为平方误差大于 1 时表现为线性误差这样既保留了 MSE 在收敛末期的精细梯度又避免了离群点对梯度的过度主导——数据集中偶尔会有标注错位的样本MSE 对这种离群样本的惩罚是平方级别的会让模型为了少数坏样本牺牲整体精度。import torch.optim as optim model KeypointCNN() criterion nn.SmoothL1Loss() optimizer optim.Adam(model.parameters(), lr0.001, weight_decay1e-5) # 训练一步的示例代码 def train_step(batch_images, batch_keypoints): model.train() optimizer.zero_grad() outputs model(batch_images) # 预测形状 (B, 136) loss criterion(outputs, batch_keypoints) loss.backward() optimizer.step() return loss.item()优化器我选 Adam 而不是 SGD。原因很简单这个任务没有特别复杂的损失地形Adam 默认参数就能稳定收敛省去手动调学习率和动量的时间。weight_decay1e-5加了一点 L2 正则和 Dropout 配合能进一步压住过拟合。如果你的模型训练到后期 Loss 不再下降可以尝试把lr从 0.001 降到 0.0001或者在训练 30 个 epoch 后使用学习率衰减。4. 训练起来才明白的细节学习率、批大小与验证集划分的取舍4.1 训练循环的最小骨架每 5 轮存一次 checkpoint写训练循环不要一上来就追求复杂先跑通一个最小闭环再逐步加功能。我通常把训练和验证分成两个函数代码结构清晰排错也方便。训练集和验证集的划分在数据加载阶段完成——我一般按 8:2 切分而且固定随机种子保证每次实验的可复现性。import torch from torch.utils.data import DataLoader, random_split # dataset 是自定义 Dataset 实例 train_size int(0.8 * len(dataset)) val_size len(dataset) - train_size train_dataset, val_dataset random_split( dataset, [train_size, val_size], generatortorch.Generator().manual_seed(42) ) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse) best_val_loss float(inf) for epoch in range(50): # 训练阶段 model.train() running_loss 0.0 for images, keypoints in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, keypoints) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) # 验证阶段 model.eval() val_loss 0.0 with torch.no_grad(): for images, keypoints in val_loader: outputs model(images) loss criterion(outputs, keypoints) val_loss loss.item() * images.size(0) print(fEpoch {epoch1}: train_loss{running_loss/len(train_dataset):.4f}, fval_loss{val_loss/len(val_dataset):.4f}) # 保存验证集上最好的模型 if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_keypoint_model.pth)这段代码里的关键习惯是验证阶段务必要加model.eval()和torch.no_grad()。model.eval()会关闭 Dropout 和 BatchNorm 的训练模式——如果不加验证时 Dropout 仍然随机丢神经元验证 Loss 会忽高忽低让你误判模型的真实水平。torch.no_grad()关闭梯度计算能省一半以上的显存和计算时间验证集不大时可能感觉不明显但这是一个必须养成的习惯。4.2 学习率衰减和早停两个让训练稳定下来的开关训练过程中的一个典型现象是前 20 个 epoch 损失降得很快之后进入平台期Loss 在小范围内震荡。这时候继续用固定学习率模型很难再精调坐标到亚像素精度而关键点检测恰恰需要这种精度。解决办法是引入学习率衰减每过一定轮数把学习率乘以一个衰减系数。scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience5, verboseTrue ) # 在每个 epoch 验证结束后调用 # scheduler.step(val_loss)ReduceLROnPlateau的工作方式是当验证 Loss 连续patience个 epoch 都没有下降时就把学习率乘以factor。我设置factor0.5, patience5意思是连续 5 轮不进步学习率减半。这个策略比固定轮数衰减比如每 30 轮降一次更稳因为它是看验证集的实际表现来决定是否降学习率。早停是另一个实用的控制开关当验证 Loss 连续多个 epoch 不降反升说明模型开始过拟合了这时候应该停止训练并恢复历史最优模型。简单实现就是记录best_val_loss当连续 10 个 epoch 没有刷新时直接break退出循环然后加载之前保存的最优权重。很多初学者会把 epoch 数设得很大比如 200然后完全不看训练曲线——这是在浪费时间早停能帮你省下大量无意义的训练轮次。4.3 评估指标别只看 Loss用 NME 衡量关键点误差才是项目评审关心的Loss 值本身是个抽象的数字不同的损失函数、不同的归一化方式都会改变它的量纲你很难告诉别人“我的 Loss 是 0.003”到底代表什么水平。关键点检测领域的通用评估指标是 NMENormalized Mean Error也就是归一化平均误差。NME 的计算方法是先求每个关键点的预测值和真实值的欧氏距离再取平均最后除以一个人脸尺度因子。这个尺度因子通常取两眼之间的距离inter-ocular distance或两眼中心到下巴尖的距离。用尺度因子归一化后NME 就可以跨不同分辨率、不同人脸大小进行比较。def compute_nme(predictions, ground_truth, interocular_dist): predictions: (N, 68, 2) 预测关键点 ground_truth: (N, 68, 2) 真实关键点 interocular_dist: (N,) 每张图的两眼距离 # 逐图计算平均欧氏距离 diff np.sqrt(((predictions - ground_truth) ** 2).sum(-1)) # (N, 68) mean_error diff.mean(-1) # 每张图的平均误差 (N,) nme mean_error / interocular_dist return nme.mean() # 返回整个测试集的平均 NME两眼距离的计算需要先确定哪两个点是左眼和右眼中心。在 68 点标注中一般取left_eye_center索引 36 到 41 的均值和right_eye_center索引 42 到 47 的均值。如果你的预测结果里没有单独的眼睛中心点可以用眼角点近似。NME 的值通常在 0.03 到 0.08 之间小于 0.05 就算不错的水平这个数字才是项目评审时能一眼看懂你做得好不好的指标。5. Facial_Keypoints 的常见问题排查归一化、NaN 和过拟合的 4 条踩坑记录5.1 Loss 停在 0.01 不再下降但预测点全挤在图像中心这是一个非常隐蔽且容易让人抓狂的问题。表面上看训练一切正常Loss 按照正常速度下降并最终稳定在一个看似不错的数值但你把预测点画到原图上发现所有关键点都堆在图片中心区域形状像一团散不开的线团——并没有真实贴合人脸轮廓。原因是如果输出的 136 维向量没有做范围限制网络会倾向输出一个“安全”的中间值。这个中间值在归一化坐标里就是 0.5 左右。SmoothL1Loss 对数值接近 0.5 的预测惩罚很小因为监督信号的坐标也大致分布在 0.3 到 0.7 之间取中值能保证一个不算差的基础 Loss但完全学不到精确位置。解决方法是检查两处一是输出层是否缺少约束二是标签的真实分布是否过于集中。如果你的标签经过归一化后确实大部分落在 0.3 到 0.7 之间那说明网络很可能只学到了均值。可以尝试在输出层后加torch.sigmoid强制输出落在 (0,1) 区间让网络必须“选择”每个点的具体位置而不是停在中间值上“和稀泥”。我实测加 Sigmoid 后NME 能下降 10% 到 15%。注意如果加了 Sigmoid前面提到的线性输出和clamp方案就要替换掉两者不能同时使用。5.2 验证 Loss 下降但测试图预测错位数据增强的翻转逻辑写反了这个坑专门坑那些“训练曲线一切正常”的人。Loss 在训练集和验证集上都稳步下降说明网络确实学到了某种映射但当你想把模型应用到手头一张新图上时预测点像被镜子反射了一样——左眼的点跑到了右眼位置。原因基本锁定在随机水平翻转的实现上。翻转图片后坐标必须同步变换但我们前面说过翻转后x变成1 - x如果只翻转图像而忘记转换坐标模型会学到自相矛盾的样本最后学出一个“翻转纠正”的效果——正常图片输入时它会按翻转后的坐标输出看起来就是左右颠倒的。解决方法是检查你的random_flip函数里是否确实执行了x 1.0 - x这一步以及翻转维度是否正确。一个快速自查方法是固定随机种子打印同一张图翻转前后的图片和关键点手动画图对比看翻转后的关键点是否仍然贴在人脸的正确位置。5.3 训练到一半出现 NaN输入图片里有纯黑或异常像素训练初期一切正常某几个 epoch 之后 Loss 突然变成nan然后永远恢复不回来——这是最让人沮丧的故障之一。原因通常是输入数据里有异常值部分图片是纯黑图、全白图或者某个像素点的值极端到让梯度爆炸。更隐蔽的原因出在Normalize的操作上。如果输入图片是单通道mean和std必须是单元素张量或标量。但如果你在加载时用了错误的数据类型——比如图片矩阵是uint8而ToTensor没有正确转换像素值会超出预期范围经过几层卷积后数值就失控了。解决方式是三步排查第一步在 Dataset 的__getitem__里检查图片像素值的范围加入断言确保所有值都在正负 3 以内第二步把学习率调低一个数量级排除梯度爆炸的可能第三步给模型梯度加裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)这是应对偶发异常输入的有效兜底。另外要注意全黑图片本身对网络没有惩罚意义——所有关键点可能都是未知的加载时应该直接跳过。5.4 模型对侧脸和闭眼效果差数据集本身分布不均注意采样如果你在测试时发现正面脸的关键点预测很准但一到侧脸或闭眼的照片就一塌糊涂这大概率不是模型结构的问题而是数据集分布不均。Facial_Keypoints 数据集中绝大多数训练样本都是正面、睁眼、光照均匀的侧脸样本占比可能只有 5% 到 10%模型根本没机会学到这些罕见姿态。应对方法有几个一是检查标签中特征点的可见性数据集里侧脸的某些点比如另一侧的眼睛根本没有标注这些样本本身就残缺二是在训练时按头姿分布做重采样让罕见姿态的样本在每次 epoch 中出现的概率更高三是做姿态相关的增强比如增加更大角度的旋转、添加模拟侧脸的光照变化。这里要特别提醒——不要指望单纯堆模型容量来解决分布不均小数据集上加深网络只会让过拟合更严重先把采样捋顺了再说效果。6. 把 68 点预测接进真实流程从关键点到人脸对齐的进阶验证6.1 用测试集可视化验证把预测坐标画回原图是第一步验收很多人把模型训完、Loss 数字好看就交差了这是不对的。我强烈建议在评估阶段加一个可视化脚本从测试集随机抽 8 到 12 张图片把预测的关键点按顺序连线画到原图上——眼睛连一圈、嘴巴连一圈、下巴轮廓连一条线。这个简单的可视化能让你一眼看出模型的真实水平也能暴露很多数值指标看不出来的问题。画图时要注意坐标反归一化模型输出的是 0 到 1 之间的归一化坐标画图前要乘以图片原始尺寸否则点会挤在左上角的小区域内。推荐用matplotlib的scatter画点、plot连线点的颜色按部位区分比如眼睛用绿色、鼻子用红色、嘴巴用蓝色这样一眼就能看出五官是否对齐。6.2 从坐标回归升级到热图回归换一种监督信号误差能再降一截如果你做完关键点回归还有余力值得尝试的方向是把输出从坐标数值换成热图heatmap。所谓热图回归就是让网络输出 68 张二维响应图每张图上只有一个高斯峰峰值位置对应关键点的坐标。训练时把每个标注点变成一个以该点为中心的高斯分布网络的任务就是预测这个分布。热图回归的优点是网络的空间分辨率直接和特征图挂钩不会像全连接层那样丢失位置信息而且热图的监督信号是密集的每个像素都能提供梯度训练更容易收敛。缺点是显存开销增大、输出需要解码找峰值位置实现复杂度明显上升。但经验上相同数据下热图回归的 NME 能比直接回归坐标低 20% 到 30%这个提升在 CVND 项目中足够让评审眼前一亮。6.3 把关键点接进实际人脸对齐流程先看这四件事最后说一个实践中的教训。做完 Facial_Keypoints 这个项目后我尝试把它接到一个实时人脸特效应用里结果发现训练时没注意的细节全在工程化阶段跳出来了。第一模型推理速度——5 层 CNN 在 CPU 上跑一帧要 20 到 30 毫秒看起来不慢但如果做人脸检测加关键点检测的串行流程整体帧率就掉下来了需要用小模型或者量化加速。第二关键点的时序稳定性——单帧预测有抖动连续视频帧上点会轻微跳动需要加时序平滑滤波。第三输入分布差异——训练数据是正脸证件照风格但摄像头画面的透视畸变和光照完全不同模型效果断崖式下跌需要额外采集数据做微调。第四输出坐标的坐标系转换——检测框在原图上的坐标和模型输入尺寸之间要做逆变换这个映射如果算错全部点位都会偏。这个项目本身就是一份很好的入门答卷——它把数据预处理、CNN 回归、训练调参、评估可视化这一条链路完整走了一遍。如果你能把训练好的模型接到一个新的数据集上重新微调、重新评估你就真正掌握了人脸关键点检测的通用方法论而不只是会跑通一个 CVND 作业。我自己在这上面踩过的最大教训就是别急着堆网络结构先把数据分布和评估指标吃透后者往往决定了你调参时的判断力。希望帮到你。本文还有配套的精品资源点击获取
返回列表