Hough变换与轮廓匹配的红外视频瞳孔直径精确检测 简介《基于Hough变换和轮廓匹配的红外视频瞳孔精确检测算法研究》以单篇PDF论文形式收录面向图像处理与计算机视觉方向的研究人员和工程师聚焦红外视频中瞳孔中心与直径检测精度偏低的问题。压缩包内仅1个PDF文件约1.04MB即论文全文涵盖算法原理推导、改进Hough梯度法步骤、轮廓匹配参数设置与实验对比数据。核心方法Hough-Contour先对每帧灰度化滤波去噪用改进Hough梯度法初步定位瞳孔并以累加值阈值自动排除闭眼帧再在瞳孔附近做位置与半径受限的圆轮廓匹配使直径检测精确匹配率由OpenCV圆变换的约10%提升到99.8%低端设备处理320×240视频单帧约60ms。已有170人学习适合希望复现实时瞳孔检测、借鉴参数调优与降耗思路的读者精读。1. 一个眼睛视频里的直径误差能把整套实验数据带偏瞳孔直径测不准这件事做红外视频瞳孔检测的同行都有体会帧率够、画面也干净可直径数字帧间跳三五个像素后面的分析全得打折。Hough 变换做圆检测是常规套路直接拿来框瞳孔圆心还凑合半径基本靠运气。这套结合 Hough 变换和轮廓匹配的红外视频瞳孔精确检测算法文中简称 Hough-Contour把两件事拆开做Hough 只负责给出「大致在哪儿、大概多大」的初始圆真正的中心和直径交给轮廓匹配在局部一点点试出来。论文给的数字挺硬——320×240 在低端机器上 60 ms 一帧640×480 也能跑实时精确匹配率从 OpenCV 圆变换的 10% 左右拉到 99.8%还顺手用累加值阈值把闭眼帧剔掉。适合做眼动、疲劳监测、心理生理实验手上有红外摄像头、需要逐帧拿瞳孔坐标和直径的开发者。2. 算法骨架与工程落地一帧图像进来要过几道手2.1 四段式流水线灰度滤波、Canny、Hough 初检、轮廓匹配原算法的流程拆得很干脆没有多余的模块。读入一帧红外眼睛图像先转灰度再用高斯平滑把传感器噪声压掉老接口里就是cvSmooth新版本对应cv2.GaussianBlur第二步用 Canny 提边缘阈值取 100/50这个组合是照顾红外图像里瞳孔边界对比度高但睫毛、眼皮边缘同样锐利的特点第三步跑改进的 Hough 梯度法得到初始圆c0(x0, y0, r0)第四步以c0为中心在位置和半径都有小幅变化的圆形轮廓上做匹配输出最终的圆心坐标和直径。关键在于第三步和第四步的分工。Hough 变换是全局搜索鲁棒但分辨率受累加器离散化限制半径只能取整边缘稍有缺失圆心就会漂轮廓匹配是局部搜索精度高但必须先知道大致位置否则搜索空间爆炸。前者的输出正好是后者的输入后者又只在r0/10的邻域里翻找两边互补。提示如果你的红外视频是固定机位、头部有头托固定瞳孔位置帧间变化很小这条流水线基本可以原样搬如果是头戴式眼动仪、视野在动第四步的搜索窗要适当放大代价是耗时线性上升。从工程角度看这条流水线还有个隐性优点每一步都能单独拿中间结果做可视化。灰度滤波看噪声Canny 看边缘有没有断Hough 看累加器峰值轮廓匹配看最终圆和原始边缘贴合不贴合。调参的时候不用猜哪一步出问题一眼能看出来。2.2 环境与依赖老接口icvHoughGradient对应现在的什么原始实现跑在 OpenCV 1.0 加 VC6 上用的是icvHoughGradient这个内部函数。这个名字在新版本里已经不存在了对外暴露的是cv::HoughCirclesmethod参数传HOUGH_GRADIENT。但这里有个实际问题cv2.HoughCircles只返回排好序的圆不给你累加器数组而算法里「用累加峰值阈值剔除闭眼帧」这一步恰恰需要累加值。所以要么改 OpenCV 源码要么自己实现一遍梯度法投票——后者更现实代码量也就几十行。环境我一般这么配# 建议 Python 3.9 以上opencv 4.x 和 numpy 都走 wheel 包 pip install opencv-python4.9.0.80 numpy1.26.4 # 只在要看曲线、做批量统计时才装运行时不依赖 pip install matplotlib版本上没必要锁太死cv2.Sobel、cv2.Canny、cv2.GaussianBlur这几个从 3.x 到 4.x 行为一致。真正需要注意的是cv2.HoughCircles的param1、param2语义在不同大版本间调过默认值如果你的代码是靠调这两个参数收敛的换版本一定要重测。至于「60 ms 一帧」这个指标要有个清醒认识那是 C 加 OpenCV 原生循环的成绩。同样的逻辑用 Python 逐像素写一帧能跑到几秒。所以要复现性能投票和匹配这两段必须向量化把循环压到 numpy 里后面第 5 章会专门讲这个坑。2.3 目录结构和一帧数据的进出项目不需要多复杂的工程结构我一般按处理阶段拆文件方便单独验证pupil_hough_contour/ ├── config.yaml # 半径范围、Canny 阈值、k、d_ratio、累加阈值 ├── io_utils.py # 视频读帧、ROI 裁剪、结果 CSV 落盘 ├── preprocess.py # 灰度化 高斯滤波 ROI ├── hough_init.py # 自实现梯度法投票返回 (x, y, r, peak) ├── contour_refine.py # 圆形轮廓匹配精修 ├── pipeline.py # 串起四步输出单帧结果 └── tools/ ├── visualize.py # 叠框看效果 └── eval_radius.py # 与人工标注半径比对单帧的输入输出定成两个结构就够了。输入是grayuint8 单通道加一个roi可选眼睛所在矩形输出是(cx, cy, d, score)d是直径score是轮廓匹配的平均差异值用来做质量门控——score突然变大多半是眨眼、眯眼或者眼睛跑出画面了。把这个分数一起存下来后期清洗数据比看视频快得多。参数统一放config.yaml别散在代码里。半径范围r_min/r_max是最该先确定的东西它跟你的摄像头焦距、眼睛到镜头的距离直接相关用人工量一批帧估出来比反复试快。3. Hough 初检把降序排序换成求最大值省下的时间3.1 梯度法投票为什么只用梯度方向上的两个候选点标准 Hough 圆变换要对每个边缘点在参数空间 (a, b, r) 里三维投票复杂度高得没法实时。梯度法做了个假设圆心一定在边缘点的梯度方向上。于是每个边缘点只需要沿梯度正负两个方向各投一次票投票量从n × N_a × N_b × N_r降到n × N_r × 2。具体做法是对灰度图做 Sobel得到gx、gy某点(x, y)的梯度模长为m单位方向向量就是(gx/m, gy/m)。从该点出发沿正方向走r步得到候选圆心(x r·nx, y r·ny)沿反方向走得到(x - r·nx, y - r·ny)两者都在半径范围里遍历投票。边缘点的来源是 Canny 结果不是全图。这一步很关键红外图像里瞳孔区域是暗的、虹膜是亮的边界对比强Canny 能稳定抓到但睫毛、眼皮褶皱也会产生大量边缘点全部拿来投票累加器会被噪声拉偏。所以我在投票前加一道梯度模长过滤低于阈值的直接跳过。还有一个极性细节。梯度方向指向亮度增加的方向如果瞳孔比周围暗大多数红外反射成像都是这样那么从瞳孔内指向瞳孔外就是梯度正方向反向投票那一支基本是废票。但不同波长、不同摄像头装法会导致极性反转稳妥做法是两支都投或者先用一帧人工确认极性再固定一支能省一半计算。3.2 累加数组求最大值代码实现原算法在这一步做了个改动原来的icvHoughGradient会把候选圆心按累加值降序排序再依次算半径改成直接在累加数组里找最大值省掉排序和后续的半径计算。下面这版是自己实现的等价逻辑用 numpy 把半径维的循环向量化import cv2 import numpy as np def hough_gradient_peak(gray, r_min, r_max, canny_thr(100, 50), grad_thresh80, peak_ratio0.30): 梯度法 Hough直接返回累加器峰值对应的 (cx, cy, r, peak)。 r_min/r_max : 瞳孔半径的物理范围按你的成像条件标定 canny_thr : 沿用红外图像经验值 (100, 50) grad_thresh : 梯度模长下限滤掉睫毛等弱边缘 peak_ratio : 峰值归一化阈值低于它视为闭眼帧 h, w gray.shape gx cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize3) gy cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize3) mag cv2.magnitude(gx, gy) edges cv2.Canny(gray, canny_thr[0], canny_thr[1]) ys, xs np.nonzero(edges) # 用模长过滤只留有方向的强边缘 keep mag[ys, xs] grad_thresh xs, ys xs[keep], ys[keep] nx gx[ys, xs] / mag[ys, xs] ny gy[ys, xs] / mag[ys, xs] # 累加器只开半径这一段索引 r - r_min省内存 acc np.zeros((r_max - r_min 1, h, w), dtypenp.uint16) for r in range(r_min, r_max 1): for sign in (1.0, -1.0): a np.rint(xs sign * r * nx).astype(np.int32) b np.rint(ys sign * r * ny).astype(np.int32) valid (a 0) (a w) (b 0) (b h) # np.add.at 是慢路径帧率要求高时换成 bincount reshape np.add.at(acc[r - r_min], (b[valid], a[valid]), 1) r_idx, cy, cx np.unravel_index(np.argmax(acc), acc.shape) peak int(acc[r_idx, cy, cx]) r0 r_idx r_min # 归一化峰值 / 理论最大投票数周长上的边缘点数消除半径影响 if peak peak_ratio * 2 * np.pi * r0: return None # 判为闭眼或无效帧 return int(cx), int(cy), int(r0), peak逐段说下意图。gx/gy用CV_32F存避免负数溢出mag同时用于过滤和方向归一化。np.nonzero(edges)拿到所有边缘点坐标后立刻用keep筛一遍后面所有计算都在这个子集上图越小收益越大。np.rint取整是原算法的做法理论上双线性累加更平滑但累加器本身离散取整对结果影响不大。累加器的形状是(r_max - r_min 1, h, w)只开半径这一段320×240 图像、半径范围 10 到 50 的话大概 10 MB 出头吃得下。np.add.at是 numpy 里少有的「原地散射加法」遇到重复索引会正确累加但性能一般如果 профилирование 发现这里是瓶颈把它换成np.bincount(b * w a, minlengthh * w).reshape(h, w)能快好几倍。np.argmax加np.unravel_index就是「求最大值」这一步的全部替代了原来的降序排序。排序是 O(n log n)求最大值是 O(n)候选圆心数量上万的时候差距就出来了。3.3 用累加峰值阈值把闭眼帧挡在外面闭眼帧是这类系统里最烦的东西没有瞳孔边界Hough 会在一堆睫毛边缘上凑出一个假圆圆心乱飘、半径随机后面的轮廓匹配还会把这个错误「精修」得像模像样最后整段数据被污染。原算法的办法是给累加峰值定一个阈值低于阈值直接判为闭眼帧跳过。这个思路对但直接用绝对峰值有问题半径大的圆理论最大投票数多累加值天然更高光照变化也会整体压低或抬高投票数。所以我在这版实现里做了归一化用peak / (2πr)也就是「平均每个边界点拿到了几张票」作为判据peak_ratio取 0.30 左右这个值对半径和整体亮度都不敏感。阈值怎么定别凭空猜。拿一段包含明显眨眼动作的录制把每帧的归一化峰值打出来画条曲线睁眼帧和闭眼帧会分成两团阈值取中间偏闭眼一侧一般能看到明显的一段空隙。这个方法比在代码里反复调数字靠谱。注意阈值定得太高会误杀正常帧尤其在瞳孔被上眼皮遮住一部分、边界只剩半个圆的时候。宁可放进来再靠轮廓匹配的score二次过滤也别在 Hough 层就砍掉否则丢帧率会很难看。4. 轮廓匹配精修半径 1/10 的搜索窗和 40 个点对怎么定4.1 圆形轮廓模型内外两圈点对的构造Hough 给出的c0(x0, y0, r0)只是个粗定位。半径是整数、圆心受累加器网格限制误差通常在一到三个像素。轮廓匹配要在这个基础上把精度拉上来做法是构造一系列候选圆比较每个候选圆「贴合瞳孔边界」的程度取最好的那个。贴合程度怎么算在候选圆(x, y, r)上按角度均匀取k个点每个角度θ对应两个采样位置往内缩 1 像素的p (x (r-1)cosθ, y (r-1)sinθ)往外扩 1 像素的p (x (r1)cosθ, y (r1)sinθ)。如果r正好落在瞳孔边界上p在瞳孔内部暗p在瞳孔外部亮两点灰度差最大偏大或偏小差值都会掉下来。这就把「找边界」变成了「找灰度跳变最剧烈的那一圈」。相比拟合边缘点这种方法不依赖边缘检测是否完整哪怕瞳孔下半部分被遮挡、Canny 断成几截只要剩下的角度上还有清晰跳变匹配就能收敛。import numpy as np def contour_match(gray, cx0, cy0, r0, k40, d_ratio0.1): 在 c0 邻域内搜索最佳圆。 k : 圆周采样点数40 是精度/耗时的拐点 d_ratio : 搜索窗相对 r0 的比例1/10 是实验最优 返回 (cx, cy, r, score)score 越小说明边界越干净 h, w gray.shape d max(1, int(round(r0 * d_ratio))) theta np.arange(k, dtypenp.float32) * (2 * np.pi / k) cos_t, sin_t np.cos(theta), np.sin(theta) best_score, best np.inf, (cx0, cy0, r0) for r in range(r0 - d, r0 d 1): for x in range(cx0 - d, cx0 d 1): for y in range(cy0 - d, cy0 d 1): xi np.rint(x (r - 1) * cos_t).astype(np.int32) yi np.rint(y (r - 1) * sin_t).astype(np.int32) xo np.rint(x (r 1) * cos_t).astype(np.int32) yo np.rint(y (r 1) * sin_t).astype(np.int32) # 出界的候选圆直接跳过避免索引异常 if (xi.min() 0 or xi.max() w or yi.min() 0 or yi.max() h or xo.min() 0 or xo.max() w or yo.min() 0 or yo.max() h): continue f_in gray[yi, xi].astype(np.float32) f_out gray[yo, xo].astype(np.float32) score np.abs(f_out - f_in).mean() if score best_score: best_score, best score, (x, y, r) return best[0], best[1], best[2], float(best_score)采样用np.rint最近邻跟原算法的f(x, y)一致。想再提精度可以换双线性插值但要注意插值会把边界「抹平」在强反光或者瞳孔边缘有渐变的帧上反而可能让 score 分布变平最优点不好挑。我一般在 320×240 上就用最近邻640×480 才考虑插值。score这个输出别丢。它的物理含义是「边界两侧的平均灰度差」瞳孔边界清晰时是个较大的数用abs的话模糊、遮挡、假圆时明显偏小。拿它做帧级质量门控比只看 Hough 峰值灵敏。4.2 三重循环搜索d r0/10的取值依据搜索窗d取初始圆半径的 1/10是个有实验支撑的经验值。论文里对比过不同d取r0/20时搜索范围太窄Hough 初检偏差稍大就找不回真值取到r0/2精度几乎不再提升耗时却按(2d1)³涨。1/10 正好卡在精度饱和点上。三重循环的复杂度是(2d1)³ × k。按r0 30、k 40算d 3候选组合 7×7×7 343 个每个算 40 个点对总共一万三千多次采样全向量化之后在 Python 里也就几毫秒。有个优化值得一提三个维度不是等权的。半径误差对 score 的影响比圆心位置更平滑也就是说(x, y)方向的目标函数更尖锐。所以可以先固定r r0在平面上粗搜一遍找出最优(x, y)再以它为原点展开半径搜索。这样能砍掉一多半的无效组合代价是有极小概率错过全局最优。数据质量好的时候我常这么干抖得厉害的录制就老老实实全搜。4.3 点对数k与精度、耗时的平衡k是圆周采样点数直接决定精度上限也直接决定耗时。论文里给的对照数据很有参考价值点对数 k精确匹配率单帧匹配耗时1050.0%5.2 ms2090.0%11.7 ms3099.0%15.9 ms4099.8%22.2 ms从 10 到 20 是最陡的一段匹配率从一半跳到九成——点太少的时候采样点很可能全落在边界缺失的那半圈上评分完全失真。20 到 30 还在涨30 到 40 只多了 0.8 个百分点耗时却多了 6 ms。所以 40 是「精度优先」的选择如果你的机器吃力或者帧率要求高30 是性价比更好的点99% 的匹配率对绝大多数应用足够了。提示k不要随便取 36、45 这种数。k 40对应每个点间隔 9°瞳孔半径 20 到 40 像素时圆周采样点的弧长差不多正好两三个像素相邻点不会挤在一起也不会漏掉局部边界。取 8 的倍数还能顺手把角度表缓存成常量省掉每帧的三角函数计算。实际耗时还跟图像分辨率强相关。320×240 上单帧总计 60 ms 的量级主要是 Hough 投票占大头换到 640×480边缘点数量翻四倍投票阶段的耗时会线性上去必须靠 ROI 裁剪把处理面积压回来。5. 踩坑排查这套算法在真实视频上翻车的几个地方5.1 强反光点上位Hough 圆心被拉走现象某些帧的检测圆完全跑偏落在角膜反光斑上半径还特别小肉眼一看就知道不对但 Hough 峰值很高。原因红外光源在角膜上形成的亮斑是个小而亮的圆边缘极锐利Canny 能检出完整闭合的边界梯度方向还高度一致投票时这些点全指向同一个圆心累加值轻松超过真正的瞳孔边界。瞳孔边缘因为被眼皮遮挡反而是不完整的。解决三个手段叠着用。一是限制r_min反光斑半径通常只有 2 到 5 像素把下限定在 10 以上就能挡掉大部分二是在投票前对反光斑做掩膜用阈值分割找灰度饱和区比如gray 240膨胀几像素后从边缘点里剔除三是靠score兜底反光斑所在位置的内外灰度差是对称的亮斑内外都亮|f_out - f_in|会明显偏小匹配阶段有机会被真正的瞳孔抢回来。5.2 闭眼帧漏判阈值跟着光照跑现象一段视频前半段闭眼帧剔得干干净净后半段开始漏假圆照样输出。原因用的是绝对峰值阈值而录制过程中红外光源功率漂移、被试距离变化整体投票数跟着上下浮动固定阈值必然在一头失效。解决前面第 3.3 节说的归一化用peak / (2πr)代替绝对峰值。再进一步可以在视频开头取 30 帧做自适应标定算归一化峰值的均值和方差阈值设成μ - 2σ运行中再用滑动窗口缓慢更新。闭眼帧占比高的时候别用中位数做基线会被拽偏。5.3 直径整体偏大或偏小极性搞反了现象所有帧的直径都比人工标注大两三个像素或小两三个像素而且是系统性的不是随机抖动。原因轮廓匹配的目标函数用了绝对值差|f_out - f_in|。如果图像极性是「瞳孔亮、虹膜暗」某些反射式成像会出现内外圈的定义就反了(r-1)落在亮区、(r1)落在暗区最优点会偏移到边界外一点点导致半径系统性偏大。解决先确认极性。取一帧人工标出瞳孔中心和半径打印边界内外各 3 像素的灰度剖面看是「内暗外亮」还是反过来。如果是反的把内外圈公式对调或者在评分里去掉绝对值、改成带符号的f_out - f_in然后取最大。这一步花十分钟能省掉后面几天的数据返工。5.4 帧间抖动搜索窗继承了上一帧的误差现象单帧看精度都不错连续播放时圆框肉眼可见地抖直径曲线毛刺多。原因有人为了提速把上一帧的结果当本帧的初值跳过 Hough 直接做轮廓匹配。问题在于轮廓匹配的搜索窗只有r0/10一旦某一帧因为眨眼、抖动跑偏误差会被后一帧继承还可能越跑越远形成漂移。解决Hough 初检不能省每帧都跑只是可以缩小半径范围和搜索区域。真要利用帧间连续性也应该用「Hough 结果和预测结果做一致性校验」的方式两者偏差小于阈值就取平均大于阈值就信任 Hough 并对该帧打标记。连续标记超过 N 帧说明跟踪真的丢了重新初始化。5.5 Python 版慢十倍循环写在了解释器里现象C 版 60 ms 一帧照抄逻辑写成 Python 变成一秒多实时性完全没了。原因投票阶段的for x, y in zip(xs, ys)这种逐点循环Python 解释器的开销比实际计算大得多轮廓匹配的三重循环加内层k次采样如果也写成 Python 循环一帧就是几十万次解释器调度。解决所有以像素为单位的循环都必须压到 numpy 里。投票阶段按半径外层循环、边缘点内层向量化第 3.2 节的写法匹配阶段把k个角度做成常量数组用一次gray[yi, xi]完成全部采样。改完之后 320×240 能压到 100 ms 以内。还不够就上numba.jit或者用 Cython 重写投票函数收益立竿见影。用cProfile先定位别盲目优化八成时间都花在那一两个函数上。6. 二次开发把它接进你自己的预处理链路这套算法的价值不在单帧精度而在于它是个很干净的预处理环节——输入一帧灰度图输出(cx, cy, d, score)中间不依赖任何状态。这意味着你可以把它塞进任何视频处理框架里前后都不用改。我最常做的三处改造按收益排第一处是 ROI 裁剪。红外眼动视频里眼睛通常只占画面一小块全图跑 Hough 纯属浪费。用cv2.selectROI手工框一次固定机位只需一次后续每帧裁出来处理640×480 的处理面积能降到原来的四分之一耗时基本跟着线性下降。要小心的是裁剪后r_min/r_max不用改但输出的坐标记得加回偏移。第二处是双分辨率策略。先用 320×240 下采样跑一遍拿到粗略位置再在原图对应区域做小范围 Hough 和轮廓匹配。这样大分辨率视频的耗时接近小分辨率精度却不掉。代价是多一次缩放缩放插值会轻微模糊边界score的绝对值会变小质量门控的阈值需要按新尺度重新标一次。第三处是把score用起来。单看一帧的score只能判断质量看连续帧的score序列能判断状态缓慢下降说明眼睛在眯、在疲劳突然跳变说明眨眼或有遮挡。做疲劳监测的话这就是个几乎零成本的副产物# 用 score 序列做简易状态判定比单独训练分类器省事 import numpy as np def judge_state(scores, win15, drop_ratio0.6): scores: 最近若干帧的轮廓匹配分数越大表示边界越清晰 if len(scores) win: return warming_up recent np.array(scores[-win:], dtypenp.float32) base np.median(recent[: win // 3]) # 用片段开头做基线 if recent.min() base * drop_ratio: # 明显塌陷 return blink_or_occlusion if recent[-1] base * 0.85: return drowsy_suspect # 持续走低疑似眯眼 return normal参数上win取 15 帧在 30 fps 下是半秒够捕捉一次眨眼但不会太迟钝drop_ratio给 0.6 是留了余量因为真实评分本身就有波动。这套判据不严谨但作为预处理标记足够用了被标出来的片段可以人工复核或者直接剔除。改造之后一定要有验证手段不然改着改着就不知道精度掉没掉了。我的习惯是攒一份小规模金标数据——两三百帧每帧人工标出圆心和半径别嫌少够用了——然后跑一个对比脚本输出平均偏差、标准差和匹配率指标含义可接受范围圆心偏差与人工标注的欧氏距离均值 1.5 像素半径偏差直径误差绝对值均值 2 像素匹配率偏差在 2 像素内的帧占比 95%无效帧率返回 None 或 score 过低的帧占比 5%每次改完参数、换了预处理、升级了 OpenCV都跑一遍这张表。有一回我把高斯核从 5×5 改成 3×3主观感觉画面更清晰了结果半径偏差均值从 1.2 涨到 2.8匹配率掉了十几个点——红外图像的传感器噪声本来就重核一小Canny 检出的边缘全是毛刺投票自然被带偏。从那以后我每次动滤波参数都强制先跑一遍金标对比再往下走不再信肉眼。这套东西的坑多半不在算法本身而在「以为参数可以通用」。不同摄像头、不同光源、不同被试半径范围和阈值都得重新标别指望一套配置打通。希望帮到你。本文还有配套的精品资源点击获取