
简介面向图像处理与计算机视觉领域研究人员与工程师围绕红外视频中瞳孔直径检测精度偏低的问题给出结合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/10k 40 out contourRefine(gray, c0, c0.r / 10, 40); cvReleaseImage(gray); cvReleaseImage(edge); return true; }这段是整个工程的主干参数含义逐条说清楚CV_GAUSSIAN, 5, 5里的 5 是核宽红外图噪点颗粒一般在 12 像素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那一支做了两件事降维一是边缘点只沿梯度方向投票圆心必然落在这条法线上二是先估圆心再算半径把三维搜索拆成 21。这套算法就是在梯度法的基础上继续往下压——把「候选圆心按累加值降序排序」这一步去掉直接求最大值。排序在候选点多的时候是 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这个字段别小看。它是最优圆上所有采样点「外圈灰度减内圈灰度」的平均值瞳孔边界清晰时通常在 4080 之间边界模糊或被睫毛切掉一半时会掉到 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 上大约是 812ms在 640×480 上能到 20ms 以上。别小看这几十毫秒原方案在低端机上单帧 60ms 的预算里这一项占了不小的比例。有读者可能会问排序不是为了「优先试最像的圆心」提高鲁棒性吗在实际红外眼图里累加器往往只有一个明显尖峰瞳孔是画面里最圆的暗区多峰的情况基本是睫毛或反光点造成的伪峰这时候排序反而会把伪峰排到前面浪费时间。直接取最大值配合阈值效果更好。3.2 峰值阈值闭眼帧的免费过滤器闭眼时没有瞳孔边界但睫毛、眼睑褶皱在边缘图上依然会形成一段段弧Hough 照样能凑出一个「圆」来只是支持它的边缘点少累加值低。所以峰值天然就是一个闭眼指示器。// 闭眼/无瞳孔帧判定峰值低于阈值直接跳过不进精修 static const int PEAK_MIN 30; // 由一段睁眼/闭眼混合视频标定不要照抄 bool houghInit(const IplImage* edge, const IplImage* gray, Circle c0) { std::vectorint 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 初检给的圆圆心误差通常 13 像素半径误差可能到 5 像素以上——这是 Hough 的固有精度累加器是整数网格投票又是离散的。直接拿这个半径当直径输出误差就大得没法用。轮廓匹配干的事情很朴素既然已经知道大概在哪、大概多大那就把 (x, y, r) 三个量各自在一个小范围里微调找出让「圆周内外灰度差最大」的那一组。这一步把连续灰度信息用上了精度自然上去了。4.1 采样点对的构造为什么是 r±1 而不是 r±3核心思路是在圆周上均匀取 k 个方向每个方向取两个点一个在半径 r-1 的内侧一个在 r1 的外侧两点灰度相减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。实际测试中边界清晰的帧最优值在 5090边缘模糊的在 2040完全对不上的在 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; }搜索空间的大小是(2d1)^3 × k。以典型的 r030、d3、k40 算大概是 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 参数敏感性dr/10、k40 是怎么试出来的这两个参数不是拍脑袋定的。原方案做了两组对照实验结论很明确。先看 k也就是圆周上取多少对采样点采样点对数 k直径精确匹配率单帧耗时320×2401050.0%5.2 ms2690.0%11.7 ms3099.0%15.9 ms4099.8%22.2 msk 从 10 涨到 40精度从 50% 跳到 99.8%耗时只多了 17ms。原因是 k 太小时圆周采样稀疏某几个点恰好落在睫毛或反光点上就能把平均分拉高选出一个偏心的圆k 大了之后单点噪声被平均掉打分才反映真实边界。k40 之后再往上加精度提升趋近饱和耗时却线性增长不划算。所以在 320×240 上 k40 是性价比拐点。再看 d也就是搜索窗口相对初始半径的比例d 取值表现r0/20窗口太窄Hough 初检误差稍大就搜不到真值精度掉到 60% 以下r0/10精度 99.8%耗时可控是推荐值r0/2窗口过宽耗时涨 5 倍以上还会引入「远处伪圆」误匹配d r0/10的本质是让搜索范围随瞳孔大小自适应。瞳孔大时绝对误差也大窗口相应放宽瞳孔缩小时窗口自动收窄避免搜到虹膜外缘。如果写成固定像素值比如统一 d3在瞳孔直径 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 之后帧率掉到个位数。现象是分辨率一升程序就卡成幻灯片。原因是搜索复杂度是(2d1)^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.GaussianBlurcvCanny参数顺序变成(image, low, high)IplImage相关的宏全部要改成 numpy 索引。核心的ringDiff用 numpy 可以向量化把 40 个角度一次性算出来import numpy as np, cv2 def ring_diff(gray, x, y, r, k40): 向量化版本一次算出 k 个采样点对的内外灰度差 t np.linspace(0, 2 * np.pi, k, endpointFalse) 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)做预测把当前帧的搜索窗口中心往预测值上挪一点。这一步不改变算法本身但能把抖动压掉一半以上。从那以后我每次接手这类检测算法都强制先用一段极端场景的视频强反光、闭眼、快速眼动各来一遍跑通再谈精度因为血泪经验告诉我平均指标好看不等于能上线。希望帮到你。本文还有配套的精品资源点击获取