ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# OpenCVSharp圆形检测实战:霍夫圆算法、边缘检测与亚像素精修

C# OpenCVSharp圆形检测实战:霍夫圆算法、边缘检测与亚像素精修

有段时间我的工作台上摆着一批金属垫片,项目要求很简单:在来料图像里快速找到每个垫片的圆心,把圆心坐标和半径发给后续机械臂。听着不难,真正开始做才发现,圆这种东西在照片里非常容易变形——光照一变,边缘就有一截消失了;分辨率一换,以前调好的参数立刻罢工。我最终用C#写了一套带自定义控件的检测工具,底层图像处理交给OpenCVSharp,界面、参数调节和检测逻辑都封装在控件里,能比较稳定地解决“快速精确找出圆的位置”这类问题。这篇文章就把这套方案的选型、图像预处理、霍夫圆参数调试、控件封装和精度优化完整记录下来,适合正在做视觉定位、尺寸测量的C#开发者参考。

1. 不急着写代码:先确定为什么在C#里做圆形检测

1.1 圆形检测在哪一类项目里最有价值

先说结论:找圆这件事,本质上是给后续的定位或测量提供一个稳定的几何锚点。工业生产里最常见的三类需求是:视觉引导抓取、尺寸测量、数量统计。

视觉引导抓取会把圆孔、圆形零件或圆形标记当作坐标基准,要求圆心位置尽可能准确;尺寸测量关心的是半径或者直径,精度往往要逼近亚像素;数量统计则更看重查全率,少检一个比错检一个更严重。这三种场景对“快”和“精确”的定义并不完全一样,但共同点是:算法不能只在一张完美图片上跑通,必须在光照变化、分辨率变化、噪声干扰下都表现稳定。

我选择C#而不是C++,主要原因是整个上位机软件体系都在.NET上,相机采集、PLC通信、数据上报都需要C#来完成。把视觉检测直接做进主程序里,维护成本最低。如果你只是离线跑一批图片,Python加OpenCV可能更灵活;但一旦涉及自绘控件、多线程界面、与硬件设备交互,C#的工程化优势会非常明显。

1.2 OpenCVSharp与Emgu CV、纯自研方案的取舍

在C#里用OpenCV,基本就是两条路:Emgu CV和OpenCVSharp。我也见过一些人选择完全自研圆形检测算法,下面把这三条路放在一起看。

对比维度Emgu CVOpenCVSharp纯自研
API风格封装较厚,需要适应它的Warp类结构和原生OpenCV接口接近,用起来更直接完全自己定义,没有束缚
文档与示例老牌方案,资料较多但不少已过时示例相对少,但API直观,翻原生文档就行没有现成资料
NuGet/部署依赖项多,版本兼容问题偶尔会遇到包结构清晰,x86/x64运行时选择简单无外部依赖
算法鲁棒性依赖OpenCV底层,很成熟同样依赖OpenCV底层,很成熟需要自己处理噪声、光照、断边等所有问题

实际体验下来,OpenCVSharp的API手感更接近OpenCV官方,写C#代码时不需要在脑海里做一层“翻译”。比如Cv2.HoughCircles这个函数的参数顺序、返回值都和OpenCV保持一致,我直接对着OpenCV官方文档也能写出C#代码,这一点对效率提升非常关键。

纯自研我劝你慎重。倒不是数学上做不出圆的拟合,而是真实图像里的噪声、反光、遮挡会让纯几何方法变得极其脆弱。霍夫变换这类投票方法之所以被广泛使用,正是因为它在边缘不完整、存在噪声时依然能通过全局信息找到圆,这背后是概率和统计的思想,自己从头实现而且要做得稳,工程量远超想象。

1.3 为什么不能只靠“边缘点解方程”

有人会问:既然圆的数学方程这么简单,把边缘点提取出来,用最小二乘法拟合圆不就行了吗?这个思路本身没错,但它有两个前提:边缘点必须全部属于同一个圆,并且不能有太多离群点。实际图像里,一条直线、一个角点、一处反光都会产生边缘点,这些点只要有一小部分混进去,拟合结果就会偏。

我更愿意把霍夫圆变换理解为一种“选举机制”:每个边缘点都投出自己的一票,支持某些候选圆心和半径组合,最终票数最高的组合胜出。单个点可能因为噪声投出错误的一票,但当几十个点都指向同一个圆心时,这个圆就很可能是真实存在的。这种鲁棒性,是直接解方程很难替代的。

2. 预处理链路才是精度的地基:灰度、模糊、边缘

2.1 灰度化与高斯模糊:删掉的信息不影响找圆

