简介:面向半导体制造与质量控制领域的工程技术人员,该资源提供了一套基于Mask RCNN与UNet的芯片表面缺陷检测算法实现,支持bump、dent、dot三类缺陷的像素级识别与分割,主要用于替代传统人工目检,提升产线检测效率与精度,适合有一定深度学习基础并希望快速搭建检测方案的开发者和研究者。压缩包共含73个文件,大小约4.03MB,以Python脚本、Jupyter Notebook、CSV数据文件及pyc编译文件为主,另有XML配置与ReadMe说明;py文件承载模型定义、数据加载与训练流程,ipynb用于交互式调试和结果可视化分析,目录结构清晰,便于按模块查阅。目前已有119人浏览学习。项目覆盖从数据预处理、模型构建与训练到结果评估与应用部署的完整流程,MRCNN与UNet两套实现相互对照,配套的notebook便于复现与二次开发,能帮助读者理解实例分割与语义分割在工业视觉检测中的落地思路,并掌握缺陷数据组织、模型参数调优与评估指标选择等关键工程细节。
1. 芯片表面缺陷检测:为什么把 MaskRCNN 和 UNet 叠起来用
在芯片封装基板的 AOI 工位上,最让视觉算法头疼的是 bump 表面镜面反射产生的假缺陷。传统灰度阈值一遍扫过去,高光区全部报异常,复判员一天要手动点掉几千个误检。这个标题给出的路线是用 MaskRCNN 和 UNet 双模型做像素级缺陷检测——UNet 先把疑似缺陷区域整体分割出来做语义粗筛,MaskRCNN 再对每个缺陷实例做边界精化和类型判断,最终输出 bump、dent、dot 三种缺陷的像素级掩码。方案适合正在被样本不平衡和边界精度折磨的 AOI 算法工程师,也适合想从目标检测切到像素级分割的视觉团队。下面直接讲两条网络怎么分工、参数怎么配,以及五个容易翻车的坑。
2. 三类缺陷的标注口径与数据准备:bump、dent、dot 怎么标才不算白标
做像素级缺陷检测,标注质量直接决定模型输出上界。很多人上来就用矩形框标,训练完再做后处理切割,结果边界永远对不齐。芯片表面缺陷的难点不在“能不能看到”,而在缺陷和刻痕、划伤、氧化区域在灰度上高度相似,标注口径不一致,模型学到的就是噪声。
2.1 成像差异决定了标注边界
先说三种缺陷在图像上长什么样。bump 是焊球或凸点,典型尺寸在 30 到 120 像素之间,成像时中央高光、边缘带暗环,真正的缺陷边界是暗环外缘而不是亮球本身。dent 是凹陷压痕,尺寸约 15 到 80 像素,整体比周围暗,有阴影渐变,边界常常是一圈模糊的灰度过渡带。dot 是异物颗粒或污点,尺寸最小,常见只有 3 到 30 像素,灰度突变明显,但容易被当作噪声滤掉。
| 缺陷类型 | 典型尺寸(像素) | 成像特征 | 标注口径 |
|---|---|---|---|
| bump 凸点/焊球 | 30 - 120 | 中央高光,边缘暗环 | 标到暗环外缘,不标高光球体 |
| dent 凹陷/压痕 | 15 - 80 | 整体偏暗,有阴影渐变 | 标完整凹陷畸变区域,含阴影带 |
| dot 异物/颗粒 | 3 - 30 | 灰度突变,与背景色差大 | 按灰度突变轮廓,小点外扩 1 - 2 像素 |
标注工具推荐用 polygon 模式,不要用矩形框。bump 和 dent 如果用矩形框标,MaskRCNN 的掩码分支会强行拟合出一个矩形,边界精度直接报废。dot 这类小点,polygon 标两点或四点就行,但外扩比例要固定,否则模型会学出两种不一致的“真实边界”。
2.2 采集、ROI 裁剪与切块:喂给网络前先做的事
数据采集阶段就要固定相机高度和光源角度。芯片表面是镜面加漫反射的混合体,光源角度差 5 度,同一个 bump 的暗环宽度能差出 3 个像素,这对像素级分割是致命的。产线上通常先做 chip 定位,把每颗芯片的 ROI 裁出来,再切块送入网络。直接送整张晶圆图会导致两类问题:一是缺陷占比太小,损失函数被背景主导;二是 GPU 显存撑不住高分辨率输入。
import numpy as np def crop_chip_roi(image, chip_box, pad_ratio=0.08): x0, y0, x1, y1 = chip_box pad_x = int((x1 - x0) * pad_ratio) pad_y = int((y1 - y0) * pad_ratio) x0, y0 = max(0, x0 - pad_x), max(0, y0 - pad_y) x1, y1 = min(image.shape[1], x1 + pad_x), min(image.shape[0], y1 + pad_y) return image[y0:y1, x0:x1] def make_tiles(image, tile_size=512, stride=256): h, w = image.shape[:2] tiles, boxes = [], [] for y in range(0, h - tile_size + 1, stride): for x in range(0, w - tile_size + 1, stride): tiles.append(image[y:y + tile_size, x:x + tile_size]) boxes.append((x, y, x + tile_size, y + tile_size)) return tiles, boxespad_ratio 取 0.08 是因为缺陷通常不会正正好好落在芯片边缘,留出外围上下文才能让 UNet 的编码器看到足够大的感受野。stride 取 256,与 tile_size 512 形成一半的 overlap,目的是避免缺陷恰好被切在 tile 边缘。推理时重叠区域的预测结果需要做均值融合或最大置信度融合,这一步对边界稳定性非常重要。
2.3 类别不平衡与小缺陷增强:别让 dot 淹掉 bump
产线上 dot 的出现频率往往是 bump 的 5 倍以上,直接按文件随机采样,一个 batch 里可能全是 dot。常见做法是按类别过采样:bump 和 dent 的样本在训练集中至少占总样本的四分之一,不能满足时用复制粘贴的方式合成缺陷样本。把无缺陷芯片图像当背景,把一张图中标注好的 bump 区域切出来,做随机旋转、缩放、亮度扰动后贴上去,同时更新对应掩码。这种合成增强需要注意光源方向要一致,否则模型会把不真实的高光当成特征。
增强参数上,我一般用轻度旋转(正负 15 度)、加性高斯噪声(scale 0 到 8)、乘法亮度扰动(0.85 到 1.15)。务必不要开 ElasticTransform 这类形变增强,焊点形状被扭曲后,标注边界就失去了物理意义。亮度扰动幅度控制在正负 15% 以内,超过这个范围,镜面反射区域会被过度增强,UNet 输出概率图会变得非常碎。
3. UNet 分支怎么搭:语义粗分割与低阈值召回
标题里把 MaskRCNN 放在 UNet 前面,但实际落地时两条网络是串行配合的。MaskRCNN 做实例分割没问题,但单独用在小缺陷密集的芯片表面上,RPN 容易漏检,尤其是 dot 这种十几个像素的小目标。UNet 的作用不是最终输出,而是先用低阈值把“疑似区域”全部圈出来,给 MaskRCNN 的 RPN 一个先验范围。
3.1 为什么让 UNet 先“圈地”
UNet 的结构优势是 U 形跳连,编码器从高分辨率开始逐级下采样,解码器再逐级上采样并融合各层特征。这个特点对小目标特别友好:小缺陷的语义信息在深层,位置信息在浅层,跳连能把两路拼在一起。相比直接用 MaskRCNN 在整张图上让 RPN 搜区域,UNet 先出一个概率图,相当于告诉 RPN“缺陷大概率分布在这几个区域”,候选区域数量可以减少一半以上,误检也随之下降。
这里要纠正一个常见误用:有人把 UNet 当成最终分割网络,追求输出边界和实际缺陷严丝合缝。UNet 的输出边界天生偏平滑,达不到像素级精度。所以训练 UNet 时不要把阈值定在 0.5,0.3 甚至 0.25 都可以,目的是高召回,漏掉一个不该漏的区域,比多报一个区域更麻烦。
3.2 编码器选择与注意力改进
编码器用预训练 ResNet34 就够,不需要上 EfficientNet-B7 这类重网络。芯片切块后的图像尺寸是 512 见方,缺陷尺寸又小,重网络深层特征会丢失太多细节,同时推理速度也扛不住产线节拍。解码器通道数取 256,最后一层输出三个二值通道,分别对应 bump、dent、dot,背景由“三个通道都低于阈值”表达。
我给 UNet 加了一个轻量改进:在解码器最后一层之前接 CBAM 注意力模块。CBAM 先做通道注意力,再把空间注意力作用在通道加权后的特征上,能有效抑制镜面反射区域在特征图上的高响应。加了 CBAM 之后,UNet 在 dent 这类弱对比缺陷上的召回率提升最明显,原因是 dent 的灰度差异不大,但空间位置信息有规律,注意力机制能放大这一部分响应。
import torch import torch.nn as nn class CBAM(nn.Module): def __init__(self, channels, reduction=8): super().__init__() self.avg_pool = nn.AdaptiveAvgPool2d(1) self.max_pool = nn.AdaptiveMaxPool2d(1) self.fc = nn.Sequential( nn.Conv2d(channels, channels // reduction, 1), nn.ReLU(inplace=True), nn.Conv2d(channels // reduction, channels, 1), ) self.spatial = nn.Conv2d(2, 1, kernel_size=7, padding=3) def forward(self, x): b, c, h, w = x.size() avg = self.fc(self.avg_pool(x)).expand_as(x) mx = self.fc(self.max_pool(x)).expand_as(x) attn = torch.sigmoid(avg + mx) x = x * attn cat = torch.cat([x.mean(dim=1, keepdim=True), x.max(dim=1, keepdim=True).values], dim=1) return x * torch.sigmoid(self.spatial(cat))CBAM 的 reduction 取 8,空间注意力卷积核取 7,这两个是改动成本低、效果容易复现的参数。通道数太少时 reduction 拉大反而损失表征能力,512 通道情况下 8 是安全起点。
3.3 损失函数与训练参数:Dice 和 Focal 各管一段
芯片缺陷分割的类别不平衡非常严重,向背景类的像素占比经常超过 99%。单独用 BCE 会让模型倾向于把所有像素都预测为背景,召回率极低。我一般用 Dice Loss 加 Focal Loss 的组合,Dice 管整体区域重叠,Focal 管难分像素,尤其是 dot 这种小目标。
class DiceFocalLoss(nn.Module): def __init__(self, alpha=0.7, gamma=2.0): super().__init__() self.alpha = alpha self.gamma = gamma def forward(self, pred, target): pred = pred.clamp(1e-6, 1 - 1e-6) inter = (pred * target).sum(dim=(2, 3)) union = pred.sum(dim=(2, 3)) + target.sum(dim=(2, 3)) dice = 1 - (2 * inter + 1) / (union + 1) focal = -(target * (1 - pred) ** self.gamma * pred.log()).mean(dim=(2, 3)) return 0.6 * dice.mean() + 0.4 * focal.mean()Focal 的 gamma 设 2.0,alpha 设 0.7,给少数类更高的权重。pred 和 target 都是 B×3×H×W 的多通道格式,分别对应 bump、dent、dot 三个类别,背景不参与 loss 计算。整个组合的意义是:Dice 保证预测区域和标注区域的重叠度,Focal 保证少数类不被背景淹没。训练时 batch size 8,初始学习率 1e-3,AdamW 优化器,60 个 epoch,每 10 个 epoch 验证一次。BN 层在小 batch 下统计量容易漂移,如果 batch size 只能开到 2 或 4,就把编码器里的 BN 换成 GroupNorm,别硬扛。
4. MaskRCNN 分支与两路掩码融合:像素级结果怎么落
UNet 给了疑似区域,但分不清哪些区域属于同一个缺陷,也分不清相邻 bump 的具体边界。这一步交给 MaskRCNN。芯片表面的 bump 经常成排出现,彼此间距只有几个像素,目标检测框会大量重叠,只有用 MaskRCNN 的实例分割才能把每个焊球单独拎出来。
4.1 MaskRCNN 在芯片场景里不负责“发现”,只负责“分实例”
MaskRCNN 的 RPN 仍然会在整图上滑窗,但因为 UNet 概率图已经提前标记了疑似区域,实际推理时可以做一个加速:把 UNet 概率图 binarize 后取连通域的外接矩形,作为 RPN 的 proposal 候选框输入。这样 RPN 不需要从零开始搜,误检数量明显下降。MaskRCNN 真正干的重活是 mask head——在每个候选框内做逐像素分类,输出每个实例的轮廓。
训练 MaskRCNN 时 backone 用 ResNet50+FPN,FPN 的多尺度特征对芯片场景特别重要。bump 和 dent 尺寸跨度有 4 倍,FPN 把不同层级的特征都保留下来,小目标不至于在高层特征图上消失。这里有个参数要注意:ROI Align 输出的 mask 尺寸默认是 28×28,对 dot 这种 10 像素级别的小目标,28×28 的输出太粗糙,建议在配置里显式调大到 56×56。
4.2 微调策略与关键参数
MaskRCNN 不建议从头训练,直接用 COCO 预训练权重微调。芯片表面图像和自然图像差异不小,但预训练模型学到的边缘纹理基础特征仍然有效。微调分两步走:先冻结 backbone,只训练 RPN 和两个 head,跑 20 个 epoch 左右;然后解冻所有层,用更低的学习率联合训练。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 768×768(可和 UNet 不同) | 大图保小目标细节 |
| ROI Align 尺寸 | 56×56 | 提升 dot 掩码精度 |
| anchor scales | [8, 16, 32, 64, 128] | 覆盖 3 像素到 120 像素缺陷 |
| NMS 阈值 | 0.3 | 防止相邻 bump 框合并 |
| 检测置信度阈值 | 0.5 | 低于此值判为弱候选 |
| image_per_batch | 1 | 768×768 输入下显存紧张 |
anchor scales 上限取 128 而不是更大,是因为切块后的图像里单个缺陷不会超过图像尺寸的三分之一。NMS 阈值取 0.3 比默认的 0.5 更严格,原因是 bump 之间的框重叠率极高,宽松 NMS 会把两个焊球框成一个实例。训练时如果显存不够,保持 image_per_batch=1,加上梯度累积效果等同。
4.3 推理融合与后处理:低阈值 UNet + 高阈值 MaskRCNN
推理时两条网络各出一份掩码,直接取交集会漏掉很多像素,直接取并集又会有碎片。我用的策略是“UNet 低阈值保召回,MaskRCNN 高阈值保精度”,然后按下面的规则融合。
import cv2 import numpy as np def fuse_masks(unet_prob, rcnn_mask, thr_u=0.3, thr_r=0.5, min_area=20): u_bin = (unet_prob > thr_u).astype(np.uint8) r_bin = (rcnn_mask > thr_r).astype(np.uint8) only_u = cv2.bitwise_and(u_bin, cv2.bitwise_not(r_bin)) only_r = cv2.bitwise_and(r_bin, cv2.bitwise_not(u_bin)) final = cv2.bitwise_or(r_bin, only_u) num, labels, stats, _ = cv2.connectedComponentsWithStats(final, connectivity=8) keep = np.where(stats[1:, cv2.CC_STAT_AREA] >= min_area)[0] + 1 result = np.zeros_like(labels, dtype=np.uint8) for idx in keep: result[labels == idx] = 1 return result, only_u, only_r融合规则的核心是:MaskRCNN 标出的实例区域全部保留,UNet 独有区域也保留,但 UNet 独有区域会被标记为弱置信候选,后续复判工具里用不同颜色显示。这样做的好处是——bump 边缘的暗环有时会被 MaskRCNN 的 mask head 裁掉,UNet 能补回来;而 UNet 过平滑漏掉的实例,MaskRCNN 能兜住。min_area 设 20 像素,小于这个面积的连通域大概率是镜面反射的碎片噪声。
后处理输出用单通道 uint8 掩码存储,1 表示 bump,2 表示 dent,3 表示 dot,0 表示无缺陷。实际工程里可以按类各做一次融合,最后拼成一张标签图。存储时用 PNG,缺陷区域直接 ROI 编码,避免整张高分辨率图反复读写拖慢产线节拍。
5. 芯片表面缺陷检测的五个翻车点:现象、原因与排查
双模型方案最怕的就是“离线指标好看,上线就翻车”。以下五条是芯片缺陷检测项目里最常踩的坑,每一条都是现象、原因、解决三个步骤排下来的。
5.1 高光反射把 bump 误报成缺陷
现象:UNet 和 MaskRCNN 在无缺陷芯片上大量报 bump,特别是芯片中央的镜面区域,报检率高达 30% 以上。原因:bump 是中央高光加暗环,镜面反射区域也有高光,灰度特征高度相似,单靠图像内容分不开。解决:采集端加偏振片消除镜面反射,这是最有效的做法;算法端对图像做局部灰度归一化,把每个 chip 的亮度直方图对齐到固定范围,不要让模型靠绝对亮度判断缺陷。我见过不加偏振片只靠算法硬扛的项目,换了三条产线,怎么调阈值都压不住误检。
5.2 UNet 低置信度区域抖动
现象:同一个缺陷在不同批次的推理结果里,像素级边界忽大忽小,有时多出 10 个像素,有时少 10 个像素。原因:训练时增强的随机性太大,特别是亮度扰动幅度过猛,导致 UNet 对边界附近像素的置信度不稳定。解决:把亮度扰动乘法因子控制在 0.85 到 1.15,不要开到 0.5 到 1.5;训练时固定验证集和随机种子,让每个 epoch 的验证结果可比;再加 EMA 权重平滑,用滑动平均后的权重做推理。EMA 的 decay 取 0.99 即可,边界抖动能明显缓解。
5.3 MaskRCNN 把相邻 bump 合并成一个实例
现象:两个间距 5 到 10 像素的 bump,MaskRCNN 输出一个相连的掩码,复判时被当作一个大缺陷。原因:NMS 阈值太高,相邻候选框被合并;另一个可能是 mask head 输出的边界过度膨胀,两个实例的掩码天然接在一起。解决:NMS 阈值从默认 0.5 降到 0.3,增加相邻实例的抑制;对 mask head 的 loss 加一个惩罚项,两个实例掩码的重叠率超过 0.2 时额外加 loss。做好这一步,成排 bump 的实例数才能和人工复判对上。
5.4 小于十个像素的 dot 漏检
现象:dot 尺寸在 3 到 8 像素之间时,MaskRCNN 几乎全部漏检,UNet 偶尔能框出来,但边界完全不可用。原因:特征图经过 FPN 多次下采样后,小缺陷的响应被相邻像素“稀释”,ROI Align 在 28×28 输出下也几乎没有有效像素。解决:把输入分辨率从 512 提到 768,ROI Align 输出调到 56×56,再给 FPN 增加一个更高分辨率的 P2 层。代价是显存和推理时间,但 dot 的召回率通常能从 50% 涨到 80% 以上。
5.5 离线验证好但换产线失效
现象:测试集 mIoU 有 0.6,模型部署到另一条产线后,误检率直接翻倍。原因:两条产线的相机型号、光源角度、chip 在画面中的位置都不一致,模型学到的其实是上一套采集环境的分布。解决:做跨产线验证,用产线 B 的 200 张图做盲测,不参与任何训练;上线前把采图参数统一,包括相机增益、曝光时间、光源亮度,甚至 ROI 裁剪的 pad 比例。像素级模型对采集一致性非常敏感,这一点比检测框模型严重得多。
6. 从验证到上线的最后一环:像素级指标与模型迭代
上线前要同时看两套指标,缺一不可。第一套是像素级 mIoU,直接度量掩码和标注的逐像素重叠程度,公式是每个类别的交并比取平均。第二套是实例级 mAP@0.5,判断“是不是把每个缺陷作为一个实例找出来了”。很多项目只盯 mAP,结果像素边界对不齐,产线复判工具根本没法用。
def compute_miou(gt, pred, num_classes=3): miou = 0.0 for c in range(1, num_classes + 1): inter = ((gt == c) & (pred == c)).sum() union = ((gt == c) | (pred == c)).sum() miou += inter / (union + 1e-6) return miou / num_classes我给验收定的底线是:像素级 mIoU 大于 0.55,实例级 mAP@0.5 大于 0.4,单颗芯片总推理时间在 80ms 以内(GPU 条件下)。低于这个线,产线复判压力会非常大。
进阶验证可以加一个轮廓距离指标,用 Hausdorff 95% 分位数看预测边界和标注边界的最坏偏差。这个指标对像素级项目比 mIoU 更直观,偏差小于 3 个像素才算拖焊点边缘这类精细结构也能用。
模型上线后要建一个滚动标注循环:每周从产线误检和漏检里抽 100 张,人工修正后混入训练集。我第一个版本只盯着 mAP 调参,上线两周后被产线退回,原因是边界精度不达标。后来把像素级指标纳入验收,问题才真正暴露出来。芯片表面缺陷检测的难点从来不是跑通模型,而是把边界精度、小目标召回和产线一致性同时守住。希望帮到你。
本文还有配套的精品资源,点击获取