☰
68点人脸关键点检测:P1_Facial_Keypoints 基线实战
2026/11/2 6:54:51 网站建设 项目流程

简介:面部关键点检测项目的完整实践材料,面向计算机视觉初学者与具备一定深度学习基础的学习者,围绕眼睛、鼻尖与嘴角等关键点的定位任务,覆盖数据加载与可视化、CNN 网络定义与训练、Haar 级联结合 CNN 的完整检测管线,并可延伸到人脸跟踪、姿态识别与表情分析等场景。压缩包约 330MB,共 2000 个文件,其中大量 jpg 人脸图像与关键点标注构成训练和测试基础,9 个 ipynb 笔记本对应四个阶段的分步实验,另含 py 网络脚本、csv 关键点标注表、xml 检测器配置以及少量 png、txt 说明文档,目录清晰,便于按模块复现。已有 201 人学习。读者可借此走通数据可视化、CNN 回归预测关键点、级联检测串联和趣味滤镜实现的全链路,并利用 checkpoint 笔记本对照排查训练过程,适合作为 CVND 课程的配套实操参考。

1. 面部关键点不是“画点”:P1_Facial_Keypoints 的输入输出先对齐

第一次打开 P1_Facial_Keypoints 的 Jupyter Notebook,看到一张 96×96 的灰度脸和 68 个关键点坐标,很容易以为这只是一个画点练习。真正跑起来才发现,难点不在画图,而在把“人脸框、关键点顺序、坐标归一化、通道维度”这四件事对齐。这个资源包适合刚接触人脸关键点检测、想用一份能跑通的 Notebook 建立完整基线的人:数据怎么读、网络怎么搭、训练怎么监控、预测怎么画回原图,都有现成路径。它不追求 SOTA,价值在于把 68 点回归的整条管线拆开给你看;先把基线跑稳,再去换热图或更高分辨率,才不容易在坐标错位里打转。

2. 把 Jupyter Notebook 跑通:环境、数据与 68 点标注的四步校验

2.1 目录与依赖:先让 Notebook 找到数据

拿到 P1_Facial_Keypoints 资源包,第一件事不是点 Run All,而是确认 Notebook 的当前工作目录。Jupyter 启动后,Path.cwd()返回的是 notebook 文件所在目录,不一定是仓库根目录;很多“文件找不到”的报错,都是因为data/training相对路径少了一层。

我一般先看目录职责,再决定把哪一层作为工作目录:

路径作用先检查什么
data/training/训练图像图像文件名是否与 CSV 第一列完全一致
data/test/推理图像是否缺少对应标注,评估时只能可视化
data/training_frames_keypoints.csv图像名 + 136 列坐标列顺序是 x,y 交错还是分块
notebooks/数据探索、训练、可视化内核当前目录是否指向仓库根
src/可复用的数据加载、模型、训练脚本是否与 Notebook 里的代码重复

依赖不用一次装太多,先把最小闭环跑起来:

python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install torch torchvision numpy pandas matplotlib pillow opencv-python jupyter jupyter notebook

这段命令做三件事:建独立环境、装 Notebook 运行所需的基础包、启动 Jupyter。torch按本机情况装 CPU 版或对应加速版即可,第一版关键点回归在 CPU 上也能跑;opencv-python用于人脸检测和绘图,如果只跑模型、不做 Haar 检测,也可以先不装。注意不要同时装opencv-python和opencv-python-headless,两个包会争同一套动态库,导入时容易报奇怪的符号错误。Python 版本我一般选 3.10 或 3.11,太新的版本偶尔会遇到某个轮子还没跟上,这不是资源包的问题,换环境比硬修快。

提示:Notebook 里先跑import os; print(os.getcwd()),再用Path.cwd().parent或显式os.chdir()切到仓库根。不要靠“感觉”猜相对路径。

2.2 读 CSV 和图像:68 点标注的三种错位

P1_Facial_Keypoints 的标注通常是一行一张图:第一列是图像名,后面 136 列是 68 个点的 x、y 坐标。麻烦在于不同导出方式会把列排成x1,y1,x2,y2...或先所有 x 再所有 y;不先打印列名,直接reshape(-1,2),点就会像被揉过一样散开。

