简介:一套面向工业视觉与智能制造领域的布匹瑕疵检测方案,源自广东工业智造大赛复赛,适合竞赛选手、算法工程师及计算机相关专业学生,用于缺陷检测算法学习与实战。压缩包共543个文件,主体为311个Python源码和170个pyc编译文件,并附带C++/CUDA扩展算子(涵盖DCN、ROIAlign、NMS等)以提升推理效率;另有md说明文档、sh部署脚本、Dockerfile及JSON配置,便于快速还原运行环境。整体仅11.48MB,目录围绕数据、模型、训练与推理模块组织,已有168人学习。代码经完整测试且运行成功,可直接复现赛题结果,也可作为毕业设计或课程项目二次开发;配套说明文档与原始数据支持远程答疑,方便在真实布匹样本上开展检测实验与参数调优。
1. 布匹瑕疵检测,复赛题和你平时练手的目标检测不是一回事
复赛数据拿到手,最先让人栽跟头的往往不是模型,而是数据本身。赛题给的布匹瑕疵样本里,一类瑕疵可能只有几十个标注实例,另一类却占了大半张图;同一个疵点在不同光照、不同布色下拍出来,特征差异比类别差异还大。这种长尾分布加上工业现场的噪声,决定了你不能照搬COCO那套训练流程直接跑。标题里同时出现“python源码”“文档说明”“数据”三样东西,意味着这题的重心不只是刷精度,还需要你把数据组织、模型选型、训练策略和结果验收串成一条可复现的流水线——源码是骨架,文档是暗号,数据才是真正决定排名的东西。想在这种比赛里拿到稳的中上成绩,关键不是找某个“神网络”,而是搞清楚瑕疵检测的评估口径和数据泄漏的边界。这篇文章就按“数据怎么准备、模型怎么选、参数怎么调、坑怎么避”的顺序,把这套方案完整落一遍。
2. 布匹瑕疵数据怎么组织、怎么标注、怎么切分:先把“数据泄漏”这个坑填平
2.1 复赛给到的布匹瑕疵数据,先按这个目录结构归好类
不管从网上下到的源码包是zip解压还是git clone,先不要急着读模型文件,第一步永远是整理数据目录。布匹瑕疵检测的训练集通常包含两类东西:原始布匹图像和对应的像素级标注图(mask)。有的比赛给的是单通道png,像素值0表示背景、1、2、3…表示不同瑕疵类别;有的给的是RGB伪彩色标注,需要做颜色到类别ID的映射。
常见做法是先落成一个标准结构:
data/ train/ images/ 20230101_lot42_piece17_r01.png 20230101_lot42_piece17_r02.png masks/ 20230101_lot42_piece17_r01.png 20230101_lot42_piece17_r02.png val/ images/ masks/ test/ images/把图像和mask文件名对齐,是后续所有脚本能跑通的前提。很多开源的瑕疵检测代码里默认按_mask或_label后缀去匹配mask文件,如果你的文件名对不上,第一个报错就会出现在DataLoader里。
2.1.1 用Python脚本做一次完整性校验
我一般会先写一个五分钟的检查脚本,把缺失、尺寸不一致、类别数异常的样本全部滤出来:
from PIL import Image import os, numpy as np img_dir = "data/train/images" mask_dir = "data/train/masks" bad = [] for name in sorted(os.listdir(img_dir)): img_path = os.path.join(img_dir, name) mask_path = os.path.join(mask_dir, name.replace(".jpg", ".png")) if not os.path.exists(mask_path): bad.append((name, "mask missing")) continue im = Image.open(img_path) ms = Image.open(mask_path) if im.size != ms.size: bad.append((name, f"size mismatch {im.size} vs {ms.size}")) mask_arr = np.array(ms) if len(np.unique(mask_arr)) > 8: # 假设类别上限是7+背景 bad.append((name, f"unexpected classes: {np.unique(mask_arr)}")) print(f"checked {len(os.listdir(img_dir))} images, {len(bad)} problems") for item in bad[:20]: print(item)逻辑说明:这段脚本把图像路径、mask路径、尺寸和类别数一次性对齐。name.replace(".jpg", ".png")是常见的命名替换,如果你的数据全是png,就换成直接拼接或replace("_image", "_label")。检查出问题不要直接删样本,先看是不是切分脚本路径写错了——多数情况下是源码包里的路径变量没改,而不是数据本身损坏。
2.2 切分数据集的正确姿势:按布匹ID分,不要按图片随机分
这是布匹瑕疵检测和数据竞赛里最容易被忽略、又最影响复现结果的一步。一张布匹在生产线上会被裁成很多段连续拍摄,同一个布匹ID下的多张图像在纹理、底色、光照上是强相关的。如果按图片粒度随机切分,同一匹布的图像会同时出现在训练集和验证集里,验证分数会明显虚高;等你把模型提交上去跑测试集,成绩立刻缩水。这种问题在竞赛里叫数据泄漏,在数据库语境里有时也叫“样本泄露”。布匹瑕疵检测里正确的做法是按布匹ID做group切分。
from sklearn.model_selection import GroupShuffleSplit import glob image_paths = sorted(glob.glob("data/train/images/*.png")) # 文件名形如 20230101_lot42_piece17_r01.png ,第3段是布匹ID def get_piece_id(path): return path.split("/")[-1].split("_")[2] piece_ids = [get_piece_id(p) for p in image_paths] gss = GroupShuffleSplit(n_splits=1, test_size=0.15, random_state=42) train_idx, val_idx = next(gss.split(image_paths, groups=piece_ids)) print(f"train images: {len(train_idx)}, val images: {len(val_idx)}") print(f"val pieces: {len(set([piece_ids[i] for i in val_idx]))}")参数说明:GroupShuffleSplit里的groups参数传的是每个样本所属的组ID,这里就是布匹ID。test_size=0.15表示拿15%的布匹ID做验证,而不是15%的图片。random_state=42固定随机种子,保证每次切分结果一致,这对后面调参时对比实验非常关键。如果源码包的文档说明里写了“验证集按图随机切分”,建议自己改成按布匹ID重切,否则后期换模型时会被虚高分数误导。观察一下输出里验证集的布匹数:验证集有不少于3个完整布匹会比较稳,如果只有一个布匹,说明切分运气不好,建议调整random_state或增加test_size。
2.3 布匹瑕疵数据增强:哪些可靠,哪些反而把纹理变成了伪瑕疵
布匹瑕疵检测的数据增强和通用语义分割不太一样。工业图像一个显著特点是拍摄环境相对固定,但不同布种之间的纹理差异极大;同时瑕疵是局部小目标,增强操作不能破坏瑕疵和背景的边界关系。
| 增强操作 | 建议 | 原因 |
|---|---|---|
| 水平翻转 / 垂直翻转 | 常用 | 布匹图像没有方向性,翻转不改变瑕疵语义 |
| 随机裁剪缩放 | 常用 | 模拟瑕疵在不同视野尺度下的成像,增强尺度鲁棒性 |
| 亮度/对比度扰动 | 常用 | 工业现场光源衰减、反光导致亮度波动 |
| 小角度旋转(±15°) | 谨慎 | 大角度旋转会破坏布匹经纬纹理方向,干扰模型学习纹理背景 |
| 弹性形变 | 低频使用 | 形变过度会把正常纹理扭曲成类似褶皱的伪瑕疵 |
| 随机擦除 | 不推荐 | 布匹背景本来就有大量纹理,擦除后模型容易把擦除区域学成背景空洞 |
具体到代码,如果源码里用的是albumentations,我通常这样配置:
import albumentations as A train_transform = A.Compose([ A.RandomCrop(512, 512), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.5), A.Rotate(limit=10, border_mode=0, p=0.3), ]) # mask和image必须走同一套transform def apply_transform(image, mask): aug = train_transform(image=image, mask=mask) return aug["image"], aug["mask"]RandomCrop的尺寸要和模型输入匹配。Rotate设了border_mode=0,旋转产生的空白区填黑值,不会引入额外的纹理噪声;如果你不填这个参数,默认填充方式可能复制边缘像素,在瑕疵分割任务里会造成伪边缘。RandomBrightnessContrast的幅度不要超过0.2,否则会把浅色瑕疵直接淹没在背景里。
还有一个和增强配套的细节:训练时mask和image必须用同一套随机参数做变换,也就是上面Compose里定义的统一调用方式。如果分两次调用不同的transform,图像和标注会完全错位,训练过程不会报错,但loss曲线会一直在高位抖动。
这一章把所有数据问题解决掉之后,才能进入模型选型阶段。很多时候复现成绩不理想,不是模型代码的问题,而是数据切分和增强设置从一开始就歪了。
3. 布匹瑕疵检测模型选型:语义分割还是目标检测,榜单指标到底在比什么
3.1 像素级分割与框级检测的取舍:先看瑕疵形态再定技术路线
布匹瑕疵的形态大致分两类:一类边界清晰的异物,比如破洞、油污、线头;一类是区域性的纹理异常,比如褶皱、色差、起球。边界清晰的用目标检测也能框得住,但纹理类瑕疵没有明显的矩形边界,框级标注会把大量正常区域圈进正样本里,模型学到的是“这块区域整体不对”,而不是“哪些像素不对”。复赛级别的布匹瑕疵检测,源码里清一色走语义分割路线的原因就在这里——输出和标注都是像素级mask,对不规则瑕疵的表达能力更强。目标检测适合上游定位、快速筛选可疑区域;语义分割适合需要知道瑕疵面积和形状的质检场景。如果源码里同时包含检测头和分割头,优先用分割结果作为主输出。
从落地角度给一个选型表:
| 需求 | 推荐方案 | 场景 |
|---|---|---|
| 只判断有没有瑕疵 + 定位到框 | YOLOv8 或 Faster R-CNN | 在线粗筛、低成本快速检测 |
| 需要瑕疵面积、形状、精细边界 | U-Net 系 / DeepLabV3+ / SegFormer | 复赛“像素级标注”的标准做法 |
| 同时要检测和分割 | Mask R-CNN 或 Mask2Former | 分类细、要求召回高但算力充足 |
复赛给了像素级mask标注,那主模型就用分割模型,没必要自己发明混合结构。常见的做法是U-Net加一个预训练encoder骨干,或者直接上DeepLabV3+。基于标题里的源码方向,我下面的示例用segmentation_models_pytorch这个库搭建,因为它封装了多种encoder,换骨干只需要改一行参数。
3.2 布匹瑕疵检测评估指标:mIoU、F1-score 和像素准确率的坑
复赛榜上有三个常见评估口径:mIoU(平均交并比)、F1-score、像素准确率(pixel accuracy)。像素准确率是最容易骗人的指标,背景像素占了绝大多数,模型只要把所有像素预测成背景,准确率也可能超过90%。所以评判分割模型的主指标优先看mIoU,它是逐类别计算交并比再取平均,对类别不均衡更敏感。mIoU的计算方式在代码里非常直白:
import numpy as np def compute_miou(pred_mask, true_mask, num_classes): ious = [] for cls in range(num_classes): pred = (pred_mask == cls) true = (true_mask == cls) intersection = np.logical_and(pred, true).sum() union = np.logical_or(pred, true).sum() if union == 0: continue # 当前类在真值和预测中都没出现,跳过 ious.append(intersection / union) return np.mean(ious)参数说明:num_classes要包含背景类,通常是类别数+1。union == 0的情况表示这一类别在整个样本里既没有真值也没有预测,在工业瑕疵检测里经常出现某个类别在单张图里完全不存在的情况,直接跳过比返回0更合理,否则会把mIoU拉低,和榜单口径也对不上。如果你的模型有ignore_index之类的参数,注意和这个跳过逻辑保持一致。
F1-score在瑕疵检测里通常按像素计算,也就是把分割结果当成像素级的二分类或者多分类输出来算precision和recall。实际提交时,如果榜单用mask IoU排名,训练时就以mIoU为主要监控指标;如果榜单把mask读成某种评测协议,那么以官方文档说明为准。源码里如果写了自定义的评估脚本,优先用源码里的那个,因为复赛的官方评价口径往往和公开数据集的默认指标有细微差别。
3.3 布匹瑕疵检测训练参数:一个能稳定跑到中上成绩的基线配置
我不建议一上来就换大模型。先跑一个能在验证集上正常收敛的基线,再逐步迭代。以一个输入512×512的U-Net为例子,用segmentation_models_pytorch初始化:
import segmentation_models_pytorch as smp import torch model = smp.Unet( encoder_name="timm-efficientnet-b4", # 换backbone只要改这里 encoder_weights="imagenet", in_channels=3, classes=8, # 和数据集真实类别数保持一致 activation="softmax2d", ) criterion = smp.losses.DiceLoss(mode="multiclass") optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=60, eta_min=1e-6)参数说明:encoder_weights="imagenet"使用ImageNet预训练权重,虽然工业布匹纹理和自然图像差距不小,但预训练权重提供的底层边缘、颜色特征仍然是有效的,从头训练的收敛速度和最终精度都明显更差。AdamW配合weight_decay=1e-4在分割任务里比较稳,SGD需要更长训练轮数才能发挥出来。CosineAnnealingLR的T_max=60表示60个epoch内把学习率从3e-4余弦降到1e-6,这样的学习率曲线在瑕疵检测里比固定学习率更好收敛。classes参数要改成你数据里瑕疵类别数+1(背景),否则最后的输出层分类数量对不上。
| 参数 | 建议值 | 调整方向 |
|---|---|---|
| 输入尺寸 | 512×512 | 显存够就上640,小瑕疵效果提升明显 |
| batch size | 8~16 | 根据显存调,太小会引入噪声 |
| 初始学习率 | 3e-4 (AdamW) | 不收敛或Loss震荡就降到1e-4 |
| Dice系数权重 | 0.7~1.0 | 小目标占比大类,保持高权重 |
| 混合精度 | FP16 | 开启可显著提速,注意loss scale设置 |
| 训练轮数 | 60~100 | 早停点在验证集mIoU不再上升时 |
为什么Dice Loss在这里比单纯的交叉熵好用?布匹瑕疵里的局部缺陷像素占比可能只有0.1%甚至更低,交叉熵的梯度会被背景像素主导,Dice Loss直接优化区域重叠程度,天然对前景类有侧重。训练中途如果发现验证分数波动很大,可以检查是不是batch size太小导致Dice Loss不稳定,适当加大batch或降低学习率。
4. 布匹瑕疵检测训练踩坑实录:Loss不下降、类别不均衡、小瑕疵漏检
4.1 布匹瑕疵模型Loss不收敛,先查这四个位置
训练布匹瑕疵模型时最常遇到的现象是Loss一直不降,或停在某个高位震荡。先从数据端排查,再动模型参数。第一个容易出问题的是类别映射。RGB标注图和单通道mask的类别ID可能不一致,比如源码期待类别从0开始,你的标注里背景是255,那就等于多了一个类别,Loss会被干扰。第二个是多分类时用了sigmoid激活而没改成softmax,输出通道之间不互斥,模型无法正确学习类别归属。第三个是学习率过大,尤其是用预训练encoder时,微调阶段初始学习率超过1e-3很容易出现Loss爆升。第四个是DataLoader里mask和image没有同步归一化,mask被当成图像做标准化之后类别值变成了小数,模型读出的是连续值而不是离散标签。查这四处基本能覆盖90%的“不收敛”。
当Loss数值正常下降但验证集mIoU纹丝不动时,问题多半在数据切分或评估脚本上。我之前遇到过一次val损失在下降、mIoU却接近0的情况,最后发现是验证集里混入了几个没有mask的空样本,标注全黑,mIoU被强制拉低。遇到这种情况,先把验证集里任何标注为空或全背景的样本打出来,确认是否真的是“无瑕疵布匹”的负样本——这类负样本在复赛数据里存在,但不能占比过高。
4.2 布匹瑕疵样本不均衡:用加权损失和OHEM控制易分类样本的梯度
瑕疵类别之间的样本量差距往往在几十倍以上,比如“破洞”有500个标注区域,“起球”可能只有20个区域。直接用Dice Loss或交叉熵,模型会把精力全放在出现频率高的类别上,低频瑕疵几乎学不到。我一般用类别频率加权的交叉熵混合Dice Loss来缓解:
import torch.nn.functional as F class_weights = torch.tensor([0.5, 1.2, 2.0, 3.5, 1.0, 4.0, 2.5, 3.0]).cuda() bce_loss = F.cross_entropy(logits, mask, weight=class_weights, ignore_index=255) dice_loss = smp.losses.DiceLoss(mode="multiclass")(logits, mask) total_loss = 0.5 * bce_loss + 0.5 * dice_loss参数说明:class_weights按每个类别出现像素频率的倒数做归一化,数字越大代表该类样本越稀缺。你不需要精确统计,只要把低频瑕疵的权重调到高频类的2~4倍即可,过高的权重会让模型对噪声敏感。ignore_index=255把标注里的无效区域排除在loss计算之外,避免边界标注不准的地方干扰训练。这个混合策略比单独用Dice Loss稳定,因为交叉熵提供了像素级梯度,Dice Loss提供了区域级约束。
如果混合Loss仍然压不住梯度噪声,可以考虑在训练后期加入OHEM(在线困难样本挖掘),只选取loss值排在前30%的像素回传梯度。不过在语义分割任务里OHEM对超参数更敏感,建议先用加权CE+Dice跑通主线,遇到低频瑕疵不收敛再加OHEM,不要在一开始就把复杂度拉满。
4.3 小瑕疵漏检:从标注协同和重叠滑窗推理两个方向修
布匹瑕疵里的小目标问题是另一个重灾区。某些瑕疵占整张图不到1%的像素,被连续下采样之后直接消失。根本原因是模型输入的尺寸受限,比如Encoder下采样32倍,512×512的输入到了最深层特征图就只剩16×16了,小块破洞的信息在这个尺度下已经丢失。第一步修复方法是把输入尺寸从512提升到640甚至768,但显存有限时成本太高。更常见的做法是在推理阶段用重叠滑窗,把小瑕疵放大到模型更敏感的尺度。
def sliding_window_infer(model, image, window=512, stride=256): h, w = image.shape[:2] pred = np.zeros((h, w), dtype=np.int64) count = np.zeros((h, w), dtype=np.float32) for y in range(0, h, stride): for x in range(0, w, stride): y2 = min(y + window, h) x2 = min(x + window, w) crop = image[y:y2, x:x2] crop = torch.tensor(crop).permute(2,0,1).unsqueeze(0).float().cuda() with torch.no_grad(): out = model(crop)["out"] if isinstance(model(crop), dict) else model(crop) pred_crop = out.argmax(dim=1)[0].cpu().numpy() pred[y:y2, x:x2] = pred_crop count[y:y2, x:x2] += 1 return pred参数说明:window是模型输入尺寸,stride控制相邻窗口的重叠程度。stride=256表示每次滑动半个窗口,重叠区域的预测结果会被后写入的窗口覆盖,不会做加权平均;如果想更平滑,可以把pred改成累加每个类别的概率再取argmax。滑窗推理的代价是推理时间成倍上升,通常只在验证集或测试集上对漏检严重的小瑕疵启用。
在数据端还有一个容易忽略的办法:检查标注框边缘是否贴合瑕疵像素。很多提供出来的源码数据里,标注是半自动生成的,低频瑕疵的标注质量往往不如高频类。对漏检的低频类别,可以单独统计这类瑕疵的边缘像素中预测为背景的比例;如果比例高,说明标注边界不齐导致模型没学会精确轮廓。此时不要急着换模型,先用形态学腐蚀膨胀修正mask边缘,再重新训练,往往比换backbone更有效。
5. 复赛源码和文档说明的正确用法:伪标签迭代和提交前验证
5.1 用预训练模型给无标注数据生成伪标签,补低频瑕疵样本
复赛的“数据”目录里往往会带一部分无标注图像,这些图像的价值在于扩充低频瑕疵样本。但要注意伪标签的生成方式:先用训练好的模型推理出像素级mask,再对mask做置信度过滤,只有置信度高于0.9的区域才进入训练集。低置信度区域的噪声会让模型记住错误特征,得不偿失。生成伪标签时,不要让模型自己确认自己,建议用两个不同结构的模型(比如U-Net和DeepLabV3+)分别推理,只保留两个模型预测一致的像素做标签。
5.2 文档说明里藏着推理路径:按官方要求对齐输出格式
源码包里的文档说明除了介绍数据,还会写明测试集推理结果的上传格式。常见的两种格式:一是输出RGB伪彩色图,每种颜色对应一个瑕疵类;二是输出单通道灰度图,像素值就是类别ID。如果文档里要求RGB格式,而你直接提交了单通道灰度图,评分脚本会把图像加载成三通道再读像素值,导致所有类别识别失败。写一个输出格式校验函数,在提交前跑一遍:
def check_submission_format(pred_path): img = Image.open(pred_path) arr = np.array(img) print(f"shape: {arr.shape}, dtype: {arr.dtype}, unique: {np.unique(arr)}") if arr.ndim == 3: print("RGB mode, verify colors match label map") elif arr.ndim == 2: print("single channel, pixel values are class ids")参数说明:arr.shape如果返回(H, W, 3)说明是RGB输出,(H, W)说明是灰度输出。unique输出真实的类别值列表,如果出现负值或大于最大类别数的数,说明预测mask越界,需要检查softmax解码逻辑。
5.3 一套可以照抄的提交前验收流程
临近提交,按下面的顺序过一遍,能避免大部分低级失误:
- 用训练好的模型跑完整个验证集,按榜单同样口径算mIoU,确认与训练日志的val分数偏差在2%以内。偏差过大的话,检查验证集切分方式是否与代码一致。
- 随机挑10张验证图,把原图、真值mask、预测mask拼在一起人工看一眼,重点看低频瑕疵类是否被预测出来。指标一致性和可视化结果要同时通过。
- 检查测试集推理输出尺寸是否和原图一致。布匹图像长宽不是固定值,很多模型会把输入resize成固定尺寸,推理时要恢复回原始尺寸再保存。
- 确认提交文件的命名和目录层级和文档说明完全一致,zip包内不能有多余文件。
整套流程跑完,复现的分数才算真正属于你的。调参过程中,每改动一个变量就重新记录一次验证分数,对比实验会直接告诉你哪个策略在你的数据上有效、哪个只是拖慢训练。
本文还有配套的精品资源,点击获取