很多人在预处理阶段很随意,觉得随便转个灰度就能跑霍夫圆。实际上预处理直接决定了后续投票质量。

第一步是灰度化。OpenCV的HoughCircles要求输入单通道8位灰度图,颜色信息在几何检测里几乎没有价值。一个红圆和一个蓝圆在灰度图里可能灰度值接近,但在彩色空间里差异巨大;反过来,颜色的噪声却会影响边缘提取。所以第一步就是Cv2.CvtColor或直接用ImreadModes.Grayscale读取。

第二步是高斯模糊。模糊的目的是降低图像噪声,避免Canny算子提出一堆细碎的假边缘。高斯模糊有两个参数:核大小和标准差σ。核越大,图像越平滑,但边缘位置也会被推移;σ控制着平滑强度。

我常用的起点是GaussianBlur的核大小(5, 5),σ取1.5。这个组合对一般的零件图、PCB图、纸盒图都够用。如果图像分辨率特别高、目标圆很小,我会降级到(3, 3)加σ1.0;如果噪声明显,则用(7, 7)加σ2.0。

using OpenCvSharp; Mat src = new Mat(imagePath, ImreadModes.Grayscale); Mat blurred = new Mat(); Cv2.GaussianBlur(src, blurred, new Size(5, 5), 1.5);

2.2 Canny参数与霍夫圆的微妙关系

说到边缘检测,有个关键知识点很多人会搞混:HoughCircles并不是直接把Canny边缘图作为输入,而是内部使用梯度信息进行投票。param1这个参数实际上就是传给Canny的高阈值,低阈值默认取其一半。

这意味着,当我设置param1 = 100时,它等效于对模糊后的图像做了一次Canny(50, 100)。如果你想直观看到哪些边缘点参与了霍夫投票,可以先单独跑一次Canny,把边缘图显示出来。如果边缘图上圆的轮廓断断续续、或者目标圆的边缘淹没在大量杂边中,那霍夫圆的结果大概率也会很差。

务必要注意:不要把Canny边缘图直接传给HoughCircles。HoughGradient方法依赖的是原始灰度梯度方向和幅值,边缘图已经丢失了部分梯度方向信息,直接传边缘图会导致检测结果非常奇怪。我在早期项目里犯过这个错误,换成灰度图后一切正常。

Mat edges = new Mat(); Cv2.Canny(blurred, edges, 50, 150); // edges 只是用来“看”,不是用来传进 HoughCircles

2.3 一套可复制的预处理参数起点

如果你是第一次在项目里跑圆检测,我建议用下面的顺序快速把流程跑通:读取灰度图 → 高斯模糊 → 显示边缘图确认圆轮廓清晰 → 调节模糊参数直到边缘图干净 → 再进HoughCircles。

以下是不同场景的推荐起点:

场景核大小σ备注
一般金属零件、垫片5x51.5最常见起点
噪声较强、图像较暗7x72.0先平滑,再谈精度
高清图中很小的圆3x31.0核太大容易把小圆抹掉
圆边缘与背景对比度低3x30.8~1.0配合对比度拉伸效果更好

预处理不是越狠越好。模糊太强会把圆的边缘位置“推”向内部,导致半径系统性偏小。我见过有人为了压噪声把核调到15x15,结果得到的所有半径都小了3个像素。预处理的核心目标只有一个:让圆的边缘在Canny结果里连续、干净、位置准确。

3. 霍夫圆变换:投票的整个过程与六个参数

3.1 三维参数空间的投票如何变成坐标

霍夫圆变换的原理,一句话可以概括:在(x, y, r)的三维空间里做投票。每个边缘点都有属于自己的梯度方向,而真实圆的圆心一定在该点沿法线方向的某个位置。于是,这个边缘点会对法线上的每一个候选圆心和半径组合分别投一票。

当图像里有几十个边缘点都指向同一个圆心(cx, cy)、同一个半径R时,这个三维单元的票数就会很高。param2这个参数就是“多少票才算真圆”的门槛。票数越高,找到的圆越可靠;门槛设得太低,背景里稍微圆一点的纹理也会被当成圆。

这里有一个很容易理解偏差的地方:HoughCircles并不直接使用传统的二维霍夫圆变换,而是采用HoughGradient方法,它先用Canny找到边缘点,再基于梯度方向进行投票。相比标准霍夫圆变换对每个边缘点遍历所有半径计算所有圆心,这种方法大大减少了计算量,这也是它能够在工业现场做到“快速”的重要原因。

3.2 六个参数怎么排优先级

我调试HoughCircles参数时有个固定顺序,可以帮你少走很多弯路:

