☰
PyTorch人脸关键点检测:68点回归与96×96数据管线
2026/10/29 11:27:34 网站建设 项目流程

简介:面向计算机视觉与深度学习学习者,这份CVND首个项目资料围绕面部关键点检测展开,适合有一定Python基础、正在学习卷积神经网络与计算机视觉项目流程的读者。它帮助读者将CNN知识与人脸检测流程串联起来,理解眼睛、鼻子和嘴部关键点的定位方法,并认识其在面部跟踪、姿态识别、滤镜和情感识别等场景中的用途。压缩包共2000个文件,整体约330.12MB,以大量jpg人脸图像样本和9个Jupyter Notebook为主,辅以png、xml、csv、py等格式的数据标注、级联模型与辅助脚本,便于按阶段复现实验和查阅素材。已有201人学习,可作为课程项目或自学案例参考。内容按数据加载与可视化、CNN网络定义与训练、Haar级联结合CNN的完整检测、趣味滤镜与关键点用途四部分组织,评分重点落在网络训练与完整检测管线,并含models.py及训练/测试关键点CSV,可帮助读者掌握从数据预处理、模型训练到端到端检测的实现脉络,积累调参与排错经验。

1. 从一张 96×96 灰度脸开始:P1_Facial_Keypoints 到底练什么

把一张 96×96 的灰度人脸丢进 JupyterNotebook,让网络吐出 68 个关键点对应的 136 个坐标——这就是 P1_Facial_Keypoints 资源包最直接的用法。它不是只贴一个模型定义的演示,而是把 CSV 标注、图像缩放、Dataset 封装、卷积回归网络、训练循环和预测可视化串成一条能跑通的链路。对刚学完卷积、想跑通第一个回归项目的人来说,这份资源的价值在于“闭环”:你知道数据怎么进、loss 怎么降、点为什么偏。

它适合想练手人脸关键点、姿态估计前置任务、回归网络调参的人;不适合直接拿去做人脸识别产品,因为单人脸、小分辨率、轻量网络的边界很明显。最容易被忽略的坑是坐标归一化和图像缩放不同步:图像缩到 96×96,关键点没跟着缩放,训练越久偏得越离谱。

2. 拆开 JupyterNotebook:数据管线里 68 个关键点怎么排布

JupyterNotebook 的第一课通常不是写网络,而是把数据看明白。P1_Facial_Keypoints 里的标注以 CSV 形式存在,训练图像和测试图像分目录存放,文件名和关键点坐标靠第一列关联。很多人急着定义 Net,结果训练到一半才发现列数对不上、坐标范围不对、图像通道数不对,这些都是数据管线没拆干净。先把 CSV 的 137 列、图像的宽高顺序、关键点的归一化范围固定下来,后面调模型才有意义。

2.1 目录结构与 CSV 的 137 列

拿到资源包后先别打开 notebook 从头跑到尾,先看目录。常见结构是data/training_frames_keypoints.csv、data/test_frames_keypoints.csv,以及对应的data/training/、data/test/图像目录,模型保存目录一般是models/。先用一条命令把层级看清楚,避免把 CSV 和图像目录的相对路径写错。

find . -maxdepth 2 -type d

这条命令只列两层目录,够用来确认data/、models/、notebooks/的位置。如果 CSV 在data/下而图像在data/training/下,root_dir就应指向图像目录,而不是 CSV 所在目录。路径错的表现通常是FileNotFoundError,但更隐蔽的是文件名带空格或后缀不一致,读出来是None,后面 resize 直接崩。

接着读 CSV,确认列数和坐标范围。

import pandas as pd csv_path = 'data/training_frames_keypoints.csv' df = pd.read_csv(csv_path) print('shape:', df.shape) print('first columns:', df.columns[:5].tolist()) print('first row sample:', df.iloc[0, :5].tolist()) key_pts = df.iloc[:, 1:].values.astype('float32') print('keypoints min/max:', key_pts.min(), key_pts.max()) print('nan count:', df.isnull().sum().sum())

