简介:iris_tracking_sample.zip 面向在 Windows 10 下使用 Mediapipe Iris 进行眼部关键点追踪的开发者,聚焦眼角、眼睑、眼球边界与虹膜坐标的获取,可用于 AR/VR、生物识别及健康监测等场景的原型验证。包内共 5 个文件,以 cpp、cc、h 源码和 BUILD 构建配置为主,压缩包约 7KB,体量轻巧,便于快速浏览流水线组织与接口调用。已有 411 人学习,说明该示例在入门与实践参考上有一定关注度。读者可从中了解 Iris 流水线的初始化、图像输入、逐帧处理与结果可视化思路,并参考 Landmark 输出的解析方式,为后续调整检测精度与速度、结合 FaceMesh 扩展面部追踪功能提供起点。
1. 摄像头前面坐一天,我只想要那两个瞳孔坐标
接手一个用摄像头做视线交互的 demo 时,我第一反应是找现成的瞳孔检测,结果一圈试下来,阈值法怕反光、Haar 怕睫毛、传统 68 点人脸关键点根本没有瞳孔位置。后来把 MediaPipe Iris 拉进来跑了一版,478 个面部关键点里专门留了 10 个给虹膜,每只眼睛 5 个,中心那个就是瞳孔中心,坐标直接给归一化值,这个坑才算填上。iris_tracking_sample.zip 就是这么一份最小可跑的示例:图片、视频文件、摄像头三条输入通道,输出关键点坐标加上可视化叠加。适合谁?想在一两天内把眼动、眨眼、注视方向这类功能先跑出个能演示的版本,再决定要不要上自训练模型的开发者,拿它当第一块垫脚石正合适。
2. 项目定位与技术选型:iris_tracking_sample 补上了哪 10 个点
把这份示例当成“调个库”就低估它了。它真正解决的问题是:在没有专用眼动仪、没有红外摄像头、只有一颗普通 RGB 网络摄像头的条件下,稳定拿到瞳孔在画面里的二维位置。这条路线上,市面上常见的选择就那么几种,各自能走到哪一步,值得先说清楚,因为这直接决定你后面改代码的成本。
用 OpenCV 做阈值分割找瞳孔,是最便宜的一条路:灰度化、二值化、找轮廓、取质心。它在实验室的均匀光线下能跑,但一到真实环境就崩——屏幕反光、睫毛遮挡、眼镜片上的倒影,任何一个都能让质心跑到眼角去。用传统人脸关键点做,问题换了一种:面部轮廓是有了,眼睑、眼角都有,但眼睛内部是空的,没有瞳孔,你只能靠眼角的中点去猜注视方向,精度天然差一截。MediaPipe Iris 走的是第三条路:在面部网格的基础上,用一个专门的虹膜细分模型把眼睛内部的 5 个点回归出来,其中 1 个是虹膜中心,另外 4 个落在虹膜边缘上。这条路的好处是不挑硬件,普通摄像头就够。
2.1 468 与 478 的差别,全在 refine_landmarks 这一个参数上
很多人第一次用会踩这个坑:跑完之后len(face_landmarks.landmark)是 468,找不到文档里说的 468 到 477 号点。原因不在模型,在初始化参数——refine_landmarks默认是False,它关掉的时候只输出基础的面部网格 468 点;打开之后,模型会在眼睛区域再跑一次细分,把虹膜那 10 个点补到队尾。这个参数是整个项目的地基,开与不开,输出结构完全不同。
补上的这 10 个点,索引和含义是一一对应的:
| 索引 | 归属 | 含义 |
|---|---|---|
| 468 | 第一只眼 | 虹膜中心(瞳孔中心近似) |
| 469 – 472 | 第一只眼 | 虹膜边缘 4 点,绕中心分布 |
| 473 | 另一只眼 | 虹膜中心(瞳孔中心近似) |
| 474 – 477 | 另一只眼 | 虹膜边缘 4 点,绕中心分布 |
需要提醒的是,模型内部对“左眼”“右眼”的命名,是按输入图像的坐标来的,不是按被拍摄者的生理左右。如果你后续接了镜像显示(自拍场景几乎一定会接),这两组索引在画面上会左右对调。我一般的做法是:不纠结命名,代码里统一叫eye_a和eye_b,靠中心点的 x 坐标大小来区分解算,画面翻转时逻辑不用动。
提示:索引顺序(469 到 472 究竟是顺时针还是逆时针)在不同输入下可能有细微差别,如果你的算法依赖“哪个点是上边缘”,别写死索引,用 y 坐标最小/最大去找,更稳妥。
2.2 目录结构与依赖:一份能两天跑通的最小骨架
示例包的目录组织得很克制,没有为了“看起来像工程”而堆分层。拆开大致是这么几块:
iris_tracking_sample/ ├── data/ │ └── test_face.jpg # 一张带正脸的测试图,先验模型能不能出 478 点 ├── src/ │ ├── detect_image.py # 单图入口,最简的一根线 │ ├── detect_video.py # 视频文件入口,逐帧读、逐帧写 │ ├── detect_webcam.py # 摄像头入口,实时循环 │ ├── iris_utils.py # 虹膜点位、EAR、偏移量等纯计算函数 │ └── draw_utils.py # 所有 cv2 绘制逻辑集中在这里 ├── requirements.txt └── README.md这个结构的价值在于:三个入口脚本只负责“取帧 + 显示”,真正的业务计算全在iris_utils.py里,绘制全在draw_utils.py里。你后续要把它塞进自己的工程,只需要搬这两个文件,入口脚本扔掉都不影响。我见过太多示例把检测、计算、绘制、UI 全揉在一个 300 行的main.py里,改一行要通读全文,那种代码只适合演示,不适合二次开发。
依赖清单也很短,基本就是三件套:
# 建议单独建虚拟环境,MediaPipe 的依赖树不算浅 python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install opencv-python mediapipe numpyopencv-python负责取帧、颜色空间转换和绘制;mediapipe提供 FaceMesh 与虹膜细分;numpy用于关键点数组化之后的角度、距离计算。这三个装齐,示例就能跑。不用装 TensorFlow,也不用单独下载模型权重文件——模型是随包分发的,第一次process()调用时会从包内加载,这点对新手很友好,省掉了“模型路径配错”这类经典事故。
2.3 三条输入通道:图片、视频文件、摄像头
三个入口脚本对应三种典型调试阶段,不是简单重复。
单图入口detect_image.py是最先该跑的。它的作用是排除变量:不涉及帧率、不涉及摄像头驱动、不涉及写入编码器,只验证“模型能不能在我这台机器上把 478 个点吐出来”。我调试任何视觉项目都从这一步开始,因为后面所有问题(卡顿、闪退、坐标漂移)在单图阶段都不会出现,一旦单图能出正确结果,问题范围立刻缩小到输入源或循环逻辑上。
视频文件入口detect_video.py的价值在于可复现。摄像头是实时的,出了问题你没法倒回去看那一帧到底长什么样;视频文件可以反复喂同一段素材,改一版跑一遍,逐帧对比输出。做参数调优(比如检测阈值、平滑系数)时,这是唯一靠谱的回归手段。
摄像头入口detect_webcam.py才是最终形态,它比前两个多出来的东西是:帧循环、按键退出、实时 FPS 显示。这三样看着简单,但决定了你能不能直观感受到延迟。注视方向这类交互,端到端延迟超过 150 毫秒人就能明显感觉到“拖”,所以在摄像头入口里挂一个 FPS 计数是必要的,不是装饰。
3. 核心链路拆解:一帧图像从进入到拿到虹膜坐标
这条链路的长度其实很短:读帧 → 转 RGB → 送进模型 → 从结果里取关键点 → 换算坐标。短归短,中间每一步都有容易写错的地方,尤其是颜色空间和坐标系这两处,堪称新手劝退双雄。下面按调用顺序拆。
3.1 FaceMesh 初始化:refine_landmarks 是总开关
模型对象不要在每个帧循环里创建,那等于每帧重新加载一次计算图,帧率会直接掉到个位数。正确的做法是在循环外创建一次,循环内反复调用process()。初始化代码如下:
import mediapipe as mp mp_face_mesh = mp.solutions.face_mesh def create_face_mesh(static_mode=False, max_faces=1): """创建一个 FaceMesh 实例,虹膜点依赖 refine_landmarks=True""" return mp_face_mesh.FaceMesh( static_image_mode=static_mode, # 图片用 True,视频/摄像头用 False max_num_faces=max_faces, # 虹膜细分只在单脸上准,建议就设 1 refine_landmarks=True, # 关键:不开只有 468 点,没有虹膜 min_detection_confidence=0.5, # 首帧人脸检测阈值 min_tracking_confidence=0.5, # 后续帧关键点跟踪阈值 )参数逐个说清楚,因为它们直接影响你后面遇到的绝大多数问题。
static_image_mode决定模型走哪种策略。设为True时,每一帧都当作独立图片重新做人脸检测,好处是不会把上一帧的错误带到下一帧,坏处是慢。设为False时,首帧做检测,后续帧走跟踪,速度快很多,但如果人脸快速移动或者短暂离开画面,跟踪可能跟丢之后需要几帧才能恢复。所以单图脚本传True,视频和摄像头脚本传False,这不是风格问题,是性能问题。
max_num_faces建议保持 1。虹膜细分模型在多人场景下的表现并不均衡,检测到多张脸时,返回的列表顺序在不同帧之间可能变化,你按索引 0 取脸,就会看到关键点在两个人之间来回跳,表现为画面剧烈闪烁。做单用户交互时,设成 1 从源头规避这个麻烦。
两个 confidence 阈值是权衡项。调低(比如 0.3)能让你在侧脸、暗光下更容易检出,代价是误检率上升,可能出现“画面里没人也返回一张脸”的玄学现象;调高(比如 0.7)检测更严格,但用户在画面边缘或者低头时会频繁丢帧。0.5 是个能覆盖大多数室内场景的起点,实际项目里我一般会把min_tracking_confidence稍微调低一点到 0.4,让跟踪更黏,减少闪烁。
3.2 单帧推理:BGR 到 RGB 的转换与结果读取
OpenCV 读进来的帧是 BGR 顺序,MediaPipe 要的是 RGB。这一步写错的话不会报错,只会得到一个“检测不到人脸”的结果,非常容易误判成模型坏了。完整的一帧处理函数长这样:
import cv2 def detect_frame(frame_bgr, face_mesh): """输入 BGR 帧,返回像素坐标下的 478 个关键点,无人脸时返回 None""" h, w = frame_bgr.shape[:2] # 1. BGR -> RGB,这一步漏了就是永远检测不到 rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB) # 2. 标记为只读,避免 MediaPipe 内部拷贝,帧率能抬一点 rgb.flags.writeable = False # 3. 推理 results = face_mesh.process(rgb) if not results.multi_face_landmarks: return None # 4. 取第一张脸,把归一化坐标换算成像素坐标 face = results.multi_face_landmarks[0] points = [] for lm in face.landmark: points.append((lm.x * w, lm.y * h, lm.z * w)) return points逐段解释。cv2.cvtColor那行是硬性要求,没有替代方案。rgb.flags.writeable = False是个小优化,告诉底层这块内存不会被改写,省掉一次防御性拷贝,单帧看不出来,跑实时的时候能省几个毫秒。
results.multi_face_landmarks是个列表,没检测到脸时是None,不是空列表,判断的时候别写if len(...) == 0,会抛异常。
最关键的是最后那段坐标换算。模型输出的lm.x、lm.y都是 0 到 1 之间的归一化值,相对于输入图像的宽高。你要画图、要算距离,就必须乘回像素尺寸。lm.z稍微特殊一点:它是以头部中心为原点的相对深度,量纲和 x 相同(也是按图像宽度归一化的),所以换算成像素尺度要乘w而不是乘某个固定的数。这个 z 值不是真实物理距离,只能用来做相对比较——比如判断鼻子和耳朵谁更靠前——不能直接当厘米用,后面讲深度估计时会用另一种方式绕开它。
注意:归一化坐标乘以
w和h得到的是原图像素坐标。如果你在送入模型之前对帧做了缩放(比如为了提速缩到 640 宽),那这里乘的必须是缩放后的尺寸,否则画出来的点会整体偏移。我吃过这个亏,画面上的人脸和关键点整整错开半个身位。
3.3 归一化坐标到像素坐标:先统一尺度再谈算法
拿到 478 个像素点之后,第一件事不是急着算眼睛,而是先做个切片,把注意力集中到有用的点上。面部网格的 468 个基础点里,和眼睛关系最密切的是眼角和上下眼睑那几个:
- 一只眼的轮廓常用索引:33(外眼角)、133(内眼角)、159(上眼睑)、145(下眼睑)
- 另一只眼的对应索引:362、263、386、374
这四个点加虹膜中心,就够算出一堆指标了。把它们单独提出来做成一个结构化对象,比在一堆魔法索引里翻来翻去清爽得多:
# 常用眼部索引,按"图像坐标下的第一只眼/第二只眼"分组 EYE_A = {"outer": 33, "inner": 133, "upper": 159, "lower": 145, "iris": 468} EYE_B = {"outer": 362, "inner": 263, "upper": 386, "lower": 374, "iris": 473} def pick_eye(points, idx_map): """从 478 点里抽出一只眼的 5 个关键点""" return {name: points[i] for name, i in idx_map.items()}这个pick_eye看着简单,作用是给后续所有计算提供一个统一入口。等你以后要换另一套索引(比如想用 6 点版本算 EAR),只改EYE_A、EYE_B两个字典就行,下游一行不用动。这类小封装在示例项目里看着多余,等你把它搬进真实工程、需求改了三版之后就知道值了。
顺带说一句尺度问题。像素坐标是跟分辨率绑定的,同一张脸在 640×480 和 1920×1080 下算出来的眼角距离能差三倍。所以凡是涉及“距离”的判断(比如虹膜偏移是否超标),要么全部转成相对于眼睛宽度的比值,要么全部归一化到 0 到 1,绝不能拿裸像素值去和阈值比。这是血泪经验,换了个摄像头分辨率之后所有阈值全部失效的场景,我遇到过不止一次。
3.4 摄像头主循环与帧率控制
主循环的骨架很固定,但顺序有讲究:
import cv2 import time cap = cv2.VideoCapture(0) # 0 是默认摄像头,多设备时按顺序试 1、2 face_mesh = create_face_mesh(static_mode=False, max_faces=1) prev = time.time() while True: ok, frame = cap.read() if not ok: break # 水平翻转,让画面像镜子一样,符合用户直觉 frame = cv2.flip(frame, 1) points = detect_frame(frame, face_mesh) if points: draw_all(frame, points) # 绘制逻辑见下一章 # 实时 FPS,用于判断端到端延迟是否可接受 now = time.time() fps = 1.0 / max(now - prev, 1e-6) prev = now cv2.putText(frame, f"FPS {fps:.1f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow("iris", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()cap.read()返回的ok必须判断。摄像头被其他程序占用、USB 松动、驱动异常,都会让ok变成False而frame是None,这时候如果直接送进detect_frame,会在cvtColor那里抛一个看不出根因的错误。
cv2.flip(frame, 1)这个翻转要放在检测之前还是之后?放在检测之前,意味着模型看到的是翻转后的图,输出的坐标直接就是显示坐标,绘制时不用再做任何变换,最省事。放在检测之后,你就得自己把 x 坐标做w - x的镜像,容易漏掉。所以统一建议:先翻转,再检测。
cv2.waitKey(1)里的 1 是毫秒,不能省,它同时承担了刷新窗口和处理键盘事件两个职责。省掉的话窗口不会更新,你会以为程序卡死了。& 0xFF是为了跨平台兼容,某些系统上返回值高位有脏数据,不做掩码比较会失效。
FPS 那一小段不是给你看的,是给你判断“要不要做性能优化”的。普通笔记本摄像头跑 478 点模型,30 到 60 FPS 属于正常区间;如果掉到 15 以下,先排查是不是每帧都在重建模型对象,其次看是不是分辨率太高(把帧缩到 640 宽再送进去,通常能翻倍)。
4. 关键实现细节:虹膜偏移、EAR 与深度近似
坐标拿到手只是原材料,真正能拿去做功能的是从这些点里算出来的几个指标。这一章讲三个最常用的,都是纯几何计算,不依赖额外模型。
4.1 虹膜相对眼角的归一化偏移
人看不同方向时,眼球在眼眶里转动,虹膜中心相对眼角的水平位置会变。这个变化量除以眼睛宽度,就得到一个无量纲的比例,可以跨分辨率、跨人脸大小直接比较:
def iris_offset(eye): """返回虹膜中心在眼睛框内的归一化位置 (水平, 垂直),范围约 0~1""" ox, oy, _ = eye["outer"] ix, iy, _ = eye["inner"] cx, cy, _ = eye["iris"] eye_width = max(abs(ix - ox), 1e-6) # 防零除 eye_height = max(abs(eye["upper"][1] - eye["lower"][1]), 1e-6) # 用眼角中点作为参考原点,消除整张脸平移的影响 mid_x = (ox + ix) / 2.0 mid_y = (oy + iy) / 2.0 return (cx - mid_x) / eye_width, (cy - mid_y) / eye_height这里用眼角中点而不是外眼角做原点。原因很实在:外眼角在转头时位置变化比中点大,用中点能抵消掉一部分头部平移带来的干扰。max(..., 1e-6)是防零除,侧脸角度过大时眼角 x 可能几乎重合,不防的话会直接抛ZeroDivisionError,而且是偶发的,特别难复现。
输出的两个值大致在 -0.5 到 0.5 之间波动,正向对应往某一侧看。注意这只是“眼球相对眼眶”的位置,不是“视线在屏幕上的落点”,两者之间还差一个头部姿态的修正。做简单的左右/上下四方向判断够用,要做九宫格那种精度就得加标定,第 6 章讲。
4.2 眼睛纵横比 EAR:睁眼闭眼与眨眼检测
EAR(Eye Aspect Ratio)是判断睁闭眼最省事的指标,本质是“眼睛高度除以宽度”:
def eye_aspect_ratio(eye): """经典 6 点 EAR,值越大眼越睁,约 0.2 以下算闭眼""" p1 = eye["outer"] # 外眼角 p4 = eye["inner"] # 内眼角 p2 = eye["upper"] # 上眼睑 p3 = eye["lower"] # 下眼睑 # 示例里是 4 点简化版:2 * 高度 / 宽度 height = abs(p2[1] - p3[1]) width = max(abs(p4[0] - p1[0]), 1e-6) return height / width标准版 EAR 用 6 个点算两组眼睑距离的平均值,能抵消单点抖动。示例里的 4 点简化版已经够判断睁闭,误差主要来自侧脸——脸转到 30 度以上时,眼睛在图像里的投影被压扁,宽度变小,EAR 会虚高,本来闭着的眼可能被判成睁着。所以这个阈值不能写死,我一般会让它跟着头部偏航角动态调整,或者干脆在检测到明显侧脸时跳过眨眼判定。
判眨眼的常规做法是:EAR 连续低于阈值若干帧,再回到阈值以上,才算一次有效眨眼。只判单帧会出现大量误触发,因为关键点本身有抖动,偶尔一帧掉下去很正常。用计数器做去抖,是这类信号处理的标准套路。
4.3 虹膜直径与深度近似:用针孔模型倒推距离
前面说过lm.z不能当物理距离用。想拿到真正的距离量级,得换一种思路:人的虹膜直径个体差异不大,通常在 11.7 毫米左右,那么在图像里测得虹膜占据多少像素,就能反推距离。这是标准针孔相机模型的应用:
import math IRIS_DIAMETER_MM = 11.7 # 成年人虹膜水平直径的常用近似值 def focal_length_px(frame_width, hfov_deg=60.0): """由水平视场角估算焦距(像素单位)""" return frame_width / (2.0 * math.tan(math.radians(hfov_deg) / 2.0)) def estimate_distance_mm(iris_width_px, focal_px, real_mm=IRIS_DIAMETER_MM): """d = f * W / w,w 为虹膜在图像中的像素直径""" if iris_width_px < 1e-6: return float("inf") return focal_px * real_mm / iris_width_px三个参数说清楚。iris_width_px是虹膜在画面里的像素宽度,用 469 到 471 或者 470 到 472 这两组相对的点去算,取两次的平均更准。focal_px是相机焦距换算成像素单位后的值,普通网络摄像头不知道真实焦距,用视场角反推是个够用的近似,60 度是笔记本内置摄像头的常见水平视场。real_mm是物理先验,成年人虹膜直径大概 11 到 12 毫米,取值偏差 1 毫米,距离估算就会偏 8% 左右。
这套算法给出的是量级正确的估计,不是精密测量。做“用户离屏幕是不是太近了”这类判断完全够用,做医疗级测量那是不可能的。但它比lm.z强的地方在于:z 是相对头部中心的无量纲值,跨帧、跨人都不好比较;这套算法给的是厘米量级,至少能设一个有意义的上限阈值。
4.4 绘制:把点位画回画面上
绘制代码看着琐碎,但把它单独放进draw_utils.py有个实际好处——调试时你能一眼看出到底是检测错了还是画错了。这两类问题的表征很像(点跑到奇怪的地方),排查方向却完全相反。
import cv2 def draw_all(frame, points): """把两只眼的虹膜中心和边缘画出来""" # 每只眼:(中心索引, 边缘索引范围) eyes = ((468, range(469, 473)), (473, range(474, 478))) for center_idx, ring in eyes: cx, cy, _ = points[center_idx] cv2.circle(frame, (int(cx), int(cy)), 2, (0, 255, 0), -1) for i in ring: x, y, _ = points[i] cv2.circle(frame, (int(x), int(y)), 1, (0, 200, 255), -1) # 用四根线把边缘点连成菱形,方便肉眼判断虹膜是否贴合 ring_pts = [points[i] for i in ring] for a, b in zip(ring_pts, ring_pts[1:] + ring_pts[:1]): cv2.line(frame, (int(a[0]), int(a[1])), (int(b[0]), int(b[1])), (255, 128, 0), 1)cv2.circle的坐标必须是整数,浮点会抛类型错误,所以每处都做了int()截断。截断而不是四舍五入,在点密集的区域会造成轻微偏移,但虹膜点只有 5 个,看不出来。如果以后要画全部 478 个点,建议改用np.round再转int,视觉上更均匀。
连成菱形的那个循环用ring_pts[1:] + ring_pts[:1]做首尾相接,是个常用技巧,比写(i+1) % 4省事。这套绘制的意义在于:你盯着屏幕看虹膜菱形有没有跟着眼球一起动、有没有跑到眼白上去,就能判断是模型问题还是你的索引写错了。这比打印坐标值效率高得多。
5. 常见问题排查:跑不通的时候先看这几处
这套东西的报错信息普遍不给力,表现为“不报错但结果不对”的情况特别多。下面几条都是实际调试时反复遇到的,按现象、原因、解决三段写。
5.1 现象:multi_face_landmarks一直是None
原因:颜色空间没转,帧还是 BGR 就送进去了;或者摄像头被别的程序占用,cap.read()失败但没判断ok,送进去的是None;还有一种情况是光照太差,人脸在画面里整体偏暗,检测阈值过不去。
解决:按顺序排查三件事。第一,在cvtColor之后存一帧到本地看一眼,确认是彩色且颜色正常(人脸是肉色不是蓝色)。第二,打印cap.isOpened()和每帧的ok。第三,把min_detection_confidence临时降到 0.3 试试,如果降了就出结果,说明是光照问题,补光比调参更根本。
5.2 现象:能检测到脸,但关键点只有 468 个
原因:refine_landmarks是False,或者根本没传这个参数(默认值就是False)。
解决:检查FaceMesh(...)的构造参数,把refine_landmarks=True显式写上。另外注意别在循环里重复构造——有人为了“重置状态”每帧新建一次模型,那不仅慢,还容易在参数复制过程中漏掉这一项。判断是否生效最快的办法就是打印len(results.multi_face_landmarks[0].landmark),看是不是 478。
5.3 现象:画面里点和脸对不上,整体偏移
原因:送入模型的图像尺寸和换算坐标时用的尺寸不一致。典型场景是为了提速把帧resize到 640 宽再送进模型,但坐标换算时用的还是原图w、h。
解决:把尺寸变量跟着帧一起传,别用全局的。写成detect_frame(frame),函数内部第一行取h, w = frame.shape[:2],这样进什么尺寸就用什么尺寸换算。另外如果做了cv2.flip,翻转必须在检测之前,否则 x 坐标要额外做w - x处理。
5.4 现象:实时画面一卡一卡,FPS 掉到个位数
原因:优先级从高到低通常是——模型对象建在循环内;摄像头分辨率开到了 1920×1080 但实际只需要 640 宽;每帧都在做全画面绘制;static_image_mode设成了True导致每帧重新检测。
解决:先关掉所有绘制只保留推理,看 FPS 能到多少,以此判断是绘制拖的还是推理拖的。推理拖的话,用cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)降分辨率,通常立竿见影。绘制拖的话,别画全部 478 个点,只画关心的那些。还有一个容易忽略的点:cv2.imshow在高分屏上会做缩放,本身就有开销,调试时可以临时不显示,只统计 FPS。
5.5 现象:换到另一台机器上import mediapipe直接失败
原因:依赖版本冲突,尤其是numpy和protobuf这两个。mediapipe对它们的版本范围有要求,如果环境里已经装了别的库锁定了旧版numpy,就会在导入时抛出看起来和mediapipe毫无关系的错误。
解决:新建一个干净的虚拟环境,只装opencv-python、mediapipe、numpy三个,先跑通再往里面加别的东西。这是最省时间的排查方式——在污染过的环境里逐个降级依赖,能折腾一下午。装的时候如果下载很慢,换一个能连通的软件源就行。
6. 二次开发:从示例脚本到能用的眼动模块
示例脚本能跑通只是起点,真要接进项目,第一件事是把它从“一个脚本”变成“一个对象”。我习惯把检测逻辑封成一个持有模型实例的类,外部只调两个方法:process(frame)拿结果,close()释放资源。这样调用方不需要知道模型是什么、初始化参数有几个,将来换检测后端的成本被压到最低。
class IrisTracker: def __init__(self, static_mode=False): self.mesh = create_face_mesh(static_mode=static_mode, max_faces=1) self._smooth = {} # 按 key 存历史值,做 EMA 平滑 def process(self, frame_bgr): points = detect_frame(frame_bgr, self.mesh) if points is None: return None eye_a = pick_eye(points, EYE_A) eye_b = pick_eye(points, EYE_B) return { "offset_a": self._ema("a", iris_offset(eye_a)), "offset_b": self._ema("b", iris_offset(eye_b)), "ear_a": self._ema("ear_a", eye_aspect_ratio(eye_a)), "ear_b": self._ema("ear_b", eye_aspect_ratio(eye_b)), } def close(self): self.mesh.close()平滑那层别省。原始输出的虹膜偏移在静止注视时会持续小幅抖动,映射到界面光标上就是永远停不下来的微颤,用起来非常难受。指数滑动平均一行就够:new = alpha * cur + (1 - alpha) * old,alpha取 0.2 到 0.4 之间,越小平滑越强但延迟越大。这个参数没有通用最优值,得拿着秒表边看边调。
要做真正的注视点落屏,还得补一步标定。做法很朴素:让用户依次注视屏幕上九个已知位置,每次记录一组虹膜偏移,最后用最小二乘拟合一个从偏移到屏幕坐标的仿射变换。标定数据存成配置,下次启动直接加载,用户不用每次重来。这一步不加,四方向判断能凑合,九宫格定位就是玄学。
改完之后怎么验证没坏?我固定走三步:先用一张标准正脸图跑单图模式,确认还是 478 点且虹膜画在瞳孔上;再跑一段录好的视频,对比改动前后的偏移量曲线,看整体趋势有没有漂;最后开摄像头连续跑五分钟,盯 FPS 和人脸转侧面时的丢帧率。三步都过,才敢往主工程里合。
从那以后我每次改这类视觉模块,都强制先跑单图再跑视频,最后才开摄像头——因为摄像头是唯一不能复现的输入源,一旦在它上面才发现问题,你连那一帧长什么样都存不下来。希望帮到你。
本文还有配套的精品资源,点击获取