1. 先确定 minRadius / maxRadius 2. 再确定 minDist 3. dp 用 1 或 1.5 4. param1 从 100 起步 5. param2 从大往小调 6. 最后回头微调模糊参数

3.2 六个参数怎么排优先级

先设半径范围。这一步几乎决定了检测的搜索区间,半径范围设置得越窄,误检越少,速度越快。比如目标圆直径是12mm,像素当量0.05mm/pixel,那半径大约120像素,minRadius可以设100,maxRadius设140。不要设成10到1000,那样不仅慢,而且更容易凑出假圆。

minDist是圆心之间的最小距离。如果图像里是单圆检测,这个值可以设成半径范围的上限;如果是有多个圆,且圆间距均匀,可以设成目标直径的1.0到1.5倍。dp是累加器分辨率与图像分辨率的反比,dp=1表示累加器和原图一样大,精度最高,速度最慢;dp=1.5表示累加器按比例缩小,速度快一些,精度略降。

param1是Canny高阈值,前面说过,它决定参与投票的边缘数量。param2是核心中的核心,也是我和同事调试时花费最多时间的地方。通常从50开始往下试,如果屏幕上出现一堆假圆,就往上调到80、100;如果真实圆没被检测出来,就往下降。

CircleSegment[] circles = Cv2.HoughCircles( blurred, HoughMethods.HoughGradient, dp: 1.5, minDist: 60, param1: 100, param2: 40, minRadius: 25, maxRadius: 60 );

3.3 调了很久还是误检,检查这三件事

如果你发现参数调了很久,假圆还是压不下去,先别急着继续猛调param2。我一般会按顺序查三件事:

第一,重新看一眼Canny边缘图。如果圆边缘断裂严重,霍夫投票的票数自然上不去,真实圆会被漏检;如果背景纹理的边缘和圆边缘一样密集,即使真圆检出来了,也会伴随一堆假圆。

第二,确认半径范围是不是太宽。我曾经在一个项目里把半径范围设成15到200像素,原因只是“懒得量准”,结果连续出现了很多票数很高的假圆。后来把范围缩到58到72像素,误检立刻消失。

第三,检查光照均匀性。如果圆边缘一半亮一半暗,Canny只能提取出亮的那半边,霍夫投票就只能画出半个弓形,票数不够会被漏检;而暗处的背景纹理反而可能形成伪圆。这种情况应该先解决打光,而不是纯调算法。

4. 从算法到控件:让检测结果能在界面上稳定显示

4.1 控件应该管什么、不该管什么

有了算法以后,下一步是把它变成界面里一个顺手可用的工具。我做的自定义控件叫CircleDetectControl,继承自UserControl,内部放一个PictureBox用于显示图像,右侧或下方放参数面板。

控件最重要的设计原则是:控件只管显示和交互,检测逻辑放在独立的CircleDetector类里。这样做的原因是,算法逻辑可以被单元测试、被命令行调用、被服务化,而不依赖任何界面元素。

public class CircleDetector { public DetectionOptions Options { get; set; } = new DetectionOptions(); public List<CircleSegment> Detect(Mat image) { using Mat blurred = new Mat(); Cv2.GaussianBlur(image, blurred, new Size(5, 5), 1.5); CircleSegment[] circles = Cv2.HoughCircles( blurred, HoughMethods.HoughGradient, Options.Dp, Options.MinDist, Options.Param1, Options.Param2, Options.MinRadius, Options.MaxRadius); return circles.ToList(); } }

DetectionOptions是一个专门容纳霍夫参数的配置类,控件、配置文件、测试代码都可以操作它。很多初学者喜欢在控件里直接写HoughCircles调用,看起来方便,但项目一复杂就会想重构。

4.2 多线程与UI更新:检测不能卡窗口

图像检测是计算密集型操作,如果直接在UI线程里跑,拖动窗口时会发现界面卡成幻灯片。正确做法是把检测放到后台线程,拿到结果后再用BeginInvoke回到UI线程绘制。

private async void OnFrameCaptured(Mat frame) { List<CircleSegment> result = await Task.Run(() => _detector.Detect(frame)); BeginInvoke(new Action(() => { pictureBox.Image?.Dispose(); pictureBox.Image = BuildResultImage(frame, result); OnCircleDetected?.Invoke(this, result); })); }

有个细节值得注意:Mat对象不是线程安全的。如果你采集帧的线程和检测线程同时在操作同一个Mat,可能出现内存访问错误。稳妥的办法是采集线程拿到帧后,立刻Clone()出一个新对象给检测线程,或者用一个带锁的队列来传递帧引用。不要抱着侥幸心理去共用同一个Mat。

