简介这份资源面向图像处理与计算机视觉领域的研究人员和工程师针对红外视频中瞳孔直径检测精度偏低的痛点提出结合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 内做自适应直方图均衡。注意不要用全局直方图均衡红外视频里背景占了大半全局均衡会把瞳孔对比度拉低。用 CLAHEclipLimit 设 2.0 到 3.0tileGridSize 设 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(clipLimit2.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, blockSize31, C8 ) # 4) 形态学开运算去掉睫毛和孤立噪点 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) opened cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations1) 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.2minDist设瞳孔半径的 1.5 倍param1设 80 到 120param2设 15 到 25。下面是一个可复现的调用示例。def detect_pupil_candidates(binary_img, min_radius8, max_radius40): # HoughCircles 对输入灰度图更敏感这里用二值图也可以 circles cv2.HoughCircles( binary_img, cv2.HOUGH_GRADIENT, dp1.2, minDistmin_radius * 1.5, param1100, # Canny 高阈值 param218, # 累加器阈值越小越容易出假圆 minRadiusmin_radius, maxRadiusmax_radius ) if circles is None: return [] circles np.round(circles[0, :]).astype(int) return circles参数说明dp1.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, keycv2.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, dtypenp.uint8) cv2.circle(mask_pupil, (x, y), int(r * 0.8), 255, -1) mask_ring np.zeros(gray_img.shape, dtypenp.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, maskmask_pupil)[0] ring_mean cv2.mean(gray_img, maskmask_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越大越信任预测适合检测噪声大的场景。我一般先用Q1e-2, R5e-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% 左右就停剩下的交给帧间滤波整体精度反而更高。这个方向值得做但别指望一套参数打天下按场景标定才是正路。希望帮到你。本文还有配套的精品资源点击获取