先做一次最小检查:

from pathlib import Path import numpy as np import pandas as pd from PIL import Image DATA_DIR = Path("data") img_dir = DATA_DIR / "training" csv_path = DATA_DIR / "training_frames_keypoints.csv" df = pd.read_csv(csv_path) print(df.shape) print(df.columns[:8].tolist()) image_col = df.columns[0] # 常见是第一列图像名,按实际列名改 row = df.iloc[0] img = np.asarray(Image.open(img_dir / row[image_col]).convert("L")) pts = row.drop(labels=[image_col]).to_numpy(dtype=np.float32).reshape(-1, 2) print("image:", img.shape, img.dtype, img.min(), img.max()) print("points:", pts.shape, pts.min(axis=0), pts.max(axis=0))

convert("L")把图像统一成单通道灰度,后面网络第一层才能固定写in_channels=1;dtype=np.float32是为了后续除以 255 或做归一化时不丢精度;reshape(-1, 2)的前提是列顺序确实是 x,y 交错,打印pts[:3]和原图叠加一次就能确认。坐标范围也要看:如果最大值接近 96,说明是像素坐标;如果最大值在 1 附近,说明已经归一化;如果最大值远大于 96,可能是原图尺寸坐标,喂进 96×96 网络前必须按比例缩放,否则点会整体跑到图外。

这里还有一个容易混的地方:imshow画出来的横轴是 x、纵轴是 y,而 NumPy 数组索引是行、列。scatter(pts[:,0], pts[:,1])对应 x,y 是对的,但如果你写成pts[:,1], pts[:,0],脸就会转 90 度。这个错不报错,只是“看起来怪”,靠可视化十张图比盯 loss 更快发现。

2.3 跑通第一个基线:Haar 检测 + 回归网络的最小闭环

不要一上来训 100 轮。先用一个小 Dataset 把“读图 → 归一化 → 过网络 → 输出 136 维”跑通,确认输入输出 shape 对得上,再谈精度。下面这个 Dataset 假设图像已经是 96×96,关键点按图像宽高归一化到[-1, 1],和模型输出对齐:

from pathlib import Path import numpy as np import torch from torch.utils.data import Dataset from PIL import Image class FaceKeypointDataset(Dataset): def __init__(self, df, img_dir, image_col="image_name", image_size=96): self.df = df.reset_index(drop=True) self.img_dir = Path(img_dir) self.image_col = image_col self.image_size = image_size def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img_path = self.img_dir / row[self.image_col] img = Image.open(img_path).convert("L").resize( (self.image_size, self.image_size) ) img = np.asarray(img, dtype=np.float32) / 255.0 pts = row.drop(labels=[self.image_col]).to_numpy(dtype=np.float32) pts = pts.reshape(-1, 2) pts = pts / (self.image_size - 1) # 先到 [0,1] pts = pts * 2.0 - 1.0 # 再到 [-1,1] return torch.from_numpy(img).unsqueeze(0), torch.from_numpy(pts.reshape(-1))

unsqueeze(0)把H×W变成1×H×W,正好对应卷积层单通道输入;pts.reshape(-1)把 68×2 拉成 136 维,和网络输出一一对应。归一化用image_size - 1而不是image_size,是因为像素坐标的最大索引是 95,不是 96;这个 1 像素差在 96 图上不明显,换成 256 图就会变成肉眼可见的整体偏移。image_size一旦改动,网络全连接层的维度也要跟着改,不能只改 Dataset。

Haar 检测不是训练必需,但推理单张图时很有用:先框出人脸、裁出来、缩到 96×96,再送进关键点网络。常见做法是用 OpenCV 自带的正脸级联做粗定位,框的边留 20% 余量,避免下巴和额头被切掉。Haar 在侧脸、遮挡、暗光下会漏检,所以它只适合做 demo 和预处理,不适合把检测结果当标注。

2.4 固定随机种子与训练/验证切分

