
简介本资源面向半导体质量控制与深度学习缺陷检测方向的研究者及工程师提供一套基于Mask RCNN与UNet融合的芯片表面缺陷检测完整项目实战。算法可对bump凸起、dent凹陷、dot点状等缺陷实现像素级实例分割与精确定位兼顾检测精度与处理速度适配工业实时性要求。压缩包共73个文件约4.03MB以22个py源码、21个pyc编译文件、12个csv数据表、9个ipynb实验笔记及xml标注文件为主涵盖数据预处理、模型训练、结果评估与应用部署等关键环节目录按UNet、mrcnn、chips等模块清晰组织。已有118人学习关注。读者可获取双模型融合的完整代码实现、训练与评估脚本、可视化分析笔记及项目配置说明便于快速复现实验、理解像素级缺陷分割流程并迁移至自身芯片质检场景中参考落地。1. 芯片表面缺陷检测从 Mask R-CNN 到 UNet 的像素级方案芯片封装完成后表面缺陷检测是良率管控的最后一道关卡。bump凸点缺失、dent压痕、dot污点这三类缺陷有个共同特点它们不是整张图级别的分类问题而是像素级别的定位问题。一颗芯片上可能有几百个 bump其中某一个 bump 边缘破损了几个像素传统图像处理手段用阈值分割加形态学操作换一批光照条件就集体翻车。这也是为什么越来越多的团队转向深度学习方案——用 Mask R-CNN 做实例级缺陷定位用 UNet 做像素级语义分割两者互补覆盖不同尺度的缺陷形态。这套方案适合谁如果你手上有几百张带标注的芯片表面图像正在纠结用语义分割还是实例分割或者已经跑通了 unet 但发现小目标漏检严重那这篇内容就是为你写的。我会把从数据标注、模型选型、训练调参到推理部署的完整链路拆开讲包括我实际踩过的坑和参数配置。不堆概念直接上可复现的操作。2. 为什么芯片缺陷检测需要 Mask R-CNN 和 UNet 配合2.1 两类缺陷形态决定了双模型架构芯片表面的 bump 缺陷和 dent/dot 缺陷在图像上的表现差异很大。bump 是规则排列的圆形凸点缺陷表现为缺失、偏移、破损每个 bump 是一个独立实例需要区分“这是第几个 bump 出了问题”。dent 和 dot 则是不规则形状的局部区域边界模糊像素级分割比实例分割更合适。Mask R-CNN 在 Faster R-CNN 的基础上增加了一个 mask 分支对每个候选框输出一个二值掩码。它的优势在于能同时给出缺陷的类别、边界框和像素级掩码而且对实例的区分能力很强。但它的短板也明显——对小目标的分割精度受 RoI Align 分辨率限制一个 20x20 像素的 dot 缺陷经过骨干网络下采样后特征图只剩几个像素mask 分支很难恢复出精细边界。UNet 的编码器-解码器结构配合跳跃连接能把浅层的高分辨率特征直接传到解码器对细小缺陷的边界恢复能力更强。但 UNet 是语义分割它不区分实例——如果一张图上有 50 个 bumpUNet 输出的是一张二值图你没法知道哪个像素属于哪个 bump。所以实际落地时我一般这样分工Mask R-CNN 负责 bump 类缺陷的实例级检测和粗定位UNet 负责 dent/dot 类缺陷的像素级精细分割。两个模型的输出在后处理阶段融合最终生成统一的缺陷报告。2.2 数据标注策略COCO 格式与掩码生成Mask R-CNN 的标准训练格式是 COCO 数据集格式需要每张图对应一个 JSON 标注文件包含图像的宽高、缺陷的类别 ID、边界框坐标和多边形分割点。UNet 则只需要掩码图——每个像素的值代表类别背景为 0缺陷区域为 1 或 2。实际操作中我建议用一套标注数据同时生成两种格式。用 LabelMe 或 CVAT 标注多边形然后写脚本转换import json import numpy as np from PIL import Image, ImageDraw def labelme_to_coco(labelme_json_path, output_coco_path, category_map): 将 LabelMe 标注转换为 COCO 格式 category_map: {bump: 1, dent: 2, dot: 3} with open(labelme_json_path, r) as f: data json.load(f) coco { images: [], annotations: [], categories: [] } for name, cid in category_map.items(): coco[categories].append({id: cid, name: name}) img_id 1 ann_id 1 for item in data: img_w item[imageWidth] img_h item[imageHeight] coco[images].append({ id: img_id, file_name: item[imagePath], width: img_w, height: img_h }) for shape in item[shapes]: label shape[label] points shape[points] # 计算边界框 xs [p[0] for p in points] ys [p[1] for p in points] x_min, x_max min(xs), max(xs) y_min, y_max min(ys), max(ys) bbox [x_min, y_min, x_max - x_min, y_max - y_min] # 展平多边形点 segmentation [[coord for point in points for coord in point]] coco[annotations].append({ id: ann_id, image_id: img_id, category_id: category_map[label], bbox: bbox, segmentation: segmentation, area: (x_max - x_min) * (y_max - y_min), iscrowd: 0 }) ann_id 1 img_id 1 with open(output_coco_path, w) as f: json.dump(coco, f) return coco def coco_to_unet_mask(coco_data, image_dir, output_mask_dir, class_num4): 从 COCO 标注生成 UNet 训练用的掩码图 class_num: 包含背景的类别总数 import os os.makedirs(output_mask_dir, exist_okTrue) for img_info in coco_data[images]: img_id img_info[id] w, h img_info[width], img_info[height] mask Image.new(L, (w, h), 0) draw ImageDraw.Draw(mask) for ann in coco_data[annotations]: if ann[image_id] ! img_id: continue seg ann[segmentation][0] polygon [(seg[i], seg[i1]) for i in range(0, len(seg), 2)] draw.polygon(polygon, fillann[category_id]) mask.save(os.path.join(output_mask_dir, img_info[file_name].replace(.jpg, .png)))这段代码做了两件事先把 LabelMe 的多边形标注转成 COCO JSON再从 COCO 标注生成 UNet 需要的掩码 PNG。参数说明category_map里 bump 设为 1、dent 设为 2、dot 设为 3背景默认为 0。掩码图的像素值直接对应类别 IDUNet 训练时用交叉熵损失就能直接读取。注意多边形标注的精度直接影响最终分割效果。bump 边缘的标注误差超过 2 个像素训练出来的模型在推理时边界就会明显偏移。建议标注时放大到 400% 再画点。3. Mask R-CNN 训练芯片 bump 缺陷的完整配置3.1 骨干网络选择与锚框参数调整Mask R-CNN 默认用 ResNet-50 FPN 作为骨干网络。芯片表面图像的特点是纹理重复性高、缺陷区域小ResNet-50 的感受野在深层已经覆盖了大部分 bump 排列周期反而可能把相邻 bump 的特征混在一起。我的经验是如果 bump 直径小于 32 像素把骨干换成 ResNet-18 或 MobileNetV2浅层特征保留更完整小目标召回率能提升 8-12 个百分点。锚框anchor的尺度和长宽比需要根据 bump 的实际尺寸重新聚类。用 K-means 对训练集中所有 bump 的边界框做聚类import numpy as np from sklearn.cluster import KMeans def cluster_anchors(bboxes, k5): bboxes: list of [w, h] 归一化后的宽高 返回 k 个聚类中心的 (w, h) data np.array(bboxes) kmeans KMeans(n_clustersk, random_state42) kmeans.fit(data) centers kmeans.cluster_centers_ # 按面积排序 areas centers[:, 0] * centers[:, 1] sorted_idx np.argsort(areas) return centers[sorted_idx] # 假设 bump 的宽高比接近 1:1聚类后得到 5 组尺度 # 典型输出[[0.02, 0.02], [0.04, 0.04], [0.06, 0.06], [0.08, 0.08], [0.12, 0.12]]把聚类结果填入 Mask R-CNN 的ANCHOR_SCALES和ANCHOR_RATIOS。如果 bump 是圆形ANCHOR_RATIOS设为 [0.8, 1.0, 1.2] 就够了不需要 [0.5, 1, 2] 这种通用配置。锚框数量从默认的 15 个降到 9 个训练速度提升约 20%误检率下降。3.2 训练参数与学习率调度用 Detectron2 或 MMDetection 训练时关键参数如下# Detectron2 配置示例 from detectron2.config import get_cfg from detectron2 import model_zoo cfg get_cfg() cfg.merge_from_file(model_zoo.get_config_file( COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_3x.yaml)) cfg.MODEL.WEIGHTS model_zoo.get_checkpoint_url( COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_3x.yaml) # 数据集 cfg.DATASETS.TRAIN (chip_train,) cfg.DATASETS.TEST (chip_val,) cfg.DATALOADER.NUM_WORKERS 4 # 骨干网络改为 ResNet-18 cfg.MODEL.RESNETS.DEPTH 18 cfg.MODEL.RESNETS.OUT_FEATURES [res3, res4, res5] # 锚框参数 cfg.MODEL.ANCHOR_GENERATOR.SIZES [[16], [32], [64], [128], [256]] cfg.MODEL.ANCHOR_GENERATOR.ASPECT_RATIOS [[0.8, 1.0, 1.2]] # 学习率小数据集用 0.001大数据集用 0.02 cfg.SOLVER.BASE_LR 0.001 cfg.SOLVER.MAX_ITER 10000 cfg.SOLVER.STEPS (7000, 9000) cfg.SOLVER.GAMMA 0.1 cfg.SOLVER.IMS_PER_BATCH 4 # ROI 采样小目标多时提高正样本比例 cfg.MODEL.ROI_HEADS.BATCH_SIZE_PER_IMAGE 256 cfg.MODEL.ROI_HEADS.POSITIVE_FRACTION 0.5 # 数据增强 cfg.INPUT.MIN_SIZE_TRAIN (800,) cfg.INPUT.MAX_SIZE_TRAIN 1333 cfg.INPUT.MIN_SIZE_TEST 800 cfg.INPUT.RANDOM_FLIP horizontal参数说明BASE_LR设为 0.001 是因为芯片缺陷数据集通常只有几百到几千张图学习率太大会导致损失震荡。MAX_ITER10000 次在单卡 2080Ti 上大约跑 3-4 小时。POSITIVE_FRACTION从默认的 0.25 提到 0.5是因为 bump 缺陷在整张图中的占比很小提高正样本比例能让模型更关注缺陷区域。训练过程中重点看两个指标total_loss和mask_loss。如果mask_loss在 2000 次迭代后还在 0.5 以上说明 mask 分支的学习率不够或者 RoI Align 的输出分辨率太低。可以把MODEL.ROI_MASK_HEAD.POOLER_RESOLUTION从 14 提到 28代价是显存增加约 30%。4. UNet 分割 dent 和 dot 缺陷的调参细节4.1 编码器深度与跳跃连接裁剪UNet 的经典结构是 4 层下采样 4 层上采样。对于芯片表面的 dent 和 dot 缺陷我建议把编码器减到 3 层。原因芯片图像的分辨率通常在 1024x1024 以上4 层下采样后特征图只有 64x64一个 10 像素的 dot 缺陷在特征图上只剩不到 1 个像素解码器再上采样也恢复不出来。import torch import torch.nn as nn class UNet(nn.Module): def __init__(self, in_channels3, out_channels4, features[64, 128, 256]): super().__init__() self.downs nn.ModuleList() self.ups nn.ModuleList() self.pool nn.MaxPool2d(kernel_size2, stride2) # 编码器3 层 for feature in features: self.downs.append(DoubleConv(in_channels, feature)) in_channels feature # 瓶颈层 self.bottleneck DoubleConv(features[-1], features[-1]*2) # 解码器 for feature in reversed(features): self.ups.append(nn.ConvTranspose2d( feature*2, feature, kernel_size2, stride2)) self.ups.append(DoubleConv(feature*2, feature)) self.final_conv nn.Conv2d(features[0], out_channels, kernel_size1) def forward(self, x): skip_connections [] for down in self.downs: x down(x) skip_connections.append(x) x self.pool(x) x self.bottleneck(x) skip_connections skip_connections[::-1] for idx in range(0, len(self.ups), 2): x self.ups[idx](x) skip skip_connections[idx//2] # 裁剪跳跃连接以匹配尺寸 if x.shape ! skip.shape: x torch.nn.functional.interpolate( x, sizeskip.shape[2:], modebilinear) x torch.cat([skip, x], dim1) x self.ups[idx1](x) return self.final_conv(x) class DoubleConv(nn.Module): def __init__(self, in_c, out_c): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_c, out_c, 3, padding1, biasFalse), nn.BatchNorm2d(out_c), nn.ReLU(inplaceTrue), nn.Conv2d(out_c, out_c, 3, padding1, biasFalse), nn.BatchNorm2d(out_c), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x)features[64, 128, 256]表示编码器每层的通道数。如果显存紧张可以降到[32, 64, 128]分割精度损失约 2-3 个百分点。out_channels4对应背景 bump dent dot 四类。跳跃连接处的interpolate是为了处理输入尺寸不是 2 的整数次幂时上采样和跳跃连接尺寸不匹配的问题。4.2 损失函数选择与类别不平衡处理芯片表面缺陷检测的类别不平衡非常严重背景像素通常占 95% 以上dent 和 dot 可能只占 0.5%。直接用交叉熵损失模型会倾向于全部预测为背景。我一般用 Dice Loss Focal Loss 的组合class DiceLoss(nn.Module): def __init__(self, smooth1e-6): super().__init__() self.smooth smooth def forward(self, pred, target): # pred: (B, C, H, W) logits # target: (B, H, W) 类别索引 pred torch.softmax(pred, dim1) target_one_hot torch.nn.functional.one_hot( target, num_classespred.shape[1]).permute(0, 3, 1, 2).float() intersection (pred * target_one_hot).sum(dim(2, 3)) union pred.sum(dim(2, 3)) target_one_hot.sum(dim(2, 3)) dice (2. * intersection self.smooth) / (union self.smooth) return 1 - dice.mean() class FocalLoss(nn.Module): def __init__(self, alpha0.25, gamma2.0): super().__init__() self.alpha alpha self.gamma gamma def forward(self, pred, target): ce_loss torch.nn.functional.cross_entropy( pred, target, reductionnone) pt torch.exp(-ce_loss) focal_loss self.alpha * (1 - pt) ** self.gamma * ce_loss return focal_loss.mean() # 组合损失 dice_loss DiceLoss() focal_loss FocalLoss(alpha0.25, gamma2.0) total_loss 0.5 * dice_loss(pred, target) 0.5 * focal_loss(pred, target)Dice Loss 直接优化预测区域和真实区域的重叠度对类别不平衡不敏感。Focal Loss 通过(1-pt)^gamma降低易分类样本的权重让模型聚焦在难分的缺陷像素上。alpha0.25是正样本的权重系数如果 dent 缺陷特别少可以提到 0.5。训练时用 AdamW 优化器学习率 1e-3余弦退火调度batch size 8。数据增强用随机旋转90 度的整数倍、随机亮度对比度调整、高斯噪声。不要用随机裁剪因为芯片表面的缺陷位置和 bump 排列有空间关联裁剪会破坏这种关联。5. 推理部署与后处理把两个模型串起来5.1 推理流程与置信度阈值调优推理时Mask R-CNN 和 UNet 可以并行跑也可以串行。我的做法是先用 Mask R-CNN 检测 bump 缺陷如果检测到 bump 异常再触发 UNet 对同一区域做精细分割。这样能减少 UNet 的无效计算。def inference_pipeline(image, mask_rcnn_model, unet_model, bump_threshold0.7, unet_threshold0.5): 返回缺陷列表每个缺陷包含类别、边界框、掩码 # 第一步Mask R-CNN 检测 bump with torch.no_grad(): outputs mask_rcnn_model(image) instances outputs[instances] # 按置信度过滤 keep instances.scores bump_threshold instances instances[keep] defects [] for i in range(len(instances)): cls instances.pred_classes[i].item() box instances.pred_boxes[i].tensor.cpu().numpy()[0] mask instances.pred_masks[i].cpu().numpy() defects.append({ type: bump, class_id: cls, bbox: box, mask: mask, score: instances.scores[i].item() }) # 第二步UNet 分割 dent 和 dot with torch.no_grad(): unet_out unet_model(image) unet_pred torch.softmax(unet_out, dim1) unet_mask torch.argmax(unet_pred, dim1).cpu().numpy()[0] # 提取 dent 和 dot 区域 for class_id, class_name in [(2, dent), (3, dot)]: class_mask (unet_mask class_id).astype(np.uint8) if class_mask.sum() 10: # 忽略过小区域 continue # 连通域分析 from scipy import ndimage labeled, num_features ndimage.label(class_mask) for j in range(1, num_features 1): component (labeled j) if component.sum() 20: # 面积过滤 continue ys, xs np.where(component) bbox [xs.min(), ys.min(), xs.max(), ys.max()] defects.append({ type: class_name, class_id: class_id, bbox: bbox, mask: component, score: unet_pred[0, class_id][component].mean().item() }) return defectsbump_threshold0.7是 Mask R-CNN 的置信度阈值设太低会引入大量误检设太高会漏掉模糊缺陷。unet_threshold0.5是 UNet 的 softmax 概率阈值实际用的时候可以按类别调——dent 的边界模糊阈值可以降到 0.4dot 的对比度高阈值提到 0.6 能减少误检。5.2 掩码融合与缺陷报告生成两个模型的掩码可能有重叠区域需要做融合。简单做法是bump 掩码优先级高于 dent/dot因为 bump 是实例级检测置信度更可靠。重叠区域归为 bump。def merge_defects(defects, image_shape): 融合重叠掩码生成最终缺陷图 final_mask np.zeros(image_shape[:2], dtypenp.uint8) # 先放 dent 和 dot for d in defects: if d[type] in [dent, dot]: final_mask[d[mask]] d[class_id] # bump 覆盖上去 for d in defects: if d[type] bump: final_mask[d[mask]] d[class_id] # 生成报告 report [] for class_id, class_name in [(1, bump), (2, dent), (3, dot)]: count 0 area 0 for d in defects: if d[class_id] class_id: count 1 area d[mask].sum() if count 0: report.append({ defect_type: class_name, count: count, total_area_pixels: int(area), avg_area: float(area / count) }) return final_mask, report最终输出是一张和原图同尺寸的掩码图每个像素的值代表缺陷类别外加一份统计报告。产线部署时报告可以直接对接 MES 系统按缺陷数量和面积做分级报警。6. 避坑与排查芯片缺陷检测落地时最容易翻车的 5 个点6.1 现象训练 loss 正常下降但验证集 mask 全是背景原因UNet 的类别不平衡导致模型学会了“全部预测背景”这个局部最优解。交叉熵损失在背景占 95% 时全部预测背景的 loss 只有 0.05 左右看起来很低但模型什么都没学到。解决换成 Dice Loss Focal Loss 组合同时监控验证集的 Dice 系数而不是只看 loss。如果 Dice 系数在 0.1 以下说明模型确实没学到缺陷区域。另外检查掩码图的像素值是否正确——我遇到过标注工具导出的掩码是 0/255 而不是 0/1/2/3导致类别 ID 全部错位。6.2 现象Mask R-CNN 对 bump 的检测框偏移 3-5 个像素原因RoI Align 的采样点数量不够。默认POOLER_SAMPLING_RATIO0表示自适应采样但在小目标上自适应采样会引入量化误差。解决把POOLER_SAMPLING_RATIO设为 2POOLER_RESOLUTION从 14 提到 28。代价是显存增加但 bump 的边界框精度能提升到 1-2 个像素以内。如果显存不够至少把POOLER_RESOLUTION提到 21。6.3 现象UNet 在 dent 缺陷上的分割边界呈锯齿状原因上采样用了最近邻插值nn.Upsample(modenearest)而不是转置卷积。最近邻插值不学习参数边界恢复能力差。解决把上采样换成nn.ConvTranspose2d让网络自己学习上采样的权重。如果已经用了转置卷积但边界仍然粗糙检查跳跃连接是否真的传了浅层特征——有时候代码里torch.cat的维度搞错了浅层特征根本没拼上。6.4 现象推理时同一张图多次运行结果不一致原因模型没有切换到 eval 模式BatchNorm 层在推理时仍然用 batch 统计量而不是滑动平均。或者 Dropout 层没有关闭。解决推理前必须调用model.eval()并且用torch.no_grad()包裹。如果用了混合精度推理还要注意torch.cuda.amp.autocast的上下文管理。我习惯在推理脚本开头就写死这两行避免忘记。6.5 现象产线部署后 GPU 显存溢出原因Mask R-CNN 和 UNet 同时加载到显存加上输入图像分辨率是 2048x2048单张图推理峰值显存超过 8GB。解决两个模型分时加载——先跑 Mask R-CNN释放显存后再加载 UNet。或者用 ONNX Runtime 做推理显存占用能降低 30-40%。如果延迟要求不高还可以把图像切成 512x512 的块分批推理但要注意切块边界处的缺陷会被截断需要重叠 64 个像素再做融合。7. 用 TensorRT 把推理速度压到 15ms 以内训练完的模型直接部署单张 1024x1024 图像的推理时间大约在 80-120ms2080Ti。产线节拍要求通常是一秒检测 3-5 颗芯片这个速度不够。用 TensorRT 做 FP16 量化后Mask R-CNN 和 UNet 的推理时间可以压到 12-18ms。具体步骤先把 PyTorch 模型导出为 ONNX注意 Mask R-CNN 的导出需要自定义 op 来处理 RoI Align。UNet 的导出比较简单import torch.onnx # 导出 UNet unet UNet(in_channels3, out_channels4) unet.load_state_dict(torch.load(unet_best.pth)) unet.eval() dummy_input torch.randn(1, 3, 1024, 1024).cuda() torch.onnx.export( unet, dummy_input, unet.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, opset_version11 )然后用 TensorRT 的trtexec工具转换trtexec --onnxunet.onnx \ --saveEngineunet_fp16.engine \ --fp16 \ --workspace4096 \ --minShapesinput:1x3x512x512 \ --optShapesinput:1x3x1024x1024 \ --maxShapesinput:1x3x2048x2048--fp16开启半精度量化--workspace4096给 4GB 显存做优化空间。minShapes和maxShapes定义了动态输入的尺寸范围TensorRT 会为这个范围生成最优的 kernel。Mask R-CNN 的 TensorRT 部署要复杂一些因为 RoI Align 在 ONNX 里是自定义 op。我一般用 MMDetection 的 TensorRT 插件或者把 Mask R-CNN 拆成骨干网络 RPN ROI Head 三段分别导出。实测 FP16 量化后 mask 的 IoU 下降约 0.5-1 个百分点对产线判定影响不大。验证 TensorRT 引擎的精度时用 100 张验证集图像对比 PyTorch 和 TensorRT 的输出。如果 Dice 系数下降超过 2 个百分点检查是不是某些层的量化误差累积了——可以把第一层和最后一层保持 FP32只量化中间层。这套方案我从数据标注到产线部署跑通了完整链路最大的体会是模型结构的选择只占 30% 的工作量剩下 70% 都在数据处理、参数调优和部署优化上。尤其是掩码标注的质量直接决定了模型精度的上限。如果让我重新做一遍我会在标注阶段花两倍的时间把每个 bump 的边缘都放大到像素级对齐后面训练和调参会省很多事。希望帮到你。本文还有配套的精品资源点击获取