Hough-Contour两级串联实现瞳孔直径99.8%精确测量
简介:面向图像处理与计算机视觉方向的研究人员和工程师,这份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-r,a、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::vector<cv::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.at<uchar>(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.at<uchar>(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].at<float>(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 与外圈 r+1
对圆周上的每个角度 θ,取两个点:半径方向靠内一圈的点 p 和靠外一圈的点 p′,坐标分别是圆心加上 (r−1) 和 (r+1) 倍的方向向量。然后用 p′ 的灰度减去 p 的灰度,得到一个差值。如果这个圆正好压在瞳孔边界上,内圈落在瞳孔里(暗),外圈落在虹膜上(亮),差值应该是正的,而且越大说明边界卡得越准。
把所有 k 对点的差值取平均,就是当前 (x, y, r) 的得分。用平均而不是求和,是为了让不同 k 值之间的结果可比,也避免采样点数量变化时得分尺度漂移。
内圈取 r−1、外圈取 r+1,这个偏移量不能太大。偏移到 3 像素以外,就会跨过虹膜进入别的结构,差值反而变小,最优点会被推到错误的位置上。1 像素的间隔刚好覆盖边界的过渡带,同时保持足够的对比度。
4.2 三重循环与平均灰度差最大化
搜索顺序是外层半径、中层横坐标、内层纵坐标,最里面再套一层角度循环。四层循环听起来吓人,但每层的范围都很小:半径浮动 2d+1 个值,两个坐标各 2d+1 个值,角度 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.at<uchar>(yo, xo) - gray.at<uchar>(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 单帧耗时 / ms |
|---|---|---|
| 10 | 50.0 | 5.2 |
| 20 | 90.0 | 11.7 |
| 30 | 99.0 | 15.9 |
| 40 | 99.8 | 22.2 |
k = 10 时点上太稀疏,某几对点恰好落在噪声或者睫毛上,平均值就被带偏;k = 40 时,单点噪声的影响被平均到 1/40,成绩稳定在 99.8%。再往上加,边际收益已经接近零,耗时却还在涨,所以 40 是这条曲线上的拐点。选参数就该找这种“精度已经贴顶、耗时还没起飞”的位置。
d 的取法是 r0/10,也就是初始圆半径的十分之一。这个比例的合理性在于:初定位的误差通常在半径的 5% 以内,取 10% 能完整覆盖,同时窗口又足够小,不至于把虹膜外缘或者眼睑边缘圈进来。d 取大了有两个坏处,一是耗时按 (2d+1)³ 增长,二是搜索空间里混进了不属于瞳孔的圆形结构,反而容易选错。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×480) |
|---|---|---|
| OpenCV 现成霍夫圆变换 | 约 10% | 快,但直径不可用 |
| 仅霍夫初定位 | 需自行实测 | 快 |
| Hough + 轮廓匹配(d = r0/10,k = 40) | 99.8% | 22.2 ms |
性能这部分要按分辨率分开报。原始方案的结论是低端机上处理 320×240 单帧约 60 ms,能做实时;高端机上 640×480 也能实时。这个说法的关键在“低端机”三个字,答辩时最好把测试机器的配置一并写在表头里,否则“实时”两个字站不住。同一份代码在不同机器上差三倍是常事。
最后准备一张时序图,横轴帧号,纵轴瞳孔直径,把眨眼段标灰。这张图比任何表格都直观:观众一眼就能看到曲线在睁眼段是平的、眨眼处是断开的。这是这套方案最有说服力的地方——它不只是一个静态精度数字,而是一条能拿去做后续分析的干净序列。
血泪经验是,我每次带人做这类课题,都强制要求先把指标定义和测试集划分写进文档,再动手写代码。顺序反过来的,最后都要返工重跑一遍实验,因为标注口径中途改了,前面所有数字作废重来。希望这套结构能帮你少走一遍这个弯路。
本文还有配套的精品资源,点击获取