训练前先切分,不要等 loss 不降了才想起来验证集。关键点数据里同一个人可能有多张图,按行随机切会让同一个人的不同角度同时出现在训练和验证里,验证 RMSE 会虚低。我一般按图像名前缀或人物 ID 分组切,没有 ID 时至少固定random_state,保证每次实验可比。

import random import numpy as np import torch from sklearn.model_selection import train_test_split seed = 42 random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) train_df, val_df = train_test_split( df, test_size=0.15, random_state=seed, shuffle=True ) print(len(train_df), len(val_df))

test_size=0.15是 96×96 小数据上比较稳的比例,验证集太小会让 RMSE 抖动很大;shuffle=True在按行切时可用,但如果有人物 ID,应该用分组切分。torch.manual_seed只固定 PyTorch,DataLoader 的num_workers>0还会引入额外随机性,所以第一版先设num_workers=0,在 Windows 和 Jupyter 里最稳;Linux 上再开到 2 或 4。batch_size从 32 或 64 起步,显存放不下就减半,不要一开始上 256,小图大 batch 容易让 BatchNorm 的统计量偏。

3. 从卷积特征到 136 维坐标:关键点回归网络与训练循环

3.1 为什么第一版用轻量 CNN 而不是 ResNet

P1_Facial_Keypoints 的输入是 96×96 灰度图,输出是 136 个连续坐标,数据规模和分辨率都不算大。直接上 ResNet 有两个现实问题:预训练权重通常是三通道 RGB,改成单通道后第一层卷积的预训练特征基本废掉;模型参数量大,小数据上更容易过拟合,训练轮次也更难调。第一版用四层卷积加一个回归头,目的是先把“坐标回归”这件事跑通,而不是冲榜。

损失函数我一般先用 SmoothL1,而不是纯 MSE。关键点里偶尔会有标注偏掉的离群点,MSE 会把这些点的误差平方放大,训练后期 loss 被少数样本拖着走;SmoothL1 在误差大时接近线性,稳一些。优化器用 Adam,学习率1e-3,权重衰减1e-4,训练 30 到 50 轮,验证 RMSE 连续 8 轮不降就停。这些不是固定公式,而是小数据回归任务里比较省事的起点。

3.2 网络结构与输出维度

下面这个结构把 96×96 逐层降到 6×6,再展平回归到 136 维。每层卷积后接 BatchNorm 和 ReLU,目的是让不同尺度的特征都能稳定传下去:

import torch.nn as nn class KeypointNet(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), # 96 -> 48 nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), # 48 -> 24 nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), # 24 -> 12 nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(), nn.MaxPool2d(2), # 12 -> 6 ) self.head = nn.Sequential( nn.Flatten(), nn.Linear(256 * 6 * 6, 512), nn.ReLU(), nn.Dropout(0.3), nn.Linear(512, 136) # 68 点 * 2 ) def forward(self, x): return self.head(self.features(x))

256 * 6 * 6是展平后的特征维度:输入 96 经过四次池化变成 6,通道数是 256,所以是 256×6×6。输入尺寸一改,这个数就要重算,否则Linear会报 shape 不匹配。Dropout(0.3)放在全连接前,是为了压一压小数据上的过拟合;如果 batch 很小(比如 8),BatchNorm 的统计量会抖,可以换成 GroupNorm,但第一版先别换,先把基线跑出来。输出层不加tanh,因为坐标已经归一化到[-1,1],让线性层自己学,推理时再按需clamp,比强加饱和激活更好调。

3.3 训练循环:损失、学习率与早停

训练循环里最容易漏的是梯度裁剪和验证集反归一化。归一化坐标下的 RMSE 看起来很小,乘回像素才是能解释的数:

import torch from torch.optim.lr_scheduler import ReduceLROnPlateau device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = KeypointNet().to(device) criterion = nn.SmoothL1Loss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = ReduceLROnPlateau(optimizer, mode="min", factor=0.5, patience=3) def train_one_epoch(model, loader, optimizer, criterion): model.train() total_loss = 0.0 for imgs, pts in loader: imgs, pts = imgs.to(device), pts.to(device) pred = model(imgs) loss = criterion(pred, pts) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() * imgs.size(0) return total_loss / len(loader.dataset) @torch.no_grad() def evaluate(model, loader, image_size=96): model.eval() se = 0.0 n = 0 for imgs, pts in loader: imgs, pts = imgs.to(device), pts.to(device) pred = model(imgs) pred_px = (pred + 1.0) * (image_size - 1) / 2.0 true_px = (pts + 1.0) * (image_size - 1) / 2.0 se += torch.sum((pred_px - true_px) ** 2).item() n += pred_px.numel() return (se / n) ** 0.5

