简介:面向计算机视觉与OpenCV入门学习者的实验工程,在Visual Studio中演示Susan算子边缘检测与米粒图像处理任务。工程覆盖中值滤波去噪、灰度直方图统计、大津法与经验阈值分割、Susan边缘检测、连通区域分析与米粒计数等步骤,并以实际米粒图像展示灰度化、二值化、轮廓提取、最大米粒定位及分割结果与真实标记对比等中间结果。
压缩包为rar格式,共40个文件、约1.6MB;cpp/h源文件与sln/vcxproj工程配置可直接编译运行,jpg/bmp图像保存了中值滤波图、直方图、Susan轮廓图、两类阈值二值图与最大米粒图等输出,pdb/tlog/ilk等调试构建产物一并包含在内。
已有1714人浏览/学习,适合课程实验与OpenCV自学。通过工程可直观对比大津法自动阈值与经验阈值的分割差异,观察Susan算法对边缘的捕捉效果,并结合最大米粒标记结果理解连通组件分析思路,也可按各阶段中间图反推参数调整与算法原理。
1. 用 Visual Studio + OpenCV 复现 SUSAN:边缘检测与米粒计数的实验闭环
很多人拿到「使用 Visual Studio + OpenCV 进行的 SUSAN 算子边缘检测及数米粒图像处理实验」这个题目时,以为它跟 Canny 一样是个调接口的事。实际上 OpenCV 并没有内置 SUSAN,它的一切效果来自你自己写的那几十行像素循环。这套实验真正解决的是两件事:在没有深度学习的前提下做轻量边缘检测,以及把二值化结果喂给连通域分析,让程序代替人眼数出整盘米粒数量。适合正在做课设的本科生,也适合想快速搭一套图像预处理链路的从业者。下面直接把原理、代码、参数和踩坑点串起来讲。
2. 为什么不选 Canny:SUSAN 的 USAN 判据、两个核心参数与适用边界
2.1 SUSAN 的判定逻辑:圆形模板与 USAN 面积
SUSAN 的全称是 Smallest Univalue Segment Assimilating Nucleus,最小核同值区。名字很长,核心思想却很朴素:用一个圆形模板,把中心像素当作核,逐个比较模板内其他像素与核的灰度差,统计“灰度差小于某个容忍度”的像素个数。这个统计值叫 USAN 面积。
边缘上的像素,它的邻域会被边缘线切成两块,与它灰度相近的像素大约只有最大面积的一半;角点上的像素,相近的像素更少;平坦区域的像素,USAN 面积接近模板有效面积的最大值。SUSAN 就用这个差异做判断:USAN 面积小于几何阈值 g 的像素,判为边缘或角点。SUSAN 原始论文里对边缘检测的判据是R = g - usan,当 usan < g 时才有响应,响应强度与差值成正比。
与 Sobel、Canny 最本质的区别在于:Sobel 靠梯度幅值找边缘,本质是灰度差分,对噪声非常敏感;Canny 依赖梯度方向做非极大值抑制,又要高斯平滑,又要在平滑和保边之间做取舍。SUSAN 从头到尾没有计算任何导数,只做灰度相似性统计,孤立噪声点在模板内形成的“相似簇”面积有限,不容易触发边缘判据,这是它抗噪的底层原因。
2.2 两个核心参数 t 和 g 怎么定
SUSAN 的调参压力集中在两个参数上。
第一个是灰度差阈值 t。它决定哪些像素算“与核相似”。abs(像素灰度 - 核灰度) < t 的像素才会被计入 USAN。t 太小,正常纹理的灰度抖动也会被视为不相似,USAN 面积骤减,整张图会到处响应,看起来像噪点糊了一层;t 太大,边缘两侧的灰度差会被吞掉,边缘变宽甚至消失。
第二个是几何阈值 g。它决定 USAN 面积小到什么程度算边缘。g 一般取模板有效像素点数 nMax 的比例。原始论文推荐 3/4 * nMax,我实验下来 0.6~0.8 倍都能出不错的轮廓。g 越小,判定越严格,边缘越稀疏;g 越大,边缘越厚实,但伪响应也会变多。
模板尺寸也影响 nMax。我用得最多的是 7×7 近似圆形模板,里面实际参与比较的像素是 25 个,nMax = 25,g 的起点建议取 18。如果换 5×5 模板,nMax 变小,边缘会细但容易断;换 9×9,nMax 变大,边缘更连续但计算量涨一倍。
参数速查表如下:
| 参数 | 含义 | 推荐起点 | 调整方向 |
|---|---|---|---|
| t | 灰度相似容忍度 | 20~30 | 噪声多则调小,边缘碎则调大 |
| g | 几何阈值 | 0.75 * nMax | 边缘太粗调大,边缘太碎调小 |
| 模板 | 圆形近似尺寸 | 7×7(25 点) | 图大用 9×9,图小用 5×5 |
| 预处理 | 灰度化 | IMREAD_GRAYSCALE | 彩色图直接处理会翻车 |
2.3 什么场景适合 SUSAN,什么场景别硬上
SUSAN 最擅长的是光照不均匀、边缘对比度一般的图像。米粒俯拍图就很典型:米粒表面有反光,阴影边缘的灰度梯度是渐变的,Canny 的双阈值在这种图上特别容易断边——高阈值断掉的边,低阈值补回来又会带出一堆纹理。SUSAN 只关心“邻域和核够不够像”,对梯度大小不敏感,反而能把这些软边一次性框出来。
劣势也要说清楚。SUSAN 是逐像素遍历模板,没有积分图加速的话,比 OpenCV 内置的 Canny 慢一个数量级。实时视频流处理我一般不会用纯 C++ 循环版 SUSAN,除非先降采样。另外,SUSAN 原始输出是“粗边缘”,没有自带非极大值抑制,这一点会在后面的避坑章细说。做实验报告拿到一张清晰的米粒边缘图,它的表现是够格的;做工业检测且对速度有硬要求,建议先用小图验证效果再考虑优化。
3. 环境与实现:在 VS 里配置好 OpenCV,再手写一版带注释的 SUSAN 算子
3.1 在 Visual Studio 里配置 OpenCV 的完整步骤
这套实验我用的是 Visual Studio 2019 + OpenCV 4.x,x64 平台。OpenCV 的 Windows 预编译包解压后目录结构很固定,里面有 include、x64 的 vc15/vc16 这些子目录,vc16 对应 VS2019,vc17 对应 VS2022。
配置就走这几步:
- 新建空 C++ 项目,解决方案配置选 x64,Debug 或 Release 都行,但后面链接的库必须和运行模式一致。
- 打开“项目 → 属性 → VC++ 目录”,在“包含目录”里加
D:\opencv\build\include,在“库目录”里加D:\opencv\build\x64\vc16\lib。 - 打开“链接器 → 输入 → 附加依赖项”,Debug 填
opencv_world410d.lib,Release 填opencv_world410.lib,版本号按你解压的 OpenCV 版本写。 - 把
D:\opencv\build\x64\vc16\bin里的opencv_world410.dll(Debug 对应带 d 的版本)复制到 exe 所在目录,这一步省去配环境变量的麻烦。
这里有个常见的翻车点:Debug 模式误链接了不带 d 的 release 库,或者 Release 模式误链接了带 d 的 debug 库,编译能过,运行时会报内存分配错误,崩得莫名其妙。我一般会在属性管理器里建一张 OpenCV.props 属性表,把这些配置固化进去,每个新项目导入一次,比每次手填属性靠谱得多。
路径里的 vc16 是 VS2019 的版本号,如果你用 VS2022,目录名可能是 vc17,具体看解压后实际存在的文件夹。路径里不要带中文和空格,OpenCV 的构建脚本对中文路径支持不佳,这是很多人忽略的隐性源头。
3.2 SUSAN 算子的 C++ 实现:模板、遍历与判据
下面这版代码我按可复现的标准写,模板用 7×7 近似圆形,遍历时跳过模板覆盖不到图像边界的像素,避免模板残缺带来的伪边缘。
#include <opencv2/opencv.hpp> using namespace cv; using namespace std; // 7x7 近似圆形模板,1 表示参与灰度比较的像素 const int mask7[7][7] = { {0, 0, 0, 1, 0, 0, 0}, {0, 0, 1, 1, 1, 0, 0}, {0, 1, 1, 1, 1, 1, 0}, {1, 1, 1, 1, 1, 1, 1}, {0, 1, 1, 1, 1, 1, 0}, {0, 0, 1, 1, 1, 0, 0}, {0, 0, 0, 1, 0, 0, 0} }; // 统计模板内有效像素点个数 nMax int nMax = 0; Mat susanEdge(const Mat& src, int t, int g) { Mat gray; if (src.channels() == 3) cvtColor(src, gray, COLOR_BGR2GRAY); else gray = src.clone(); Mat out = Mat::zeros(gray.size(), CV_8UC1); int rows = gray.rows, cols = gray.cols; for (int y = 0; y < rows; y++) { for (int x = 0; x < cols; x++) { int usan = 0; bool touchBorder = false; uchar center = gray.at<uchar>(y, x); for (int m = 0; m < 7; m++) { for (int n = 0; n < 7; n++) { if (mask7[m][n] == 0) continue; int yy = y + m - 3; int xx = x + n - 3; if (yy < 0 || yy >= rows || xx < 0 || xx >= cols) { touchBorder = true; continue; } if (abs(gray.at<uchar>(yy, xx) - center) < t) usan++; } } // 模板触及图像边界时直接跳过该像素 if (touchBorder) continue; // USAN 面积小于 g 则判为边缘点 if (usan < g) out.at<uchar>(y, x) = 255; } } return out; }这段代码的逻辑分三层:外层两个 for 循环遍历每个像素作为 SUSAN 的核;内层两个 for 循环按照 mask7 模板逐点比较灰度;最后用usan < g做边缘判定。模板中心偏移 (m-3, n-3) 是为了把核放到模板中心。
代码里的要点:touchBorder这个标志位必须有。如果不跳过边界像素,模板在边界处只覆盖一部分,有效点数不足,USAN 面积天然偏小,边缘响应会出现一圈伪轮廓。模板内为 0 的位置直接 continue,不参与计算,能省掉约三分之一的无效比较。
t 和 g 的传参方式我写成了函数入参,方便你写一个 for 循环去扫参数。nMax全局变量是留给调试用的,打印一次确认模板有效点数,正常的话 7×7 模板应该输出 25。t 我建议从 25 起步,g 从 18 起步。
调用方式很简单,读取灰度图后一行出结果:
int main() { Mat src = imread("rice.jpg", IMREAD_GRAYSCALE); if (src.empty()) { printf("image not found\n"); return -1; } Mat edge = susanEdge(src, 25, 18); imwrite("susan_out.jpg", edge); imshow("SUSAN", edge); waitKey(0); return 0; }imread第二个参数用了IMREAD_GRAYSCALE,这一步直接读成单通道,避免彩色图混入 RGB 通道比较。imwrite把结果落盘,方便跟后面米粒计数的中间结果做对照。
3.3 对比实验:SUSAN 输出和 Canny 输出的直观差异
同一张米粒图,Canny 的参数是Canny(src, dst, 50, 150),SUSAN 用 t=25、g=18。肉眼能看到的差异很典型:Canny 的边缘连续性好,但米粒表面的反光纹理也会被勾勒出来,图面细碎点明显偏多;SUSAN 的边缘更粗,纹理误检少,但边缘线偶尔会断开。
如果 SUSAN 的结果断得太厉害,我一般先把 g 往上调,调大阈值让更多像素被判成边缘;再不行的就换 9×9 模板,nMax 变大后边缘响应更稳。这里要提醒一点:SUSAN 输出的是响应强度图,不是细线边缘图,想拿去做后续匹配、测量,得先做细线化,这个在第 6 章讲。
4. 数米粒流水线:Otsu 阈值分割、形态学去噪与连通域计数的完整流程
4.1 为什么不能直接用 SUSAN 结果数米粒
SUSAN 输出的是边缘像素,是镂空的轮廓。数米粒要数的是“一颗一颗的米”,得先得到实心的米粒区域。正确的顺序是:先对原始灰度图做阈值分割,把米粒和背景分开,再用形态学处理清掉噪点,最后做连通域标记,统计连通域个数。
阈值分割我直接用 Otsu。OpenCV 里threshold函数传入THRESH_BINARY | THRESH_OTSU标志,它会自动算出一个类间方差最大的阈值,不需要手调。米粒在浅色背景下通常是暗色或亮色,如果前景和背景反了,补一个THRESH_BINARY_INV就行。这个实验里默认米粒是浅色背景上的深色颗粒,操作起来最省事。
4.2 形态学开运算的作用
阈值分割出来的二值图总有些零星小点,可能是桌面灰尘、米粒碎屑、反光形成的孤立小区域。直接连通域计数的话,这些点会被算成一颗米。解决办法是形态学开运算,先腐蚀再膨胀,能去掉小于结构元素的小亮块。
结构元素大小是个权衡。3×3 的内核只能过滤单像素噪点,5×5 能滤掉稍大的碎屑,但米粒本身的边缘也会被磨掉一圈,如果米粒太小,开运算会把米粒直接腐蚀没了。我的建议:先看二值图里噪点的直径,结构元素取噪点直径的 1.5 倍左右,别贪心。
形态学处理位置也有讲究:阈值分割之后、连通域标记之前。顺序错了效果完全不同——先连通域再开运算,会把本来就不大的连通域拆得更碎。
4.3 连通域标记与计数:connectedComponentsWithStats 的用法
OpenCV 在 3.0 之后提供了connectedComponentsWithStats,一次拿到标签图、统计信息和质心坐标。对米粒计数来说,质心坐标还可以用来画标记点,验证计数结果对应到图上哪个位置。
#include <opencv2/opencv.hpp> using namespace cv; using namespace std; int countRice(const Mat& src) { Mat gray, binary, morph; if (src.channels() == 3) cvtColor(src, gray, COLOR_BGR2GRAY); else gray = src.clone(); // 1. Otsu 阈值分割,自动计算分割阈值 threshold(gray, binary, 0, 255, THRESH_BINARY_INV | THRESH_OTSU); // 2. 开运算去小噪点,结构元素 3x3 Mat kernel = getStructuringElement(MORPH_ELLIPSE, Size(3, 3)); morphologyEx(binary, morph, MORPH_OPEN, kernel); // 3. 连通域标记,拿统计量 Mat labels, stats, centroids; int n = connectedComponentsWithStats(morph, labels, stats, centroids, 8, CV_32S); // 4. 按面积阈值过滤:背景连通域 id=0,其余按面积筛 int count = 0; int minArea = 50, maxArea = 5000; for (int i = 1; i < n; i++) { int area = stats.at<int>(i, CC_STAT_AREA); if (area >= minArea && area <= maxArea) { count++; } } return count; }这段流水线的逻辑按四步走:threshold一句完成分割,morphologyEx处理噪点,connectedComponentsWithStats完成连通域标记,最后遍历统计数组过滤候选区域。
几个参数要注意。MORPH_ELLIPSE椭圆结构元素对圆形或不规则颗粒比较友好,比矩形内核更能保留粒形。连通域标记的连通性用的 8 邻域,四邻域容易把斜向粘连的米粒拆成两半,实际效果差不少。CC_STAT_AREA是统计矩阵里的面积列索引,不能直接写数字 2,虽然那也能跑,但换成宏定义可读性好得多,编译期也不会出错。
面积阈值的两个数字 minArea=50、maxArea=5000 是我按常见米粒尺寸拍的。你可以用stats.at<int>(i, CC_STAT_AREA)把面积打印出来,观察分布,再回头定阈值。实际跑的时候,面积阈值是数米粒误差最大的来源,阈值写死了换一张图就可能翻车。
4.4 可视化验证:把计数结果画回原图
计数结果不能只看一个数字。我会把每个候选中粒的质心画到原图上,存成一张带标记点的图,人工核对一下有没有漏数、重数。
Mat visualizeCount(const Mat& src, const Mat& labels, const Mat& stats, const Mat& centroids, int minArea, int maxArea) { Mat vis; cvtColor(src, vis, COLOR_GRAY2BGR); for (int i = 1; i < stats.rows; i++) { int area = stats.at<int>(i, CC_STAT_AREA); if (area < minArea || area > maxArea) continue; int cx = centroids.at<double>(i, 0); int cy = centroids.at<double>(i, 1); circle(vis, Point(cx, cy), 6, Scalar(0, 0, 255), 2); putText(vis, to_string(i), Point(cx - 20, cy - 20), FONT_HERSHEY_SIMPLEX, 0.5, Scalar(255, 0, 0), 1); } return vis; }质心坐标是 double 类型,取整之后画圆,不会偏出米粒范围。红色圆圈加蓝色编号,一眼就能看出哪个区域被算了、哪个被漏了。这一步建议写进实验报告的流程里,评审看到可视化验证的记录,比你口头说“我数了 128 个”可信得多。
5. 避坑:五个高频翻车点,按现象、原因、解决逐条排查
这一章是最值得先看的。下面五个坑,我从配置环境到调参到计数,全部踩过,每条按「现象 → 原因 → 解决」讲清楚。
5.1 编译通过,运行时报错找不到 opencv_world4xx.dll
现象:VS 里编译毫无问题,点运行弹出“由于找不到 opencv_world410.dll,无法继续执行代码”。
原因:OpenCV 的 DLL 没有进入程序加载路径。你配置了 include 和 lib,编译期能找到头文件和静态导入库,但运行期动态加载 DLL 需要到 PATH、exe 目录或 Windows 系统目录里找。Debug 和 Release 用的 DLL 文件名还不同,混用了也会报错。
解决:把D:\opencv\build\x64\vc16\bin\opencv_world410.dll复制到 exe 生成的目录下,这是最省心的做法。也可以配置 PATH 环境变量,但系统存在多个 OpenCV 版本时容易串,不建议。复制 DLL 的附加好处是换个电脑拷贝整个项目文件夹就能跑,实验课演示不依赖现场环境。
5.2 t 取 10 时整张图糊成一片白色噪点
现象:SUSAN 输出的边缘图全是密密麻麻的白点,完全看不出米粒轮廓,放大看每个区域都有响应。
原因:t 太小。灰度差小于 10 的“相似像素”太少,包括平坦区域内部也会因为轻微噪点导致 USAN 面积不足,触发边缘判据。这个阈值本质上是允许的灰度波动范围,它的下限由图像噪声水平决定,不能拍脑袋往小里取。
解决:把 t 拉回 25 附近,再打开直方图看灰度分布。t 的经验下限是图像噪声标准差的两倍左右,低于这个值必然出噪点。如果 t 调到 30 还是碎,说明图像本身噪声大,先做高斯滤波再跑 SUSAN,而不是继续硬调阈值。
5.3 彩色图没转灰度,at<uchar> 读取出来的结果全黑或程序内存访问违例
现象:SUSAN 函数直接对彩色 Mat 遍历,结果要么全黑要么随机崩溃,调试器停在abs(gray.at<uchar>(yy, xx) - center)。
原因:三通道 BGR 图的 Mat 是 CV_8UC3,at<uchar>读的是地址,不会报错但逻辑全乱,因为三个通道的字节被当成稀疏的灰度序列去比较,计算量也暴涨三倍。
解决:在函数开头强制转灰度,用cvtColor(src, gray, COLOR_BGR2GRAY);读图时直接imread(path, IMREAD_GRAYSCALE)在源头解决,两条路都能走。更稳妥的做法是在函数入口断言src.type() == CV_8UC1,类型不对直接打日志,省得后面排查半天。这个问题在实验报告里最好写明“灰度化预处理步骤”,体现的是基础知识点。
5.4 米粒粘连导致计数明显偏少
现象:摆了一排米粒,肉眼能看出是 10 颗,程序输出 7 或者 8 个连通域。
原因:光照角度让相邻米粒之间的阴影区域没有完全断开,阈值分割后粘连区形成一个连通域,connectedComponentsWithStats只能把它算成一个对象。
解决:分两层走。轻度粘连,把形态学开运算的结构元素放大到 5×5 甚至 7×7,先尝试把细连接带磨断;重度粘连,开运算会把米粒本身的形状也磨坏,这时用距离变换加分水岭分割,先对二值图算distanceTransform,再找局部极大值作为种子,最后watershed把粘连区域切开。这个方案我在第 6 章给了可复现代码,原理是按距离找“分水岭”,本质上把“一颗米”切成了“多颗米”再计数。
5.5 同一套参数换一张图,结果完全不同
现象:A 图参数跑出漂亮边缘,B 图同一套代码边缘要么全断要么全糊,两图光照不一样。
原因:t 是绝对灰度差,光照偏暗或偏亮都会影响灰度差分布。白平衡偏移会让米粒边缘的灰度差整体变大,原来小于 t 的像素现在大于 t,USAN 面积减小,边缘响应变少。SUSAN 没有内置光照归一化,这个坑换图必现。
解决:计数之前先做直方图均衡,equalizeHist拉一拉对比度;更稳的方案是用第 6 章的自适应 t 估算,让代码根据每个图的灰度分布自动换参数,而不是永远写死 25。如果项目要求极严,就固定拍摄条件,光源、角度、相机曝光都锁死,把变量控制在阈值适应范围内。
6. 进阶:自动估算 SUSAN 的灰度差阈值 t,告别手调参数
手调 t 本质上是在猜“图像灰度允许的噪声范围”,这有很强的玄学成分。我的做法是用 Otsu 分割结果反过来推 t,让程序自己看图。
思路:THRESH_OTSU返回的分割阈值 T 把前景和背景分开,它代表两类灰度中心的分界。SUSAN 的 t 应该取一个明显小于 T 的值,只覆盖同质区域内部的灰度波动。实用公式是t = clamp(T * 0.2, 10, 40),再加一个下限保护。T 太大说明前景背景对比强,t 可以适当放宽;T 很小说明整张图灰度扁平,t 必须收紧否则全图响应。
// 自动估算 t:用 Otsu 阈值按比例映射到灰度差容忍度 int autoT(const Mat& gray) { double T = threshold(gray, gray, 0, 255, THRESH_OTSU); int t = static_cast<int>(T * 0.2); if (t < 10) t = 10; if (t > 40) t = 40; return t; } // 重度粘连米粒的分水岭分割 void watershedCount(const Mat& binary, Mat& result) { Mat dist, labels; distanceTransform(binary, dist, DIST_L2, 3); double maxVal; minMaxLoc(dist, nullptr, &maxVal); // 距离图二值化得到每个米粒的“种子” threshold(dist, dist, 0.4 * maxVal, 255, THRESH_BINARY); // 找种子连通域,每个种子对应一个待分割区域 int n = connectedComponents(dist, labels); result = Mat::zeros(binary.size(), CV_32S); for (int i = 1; i < n; i++) { Mat mask = (labels == i); result.setTo(i, mask); } }autoT的映射比例不是唯一答案,我这边的米粒图 0.2 倍效果最好,如果你的图像噪声水平不一样,可以把 0.2 提到 0.3 或降到 0.15,但逻辑不变:阈值分割给的是全局对比度,SUSAN 的 t 要的是局部一致性,两者是同一个灰度分布的两种度量,比例约定好了就不用每次手猜。
分水岭那段代码里,0.4 * maxVal是关键:距离变换结果里,越靠近米粒中心值越大,取阈值的 40% 能保证每颗米只保留一个内部种子区。这个比例太高会把小颗粒的种子整个滤掉,太低又会让种子区连成片,切不开粘连。切好的 result 图上每颗米有独立标号,接上第 4 章的统计逻辑就能数出正确颗数。
从那以后,我每次跑 SUSAN 前都强制先走一遍autoT算阈值,再打印直方图确认图像状态,把“看图再调参”固化成了习惯。这套自动估算的代码我也留在了工程里,新图进来先跑一遍,再决定要不要手动干预。希望帮到你。
本文还有配套的精品资源,点击获取