简介:围绕红外视频中的瞳孔精确检测,这份PDF文献提出Hough-Contour算法,将改进的Hough梯度法与轮廓匹配相结合,面向图像处理、计算机视觉方向的研究人员和工程师,也可供心理健康研究、犯罪侦查、医疗诊断等需要高精度瞳孔直径测量的场景参考。压缩包内仅含1个PDF文件,约1.04MB,为期刊论文全文,便于直接阅读与存档引用,已有170人学习。内容完整交代灰度化滤波、Canny边缘提取、Hough初检以及瞳孔附近圆形轮廓的位置与半径受限匹配等环节,并给出将候选圆心累积值改为求最大值、用阈值排除闭眼帧、轮廓移动与伸缩范围取初始半径十分之一、点对数量取40等参数设置技巧;实验显示精确匹配率由OpenCV圆变换的约10%提升至99.8%,低端设备处理320×240、高端设备处理640×480视频均可实时。读者可据此复现算法、调参并迁移到自有的红外眼动数据。
1. 一帧 320×240 的红外眼睛视频,普通圆检测为什么只到 10%
红外视频里的瞳孔从来不是一个标准圆。睫毛压住上半圈、红外灯环在角膜上打出一个亮斑、眼睑开合把圆切成一段弧,任何一样都能让理想圆拟合跑偏。图像处理里做瞳孔检测,最容易低估的就是这一步:Hough 变换只能找到"看起来像圆的东西",而这个圆的圆心和直径未必属于瞳孔。这份 Hough-Contour 算法资料要解决的正是最后这一公里——先用改进的 Hough 梯度法给出粗略初值,再用轮廓匹配在初值附近做一次小范围搜索,公开实验数据是精度从 OpenCV 圆变换的 10% 左右提升到 99.8%,低端设备上 320×240 单帧 60ms 能跑。适合已经在做眼动、疲劳监测、红外成像相关开发,并且被"圆心老是抖、直径忽大忽小"折磨过的工程师。
2. 算法总览:从一帧灰度图到 (x, y, r) 的五段流水线
这套算法的整体结构很朴素,没有神经网络那种黑匣子,五个步骤串成一条直线:灰度化 → 滤波去噪 → Canny 提取边缘 → 改进 Hough 梯度法求初值 → 轮廓匹配精修。真正花心思的地方在后两步的参数配合,前面的预处理反而是决定下限的部分。红外图像本身对比度不低,瞳孔区域暗、虹膜区域亮,所以灰度化之后直接做高斯滤波就够了,不需要直方图均衡这类会放大噪声的操作。
理解这条链路的顺序很重要。Hough 负责"从无到有",轮廓匹配负责"从有到准",两者职责不能混。Hough 阶段不需要精确,它只要保证真实瞳孔落在初值周围约十分之一个半径的范围内,后面的精修就能把它拉回来;而在轮廓匹配阶段,任何一次全局搜索都是浪费,因为瞳孔不可能凭空跳到画面另一侧。
2.1 模块怎么切:四个文件管一条链路
按这条链路拆,工程里我一般会切成四个编译单元,每个单元只干一件可测的事。这样做的好处是,调参数的时候你知道该改哪个文件,而不是在一坨八百行的 main 里翻。
| 文件 | 职责 | 关键接口 |
|---|---|---|
frame_source.cpp | 读视频/摄像头、灰度化、按比例缩放 | bool nextFrame(cv::Mat& gray) |
pupil_hough.cpp | 改进 Hough 梯度法,输出初值圆与累积峰值 | bool houghInit(const cv::Mat&, Pupil&, int& peak) |
pupil_contour.cpp | 在初值邻域做圆环采样匹配 | bool refineByContour(const cv::Mat&, Pupil&) |
tracker.cpp | 时序平滑、异常帧丢弃、结果输出 | void update(const Pupil&) |
Pupil就是一个三元组,圆心坐标加半径,另外带一个时间戳。
// pupil_types.h struct Pupil { float x = 0.f, y = 0.f, r = 0.f; // 单位:像素,坐标系与输入帧一致 bool valid = false; // 本帧是否检出,闭眼帧为 false double t = 0.0; // 采集时间戳,毫秒 };valid这个字段看着不起眼,但在实际链路里非常关键。闭眼帧、大幅眨眼帧、镜头被遮挡帧都会让 Hough 返回一个毫无意义的圆,如果不加标记直接送进下游做平滑,平滑器会把脏值一路带下去,好几帧都恢复不过来。
2.2 主循环:一帧图像进来先做什么、后做什么
把四个模块串起来的主循环不到三十行,但每一步的顺序都不能调换。特别是缩放这一步,放在灰度化之后、Hough 之前,而不是放在最后——因为 640×480 直接跑 Hough 的耗时是 320×240 的四倍左右,先缩再算能把单帧时间压到实时线以内。
// main.cpp —— 一帧红外图像从读入到输出瞳孔直径的完整链路 #include <opencv2/opencv.hpp> #include "pupil_types.h" #include "pupil_hough.h" #include "pupil_contour.h" int main(int argc, char** argv) { cv::VideoCapture cap(argv[1]); // 红外视频文件;传 "0" 则打开首个摄像头 if (!cap.isOpened()) return -1; cv::Mat frame, gray, small; while (cap.read(frame)) { cv::cvtColor(frame, gray, cv::COLOR_BGR2GRAY); // 1. 灰度化 cv::GaussianBlur(gray, gray, cv::Size(5, 5), 0); // 2. 5x5 高斯去噪 double scale = 320.0 / gray.cols; // 3. 长边统一缩到 320 cv::resize(gray, small, cv::Size(), scale, scale, cv::INTER_AREA); Pupil p; int peak = 0; if (!houghInit(small, p, peak)) { // 4. Hough 求初值 continue; // 失败即视为闭眼/无效帧 } refineByContour(small, p); // 5. 轮廓匹配精修 p.x /= (float)scale; p.y /= (float)scale; p.r /= (float)scale; printf("%.2f,%.2f,%.2f\n", p.x, p.y, p.r * 2); // 输出圆心与直径 } return 0; }几个参数值得单独说。cv::Size(5,5)的高斯核是按红外图像的噪声尺度定的,核太小压不住传感器噪声,核太大把瞳孔边缘糊掉,5×5 是这类 320×240 图像的经验折中。INTER_AREA而不是默认的双线性,是因为缩小图像时区域平均更不容易产生混叠,边缘位置更稳。缩放系数scale必须原样保存,最后一步把坐标映射回原图时必须除以它,忘了这一步会出现"瞳孔永远贴在画面左上角"的经典 bug。
2.3 环境搭起来:OpenCV 加 CMake 的最小骨架
原始资料里的运行环境是很老的组合,直接照抄在今天基本跑不动。现在复现,用 OpenCV 4.x 加 CMake 最省事,接口基本一一对应,改几个函数名就行。
# Ubuntu 下装依赖;macOS 用 brew install opencv sudo apt install -y build-essential cmake libopencv-dev # 编译 mkdir -p build && cd build cmake .. && make -j4 # 跑一段红外视频,输出每帧的 x,y,直径 ./pupil_demo ../data/eye_ir_320x240.avi > out.csv# CMakeLists.txt cmake_minimum_required(VERSION 3.10) project(pupil_hough_contour CXX) set(CMAKE_CXX_STANDARD 14) find_package(OpenCV REQUIRED) add_executable(pupil_demo main.cpp pupil_hough.cpp pupil_contour.cpp tracker.cpp) target_link_libraries(pupil_demo ${OpenCV_LIBS})如果你手上没有红外视频,普通摄像头也能先跑通链路——用手遮住镜头再松开,观察valid字段是否跟着翻转,这是验证闭眼帧判断是否生效的最快办法。
2.4 第一个可跑的验证:在静态帧上打印圆心和直径
不要一上来就接实时视频。我一般先抽一帧存成 PNG,用单帧调试,把中间结果全画出来:边缘图、Hough 初值圆、精修后的圆,三张图叠在一起看。瞳孔边缘如果没被 Canny 完整框住,后面所有参数调整都是白费力气。
// debug_draw.cpp —— 单帧可视化,定位问题比看数字快得多 cv::Mat vis; cv::cvtColor(gray, vis, cv::COLOR_GRAY2BGR); cv::circle(vis, cv::Point(cvRound(p0.x), cvRound(p0.y)), cvRound(p0.r), cv::Scalar(0, 255, 0), 1); // 绿色:Hough 初值 cv::circle(vis, cv::Point(cvRound(p.x), cvRound(p.y)), cvRound(p.r), cv::Scalar(0, 0, 255), 1); // 红色:精修结果 cv::imwrite("debug.png", vis);绿圈和红圈偏差在一两个像素内,说明 Hough 初值够用;偏得离谱甚至跑出画面,问题在 Hough 阶段,别急着调轮廓匹配的参数。
3. 改进的 Hough 梯度法:把候选圆心降序排序改成一次求最大值
标准 Hough 圆变换的做法是:对每个半径,把所有边缘点按梯度方向投票到一个 (a, b) 累积平面,然后对整个平面做降序排序,取前若干个作为候选圆心。这个排序在 320×240 的图像上还能忍,一旦分辨率上去,或者半径搜索范围拉宽,排序本身就吃掉大半帧时间。原始资料里的第一个改动就落在这里——不排序,直接找累积数组的最大值及其位置。
这个改动听起来是纯性能优化,实际还带来一个额外好处:最大值本身就是一个天然的质量指标。瞳孔清晰、边缘完整时,累积峰值很高;闭眼、睫毛遮挡、大幅度运动模糊时,投票分散,峰值显著下降。于是同一个数组既能给出圆心,又能顺手做闭眼帧判断,一举两得。
3.1 原始梯度法的时间花在哪
梯度 Hough 的核心是两件事:算边缘点的梯度方向,沿方向正负两侧各走 r 个像素投一票。对 320×240、半径范围 20 到 50,一次完整搜索的投票次数是边缘点数乘以半径档数再乘以二。边缘点大概两三千个,半径档三十个,投票量在十几万次量级,这部分省不掉。真正可以省的是后面的候选排序:候选圆心可能有上万个非零项,排序的复杂度是 O(n log n),而且每次都要重新排。
改成求最大值之后,复杂度降到 O(n) 一次扫描,更关键的是——你根本不需要第二名。瞳孔只有一个,累积值最高的那个位置就是它。多保留候选做后续筛选,是通用圆检测为了适应"一图多圆"场景的设计,而瞳孔检测是单目标场景,用不上。
3.2 用最大值索引替代排序:少一次排序,多一个闭眼判断
下面这段是核心实现,逻辑上等价于 OpenCV 的梯度法,但去掉了候选列表和排序。注意累积数组用的是CV_32S,minMaxLoc支持整型,不用转浮点。
// pupil_hough.cpp bool houghInit(const cv::Mat& gray, Pupil& out, int& peak) { cv::Mat edges, gradX, gradY; // Canny:低阈值在前、高阈值在后,这是 OpenCV 的参数顺序 cv::Canny(gray, edges, 50, 100, 3); cv::Sobel(gray, gradX, CV_32F, 1, 0, 3); // 水平梯度 cv::Sobel(gray, gradY, CV_32F, 0, 1, 3); // 垂直梯度 const int rMin = 18, rMax = 55; // 半径搜索范围,按实际成像改 cv::Mat accum(gray.size(), CV_32S, cv::Scalar(0)); int bestAccum = -1; cv::Point bestC(0, 0); int bestR = rMin; for (int r = rMin; r <= rMax; ++r) { accum.setTo(0); for (int y = 0; y < edges.rows; ++y) { const uchar* e = edges.ptr<uchar>(y); for (int x = 0; x < edges.cols; ++x) { if (!e[x]) continue; float gx = gradX.at<float>(y, x); float gy = gradY.at<float>(y, x); float len = std::sqrt(gx * gx + gy * gy); if (len < 1e-3f) continue; // 弱梯度点跳过,避免方向噪声 gx /= len; gy /= len; // 圆心可能位于梯度的正方向或负方向,两侧都投票 for (int s = -1; s <= 1; s += 2) { int a = cvRound(x - s * r * gx); int b = cvRound(y - s * r * gy); if (a < 0 || b < 0 || a >= accum.cols || b >= accum.rows) continue; accum.at<int>(b, a) += 1; } } } double maxVal; cv::Point maxLoc; cv::minMaxLoc(accum, nullptr, &maxVal, nullptr, &maxLoc); // 替代排序 if ((int)maxVal > bestAccum) { bestAccum = (int)maxVal; bestC = maxLoc; bestR = r; } } peak = bestAccum; if (bestAccum < kPeakThreshold) return false; // 峰值过低 → 判为闭眼帧 out.x = (float)bestC.x; out.y = (float)bestC.y; out.r = (float)bestR; out.valid = true; return true; }逐项说一下。rMin、rMax是整段代码里最需要按现场改的参数,18 到 55 对应的是 320×240 分辨率下瞳孔半径的典型区间,换成 640×480 就得整体翻倍,写死会在换摄像头之后直接失效。len < 1e-3f这个判断是必须的,平坦区域梯度接近零,归一化之后方向随机,会往错误位置投一堆噪声票。
kPeakThreshold就是闭眼帧的判据。它的取值不能凭感觉,得用数据标定:拿一段包含睁眼、闭眼、眨眼的视频,把每帧的peak打出来看分布,睁眼帧的峰值通常集中在高处,闭眼帧掉到很低,中间会有一段空档,阈值放在空档里最稳。原始资料里也是用累积数组的最大值阈值来排除闭眼帧,思路一致。
3.3 半径范围与 Canny 阈值:这组参数得按摄像头改
预处理参数里最容易翻车的是 Canny 的双阈值。红外图像对比度高,边缘响应强,用 50/100 一般够;但如果红外补光偏弱,或者眼周有皮肤反光,边缘会碎成一段一段,这时候要把低阈值往下压到 30 左右,同时把高阈值保持在低阈值的两到三倍,避免噪声边缘被提上来。
半径范围则跟成像距离直接相关。同一套代码换一个镜头位置,瞳孔在画面里的大小可能差一倍。稳妥的做法是加一段自标定:让使用者保持正常注视三秒,统计前几十帧 Hough 给出的半径中位数,再以这个中位数为中心上下浮动 40% 作为搜索范围。这比写死两个数字可靠得多,也是我在实际项目里一定会补上的一步。
3.4 怎么验证这一步没白改
验证改进是否有效,不能只看最后精度。我一般做三组对照:原版排序法、只求最大值法、求最大值加闭眼过滤,在同一段视频上比单帧耗时和峰值分布。前两者耗时差通常在 20% 到 40% 之间,分辨率越高差距越明显;第三者不改善耗时,但能显著减少下游的脏数据。
还有一个隐蔽的指标:连续帧之间圆心的跳变幅度。Hough 阶段如果参数不稳,圆心会在一两帧内跳动十几个像素,这种抖动在轮廓匹配阶段很难完全拉回来。把连续帧的(x, y)差值画成曲线,正常应该平滑,出现尖刺就说明投票被噪声主导了。
4. 轮廓匹配精修:d = r/10 与 40 对采样点是怎么定下来的
Hough 给出初值之后,真正决定精度的是轮廓匹配这一层。思路不复杂:以初值圆为基准,在圆心和半径各自允许的小范围内穷举,对每个候选圆在圆周上均匀取若干对采样点,每对包含一个"稍微靠内"的点和一个"稍微靠外"的点,计算外侧灰度减内侧灰度的均值。瞳孔在红外图像里偏暗、周围虹膜偏亮,所以真实瞳孔边缘对应的这个均值最大,取最大者即为最优解。
这个设计的好处是绕开了边缘提取的二值化损失。Canny 之后边缘只有一个像素宽的位置信息,而这里直接在灰度图上采样,配合双线性插值可以做到亚像素级,抗噪能力也更好。
4.1 圆环采样点的几何:内外各偏 1 像素
对圆周上角度为 θ 的点,内外两个采样位置分别是:
p_in = (x + (r - 1) * cosθ, y + (r - 1) * sinθ) p_out = (x + (r + 1) * cosθ, y + (r + 1) * sinθ)偏移量取 1 像素是个有讲究的选择。取 0 的话内外两点重合,差值为零,代价函数失效;取 2 或更大,采样点容易跨过虹膜边界或者撞上睫毛,把不属于瞳孔边缘的灰度差算进来。1 像素刚好跨在边缘两侧,既能捕捉到梯度,又不会越界。这个值跟成像的模糊程度有关,图像越模糊,边缘过渡带越宽,偏移量可以适当放大到 2。
采样点数 k 决定角度的分辨率。k 太小,圆周上采样稀疏,睫毛遮挡一段就可能让整个圆匹配到错误位置;k 太大,计算量线性上升,而精度提升很快饱和。
4.2 三层循环的代价函数
搜索范围、采样点数、评分方式,三个参数构成这段代码的全部。
// pupil_contour.cpp static inline float bilinear(const cv::Mat& img, float x, float y) { int x0 = (int)std::floor(x), y0 = (int)std::floor(y); if (x0 < 0 || y0 < 0 || x0 + 1 >= img.cols || y0 + 1 >= img.rows) return 0.f; // 越界返回 0,等价于该点不参与 float fx = x - x0, fy = y - y0; const uchar* r0 = img.ptr<uchar>(y0); const uchar* r1 = img.ptr<uchar>(y0 + 1); return (1 - fx) * (1 - fy) * r0[x0] + fx * (1 - fy) * r0[x0 + 1] + (1 - fx) * fy * r1[x0] + fx * fy * r1[x0 + 1]; } bool refineByContour(const cv::Mat& gray, Pupil& p) { const float d = p.r / 10.0f; // 搜索半径 = 初值半径的 1/10 const int k = 40; // 每圈 40 对采样点 const float dTheta = 2.f * (float)CV_PI / k; float bestScore = -1e9f; Pupil best = p; for (float r = p.r - d; r <= p.r + d; r += 1.0f) for (float cx = p.x - d; cx <= p.x + d; cx += 1.0f) for (float cy = p.y - d; cy <= p.y + d; cy += 1.0f) { if (r < 2.f) continue; float sum = 0.f; for (int i = 0; i < k; ++i) { float th = i * dTheta; float c = std::cos(th), s = std::sin(th); float xin = cx + (r - 1.f) * c, yin = cy + (r - 1.f) * s; float xout = cx + (r + 1.f) * c, yout = cy + (r + 1.f) * s; sum += bilinear(gray, xout, yout) - bilinear(gray, xin, yin); } float score = sum / k; // 内外灰度差均值,越大越像瞳孔边缘 if (score > bestScore) { bestScore = score; best = {cx, cy, r, true, 0}; } } p = best; return true; }算一下代价:初值半径 30 时d = 3,三个维度各自 7 个取值,共 343 个候选圆,每个圆 40 对采样点、每对两次双线性插值,总共约两万七千次采样。这个量级在 320×240 的分辨率下,单帧控制在几十毫秒内是现实的,原始资料给出的 60ms 也是同一量级。
4.3 参数敏感性:k 从 40 降到 30,精度掉得比想象快
采样点数 k 是最值得做一次扫参的参数。原始实验给出的对应关系大致是:
| k(采样点数) | 检测精度 | 该阶段耗时 |
|---|---|---|
| 10 | 50.0% | 5.2 ms |
| 20 | 90.0% | 11.7 ms |
| 30 | 99.0% | 15.9 ms |
| 40 | 99.8% | 22.2 ms |
这张表说明的问题很直白:k 小于 20 的时候,圆周采样太稀疏,随便一段睫毛遮挡就能把匹配拉偏,精度断崖式下跌;k 从 30 加到 40,精度只涨了不到一个百分点,耗时却多了近一半。所以 40 是精度和耗时之间一个偏保守的取值,如果目标平台算力紧张,30 是能接受的下限。
d的影响同样明显,只是方向相反。d 取初值半径的十分之一,意味着允许圆心移动的幅度和允许半径伸缩的幅度是同一个量级;取太大(比如半径的一半),搜索区域会覆盖到虹膜甚至眼睑,最优解可能落到虹膜边界上;取太小(比如二十分之一),Hough 初值偏得稍多就够不着真实边缘,精修变成无效劳动。实验数据支持十分之一这个取值,实际上它也和 Hough 阶段的位置误差量级是对得上的。
4.4 双线性采样与亚像素
如果偷懒用最近邻取整,代码更快,但精度会掉一截。原因是圆周采样点的坐标本身是浮点数,取整之后相当于把采样位置量化到像素格,相邻候选圆之间的分数差异被抹平,最优解会在几个几乎同分的候选里随机跳,表现出来就是直径在连续帧之间抖动一两个像素。
双线性插值的代价是每次采样多几次乘加,相对整个搜索来说可以忽略。真正需要注意的是边界处理——采样点落到图像外面时必须直接返回 0 并跳过,而不是让指针越界。瞳孔贴近画面边缘的时候这种情况很常见,一个数组越界就能让整个程序崩在最后一帧。
5. 避坑与排查:五个让精度悄悄归零的细节
算法本身不难,难的是把参数和实际数据对上。下面这几条都是复现过程中真实会撞上的,按现象、原因、解决三段说清楚。
5.1 闭眼帧没排除,累积峰值一路污染下游
现象:视频里睁眼段检测很稳,一到眨眼或者闭眼,输出的圆心突然跳到画面某个角落,闭眼结束后还要好几帧才恢复正常。
原因:闭眼时图像里仍然有大量边缘(眼睑、睫毛、皮肤纹理),Hough 照样能投出票来,而且这些票分布分散,最大值可能落在任何位置。没有门槛的话,这个假圆会被当成有效结果往下一帧传。
解决:用累积峰值做门限,低于kPeakThreshold直接返回无效帧,同时在下游加一个状态机,连续 N 帧无效才判定为闭眼,避免单帧噪声导致的频繁切换。阈值不要拍脑袋,导出peak序列看分布再定。
5.2 先 Canny 还是先滤波,顺序反了边缘全是毛刺
现象:边缘图上看瞳孔轮廓断断续续,还夹杂大量零散短边,Hough 投票被这些短边干扰。
原因:直接在原始灰度图上做 Canny,传感器噪声会被当成边缘响应保留下来。梯度方向由噪声主导时,投票方向完全随机。
解决:GaussianBlur必须在Canny之前,且核大小与噪声尺度匹配。5×5 是通用起点,噪声更大的红外相机可以试 7×7,但别超过瞳孔半径的三分之一,否则边缘被平滑掉,Hough 找不到完整圆。
5.3 半径范围写死,换个镜头整个算法失效
现象:同一套代码换了采集设备,检测率突然掉到个位数,画面上明明能看到清晰的瞳孔。
原因:rMin、rMax是按某一固定成像距离标定的。镜头拉近,瞳孔半径可能翻倍,超出搜索范围,Hough 永远找不到;镜头拉远,半径变小,同样出界。
解决:加自标定流程,启动时先用宽松范围(比如 10 到 80)跑几十帧,取半径中位数 M,再把正式范围设成[0.7M, 1.3M]。这一步在工程上只多写二十行,能省掉大量现场调试。
5.4 点对偏移量取 2 像素,睫毛区域把匹配带偏
现象:眼睑开度较小或睫毛较长时,精修后的圆明显偏大,圆心向上偏移。
原因:内外采样偏移量从 1 改成 2 之后,外侧采样点更容易越过瞳孔边缘进入睫毛区域,那里灰度反而更暗,导致灰度差被低估甚至变负,代价函数对真实边缘不再敏感。
解决:偏移量保持 1 像素,或者按图像模糊程度自适应。判断标准是:把最优候选圆和真实边缘叠加,看内外采样点是否恰好卡在灰度过渡带上。偏了就调,不要凭理论值。
5.5 OpenCV 版本差异,函数签名变了程序编译不过
现象:照原始资料的写法复制,C 风格接口在新版上找不到,或者 Canny 参数顺序反了导致边缘图几乎全黑。
原因:老版本用的是cvCanny(img, edges, low, high, aperture)这类 C 接口,新版统一成cv::Canny(src, dst, threshold1, threshold2, apertureSize),其中threshold1是低阈值。传入时如果按"高阈值在前"的习惯写,效果会正好反过来。
解决:统一用 C++ 接口重写一遍,编译期就能发现不匹配。参数顺序记牢:先低后高。写完抽一帧把边缘图存下来肉眼确认,比看编译通过更有意义。
6. 二次开发:把这套算法接进自己的眼动监测链路
真要把这套东西用起来,最后一层是时序处理。单帧精度再高,逐帧独立输出也会有 ±1 像素的抖动,直接拿去做注视点估计会让人眼疲劳数据非常难看。我一般会在tracker.cpp里加一个极简的一阶低通,公式就一行,效果立竿见影:
// tracker.cpp —— 抑制逐帧抖动,alpha 越小越平滑但延迟越大 void Tracker::update(Pupil& p, const Pupil& last) { if (!p.valid) { missCount++; return; } // 无效帧直接跳过,不参与平滑 missCount = 0; const float alpha = 0.6f; // 0.5~0.7 之间比较稳妥 p.x = alpha * p.x + (1 - alpha) * last.x; p.y = alpha * p.y + (1 - alpha) * last.y; p.r = alpha * p.r + (1 - alpha) * last.r; }alpha的取值要跟帧率一起看。30fps 下取 0.6 大约引入一帧多一点的延迟,肉眼几乎不可察觉;如果是 60fps 且下游要做快速眼动分析,可以提到 0.75,牺牲一点平滑换响应速度。missCount用来区分偶发丢帧和真正的闭眼:连续丢帧超过阈值就宣告一次眨眼事件,逐帧的瞬时抖动则直接忽略。
几个常见的改造方向,按投入产出比排:
| 改造点 | 做法 | 收益 |
|---|---|---|
| 感兴趣区域裁剪 | 用上一帧圆心 ±2r 作为下一帧搜索窗 | 耗时降一半以上 |
| 亚像素初值 | Hough 后对峰值邻域做抛物线拟合 | 圆心更稳,精修范围可缩小 |
| 双红外差分 | 亮瞳暗瞳两路相减再走同一条链路 | 抗环境光干扰明显改善 |
| 多帧一致性校验 | 连续三帧半径差超过阈值则丢弃 | 抑制遮挡导致的跳变 |
裁剪搜索窗这一条最值得先做。轮廓匹配的耗时代价是候选圆数量的线性函数,而候选数量由搜索范围的三次方决定。把 Hough 的搜索限制在上一帧附近,d还能进一步缩小到r/20,单帧耗时能压到十几毫秒,低端硬件上的实时性就这么来的。
最后说个我踩过的坑。我一开始把所有参数都写在头文件里当常量,调一次参数就得重编译一次,一轮扫参下来半小时就没了。从那以后我每次做这类算法复现,都强制走一遍"参数外置到配置文件"的流程,半径范围、Canny 阈值、采样点数、搜索系数全部从config.yaml读,改完直接重跑,扫参效率完全不是一个量级。这套 Hough-Contour 的资料里参数给得很具体,但具体不等于通用,你手上那台相机的最优值大概率还得自己扫一遍。希望这些细节能帮你少走点弯路。
本文还有配套的精品资源,点击获取