clip_grad_norm_(..., max_norm=5.0)是防止个别 batch 梯度爆炸,尤其是关键点标注里有异常值时;ReduceLROnPlateau在验证 RMSE 停涨时把学习率减半,patience=3表示连续 3 轮没改善就动学习率,比手动调省事。evaluate里先把[-1,1]反变换回像素,再算 RMSE,这样返回的数就是“平均每个坐标差几像素”,不会出现 0.02 这种没法解释的值。注意这里的 RMSE 是把 136 个坐标混在一起算的,后面第 4 章会拆成每点欧氏距离。

3.4 数据增强:水平翻转必须同步交换关键点

人脸关键点做水平翻转,不能只用torchvision的RandomHorizontalFlip,因为它只翻图像,不会交换左右眼、左右嘴角的索引。翻完图不换点,模型会学到“左眼在右边”的错误对应,训练 loss 可能还降,但可视化一看就翻车。

常见做法是自己写翻转,先翻图像,再把 x 坐标取反,最后按左右点对重排:

import numpy as np FLIP_PAIRS = [ (0, 16), (1, 15), (2, 14), (3, 13), (4, 12), (5, 11), (6, 10), (7, 9), (8, 8), (17, 26), (18, 25), (19, 24), (20, 23), (21, 22), (36, 45), (37, 44), (38, 43), (39, 42), (40, 47), (41, 46), (48, 54), (49, 53), (50, 52), (51, 51), (55, 59), (56, 58), (57, 57), (60, 64), (61, 63), (62, 62), (65, 67), (66, 66) ] def build_flip_index(num_points=68): idx = np.arange(num_points) for a, b in FLIP_PAIRS: idx[a], idx[b] = b, a return idx FLIP_INDEX = build_flip_index() def horizontal_flip(img, pts): # img: 1xHxW tensor, pts: 136 维,归一化到 [-1,1] img = torch.flip(img, dims=[-1]) pts = pts.reshape(-1, 2).clone() pts[:, 0] = -pts[:, 0] # 归一化坐标下翻转 x pts = pts[FLIP_INDEX] # 交换左右点索引 return img, pts.reshape(-1)

FLIP_PAIRS是按 68 点常见顺序写的左右配对,实际数据如果点序不同,必须按标注文档重排,不能照抄。pts[:,0] = -pts[:,0]只在[-1,1]归一化下成立;如果坐标还在[0, W-1],应该写W - 1 - x。FLIP_INDEX先建好,每次增强直接索引,比在__getitem__里循环交换快。翻转概率我一般设 0.5,旋转和缩放先不加,等基线 RMSE 稳定后再试,否则出错时很难分清是增强还是模型的问题。

4. 评估不只看 loss:RMSE、可视化与失败样本归因

4.1 RMSE 怎么算才和像素对得上

训练日志里的 loss 是归一化坐标下的 SmoothL1,数值小不代表画出来准。评估至少要给两个数:整体坐标 RMSE 和每点平均欧氏距离。前者把所有 x、y 混在一起,后者更接近“一个点平均偏几像素”的直觉。

import torch def coord_rmse(pred, target, image_size=96): pred_px = (pred + 1.0) * (image_size - 1) / 2.0 true_px = (target + 1.0) * (image_size - 1) / 2.0 return torch.sqrt(torch.mean((pred_px - true_px) ** 2)).item() def mean_point_distance(pred, target, image_size=96): pred_px = (pred + 1.0) * (image_size - 1) / 2.0 true_px = (target + 1.0) * (image_size - 1) / 2.0 d = torch.sqrt(torch.sum((pred_px - true_px) ** 2, dim=-1)) return d.mean().item()

