☰
机器视觉弹簧圈数自动计数:六大核心算法与实战指南
2026/10/18 19:51:11 网站建设 项目流程

在来料质检里,弹簧圈数不对是个老难题。一批弹簧几百上千个,人工数圈数既费眼又容易错,尤其表面发黑、间距很密的弹簧,数到一半就花了。之前接了一个项目,要求用工业相机做金属弹簧圈数自动计数,用于来料快速检验,节拍要求单件2秒以内,误检率压到很低。整套方案做下来,最核心的不是某个单一算法,而是不同场景下怎么组合使用6个核心算法。这篇文章把整个项目从头到尾拆开讲,包括硬件选型、6个算法怎么用、OpenCV和Halcon的实战代码,还有我在现场踩过的坑,给做机器视觉和来料质检的朋友做个参考。

整套流程从硬件到软件走下来,最直观的感受是:弹簧计数看起来只是数“几圈”,一旦放到产线上,问题就全出来了——反光、遮挡、端部不完整、振动模糊、物料尺寸波动,每一样都能让结果翻车。所以下面的内容不只是贴算法,还会讲清楚每个算法在什么条件下好用、在什么条件下会失效,以及对应的补救手段。

1. 项目需求与整体方案设计

1.1 这道题到底难在哪

弹簧圈数自动计数,本质是一个周期结构检测问题。但和螺丝螺纹这种规则件不一样,弹簧是三维螺旋结构,从相机一侧看过去,线圈之间有相互遮挡,同一圈的金属丝在投影图上可能被压成一条线,也可能因为透视关系产生扭曲。

另一个麻烦是端圈。弹簧两端通常有收口和磨平,端面的半圈、压并圈和中间圈在图像上的形态完全不一样,如果直接按整体周期去数,很容易多一截或者少一截。再加上来料弹簧可能有轻微变形,圈距不恒定,表面有油污、锈迹或反光,图像质量很难保证。

从项目需求上说,来料检验和研发阶段的“测着玩”差别很大。来料检验要求的是稳定、快速、傻瓜化,每颗弹簧进来,要么判合格,要么判退货,操作工不需要懂算法。因此方案不能只做一个“能跑通”的Demo,必须把成像、算法、通讯这几个环节都固定下来,并且要能处理产线上出现的各种干扰。

1.2 成像系统的选型逻辑

项目用的是某德系工业相机,面阵全局快门,分辨率500万像素,配一颗低倍远心镜头,光源用的是背光源。为什么这样搭?弹簧属于细长零件,放在背光源上面,得到的是剪影图,前景弹簧是暗的,背景是亮的,这样受表面反光和纹理影响最小,轮廓最干净。如果用正光或环形光,弹簧表面的金属纹理、油污会严重干扰二值化,计数算法基本上就没法稳定工作。

远心镜头的作用是消除透视误差。普通镜头靠近相机一端看弹簧会“近大远小”,弹簧两端看起来圈距不一样,这对周期计数非常致命。远心镜头在一定景深范围内保持放大倍率一致,图像中各处的圈距都真实反映物体的几何比例,后面做投影或者频域分析才靠谱。

光源这一块我踩过坑。最开始用一个普通的LED平板背光,亮度不均匀,弹簧边缘一部分清晰一部分发虚,二值化后出现断裂带。后来换成了高均匀性的背光源,并且在光源面板上加了一层漫射板,效果好了很多。对于表面发黑、吸光的弹簧,背光亮度还要适当调高,否则弹簧边缘和背景灰度差太小,阈值分割不稳定。

1.3 六条算法线的整体脉络

这个项目里我把算法拆成6条线,不是每个项目都要全部用,而是按实际工况选择组合。它们分别是:

  1. 图像预处理增强——去噪、提升对比度、统一灰度;
  2. 灰度投影计数——从投影曲线数峰谷,最直接;
  3. 边缘轮廓极值计数——抓上下包络线的极值点;
  4. 形态学处理与骨架端点分析——处理断裂和端部干扰;
  5. 频域周期估计——用FFT算主频,抗局部噪声;
  6. 模板匹配校验——用已知圈数模板做最终确认。

这6条线的定位不完全一样。第1条是基础,第2、3、5条是“计数主体”,第4条是预处理里的关键补充,第6条是“校验兜底”。实际项目里,我通常用第2条或第5条做主计数,用第6条做二次确认,最终结果取两者一致的判断。下面逐条展开。

2. 六个核心算法逐个拆解

2.1 算法一:图像预处理增强

