做了几年机器视觉,真正被问到最多的功能,永远是“把图里的几个圆找出来,再把圆心间的距离算清楚”。不管是圆形工件定位、多孔板的孔位检测,还是药片排列计数、轴承滚珠测量,本质都是同一件事:在图像中稳定地检出多个圆,拿到圆心坐标,再计算距离。Halcon这套工业视觉库在处理这类需求时效率很高,但如果方法选错、参数靠猜,照样会翻车。这篇不绕弯子,直接把我在Halcon里处理“多个圆 + 圆心距离”的完整代码和调参思路拆开讲,适合刚接触Halcon的,也适合已经能跑通Demo、一到复杂图像就露馅的兄弟参考。
1. 先看清楚问题:多圆测量项目的真实需求往往是什么
1.1 典型场景:从零件定位到孔心距检测
我接触过的项目里,“找圆+测距”一般落在这么几类场景上:
- 机械臂抓取前的圆形工件定位,要的是圆心在图像坐标系里的准确位置;
- 法兰盘、PCB板、外壳上的孔径检测,要的是孔心距是否超差;
- 圆形目标计数,比如药片、胶囊、滚珠,要的是“有几个圆”以及它们大概在哪;
- 圆形零件的外径或内径测量,要求亚像素级的半径拟合。
这些需求表面上都叫“找圆”,但实际侧重点不一样:定位场景更看重圆心稳定,测量场景更看重精度,计数场景更看重不漏检。所以在动手写代码之前,先问清楚一个问题:客户到底要的是坐标,还是距离,还是半径?这决定了整个代码链路的长短。
本文标题里的“多个圆+圆心距离”是这几类需求里最典型的组合:先把所有圆找全,再算出两两圆心之间的距离。理解了这一点,后面的所有步骤都围绕“稳定检出 + 准确输出坐标 + 正确算距离”展开。
1.2 “找圆”到底要找什么
在Halcon里,一个圆最终表现出来的是一组数值:
- 圆心坐标:
Row和Column,注意Halcon里行列坐标对应图像的Y和X方向,别和常规的x/y搞混; - 半径:
Radius,单位是像素; - 如果需要测距,再根据两个圆心坐标算出欧氏距离。
当图像里有多个圆时,Row、Column、Radius都是以元组(tuple)形式输出的,每个下标对应一个圆。后续算距离实际上就是对这两个元组做配对计算。所以有一个非常容易被忽略的点:Row、Column、Radius三者的顺序必须保持一致,否则后面算出来的距离全是错的。
1.3 Halcon环境和版本说明
本文代码基于 HDevelop 编写,从 Halcon 17.12 到最新版本都能直接运行。老版本唯一要注意的是个别算子签名可能略有差异,但threshold、connection、select_shape、gen_contour_region_xld、fit_circle_contour_xld、distance_pp这几个核心算子在近十年内没有大变化,可以直接放心用。
安装激活这块就不展开说了,网上关于 Halcon 下载、安装教程、License 激活的流程已经很多,按官方步骤操作即可。需要注意的是安装的时候尽量选择完整安装,否则可能缺少深度学习或某些扩展库,但对本文涉及的测量功能没有影响。
2. 找圆的两条技术路线,选哪条得看图像条件
很多新手一上来就问“用哪个算子找圆”,其实这不是最优先的问题。最优先的问题是:你的图像里的圆,是以什么形式存在的。
2.1 路线A:阈值分割 + 区域筛选 + 轮廓拟合
这是我最常用也最推荐先试的方案,思路很简单:
- 用
threshold把目标从背景里分离出来; - 用
connection把连通的区域拆成一个个独立目标; - 用
select_shape按面积、圆度等特征筛掉非圆目标; - 用
gen_contour_region_xld提取区域外轮廓; - 用
fit_circle_contour_xld拟合出圆心和半径。
这条路线最大的优点是:一个圆天然对应一个连通区域,不存在“一个圆被拟合成多个圆”的问题。而且代码量少、速度快,对于背景干净、圆与背景灰度差异明显的图像,效果非常好。
缺点也明显:如果两个圆靠得很近甚至粘连,connection会把它们当成一个大区域,导致漏检;如果圆不是实心色块而是线条圆环,区域的外轮廓就会变得很复杂,拟合容易出偏差。
2.2 路线B:亚像素边缘 + 圆弧拟合
当图像里的圆带纹理、边缘模糊,或者目标本身只是轮廓线时,阈值分割很难把圆的区域干净地抠出来。这时候用边缘法会更靠谱:
edges_sub_pix提取亚像素边缘;segment_contours_xld把边缘段拆成直线和圆/圆弧;select_contours_xld筛掉短线噪声;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里的area和circularity是两条硬性过滤规则。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') endifdistance_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(边缘法)时,Row、Column元组里出现了好几组非常接近的圆心,半径也差不多,明显是同一颗圆被反复拟合了。
原因: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 endforMinDist要根据图像里圆的像素尺寸来设,一般取图像对角线长度的1%左右够用。去重后的FilteredRow、FilteredCol才是最终可信的圆心列表。
说实话,如果项目里用不到亚像素边缘的高精度,我宁愿一开始就走路线A,彻底绕开这个坑。
4.4 光照不均导致二值分割不完整
现象:同一张图里,有的圆很暗、有的圆稍亮,固定阈值只能分出其中一部分,顾此失彼。
排查方法:显示threshold后的Regions,观察漏掉的目标是不是集中在某个区域。如果是,多半是光照不均。
解决思路:
- 用
median_image或illuminate做预处理,压制低频光照分量; - 用
local_threshold或dyn_threshold做局部自适应阈值分割,让不同区域的圆都能被分出来。
local_threshold的基本用法:
local_threshold (GrayImage, LocalRegions, 'adapted', 5, 15, 0.05)局部阈值在外界光和阴影不均匀的场景下效果立竿见影,但参数也需要多试几组。我的习惯还是那句话:先用固定阈值看大致效果,确认目标和背景的可分离程度,再决定要不要走局部阈值这条路。
5. 从像素距离到物理距离:标定换算的关键两步
标题里虽然写的是“圆心距离”,但实际项目里客户要的从来不是像素,而是毫米。像素距离到物理距离的换算,才是很多项目真正卡壳的地方。
5.1 像素当量法:最简单但最容易出错
原理:放一个已知尺寸的参考物,算出每个像素对应多少毫米,然后用这个比例系数把像素距离乘过去。
操作步骤:
- 在测量位姿下拍一张已知尺寸的物体,比如标定尺、硬币、量块;
- 用
distance_pp量出参考物在图像里对应的像素距离; - 算出
mm_per_pixel = 实际尺寸(毫米) / 像素距离(px); - 程序里把像素距离乘上这个系数。
举个例子:标定尺上10毫米的长度在图像里占了500像素,那么mm_per_pixel = 10 / 500 = 0.02。测得某两个圆心距离300像素,实际距离就是300 * 0.02 = 6毫米。
容易犯的错:
- 换了对焦距离或工作距离,没有重新标定;
- 镜头畸变没有考虑,把旁边的距离也按同一个系数算;
- 工件平面和相机像平面不平行,导致画面不同位置的像素当量不一样。
这套方法只适合精度要求不高、镜头畸变小、工件近似垂直于相机光轴的场景。如果项目精度要求高于0.1毫米,建议老老实实做完整标定。
5.2 什么时候必须上完整标定
当镜头用广角、短焦距,或者工件本身有高度差,或者测量范围覆盖到图像边缘时,畸变带来的误差会非常明显。这时候像素当量法就不够用了。
Halcon标准做法是使用标定板:
- 打印或购买Halcon专用的
caltab标定板; - 在不同位姿下拍摄十几张标定板图像;
- 用
find_caltab、find_marks_and_pose提取标定板标记点; - 用
calibrate_cameras得到相机内参和畸变系数; - 用
change_radial_distortion对测量图像做畸变矫正,然后再走找圆流程。
完整标定的精度很高,但调试周期长,对环境、标定板制作精度都有要求,不是每次测量都值得上。如果只是做粗测,像素当量法完全够用。
5.3 一个实用的偷懒技巧:固定方向单轴标定
如果项目只需要测量水平方向或垂直方向的距离,不需要完整二维映射,可以只标定主轴方向上的像素当量。比如只算孔心在水平方向的距离,就用一把已知间距的孔板在图像里水平方向量出像素差,得到水平方向的mm_per_pixel,距离全部按这个系数换算。
还有一个细节:镜头畸变通常在图像中心最小、边缘最大。所以做测量时尽量把要测的圆放在图像中心区域,远离边缘,哪怕只是“取景时多留点边距”,都能显著降低误差。这个习惯帮我避免了好几次精度不够的麻烦。
6. 最后分享一点实际体会
代码和参数都给你了,但有一点我特别想说:调Halcon找圆,不是算子用得越多越厉害,而是每一步都要知道自己在干什么。我去年做一个检测项目,一开始坚持用边缘法,结果被“一个圆拟合出多个结果”的问题折腾了一下午,后来换成阈值分割+区域轮廓拟合,半小时就解决了。
现在我的习惯是:拿到一张圆检测的图,先看“圆是色块还是轮廓线”,再看“圆与背景对比度够不够”,最后才决定走哪条技术路线。绝大多数项目,先跑通最简单的方案,再根据精度和稳定性的反馈决定要不要加边缘、加鲁棒拟合、加标定。希望这篇文章能让你少走几步弯路,在Halcon里快速把“找多个圆、算圆心距离”这件事落下来。