Halcon多圆检测与圆心距离测量实战:从阈值分割到距离计算
2026/9/16 10:14:31 网站建设 项目流程

做了几年机器视觉,真正被问到最多的功能,永远是“把图里的几个圆找出来,再把圆心间的距离算清楚”。不管是圆形工件定位、多孔板的孔位检测,还是药片排列计数、轴承滚珠测量,本质都是同一件事:在图像中稳定地检出多个圆,拿到圆心坐标,再计算距离。Halcon这套工业视觉库在处理这类需求时效率很高,但如果方法选错、参数靠猜,照样会翻车。这篇不绕弯子,直接把我在Halcon里处理“多个圆 + 圆心距离”的完整代码和调参思路拆开讲,适合刚接触Halcon的,也适合已经能跑通Demo、一到复杂图像就露馅的兄弟参考。

1. 先看清楚问题:多圆测量项目的真实需求往往是什么

1.1 典型场景:从零件定位到孔心距检测

我接触过的项目里,“找圆+测距”一般落在这么几类场景上:

  • 机械臂抓取前的圆形工件定位,要的是圆心在图像坐标系里的准确位置;
  • 法兰盘、PCB板、外壳上的孔径检测,要的是孔心距是否超差;
  • 圆形目标计数,比如药片、胶囊、滚珠,要的是“有几个圆”以及它们大概在哪;
  • 圆形零件的外径或内径测量,要求亚像素级的半径拟合。

这些需求表面上都叫“找圆”,但实际侧重点不一样:定位场景更看重圆心稳定,测量场景更看重精度,计数场景更看重不漏检。所以在动手写代码之前,先问清楚一个问题:客户到底要的是坐标,还是距离,还是半径?这决定了整个代码链路的长短。

本文标题里的“多个圆+圆心距离”是这几类需求里最典型的组合:先把所有圆找全,再算出两两圆心之间的距离。理解了这一点,后面的所有步骤都围绕“稳定检出 + 准确输出坐标 + 正确算距离”展开。

1.2 “找圆”到底要找什么

在Halcon里,一个圆最终表现出来的是一组数值:

  • 圆心坐标:RowColumn,注意Halcon里行列坐标对应图像的Y和X方向,别和常规的x/y搞混;
  • 半径:Radius,单位是像素;
  • 如果需要测距,再根据两个圆心坐标算出欧氏距离。

当图像里有多个圆时,RowColumnRadius都是以元组(tuple)形式输出的,每个下标对应一个圆。后续算距离实际上就是对这两个元组做配对计算。所以有一个非常容易被忽略的点:RowColumnRadius三者的顺序必须保持一致,否则后面算出来的距离全是错的。

1.3 Halcon环境和版本说明

本文代码基于 HDevelop 编写,从 Halcon 17.12 到最新版本都能直接运行。老版本唯一要注意的是个别算子签名可能略有差异,但thresholdconnectionselect_shapegen_contour_region_xldfit_circle_contour_xlddistance_pp这几个核心算子在近十年内没有大变化,可以直接放心用。

安装激活这块就不展开说了,网上关于 Halcon 下载、安装教程、License 激活的流程已经很多,按官方步骤操作即可。需要注意的是安装的时候尽量选择完整安装,否则可能缺少深度学习或某些扩展库,但对本文涉及的测量功能没有影响。

2. 找圆的两条技术路线,选哪条得看图像条件

很多新手一上来就问“用哪个算子找圆”,其实这不是最优先的问题。最优先的问题是:你的图像里的圆,是以什么形式存在的。

2.1 路线A:阈值分割 + 区域筛选 + 轮廓拟合

这是我最常用也最推荐先试的方案,思路很简单:

  1. threshold把目标从背景里分离出来;
  2. connection把连通的区域拆成一个个独立目标;
  3. select_shape按面积、圆度等特征筛掉非圆目标;
  4. gen_contour_region_xld提取区域外轮廓;
  5. fit_circle_contour_xld拟合出圆心和半径。

这条路线最大的优点是:一个圆天然对应一个连通区域,不存在“一个圆被拟合成多个圆”的问题。而且代码量少、速度快,对于背景干净、圆与背景灰度差异明显的图像,效果非常好。

缺点也明显:如果两个圆靠得很近甚至粘连,connection会把它们当成一个大区域,导致漏检;如果圆不是实心色块而是线条圆环,区域的外轮廓就会变得很复杂,拟合容易出偏差。