coord_rmse对 x 和 y 一视同仁,适合看整体收敛;mean_point_distance先算每个点的欧氏距离再平均,更贴近“眼睛偏 2 像素、下巴偏 6 像素”这种业务描述。96×96 图上,整体 RMSE 到 5 像素以内可以认为基线可用,3 像素以内算比较稳;但眼周和嘴角本来就更难,拆开看比一个总数有用。不要拿这个阈值去套 256×256 的图,分辨率一变,像素误差的绝对值也会变。

指标计算方式用途
坐标 RMSE136 个坐标差平方均值开方看整体收敛和实验对比
每点平均距离68 个点欧氏距离取平均看单点偏移量级
眼周平均距离取 36–47 号点单独算判断五官细节是否可用
轮廓平均距离取 0–16 号点单独算判断脸型边缘是否被裁掉

4.2 可视化验收:把预测点画回原图

数值达标不代表图能看。我一般固定抽 8 张验证图,把真值画绿色、预测画红色,叠在同一张灰度图上。不要随机抽完就换,固定一组样本才能看出每轮改动到底修了哪个问题。

import matplotlib.pyplot as plt import numpy as np def denorm_pts(pts, image_size=96): pts = pts.reshape(-1, 2) return (pts + 1.0) * (image_size - 1) / 2.0 model.eval() fig, axes = plt.subplots(2, 4, figsize=(12, 6)) indices = np.random.RandomState(7).choice(len(val_ds), 8, replace=False) for ax, idx in zip(axes.ravel(), indices): img, pts = val_ds[idx] with torch.no_grad(): pred = model(img.unsqueeze(0).to(device)).cpu().numpy()[0] ax.imshow(img.squeeze(0), cmap="gray") ax.scatter(*denorm_pts(pts.numpy()).T, s=8, c="lime", label="gt") ax.scatter(*denorm_pts(pred).T, s=8, c="red", marker="x", label="pred") ax.set_xticks([]); ax.set_yticks([]) axes[0, 0].legend(loc="upper right", fontsize=6) plt.tight_layout() plt.show()

RandomState(7)是为了每次抽同一组图,方便对比;s=8是点大小,96 图上再大就会糊成一片;marker="x"让预测点和真值点即使重叠也能看出偏差方向。如果预测点整体缩在中心,先查反归一化有没有漏乘(image_size-1)/2;如果点整体偏上或偏左,查resize时有没有保持宽高比;如果只有某几个点乱飞,查标注本身和翻转索引。

4.3 失败样本归因:五类典型错法

可视化不是为了“看个效果”,而是为了把错误归类。下面这张表是我在 P1_Facial_Keypoints 上最常遇到的五类现象,按优先级排查比盲调学习率快:

现象优先检查处理
所有点整体偏移反归一化公式、resize比例统一用同一个denorm函数,图像和点同步缩放
左右眼、嘴角互换水平翻转是否交换索引用FLIP_PAIRS重排,翻图必翻点
轮廓点散、下巴点跑出脸人脸框是否裁掉边缘、标注是否越界检测框留 20% 余量,过滤越界标注
眼周点糊成一团96×96 分辨率不够、回归头容量不足先换热图或提高输入到 128,再考虑加层
单张图全错但其他正常该图人脸角度、遮挡、灰度异常单独拿出来看,不要用一张图否定整个模型

这张表的价值在于“先定位再改”。比如整体偏移和左右互换看起来都是“点不准”,但前者是坐标变换问题,后者是索引问题,调学习率没用。把失败样本截图存到debug/下,按现象命名,下一轮改完直接对比,比凭记忆靠谱。

4.4 用表格记录每次实验

小数据项目最怕“改完忘了上次是什么配置”。我一般用一个固定表格记实验,至少保留输入尺寸、模型、学习率、batch、增强、验证 RMSE 和一句备注。下面是一个空表样式,填几次就能看出哪类改动真正有效:

