资讯详情

Mediapipe Iris 虹膜关键点检测与眼动信号落地指南

📅 2026/10/24 4:20:22 | 华诺云谱 👁 阅读
Mediapipe Iris 虹膜关键点检测与眼动信号落地指南
简介iris_tracking_sample.zip 是一份面向计算机视觉开发者的 Mediapipe Iris 示例代码包聚焦 Windows 10 环境下眼角、眼睑、眼球边界及虹膜关键点坐标的提取与追踪可用于 AR/VR 交互、生物识别和眼部健康监测等场景的原型验证。压缩包共 5 个文件约 7KB以 C 源码和头文件为主辅以 BUILD 构建配置分别承载虹膜检测逻辑、接口声明、桌面端图像/深度图输入示例与工程编译组织体量轻便便于快速查阅和嵌入现有工程。目前已有 411 人学习。读者可从中了解 Iris 流水线的初始化、逐帧处理、Landmark 结果解析以及追踪结果可视化的代码组织方式并参考检测接口与构建脚本的写法为后续调整精度与速度、结合 FaceMesh 扩展面部追踪功能提供可复用的起点。1. 从一段「眼睛不动了」的监控画面说起iris_tracking_sample.zip这个名字听起来很朴素但里面用的其实是 Mediapipe Iris 这套虹膜关键点检测流程先用 468 点人脸网格把人脸定住再在双眼区域叠加 10 个虹膜点每只眼一个中心加四个边缘点。它要回答的不是“这是谁”而是“眼球此刻朝哪看、睁眼还是闭眼、视线有没有持续偏离”。做疲劳监测、注意力评估、无障碍交互这类需要逐帧眼动信号的项目都会用到它。我拿到这个包时第一件事不是去跑 demo而是先确认里面给的是模型文件还是可执行脚本——这两者完全不同后者决定你后面是改代码还是只改参数。2. 拆包先看结构两阶段管线与坐标约定在动手之前得先弄清楚这个包里到底在跑什么。很多人拿到压缩包的第一步是双击requirements.txt装依赖然后按 README 跑一个 demo看到屏幕上有绿点就觉得成了。这个顺序没错但会让你在出问题时完全不知道从哪查。我一般会先花十分钟读一遍目录结构把“模型、脚本、样例数据”这三类文件分清楚。常见的组织方式是这样models/下放两个模型文件一个人脸网格模型一个虹膜模型src/或根目录下放几个脚本通常包含单图检测、视频检测可能还有一个算指标的辅助模块assets/或data/放几张测试图或者一段短视频最后是requirements.txt。你拿到的包和我描述的不完全一致也没关系按“找模型、找入口脚本、找样例”这个顺序找就行。2.1 468 加 10为什么要跑两次推理Mediapipe Iris 通常采用两级串联并非一个大模型端到端出结果。第一级是 Face Mesh先检测人脸再回归出 468 个三维关键点把眼睛、眉毛、鼻唇的位置定下来。第二级才是虹膜模型它不直接处理整张图而是拿第一级给出的眼睛区域做裁剪把这块小图放大后单独送进去回归出更精细的眼部点。所以输出里的点数是 478前面 468 个是脸后面 10 个是虹膜。为什么要多跑这一次因为虹膜太小了。在 1280×720 的画面里一只眼睛可能只占 40×25 像素虹膜在里面不到 15 个像素。直接在全图上回归的话误差会被整张图的尺度稀释掉。裁出来放大到 64×64 再回归同样的网络容量就能落到像素级精度上。做过工业视觉的人对这个思路应该很熟先粗定位再精定位代价就是多一次推理延迟。延迟在结构上是固定的一次人脸检测一次人脸网格两次眼部裁剪推理左右眼各一次。这里有个参数特别关键refine_landmarks。它默认是False为False时只输出 468 个点你拿不到虹膜必须设成True才会把后面 10 个点带上也才会触发第二次推理。我见过不止一个人说“Mediapipe 根本没有虹膜点”最后一个布尔值就查了半天。关键点索引的分布以 Face Mesh 拓扑为准区间含义备注0–467人脸网格含轮廓、眉、眼睑、鼻、唇468–472一只眼的虹膜1 个中心 4 个边缘473–477另一只眼的虹膜1 个中心 4 个边缘记住一句话以 outputs 的实际长度为准。不同版本、不同封装会导致 468 和 473 哪个对应左眼对不上。我一般先在正脸图上打点可视化一次确认左右再把常量写死。这两分钟的活儿能省掉后面半天的“数据看起来在乱跳”的排查。2.2 三个坐标系归一化、像素以及那个“假深度”这是最容易出现隐蔽错误的地方。脚本里输出的每个点通常是x, y, z三元组其中x和y是归一化到[0, 1]的比值不是像素。也就是说x0.5表示画面宽度的中点跟分辨率无关。好处是换分辨率不用改代码坏处是你直接把两点相减算出来的“距离”是个无量纲比值跟真实厘米数没关系。转像素很简单乘上宽高就行但要注意顺序和取整import numpy as np def to_pixel(landmarks, w, h): 把归一化关键点转成像素坐标返回 (N, 3) 的 float 数组 arr np.array([[p.x * w, p.y * h, p.z * w] for p in landmarks], dtypenp.float32) # x 乘宽、y 乘高z 按宽缩放保持与 x 同尺度便于后续做比值 return np.clip(arr, 0, None) # 越界点裁到画面内避免画图时飞出画布z是这个坐标系里最容易被误解的一项。它表示相对深度原点是头部中心附近量纲和x一致所以乘宽度做缩放是常见做法。但它不是到相机的真实距离也不是毫米。我见过有人直接拿z当“眼睛到屏幕的距离”去判疲劳结果人往前凑一点数值就翻倍。真要做距离推算得走相机内参和solvePnP那条路代价是完全不同的。注意归一化坐标在画面边缘会被裁掉人脸贴边时x可能被截到 0 或 1此时算出来的虹膜偏移量是失真的必须配合人脸框位置做有效区域门限。2.3 环境准备与第一次跑通依赖不多但有版本要求。Opencv 和 Mediapipe 的版本搭配偶尔会出兼容问题我的习惯是先把 Python 版本压到 3.9 或 3.10这两个版本在实践中遇到的环境问题最少python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install -U pip pip install mediapipe opencv-python numpy # 如果包里带了 requirements.txt优先按它装版本冲突时以包的锁定版本为准 pip install -r requirements.txt装完先确认模型加载成功再谈精度。最小验证脚本如下逻辑是读一张正脸图跑一次静态模式推理打印点数并确认出现了 478 这个数import cv2 import mediapipe as mp mp_face_mesh mp.solutions.face_mesh # static_image_modeTrue单图模式每张图都重新检测不做帧间跟踪 # refine_landmarksTrue开启虹膜细化这是拿到 468 之后那 10 个点的开关 # max_num_faces1只处理一张脸人多时降低延迟 with mp_face_mesh.FaceMesh( static_image_modeTrue, max_num_faces1, refine_landmarksTrue, min_detection_confidence0.5, ) as face_mesh: image cv2.imread(assets/sample.jpg) h, w image.shape[:2] results face_mesh.process(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) if not results.multi_face_landmarks: raise SystemExit(没检测到人脸换一张正脸图再试) lm results.multi_face_landmarks[0].landmark print(关键点总数:, len(lm)) # 正常应为 478 print(左虹膜中心像素:, lm[468].x * w, lm[468].y * h)这段代码里min_detection_confidence是检测阈值默认 0.5。测试图光照好的话0.5 没问题如果是逆光或侧脸可以降到 0.3 试试代价是误检率上升。max_num_faces设成 1 是因为大多数人脸网格模型在多脸场景下会让延迟随人数线性上涨做单人眼动不需要开。跑通之后打印出来的点数是 478 说明链路是完整的如果只有 468回到上一节检查refine_landmarks。3. 从单帧到视频流把点变成可计算的眼动信号单张图跑通只证明环境没问题真正有价值的是连续帧上的稳定信号。这一步会踩到时间戳、镜像、帧对齐三个坑它们都不报错只是让数据悄悄地不对。3.1 单帧推理的参数取舍上面那段脚本用的是静态模式。静态模式每次都重新跑人脸检测好处是换了人不会串坏处是慢而且帧间的点位会轻微跳动。视频场景我一般切成跟踪模式static_image_modeFalse此时第一次跑全量检测后续帧基于上一帧的人脸位置做跟踪延迟能降一半以上。跟踪模式依赖min_tracking_confidence默认也是 0.5。这个值调高的效果是“跟踪一旦不够确信就重新检测”表现更稳但更慢调低则是更信任历史位置快但可能跟丢后还在原地画点。我的经验是摄像头固定、人坐在工位上的场景0.6 到 0.7 比较舒服手持或车载场景老老实实回到 0.5或者干脆用静态模式换稳定性。还有一个容易忽略的参数是人脸检测的输入尺寸。部分封装会暴露类似min_detection_confidence之外的缩放选项本质是把输入图缩到固定尺寸再送网络。缩得越小越快但远处的小脸就丢了。如果你的场景里人离相机超过两米不要为了帧率去压这个值。3.2 视频流时间戳必须单调递增跟踪模式下时间戳不是可选项。管线内部拿它做帧间对齐如果你给的时间戳比上一帧小或者两帧给了一样的值就会出现“关键点直接归零”或者“画面卡住不动”的现象——而且不报错。这个坑我在第 5 章会展开这里先给正确写法import time import cv2 import mediapipe as mp mp_face_mesh mp.solutions.face_mesh cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) fps cap.get(cv2.CAP_PROP_FPS) or 30.0 frame_idx 0 with mp_face_mesh.FaceMesh( static_image_modeFalse, max_num_faces1, refine_landmarksTrue, min_detection_confidence0.5, min_tracking_confidence0.6, ) as face_mesh: while True: ok, frame cap.read() if not ok: break # 关键用帧序号除以 fps 生成时间戳保证严格单调 # 不要用 time.time()取系统时间的两次调用可能相同也可能被外部校时打乱 timestamp_ms int(frame_idx * 1000 / fps) frame_idx 1 rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) rgb.flags.writeable False # 避免管线内部拷贝省一点内存 results face_mesh.process(rgb) # 注意process 不接受时间戳参数时 # 时间戳由内部按调用顺序递增维护 if results.multi_face_landmarks: lm results.multi_face_landmarks[0].landmark if len(lm) 478: h, w frame.shape[:2] cx, cy lm[468].x * w, lm[468].y * h cv2.circle(frame, (int(cx), int(cy)), 2, (0, 255, 0), -1) cv2.imshow(iris, frame) if cv2.waitKey(1) 0xFF 27: break cap.release() cv2.destroyAllWindows()这段代码的关键在于时间戳的处理方式。你用的封装如果把process()简化成只吃图像那内部会自己维护一个递增的序号这种情况别手动去塞时间如果封装提供了process(image, timestamp_ms)就必须保证你传的值严格递增。两种风格的写法不要混着抄混用就会出现“有时正常有时全零”的现象。rgb.flags.writeable False这一行看着无关紧要实际能省下每帧一次内存拷贝。在 1080p 下这个拷贝是几毫秒级别叠在 30fps 的预算里不算小。改完记得别再对这帧做写操作否则会抛异常。3.3 用 EAR 和虹膜占比算睁闭眼与注视偏移原始关键点没法直接进业务逻辑中间得有一层指标。睁闭眼最常用的是 EAR也就是眼睛纵横比取上眼睑、下眼睑和两个眼角纵向距离比横向距离。这个比值的好处是跟人脸大小无关正脸时稳态在 0.25 到 0.35 之间闭眼会掉到 0.15 以下。import numpy as np def ear(pts, idx): pts: (N,2) 像素坐标idx: 该眼的 6 个点顺序为 [左角, 上1, 上2, 右角, 下2, 下1] p pts[idx] v1 np.linalg.norm(p[1] - p[5]) v2 np.linalg.norm(p[2] - p[4]) h np.linalg.norm(p[0] - p[3]) return (v1 v2) / (2.0 * h 1e-6) # 分母加小量防除零 def iris_offset(pts, eye_idx, iris_center_idx): 虹膜中心相对眼角的水平占比0 靠内眼角1 靠外眼角 eye pts[eye_idx] x_min, x_max eye[:, 0].min(), eye[:, 0].max() cx pts[iris_center_idx][0] return (cx - x_min) / (x_max - x_min 1e-6)ear的索引顺序不能乱必须按“左角、上睑两点、右角、下睑两点”的顺时针或逆时针顺序排列否则两条纵向距离会算错。不同的网格拓扑给出的眼睑点编号不一样我一般在可视化图上挨个点一次把编号抄下来写成常量比在代码里现算更省事。iris_offset这个指标比直接看虹膜中心的像素坐标稳得多因为它做了横向归一化人往前凑或者往后退比值基本不变。实测正脸平视时左右眼都在 0.45 到 0.55 之间视线偏到屏幕边缘会跑到 0.25 或 0.75 附近。做注视区域判断时用这个比值加一个阈值带就够了不需要标定。提示EAR 阈值不要抄别人的数字。不同人种、不同年龄段、戴不戴眼镜闭眼阈值都不一样。我一般让人做三次睁闭眼取闭眼段的最大值再往上加 0.03 当阈值这比用固定值稳得多。4. 精度掉在哪分辨率、光照与头部姿态的边界跑通之后就该问一句这套东西在什么条件下还能信我在实际使用中遇到的精度衰减八成集中在三个地方——眼睛在画面里太小、光打在镜片上、头转过去超过大概 30 度。4.1 放大眼睛不是放大整张图一个直观的误区是把摄像头分辨率从 720p 提到 1080p 甚至 4K觉得精度就上去了。实际效果往往相反——整图变大人脸检测和网格回归的耗时线性上涨但因为虹膜模型吃的是裁剪后的固定尺寸眼部区域输入那一块的像素增益有限帧率反而掉下来。真正有效的是缩短工作距离或者用长焦把脸拉大。按经验虹膜中心在画面里稳定下来需要单眼横向不少于 60 像素也就是人脸框宽度 200 像素以上。低于这个数你能看到点但点会跳。如果摄像头位置固定不能动退而求其次的做法是只对眼部区域做上采样再送模型不过这个改动要看你用的封装有没有暴露裁剪接口。场景单眼横向像素表现笔记本内置摄像头正常坐姿80–140稳定可用外接摄像头距离 60cm150–250精度好可做注视区域判断监控视角距离 3m30–50点会跳不建议做指标这张表是我自己在几种常见设备上量的不是绝对标准但量级能帮你快速判断手上的场景够不够用。4.2 光照、反光与眼镜RGB 的能力上限戴眼镜是绕不开的。镜片反光会在虹膜区域形成高亮块模型把高亮块当成虹膜边缘中心点就会偏移。轻度的表现是偏移几个像素重度的表现是中心点直接甩到眼角。常见的缓解手段有三个把光源从正前方挪到侧上方避开镜片法线方向在预处理里做一次局部直方图均衡压一下高光或者直接上红外摄像头红外对镜片反光的敏感度低很多但那是换硬件了。逆光场景的问题不一样。整张脸欠曝时网格还能勉强定住轮廓但虹膜区域几乎全黑此时输出的点位看着挺正常实际上全是猜的。判断方法是看置信度或者直接算一下眼部区域的平均亮度低于某个值就把这一帧标记为无效不要喂给下游。注意不要在上游做全局的自动曝光补偿之后再送模型。全局补偿会把过曝的部分压回来但同时把本来正常的部分压暗网格回归的稳定性反而下降。要补就补眼部 ROI别动整图。4.3 抖动抑制滑动窗口和 One Euro即使条件都合格逐帧输出的点还是会有小幅抖动幅度大概一到两个像素肉眼看不出来但算一阶差分的时候就会被放大成噪声。最简单的处理是滑动窗口平均from collections import deque class Smoother: def __init__(self, n5): self.buf deque(maxlenn) # 窗口越大越平滑但延迟越高 def push(self, x, y): self.buf.append((x, y)) xs sum(p[0] for p in self.buf) / len(self.buf) ys sum(p[1] for p in self.buf) / len(self.buf) return xs, ys窗口长度是个权衡。N5 在 30fps 下引入大约 80ms 的滞后做注意力统计完全够做实时交互就嫌迟钝了。要兼顾平滑和低延迟可以用 One Euro 滤波低速时重平滑高速时放开来跟。它的两个参数是min_cutoff和beta前者越小越平滑后者越大对快速运动响应越好我一般从min_cutoff1.0, beta0.007起调。这个滤波器的实现不长包里的辅助模块如果有就直接用没有的话自己写二十行也能跑。5. 避坑记录四个把 demo 跑成事故的细节这一章记录的都是我或者身边人实际踩过的坑。共同特点是不报错、不崩溃只是结果悄悄不对等你发现的时候已经积累了一堆脏数据。5.1 关键点全是零跟踪模式下的时间戳倒退现象视频跑到一半multi_face_landmarks突然变成空或者返回的点全是(0, 0)过几帧又自己恢复。日志里没有任何异常。原因跟踪模式内部依赖时间戳做帧对齐。用time.time()生成时间戳时两次调用的返回值可能相同系统时钟精度不够也可能因为外部校时往回跳。一旦时间戳不再严格递增管线就拒绝输出。解决时间戳一律用帧序号除以 fps 生成别碰系统时钟。视频文件场景直接用cap.get(cv2.CAP_PROP_POS_MSEC)这个是解码器给的天然单调。另外注意切换视频源或者重新初始化的时候要把帧计数清零否则从大值突然跳到小值同样会触发。5.2 左右眼反了镜像和索引的双重误判现象算出来的左眼 EAR 和右眼 EAR 差了一个明显的常数或者做注视方向判断时人往左看指标却往右走。原因两层。第一层是摄像头画面默认是镜像的自拍视角你以为的左边其实是人的右眼。第二层是 468 和 473 这两个索引在不同封装里对应的眼不一样官方拓扑里 468 起的那组和物理左眼并不总是一致。解决先解决镜像再解决索引。镜像的处理方法是在送模型之前做一次cv2.flip(frame, 1)或者干脆不翻后面所有左右相关的逻辑按“图像左”来写只在展示层翻。索引的问题必须在正脸图上打点确认两只眼的虹膜中心分别用不同颜色画出来让人往左看一次、往右看一次看哪个点在动一次就能定死。5.3 侧脸时虹膜点飞出去缺一个姿态门限现象人转头跟旁边同事说话虹膜中心点跑到脸颊上甚至跑到画面外EAR 掉到 0.05下游的疲劳判定直接误报。原因虹膜模型是在眼部裁剪区域上工作的头转过去之后一只眼被鼻梁遮挡裁剪出来的区域里根本没有完整的虹膜模型只能在错误的位置回归。它不会告诉你“我不确定”照样输出坐标。解决加姿态门限。最轻量的做法是用鼻尖和两侧脸颊的横向距离比值判断偏航超过大概 30 度就把这一帧标记为无效指标沿用上一有效帧或者直接丢弃。更严谨的做法是走solvePnP算头部欧拉角但要标定相机内参成本高一个量级。做疲劳监测这类场景轻量门限就够了别一上来就上重方案。提示门限触发时不要用“保持上一帧”这种写法会让指标看起来一直在正常值附近反而掩盖了遮挡。正确做法是输出一个明确的无效标记下游按缺失处理。5.4 内存缓慢上涨每帧新建实例现象跑十几分钟之后内存涨到几个 G最后被系统杀掉。短时间测试完全看不出来。原因把FaceMesh(...)的构造写在了while循环里面。每次构造都会加载模型、分配内部缓冲区with语句不写或者写错位置资源就不会释放。解决模型实例在循环外构造一次循环内只调process()。这个错误看着低级但在把单图脚本改成视频脚本的时候特别容易发生——原来那段with是包住整段逻辑的复制过来顺手就包住了整个循环。另外记得在finally里调close()摄像头和窗口也要释放。6. 接进业务之前先把基线跑出来最后说一个我强烈建议做的动作在写任何业务逻辑之前先把这个包跑成一条基线把指标分布记下来。做法很简单让同一个人在同一位置做三组动作——平视 30 秒、左右各看屏幕边缘 30 秒、睁眼闭眼各 10 次——把每帧的 EAR 和虹膜占比记进一个表然后看分位数。import numpy as np def baseline(records, key): v np.array([r[key] for r in records if r.get(key) is not None]) return { p05: float(np.percentile(v, 5)), p50: float(np.percentile(v, 50)), p95: float(np.percentile(v, 95)), std: float(v.std()), } # 平视段的 iris_offset 标准差如果能压到 0.03 以内说明这套参数在你的场景下是站得住的这个基线有三个用处一是定阈值闭眼阈值直接从 p05 推不用抄别人的数字二是验证改动换了平滑窗口、调了分辨率跑一遍基线就知道是变好还是变坏三是排查异常线上数据分布跟基线对不上基本就是光照或者姿态变了。我吃过一次亏直接拿别人论文里的 EAR 阈值上线结果遇到一批戴眼镜的用户误报率高得离谱事后补基线花的时间比一开始做多三倍。从那以后我每次接这类视觉指标都强制走一遍“先基线、后阈值、再上线”的流程哪怕项目再赶也不跳。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