2.2 路线B:亚像素边缘 + 圆弧拟合

当图像里的圆带纹理、边缘模糊,或者目标本身只是轮廓线时,阈值分割很难把圆的区域干净地抠出来。这时候用边缘法会更靠谱:

  1. edges_sub_pix提取亚像素边缘;
  2. segment_contours_xld把边缘段拆成直线和圆/圆弧;
  3. select_contours_xld筛掉短线噪声;
  4. fit_circle_contour_xld对圆弧做圆拟合。

这条路线的精度潜力更高,但有个很烦人的问题:一个完整圆形边缘会被拆成好几段圆弧,每个圆弧都能独立拟合出一个圆,结果里会出现一堆圆心非常接近的“同一颗圆”。后续必须做去重处理,代码链路变长,调参也更讲究。

2.3 两条路线的对比和选型建议

我把两条路线的特性整理成一张表,方便对照:

对比项路线A:阈值+区域+拟合路线B:亚像素边缘+拟合
实现难度低,半小时能跑通中高,调参链条长
精度足够应付大多数测量潜力更高,可达亚像素级
多圆友好度高,一个圆一个区域低,一个圆可能拟合出多个结果
抗光照能力一般,依赖分割效果相对更好
适用场景实心圆、色块、背景干净圆环、轮廓线、边缘模糊、高精度测量

我的选型习惯是:先跑路线A,确认图像能不能干净分割,再考虑要不要上路线B。多数实际项目里,只要打光正常、背景可控,路线A已经足够。没有必要为了“看起来更专业”把所有算子堆上去。

3. 完整HDevelop代码逐段拆解:从读图到输出圆心距离

下面给出完整代码。为了照顾不同基础的读者,我先按步骤拆开讲,最后再贴一个可以直接复制的基础模板。

3.1 第一步:读图与灰度化

* Read image read_image (Image, 'circles.png') * Convert to gray if needed count_channels (Image, Channels) if (Channels == 3) decompose3 (Image, R, G, B) convert_image_type (R, GrayImage, 'byte') else copy_image (Image, GrayImage) endif

第一步看起来基础,但有两个细节值得注意。

第一,Halcon里很多算子对输入图像的类型和通道数是有要求的。彩色图直接做threshold不是不行,但三个通道混在一起会让阈值选择变得很不可控。所以我的习惯是统一先转灰度,后面所有的处理都在灰度图上进行。

第二,decompose3会拆出 R、G、B 三个通道,我通常只用 R 通道或者 G 通道来转灰度,而不是用rgb1_to_gray做标准加权转灰度。为什么?因为如果拍摄时用了红色光源、红色滤光片,某些通道的对比度会远好于其他通道,选对了通道能让后续分割省一大半功夫。

3.2 第二步:阈值分割与候选区域筛选

* Optional denoise median_image (GrayImage, ImageMedian, 'circle', 3, 'mirrored') * Binarize: assume circles are darker than background threshold (ImageMedian, Regions, 0, 100) * Split connected regions connection (Regions, ConnectedRegions) * Select circular regions by area and circularity select_shape (ConnectedRegions, SelectedRegions, ['area', 'circularity'], 'and', [200, 0.5], [10000, 1.0])

这步是整个流程里最需要动脑的地方。

threshold的上下限怎么定?我一般不会拍脑袋填数字。有两种常用做法:

  • 先看灰度直方图。HDevelop里可以用gray_histo统计灰度分布,然后观察目标和背景的两个峰分别落在什么灰度区间,再取一个中间值;
  • 直接开一个窗口拖滚动条试阈值,threshold配合connection反复看效果,速度也很快。

select_shape里的areacircularity是两条硬性过滤规则。area限制面积范围,单位是像素;circularity是圆度,计算方式基于区域面积和周长的关系,最大值是1,越接近1越像圆。正方形大约是0.785。

一个常见误区是上来就写circularity要大于0.8。如果圆的边缘有锯齿、有毛刺,或者区域本身带个小孔,圆度值会被拉低,结果把目标全筛掉了。更稳的做法是先用select_shape把面积范围框住,把圆度阈值先放宽到0.4左右,看看到底漏了哪些目标,再逐步收紧。

3.3 第三步:提取轮廓并拟合圆