实验输入模型lrbatch增强val RMSE(px)备注
E0196 灰度4 层 CNN1e-364无基线
E0296 灰度4 层 CNN1e-364水平翻转看左右点是否更稳
E0396 灰度4 层 CNN5e-432翻转batch 变小后 BN 是否抖
E04128 灰度4 层 CNN1e-364翻转全连接维度要重算

记录时把“验证集 RMSE”和“眼周平均距离”分开写,不然整体指标被轮廓点拉平,容易误判。实验编号不要复用,失败配置也留着,后面调参时能少走回头路。

5. 避坑与排查:Jupyter 里最容易翻车的五件事

5.1 现象:训练 loss 正常,画出来的点整体偏移

现象很典型:训练 loss 从 0.2 降到 0.02,验证 RMSE 也还行,但把预测点画回原图,所有点整体往左上偏一截。原因通常不是模型没学会,而是训练和推理用了两套坐标变换:训练时pts / (image_size - 1)再*2-1,推理时直接pred * image_size,少减了 1,或者反归一化时用了image_size而不是image_size - 1。96 图上差 1 像素不明显,但加上resize的宽高比变化,就会变成系统性偏移。

解决方式是把归一化和反归一化写成一对函数,训练、验证、可视化全部调用同一份,不允许在 Notebook 里手写第二遍。图像和关键点必须同步缩放:先按原图宽高算缩放比例,再同时作用到图像和坐标上,不要先resize图像再拿原始坐标去除以新尺寸。每次改完变换,先抽 10 张图叠一次真值,真值对齐了再训模型;真值都对不齐,模型训得再好也是错的。

5.2 现象:左右眼关键点互换,loss 却还在降

水平翻转增强之后,模型把左眼特征学成了右眼坐标,训练 loss 可能还是降的,因为网络在拟合翻转后的错误对应;但验证集上眼周点会明显交叉,画出来像“对眼”。原因就一个:翻了图,没有按左右点对交换索引。torchvision的RandomHorizontalFlip只处理图像,不知道 68 点的语义顺序。

解决方式是维护一份FLIP_PAIRS,每次翻转后先取反 x,再按索引重排。注意坐标系:[-1,1]下是-x,[0,W-1]下是W-1-x,两种写法不能混。改完先拿一张图做单元测试:翻转两次应该回到原图,翻转一次后左右眼位置应该交换。这个测试不通过,不要开训练,否则后面所有指标都不可信。

5.3 现象:Notebook 越跑越慢,内存一路涨

Jupyter 内核不会因为单元格跑完就释放所有变量。反复执行训练单元格,旧的model、optimizer、train_loader还挂在内核里,GPU 显存尤其明显;再加上每轮都往列表里塞 loss 和图片,内存会一路涨。现象是前几轮正常,后面越来越慢,最后CUDA out of memory或者内核直接挂掉。

解决方式分两步:训练循环里不要保留计算图,验证和可视化统一包torch.no_grad();换实验前显式清理:

import gc import torch del model, optimizer, train_loader, val_loader gc.collect() if torch.cuda.is_available(): torch.cuda.empty_cache()

更稳的习惯是“一个实验一个内核”,改模型结构或输入尺寸时直接 Restart Kernel 再 Run All,不要在一个内核里反复覆盖同名变量。Jupyter 的方便之处是交互,坑也在这里:你以为重置了,其实旧变量还在。

5.4 现象:验证 RMSE 突然变成 NaN

训练前几轮正常,某一轮开始 loss 变nan,接着所有指标都是nan。常见原因有三个:CSV 里有空值或坐标越界,除零后污染了整个 batch;学习率太大,SmoothL1 在异常点上把梯度推爆;输入图像全黑或全白,BatchNorm 统计量出现极端值。

排查顺序是先从数据下手:df.isna().sum()看空值,坐标min/max看越界,图像mean/std看有没有异常样本。数据没问题再降学习率到5e-4或1e-4,加梯度裁剪max_norm=5.0,并在训练循环里打印当前 batch 的 loss,定位到具体 batch 后把对应图像和标注单独可视化。不要一上来就改网络结构,nan十有八九是数据或数值稳定性问题。

5.5 现象:换一张图预测,所有点挤在中心