所有计数算法都依赖清晰、干净的图像,但相机拿到的原始图往往带噪声、灰度不均、反光点。预处理的主要目标是让弹簧区域和背景区域尽量“泾渭分明”。

我习惯的顺序是:转灰度 → 中值滤波 → 对比度增强 → 二值化。

中值滤波对去除椒盐噪声和细小反光点非常有效,同时相对高斯滤波来说能更好地保留边缘锐度。对于弹簧这种金属件,反光造成的孤立亮点经常出现,中值滤波比均值滤波更好用。滤波核大小一般取3×3或5×5,过大会把细小的线圈边缘磨平,尤其小弹簧上很致命。

对比度增强可以用直方图均衡化,也可以用自适应Gamma校正。工业场景下我更推荐限制对比度的自适应直方图均衡化(CLAHE),它能避免把背光不均匀的亮度差异放大成伪边缘。

二值化这里有一个关键点:如果背景亮、目标暗,需要用THRESH_BINARY_INV;如果弹簧表面很亮、背景暗,反过来。而且尽量不要用固定阈值,不同批次弹簧的透光率和表面状态会有差异,固定阈值很容易出问题。我的做法是先用大津法(Otsu)自动算初始阈值,再根据实测样本手动微调一个偏移量。

OpenCV的预处理代码大致这样:

import cv2 import numpy as np img = cv2.imread("spring_raw.png") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 中值滤波去噪,核大小选5,太大会损失边缘 gray = cv2.medianBlur(gray, 5) # CLAHE增强,clipLimit控制对比度强度 clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)) enhanced = clahe.apply(gray) # 大津法二值化,背景亮、目标暗所以用INV _, binary = cv2.threshold(enhanced, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 开运算去除细小的孤立噪点 kernel = np.ones((3, 3), np.uint8) binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=1)

跑完这段,弹簧尽量是一个完整的白色区域,背景是黑色。如果弹簧中间还有断开,说明预处理还需要进一步调参数,而不是急着上计数算法。

2.2 算法二:灰度投影计数

灰度投影是我在这个项目里最常用的算法,也是最好理解的一个。把二值化后的弹簧图像沿轴向做像素统计,因为弹簧一圈一圈有规律地排列,每一圈在投影曲线上会对应一个峰或谷,数峰谷的数量就能得到圈数。

举例:假设弹簧水平放置,轴向是X方向。我对图像每一列统计白色像素的数量,得到一条“列投影曲线”。弹簧每个线圈在图像上占据一个区域,线圈的疏密交替反映成曲线的周期性波动。对规则弹簧来说,相邻两个波峰(或波谷)之间代表一个完整的螺旋周期,所以圈数=波峰数-1或波峰数,具体要看弹簧端部和图像截断情况。

使用OpenCV和SciPy的代码:

import cv2 import numpy as np from scipy.signal import find_peaks # binary是上一步预处理得到的二值图 # 统计每一列白色像素数 col_sum = np.sum(binary > 0, axis=0).astype(np.float32) # 曲线做平滑,避免毛刺峰 kernel = np.ones(5) / 5 col_smooth = np.convolve(col_sum, kernel, mode="same") # 找峰;peak height 根据实际像素高度设置,distance按圈距像素设置 peaks, props = find_peaks( col_smooth, height=10, # 小于10个白像素的峰忽略 distance=20 # 两个峰至少间隔20像素,防止误分 ) turns = len(peaks) - 1 # 实测后校准偏移量

这里有两个参数需要根据实际图像标定:height代表一个有效周期在投影曲线上的最小响应强度,如果弹簧很细或投影重叠严重,数值要调低;distance代表最小圈距像素数,防止一个圈被误认为两个峰。我通常会在项目初期打印投影曲线,人工对照实测圈数来标定这两个参数。

投影法的优势是计算量极小,一毫秒内就能出结果,非常适合高速来料检验。缺点也很明显:如果弹簧圈距不均匀、端部压并圈太多,或者两个线圈在水平方向上有局部重叠,峰谷位置会偏移或者合并,造成计数错误。这种时候就要结合边缘轮廓法或者频域法了。

2.3 算法三:边缘检测与轮廓周期分析

投影法是对整幅图做统计,圈与圈之间的局部畸变容易被平均掉。边缘检测法则更“聚焦”,它只抓住弹簧上边缘或下边缘的轮廓曲线,把这条轮廓线当成一个周期信号来分析。

具体做法是先用Canny边缘检测提取弹簧的外轮廓,然后按X坐标排序,取上边缘(Y值较小的点)构成一条曲线。弹簧在侧影中每一圈都会在上边缘形成一个拱形,曲线呈波浪状。数出波浪的数量,就等于圈数。

算法的关键在边缘点的提取和排序。findContours返回的是轮廓点集合,需要先把整个弹簧区域的轮廓点合并,再按照X坐标排序。注意弹簧可能分为多个轮廓片段,要把属于同一根弹簧的点都合并成一个数组。

参考代码:

import cv2 import numpy as np edges = cv2.Canny(enhanced, 30, 100) contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) if len(contours) == 0: raise ValueError("No contour") # 把轮廓点合并,并获取上边缘轮廓点 all_pts = np.vstack([c.reshape(-1, 2) for c in contours]) # 按X排序,每个X只保留Y最小的点作为上边缘 sorted_pts = all_pts[np.argsort(all_pts[:, 0])] upper_y = [] last_x = None for x, y in sorted_pts: if last_x is None or x != last_x: upper_y.append(y) last_x = x upper_y = np.array(upper_y, dtype=np.float32) # 平滑 upper_smooth = cv2.GaussianBlur(upper_y.reshape(-1, 1), (1, 15), 0).flatten() # 检测极值点:一阶差分变号处 dy = np.diff(upper_smooth) sign_changes = np.where(np.diff(np.sign(dy)) != 0)[0] + 1 # 极值点数量的一半(一个完整周期含一个峰一个谷)就是圈数 turns = len(sign_changes) // 2

这个方法对局部畸变的容忍度比投影法高,因为上边缘的波浪在单个圈上即使有毛刺,极值检测也能通过平滑去掉。但需要注意,如果弹簧是倾斜的,或者上下边缘重叠严重,上边缘曲线会被截断,极值数偏少。这时候先在预处理里做旋转校正,或者使用远心镜头保证轴向水平,效果会好很多。

另外Canny的两个阈值也要按图像噪声调节。我发现很多人在Canny参数上很随意,但在工业现场,图像灰度分布很稳定后,阈值一般固定在一个范围就不会乱跳。

2.4 算法四:形态学处理与骨架端点分析

形态学这步最开始我是放在预处理里的,后来单独拎出来当算法用,原因是有不少弹簧表面有锈斑或油污,二值化之后区域内部会有空洞,边缘上会有锯齿。形态学开运算能去毛刺,闭运算能填空洞,这是图像层面的净化。

但形态学对计数真正的价值在于:它能帮我们定位弹簧的端点位置,尤其是端部半圈。来料弹簧的两端形状往往不完整,如果直接按投影曲线计数,端部的半圈容易被算成整圈或者被忽略。通过形态学腐蚀和骨架提取,可以把弹簧区域“瘦”成一条中心线,然后再分析中心线的弯曲次数,这样对端部缺失不敏感。

Halcon在形态学和骨架提取上比OpenCV更顺手,直接用算子就能拿到骨架。下面是一段典型Halcon代码,处理流程是读取图像 -> 灰度化 -> 阈值分割 -> 连通域筛选(排除小杂质) -> 骨架化:

read_image (Image, 'spring.png') rgb1_to_gray (Image, GrayImage) // 背景亮、弹簧暗,所以阈值区域选0到中间灰度 threshold (GrayImage, Region, 0, 100) // 连通域分离,去掉小于2000像素的杂质 connection (Region, ConnectedRegions) select_shape (ConnectedRegions, SelectedRegions, 'area', 'and', 2000, 9999999) // 填充内部空洞,再开运算平滑边界 fill_up (SelectedRegions, RegionFillUp) opening_circle (RegionFillUp, RegionOpened, 3.5) // 骨架化 skeleton (RegionOpened, Skeleton)

有了骨架之后,可以再配合get_region_polygon等算子把骨架转成折线,分析折线的转折点数量。转折点数量与圈数也呈线性关系,具体要用已知样品标定一下偏移量。这个方法的好处是不需要依赖上下边缘,即使弹簧有轻微扭转也不会丢圈;缺点是对骨架的连续性要求高,如果预处理造成骨架断裂,转折点会少很多,所以前面的连通域筛选和洞填充非常重要。

2.5 算法五:频域周期估计

如果你是“算力洁癖”的人,一定会喜欢FFT。灰度投影曲线本质上是一个沿轴向的周期信号,弹簧圈距稳定时,这个信号的频率是很集中的。通过傅里叶变换找出主频,就能算出平均周期长度,用弹簧总长度除以周期长度就得到圈数。

这个方法的抗局部干扰能力非常好。即使某几个圈因为反光在图像上糊掉了,只要整体周期还在,频域里的主峰就不会消失。它特别适合那种“圈数很多、局部拍不清、但整体规律”的弹簧。

代码要点是先去掉投影曲线的直流分量,否则FFT结果里0频分量会直接盖住主频:

import numpy as np # col_smooth来自2.2节的投影曲线 signal = col_smooth - np.mean(col_smooth) # 去直流 n = len(signal) # 加窗,减少频谱泄漏 window = np.hanning(n) signal_win = signal * window # FFT spectrum = np.fft.fft(signal_win) freqs = np.fft.fftfreq(n, d=1.0) # d=1个像素 # 只看正频部分,忽略直流 half_n = n // 2 mag = np.abs(spectrum[1:half_n]) peak_idx = np.argmax(mag) + 1 # 主频对应的周期像素数 period_pix = n / peak_idx # 弹簧在图像中的长度像素数 spring_len_pix = np.count_nonzero(binary > 0) # 或者用轮廓外接矩形长 turns = spring_len_pix / period_pix turns = int(round(turns))

这里有个工程细节:如果弹簧的端部压并圈长度与正常圈距差别很大,频谱主峰会被“拉宽”,导致peak_idx搜索不够精确。解决办法是先做一个粗略ROI,把端部压并圈区域裁掉,只对中间正常圈部分做FFT,得到的圈数再加上修正值。像这类“先截断再处理”的思路,在整个项目里反复用到了。

FFT和投影法可以互为校验:投影法峰谷计数对局部敏感,FFT对局部不敏感,两者结果一致说明计数可信;不一致时触发重新拍照或人工复核。

2.6 算法六:模板匹配校验

模板匹配这番操作,听起来最“不炫”,但在来料检验里反而是最稳的兜底方案。思路很直接:提前制作圈数分别为10圈、12圈、15圈等的一组模板,然后对待检弹簧图像做形状模板匹配,看它和哪个模板得分最高,从而判断圈数。如果已有的弹簧图库够全,用Halcon模板匹配实现起来很快,部署也很稳定。

Halcon的形状模板匹配对灰度变化、局部遮挡有很强的鲁棒性,特别适合现场光照有小幅波动的情况。核心代码:

// 离线阶段:制作模板 read_image (TemplateImage, 'template_12.jpg') reduce_domain (TemplateImage, TemplateRegion, TemplateReduced) create_shape_model (TemplateReduced, ShapeModel, 'auto', -0.39, 0.79, 'auto', 'auto', 'ignore_local_polarity', 5) // 在线阶段:找模板 read_image (TestImage, 'spring_test.jpg') find_shape_model (TestImage, ShapeModel, -0.39, 0.79, 0.6, 1, 0.5, 'least_squares', 0, 0.7, Row, Column, Angle, Score) if (Score >= 0.75) // 匹配成功,用形状对应的圈数作为结果 endif

注意模板匹配返回的Score代表匹配相似度,圈数不同但外观接近的弹簧,得分可能很接近。所以我通常不只看最高分,而是看前两名分数的差值:如果第一名明显领先第二名,就采信第一名;如果领先不明显,就交给其他算法复核。这本质上是做一次“相似度鉴别”,而不是单纯找模板。

模板匹配的另一个好处是可以同时给出弹簧的位置和角度,方便后续机器人抓取或分类摆料。在来料检验工位上,我把它放在最后一步,前面投影法或FFT先算一个圈数,模板匹配再去验证,两票一致才放行。这样误检率可以压得非常低。

3. 参数计算与工程调优

3.1 相机分辨率和视野怎么算

工业视觉项目里,很多人一上来就选高分辨率相机,其实没必要。弹簧圈数计数取决于能否分辨单个圈距,而不是弹簧表面的微小瑕疵。先算两个指标:弹簧最大长度和最小圈距。

假设弹簧最长50mm,最小圈距1.5mm,要求至少用10个像素去表示一圈,那么一个圈距在图像里就是10像素。50mm的总长度至少需要50/1.5×10≈333像素的轴向分辨率。考虑到需要留出20%余量防止弹簧摆放偏移,400像素就够。听着很小对不对?但如果弹簧直径较大,你要保证整个弹簧都在视野内,就需要更大的传感器,这时优先保证覆盖范围而不是盲目追求高像素。

实际项目里我选了500万像素相机,视野覆盖60mm左右,圈距最小1.2mm时,一个圈距对应约20个像素,完全够用。分辨率再高不仅浪费成本,还会带来更大的图像处理耗时。

3.2 曝光时间怎么定

来料检验有节拍要求,弹簧可能是连续通过,也可能停在一个工位上。如果是停在工位拍照,曝光时间不用太短,只要够亮、无手振即可。如果是输送带上动态拍照,曝光时间必须短到让图像中的运动模糊小于一个像素。

计算方式很简单:假设输送带速度200mm/s,曝光时间5ms,那弹簧在曝光期间移动了1mm,如果这个1mm对应10个像素,那图像糊得非常明显。要把移动量控制在0.2mm以内,曝光时间就要小于1ms。工业面阵相机的全局快门一般能做到几十微秒,问题不大,但光源亮度也要相应提高,否则图像会太暗。

3.3 阈值和形态学参数的标定技巧

阈值不要以单张图像看着“顺眼”为准,要拿20~30张不同批次的弹簧图去跑一遍二值化,统计灰度直方图的谷底位置。我发现弹簧表面发黑时,目标灰度峰和背景峰距离很近,Otsu自动阈值有时会偏到背景里去。解决办法是用Otsu算出的阈值减去一个固定偏移,例如把阈值设为Otsu值的90%,让分割偏向“宁可多一点,不可少一块”,后续再用开运算去掉多余部分。

形态学的核尺寸也要以最小圈距作为参考。开运算的核宽度不能超过最小圈距的一半,否则会把正常线圈之间的间隙都给“开”没了。正常我取3到5像素,如果图像中一圈距离是20像素,那么开运算核3像素就很安全。

4. 常见问题与排查技巧

4.1 端部压并圈干扰计数

弹簧端部压并圈之间通常没有间隙,投影曲线上看不到明显的峰谷,而中间圈又很规律。按整体投影峰谷数算,压并圈段会被漏数。我的处理办法是:先找出投影曲线中峰谷响应最强的中间区域,用这个区域的平均峰距外推整根弹簧的圈数,然后根据端部形态加补偿值。还可以直接使用算法四的骨架端点分析,识别压并圈形成的“实体端”,从总长里扣除端部长度再折算圈数。

4.2 反光造成轮廓断裂

弹簧表面有油膜或者抛光得很亮时,背光下依然可能反射出高光斑,使二值化后弹簧区域内部出现孔洞。小孔洞可以通过fill_holes填充,大范围的断裂则需要检查光源角度是否正对。最好在光源上贴漫射板,或者更换偏振背光。偏振方案效果很好,但会降低整体亮度,需要相应增加曝光时间或光源强度。

4.3 弹簧倾斜导致计数偏少

如果弹簧在料盘里没有完全水平,轴向和相机坐标轴偏了一个角度,投影曲线会被拉伸,峰距变大,FFT主频偏移。最省事的办法是加一个V型槽治具,让弹簧每次都落在同一个水平位置。如果无法加治具,就用模板匹配先找到弹簧的角度,旋转图像后再做投影。日常维护中也要定期检查治具是否磨损,V型槽变形会让弹簧高度变化,影响景深和放大倍率。

4.4 OpenCV和Halcon结果不一致

现场出现过投影法计数是15圈,Halcon模板匹配判成16圈的情况。原因多半是模板匹配对不同圈数的相似度区分不够,或者投影法把某一段毛刺误判成峰。建议建立一个“仲裁机制”:两种算法结果相同则直接输出;不同则用FFT再投一票,三取二;仍然不一致就把图像存到一个“待人工确认”文件夹,由质检员二次判断。这样既能保证效率,又能积累一批难例来反复优化算法。

5. 实测效果与个人心得

这个项目实际跑下来,主计数用灰度投影加FFT校验,模板匹配做后端确认,识别单根弹簧耗时大约0.6秒,包含拍照和图像处理,节拍完全满足。准确率在正常来料状态下可以达到99.5%以上,主要剩余误差来自弹簧严重变形和端部过度压并。为了应对这两种情况,我额外加了一个“置信度过低自动转人工确认”的逻辑,而不是强行给一个结果。

最后分享一个小技巧:不管用哪个算法,都要在界面上把投影曲线实时画出来,哪怕是只给调试人员看。曲线的周期形态一眼就能看出问题,比看二值图直观太多。调试的时候我花了大量时间盯着这条曲线调参数,测峰峰距、看频谱主峰,几乎一半的优化都是在这里完成的。弹簧计数看着是个小需求,但把成像、算法、节拍、容错全串起来,一点都不比大项目轻松。希望这套思路能帮你少走几步弯路。

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

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

立即咨询