简介面向图像处理与计算机视觉方向的研究人员和工程师这份PDF聚焦红外视频瞳孔直径检测精度不足的问题给出Hough变换与轮廓匹配结合的Hough-Contour算法。内容拆解灰度化滤波去噪、改进Hough梯度法初步定位、瞳孔附近轮廓匹配修正中心与直径等环节并说明候选圆心累积值改为最大值搜索、闭眼帧阈值排除等实现细节。包内仅1个PDF文件压缩包约1.04MB篇幅紧凑便于直接阅读与检索。已有170人学习适合关注实时瞳孔检测方案、参数设置与实验验证的读者参考。文档还提供320×240与640×480分辨率下的实时性表现、99.8%精确匹配率及与OpenCV圆变换的对比数据便于权衡精度、耗时与工程落地。1. 瞳孔直径测不准多半不是摄像头的问题某高校一个疲劳评估课题要从红外眼部视频里逐帧读出瞳孔中心和直径硬件是 320×240、30 帧每秒的工业相机打光、对焦都调得很稳。第一版直接调 OpenCV 的霍夫圆变换跑完一段 20 秒视频直径曲线抖得像心电图同一只眼相邻两帧能差十几个像素做眨眼统计时根本没法用。问题不在相机也不在红外补光而在于霍夫圆变换给出的只是“看起来像圆的东西”的粗略位置它天然不保证直径精度。Hough-Contour 这套方案要解决的就是后半句——用改进的 Hough 变换把瞳孔粗定位到像素级再用圆轮廓匹配在局部逐点比对做精修把直径精确匹配率从 10% 这个量级拉到 99.8%单帧耗时压进 60 ms同时自动丢掉闭眼帧。做眼动、疲劳、心理生理测量方向毕设或课题的人可以直接拿这套结构去改。2. 总体方案设计两级串联的 Hough-Contour 流水线单级检测在这类场景里几乎必然失败原因很朴素霍夫变换是全局投票它回答的是“整幅图里哪里最像一个圆”而瞳孔直径需要的是“这个圆边界具体落在哪一圈像素上”。前者对噪声鲁棒但粒度粗后者精度高但必须有个靠谱的起点否则会在整幅图上做无谓搜索。把它们串成两级前一级负责把搜索空间从 320×240 压到一个半径十几像素的小窗口后一级在这个小窗口里做穷举式精修两头的好处都拿到了。2.1 四阶段流水线与每一步的输入输出整条链路固定为四步每步的输入输出都定义清楚这样后面做消融实验和排查问题时才有明确的切分点。第一步灰度化加滤波。输入是相机输出的 BGR 帧或单通道红外帧输出是一张 5×5 邻域平滑后的灰度图。红外图像的高频噪声主要来自传感器和工频闪烁这一步不做后面的边缘会碎成一地。第二步Canny 边缘提取。输入是平滑图输出是二值边缘图高低阈值按原始方案取 100 / 50。这一步只为霍夫投票服务轮廓匹配阶段不再用边缘图。第三步改进霍夫梯度法初定位。输入是平滑图和边缘图在限定的半径区间 [rMin, rMax] 内投票输出初始圆参数 (x0, y0, r0) 以及累加数组的峰值。峰值这个量很关键它同时兼任闭眼帧的判据。第四步圆轮廓匹配精修。输入是平滑图和初始圆 (x0, y0, r0)在半径浮动 ±r0/10 的邻域内做穷举输出最终的 (x, y, r)。只有这一步的结果才会写进数据文件。四步串起来一帧的完整处理链路没有任何需要人工干预的环节这是能做成实时检测的前提。中间任何一步引入交互30 帧每秒的输入就会堆积。2.2 为什么单靠霍夫圆变换精度上不去OpenCV 里那个霍夫梯度实现内部维护的是一个三维累加数组 a-b-ra、b 是圆心r 是半径。它的标准做法是把候选圆心按累加值降序排序取前若干个再反算半径。这套流程有三个躲不开的代价。第一个代价是排序本身。候选点数量随图像分辨率平方增长排序是 O(M log M)而真正需要的其实只是“最大值在哪”一个线性扫描就能解决。原始方案正是在这里动手把降序排序换成直接求累加数组的最大值顺带把后续那一轮半径计算也省掉了。第二个代价是精度。霍夫的投票是按整像素累加的半径也是整像素输出天然带着量化误差。瞳孔直径在红外视频里通常只有二三十像素±1 像素的量化误差换算成比例就是 3% 到 7%再叠加噪声引起的圆心漂移实测下来精确匹配率只有 10% 左右。第三个代价是没有“这帧能不能用”的判断。人眨眼时瞳孔被眼睑遮住边缘几乎消失霍夫照样会投出一个勉强的圆这个圆会作为异常值污染整条时间序列。原始方案用累加峰值做阈值低于阈值直接判为闭眼帧跳过等于给流水线加了一道免费的质检关口。2.3 参数总表与模块职责参数不多但每一个都直接决定成败先把它们集中列出来后面各章逐个展开。参数含义取值调整依据平滑窗口预处理邻域大小5×5再大瞳孔边缘会被抹钝Canny 高/低阈值边缘提取灵敏度100 / 50换相机或换打光必须重标rMin / rMax半径搜索区间按镜头与人眼距离标定误差超过 ±30% 会漏检d轮廓匹配搜索半径r0 / 10见 4.3 节k圆周采样点对数40见 4.3 节峰值阈值闭眼判定门限实验标定按视频段统计分位定模块划分上预处理和边缘提取是一个无状态的工具模块可以被任何一帧独立调用霍夫初定位持有跨帧不共享的累加数组每次调用前必须清零这是踩过坑的地方轮廓匹配是纯函数输入初始圆和灰度图输出最终圆不依赖任何历史状态。把状态收敛到一处实时循环里才不会出现“第 300 帧突然漂了”这种玄学问题。3. Hough 初定位用累加峰值替换降序排序初定位这一级的目标不是准而是稳和快。它只要保证真实瞳孔落在这条流水线给出的搜索窗口内剩下的交给下一级。所以这里的优化方向很明确砍掉一切可以砍的计算但把峰值这个副产品完整保留下来。3.1 预处理灰度化、5×5 平滑与 Canny 边缘红外的眼部图像有个特点瞳孔区域灰度低虹膜中等巩膜和眼睑反光偏高整体对比度其实不差难的是噪声。传感器噪声加上光源的工频波动会让瞳孔边缘出现锯齿Canny 在这种锯齿上会提出一堆短碎边投票时这些碎边各自贡献几个错误方向。5×5 邻域平滑能把这类高频抖动压下去同时因为瞳孔直径通常还有二十像素以上5×5 的窗口不至于把边界糊掉。这个尺寸是上限再大就会出现小瞳孔被抹平的情况尤其是拍摄距离偏远的场景。Canny 的高低阈值取 100 和 50比例 2:1是这类红外图像的常用起点但它对打光强度极其敏感换一套光源就必须重标不能当常数抄。// 单帧预处理灰度化 平滑 边缘提取 // 返回值是平滑图霍夫投票和轮廓匹配都以它为输入 cv::Mat preprocess(const cv::Mat src, cv::Mat edges) { cv::Mat gray; if (src.channels() 3) cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY); else gray src.clone(); cv::Mat smooth; // 5x5 均值平滑压制传感器噪声和工频闪烁引起的高频抖动 cv::blur(gray, smooth, cv::Size(5, 5)); // 高低阈值 100/50仅用于霍夫投票不参与轮廓匹配 cv::Canny(smooth, edges, 100, 50); return smooth; }这段代码里唯一需要解释的是为什么返回平滑图而不是原图。轮廓匹配比对的是瞳孔内圈和外圈的灰度差如果拿未平滑的图去做单个噪点就能让某一对点的差值翻倍平均值被拉偏而如果拿边缘图去做边界信息已经二值化灰度差的物理意义就没了。平滑灰度图是唯一同时满足两个阶段需求的中间产物。3.2 沿梯度方向投票改进 Hough 梯度法的核心标准霍夫圆变换对每个边缘点要在整个参数空间投票复杂度是边缘点数乘以半径数乘以角度数太慢。梯度法把它压缩了一个维度圆的法线方向就是边缘点的梯度方向圆心必然落在过该点、沿梯度方向的那条直线上所以每个边缘点只需要在正负两个方向上按各个候选半径走一遍投票次数从 O(角度) 降到 O(半径数)。具体的投票规则是对边缘点 (x, y) 求 Sobel 梯度 (gx, gy)归一化得到单位方向 (ux, uy)然后对每个候选半径 r往 (x r·ux, y r·uy) 和 (x − r·ux, y − r·uy) 两个位置各投一票。两个方向都要投因为梯度指向可能是由暗到亮也可能是由亮到暗取决于边缘点落在瞳孔边界的哪一侧。漏掉一个方向就会有一半的边缘点白算。累加数组按半径分层组织第 i 层对应半径 rMin i 的 a-b 平面。投票过程中同步维护一个全局最大值 peak 及其对应的 (cx, cy, r)一次扫描结束直接拿到结果。这就是“降序排序换成求最大值”的落地方式不需要保存所有候选再排序也不需要事后反算半径边走边比内存和 CPU 都省。提示累加数组必须在每帧开始前清零尤其是在实时循环里复用同一块内存时。忘记清零的表现是前几十帧正常之后圆心开始往画面某个固定角落漂非常难查。3.3 用峰值阈值剔除闭眼帧闭眼帧的处理是这套方案里很实用的一笔。人眼闭合时瞳孔被眼睑完全覆盖画面里只剩下睫毛和皮肤的纹理Canny 提不出连续的圆弧边缘投票结果自然分散累加数组的最大值明显低于睁眼帧。原始方案就是利用这个差异给峰值设一个阈值低于阈值直接判为闭眼不输出检测结果。阈值的定法不能拍脑袋。我一般的做法是取一段包含多次眨眼的视频把每一帧的峰值打印出来画一条时间曲线睁眼段的峰值会稳定在一个平台区闭眼段会明显塌陷取平台区下沿的 60% 到 70% 作为门限留够余量。不同相机、不同距离下这个绝对值会变但“睁眼平台、闭眼塌陷”这个形态是稳定的所以按相对比例定比按绝对值定靠谱得多。有了这道判断下游拿到的直径序列在眨眼处是断开的而不是跳变的插值或者分段统计都好处理。省掉这一步后面做数据清洗要花的时间远超过在算法里加几行判断。3.4 初定位模块的代码落地// 改进霍夫梯度法沿梯度方向投票同步求累加数组最大值 // 返回 false 表示峰值低于阈值判为闭眼帧 bool houghInit(const cv::Mat smooth, const cv::Mat edges, int rMin, int rMax, double peakThr, cv::Point bestC, int bestR, double peakOut) { const int nR rMax - rMin 1; std::vectorcv::Mat acc(nR); for (int i 0; i nR; i) acc[i] cv::Mat::zeros(smooth.size(), CV_32F); // 每帧必须清零 double peak 0.0; bestC cv::Point(-1, -1); bestR 0; for (int y 1; y smooth.rows - 1; y) { for (int x 1; x smooth.cols - 1; x) { if (edges.atuchar(y, x) 0) continue; // 只处理边缘点 // Sobel 梯度决定圆心所在的法线方向 float gx 0, gy 0; for (int dy -1; dy 1; dy) for (int dx -1; dx 1; dx) { float w (dx 0 ? 2.0f : 1.0f) * (dy 0 ? 2.0f : 1.0f); float v smooth.atuchar(y dy, x dx) * w; gx v * dx; gy v * dy; } float mag std::sqrt(gx * gx gy * gy); if (mag 1e-3f) continue; // 平坦区跳过 float ux gx / mag, uy gy / mag; for (int ri 0; ri nR; ri) { int r rMin ri; for (int s -1; s 1; s 2) { // 正负两个方向都投 int cx cvRound(x s * r * ux); int cy cvRound(y s * r * uy); if (cx 0 || cy 0 || cx smooth.cols || cy smooth.rows) continue; float cell acc[ri].atfloat(cy, cx); cell 1.0f; // 边投票边比最大替代排序 事后反算半径 if (cell peak) { peak cell; bestC cv::Point(cx, cy); bestR r; } } } } } peakOut peak; return peak peakThr; // 峰值过低判为闭眼帧 }参数上rMin 和 rMax 决定了整个算法的适用范围标定方法是让人眼在正常工作距离上量一遍画面里瞳孔直径的像素范围上下各放 30% 余量。设窄了超出范围的眼帧全部漏检设宽了投票的层数变多耗时线性上涨而且大半径层容易在虹膜外缘投出假峰。peakThr 就是 3.3 节里定的闭眼门限它是唯一一个必须靠数据统计出来的参数别用默认值。4. 轮廓匹配精修±r/10 的搜索窗和 40 对采样点初定位给出的圆直径误差可能有百分之几这个量级对心率和疲劳指标来说不够。精修这一级换了个思路不再投票而是在初始圆附近穷举所有可能的 (x, y, r) 组合用“瞳孔内圈是不是真的比外圈暗”这一物理事实来打分谁的分数最高谁就是答案。红外图像里瞳孔是画面中最暗的区域之一这个先验非常可靠比任何形状模型都稳。4.1 点对怎么取内圈 r-1 与外圈 r1对圆周上的每个角度 θ取两个点半径方向靠内一圈的点 p 和靠外一圈的点 p′坐标分别是圆心加上 (r−1) 和 (r1) 倍的方向向量。然后用 p′ 的灰度减去 p 的灰度得到一个差值。如果这个圆正好压在瞳孔边界上内圈落在瞳孔里暗外圈落在虹膜上亮差值应该是正的而且越大说明边界卡得越准。把所有 k 对点的差值取平均就是当前 (x, y, r) 的得分。用平均而不是求和是为了让不同 k 值之间的结果可比也避免采样点数量变化时得分尺度漂移。内圈取 r−1、外圈取 r1这个偏移量不能太大。偏移到 3 像素以外就会跨过虹膜进入别的结构差值反而变小最优点会被推到错误的位置上。1 像素的间隔刚好覆盖边界的过渡带同时保持足够的对比度。4.2 三重循环与平均灰度差最大化搜索顺序是外层半径、中层横坐标、内层纵坐标最里面再套一层角度循环。四层循环听起来吓人但每层的范围都很小半径浮动 2d1 个值两个坐标各 2d1 个值角度 k 个值。按 d r0/10、r0 ≈ 15、k 40 算一次精修大约 4×4×4×40 ≈ 2560 次灰度读取相比整幅图投票是微不足道的开销。判优条件是取平均差值的最大值不是最小值。这一点容易被写反如果误以为瞳孔边缘是“由亮到暗”就会把内圈外圈的减法写反结果算法会去找画面里最亮的圆形结构通常落在眼睑反光上输出一条完全错误的曲线而且看起来还挺稳定——这种错误最难查因为算法不报错。搜索窗口的边界要处理好。当圆心靠近图像边缘时某些角度上的采样点会越界这时候直接跳过该点并记录有效点数量如果有效点少于总点数的四分之三这个候选直接丢弃不参与比较。否则越界点被当成灰度 0 参与平均会把边界处的候选分数拉高圆心被系统性地往画面边缘拽。// 轮廓匹配在初始圆邻域内穷举取平均灰度差最大的 (x, y, r) // gray 必须是平滑后的灰度图d 和 k 见 4.3 节 void contourRefine(const cv::Mat gray, cv::Point c0, int r0, int d, int k, cv::Point bestC, int bestR) { const double step 2.0 * CV_PI / k; double bestScore -1e9; bestC c0; bestR r0; for (int r r0 - d; r r0 d; r) { if (r 4) continue; for (int cx c0.x - d; cx c0.x d; cx) { for (int cy c0.y - d; cy c0.y d; cy) { double sum 0.0; int valid 0; for (int i 0; i k; i) { double th i * step; double c std::cos(th), s std::sin(th); int xi cvRound(cx (r - 1) * c), yi cvRound(cy (r - 1) * s); int xo cvRound(cx (r 1) * c), yo cvRound(cy (r 1) * s); if (xi 0 || yi 0 || xi gray.cols || yi gray.rows) continue; if (xo 0 || yo 0 || xo gray.cols || yo gray.rows) continue; // 外圈减内圈瞳孔内暗外亮差值为正才说明压在边界上 sum gray.atuchar(yo, xo) - gray.atuchar(yi, xi); valid; } if (valid k * 3 / 4) continue; // 越界点过多直接丢弃 double avg sum / valid; if (avg bestScore) { bestScore avg; bestC cv::Point(cx, cy); bestR r; } } } } }两个参数的含义很清楚d 决定搜索窗口的边长k 决定圆周采样的密度。d 的单位是像素写成 r0 的比例是为了自适应不同距离下的瞳孔大小k 是点数写成 40 是因为再往上收益就很小了。这两个值怎么定下一节用数据说话。4.3 d 和 k 的取值一张敏感性表定参数k 的取值直接决定精度和耗时的平衡原始方案做过一组对照结论很干脆k 从 10 往上加精度是台阶式上升耗时是缓慢线性上升。采样点对数 k直径精确匹配率 / %640×480 单帧耗时 / ms1050.05.22090.011.73099.015.94099.822.2k 10 时点上太稀疏某几对点恰好落在噪声或者睫毛上平均值就被带偏k 40 时单点噪声的影响被平均到 1/40成绩稳定在 99.8%。再往上加边际收益已经接近零耗时却还在涨所以 40 是这条曲线上的拐点。选参数就该找这种“精度已经贴顶、耗时还没起飞”的位置。d 的取法是 r0/10也就是初始圆半径的十分之一。这个比例的合理性在于初定位的误差通常在半径的 5% 以内取 10% 能完整覆盖同时窗口又足够小不至于把虹膜外缘或者眼睑边缘圈进来。d 取大了有两个坏处一是耗时按 (2d1)³ 增长二是搜索空间里混进了不属于瞳孔的圆形结构反而容易选错。d 取小了则是另一种翻车真实边界落在窗口外精修只能在窗口边界上取到局部最优输出一个“看起来合理但一直偏小”的半径。4.4 精修模块的代码落地把两级串起来的调用逻辑如下这一段是实时循环的骨架可以直接套。// 单帧完整流程预处理 - 霍夫初定位 - 轮廓匹配精修 bool processFrame(const cv::Mat frame, double peakThr, cv::Point outC, int outR) { cv::Mat edges; cv::Mat smooth preprocess(frame, edges); // 3.1 节 cv::Point c0; int r0; double peak 0; if (!houghInit(smooth, edges, 12, 24, peakThr, c0, r0, peak)) return false; // 闭眼帧直接丢弃 // d 随初始半径自适应k 固定 40两者共同决定精度和耗时 int d cvRound(r0 / 10.0); contourRefine(smooth, c0, r0, d, 40, outC, outR); return true; }调用顺序不能颠倒也不能跳步。有人会想“既然精修已经穷举了初定位随便给个中心就行”实测下来不行初定位偏差超过 d 的时候精修就是在错误的位置上做局部优化输出会稳稳地偏一个固定量这种系统性误差比随机抖动更难在后处理里修掉。5. 避坑与排查这五处最容易翻车这套算法结构不复杂但真正跑在实时视频上问题几乎都集中在几个固定的地方。下面五条是按出现频率排的每条都给出可以照着做的处理办法。5.1 直径曲线在眨眼处出现尖峰现象是睁眼段曲线平顺一到眨眼就蹦出一个明显偏离的值然后再回落。原因有两层闭眼帧的峰值阈值设得偏低闭眼时仍然通过了判断或者阈值判断通过了但闭眼时残留的眼睑边缘把霍夫投票引到了错误的位置。解决办法是把 peakThr 按 3.3 节的方法重新统计取睁眼平台下沿的 60% 到 70%同时在输出端加一道硬约束——精修后的半径如果落在 [rMin, rMax] 之外直接标为无效帧。两处都加上这类尖峰基本消失。5.2 圆心稳定地偏向画面某一侧现象是同一只眼连续几百帧的圆心坐标都往同一个方向偏几个像素看着稳定其实全错。这是搜索窗口越界处理不当的典型症状。轮廓匹配在图像边缘附近丢掉了越界采样点剩余点的平均差值被抬高最优解被拽向边界。处理办法是回到 4.2 节的有效点占比判断把阈值卡在四分之三并且在 ROI 提取阶段就给瞳孔周围留够余量别让瞳孔贴着图像边界。如果场地限制没法留余量那就把这一帧判为无效不要输出。5.3 换一台相机后精度断崖下跌现象是代码一行没改换设备或者换了打光方案精确匹配率从 99% 掉到 60% 以下。原因是 Canny 的 100/50 和 5×5 平滑都是按某一种成像条件标定的新设备的对比度、噪声水平、瞳孔成像尺寸都变了参数就失配。处理办法是把三个参数都做成可标定项先用新设备录一段统计瞳孔直径的像素分布定 rMin/rMax画灰度直方图定 Canny 阈值最后用小样本跑一遍精度确认。这套流程走一遍大概半小时比在代码里猜一下午划算。5.4 小瞳孔被平滑抹掉直接漏检现象是拍摄距离偏远或者瞳孔收缩时检测时有时无。5×5 均值平滑对二十像素以上的瞳孔没问题但瞳孔缩到十像素左右平滑会把两侧的边界糊在一起Canny 提不出闭合弧峰值上不去被误判成闭眼帧。解决方向有两个一是把平滑换成保边滤波代价是耗时上涨二是按 rMin 做分支小半径场景下把平滑窗口降到 3×3。我一般选后者因为它不改算法结构只改一个参数风险可控。5.5 时间统计出来的数字完全不可信现象是单帧耗时一会儿 0 ms 一会儿 15 ms波动大得没法写进报告。原因是用了毫秒级的系统时间接口一次调用本身就有 1 ms 量级的粒度误差而单帧处理只有几毫秒误差和被测对象同量级。解决办法是换成高精度计时器并且累计测一千帧取平均别单帧单测。这件事看起来是小事但答辩时性能数据被质疑精度不够是很难圆回来的。6. 验收与答辩把 99.8% 讲成别人能复现的数字精度数字本身不构成说服力能复现才构成。答辩现场被问得最多的不是“算法怎么想出来的”而是“99.8% 是怎么算出来的”。这个问题答不好前面所有工作都会被归到“调参调出来的”那一类里。先把指标定义写死写进方案文档里别留在脑子里。所谓“精确匹配”我一般定义为算法输出的瞳孔直径与人工标注值之差不超过 1 像素并且圆心偏移不超过 2 像素两个条件同时满足才算一次匹配。人工标注的做法是抽 200 帧两人独立标分歧超过 1 像素的帧直接剔除剩下的取平均作为真值。这套流程要写清楚因为它决定了 99.8% 这个数字的分母是什么。然后是消融对照。至少准备三行数据直接用 OpenCV 现成的霍夫圆变换、只做霍夫初定位不做精修、完整两级流水线。第二行往往是最有说服力的——它证明精度提升确实来自轮廓匹配这一级而不是来自参数调得好。原始方案给出的对照是现成圆变换约 10%、完整方案 99.8%中间那一档需要自己跑跑出来的数字是多少就写多少别去凑。方案直径精确匹配率单帧耗时640×480OpenCV 现成霍夫圆变换约 10%快但直径不可用仅霍夫初定位需自行实测快Hough 轮廓匹配d r0/10k 4099.8%22.2 ms性能这部分要按分辨率分开报。原始方案的结论是低端机上处理 320×240 单帧约 60 ms能做实时高端机上 640×480 也能实时。这个说法的关键在“低端机”三个字答辩时最好把测试机器的配置一并写在表头里否则“实时”两个字站不住。同一份代码在不同机器上差三倍是常事。最后准备一张时序图横轴帧号纵轴瞳孔直径把眨眼段标灰。这张图比任何表格都直观观众一眼就能看到曲线在睁眼段是平的、眨眼处是断开的。这是这套方案最有说服力的地方——它不只是一个静态精度数字而是一条能拿去做后续分析的干净序列。血泪经验是我每次带人做这类课题都强制要求先把指标定义和测试集划分写进文档再动手写代码。顺序反过来的最后都要返工重跑一遍实验因为标注口径中途改了前面所有数字作废重来。希望这套结构能帮你少走一遍这个弯路。本文还有配套的精品资源点击获取