ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

镀金散热器表面缺陷语义分割数据集制作全流程实录

镀金散热器表面缺陷语义分割数据集制作全流程实录

如果你最近也在跟金属表面缺陷识别打交道,尤其碰到镀金钨铜合金散热器这类高反光、低对比度的小目标,应该能明白我为什么要把这套分割数据集单独整理成一个项目。这个项目前后跑了两个月,最终得到1000张labelme格式的标注图,覆盖污渍和划痕两个缺陷类别,图像分辨率并不高,甚至可以说是典型的低分辨率工业小图。但实测下来,这套数据用来训练语义分割模型,效果完全可以支撑产线初筛。接下来我把采集、标注、转换、训练和踩坑过程全部摊开讲,适合正在做工业表面缺陷检测、或者刚接触语义分割数据集制作的朋友参考。

1. 项目背景:为什么给镀金散热器做像素级缺陷检测

1.1 镀金钨铜合金散热器是什么,表面的“脏”和“伤”有多大影响

钨铜合金是把钨的高熔点、低热膨胀系数和铜的优良导热导电能力组合起来的材料,常用于大功率半导体器件、微波组件、激光器模块的散热基座。为了让表面具备更好的可焊性、耐腐蚀性和稳定的接触电阻,很多散热器会在机加工之后再做化学镀镍和镀金处理。镀金层一旦出现污渍或者划痕,不只是外观问题。污渍可能说明清洗环节残留了助焊剂或油污,这会直接影响后续焊接可靠性,严重时还会造成局部接触电阻升高;划痕则可能破坏镀层,让底下的镍层甚至钨铜基体暴露,在湿热环境下引发腐蚀,缩短器件寿命。

我最初接这个项目时,产线上一大半缺陷还是靠老师傅用肉眼在高亮台灯下看的。看一个散热器要转好几个角度,因为镀金表面反光太强,划痕在某个角度下可能完全看不出来。人工检测的问题很直接:疲劳之后漏检率上升、判断标准不统一、缺陷没法量化。尤其是镀金表面的“高光”和“划痕”混在一起时,老师傅都要拿放大镜凑近了看,更不要说流水线上的新人。这时候就需要一个能自动识别污渍和划痕的视觉方案,而且最好能做到像素级分割,而不是只给一个“有没有问题”的结论。

1.2 为什么选分割而不是分类或目标检测

做过工业视觉的朋友应该知道,常规排序是先用分类判断“有没有缺陷”,再用目标检测框出位置。但在这个项目里,我一开始就排除了这两个方案,直接锁定语义分割。原因很简单:划痕是细长条目标,一个矩形检测框会把大量背景包进来,IoU天然就低;污渍的形状又完全不规则,可能是一片油膜、一摊水迹,也可能是沿着边角扩散的变色区域,目标检测框很难贴合实际缺陷。

分割输出的是一张和原图同尺寸的mask,每个像素都告诉你是背景、污渍还是划痕。这样下游可以轻松计算缺陷面积、划痕长度、缺陷占比,再根据客户标准去做缺陷评级。比如同样一条划痕,在散热器边缘和散热器中心位置对可靠性的影响完全不同,分割结果配合坐标就能做更精细的判定。反过来,如果只是做产线初筛,分类模型也能用,但当现场要求“把缺陷区域框出来交给工艺复查”时,分割就是最合适的粒度。

1.3 这套数据集的三条底线:1000张、2类别、低分辨率

很多人在工业采集上一味追求“高清大图”,觉得分辨率越高越能保留缺陷细节。实际上产线视觉系统受视野、帧率、曝光时间和存储带宽限制,经常只能输出几十万到两百万像素的小图。这套数据集的定位很明确:在低分辨率图像上训练出可用的缺陷分割模型,而不是做一个实验室里的超高清数据集。

1000张图对深度学习来说不算大,尤其对语义分割任务来说,如果全部靠随机裁剪硬撑,很容易过拟合。因此数据集设计时有两个关键决定:一是只做2个缺陷类别,降低标注歧义和模型负担;二是不回避低分辨率模糊问题,靠合理的成像方案和标注规范来弥补。1000张图如果标注质量高、场景覆盖全,训练一个轻量级分割模型完全足够;如果盲目堆到5000张但标注标准一团乱,效果反而不如精标的小数据集。

2. 数据采集与样本规划:低分辨率小图也要讲章法

2.1 成像系统搭建与“低分辨率”是怎么来的

