人脸关键点检测实战:PyTorch坐标回归与迁移学习避坑指南
发布时间:2026/9/29 2:00:11来源:尧图网络
简介一份面向计算机视觉与深度学习初学者的面部关键点检测项目资源源自CVND计算机视觉纳米学位的第一个实战项目。资源将任务拆解为四个Jupyter Notebook先完成人脸关键点数据集的加载与可视化再定义并训练卷积神经网络CNN进行关键点预测进而借助Haar级联完成完整的检测流程最后实现趣味滤镜等应用适合正在学习人脸跟踪、姿态识别、情感识别等场景的开发者作为完整参考。资源压缩包共2000个文件大小约330MB主要包含jpg图像数据、ipynb笔记本、xml级联文件、csv标注文件及py脚本等结构清晰便于按步骤复现。目前已有200人学习下载评价较为实用。通过这份资源读者可获得一整套面部关键点检测的实现思路与可运行代码包括数据组织方式、网络结构定义、训练与推理流程等能有效缩短从理论到实践的摸索时间。1. 人脸关键点检测不是图像分类P1_Facial_Keypoints 到底要你做什么如果你正在啃 Udacity 的计算机视觉纳米学位CVND大概率会在第一个项目 P1_Facial_Keypoints 上停下来。这个项目的标题看起来像是一个人脸关键点检测的入门练习很多新手会顺手把它当成图像分类或者目标检测来做——用预训练模型一跑loss 降了就以为完事了。但实际上这个项目的本质是一个坐标回归任务给一张人脸灰度图让模型输出 68 个关键点的 (x, y) 像素坐标。分类的思维在这里几乎全部失效Softmax、交叉熵、分类准确率这些老朋友一个都用不上你需要换一套评估思路来对待它。P1 的价值在于它恰好卡在 CVND 课程的前半段你已经会了卷积网络的基本概念但还没接触目标检测和语义分割。这个项目逼着你第一次独立完成数据预处理 → 网络设计 → 回归训练 → 可视化验证的完整闭环。做完它你对 PyTorch 的 Dataset、DataLoader、损失函数选择的理解会比刷十遍教程都扎实。适合人群很明确打算系统走完 CVND 或者想从分类任务转向回归任务的初学者以及需要快速上手人脸关键点落地方案的从业者。2. 数据集预处理与 DataLoader把 68 个点坐标和 96x96 灰度图对齐2.1 课程数据集长什么样真实脸和合成脸混在一起CVND 这门课在 P1 阶段给的数据集是配好的里面混合了真实人脸图像和合成人脸图像。合成脸的比例不小一开始我没太在意后来发现这直接影响模型能不能泛化到真实照片上。训练用的 CSV 文件里每一行对应一张图片图片文件名后面跟着 136 列数值前 68 列是 x 坐标后 68 列是 y 坐标按关键点索引顺序排列。课程数据集并不是所有图片都是同一尺寸有的接近正方形有的是长方形的合成脸分辨率偏高真实脸偏低。P1 的项目说明里会建议你把图像统一处理成 96x96 的灰度图坐标也按比例做归一化。这个统一尺寸不是实验室洁癖而是 PyTorch 的 DataLoader 在 collate 时会要求一个 batch 内的张量形状完全一致尺寸不统一就会在训练第一个 batch 直接抛错。数据里还有一个很容易忽略的问题不是每一行都有完整的 68 个点标注。部分样本存在关键点缺失表现为对应位置为空值。处理办法是直接过滤掉这些行因为关键点回归任务里缺一个点就意味着这个样本的监督信号不完整。我一般会在加载 CSV 后先做一次非空校验把有效样本数打印出来确认过滤比例没有异常。2.2 用 Dataset 类把图片和关键点绑在一起PyTorch 里处理这类任务的标准姿势是自定义一个 Dataset 子类。它的职责是给定一个样本索引返回 (image_tensor, keypoint_tensor) 对。图片用 PIL 或 OpenCV 读进来转灰度图然后 resize 到 96x96关键点坐标在返回之前必须归一化到 [0,1] 区间否则训练时数值范围差异会让损失函数被坐标绝对值大的点主导。下面是我在 CVND P1 里用过的 Dataset 结构你可以直接照着改import pandas as pd import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class FaceKeypointDataset(Dataset): def __init__(self, csv_path, image_dir, transformNone): self.df pd.read_csv(csv_path) # 过滤掉关键点存在空值的样本 self.df self.df.dropna().reset_index(dropTrue) self.image_dir image_dir self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] # 图片路径在 CSV 第一列 img Image.open(f{self.image_dir}/{row[0]}).convert(L) # 取出 136 个坐标值并转为 float kp row[1:].values.astype(float32) # 前 68 个是 x后 68 个是 y x kp[:68] y kp[68:] # 按图片实际尺寸归一化到 [0, 1] w, h img.size x_norm x / w y_norm y / h keypoints torch.tensor(np.stack([x_norm, y_norm], axis1), dtypetorch.float32) if self.transform: img self.transform(img) return img, keypoints.view(-1)这段代码里有一个关键设计keypoints 最终被拉平成 136 维的向量而不是 68x2 的二维张量——原因是后面全连接输出层更方便直接对接 136 个神经元。归一化时用x / w、y / h而不是统一除以 96因为 CSV 里记录的标注坐标是原始图像尺寸下的绝对像素值只有在归一化之后才能和 resize 后的图像保持一致。transform 部分我会在训练集上用T.Resize((96, 96))加T.ToTensor()测试集不额外增强只做同样的 resize。注意这里 transform 不能放在 Dataset 外面否则每个 epoch 的随机增强和尺寸统一会互相干扰后面避坑章节里还会细说。2.3 坐标归一化与数据增强里容易踩的细节坐标归一化是整个项目里最容易出错的地方。常见的翻车姿势是用 96 作为分母因为图像最终要 resize 到 96x96。但 CSV 里的坐标是原始图上标出来的原始图可能是 120x80直接除以 96 会让所有点等比缩放到错误的位置上。我一般会在训练脚本里打印一个样本的原始尺寸和归一化后的坐标范围确认 x 和 y 都在 [0,1] 内再做训练。数据增强方面CVND P1 允许你用一些基础操作比如随机旋转、灰度扰动、水平翻转。水平翻转是所有增强里最危险的一个图像翻转后坐标必须跟着翻转而且只有 x 坐标要变y 坐标保持不变。如果你用的是 torchvision 的RandomHorizontalFlip(p0.5)它只翻转图像不会动关键点张量需要你把翻转逻辑写进 Dataset 里手动同步。很多课程的参考实现里都藏着一个自己写的flip_points函数就是为了处理这一步。我习惯在 Dataset 的__getitem__里做翻转增强时先拿到图像和关键点再用随机数决定是否翻转这样图像和坐标的同步逻辑很直观if self.augment and random.random() 0.5: img T.functional.hflip(img) # 翻转后 x 坐标变成 1 - xy 不变 keypoints[:, 0] 1.0 - keypoints[:, 0]另一个容易出问题的细节是灰度图归一化。ToTensor()会把像素值除以 255 变成 [0,1]这对卷积网络是友好的但如果你在 transform 里再叠一层Normalize((0.5,), (0.5,))那是把像素值映射到 [-1,1]。两种做法都能训只是训练时要保持一致。我推荐保持 [0,1] 不额外 Normalize因为 P1 的网络比较浅坐标回归对输入分布不敏感多一层标准化反而让可视化时像素值对不上。3. 用 PyTorch 搭关键点回归 CNN为什么最后接的是 Sigmoid 不是 Softmax3.1 任务本质变了损失函数和网络出口都要换人脸关键点检测在 CVND 的课程语境里是一个全监督的坐标回归问题输入 96x96 灰度图输出 136 个浮点数。这和图像分类有本质区别——分类网络最后接 Softmax输出的是各类别的概率分布损失函数用交叉熵回归网络的输出层不需要概率化直接用线性层输出原始数值损失函数用 L1 或 L2 距离。很多新手在这里栽的第一个跟头就是把输出层设计成 68 类别的 Softmax或者干脆复制一个分类网络只改了最后的类别数。这样的网络在训练时 loss 降不下去因为交叉熵根本没法衡量预测坐标和真实坐标有多远这个回归误差。还有一个容易被忽略的问题输出坐标的数值范围。如果网络输出层是普通全连接它的输出理论上是无界的可能在训练初期爆出几十甚至几百的值让梯度爆炸。稳妥做法是在网络最后一个全连接后接一个 Sigmoid把输出约束在 (0,1)这正好和坐标归一化后的 [0,1] 标签对齐。这也是我在 CVND P1 里从课程讨论区学到的最实用的一招。3.2 一个能跑动的手写 CNNConvPooling 堆叠与参数说明P1 项目本身允许你手写一个简单的 CNN不强制上预训练模型。我建议第一阶段先自己搭一个后面再试迁移学习。结构上不需要花哨三组Conv ReLU MaxPool加两个全连接就够用了。关键是把通道数和下采样倍数算清楚别让最后的全连接输入维度对不上。import torch.nn as nn class KeypointCNN(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), # 96x96 - 96x96 nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # - 48x48 nn.Conv2d(32, 64, kernel_size3, padding1), # 48x48 - 48x48 nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # - 24x24 nn.Conv2d(64, 128, kernel_size3, padding1),# 24x24 - 24x24 nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # - 12x12 ) self.regressor nn.Sequential( nn.Flatten(), nn.Linear(128 * 12 * 12, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, 136), nn.Sigmoid() # 输出约束到 (0,1)对齐归一化后的坐标 ) def forward(self, x): x self.features(x) x self.regressor(x) return x这里有几个参数值得说明。第一所有卷积层都用kernel_size3, padding1这是为了保持特征图尺寸不变让下采样完全由 MaxPool 完成通道数 32-64-128 是常见的倍增策略参数总量可控在 CPU 上也能较快跑完一个 epoch。第二全连接的输入是 128x12x12因为 96 经过三次 2 倍池化变成了 12这个数字要手算一遍确认改输入尺寸时这里最容易算错。第三Dropout 放在倒数第二层全连接之前概率设 0.3 而不是 0.5因为关键点坐标之间的空间相关性较强Dropout 太强会让点与点之间的相对位置信息丢失。3.3 训练循环与调参SmoothL1、Adam、batch size 和 epoch训练这个网络时我用的损失函数是nn.SmoothL1Loss()而不是很多人默认的nn.MSELoss()。SmoothL1 在误差较小时的表现接近 L2在误差较大时退化为 L1对离群点和异常标注不那么敏感。人脸关键点数据集中偶尔有标注偏差很大的样本如果用 MSE这些样本会产生很大的梯度把整个训练带偏。SmoothL1 在这类场景下几乎是回归任务的标准答案。优化器直接选 Adam学习率 1e-3后面如果看到 loss 平台期再降一档到 1e-4。batch size 设在 64 左右比较合适——课程数据集不算大batch 太小会导致梯度抖动太大又会让模型收敛变慢。训练循环的骨架如下model KeypointCNN().to(device) criterion nn.SmoothL1Loss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(30): model.train() running_loss 0.0 for images, keypoints in train_loader: images, keypoints images.to(device), keypoints.to(device) preds model(images) loss criterion(preds, keypoints) optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item() * images.size(0) epoch_loss running_loss / len(train_loader.dataset) print(fEpoch {epoch1:02d} | Loss: {epoch_loss:.4f})这个循环本身很简单但有两个细节值得强调。第一loss.item()要乘上images.size(0)再累加最后除以数据集总数而不是直接除以迭代次数否则 batch 大小不同的时候 loss 曲线没法横着比。第二每个 epoch 结束后最好在验证集上跑一遍前向传播算验证 loss但记得把模型切回model.eval()并且用torch.no_grad()包住验证过程否则 BatchNorm 和 Dropout 在验证阶段的行为会和训练阶段不一致验证 loss 会虚高。我第一次跑这个模型时30 个 epoch 后验证 loss 大约在 0.02 附近画出来的点已经能看出五官轮廓但嘴角和眼角区域还有点抖。这说明网络容量够用问题出在数据多样性和后处理细节上。如果你发现 loss 卡在 0.05 左右下不去优先检查是不是坐标归一化出了问题而不是急着加深网络。4. 迁移学习路线用预训练 ResNet18 改最后一层回归 136 个坐标4.1 输入管道先对齐灰度图转 3 通道、resize 到 224当你把第一个手写 CNN 跑通之后CVND P1 的进阶要求通常是做一次迁移学习。常见做法是拿 torchvision 里的预训练 ResNet18 当骨干网络。ResNet18 的输入要求是三通道、224x224 的 RGB 图像而我们手头是 96x96 的灰度图这中间有两层要改。第一层是通道数。灰度图只有一个通道但 ResNet18 的第一个卷积层是 Conv2d(3, 64, ...)形状对不上。常见做法不是改网络结构而是在 transform 里把灰度图转成三通道让三个通道内容完全一样。T.Grayscale(num_output_channels3)这个操作正好做这件事它在读入灰度图后复制成三份不增加额外存储负担。第二层是分辨率。ResNet18 内部的池化结构要求输入至少是 224x224因为它的最后一个全连接层前有一个全局平均池化输入尺寸只要大于等于某个最小值都能跑到但 96 太小会明显掉精度。我在迁移学习时会把图像统一 resize 到 224x224同时把关键点坐标按 224 重新归一化——这里要特别小心因为你如果用之前的 Dataset 类它里面归一化分母用的是原始图的img.size只要 Dataset 里先 resize 再取尺寸这个逻辑仍然成立但如果写死在 transform 外面就会踩坑。4.2 替换模型头部model.fc nn.Linear(512, 136)用 torchvision 加载 ResNet18 之后它的最后一层是model.fc原始输出是 1000 类。我们要把这一层替换成输出 136 的线性层对应 68 个关键点的 x/y 坐标。替换方法很简单from torchvision import models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) n_features model.fc.in_features model.fc nn.Linear(n_features, 136) # 512 - 136 # 对输入做 ImageNet 标准化和预训练权重对齐 transform T.Compose([ T.Resize((224, 224)), T.Grayscale(num_output_channels3), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])替换model.fc的代码本身没有技术难度但有一个细节值得注意model.fc.in_features在 ResNet18 里是 512这个数字来自最后一个残差块的输出通道数不要手写成 512用in_features动态获取更安全因为你换成 ResNet34 时它可能是 512 不变换 ResNet50 就变成 2048 了。还有Grayscale(num_output_channels3)和Normalize的配合问题。ResNet18 的预训练权重是在 ImageNet 上训练的输入需要做同样的标准化均值 [0.485, 0.456, 0.406]、方差 [0.229, 0.224, 0.225]。如果你不标准化第一阶段手写 CNN 那套 [0,1] 像素值直接喂进去迁移学习的收敛速度会明显变慢因为预训练特征分布没有被激活。这个标准化参数是 torchvision 官方在训练时就用的迁移时必须保持一致。4.3 冻结与微调两种策略和课设中的对比迁移学习不是把所有层都放开训练就叫迁移。如果直接把 ResNet18 的全部参数丢进 Adam 里训预训练权重很快就会被冲掉最后效果可能还不如自己搭的小 CNN而且训练速度慢很多。更稳妥的做法是冻结大部分骨干网络只训练新加的全连接层。# 冻结全部特征提取层 for param in model.parameters(): param.requires_grad False # 只解冻最后一层 fc让它从随机初始状态学坐标回归 for param in model.fc.parameters(): param.requires_grad True optimizer torch.optim.Adam(model.fc.parameters(), lr1e-3)这个策略叫特征提取前面的卷积层负责提取通用的脸部纹理和边缘特征这些特征在 ImageNet 上就已经学得很好了不需要针对人脸重新学。只有最后的线性层需要从零学——把 512 维特征映射到 136 个坐标。它的优势是训练极快几个 epoch 就能看到效果缺点是坐标预测的精度上限受限于预训练特征是否适配人脸关键点这个任务。我当时对比过两种策略。只训练 fc 层时验证 loss 大概在 0.02 到 0.03和手写 CNN 差距不大但把最后一个残差块的参数也解冻之后loss 能降到 0.01 左右眼角的预测明显更稳。做法是把model.layer4里的参数requires_grad设回 True同时把它加入优化器for name, param in model.named_parameters(): # layer4 是最后一个残差块解冻它做微调 if name.startswith(layer4) or name.startswith(fc): param.requires_grad True optimizer torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr5e-4)这里的学习率要降一半因为微调阶段如果学习率太高会把预训练权重破坏掉。一个常用的经验是全连接层用 1e-3微调的卷积层用 1e-4 到 5e-4分开两组参数设不同学习率虽然实现上要手动写两个 optimizer效果却值得。对比手写 CNN 和迁移学习有一个直觉性的结论手写 CNN 在训练数据量足够时能逼近迁移学习的效果但训练时间和调试成本更高迁移学习在前几个 epoch 就展现出更强的稳定性和更好的可视化效果。CVND P1 的第一阶段建议手写第二阶段建议迁移这个顺序的设计意图就是让你先理解回归任务的本质再享受预训练模型的福利。5. 人脸关键点项目避坑清单loss 收敛但点乱飞的四个事故现场5.1 水平翻转增强后 loss 不降反升现象加了RandomHorizontalFlip之后训练 loss 在头两个 epoch 正常下降之后突然反弹甚至比不加增强还高。验证集上画出的关键点整体左右颠倒。原因torchvision 的RandomHorizontalFlip只翻转图像张量不会自动翻转关键点坐标。模型看到的是翻转后的脸但标签还是翻转前的坐标等于训练数据里有一部分是脸朝右、标签点在左的错乱状态。网上很多参考代码直接套用图像分类的 transform根本没意识到关键点也是标签的一部分要和图像同步变换。解决不要在 transform 里用RandomHorizontalFlip改到 Dataset 的__getitem__里自己控制翻转逻辑。翻转图像的同时把 x 坐标映射为1 - xy 不变然后在返回之前对关键点张量执行同样的映射。我习惯把这段逻辑写成一个独立的小函数flip_points(keypoints, width)在训练脚本里单测一次翻转两次应该回到原始坐标确认无误再开始训练。5.2 图片尺寸不统一导致 DataLoader 报错现象训练到第一个 batch 结束第二个 batch 加载时直接抛RuntimeError: stack expects each tensor to be equal size或者更常见的Expected input batch_size to match target batch_size。原因数据集里的图片原始尺寸不统一有些是 120x80有些是 100x100。如果 resize 操作写在单独的外部 transform 里但没被正确应用或者 Dataset 里某些分支路径忘了 resize同一个 batch 内就会出现不同大小的张量DataLoader 在 collate 阶段无法把它们堆叠成一个 batch。解决把 resize 写进 Dataset 里的强制路径——在__getitem__一开始就对图像做img img.resize((96, 96))不要在 transform 里依赖调用顺序。另一个排查技巧是在训练前写一个 for 循环遍历 DataLoader 生成一个 batch打印每张图的 shape确认所有样本都是(1, 96, 96)。这一步能帮你区分是数据路径问题还是模型输入输出维度问题不用在损失函数上白调试半天。5.3 验证集 loss 很低但画出的点整体偏移现象训练 loss 降到 0.01验证 loss 也稳定但把预测点画回原图上发现所有点整体往左上角偏了一段距离五官位置关系是对的但整体坐标不对。原因归一化和反归一化的基准不一致。最常见的是训练时用 96x96 做归一化分母可视化时却又拿原始图尺寸去乘预测坐标或者 Dataset 里用了T.Resize((96, 96))之后再取img.size拿到的尺寸变成了 96但 CSV 里的标注是基于原始图的分子分母来源不同算出来的归一化坐标本身就是错的。解决固定一套坐标换算逻辑——在 Dataset 里先 resize 再取w, h用 resize 后的尺寸做归一化分母可视化时用 96 作为乘数还原坐标。关键是要把标注坐标所在的参考系和模型输入的参考系统一成同一个。我一般会在训练脚本里写一个denormalize(preds, scale96)函数把所有可视化逻辑都走这个函数不在别处手动乘。5.4 合成脸和真实脸混训导致验证集表现飘忽现象模型在训练集上 loss 正常下降但验证集的 loss 曲线忽高忽低完全没有规律随机挑几张真实照片测试关键点经常落在皮肤纹理上而不是五官边缘。原因课程数据集里合成脸和真实脸混合合成脸的标注是程序生成的、非常干净真实脸的标注可能有噪声或遮挡。网络在训练时优先拟合干净且数量占优的合成脸对真实脸的泛化能力很弱。验证集如果也含这两种类型loss 就会随 batch 里合成脸和真实脸的比例波动。解决加载数据后按文件名的前缀或来源目录给样本分桶把合成和真实样本分开做两个验证集。训练时观察两个验证集的 loss 差异——如果合成集 loss 远低于真实集说明模型被合成脸主导了这时候可以减少合成脸在训练数据中的比例或者对真实脸样本做额外的随机遮挡增强强迫模型不要只依赖干净的边缘特征。CVND P1 不要求你做一个生产级模型但这个排查思路对后续任何真实场景项目都适用。6. 用可视化验证模型把预测点叠回原图并按残差找最差样本6.1 一行代码把预测点画回图上模型训练完很多人只看 loss 数字就收工了这其实是最容易漏掉问题的一步。人脸关键点检测是视觉任务唯一的硬核验证方式是肉眼看图。我习惯写一个可视化函数把测试图片灰度图显示出来用红色散点叠上模型预测的 68 个点。import matplotlib.pyplot as plt def show_prediction(image_tensor, preds): # image_tensor: (1, 96, 96) 的灰度图张量 # preds: 136 维向量前 68 是 x后 68 是 y已归一化到 [0,1] img image_tensor.squeeze().numpy() preds preds.view(-1, 2).numpy() * 96 # 反归一化到 96x96 像素坐标 plt.imshow(img, cmapgray) plt.scatter(preds[:, 0], preds[:, 1], s2, cred) plt.axis(off) plt.show()看着简单但这个函数能暴露绝大多数训练问题。如果点的顺序性很差、左眼的点跑到右眼位置说明坐标排序出了问题如果点整体轮廓能看出五官但不贴边大概率是数据增强和归一化的坑如果点的形状看起来像一团散射那模型基本没学到空间结构。把训练集出图 20 张、测试集出图 20 张你比看任何 loss 曲线都更有数。6.2 用 68 点顺序和残差排序定位失败原因68 个关键点不是乱序的它遵循一个固定语义顺序0-16 是脸的轮廓17-21 是左眉22-26 是右眉27-35 是鼻子36-41 是左眼42-47 是右眼48-67 是嘴巴。可视化时如果给不同区域染不同颜色就能一眼看出是哪块失灵——大部分情况下嘴部或轮廓点的残差最大因为它们的标注噪声也最大。进阶一点的验证是统计残差把验证集每张图的预测点和标注点做平均欧氏距离按距离从大到小排序输出前 5 张最差样本的图。这些样本往往集中在侧脸、遮挡、表情夸张这几类上。CVND P1 对这个不强制要求但做完这一步你对自己模型的边界心里有底后续调参也不用靠猜。我自己的教训是第一版训练完觉得 loss 很漂亮直到把点画回图上才发现下巴轮廓点全聚在脸里面根本原因是归一化时用了原始图尺寸做分母但标注本身来自 resize 后的图。从那以后我的习惯是先出图再谈指标眼见为实。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网