df.shape应该是(样本数, 137),137 = 1 列文件名 + 68 个关键点 × 2 个坐标。df.iloc[:, 1:]跳过第一列,astype('float32')是为了后面直接进 Tensor 时不因为 float64 报类型不一致。key_pts.min()和key_pts.max()很关键:如果最大值在几十到几百,说明是原图像素坐标;如果最大值是 1 左右,说明已经被归一化过,后面不能再除一次。nan count不是 0 时先清洗,别把脏数据带进 loss。

文件名列不要写死成Image或image_name,不同版本的 CSV 表头可能不同。稳妥做法是用df.columns[0]取第一列名,再在__getitem__里按位置取。这个细节看起来小,但在 notebook 里重跑几次之后,最容易出现“昨天能跑、今天读不到图”的玄学问题。

2.2 用 Dataset 和 transforms 把脸对齐到 96×96

关键点回归最怕“图像缩了、标签没缩”。常见做法是在__getitem__里读原图,先记下原始宽高,再分别算scale_x = 96.0 / w、scale_y = 96.0 / h,关键点的 x、y 分别乘对应的 scale。不要直接把transforms.Resize((96, 96))套在图像上就完事,因为 torchvision 的 transform 不会自动改关键点坐标,除非你自己写同步变换。

下面这个 Dataset 把灰度读取、缩放、归一化和 Tensor 转换放在一起,训练和推理都复用同一套逻辑。

import os import cv2 import numpy as np import torch from torch.utils.data import Dataset class FacialKeypointsDataset(Dataset): def __init__(self, csv_file, root_dir): self.key_pts_frame = pd.read_csv(csv_file) self.root_dir = root_dir def __len__(self): return len(self.key_pts_frame) def __getitem__(self, idx): img_name = self.key_pts_frame.iloc[idx, 0] img_path = os.path.join(self.root_dir, img_name) image = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if image is None: raise FileNotFoundError(img_path) h, w = image.shape[:2] key_pts = self.key_pts_frame.iloc[idx, 1:].values.astype(np.float32).reshape(-1, 2) # 图像和关键点同步缩放到 96x96 image = cv2.resize(image, (96, 96), interpolation=cv2.INTER_AREA) key_pts[:, 0] *= 96.0 / w key_pts[:, 1] *= 96.0 / h # 关键点归一化到 [-1, 1],与图像 Normalize(0.5, 0.5) 对齐 key_pts = (key_pts / 48.0) - 1.0 # 图像归一化到 [-1, 1],并加通道维 image = image.astype(np.float32) / 255.0 image = (image - 0.5) / 0.5 image = torch.from_numpy(image).unsqueeze(0) # [1, 96, 96] key_pts = torch.from_numpy(key_pts.reshape(-1)) # [136] return image, key_pts

cv2.IMREAD_GRAYSCALE保证输入是单通道,和网络第一层in_channels=1对齐。cv2.resize的第二个参数是(width, height),不是(height, width),这里写反会导致图像横竖颠倒,关键点也跟着错。INTER_AREA在缩小图像时更稳,边缘不容易出现锯齿。96.0 / w和96.0 / h分开算,是因为原图不一定正方形;如果直接用一个 scale,遇到非正方形图像就会在某个方向拉伸。

关键点归一化到[-1, 1]是我更习惯的做法:(key_pts / 48.0) - 1.0把 0~96 映射到 -1~1,和图像的Normalize(0.5, 0.5)保持同一量纲。这样 MSE loss 的初始值不会飙到几千,学习率用1e-3也更容易稳住。如果坚持让网络回归 0~96 的像素坐标,loss 尺度会大很多,学习率要往下压,否则前几个 batch 就容易 NaN。

2.3 关键点归一化与可视化自检

数据管线写完,先别训练。随机抽 4 张图,把关键点画回图像上,确认眼睛、鼻尖、嘴角的位置对得上。这个自检能提前抓出大部分坐标错位问题,比看 loss 曲线有效得多。