训练集上看着还行,换一张新图推理,68 个点全挤在脸中央,像没展开。原因通常不是模型泛化差,而是推理预处理和训练不一致:训练时图像除以 255,推理时忘了除;训练时转灰度,推理时读了 RGB 三通道;训练时resize到 96×96,推理时直接喂原图。输入分布一变,网络输出就会塌到均值附近。

解决方式是固定一个preprocess_image函数,训练和推理都走它,函数里把convert("L")、resize、/255.0、unsqueeze(0)一次做完,并打印输出 shape 和min/max。推理前先拿一张训练图回训一遍,如果训练图也挤在中心,说明预处理错了;如果训练图正常、新图不行,再去看人脸框和姿态。这个排查顺序能省很多“模型不行”的误判。

6. 进阶压误差:热图回归与水平翻转 TTA 的落地细节

6.1 热图回归:把 136 维输出换成分步分布

坐标回归跑通后,想再压误差,第一件事不是加深网络,而是换输出形式。直接回归 136 个连续值,模型要一次性记住所有点的空间关系;热图回归让网络输出 68 张低分辨率热图,每个像素表示“该点在这里的概率”,最后用 soft-argmax 取坐标。对小数据和遮挡场景,热图通常比纯回归稳,因为空间结构被保留下来了。

落地时不用一上来就上高分辨率。96×96 输入经过四次池化到 6×6,热图也做 6×6 或 12×12,先跑通再接上采样。损失用 MSE 对高斯热图监督,目标热图以真值点为中心、半径为 1 到 2 个像素生成;推理时对每张热图做 softmax 再求期望坐标:

import torch import torch.nn.functional as F def soft_argmax(heatmap, temperature=1.0): # heatmap: B x 68 x H x W b, k, h, w = heatmap.shape prob = F.softmax(heatmap.reshape(b, k, -1) / temperature, dim=-1) ys = torch.arange(h, device=heatmap.device).view(1, 1, h, 1) xs = torch.arange(w, device=heatmap.device).view(1, 1, 1, w) x = (prob.reshape(b, k, h, w) * xs).sum(dim=(2, 3)) y = (prob.reshape(b, k, h, w) * ys).sum(dim=(2, 3)) return torch.stack([x, y], dim=-1)

temperature越小,期望越接近峰值,但梯度也越尖;第一版用 1.0,先看热图有没有对准,再调。热图分辨率低时,soft-argmax 会带来量化误差,可以在 12×12 上做,或者对输出做一次双线性上采样再取期望。这个改动的代价是训练目标从 136 维变成 68×H×W,显存和生成目标热图的代码都要改,别在基线还没稳的时候直接上。

6.2 水平翻转 TTA:推理期多算一次

测试时增强(TTA)是最便宜的涨点手段:把原图和水平翻转图各推理一次,把翻转图的预测点按FLIP_INDEX换回原顺序,再和原图预测平均。它不改变训练,只增加推理时间,适合在最终验收前跑一次。

@torch.no_grad() def predict_with_flip_tta(model, img, device): model.eval() x = img.unsqueeze(0).to(device) p1 = model(x).cpu() x_flip = torch.flip(x, dims=[-1]) p2 = model(x_flip).cpu() p2 = p2.reshape(-1, 2) p2[:, 0] = -p2[:, 0] # 翻转坐标取反 p2 = p2[FLIP_INDEX].reshape(1, -1) return ((p1 + p2) / 2.0).squeeze(0)

这段代码的前提是模型输入输出都按[-1,1]归一化,且FLIP_INDEX与训练时一致;如果训练时没做翻转增强,TTA 的收益会小一些,但仍可试。平均之后建议clamp(-1, 1),防止个别点被推到图外。TTA 不是万能药,如果验证集上原图预测已经左右不一致,先修数据和翻转索引,再谈平均。

从那以后我每次改关键点项目,都先拿 10 张图做可视化对齐,再开训练;这套 P1_Facial_Keypoints 资源包我也是这么跑通的——先 2.3 的基线,再 3.2 的训练循环,最后才动第 6 章的热图和 TTA,顺序反了就会在坐标错位里反复打转。希望帮到你。

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

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

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

立即咨询