Susan算子边缘检测与数米粒:OpenCV手写实现与避坑指南

发布时间:2026/10/13 13:49:12
Susan算子边缘检测与数米粒:OpenCV手写实现与避坑指南
简介面向计算机视觉初学者与OpenCV入门者这份Visual Studio OpenCV实验资源覆盖了Susan算子边缘检测与米粒图像处理全流程从灰度化、中值滤波、直方图显示到大津法与经验阈值分割、Susan边缘提取再到米粒计数与最大米粒定位适合作为图像处理课程实验或项目起步参考。资源包共40个文件压缩后约1.6MB包含cpp源代码、sln/vcxproj工程配置、exe可执行程序、jpg/bmp中间结果图以及pdb调试与日志文件既能直接编译运行也便于对照代码和输出图像理解各环节算法效果。已有1714人学习使用。文件按实验目录组织调试文件夹与结果图分开存放方便快速定位关键内容通过运行主工程可以依次观察中值滤波、二值化、边缘检测及米粒计数等步骤的中间输出对掌握OpenCV图像处理基础与工程配置方法有较直接的帮助。1. Susan算子边缘检测与数米粒一个实验把图像处理底层逻辑串起来如果把图像处理课设里最容易撞车的两个任务放在一起就是这个标题的典型场景一边用 Visual Studio 配 OpenCV手写 Susan 算子做边缘检测另一边拿同一套环境给一张米粒照片做自动计数。Susan 算子很特别它不碰梯度、不依赖 Sobel 求导纯粹用一个圆形模板在图像里“数和自己灰度相近的邻居有多少”而数米粒看上去更简单二值化加连通域就能交差可真遇到米粒相互粘连连通域计数会直接翻车。这篇实验笔记就是把两件事串成一条完整链路环境怎么配、Susan 怎么写、米粒怎么数、参数怎么调、哪些步骤是血泪坑适合正在做图像处理课设或者想手写传统算子的开发者照着复现一遍。2. Susan 算子怎么检测边缘不靠梯度靠一块圆模板数“同值邻居”2.1 圆形模板与 USAN 面积Susan 衡量的是邻域同质性SUSAN 的全称是 Smallest Univalue Segment Assimilating Nucleus翻译过来就是“最小同值段吸收核”。名字里的 Nucleus 指的是模板中心的那个像素叫“核”Univalue Segment 指模板内与核灰度差值小于某个阈值的像素集合。算法运行时一个圆形模板以每个像素为核滑过整幅图每停一次就统计一次模板内有多个像素和核的灰度“同值”这个数量就是 USAN 面积。可以把这个过程理解成核像素在问周围的邻居“你们谁和我亮度差不多报个数。”平坦区域里整个模板都被同一种灰度覆盖报数最多核刚好骑在边缘上时模板一半在亮区一半在暗区只有一半左右的邻居报数核落在角点附近时同值邻居更少。于是 USAN 面积的大小本身就携带了几何结构信息边缘处面积约是模板像素数的一半角点处更小平坦区域最大。实际判断不需要计算梯度方向只做灰度差绝对值的比较这是它与 Sobel、Canny 最本质的差别。对一幅 8 位灰度图给定核像素灰度 I(r0)模板内任意像素灰度 I(r)当 |I(r) - I(r0)| t 时认为两者同值t 就是亮差阈值。统计出的同值像素个数记为 n边缘响应就由 n 与几何阈值 g 的关系决定n g 时输出响应响应强度通常取 g - n。模板半径 r 一般取 3对应的圆形模板包含约 37 个像素这是 Susan 论文里最常用的配置。半径再大定位变差半径更小又容易被单像素噪声干扰。模板是离散近似的圆不要求是严格的数学圆只要对称、能覆盖核周围一圈即可。2.2 参数 t 和 g一个管“同值”一个管“响应灵敏度”Susan 算子核心参数只有两个亮差阈值 t 和几何阈值 g理解它们的作用比背代码更重要。t 决定什么样的邻居算“同值”t 越大容忍的灰度差越大USAN 面积偏大边缘响应更稳定但对细节不敏感t 越小边缘定位更细可噪声点也更容易被当成边缘。对普通室内光照的 8 位灰度图t 落在 10 到 27 之间比较常见低对比度图像取小值强噪声图像取大值。g 决定边缘响应的敏感度。模板像素总数记为 n_max对 r3 的模板约是 37。平坦区域 n 接近 37边缘上 n 约为 18 到 20角点处 n 往往小于 10。把 g 设为 0.75 倍的 n_max能稳定响应边缘位置把 g 降到 0.5 倍的 n_max响应点会明显变密噪声更容易被激活反过来把 g 调高到接近 n_max边缘响应会稀疏很多甚至断裂。做边缘检测时我习惯默认取 g 0.75 * n_max角点检测才用更小的 g。参数调大的效果调小的效果t亮差阈值USAN 偏大边缘稳定但变粗定位模糊边缘偏细偏碎对噪声敏感g几何阈值响应更稀疏只留 USAN 面积很小的点响应更密集噪声点也容易被保留这两个参数是相互配合的关系不是独立调优。t 已经很大时USAN 面积普遍偏大g 如果还取 0.75 的 n_max响应点会偏少t 很小而 g 也小整张图会变成密密麻麻的噪声点。实际调试顺序是先固定 t在 0.5 到 0.85 倍的 n_max 之间扫 g观察边缘连续性再回头调 t 控制噪声。2.3 为什么这个实验值得手写一遍 Susan而不是直接用 CannyOpenCV 里已经封装好了 Canny两行代码就能出边缘图但 Canny 对梯度的依赖在特定场景里很吃亏低对比度区域梯度弱双阈值设小了噪声全出来设大了边缘断裂。Susan 不计算梯度它衡量的是区域同质性对梯度不强的边缘反而稳定这是它至今还在工业视觉里被用来做某些特定检测的原因。另外OpenCV 并没有内置 Susan 算子想用只能自己实现这就逼着你去理解模板遍历、灰度比较、阈值设计这些细节。相比直接调用 cv::Canny手写一遍 Susan 能体会到的坑完全不同输出响应是浮点值显示前要归一化边缘是粗块要细化三层循环很慢要优化。这些经验对后面做任何传统算子都有迁移价值。对比项CannySusan核心原理Sobel 梯度 双阈值滞后圆形模板同值像素计数OpenCV 内建有没有需要手写边缘宽度非极大值抑制输出细线原始响应偏粗需自行细化参数语义双阈值、高斯 sigmat 和 g直观可解释低对比度表现阈值难调容易断边区域同质性相对稳3. Visual Studio OpenCV先把最小闭环跑通再进算法3.1 三处配置不能错包含目录、库目录、附加依赖项很多人写传统图像处理算法算法本身没写几行时间全耗在环境配置上。Visual Studio 配 OpenCV 最容易出问题的就是三处VC 目录里的包含目录、库目录以及链接器输入里的附加依赖项。包含目录要指向 OpenCV 头文件所在路径通常在 opencv 解压目录下的 build/include库目录指向 build/x64/vc16/lib 这类路径取决于你装的是哪个版本附加依赖项填的是具体的 lib 文件名。配置路径在项目属性页里依次展开VC 目录 - 包含目录VC 目录 - 库目录链接器 - 输入 - 附加依赖项。一个常见的低级错误是只配了包含目录没配库目录编译能过链接直接报 unresolved external symbol或者反过来。三个配置必须同时生效少一个都不行。配置项填写内容说明包含目录opencv\build\include让编译器找到 opencv2/opencv.hpp库目录opencv\build\x64\vc16\lib按实际 x64 与 vc 版本调整附加依赖项opencv_world4xx.libDebug 改为带 d 的 libDebug 配置下附加依赖项要填带 d 的版本比如 opencv_world460d.libRelease 填不带 d 的版本。Windows 下缺失这个区分运行时大概率弹窗报“找不到 opencv_world460d.dll”因为 Debug 程序会优先去加载带调试符号的 DLL。建议在项目属性里给 Debug 和 Release 分别配置不要图省事只填一个 Release 版本。3.2 验证环境的十行代码能出图就算配成环境配得好不好不要一上来就跑完整的 Susan 算法写一个最小读图程序先验证链路。下面这段代码能读一张灰度图并弹窗显示任何一步出问题都能立刻定位是环境问题还是代码问题。#include opencv2/opencv.hpp #include iostream int main() { cv::Mat img cv::imread(rice.bmp, cv::IMREAD_GRAYSCALE); if (img.empty()) { std::cout image not found std::endl; return -1; } cv::imshow(test, img); cv::waitKey(0); return 0; }imread 第二个参数 IMREAD_GRAYSCALE 表示强制以单通道灰度读入Susan 算子和后面的米粒计数都基于单通道图像在入口处就把通道数统一掉能省很多麻烦。img.empty() 检查不能省路径写错或者文件名不对时 OpenCV 不会抛异常而是返回一张空 Mat直接往下处理会出现访问越界。如果这段代码能弹窗显示图片环境就算基本通了接下来才值得去写算法。运行前记得把 OpenCV 的 bin 目录加到系统 PATH或者每次运行前把需要的 DLL 复制到 exe 同目录否则程序在启动阶段就会因为找不到 DLL 直接闪退。注意区分包含目录和库目录是给编译器、链接器用的PATH 是给程序运行时加载 DLL 用的两者解决的是不同阶段的问题。3.3 x64 还是 x86平台和库目录必须对齐Visual Studio 新建项目默认可能是 Win32 也就是 x86 平台而下载的 OpenCV Windows 包通常只带 x64 版本库平台对不上就会报“无法解析的外部符号”之类的链接错误。把项目配置管理器里的活动解决方案平台切到 x64再确认配置管理器里当前项目的平台也是 x64不能只看界面上写的“Debug”要看整个解决方案的平台。我习惯的做法是新建项目之后第一件事就是切 x64而不是等报错再回头改。切换后重新打开项目属性页检查库目录是否还在生效因为有些属性页设置会按平台分开保存Win32 和 x64 各有一份独立配置只配了其中一个平台另一个平台编译时依然报找不到头文件或库文件。排查顺序也很固定先看编译阶段报不报头文件缺失再看到链接阶段报不报 unresolved最后看运行阶段弹不弹 DLL 缺失窗口。三个阶段对应三种原因编译挂了优先查包含目录和平台位数链接挂了优先查库目录和附加依赖项运行闪退优先查 PATH 和 DLL 版本按这个顺序排查远比乱改配置高效。4. 手写 Susan 并完成数米粒核心代码与参数设置4.1 自己写 susanEdgeDetect 函数模板生成、遍历、响应计算有了环境下一步就是把 Susan 算子从论文描述变成可运行的 C 代码。核心步骤分四块生成圆形掩码、遍历图像像素、统计 USAN 面积、按几何阈值输出响应。下面的函数用半径 r3 的模板返回一张 8 位灰度图响应越强的点像素值越高。void susanEdgeDetect(const cv::Mat src, cv::Mat dst, int r 3, int t 15, int g 0) { CV_Assert(src.type() CV_8UC1); // 必须是单通道灰度图 // 1. 生成半径 r 的圆形掩码 cv::Mat mask cv::Mat::zeros(cv::Size(2 * r 1, 2 * r 1), CV_8U); int max_n 0; for (int y 0; y mask.rows; y) { for (int x 0; x mask.cols; x) { int dx x - r, dy y - r; if (dx * dx dy * dy r * r r) { // 离散近似圆 mask.atuchar(y, x) 1; max_n; } } } // 2. 几何阈值 g 默认取 0.75 * max_n if (g 0) g (int)(max_n * 0.75); // 3. 遍历图像逐像素统计 USAN 面积 dst cv::Mat::zeros(src.size(), CV_32F); for (int y r; y src.rows - r; y) { for (int x r; x src.cols - r; x) { int nucleus src.atuchar(y, x); // 核像素灰度 int n 0; for (int dy -r; dy r; dy) { for (int dx -r; dx r; dx) { if (!mask.atuchar(dy r, dx r)) continue; int diff std::abs(src.atuchar(y dy, x dx) - nucleus); if (diff t) n; // 同值像素计数 } } if (n g) dst.atfloat(y, x) (float)(g - n); // 响应值 } } // 4. 归一化到 0~255 并转成 8 位图方便显示 cv::normalize(dst, dst, 0, 255, cv::NORM_MINMAX); dst.convertTo(dst, CV_8U); }生成掩码时 dx * dx dy * dy r * r r 这个条件是一个离散圆近似比严格的圆半径判断多留一圈像素避免圆形模板过小让边缘响应断裂。max_n 在掩码生成过程中同步统计不写死这样换半径时几何阈值自动跟着变化。遍历时从 r 行 r 列开始到 rows-r、cols-r 结束跳过边界区域边界像素的模板会越界不做特殊处理会读到野数据。响应值存的是 g - nn 越小响应越强角点位置的响应值会明显高于普通边缘点。最后一步 normalize 非常重要因为 g - n 的绝对值很小如果不归一化imshow 会显示成一片黑。convertTo 转成 CV_8U 后可以直接参与显示和后续叠加。4.2 数米粒流程二值化、形态学、连通域计数数米粒本质是图像分割加连通域统计。先对灰度图做中值滤波去掉米粒表面的纹路再用 Otsu 自动阈值做二值化然后开运算断开细小的粘连最后通过 findContours 找外轮廓并按面积过滤噪声。下面函数返回米粒数量同时把每粒米的质心坐标输出到 centers 向量。int countRice(const cv::Mat gray, std::vectorcv::Point centers) { cv::Mat blurred, bin; cv::medianBlur(gray, blurred, 5); // 中值滤波抑制表面纹理 double th cv::threshold(blurred, bin, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU); // 如果米粒比背景暗改用 THRESH_BINARY_INV让米粒变成白色前景 cv::Mat kernel cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(3, 3)); cv::morphologyEx(bin, bin, cv::MORPH_OPEN, kernel); // 开运算去细线噪声 std::vectorstd::vectorcv::Point contours; cv::findContours(bin, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); const double min_area 30.0; // 面积过滤阈值按图像分辨率调整 int count 0; for (auto c : contours) { double area cv::contourArea(c); if (area min_area) continue; // 丢掉小噪点 count; cv::Moments m cv::moments(c); centers.emplace_back((int)(m.m10 / m.m00), (int)(m.m01 / m.m00)); } return count; }Otsu 阈值本来就是为了这类背景和前景分离明显的图像设计的固定阈值的麻烦在于光照一变就要手动改Otsu 能自动算出一个分割阈值省事很多。注意 threshold 在高版本 OpenCV 里返回的是实际使用的阈值存到 th 里可用于调试打印确认阈值是否符合预期。RETR_EXTERNAL 只取最外层轮廓米粒内部如果有纹路也不会被当成新的目标CHAIN_APPROX_SIMPLE 压缩轮廓点减少后续 contourArea 的计算量。开运算是先腐蚀后膨胀作用是把二值图里细小的连接线和孤立点去掉。米粒边缘如果有一点轻微连在一起开运算会把连接处腐蚀掉。min_area 30 这个值不是万能的它取决于图像分辨率图片越大单粒米面积越大这个阈值要按比例调。判断依据很简单找一张只有一颗米且没有噪声的图量出单粒米轮廓面积把阈值设成这个面积的十分之一以下。质心计算用的图像矩m10 / m00 是质心的 x 坐标m01 / m00 是 y 坐标。这个值可以直接用来在图上画圆标记比用最小外接矩形的中心更贴近米粒实际位置特别对长条形的米粒。4.3 用一个 main 跑完整实验先看 Susan 边缘再输米粒计数两个核心函数写完后在 main 里把它们串起来读图分别调用 Susan 和计数函数展示标注结果这样一张图既能看边缘效果又能确认米粒有没有漏检多检。下面的 main 代码可以直接替换 3.2 节的测试程序。#include opencv2/opencv.hpp #include iostream #include vector // susanEdgeDetect 和 countRice 函数定义放在这里 int main() { cv::Mat img cv::imread(rice.bmp, cv::IMREAD_GRAYSCALE); if (img.empty()) { std::cout load image failed std::endl; return -1; } cv::Mat edge; susanEdgeDetect(img, edge, 3, 15, 0); // 半径 3t15g 默认 cv::imshow(susan edge, edge); std::vectorcv::Point centers; int count countRice(img, centers); std::cout rice count count std::endl; cv::Mat color; cv::cvtColor(img, color, cv::COLOR_GRAY2BGR); for (cv::Point p : centers) { cv::circle(color, p, 4, cv::Scalar(0, 0, 255), -1); // 红圈标记质心 } cv::imshow(rice count, color); cv::waitKey(0); return 0; }这个 main 的设计思路是把“边缘检测”和“目标计数”两个任务并排展示便于对照。如果 Susan 边缘图和计数标记图同时看都没问题说明整条实验链路打通。数米粒本身并不依赖 Susan 的输出两件事是独立实验但共用同一个环境同一张输入图正好验证不同算法在统一接口下的协作方式。cv::Scalar(0, 0, 255) 是 BGR 顺序的红色画在米粒质心上一眼能看出计数结果是否合理。5. 避坑四类常见翻车与排查顺序5.1 现象imshow 全白或全黑看不到边缘第一次跑完 Susan 代码最常见的结果是窗口里一片白或者一片黑看起来像算法完全失败了。实际算法可能没问题问题出在显示方式上响应图是 CV_32F 浮点型值域是 0 到 g 之间的小数imshow 对浮点图默认按 0 到 1 范围显示大部分值超过 1 就全映射成白色反过来如果不做任何处理直接 convertTo 成 8 位小数值截断成 0又变成全黑。原因定位没有对响应图做归一化就直接显示。解决响应图上调用 normalize(dst, dst, 0, 255, NORM_MINMAX) 把最小值映射到 0、最大值映射到 255再 convertTo 成 CV_8U。这是 Susan 手写实现最容易踩的坑因为 n g 时输出 g - n最大值再大也就二十几远小于 255必须人工拉伸到整个灰度范围。5.2 现象Debug 能跑 Release 崩或者反过来代码一模一样Debug 编译运行正常切到 Release 突然启动崩溃弹窗提示找不到 opencv_world4xx.dll 之类的错误。反过来也有Release 好好的Debug 一跑就报找不到 opencv_world4xxd.dll。原因就一个附加依赖项里的 lib 是固定的没跟着配置切换Debug 加载带 d 的调试库Release 加载不带 d 的发布库两者混用必然出问题。解决在项目属性里分别配置 Debug 和 Release 两个配置的附加依赖项Debug 填 opencv_world4xxd.libRelease 填 opencv_world4xx.lib。另外检查 PATH 里是否同时存在两份 OpenCV 的 bin 目录如果系统环境变量把多个版本的 bin 都加进去了运行时加载哪个 DLL 凭运气这种冲突很难排查干脆只保留一份。5.3 现象米粒粘连导致计数严重偏少数米粒的经典翻车场景是统计结果明显少于实际数量数出来 15 粒肉眼数有 20 粒。先在图上画质心标记会发现被漏掉的米粒通常不是单独一粒而是两三粒粘在一起被当成了一个连通域质心落在了中间位置标记圆画在粘连区域上一眼就能看出来。原因二值化后米粒之间的灰度差不够边缘轻微连在一起没有断开。解决先加重开运算把核尺寸从 3x3 提到 5x5多数轻微粘连能断开还不行就做一次腐蚀再重新找轮廓对严重粘连的图简单的形态学已经不够需要走距离变换加水岭分割这个放到后面进阶章节里处理。计数偏少时不要急着调连通域逻辑先看二值化中间形态是不是连成一片。5.4 现象二值化后黑白颠倒计数出来的全是背景碎片用 Otsu 自动阈值做二值化假设的是前景和背景的亮度关系是确定的。如果图像里米粒是深色、背景是浅色THRESH_BINARY 会把背景变成白色、米粒变成黑色findContours 数出来的就全是背景块。有时候结果看起来也像“有东西”但画上质心后会看到标记圈全落在背景噪声上大小也千奇百怪。原因THRESH_BINARY 与 THRESH_BINARY_INV 的选择和实际前景背景关系不匹配。解决先 imshow 看一眼二值图确认视觉上目标物体是白色周围是黑色如果反了把 threshold 的标志改成 THRESH_BINARY_INV。判断逻辑其实很简单你要计数的物体必须在前景里前景在二值图里是白色findContours 找的才是它。6. 进阶给 Susan 边缘做细化再用分水岭拆粘连米粒6.1 对 Susan 响应图做一次非极大值抑制边缘从“块”变“线”Susan 原始响应是粗的条带看起来像一块一块的白斑直接拿去做后续定位不够精确。常见的做法是对归一化后的响应图做非极大值抑制只在 3x3 邻域内是最大值的点才保留把条带压成细线。cv::Mat thin cv::Mat::zeros(edge.size(), CV_8U); for (int y 1; y edge.rows - 1; y) { for (int x 1; x edge.cols - 1; x) { uchar v edge.atuchar(y, x); if (v 0) continue; bool isMax true; for (int dy -1; dy 1 isMax; dy) for (int dx -1; dx 1 isMax; dx) { if (dy 0 dx 0) continue; if (edge.atuchar(y dy, x dx) v) isMax false; } if (isMax) thin.atuchar(y, x) 255; } }这段代码保留的是严格大于的判断所以一条粗边中间会产生一条单像素亮线不会因为邻域里有相等值而全灭。代价是计算量再翻一倍但做完之后的边缘图干净不少。我现在的习惯是出来一张响应图先看数据范围再决定要不要上 NMS不要一上来就默认细化。6.2 粘连米粒的分水岭分离距离变换加 markers对于形态学断不开的粘连米粒分水岭是更可靠的选择。流程固定二值图上做距离变换得到每个前景像素到背景的距离距离图的局部极大值作为米粒的初始标记然后调用 watershed 对原图做分水岭分割每个标记对应一个独立米粒区域。watershed 需要传入三通道图作为 markers先生成标记再在原图上执行跑完用分水岭边界把粘连区域切开。做这类实验我的个人习惯是每一步都存一张中间结果图二值图、开运算后、距离变换、标记图、分水岭结果出了问题能直接回溯到具体步骤。靠肉眼记状态调试传统图像处理流程基本等于开盲盒一张张中间图看过去比闷头调参快得多。希望帮到你。本文还有配套的精品资源点击获取