简介:P1_Facial_Keypoints 是 Udacity 计算机视觉纳米学位(CVND)中的第一个实战项目,面向正在学习深度学习和计算机视觉的开发者。该项目以面部关键点检测为主线,覆盖从数据加载与可视化、卷积神经网络(CNN)的定义与训练,到基于 Haar 级联的完整检测流程,并延伸出情感识别、趣味滤镜等应用场景。资源共 2000 个文件,压缩包约 330.12MB,核心内容分为四个 Jupyter Notebook 实验模块:第一模块讲解关键点数据的加载和可视化,第二模块完成 CNN 的网络架构定义与训练,第三模块将 Haar 级联与训练好的模型结合,实现真实图像中的面部关键点检测,第四模块展示关键点的趣味玩法。文件类型以 jpg 样例图片居多,同时包含 ipynb 实验笔记、xml 级联分类器、csv 标注文件和 py 辅助脚本,目录结构清晰,适合按步骤对照实践。已有 200 人学习下载。配套的完整代码与实现思路可帮助读者快速搭建设计好的面部关键点检测系统,理解图像预处理、模型训练与级联检测的协作方式。
1. 用坐标回归给68个点定位:Facial_Keypoints 到底在训什么模型
人脸关键点检测是很多人脸应用的前置步骤,而 CVND(计算机视觉纳米学位)的第一个项目 P1_Facial_Keypoints,本质上是让你用 CNN 从一张人脸图里回归出 68 个关键点的 (x, y) 坐标。项目本身不复杂,输入是一张灰度人脸图,输出是 136 个浮点数——但正是这个“看起来很简单”的回归任务,把图像预处理、标签归一化、卷积网络设计和训练稳定性全部串在了一起,适合作为第一次完整跑通视觉项目的练手。它能解决的实际问题很直接:给你任意一张人脸照片,模型能标出眼睛、鼻子、嘴巴、下颌线的位置,这也是后续做表情识别、人脸对齐、面具特效的底座。适合的人群是刚学完 CNN 基础、想动手训练一个端到端模型的初学者,也适合想复习回归任务细节的在职工程师。
2. 把数据喂进 CNN 之前:关键点坐标归一化为什么能决定模型能不能收敛
2.1 标签构造:先手工对齐再让网络学残差,别直接扔原始坐标
原始数据集的标签是 CSV 格式,每行对应一张图片,列名类似left_eye_center_x、nose_tip_y这种,未标注的位置填的是 NaN。第一步不是急着写网络,而是把标签整理成模型能吃的张量。
我一般会先把缺失值处理掉:对于训练集,直接丢弃缺失超过一定比例的行;对于测试集,因为提交时不能丢样本,常见做法是用该列均值填充或者用相邻点插值。然后,把 68 个点的 x 和 y 顺序交错排列,变成一个长度为 136 的向量——前 68 个是 x 坐标,后 68 个是 y 坐标,或者按(x0, y0, x1, y1, ...)交替排都行,但要保证和网络输出的排列方式一致。
关键的一步是坐标归一化。假设图片尺寸是 96x96,原始坐标范围是 0 到 96,直接把这个值丢给网络做回归,损失函数会被大的数值主导,模型训练初期极其不稳定。我的做法是把坐标除以图片宽高,压到 0 到 1 区间:
# 假设 keypoints 是形状为 (N, 136) 的原始坐标矩阵,img_size = 96 # 原始坐标顺序:前68列为x,后68列为y keypoints[:, :68] = keypoints[:, :68] / img_size # x 归一化到 [0,1] keypoints[:, 68:] = keypoints[:, 68:] / img_size # y 归一化到 [0,1]这段代码的逻辑是:保持 x 和 y 分开处理,分别除以图片边长。img_size是你在预处理阶段统一缩放后的尺寸,这个值必须和训练时输入图片的尺寸严格一致。归一化之后,网络输出的 136 个值天然就在 0 到 1 范围内,配合 Sigmoid 输出层或者对输出做裁剪,可以有效避免预测出“跑到图像外面”的坐标。
2.2 灰度图、缩放和边框裁剪:PyTorch 里一套可复用的数据预处理管线
Facial_Keypoints 数据集的原始图片尺寸不一,有 96x96 的,也有更大或更小的,而且部分图片人脸只占画面的一小块。如果不做统一缩放,数据加载器根本没法凑成一个 batch。我习惯用一个transforms.Compose把预处理串起来:
from torchvision import transforms from PIL import Image import torch import numpy as np train_transforms = transforms.Compose([ transforms.Resize((96, 96)), # 统一尺寸 transforms.Grayscale(num_output_channels=1), # 转单通道灰度图 transforms.ToTensor(), # HWC -> CHW,像素值缩放到 [0,1] transforms.Normalize(mean=[0.5], std=[0.5]) # 将 [0,1] 映射到 [-1,1] ])这里Resize是唯一会改变坐标映射关系的操作——图像缩放后,关键点坐标必须跟着等比例缩放,所以上面这段代码只在加载图片时用,标签的缩放要在 DataLoader 的__getitem__里单独做,不能依赖 torchvision 的 transform。Normalize的参数mean=0.5, std=0.5是把像素从 [0,1] 映射到 [-1,1],这是很多预训练模型默认的输入分布,自己从零训练时用不用影响不大,但加上之后损失曲线的数值范围会更稳定。
有一个容易忽略的细节:数据集中部分图片带有边框或黑边,直接Resize会把人脸压扁。我的处理方式是在预处理阶段先做一次人脸检测,把 bounding box 裁出来再缩放。常见做法是用 OpenCV 的CascadeClassifier检测人脸区域,然后按检测框裁剪,裁剪后的图片再进入上面的 transform 流程。这样能显著减少背景干扰,模型学到的特征更集中于人脸本身。
2.3 数据增强的操作清单与参数边界:随机旋转、水平翻转和它的坐标陷阱
数据增强是这个小项目里性价比最高的技巧,但因为标签是坐标数值,增强操作必须同步作用在坐标上,不能只对图片做。两条最常用的增强是随机水平翻转和随机小角度旋转。
水平翻转的坐标变换逻辑是:新 x = 1 - 原 x,y 不变。但要注意翻转后左右眼的坐标会互换,如果项目后续要做左右眼相关的分析,需要同步交换标签索引。旋转更麻烦一点,坐标绕图片中心旋转后,可能会超出 0 到 1 的边界,所以旋转角度不宜过大,我一般控制在 ±15 度以内,旋转后对坐标做裁剪,超出边界的点直接丢弃或钳制到边界:
import random import torch def random_flip(image, keypoints, p=0.5): """ image: tensor shape (1, H, W),已归一化 keypoints: tensor shape (136,),顺序为 [x0..x67, y0..y67],值域 [0,1] 返回翻转后的图片和对应坐标 """ if random.random() < p: image = torch.flip(image, dims=[2]) # 水平翻转,W 维度 # 关键点128个点,前半是x,后半是y n = keypoints.shape[0] // 2 x = keypoints[:n] y = keypoints[n:] x = 1.0 - x # 翻转后 x 坐标映射 keypoints = torch.cat([x, y]) return image, keypoints实现里最容易翻车的地方是torch.flip的dims参数。image的 shape 是(C, H, W),水平翻转要翻转宽那一维,所以dims=[2]。如果你写成了dims=[1],图像变成上下翻转,但坐标只做了水平变换,那训练出来的模型预测的点位会全部错位,而且这种错误不会引起训练崩溃,损失会正常下降,直到可视化时才暴露——这是最容易踩的隐形坑。
3. 从全连接到卷积回归:适合 CVND 入门的一版模型结构
3.1 设计思路:全连接打底还是小 CNN?显存和效果怎么权衡
在刚开始做这个项目时,我犯过一个典型的初学者错误:上来就用三层全连接网络,把 96x96 的图拍平成一维向量直接回归。结果训练 Loss 确实在掉,但预测出来的关键点经常成片地偏移——因为全连接层没有空间不变性,眼睛稍微换个位置,网络就认不出来了。
换成卷积网络之后,情况立刻不同。卷积层天然具备局部感受野,能捕捉人脸的局部纹理特征,比如眼睛周围的梯度变化、鼻梁的明暗过渡,这些特征对关键点定位更关键。
但也不是说卷积层越多越好。这个数据集本身不大,训练集大概 2000 多张,测试集几百张,深层网络很容易过拟合。我试过 ResNet50,在训练集上 Loss 能降到非常低,但验证集表现反而更差。所以对于这种规模的数据,一个 4 到 6 层的轻量 CNN 是最务实的方案,参数少、训练快、效果不差。CVND 这个项目的定位本来就是让你掌握“从数据到模型再到评估”的完整流程,而不是比谁的网络更深。
3.2 用 PyTorch 写一个 5 层 CNN 回归器,输出 136 维坐标
下面是一个我在这个项目里反复调整后比较稳定的结构。它由 3 个卷积块和 2 个全连接层构成,输入 96x96 单通道图片,输出 136 维向量:
import torch.nn as nn import torch.nn.functional as F class KeypointCNN(nn.Module): def __init__(self): super().__init__() # 第一个卷积块:1 -> 32 通道,空间尺寸 96 -> 48 self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(32) self.pool1 = nn.MaxPool2d(2) # 第二个卷积块:32 -> 64 通道,空间尺寸 48 -> 24 self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(64) self.pool2 = nn.MaxPool2d(2) # 第三个卷积块:64 -> 128 通道,空间尺寸 24 -> 12 self.conv3 = nn.Conv2d(64, 128, kernel_size=3, padding=1) self.bn3 = nn.BatchNorm2d(128) self.pool3 = nn.MaxPool2d(2) # 展平后的特征维度:128 * 12 * 12 = 18432 self.fc1 = nn.Linear(128 * 12 * 12, 512) self.dropout = nn.Dropout(0.3) self.fc2 = nn.Linear(512, 136) def forward(self, x): x = self.pool1(F.relu(self.bn1(self.conv1(x)))) x = self.pool2(F.relu(self.bn2(self.conv2(x)))) x = self.pool3(F.relu(self.bn3(self.conv3(x)))) x = x.view(x.size(0), -1) x = F.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) # 输出 136 维坐标,不加激活函数 return x结构上有几个关键选择值得说明。第一,每个卷积层后面都接了BatchNorm2d,这个项目的数据量不大,BatchNorm 能显著加速收敛,也能缓解梯度消失——实测不加 BN 时相同 epoch 下 Loss 高出不少。第二,全连接层之间加了Dropout(0.3),这是针对小数据集过拟合的必要手段,也是 CVND 项目评估中常被考察的知识点。第三,最后的输出层没有加 Sigmoid 或 Tanh,而是直接线性输出——因为我们在训练时用归一化坐标配合损失函数约束,推理时再做一次 clamp 把输出裁剪到 [0,1],如果输出层加了 Sigmoid,虽然值域天然落在 [0,1],但梯度在饱和区会消失,影响训练后期精度。
全连接层的输入维度是128 * 12 * 12,这个数字是怎么来的?输入 96x96,经过三次MaxPool2d(2),每次尺寸减半:96 -> 48 -> 24 -> 12。如果你的输入尺寸变了,这里必须跟着重算,否则view那一步会直接报错。
3.3 损失函数与优化器:SmoothL1Loss 比 MSE 好在哪
关键点检测是个回归任务,最常见的损失选择是 MSE(均方误差),但我在实践里更推荐SmoothL1Loss,也就是 Huber Loss。它在误差绝对值小于 1 时表现为平方误差,大于 1 时表现为线性误差,这样既保留了 MSE 在收敛末期的精细梯度,又避免了离群点对梯度的过度主导——数据集中偶尔会有标注错位的样本,MSE 对这种离群样本的惩罚是平方级别的,会让模型为了少数坏样本牺牲整体精度。
import torch.optim as optim model = KeypointCNN() criterion = nn.SmoothL1Loss() optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-5) # 训练一步的示例代码 def train_step(batch_images, batch_keypoints): model.train() optimizer.zero_grad() outputs = model(batch_images) # 预测形状 (B, 136) loss = criterion(outputs, batch_keypoints) loss.backward() optimizer.step() return loss.item()优化器我选 Adam 而不是 SGD。原因很简单:这个任务没有特别复杂的损失地形,Adam 默认参数就能稳定收敛,省去手动调学习率和动量的时间。weight_decay=1e-5加了一点 L2 正则,和 Dropout 配合,能进一步压住过拟合。如果你的模型训练到后期 Loss 不再下降,可以尝试把lr从 0.001 降到 0.0001,或者在训练 30 个 epoch 后使用学习率衰减。
4. 训练起来才明白的细节:学习率、批大小与验证集划分的取舍
4.1 训练循环的最小骨架:每 5 轮存一次 checkpoint
写训练循环不要一上来就追求复杂,先跑通一个最小闭环,再逐步加功能。我通常把训练和验证分成两个函数,代码结构清晰,排错也方便。训练集和验证集的划分在数据加载阶段完成——我一般按 8:2 切分,而且固定随机种子,保证每次实验的可复现性。
import torch from torch.utils.data import DataLoader, random_split # dataset 是自定义 Dataset 实例 train_size = int(0.8 * len(dataset)) val_size = len(dataset) - train_size train_dataset, val_dataset = random_split( dataset, [train_size, val_size], generator=torch.Generator().manual_seed(42) ) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False) best_val_loss = float('inf') for epoch in range(50): # 训练阶段 model.train() running_loss = 0.0 for images, keypoints in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, keypoints) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) # 验证阶段 model.eval() val_loss = 0.0 with torch.no_grad(): for images, keypoints in val_loader: outputs = model(images) loss = criterion(outputs, keypoints) val_loss += loss.item() * images.size(0) print(f"Epoch {epoch+1}: train_loss={running_loss/len(train_dataset):.4f}, " f"val_loss={val_loss/len(val_dataset):.4f}") # 保存验证集上最好的模型 if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), 'best_keypoint_model.pth')这段代码里的关键习惯是:验证阶段务必要加model.eval()和torch.no_grad()。model.eval()会关闭 Dropout 和 BatchNorm 的训练模式——如果不加,验证时 Dropout 仍然随机丢神经元,验证 Loss 会忽高忽低,让你误判模型的真实水平。torch.no_grad()关闭梯度计算,能省一半以上的显存和计算时间,验证集不大时可能感觉不明显,但这是一个必须养成的习惯。
4.2 学习率衰减和早停:两个让训练稳定下来的开关
训练过程中的一个典型现象是:前 20 个 epoch 损失降得很快,之后进入平台期,Loss 在小范围内震荡。这时候继续用固定学习率,模型很难再精调坐标到亚像素精度,而关键点检测恰恰需要这种精度。解决办法是引入学习率衰减:每过一定轮数,把学习率乘以一个衰减系数。
scheduler = optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=5, verbose=True ) # 在每个 epoch 验证结束后调用: # scheduler.step(val_loss)ReduceLROnPlateau的工作方式是:当验证 Loss 连续patience个 epoch 都没有下降时,就把学习率乘以factor。我设置factor=0.5, patience=5,意思是连续 5 轮不进步,学习率减半。这个策略比固定轮数衰减(比如每 30 轮降一次)更稳,因为它是看验证集的实际表现来决定是否降学习率。
早停是另一个实用的控制开关:当验证 Loss 连续多个 epoch 不降反升,说明模型开始过拟合了,这时候应该停止训练并恢复历史最优模型。简单实现就是记录best_val_loss,当连续 10 个 epoch 没有刷新时直接break退出循环,然后加载之前保存的最优权重。很多初学者会把 epoch 数设得很大,比如 200,然后完全不看训练曲线——这是在浪费时间,早停能帮你省下大量无意义的训练轮次。
4.3 评估指标别只看 Loss:用 NME 衡量关键点误差才是项目评审关心的
Loss 值本身是个抽象的数字,不同的损失函数、不同的归一化方式都会改变它的量纲,你很难告诉别人“我的 Loss 是 0.003”到底代表什么水平。关键点检测领域的通用评估指标是 NME(Normalized Mean Error),也就是归一化平均误差。
NME 的计算方法是:先求每个关键点的预测值和真实值的欧氏距离,再取平均,最后除以一个人脸尺度因子。这个尺度因子通常取两眼之间的距离(inter-ocular distance)或两眼中心到下巴尖的距离。用尺度因子归一化后,NME 就可以跨不同分辨率、不同人脸大小进行比较。
def compute_nme(predictions, ground_truth, interocular_dist): """ predictions: (N, 68, 2) 预测关键点 ground_truth: (N, 68, 2) 真实关键点 interocular_dist: (N,) 每张图的两眼距离 """ # 逐图计算平均欧氏距离 diff = np.sqrt(((predictions - ground_truth) ** 2).sum(-1)) # (N, 68) mean_error = diff.mean(-1) # 每张图的平均误差 (N,) nme = mean_error / interocular_dist return nme.mean() # 返回整个测试集的平均 NME两眼距离的计算需要先确定哪两个点是左眼和右眼中心。在 68 点标注中,一般取left_eye_center(索引 36 到 41 的均值)和right_eye_center(索引 42 到 47 的均值)。如果你的预测结果里没有单独的眼睛中心点,可以用眼角点近似。NME 的值通常在 0.03 到 0.08 之间,小于 0.05 就算不错的水平,这个数字才是项目评审时能一眼看懂你做得好不好的指标。
5. Facial_Keypoints 的常见问题排查:归一化、NaN 和过拟合的 4 条踩坑记录
5.1 Loss 停在 0.01 不再下降,但预测点全挤在图像中心
这是一个非常隐蔽且容易让人抓狂的问题。表面上看训练一切正常,Loss 按照正常速度下降并最终稳定在一个看似不错的数值,但你把预测点画到原图上,发现所有关键点都堆在图片中心区域,形状像一团散不开的线团——并没有真实贴合人脸轮廓。
原因是:如果输出的 136 维向量没有做范围限制,网络会倾向输出一个“安全”的中间值。这个中间值在归一化坐标里就是 0.5 左右。SmoothL1Loss 对数值接近 0.5 的预测惩罚很小,因为监督信号的坐标也大致分布在 0.3 到 0.7 之间,取中值能保证一个不算差的基础 Loss,但完全学不到精确位置。
解决方法是检查两处:一是输出层是否缺少约束,二是标签的真实分布是否过于集中。如果你的标签经过归一化后确实大部分落在 0.3 到 0.7 之间,那说明网络很可能只学到了均值。可以尝试在输出层后加torch.sigmoid,强制输出落在 (0,1) 区间,让网络必须“选择”每个点的具体位置,而不是停在中间值上“和稀泥”。我实测加 Sigmoid 后,NME 能下降 10% 到 15%。注意如果加了 Sigmoid,前面提到的线性输出和clamp方案就要替换掉,两者不能同时使用。
5.2 验证 Loss 下降但测试图预测错位:数据增强的翻转逻辑写反了
这个坑专门坑那些“训练曲线一切正常”的人。Loss 在训练集和验证集上都稳步下降,说明网络确实学到了某种映射,但当你想把模型应用到手头一张新图上时,预测点像被镜子反射了一样——左眼的点跑到了右眼位置。
原因基本锁定在随机水平翻转的实现上。翻转图片后,坐标必须同步变换,但我们前面说过,翻转后x变成1 - x,如果只翻转图像而忘记转换坐标,模型会学到自相矛盾的样本,最后学出一个“翻转纠正”的效果——正常图片输入时,它会按翻转后的坐标输出,看起来就是左右颠倒的。
解决方法是检查你的random_flip函数里,是否确实执行了x = 1.0 - x这一步,以及翻转维度是否正确。一个快速自查方法是:固定随机种子,打印同一张图翻转前后的图片和关键点,手动画图对比,看翻转后的关键点是否仍然贴在人脸的正确位置。
5.3 训练到一半出现 NaN:输入图片里有纯黑或异常像素
训练初期一切正常,某几个 epoch 之后 Loss 突然变成nan,然后永远恢复不回来——这是最让人沮丧的故障之一。原因通常是输入数据里有异常值:部分图片是纯黑图、全白图,或者某个像素点的值极端到让梯度爆炸。
更隐蔽的原因出在Normalize的操作上。如果输入图片是单通道,mean和std必须是单元素张量或标量。但如果你在加载时用了错误的数据类型——比如图片矩阵是uint8而ToTensor没有正确转换,像素值会超出预期范围,经过几层卷积后数值就失控了。
解决方式是三步排查:第一步,在 Dataset 的__getitem__里检查图片像素值的范围,加入断言确保所有值都在正负 3 以内;第二步,把学习率调低一个数量级,排除梯度爆炸的可能;第三步,给模型梯度加裁剪,torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0),这是应对偶发异常输入的有效兜底。另外要注意,全黑图片本身对网络没有惩罚意义——所有关键点可能都是未知的,加载时应该直接跳过。
5.4 模型对侧脸和闭眼效果差:数据集本身分布不均,注意采样
如果你在测试时发现,正面脸的关键点预测很准,但一到侧脸或闭眼的照片就一塌糊涂,这大概率不是模型结构的问题,而是数据集分布不均。Facial_Keypoints 数据集中,绝大多数训练样本都是正面、睁眼、光照均匀的,侧脸样本占比可能只有 5% 到 10%,模型根本没机会学到这些罕见姿态。
应对方法有几个:一是检查标签中特征点的可见性,数据集里侧脸的某些点(比如另一侧的眼睛)根本没有标注,这些样本本身就残缺;二是在训练时按头姿分布做重采样,让罕见姿态的样本在每次 epoch 中出现的概率更高;三是做姿态相关的增强,比如增加更大角度的旋转、添加模拟侧脸的光照变化。这里要特别提醒——不要指望单纯堆模型容量来解决分布不均,小数据集上加深网络只会让过拟合更严重,先把采样捋顺了再说效果。
6. 把 68 点预测接进真实流程:从关键点到人脸对齐的进阶验证
6.1 用测试集可视化验证:把预测坐标画回原图是第一步验收
很多人把模型训完、Loss 数字好看就交差了,这是不对的。我强烈建议在评估阶段加一个可视化脚本:从测试集随机抽 8 到 12 张图片,把预测的关键点按顺序连线画到原图上——眼睛连一圈、嘴巴连一圈、下巴轮廓连一条线。这个简单的可视化能让你一眼看出模型的真实水平,也能暴露很多数值指标看不出来的问题。
画图时要注意坐标反归一化:模型输出的是 0 到 1 之间的归一化坐标,画图前要乘以图片原始尺寸,否则点会挤在左上角的小区域内。推荐用matplotlib的scatter画点、plot连线,点的颜色按部位区分,比如眼睛用绿色、鼻子用红色、嘴巴用蓝色,这样一眼就能看出五官是否对齐。
6.2 从坐标回归升级到热图回归:换一种监督信号,误差能再降一截
如果你做完关键点回归还有余力,值得尝试的方向是把输出从坐标数值换成热图(heatmap)。所谓热图回归,就是让网络输出 68 张二维响应图,每张图上只有一个高斯峰,峰值位置对应关键点的坐标。训练时,把每个标注点变成一个以该点为中心的高斯分布,网络的任务就是预测这个分布。
热图回归的优点是:网络的空间分辨率直接和特征图挂钩,不会像全连接层那样丢失位置信息;而且热图的监督信号是密集的,每个像素都能提供梯度,训练更容易收敛。缺点是显存开销增大、输出需要解码(找峰值位置),实现复杂度明显上升。但经验上,相同数据下热图回归的 NME 能比直接回归坐标低 20% 到 30%,这个提升在 CVND 项目中足够让评审眼前一亮。
6.3 把关键点接进实际人脸对齐流程:先看这四件事
最后说一个实践中的教训。做完 Facial_Keypoints 这个项目后,我尝试把它接到一个实时人脸特效应用里,结果发现训练时没注意的细节全在工程化阶段跳出来了。第一,模型推理速度——5 层 CNN 在 CPU 上跑一帧要 20 到 30 毫秒,看起来不慢,但如果做人脸检测加关键点检测的串行流程,整体帧率就掉下来了,需要用小模型或者量化加速。第二,关键点的时序稳定性——单帧预测有抖动,连续视频帧上点会轻微跳动,需要加时序平滑滤波。第三,输入分布差异——训练数据是正脸证件照风格,但摄像头画面的透视畸变和光照完全不同,模型效果断崖式下跌,需要额外采集数据做微调。第四,输出坐标的坐标系转换——检测框在原图上的坐标和模型输入尺寸之间要做逆变换,这个映射如果算错,全部点位都会偏。
这个项目本身就是一份很好的入门答卷——它把数据预处理、CNN 回归、训练调参、评估可视化这一条链路完整走了一遍。如果你能把训练好的模型接到一个新的数据集上,重新微调、重新评估,你就真正掌握了人脸关键点检测的通用方法论,而不只是会跑通一个 CVND 作业。我自己在这上面踩过的最大教训就是:别急着堆网络结构,先把数据分布和评估指标吃透,后者往往决定了你调参时的判断力。希望帮到你。
本文还有配套的精品资源,点击获取