2026/10/17 10:56:05

PyTorch人脸关键点检测实战:从数据归一化到68点回归的完整指南

PyTorch人脸关键点检测实战:从数据归一化到68点回归的完整指南 简介这份资源是CVND计算机视觉纳米学位的第一个实战项目——面部关键点检测的完整代码与数据集面向具备一定Python与深度学习基础、希望动手实践CNN图像回归任务的学习者。项目围绕眼睛、鼻子、嘴部关键点展开可应用于面部跟踪、姿态识别、滤镜与情感识别等场景。压缩包共约2000个文件整体330.12MB以5774张jpg人脸图像和对应关键点标注为主辅以9个ipynb笔记本、4个xml级联文件、2个csv标注表、2个py脚本及若干png、txt、md说明文件覆盖数据加载可视化、CNN网络定义与训练、Haar级联结合CNN的完整检测流程以及趣味滤镜应用四个阶段。目前已有200人学习下载。借助分阶段笔记本与配套数据读者可完整走通从数据读取、模型搭建到端到端关键点预测的流程理解回归型CNN的训练要点与级联检测的工程组合方式适合作为计算机视觉入门到进阶的练手项目。1. 从一张人脸到 68 个坐标这个 CVND 入门项目到底在练什么如果你刚接触计算机视觉大概率会遇到这样一个尴尬分类任务跑通了ResNet 也能背下来但一让你做关键点检测就不知道从哪下手。P1_Facial_Keypoints就是冲着这个断层来的——它是 CVNDComputer Vision Nanodegree的第一个实战项目用 Jupyter Notebook 串起一条完整链路从人脸检测、关键点回归到数据增强、模型训练、可视化验证。核心任务很明确输入一张人脸图输出 68 个关键点坐标覆盖眉毛、眼睛、鼻子、嘴巴和下颌轮廓。它适合两类人一类是刚学完卷积、想找一个麻雀虽小五脏俱全的项目练手的新手另一类是做惯了分类检测、想补上回归任务这块短板的熟手。整个项目跑在 Jupyter Notebook 里依赖 PyTorch 和 torchvision数据来自带 68 点标注的人脸数据集。你不需要自己写数据标注工具但需要理解坐标归一化、回归损失、翻转增强这些细节否则训练出来的模型要么点全糊在一起要么在侧脸上直接翻车。下面我按实际拆包的顺序把这份资源怎么用、参数怎么调、坑在哪一条条讲清楚。2. 环境搭建与数据管线Notebook 里先把张量形状对齐2.1 依赖安装与目录结构确认这个项目对环境的容忍度不算高PyTorch 版本和 torchvision 版本对不上transforms里的Resize和ToTensor行为会有差异。我一般会先建一个干净的虚拟环境再按 Notebook 里 import 的包逐个核对。# 创建独立环境避免和系统里的 torch 冲突 conda create -n facial_kp python3.8 -y conda activate facial_kp # 安装核心依赖torch 版本建议 1.8 以上 pip install torch1.10.0 torchvision0.11.0 pip install numpy matplotlib pandas jupyter pip install opencv-python # 用于人脸检测和图像读写装完之后别急着跑 Notebook先确认三件事torch.cuda.is_available()是否返回 True有卡的话、cv2.__version__是否正常、Notebook 的工作目录是否在项目根目录。很多人第一次跑报FileNotFoundError就是因为 Jupyter 启动目录不对相对路径全乱了。提示如果用的是 CPU 环境把后面训练部分的 batch size 降到 16 以下否则一个 epoch 能跑十几分钟。2.2 数据加载与 68 点坐标的归一化处理这个项目的数据管线是整个流程里最容易埋雷的地方。原始标注通常是 CSV 或 pickle里面存的是每张图对应的 68 个 (x, y) 坐标。关键点回归和分类最大的区别在于标签是连续值必须做归一化否则损失函数会被大坐标值主导。import numpy as np import pandas as pd import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms class FacialKeypointsDataset(Dataset): def __init__(self, df, transformNone): self.df df self.transform transform # 关键点列名形如 x0,y0,x1,y1... self.keypoint_cols [c for c in df.columns if c.startswith((x, y))] def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] # 图像路径和关键点从同一行取出 img cv2.imread(row[image_path]) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) keypoints row[self.keypoint_cols].values.astype(float32) # 归一化到 [-1, 1]和 ToTensor 后的像素范围对齐 h, w img.shape[:2] keypoints[0::2] keypoints[0::2] / w * 2 - 1 keypoints[1::2] keypoints[1::2] / h * 2 - 1 keypoints keypoints.reshape(-1, 2) if self.transform: img self.transform(img) return img, torch.from_numpy(keypoints).float()这段代码里有两个参数必须盯死归一化分母用的是图像实际的w和h不是固定 224keypoints最后 reshape 成(68, 2)因为后面模型输出层也是这个形状。如果归一化时用了错误的尺寸训练 loss 会卡在一个很高的值下不去看起来像模型没学到东西其实是标签和输入对不上。数据增强部分项目里一般会用到随机水平翻转。注意翻转图像的同时关键点的 x 坐标也要镜像而且左右对称的点需要交换索引否则模型学到的就是一张错位脸。class RandomHorizontalFlip: def __init__(self, p0.5): self.p p # 68 点中左右对称的索引对需按实际标注顺序调整 self.symmetric_pairs [(0,16),(1,15),(2,14),(3,13),(4,12),(5,11), (6,10),(7,9),(17,26),(18,25),(19,24), (20,23),(21,22),(36,45),(37,44),(38,43), (39,42),(40,47),(41,46),(31,35),(32,34)] def __call__(self, img, keypoints): if np.random.rand() self.p: img np.fliplr(img).copy() keypoints[:, 0] -keypoints[:, 0] # x 坐标取反 for i, j in self.symmetric_pairs: keypoints[[i, j]] keypoints[[j, i]] return img, keypoints翻转增强是提升泛化最便宜的手段但对称索引对写错是高频事故。我见过有人直接把keypoints[:, 0]取反就完事结果模型在正脸上还行一遇到侧脸就崩原因就是左右点没交换标签语义乱了。3. 模型结构与训练循环回归头怎么接、损失怎么选3.1 骨干网络选型与回归头设计这个项目的模型结构不复杂常见做法是拿一个预训练的卷积骨干比如 ResNet 或一个简化版 CNN做特征提取然后把最后的分类头换成回归头。回归头的输出维度是 136对应 68 个点的 x、y 坐标。import torch.nn as nn import torchvision.models as models class KeypointModel(nn.Module): def __init__(self, pretrainedTrue): super().__init__() # 用 resnet18 做骨干去掉原分类层 backbone models.resnet18(pretrainedpretrained) self.features nn.Sequential(*list(backbone.children())[:-2]) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) # 回归头512 - 256 - 136 self.regressor nn.Sequential( nn.Linear(512, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, 136) ) def forward(self, x): x self.features(x) x self.avgpool(x) x torch.flatten(x, 1) x self.regressor(x) return x.view(-1, 68, 2) # 输出形状 (B, 68, 2)选 ResNet18 而不是更深的网络是因为这个任务的数据量不大深网络容易过拟合而且训练慢。回归头里加 Dropout 是为了压过拟合0.3 是我试下来比较稳的值太高会导致欠拟合点会聚成一团。输出层不加激活函数因为坐标是回归值加了 sigmoid 或 tanh 反而限制范围。3.2 损失函数与训练循环的关键参数关键点回归最常用的损失是 Smooth L1也叫 Huber Loss它对异常值不像 MSE 那么敏感。人脸关键点里下颌线的标注偶尔会有偏差用 MSE 会被这些点带偏Smooth L1 稳得多。import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model KeypointModel().to(device) criterion nn.SmoothL1Loss() optimizer optim.Adam(model.parameters(), lr1e-3) scheduler optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.1) for epoch in range(30): model.train() running_loss 0.0 for imgs, kps in train_loader: imgs, kps imgs.to(device), kps.to(device) optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, kps) loss.backward() optimizer.step() running_loss loss.item() scheduler.step() print(fEpoch {epoch1}, Loss: {running_loss/len(train_loader):.4f})学习率 1e-3 配 Adam 是这套结构的常用起点如果 loss 在前几个 epoch 震荡厉害降到 5e-4。StepLR 每 10 个 epoch 降一次是为了后期微调。batch size 在 GPU 上可以设 64CPU 上设 16。训练时如果 loss 降到 0.01 以下还在降别高兴太早大概率是过拟合了这时候要看验证集上的关键点可视化结果而不是只盯 loss。注意验证阶段一定要把模型切到eval()并且用torch.no_grad()包住否则 Dropout 和 BatchNorm 会继续更新验证结果不可信。4. 推理与可视化把 136 个数字还原成人脸4.1 单张图像的关键点预测与反归一化训练完之后模型输出的是归一化坐标必须反归一化回原图尺寸才能画在脸上。这一步和前面的归一化是逆操作分母还是原图的宽高。def predict_keypoints(model, image_path, device): model.eval() img cv2.imread(image_path) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w img_rgb.shape[:2] transform transforms.Compose([ transforms.ToPILImage(), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) input_tensor transform(img_rgb).unsqueeze(0).to(device) with torch.no_grad(): output model(input_tensor).cpu().numpy()[0] # (68, 2) # 反归一化先回到 [0,1]再乘原图尺寸 output (output 1) / 2 output[:, 0] * w output[:, 1] * h return img_rgb, output这里有个容易忽略的点训练时如果用了Normalize推理时也必须用同样的 mean 和 std否则输入分布不一致预测点会整体偏移。我见过有人训练用了归一化、推理忘了加结果点全跑到脸外面去了排查半天以为是模型问题。4.2 可视化验证与常见偏差判断把点画回图上是判断模型好坏最直接的方式。不要只看 loss 曲线loss 低不代表点准。import matplotlib.pyplot as plt def visualize(img, keypoints): plt.figure(figsize(8, 8)) plt.imshow(img) plt.scatter(keypoints[:, 0], keypoints[:, 1], s10, cr, markero) plt.axis(off) plt.show()看可视化结果时重点盯三个区域眼睛周围的点是否聚在眼角和瞳孔位置、嘴角点是否贴合唇形、下颌线是否跟着脸型走。如果所有点都往脸中心缩说明回归头欠拟合需要加训练轮数或调大学习率如果点整体偏移一个固定方向多半是归一化或反归一化尺寸对不上如果侧脸点全乱检查翻转增强的对称索引有没有写错。5. 避坑与排查训练不收敛、点偏移、显存爆的几条血泪经验5.1 现象loss 卡在 0.2 附近不下降原因最常见的是关键点归一化范围和数据增强后的坐标没同步。比如图像做了 Resize 到 224但关键点还是按原图尺寸归一化的输入和标签尺度不一致模型学不动。解决把归一化和 Resize 绑在一起图像缩放到 224 后关键点也按 224 的宽高重新归一化。或者干脆在 Dataset 里先 Resize 图像再基于 Resize 后的尺寸归一化关键点。5.2 现象验证集点全挤在图像中心原因回归头输出被激活函数限制或者学习率太大导致模型输出饱和。有些人习惯性在最后一层加Sigmoid但坐标归一化到 [-1,1] 后Sigmoid 只能输出 [0,1]负坐标全被截断。解决去掉输出层的激活函数确认归一化范围是 [-1,1] 而不是 [0,1]。如果已经加了改成Tanh并确认归一化范围匹配。5.3 现象训练中途显存爆掉原因batch size 设太大或者验证阶段没加torch.no_grad()计算图一直累积。解决先把 batch size 减半验证循环用with torch.no_grad():包住。另外如果用了 DataLoader 的num_workers设成 2 到 4 就行设太高反而会占额外内存。5.4 现象翻转增强后左右眼关键点互换原因对称索引对写错或者只翻转了图像没交换关键点索引。解决对照标注文档把 68 点的左右对称对列出来逐对检查。一个笨但有效的办法是拿一张正脸图翻转后把点画出来看左右眼位置是否合理。5.5 现象CPU 上训练一个 epoch 要半小时原因图像没做预 Resize每次都在原图上跑卷积或者 DataLoader 的num_workers设成 0数据加载串行。解决在 Dataset 里先把图像 Resize 到 224 再送进模型num_workers设成 4。如果还是慢考虑把数据预处理好存成 npy训练时直接读。6. 进阶技巧用关键点做对齐、算误差、接下游任务训练完模型只是第一步真正让这个项目有价值的是后面怎么用。我一般会先做一个验证用 NMENormalized Mean Error量化模型精度而不是凭肉眼看。NME 的计算方式是用两眼距离做归一化把预测点和真实点的平均距离除进去数值越小越好。def compute_nme(pred, gt): # pred, gt 形状均为 (68, 2)已反归一化到像素坐标 left_eye gt[36:42].mean(axis0) right_eye gt[42:48].mean(axis0) inter_ocular np.linalg.norm(left_eye - right_eye) diff np.linalg.norm(pred - gt, axis1).mean() return diff / inter_ocularNME 低于 0.05 基本可用低于 0.03 算不错。如果 NME 偏高先别急着换模型回头检查数据里有没有标注质量差的样本把那些点明显偏离人脸的直接剔掉往往比调模型管用。另一个进阶用法是把关键点拿去做人脸对齐。用两眼坐标算旋转角把脸转正后再送进识别模型识别率通常能涨几个点。具体做法是算两眼连线的角度构造仿射变换矩阵用cv2.warpAffine把图像转正关键点也跟着变换。def align_face(img, keypoints): left_eye keypoints[36:42].mean(axis0) right_eye keypoints[42:48].mean(axis0) dy right_eye[1] - left_eye[1] dx right_eye[0] - left_eye[0] angle np.degrees(np.arctan2(dy, dx)) center tuple(((left_eye right_eye) / 2).astype(int)) M cv2.getRotationMatrix2D(center, angle, 1.0) aligned cv2.warpAffine(img, M, (img.shape[1], img.shape[0])) return aligned这个对齐步骤在做人脸属性分析、表情识别时几乎是标配。我自己的习惯是每次训完关键点模型先跑一遍 NME再抽 20 张不同姿态的图做对齐可视化确认侧脸和低头场景下点没飘才敢把模型接到下游任务里。从那以后我每次做关键点项目都强制走一遍归一化检查 → 翻转索引核对 → NME 量化 → 对齐可视化这四步少一步后面都可能返工。希望帮到你。本文还有配套的精品资源点击获取