简介:面向计算机视觉与深度学习入门者的面部关键点检测项目实践包,对应 CVND 首个项目,围绕人脸检测与眼、鼻、嘴周围关键点定位展开,可用于面部跟踪、姿态识别、滤镜与情绪识别等场景的自学与课程作业。包内收录约2000个文件,以 jpg 人脸图像样本为主,配合 ipynb 分步笔记本、png 示意图、xml 标注、csv 关键点索引及少量 Python 脚本与说明文档,压缩包约330.12MB,便于按数据、建模、检测流程检索。已有201人学习,内容将任务拆为数据加载与可视化、CNN定义与训练、Haar级联结合CNN的完整检测管线、趣味滤镜与关键点应用四部分,并附模型代码,可帮助读者理解关键点回归的训练细节、评估思路和从单图预测到端到端检测的衔接。全流程采用 Jupyter Notebook 组织,适合按编号顺序复现与二次修改,也能为课程展示和项目复盘提供参考。
1. 从一个困惑说起:手机相机里那圈跟着你动的小点是什么
你有没有盯着手机前置摄像头看过:一张脸凑过去,屏幕上浮出一圈小点,眼角、鼻尖、嘴角,一共十几个,你歪头它也跟着歪。这圈东西就是人脸关键点,英文叫 Facial Keypoints。这份叫 P1_Facial_Keypoints 的资源,就是一个用 Jupyter Notebook 带你亲手把这件事做一遍的入门项目:输入一张人脸照片,让模型吐出 15 组坐标,再把这些点画回脸上。它不需要你先啃完一本深度学习教材,也不用先学完一整门课,只要你能打开一个笔记本页面、照着敲几行 Python,就能看到第一张被标出五官的脸。适合完全没跑过模型、但想真正动手一次的新手。
2. 拆开这份资源:一个 Jupyter Notebook 里装着怎样一条流水线
2.1 它到底在解决什么问题
先把任务说清楚,后面才不会糊。这个项目做的事,本质上就是「填表」:给一张人脸图片,填出一张 15 行的表,每行两个数字,分别代表某个五官位置在图片里的横坐标和纵坐标。左眼中心一行、右眼中心一行、鼻尖一行、嘴角左右各一行……凑齐 15 行,也就是 30 个数字。
为什么是 30 个数字而不是别的?因为每个点要两个数才能定位:一个管左右,一个管上下。这 30 个数字按固定顺序排成一串,就是一个长度为 30 的向量。模型要学的,就是从「一张图」到「这串数字」的映射关系。
这里有个新手常混淆的地方,值得多说两句。常见的图像任务分两类:一类叫分类,回答「这张图是猫还是狗」,输出是几个类别里挑一个;另一类叫回归,回答「这个数值是多少」,输出是连续的数字。人脸关键点属于后者——它不是在猜「这是不是一张脸」,而是在算「这张脸上鼻尖大概在第 137 像素、第 92 像素的位置」。既然是算数字,误差就可以用「预测值和真实值差了多少」来衡量,这也是为什么这类任务常用均方误差当损失函数:差得越远,惩罚越大,模型就往差得少的方向调。
那这东西学了有什么用?最直接的是给相机做贴纸和特效:知道眼睛在哪,才能把猫耳朵戴到头顶而不是戴到下巴上。往深一点走,可以做表情驱动——把关键点的位移翻译成表情参数;可以做视线追踪,判断你在看屏幕的哪个角落;也可以做图像对齐,换脸、修图、人脸变形都靠先找到这些基准点。这些应用听着远,但它们的第一步完全一样:先把 15 个点稳稳地找出来。这个项目就是那第一步。
再说数据长什么样。资源里的数据通常由两部分组成:一堆人脸图片文件,和一张表格。表格每行对应一张图,第一列是图片文件名,后面若干列是这张图上那 15 个点的坐标。所以你打开表格,看到的就是一行一行的数字,没有图片——图片是分开存的,文件名是它们之间的钥匙。这个「图片 + 表格」的组合,是后面所有步骤的地基,先把这层关系记住,看代码时就不会晕。
2.2 打开一个 Notebook 你会看到什么
Jupyter Notebook 最反直觉的地方,是它不像普通程序那样从头跑到尾。它是一格一格(cell)的,每一格可以是文字,也可以是代码,你可以只跑其中一格,跑完结果就留在页面上。你可以把它想成一本实验记录本:上面写着说明,下面是能直接执行的小实验,做完一个看一个结果。
这份资源的结构大致是这样的顺序,你可能第一眼看到会觉得格数很多,其实就六段:
第一段是读数据。把图片路径、关键点表格、训练集和验证集的划分准备好。这一段跑完,你能看到表格的形状,比如有多少行多少列。
第二段是看数据。随机挑几张图,把图画出来,再把关键点叠上去。这一段是给人看的,不是给模型看的——你要亲自确认「表格里的坐标和图片对得上」,否则后面训练半天全是错的方向。
第三段是预处理。把人脸区域裁出来、把图片统一成同一个尺寸(常见是 96×96 或 224×224)、把坐标从「像素值」换算成「相对比例」。这一步是所有新手最容易出错的地方,第 5 章会专门讲。
第四段是定义模型。通常是一个小型卷积网络:几层卷积加池化负责看,最后接几层全连接负责算,输出长度是 30。
第五段是训练。设定损失函数、优化器、迭代轮数,然后循环:前向算预测、算损失、反向传梯度、更新参数。每一轮打印一次损失,你能看到它从大变小。
第六段是预测和可视化。拿验证集里没见过的图,预测出 15 个点,画在图上,用眼睛判断准不准。
提示:第一次打开时不要急着「全部运行」。一格一格往下跑,每跑完一格就看输出,出问题你能立刻知道是哪一步出的。
还有一点要提醒:Notebook 里的变量是「记住」的。你在第一格定义了变量,跑到第十格它还活着。这是它的方便之处,也是它的陷阱——如果中间某格你改过又没重跑,后面的结果可能是旧的变量算出来的,看起来就像「代码明明改了却没生效」。遇到这种玄学情况,第一反应应该是「从第一格重新跑一遍」,而不是盯着代码找 bug。
2.3 为什么它适合零基础,而不是先学三个月理论
很多新手会问:我是不是得先看完线代、概率、反向传播推导,才有资格碰这种项目?答案是反过来的。先跑通,再回头补理论,效率高得多。
第一,反馈极其直观。分类任务你只能看到一个准确率数字,90% 还是 92%,你其实没什么感觉。关键点任务不一样,预测完直接画在脸上,准不准你一眼就能看出:眼睛的点是落在眼珠上还是歪到鼻梁上。这种「肉眼可见对错」的反馈,对新手建立感觉特别重要。
第二,数据量小、训练快。这种入门数据集通常只有几千张脸,用一张普通显卡甚至只用 CPU,跑几轮也就几分钟。你可以在一个下午里试五六种改动,而不是等一晚上才看到一次结果。
第三,脚手架已经搭好了。你不需要从「怎么读取一张图片」写起,读数据、画图、训练循环这些重复劳动,资源里大半已经给你写好了。你要做的是在已有的格子里改几个数字,然后看结果怎么变。这种「改一点、看一点」的节奏,比从零搭整个项目轻松太多。
第四,它把深度学习的完整流程走了一遍,一个都没漏:数据、预处理、模型、损失、训练、评估、可视化。走完一遍之后,你再看别的项目,会发现它们只是把其中某一段换掉了,骨架是同一副。
走完这个项目,你能带走的东西很具体:你知道一张图片在程序里其实是一个数字矩阵;你知道坐标要先归一化再喂给模型;你知道损失不降的时候该先怀疑学习率和数据;你能独立把一个 Notebook 从第一格跑到最后一格并解释每一步在干什么。这些听起来朴素,但它们是往后所有计算机视觉任务的地基。
3. 动手前的一小时:把运行环境配成「能跑的样子」
3.1 先分清三样东西:Python、Notebook、框架
新手最容易在装环境这一步翻车,原因是把三个不同层级的东西混为一谈。
Python 是语言本身,是地基。你写的每一行代码最终都由它执行。
Jupyter Notebook 是一个「运行界面」,它让你在浏览器里一格一格地写和跑 Python。它本身不是语言,也不是框架,它是壳。你可以用命令行跑 Python,也可以用这个壳跑,壳的好处是结果能直接留在页面上,图也能直接显示。
深度学习框架是「工具箱」,比如 PyTorch 这类。它把卷积、梯度、张量运算这些底层活封装成一行调用,你不用自己算导数。这份资源可能用其中某一种,跟着它的代码走就行,不用为了「哪个更好」纠结。
三者的关系是:Notebook 里写 Python,Python 调用框架。所以顺序是——先有 Python,再有 Notebook,再装框架。顺序反了就会出现「明明装了却 import 报错」这类问题。
3.2 装依赖:按顺序执行的三条命令
假设你用的是 conda 这类环境管理工具(新手强烈建议用,别直接在系统 Python 里装东西,装崩了很难收拾),可以按下面的顺序走。先建一个独立环境,把它想成一个专用的抽屉,里面的包互不干扰:
# 建一个独立环境,指定 Python 版本,避免和系统里的包打架 conda create -n keypoints python=3.10 -y # 进入这个环境,之后所有安装都发生在抽屉里 conda activate keypoints # 安装 Notebook 本体,用来打开 .ipynb 文件 pip install notebook接着装计算和画图要用的包。这一步建议一次装完,避免后面一格一格报错、一格一格补:
# 数值计算、表格处理、画图、读图片 pip install numpy pandas matplotlib pillow # 深度学习框架,按资源里 import 的那个名字选一个装 pip install torch torchvision装完之后,在项目目录下执行启动命令:
# 启动 Notebook,浏览器会自动打开一个带文件列表的页面 jupyter notebook执行完你会看到一个网页,里面列出当前目录的所有文件。点开那个后缀是 .ipynb 的文件,就是这份资源的主体。如果浏览器没自动弹出来,终端里会打印一个带端口的地址,复制到浏览器里打开即可。
注意:启动命令要在「资源所在的那个目录」下执行。在别的目录启动,页面里就看不到文件,很多新手会以为文件丢了。
3.3 检查环境是否真的通了
装完不代表能用,一定要验证。新建一格,把下面这段贴进去跑:
import sys import numpy as np import pandas as pd import matplotlib.pyplot as plt import torch # 打印 Python 版本,确认跑的是刚建的那个环境而不是系统自带的 print("python:", sys.version.split()[0]) print("numpy:", np.__version__) print("torch:", torch.__version__) # 有独立显卡的话这里会打印 True,没有就是 False,CPU 也能跑这个项目 print("cuda 可用:", torch.cuda.is_available()) # 造一个小张量做一次真实运算,确认框架不是"装了个空壳" x = torch.randn(2, 3) print("张量形状:", x.shape, "均值:", round(x.mean().item(), 4))这段的作用是逐一确认四件事:Python 版本对不对、几个包能不能 import、框架有没有真的装好、显卡能不能用。四行输出全打出来且不报错,环境就算通了。
如果torch.cuda.is_available()打印 False 而你确实有显卡,别慌,先按 CPU 跑。这个项目的数据量小,CPU 跑几轮也能出结果,只是慢一点。环境问题留到后面再修,先让自己看到第一个能跑的模型,比卡在装环境上三天要值。
再补一个新手常忽略的检查:确认当前 Notebook 的工作目录。跑import os; print(os.getcwd()),看看打印出来的路径是不是资源所在的目录。后面所有读文件的相对路径,都是相对这个目录算的。路径不对,读数据那一格一定报「找不到文件」,而这跟代码写得对不对毫无关系。
4. 跑通第一个例子:一张人脸图变成 15 个坐标点的三步
4.1 先把数据读进来,看看一张脸长什么样
这一步的目标很朴素:确认你能拿到图片,也能拿到对应的坐标。下面这段是通用写法,具体文件名以你解压后看到的为准:
import numpy as np import pandas as pd import matplotlib.pyplot as plt from PIL import Image # 读关键点表格:每行一张图,第一列是图片文件名,后面 30 列是 15 个点的坐标 keypoints = pd.read_csv("data/training_frames_keypoints.csv") # 看看整体规模:多少张图、多少列 print("表格形状:", keypoints.shape) # 前几列的名字,确认第一列确实是文件名 print("前几列:", keypoints.columns[:4].tolist()) # 取第一张图的前 6 个数字,也就是前 3 个点的 (x, y) print("第一张图前三个点:", keypoints.iloc[0, 1:7].values)读完之后要做一件事:确认列的顺序。很多关键点表格的坐标不是按「x1, y1, x2, y2」这样成对排列,而是「所有 x 排一起,所有 y 排一起」。这两种排法在画图时结果完全不一样,一个画得对,一个画出一堆乱七八糟的点。所以别急着往下走,先打印几列看看数值范围:如果某一列的值普遍在 0 到 200 之间,那它大概率是像素坐标;如果出现负数或者超过图片宽度很多的值,说明数据本身有问题或者列理解错了。
再把图片读进来看看:
# 按表格里的文件名去图片目录找图,转成 RGB 三通道 img = np.asarray(Image.open("data/training/000001.jpg").convert("RGB")) # 打印形状:(高, 宽, 3),三个数字分别对应行数、列数、通道数 print("图片形状:", img.shape, "数据类型:", img.dtype) # 画出来看看 plt.imshow(img) plt.axis("off") plt.show()图片在程序里就是一个三维数组:高、宽、通道。img.shape打印出(96, 96, 3)这样的结果,意思是 96 行像素、96 列像素、3 个颜色通道。新手看到「图片变成数字」经常觉得抽象,其实不抽象——每一个像素就是三个 0 到 255 的数字,分别代表红绿蓝的强度。整张图就是这些数字排成的一张表。
提示:读进来的图片尺寸往往不统一。有的 96×96,有的 250×250。后面喂给模型前必须统一,否则网络层会直接报维度不匹配。
4.2 把关键点画上去,用眼睛验证数据
这是整个流程里最值得花时间的一步,也是最容易被跳过的一步。画一次图,能省掉后面几小时的排查。
# 从表格里取第一张图的 30 个坐标,重排成 15 行 2 列:每行是一个点 pts = keypoints.iloc[0, 1:].values.astype("float32").reshape(-1, 2) fig, axes = plt.subplots(1, 2, figsize=(8, 4)) # 左图:原图 axes[0].imshow(img) axes[0].set_title("original") axes[0].axis("off") # 右图:原图 + 关键点。x 是横坐标(列),y 是纵坐标(行) axes[1].imshow(img) axes[1].scatter(pts[:, 0], pts[:, 1], s=14, c="red", marker="x") axes[1].set_title("with keypoints") axes[1].axis("off") plt.show()几个参数值得说清楚。reshape(-1, 2)里的 -1 是「这一维让程序自己算」,30 个数字自然排成 15 行 2 列。s=14是点的显示大小,图小的时候调大一点才看得清。c="red"是颜色,marker="x"是标记形状,用叉号比圆点更容易看清点落在哪个位置。
画出来之后怎么判断对不对?看三件事。第一,点的数量是不是 15 个。第二,点是不是集中在脸部区域,如果有一半的点飘到背景里,说明坐标和图片没对上,很可能是文件名匹配错了行。第三,左右眼的两个点是不是水平对称的,如果明显一高一低,可能是坐标的列顺序被搞混了。
这一步的真正价值在于:它是你唯一能用眼睛验证「数据和标签是配套的」的机会。跳过它直接训练,模型也能跑,损失也能降,但你可能训练了一个小时才发现自己一直在学一堆错位的标签。这种血泪经验,几乎每个入门的人都交过一次学费。
4.3 搭一个最小模型,让损失先降下来
现在到了最有仪式感的一步。先别追求精度,先追求「能跑通、损失在降」。下面这个网络很小,三层卷积加两层全连接,输入 96×96 的图,输出 30 个数字:
import torch import torch.nn as nn class TinyKeypointNet(nn.Module): def __init__(self): super().__init__() # 三层卷积:每层后接 ReLU 和池化,把 96x96 一路缩到 12x12 self.features = nn.Sequential( nn.Conv2d(3, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 96 -> 48 nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 48 -> 24 nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 24 -> 12 ) # 全连接头:把特征图拉平,最后回归出 30 个坐标值 self.head = nn.Sequential( nn.Flatten(), nn.Linear(64 * 12 * 12, 128), nn.ReLU(), nn.Linear(128, 30), ) def forward(self, x): return self.head(self.features(x)) # 造一个假输入验证输出形状,这是养成习惯的好动作 model = TinyKeypointNet() print(model(torch.zeros(1, 3, 96, 96)).shape) # 期望 torch.Size([1, 30])Conv2d(3, 16, 3)三个参数分别是输入通道、输出通道、卷积核大小。第一层的输入通道必须是 3,因为图片是 RGB 三通道,写错了会直接报错。MaxPool2d(2)是每次把长宽各缩一半,所以 96 经过三次池化变成 12,全连接层的输入就是64 * 12 * 12。这个数字必须和前面算出来的一致,差一个数就报维度错误——新手最常卡的地方就在这。
再往下是训练循环:
import torch # 坐标回归用均方误差:预测偏离真实值越远,惩罚越大 loss_fn = nn.MSELoss() # 学习率 1e-3 是这类小项目的稳妥起点,太大容易震荡,太小降得慢 optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(5): model.train() total = 0.0 for images, targets in train_loader: # images: [N,3,96,96] targets: [N,30] pred = model(images) # 前向:算出一批预测坐标 loss = loss_fn(pred, targets) # 算这批预测和真实值差多少 optimizer.zero_grad() # 清掉上一轮的梯度,不清会累加 loss.backward() # 反向:算出每个参数该往哪调 optimizer.step() # 按梯度真的调一次参数 total += loss.item() print(f"epoch {epoch}, loss {total / len(train_loader):.4f}")train_loader需要你自己封装一下:把图片统一缩放、转成张量,把坐标归一化到 -1 到 1 之间,然后按批次打包。如果资源里已经写好了这部分,直接用,别重复造。
跑起来之后盯着 loss 看。正常情况是从一个较大的数(比如 0.1 上下)一路往下降,前几轮降得明显,后面变缓。如果五轮下来几乎没动,先别怀疑模型结构,去看第 5 章的第一条和第二条——八成是数据归一化或者学习率的问题。
5. 新手最容易翻车的四个地方:现象、原因和修法
5.1 画出来的图是黑的或者颜色很怪
现象:plt.imshow显示出来的脸要么一片黑,要么颜色像反相,红脸变青脸。
原因有两个。一是通道顺序。有些读图库默认按「蓝绿红」的顺序给出通道,而画图和预训练模型都期望「红绿蓝」,顺序反了颜色就错乱。二是数值范围。有的流程会把像素除以 255 变成 0 到 1 的小数,如果这里被做了两次归一化,或者被减了均值,显示出来自然一片灰黑。
解决:读图之后立刻打印img.shape和img.min()、img.max()。正常像素值应该落在 0 到 255 之间。如果通道顺序可疑,用img[:, :, ::-1]反转一下通道再画,颜色对了就说明是这个问题。显示和训练是两件事,训练前该归一化就归一化,但画图给人看的时候记得还原成原始像素。
5.2 损失一直不降,甚至越跑越大
现象:训练五轮、十轮,loss 在同一个数字附近晃,或者干脆往上窜。
原因通常是学习率不合适或标签没归一化。坐标是像素值,动辄几十上百,而模型输出的初始值在 0 附近,两边的量级差太远,梯度会非常大,参数一更新就跳飞。另外学习率设成 0.1 这种大值,在这种小网络上很容易震荡。
解决:先把坐标归一化到 -1 到 1 之间再算损失,预测时再乘回去;学习率从 1e-3 开始试,不降就降到 1e-4。还有一个隐蔽原因:忘记清梯度。如果optimizer.zero_grad()写在了backward()后面或者干脆没写,梯度会一轮轮累加,表现为损失突然爆掉。这几个都是新手常踩的坑,按顺序排查,八成能定位。
5.3 点全部挤在图片的一个角落
现象:训练完了,预测出来的 15 个点全部堆在左上角或者正中间一小团,看起来像模型「摆烂」。
原因:归一化和反归一化没配对。训练时你把坐标压到了 -1 到 1,预测时忘了乘回图片尺寸,那 30 个 0 附近的小数画在几百像素的图上,自然全挤在原点附近。反过来也有:训练时没归一化,预测时却乘了一次,点会飞到图外面去。
解决:把「归一化」和「反归一化」写成两个小函数,训练和预测时严格用同一对。归一化用「减最小值除范围」,反归一化用「乘范围加最小值」,两边参数必须一致。写完先拿一张训练集里的图自测:用真实标签归一化再反归一化,看能不能还原成原来的坐标,还原得回来,说明这对函数是对的。
5.4 明明有显卡,跑起来却像在爬
现象:torch.cuda.is_available()是 True,但训练一轮要好几分钟,风扇也不怎么转。
原因:张量和模型没有搬到同一块设备上。框架默认把新创建的张量放在 CPU 上,模型如果也留在 CPU,那显卡就一直闲着。只有模型和数据都在显卡上,计算才会真正走显卡。
解决:在定义模型之后加一句model = model.to(device),在每一批数据取出之后加一句images, targets = images.to(device), targets.to(device),其中device提前定义为torch.device("cuda" if torch.cuda.is_available() else "cpu")。注意预测和画图时要把张量搬回 CPU:pred.cpu().numpy(),否则和画图库配合时会报设备不匹配。
6. 走完这一站往哪去:从 15 个点到完整项目的学习路线
跑通这个项目之后,最怕的是「跑完了,然后呢」。下面这条路径是我带新手时最常用的一条,从近到远排,每一步都能独立看到结果,不用等学完才敢动手。
| 顺序 | 下一步做什么 | 具体学什么 | 大概投入 |
|---|---|---|---|
| 1 | 把当前模型调好一点 | 加数据增强(随机翻转、轻微旋转)、换成更深的骨干网络、加早停 | 一到两个晚上 |
| 2 | 学会看训练过程 | 画损失曲线、留出验证集、区分过拟合与欠拟合 | 一个晚上 |
| 3 | 换一个相近任务 | 人体姿态关键点、手部关键点,流程几乎一样,只是点的数量变了 | 两到三天 |
| 4 | 补一层理论 | 卷积在算什么、感受野是什么、批归一化为什么有用 | 边做边补 |
| 5 | 做成能演示的东西 | 读摄像头画面、实时画出关键点、做成一个小界面 | 一周左右 |
第 1 步最值得先做,因为收益最直观。加一个随机水平翻转,标签里的横坐标要跟着镜像,这一步能让你的验证误差明显下降,也能让你真正理解「数据增强不只是把图转一下,标签也得跟着动」。
第 4 步要提醒一句:理论不用等学完再做项目,但也别一直不补。我的建议是「遇到再补」——模型不收敛的时候去查学习率和归一化,精度上不去的时候去查过拟合和正则化。带着问题去看,比从头啃教材记得牢得多。
还有一个小技巧,是我自己一直在用的:每开一个新项目,先在 Notebook 最上面写三行注释——输入是什么、输出是什么、成功标准是什么。这个项目就是「输入一张人脸图 / 输出 15 个点的坐标 / 成功标准是点能画在五官上」。别小看这三行,它能挡住很多「写了一堆代码但不知道自己要做啥」的时刻。
我刚开始学的时候,最大的教训是急着调模型结构。第一次跑这个项目,损失不降,我第一反应是「网络太浅了」,于是加卷积层、加通道数,折腾了一整晚,最后发现问题出在坐标忘了归一化——数据这一层错了,模型再深也没用。从那以后我每次跑新流程,都强制按「先看一张图、再看一组标签、再跑一个批次、最后才看损失」的顺序走一遍,任何一步的输出不符合预期就停下来。这个习惯帮我省下的时间,比任何调参技巧都多。希望帮到你。
本文还有配套的精品资源,点击获取