简介:这份源码包源自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、ymax,name给出类别。训练/验证划分一般依靠单独的 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("框数", ascending=False))这段脚本把每个标注框转成一行记录,框数能直接暴露类别不均衡程度,平均宽高能看出哪些瑕疵是细长条。这套工业瑕疵数据里常见的类别形态规律如下表,具体类别名和数字以你解压出来的 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_ascii=False) 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.005 | batch size 翻倍时按线性缩放规则同步翻倍 |
| 学习率衰减步 | [8, 11] epoch | 12 epoch 总时长下比较稳 |
| warmup | 500 iter | 稳定前期的 BN 统计量 |
| batch size | 8 | 显存不足降到 4,学习率同步减半 |
| 图像短边/长边 | 800/1333 | 小目标多时可试 1000/1500 |
| 验证间隔 | 每个 epoch 一次 | 用 val mAP 判断而不是只看 train loss |
这套配置在类似缺陷检测数据上通常第一轮就能跑到一个不丢人的基线。如果赛题给的训练时间只有几个小时,直接把骨干的frozen_stages设为 2,冻结前两层再训,收敛会更快。
4. 数据增强与类别不均衡:把训练损失压下去的调整点
基线跑通之后,分数提升主要来自三个调整点:增强策略是不是贴合铝材图像的特点、类别不均衡处理得够不够狠、学习率调度和收敛判断有没有按检测任务的特点来。这三个点单独做每项可能只涨零点几个百分点的 mAP,叠加起来效果明显。
4.1 面向铝材图像的增强管线:翻转与裁剪的边界
# mmdetection 配置文件里的 train_pipeline 关键片段 train_pipeline = [ dict(type="LoadImageFromFile"), dict(type="LoadAnnotations", with_bbox=True), dict(type="RandomFlip", flip_ratio=0.5, direction="horizontal"), dict(type="Resize", img_scale=(1333, 800), keep_ratio=True), dict(type="Normalize", mean=[123.675, 116.28, 103.53], std=[58.395, 57.12, 57.375], to_rgb=True), dict(type="Pad", size_divisor=32), dict(type="DefaultFormatBundle"), dict(type="Collect", keys=["img", "gt_bboxes", "gt_labels"]), ]水平翻转对铝材表面图像是安全的,因为型材的左右方向不携带语义。垂直翻转要谨慎:喷流、涂料这类瑕疵的形态与重力走向有关,翻过来会让模型学到不存在的分布,这类带有方向性的增强宁可不做。随机裁剪在这里也容易帮倒忙,细长瑕疵被裁掉一半后,标注框面积严重缩水,模型学到的是残缺样本。如果确实要多尺度,优先在 Resize 阶段做短边 640 到 1000 的随机采样,比 RandomCrop 温和得多。
需要旋转增强时,mmdetection 原生 pipeline 没有现成的 RandomRotate,常见做法是用 albumentations 包一个自定义 transform 接入类似dict(type="RandomRotate", 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_head=dict( type="Shared2FCBBoxHead", reg_class_agnostic=False, loss_cls=dict( type="CrossEntropyLoss", use_sigmoid=False, class_weight=class_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_file=aluminum_data/train.json \ data.val.ann_file=aluminum_data/val.json \ runner.max_epochs=12 \ lr_config.step=[8, 11] \ evaluation.interval=1用--cfg-options覆盖数据集路径而不是改配置文件,可以保留一份干净的官方模板,后续对比实验时只需换参数。evaluation.interval=1让每个 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(f"th={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, flip=True): # 原图推理 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_thr=0.4) return final_boxes翻转 TTA 的关键坑在于坐标映射:推理时把图水平翻转,输出框的 xmin/xmax 必须映射回原图坐标系后才能和其他结果合并,只翻箱不映射会导致框全部偏到画面错误半区,mAP 反而下降。合并时 NMS 阈值要比训练时低,一般取 0.4 左右,让重叠框有机会被融合;如果两个方向给出的同一目标分数接近,可以按分数加权平均代替单纯保留最大分。
5.3 NMS 参数与多模型融合要不要做
初赛阶段不建议做多模型集成,训练多个模型然后加权投票,时间和算力成本高,在数据量小的瑕疵数据集里收益有限。把精力按顺序花在这三件事上更划算:阈值网格搜索、翻转 TTA、最后用宽高比过滤异常框。宽高比超过 15 的长框通常在细长瑕疵类别上反复出现,是典型的误检形态,直接从结果里剔除比再训一轮正则化更直接。提交前把验证集上的最终阈值记录下来,用同一阈值处理测试集输出。
提示:TTA 和阈值搜索都应该只在训练集上选择,验证集只作为最终确认,否则你在测试集上的得分会带上验证集的过拟合偏差。
这场初赛的分数曲线大致遵循一个规律:基线跑通占六成,数据清洗和标注统计占两成,后处理占两成。把 zip 解包、标注可视化、类别统计这几步做扎实,比反复换模型骨架更能拉开差距。提交前最后检查一下评测要求里的框坐标格式是整数还是浮点,以及类别编号是否从 0 开始,这两处格式错误在历届类似赛事里导致过不少次无效提交。
本文还有配套的精品资源,点击获取