
简介针对圆缺陷检测与定位任务本压缩包提供了一套完整的Matlab实现方案面向计算机视觉初学者及工业质检、医疗影像等场景的开发者。内容涵盖图像边缘检测、霍夫变换圆检测、缺陷定位与可视化等关键环节帮助读者理解从边缘提取到形状识别再到异常标注的技术链路。压缩包共6个文件以5个m脚本和1张示例图片为主m文件分别承担图像预处理、边缘检测、圆检测、缺陷标记等功能整体体积仅25KB轻量易读。已有1678人学习浏览适合动手实践。读者可参考其中的Canny边缘检测、霍夫变换参数调优、缺陷对比分析等实现细节并结合示例图片直观查看检测效果为后续引入机器学习或深度学习缺陷分类打下基础。1. 圆缺陷检测到底在检测什么边缘检测不是找圆是找圆的不对劲工业视觉里一大类需求可以浓缩成一句话在一个圆的轮廓上找出哪里凹了、凸了、缺了、多了。无论是轴承滚珠、机油盖密封圈、陶瓷基板还是摄像头镜片表面缺陷的形态千差万别但落到图像处理层面它们的共同特征都是边缘的局部突变。所谓检测圆的缺陷本质上是先拿到圆的完整边缘再做差分最后把差分结果换算成物理坐标告诉产线几点钟方向、径向偏了多少、缺陷宽度多少。图像边缘检测在这里不是终点是第一步——它决定了后续缺陷定位的精度上限。这篇文章我按自己做视觉检测项目的顺序来写从边缘检测选型、圆拟合、极坐标展开到缺陷量化每一步都给可复现的代码和参数最后把踩过的坑摊开说。2. 图像边缘检测选型Canny为主小波和其他算子的边界在哪2.1 为什么Canny是圆缺陷检测的默认选项做圆类零件的缺陷检测我一般不会一上来就上深度学习。传统视觉方案里边缘检测的候选有Sobel、Laplacian、Canny、小波变换但Canny是默认起点。原因有三一是Canny自带非极大值抑制和双阈值滞后连接边缘是单像素宽的后续做圆拟合和缺陷定位不会因为边缘太厚带来系统性偏差二是它对弱边缘的容忍度可以通过两个阈值独立调节这在打光不完美的现场非常实用三是OpenCV里实现成熟调参路径清晰出了问题好排查。Sobel和Laplacian不是不能用而是它们输出的是梯度幅值图边缘是带状的如果你要用最小二乘拟合圆边缘点得先做细化否则拟合结果对阈值切割位置极其敏感。小波变换在缺陷检测里的价值我在后面第6章会展开它的强项是频域局部化适合信噪比低的场景但代价是计算复杂度和参数敏感性都不友好做一个实时检测项目优先把Canny跑通再说。import cv2 import numpy as np gray cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) # 先做高斯模糊sigma1.2核大小按sigma自动推算即可 blurred cv2.GaussianBlur(gray, (0, 0), 1.2) # Canny双阈值低阈值50高阈值120经验上高:低2:1到3:1 edges cv2.Canny(blurred, 50, 120) # 可选闭运算把断裂边缘接上核别太大3x3就够 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) edges_closed cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel, iterations1)这段代码的逻辑分四步转灰度去掉颜色干扰高斯模糊抑制噪声这里核大小写(0,0)是让OpenCV根据sigma自动推算比手动指定7x7或9x9更稳妥Canny双阈值是核心参数低阈值决定边缘起始灵敏度高阈值决定强边缘认定线最后的闭运算只做一次因为圆类零件边缘通常是连续的闭运算能把光照不均导致的小断裂补上但迭代次数多了会把真实缺陷也填平。参数说明sigma1.2适合直径50到500像素的圆如果工件成像直径在300像素以上sigma可以加到1.5到2.0。Canny低阈值低于30时噪声边缘会爆炸高阈值高于200时弱缺陷边缘会被吞掉现场的通用起点是(50, 120)然后依据缺陷样件来回调。2.2 光照和环境对边缘检测的干扰优先级很多新手把边缘检测调参当成纯算法问题实际上现场80%的翻车是光学问题。圆类零件最大的干扰来自两个方向一是镜面反光金属轴承外圈、陶瓷基板边缘会产生高光高光区域的梯度幅值远超真实边缘Canny会在高光边界上画出伪缺陷二是环境光变化同一个参数白天和晚上效果完全不同车间灯管频闪直接影响边缘稳定性。我的一般做法是先定打光再定算法。圆类零件优先用同轴光或低角度环形光目的是让圆的轮廓和背景的灰度差稳定在30到100个灰度级之间。判断打光是否合格的量化标准很简单在ROI内采样边缘附近像素计算边缘两侧灰度差的均值如果标准差超过灰度均值的20%算法再怎么调也是白搭。另一个经验是永远不要依赖自适应阈值做边缘检测的预处理自适应阈值适合文档二值化在工业缺陷检测场景下它会把光照渐变当成边缘反而增加后续圆拟合的噪声。# 用灰度统计快速评估打光稳定性 roi gray[100:300, 200:400] mean_val np.mean(roi) std_val np.std(roi) # 经验判据标准差占比超过20%说明打光不均先调光再调算法 if std_val / (mean_val 1e-6) 0.2: print(打光不稳定边缘检测参数无意义)这段代码的价值在于把打光好坏从主观感受变成可执行判断。np.std算的是ROI内灰度波动如果波动主要来自表面纹理而非光照可以把ROI移到缺陷少的光滑区域重测。这里不是严格统计学检验就是个经验门限现场够用。2.3 Halcon和LabVIEW的对照工业场景的另两条路标题里的热词包含了Halcon和LabVIEW机器视觉这两个在实际产线上覆盖了很大比例。Halcon的优势是亚像素边缘提取算子成熟edges_sub_pix配合fit_circle_contour_xld能直接拿到亚像素圆轮廓在测量类应用里精度比OpenCV高半个量级。LabVIEW的Vision模块则胜在PLC通讯和界面集成快适合设备厂商做整机交付。但这两条路的前提都一样边缘检测的输入质量决定输出精度。Halcon的典型做法是阈值分割后用select_shape筛选圆轮廓再用fit_circle_contour_xld做圆拟合它的鲁棒性来自轮廓点加权离群点缺陷点在拟合时可以被自动降权。OpenCV里对应的圆拟合是cv2.fitEllipse或最小二乘圆拟合但OpenCV没有内置的鲁棒圆拟合需要自己写RANSAC或Tukey权重——这一块我在第4章展开。如果你所在团队已经买了Halcon授权缺陷定位的精度会更好但调试门槛和授权成本也摆在那里。3. 用OpenCV跑通圆缺陷检测最小流程边缘检测→圆拟合→极坐标展开3.1 最小二乘圆拟合拿到圆心和半径是后续一切的基础圆的缺陷定位必须有一个基准坐标系这个基准就是圆心和半径。拟合方法最常见的两种一是cv2.HoughCircles二是对边缘点做最小二乘圆拟合。HoughCircles在圆完整、噪声少的时候好用但它需要调param1和param2两个累加器参数且对边缘断裂敏感。我一般不用HoughCircles做精密定位原因很简单Hough空间离散化本身就带了量化误差像素级定位都费劲追论亚像素。最小二乘圆拟合的思路是从Canny边缘图里拿到所有轮廓点坐标然后用代数最小二乘解圆心(cx, cy)和半径r。Kasa法代数拟合最快但噪声大时圆心会偏向弧段密集的一侧几何拟合距离最小化更准但需要迭代。对圆类缺陷检测圆是接近完整的Kasa法就够了。def fit_circle_kasa(points): 代数最小二乘圆拟合(Kasa法) points: (N, 2) 的边界点坐标 返回 (cx, cy, radius) x points[:, 0].astype(np.float64) y points[:, 1].astype(np.float64) A np.stack([2*x, 2*y, np.ones_like(x)], axis1) b x**2 y**2 # 最小二乘解: (A^T A)^-1 A^T b result, _, _, _ np.linalg.lstsq(A, b, rcondNone) cx, cy, w result r np.sqrt(w cx**2 cy**2) return cx, cy, r这段代码是Kasa法的标准实现。关键点在w那一项最小二乘解出的第三个分量不是半径本身而是r^2 - cx^2 - cy^2所以半径要最后一步算回来。np.linalg.lstsq比手写正规方程更稳圆拟合的A矩阵条件数在不同直径下差别很大手写inv(A^T A)容易数值不稳。如果边缘点有几万个这个函数的耗时在毫秒级实时性完全够。用这个函数前先把Canny的edges图转成坐标点ys, xs np.where(edges_closed 0) contour_points np.stack([xs, ys], axis1) # 均匀采样避免某一段弧线点太多拉偏拟合 if len(contour_points) 4000: idx np.random.choice(len(contour_points), 4000, replaceFalse) contour_points contour_points[idx] cx, cy, r fit_circle_kasa(contour_points)这里的均匀采样是容易被忽略的细节。如果圆轮廓上有缺陷缺陷点的密度并不均匀不做采样直接拟合圆心会被缺陷密集区拉偏。随机采样4000点是对精度和速度的折中直径300像素的圆周长约940像素4000点已经是过采样足够稳定。3.2 极坐标展开把圆上缺陷拉直成一维信号得到圆心和半径之后核心技巧是极坐标展开。缺陷在直角坐标系里是环形区域上的局部异常直接在这个环形区域上检测需要处理扇形兴趣区、边缘干扰、角度分辨率不均匀等问题。把环形区域沿径向和角度方向重采样成矩形图检测就从二维区域异常变成一维行信号上的脉冲难度下降一个量级。def polar_unwrap(img_gray, cx, cy, r_in, r_out, angle_steps720, radial_steps50): 把环形区域展开成矩形 r_in: 内半径(像素), r_out: 外半径(像素) 返回 (H, W) 的极坐标图行对应径向列对应角度 angles np.linspace(0, 2*np.pi, angle_steps, endpointFalse) radii np.linspace(r_in, r_out, radial_steps) # 构建采样网格 angle_grid, radius_grid np.meshgrid(angles, radii) xx cx radius_grid * np.cos(angle_grid) yy cy radius_grid * np.sin(angle_grid) # 双线性插值采样 polar_img cv2.remap(img_gray, xx.astype(np.float32), yy.astype(np.float32), interpolationcv2.INTER_LINEAR) return polar_img展开后图像的宽度对应角度0到360度高度对应径向距离内半径到外半径。列方向用720个像素表示一圈相当于每个像素对应0.5度这个分辨率已经超过绝大多数工业检测需求径向取50层覆盖圆环的宽度。cv2.remap是整套流程的性能关键点它的双线性插值在优化过的C实现里跑比用for循环逐像素采样快两个数量级。展开图做好后缺陷检测逻辑变得非常直观正常圆环展开后是灰度均匀的条带缺陷表现为条带上的暗点或亮点。这时cv2.blur配合差分就能把缺陷找出来。3.3 缺陷粗检背景差分和形态学过滤极坐标图上的缺陷检测我最常用的是平滑差分阈值分割。原理很简单正常区域的灰度在局部是平滑的缺陷区域的灰度是突变的。用一个较大的均值滤波核估计背景原图减背景得到差分图差分图里绝对值大的像素就是候选缺陷。# 展开图平滑核大小用(1, 15)只在角度方向上平滑 background cv2.blur(polar_img, (1, 15)) diff cv2.absdiff(polar_img, background) # 自适应阈值均值 3倍标准差 thresh_val np.mean(diff) 3 * np.std(diff) _, defect_mask cv2.threshold(diff, thresh_val, 255, cv2.THRESH_BINARY) # 形态学过滤太小的点不是缺陷是噪声 defect_mask cv2.morphologyEx(defect_mask, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)))这里的核(1, 15)是刻意设计的高度为1是因为径向方向上正常边缘本身有灰度变化从亮到暗不能在径向平滑宽度为15是因为只在角度方向上估计背景这样缺陷在角度方向上的局部突变才能被差分保留。thresh_val mean 3*std是个通用门限标准差反映整体噪声水平3倍的关系对应正态分布下约0.3%的误检率——但图像噪声不严格正态所以这只是一个起点现场按缺陷样件微调。到这一步你已经能从defect_mask里找到有缺陷的像素了但还回答不了产线最关心的两个问题缺陷在哪个角度、哪个径向位置。这就是下一章的内容。4. 缺陷定位与量化从画个框到告诉产线偏了多少4.1 把像素坐标换算回物理坐标角度和径向距离怎么算展开图上的一列对应一个角度一行对应一个半径。把掩码里的每个缺陷像素(row, col)换算回物理位置公式是def polar_to_physical(row, col, cx, cy, r_in, r_out, radial_steps50, angle_steps720): 极坐标图坐标 - 原图物理坐标 row: 0~radial_steps-1, col: 0~angle_steps-1 radius r_in (r_out - r_in) * row / (radial_steps - 1) angle 2 * np.pi * col / angle_steps x cx radius * np.cos(angle) y cy radius * np.sin(angle) return x, y, angle, radius这段代码里最容易出错的地方是角度起始方向。np.cos(angle)对应的是x轴正方向3点钟方向如果你希望0度在12点钟方向需要把角度减去90度即angle 2*np.pi*col/angle_steps - np.pi/2。我因为这个细节在客户现场翻过车——报告上写的3点钟方向缺陷实际在6点钟方向后来统一在输出函数里把角度基准写死并加注释避免每次换算都重新想一遍。有了坐标换算缺陷报告可以精确写成在直径89.2mm的环带上距圆心31.4mm处偏角47.2度缺陷宽度2.3mm。产线工人不需要看图像直接按坐标去找就行。4.2 缺陷参数计算最大弦长、径向跨度和面积缺陷严重程度不能只看像素多少。一个长的细划痕和一个短的宽凹坑像素面积可能一样但对密封性能的影响完全不同。我一般量化三个指标最大弦长缺陷掩码在角度方向上的最大跨度对应这个缺陷绕着圆周走了多长径向跨度缺陷掩码在径向方向上的最大跨度对应缺陷吃进去了多深像素面积快速粗筛用的面积小于阈值的直接忽略def measure_defects(defect_mask, pixel_per_mm): defect_mask: 二值图255为缺陷 pixel_per_mm: 标定好的像素/毫米比例 n, labels, stats, _ cv2.connectedComponentsWithStats( defect_mask, connectivity8) defects [] for i in range(1, n): # 0是背景 x, y, w, h, area stats[i] if area 5: # 小于5像素的忽略 continue defects.append({ bbox_xywh_px: (x, y, w, h), area_mm2: area / (pixel_per_mm ** 2), chord_len_deg: w / defect_mask.shape[1] * 360, radial_span_mm: h / pixel_per_mm }) return defectsconnectedComponentsWithStats返回的stats矩阵里第2、3列是连通域外接矩形的宽和高正好对应角度方向和径向方向的跨度。宽除以展开图总宽度再乘以360度就是缺陷在圆上的角度弦长高除以pixel_per_mm就是径向的物理深度。这里有个细节弦长用外接矩形的宽来近似对弯曲的弧线缺陷会偏大但作为产线分级的输入已经够了如果要精确弧长得沿角度方向做累积投影复杂度不高但代码量上去了我这里不展开。4.3 标定像素和毫米的换算必须做对所有量化指标都依赖pixel_per_mm这个标定的准确度直接决定报告的数值可信度。常见做法是放一个已知直径的校准圆比如直径10mm的精密钢珠跑一遍同样的流程用fit_circle_kasa拟合出半径像素值然后除以物理半径calib_diameter_mm 10.0 calib_radius_px r # 前面拟合出来的半径单位像素 pixel_per_mm calib_radius_px / (calib_diameter_mm / 2.0)就这么一行但很多项目死在这里。坑有两个一是校准件和被测件不在同一个工作距离上镜头景深不够的时候离焦导致的边缘模糊会让拟合半径偏大或偏小几个像素在0.1mm精度要求下这是致命的二是校准件摆放位置不在视野中心镜头畸变会让边缘的拟合半径随位置变化所以校准件的摆放位置必须和被测件的实际位置保持一致。如果视野大、精度高用一张棋盘格做畸变标定再配合单应性校正这是另一个话题但原理和这个一样。5. 圆缺陷检测的避坑指南5个高频翻车点与排查方法5.1 边缘断裂导致圆拟合半径偏小现象Canny边缘图里圆轮廓有断续最小二乘拟合出的半径比实际小1到2个像素。原因边缘断裂意味着参与拟合的点变少Kasa法对短弧段的拟合会系统性偏向圆心方向。圆轮廓缺了三分之一半径误差能达到5个像素。解决不要急着换拟合算法先用形态学闭运算把断裂接上。如果闭运算后还是断说明光照均匀度不够回到打光环节。拟合前一定要看轮廓点数少于周长像素数的80%就需要警告。5.2 内圆和外圆同时成像时拟合错圆现象轴承端面有内外两个圆环边缘图上两组圆同时存在拟合结果在内外圆之间跳变。原因np.where(edges 0)把所有边缘点都拿进来了两组圆的点混在一起最小二乘只能给你一个平均圆。解决先用半径先验把边缘点分组。比如已知外圆半径约200像素就只保留距离初始圆心180到220像素范围内的边缘点再做拟合。我习惯用cv2.HoughCircles先粗定位圆心再用半径范围过滤两层保险。5.3 极坐标展开图上的伪缺陷径向渐变被当成缺陷现象展开图在无缺陷区域有明显的明暗条纹差分后误报一堆。原因环形光从内圈到外圈的照度本身不均匀展开图在径向方向上有灰度渐变。cv2.blur(polar_img, (1, 15))只在角度方向平滑径向的渐变没有被背景估计捕捉到。解决差分之前先对每一行做高斯拉普拉斯滤波抑制低频渐变。或者在打光时加一层漫射板让环形光更均匀。这两个办法都试过后者效果更彻底。5.4 高光反射边缘误判为缺陷现象金属工件表面有一道亮线展开图里是明亮条带差分后显示为缺陷。原因高光区域的灰度值接近饱和255平滑背景时被拉高但高光边缘的梯度仍然超过阈值。解决高光区域在极坐标图里通常表现为整行整列的亮带而真实缺陷是局部孤立的。用形态学开运算可以去掉细长亮带或者在缺陷筛选阶段加一个条件缺陷的角度跨度小于30度且径向跨度小于半径的20%不满足就认为是反光。5.5 同一套参数在不同工件间误检率波动大现象同一套Canny参数上午检测OK下午误检率高了一倍。原因工件来料批次不同表面氧化程度、油污情况不同导致边缘梯度幅值分布整体偏移。更隐蔽的原因是环境光——车间灯光在一天内色温和亮度都会变。解决做参数自适应。每次检测前在当前图的边缘梯度分布上取分位数动态设定Canny的高阈值。具体做法是对梯度幅值图做直方图统计取98分位数作为高阈值低阈值取高阈值的一半。这样参数跟随图像内容变化比固定值稳得多。# Canny自适应阈值梯度幅值的分位数 gx cv2.Sobel(blurred, cv2.CV_64F, 1, 0, ksize3) gy cv2.Sobel(blurred, cv2.CV_64F, 0, 1, ksize3) mag cv2.magnitude(gx, gy) high_thresh np.percentile(mag, 98) low_thresh high_thresh * 0.5 edges_adaptive cv2.Canny(blurred, low_thresh, high_thresh)这段自适应阈值的逻辑Sobel算梯度幅值np.percentile(mag, 98)取梯度幅值的98分位数。98%的像素是背景或正常边缘2%的像素是强边缘或缺陷——这个先验对大多数圆类工件成立。如果工件本身纹理多分位数可以调到95纹理少可以到99。自适应参数不是万能的但能解决一天调三次参的痛点。6. 进阶亚像素边缘与小波去噪的实战验证前面所有边缘检测都是像素级的。当检测精度要求进入0.05mm以内像素级就不够用了——一个像素对应0.1mm时0.05mm的缺陷在像素级检测里只有半个像素的响应稳定复现几乎不可能。我的做法是引入亚像素边缘提取。OpenCV里没有现成的edges_sub_pix但可以用cv2.findContours拿到像素级边缘后沿梯度方向做抛物线插值def subpixel_edge(gray, contour_point, gradient_dir): 沿梯度方向做三点抛物线插值返回亚像素坐标 contour_point: 整数像素坐标 (x, y) gradient_dir: 单位梯度方向 (dx, dy) x, y contour_point dx, dy gradient_dir p1 gray[y - dy, x - dx] p2 gray[y, x] p3 gray[y dy, x dx] denom (p1 - 2*p2 p3) if abs(denom) 1e-9: return (float(x), float(y)) delta 0.5 * (p1 - p3) / denom return (x dx * delta, y dy * delta)插值原理很简单真实边缘在灰度梯度极值点附近呈抛物线分布三个采样点的灰度值可以拟合出极值点的偏移量delta。这个偏移量就是亚像素修正。实测中能把边缘定位重复精度从0.3像素提升到0.1像素左右——注意这需要信噪比足够好如果图像本身模糊亚像素插值只是把噪声插得更精致。小波去噪则是另一个方向的补充。当工件表面有细纹理而缺陷信号也处于高频段时Canny会把纹理和缺陷一起检出来。小波分解可以把图像拆成低频近似和高频细节对高频细节做软阈值收缩抑制纹理响应再重构回去。PyWavelets包可以做这个import pywt def wavelet_denoise(img, waveletdb4, level2, threshold_scale0.8): 小波软阈值去噪去除细纹理保留强边缘 coeffs pywt.wavedec2(img, wavelet, levellevel) # coeffs[0]是低频其余是高频细节 detail_coeffs coeffs[1:] # 估计噪声标准差取最高频细节的中位数绝对偏差 cH, cV, cD detail_coeffs[-1] sigma np.median(np.abs(cD)) / 0.6745 # 软阈值处理 new_detail [] for detail in detail_coeffs: new_detail.append(tuple( pywt.threshold(d, sigma * threshold_scale, modesoft) for d in detail )) # 重构图像 new_coeffs [coeffs[0]] new_detail return pywt.waverec2(new_coeffs, wavelet)这里的sigma估计用的是经典的中位数绝对偏差法小波最高频细节系数里噪声占主导中位数除以0.6745就是高斯噪声标准差的无偏估计。threshold_scale0.8意味着阈值取噪声标准差的0.8倍——低于这个阈值的细节系数被归零这就是抑制纹理噪声的核心操作。但要注意如果缺陷本身很细小小波去噪也会把它一并抹掉。所以小波去噪适合表面纹理密、缺陷面积大的场景不适合镜面光洁表面上的微小划痕。做视觉缺陷检测这几年我最深的体会是参数永远服务于物理世界。同一个缺陷换一种光源、换一个角度图像的响应完全不同。不要迷信某一个算法也不要轻视某一个细节——从边缘检测、圆拟合到极坐标展开每一环的误差都会累积到最终定位精度上。先把流程跑通再逐环节量化误差归属是最快的路径。希望这篇笔记能帮你在自己的项目里少走几段弯路。本文还有配套的精品资源点击获取