* Extract outer contour of each region gen_contour_region_xld (SelectedRegions, OuterContours, 'border') * Fit circles fit_circle_contour_xld (OuterContours, 'algebraic', -1, 0, 0, 3, 2, Row, Column, Radius, StartPhi, EndPhi, PointOrder)

gen_contour_region_xld'border'表示提取区域的外边界。因为我们前面筛出来的是实心圆区域,所以外边界就对应圆的边缘。

fit_circle_contour_xld参数很多,但常用参数不多:

  • Algorithm:拟合算法。默认可以选'algebraic',速度快;如果边缘有噪声或离群点,改用'ahuber''atukey'这类鲁棒算法,代价是速度稍慢;
  • MaxNumPoints:参与拟合的最大点数,-1表示全部;
  • MaxClosureDist:判断轮廓首尾距离多近时认为它是闭合圆,设0表示不关心闭合性;
  • ClippingEndPoints:裁剪轮廓端点的数量,设0表示不裁剪;
  • Iterations:迭代次数,默认3够用,精度要求高可以加到5;
  • ClippingFactor:离群点裁剪因子,默认2,通常不需要动。

这里我强烈建议先选'algebraic'跑通,看结果稳定之后,再试'ahuber'对比一下偏差。不要一开始就上最复杂的参数组合,否则出了问题都不知道该查哪一边。

3.4 第四步:圆心距离计算与批量输出

* Open a window and display the result dev_open_window_fit_image (GrayImage, 0, 0, -1, -1, WindowHandle) dev_display (GrayImage) * Draw fitted circles for visualization gen_circle_contour_xld (Circles, Row, Column, Radius, 0, 6.28318, 'positive', 1.0) dev_set_color ('green') dev_set_line_width (2) dev_display (Circles) * Calculate distance between the first two circles NumCircles := |Row| if (NumCircles >= 2) distance_pp (Row[0], Column[0], Row[1], Column[1], DistancePixels) set_tposition (WindowHandle, 20, 20) write_string (WindowHandle, 'Distance between circle0 and circle1: ' + DistancePixels + ' px') endif

distance_pp算的是两个像素点之间的欧氏距离,输入是(Row1, Column1, Row2, Column2),输出是距离。

如果圆的数量超过两个,或者你要的是“所有圆之间的两两距离”,可以写一个双层循环:

if (NumCircles >= 2) for i := 0 to NumCircles - 2 by 1 for j := i + 1 to NumCircles - 1 by 1 distance_pp (Row[i], Column[i], Row[j], Column[j], Distance) set_tposition (WindowHandle, 40 + 30 * (i * NumCircles + j), 20) write_string (WindowHandle, 'D(' + i + ',' + j + ') = ' + Distance + ' px') endfor endfor endif

这里要注意write_string输出到窗口时,不同行的Row坐标不能重叠,我用了40 + 30 * (...)来错开行距。如果圆太多、输出行数超出窗口高度,建议改成写文件。

输出到文件也很简单:

open_file ('result.txt', 'write', FileHandle) for i := 0 to NumCircles - 1 by 1 fwrite_string (FileHandle, 'Circle ' + i + ': Row=' + Row[i] + ', Col=' + Column[i] + ', R=' + Radius[i] + '\n') endfor close_file (FileHandle)

3.5 可直接复制的基础模板

下面这段是把上面所有步骤拼起来的完整HDevelop脚本,复制后把图像路径改成自己的就能跑:

* Basic template: find multiple circles and measure center distance read_image (Image, 'circles.png') count_channels (Image, Channels) if (Channels == 3) decompose3 (Image, R, G, B) convert_image_type (R, GrayImage, 'byte') else copy_image (Image, GrayImage) endif median_image (GrayImage, ImageMedian, 'circle', 3, 'mirrored') threshold (ImageMedian, Regions, 0, 100) connection (Regions, ConnectedRegions) select_shape (ConnectedRegions, SelectedRegions, ['area', 'circularity'], 'and', [200, 0.5], [10000, 1.0]) gen_contour_region_xld (SelectedRegions, OuterContours, 'border') fit_circle_contour_xld (OuterContours, 'algebraic', -1, 0, 0, 3, 2, Row, Column, Radius, StartPhi, EndPhi, PointOrder) dev_open_window_fit_image (GrayImage, 0, 0, -1, -1, WindowHandle) dev_display (GrayImage) gen_circle_contour_xld (Circles, Row, Column, Radius, 0, 6.28318, 'positive', 1.0) dev_set_color ('green') dev_set_line_width (2) dev_display (Circles) NumCircles := |Row| if (NumCircles >= 2) distance_pp (Row[0], Column[0], Row[1], Column[1], DistancePixels) set_tposition (WindowHandle, 20, 20) write_string (WindowHandle, 'Distance: ' + DistancePixels + ' px') endif

