简介:这份资源面向图像处理与计算机视觉领域的研究人员和工程师,针对红外视频中瞳孔直径检测精度偏低的痛点,提出结合Hough变换与轮廓匹配的Hough-Contour算法。算法先对每帧图像灰度化并滤波去噪,再用改进的Hough梯度法初步定位瞳孔,随后在瞳孔附近做圆形轮廓匹配,精确计算瞳孔中心坐标与直径;通过将候选圆心按累积值降序排序改为直接搜索最大值来降低耗时,并借助累积值阈值自动排除闭眼帧。实验表明,轮廓移动与拉伸范围取初始半径的十分之一、点对数取40时,精确匹配率由OpenCV圆变换的约10%提升至99.8%,低端设备处理320×240视频、高端设备处理640×480视频均可实时检测。资源包为1个PDF文件,约1.04MB,完整呈现算法步骤、参数设置与实验对比,已有170人学习,适合希望深入掌握红外瞳孔高精度检测实现细节的读者研读。
1. 红外视频里瞳孔检测为什么总在“最后一公里”翻车
做过红外眼动或者疲劳监测的同行大概都有体会:在可见光下跑得好好的瞳孔检测,一换到红外视频就各种玄学问题。红外成像本身对比度低、噪声大,眼睑和睫毛在 850nm 附近反光弱,瞳孔和虹膜灰度差被压缩,再加上人眼频繁眨眼、头部微动,单靠阈值或者单靠边缘检测,基本都会在“最后一公里”翻车。标题里这套“Hough 变换 + 轮廓匹配”的组合,本质上是想解决两件事:一是用 Hough 在候选圆里快速定位瞳孔的几何中心,二是用轮廓匹配把误检的圆(比如虹膜外沿、眼镜框反光、眼睑弧线)筛掉。它适合做嵌入式眼动仪、驾驶疲劳监测、VR/AR 眼动追踪的团队,也适合手里已经有红外相机、想先把瞳孔坐标稳定拿到的开发者。下面我按自己踩过的路径,把选型、参数、代码和坑一条条拆开。
2. 从红外帧到瞳孔候选圆:预处理与 Hough 参数怎么定
2.1 红外瞳孔成像的三个物理约束
先别急着写 Hough。红外视频里瞳孔之所以难搞,根子在三个物理约束上。第一,瞳孔在 850nm 附近反射率极低,但角膜和眼睑会形成高光斑,这些高光斑在二值化后经常和瞳孔连成一片,导致质心偏移。第二,红外 LED 环形光源会在角膜上留下一个亮环,这个亮环的半径和瞳孔半径接近,Hough 很容易把它当成瞳孔。第三,红外相机的自动增益会随环境光变化,同一段视频里瞳孔区域的灰度均值可能漂移 20 到 40 个灰度级,固定阈值必然失效。
我一般会先做一次 ROI 粗定位,把搜索区域压到眼睛附近,再在 ROI 内做自适应直方图均衡。注意,不要用全局直方图均衡,红外视频里背景占了大半,全局均衡会把瞳孔对比度拉低。用 CLAHE,clipLimit 设 2.0 到 3.0,tileGridSize 设 8×8,这个组合在 640×480 的红外眼图里比较稳。
2.2 预处理链:高斯滤波、CLAHE 与形态学开运算
下面这段 Python 是我常用的预处理链,依赖 OpenCV 和 NumPy。输入是单帧红外灰度图,输出是二值化后的瞳孔候选区域。
import cv2 import numpy as np def preprocess_ir_eye(frame_gray): # 1) 轻度高斯滤波,抑制红外传感器的高频噪声 blur = cv2.GaussianBlur(frame_gray, (5, 5), 1.2) # 2) CLAHE 局部对比度增强,clipLimit 控制过增强 clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8)) enhanced = clahe.apply(blur) # 3) 自适应阈值,blockSize 取奇数,C 为常数偏移 binary = cv2.adaptiveThreshold( enhanced, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, blockSize=31, C=8 ) # 4) 形态学开运算去掉睫毛和孤立噪点 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=1) return opened逻辑说明:高斯核 5×5、sigma 1.2 是为了在保留瞳孔边缘的同时压掉红外噪点,核再大瞳孔边缘会糊。CLAHE 的 clipLimit 超过 3.0 会把眼睑纹理也拉出来,后面 Hough 会多出一堆假圆。adaptiveThreshold 的 blockSize 必须大于瞳孔直径,31 在 640×480 下对应约 30 像素,如果瞳孔在画面里超过 60 像素,blockSize 要跟着加到 51 或 61。C 值越大,二值化越保守,瞳孔区域越小,我一般从 8 开始调,瞳孔缺失就降到 5,噪点多就升到 12。
2.3 HoughCircles 的四个关键参数与取值区间
预处理完,直接上cv2.HoughCircles。这个函数在红外瞳孔检测里最容易被滥用的就是param2,很多人默认 30,结果要么漏检要么满屏假圆。我的经验是:dp设 1.0 到 1.2,minDist设瞳孔半径的 1.5 倍,param1设 80 到 120,param2设 15 到 25。下面是一个可复现的调用示例。
def detect_pupil_candidates(binary_img, min_radius=8, max_radius=40): # HoughCircles 对输入灰度图更敏感,这里用二值图也可以 circles = cv2.HoughCircles( binary_img, cv2.HOUGH_GRADIENT, dp=1.2, minDist=min_radius * 1.5, param1=100, # Canny 高阈值 param2=18, # 累加器阈值,越小越容易出假圆 minRadius=min_radius, maxRadius=max_radius ) if circles is None: return [] circles = np.round(circles[0, :]).astype("int") return circles参数说明:dp=1.2表示累加器分辨率是图像分辨率的 1/1.2,比 1.0 略粗,但抗噪更好。minDist如果设得太小,同一个瞳孔会出多个同心圆;设得太大,双眼瞳孔距离近时会漏掉一只。param1是 Canny 边缘检测的高阈值,红外图边缘弱,设 100 比默认 200 更合适。param2是累加器阈值,它和候选圆数量成反比,我一般先设 18 跑一遍,看假圆数量再微调。minRadius和maxRadius必须根据实际成像距离标定,不要照搬。
提示:如果红外视频里瞳孔半径变化超过 2 倍,建议把 Hough 跑两遍,第一遍用大范围粗筛,第二遍在粗筛结果附近用小范围精筛,比一次性设大范围更稳。
3. 轮廓匹配怎么把假圆筛掉:特征、匹配与打分
3.1 为什么 Hough 之后还需要轮廓匹配
Hough 只关心“像不像圆”,不关心“是不是瞳孔”。红外视频里角膜反光、眼镜框、眼睑弧线都可能被 Hough 检成圆。轮廓匹配的作用是引入形状和灰度分布的先验:瞳孔轮廓应该是近似圆形、面积在合理区间、内部灰度均值低于周围环形区域。我一般把 Hough 输出的每个候选圆当作一个 ROI,在 ROI 内提取轮廓,再和理想瞳孔模板做匹配打分。
3.2 轮廓提取与 Hu 矩匹配的代码实现
下面这段代码对每个候选圆提取最大轮廓,计算 Hu 矩,并与理想圆模板做匹配。cv2.matchShapes返回值越小越相似,我一般设阈值 0.15,超过就丢弃。
def extract_contour_score(binary_img, circle): x, y, r = circle # 扩展 ROI,留出边缘余量 pad = int(r * 0.3) x1, y1 = max(0, x - r - pad), max(0, y - r - pad) x2, y2 = min(binary_img.shape[1], x + r + pad), min(binary_img.shape[0], y + r + pad) roi = binary_img[y1:y2, x1:x2] contours, _ = cv2.findContours(roi, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return 999.0, None # 取面积最大的轮廓作为瞳孔候选 cnt = max(contours, key=cv2.contourArea) area = cv2.contourArea(cnt) if area < 20: return 999.0, None # 与理想圆模板做 Hu 矩匹配 ideal_circle = np.zeros_like(roi) cv2.circle(ideal_circle, (roi.shape[1] // 2, roi.shape[0] // 2), r, 255, -1) ideal_cnts, _ = cv2.findContours(ideal_circle, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not ideal_cnts: return 999.0, None score = cv2.matchShapes(cnt, ideal_cnts[0], cv2.CONTOURS_MATCH_I1, 0) return score, cnt逻辑说明:pad取半径的 0.3 倍是为了让轮廓完整落在 ROI 内,太小会截断瞳孔边缘,太大引入眼睑干扰。cv2.RETR_EXTERNAL只取外轮廓,避免瞳孔内部反光造成的孔洞干扰。matchShapes的第三个参数用CONTOURS_MATCH_I1,它对尺度不敏感,适合瞳孔半径变化的情况。阈值 0.15 是我在多个红外数据集上试出来的经验值,低于 0.1 会漏掉部分真实瞳孔,高于 0.25 假圆明显增多。
3.3 灰度分布校验:瞳孔暗区与环形亮区的对比
光靠形状还不够,红外瞳孔有一个稳定特征:瞳孔内部灰度均值明显低于周围虹膜环形区域。我一般再算一个对比度分数,公式是(ring_mean - pupil_mean) / (ring_mean + pupil_mean + 1e-6),这个分数大于 0.15 才认为是真瞳孔。下面是对应的代码片段。
def gray_contrast_score(gray_img, circle): x, y, r = circle mask_pupil = np.zeros(gray_img.shape, dtype=np.uint8) cv2.circle(mask_pupil, (x, y), int(r * 0.8), 255, -1) mask_ring = np.zeros(gray_img.shape, dtype=np.uint8) cv2.circle(mask_ring, (x, y), int(r * 1.4), 255, -1) cv2.circle(mask_ring, (x, y), int(r * 1.1), 0, -1) pupil_mean = cv2.mean(gray_img, mask=mask_pupil)[0] ring_mean = cv2.mean(gray_img, mask=mask_ring)[0] score = (ring_mean - pupil_mean) / (ring_mean + pupil_mean + 1e-6) return score参数说明:瞳孔掩膜取 0.8r 是为了避开边缘过渡带,环形掩膜取 1.1r 到 1.4r 是为了避开虹膜外沿。如果红外光源角度偏,环形区域可能一边亮一边暗,这时可以把环形掩膜拆成上下左右四个扇区,取中位数而不是均值,抗偏光更好。
4. 避坑与排查:红外瞳孔检测里最常见的五类翻车
4.1 瞳孔被高光斑“吃掉”一半
现象:Hough 检出的圆心明显偏向高光斑一侧,瞳孔轮廓缺了一块。原因:红外 LED 在角膜上的反光斑和瞳孔区域在二值化后连在一起,形态学开运算去不掉。解决:先做高光抑制,用cv2.inpaint或者简单的灰度截断,把灰度大于 240 的像素用周围均值替换,再走预处理链。我一般用 3×3 邻域均值替换,比 inpaint 快,嵌入式上也能跑。
4.2 param2 设太低导致满屏假圆
现象:一帧里检出十几个圆,瞳孔只是其中之一。原因:param2默认或手动设成了 10 以下,累加器阈值太低。解决:先把param2提到 20 以上跑一遍,确认真瞳孔还在,再逐步降到刚好不漏检的值。如果真瞳孔和假圆混在一起降不下来,说明预处理没做好,回去检查 CLAHE 和自适应阈值的参数。
4.3 眨眼帧导致轮廓匹配分数突变
现象:连续视频里瞳孔坐标在眨眼前后跳变十几像素。原因:眨眼时眼睑遮挡瞳孔,轮廓变成半圆或月牙形,matchShapes分数飙升,被误判为假圆丢弃,下一帧又恢复,坐标就跳了。解决:加一个简单的帧间连续性校验,如果当前帧没有通过匹配的候选,就用上一帧的圆心和半径做预测,用卡尔曼滤波或者简单的线性外推补一帧。注意,补帧不要超过 3 帧,否则会掩盖真实丢失。
4.4 红外相机自动增益导致阈值漂移
现象:同一段视频前 10 秒检测正常,后面瞳孔突然检不到。原因:相机自动增益把整体灰度拉高,自适应阈值的 C 值相对变小,二值化后瞳孔区域被压缩。解决:关掉相机自动增益,或者每 30 帧重新估计一次瞳孔区域灰度均值,动态调整 C 值。我一般把 C 值设成max(5, int(0.15 * pupil_mean)),让阈值跟着瞳孔灰度走。
4.5 轮廓匹配阈值在不同人种上失效
现象:深色虹膜的被试检测稳定,浅色虹膜的被试假圆多。原因:浅色虹膜在红外下反射率更高,瞳孔和虹膜的灰度对比度下降,gray_contrast_score低于 0.15 被误杀。解决:把对比度阈值从固定值改成自适应,用当前帧 ROI 内灰度直方图的双峰间距来定,间距小于 20 灰度级时把阈值降到 0.08。这个改动在浅色虹膜上召回率能提上来,但假圆也会略增,需要配合轮廓分数一起用。
5. 把检测串成视频流水线:帧间滤波与精度验证
5.1 帧间卡尔曼滤波的轻量实现
单帧检测再准,视频里也会有抖动。我一般加一个匀速卡尔曼滤波,状态量是[x, y, r, vx, vy, vr],观测是 Hough + 轮廓匹配输出的[x, y, r]。过程噪声协方差 Q 设 1e-2,观测噪声 R 设 5e-1,这个组合在 30fps 红外视频里比较跟手。如果不想引入卡尔曼,用滑动平均也行,窗口取 5 帧,但对快速眼跳会有延迟。下面是一个简化的卡尔曼更新片段。
import numpy as np class PupilKalman: def __init__(self): self.x = np.zeros((6, 1)) # [x, y, r, vx, vy, vr] self.P = np.eye(6) * 500 self.F = np.eye(6) self.F[0, 3] = self.F[1, 4] = self.F[2, 5] = 1.0 # 匀速模型 self.H = np.zeros((3, 6)) self.H[0, 0] = self.H[1, 1] = self.H[2, 2] = 1.0 self.Q = np.eye(6) * 1e-2 self.R = np.eye(3) * 5e-1 def predict(self): self.x = self.F @ self.x self.P = self.F @ self.P @ self.F.T + self.Q return self.x[:3].flatten() def update(self, z): z = np.array(z).reshape(3, 1) y = z - self.H @ self.x S = self.H @ self.P @ self.H.T + self.R K = self.P @ self.H.T @ np.linalg.inv(S) self.x = self.x + K @ y self.P = (np.eye(6) - K @ self.H) @ self.P return self.x[:3].flatten()逻辑说明:F矩阵把速度项耦合到位置项,实现匀速预测。Q越小,滤波越信任模型,眼跳时会滞后;Q越大,越信任观测,抖动会回来。R越大,越信任预测,适合检测噪声大的场景。我一般先用Q=1e-2, R=5e-1跑,如果眼跳跟踪滞后就把 Q 提到 5e-2。
5.2 精度验证:用标注数据算中心误差和半径误差
验证不要只看“看起来稳不稳”,要算指标。我一般手工标注 200 到 500 帧的瞳孔圆心和半径,然后算平均中心误差和半径误差。中心误差用欧氏距离,半径误差用绝对差。下面是一个批量评估的代码框架。
def evaluate_pupil(pred_list, gt_list): center_errors = [] radius_errors = [] for (px, py, pr), (gx, gy, gr) in zip(pred_list, gt_list): center_errors.append(np.sqrt((px - gx) ** 2 + (py - gy) ** 2)) radius_errors.append(abs(pr - gr)) return { "center_mae": float(np.mean(center_errors)), "center_p95": float(np.percentile(center_errors, 95)), "radius_mae": float(np.mean(radius_errors)), "radius_p95": float(np.percentile(radius_errors, 95)), }参数说明:center_mae反映整体精度,center_p95反映最差 5% 帧的稳定性。红外视频里如果center_p95超过center_mae的 3 倍,说明有离群帧,回去查眨眼和高光斑。半径误差的p95如果超过 3 像素,说明 Hough 的minRadius和maxRadius范围设宽了,收窄再跑。
5.3 一个我常犯的错:过度依赖单帧最优
早期我总想把单帧检测调到完美,param2和匹配阈值反复拧,结果换一段视频就崩。后来才明白,红外瞳孔检测的稳定性一半来自单帧,一半来自帧间。与其把单帧阈值卡到极限,不如留一点余量,让卡尔曼和连续性校验去补。我现在的习惯是:单帧召回率调到 90% 左右就停,剩下的交给帧间滤波,整体精度反而更高。这个方向值得做,但别指望一套参数打天下,按场景标定才是正路。希望帮到你。
本文还有配套的精品资源,点击获取