import matplotlib.pyplot as plt def show_keypoints(image, key_pts): # image: [1, 96, 96] tensor, key_pts: [136] tensor img = image.squeeze().cpu().numpy() img = img * 0.5 + 0.5 # 反归一化到 [0, 1] pts = key_pts.cpu().numpy().reshape(-1, 2) pts = (pts + 1.0) * 48.0 # 回到 0~96 像素坐标 plt.figure(figsize=(4, 4)) plt.imshow(img, cmap='gray') plt.scatter(pts[:, 0], pts[:, 1], s=12, c='m', marker='.') plt.axis('off') plt.show() ds = FacialKeypointsDataset('data/training_frames_keypoints.csv', 'data/training') for i in range(4): image, key_pts = ds[i] show_keypoints(image, key_pts)

反归一化的顺序必须和前面完全一致:图像先* 0.5 + 0.5,关键点先+ 1.0再* 48.0。如果发现所有点都挤在左上角,先查scale_x/scale_y是不是乘反了;如果点整体上下颠倒,查cv2.resize的宽高顺序;如果点只在一个方向偏,查原图是不是被裁剪过而 CSV 坐标还是原图坐标。下面这张检查表可以贴在 notebook 第一屏。

检查项期望异常时先看
CSV 列数137 列分隔符、表头、缺失列
坐标范围0 到原图宽高是否已被归一化
图像尺寸1×96×96通道数、宽高顺序
标签维度136reshape 是否错位
可视化点落在五官缩放、翻转、裁剪

表格里的“坐标范围”不是绝对值越小越好,而是要和你的归一化策略一致。训练集和测试集必须用同一套缩放和归一化逻辑,否则模型在训练集上看着还行,换测试图就崩。

3. 搭一个能收敛的关键点回归网络:从 3 层卷积到 136 维输出

数据对齐之后,网络反而不需要太复杂。96×96 的灰度输入、单人脸、数据量有限,堆太深的 backbone 容易过拟合,训练时间也不划算。P1_Facial_Keypoints 这个场景更适合先用 3 个卷积块把局部纹理提出来,再用全连接层学 68 个关键点的全局空间关系,输出层直接给 136 个连续值。先跑通这个轻量结构,再考虑换 backbone 或 heatmap。

3.1 为什么关键点检测用回归而不是分类

关键点坐标是连续值,回归直接输出 136 个实数,MSE 对每个坐标的偏差可导,训练目标很直接。分类的做法是把坐标离散成网格 bin,比如把 96 个像素分成 48 个区间,每个关键点预测落在哪个 bin;精度会被 bin 宽度卡住,而且 68 个点要做 136 次分类,输出维度更大。对小分辨率的单人脸任务,回归更省事。

另一种常见方案是 heatmap:每个关键点输出一张概率图,取最大响应位置作为坐标。heatmap 精度高、对多人和大分辨率更友好,但需要额外的编码和解码,计算量也更大。这个项目输入只有 96×96,数据量不算大,直接用全连接回归更容易在 JupyterNotebook 里快速验证。代价是空间精度不如 heatmap,遇到遮挡或大姿态时容易把点拉到平均位置。

网络结构我一般这样定:3 个卷积块,通道数 32、64、128,每个卷积后接 ReLU 和 2×2 max pooling;卷完得到 128×12×12,拉平后接 512 维全连接,dropout 0.2,最后输出 136。输出层不加 sigmoid 或 tanh,因为关键点归一化到[-1, 1]后可以为负,加了激活反而容易饱和。这个结构参数量约 9.6M,显存占用不大,batch 32 在普通显卡上就能跑。

3.2 卷积层、池化层与全连接层的尺寸推导

尺寸推导是后面排错的基础。输入 96×96,卷积用 3×3、padding=1、stride=1,空间尺寸不变;每次 2×2 max pooling 把宽高减半。所以 96 → 48 → 24 → 12,第三个池化后是 128×12×12。全连接第一层的输入就是128 * 12 * 12 = 18432,这个数字写错,训练一开始就会报mat1 and mat2 shapes cannot be multiplied。

