ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

芯片表面缺陷检测:Mask R-CNN+UNet像素级分割方案

芯片表面缺陷检测:Mask R-CNN+UNet像素级分割方案 简介面向半导体制造与质量控制领域的工程师、算法研究者及计算机视觉学习者这份项目实战包聚焦芯片表面bump、dent、dot三类缺陷的像素级检测。方案融合Mask RCNN的实例分割能力与UNet的精细分割优势覆盖数据预处理、模型训练、结果评估到应用部署的完整流程适合希望快速落地深度学习视觉检测方案的读者参考。资源共73个文件压缩包约4.03MB以Python脚本py/pyc、模型与性能分析Notebookipynb、标注与训练数据CSV等为主另有配置XML及Markdown说明文档目录按UNet和MaskRCNN两套模型模块化组织便于对照源码复现实验。目前已有119人学习下载可作为芯片质检智能化、工业视觉缺陷检测方向的优质项目模板。1. 芯片表面缺陷检测bump、dent、dot 为什么需要像素级方案芯片表面缺陷检测难不在“看出来”难在“说清楚”。bump 是凸起点dent 是凹坑dot 是微小点状异物它们往往只有几十到几百像素还长在金属走线、引脚和字符附近单靠目标框根本没法描述缺陷的真实边界。基于 Mask R-CNN UNet 的像素级检测算法是把这件事拆成两步Mask R-CNN 先定位可能缺陷区域UNet 再对区域做逐像素分割最终输出带轮廓、面积和类别的结果而不是一个模糊的矩形框。对做芯片测试、AOI 复判和良率分析的人来说这套方案能直接回答“这个 bump 直径多少像素、缺陷面积占比多少”也方便后续接机械臂或光学复检。适合手里已有几千张标注图、想从普通目标检测往像素级检测升级的视觉工程师也适合刚接触分割网络的入门者照着搭一个能跑通的基线。2. Mask R-CNN UNet 联合两个模型的分工与选型理由2.1 为什么单模型搞不定小缺陷很多人一开始会用纯目标检测比如 YOLO 或者 Faster R-CNN跑完发现 mAP 不差但真正拿到产线复判时缺陷面积算不准。原因很直接目标检测输出的是水平矩形框而 bump 和 dot 的形状是类圆或不规则块框住它的矩形框里可能一半是背景。以 IoU 为标准评估时一个 5×5 像素的 dot哪怕框偏移 2 个像素IoU 就跌破 0.5检测头很难稳定收敛。反过来单用 UNet 做全图分割也有问题。UNet 擅长像素级分类但它是对整张图做密集预测在芯片这种纹理密集的场景里金属走线、字符边缘、引脚反光都会产生和缺陷相近的局部梯度网络容易把“像缺陷”的东西也分出来。更关键的是bump 在整图里只占不到 0.1% 的像素UNet 的损失函数会被背景主导训练一段时间后预测结果几乎全是背景也就是常说的“假收敛”。单模型不是不能做而是要在小目标召回和像素级精度之间反复调参调到最后往往还是需要两个模型配合。2.2 两个网络的分工检测负责召回分割负责精度我一般会这样定义职责Mask R-CNN 负责召回UNet 负责精度。Mask R-CNN 自带 RPN 和 FPN对多尺度缺陷的定位能力比纯分割网络强很多它先输出一批候选框候选框里可能有背景也可能 IoU 不算高但只要缺陷主体在框内后续就有救。UNet 的输入是 Mask R-CNN 框出来的局部图像块这时候缺陷在图像块里的占比可能从不到 0.1% 变成 5% 到 20%类别极度不平衡的问题被大幅缓解UNet 可以专心学习“这个局部区域里哪些像素是缺陷”。很多人会问Mask R-CNN 自己也有 mask 分支为什么还要再接一层 UNet因为 Mask R-CNN 的 mask head 输出分辨率默认只有 28×28通过双线性插值放大回原图尺寸后边缘非常粗糙。对于 dent 这种边界要靠阴影过渡来判断的缺陷28×28 的 mask 基本无法给出准确轮廓。UNet 的跳跃连接能把浅层高分辨率特征和深层语义特征融合在同样的感受野下能输出更精细的边界这也是它在医学图像分割里被广泛使用的原因。所以在我的落地配置里Mask R-CNN 更接近一个“区域提议网络”的增强版UNet 才是真正的像素级分割主力。2.3 级联 pipeline 的接口与对齐细节连接两个模型时最容易出错的是坐标对齐。Mask R-CNN 输出的 box 是原图坐标系裁剪时如果直接对坐标取整再 resize 到 UNet 输入尺寸回贴到原图时会出现几个像素的偏移这在缺陷检测里可能直接把一个小 dot 从 6 像素变成 4 像素。我一般会在裁剪时把 box 向外扩 margin同时记录原始矩形的位置和尺寸而不是只记录一个 resize 后的固定尺寸。def extract_roi(image, box, margin16, target_size(256, 256)): # 原始 box 坐标来自 Mask R-CNN 输出 x1, y1, x2, y2 [int(round(v)) for v in box] # 向外扩 margin避免缺陷正好卡在边界也让 UNet 看到周围环境 x1, y1 max(0, x1 - margin), max(0, y1 - margin) x2, y2 min(image.shape[1], x2 margin), min(image.shape[0], y2 margin) roi image[y1:y2, x1:x2] orig_h, orig_w roi.shape[:2] # 统一到 256x256UNet 的输入不需要保持原图宽高比 roi_resized cv2.resize(roi, target_size, interpolationcv2.INTER_LINEAR) return roi_resized, (x1, y1, orig_w, orig_h)这个函数的逻辑是先把 box 转成整数坐标并扩展边距然后从原图裁剪。扩展 margin 非常重要很多 dot 缺陷的边缘就是几个像素如果不加 margin裁剪时很可能把缺陷的一部分切出去UNet 永远学不到完整缺陷。返回的(x1, y1, orig_w, orig_h)是回贴 mask 时必不可少的映射信息推理阶段要用它把 UNet 输出的概率图 resize 回原始裁剪区域大小再放到整图 mask 的对应位置。如果两个候选框有重叠回贴时按像素取最大值而不是直接覆盖否则重叠区域的缺陷面积会被低估。3. 准备像素级标注数据从标注 JSON 到双模型输入3.1 bump、dent、dot 的标注规范与类别映射像素级检测的第一步不是写模型而是定标注规范。我和标注同学对齐的规则是bump 以明暗交界的亮边内沿为准dent 以凹陷阴影的内侧边界为准dot 只要像素面积大于 3 就标小于 3 的先记录不计入训练标签。标注工具用 VIA 或 LabelMe 都行关键是输出必须是多边形而不是矩形框。芯片表面有大量重复的 die、引脚和字符这些都要明确排除尤其是字符边缘的反光很多人会误标成 dot。类别映射我建议固定下来bump1, dent2, dot30 保留给背景。这个映射从数据准备阶段就不要改否则后面 Mask R-CNN 的分类头、UNet 的二值 mask、评估脚本都得跟着动非常容易出错。另外标注时我要求每个缺陷必须是独立的多边形不能把同一片区域里的多个 dot 合并成一个多边形否则 UNet 学习到的是“一片区域”而不是“一个个点”后处理阶段没法按连通域拆分。3.2 VIA/LabelMe 标注转 COCO转换脚本与边界坑Mask R-CNN 标准训练格式是 COCO JSONUNet 需要的是二值 mask所以需要把 VIA 导出的 JSON 一次转换成两套数据。下面是我常用的转换脚本import json, os, cv2 import numpy as np from pycocotools import mask as mask_util cls_map {bump: 1, dent: 2, dot: 3} def convert_via2_to_coco(via_json, image_dir, out_coco, out_mask_dir): with open(via_json, r, encodingutf-8) as f: data json.load(f) images, annotations [], [] ann_id 1 os.makedirs(out_mask_dir, exist_okTrue) for img_id, (key, meta) in enumerate(data.items(), 1): filename meta[filename] img cv2.imread(os.path.join(image_dir, filename)) if img is None: print(fskip {filename}, check path) continue h, w img.shape[:2] images.append({id: img_id, file_name: filename, height: h, width: w}) regions meta[regions] # VIA 2 的单区域导出的 regions 可能是 dict需要兼容 if isinstance(regions, dict): regions [regions] for region in regions: shape_attr region.get(shape_attributes, {}) if shape_attr.get(name) ! polygon: continue region_attr region.get(region_attributes, {}) cls_name region_attr.get(type, region_attr.get(class, bump)) if cls_name not in cls_map: print(funknown class {cls_name}, skip) continue xs shape_attr[all_points_x] ys shape_attr[all_points_y] poly np.stack([xs, ys], axis1).astype(np.int32) mask_img np.zeros((h, w), dtypenp.uint8) cv2.fillPoly(mask_img, [poly], 1) # 转 RLECOCO 里 polygon 坐标可能超出图像边界RLE 更稳 rle mask_util.encode(np.asfortranarray(mask_img)) x, y, poly_w, poly_h cv2.boundingRect(poly) annotations.append({ id: ann_id, image_id: img_id, category_id: cls_map[cls_name], segmentation: rle, bbox: [x, y, poly_w, poly_h], area: int(mask_img.sum()), iscrowd: 0 }) # 同步保存 UNet 用的独立二值 mask cv2.imwrite( os.path.join(out_mask_dir, f{img_id:05d}_{ann_id:05d}.png), mask_img * 255 ) ann_id 1 coco { images: images, annotations: annotations, categories: [ {id: 1, name: bump}, {id: 2, name: dent}, {id: 3, name: dot} ] } with open(out_coco, w, encodingutf-8) as f: json.dump(coco, f)逻辑说明脚本把 VIA 的 regions 统一成 list兼容单区域和多区域导出对每个多边形生成一张二值 mask再把 mask 转成 COCO 的 RLE 格式。用 RLE 而不是直接用 polygon是因为 VIA 的多边形点可能落在图像外RLE 能天然处理这种越界。annotations里的bbox由cv2.boundingRect计算不需要自己求 min/max避免漏掉凹多边形的极端点。同时导出的二值 mask 按image_id ann_id命名后面 UNet 训练时可以直接按文件名索引到原图和标注。3.3 patch 切分与数据增强策略芯片原图分辨率通常在 2000×2000 以上直接送进 UNet 训练会把显存吃满而且小缺陷在这么大一张图上被反复下采样后几乎消失。我的做法是把原图切成 512×512 的 patch相邻 patch 之间重叠 64 像素这样缺陷如果正好在切分线上不至于被切成两半。切出来的 patch 里包含缺陷的留下不包含缺陷但包含字符、引脚、走线的也留一部分作为负样本负样本数量控制在正样本的 1.5 倍以内太多会让 UNet 偏向背景。数据增强方面随机翻转、90 度旋转、亮度对比度调整是基础。对 dot 这种样本量极少的类别我建议做复制粘贴增强从现有的 dot 标注中随机裁一小块缺陷贴到无缺陷 patch 的随机位置并同步生成对应的 mask。这样相当于人为增加了 dot 的样本数量和位置多样性。注意增强必须同步作用于图像和 mask不能只转图像不转 mask否则 Mask R-CNN 训练时目标框和 mask 对不上UNet 训练时损失函数直接混乱。4. 训练细节Mask R-CNN 检测分支和 UNet 分割分支4.1 用 torchvision 跑通 Mask R-CNN 的最小训练配置Mask R-CNN 的训练可以直接基于 torchvision 的实现不需要从零搭网络结构。我们只需要把分类头和 mask 头替换成自己的类别数。背景算一类加上 bump、dent、dot 三个缺陷类所以num_classes4。import torch from torchvision.models.detection import maskrcnn_resnet50_fpn from torchvision.models.detection.faster_rcnn import FastRCNNPredictor from torchvision.models.detection.mask_rcnn import MaskRCNNPredictor def build_maskrcnn(num_classes4): model maskrcnn_resnet50_fpn(weightsCOCO_V1) # 替换分类头原来预测 COCO 80 类现在只有 4 类 in_features model.roi_heads.box_predictor.cls_score.in_features model.roi_heads.box_predictor FastRCNNPredictor(in_features, num_classes) # 替换 mask 头输出通道数也要等于 num_classes in_features_mask model.roi_heads.mask_predictor.conv5_mask.in_channels model.roi_heads.mask_predictor MaskRCNNPredictor( in_features_mask, 256, num_classes ) return model参数说明weightsCOCO_V1表示加载在 COCO 上预训练好的权重这个预训练对自然图像的通用特征很有帮助尤其 FPN 层的参数可以直接复用。如果芯片图像和自然图像差异极大把pretrained改成False从头训练也可以但需要更多数据和更长的训练时间我一般还是选择微调预训练权重。替换 mask 头时256是中间隐藏层通道数不需要动重点是num_classes必须和分类头一致。这里有一个容易踩的坑数据加载时必须把masks转成FloatTensor并保持[N, H, W]的形状torchvision 的 Mask R-CNN 接受target的masks是布尔或浮点型。下面是一个最简训练循环device torch.device(cuda if torch.cuda.is_available() else cpu) model build_maskrcnn().to(device) optimizer torch.optim.SGD( model.parameters(), lr1e-4, momentum0.9, weight_decay1e-4 ) lr_scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.5) def train_one_epoch(dataloader): model.train() for images, targets in dataloader: images [img.to(device) for img in images] targets [{k: v.to(device) for k, v in t.items()} for t in targets] loss_dict model(images, targets) losses sum(loss for loss in loss_dict.values()) optimizer.zero_grad() losses.backward() optimizer.step()逻辑说明torchvision 的 model 在训练模式下接受images和targets返回的是一个loss_dict包含 RPN 分类损失、RPN 框回归损失、ROI 分类损失、ROI 框回归损失和 mask 损失。把几个损失直接求和再反传这是最简单也最稳定的做法。学习率用 1e-4 而不是默认的 1e-2是因为我们数据量不大太大的学习率会让预训练权重在早期就被破坏。4.2 UNet 训练Dice BCE 双损失和边界权重UNet 输入是 Mask R-CNN 框出来的局部图块输出是单通道概率图。因为缺陷像素在局部图块里占比仍然偏低只用 BCE 很容易让模型倾向于全背景。我用 Dice Loss 加 BCE 的组合Dice 对前景占比较小的场景更敏感BCE 能保持训练初期的稳定性。import torch import torch.nn as nn class DiceLoss(nn.Module): def __init__(self, smooth1.0): super().__init__() self.smooth smooth def forward(self, logits, mask): pred torch.sigmoid(logits).reshape(logits.size(0), -1) mask mask.reshape(mask.size(0), -1) inter (pred * mask).sum(dim1) union pred.sum(dim1) mask.sum(dim1) dice (2 * inter self.smooth) / (union self.smooth) return 1 - dice.mean() bce nn.BCEWithLogitsLoss() def combined_loss(logits, mask): return bce(logits, mask) DiceLoss().to(logits.device)(logits, mask)参数说明smooth取 1.0 是为了防止预测和标注都是空时出现除零错误。reshape(logits.size(0), -1)把每个样本的所有像素展平这样 Dice 是按样本计算而不是按整个 batch 混合计算避免某些样本的缺陷像素被另一些样本的背景像素稀释。训练循环很短optimizer torch.optim.AdamW(unet.parameters(), lr1e-4) for epoch in range(num_epochs): for patch, mask in unet_loader: patch patch.float().to(device) mask mask.float().to(device) logits unet(patch) loss combined_loss(logits, mask) optimizer.zero_grad() loss.backward() optimizer.step()逻辑说明patch是裁剪并 resize 到 256×256 的图像块mask是对应的二值 mask尺寸必须和输入一致。UNet 输出的是原始 logits没有经过 sigmoid因为BCEWithLogitsLoss内部会做 sigmoidDiceLoss 里再单独对 logits 做一次 sigmoid。两个损失相加后反传UNet 在训练初期不会因为 Dice 的梯度不稳定而完全不动后期 BCE 又不会让稀疏前景被完全忽略。4.3 显存、学习率与 epoch几个更实用的参数建议下面是几组我实际用下来比较稳的参数可以直接作为起点参数项推荐值说明Mask R-CNN backboneResNet-50-FPN兼顾速度和精度芯片缺陷不追求超大模型训练输入尺寸512×512 patch原图太大时先切 patch避免小目标被下采样掉Mask R-CNN 学习率1e-4数据量小用低学习率微调预训练权重Mask R-CNN batch size每卡 2超过 2 很容易显存溢出用梯度累积代替UNet 输入尺寸256×256来自 ROI 裁剪不需要更大的尺寸UNet 学习率1e-4AdamW配合 DiceBCE 比较稳UNet batch size16ROI 小显存压力小可以大一点训练 epoch50 到 100加早停观察验证集 IoU不要盲目跑满显存控制的常见做法是先离线把 Mask R-CNN 推理得到的 ROI 和对应 mask 保存成文件再单独训练 UNet。这样两个模型不会同时占显存也方便调整 UNet 的输入样本。训练 Mask R-CNN 时如果显存不足我会把 batch size 降到 1然后用梯度累积accumulation_steps4模拟 batch size 4效果比直接撑爆显存好得多。5. 像素级缺陷检测避坑训练和部署中常见的 5 个问题5.1 模型全预测背景小样本类别的“假收敛”现象训练 Loss 在下降但验证集预测结果全是背景bump 偶尔能检出dent 和 dot 完全没有输出。原因缺陷像素在全图占比极低尤其是 dot一个 6 像素的点在 512×512 patch 里占比不到 0.02%。BCE 或常规分类损失被背景主导模型发现“全部输出背景”也能把损失降到很低于是陷入局部最优。Mask R-CNN 的分类头也同样会被大量负样本带偏。解决先按类别做样本重采样保证每个 batch 里都包含 dent 和 dot 样本不能简单按文件顺序加载。如果 dot 样本量实在不够用复制粘贴增强生成更多 dot patch。训练时给少数类的损失加权重比如在分类损失里把 dent 和 dot 的权重设为 3 到 5。我会在训练开始前先统计每个类别的标注数量低于 500 的类别优先做增强。5.2 检测框和分割边缘对不上ROI 对齐误差现象UNet 输出的缺陷轮廓比实际边缘偏了 2 到 3 个像素或者边缘出现明显的台阶状错位叠加到原图上怎么看怎么别扭。原因在 ROI 裁剪时直接对 box 坐标取整回贴 mask 时又采用不同的插值方式比如裁剪用INTER_NEARESTresize 回原图用INTER_LINEAR两种插值的边缘位移叠加起来缺陷边界就偏了。另一个常见原因是裁剪 box 没加 margin缺陷被切到边缘UNet 是在残缺目标上学习。解决所有坐标处理统一用浮点计算裁剪时不取整只在切片时通过int截断回贴 mask 时用cv2.resize的INTER_LINEAR并且根据原始 ROI 的(x1, y1, orig_w, orig_h)精确映射。每轮训练结束我习惯随机抽 5 张图把预测 mask 以半透明红色叠加到原图上用眼睛确认边缘错位情况这种可视化比只看 IoU 指标直接得多。5.3 反光、引脚干扰导致的误检现象模型把芯片表面的字符、引脚边缘、金属走线的反光当成 bump 或 dot产线误检率居高不下。原因这些区域在局部 ROI 里和缺陷的灰度梯度非常相似UNet 学到的是“有亮暗边界就是缺陷”。尤其是字符笔画边缘的反光在二值化后和小型 bump 几乎无法区分。解决在标注阶段把所有字符、引脚、走线区域标记为“排除区域”UNet 损失计算时跳过这些位置的像素这样模型不会被这些非缺陷边缘反复带偏。训练数据里必须加入大量无缺陷但包含字符和引脚的负样本 patch让网络知道“看起来像缺陷的未必是缺陷”。推理时我还习惯用 Mask R-CNN 的置信度分数做第一道过滤低于 0.7 的候选框不送入 UNet虽然会漏掉一点真缺陷但误检率下降更明显。5.4 训练集涨点产线翻车现象测试集上 mAP 到 0.9IoU 到 0.85但同一套权重换到新一批芯片上误检和漏检同时增加。原因芯片表面缺陷检测的数据分布高度依赖采集条件固定光源、固定角度拍出来的图和产线换了一盏灯、换了一个批次后的图差别很大。随机打乱数据划分验证集时同一批次不同图片被分到训练和验证里看起来指标很漂亮但换个批次就露馅。解决按批次而不是按单张图划分验证集保证验证集里的芯片批次完全没有参与过训练。采集环节要覆盖不同站位、不同光源角度和不同氧化程度的芯片。训练时做光照域随机化把亮度、对比度、色温的随机范围调大模型对光照变化的适应会好很多。我现在做项目会特意保留一个“新批次盲测集”每次训练完先用盲测集跑一遍再决定要不要调阈值。5.5 显存溢出与训练时间失控现象Mask R-CNN 和 UNet 同时训练显存很快占满一个 epoch 要跑几十分钟训练进度几乎没法推进。原因直接把大分辨率原图送进 Mask R-CNN又把每张图的所有 ROI 放大到 256×256 送进 UNet两个模型的前向和反向同时占显存谁也跑不动。解决把两个模型拆成分阶段训练。先训练 Mask R-CNN然后用它离线推理原始训练集把所有候选框的 ROI 裁剪保存为图片和对应 mask再单独训练 UNet。UNet 训练时依赖的是一批已经准备好的小图片显存压力很小迭代速度也快。推理时如果单张芯片图检测出超过 10 个候选框我只看置信度最高的 top 10或者按坐标聚合成几个大区域再送 UNet避免一次性把所有 ROI 都放进去。6. 后处理与效果验证从二值图到像素级缺陷报告模型输出的概率图不能直接拿去算缺陷面积必须先做后处理。我会先按类别单独二值化再做形态学闭运算和小连通域剔除最后通过连通域分析得到每个缺陷的包围框、像素面积和类别。import cv2 import numpy as np def postprocess_probmap(prob, class_id1, thresh0.5, min_area12): # prob: HxW 的 float32 概率图 mask (prob thresh).astype(np.uint8) # 闭运算把断裂的边缘接起来dot 不需要这一步bump/dent 建议加 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 连通域分析把每个缺陷单独拆出来 num, labels, stats, _ cv2.connectedComponentsWithStats(mask, 8) results [] for i in range(1, num): area stats[i, cv2.CC_STAT_AREA] if area min_area: continue left, top stats[i, cv2.CC_STAT_LEFT], stats[i, cv2.CC_STAT_TOP] w, h stats[i, cv2.CC_STAT_WIDTH], stats[i, cv2.CC_STAT_HEIGHT] results.append({ class_id: class_id, bbox: [left, top, w, h], area_px: int(area), score: float(prob[labels i].max()), }) return results参数说明thresh默认 0.5但 dot 类因为目标太小预测概率往往整体偏低我会把它降到 0.3。min_area默认 12 像素小于这个值的基本是反光噪点或标注差异。闭运算的核大小 3×3 对 bump 和 dent 足够对 dot 不要做闭运算因为两个距离近的 dot 可能会被闭运算连成一个缺陷。验证时不能只看像素 IoU。我会同时统计三类指标像素级 IoU/Dice 说明分割精度按连通域计算的召回率和虚警率说明产线实用效果还有一个边界容差指标允许预测边界和标注边界偏差 1 到 2 像素内算对因为芯片缺陷边界本来就有过渡带不同标注员画出的边界可能差 1 到 2 像素。我最早把 IoU 阈值卡得很死结果验收时产生很大分歧后来统一改成带容差的评估口径才跟产线对齐了标准。最后抽查 100 张原始图把预测轮廓叠加到图上人工看一眼这一步永远不能省。希望这套从标注、训练到后处理的路径能帮你把像素级缺陷检测真正落地。本文还有配套的精品资源点击获取
返回列表