简介基于Hough变换与轮廓匹配的瞳孔检测方案来自《计算机应用》期刊2016年论文面向图像处理与计算机视觉领域的研究人员、工程师核心解决红外视频中瞳孔直径检测精度低的问题。算法先对每帧图像灰度化并滤波去噪通过改进的Hough梯度法初步定位瞳孔再在候选区域进行轮廓匹配精确计算瞳孔中心与直径实验显示匹配精度达99.8%可自动排除闭眼帧低端设备能实时处理320×240视频。资源包为单份PDF文档大小1.04MB目前已有169人学习。内容详细展开改进Hough变换的累计值寻优、轮廓匹配范围与点对数设定等关键细节适合需要复现实验或迁移至心理健康、医疗诊断等场景的读者。1. 红外视频里的瞳孔检测为什么“能找到”和“能精确找到”是两码事红外视频瞳孔精确检测真正卡住进度的往往不是检出率而是稳定性瞳孔明明就在画面里但输出的中心点每帧跳一两个像素下游的注视估计和疲劳判断就会跟着抖。基于Hough变换和轮廓匹配的组合算是一条把“先粗定位、再精修”落到代码上的可靠路线——Hough变换用圆的先验快速锁定瞳孔的大致位置轮廓匹配在锁定区域里用边界形状把中心修正到接近亚像素量级。这个方案适合正在做眼动数据采集、红外视频疲劳监测、注视估计算法原型的人。你不需要昂贵的眼动仪一台带近红外滤光片的摄像头加一套能跑 cv2 的 Python 环境就能复现但它也不是拿来直接训练的模型而是一条可解释、可调参、出问题能定位到具体环节的工程流水线。下面从原理、代码、参数到翻车记录按我实际调通的顺序讲清楚。2. Hough变换先做粗定位给瞳孔中心一个“够用”的初值2.1 为什么第一层必须用Hough变换圆的先验在红外图里的优势在可见光图像里瞳孔和虹膜的颜色对比常常被环境光、眼影、美瞳干扰而近红外波段下瞳孔对红外光的吸收明显高于虹膜成像上会稳定地呈现为一个暗色圆形区域边缘比可见光清晰得多。这个先验直接交给Hough变换来吃是最省力也最稳的。Hough梯度法的内部实际上是两段式先用Canny提取边缘再利用边缘点的梯度方向向参数空间投票投票峰值对应的位置就是圆心。它不要求边缘完整只要瞳孔圆弧上残留一段可投票的弧圆心就能被锁定这对睫毛遮挡、眨眼半开帧非常友好。代价同样明显Hough的输出分辨率受累加器量化和边缘点离散化的限制精度通常只能到一两像素而且角膜上的红外反射亮斑如果正好落在瞳孔边界附近会把投票重心拉偏。所以它只适合当第一层负责在整帧或局部窗口里给出一个不差太远的初值。相比之下直接用深度学习分割网络去做这一步在实验室里精度确实漂亮但落地时有两个绕不开的问题一是需要大量带像素级标注的红外瞳孔数据标注成本高二是网络输出是黑匣子单帧失败时很难判断是数据问题还是模型问题。Hough变换这一层没有训练成本参数就几个出问题能直接定位这也是它在这个场景里至今没被完全替代的原因。2.2 红外帧的预处理亮斑扣除与灰度归一化进Hough之前红外帧要做两步处理顺序不能反。第一步是中值滤波尺寸取3×3或5×5作用是干掉传感器噪声和细碎的睫毛阴影——噪声如果不干净Canny会多出一圈假边缘累加器里全是杂散票。第二步是扣亮斑红外LED的角膜反射通常是一块接近饱和的亮点它本身不是瞳孔边界却会在边缘图里生成一个强响应。常见做法是先对灰度图做形态学闭运算闭出亮斑区域生成亮斑掩膜再把掩膜区域的灰度值压到瞳孔的平均灰度附近。还有一个容易忽略的点红外波长不同瞳孔和虹膜的对比度不一样。700nm附近对比度偏弱850nm以上成像更亮、边缘更硬但亮斑也更尖锐。如果摄像头带的滤光片中心波长在850nm附近预处理里的亮斑扣除基本是必须的如果只有700nm左右亮斑问题轻一点但中值滤波的核可以加大到5×5补回边缘质量。我这里直接把预处理和粗定位合成一个函数放在同一段代码里。需要注意HoughCircles的输入要求是单通道灰度图且不要求先做二值化噪声预处理直接影响的是param1的值而不是圆检测本身。2.3 HoughCircles 的最小可跑代码与参数含义import cv2 import numpy as np def preprocess_infrared(gray): # 中值滤波去除传感器噪声与细睫毛阴影 blurred cv2.medianBlur(gray, 5) # 形态学闭运算提取红外亮斑角膜反射 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)) bright_mask cv2.morphologyEx(blurred, cv2.MORPH_CLOSE, kernel) bright (blurred (bright_mask - 10)) (bright_mask 200) # 把亮斑区域压到瞳孔平均灰度附近避免边缘响应 out blurred.copy() out[bright] 60 return out def hough_rough(gray, min_r, max_r): # 输入必须是预处理后的单通道灰度图 circles cv2.HoughCircles( gray, cv2.HOUGH_GRADIENT, dp1.5, minDistmax(gray.shape), # 只关心一个瞳孔距离设到帧宽以上 param1140, # Canny 高阈值 param224, # 累加器阈值越小假圆越多 minRadiusmin_r, maxRadiusmax_r ) if circles is None: return None # HoughCircles 返回 (N,1,3)取投票最高的第一个圆 x, y, r circles[0][0] return int(x), int(y), int(r)逻辑说明preprocess_infrared 里先用中值滤波压噪再用闭运算估计背景亮场把角膜反射这种“小面积过曝区”从边缘提取里排除掉hough_rough 把所有参数都提到函数参数便于后续对不同分辨率视频单独调。参数说明dp1.5 表示累加器分辨率比原图低一级处理1080p帧时速度提升明显minDist 设置成图像长边长度保证全图只输出一个瞳孔圆param1 越高Canny保留的边缘越少红外图对比度高140附近比较好用param2 是投票阈值24在遮挡较重时会漏检这时调到12到15会捡回结果但引入少量假圆所以后面要靠ROI和轮廓匹配去滤。参数取值区间作用与调整方向dp1~2累加器分辨率值越大定位越粗但越快ROI内建议1.5全图1080p建议2minDist图像长边只检测一个圆设到最大可避免输出多个瞳孔候选param1100~160Canny高阈值红外对比度高时可偏高边缘断裂时降低param212~30累加器阈值越小越容易出圆也越容易混入假圆遮挡严重向12调minRadius/maxRadius由场景估算限制半径范围是挡假圆最有效的手段不设会检到眼睑弧线minRadius和maxRadius的估算有个实用技巧瞳孔物理直径一般在2到8mm之间已知摄像头工作距离和视场角时可以用相似三角形换算成像素半径。不用算得很准估到真实半径的0.5倍到1.5倍区间即可这个区间本身就能把瞳孔和眼睑、镜框的弧线区分开。2.4 为什么粗定位结果不能直接当输出把Hough的结果直接标到视频上肉眼看圆心位置“大体正确”但逐帧回放会发现中心在1到3像素内来回跳。原因有两层一是Hough的累加器本身有量化网格同一个圆在不同帧里投票峰值可能落在相邻网格二是亮斑或睫毛残留的边缘会持续把峰值往一侧拉。有人会想那把param2调低、把累加器分辨率调高是不是就能让Hough单层扛住精度实际上很难。param2越低假圆越多dp越大计算量越大在实时视频里都不可持续。而且就算调出高精度斜视时瞳孔投影变成椭圆圆模型本身的偏差不是调参能消掉的。理解这一层就能明白轮廓匹配那一层不是锦上添花而是必须。3. 轮廓匹配做精定位把瞳孔从“圆”改写成“椭圆”3.1 在ROI里找最有资格的轮廓面积、圆度、凸性三指标拿到粗定位的圆心和半径第一步是把搜索范围缩到一个ROI一般取2.5倍粗半径的矩形。ROI内部做二值化。这里有个红外图特有的坑瞳孔是暗的直接threshold会把瞳孔背景搞反所以要么用THRESH_BINARY_INV要么先二值化再位取反。二值化阈值我一般用Otsu而不是固定值因为红外LED的发光强度在视频流里会有周期性波动固定阈值在暗帧里会把瞳孔面积撑大一圈。轮廓匹配不是拿一个预先存好的模板去匹配而是在提取出的候选轮廓里做形状筛选。三个指标最有用面积、圆度、凸性。面积直接用 cv2.contourArea候选必须落在粗定位半径决定的范围内比如 π×(0.6r)² 到 π×(1.5r)²。这个范围之所以放宽到1.5倍是因为斜视时椭圆外接面积明显变大。圆度是 4πA/P²正圆为1椭圆会降到0.7到0.9被睫毛切过的轮廓会更低。阈值通常取0.5太低会把背景杂物放进来太高会把遮挡帧全部丢掉。凸性用轮廓面积与凸包面积的比值衡量如果轮廓被睫毛挤出一个凹口凸性会明显下降这能识别出“看起来像圆但其实是被遮挡拉坏”的伪轮廓。这三个指标配合粗定位的半径范围基本能过滤掉ROI里90%以上的噪声轮廓。剩下的工作就是拟合和校验。3.2 椭圆拟合并计算中心fitEllipse 的最小实现import cv2 import numpy as np def contour_refine(roi, rough_x, rough_y, rough_r): # roi: 以粗定位中心裁出的局部灰度图 # rough_x/rough_y/rough_r: 与roi同一坐标系下的粗定位结果 blur cv2.GaussianBlur(roi, (5, 5), 0) # Otsu 二值化瞳孔是暗区反相后瞳孔变为白色 _, th cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) th cv2.bitwise_not(th) # 只取最外层轮廓瞳孔内部的反光斑不会形成独立候选 contours, _ cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) best None for cnt in contours: area cv2.contourArea(cnt) peri cv2.arcLength(cnt, True) if peri 0: continue roundness 4 * np.pi * area / (peri * peri) # 面积筛选以粗半径的0.6~1.5倍为范围 if area np.pi * (0.6 * rough_r) ** 2 or area np.pi * (1.5 * rough_r) ** 2: continue if roundness 0.5: # 睫毛切边或碎片轮廓直接丢弃 continue # 椭圆拟合返回中心、长短轴、旋转角 (cx, cy), (MA, ma), angle cv2.fitEllipse(cnt) # 偏心度检查正常瞳孔长短轴比不超过1.6 ratio MA / ma if MA ma else ma / MA if ratio 1.6: continue # 中心离粗定位中心太远的轮廓视为反光斑或泪液边缘 if np.hypot(cx - rough_x, cy - rough_y) 2.0 * rough_r: continue # 保留面积最大的合格轮廓抗遮挡效果最好 if best is None or area best[3]: best ((cx, cy), (MA, ma), angle, area) return best逻辑说明整个函数只做一件事——在ROI内找到“唯一一个最像瞳孔的轮廓”然后用最小二乘椭圆拟合输出中心。Otsu自适应阈值解决LED亮度波动只取RETR_EXTERNAL让内部反光斑不参与候选最后返回的 (cx, cy) 与输入roi使用同一坐标系调用方负责把ROI坐标映射回原图坐标。参数说明圆度0.5是下限在睫毛遮挡严重的帧里可以降到0.35但代价是可能选到眼睑边缘的弧形轮廓偏心度1.6是比较安全的阈值正常生理范围内的斜视不会超过这个值如果场景包含大幅扫视可以放宽到2.0。fitEllipse返回的已经是浮点中心这一步天然具备亚像素输出能力真正的精度瓶颈在边缘点本身的定位精度这也是第6章要展开的延伸点。3.3 为什么“椭圆中心”比“圆中心”更适合当输出很多人在这里会问Hough已经给出圆了为什么还要费劲拟椭圆实际原因是瞳孔只是正视时投影近似圆水平左右扫视时会变成椭圆。Hough的圆模型在椭圆度较大时会把圆心往长轴方向拖偏差可以到3像素以上而fitEllipse是对边缘点做最小二乘拟合长短轴和角度信息都保留了下来中心更接近解剖学上的瞳孔中心。此外椭圆长短轴比值和角度本身就是有价值的中间量。眼动追踪里椭圆的角度可以反映眼球滚动方向疲劳监测里长轴变化可以辅助判断注视停留。只保留圆心会丢掉这些信息而轮廓匹配这条路径天然把它们留了下来这也是这个方案相对“端到端回归网络”最大的工程优势——可解释、可审计、每一层输出都能可视化查因。4. 红外视频瞳孔检测避坑亮斑、遮挡与帧间抖动的排查记录4.1 角膜反射亮斑把瞳孔中心拉偏几像素现象局部区域检测的圆心明显向某一方向偏叠加在视频上能看到圆心贴在亮斑和瞳孔边缘中间。原因角膜反射亮斑在边缘图里产生一圈高响应Hough投票时把这一圈当作瞳孔边界的一部分投票重心被拉向亮斑侧轮廓提取时亮斑如果紧贴瞳孔边界轮廓会出现一个“鼓包”。解决我在预处理里用闭运算提取亮斑掩膜再把它压暗如果亮斑在瞳孔正中央压暗后会在瞳孔内部留一个暗洞这时靠RETR_EXTERNAL只取外轮廓就能忽略内部干扰。硬件上如果能把红外光源从相机光轴移开一点让亮斑落在虹膜而不是瞳孔上算法压力会小很多。这条属于改代码改动不大、收益很大的处理建议在搭采集环境时就先把光源位置试好。4.2 HoughCircles 在边缘断裂时直接“查无此圆”现象某些帧HoughCircles返回None程序崩在下一步取circles[0]的地方在睫毛浓密和眨眼瞬间尤其频繁。原因param2固定时Canny提取出的边缘数量受遮挡和对比度影响很大边缘弧段太短投票累积不够没有峰值。解决给Hough包一层重试逻辑第一次用param224失败后把param2降到14再试同时把minRadius/maxRadius按上一帧半径缩放再失败就返回上一帧的中心标记该帧状态为“遮挡”。不要为了追求一次成功把param2设得很低否则假圆会把后面的轮廓匹配全部带偏。4.3 正视是圆、侧视是椭圆圆和椭圆的结果打架现象同一帧里Hough圆中心和fitEllipse椭圆中心相差2到3像素且差值方向随眼动方向变化。原因瞳孔在斜视时投影是椭圆圆模型强行用一个等价的圆去拟合圆心会落在椭圆长轴偏外侧。解决以椭圆中心为唯一输出Hough结果只用来决定ROI位置和半径范围。如果你需要在两套结果之间可视化对比记得把Hough的圆也画出来人眼很容易看出它是“包着椭圆的大圆”这是正常现象不是bug。最怕的是两头都想要拿两个中心做平均结果在斜视帧里反而比单用椭圆更偏。4.4 逐帧独立检测导致中心抖动现象视频回放时检出圆心整体位置稳定但每一帧之间有1到2像素随机跳变绘制轨迹射线呈锯齿状。原因每一帧的Hough投票峰值和Otsu二值化结果都有微小随机差异独立检测没有利用时间连续性。解决给检测加状态。后续帧在上一帧中心的局部窗口里做检测而不是全图搜索输出中心做指数滑动平均。这里有个经验平滑系数不要低于0.4否则真实眼动会被当噪声吃掉扫视时中心拖在后面不跟手。具体参数在第5章的跟踪器代码里会给出。提示如果你是在做离线数据分析而不是实时眼动追踪可以不做滑动平均而是对整段视频做双向滤波效果比在线EMA更平滑但实时性方案必须用因果滤波。4.5 Otsu在红外反光不均时二值化面积突变现象某几帧瞳孔轮廓面积突然比相邻帧大30%以上fitEllipse结果被这帧带偏。原因红外反光不均匀时Otsu会按全局直方图找阈值暗区的部分虹膜被划进瞳孔区轮廓边界外扩。解决对面积和长短轴做帧间合法性检查面积超出上一帧±30%就丢弃重检重检仍然异常则沿用上一帧结果并调低Otsu区域范围——把Otsu只作用在ROI内部不要全图计算。多数情况下这一条能同时治掉4.4里的部分抖动因为面积突变帧一旦被拦下中心输出就不会被带飞。5. 序列帧的连续检测让瞳孔中心稳定输出的调参实战5.1 用上一帧中心约束搜索窗口单帧管线跑通后直接逐帧调用会发现每一帧都在整帧图上做Hough时间开销大而且容易被背景假圆干扰。常见做法是加一个跟踪器状态第一帧全图粗定位之后每一帧都以上一帧平滑后的中心为基准裁一个边长约为2.5倍瞳孔半径的正方形ROI。窗口太小会跟丢快速扫视太大又回到全图搜索。我一般取 max(2.5 * r, 24) 像素作为半窗长既保证半径的0.7到1.4倍变化范围都能落在窗内又给扫视留了一点余量。半径范围同理逐帧从上一帧半径缩放而不是每帧重新全范围搜索这样Hough的投票峰值被约束在真实瞳孔附近假圆很难进来。5.2 滑动平均与半径限制一个可直接用的PupilTrackerimport cv2 import numpy as np class PupilTracker: def __init__(self, alpha0.5, max_jump0.25): self.center None # 平滑后的瞳孔中心 (x, y) self.radius None # 平滑后的瞳孔半径 self.alpha alpha # 滑动平均系数 self.max_jump max_jump # 半径单帧允许变化比例 def process(self, gray, min_r10, max_r90): if self.center is None: # 第一帧全图Hough粗定位 轮廓精修 rough hough_rough(preprocess_infrared(gray), min_r, max_r) if rough is None: return None, False result contour_refine(gray, *rough) if result is None: return None, False (cx, cy), (MA, ma), _, _ result self.center (cx, cy) self.radius max(MA, ma) / 2.0 return self.center, True # 后续帧以上一帧中心裁窗 cx, cy self.center win int(max(self.radius * 2.5, 24)) x0, y0 max(0, int(cx - win)), max(0, int(cy - win)) x1, y1 int(cx win), int(cy win) roi gray[y0:y1, x0:x1] # 半径范围按上一帧缩放而非全范围搜索 r_min max(int(self.radius * (1 - self.max_jump)), 6) r_max int(self.radius * (1 self.max_jump)) rough hough_rough(preprocess_infrared(roi), r_min, r_max) if rough is None: return self.center, False # 遮挡帧沿用上帧结果不更新状态 result contour_refine(roi, *rough) if result is None: return self.center, False (rcx, rcy), (MA, ma), _, _ result new_center (rcx x0, rcy y0) # ROI坐标映射回原图 new_radius max(MA, ma) / 2.0 # 指数滑动平均中心不突变扫视跟手 self.center (self.alpha * new_center[0] (1 - self.alpha) * self.center[0], self.alpha * new_center[1] (1 - self.alpha) * self.center[1]) self.radius self.alpha * new_radius (1 - self.alpha) * self.radius return self.center, True逻辑说明PupilTracker把第2、3章的检测管线封装成带记忆的循环。第一帧没有先验走全图粗定位后续帧全部在ROI窗口里做半径范围也随上一帧缩放这直接从搜索空间层面把假圆滤掉。每次返回两个值中心点和一个布尔状态——状态为False说明该帧判定为遮挡或失败下游逻辑可以据此不更新注视点。参数说明alpha0.5是平滑与跟手之间的折中alpha越小输出越平滑但扫视延迟越大疲劳监测可以取0.3需要高实时性的注视估计取0.6比较合适。max_jump0.25表示半径在相邻帧之间最多变化25%眨眼后睁眼半径变化通常超过这个幅度会被判定为状态异常而不是输出错误中心如果你的视频帧率低于30fps把max_jump放宽到0.35否则快速靠近摄像头的帧会误报。5.3 用模拟数据验证精度像素误差怎么算参数调完最怕“看着稳、实际偏”。我习惯先造一组带真值的模拟瞳孔帧来回归用一个已知中心和长短轴的椭圆填充瞳孔灰度叠加高斯噪声再交给管线跑。def synthetic_pupil(cx200, cy150, rx40, ry36, angle30, noise4): img np.full((300, 400), 180, dtypenp.uint8) # 虹膜底亮度 mask np.zeros((300, 400), dtypenp.uint8) cv2.ellipse(mask, (cx, cy), (rx, ry), angle, 0, 360, 255, -1) img[mask.astype(bool)] 40 # 瞳孔更暗 img img np.random.randn(*img.shape) * noise return img.astype(np.uint8) # 跑50帧统计中心误差 errs [] tracker PupilTracker(alpha0.6) for i in range(50): # 模拟缓慢移动每帧真值中心只移动0.3像素 fake synthetic_pupil(cx200 i * 0.3, cy150, rx40, ry36) center, ok tracker.process(fake) if ok: errs.append(np.hypot(center[0] - (200 i * 0.3), center[1] - 150)) print(mean err:, np.mean(errs), std:, np.std(errs))逻辑说明模拟帧每一帧只把真值中心移动0.3像素用来考察重复性和亚像素分辨率。真实视频没有真值只有这种可迁移的合成数据能在调参时给出量化反馈。参数说明noise取值4到10之间分别对应干净场景和弱光场景椭圆度通过rx/ry差异模拟不同视角。经验上Hough单层在这套数据上容易把误差做到1.5像素以上叠加轮廓匹配后均值能压到0.3像素以内如果实测稳到0.5像素以内下游做视线角估计基本不会明显抖动。6. 进阶把中心精度压到亚像素级只靠两个习惯fitEllipse返回浮点中心但椭圆的输入边缘点仍是整像素坐标所以精度上限由边缘定位决定。要再进一步常见做法是做边缘亚像素插值在椭圆拟合后沿每条边缘点的法线方向取一维灰度剖面用梯度峰附近的三个点拟合抛物线抛物线顶点作为该边缘的亚像素位置再用这些修正后的点重新fitEllipse。这一步能让中心均方误差从0.3像素降到0.1像素左右代价是单帧耗时增加但只对ROI内部边缘点处理的话开销完全可控。另一个习惯是给每次调参准备“后悔药”所有参数改动先跑一遍模拟基准集记录中心误差均值与标准差再跑一段真实红外视频对比状态异常计数。我早期直接全图Hough就上眼动实验一遇到亮斑就翻车后来把所有阈值收敛成一组固定默认值只有场景分辨率变化时才动minRadius和maxRadius。这套方案我最看重的一点是可解释——每一帧卡在哪一层画边缘、画投票峰值、画椭圆一眼能看出来而不是把问题丢给黑匣子。做红外瞳孔检测没有一处纯靠运气的地方亮斑有亮斑的来源抖动有抖动的机理调参数之前先问自己改的是什么环节、压的是哪个误差。每改一个参数把模拟基准和真实样本各跑一遍对比三组数字——中心误差均值、标准差、丢失帧数。这三个数字都稳定了再上眼动场景基本不会有回头路。希望帮到你。本文还有配套的精品资源点击获取