层配置输出尺寸参数量
conv11→32, 3×3, padding=132×96×96320
pool12×2, stride=232×48×480
conv232→64, 3×3, padding=164×48×4818,496
pool22×2, stride=264×24×240
conv364→128, 3×3, padding=1128×24×2473,856
pool32×2, stride=2128×12×120
fc118432→5125129,437,184
dropoutp=0.25120
fc2512→13613669,632

fc1 占了绝大部分参数。如果显存紧张,可以把 512 降到 256,参数量大约减半,精度通常不会掉太多;如果数据量很小,甚至可以降到 128,配合 dropout 抑制过拟合。输入尺寸不是 96×96 时,要么固定 resize 到 96,要么在卷积后面加nn.AdaptiveAvgPool2d((12, 12)),把任意尺寸压到 12×12,避免全连接层写死。

3.3 用 PyTorch 定义 Net 并做一次前向 shape 检查

定义完网络先别急着训练,造一个随机 batch 走一遍前向,确认输出是[B, 136]。这一步花 10 秒,能省掉后面半小时的 shape 报错。

import torch import torch.nn as nn class Net(nn.Module): def __init__(self): super(Net, self).__init__() self.conv1 = nn.Conv2d(1, 32, 3, padding=1) self.conv2 = nn.Conv2d(32, 64, 3, padding=1) self.conv3 = nn.Conv2d(64, 128, 3, padding=1) self.pool = nn.MaxPool2d(2, 2) self.fc1 = nn.Linear(128 * 12 * 12, 512) self.fc2 = nn.Linear(512, 136) self.dropout = nn.Dropout(0.2) self.relu = nn.ReLU() def forward(self, x): x = self.pool(self.relu(self.conv1(x))) # [B, 32, 48, 48] x = self.pool(self.relu(self.conv2(x))) # [B, 64, 24, 24] x = self.pool(self.relu(self.conv3(x))) # [B, 128, 12, 12] x = x.view(x.size(0), -1) # [B, 18432] x = self.dropout(self.relu(self.fc1(x))) # [B, 512] x = self.fc2(x) # [B, 136] return x model = Net() x = torch.randn(8, 1, 96, 96) y = model(x) print('output shape:', y.shape) print('params:', sum(p.numel() for p in model.parameters()))

x.view(x.size(0), -1)里的x.size(0)是 batch 维,不能写成固定值,否则换 batch size 就崩。dropout在model.train()时生效,model.eval()时自动关闭。输出层fc2后面没有 ReLU,因为关键点坐标可以为负;如果误加了 ReLU,所有负坐标会被截成 0,可视化时会看到点全挤在图像上半部分或中心。params打印出来约 9.6M,和表格里的估算对得上,说明结构没写错。

如果输出 shape 是[8, 136],但训练时 loss 不降,先别改网络,回去查数据归一化和学习率。网络结构本身不复杂,大部分翻车都发生在数据管线和 loss 尺度上。

4. 训练循环与损失函数:MSE、Adam 和学习率怎么设

网络定义完,训练循环是第二个容易翻车的地方。关键点回归的 loss 尺度、学习率、验证集监控要一起看:只看训练 loss 下降,模型可能只是学会了输出“平均脸”;只看验证 loss,又可能被少量样本带偏。比较稳的做法是固定一套轻量参数,先让 loss 进入平台,再用可视化确认五官是否真的贴合。

4.1 为什么用 MSELoss 而不是 CrossEntropy

CrossEntropy 需要类别标签,关键点坐标不是类别,直接套会维度对不上。MSE 对每个坐标的偏差做平方,梯度形式简单,配合归一化到[-1, 1]的标签,初始 loss 通常在可控范围内。如果标注里有明显离群点,SmoothL1Loss会更稳,因为它在误差较大时梯度不会爆;但这个项目的默认目标就是 MSE,先用 MSE 跑通,再考虑替换。

优化器我一般用 Adam,学习率1e-3,batch 32,epoch

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询