最近因为产线要盘点一批LED料盘,我在实验室搭了一套基于Matlab的LED元器件计数系统。一开始我天真地以为,数LED嘛,把照片里亮的地方找出来不就行了?真正拍回来才发现,LED表面的镜面反光、料盘的透明纹路、引脚阴影,一样比一样坑。这篇文章是完整梳理这个项目的现场记录,包含为什么非用Matlab不可、图像处理流程怎么定参、实测精度能到多少,还有我踩过的几个典型问题。如果你也在做元器件视觉计数,或者想用图像处理做小批量盘点,这篇内容应该能帮你省下不少试错时间。
1. 这个项目到底在解决什么问题
1.1 为什么需要给LED数数
LED这个词在硬件圈里实在太常见,但很少有人专门为它写一套光学计数流程。实际上,无论是最小号的贴片发光管,还是常见插件LED,出厂的时候多数是编带包装,一盘料就是几百到几千颗。客户来料验收、仓库盘点、上料前的余量确认,全都绕不开一个基础动作——数一数盘里到底还有多少颗。过去大家靠人工数,或者干脆用称重估算,实际误差经常让人头疼,因为不同批次的LED重量偏差不小,编带的余料长度也不均,称一称很难算准。
我做的这套系统,本质上是用图像告诉用户:照片里这批LED里,一共出现了多少个有效的元器件。它解决的痛点非常具体:批量盘点效率低、人工数数容易疲劳出错、传统的机器视觉方案要么太贵要么得专门配置专用光源。用Matlab写这套系统,优势在于从原型验证到处理流程调整都很顺滑,不需要工业相机SDK那样绕一圈再进算法。
这套计数方案不只是能用在上料盘,凡是规则排列、背景可控、外形清晰的贴片类元件,比如电阻电容,甚至一些小的五金件,思路都是一模一样的。所以这篇内容看起来是LED专用,实际提炼出来的是一套“先分割后计数”的可复用视觉逻辑。
1.2 Matlab在这个场景里凭什么合适
选Matlab不是因为它是什么时髦框架,而是这个问题本身的尺度决定了它最顺手。LED计数不是一个需要海量数据训练的复杂识别任务,它更多依赖稳定背景下的图像分割和连通域统计。这一块恰好是Matlab图像处理工具箱的强项。我只需要用一个imread读完图,再用几个形态学操作把噪声滤掉,最后regionprops一算,面积、质心、包围盒全出来。从处理像素到统计目标的完整链路,一个脚本就能捋顺,这对需要现场快速调参的场景太重要了。
相比Python那边需要自己拼OpenCV加matplotlib或者scikit-image,Matlab在交互调参上明显更高效。figure里直接拖动阈值观察二值图变化,Live Script里可以反复改Sensitivity然后立刻看到结果。你可以在半小时内模拟出好几种光照条件下的分割效果。对于一个偏重光电装配和产线导入的工程师来说,这比写完整软件框架更友好。
说句实在话,深度学习方案也不是不能做。但如果只是为了给一排LED计数,就去采集几万张标注图来训练目标检测模型,明显用力过猛。LED的光学特征非常明确,背景又相对可控,用经典的阈值分割加形态学处理完全够用。除非你的来料姿态非常混乱,比如完全散堆、重叠遮挡一大堆,那才需要考虑深度学习。就我当前这个稳定摆盘的应用场景来说,传统图像处理更稳定,也更容易被现场接受。
2. 系统整体架构与关键设计决策
2.1 采集端的三个关键选择
图像计数系统的源头不是算法,而是采集。第一次做这种项目的人往往上来就调阈值,结果发现现场换了一盘料就不灵了。根源多半是图像就没拍稳。我这边搭的是固定高度的载物台,CCD工业相机朝下俯拍,配合一个低角度环形光源。这种结构下,LED轮廓和背景之间的边界比较一致,后续处理才有通用性。
第一个选择是相机。普通USB摄像头拍静态图也能用,但曝光和色偏波动比较大。工业相机虽然贵一点,胜在曝光时间固定、增益可调,长期做批量检测更放心。没有工业相机时,用手机固定在三脚架上也能走通整个流程,只是一个位置摆放好就不能随便动,不然标定和参数全得重来。第二个选择是光源角度。LED是强反光体,顶部直射光会在表面形成高亮光斑,直接把分割变成噩梦。我试下来,低角度环形光可以把元件的四周照清楚,中心又不至于一片白闪。有条件的话,背光方案会更干净,LED轮廓会变成清晰剪影,计数准确率最高。
第三个选择是背景和载物设计。原来我直接用白纸垫底,发现透明料盘和白纸叠在一起根本分不清边界。后来换了磨砂黑色亚克力板,反光干扰明显下降。如果做在线部署,还需要固定料盘位置,保留一个机械限位,避免拍照时位置漂移。
2.2 算法管线怎么搭才合理
整套软件的逻辑其实很直白,核心就五步:读图、预处理、二值分割、形态学清理、连通域统计。听起来简单,真要做到稳定可靠,每一步都得配合现场场景去微调。我把这些步骤拆成了独立函数,每个函数负责一个小环节,比如preprocessImage.m只管灰度化和降噪,segmentLED.m只管返回二值掩膜。这样调试的时候,只改一个环节的参数,不会牵连到别的逻辑。
为了现场操作方便,我还在Matlab里做了个简单的App界面,用App Designer放了“导入图像”“开始计数”“显示结果”三个按钮。界面本身不是核心,但没有界面的话,操作员每次都得敲命令行,容易误改参数。我尝到甜头之后,又把每次处理的参数自动存成配置文件,下次启动直接读取,避免同一个脚本今天一个样子明天另一个样子。
这里也想聊一下Matlab OOP架构的事。很多同学喜欢把一个几十行的脚本从开头写到尾,确实对这种一次性分析够用。但项目一旦要长期维护、现场部署,还是建议至少用类来封装相机参数、处理链条和结果表格。我第二版就是把全局变量全部收进一个LedCounter类里,属性和方法边界清楚,后面加功能方便很多。
3. 核心算法的实现与参数选择
3.1 预处理:从一张LED料盘图到干净的二值图
先把一张实拍图放进来跑处理流程。假设你已经把彩色图读进了Matlab,第一步是转灰度。这一步没什么好说的,因为后续所有操作都不依赖颜色信息。紧接着我会做imadjust对比度拉伸,最简单的实现是imadjust(gray),它在默认范围内拉高灰度对比度,让暗色元件主体和浅色料盘背景之间的差距更明显。
然后是降噪。光照稍微有点杂散时,图像上会产生细小颗粒。中值滤波medfilt2(gray, [3 3])能有效滤掉椒盐类噪声,同时比均值滤波更保留边缘。如果你的图像分辨率很高,可以先用imresize把长边缩到2000像素左右,处理速度快不少,而且对于计数这种宏观任务,分辨率不需要拉到极大值。
预处理里还有一招我是后来才加进去的:彩色空间分离。LED外壳反光非常厉害,灰度图上很容易出现“亮本体、暗引脚、亮反光斑”这种复杂灰度结构。如果只靠灰度图做阈值,很难把高亮点和元件轮廓同时区分清楚。这时可以转到HSV空间,用Saturation和Value通道做加权融合。更简单的做法是保留灰度阈值作为主体分割,再结合原始图的高亮区域做反光点掩膜,之后通过形态学填洞把反光点重新归回元件本体。
3.2 分割与连通域分析:真正数出数量的地方
图像分割我是用imbinarize完成的。Matlab的imbinarize支持Otsu全局阈值和自适应阈值两种模式。在光照比较均匀的背光场景下,Otsu一把梭就够了。但在实际车间,光照不那么理想,我建议直接用自适应阈值:
bw = imbinarize(denoisedGray, 'adaptive', ... 'ForegroundPolarity', 'dark', 'Sensitivity', 0.45);ForegroundPolarity参数很关键,它告诉算法你要分割的是比背景暗的前景,还是比背景亮的前景。因为多数LED外壳比浅色料盘暗,所以用dark。如果是透明支架的LED,你可能得反过来设。Sensitivity控制分割灵敏度,调大容易把阴影也分出来,调小可能肢解元件本体。我最开始设的0.5,结果把引脚阴影也算成一个独立连通域,数量整整多出一倍,后来慢慢调到0.4到0.45之间才稳定。
拿到初始二值图后,要做两件事:开运算去噪和填充内部空洞。LED表面如果出现反光斑,二值图里本体就会变成“空心饼”,一个元件被识别成两个区域。我一般在分割后立刻补bw2 = imopen(bw, strel('disk', 2));去掉零星小点,再bw2 = imfill(bw2, 'holes');把反光空洞补上。
接下来就是核心的一步:
cc = bwconncomp(bw2, 8); stats = regionprops(cc, 'Area', 'Centroid', 'BoundingBox', 'Eccentricity');bwconncomp会找到所有二值图中的连通区域,也就是算法眼中的“候选元件”。8表示用8邻域计算连通性,意思是上下左右加对角只要碰到就算一个区域,这对贴片类元件更合适,因为它们在图像中往往轻微粘连,用4邻域反而会拆散边缘。
这里给出一个完整的核心计数函数片段,是我目前实测可用的版本:
function tbl = countLEDs(grayImg) denoised = medfilt2(grayImg, [3 3]); bw = imbinarize(denoised, 'adaptive', ... 'ForegroundPolarity', 'dark', 'Sensitivity', 0.42); bw = imopen(bw, strel('disk', 2)); bw = imfill(bw, 'holes'); cc = bwconncomp(bw, 8); stats = regionprops(cc, grayImg, 'Area', 'Centroid', ... 'BoundingBox', 'Eccentricity'); minArea = 80; % 根据实际分辨率调整 maxArea = 3000; % 防止把料盘纹理识别成元件 okIdx = [stats.Area] > minArea & [stats.Area] < maxArea; finalStats = stats(okIdx); tbl = struct2table(finalStats); tbl.Index = (1:height(tbl))'; tbl.Count = height(tbl); end比例上,最小面积这个参数不能拍脑袋设。我的做法是:先取一张包含单个LED细节的放大截图,数出它所占像素面积,然后用这个值的0.5倍作为minArea下限,2到3倍作为上限。如果同一盘料里混有多种规格LED,面积区间要适当放宽,后面用长宽比或偏心度再二次筛选。
3.3 计数逻辑与边界情况的处理
regionprops统计出来的直方区域不都是LED。料盘的定位孔、印刷标记、边缘压痕,以及料带上的圆形透明窗口,稍有不慎也会被分割成前景区域。这时候不能光靠面积,还要引入形状特征。LED封装大多数是矩形或接近矩形的梯形,Eccentricity也就是偏心率,可以很好区分细长条和高宽比悬殊的干扰。若目标的偏心率接近1,说明这个区域特别狭长,大概率不是正常元件,我会把它过滤掉。
料盘边缘的半个元件也是容易出问题的地方。如果一张图正好拍了料盘边界,有些LED只露了半边甚至一个角,既不能算一个完整元件,但也不能完全忽略。我实验后认为,对计数类需求,最好统一策略:要求连通域与图像边界不能直接接触。可以直接用BoundingBox判断,只要包围盒贴到图像边缘,就把它另存到一个“边缘待确认”列表,而不是直接计入总数。实际盘点时,边缘半截元件通常对应着正在供料中的情况,单独提示人工处理更稳妥。
边界还有一个问题是视野内一排LED之间出现大面积连通。造成这种粘连的主要原因是暗场阴影把相邻元件之间的间隙填平了。当两个元件被误连成一个连通域时,区域面积会异常大,计算出来的数量自然偏低。这个我在第5章专门讲怎么处理,这里先留个悬念。
4. 实测过程与结果验证
4.1 实验数据准备与标定
搭建好系统后,我没有直接拿一整盘料去测,而是先做了小样本标定。我从料带里剪下了几段,分别编号为A、B、C三组,A组是50颗的连续料带,B组是100颗但中间有两颗缺料空位的样件,C组是我故意用手摆成稍微倾斜角度的散装样品,用于测试系统对姿态变化的容忍度。每组样品我都在人工计数确认后,放到载物台下拍照。
背景的光照条件也要标定。我分别在三种环境下拍了对照图:低角度环形光、顶部直射光、背光透射光。这轮对照实验非常值得做,它能直接告诉你光源方案对算法成功率的权重有多大。反正我的结果很清楚:顶部直射光下,单个LED的反光斑导致分割严重撕裂,准确率甚至低于80%。背光透射光下,LED轮廓一片清晰,面积特征稳定,准确率接近100%。
标定的另一个输出是像素尺寸换算。如果不需要知道每一个LED的物理位置,只求数量,这一步可以省略。但如果你想把结果同步给贴片机或者用于坐标引导,那就要用棋盘格标定板先算好每毫米对应多少像素,再对Centroid坐标做换算。
4.2 结果对比与误差分析
测试时我在每张图上运行同一套参数,把系统输出与人工计数对照,结果整理成表格如下:
| 样品编号 | 实际数量 | 系统计数 | 偏差 | 备注 |
|---|---|---|---|---|
| A-背光 | 50 | 50 | 0 | 完全一致 |
| B-背光 | 98 | 98 | 0 | 缺料空位被正确忽略 |
| C-背光 | 41 | 42 | +1 | 边缘反光把空位误判为LED |
| A-环光 | 50 | 45 | -5 | 两个LED粘连,面积过界被滤除 |
| A-顶光 | 50 | 38 | -12 | 反光导致严重分裂 |
从表格里能看出来,背光条件下系统稳定性非常好,差的那1个是因为样品中有一个透明支架的LED反光太强,反光斑形成独立连通域。后来我在形状筛选里增设了偏心率阈值,这个误判也被消除了。A-环光场景里的-5漏洞,让我开始认真对待粘连分割,这直接推动了我后来写分水岭处理的代码。
除了准确率,我还统计了处理耗时。一张2048×1536的彩色图像,跑完整个预处理加计数流程,在我的主力工作机上是0.18秒左右。如果用批量文件夹遍历,Matlab连续处理50张图也不会超过12秒。对一次盘点操作或者产线看板刷新来说,这个速度完全够用,不需要GPU。
4.3 批量使用时的效率优化
测试完成后,我把脚本升级成批量模式。用dir扫描一个文件夹里的所有成品图,循环处理并把结果汇总成一张Excel表格,里面自动填好每张图的文件名、识别数量、识别时间。这个功能在实际盘点中非常实用,因为用户经常一次性拍几十盘料,希望最后拿到一份总账。
imageDir = 'D:\LED_Count_Images\'; files = dir(fullfile(imageDir, '*.png')); results = table(); for k = 1:numel(files) img = imread(fullfile(imageDir, files(k).name)); if size(img, 3) == 3 img = rgb2gray(img); end tbl = countLEDs(img); results = [results; table({files(k).name}, height(tbl), 'VariableNames', ... {'FileName', 'LEDCount'})]; end writetable(results, fullfile(imageDir, 'summary.xlsx'));有一点必须提醒:循环里每次读取图像都做fullfile路径拼接比较耗资源,大批量处理时可以先在内存里预加载图像列表。另外,writetable后检查一下编码,中文文件名的乱码问题可以通过统一文件命名规则来规避。
5. 常见问题与排查技巧实录
5.1 计数对不上,到底卡在哪一环
我最常被问到的问题是“为什么我数出来就是不对”。这种问题往往不是算法本身崩了,而是某个环节的参数跟现场不匹配。第一步我建议先在原图上叠加显示二值掩膜。就这一招,能快速定位问题出在分割、过滤还是后续统计。
imshow(img); hold on; visboundaries(bwMask, 'Color', 'r', 'LineWidth', 1); hold off;如果看到红色边界把料盘纹理也框进去了,说明面积下限太低或者形态学开运算不够;如果发现很多LED中间是空的,说明imfill没有生效;如果红边界形状明显比正常LED大一圈,多半是两个元件被粘连到了一起。这不是猜谜,是一眼就能看出来的实际逻辑。
还有一个经常被忽略的坑:图像的保存格式。截图软件或手机拍出来的JPEG会引入压缩噪声,阈值分割时这些小噪声可能被放大成一条条伪轮廓。我后来统一要求保存为PNG或者无损BMP,计数结果立刻稳定了。调了好久的参数,最后发现是图像格式的锅,这种教训说多了都是眼泪。
5.2 两颗粒料被连成一个区域,怎么拆开
粘连问题是贴片元件视觉计数里最典型的一种事故。两个LED挨得近、间距又被阴影填充时,连通域会把它们看成一块大区域。最简单的对策是形态学腐蚀。先对二值图做一次半径为2或3像素的腐蚀,让粘连处断开,再统计连通域。但腐蚀会缩小正常元件的面积,面积的上下限也要跟着调整。腐蚀掉2个像素后,面积大约会缩小一圈,尤其在分辨率比较低的情况下,误差不可忽略。
如果腐蚀拆不开,就上分水岭分割。这是视觉里处理粘连目标的经典手段,可以用距离变换找到每个目标内部的“峰”,再在峰之间划定分界线。Matlab实现起来不长:
D = -bwdist(~bwMask); L = watershed(D); % L中每个区域对应一个分割后的目标需要注意,分水岭在噪声大的图上容易过分割,一个LED被切成好几块。配合imimposemin设置标记点,可以把过分割压下去。我在实际测试中比较了腐蚀和分水岭两种方案,结论是:数量少、粘连不严重时,腐蚀更稳定;批量处理、粘连存在位置随机时,分水岭可以救回来不少,要配合面积过滤结果。
5.3 长期使用时,怎么保持稳定性
计数系统不是跑通一次就完事了,LED来料的批次、颜色、料盘材质都可能变化。透明LED和白色LED的灰度直方图分布完全不同,如果只设一套全局参数,很快会碰到误判。我的经验是做一个简单的切换逻辑,根据图像的平均灰度或饱和度自动选择几组预设参数。
相机长期使用还可能产生亮度漂移,尤其LED光源有一定寿命,用几个月后照度会下降。现场如果发现计数开始异常,不要着急改阈值,先拍一张标准灰阶卡或者标准料盘做亮度校准。每次开机后也建议拍一张基准图存档,通过比较灰度均值判断环境光是否发生明显变化。
界面里最好再放一个“结果复核”按钮,每次判完把二值掩膜和统计表格同时显示出来,操作员肉眼快速确认一遍。这在生产现场不是多此一举,反而是避免批量错账的保险。机器给一个结果,人再做最终裁决,效率和准确率才能同时保住。
这套系统后续想再扩展,可以把计数结果直接对接ERP库存表,或者把每颗LED的质心坐标输出给自动贴片设备做引导。我在实际使用中体会最深的一条是:不要迷信复杂算法,先把采集环境和图像格式搞稳定,后续的所有处理都能事半功倍。毕竟,一张干净的图片,胜过十个精心调过的参数。