另外,结果图像需要复制一张干净的背景再画圆。千万不要在原始Mat上直接画,否则下一帧还没开始检测,原始图已经被上一帧的画圆结果污染了。

4.3 把参数面板做成实时可调

开发阶段最刚需的功能,是拖动滑块就能看到检测结果变化。我的参数面板里有6个滑块,分别对应dp、minDist、param1、param2、minRadius、maxRadius,并在旁边显示当前数值和检测耗时。

滑块联动时要注意防抖。TrackBar.ValueChanged在拖动过程中会高频触发,如果每次都执行一次完整检测,界面依然会卡。我的做法是:滑块触发后启动一个150ms的防抖定时器,定时器到期才真正执行检测。

private void OnParamChanged(object sender, EventArgs e) { _debounceTimer.Stop(); _debounceTimer.Start(); // 150ms 后执行检测 }

等参数调好后,再把这些值写进配置文件。生产模式下我建议锁定参数面板,只保留检测结果展示,避免现场人员随手把滑块调坏。这是一个很现实的问题,后面我会再展开讲。

5. 当霍夫圆精度不够时:亚像素精修与最小二乘拟合

5.1 霍夫圆在工业场景下的精度上限

霍夫圆变换的圆心坐标和半径,本质上都来自累加器空间的离散单元。dp=1的时候,累加器中心和半径的步长约为1像素;dp=1.5时步长接近1.5像素。对一个需要把精度控制在0.1像素以内的视觉项目来说,直接输出霍夫圆结果通常是不够的。

更麻烦的是,霍夫圆对半径的量化比较粗。如果真实半径是62.4像素,霍夫圆很可能给出62或63;而圆心也可能偏移0.3个像素。这些误差在单次测量里可能不明显,但当你计算两个圆心之间的距离、或者把半径换算成毫米时,误差会被放大。

所以我的处理链路从来都是两段式:霍夫圆负责粗定位,圈定一个大致区域;精修阶段在霍夫圆结果附近,基于原始边缘点做更精确的拟合。

5.2 用轮廓点做圆拟合的工程实现

精修的核心思路是:在霍夫圆给出的圆心和半径附近,提取属于这个圆的边缘点,然后用最小二乘法拟合出更精确的圆心和半径。

具体步骤:

  1. 以霍夫圆的圆心为中心,取一个比检测半径大6到10像素的ROI区域;
  2. 在这个ROI内做Canny边缘提取,得到轮廓点;
  3. 对每个轮廓点计算到霍夫圆圆心的距离,把距离超出合理范围的离群点剔除;
  4. 对剩下的点做代数圆拟合。

最小二乘圆拟合最常用的方法是代数拟合法,目标是最小化所有点到拟合圆距离的平方和。网上有很多C#实现,下面是我常用的一个简化版本:

public static (Point2f center, float radius) FitCircle(List<Point2f> points) { int n = points.Count; if (n < 3) throw new ArgumentException("至少需要3个点"); double sumX = 0, sumY = 0, sumX2 = 0, sumY2 = 0; double sumX3 = 0, sumY3 = 0, sumXY = 0, sumX2Y = 0, sumXY2 = 0; foreach (var p in points) { double x = p.X, y = p.Y; double x2 = x * x, y2 = y * y; sumX += x; sumY += y; sumX2 += x2; sumY2 += y2; sumXY += x * y; sumX3 += x2 * x; sumY3 += y2 * y; sumX2Y += x2 * y; sumXY2 += x * y2; } double C = n * sumX2 - sumX * sumX; double D = n * sumXY - sumX * sumY; double E = n * sumY2 - sumY * sumY; double G = 0.5 * (n * sumX3 + n * sumXY2 - (sumX2 + sumY2) * sumX); double H = 0.5 * (n * sumY3 + n * sumX2Y - (sumX2 + sumY2) * sumY); double a = (D * H - E * G) / (C * E - D * D); double b = (C * H - D * G) / (D * D - C * E); double c = -(a * sumX + b * sumY + sumX2 + sumY2) / n; Point2f center = new Point2f((float)(-a / 2), (float)(-b / 2)); float radius = (float)Math.Sqrt(a * a / 4 + b * b / 4 - c); return (center, radius); }

这个公式如果你要直接用在项目里,一定要先用仿真圆做一次验证。我建议画一个已知圆心和半径的圆,叠加一点噪声,然后对比拟合结果和真值,确认公式系数没有方向性问题,再投入实际图像。

这里还藏着一个非常实用的技巧:拟合完第一轮以后,把每个点到拟合圆心的距离重新算一遍,剔除掉距离超过1.5像素或2像素的点,再做一次拟合。这个“拟合-剔除-再拟合”的过程,对消除反光边缘点的影响特别有效。

5.3 一组实测对比数据

我在仿真图上做了三组对比,图像大小1024x1024,真圆半径128像素,叠加了高斯噪声和少量离群点,检测结果如下:

方法圆心误差半径误差耗时
霍夫圆直接输出0.42像素0.51像素约18ms
霍夫圆+ROI切边直接平均0.25像素0.38像素约22ms
霍夫圆+最小二乘精修+剔除0.08像素0.11像素约24ms

可以看到,霍夫圆阶段的定位精度已经不错,但半径误差接近0.5像素;加上最小二乘精修之后,半径误差降到了0.1像素级别,而耗时增加非常有限。这就是为什么工业上很少直接拿霍夫圆的原始结果作为最终测量值。

6. 从理论到现场:我踩过的坑和排查链路

6.1 换分辨率后参数全部失效的完整排查

有一次我在实验室里用640x480的分辨率调好了一套参数,圆检测又快又准。结果把相机分辨率改成1280x960之后,居然一个圆都找不到。第一反应是算法出问题了,后来排查才发现根本不是算法的问题。

问题的根源是半径范围。在640x480下,目标圆半径是30像素,minRadius/MaxRadius是20/45;但同样物理大小的圆在1280x960下变成60像素半径,原来的范围上限45像素连边都摸不到,自然检测不到任何圆。minDist也存在同样的问题,需要按比例缩放。

这个坑的排查链路其实非常清晰:先确认图像分辨率是否变化,再把半径范围按分辨率比例重新换算,最后检查minDist。从那以后,我把半径范围的定义全部改成了基于像素当量自动计算,比如“半径=物理半径/像素当量”,而不是在界面上写死数字。

6.2 反光把圆“吃”掉一半时怎么办

金属零件的边缘在有反光时,会形成一段高光区域,这段区域的图像梯度方向会混乱,Canny边缘断成好几段。霍夫圆投票的票数不够,只能检测出残缺的弓形。

我的处理思路是分两线作战。一线是物理层,增加低角度光源或者偏振片,尽量消除高光;二线是算法层,把param2适当降低,允许弧长不足的圆也能被接受,然后依靠最小二乘精修用残缺弧段拟合出完整圆。

这里要强调:打光问题不能全靠算法兜底。算法可以容忍一定程度的反光,但不可能无中生有地把完全丢失的边缘信息恢复出来。遇到这种问题,先拍几张不同角度光源的照片看看边缘是否有改善,再决定要不要调参数。

6.3 背景里的圆和圆相似物混在一起

还有一种情况比反光更让人头疼:图像里除了目标圆,还有一堆螺丝孔、定位销、装饰圆。霍夫圆可能全部找出来,但程序分不清哪个才是目标。

我的解决办法是组合约束:先通过minRadius/maxRadius限制半径范围,再用ROI把搜索区域限定在目标附近,最后用轮廓面积和圆度做一级过滤。圆度是用4π乘以面积除以周长的平方来判断,一个真圆的圆度接近1,而方孔、椭圆孔的圆度明显偏低。

如果同一ROI里仍然有多个候选圆,我会根据圆心间距、圆边的灰度均值、边缘强度去打分,选分数最高的作为目标。这些规则写起来并不复杂,但能比单纯调param2稳定得多。

6.4 Mat生命周期与内存释放

最后提醒一个常见的稳定性问题:内存持续上涨。Mat在OpenCVSharp里虽然是托管对象,但底层非托管内存需要显式释放。如果你在一段循环代码里频繁创建Mat而没有释放,几十帧之后内存就可能翻倍。

我的习惯是:临时Mat用using包裹,或者手动调用Dispose;往控件里传图像时,只传克隆出来的展示副本,用完立即释放;相机帧队列保持固定长度,防止帧堆积。排查内存问题时,重点看任务管理器里的内存曲线是不是持续增长,如果检测模块跑了一小时内存涨了数百MB,多半是某个Mat在循环里泄漏了。

最后说一个我自己的习惯:不管控件做得多顺手,第一版上线前我都会把霍夫圆的参数固化到一个配置类里,并且在自动检测模式下不再开放param2的滑块。原因很简单,现场操作人员为了“看见更多圆”会把参数调到能搜出一堆假圆的状态,调试期是福利,生产期就是灾难。如果你正在纠结自己的检测工具为什么时好时坏,大部分时候问题不在算法,而在参数控制逻辑。希望这篇文章能帮你少走点弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进