铝型材表面瑕疵识别实战:从数据清洗到目标检测模型调优
简介这份源码包源自2018年天池广东工业智造大数据创新大赛初赛铝型材表面瑕疵识别赛题面向计算机视觉、智能制造方向的课程设计、毕业设计及大作业、竞赛复现场景也适合希望快速上手工业质检算法的开发者学习参考。压缩包整体仅8KB共含4个文件包括3个Python脚本和1个说明文档。data_process.py负责数据预处理myDensenet.py实现基于DenseNet的瑕疵分类模型model2.py提供另一套模型对照README.md则补充了运行方式与项目结构。已有126人浏览学习。代码全部通过运行验证项目答辩平均评分达96分。从数据处理到模型训练推理均有脚本覆盖结构清晰简洁既可作深度学习图像分类项目的参考范例也可修改扩展应用于其他工业检测任务。1. 铝型材表面瑕疵识别是目标检测不是图像分类初赛任务到底在考什么铝型材生产线上的表面质检过去主要靠老师傅在强光下目检速度快但漏检率随疲劳程度波动明显。天池 2018 广东工业智造大数据创新大赛的智能算法赛初赛把这个问题压缩成一份带标注的图像数据包给定铝材表面照片选手需要定位出画面里的瑕疵区域并给出类别。标题末尾挂着 zip意味着你拿到的不是在线评测 API而是一个需要自己解包、整理、对齐标注的压缩包这跟实际项目里接手一份没清洗过的数据资产的路径几乎一致。这个任务最容易踩的认知误区是把它当成图像分类。一张图上可能同时出现擦花、脏点、桔皮三类缺陷同一类缺陷也可能出现多处整图贴一个标签无法回答“瑕疵在哪”和“一共有几处”而这两个信息恰恰是产线质检最关心的。所以正确的落点是目标检测对每个瑕疵输出边界框、类别和置信度评测再按框与真实标注的 IoU 匹配来计分。下面的思路按我处理这类赛题的顺序展开先解包统计理解数据再定检测模型体系然后调增强与类别不均衡最后做提交前的后处理。跑过分类任务但没正经碰过检测的人或者正在做工业视觉表面缺陷检测的从业者都可以顺着这套流程直接抄作业。2. 从 zip 到标注统计铝型材瑕疵数据集解包、解析与类别分布拿到压缩包的第一件事不是解压后立刻开训而是先确认压缩包内层的目录结构。赛题 zip 里通常还嵌套着一层或两层目录直接 extractall 会把图片、XML、划分文件混在同一个目录里后面写数据加载脚本时会反复踩路径坑。先打印文件名列表看清层级再决定解压方式。2.1 解包后的目录结构图片、XML 标注与划分文件的对应关系import zipfile ZIP_PATH 天池2018广东工业智造大数据创新大赛——智能算法赛初赛铝型材表面瑕疵识别.zip OUT_DIR aluminum_data with zipfile.ZipFile(ZIP_PATH) as zf: names zf.namelist() print(len(names)) # 文件总数判断包是否完整 for n in names[:40]: print(n) # 观察是否存在统一的顶层目录 zf.extractall(OUT_DIR)先打印后解压是处理未知 zip 的稳妥顺序如果顶层目录是images/和annotations/平级后续脚本就直接引用这两个路径如果包内还有一级train/、val/子目录则按子目录组织数据集。extractall 之前还应确认包内没有同名文件覆盖问题Windows 路径下的中文文件名偶尔会变成乱码发现异常就改用zf.extract(member, OUT_DIR)逐个解出并重命名。解压完成后要确认标注格式。这类比赛数据最常见的组织方式是每个图片对应一个同名 XML 文件即 Pascal VOC 格式size节点记录图像宽高object节点里的bndbox给出 xmin、ymin、xmax、ymaxname给出类别。训练/验证划分一般依靠单独的 txt 清单文件或者直接按目录分好不要在脚本里硬编码划分先读官方给出的划分文件。2.2 用 ElementTree 解析标注并统计瑕疵类别的框形态import xml.etree.ElementTree as ET import pandas as pd from pathlib import Path ann_dir Path(OUT_DIR) / annotations rows [] for xml_path in ann_dir.glob(*.xml): root ET.parse(xml_path).getroot() img_w int(root.find(size/width).text) img_h int(root.find(size/height).text) for obj in root.iter(object): name obj.find(name).text box obj.find(bndbox) x1 float(box.find(xmin).text) y1 float(box.find(ymin).text) x2 float(box.find(xmax).text) y2 float(box.find(ymax).text) rows.append({ img: xml_path.stem, cls: name, w: x2 - x1, h: y2 - y1, rel_area: (x2 - x1) * (y2 - y1) / (img_w * img_h), }) df pd.DataFrame(rows) stats df.groupby(cls).agg( 框数(img, count), 平均宽(w, mean), 平均高(h, mean), 平均相对面积(rel_area, mean), ) print(stats.sort_values(框数, ascendingFalse))这段脚本把每个标注框转成一行记录框数能直接暴露类别不均衡程度平均宽高能看出哪些瑕疵是细长条。这套工业瑕疵数据里常见的类别形态规律如下表具体类别名和数字以你解压出来的 XML 为准类别形态典型示例框特征对检测的主要挑战细长条状擦花、漏底、喷流宽高比可达 5:1 以上长条容易被 NMS 拆断小圆斑状脏点、起坑面积占比常在 1% 以下漏检率高需小目标处理大块面状桔皮、杂色面积占比大边界模糊框不准极少量类角位漏底数量少、位置固定样本不足容易过拟合统计完类别分布后下一步决策就清晰了如果最少的类别只有几十个框就得准备重采样或损失加权如果大量框是细长条anchor 比例就要往大比例方向扩展。这些判断在写模型配置文件之前完成能省掉至少一轮试错。2.3 把标注画回原图排查标注异常的三个常见现象import cv2 import xml.etree.ElementTree as ET img cv2.imread(aluminum_data/images/sample_0001.jpg) root ET.parse(aluminum_data/annotations/sample_0001.xml).getroot() for obj in root.iter(object): name obj.find(name).text if name is None: continue b obj.find(bndbox) x1, y1 int(float(b.find(xmin).text)), int(float(b.find(ymin).text)) x2, y2 int(float(b.find(xmax).text)), int(float(b.find(ymax).text)) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, name, (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imwrite(check_sample_0001.jpg, img)随机挑 20 张图画出标注能发现三类常见问题第一部分 XML 的坐标是浮点且写到了图像边界外取整后照样画出框但训练时会有负坐标或超界第二个别 object 节点缺name或bndbox上面的find返回 None脚本里要显式跳过并记录日志而不是直接抛异常第三手机或扫描件图像带 EXIF 旋转信息OpenCV 读进来后内容和 XML 坐标错位特征表现为框总是画在空白区。这些都是真实比赛包里出现过的脏数据形态处理掉它们比调模型参数更优先。3. 检测模型选型与基线搭建Faster R-CNN 为什么比 YOLO 更适合这场初赛这类瑕疵识别赛的通用 baseline 其实很固定检测模型加 ImageNet 预训练骨干。初赛阶段我更倾向先跑 Faster R-CNN 而不是 YOLO原因是这个数据集的缺陷形态分布特殊。铝型材图像的分辨率不高瑕疵又偏小偏细长单阶段检测器虽然快但对小目标的召回通常弱于两阶段模型。初赛的目标是先拿到稳定基线得分训练时间有限两阶段模型省心。3.1 小目标与形态细长的瑕疵决定了 anchor 设计Faster R-CNN 的 RPN 用密集预设 anchor 做前景/背景粗筛anchor 的尺度和比例必须跟标注框统计对齐。前面用 pandas 统计出的平均宽高比不是白算的如果擦花类框的宽高比普遍超过 5:1默认的ratios[0.5, 1.0, 2.0]就覆盖不到RPN 会把这些长条当成难例。常见做法是把比例扩成[0.5, 1.0, 2.0, 4.0, 8.0]同时配合 Feature Pyramid Network 的高低层特征融合让 1/8、1/16、1/32 分辨率下都能感知不同尺度的瑕疵。骨干网络选 ResNet50 还是 ResNet101主要看训练时限和显存。初赛通常只有几天窗口单卡 11G 显存跑 ResNet50-FPN 的 batch size 8 比较从容ResNet101 能把 mAP 顶高零点几个点但训练时间增加近一倍。更值得做的是用 COCO 预训练权重做迁移初始化而不是从零训练复现成本低且收敛快得多。3.2 用 mmdetection 组织数据并搭 Faster R-CNN 基线import json import xml.etree.ElementTree as ET from pathlib import Path # 类别列表以实际解包后的 XML 为准 cls_names [擦花, 脏点, 漏底, 喷流, 起坑, 桔皮, 杂色, 涂料, 不导电, 角位漏底] cls2id {c: i 1 for i, c in enumerate(cls_names)} def voc2coco(img_root, ann_root, out_path): images, annotations [], [] ann_id 1 for xml_path in sorted(Path(ann_root).glob(*.xml)): root ET.parse(xml_path).getroot() stem xml_path.stem img_path Path(img_root) / f{stem}.jpg if not img_path.exists(): print(missing:, img_path) # 只报警不退出统计脏数据 continue img_id len(images) 1 images.append({ id: img_id, file_name: img_path.name, width: int(root.find(size/width).text), height: int(root.find(size/height).text), }) for obj in root.iter(object): name obj.find(name).text b obj.find(bndbox) x1 float(b.find(xmin).text) y1 float(b.find(ymin).text) x2 float(b.find(xmax).text) y2 float(b.find(ymax).text) cid cls2id.get(name, 0) if cid 0: continue annotations.append({ id: ann_id, image_id: img_id, category_id: cid, bbox: [x1, y1, x2 - x1, y2 - y1], area: (x2 - x1) * (y2 - y1), iscrowd: 0, }) ann_id 1 with open(out_path, w) as f: json.dump({images: images, annotations: annotations, categories: [{id: v, name: k} for k, v in cls2id.items()]}, f, ensure_asciiFalse) voc2coco(aluminum_data/images, aluminum_data/annotations, aluminum_data/train.json)这段代码把 VOC 标注转成 COCO 格式好让 mmdetection 的 CocoDataset 直接消费。注意bbox必须是[x, y, w, h]而不是两个角点category_id从 1 开始iscrowd置 0这三处写错会导致训练时类别错位或面积计算异常。dict.get的兜底配合裸continue让未知类别只丢框不丢图。转完格式后在训练脚本里指明标注路径即可。3.3 训练配置参数表初始学习率、batch size 与步数的配合下列参数是按单卡 11G 显存、mmdetection 的 Faster R-CNN R50-FPN 模板整理的常用起点跑通后优先调整学习率调度而不是一上来就换模型参数项推荐起点调整方向说明初始学习率0.005batch size 翻倍时按线性缩放规则同步翻倍学习率衰减步[8, 11] epoch12 epoch 总时长下比较稳warmup500 iter稳定前期的 BN 统计量batch size8显存不足降到 4学习率同步减半图像短边/长边800/1333小目标多时可试 1000/1500验证间隔每个 epoch 一次用 val mAP 判断而不是只看 train loss这套配置在类似缺陷检测数据上通常第一轮就能跑到一个不丢人的基线。如果赛题给的训练时间只有几个小时直接把骨干的frozen_stages设为 2冻结前两层再训收敛会更快。4. 数据增强与类别不均衡把训练损失压下去的调整点基线跑通之后分数提升主要来自三个调整点增强策略是不是贴合铝材图像的特点、类别不均衡处理得够不够狠、学习率调度和收敛判断有没有按检测任务的特点来。这三个点单独做每项可能只涨零点几个百分点的 mAP叠加起来效果明显。4.1 面向铝材图像的增强管线翻转与裁剪的边界# mmdetection 配置文件里的 train_pipeline 关键片段 train_pipeline [ dict(typeLoadImageFromFile), dict(typeLoadAnnotations, with_bboxTrue), dict(typeRandomFlip, flip_ratio0.5, directionhorizontal), dict(typeResize, img_scale(1333, 800), keep_ratioTrue), dict(typeNormalize, mean[123.675, 116.28, 103.53], std[58.395, 57.12, 57.375], to_rgbTrue), dict(typePad, size_divisor32), dict(typeDefaultFormatBundle), dict(typeCollect, keys[img, gt_bboxes, gt_labels]), ]水平翻转对铝材表面图像是安全的因为型材的左右方向不携带语义。垂直翻转要谨慎喷流、涂料这类瑕疵的形态与重力走向有关翻过来会让模型学到不存在的分布这类带有方向性的增强宁可不做。随机裁剪在这里也容易帮倒忙细长瑕疵被裁掉一半后标注框面积严重缩水模型学到的是残缺样本。如果确实要多尺度优先在 Resize 阶段做短边 640 到 1000 的随机采样比 RandomCrop 温和得多。需要旋转增强时mmdetection 原生 pipeline 没有现成的 RandomRotate常见做法是用 albumentations 包一个自定义 transform 接入类似dict(typeRandomRotate, angle_range(-15, 15))的接口注意旋转后的标注框要用最大外接矩形重新计算而不是沿用原坐标。角位漏底这类位置固定的缺陷旋转增强反而会造成标注语义漂移建议针对具体类别做条件增强工作量不大但效果稳定。4.2 重采样与损失加权克服少样本类别的过拟合# 按类别框数生成 loss 权重少样本类给更高权重 class_weight [1.0] * (len(cls_names) 1) class_weight[cls2id[角位漏底]] 5.0 class_weight[cls2id[脏点]] 3.0 class_weight[cls2id[喷流]] 2.0 # 在 mmdetection 里通过继承 bbox_head 或直接改 loss 配置传入 bbox_headdict( typeShared2FCBBoxHead, reg_class_agnosticFalse, loss_clsdict( typeCrossEntropyLoss, use_sigmoidFalse, class_weightclass_weight)), # 关键参数损失加权的思路是让少样本类别在反向传播时占有更高梯度比例但它不增加样本本身类别特征仍然只从几十个框里学噪声会被放大。更稳妥的组合是重采样加加权把含角位漏底样品的图片在数据加载器里重复采样三到四倍配合轻度加权。重采样要放在训练集的 sampler 层做不要在文件系统里复制图片否则验证集划分会被污染。这两类处理都会放大少样本类的过拟合风险判断标准是验证集上该类的召回是否改善而精度没有崩塌。如果发现少样本类框出一堆假阳性多半是权重给太大退一步只做重采样或者把权重降到 2.0 即可。4.3 用学习率衰减与验证集 mAP 判断收敛python tools/train.py configs/faster_rcnn_r50_fpn.py \ --work-dir work/faster_rcnn \ --cfg-options \ data.train.ann_filealuminum_data/train.json \ data.val.ann_filealuminum_data/val.json \ runner.max_epochs12 \ lr_config.step[8, 11] \ evaluation.interval1用--cfg-options覆盖数据集路径而不是改配置文件可以保留一份干净的官方模板后续对比实验时只需换参数。evaluation.interval1让每个 epoch 结束都跑一次验证 mAP这是判断收敛的最直接信号如果 train loss 还在降但 val mAP 连续两到三个 epoch 不涨甚至回落就是过拟合优先调低增强强度或加大 weight decay而不是加训练轮数。检测任务的验证耗时比分类长不少初赛数据量不大通常几分钟能跑完。如果时间紧张可以把验证集的采样间隔从每个 epoch 改为每两个 epoch省出的时间用于多试一组学习率。学习率调度上12 epoch 的 1x schedule 已经够用把总轮数拉到 24 但步数等比后移的收益通常不如把时间花在后处理和阈值选择上。5. 提交前的一轮后处理置信度阈值、NMS 与翻转 TTA 的叠加收益模型训完不要直接拿默认输出的 0.05 置信度结果去提交。检测模型输出的分数分布和评测口径之间存在一个可优化的间隔这个间隔里通常能找回一到两个点的 mAP而且不需要重训模型。按下面三个步骤依次做每步都有独立收益。5.1 置信度阈值的网格搜索import numpy as np # preds 是模型在验证集上的全部输出列依次为 图片id, 类别, 分数, 框 # anns 是验证集真实标注匹配逻辑按 IoU 0.5 判定命中 best (0.0, None) for th in np.arange(0.1, 0.7, 0.05): keep preds[preds[score] th] p compute_precision(keep, anns) r compute_recall(keep, anns) f1 2 * p * r / (p r) if p r 0 else 0.0 print(fth{th:.2f} p{p:.3f} r{r:.3f} f1{f1:.3f}) if f1 best[0]: best (f1, th) print(best threshold:, best[1])阈值偏高会降低召回偏低会引入大量低质量框网格搜索的目标是 F1 最高点。注意这里的匹配逻辑要和赛题评测口径保持一致。5.2 翻转 TTA 的坐标映射代码def tta_infer(model, img, flipTrue): # 原图推理 boxes, scores, labels model(img) results [(boxes.copy(), scores.copy(), labels.copy())] if flip: img_f img[:, ::-1].copy() boxes_f, scores_f, labels_f model(img_f) h, w img.shape[:2] # 水平翻转变换只改 x 坐标x w - 1 - x boxes_f[:, [0, 2]] w - 1 - boxes_f[:, [0, 2]] results.append((boxes_f, scores_f, labels_f)) # 两组框合并后做一次低阈值 NMS保留分数更高的一次 final_boxes merge_and_nms(results, iou_thr0.4) return final_boxes翻转 TTA 的关键坑在于坐标映射推理时把图水平翻转输出框的 xmin/xmax 必须映射回原图坐标系后才能和其他结果合并只翻箱不映射会导致框全部偏到画面错误半区mAP 反而下降。合并时 NMS 阈值要比训练时低一般取 0.4 左右让重叠框有机会被融合如果两个方向给出的同一目标分数接近可以按分数加权平均代替单纯保留最大分。5.3 NMS 参数与多模型融合要不要做初赛阶段不建议做多模型集成训练多个模型然后加权投票时间和算力成本高在数据量小的瑕疵数据集里收益有限。把精力按顺序花在这三件事上更划算阈值网格搜索、翻转 TTA、最后用宽高比过滤异常框。宽高比超过 15 的长框通常在细长瑕疵类别上反复出现是典型的误检形态直接从结果里剔除比再训一轮正则化更直接。提交前把验证集上的最终阈值记录下来用同一阈值处理测试集输出。提示TTA 和阈值搜索都应该只在训练集上选择验证集只作为最终确认否则你在测试集上的得分会带上验证集的过拟合偏差。这场初赛的分数曲线大致遵循一个规律基线跑通占六成数据清洗和标注统计占两成后处理占两成。把 zip 解包、标注可视化、类别统计这几步做扎实比反复换模型骨架更能拉开差距。提交前最后检查一下评测要求里的框坐标格式是整数还是浮点以及类别编号是否从 0 开始这两处格式错误在历届类似赛事里导致过不少次无效提交。本文还有配套的精品资源点击获取