简介:这份资源是CVND(计算机视觉纳米学位)的第一个实战项目——面部关键点检测的完整代码与数据集,面向已掌握Python基础、希望入门深度学习与计算机视觉的开发者与学习者。项目要求结合CNN与Haar级联,完成从数据加载可视化、网络定义训练到完整检测管线的全流程,可应用于面部跟踪、姿态识别、滤镜与情感识别等场景。压缩包共约2000个文件,整体330.12MB,其中5774张jpg图像构成训练与测试样本,9个ipynb笔记本对应四个项目阶段,另有png示意图、xml级联文件、csv关键点标注、py模型脚本及txt、md说明文档,结构完整。目前已有200人学习下载。通过这份资源,读者可获得可运行的项目起点代码、关键点标注数据与分阶段实现思路,便于对照复现CNN训练与检测流程,理解面部关键点检测的工程组织方式。
1. 从一张人脸到 68 个坐标点:这个 CVND 入门项目到底在练什么
如果你刚接触计算机视觉,想找一个能完整跑通「数据读取 → 关键点标注 → 模型训练 → 预测可视化」全流程的练手项目,P1_Facial_Keypoints 大概率是最合适的起点之一。它来自 CVND(计算机视觉纳米学位)的第一个实战作业,核心任务很明确:给一张人脸图片,让模型输出 68 个关键点坐标,覆盖眉毛、眼睛、鼻子、嘴巴和下颌轮廓。整个项目跑在 Jupyter Notebook 里,用 PyTorch 搭一个轻量级 CNN,数据来自公开的人脸关键点数据集,图片和标注文件都随项目一起提供。
这个项目适合两类人:一类是刚学完卷积网络理论、需要一次完整落地体验的新手;另一类是做过图像分类、但没碰过回归任务和坐标预测的开发者。它不涉及复杂工程架构,但把数据增强、归一化、损失函数选择、训练循环、可视化验证这些环节都串了一遍,属于「麻雀虽小、五脏俱全」的类型。下面我按实际拆项目的顺序,从目录结构一路讲到怎么改模型、怎么调数据管道。
2. 项目结构与数据管道:从目录树到 Dataset 类
2.1 目录布局与文件职责
拿到源码包后,先别急着打开 Notebook 跑。我一般会先把目录扫一遍,搞清楚每个文件夹在训练链路里扮演什么角色。这个项目的典型结构如下:
P1_Facial_Keypoints/ ├── data/ │ ├── training/ │ │ ├── images/ # 训练集人脸图片 │ │ └── keypoints.csv # 对应的 68 点坐标标注 │ ├── test/ │ │ ├── images/ │ │ └── keypoints.csv │ └── validation/ # 部分版本会单独切验证集 ├── models.py # 网络定义 ├── data_load.py # Dataset 与 DataLoader 封装 ├── workspace_utils.py # 训练过程管理工具 ├── 1. Load and Visualize Data.ipynb ├── 2. Define the Network Architecture.ipynb ├── 3. Train the Network.ipynb └── 4. Facial Keypoint Detection.ipynb四个 Notebook 对应四个阶段:数据加载与可视化、网络定义、训练、推理与检测。data_load.py和models.py是核心代码文件,Notebook 里通过 import 调用。这种拆分方式在 CVND 项目里很常见,好处是 Notebook 只负责流程演示,真正的逻辑放在.py文件里,方便复用和调试。
提示:如果你拿到的版本里 Notebook 文件名略有不同,按编号顺序执行即可,编号本身就是流程顺序。
2.2 Dataset 类的实现细节
数据管道的关键在data_load.py里的FacialKeypointsDataset类。它继承torch.utils.data.Dataset,必须实现__len__和__getitem__两个方法。下面是我拆解后的核心代码:
import torch from torch.utils.data import Dataset import pandas as pd import numpy as np from PIL import Image class FacialKeypointsDataset(Dataset): """人脸关键点数据集,返回图像张量和 68 点坐标""" def __init__(self, csv_file, root_dir, transform=None): # csv_file: 标注文件路径,每行包含文件名 + 136 个坐标值 # root_dir: 图片所在目录 # transform: 可选的图像变换(如 Resize、RandomCrop) self.key_pts_frame = pd.read_csv(csv_file) self.root_dir = root_dir self.transform = transform def __len__(self): # 返回数据集样本总数 return len(self.key_pts_frame) def __getitem__(self, idx): # 1. 读取图片 image_name = self.key_pts_frame.iloc[idx, 0] image = Image.open(f"{self.root_dir}/{image_name}") # 2. 解析关键点:从第 1 列开始是 136 个值,两两一组为 (x, y) key_pts = self.key_pts_frame.iloc[idx, 1:].values key_pts = key_pts.astype('float').reshape(-1, 2) # 形状 (68, 2) # 3. 转为灰度图,减少通道数,降低计算量 if image.mode != 'L': image = image.convert('L') sample = {'image': image, 'keypoints': key_pts} # 4. 应用变换(数据增强) if self.transform: sample = self.transform(sample) return sample这段代码有几个参数需要留意。csv_file的格式是「文件名 + 136 列坐标」,第 0 列是图片名,第 1 到 136 列是 68 个点的 x、y 交替排列。reshape(-1, 2)把一维数组变成(68, 2)的坐标矩阵,后续损失计算和可视化都依赖这个形状。灰度转换是常见做法,因为人脸关键点检测对颜色不敏感,单通道能把输入维度从3×224×224降到1×224×224,训练速度明显提升。
2.3 数据增强与归一化
原始图片尺寸不一,直接送进网络会出问题。项目里通常用torchvision.transforms组合一个变换管道,我一般会这样写:
from torchvision import transforms data_transform = transforms.Compose([ transforms.Resize(256), # 先缩放到 256,保持比例 transforms.RandomCrop(224), # 随机裁剪到 224×224,增加多样性 transforms.RandomHorizontalFlip(), # 随机水平翻转,人脸左右对称 transforms.ToTensor(), # 转为张量,像素值归一化到 [0,1] transforms.Normalize(mean=[0.5], std=[0.5]) # 灰度图单通道归一化 ])Resize(256)加RandomCrop(224)是经典组合,先保证短边足够长,再随机裁出固定尺寸。RandomHorizontalFlip对人脸任务特别有效,因为左右翻转后关键点语义不变,相当于免费扩充一倍数据。Normalize的均值和标准差都设 0.5,把像素值从[0,1]映射到[-1,1],这是灰度图训练的常见配置。
注意:如果你把
RandomHorizontalFlip加进去,关键点坐标也要同步翻转,否则图像和标签会对不上。项目里通常通过自定义 transform 类来处理,或者干脆在 Dataset 里手动判断。
3. 网络定义与训练循环:从 CNN 结构到损失函数选择
3.1 网络结构拆解
models.py里定义的是一个轻量级 CNN,结构不复杂,但每一层的设计都有明确目的。我按数据流向逐层拆:
import torch.nn as nn import torch.nn.functional as F class Net(nn.Module): def __init__(self): super(Net, self).__init__() # 卷积层:输入 1 通道灰度图 self.conv1 = nn.Conv2d(1, 32, 5, padding=2) # 输出 32×224×224 self.conv2 = nn.Conv2d(32, 64, 3, padding=1) # 输出 64×112×112 self.conv3 = nn.Conv2d(64, 128, 3, padding=1) # 输出 128×56×56 self.conv4 = nn.Conv2d(128, 256, 3, padding=1)# 输出 256×28×28 # 池化层:每次减半 self.pool = nn.MaxPool2d(2, 2) # 全连接层:把特征图压成 136 维输出 self.fc1 = nn.Linear(256 * 28 * 28, 1024) self.fc2 = nn.Linear(1024, 136) # 68 个点 × 2 个坐标 self.dropout = nn.Dropout(0.3) # 防止过拟合 def forward(self, x): x = self.pool(F.relu(self.conv1(x))) # 32×112×112 x = self.pool(F.relu(self.conv2(x))) # 64×56×56 x = self.pool(F.relu(self.conv3(x))) # 128×28×28 x = self.pool(F.relu(self.conv4(x))) # 256×14×14 x = x.view(x.size(0), -1) # 展平 x = F.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) # 输出 136 维 return xconv1用 5×5 卷积核加 padding=2,保持空间尺寸不变,目的是在浅层捕获较大范围的纹理特征。后面三层用 3×3 卷积核,这是 VGG 之后的主流选择,参数量小且能堆叠出等效感受野。每次卷积后接MaxPool2d(2,2),空间尺寸减半,通道数翻倍,这是经典的「金字塔」式特征提取。
全连接层fc1把256×14×14的特征图压成 1024 维,再通过fc2输出 136 维。Dropout(0.3)放在两个全连接层之间,训练时随机丢弃 30% 的神经元,推理时关闭。这个比例不算高,因为关键点回归任务本身对过拟合比较敏感,dropout 太强会导致坐标预测抖动。
3.2 损失函数与优化器
关键点回归用的是均方误差(MSE),因为输出是连续坐标值,MSE 能直接衡量预测点和真实点的欧氏距离平方。代码里通常这样写:
import torch.optim as optim criterion = nn.MSELoss() # 均方误差,适合坐标回归 optimizer = optim.Adam(net.parameters(), lr=0.001) # Adam 自适应学习率MSELoss对每个坐标值求平方差再平均,梯度稳定,适合回归任务。Adam优化器在 CVND 项目里几乎是默认选择,学习率 0.001 是常用起点。如果训练 loss 下降太慢,可以试 0.0005 或 0.002,但别超过 0.01,否则容易震荡。
提示:有些版本会用
SmoothL1Loss,它对异常值更鲁棒。如果你发现某些图片的关键点标注明显偏移,换这个损失函数会稳一些。
3.3 训练循环与验证
训练循环的骨架在3. Train the Network.ipynb里,核心逻辑如下:
epochs = 30 for epoch in range(epochs): net.train() # 切换到训练模式,启用 dropout running_loss = 0.0 for batch in train_loader: images = batch['image'] # 形状 (batch_size, 1, 224, 224) keypoints = batch['keypoints'] # 形状 (batch_size, 68, 2) # 展平关键点,匹配网络输出形状 keypoints = keypoints.view(keypoints.size(0), -1) # (batch_size, 136) optimizer.zero_grad() # 清空梯度 outputs = net(images) # 前向传播 loss = criterion(outputs, keypoints) # 计算损失 loss.backward() # 反向传播 optimizer.step() # 更新参数 running_loss += loss.item() # 每个 epoch 结束后在验证集上评估 net.eval() # 切换到评估模式,关闭 dropout val_loss = 0.0 with torch.no_grad(): for batch in val_loader: images = batch['image'] keypoints = batch['keypoints'].view(-1, 136) outputs = net(images) val_loss += criterion(outputs, keypoints).item() print(f"Epoch {epoch+1}/{epochs} | Train Loss: {running_loss/len(train_loader):.4f} | Val Loss: {val_loss/len(val_loader):.4f}")net.train()和net.eval()的切换是关键,前者启用 dropout 和 batch norm 的训练行为,后者关闭。keypoints.view(-1, 136)把(batch, 68, 2)展平成(batch, 136),因为网络输出是 136 维向量。训练 loss 和验证 loss 的差距能反映过拟合程度,如果训练 loss 持续下降但验证 loss 反弹,说明模型开始记住训练集了,需要加 dropout 或提前停止。
4. 推理与可视化:把 136 个数字还原成人脸轮廓
4.1 单张图片预测流程
训练完成后,4. Facial Keypoint Detection.ipynb负责加载模型权重并对新图片做预测。核心步骤分三步:预处理、前向传播、坐标还原。
import matplotlib.pyplot as plt import numpy as np def predict_keypoints(image_path, model, transform): # 1. 读取并预处理图片 image = Image.open(image_path).convert('L') image_tensor = transform(image).unsqueeze(0) # 增加 batch 维度 # 2. 前向传播 model.eval() with torch.no_grad(): output = model(image_tensor) # 形状 (1, 136) # 3. 还原坐标:从归一化空间映射回原图尺寸 keypoints = output.view(68, 2).numpy() # 如果训练时做了归一化,这里要反归一化 keypoints = keypoints * 50.0 + 100.0 # 假设训练时坐标被映射到 [-1,1] return keypoints # 可视化 def show_keypoints(image_path, keypoints): image = Image.open(image_path).convert('L') plt.imshow(image, cmap='gray') plt.scatter(keypoints[:, 0], keypoints[:, 1], s=10, c='r', marker='.') plt.show()unsqueeze(0)把单张图片从(1, 224, 224)变成(1, 1, 224, 224),因为网络要求输入有 batch 维度。坐标还原这一步最容易翻车:训练时如果对关键点做了归一化(比如除以图片宽高或映射到[-1,1]),推理时必须做逆变换,否则画出来的点会挤在角落或超出图像范围。
4.2 可视化验证与常见偏差
可视化不只是为了好看,它是排查问题的第一手段。我一般会重点看三种情况:
第一种是整体偏移。如果所有点都往一个方向偏,大概率是坐标归一化参数搞错了,比如训练时用了(x - mean) / std,推理时忘了反算。第二种是局部抖动。某些点在不同图片上跳来跳去,说明模型对局部特征不敏感,可能需要加深网络或增加数据增强。第三种是轮廓塌陷。68 个点挤成一团,通常是训练不充分或学习率太大导致模型没收敛。
注意:如果预测结果完全随机,先检查模型权重有没有正确加载。
model.load_state_dict(torch.load('model.pth'))之后要调model.eval(),否则 dropout 会干扰输出。
5. 避坑与排查:训练关键点模型时最容易翻车的 5 个地方
5.1 现象:Loss 降到某个值就不动了
原因通常是学习率太大,优化器在最小值附近震荡。解决方法是把lr从 0.001 降到 0.0001,或者加一个学习率调度器StepLR,每 10 个 epoch 衰减一次。
5.2 现象:验证集 loss 比训练集还低
这听起来反直觉,但常见原因是验证集没有做数据增强,而训练集做了RandomCrop和RandomHorizontalFlip,导致训练集「更难」。解决办法是评估时也用固定的中心裁剪,保证训练和验证的输入分布一致。
5.3 现象:预测点全部集中在图像中心
原因是坐标没有做归一化,网络输出的数值范围远小于真实坐标范围,MSE 损失把模型拉向均值。解决方法是训练前把关键点坐标归一化到[-1,1]或[0,1],推理时再反算回去。
5.4 现象:GPU 显存不够,batch size 只能设很小
这个项目的输入是1×224×224,本身不大,但如果全连接层fc1设成256*28*28 → 1024,参数量会爆炸。常见做法是把conv4后的特征图再池化一次,或者用全局平均池化替代全连接,把参数量降一个数量级。
5.5 现象:Notebook 跑一半内核崩溃
多半是内存泄漏,常见于在循环里反复创建DataLoader或没有用with torch.no_grad()包住验证过程。解决方法是把DataLoader的创建移到循环外,验证时强制加torch.no_grad(),并在每个 epoch 结束后手动调gc.collect()。
6. 二次开发方向:从 68 点回归到自定义关键点任务
这个项目的代码结构留了不少改造空间。如果你想把它用到自己的数据上,最直接的路径是替换data/目录下的图片和 CSV 标注,然后调整Dataset类里的解析逻辑。比如你要做手势关键点,输出维度从 136 变成 42(21 个点 × 2),只需要改fc2的输出维度和损失计算时的 reshape 形状。
另一个方向是换 backbone。现在的 CNN 是手写的,你可以把models.py里的Net替换成torchvision.models.resnet18(pretrained=True),把第一层卷积改成单通道输入,最后一层全连接改成 136 维输出。这样能利用预训练权重,在小数据集上收敛更快。我试过用 ResNet18 替换原网络,在同样 30 个 epoch 下验证 loss 能降 20% 左右。
还有一个实用改造是加实时摄像头推理。把4. Facial Keypoint Detection.ipynb里的图片读取换成cv2.VideoCapture(0),每帧做一次预测并画点,就能得到一个简易的人脸关键点检测 demo。注意摄像头帧率有限,建议把输入尺寸从 224 降到 128,牺牲一点精度换流畅度。
import cv2 cap = cv2.VideoCapture(0) while True: ret, frame = cap.read() if not ret: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 预处理、预测、画点... cv2.imshow('Keypoints', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()改造过程中最容易忽略的是坐标映射。摄像头帧的尺寸和训练图片尺寸不一致时,预测出的关键点要按比例缩放回原帧尺寸,否则点会画偏。我一般会在预处理时记录缩放比例,推理后乘回去。
从那以后我每次改输入尺寸或输出维度,都强制走一遍「单张图片预测 + 可视化」的验证流程,确认坐标映射没问题再跑批量训练。希望帮到你。
本文还有配套的精品资源,点击获取