把这个模板跑通之后,再去应对各种复杂图像就有底了。

4. 多圆场景最容易翻车的四个坑,我的排查建议

代码跑通只是开始,真正让人头大的是各种“看起来没问题,结果就是不对”的情况。下面这几个坑我基本每次做多圆项目都会遇到。

4.1 圆与圆粘连,区域被合并成一个

现象:两个圆靠得很近、甚至边缘接触,connection之后它们被当成一个葫芦形区域,最后拟合出来的只有一个圆,或者拟合出个不圆不扁的东西。

排查方法:先看threshold后的Regions显示结果,确认粘连发生在哪个环节。如果是区域合并了,再往下查。

解决思路

  • 优先从打光和背景入手,尽量让圆和圆之间留出黑色缝隙;
  • 如果缝隙已经存在但很细,可以用开运算把细缝撑宽,再用connection分离;
  • 如果圆之间完全接触,可以试试距离变换加分水岭:
distance_transform (Regions, DistanceImage, 'chamfer', 10, 10) watersheds_threshold (DistanceImage, Basins, 10) connection (Basins, ConnectedBasins)

分水岭能把粘连区域切成分块,但这个方法对参数敏感,而且分出来的区域边界可能不完整,拟合效果不一定好。我的建议是:分水岭是最后手段,不是第一选择。如果可以通过调整光源或机械定位让圆之间真正分开,代码上能省掉一多半麻烦。

4.2 圆度筛选阈值选错,候选区域被误删或漏删

现象select_shape之后,该保留的圆少了,或者该滤掉的噪声留下了。

排查方法:把筛出来的区域dev_display出来,再在原图上叠加查看。如果少了,八成是circularity阈值设得太高;如果多了,可能是area范围没卡紧。

解决思路:不要靠猜,而是把每个候选区域的真实特征值打印出来。用area_center (Regions, Area, Row, Column)circularity (Regions, Circularity)看到数值分布后,再回头定阈值区间,一次就能定准。

另外要注意,circularity是基于区域面积和周长算的。如果圆区域边缘有锯齿,周长会偏大,圆度值会偏低。这种情况下强行提高圆度阈值,等于把所有目标全部删掉。我的习惯是:先看真实值分布,再给定阈值区间,并且前后留10%的余量。

4.3 一个圆被拟合成多个结果

现象:走路线B(边缘法)时,RowColumn元组里出现了好几组非常接近的圆心,半径也差不多,明显是同一颗圆被反复拟合了。

原因edges_sub_pix得到的一整圈边缘,经过segment_contours_xld会被拆成很多条圆弧,每条圆弧都能独立拟合出一颗圆。

解决思路:在算法链路里加一道去重,按圆心距离聚类,把圆心距离小于某个阈值的圆合并成一颗。简单写个过滤逻辑:

MinDist := 5.0 FilteredRow := [] FilteredCol := [] FilteredRadius := [] for i := 0 to |Row| - 1 by 1 IsDuplicate := 0 for j := 0 to |FilteredRow| - 1 by 1 distance_pp (Row[i], Column[i], FilteredRow[j], FilteredCol[j], TmpDist) if (TmpDist < MinDist) IsDuplicate := 1 break endif endfor if (IsDuplicate = 0) FilteredRow := [FilteredRow, Row[i]] FilteredCol := [FilteredCol, Column[i]] FilteredRadius := [FilteredRadius, Radius[i]] endif endfor

MinDist要根据图像里圆的像素尺寸来设,一般取图像对角线长度的1%左右够用。去重后的FilteredRowFilteredCol才是最终可信的圆心列表。

说实话,如果项目里用不到亚像素边缘的高精度,我宁愿一开始就走路线A,彻底绕开这个坑。

4.4 光照不均导致二值分割不完整

现象:同一张图里,有的圆很暗、有的圆稍亮,固定阈值只能分出其中一部分,顾此失彼。