先交代一下采集环境。散热器表面是镀金层,反光率很高,普通环形光源打上去会出现大面积过曝,缺陷区域反而被高光淹没。我们现场采用的方案是同轴光源加低角度环形光组合:同轴光负责把平面纹理均匀照亮,低角度环形光则让划痕这类浅沟槽产生明暗对比。相机用的是200万像素工业相机,配合50mm镜头,视野控制在50mm × 50mm左右,最终输出图像裁成512 × 512。

你算一下就知道,50mm视野放到512像素,每个像素对应的实际尺寸差不多是0.1mm。镀金表面的细划痕往往只有0.2到0.3mm宽,换算到图像上就是2到3个像素。这确实是典型的低分辨率场景。想拍得更清楚当然可以缩小视野到20mm,但那样一块散热器就要拍多张图,产线节拍完全跟不上。与其追求单像素物理精度,不如想办法让模型在2到3像素宽的缺陷特征上也能稳定响应。

2.2 缺陷样本收集:不要把“坏图”全当成宝

数据采集了大概三周,我把产线上不同工序阶段的散热器都过了一遍。污渍类缺陷主要来自清洗残留、助焊剂飞溅、手印油污、镀金层变色和水渍印记;划痕类缺陷则包括装配搬运造成的磕碰划伤、机械加工残留的直线划痕、还有抛光后留下的交叉纹路。收集时不要只挑“标准缺陷图”,因为模型最终要在产线上面对各种光照波动和角度变化。

低分辨率图像有个容易踩的坑:你把所有模糊图、过曝图、欠曝图都收进去,标注的人根本看不清缺陷边界,标出来的mask全是噪声。我们当时定了个筛选原则:无法在5倍放大下看清缺陷轮廓的图直接淘汰,过曝面积超过三分之一也淘汰。最后留下的1000张图并不完美,但每张图至少有一个缺陷是“可辨认”的,这就保证了标注的下限。

2.3 图像预处理和命名规范

采集后的原图我做了几个固定操作:统一转成8位RGB的PNG格式,避免JPEG压缩在边缘引入假纹理;所有图像调整到512 × 512;文件名按“日期_批次_散热器编号_缺陷类型_序号”的规则命名,比如“20241012_B03_S021_mixed_001.png”。命名规范看似无用,但到后面训练时排查样本、回溯产线批次信息,能救你很多时间。

我还额外做了一份CSV清单,记录每张图对应的散热器批次、采集时间、光源参数和初步缺陷类型。后面标注完做数据分析时,这份清单能帮你快速回答“划痕样本都集中在哪一批次”“是不是某一批清洗方式导致污渍特别多”。数据集的构建从来不只是把图片堆在一起,信息本身和图片同样重要。

3. Labelme标注全流程:从环境安装到导出mask

3.1 用conda安装labelme并准备标注环境

标注工具我选了labelme,因为它简单、开源、支持多边形标注,导出JSON之后改造成本低。建议用conda单独建环境,不要直接装到训练环境里,否则PyQt和OpenCV这些依赖版本纠缠在一起会很难受。

conda create -n labelme python=3.9 conda activate labelme pip install labelme labelme

这里有个经验:labelme对python版本比较敏感,我在3.10环境里遇到过PyQt5启动直接报错的问题。建议用python 3.8或3.9,稳定很多。如果下载慢,换镜像源即可,但环境隔离这个习惯一定要养成。工欲善其事,必先利其器,标注工作往往要持续几周,一个干净、稳定的工具环境能省下大量时间。

启动labelme后默认打开一个图形窗口,左侧是文件列表,右侧是图像显示区。用“Open Dir”打开存放图片的文件夹,然后选择“Create Polygons”开始画框。在低分辨率图上标注时,我强烈建议你把窗口放大到400%到800%再做精细描边,否则2像素宽的划痕很容易标偏。

3.2 标注规范:污渍、划痕的边界怎么画才能训练出好模型

标注规范是整个数据集的核心,比模型结构重要得多。如果三个人标出来的划痕宽度、污渍范围完全不一样,模型学到的就是各种矛盾的边界。

