YAOTU INSIGHTS

C++ OpenCV 虹膜分割实战:从灰度预处理到霍夫圆与微积分算子

C++ OpenCV 虹膜分割实战:从灰度预处理到霍夫圆与微积分算子
简介这是一份面向计算机视觉初学者与图像处理方向开发者的虹膜分割实现代码采用C与OpenCV编写可用于生物特征识别、人眼图像分析等场景的入门实践与课程设计参考。压缩包共39个文件约3.26MB包含2个cpp源码文件、1个vcproj工程与sln解决方案以及bmp、jpg测试图片各10张另附1份PDF方法介绍PPT便于对照代码理解分割流程与算法思路。资源已有1124人学习下载说明其在虹膜提取这一细分方向具有一定参考价值。读者可从中获得可直接编译运行的完整工程、配套测试图像与算法讲解材料快速上手虹膜区域定位与分割实验并在此基础上进行参数调整与功能扩展。1. 虹膜分割到底在分什么从一张灰度眼图说起虹膜分割Iris Segmentation在 C 和 OpenCV 里落地本质就一件事在一张灰度眼睛图像里把虹膜这个圆环区域的内边界瞳孔边缘和外边界虹膜与巩膜交界找出来再顺手把上下眼睑遮掉的弧段剔除。听起来像纯几何题但真正上手你会发现它更像一场和光照、睫毛、反光点的拉锯战。我见过太多人拿着现成的 Python 脚本跑得挺欢一换到 C 工程里、一换到近红外和可见光混合的相机流上边缘检测就开始集体翻车。这篇笔记面向的是要在 C 工程里真正把虹膜分割跑起来的人可能是做身份识别的可能是做眼动追踪预处理的也可能是做疲劳检测想拿虹膜当稳定参考的。我不假设你手里有某个特定数据集也不假设你用的是哪家的相机只讲一条能复现的路径——从灰度预处理、边缘提取到用霍夫变换和微积分算子定位内外圆再到把参数调稳、把坑填平。读完你应该能自己搭出一个可用的分割模块并且知道它在什么条件下会失效。2. 为什么先做边缘图而不是直接上霍夫预处理决定成败2.1 灰度化与光照归一化的取舍虹膜分割的第一步不是找圆而是让圆“显形”。彩色图里虹膜和巩膜的对比度往往被肤色和光照带偏所以先转灰度。但直接cvtColor之后你会发现图像一侧亮一侧暗边缘检测阈值根本没法统一。常见做法是加一步光照归一化我一般用两种一种是简单的直方图均衡化另一种是高斯模糊后做除法归一化也叫同态滤波的简化版。#include opencv2/opencv.hpp // 输入BGR 眼图输出归一化后的灰度图 cv::Mat normalizeGray(const cv::Mat bgr) { cv::Mat gray; cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY); // 高斯模糊估计背景光照sigma 取图像宽度的 1/30 左右 cv::Mat bg; int ksize std::max(3, (gray.cols / 30) | 1); // 保证奇数 cv::GaussianBlur(gray, bg, cv::Size(ksize, ksize), 0); // 除法归一化原图 / 背景抑制大尺度光照不均 cv::Mat norm; cv::divide(gray, bg, norm, 255.0); // 缩放回 0-255 // 轻度直方图均衡拉对比度 cv::Mat eq; cv::equalizeHist(norm, eq); return eq; }逻辑说明GaussianBlur用大核估计低频光照背景divide把原图除以背景等于把光照的乘性影响变成加性再均衡化拉对比度。参数上ksize是关键——太小会把虹膜纹理当背景抹掉太大则光照校正不充分。我一般从cols/30起步图像宽 640 时约 21实测对多数近红外眼图够用。注意divide的缩放因子 255.0 是为了把结果拉回 8 位可显示范围否则会一片黑。2.2 Canny 阈值怎么定才不靠玄学预处理完就是边缘。Canny 是默认选择但它的双阈值是出了名的玄学。我的经验是不要拍脑袋定而是用图像自身的梯度统计来推。先算 Sobel 幅值取中位数和上四分位数高阈值取上四分位低阈值取高阈值的 0.4 倍。这样不同曝光下都能自适应。cv::Mat edgeMap(const cv::Mat gray) { cv::Mat gradX, gradY, mag; cv::Sobel(gray, gradX, CV_32F, 1, 0); cv::Sobel(gray, gradY, CV_32F, 0, 1); cv::magnitude(gradX, gradY, mag); // 用梯度幅值分布推阈值 cv::Mat flat mag.reshape(1, 1); std::vectorfloat v; flat.copyTo(v); std::sort(v.begin(), v.end()); float q75 v[static_castsize_t(v.size() * 0.75)]; float high q75; float low high * 0.4f; cv::Mat edges; cv::Canny(gray, edges, low, high); return edges; }逻辑说明Sobel算水平和垂直梯度magnitude合成幅值。取 75 分位作为高阈值是因为虹膜边界属于强边缘落在分布尾部低阈值取 0.4 倍是 Canny 的经典比例能连上弱边缘又不至于引入太多噪声。参数上如果你发现边缘断裂把 0.4 降到 0.3如果噪声太多升到 0.5。这一步的产物是二值边缘图后面霍夫和微积分算子都吃它。2.3 形态学清理别让睫毛和反光点骗了你边缘图里最烦的是睫毛——它又长又弯霍夫圆变换经常被它带偏。反光点则会在瞳孔内部制造假边缘。我的做法是先做一次开运算去掉孤立细线再用连通域面积过滤掉太小的碎片。cv::Mat cleanEdges(const cv::Mat edges) { cv::Mat kernel cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(3, 3)); cv::Mat opened; cv::morphologyEx(edges, opened, cv::MORPH_OPEN, kernel); // 连通域过滤面积小于 30 像素的丢掉 cv::Mat labels, stats, centroids; int n cv::connectedComponentsWithStats(opened, labels, stats, centroids); cv::Mat cleaned cv::Mat::zeros(opened.size(), CV_8U); for (int i 1; i n; i) { int area stats.atint(i, cv::CC_STAT_AREA); if (area 30) { cleaned.setTo(255, labels i); } } return cleaned; }逻辑说明开运算用 3×3 椭圆核能吃掉 1 到 2 像素宽的睫毛残影又不至于把虹膜边界打断。连通域面积阈值 30 是经验值图像分辨率 640×480 下真实虹膜边缘连通域通常几百像素以上睫毛碎片往往几十像素。分辨率翻倍时这个阈值要跟着调别死记。3. 用霍夫圆和微积分算子定位内外边界3.1 霍夫圆变换找瞳孔半径范围比累加器阈值更重要瞳孔是虹膜分割里最稳的一环因为它对比度最高、形状最接近圆。OpenCV 的HoughCircles用起来简单但参数里真正决定成败的是minRadius和maxRadius不是param2。很多人把半径范围设成 0 到图像宽度结果算法在整张图里乱找累加器阈值再高也压不住。cv::Vec3f findPupil(const cv::Mat gray, const cv::Mat edges) { // 先估计大致范围瞳孔直径通常是图像宽度的 1/10 到 1/4 int minR gray.cols / 20; int maxR gray.cols / 6; std::vectorcv::Vec3f circles; cv::HoughCircles(gray, circles, cv::HOUGH_GRADIENT, 1.5, // dp累加器分辨率比 gray.rows / 8, // minDist圆心最小间距 200, // param1Canny 高阈值内部用 50, // param2累加器阈值 minR, maxR); if (circles.empty()) return cv::Vec3f(-1, -1, -1); // 取第一个通常是最强响应 return circles[0]; }逻辑说明dp1.5表示累加器分辨率是原图的 1/1.5比 1 快但精度略降实测够用。minDist设成行高的 1/8防止同一个瞳孔被检出多次。param1是内部 Canny 高阈值200 偏严能压掉纹理噪声。param2是累加器阈值50 是中等偏严太低会检出假圆。半径范围cols/20到cols/6是核心——它把搜索空间锁死在合理区间比调param2有效得多。如果瞳孔偏大或偏小先改这两个值。3.2 微积分算子找外边界沿径向扫描的工程实现外边界虹膜外圆比瞳孔难因为虹膜和巩膜对比度低还常被眼睑遮挡。经典方法是 Daugman 的微积分算子以瞳孔圆心为中心沿每个角度做径向扫描找梯度最大的半径。工程上我不会真的去算积分而是对每个角度取一维灰度剖面求导后找峰值再对所有角度的峰值做圆拟合。// 沿径向找外边界对每个角度在 [rMin, rMax] 内找梯度最大点 cv::Vec3f findIrisOuter(const cv::Mat gray, cv::Point2f center, int rMin, int rMax) { const int angleSteps 360; std::vectorcv::Point2f edgePoints; for (int a 0; a angleSteps; a) { double theta a * CV_PI / 180.0; double dx std::cos(theta), dy std::sin(theta); // 采样径向剖面 std::vectorfloat profile; for (int r rMin; r rMax; r) { int x cvRound(center.x r * dx); int y cvRound(center.y r * dy); if (x 0 || y 0 || x gray.cols || y gray.rows) break; profile.push_back(gray.atuchar(y, x)); } if (profile.size() 5) continue; // 平滑后求导找最大梯度位置 cv::Mat p(profile); cv::GaussianBlur(p, p, cv::Size(5, 1), 0); int bestR -1; float bestGrad 0; for (size_t i 1; i 1 profile.size(); i) { float grad std::abs(p.atfloat(i 1) - p.atfloat(i - 1)); if (grad bestGrad) { bestGrad grad; bestR rMin i; } } if (bestR 0 bestGrad 10) { // 梯度阈值过滤弱响应 edgePoints.push_back(cv::Point2f(center.x bestR * dx, center.y bestR * dy)); } } // 用最小二乘拟合圆 if (edgePoints.size() 20) return cv::Vec3f(-1, -1, -1); cv::Vec4f circle; cv::fitCircle(edgePoints, circle); // 假设有 fitCircle 辅助函数 return cv::Vec3f(circle[0], circle[1], circle[2]); }逻辑说明对 360 个角度各采一条径向灰度剖面高斯平滑后求中心差分梯度取最大梯度对应半径。梯度阈值 10 用来过滤眼睑遮挡导致的弱响应。最后用fitCircle做最小二乘拟合比直接取平均半径稳。参数上rMin一般取瞳孔半径的 1.5 倍rMax取瞳孔半径的 3 倍左右具体看数据集。角度步长 360 够用追求速度可以降到 180。3.3 眼睑遮挡剔除上下弧段怎么判外圆拟合完上下眼睑遮掉的部分不能算进虹膜。常见做法是看每个角度上的梯度强度如果某个角度区间内梯度普遍低于阈值就标记为遮挡。更稳的是结合灰度眼睑区域通常比虹膜亮或暗得不够明显。我一般用梯度强度做初筛再用拟合圆的一致性做二次确认。// 标记被遮挡的角度区间梯度低于阈值的连续段 std::vectorbool maskOcclusion(const std::vectorfloat gradPerAngle, float thresh) { std::vectorbool occluded(gradPerAngle.size(), false); for (size_t i 0; i gradPerAngle.size(); i) { occluded[i] gradPerAngle[i] thresh; } // 形态学闭运算把短缺口连起来 for (int pass 0; pass 2; pass) { for (size_t i 0; i occluded.size(); i) { size_t prev (i occluded.size() - 1) % occluded.size(); size_t next (i 1) % occluded.size(); if (occluded[prev] occluded[next]) occluded[i] true; } } return occluded; }逻辑说明gradPerAngle是每个角度上的最大梯度低于阈值判为遮挡。两轮闭运算把零散缺口连成连续弧段避免把睫毛造成的单点弱响应误判成整段遮挡。阈值一般取全局最大梯度的 0.3 到 0.4 倍。这一步的产物是一个布尔掩码后续做虹膜归一化极坐标展开时直接跳过遮挡角度。4. 避坑与排查虹膜分割最常见的五类翻车4.1 瞳孔检不到先看半径范围再看图像质量现象HoughCircles返回空或者检出的圆明显偏大偏小。原因九成是minRadius/maxRadius和实际瞳孔尺寸不匹配剩下的一成是图像太糊或瞳孔被反光点盖住。解决先把图像存下来用imshow看一眼瞳孔占多少像素再反推半径范围。如果瞳孔被反光盖住先做一次中值滤波或 inpaint 补一下再检测。4.2 外边界被睫毛带偏梯度阈值和角度过滤要一起上现象拟合出的外圆明显偏向一侧或者半径忽大忽小。原因是睫毛在某个角度产生了比虹膜边界更强的梯度。解决把梯度阈值从 10 提到 20 到 30同时在拟合前剔除离群点——先算所有边缘点到临时圆心的距离去掉距离偏离中位数超过 2 倍标准差的点再重新拟合。4.3 光照不均导致半边边缘消失归一化不能省现象图像一侧边缘清晰另一侧完全检不到。原因是光源偏置暗侧梯度低于阈值。解决回到 2.1 的除法归一化把ksize调大一点比如cols/20让光照校正更激进。如果还不行考虑分块归一化把图像切成 4 块各自均衡。4.4 霍夫圆重复检出minDist 和 param2 要联动调现象同一个瞳孔被检出多个圆圆心差几个像素。原因是minDist太小累加器在邻近位置都过了阈值。解决minDist至少设成瞳孔半径的 1.5 倍同时把param2提高 20% 到 30%。两者一起调单改一个往往按下葫芦浮起瓢。4.5 实时性不够降分辨率比优化算法更有效现象单帧处理超过 30 毫秒视频流掉帧。原因是全分辨率下霍夫和径向扫描都太慢。解决先把图像缩到宽度 320 做检测得到圆心和半径后按比例放大回原图再在原图上做一次局部精修。实测这样能把耗时压到 10 毫秒以内精度损失很小。5. 把分割结果用起来极坐标展开与一个验证技巧分割出内外圆和遮挡掩码之后下一步通常是虹膜归一化——把圆环展开成固定尺寸的矩形方便后续特征提取。OpenCV 里没有现成的极坐标展开函数但用remap可以自己搭。我一般把展开图设成 64×512径向 64角度 512这个尺寸在特征维度和计算量之间比较平衡。cv::Mat unwrapIris(const cv::Mat gray, cv::Point2f center, int rPupil, int rIris, const std::vectorbool occluded) { const int outH 64, outW 512; cv::Mat mapX(outH, outW, CV_32F), mapY(outH, outW, CV_32F); for (int y 0; y outH; y) { // 径向从瞳孔边缘到虹膜边缘线性插值 float r rPupil (rIris - rPupil) * (float)y / (outH - 1); for (int x 0; x outW; x) { float theta 2 * CV_PI * x / outW; mapX.atfloat(y, x) center.x r * std::cos(theta); mapY.atfloat(y, x) center.y r * std::sin(theta); } } cv::Mat unwrapped; cv::remap(gray, unwrapped, mapX, mapY, cv::INTER_LINEAR); // 把遮挡角度对应的列涂黑后续特征提取会跳过 for (int x 0; x outW; x) { int angleIdx x * 360 / outW; if (angleIdx (int)occluded.size() occluded[angleIdx]) { unwrapped.col(x).setTo(0); } } return unwrapped; }逻辑说明mapX/mapY存的是目标图每个像素对应原图的采样坐标remap按这个映射做双线性插值。径向y从rPupil线性过渡到rIris角度x均匀铺满 0 到 2π。遮挡列涂黑是为了让后续特征提取知道哪些列不可信。参数上outH和outW可以按需调但注意outW要能整除 360 或者至少和角度掩码对齐否则遮挡标记会错位。验证分割好不好我有个偷懒但有效的习惯把拟合出的内外圆和遮挡弧段画回原图存成一张叠加图肉眼扫一遍。如果圆和真实边界贴合、遮挡弧段和眼睑位置对得上基本就没问题如果圆明显偏了回去看是霍夫参数还是梯度阈值的问题。这个习惯帮我省掉了大量盲调参数的时间——毕竟虹膜分割这行眼睛比任何指标都诚实。希望帮到你。本文还有配套的精品资源点击获取