简介:面向图像处理与计算机视觉领域研究人员与工程师,围绕红外视频中瞳孔直径检测精度偏低的问题,给出结合Hough变换与轮廓匹配的Hough-Contour算法资料。内容涵盖灰度化与滤波去噪、改进Hough梯度法初检瞳孔,以及在瞳孔附近进行圆形轮廓匹配以精确计算中心坐标和直径,并讨论阈值排除闭眼帧、关键参数设置与实时性验证;实验精确匹配率达99.8%,低端设备可实时处理320×240视频,高端设备可处理640×480视频。资源包为1个PDF文件,约1.04MB,便于系统研读算法原理、实验设计与实现细节。已有170人学习,可供心理健康研究、犯罪侦查、医疗诊断等场景的瞳孔检测工作参考。
1. 从一次「瞳孔直径测不准」说起:Hough-Contour 到底解决什么问题
某实验室做红外视频瞳孔检测,用 OpenCV 自带的圆变换跑了一遍,圆心基本能落在瞳孔上,直径却像抽签——同一段视频里相邻两帧能差出四五个像素,报告里的数值上下跳。问题不在「找到眼睛」,而在「把直径量准」。这套 Hough-Contour 的思路是把 Hough 变换和轮廓匹配串成两级:先用改进的 Hough 梯度法在边缘图上粗定位一个初始圆,再以它为种子,在灰度图上做小范围圆周轮廓匹配,圆心和半径一起精修。原方案给出的数字是分辨率 320×240 在低端机上单帧 60ms、640×480 在高端机实时,直径精确匹配率从 OpenCV 圆变换的 10% 左右提到 99.8%,还能自动剔除闭眼帧。做眼动、疲劳监测、心理生理实验的开发者,如果手里是红外相机、又不想上深度学习,这份东西的性价比很高。
2. 算法骨架与技术选型:一帧红外图像要走过哪四道工序
红外眼图的物理特征决定了整条流水线的设计。近红外补光下,瞳孔几乎不反射,呈现为一块稳定的暗区;虹膜、巩膜反射强,亮度高;角膜上还会有一个比瞳孔亮得多的反光点。也就是说,瞳孔边界在灰度上是「内暗外亮」的陡峭跳变,这个先验比任何形状模型都可靠。整套算法就是围绕这条跳变做文章:Hough 负责在全局找「大概在哪、大概多大」,轮廓匹配负责在局部验证「边界是不是真的在这里」。分工清楚了,后面每个参数才有地方落。
2.1 四阶段流水线:灰度、滤波、边缘、初检
一帧图像进来,先走四步固定动作,再进精修。顺序不能乱,尤其是滤波必须放在边缘提取之前——红外补光带来的散斑和传感器噪声会直接把 Canny 打成一片碎边,Hough 的累加器会被垃圾投票淹没。
// 单帧主流程:输入红外图像,输出瞳孔圆心与半径 bool detectPupil(const IplImage* src, PupilResult& out) { CvSize sz = cvGetSize(src); // 1) 转 8 位单通道灰度。红外相机常出 YUV 或 16bit,必须先归一化 IplImage* gray = cvCreateImage(sz, IPL_DEPTH_8U, 1); if (src->nChannels == 3) cvCvtColor(src, gray, CV_BGR2GRAY); else cvCopy(src, gray); // 2) 5x5 高斯滤波,压掉补光散斑;核再大瞳孔边界会被抹平 cvSmooth(gray, gray, CV_GAUSSIAN, 5, 5); // 3) Canny 取边缘,高低阈值 100/50 是红外眼图的经验值 IplImage* edge = cvCreateImage(sz, IPL_DEPTH_8U, 1); cvCanny(gray, edge, 100, 50); // 4) 改进 Hough 梯度法初检,失败说明闭眼或没有可用圆 Circle c0; if (!houghInit(edge, gray, c0)) { cvReleaseImage(&gray); cvReleaseImage(&edge); return false; } // 5) 在滤波后的灰度图上做轮廓匹配精修,d = r0/10,k = 40 out = contourRefine(gray, c0, c0.r / 10, 40); cvReleaseImage(&gray); cvReleaseImage(&edge); return true; }这段是整个工程的主干,参数含义逐条说清楚:CV_GAUSSIAN, 5, 5里的 5 是核宽,红外图噪点颗粒一般在 1~2 像素,5×5 足够;cvCanny(100, 50)中 100 是高阈值,决定哪些边缘「一定保留」,50 是低阈值,决定哪些「可能保留」,比例 2:1 是 Canny 的常规推荐,红外图对比度高,我一般从 80/40 起调;c0.r / 10是搜索窗口半径,后面第 4 章会详细拆。注意第 5 步用的是gray而不是edge——这是整套算法精度能上去的关键,很多实现栽在拿边缘图做匹配上,边缘图只有 0/255 两个值,差分信息全丢了。
2.2 为什么选 Hough 梯度法打底,而不是模板匹配或深度学习
选型上有三条路可走:模板匹配、深度学习、Hough 变换。模板匹配最省事,但瞳孔直径本身就是要测的量,模板尺寸一旦固定就自相矛盾,多尺度模板又会把耗时拉爆。深度学习精度好,可 2016 年那批设备上跑推理不现实,而且标注瞳孔边界的数据集不好找,闭眼、半睁眼、睫毛遮挡的样本更少。Hough 的优势在于它对「圆」这个几何先验做了显式建模,不需要训练,参数少,还能天然给出半径。
更具体地,标准 Hough 圆变换是三维参数空间 (a, b, r),每个边缘点要对所有可能的 r 投票,复杂度高得离谱。梯度法(也就是 OpenCV 里CV_HOUGH_GRADIENT那一支)做了两件事降维:一是边缘点只沿梯度方向投票,圆心必然落在这条法线上;二是先估圆心再算半径,把三维搜索拆成 2+1。这套算法就是在梯度法的基础上继续往下压——把「候选圆心按累加值降序排序」这一步去掉,直接求最大值。排序在候选点多的时候是 O(n log n),而求最大值是 O(n),在 320×240 这种小图上差别不明显,但在 640×480 上就是实打实的十几毫秒。
选 Hough 还有个隐性好处:它输出的累加峰值本身就是一个置信度。峰值高说明确实有一圈边缘在支持这个圆,峰值低说明这一帧大概率没有瞳孔——闭眼、眨眼、眼睛移出视野,都能用同一个数字判掉,不需要额外写分类器。
2.3 数据流与模块边界:一帧图像在各函数间怎么传
工程的模块划分很清晰,四层:采集层负责解码视频帧并统一成 8 位灰度;预处理层做滤波和边缘;初检层输出Circle{x, y, r}和峰值;精修层输出PupilResult{x, y, r, diff, valid}。层与层之间只传结构体,不传IplImage*,这样后面想把某一层换成别的实现(比如把初检换成cvHoughCircles)时,改动面很小。
struct Circle { int x, y, r; // 圆心像素坐标与半径 }; struct PupilResult { double x, y, r; // 精修后的亚像素级圆心与半径 double diff; // 最优圆的内外圈平均灰度差,可当质量分 bool valid; // 闭眼帧或被阈值挡掉时为 false };diff这个字段别小看。它是最优圆上所有采样点「外圈灰度减内圈灰度」的平均值,瞳孔边界清晰时通常在 40~80 之间,边界模糊或被睫毛切掉一半时会掉到 10 以下。二次开发时可以直接拿它做时间序列上的异常点剔除,比另外算圆度省事。valid则把闭眼帧的判断结果一路带到业务层,避免上层拿到一堆 (0,0,0) 还要自己判空。
数据流的方向是单向的:采集 → 预处理 → 初检 → 精修 → 输出,中间不回流。这个约束很重要,一旦允许精修结果反过来影响初检的搜索范围(很多人会想到用上一帧结果做 ROI 预测),就必须引入帧间状态,测试用例的构造难度会翻倍。原方案是逐帧独立的,好处是可以并行、可以乱序、可以随时从视频中间切入,做离线批处理时这点很省心。
3. 核心模块拆解:改进的 Hough 初检与三处提速
初检这一层是整个算法的地基,它给出的c0直接决定了精修的搜索窗口位置。地基歪了,精修再怎么调参数也救不回来——这是我在类似项目里翻过车的地方:一开始图省事直接用cvHoughCircles的默认参数,结果param1、param2两个阈值在不同光照的红外视频上表现天差地别,一段能跑一段全空。后来老老实实把梯度法内部逻辑拆出来自己控制,才有稳定的初检。
3.1 累加器峰值:把排序换成一次线性扫描
Hough 梯度法的核心是一个二维累加器accum[a][b],每个边缘点沿梯度法线投票,投票数越高的 (a, b) 越可能是圆心。OpenCV 的原实现会把候选点按累加值降序排一遍,再逐个尝试算半径,取第一个通过校验的。排序的目的是「优先试最像的」,但代价是内存和时间都上去了。
// 改进点:不做降序排序,一趟扫描同时拿到峰值和位置,O(n) 替代 O(n log n) int findBestCenter(const int* accum, int w, int h, int& bestA, int& bestB) { int best = 0; bestA = bestB = -1; for (int i = 0; i < w * h; ++i) { if (accum[i] > best) { best = accum[i]; bestA = i % w; // 列索引 -> 圆心 x bestB = i / w; // 行索引 -> 圆心 y } } return best; // 返回值即峰值,后面用来判断闭眼 }accum是一维展开的二维数组,索引换算i % w和i / w比嵌套循环快,因为内存访问是连续的。best初值给 0 而不是INT_MIN,是因为投票数不可能是负数,这样连一次比较都省了。这趟扫描还有个附带收益:它顺手把峰值返回出来了。排序版本要拿峰值还得再遍历一次,现在一次搞定。
省下来的时间在 320×240 上大约是 8~12ms,在 640×480 上能到 20ms 以上。别小看这几十毫秒,原方案在低端机上单帧 60ms 的预算里,这一项占了不小的比例。有读者可能会问:排序不是为了「优先试最像的圆心」提高鲁棒性吗?在实际红外眼图里,累加器往往只有一个明显尖峰(瞳孔是画面里最圆的暗区),多峰的情况基本是睫毛或反光点造成的伪峰,这时候排序反而会把伪峰排到前面浪费时间。直接取最大值配合阈值,效果更好。
3.2 峰值阈值:闭眼帧的免费过滤器
闭眼时没有瞳孔边界,但睫毛、眼睑褶皱在边缘图上依然会形成一段段弧,Hough 照样能凑出一个「圆」来,只是支持它的边缘点少,累加值低。所以峰值天然就是一个闭眼指示器。
// 闭眼/无瞳孔帧判定:峰值低于阈值直接跳过,不进精修 static const int PEAK_MIN = 30; // 由一段睁眼/闭眼混合视频标定,不要照抄 bool houghInit(const IplImage* edge, const IplImage* gray, Circle& c0) { std::vector<int> accum(edge->width * edge->height, 0); voteAlongGradient(edge, accum); // 梯度法投票,略 int a, b; int peak = findBestCenter(accum.data(), edge->width, edge->height, a, b); if (peak < PEAK_MIN) return false; // 闭眼帧,省下后续 20~35ms c0.x = a; c0.y = b; c0.r = estimateRadius(gray, a, b); // 用灰度梯度估初始半径 return c0.r > 0; }PEAK_MIN这个 30 不是普适常数,它跟图像分辨率、边缘点总数、Canny 阈值都相关。标定方法是拿一段包含睁眼、闭眼、眨眼的视频,把每帧峰值打印出来画个直方图,睁眼帧的峰值通常聚在一个高区间,闭眼帧聚在低位,中间取个谷底就行。我一般会取谷底偏下一点,宁可漏判几帧闭眼,也不要把正常帧误杀——误杀的后果是数据缺失,漏判的后果是直径跳变,后者更容易在后期分析里造成假阳性。
阈值判定放在半径估计之前,是因为估半径要遍历梯度方向上的像素,代价不低。先判峰值再估半径,闭眼帧的处理成本就压到了一次投票加一次扫描。原方案在 320×240 上整帧 60ms,其中闭眼帧因为提前退出实际只花 25ms 左右,对实时预览的流畅度提升明显。
3.3 半径搜索范围:低端机 60ms 是怎么抠出来的
半径估计不能全图搜。梯度法知道圆心一定在梯度法线上,所以半径只在「圆心到边缘点」的这条线段上找——从圆心往外走,遇到第一个强边缘就认为是瞳孔边界。这个「第一个」的判定用灰度梯度而不是二值边缘,避免睫毛干扰。
// 沿梯度方向从圆心向外走,找灰度跳变最强的位置作为初始半径 int estimateRadius(const IplImage* gray, int cx, int cy) { int rMin = 5, rMax = std::min(gray->width, gray->height) / 4; // 先验范围 int bestR = -1; double bestGrad = 0; for (int r = rMin; r < rMax; ++r) { double g = 0; int cnt = 0; for (double t = 0; t < 2 * CV_PI; t += CV_PI / 16) { // 32 个方向采样 int xi = cvRound(cx + r * cos(t)), yi = cvRound(cy + r * sin(t)); int xo = cvRound(cx + (r + 1) * cos(t)), yo = cvRound(cy + (r + 1) * sin(t)); if (!inBounds(gray, xi, yi) || !inBounds(gray, xo, yo)) continue; // 外圈减内圈:瞳孔暗、虹膜亮,正值越大越像边界 g += CV_IMAGE_ELEM(gray, uchar, yo, xo) - CV_IMAGE_ELEM(gray, uchar, yi, xi); ++cnt; } if (cnt < 24) continue; // 采样点太少说明圆出界了 g /= cnt; if (g > bestGrad) { bestGrad = g; bestR = r; } } return bestR; }rMin = 5是防止把反光点当成瞳孔,rMax取图像短边的四分之一,红外眼图里瞳孔半径一般不超过这个数。方向采样 32 个点(CV_PI / 16步长)是一个折中:太少会被单个噪声点带偏,太多没必要,因为这一步只是粗估。cnt < 24这个判断是边界保护,圆一旦超出图像范围,采到的有效点会骤减,平均值不可信。
这一整套下来,320×240 的单帧耗时能压到 60ms 以内,跑在当年那批 1.86GHz 的低端机器上没问题。640×480 上单帧会涨到 200ms 以上,所以原方案提到高分辨率要上高端机才能实时——本质是像素数翻了四倍,投票和扫描都跟着涨。想在高分辨率上进一步提速,最直接的办法是先在降采样图上做初检,再把结果乘 2 映射回原图做精修,这一步我在后面的二次开发部分会展开。
4. 轮廓匹配:把精度从 10% 拉到 99.8% 的那段核心代码
Hough 初检给的圆,圆心误差通常 1~3 像素,半径误差可能到 5 像素以上——这是 Hough 的固有精度,累加器是整数网格,投票又是离散的。直接拿这个半径当直径输出,误差就大得没法用。轮廓匹配干的事情很朴素:既然已经知道大概在哪、大概多大,那就把 (x, y, r) 三个量各自在一个小范围里微调,找出让「圆周内外灰度差最大」的那一组。这一步把连续灰度信息用上了,精度自然上去了。
4.1 采样点对的构造:为什么是 r±1 而不是 r±3
核心思路是在圆周上均匀取 k 个方向,每个方向取两个点:一个在半径 r-1 的内侧,一个在 r+1 的外侧,两点灰度相减,k 个差值求和取平均。这个平均值越大,说明这条圆周越贴合真实的瞳孔边界。
// 计算给定 (x, y, r) 的内外圈平均灰度差,值越大越像瞳孔边界 double ringDiff(const IplImage* gray, int x, int y, int r, int k) { const double step = 2 * CV_PI / k; double sum = 0; int pairs = 0; for (double t = 0; t < 2 * CV_PI; t += step) { int xi = cvRound(x + (r - 1) * cos(t)); // 内圈:偏瞳孔一侧 int yi = cvRound(y + (r - 1) * sin(t)); int xo = cvRound(x + (r + 1) * cos(t)); // 外圈:偏虹膜一侧 int yo = cvRound(y + (r + 1) * sin(t)); if (!inBounds(gray, xi, yi) || !inBounds(gray, xo, yo)) continue; // 外减内:瞳孔暗、虹膜亮,差值为正 sum += CV_IMAGE_ELEM(gray, uchar, yo, xo) - CV_IMAGE_ELEM(gray, uchar, yi, xi); ++pairs; } return pairs ? sum / pairs : -1e9; // 全出界返回极小值,自然被淘汰 }偏移量取 ±1 像素是有讲究的。取 ±3 的话,如果真实边界在中间,内外两点都落在同一侧区域里,灰度差会被稀释;取 ±1 刚好跨在边界两侧,梯度最大。副作用是对噪声更敏感,所以这一步必须在滤波后的灰度图上做——这就是主流程第 5 步坚持传gray的原因。pairs计数器防的是圆跑出图像边界,出界的采样点直接跳过,如果全部出界就返回一个极小值,让这个候选自动落选,不需要额外的边界判断分支。
返回值的量纲是灰度级差,8 位图上最大 255。实际测试中,边界清晰的帧最优值在 50~90,边缘模糊的在 20~40,完全对不上的在 10 以下。这个数值可以直接当质量分用,也可以拿来做过期帧检测。
4.2 三重循环的搜索空间与 d、k 两个旋钮
有了打分函数,剩下的就是在一个小立方体里搜最大值。三个维度分别是 x、y、r,每个方向上的搜索半径都是 d,采样点对数 k 决定打分的精度。
// 在初始圆 c0 附近做三重搜索,找平均灰度差最大的 (x, y, r) PupilResult contourRefine(const IplImage* gray, const Circle& c0, int d, int k) { PupilResult best; best.diff = -1e9; best.valid = false; for (int r = c0.r - d; r <= c0.r + d; ++r) { if (r < 3) continue; // 半径太小,无意义 for (int x = c0.x - d; x <= c0.x + d; ++x) { for (int y = c0.y - d; y <= c0.y + d; ++y) { double v = ringDiff(gray, x, y, r, k); if (v > best.diff) { // 求最大,不是最小 best.diff = v; best.x = x; best.y = y; best.r = r; best.valid = true; } } } } return best; }搜索空间的大小是(2d+1)^3 × k。以典型的 r0=30、d=3、k=40 算,大概是 343 × 40 ≈ 1.4 万次采样操作,单帧耗时 20ms 出头,可以接受。如果 d 提到 r0/2 = 15,空间立刻涨到 31³ ≈ 3 万组参数,耗时过百毫秒,实时性就没了。这就是「搜索窗口不能太大」的量化理由。
r < 3这个保护不能省。搜索下限c0.r - d在 r0 较小的时候会变成 0 甚至负数,cos/sin采样点全挤在圆心附近,ringDiff会算出一个虚假的高分(圆心附近灰度本来就均匀),最后输出一个半径 1 像素的「瞳孔」。这种 bug 在测试里很隐蔽,只有在瞳孔缩小的帧上才偶发,一旦出现就是数据里的离群点。
4.3 参数敏感性:d=r/10、k=40 是怎么试出来的
这两个参数不是拍脑袋定的。原方案做了两组对照实验,结论很明确。
先看 k,也就是圆周上取多少对采样点:
| 采样点对数 k | 直径精确匹配率 | 单帧耗时(320×240) |
|---|---|---|
| 10 | 50.0% | 5.2 ms |
| 26 | 90.0% | 11.7 ms |
| 30 | 99.0% | 15.9 ms |
| 40 | 99.8% | 22.2 ms |
k 从 10 涨到 40,精度从 50% 跳到 99.8%,耗时只多了 17ms。原因是 k 太小时圆周采样稀疏,某几个点恰好落在睫毛或反光点上就能把平均分拉高,选出一个偏心的圆;k 大了之后单点噪声被平均掉,打分才反映真实边界。k=40 之后再往上加,精度提升趋近饱和,耗时却线性增长,不划算。所以在 320×240 上 k=40 是性价比拐点。
再看 d,也就是搜索窗口相对初始半径的比例:
| d 取值 | 表现 |
|---|---|
| r0/20 | 窗口太窄,Hough 初检误差稍大就搜不到真值,精度掉到 60% 以下 |
| r0/10 | 精度 99.8%,耗时可控,是推荐值 |
| r0/2 | 窗口过宽,耗时涨 5 倍以上,还会引入「远处伪圆」误匹配 |
d = r0/10的本质是让搜索范围随瞳孔大小自适应。瞳孔大时绝对误差也大,窗口相应放宽;瞳孔缩小时窗口自动收窄,避免搜到虹膜外缘。如果写成固定像素值(比如统一 d=3),在瞳孔直径 20 像素的帧上窗口偏大,在直径 60 像素的帧上窗口不够用——这就是为什么很多复现版本在小瞳孔上表现特别差。
顺带说一句,实验还对比了「只在 Canny 边缘图上做匹配」和「在滤波灰度图上做匹配」,前者精度只有 10% 上下,跟直接输出 Hough 结果差不多。原因前面提过:边缘图是二值的,内外圈差分恒为 0 或 ±255,等于把打分函数退化了。这个坑很典型,复现时如果精度上不去,先检查这一步传的是哪张图。
5. 常见问题排查:五条从编译到实时性的踩坑记录
这套算法逻辑不复杂,但工程落地时的坑不少,而且大多不报错,只是结果慢慢变差。下面五条是我在复现和改造过程中真实遇到的,按「现象 → 原因 → 解决」列出来。
第一条:程序跑起来但cvCanny直接崩,报断言失败。现象是传入相机原始帧后立刻中断,错误信息指向图像深度或通道数。原因是红外相机输出的常是 YUV422 或 16 位灰度,而 OpenCV 1.x 的 Canny 只接受 8 位单通道。解决办法是在预处理最前面强制做一次格式归一:三通道走cvCvtColor转灰度,16 位先右移 8 位再cvConvertScale压到 8 位。这一步最好封装成一个toGray8()函数,所有入口统一走它,别在每个模块里各写一遍。
第二条:直径数值在相邻帧之间反复横跳,肉眼看着瞳孔没变。现象是直径时间序列像噪声,标准差能到 3 像素以上。原因有两个:一是搜索窗口 d 写成了固定像素,没有随初始半径自适应;二是ringDiff里的内外偏移写成了 ±2 或 ±3。前者导致小瞳孔时窗口相对过大,容易搜到偏心解;后者稀释了边界梯度。解决是把 d 改成c0.r / 10,偏移严格取 ±1,并且确认打分用的是滤波后的灰度图。
第三条:闭眼帧也被判成有效,直径输出一个莫名其妙的数值。现象是眨眼那几帧valid仍为 true,直径还比睁眼时大。原因是闭眼时睫毛和眼睑褶皱在边缘图上凑出的弧,恰好能被 Hough 投票凑成一个圆,只是累加峰值偏低。解决是加峰值阈值PEAK_MIN,标定方法前面说过,用混合视频画直方图取谷底。另外可以加一道二次校验:精修后的diff如果低于 15,同样标为无效。两道门槛一起用,误判率能压到很低。
第四条:换成 640×480 之后帧率掉到个位数。现象是分辨率一升,程序就卡成幻灯片。原因是搜索复杂度是(2d+1)^3 × k,而 d 又跟 r0 成正比,分辨率翻倍后 r0 大约翻倍,d 也翻倍,总操作数涨了将近 8 倍。解决办法有三条,按性价比排序:先在降采样一半的图上做初检,把c0乘 2 映射回原图,精修窗口不用放大;其次把精修限制在瞳孔 ROI 内,别全图遍历;最后如果还不行,改成隔帧精修、中间帧用上一帧结果加小窗口跟踪。
第五条:瞳孔中心被角膜反光点带偏,圆总是偏一个方向。现象是圆心系统性偏移,偏差方向和反光点位置一致。原因是反光点在灰度上极亮,落在圆周采样点上时会把「外圈减内圈」的差值拉得异常大,打分函数被单点主导。解决办法是在ringDiff里加一步剔除:算完 40 个差值后排序,去掉最大和最小的各 10%,再取平均。这一步增加的耗时不到 1ms,但对反光严重的视频效果立竿见影。另一个思路是在预处理阶段检测高亮小连通域并直接填成周围中值,代价是要多写一段形态学代码。
这五条里,第二条和第三条最容易反复,因为它们不报错,只是让数据慢慢变得不可信。上线前建议拿一段带标注的视频跑一遍,把直径序列和diff序列都画出来看,异常点一眼就能发现。
6. 二次开发:把这套算法包成可复用模块的几条路子
原方案的代码结构是围绕「跑通一篇论文的实验」组织的,函数大多是全局的,参数散在宏定义里。真要拿去用,第一步是把接口收拢成一个类,把所有可调参数抽成配置结构体,这样换一段视频、换一台相机时只需要改配置,不用改代码。
struct PupilConfig { int blurKernel = 5; // 高斯核宽 double cannyHigh = 100; // Canny 高阈值 double cannyLow = 50; // Canny 低阈值 int peakMin = 30; // 闭眼判定阈值,必须按设备标定 int sampleK = 40; // 圆周采样点对数 double windowRatio = 0.1; // 搜索窗口 = r0 * windowRatio double minDiff = 15.0; // 精修质量下限,低于此判无效 }; class PupilDetector { public: explicit PupilDetector(const PupilConfig& cfg); bool detect(const cv::Mat& frame, PupilResult& out); // 单帧入口 private: PupilConfig cfg_; };detect返回 bool 而不是抛异常,是因为视频流里闭眼帧是常态,一秒出现两三次很正常,用异常处理会把正常流程写成 try-catch 套娃。PupilResult里带上diff和valid,让调用方自己决定怎么处理低质量帧。
移植到 Python + OpenCV4 时,坑主要集中在 API 迁移上:cvSmooth换成cv2.GaussianBlur,cvCanny参数顺序变成(image, low, high),IplImage相关的宏全部要改成 numpy 索引。核心的ringDiff用 numpy 可以向量化,把 40 个角度一次性算出来:
import numpy as np, cv2 def ring_diff(gray, x, y, r, k=40): """向量化版本:一次算出 k 个采样点对的内外灰度差""" t = np.linspace(0, 2 * np.pi, k, endpoint=False) xi = np.round(x + (r - 1) * np.cos(t)).astype(int) yi = np.round(y + (r - 1) * np.sin(t)).astype(int) xo = np.round(x + (r + 1) * np.cos(t)).astype(int) yo = np.round(y + (r + 1) * np.sin(t)).astype(int) h, w = gray.shape ok = (xi >= 0) & (xi < w) & (yi >= 0) & (yi < h) & \ (xo >= 0) & (xo < w) & (yo >= 0) & (yo < h) if ok.sum() < k * 0.7: # 有效点太少直接判废 return -1e9 diff = gray[yo[ok], xo[ok]].astype(np.float32) - gray[yi[ok], xi[ok]].astype(np.float32) # 去掉最大最小各 10%,抗反光点干扰 diff.sort() cut = max(1, int(len(diff) * 0.1)) core = diff[cut:-cut] if len(diff) > 2 * cut else diff return float(core.mean())向量化之后,一次打分从 40 次 Python 循环降到一次 numpy 运算,三重搜索的总耗时能降一个数量级。ok.sum() < k * 0.7这个判断比 C++ 版本更严格,因为 Python 里索引越界会直接抛异常,宁可提前返回。排序去极值的逻辑也放进来了,对应第 5 章第五条坑。
验证方法上,别只看「看起来对」。建议自建一个小金标准:挑 100 帧覆盖睁眼、半睁、闭眼、反光强四类场景,人工用图像工具量出直径,存成 CSV。跑完算法后统计三个指标——直径的平均绝对误差、圆心偏移的均方根、valid的混淆矩阵。我自己习惯把误差超过 2 像素的帧单独存图,事后一张张看,往往能发现参数在某一类场景下系统性失效,比盯着一个总精度数字有用得多。
还有一个改造方向值得提:帧间连续性。原方案逐帧独立,直径序列的抖动全靠算法精度压。如果业务对抖动敏感,可以在输出层加一个轻量的卡尔曼或者中值滤波,用上一帧的(x, y, r)做预测,把当前帧的搜索窗口中心往预测值上挪一点。这一步不改变算法本身,但能把抖动压掉一半以上。
从那以后我每次接手这类检测算法,都强制先用一段极端场景的视频(强反光、闭眼、快速眼动各来一遍)跑通再谈精度,因为血泪经验告诉我,平均指标好看不等于能上线。希望帮到你。
本文还有配套的精品资源,点击获取