简介:这是一份基于OpenCV 4.3的C++圆形拟合实现,面向机器视觉开发者与图像处理进阶学习者,解决图像中圆形目标定位不准、边缘受噪声干扰时的拟合鲁棒性问题。其核心思路模仿Halcon的圆拟合能力,使用卡尺标定法沿径向扫描图像梯度,按卡尺窗口逐步寻找边缘响应最强点作为候选边界,再通过最小二乘法拟合圆形,从而兼顾定位速度与亚像素精度;算法与主程序分离封装,注释覆盖关键逻辑,便于理解或改用。通过将卡尺窗口宽度、径向扫描步长等参数开放出来,调用者可根据图像尺度灵活调整,兼顾粗定位与精细拟合。压缩包共4个文件,以两个cpp源文件和一个h头文件为主,另附一张测试图片供直接运行验证,包体仅85KB,源码轻量紧凑。目前已有1948人学习下载,适合需要快速落地圆拟合功能、或参考Halcon算法在OpenCV下复现的开发者。
1. 卡尺标定法径向扫描拟合圆V2.0:解决的不是“找圆”,而是“测圆”
拿到“卡尺标定法径向扫描拟合圆V2.0”这个包时,很多人第一反应是把它当成又一个找圆算法,对着模糊的轴承外圆跑一遍看圆心准不准。但找圆和测圆是两回事:前者关心圆在哪,后者关心圆多圆、多大、圆心差多少。这套方案真正解决的是精密测量问题,先用标准件完成卡尺标定,把像素当量固定下来;再以初始圆心为原点做径向扫描,沿每条射线用卡尺原理定位亚像素边缘点;最后做稳健最小二乘拟合圆,输出圆心、半径和圆度。它适合每天要和同心度、直径公差、真圆度打交道的视觉工程师,比如3C件内孔测量、密封圈内外径检测、轴承滚道在线分选。精度和稳定性靠标定—扫描—拟合三步共同托底,任何一步出问题,后面都是白用功。
2. 卡尺标定法拆解:标定在标什么,径向扫描为什么比全局Canny更适合作圆测量
2.1 卡尺标定法其实有两层含义
先把“卡尺标定法”这个词拆开。卡尺在机器视觉里不是游标卡尺,而是一条给定起点、终点和宽度的搜索带,沿搜索带的垂直方向做一维灰度剖面,在剖面上用梯度极值定位边缘。卡尺标定法就是把这一维边缘定位的标定过程固定下来:第一层标定像素当量,把图像上的像素半径换算成物理半径;第二层标定边缘响应,记录当前打光、镜头、工件材质组合下边缘梯度幅值的典型分布,后续检测的边缘强度阈值从标定结果取,而不是手工拍脑袋。
常见做法是拍一张标准圆板。标准圆板可以用陶瓷环或精密钢珠,直径经过计量确认,比如实测25.000mm。先用Hough粗定位拿到像素半径r_px,像素当量就是scale = 25.000 / r_px,单位是mm/pixel。这一步看起来简单,但有两个坑:一是Hough粗定位的半径可能带0.5像素以上的量化误差,直接拿来除会污染scale,所以标定应该用粗定位的圆心驱动一轮完整的径向扫描和圆拟合,用拟合的亚像素半径做除法;二是边缘响应标定,需要沿同一根射线记录梯度幅值直方图,得到工件边缘梯度的中位数与标准差,现场检测时阈值直接取“中位数 - kσ”作为底限,少了一次手动调参的玄学过程。
这两层标定为什么重要?因为径向扫描拟合圆的精度上限由边缘定位精度决定,边缘定位精度又由像素当量和梯度剖面质量共同决定。V1.0版本常见的翻车现场是:算法对了一只零件,换一只表面颜色稍暗的零件边缘强度不够,把阈值调低后又开始抓噪声,怎么调都不收敛。V2.0的做法是用标准件把这条线的“正常响应”记录成标定文件,现场只判断当前剖面响应是否落在标定区间内,而不是在宽范围里找最优阈值。
2.2 径向扫描:把二维边缘检测降维成一维卡尺搜索
径向扫描的思路很朴素:以初始圆心为原点,在360°内按固定角度步进发射N条射线,对每条射线沿径向按固定像素步长取灰度剖面,然后在剖面上找边缘点。整张图只处理圆周边的环形带,环形带以外的结构根本不参与计算。配合卡尺搜索来理解,径向扫描相当于把“全图找圆”换成了“在环形区域内找N个边缘点”,计算量和干扰源都大幅下降。
这一降维让数学变得干净。每条射线上的灰度是一个一维函数g(r),边缘在g(r)的导数极值处,|g'(r)|最大的那个r就是边缘的径向位置。由于射线近似沿圆心到边缘的法向方向,一维剖面的梯度方向和真实边缘法线几乎重合,边缘定位误差最小。如果圆心初始化偏差过大,射线方向和边缘法向之间出现明显夹角,边缘点会沿切线方向漂移,这就是后面要说的“外八字”问题,会在第5章展开。
和全局Canny加Hough圆检测相比,径向扫描的优势还在于可以用环形带约束搜索范围。设内外半径r_min和r_max,射线采样时直接把r限制在这个区间内,远离带内的字符、划痕、相邻孔洞都不会干扰。Canny检测则必须靠后验的轮廓分组,一旦边缘断裂、交叉,分组逻辑立刻变得复杂。这也是我在做密封圈内径检测时坚持用径向扫描的原因:密封圈上常有模具飞边和小凹陷,全局Canny会把飞边当成第二个边缘,径向扫描则可以通过限制搜索范围直接跳过。
2.3 拟合圆:代数解起步,几何残差把关
径向扫描拿到的是平面点集(x_i, y_i),最后一步是拟合出圆心(cx, cy)和半径r。最常见的代数拟合是Kása法:把圆方程
x² + y² - 2cx·x - 2cy·y + (cx² + cy² - r²) = 0
改写成关于参数(cx, cy, ρ)的线性方程组,其中ρ = cx² + cy² - r²。每条边缘点给出一行系数(2x_i, 2y_i, 1),右端为x_i² + y_i²,用最小二乘求解三参数,再反解出cx、cy和r。这个解法稳定、快,不需要迭代,工业上一帧图像内几百个点瞬间算完。参数解和稳健解之间的差异,最终体现在第3章代码里fit_circle_robust的写法上。
代数最小二乘有一个毛病:对离群点非常敏感,平方项把大偏差样本的权重放大了。工业场景里边缘上经常出现毛刺、脏污、反光高光,它们会让拟合半径偏大或偏小。所以V2.0的拟合步骤带拒绝策略:先代数拟合出一个初始圆,计算每个点到这个圆的几何残差|dist_i - r|,用残差的中位数和标准差设门限,滤掉残差过大的点,再在干净子集上重新拟合,如此迭代2到3轮。为什么用中位数不用均值?均值会被离群点本身拉偏,门限跟着放宽,离群点混过去;中位数对厚尾分布稳得多。
这里补充一个很多人忽略的点:拟合完成后,点云残差的分布本身就是圆度参考。最大残差减最小残差可以粗略当作P-V圆度,残差RMS则是判断边缘一致性的好指标。许多工程师只取半径不看残差,其实残差里藏着毛刺、椭圆变形、圆心初始化偏移的线索。现场排查不稳定时,第一件事就是调出残差随角度的曲线,比对着工艺参数猜要快得多。
3. 在本地复现整套流程:标定、径向扫描、稳健圆拟合的一份可跑代码
3.1 卡尺标定:标准圆板决定像素当量与边缘响应
import cv2 import numpy as np def calibrate_with_standard_ring(image, ring_diameter_mm): img = cv2.GaussianBlur(image, (5, 5), 0) circles = cv2.HoughCircles( img, cv2.HOUGH_GRADIENT, dp=2, minDist=300, param1=100, param2=30, minRadius=50, maxRadius=400 ) if circles is None: raise RuntimeError("Standard ring not found in image") x0, y0, r0 = circles[0][0] # 用粗定位圆心驱动一次径向扫描,以更准的拟合半径做标定 edge_pts = radial_scan(img, x0, y0, r0 * 0.8, r0 * 1.2, angle_step_deg=1.0) cx, cy, r_px, _ = fit_circle_robust(edge_pts) scale_mm_per_px = (ring_diameter_mm / 2.0) / r_px return (cx, cy, r_px), scale_mm_per_px逻辑说明:函数先做高斯模糊降低传感器噪声,Hough粗定位只是给径向扫描提供一个可靠起始圆,并不直接参与标定。拿到粗定位后,用它的x0、y0和r0构造一条环形搜索带(0.8倍到1.2倍半径),径向扫描得到边缘点,再做稳健圆拟合,最终用拟合出的亚像素半径换算像素当量。
参数说明:GaussianBlur核大小5×5适用于大多数工业图,如果工件边缘本身很锐利可以缩到3×3。HoughCircles的minRadius和maxRadius应根据标准件在画面中的大概尺寸设置,给得太宽会引入误检。minDist设为300是假设视野内只有一个标准件,若图里有多枚量具需要调大。最关键的是angle_step_deg=1.0,它决定标定过程采集约360个边缘点,点数太少时scale的随机误差会变大。
3.2 径向扫描:射线灰度剖面、梯度极值与抛物线亚像素插值
def radial_scan(img, cx, cy, r_min, r_max, angle_step_deg=1.0): h, w = img.shape[:2] angles = np.deg2rad(np.arange(0, 360, angle_step_deg)) pts = [] cos_a, sin_a = np.cos(angles), np.sin(angles) for angle, ca, sa in zip(angles, cos_a, sin_a): r = np.arange(r_min, r_max, 1.0) xs = np.round(cx + r * ca).astype(int) ys = np.round(cy + r * sa).astype(int) valid = (xs >= 1) & (xs < w - 1) & (ys >= 1) & (ys < h - 1) xs, ys, r = xs[valid], ys[valid], r[valid] if len(r) < 3: continue gray = img[ys, xs].astype(np.float32) grad = np.empty_like(gray) grad[1:-1] = (gray[2:] - gray[:-2]) / 2.0 grad[0] = gray[1] - gray[0] grad[-1] = gray[-1] - gray[-2] # 只选梯度模值最大的一条边;边缘极性决定取极大还是极小 k = int(np.argmax(grad)) if k <= 0 or k >= len(grad) - 1: continue # 抛物线插值:用离散梯度三点拟合亚像素偏移量 denom = 2.0 * grad[k] - grad[k-1] - grad[k+1] if abs(denom) < 1e-6: continue offset = 0.5 * (grad[k+1] - grad[k-1]) / denom r_sub = r[k] + offset px = cx + r_sub * ca py = cy + r_sub * sa pts.append((px, py)) return np.array(pts)逻辑说明:这段代码把每条射线的整数半径序列转成像素坐标。用np.round取整是采样阶段不可避免的量化,后面的抛物线插值是补偿这一量化的关键。梯度序列用中心差分,边缘点位于梯度序列的极值位置。argmax取的是最大梯度——如果工件是亮背景暗孔,边缘响应是负的,要么改用argmin,要么在标定界面里让用户选一次边缘极性。抛物线插值公式的由来是:在极值点附近用二次多项式拟合离散梯度三点,二次项顶点相对中间点的偏移就是亚像素位置,这是卡尺工具的常规做法。
参数说明:r_min和r_max是环形搜索带,应当充分覆盖公差上下偏差和零件定位偏移,但不要过大,否则计算变慢且引入远处干扰。angle_step_deg=1.0给360个点,常规测量足够;若目标圆只有几十像素半径,角度步长改2°到3°更合适,避免相邻射线剖面重叠导致信息冗余。
3.3 稳健拟合圆:Kása代数解和迭代去离群点
def fit_circle_kasa(points): x = points[:, 0] y = points[:, 1] A = np.column_stack([2*x, 2*y, np.ones_like(x)]) b = x*x + y*y coef, _, _, _ = np.linalg.lstsq(A, b, rcond=None) cx, cy = coef[0], coef[1] r = np.sqrt(coef[2] + cx*cx + cy*cy) return cx, cy, r def fit_circle_robust(points, max_iters=3, n_sigma=2.5): pts = points.copy() for _ in range(max_iters): cx, cy, r = fit_circle_kasa(pts) dist = np.hypot(pts[:, 0] - cx, pts[:, 1] - cy) resid = np.abs(dist - r) mask = resid < np.median(resid) + n_sigma * resid.std() if mask.sum() < pts.shape[0] * 0.6: break pts = pts[mask] return cx, cy, r, pts逻辑说明:fit_circle_kasa是第2章讲的代数最小二乘,用np.linalg.lstsq求解三参数线性方程组,稳定且不要求矩阵求逆。fit_circle_robust每轮先拟合出一个初始圆,再计算几何残差,用中位数加n_sigma设门限,用来筛掉毛刺、飞边、反光带来的离群点。n_sigma=2.5是比较均衡的值,取3.0会让离群点漏网,取1.5会误杀真实边缘上的正常灰度波动点,导致有效点不足。
参数说明:max_iters=3足够,两三轮迭代后圆心移动量通常降到0.01像素以下,再迭代收益很小。返回的pts是筛完离群点后的内点,后续计算圆度P-V、椭圆度、拟合残差RMS都要靠它。工程上不要只输出一个半径,把残差统计一起返回给MES系统,才是测量闭环。
三个函数串起来的主流程:
img = cv2.imread("ring.bmp", cv2.IMREAD_GRAYSCALE) circle, scale = calibrate_with_standard_ring(img, 10.000) pts = radial_scan(img, circle[0], circle[1], circle[2] * 0.85, circle[2] * 1.15) cx, cy, r_px, inliers = fit_circle_robust(pts) print("radius_mm =", r_px * scale)提示:如果初始圆心完全未知,先跑HoughCircles而不是手动点选。手动点选在离线调试时可以用,在线跑批时一次标定点偏就会让整批测量数据作废。
4. 决定测量精度的关键参数:角度步进、搜索半径、迭代终止条件
径向扫描拟合圆的算法不难,难的是参数。这一章把最重要的几个参数单独拎出来,每个都给推荐值和调参边界。实践中我习惯先把它们写进一个配置文件,标定结果只更新像素当量,其他参数不随产品切换乱动。
| 参数 | 推荐起始值 | 作用 | 调过头会怎样 |
|---|---|---|---|
| angle_step_deg | 1.0° | 决定边缘点密度 | 太小:射线重复耗时;太大:点数少拟合飘 |
| r_min / r_max | 粗定位半径±20% | 限定搜索环形带 | 太窄:真实边漏检;太宽:远处干扰混入 |
| edge_polarity | 亮到暗/暗到亮 | 决定找梯度极大还是极小 | 反了:抓的是背景噪声谷点 |
| edge_threshold | 标定梯度中位数-kσ | 过滤弱边缘 | 太高:漏检;太低:噪声当边缘 |
| n_sigma | 2.5 | 离群点剔除门限 | 太小:误杀正常点;太大:离群点污染 |
| max_iters | 3 | 重定位迭代轮数 | 太多:无收益且耗时 |
4.1 角度步进与边缘极性:先定方向,再定密度
角度步进决定每圈采样多少个边缘点。一个很实用的换算关系:弧长 = π/180 × 半径 × 角度步进。比如半径100像素的圆,1°步进的弧长约1.7像素,点足够密;如果半径只有30像素,1°步进的弧长只有0.5像素,相邻射线的灰度剖面几乎一样,拟合矩阵接近病态,结果反而抖动。这时把步长调到2°到3°,每圈120到180个点,稳定性明显改善。经验区间是让弧长落在0.5到2像素之间。
边缘极性问题更容易被忽略。径向扫描默认找梯度极大值,但亮背景下的暗孔边缘是负向跳变,必须在标定界面里做一次“正/负边缘”选择。如果选反了,找出来的不是边缘而是边缘附近的背景噪声谷点,拟合半径会出现系统性偏移。我在标定界面里放一个剖面预览,把当前极性下的搜索位置直接画在灰度曲线上,现场操作员一眼就能看出选对没有。
4.2 搜索半径与梯度阈值:环形带开多宽才够
r_min和r_max不只是计算范围,也是抗干扰边界。常见做法是先Hough粗定位得到r0,搜索带设为r0 ± (公差上限 + 零件定位误差 + 毛刺高度)。机械定位准的时候±10%就够,公差大或上料摆偏时加到±25%。搜索带每加宽1像素,每根射线的采样点多1,总计算量线性增长。远离边缘的干扰,比如工件旁边的字符,必须靠环形带排除,而不是靠阈值。
梯度阈值方面,建议用标定得到的边缘梯度分布来定,而不是拍一个固定灰度差。剖面图上边缘的梯度幅值大致呈单峰分布,取“中位数 - 2σ”作为硬下限,可以覆盖大多数稳定打光场景。真正容易翻车的是把阈值定成绝对灰度差,打光亮度稍有波动绝对灰度差就漂移,而梯度的相对幅值在亮度等比例变化时保持不变,这也是第2章里坚持做边缘响应标定的原因。
4.3 迭代终止条件与亚像素窗口:精度从哪里来
V2.0的改进重点,在我理解里集中在迭代重定位与亚像素插值上。第一轮径向扫描使用初始圆心,圆心偏差会把射线方向带偏,拟合出的圆把真实圆心拉回来一点;用新圆心重新径向扫描,射线逐渐逼近边缘法向,边缘点分布更均匀,残差下降。终止条件建议设为:前后两轮圆心位移小于0.01像素,且半径变化小于0.005像素。
亚像素插值窗口的设置很多新手会踩坑。抛物线插值只用极值点前后共3个点,不是窗口越长越好。窗口太长会把相邻结构纳入拟合,比如密封圈双边缘只隔5像素,插值窗口取9会把两条边都卷进来;窗口太短则噪声影响大。推荐在5到9像素之间,具体看剖面信噪比。还有一个细节:做亚像素之前先在剖面上做一次轻量高斯平滑,会比拉大插值窗口更有效。
注意:迭代重定位不能替代标定。它解决的是圆心初值偏差,不能补偿镜头畸变或像素当量错误。如果重定位后残差依然正弦分布,检查标定文件而不是继续加大迭代次数。
5. 避坑与常见问题排查:径向扫描拟合圆最常踩的5个坑
5.1 圆心初始化偏差过大,边缘点被“外八字”拉开
现象:拟合半径明显偏小,点云残差随角度呈正弦变化,P-V圆度数值离谱。
原因:初始圆心和真实圆心偏差超过容限,射线方向不再垂直于边缘,边缘点沿切线方向漂移,点云整体被拉伸成“外八字”,拟合圆半径被压缩。
解决:先用HoughCircles或模板匹配把初始圆心拉到真实圆心10像素以内;或者加入“粗拟合—重定位”迭代,按旧圆心径向扫描、拟合出新圆心,再以新圆心重新扫描。一次重定位通常不够,稳定的现场方案固定做3轮,直到圆心位移小于0.01像素。
5.2 反光和毛刺让梯度极值找错边
现象:边缘点集里出现成簇的孤立点,拟合圆心跳动超过0.05mm。
原因:高光区域形成第二个梯度峰,且峰值比真实边缘还高;或者毛刺在剖面上叠加出伪极值。
解决:先确认边缘极性没有选反,再叠加梯度强度门限,低于标定响应中位数的梯度峰直接不参与。碰到高光毛刺,我一般把搜索带宽收窄到真实边缘±3像素,让伪极值落在搜索带外。
5.3 标定只拍一个位置,换位置后精度漂移
现象:标定时精度很好,批量测试时标准差翻倍,且偏差方向随零件在画面中的位置改变。
原因:镜头畸变和透视让像素当量在视野不同位置不一致,单一位置标定的scale只对画面中心区域成立。
解决:把标准圆板在视野四角和中心各拍5组,分别计算scale,把方差当作标定不确定度写进报告。精度要求±0.01mm级别的项目,还需要做畸变校正,用校正后的图像跑径向扫描,再把圆心坐标逆映射回原图坐标系。
5.4 亚像素插值窗口拉大,精度反而劣化
现象:把插值窗口从3点改成9点,亚像素结果没有变准,反而更抖。
原因:抛物线插值的前提是极值点附近剖面近似二次曲线,窗口一旦包含远处其他结构,二次假设直接失效。
解决:插值点数固定为3或5,不要用大窗口拟合整段剖面。真正的亚像素增益来自梯度序列质量——先做一维高斯平滑再求梯度,比拉大插值窗口有效得多。我常用(1,2,1)的平滑核,简单且稳定。
5.5 测量结果时好时坏,稳定性不达标
现象:同一个零件反复放上去测,尺寸在±0.01mm来回跳,肉眼看不出原因。
原因:频闪灯和相机曝光不同步,或者设备振动导致边缘出现运动模糊。运动模糊在拟合圆上表现为各向异性噪声,沿固定方向的边缘点整体偏移。
解决:改用外部硬件触发同步曝光,确认曝光时间内边缘移动不超过1/4像素。检查相机支架刚度,把振动频率和曝光时间错开。这一步属于产线基础设施问题,算法再稳也补不回来。
6. 验证与进阶:用标准圆板做重复性测试,再看查表加速
验证这套方案能不能上产线,不要拿一批产品试,直接做重复性测试。准备一枚计量过的标准环,放在机械定位座上,同一位置连拍20次跑完全流程,记录半径序列。关键指标是“稳”不是“准”:单位置标准差要小于公差的十分之一,比如公差±0.02mm时标准差要小于0.002mm;换位置5次后最大偏差要小于公差的五分之一;残差RMS要稳定在同一量级,不能忽大忽小。
import numpy as np radii = [] # 20次连拍,标定只做一次,后续全用同一像素当量 for i in range(20): img = capture() circle, scale = calibrate_with_standard_ring(img, D_mm) pts = radial_scan(img, circle[0], circle[1], circle[2] * 0.9, circle[2] * 1.1) cx, cy, r_px, inliers = fit_circle_robust(pts) radii.append(r_px * scale) r = np.array(radii) print(f"mean={r.mean():.5f}mm, std={r.std():.5f}mm, pv={r.max() - r.min():.5f}mm")进阶技巧是查表法缓存射线坐标矩阵。流水线上零件位置固定,每帧只是灰度变化,射线坐标可以在标定后预先算好,按角度和半径索引缓存。每帧径向扫描从查表开始,省掉三角函数和坐标换算,CPU占用能降到原来的约五分之一,这是V2.0在工控机上跑多工位测量的常用优化。
我还有一个自用习惯:每次交付都在本地落盘一张剖面图日志,保存每条射线在搜索带内的灰度、梯度和最终边缘点。这个问题回溯时不用再复现现场,直接看图就能判断是标定漂了、打光变了还是零件放偏了。我自己在这个方案上翻过车,后来把“标定记录、剖面快照、拟合残差”三样东西强制落盘,现场扯皮少了九成。如果你要做在线测量,先照着这套流程把标准板的重复性跑过再上产线,会省不少事。希望帮到你。
本文还有配套的精品资源,点击获取