简介:这份面部关键点检测项目包面向计算机视觉与深度学习初学者,以及需要复现CVND课程项目的学习者,围绕人脸检测与眼睛、鼻子、嘴部关键点定位展开,帮助读者从数据加载可视化一路走到CNN训练与完整检测管线。压缩包约330.12MB,共约2000个文件,主体为jpg人脸图像样本,并配有9个Jupyter Notebook、关键点标注csv、Haar级联xml、模型py脚本及说明文档,目录按四个实验阶段组织,便于按模块检索。已有201人学习或浏览。读者可据此完成数据探索、网络结构定义与训练、Haar级联结合CNN的检测流程,并体验关键点趣味过滤器等扩展应用,适合作为课程作业、项目复现与计算机视觉入门的实操素材。
1. 从一张 96×96 的灰度人脸说起:这套 Jupyter Notebook 在练什么
第一次在 Jupyter Notebook 里跑完面部关键点检测的推理单元,按下 Shift+Enter,matplotlib 弹出的人脸图上叠了 30 个红点——大位置对了,眼角那几个偏了两三个像素,肉眼几乎分辨不出来。这套项目有意思的地方就在这:它做的是回归,不是分类。输入一张 96×96 的灰度人脸,输出 15 个关键点的 (x, y) 共 30 个连续值,"对了几分"没法用准确率衡量,只能盯着 loss 和像素偏差看。适合两类人:刚学完卷积、想找个输入输出都能可视化的任务练手的,以及想彻底搞清回归型 CNN 和分类 CNN 差在哪的。代码以 Notebook 组织,按顺序跑一遍,数据解析、网络定义、训练循环、推理管线就完整走了一遭。
2. 工程结构与数据管道:从 CSV 到模型输入张量的完整链路
拿到源码包,第一件事不是急着开 Notebook 跑,而是先看目录。这类项目的结构其实很朴素,麻烦都藏在数据那一步。下面这套拆分方式是我在一线看到最常见、也最好改的一种:Notebook 只负责"讲"和"看",可复用的逻辑全部下沉到.py文件里,这样你在 Notebook 里调参时不会把加载逻辑改坏,写脚本批跑时也不用把单元格复制一遍。
2.1 目录布局与 Notebook 的职责切分
P1_Facial_Keypoints/ ├── data/ │ ├── training_frames_keypoints.csv # 训练集:像素串 + 30 列坐标 │ └── test_frames_keypoints.csv # 测试集:只有像素串 ├── saved_models/ # 权重落盘目录 ├── images/ # 可视化结果输出 ├── 1_load_and_visualize.ipynb # 解析 CSV、抽样看图 ├── 2_network_architecture.ipynb # 定义网络、跑一次前向 ├── 3_train_network.ipynb # 训练循环、loss 曲线 ├── 4_complete_pipeline.ipynb # 加载权重、端到端推理 ├── data_load.py # Dataset / ToTensor / 可视化函数 └── models.py # 网络结构四个 Notebook 是有顺序依赖的:1 确认数据读得对,2 确认网络输出维度对,3 确认 loss 在下降,4 确认权重加载后预测结果对。很多人喜欢把四件事塞进一个 Notebook,结果就是 kernel 状态一乱,前面跑过的变量和后面新改的代码混在一起,出问题根本分不清是模型不行还是变量没刷新。拆开之后,每个 Notebook 的输入输出是明确的:1 产出"数据没问题"的结论,2 产出"形状对得上"的结论,3 产出saved_models/*.pt,4 消费这个权重文件。这种"文件即接口"的做法,比任何注释都可靠。
数据文件本身不大,一张 96×96 的灰度图展平成 9216 个浮点数,用空格分隔写在一列里。训练集大约七千行,磁盘上几十兆的量级,普通笔记本内存完全吃得下,所以没必要上什么复杂的数据管道,pandas.read_csv一把梭就行。
2.2 CSV 解析:把一行像素字符串还原成 96×96 矩阵
CSV 里最反直觉的一列是Image:它不是路径,是 9216 个用空格隔开的灰度值拼成的长字符串。第一次见容易懵,觉得这格式怎么能用,实际上它对小图特别友好——不用管理图片文件、不用管路径、一个文件就是整个数据集。
import numpy as np import pandas as pd def parse_row(row): """把一行 CSV 还原成 (96, 96) 的 float32 图像""" # Image 列是 "12 34 56 ..." 这样的空格分隔串,切完再转 float pixels = np.array(row['Image'].split(), dtype=np.float32) assert pixels.size == 96 * 96, f"像素数不对: {pixels.size}" # reshape 默认按行优先,和生成 CSV 时的顺序一致 image = pixels.reshape(96, 96) # 剩下 30 列就是 15 个点的 x/y,表头顺序即点位顺序 keypoints = row.drop('Image').values.astype(np.float32) return image, keypoints这段代码里有两个容易翻车的点。一是np.fromstring(s, sep=' ')这类老写法在新版 NumPy 上会抛弃用警告甚至报错,用split()配合np.array更稳,代价是多一次中间 list,对这个量级的数据完全可以忽略。二是那个assert千万别省——我见过有人用错分隔符(比如数据里其实是逗号加空格),split()之后拿到 18432 个值,reshape(96, 96)直接报错还好,最怕的是某些行恰好凑够数量,模型训了半天才发现图像是错位的。加一行断言,问题在第一行数据上就暴露。
另外row.drop('Image')依赖列顺序,而这个顺序决定了 30 个数的语义:第 0、1 列是左眼中心 x/y,第 2、3 列是右眼中心 x/y,后面依次是内外眼角、眉毛、鼻尖、嘴角、上下唇中点。这个映射关系是整个项目的地基,输出层第 k 个神经元代表哪个点的哪个轴,全靠它。常见做法是在data_load.py顶部定义一份列名常量,后面画图、做数据增强、算单点误差全部引用它,避免"第 11 个到底是左嘴角还是右嘴角"这种低级混乱。
2.3 缺失值处理:七千行里只有两千行能用
这个数据集有个绕不过去的现实:标注是人工点的,很多图只标了一部分关键点,某些行甚至是空的。对分类任务,缺标注还能当无标签数据用;对回归任务,缺一个坐标这一行就没法算 loss。
df = pd.read_csv('data/training_frames_keypoints.csv') print(f"原始行数: {len(df)}") # 统计每个点的缺失比例,先看清缺在哪 missing_ratio = df.drop(columns=['Image']).isna().mean().sort_values(ascending=False) print(missing_ratio.head(10)) # 方案 A:整行丢弃,只保留 15 个点全齐的样本 df_full = df.dropna().reset_index(drop=True) print(f"完整标注行数: {len(df_full)}")跑完你会发现行数从七千多掉到两千一百左右,接近三分之二的数据被扔掉。第一次看到这个数字是有点心疼的,但这就是这类项目的真实起点。常见的三种处理方式:整行丢弃,简单但样本少;按点拆成子任务,每个点只在自己的有效样本上算 loss,实现复杂但能榨干数据;或者用均值填充缺失点,代价是把噪声喂给模型,容易让网络学会"预测平均脸"。作为课程项目,第一种足够;如果你要把它做成能用的东西,第二种值得花时间,具体做法是在 Dataset 里额外返回一个 30 维的 mask,loss = (mse * mask).sum() / mask.sum(),只对有效维度回传梯度。
我一般会先把缺失比例打出来再决定用哪种,因为如果某个点的缺失率超过 50%,那这个点本身就不该进损失函数,硬撑着训只会拖累其他点。
2.4 Dataset 封装与坐标归一化:图像和标签必须在同一尺度
PyTorch 的Dataset只需要实现__len__和__getitem__,但这两个方法里藏着整个项目最容易出错的一步——归一化对齐。
import torch from torch.utils.data import Dataset class FacialKeypointsDataset(Dataset): def __init__(self, csv_file, transform=None): self.df = pd.read_csv(csv_file).dropna().reset_index(drop=True) self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img = np.array(row['Image'].split(), dtype=np.float32).reshape(96, 96) kps = row.drop('Image').values.astype(np.float32) # 关键一步:像素坐标 [0, 95] 线性映射到 [-1, 1] kps = (kps - 48.0) / 48.0 sample = {'image': img[None, :, :], 'keypoints': kps} if self.transform: sample = self.transform(sample) return sample图像这边走的是ToTensor加Normalize(mean=[0.5], std=[0.5]),最终范围也是[-1, 1]。为什么非要让两边同尺度?因为回归网络输出的 30 个数是直接和标签做 MSE 的,如果图像在[-1,1]而标签在[0,95],输出层的初始权重几乎要从零开始学一个几十倍的放大,梯度尺度差太多,训练早期 loss 会剧烈震荡。把标签压到[-1,1]之后,输出层的初始值(接近 0)恰好对应图像中心,模型从一个"把所有点预测在脸中央"的合理起点开始收敛,这是最常见的做法,也是我强烈建议保留的。
反过来的坑是有人只归一化了图像,忘了标签,训出来的模型 loss 看着在降,但一画图所有点挤在左上角。判断方法很简单:print(sample['keypoints'].min(), sample['keypoints'].max()),结果不在[-1, 1]附近就是漏了这一步。
2.5 可视化自检:不画图等于闭眼开车
数据管道的最后一步不是把 DataLoader 建起来就完事,而是抽几张图叠上关键点看一眼。这个动作花不到两分钟,能挡掉后面几个小时的调试。
import matplotlib.pyplot as plt def show_sample(sample, ax=None): ax = ax or plt.gca() img = sample['image'].squeeze().numpy() img = img * 0.5 + 0.5 # 反归一化回 [0, 1] 才能正常显示 ax.imshow(img, cmap='gray') kps = sample['keypoints'].numpy().reshape(-1, 2) kps = kps * 48.0 + 48.0 # 还原成像素坐标 ax.scatter(kps[:, 0], kps[:, 1], s=18, c='r', marker='x') fig, axes = plt.subplots(1, 4, figsize=(12, 3)) for ax in axes: show_sample(dataset[np.random.randint(len(dataset))], ax)如果点整齐地落在眼睛、鼻尖、嘴角上,说明解析和归一化都对;如果整体上下颠倒,多半是 reshape 时行优先和列优先搞反了;如果点整体偏移了一个固定量,回去查归一化用的除数是 48 还是 47.5。这一步做完,后面的训练才有意义。
3. 核心模块拆解:CNN 回归网络与训练循环
数据通了,接下来是模型。这一步的难点不在写网络,而在理解"为什么回归网络和分类网络长得很像、尾巴却完全不同"。
3.1 网络结构:卷积提单通道特征,全连接出 30 个实数
输入是单通道 96×96,输出 30 维。这个尺寸下不需要什么重型骨干,四层卷积加两层全连接就能到不错的精度。
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.regressor = nn.Sequential( nn.Flatten(), nn.Linear(256 * 6 * 6, 512), nn.ReLU(), nn.Dropout(0.3), nn.Linear(512, 30), # 15 个点 × 2,输出层不加任何激活 ) def forward(self, x): return self.regressor(self.features(x))几个设计选择的理由值得说清。卷积核统一用 3×3 加padding=1,这样每次卷积不改变特征图尺寸,下采样完全交给MaxPool2d(2),特征图尺寸变成 96→48→24→12→6,一路清晰,不会出现"算错 flatten 维度导致Linear报 size mismatch"的情况。BatchNorm放在卷积之后、激活之前,对这种小数据集能明显稳住训练。Dropout(0.3)只加在第一个全连接后面,因为全连接层参数量最大(256×6×6×512 接近 470 万),是最容易过拟合的地方;卷积层参数量小,加 dropout 收益不明显反而拖慢收敛。
最后一层nn.Linear(512, 30)后面故意什么都不接,这是回归和分类最本质的区别。分类要对 logits 做 softmax 取最大,回归必须让输出是无界的实数,一旦手滑加了Sigmoid,输出被锁死在(0,1),而标签在[-1,1],损失根本降不下去——这是这个项目最经典的翻车点之一。
参数量算一下:卷积部分大约 45 万,全连接部分约 470 万,总计五百万出头。对两千多张 96×96 的图来说,这个容量偏大,过拟合是常态,所以训练时看验证集比看训练集重要得多。
3.2 损失函数与优化器:为什么是 MSE 配 Adam
import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = KeypointNet().to(device) criterion = nn.MSELoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)MSELoss对坐标回归是默认选择,它直接对应"平均每张图偏差多少"的物理意义:loss 是 0.01 时,换算回像素是sqrt(0.01) * 48 ≈ 4.8像素的平均偏差。这个换算关系建议记在便签上,它让你看到 loss 数字时能立刻判断"这模型现在是废的还是能用的"。
Adam配1e-3是这个规模下的常用起手式,比 SGD 收敛快、对学习率不敏感,缺点是最后的极限精度可能略差,但对课程项目完全够用。weight_decay=1e-4是我加上去的——样本只有两千张,不加正则的话十几个 epoch 之后训练 loss 还在掉、验证 loss 已经开始回升,那种"训练集上表现完美、换个输入点全乱飞"的情况就是过拟合。
如果你打算认真调,可以换成ReduceLROnPlateau调度器:验证 loss 连续 5 个 epoch 不降就把学习率砍一半。这比手动调学习率省事,也能让最后的收敛更稳。
3.3 训练循环:一个 epoch 里到底发生了什么
训练循环本身没几行,但每一步的顺序错了都会静默出错。
def train_one_epoch(model, loader, criterion, optimizer, device): model.train() # 打开 Dropout 和 BatchNorm 的训练行为 total_loss = 0.0 for batch in loader: images = batch['image'].to(device) targets = batch['keypoints'].to(device) optimizer.zero_grad() # 必须先清梯度,否则会累加 outputs = model(images) # (B, 30) loss = criterion(outputs, targets) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) return total_loss / len(loader.dataset) def evaluate(model, loader, criterion, device): model.eval() # 关掉 Dropout,BatchNorm 用滑动统计量 total_loss = 0.0 with torch.no_grad(): # 验证不需要建图,省显存也更快 for batch in loader: images = batch['image'].to(device) targets = batch['keypoints'].to(device) total_loss += criterion(model(images), targets).item() * images.size(0) return total_loss / len(loader.dataset)四个细节。optimizer.zero_grad()的位置在backward()之前,忘了写的话梯度会跨 batch 累加,表现为 loss 先降后炸。loss.item() * images.size(0)是为了按样本数加权,因为最后一个 batch 可能不满,直接对 batch loss 求平均会有偏差。model.train()和model.eval()必须成对出现,只写了一半是最隐蔽的 bug——训练时忘了train(),Dropout 不生效,模型会过拟合得特别快;验证时忘了eval(),Dropout 仍然随机丢弃神经元,验证 loss 会异常偏高且抖动,让你误以为模型不行。
训练轮数上,这个项目一般 20 到 50 个 epoch 就收敛了,batch size 用 32 或 64。数据集小,一个 epoch 在 GPU 上不到十秒,在 CPU 上也就一两分钟,所以完全有条件每次都跑完整个流程再判断改动有没有效。
3.4 权重保存与加载:只存 state_dict
# 保存:只存参数字典,不存整个模型对象 torch.save(model.state_dict(), 'saved_models/keypoints_model.pt') # 加载:必须先构造同结构的模型,再灌参数 model = KeypointNet().to(device) model.load_state_dict(torch.load('saved_models/keypoints_model.pt', map_location=device)) model.eval()torch.save(model)存整个对象看着方便,但会把你定义类的模块路径一起序列化进去,换个目录、改个文件名就可能加载失败,报Can't get attribute之类的错。存state_dict是唯一可靠的做法,代价是加载时要先手工构造网络——这其实是个好事,它强迫你确认推理端的网络结构和训练时完全一致。
map_location=device这个参数别省。在有 GPU 的机器上训完、拿到只有 CPU 的环境里跑推理,不加它会直接抛错。加上之后,权重会自动搬到当前设备上。
4. 跑起来:环境、执行顺序与第一次验收
代码看懂了,接下来是把它在自己机器上跑通。这一步的坑不在代码,在环境和 Notebook 的状态管理。
4.1 环境与依赖:版本比想象中重要
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu pip install numpy pandas matplotlib opencv-python jupyter jupyter notebook依赖就这几样。PyTorch 装 CPU 版还是 CUDA 版看你的机器,没有独显就老老实实装 CPU 版,这个项目在 CPU 上跑完全程也就十几分钟,为了它折腾驱动不值当。opencv-python只在最后做摄像头实时推理时才用到,前面几个 Notebook 不需要。
版本上有个真实存在的坑:NumPy 1.24 之后np.fromstring带sep参数的用法被彻底移除,老代码直接报AttributeError。如果你拿到的是比较早的版本,把这行换成np.array(s.split(), dtype=np.float32)就行。同理,PyTorch 2.x 对torch.load加了weights_only的默认行为变化,加载老权重时可能需要显式传weights_only=False或True,看你存的是不是纯 state_dict。
4.2 Notebook 的执行顺序与 kernel 状态陷阱
四个 Notebook 必须按 1→2→3→4 的顺序跑,而且每次重新打开时先Kernel → Restart & Run All。
原因很实在:Notebook 的变量活在 kernel 内存里,你改了一行代码只重跑那一个单元格,前面单元格产生的老变量还留着,看起来"改了没生效"或者"改了反而更差"。我在这个项目上见过最典型的场景是:在 2 号 Notebook 里改了网络结构加了一层卷积,然后回到 3 号 Notebook 直接跑训练单元格——用的是 kernel 里还留着的旧模型实例,训了半小时发现 loss 曲线和上次一模一样,白等。
如果一定要在 Notebook 里边改边跑,开头加这两行能省不少事:
%load_ext autoreload %autoreload 2这样你改models.py或data_load.py保存后,下次调用会自动重新加载模块,不用重启 kernel。但注意它对 Notebook 里直接定义的类无效,只对外部.py文件生效——这也正是把网络定义放到models.py里的另一个好处。
4.3 第一次训练的验收标准
跑完之后怎么判断"这算成功"?我给三个可量化的标准。
第一,训练 loss 从初始的 0.3 到 0.5 区间(相当于随机初始化时预测全在中心、平均偏差十几像素)降到 0.01 以下,对应像素偏差 4.8 像素以内。到 0.003 左右(约 2.6 像素)已经是这个网络结构在完整标注子集上的合理水平。
第二,验证 loss 和训练 loss 的差距不超过 30%。差太多说明过拟合,回去加 dropout 或者加数据增强;两个都很高说明欠拟合,加层或者加训练轮数。
第三,也是最能说明问题的:在 4 号 Notebook 里挑五张测试图,把预测点画上去,眼睛、鼻尖、嘴角的位置肉眼可辨地对得上。如果五张里有三张对得上,这个项目就算跑通了。
5. 避坑与排查:五个我踩过的具体问题
5.1 训练 loss 卡在 0.1 附近死活不降
现象:前几个 epoch 降得挺快,到 0.1 左右就变成一条平线,再加轮数也没用。
原因:输出层被加了激活函数。有人习惯性地在最后一层后面接Sigmoid或Tanh,Sigmoid把输出锁在(0,1),而标签范围是[-1,1],负半轴的点永远预测不出来;Tanh虽然值域对得上,但饱和区梯度极小,深层的坐标几乎学不动。
解决:检查models.py里最后一层,确认nn.Linear(512, 30)后面干干净净什么都没有。顺手print(model)看一眼完整结构,比读代码可靠。
5.2 预测出来的点全挤在脸中央,像一张"平均脸"
现象:推理图上 30 个点几乎重合在鼻子附近,换个输入还是同一个位置。
原因:模型塌缩到了训练集的均值解。常见触发条件是样本量太小(比如只用了两百张图)配上过大的学习率,或者标签没归一化导致梯度尺度失衡,网络发现"输出均值"是局部最优就不再动了。
解决:确认dropna之后样本数在两千以上;学习率降到1e-3或5e-4;标签务必归一化到[-1,1]。如果还是塌,把MSELoss换成SmoothL1Loss,它对大误差的惩罚更温和,不容易一开始就被极端样本带偏。
5.3 验证 loss 剧烈震荡,可视化时点乱飞
现象:训练 loss 平稳下降,验证 loss 上下跳,同一个模型两次评估结果差很多。
原因:验证集忘了model.eval(),Dropout 还在随机丢神经元,每次前向结果都不一样;或者 DataLoader 的shuffle只给了训练集没给验证集,验证样本顺序固定导致某些难样本反复拉高 loss。
解决:evaluate函数里先写model.eval(),外面套torch.no_grad();DataLoader(val_dataset, batch_size=32, shuffle=False)保持不打乱没问题,但要确保验证集是从同一份完整标注数据里按固定随机种子切出来的,别每次运行切的不一样。
5.4 改了models.py,训练结果一点没变
现象:明明加了 BatchNorm、改了通道数,跑出来的 loss 曲线和上次一模一样,连小数位都对得上。
原因:Jupyter 的模块缓存。import models之后,Python 把模块对象留在sys.modules里,你改了磁盘上的.py文件,当前 kernel 用的还是旧对象。
解决:Kernel → Restart & Run All,或者用前面提到的%autoreload 2。判断有没有生效的土办法是在models.py里加一行print('v2'),跑一次看有没有输出。
5.5 图像和关键点整体上下颠倒或左右镜像
现象:散点图上的点分布形状是对的,但整体翻了个个儿,眼睛跑到了下巴位置。
原因:reshape的行列顺序和原始数据生成时相反,或者画图时imshow的origin参数默认是'upper',而数据处理按'lower'的习惯来的。
解决:先在show_sample里加ax.imshow(img, cmap='gray', origin='upper')试试,多数情况是画图这一侧的问题。如果调整origin后图像正了点还反着,那就是 reshape 需要用reshape(96, 96).T转置一次。左右镜像则要检查是不是在数据增强里做了翻转但忘了交换左右对称点,下一章会细说。
6. 二次开发:换骨干、加增强、上摄像头
跑通之后想再往前走一步,有三个改造方向性价比最高。
6.1 换骨干网络:ResNet18 改单通道输入
import torchvision.models as models import torch.nn as nn def build_resnet_regressor(): net = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) # 第一层原本吃 3 通道,改成 1 通道:把三个通道的权重在通道维求平均 old_conv = net.conv1 net.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) net.conv1.weight.data = old_conv.weight.data.mean(dim=1, keepdim=True) # 换掉最后的分类头,输出 30 维 net.fc = nn.Linear(net.fc.in_features, 30) return net权重求平均这个技巧比随机初始化收敛快得多,因为它保留了预训练模型在低频边缘、纹理上学到的东西。注意输入尺寸:ResNet 默认吃 224×224,直接喂 96×96 也能跑,但特征图会缩到 3×3,信息损失比较大。常见做法是把输入上采样到 128 或 224,或者把conv1的stride改成 1、去掉第一个MaxPool。改完记得重新算fc的输入维度,别照抄。
6.2 数据增强:翻转时坐标必须同步变换
这是整个二次开发里最容易出错的地方。图像翻转一行代码,坐标翻转要连"哪些点该互换"一起处理。
# 15 个点的顺序:0/1 左右眼中心,2/4 左右眼内角,3/5 左右眼外角 # 6/8 左右眉内端,7/9 左右眉外端,10 鼻尖,11/12 左右嘴角,13/14 上下唇中点 SWAP_PAIRS = [(0, 1), (2, 4), (3, 5), (6, 8), (7, 9), (11, 12)] def horizontal_flip(image, keypoints): """image: (1, 96, 96) tensor;keypoints: (30,) tensor,坐标已在 [-1, 1]""" image_f = torch.flip(image, dims=[2]) # 沿宽度方向镜像 kp = keypoints.view(-1, 2).clone() kp[:, 0] = -kp[:, 0] # x 轴取反 out = kp.clone() for a, b in SWAP_PAIRS: out[a] = kp[b] # 左右对称的点要交换身份 out[b] = kp[a] return image_f, out.view(-1)不交换身份会怎样?翻转后左边嘴角的图像内容跑到了右边,但标签里第 11 维还写着"左嘴角",网络学到的是"有时候左边是左嘴角,有时候是右嘴角",两个点的预测会同时变得模糊。上下唇中点(13、14)和鼻尖(10)在水平翻转下位置不变,不参与交换。旋转增强更麻烦,要做坐标的旋转矩阵变换,一般还会带来边界裁切问题,课程项目里可以先不做。
6.3 摄像头实时推理:把整条管线串起来
import cv2 import torch net = build_resnet_regressor().eval() net.load_state_dict(torch.load('saved_models/keypoints_model.pt', map_location='cpu')) cap = cv2.VideoCapture(0) while True: ok, frame = cap.read() if not ok: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) face = cv2.resize(gray, (96, 96)).astype('float32') / 255.0 # 缩放到 [0,1] inp = torch.from_numpy(face)[None, None] * 2 - 1 # 对齐到 [-1,1] with torch.no_grad(): kps = net(inp)[0].view(-1, 2).numpy() * 48 + 48 # 还原到 96 尺度 for x, y in kps: cv2.circle(frame, (int(x * 4), int(y * 4)), 2, (0, 0, 255), -1) cv2.imshow('keypoints', frame) if cv2.waitKey(1) == 27: break cap.release() cv2.destroyAllWindows()这段代码里最容易忽略的是归一化的两步:先/255到[0,1],再*2-1到[-1,1],和训练时的Normalize(0.5, 0.5)等价。少一步,摄像头里的点会整体偏移。另外整帧缩放和"人脸框裁剪后缩放"效果差很多,真实场景下建议先跑个人脸检测拿到 bbox,裁出来再送进关键点网络,否则背景占比太大,预测会漂。
| 改造方向 | 主要收益 | 主要成本 | 建议优先级 |
|---|---|---|---|
| 换 ResNet 骨干 | 精度提升明显,抗姿态变化 | 需要调输入尺寸和 fc 维度 | 高 |
| 水平翻转增强 | 样本翻倍,抑制过拟合 | 必须处理左右点交换 | 高 |
| 按点拆分子任务 | 可用样本从 2k 涨到 7k | 要实现 mask 损失 | 中 |
| 摄像头实时推理 | 直观展示,便于调试 | 需额外做人脸检测 | 中 |
最后说个我自己的习惯。这个项目我前后重跑了四五次,每次改完网络结构,我都会先跑一遍"单张图过拟合"——拿一张训练图反复训 200 步,看模型能不能把 30 个点精确贴上去。如果连一张图都贴不准,那一定是网络、损失或者坐标归一化有结构性问题,跟数据量无关,这时候去调超参纯属浪费时间。从那以后我每次改完模型都强制走一遍这个单样本过拟合测试,省下的时间比写它的时间多得多。希望帮到你。
本文还有配套的精品资源,点击获取