我最终定的规范如下:

  • 污渍(stain)用闭合多边形沿着可见边缘画。对于半透明油污、水渍,按视觉上能辨认的色差区域为边界,不要因为看不清就一路圈到背景里。
  • 划痕(scratch)沿着划痕沟槽画多边形,宽度尽量贴合实际划痕的可见宽度,不要故意画成粗线,也不要只画中心线。
  • 一个缺陷目标对应一个shape,两条相邻划痕如果独立存在就分开画;如果交叉成一个连通区域,可以合并成一个多边形。
  • 标签名统一使用英文“stain”和“scratch”,不要用中文或带空格的名称,后面写转换脚本时会少很多字符串匹配的麻烦。
  • 高光、正常镀金纹理、灰尘点都不算缺陷,一律不标。

低分辨率下的核心矛盾是“看不清边界”。我们补充了一条规则:如果缺陷边缘在5倍放大下仍然模糊,就按颜色明暗突变的中点画;如果只是单像素噪声引起的孤立点,不标。这条规则看着简单,却让不同标注人员的结果一致性明显提升。

3.3 Labelme导出的JSON到底长什么样

标注完成后每张图会生成一个同名JSON文件。一个典型文件长这样:

{ "version": "4.5.9", "flags": {}, "shapes": [ { "label": "scratch", "points": [ [126, 241], [134, 238], [145, 233], [152, 228] ], "group_id": null, "shape_type": "polygon", "flags": {} }, { "label": "stain", "points": [ [302, 177], [315, 165], [330, 166], [342, 184], [335, 204], [310, 201] ], "group_id": null, "shape_type": "polygon", "flags": {} } ], "imagePath": "20241012_B03_S021_mixed_001.png", "imageData": null, "imageHeight": 512, "imageWidth": 512 }

注意imageData字段。labelme有些版本会把原图以base64形式嵌进JSON,导致文件体积爆炸,新版一般默认是null。不管哪种情况,转换mask时都不要依赖imageData,应该直接读imagePath对应的原图尺寸。JSON里的imageHeight和imageWidth是标注时软件记录的,如果原图后来被压缩过,务必以最新图像尺寸为准,否则坐标会对不上。

如果你不想手写转换脚本,labelme也自带labelme_json2dataset命令,能把JSON批量转成label.png和其他文件。但这个命令生成的目录结构和训练框架需要的语义分割label格式不完全一样,所以我的项目里还是自己写了脚本,后面小节会给出可以直接用的版本。

3.4 自己写转换脚本:把多边形变成训练mask

把多边形json转成单通道mask,有几个关键点:类别的像素值要固定、输出必须是PNG、坐标数组要转成int32。我用的是下面这个脚本:

