简介:这份资源是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就是冲着这个断层来的——它是 CVND(Computer 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 python=3.8 -y conda activate facial_kp # 安装核心依赖,torch 版本建议 1.8 以上 pip install torch==1.10.0 torchvision==0.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, transform=None): 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,不是固定 224;keypoints最后 reshape 成(68, 2),因为后面模型输出层也是这个形状。如果归一化时用了错误的尺寸,训练 loss 会卡在一个很高的值下不去,看起来像模型没学到东西,其实是标签和输入对不上。
数据增强部分,项目里一般会用到随机水平翻转。注意翻转图像的同时,关键点的 x 坐标也要镜像,而且左右对称的点需要交换索引,否则模型学到的就是一张"错位脸"。
class RandomHorizontalFlip: def __init__(self, p=0.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, pretrained=True): super().__init__() # 用 resnet18 做骨干,去掉原分类层 backbone = models.resnet18(pretrained=pretrained) 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(inplace=True), 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(), lr=1e-3) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.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(f"Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}")学习率 1e-3 配 Adam 是这套结构的常用起点,如果 loss 在前几个 epoch 震荡厉害,降到 5e-4。StepLR 每 10 个 epoch 降一次,是为了后期微调。batch size 在 GPU 上可以设 64,CPU 上设 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], s=10, c='r', marker='o') 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. 进阶技巧:用关键点做对齐、算误差、接下游任务
训练完模型只是第一步,真正让这个项目有价值的是后面怎么用。我一般会先做一个验证:用 NME(Normalized Mean Error)量化模型精度,而不是凭肉眼看。NME 的计算方式是用两眼距离做归一化,把预测点和真实点的平均距离除进去,数值越小越好。
def compute_nme(pred, gt): # pred, gt 形状均为 (68, 2),已反归一化到像素坐标 left_eye = gt[36:42].mean(axis=0) right_eye = gt[42:48].mean(axis=0) inter_ocular = np.linalg.norm(left_eye - right_eye) diff = np.linalg.norm(pred - gt, axis=1).mean() return diff / inter_ocularNME 低于 0.05 基本可用,低于 0.03 算不错。如果 NME 偏高,先别急着换模型,回头检查数据里有没有标注质量差的样本,把那些点明显偏离人脸的直接剔掉,往往比调模型管用。
另一个进阶用法是把关键点拿去做人脸对齐。用两眼坐标算旋转角,把脸转正后再送进识别模型,识别率通常能涨几个点。具体做法是算两眼连线的角度,构造仿射变换矩阵,用cv2.warpAffine把图像转正,关键点也跟着变换。
def align_face(img, keypoints): left_eye = keypoints[36:42].mean(axis=0) right_eye = keypoints[42:48].mean(axis=0) 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 量化 → 对齐可视化"这四步,少一步后面都可能返工。希望帮到你。
本文还有配套的精品资源,点击获取