资讯详情

资讯详情

面部关键点检测全链路:从CSV坐标到CNN回归与掩码MSE

简介CVND首个实战项目面向已具备Python基础、希望入门计算机视觉与深度学习的学习者围绕面部关键点检测完成从数据加载、可视化到CNN训练与推理的完整实践可服务于人脸跟踪、姿态识别与表情分析等场景。压缩包约330.12MB包含约2000个文件以jpg人脸图像和ipynb交互式笔记本为主体辅以csv关键点标注、xml级联检测器、py模型代码及说明文档便于按数据、训练、检测三个环节查阅。已有201人学习说明其作为课程项目复现材料具有一定参考价值。四个笔记本分别覆盖数据探索、网络结构定义与训练、Haar级联加CNN的完整检测管线以及趣味滤镜应用读者可据此理解关键点回归任务的数据组织、模型搭建和端到端预测流程并借助随附标注与源码复现实验结果、排查训练中的常见问题。1. 面部关键点检测的知识地图一条从 CSV 到 30 个数字的链面部关键点检测Facial Keypoints这个项目本质上就是把一张 96×96 的灰度人脸压成 30 个数字15 个点每个点一个 x、一个 y。整份 Jupyter Notebook 实际上是一条流水线——CSV 读坐标 → 丢掉缺点的样本 → 增强时带着坐标一起变换 → CNN 回归 30 维 → 掩码 MSE → 人脸框裁剪后推理。流水线上跑的始终是同一个东西坐标口径。口径守住了收敛只是时间问题口径在哪一步断了后面所有调参就都成了玄学。这份笔记就按这条流水线拆开适合已经跑通过一遍、但说不清每个模块为什么这么写的人。2. 数据层CSV 里的 30 列坐标怎么变成网络能吃的张量这个项目最容易让人低估的地方是它看起来数据很干净——一个 CSV一行一张图后面跟着 30 个数。真正动手才会发现这 30 个数有三种状态完整、部分缺失、以及整行缺失。数据层要解决的就是把这三种状态翻译成张量能理解的形式同时保证后面每一层看到的坐标系是同一个。2.1 一行数据拆成图像、坐标、掩码CSV 的第一列通常是图像文件名后面 30 列按(x0, y0, x1, y1, ..., x14, y14)的顺序排开。第一步不是急着读图而是先把这 30 列 reshape 成(N, 15, 2)让“点”成为一个维度后面所有按点做的运算掩码、翻转映射、逐点损失才有地方落。import numpy as np import pandas as pd df pd.read_csv(keypoints.csv) # 第一列是文件名后面 30 列两两成对(x0,y0), (x1,y1), ... coord_cols df.columns[1:] keypoints df[coord_cols].values.astype(np.float32).reshape(-1, 15, 2) # 一个点只要 x 或 y 有一个是 NaN这个点就当作没标注 mask ~np.isnan(keypoints).any(axis2) # (N, 15) 的布尔矩阵 print(mask.mean(axis0)) # 每个关键点的标注覆盖率mask是整个项目里最该早点建出来的东西。它有三个用途算损失时把缺失点排除掉、统计哪些点天生标注得少、以及在做可视化时决定要不要画这个点。把mask.mean(axis0)打印出来看一眼通常会看到嘴角、下唇中点这些点的覆盖率明显低于眼睛中心——这就是为什么训练到后期眼睛那几个点早早就贴住了嘴角还在原地晃。不是模型笨是那些样本根本没告诉它答案。提示读完 CSV 先做这一件事覆盖率表比任何损失曲线都更早告诉你“哪些点注定学不好”。2.2 15 个关键点的下标顺序要背下来这 15 个点是有固定顺序的而这个顺序会被后面所有涉及“点与点交换”的操作引用。不背下来写翻转增强的时候只能靠猜。下标名称下标名称0left_eye_center8right_eyebrow_inner_end1right_eye_center9right_eyebrow_outer_end2left_eye_inner_corner10nose_tip3left_eye_outer_corner11mouth_left_corner4right_eye_inner_corner12mouth_right_corner5right_eye_outer_corner13mouth_center_top_lip6left_eyebrow_inner_end14mouth_center_bottom_lip7left_eyebrow_outer_end这里有个绕不开的歧义名字里的 left / right 到底是“看照片的人的左边”还是“被拍的人的左边”。不同数据集的约定并不一致笔记里也不该替你拍板。真正重要的是——不管你按哪种理解配对关系必须自洽0 和 1 是一对2 和 4 是一对3 和 5 是一对6 和 8、7 和 9、11 和 12 各是一对。把这张配对表写在代码旁边比记名字可靠得多。2.3 坐标归一化除以 95 还是除以 96原始坐标是像素值范围 0 到 95。如果直接拿去回归损失的量级会在几百到几千学习率稍微大一点就炸小一点又几乎不动。所以坐标必须先缩放。IMG_SIZE 96 def normalize_keypoints(kp, sizeIMG_SIZE): # 像素坐标 [0, size-1] - [0, 1] return kp / (size - 1) def denormalize_keypoints(kp, sizeIMG_SIZE): # 推理后一定要还原否则画出来的点全挤在左上角 return kp * (size - 1)除以size - 1而不是size是因为像素下标的最大值就是 95。这个差别在 96 的尺度下只有 1%肉眼看不出但它保证了 0 和 95 严格映射到 0 和 1边界样本不会出现 1.01 这种越界值。除以 96 也不是不能用只要你训练和推理两边用的是同一个函数——真正致命的是一边除 95 一边除 96模型学到的“眼睛在 0.42”在推理时被解释成另一个位置整张脸的点会系统性偏移而这种偏移在小图上极难察觉。图像侧的归一化同理。灰度图读进来是 0 到 255 的 uint8转 float 后除以 255 是最常见的做法。如果你用了Normalize(mean, std)那这组均值和方差必须写进配置、在推理时原样复用。写在两个不同 cell 里的魔法数字是这个项目里最经典的“血泪经验”来源。还有一个容易被跳过的小坑CSV 里的文件名可能是不带前导零的而磁盘上的文件是000001.jpg这种补零格式。批量读图时如果直接拼接路径会得到一堆“文件不存在”然后误以为是数据集下载不全。3. 增强管线图像动了坐标必须一起动增强是整条链上唯一一个“同时改两类数据”的环节。图像变换本身是现成的难的是让 30 个坐标跟着做同一件事。分类任务里增强写错了顶多掉几个点回归任务里增强写错了模型会学出一个永远贴着图像中心的“平均脸”。3.1 为什么这条管线里最容易翻车的是增强普通的数据增强只关心像素所以可以随便组合、随便调顺序。关键点回归不行因为每一次空间变换都对应一个坐标变换而且这个对应关系必须精确到像素下标。更麻烦的是增强管线的执行顺序会影响结果先裁剪再翻转和先翻转再裁剪坐标的中间值不一样虽然理论上最终结果一致但如果某一步忘了同步坐标你很难从最终的图上判断是哪一步出的问题。比较稳的做法是把“图像 坐标 掩码”打包成一个样本单元让每个变换函数同时吃这三样、同时吐这三样。这样任何一步漏改都写不出来因为函数的签名就不允许。import numpy as np import cv2 import torch from torch.utils.data import Dataset class KeypointDataset(Dataset): def __init__(self, df, img_dir, transformNone): self.df df.reset_index(dropTrue) self.img_dir img_dir self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] # 必须按灰度读和推理时的口径保持一致 img cv2.imread(f{self.img_dir}/{row[image_name]}, cv2.IMREAD_GRAYSCALE) if img is None: raise FileNotFoundError(row[image_name]) kp row.iloc[1:].values.astype(np.float32).reshape(15, 2) mask ~np.isnan(kp).any(axis1) sample {image: img, keypoints: kp, mask: mask} if self.transform: sample self.transform(sample) return sample__getitem__里那个raise不是多余的。数据集里混进一张损坏图或者文件名对不上静默跳过会让训练集悄悄少几十个样本而你只会看到损失曲线有点抖。让它早点炸比后面查半天强。3.2 水平翻转x 镜像之外还要做下标配对水平翻转是所有增强里性价比最高的一个——人脸左右近似对称翻一倍数据量几乎不花成本。但它也是唯一一个同时改坐标值和下标顺序的操作漏掉任何一半都会翻车。# 15 个关键点在水平翻转后的下标映射 FLIP_INDEX np.array([1, 0, 4, 5, 2, 3, 8, 9, 6, 7, 10, 12, 11, 13, 14]) def flip_keypoints(kp, width): kp: (15, 2)坐标单位为像素width: 图像宽度 kp kp.copy() kp[:, 0] (width - 1) - kp[:, 0] # x 轴镜像注意是 width-1 return kp[FLIP_INDEX] # 再按下标重排两步缺一不可这段代码里有两个细节值得单独说。第一个是(width - 1) - x。像素下标从 0 开始宽度为 96 的图下标范围是 0 到 95所以镜像公式是95 - x。写成width - x会让所有点整体右移一格15 个点全偏但偏差只有 1 像素在 96×96 的图上做可视化几乎看不出来——这就是它危险的地方。第二个是映射表的作用。以眼睛中心为例翻转后原来的左眼中心跑到了右边它现在的位置在语义上应该由“右眼中心”这个槽位来承载所以 0 号槽和 1 号槽的值要互换。同理 2↔4、3↔5、6↔8、7↔9、11↔12而鼻子10和上下唇中点13、14在垂直中线上映射到自己。如果只做 x 镜像、不做下标重排会发生什么坐标位置是对的但语义全乱了模型被要求用 0 号槽去预测鼻子左边的眼睛、用 1 号槽去预测右边。一半样本说 0 号在左、一半说 0 号在右模型唯一能学到的解释就是把两个槽都预测到两眼中间。表现出来就是“眼睛的两个点黏在一起”很多人会误判成模型容量不够。提示写完翻转后务必在 Notebook 里画一张三联图——原图、翻图、翻图重排后的点。三张图的点位置应该完全对称缺这一步基本等于没验证。3.3 随机裁剪越界的关键点该丢还是该置 NaN随机裁剪比翻转更麻烦因为它会让一部分关键点跑到裁剪框外面。这时候没有“正确答案”只有三种处理方式每种都有代价。处理方式做法代价整样本丢弃只要有点出界就换一张实现最简单但裁剪比例稍大就废掉大量样本置 NaN 并更新掩码出界的点标记为缺失利用率高但要求损失函数必须支持掩码坐标截断到边界clamp 到 [0, size-1]千万不要用会给模型灌入“嘴角在角落”的错误监督第三种是纯粹的坑。截断后的坐标看起来合法损失也算得出来模型会认真地去学一个根本不存在的位置。表现出来是嘴角的点在推理时总是被拉到人脸框边缘。选框的写法也要留意裁剪窗口不能纯随机得保证人脸主体还在框内。工程上常见的做法是先算出一个包含所有有效关键点的外接矩形再在这个矩形附近做小范围抖动而不是在整张图上随机开窗。def random_crop(sample, out_size96, jitter12): img, kp, mask sample[image], sample[keypoints].copy(), sample[mask].copy() h, w img.shape[:2] valid kp[mask] x0, y0 valid.min(axis0).astype(int) x1, y1 valid.max(axis0).astype(int) # 在关键点外接框上加抖动保证人脸主体一定在裁剪窗口内 cx, cy (x0 x1) // 2, (y0 y1) // 2 cx np.random.randint(-jitter, jitter 1) cy np.random.randint(-jitter, jitter 1) left np.clip(cx - out_size // 2, 0, max(w - out_size, 0)) top np.clip(cy - out_size // 2, 0, max(h - out_size, 0)) crop img[top:top out_size, left:left out_size] kp kp - np.array([left, top], dtypenp.float32) # 出界的点在掩码里抹掉交给损失函数忽略 inside (kp[:, 0] 0) (kp[:, 0] out_size) \ (kp[:, 1] 0) (kp[:, 1] out_size) mask mask inside return {image: crop, keypoints: kp, mask: mask}注意最后一步是先减偏移、再判断是否在框内顺序反了会得到一批“坐标已经出界但掩码还是 True”的样本损失里就混进了负坐标。这种错误不会报异常只会让训练慢一点、结果差一点。4. 模型与损失回归头、掩码 MSE 与训练循环数据通了之后模型部分反而是这个项目里最“标准”的一段。一个三四层的卷积堆叠接两个全连接就足以在这个数据集上得到肉眼可用的结果。真正需要想清楚的是三件事输出怎么组织、损失怎么屏蔽缺失点、训练时盯什么。4.1 输出 30 维向量还是 15×2 张量两种写法在数学上等价在工程上手感差别很大。30 维平铺全连接层直接输出 30 个数和 CSV 的列顺序天然对齐写损失时pred.view(-1)一把梭。代价是任何按点做的操作都要 reshape。15×2 张量最后一层输出 30 维后.view(-1, 15, 2)好处是掩码可以直接广播到点上逐点误差、逐点权重都写得很自然。推荐第二种因为掩码是这个项目的刚需。import torch.nn as nn class KeypointNet(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), # 96 - 48 nn.Conv2d(32, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), # 48 - 24 nn.Conv2d(64, 128, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), # 24 - 12 nn.Dropout(0.3), ) self.regressor nn.Sequential( nn.Linear(128 * 12 * 12, 512), nn.ReLU(), nn.Dropout(0.3), nn.Linear(512, 30), # 15 个点 × 2 个坐标 ) def forward(self, x): x self.features(x) return self.regressor(x.flatten(1)).view(-1, 15, 2)第一层Conv2d(1, ...)的输入通道数写 1是因为前面统一按灰度读了。如果数据集里混着彩色图而你只对部分图做了灰度转换这里就会因为批次内通道数不一致直接报错——这算幸运的不幸的版本是全部被 resize 成三通道然后模型用三倍的参数学一份重复信息。卷积堆叠里没有加 BatchNorm是因为这个项目通常在较小的显存上跑批量不会太大BN 的统计量会抖。真要用放在激活之前、并且在推理时记得model.eval()两个地方漏一个都会让验证损失莫名其妙地高于训练损失。4.2 掩码 MSE让 NaN 不参与梯度这是整个项目最核心的一行逻辑。数据里有点是 NaN直接算 MSE 会得到 NaN反传一下权重全变成 NaN之后所有的参数都是 NaN损失曲线画出来是一条断掉的空白。这是初学者遇到的第一个“黑匣子”。常见的错误修法有两种都不能用用torch.nan_to_num把 NaN 替换成 0。这等于告诉模型“这个点在人脸左上角”模型会认真地去拟合最后学出的嘴角位置会被强行拉向原点。用masked_select从预测里挑出有效的点。这能跑通但每个批次的张量形状不固定如果有哪个批次刚好全缺失直接报错。正确的做法是让掩码参与算术而不是参与索引。def masked_mse(pred, target, mask): pred, target: (B, 15, 2) mask: (B, 15) 的 0/1 张量 diff (pred - target) ** 2 # (B, 15, 2) diff diff.mean(dim2) # 每个点合成一个标量误差 (B, 15) diff diff * mask.float() # 缺失点的误差乘 0梯度自然为 0 return diff.sum() / (mask.float().sum() 1e-8)分母那个1e-8是后悔药。理论上一个批次里不可能所有点都缺失但当你为了调试把批量调成 1、又恰好抽到一张全缺失的图时除以 0 会得到 inf训练直接卡死。加个极小值代价可以忽略。还有一处需要在__getitem__里先处理target里的 NaN 必须在进损失之前就被替换成 0因为NaN - x还是 NaN乘 0 也还是 NaN。掩码的作用是让这些位置不贡献损失不是让它们不参与计算。kp np.nan_to_num(kp, nan0.0) # 放在 dataset 里配合 mask 使用顺序很关键先补零、再乘掩码。反过来就白做了。4.3 训练循环里必须盯的三个数训练循环本身没什么花样但有三类数值需要每轮打印出来否则出问题时你只能靠猜。第一类是训练损失和验证损失的比值。这个项目的数据量不大过拟合来得很快。如果训练损失还在稳步下降、验证损失已经抬头那就该加 dropout 或者提前停而不是继续加轮数。第二类是逐点平均误差最好按 15 个点分别打印。torch.no_grad() def per_point_error(model, loader, device): model.eval() total torch.zeros(15, devicedevice) count torch.zeros(15, devicedevice) for batch in loader: img batch[image].to(device) kp batch[keypoints].to(device) mask batch[mask].to(device).float() pred model(img) # 还原到像素尺度误差才有直观意义 err (pred - kp).norm(dim2) * (IMG_SIZE - 1) # (B, 15) total (err * mask).sum(dim0) count mask.sum(dim0) return (total / (count 1e-8)).cpu().numpy()打印出来通常会看到一个明显的高低分布眼睛那几个点误差最小嘴角和下颌相关的点误差最大。这和前面 CSV 覆盖率表是对得上的——不是网络结构的问题是监督信号本来就少。看清这一点就不会盲目去加深网络了。第三类是学习率与损失量级。归一化到 [0,1] 之后初始阶段的 MSE 大致在 0.0x 这个量级用1e-3的 Adam 比较稳。如果你发现损失第一天就停在 0.2 不动先回头检查坐标是不是没归一化——这是最常见的原因。4.4 推理链路检测框、灰度与反归一化训练完只是拿到了一组权重从“一张自拍照”到“脸上画好 15 个点”还差一整段链路。这段链路上每一步都必须和训练时完全对齐否则前面所有的努力都会在最后一步打水漂。import cv2, torch def predict_image(img_path, model, detector, devicecpu): img cv2.imread(img_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 和训练一致灰度 faces detector.detectMultiScale( gray, scaleFactor1.1, minNeighbors5, minSize(64, 64) ) results [] for (x, y, w, h) in faces: roi gray[y:yh, x:xw] # 紧贴人脸裁剪 roi cv2.resize(roi, (96, 96)).astype(float32) / 255.0 tensor torch.from_numpy(roi).unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): kp model(tensor).cpu().numpy()[0] # (15, 2)范围 [0, 1] # 反变换先放大回裁剪框尺寸再整体加偏移回到原图 kp[:, 0] kp[:, 0] * (w - 1) x kp[:, 1] kp[:, 1] * (h - 1) y results.append((x, y, w, h, kp)) return img, resultsdetectMultiScale的三个参数值得记一下scaleFactor控制金字塔缩放步长越小越慢越不容易漏检minNeighbors控制候选框的合并阈值调大能减少误检但会漏掉侧脸minSize是下限低于这个尺寸的框直接不看能省掉大量在小噪声上的误检。反变换那两行是整段代码里最容易写错的地方。顺序必须是“先乘尺寸、再加偏移”写成(kp offset) * size或者kp * size漏了偏移都会让点整体跑偏。而且这里用的是w - 1/h - 1和前面归一化时的size - 1是同一个约定不能一边 95 一边 96。还有一个隐蔽的问题训练时的裁剪是“按关键点外接框 抖动”推理时是“人脸检测框”这两个框的松紧程度不一样。检测框通常比关键点外接框大一圈把更多的头发和背景带进来。模型在训练时没见过这种构图预测会偏。缓解办法是在训练时故意把裁剪框放宽一点让分布更接近推理时的输入。5. 易错点排查五处坐标口径不一致损失曲线先崩这个项目报错很少但“跑得通却不对”的状态特别多。下面五处是按踩坑频率排的每一条都按“症状 → 为什么容易漏 → 怎么补”写。5.1 数据与增强侧的三处易漏点一NaN 没有在进损失前补零。为什么漏很多人只做了掩码以为乘 0 就能屏蔽。但NaN * 0的结果仍是 NaN不是 0。这个错误不会报错只会让损失从第一个批次开始就是 NaN然后所有权重变成 NaN训练看起来在跑实际一行有效梯度都没有。怎么补在 dataset 的__getitem__里统一做一次np.nan_to_num(kp, nan0.0)然后在损失里再乘掩码。两件事都做顺序不能反。写完顺手在损失函数里加一句断言assert not torch.isnan(loss)把问题挡在第一次迭代。易漏点二水平翻转只翻图像、没换下标。为什么漏翻完之后点的位置看起来是镜像的肉眼看不出问题。只有把原图和翻图的点叠在一起才会发现“左眼的点落到了右眼位置”——但因为人脸对称这个错位在视觉上极具欺骗性。怎么补翻完立刻画三联图验证原图、纯镜像图、镜像重排图。第三张的点应该和第二张完全重合。另外如果模型训练到后期眼睛的两个点始终黏在一起优先怀疑这里。易漏点三随机裁剪后没有更新掩码。为什么漏坐标减了偏移看起来还在合理范围内只是变成了负数或者大于 95。网络照样能训损失照样会降只是降得慢、最后精度差。因为一部分样本在教模型“嘴角在图像外面”。怎么补裁剪后立刻算一遍inside和原掩码做逻辑与。更保险的做法是在裁剪函数里打印一次“平均每张图保留多少个点”正常应该在 12 到 15 之间掉到 10 以下说明裁剪太激进。5.2 训练与推理侧的两处易漏点四训练归一化和推理归一化不一致。为什么漏训练管线被封装在Compose里推理代码是手写的两边的/255.0、Normalize均值方差、甚至 resize 的插值方式都可能不一样。这种不一致不会报错只会让预测点整体偏移或者尺度缩放。怎么补把归一化参数抽成一个常量或者配置文件训练和推理都引用同一份。更彻底的做法是把预处理写成可复用函数推理时直接调用训练时那份而不是重新写一遍。易漏点五模型停在 train 模式或者忘了no_grad。为什么漏model.eval()影响的是 dropout 和 BN。这个项目的网络里有 dropout训练时随机丢 30% 的神经元推理时如果没切 eval每次预测结果都不一样画出来的点会轻微抖动。而且no_grad忘了加显存会一路涨到爆。怎么补推理函数的第一行固定写model.eval()第二行固定写with torch.no_grad():。养成习惯之后就不用每次想。提示这五条里前三条属于“静默错误”不会抛出任何异常建议在 Notebook 里单独留一个 cell把这五项各写一行断言每次重启内核先跑一遍。6. 复习清单从空白 Notebook 到跑通按这个顺序自查如果要把这个项目重做一遍我大概会按下面这个顺序推进每一步都留下可验证的产物而不是一口气写完再调试。阶段要产出的东西验证方式数据读取(N,15,2)的坐标数组 (N,15)掩码打印逐点覆盖率确认和肉眼观察一致可视化随机 4 张图叠点点必须落在鼻尖、眼角上不能整体偏移归一化坐标/95、图像/255打印 min/max确认落在 [0,1]增强翻转、裁剪各自独立测一遍三联图对比点位置严格对称模型输出(B,15,2)前向一次打印形状和初始损失量级损失掩码 MSE人为把某个点置 NaN损失不应变成 NaN训练损失曲线 逐点误差逐点误差分布和覆盖率分布方向一致推理检测框 反变换在原图上画点点要跟着人脸走几个具体的检查动作比表格更管用把一张图的坐标手动画出来和原图对上。这一步能一次性抓出归一化错误、偏移错误、下标错位三类问题而这三类占了所有翻车原因的八成。把训练集的一批数据过一遍model看初始损失。归一化正确的情况下初始 MSE 应该在 0.0x 量级如果看到 200、3000 这种数说明坐标没归一化如果看到nan回去查掩码。训练 5 个 epoch 就停下来看结果不要一口气跑 50 轮。5 轮之后如果点还全部挤在图像中心说明学习率太大或者坐标尺度不对继续跑只是浪费时间。最后把推理链路单独当成一个“新任务”来做不要假设它能自动复用训练代码。我自己第一次做的时候训练侧一切正常验证损失降到了很低的水平结果推理出来的点全都贴在检测框的左上角——查了半天才发现反变换里漏了加偏移。从那以后我每次写完反变换都会立刻反向验算一遍把预测出来的归一化坐标手动乘回尺寸看看第一个点是不是落在检测框内靠近鼻子的位置不是就不往下走。希望这份清单能帮你少走两遍这条路。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →