雨刮胶条这个项目,我最初接到需求时以为是个简单的“磨损检测”:相机拍一张,算法判断磨损程度,输出合格或NG。真正进了现场才发现,这活儿藏着一个更大的问题——用户要的不是“坏没坏”,而是“还能用多久”。这台设备停在线尾,每片胶条只给了不到两秒的拍摄时间,最后要在终端上显示的不只是OK/NG,还有一个剩余寿命的预估值,方便产线把即将到寿的胶条提前挑出来。
这个项目我最终用的方案是Baumer工业相机搭OpenCV做整体框架,Halcon做关键尺寸测量,配合一套趋势回归做寿命预测。整套逻辑跑下来,比想象中多花了三倍时间,但也确实摸索出不少可复用的经验。这篇文章把六个关键技术拆开讲清楚,从成像选型到寿命预测到现场部署,基本对应项目推进的全过程。搞工业视觉的同行,或者正准备做类似“检测加预测”需求的朋友,应该都能从这里拿到点直接能用的东西。
1. 成像方案:为什么偏偏选了Baumer而不是普通USB相机
1.1 全局快门这个参数,决定了检测方案能不能成立
拿到需求后我第一件事不是写算法,而是确认产线状态。雨刮胶条在检测工位上是静止的,还是运动中的?这个问题直接锁死了相机的选型范围。项目里胶条由传送带送到工位后,气缸压紧定位,但压紧动作完成后胶条还有轻微回弹,整条不是完全静止,而是处于一个“半稳定”状态。普通卷帘快门相机拍这种场景,边缘容易出锯齿,一旦胶条有微小振动,磨损带的轮廓就会变形。
所以相机必须选全局快门。Baumer的VLXT系列和VCX系列都有全局快门CMOS,帧率不追求超高,30到60fps足够用。选Baumer还有一个很实际的原因:GigE接口的触发和采图协议稳定,和PLC联动时可以用硬件触发精准控制曝光时刻。这个在产线上太重要了,普通USB摄像头的软触发延迟抖动能达到几十毫秒,而硬件触发可以做到微秒级同步。
1.2 光源角度比相机分辨率更值得花钱
雨刮胶条是黑色EPDM橡胶,表面又是哑光又是反光,这种材质拍起来有点“薛定谔”——角度不对,要么死黑一片,要么亮得发白。一开始我用了同轴光,效果惨不忍睹,橡胶表面的颗粒被均匀照亮后,磨损带和未磨损区域几乎没有灰度差异。后来换成了低角度环形光,让光从侧面以小角度打到胶条表面,磨损区域因为表面微观纹理被磨平,对光的反射方式和周围完全不同,灰度差一下就出来了。
曝光时间我设置在200到400微秒之间,光圈收到F8左右获得足够景深。分辨率不高,500万像素足矣,因为真正的测量区域只是一条窄带。很多人觉得分辨率越高越好,实际上高分辨率只带来更多的数据量和更慢的处理速度,对边缘测量的精度提升非常有限。
2. 第一道关卡:磨损带ROI的稳定追踪
2.1 为什么固定ROI框在真实产线上必然翻车
初期原型里,我直接在一张图像上画死一个矩形区域做检测。测试前二十张图效果不错,然后来了一条装夹偏了1.5毫米的胶条,整个测量区域全部跑偏。雨刮胶条是柔性件,工装夹具把它压住,但每次压紧力稍有变化,胶条就会产生弯曲和旋转。如果ROI不能跟着胶条实际姿态走,后续一切测量都是空谈。
所以第一步必须做姿态归一化:先找到胶条主体轮廓,计算出它的倾斜角度和中心位置,然后把图像旋转到标准姿态,或者沿着胶条的方向重新建立测量坐标系。这样后续的边缘提取、宽度测量全都在一个稳定的参考系里做,系统误差小得多。
2.2 OpenCV实现:姿态锁定与磨损剖面的稳定提取
我用OpenCV完成ROI定位的核心逻辑,步骤如下:
import cv2 import numpy as np gray = cv2.imread("wiper_sample.png", cv2.IMREAD_GRAYSCALE) blur = cv2.GaussianBlur(gray, (5, 5), 0) # 胶条和背景的灰度差足够明显时,Otsu就是最简单可靠的分割方式 _, binary = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 先开运算去掉背景噪点,再闭运算填充胶条内部孔洞 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9)) binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # 只保留最大连通域,排除背景和异物干扰 contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contour = max(contours, key=cv2.contourArea) # 最小外接矩形给出胶条的倾斜角度 rect = cv2.minAreaRect(contour) angle = rect[2] center = rect[0] box = np.int0(cv2.boxPoints(rect)) # 根据角度构建旋转矩阵,把胶条拉正 M = cv2.getRotationMatrix2D(center, angle, 1.0) aligned = cv2.warpAffine(gray, M, gray.shape[::-1])这段逻辑看似简单,但有几个细节必须说明。
胶条走向接近水平时,minAreaRect返回的角度会在0度和90度之间跳变,处理时要先判断rect[1][0]和rect[1][1]哪边更长,再统一角度参考方向,否则同一批图像有时旋转正角度有时旋转负角度,特征数据会出现跳变。形态学操作的核大小不能乱调。核太小,胶条边缘的毛刺去不掉;核太大,会把磨损带本身的细节磨平,后面测量宽度就失真了。我在项目里对那个固定分辨率下的图像,9×9的核是反复试出来的平衡点。
姿态拉正之后,磨损带的位置就好找多了。胶条在图像里是一条长条,磨损带位于工作面边缘。可以沿着胶条长度方向等间隔采样若干剖面线,每条剖面线上做灰度极值搜索,找到磨损带的左右边界,再把这些边界点的均值作为当期磨损带宽度。这种做法的好处是即使胶条有轻微弯曲,每个剖面都独立定位,局部误差不会互相累积。
3. 磨损特征工程:把“手感”变成机器读得懂的数字
3.1 磨损带宽度和边缘锐度,两个指标互相补充
寿命预测不能只看一个特征。单一特征在真实数据里总会有异常跳变,多特征交叉验证才能稳定。我最终保留了三个维度的特征:磨损带宽度、边缘锐度、表面纹理粗糙度。
磨损带宽度很好理解,就是胶条工作面上被磨平区域的横向尺寸。但只有宽度有个致命弱点:磨损初期,宽度往往变化很慢,等到宽度明显增加时,胶条可能已经处于寿命末期了。所以必须加上边缘锐度这个更敏感的特征。
边缘锐度的定义方式:沿着剖面线取灰度梯度曲线,测量边缘位置从梯度峰值下降到10%峰值所对应的空间距离。新胶条的边缘干净利落,这个距离很小;磨损后的胶条边缘圆钝,过渡带变宽,梯度曲线像一个胖馒头,锐度值明显增大。实际数据显示,边缘锐度比宽度提前大约百分之二十的寿命周期开始显著变化,是早期预警的关键指标。
3.2 纹理特征:磨损带表面的灰度协方差
表面纹理是另一个维度。新胶条表面有模具压出的细纹,灰度方差较大;磨损后表面被反复刮擦,细纹磨平,灰度分布变得更均匀。用一个滑动窗口计算磨损带内的局部方差均值,或者用灰度共生矩阵的对比度指标,都能把这个变化量化出来。
OpenCV里计算方便,可以直接在磨损带区域内做灰度直方图统计:
import cv2 import numpy as np gray = cv2.imread("wiper_sample.png", cv2.IMREAD_GRAYSCALE) # 磨损带区域由ROI定位阶段给出,示意为mask mask = np.zeros(gray.shape, dtype=np.uint8) cv2.rectangle(mask, (x1, y1), (x2, y2), 255, -1) roi = cv2.bitwise_and(gray, gray, mask=mask) # 用一个滑动窗口计算局部灰度方差,窗口尺寸建议取磨损带宽度的1/3左右 win_size = 15 mean = cv2.blur(roi, (win_size, win_size)) sqr_mean = cv2.blur(roi.astype(np.float64) ** 2, (win_size, win_size)) var_map = sqr_mean - mean.astype(np.float64) ** 2 texture_score = var_map[var_map > 0].mean() # 灰度共生矩阵对比度可以直接用skimage计算 from skimage.feature import graycomatrix, graycoprops glcm = graycomatrix(roi, distances=[1], angles=[0], levels=256, symmetric=True, normed=True) contrast = graycoprops(glcm, 'contrast')[0, 0]纹理特征要注意一个问题:光源老化会导致图像整体灰度漂移,纯粹的灰度均值不可靠,但灰度方差的相对变化受光源亮度影响较小。所以在线监控场景优先使用方差、对比度这样的二阶统计量,不直接用灰度均值。
4. Halcon实战:亚像素边缘测量是纯OpenCV难以替代的硬骨头
4.1 为什么整个流程里还嵌了一个Halcon
OpenCV做预处理和ROI追踪非常顺手,但到了最终尺寸测量环节,纯OpenCV的边缘定位精度有点不够看。普通像素级边缘检测是整数精度,雨刮胶条的磨损带宽度如果只有零点几毫米的变化,像素级量化误差会直接吞掉真实信号。Halcon的亚像素边缘算子在这个场景里优势明显。
项目里我是把OpenCV和Halcon串成一个流水线:OpenCV负责粗定位,找到胶条轮廓后生成一个合适的测量区域,然后把这个区域坐标传给Halcon的测量对象,由Halcon在区域内做亚像素边缘提取和宽度计算。这样既保住了开发效率,又拿到了精度。实际交付时Halcon部分封装成一个独立的测量组件,通过动态链接库对外提供接口。
4.2 关键算子和参数整定记录
Halcon部分的核心是gen_measure_rectangle2加measure_pos的组合。这是比较标准的矩形卡尺测量流程,沿测量矩形的中线方向提取边缘点,输出的边缘距离就是磨损带宽度:
* 读取图像 read_image (Image, 'wiper_aligned.bmp') * 磨损带区域由OpenCV粗定位传入的坐标决定 gen_rectangle2 (MeasureRegion, Row, Column, Phi, Length1, Length2) reduce_domain (Image, MeasureRegion, ImageReduced) * 建立矩形测量对象,Sigma参数控制平滑程度 gen_measure_rectangle2 (Row, Column, Phi, Length1, Length2, Width, Height, 'nearest_neighbor', MeasureHandle) * 沿测量矩形提取边缘,阈值参数控制边缘响应强度 measure_pos (ImageReduced, MeasureHandle, 1, 30, 'all', 'all', RowEdge, ColumnEdge, Amplitude, Distance) close_measure (MeasureHandle)参数整定经验:Sigma取1到1.5比较合适,太大边缘被磨得太圆滑,真实位置偏移;Threshold(即边缘幅度阈值)取20到40之间,具体取决于成像对比度。真正头疼的是Length1,也就是测量矩形的半长度。设置太短,边缘可能跑出测量范围;设置太长,周围其他干扰边缘混进来。我的做法是用上一次测量的宽度值加上一个冗余量来动态更新测量矩形长度,让测量窗口始终贴合当前实测的磨损带范围。这个自适应窗口思路在整个项目中是最有价值的实践之一。
4.3 亚像素测量在Halcon与PCL之间的坐标对齐
提一句坐标对齐的坑。Halcon的坐标系原点和OpenCV不同,Halcon的Row方向在上,Column方向在左,和OpenCV的x、y方向不是一个习惯。在把它们对接时,别想当然平移,先用同一个图像上的同一点做一次坐标映射测试,确认行列互换关系没问题之后再写整套数据通路。我当时就是没先测这个,浪费了大半天查一个莫名其妙的偏移。
5. 寿命预测:别急着上深度学习,趋势回归才是真正的生产力
5.1 为什么阈值判断不适合做寿命预测
很多同行做磨损检测,最终输出的就是一个阈值:宽度大于多少毫米,判NG。这个逻辑在离散检测场景能用,但在寿命预测场景里非常鸡肋。因为阈值判定看到的是“当前状态”,而寿命预测需要的是“变化趋势”。胶条磨损在相当长一段区间内是一个缓慢的渐变过程,单看当前值很容易被噪声干扰,但看趋势斜率,稳定且可靠得多。
我采用的是滑动窗口回归加卡尔曼平滑的组合方案:把每个采样周期测得的磨损特征保存到时间序列,滑动窗口取最近N个点做线性回归,得到的斜率就是磨损速率。剩余寿命直接由目标阈值和当前值的差值除以斜率得到。
import numpy as np def predict_remaining_life(history, current_width, fail_threshold, window=15): # history为最近window帧的磨损带宽度序列 x = np.arange(window) y = np.array(history[-window:], dtype=np.float64) # 线性拟合,得到斜率和截距 k, b = np.polyfit(x, y, 1) if k <= 1e-3: return None, k # 斜率接近0,磨损尚未趋势性恶化 remaining = (fail_threshold - current_width) / k return remaining, k这里的窗口长度window是个有讲究的参数。窗口太短,对噪声敏感,斜率忽大忽小;太长,又跟不上磨损的加速变化。我测试下来,采样频率如果是每班三次,窗口取10到15比较合理,大约对应三到五天的数据窗口,既能平滑短期波动,又不会迟钝到掩盖加速磨损阶段的拐点。
5.2 卡尔曼平滑与“连续N帧确认”降低误报
线性回归输出的是原始特征序列的斜率,特征序列本身如果有毛刺,斜率就不稳。所以我先对特征序列做卡尔曼滤波,再送入回归器。卡尔曼滤波的标准代码比较长,贴核心思路:
class Kalman1D: def __init__(self, process_var=0.1, measure_var=1.0): self.x = 0.0 # 状态估计 self.p = 1.0 # 估计误差协方差 self.q = process_var # 过程噪声协方差 self.r = measure_var # 测量噪声协方差 def update(self, z): # 预测 p_pred = self.p + self.q # 更新 k = p_pred / (p_pred + self.r) self.x = self.x + k * (z - self.x) self.p = (1 - k) * p_pred return self.xprocess_var和measure_var的比值决定了滤波器的响应速度,我建议在真实数据上做一次调参,先固定测量方差,然后逐渐增大过程方差,看输出曲线在“跟得上真实变化”和“不过度抖动”之间找平衡。项目里这两个参数的比值大概在0.1左右,效果不错。
光有滤波还不够,工业现场宁可漏报一次也不要不分青红皂白地乱报。我加了“连续N帧斜率超阈值”的确认机制。连续三个采样周期的斜率都超过设定阈值,系统才判定进入加速磨损阶段,触发剩余寿命计算。这样即使偶尔有一帧图像有大块脏污导致特征跳变,也不会让预测结果一起跟着跳。
6. 现场部署的隐形工程:标定、环境光、数据追溯
6.1 像素当量标定:测出来的数据到底是多少毫米
算法在图像里跑的,测出来的宽度是像素,要换算成毫米必须有像素当量。简单做法是放一个已知尺寸的标定板,拍出来数一下像素数,一除就得。但相机视角如果有些倾斜,图像边缘的像素当量跟中心不一样,几毫米的偏差就被放大得很难看。
项目里我给相机加了一个固定高度的夹具,让物距严格保持不变,然后用高精度棋盘格标定板做线性标定,实测像素当量稳定在0.023毫米每像素左右。为什么不搞复杂的畸变校正?因为胶条检测区域集中在图像中心一块区域,光学畸变很小,用中心区域的像素当量来换算是足够可靠的。过度工程在产线上不一定加分,反而引入更多出问题的环节。
6.2 产线环境光的对抗手段
现场环境光是个大变量。车间里有顶灯、有焊接弧光、甚至某些时段阳光会从窗户斜射进来。我把整个检测工位用遮光罩围起来,只留相机镜头和光源的开口,环境光干扰直接下降了一个数量级。光源用直流供电,不建议用开关电源驱动某些LED光源,不然光源本身会有一定频率的高频脉动,图像上会有明显的明暗条纹,在曝光时间短的时候尤其严重。
6.3 测量结果的可追溯性:数据入库比想象中重要
这一点是在项目中期才补上的。用户后来提了一个要求:每片胶条的检测结果、原始图像、测量特征值要能回溯,出现客诉时能翻出当时的检测数据。
于是我在工位机上加了SQLite数据库,每次检测结束,把原图路径、特征值、预测寿命、判定结果一起写入记录。这套比任何花哨的界面都实在,它让寿命预测模型后续有了迭代的土壤,也让现场的判定争议有了依据。
部署完成后我最大的体会是,这个项目真正花时间的不是深度学习模型,也不是调参,而是把“检测”升级为“预测”的过程中,如何保证每个环节采集到的数据是干净、稳定、可对比的。工业视觉里有一句老话:前面成像差一毫,后面算法错一里。做检测类项目时这句体会还不深,做过寿命预测后,彻底服气了。
如果现在让我重新做一个类似的磨损检测项目,我会先花至少三分之一的时间在成像和特征定义上,把“磨损到底是什么”这个问题在图像上彻底想清楚,再聊算法。特征不扎实,预测模型再精巧也是空中楼阁。次之,一定要把数据记录和追溯机制提前设计进去,不要等项目上线后才补——那会儿补的可不只是数据库,还有前面积累的所有原始数据,错过了就再也找不回来了。