import json import os import numpy as np import cv2 from PIL import Image CLASS_MAP = { "stain": 1, "scratch": 2, # 背景默认 0 } def json2mask(json_path, out_dir, default_size=(512, 512)): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) h = data.get("imageHeight", default_size[1]) w = data.get("imageWidth", default_size[0]) mask = np.zeros((h, w), dtype=np.uint8) for shape in data["shapes"]: label = shape["label"].strip() if label not in CLASS_MAP: # 避免背景等多余标签混入 continue pts = np.array(shape["points"], dtype=np.int32) cv2.fillPoly(mask, [pts], CLASS_MAP[label]) base = os.path.splitext(os.path.basename(json_path))[0] out_path = os.path.join(out_dir, base + "_mask.png") Image.fromarray(mask).save(out_path) print(f"{out_path} saved, size={mask.shape}, max_id={mask.max()}") if __name__ == "__main__": json_dir = "annotations" mask_dir = "masks" os.makedirs(mask_dir, exist_ok=True) for fname in os.listdir(json_dir): if fname.lower().endswith(".json"): json2mask(os.path.join(json_dir, fname), mask_dir)

cv2.fillPoly要求点坐标是int32,这个细节很关键。float坐标直接传进去,OpenCV在某些版本会报错或静默截断。另外mask保存用PNG,千万不要为省空间存成JPEG,压缩会在label边缘产生一圈伪像素,训练时等于主动往数据里加噪音。

3.5 标注质量控制不能省

标注质量控制的方案也很简单,但我必须强调,这个步骤直接影响模型上限。我们做下面几件事:

  • 每张图标注完成后,立即把mask和原图叠加图层检查一遍,看缺陷边缘是否明显偏离;
  • 每周从已完成数据里随机抽10张图,让另一位同事重新标一遍,计算两个版本的像素级IoU,低于0.8就要求全员复查;
  • 所有JSON统一放进“annotations”目录,删除标注过程中产生的临时备份文件。

最花时间的不是画多边形,而是人工检查。但经过这一轮检查后,我明显感觉到模型训练时loss曲线更平滑,验证集mIoU也很稳定。数据质量是花多少钱都买不来的,尤其在只有1000张图的小数据集上,每一处错误标注都被模型放得很大。

4. 数据集的画像与坑点:低分辨率下的标注误差

4.1 先统计一下数据集再动手训练

训练前先做数据画像,这一步很多人会跳过,但我建议你认真做。我统计了一下1000张图的整体情况:

类别出现缺陷的图片数缺陷目标总数缺陷像素占比
污渍 stain5307425.6%
划痕 scratch4206871.8%
同时存在两类150——

可以看到,污渍单目标面积通常比较大,像素占比高;划痕数量不少,但单个目标只有几个像素宽,像素占比非常低。这是典型的长尾分布,训练时如果不做处理,模型会倾向于把所有像素都预测成背景,因为整体分类准确率依然很高。后面我用了组合损失函数和针对性的数据增强,才把划痕的召回率拉上来。

低分辨率带来的另一个现象是标注误差比例偏高。划痕宽度如果只有2到3像素,标注人员多画一个像素或少画一个像素,误差就是30%到50%。所以低分辨率数据集的标签噪声是结构性的,不可能完全消除。应对方式不是去追求“绝对准确的mask”,而是保证误差方向一致:要么都偏向缺陷内部,要么都严格贴合可见边界,不要在两种标注习惯之间来回横跳。

4.2 训练/验证/测试集的划分细节

1000张图怎么划分需要认真设计。我采用800张训练、100张验证、100张测试的比例,并且做一个特殊处理:按散热器批次分组,同一个批次的产品只允许出现在一个集合里。

这是工业数据里很常见的泄漏问题。如果同一批次的照片同时出现在训练集和验证集,因为采集环境、表面状态高度相似,模型很可能记住的是批次特征而不是缺陷特征,验证分数虚高。按批次划分之后,验证集和测试集的难度会明显变大,但分数才真正代表现场表现。

划分时还要尽量保证每个集合中污渍和划痕的比例接近。我用sklearn的train_test_split,按“是否包含划痕”做分层采样,确保测试集里两个类别都有足够样本,否则最后只拿一个mIoU数字,完全不知道模型在哪类缺陷上拉胯。

5. 分割模型训练实验记录

5.1 模型选型:U-Net和DeepLabV3怎么选

数据集准备到位之后,模型选型反而不是最纠结的事。我对比过两个方案:一个是U-Net加ResNet34编码器,另一个是DeepLabV3加MobileNetV3编码器。DeepLabV3的感受野大,对大块污渍的分割更稳;U-Net的跳跃连接保留边缘细节能力更强,对细划痕更友好。

考虑到这个项目的核心难点是低分辨率细长缺陷,我最后用U-Net作为主干,编码器换成ResNet34并使用ImageNet预训练权重。1000张的小数据集从头训练很容易欠拟合,迁移学习能显著加快收敛。如果你用的是更轻量的工业部署平台,MobileNetV2作为编码器也是可以的,只是mIoU会略有下降。

5.2 训练配置和损失函数

输入分辨率我设成512 × 512,没有降采样。虽然GPU显存占用更大,但低分辨率图像本身信息就少,再降到256 × 256会让2像素宽的划痕直接消失。batch size设为8,初始学习率1e-3,使用Adam,训练120轮,学习率用余弦退火衰减。硬件是单张RTX 3060,一个模型训练大概2到3小时,完全可接受。

损失函数我踩了坑。最开始用普通的CrossEntropyLoss,模型学到最后几乎全预测为背景,mIoU只有0.37。问题在于背景像素占了92%,交叉熵损失只看总量,根本不在意那不到2%的划痕像素。换成Dice Loss和CrossEntropy的组合后,划痕开始被召回。最终损失设置是0.4 * ce + 0.6 * dice_loss,配合focal策略对少数类加权,整体效果明显变化。

数据增强我用的是albumentations库:水平翻转、垂直翻转、随机亮度对比度、随机平移、轻微缩放。注意不要用太强的旋转增强,因为散热器表面有规则的研磨纹理方向,旋转90度会让纹理特征失去物理意义,模型可能把正常纹理误判为划痕。

5.3 评估指标与结果分析

训练结束后,我在测试集上用三个指标评估:mIoU、类别IoU、Dice系数。最终结果大致如下:

指标数值
背景 IoU0.98
污渍 IoU0.86
划痕 IoU0.71
总体 mIoU0.85
污渍 Dice0.92
划痕 Dice0.83

污渍分割效果很好,因为目标面积大、边界清晰。划痕IoU偏低在意料之中,低分辨率下边缘只有2到3像素,预测和标注之间差一两个像素就会把IoU拉低不少。实际上现场更关心的是“划痕有没有被找到”,而不是“边界完美贴合”。我额外统计了划痕召回率,在允许预测区域和标注区域重合度超过30%就算检出的宽松条件下,召回率超过了0.9,满足产线初筛需求。

后处理阶段我做了一个过滤:删除预测mask中面积小于10像素的孤立连通域。因为低分辨率图的背景噪声会产生很多小碎块,这些碎块在放大后对工艺判断没有意义。直接用OpenCV的connectedComponentsWithStats跑一遍,过滤面积阈值,简单又有效。

6. 常见问题与避坑记录

6.1 conda安装labelme时的两个坑

第一个坑是python版本问题。我尝试过在python 3.10环境里直接pip install labelme,启动时Qt插件报错,整个窗口起不来。后来把conda环境降到python 3.9,问题才解决。第二个坑是conda创建环境后没有激活就执行labelme,系统会提示找不到命令。这不是labelme的问题,是conda环境激活步骤没做。

# 推荐一套完整命令 conda create -n labelme python=3.9 -y conda activate labelme pip install labelme==4.5.9 labelme

如果显卡驱动是老的Machintosh? No, 不需要。只要用conda隔离就行。

6.2 标注时的常见分歧:高光、纹理、氧化到底算不算缺陷

这是标注中最费口舌的地方。镀金表面高光在低角度光下会出现明亮的条带,边缘锐利,看起来极像划痕;研磨纹理是均匀细密的平行线,也会被误认为划痕;镀金层局部氧化变色,颜色偏暗,容易被误认为污渍。我们花了大量时间统一口径。

最终规则是:缺陷必须有“非正常加工过程造成的形状和颜色异常”。高光会随光源角度变化而移动,照片拍下来高光区域虽然亮,但它反射的是光源形状,不是缺陷;研磨纹理是有规律的、全图均匀分布的线状结构,划痕则通常是局部、无规律、有深度的沟槽;氧化变色只要边界清晰、面积稳定,就按污渍类标注。规则明确之后,三个标注人员的交叉一致性从0.6提升到0.8以上。

6.3 训练时loss不降或mIoU低,先查这三个地方

如果loss曲线不降,先不要调模型,按下面顺序排查:一是检查mask有没有错位,把原图和mask半透明叠加显示一下,经常能发现行列坐标反了或者图像被resize过;二是检查类别权重,确认背景像素没有占据绝对主导;三是检查数据增强是否过强,尤其不要对工业表面图做随机旋转90度或180度。

如果训练正常但mIoU低,重点看错误样本的可视化结果。我画过一批测试集的可视化图,发现“假划痕”集中在散热器边缘和镀金层研磨纹理处。解决办法不是换更大的模型,而是把标注规范再收紧,把边缘伪影尽量排除在外。模型永远只能学到你标注出来的东西,这一点在低分辨率工业数据集中体现得尤为明显。

6.4 低分辨率下图像增强和mask的关系

最后提醒一个细节:低分辨率图像做增强时,如果使用了缩放、旋转或裁剪,一定要对mask施加完全相同的空间变化。albumentations库会自动同步image和mask,我全程用它,避免自己写转换导致mask和图像错位。如果自己处理,最简单的方法是把mask当作另一张“图像”一起做仿射变换,并保持插值方式一致。尤其不要对mask用线性插值,否则中间会出现非整数类别值,训练时直接报错或产生乱码标签。

7. 这个项目后续还能怎么扩展

整套流程跑完后,我心里最深的感触是:低分辨率不可怕,可怕的是标注口径不稳定。第一批标注文件里,三个人画划痕的习惯差异很大,有人把高光一起圈进去,有人只画划痕核心,后来我们花了一周重修标注,才让模型真正收敛。如果你也在准备类似的数据集,我建议开工前先做一套包含典型缺陷的标注示例图,让所有人对照着标,每周抽几张图互相检查。Labelme的JSON和mask转换脚本都只是工具,真正决定模型上限的,是你对缺陷的理解和标注的耐心。这个项目后续还可以往半监督方向走,用这1000张精标图做种子,让模型去产线新图上打伪标签,再人工修正,一点点把数据集扩上去。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进