简介:这是一份面向计算机视觉入门者的实战资源,基于dlib深度学习库,完整演示图片与视频中人脸68个关键点的检测方法,可用于人脸识别、表情分析、姿态估计等场景。压缩包共6个文件,包括两个Python脚本、一个预训练模型dat文件、示例图片与avi测试视频,整体大小约69.46MB;脚本分别覆盖图片和视频两种处理流程,模型文件无需额外训练即可直接加载,示例素材便于对照验证效果。已有346人学习下载,资源结构清晰,内容实用,适合希望快速上手关键点检测的读者。通过运行案例,可系统掌握dlib人脸检测、shape_predictor关键点预测、坐标转换与可视化标注的完整过程,同时理解68个关键点在不同面部区域的分布含义,为后续开发人脸相关应用提供扎实基础。
1. 从一张证件照到一段课堂录像:dlib 68点模型能做的事比你想的多
第一次接触 dlib 的人脸关键点检测,大多数人的场景是同一类问题:手里有一段视频或者一批图片,想数清楚画面里"谁在什么时候闭眼了、张嘴了、歪头了"。在深度学习、cv 这个行当里,这属于典型的人脸几何特征提取需求。dlib 给出的方案很朴素——用一个人脸检测器定位人脸框,再用预训练好的形状预测器输出 68 个关键点坐标。没有 CUDA 依赖,没有几百兆的神经网络权重,一个 99MB 的模型文件加两行 Python 调用就能跑通。这篇笔记把从环境搭建、单张图片检测、视频流逐帧处理到常见的翻车现场(编译失败、索引记错、检测漏人)一次说清楚。适合正在做人脸表情分析、疲劳检测或者只想快速给数据集打关键点标注的从业者。
2. 68个点是怎么排布的:索引映射与坐标语义
2.1 68 点不是均匀分布的,先记编号再谈检测
dlib 的 68 点模型最早来自 iBUG 300-W 数据集的标注规范,点的排布逻辑不是几何均匀,而是沿着面部特征轮廓线取点。整套编号规则是:
- 0 到 16:下颌轮廓线,从左侧脸颊绕下巴到右侧脸颊,共 17 点
- 17 到 21:左眉毛,5 点(从图像视角看是画面左侧的眉毛)
- 22 到 26:右眉毛,5 点
- 27 到 35:鼻梁和鼻翼,其中 27 是鼻根(眉心点),30 是鼻尖,31 到 35 是鼻翼外缘
- 36 到 41:左眼轮廓,6 点,按顺时针方向排列
- 42 到 47:右眼轮廓,6 点
- 48 到 67:嘴部外轮廓和唇线,其中 48 到 59 是外嘴唇,60 到 67 是内嘴唇
这个索引表值得打印出来贴在工位上。因为后面算眨眼(EAR)、张嘴(MAR)、头部姿态估计,全部依赖这几个区间的编号,索引一旦记错,算出来的特征曲线就是一条上下乱跳的噪声线。
用 dlib 拿到的人脸关键点对象可以直接按下标取坐标:
import dlib # 假设 shape 是 dlib.full_object_detection 对象 left_eye_start = 36 left_eye_end = 41 left_eye_points = [(shape.part(i).x, shape.part(i).y) for i in range(left_eye_start, left_eye_end + 1)] print("左眼6个关键点坐标:", left_eye_points)这段代码的逻辑很简单:通过shape.part(i)取第 i 个点的坐标,x 是横坐标,y 是纵坐标。range的右边界要加 1,因为 Python 的 range 是左闭右开区间,而 36 到 41 是闭区间,写range(36, 42)才是 6 个点。这是个很容易被忽略的边界问题——少取一个点,后面欧氏距离算出来的眼睛宽高比就完全不对。
2.2 为什么 dlib 这个 2017 年的模型至今还在用
dlib 的 68 点模型本质是一个梯度提升树回归器(ERT),用的特征是基于局部二值模式(LBP)的像素差值。相比现在动不动就上亿参数的深度神经网络,它的机制非常简单:每一级回归器根据前一级输出的形状残差,挑选若干对像素点做强度对比,然后拟合一个回归树。这套方案在 CPU 上单帧处理时间大约 1 到 3 毫秒(取决于图像尺寸和人脸数量),速度远超同期基于 CNN 的方案。
它的适用边界也很明确:正脸或小角度偏转(左右 30 度以内)表现稳定;大角度侧脸、夸张表情、极低光照会掉点。如果你的场景是课堂考勤、驾驶疲劳监测这类头部姿态不算极端的视频流,它完全够用。反之,如果要做 AR 贴纸那样的大角度精确跟踪,dlib 就不是好选择了。记住这条边界,你就能避免在错误场景里折腾。
模型文件本身不需要训练,下载shape_predictor_68_face_landmarks.dat后直接用dlib.shape_predictor加载即可。这是 dlib 官方在标注数据上训练好的成品,不是让你自己训练的框架。
3. 图片单帧检测:从加载模型到画出 68 个点
3.1 环境安装的三个关键决策点
dlib 的安装是很多人第一个翻车现场。如果你是 Windows,pip install dlib大概率会触发源码编译,然后报错说找不到 CMake 或 VS Build Tools。我的建议是:
- Linux / macOS:直接
pip install dlib,前提是系统里有 gcc 和 cmake。macOS 还需要先brew install cmake。 - Windows:先装 Visual Studio 2019 或 2022 的“使用 C++ 的桌面开发”工作负载,再装 CMake,最后
pip install dlib。不想装 VS 的话,可以用 Anaconda:conda install -c conda-forge dlib,这个渠道有预编译的 wheel,省掉整个编译过程。 - Python 版本:3.7 到 3.11 之间选一个,dlib 对 3.12 的官方支持不太及时。
安装完成后,加载模型和检测器的标准做法是:
import dlib import cv2 detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat") img = cv2.imread("test_face.jpg") img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) faces = detector(img_rgb, 1) print(f"检测到 {len(faces)} 张人脸")参数说明:detector(img_rgb, 1)的第二个参数是上采样次数。设为 1 表示把图像放大一倍再检测,能提升小人脸的召回率,代价是检测耗时增加。对一张 1080p 的图,upsample=1大约需要 100 到 200 毫秒;设为 0 则快一倍,但半张脸大小的人脸可能漏掉。我一般先在 0 下跑一遍,没检测到再提升到 1,用动态上采样控制耗时。
3.2 标注与裁剪:索引区间实操
拿到 faces 列表后,对每个框调用预测器,再逐点标注。常见做法直接用 OpenCV 的画圆函数把关键点画出来,同时按区间给不同部位标不同颜色,便于肉眼核对编号是否对应。
for face in faces: shape = predictor(img_rgb, face) for i in range(68): x, y = shape.part(i).x, shape.part(i).y # 眼睛区域用绿色,嘴巴区域用红色,其余用蓝色 if 36 <= i <= 47: color = (0, 255, 0) elif 48 <= i <= 67: color = (0, 0, 255) else: color = (255, 0, 0) cv2.circle(img, (x, y), 2, color, -1) cv2.putText(img, str(i), (x, y), cv2.FONT_HERSHEY_SIMPLEX, 0.3, (255, 255, 255), 1) cv2.imwrite("output_with_points.jpg", img)这里的color是 BGR 通道顺序,cv2.circle的第三个参数是半径(单位是像素,2 意味着 4 像素直径),第四参是颜色,第五参-1表示实心圆。putText在点旁边输出编号,方便你对着标准索引表核对哪个点没对准。
一个常见的误用是把img_rgb传给predictor但拿灰度图去做检测,两者色彩空间不一致会导致特征提取不稳定。dlib 的检测器内部会转灰度,你传 RGB 或 BGR 都能工作,但全程保持同一通道顺序更省心。
检测完如果想裁出单个人脸的区域做后续分析,用 dlib 返回的矩形框直接切片:
left, top, right, bottom = face.left(), face.top(), face.right(), face.bottom() margin = 20 # 外扩 20 像素,避免裁剪边缘 x1 = max(0, left - margin) y1 = max(0, top - margin) x2 = min(img.shape[1], right + margin) y2 = min(img.shape[0], bottom + margin) face_crop = img[y1:y2, x1:x2]margin的值取决于你的需求——外扩太大会带入背景干扰,太小会切掉下巴和额头。对 68 点分析来说,20 到 30 像素是一个比较稳的范围,因为下颌轮廓线(0 到 16 点)通常贴着脸部边缘,不留余量很容易切掉下巴上的点。
4. 视频逐帧处理:抽帧、检测与关键点跟踪的效率平衡
4.1 视频处理的三种时间预算方案
视频本质是连续的图像帧,直接对每一帧跑完整的检测器是可以的,但不划算。常见做法是按业务需求拆成三档:
- 离线批处理:每帧都检测,追求完整信息。适合事后分析,耗时以小时计也可以接受。
- 实时低延迟:跳帧检测。比如每 3 帧检测一次,中间 2 帧直接沿用上一次的关键点坐标,因为相邻帧之间人脸位移很小,关键点坐标的差异通常在 5 像素以内。
- 事件驱动检测:只在画面内容发生明显变化时重新检测。用帧差法或 MOG2 背景分割判断是否有人脸移动。
对大多数课堂考勤、会议录制分析场景,第二种方案性价比最高。视频抽帧用 OpenCV 的VideoCapture,检测和预测逻辑复用图片那套,只是加一个帧计数器的判断。
cap = cv2.VideoCapture("input_video.mp4") fps = int(cap.get(cv2.CAP_PROP_FPS)) width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) fourcc = cv2.VideoWriter_fourcc(*"mp4v") writer = cv2.VideoWriter("output_video.mp4", fourcc, fps, (width, height)) frame_idx = 0 while cap.isOpened(): ret, frame = cap.read() if not ret: break if frame_idx % 3 != 0: frame_idx += 1 continue frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) faces = detector(frame_rgb, 0) for face in faces: shape = predictor(frame_rgb, face) for i in range(68): x, y = shape.part(i).x, shape.part(i).y cv2.circle(frame, (x, y), 1, (0, 255, 0), -1) writer.write(frame) frame_idx += 1 cap.release() writer.release()这段代码里frame_idx % 3 != 0是跳帧逻辑,意思是每 3 帧只处理第 0、3、6 帧。跳帧会带来一个副作用:画面中快速移动的物体(比如转头)会呈现明显的阶梯感,标注位置会滞后一小段距离。如果要追求平滑,可以把跳帧数降到 2,或者用上一次的关键点坐标做一次简单的线性插值。
VideoWriter_fourcc(*"mp4v")是编码器标识,mp4v 是一个兼容性很好的 H.264 变体,几乎所有播放器都能读。如果你的环境里 OpenCV 编译时没带 ffmpeg 支持,VideoWriter会静默失败——就是写入的文件大小为 0,且没有任何报错。这种情况换*"XVID"编码输出 AVI 文件可以绕过去。
4.2 实时检测与效率优化:限制检测区域比缩小图像更有效
实时场景下最吃性能的不是关键点回归,而是人脸检测。detector(frame_rgb, 0)在全图搜索候选窗口,图像越大、人脸越多,耗时越长。把图像缩到一半能大致降低 4 倍耗时,但小脸就更容易漏。
一个更聪明的做法是限制检测区域。如果摄像头是固定机位,你可以先手动框定一个人脸活动区域(ROI),把frame裁剪后再传给 detector,关键点坐标需要加回 ROI 的偏移。这个思路对后台坐着听课的学生这种稳定场景非常有效,检测耗时几乎可以忽略不计。
roi = (100, 80, 540, 480) # x1, y1, x2, y2,手动标定 x1, y1, x2, y2 = roi frame_roi = frame[y1:y2, x1:x2] faces = detector(frame_roi, 1) for face in faces: # 关键点坐标需要加回 roi 偏移 shape = predictor(frame_roi, face) for i in range(68): px = shape.part(i).x + x1 py = shape.part(i).y + y1 cv2.circle(frame, (px, py), 2, (0, 255, 0), -1)测试数据:在 i5-1240P 处理器上,1080p 全图检测耗时约 80 到 120 毫秒,裁剪到 440x400 的 ROI 后降到 10 到 20 毫秒。关键点预测本身的耗时和图像大小没有关系,predictor只对 68 个点做回归,输入的是人脸框内的裁剪图像,所以不管全图多大,它的耗时恒定在 1 到 2 毫秒左右。这也是为什么真正要优化的是 detector 而非 predictor。
5. 常见问题与避坑:编译失败、漏检、关键点漂移
5.1dlib加载模型报错:文件读取失败与版本兼容
现象:dlib.shape_predictor("shape_predictor_68_face_landmarks.dat")抛出RuntimeError: Unable to load,但文件明明在同目录下。
原因:模型文件下载不完整,或者只是一个 HTML 错误页(比如用了浏览器的“保存链接”但服务器返回了 404 页面)。另一个常见情况是 dlib 版本太老,模型文件格式是带.dat后缀的旧序列化格式,新版本读不了。
解决:用 Python 检查文件大小,正常模型是 99,343,707 字节(约 94.7MB)。小于这个字节数的一律视为损坏。重新下载时不要用浏览器右键保存,用 Python 的urllib.request.urlretrieve或者wget -c做断点续传。版本方面,把 dlib 升级到 19.22 以上基本能覆盖所有模型格式。
5.2 安装编译时卡在 CMake,原因是缺少 C++ 编译链
现象:pip install dlib在 Windows 上执行到一半,控制台输出-- Building for: Visual Studio 15 2017后会长时间卡住,然后报fatal error C1083。
原因:VS 环境里的 C++ 桌面开发组件没装全,dlib 源码里的dlib/image_processing/frontal_face_detector.h依赖大量模板代码,缺编译器环境就没法编。
解决:最快路径是进 Anaconda Prompt 执行conda install -c conda-forge dlib,conda-forge 源里有预编译包,跳过本地编译。如果坚持用 pip,就先把“使用 C++ 的桌面开发”工作负载勾选完整,并安装 CMake 后确保cmake --version能在命令行输出。还有一个玄学问题是 Python 位数和 VS 位数不一致,64 位 Python 必须配 64 位的编译器组件。
5.3 视频里人脸一闪而过时关键点剧烈跳动
现象:检测框在某一帧突然消失,或者位置偏离真实人脸,导致 68 个点飞到背景上。
原因:dlib 的人脸检测器是基于 HoG 特征的,对运动模糊、大角度俯仰(低头写字的姿态)非常敏感。当人脸框不稳定时,预测器拿到错误的输入框自然输出错误的关键点。
解决:不要对每一帧都新检测一次。正确做法是给检测结果加一个“置信度保持”逻辑——如果当前帧检测到 0 张人脸,就沿用上一帧的 68 个坐标;如果检测到的人数少于上一帧,也优先沿用旧坐标,连续 10 帧检测不到才判断人脸真正离开。这个逻辑在疲劳驾驶检测里几乎是必备的,因为司机低头看仪表盘时,系统不能因为检测不到关键点就判定闭眼。
prev_shape = None loss_count = 0 while cap.isOpened(): ret, frame = cap.read() if not ret: break faces = detector(frame, 0) if len(faces) == 0: loss_count += 1 if loss_count < 10 and prev_shape is not None: # 沿用上一帧坐标 shape = prev_shape else: prev_shape = None continue else: loss_count = 0 shape = predictor(frame, faces[0]) prev_shape = shape这段代码里的loss_count是掉帧容忍计数器。它的含义是:连续 10 帧检测不到人脸才认为人脸确实离开。用上一帧坐标有个隐性问题——人脸在动,坐标是旧的,画出来的点会粘在上一帧的位置,所以只适用于分析场景(记录闭眼时长)而不适用于实时标注。如果既要稳定又要实时,就需要引入光流跟踪来修正坐标偏移,这一层复杂度就不再是 dlib 的范畴了。
5.4 同一张图片检测出多个人脸框,关键点全乱了
现象:单张证件照被检测出 3 个人脸框,其中一个框歪在背景墙的画上,预测器在框里输出一组乱飞的假关键点。
原因:HoG 检测器对纹理性强的区域(比如壁画、挂画里的脸形图案)有很高的响应,会把它们当成真实人脸。
解决:增加一个置信度判断——dlib 的detector返回的矩形对象不直接带分数,但你可以用它内部的run方法拿到检测分数和数量。设定一个正脸置信度阈值,低于阈值的框直接丢掉。处理逻辑是:先运行detector的run方法拿分数列表,按分数降序排列,只保留分数最高且面积最接近画面 20% 的框。这个闲杂框过滤对课堂多人场景尤其重要,画面里如果出现一张海报,没过滤的话关键点统计会被污染得很厉害。
5.5 关键点漂移到头发上,怎么调整才能让点贴合轮廓
现象:检测结果里下巴点(编号 7 到 9)跑到脖子区域,额头点(编号 19 到 24)粘在头发上,看起来整个轮廓外扩了一圈。
原因:dlib 的 68 点模型有约 5 到 8 像素的固有偏差,这是训练数据标注者的主观标准决定的——iBUG 数据集的标注者是按面部骨骼轮廓标注,而有的标注者倾向把下巴轮廓标得更低,模型拟合后取了一个平均值。
解决:这里的处理策略取决于下游任务。如果你做的是裁剪对齐(比如人脸识别前把脸部区域归一化),它带来的偏差无伤大雅。如果做的是特征测量(比如算半张脸宽度、眼距),你需要对特定编号做一次手动偏移校准。以我的经验,下颌轮廓点(0 到 16)向上微调 3 到 5 像素,眉部点(17 到 26)向下微调 2 到 3 像素,目测贴合度会好很多。这是一个非常工程化的操作,没有理论依据,纯粹是统计多个样本后的平均偏移量。
6. 把关键点坐标变成业务指标:疲劳检测的 EAR 计算与批量落盘
拿到 68 个坐标只是第一步,真正产生业务价值的是把坐标转成生理指标,再用阈值判断状态。最经典的是闭眼检测里广泛使用的 EAR(Eye Aspect Ratio,眼睛纵横比),原理是通过计算眼睛轮廓 6 个点之间的欧氏距离,得到一个与图像缩放无关的数值。
EAR 的分子是两条竖直方向距离之和,分母是水平方向距离。闭眼时竖直距离缩小,EAR 值骤降;睁眼时这个值大致稳定在 0.25 到 0.35 之间。计算左眼的 EAR(左眼视角的关键点是 36 到 41):
import math def eye_aspect_ratio(eye_points): # 竖直距离:第1点与第5点、第2点与第4点 vertical_1 = math.dist(eye_points[1], eye_points[5]) vertical_2 = math.dist(eye_points[2], eye_points[4]) # 水平距离:第0点与第3点 horizontal = math.dist(eye_points[0], eye_points[3]) ear = (vertical_1 + vertical_2) / (2.0 * horizontal) return ear # 抽取左眼 6 个点坐标 left_eye = [(shape.part(i).x, shape.part(i).y) for i in range(36, 42)] ear_value = eye_aspect_ratio(left_eye) print(f"当前帧左眼 EAR: {ear_value:.3f}")math.dist是 Python 3.8 以后的内置欧氏距离函数,比手动写平方根更简洁。这段代码的逻辑依赖索引顺序——36 号点是左眼最外侧点,39 号是最内侧点,它们之间构成水平参考距离;37 与 41、38 与 40 分别构成竖直参考距离。
阈值设定里有个核心细节需要关注:跨人差异。亚洲人的眼睛普遍偏细,EAR 睁眼基线可能在 0.22 左右;欧洲人基线可能到 0.32。固定阈值 0.25 会导致一部分人被误判为“永远闭眼”。我一般先取视频前 200 帧检测闭眼用户睁眼状态的平均 EAR 作为个人基线,再设警阈值 = 基线 × 0.7。这种自适应阈值远比固定阈值可靠。
批量落盘的代码,关键点坐标直接存成 JSON(JavaScript Object Notation)是最省事的格式。每帧输出一个人脸对象,内含 68 个点的 x、y 数组和当前帧的 EAR 值。后续做可视化、统计或训练集生成,全从这份 JSON 里读。
import json from collections import OrderedDict records = [] while cap.isOpened(): ret, frame = cap.read() if not ret: break faces = detector(frame, 0) if len(faces) == 0: continue shape = predictor(frame, faces[0]) left_eye = [(shape.part(i).x, shape.part(i).y) for i in range(36, 42)] ear_value = eye_aspect_ratio(left_eye) record = OrderedDict() record["frame"] = frame_idx record["ear"] = round(ear_value, 3) record["landmarks"] = [(shape.part(i).x, shape.part(i).y) for i in range(68)] records.append(record) frame_idx += 1 with open("landmarks_batch.json", "w", encoding="utf-8") as f: json.dump(records, f, ensure_ascii=False, indent=2)这份 JSON 的体量大约每帧 3KB,一小时视频约 3 万帧,数据量在 90MB 左右,直接用 pandas 读入做曲线分析完全没有压力。frame_idx += 1放在循环末尾,否则跳帧逻辑下帧号会对不齐。
那年我做课堂专注度分析时,最初直接拿固定阈值 25 判断闭眼,结果摄像头画面里一整排学生的 EAR 基线都低于 0.22,用自适应基线重新算完后才把误报压下去。这是 dlib 用得越久越能体会的事——模型给你的只是坐标,可靠指标完全靠后处理策略和技术经验。从那以后我每次跑一批新视频,都强制走一遍前三秒取基线、后三秒验证阈值、中间段重放抽错的流程。希望帮到你。
本文还有配套的精品资源,点击获取