排查方法:显示threshold后的Regions,观察漏掉的目标是不是集中在某个区域。如果是,多半是光照不均。

解决思路

  • median_imageilluminate做预处理,压制低频光照分量;
  • local_thresholddyn_threshold做局部自适应阈值分割,让不同区域的圆都能被分出来。

local_threshold的基本用法:

local_threshold (GrayImage, LocalRegions, 'adapted', 5, 15, 0.05)

局部阈值在外界光和阴影不均匀的场景下效果立竿见影,但参数也需要多试几组。我的习惯还是那句话:先用固定阈值看大致效果,确认目标和背景的可分离程度,再决定要不要走局部阈值这条路。

5. 从像素距离到物理距离:标定换算的关键两步

标题里虽然写的是“圆心距离”,但实际项目里客户要的从来不是像素,而是毫米。像素距离到物理距离的换算,才是很多项目真正卡壳的地方。

5.1 像素当量法:最简单但最容易出错

原理:放一个已知尺寸的参考物,算出每个像素对应多少毫米,然后用这个比例系数把像素距离乘过去。

操作步骤

  1. 在测量位姿下拍一张已知尺寸的物体,比如标定尺、硬币、量块;
  2. distance_pp量出参考物在图像里对应的像素距离;
  3. 算出mm_per_pixel = 实际尺寸(毫米) / 像素距离(px)
  4. 程序里把像素距离乘上这个系数。

举个例子:标定尺上10毫米的长度在图像里占了500像素,那么mm_per_pixel = 10 / 500 = 0.02。测得某两个圆心距离300像素,实际距离就是300 * 0.02 = 6毫米

容易犯的错

  • 换了对焦距离或工作距离,没有重新标定;
  • 镜头畸变没有考虑,把旁边的距离也按同一个系数算;
  • 工件平面和相机像平面不平行,导致画面不同位置的像素当量不一样。

这套方法只适合精度要求不高、镜头畸变小、工件近似垂直于相机光轴的场景。如果项目精度要求高于0.1毫米,建议老老实实做完整标定。

5.2 什么时候必须上完整标定

当镜头用广角、短焦距,或者工件本身有高度差,或者测量范围覆盖到图像边缘时,畸变带来的误差会非常明显。这时候像素当量法就不够用了。

Halcon标准做法是使用标定板:

  1. 打印或购买Halcon专用的caltab标定板;
  2. 在不同位姿下拍摄十几张标定板图像;
  3. find_caltabfind_marks_and_pose提取标定板标记点;
  4. calibrate_cameras得到相机内参和畸变系数;
  5. change_radial_distortion对测量图像做畸变矫正,然后再走找圆流程。

完整标定的精度很高,但调试周期长,对环境、标定板制作精度都有要求,不是每次测量都值得上。如果只是做粗测,像素当量法完全够用。

5.3 一个实用的偷懒技巧:固定方向单轴标定

如果项目只需要测量水平方向或垂直方向的距离,不需要完整二维映射,可以只标定主轴方向上的像素当量。比如只算孔心在水平方向的距离,就用一把已知间距的孔板在图像里水平方向量出像素差,得到水平方向的mm_per_pixel,距离全部按这个系数换算。

还有一个细节:镜头畸变通常在图像中心最小、边缘最大。所以做测量时尽量把要测的圆放在图像中心区域,远离边缘,哪怕只是“取景时多留点边距”,都能显著降低误差。这个习惯帮我避免了好几次精度不够的麻烦。

6. 最后分享一点实际体会

代码和参数都给你了,但有一点我特别想说:调Halcon找圆,不是算子用得越多越厉害,而是每一步都要知道自己在干什么。我去年做一个检测项目,一开始坚持用边缘法,结果被“一个圆拟合出多个结果”的问题折腾了一下午,后来换成阈值分割+区域轮廓拟合,半小时就解决了。

现在我的习惯是:拿到一张圆检测的图,先看“圆是色块还是轮廓线”,再看“圆与背景对比度够不够”,最后才决定走哪条技术路线。绝大多数项目,先跑通最简单的方案,再根据精度和稳定性的反馈决定要不要加边缘、加鲁棒拟合、加标定。希望这篇文章能让你少走几步弯路,在Halcon里快速把“找多个圆、算圆心距离”这件事落下来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询