多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

毛巾缺陷检测数据集+YOLOv8训练实战:从XML标注到模型部署全流程

毛巾缺陷检测数据集+YOLOv8训练实战:从XML标注到模型部署全流程 简介面向布匹质检与毕业设计缺陷检测场景毛巾缺陷数据集提供435张真实采集的毛巾图像并配套436个VOC格式XML标注与436个YOLO格式TXT标签可直接用于目标检测模型的训练与验证。数据集覆盖多种典型缺陷类型适合计算机视觉方向的学生、研究人员快速开展模型实验也方便在此基础上进行数据增强与算法调优。压缩包内共1307个文件其中jpg原图435张、xml标注436个、txt标签436个整体仅11.72MB体积轻量易于下载与迁移使用。已有420人学习或下载作为入门级缺陷检测数据集能够帮助使用者省去繁琐的标注环节直接聚焦于网络搭建、损失函数设计与检测效果优化也可按需留言获取数据增强脚本进一步扩充训练样本。1. 435张毛巾缺陷图够不够训练一个能上线的检测模型同样做布面缺陷检测有人拿了十万张工业图还是被纹理干扰打到怀疑人生有人只用四五百张就把复现流程跑通了差别基本不在模型而在数据标签有没有一次到位。这套毛巾缺陷检测数据集一共435张图片每张都带对应的xml标签和yolo格式标签不用你再半夜补标注、对坐标。它解决的是目标检测里最磨人的第一步拿到一批真实缺陷图片后怎么快速喂进yolov8这类框架并保证训练和验证阶段不因格式问题翻车。适合刚用yolov8训练自己数据集的人也适合纺织质检做预研的小团队。435张不算多但配齐双格式标签以后它就是你把检测流程整个走通的那块最佳起步磨刀石。2. 先看清手里的牌xml标签与yolo格式标签怎么对应、怎么核对毛巾缺陷数据集的特殊之处在于同一张图同时给你Pascal VOC的xml标注和YOLO的txt标注。这两套东西描述的是同一批框但坐标系、单位、组织方式完全不同。很多人拿到手就急着训练结果训练日志里一大半图片提示没有标签回头查才发现txt文件里第一列类别写的是字符串而yolov8要的是整数。所以先花十分钟看清这两套标签后面省一天。2.1 xml标签读什么object、name、bndboxxml里最要紧的节点就三个size管图像宽高object/name管缺陷类别名object/bndbox管缺陷矩形在像素坐标系下的左上角和右下角坐标。注意VOC里的坐标全部是绝对像素值单位是“个像素”不是百分比。import xml.etree.ElementTree as ET from pathlib import Path xml_path Path(Annotations/0001.xml) root ET.parse(xml_path).getroot() size root.find(size) img_w int(size.find(width).text) img_h int(size.find(height).text) print(f图片尺寸: {img_w} x {img_h}) for obj in root.findall(object): name obj.find(name).text bndbox obj.find(bndbox) xmin int(bndbox.find(xmin).text) ymin int(bndbox.find(ymin).text) xmax int(bndbox.find(xmax).text) ymax int(bndbox.find(ymax).text) print(f{name}: ({xmin}, {ymin}) - ({xmax}, {ymax}))这段代码的核心是把xml解析成可读的标注列表。root.find(size)取的是第一个匹配节点比findall更直接。读取bndbox时全部转成int后面做归一化换算时才能得到正确比例。毛巾缺陷的框通常比较紧凑破洞、污渍这类目标不会占满整张图所以xmin和xmax一般不会等于图像边界如果解析后发现大量框贴边那多半是标注时把背景框进去了后面训练会很难收敛。2.2 yolo格式为什么是五列归一化数值怎么和xml互相换算yolo格式的txt每一行代表一个目标五列依次是类别id、目标中心点x坐标、目标中心点y坐标、目标宽度、目标高度。后四列全部除以图像宽高做归一化取值范围落在0到1之间。这就是为什么yolov8训练时完全不看xml只认txt——它对路径和格式的依赖比你想的更死板。from pathlib import Path import xml.etree.ElementTree as ET def voc_to_yolo(xml_path: Path, cls_map: dict, out_path: Path): root ET.parse(xml_path).getroot() size root.find(size) img_w int(size.find(width).text) img_h int(size.find(height).text) lines [] for obj in root.findall(object): name obj.find(name).text cls_id cls_map[name] bndbox obj.find(bndbox) xmin int(bndbox.find(xmin).text) ymin int(bndbox.find(ymin).text) xmax int(bndbox.find(xmax).text) ymax int(bndbox.find(ymax).text) x_center (xmin xmax) / 2.0 / img_w y_center (ymin ymax) / 2.0 / img_h box_w (xmax - xmin) / img_w box_h (ymax - ymin) / img_h lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}) out_path.write_text(\n.join(lines)) cls_map {破洞: 0, 污渍: 1, 线头: 2} voc_to_yolo(Path(Annotations/0001.xml), cls_map, Path(labels/0001.txt))关键点在第一列。xml里存的是可读的类别名yolo里只认数字id这个cls_map必须和后面data.yaml里的names顺序完全一致。归一化时刻注意中心点和宽度都除以图像宽度高度相关除以图像高度不能统一除以长边。我曾见过有人把x、y、w、h全部除以宽结果竖构图的目标框全部上移。换算完别急着删xml留着后面做交叉验证。2.3 用一个脚本核对xml与txt是否一一对应拿到手的数据集不一定干净最常见的情况是某张图有xml但txt是空的或者txt里面框的数量和xml对不上。这个问题在训练时会被yolov8忽略掉只在训练日志的角落留下一个warning等你发现时模型已经学歪了。from pathlib import Path img_dir Path(images) xml_dir Path(Annotations) txt_dir Path(labels) for img_path in sorted(img_dir.glob(*.jpg)): stem img_path.stem xml_file xml_dir / f{stem}.xml txt_file txt_dir / f{stem}.txt if not xml_file.exists(): print(缺少xml:, img_path.name) continue if not txt_file.exists(): print(缺少txt:, img_path.name) continue xml_box_count xml_file.read_text().count(object) txt_box_count len(txt_file.read_text().strip().splitlines()) if txt_file.read_text().strip() else 0 if xml_box_count ! txt_box_count: print(f框数不一致 {img_path.name}: xml{xml_box_count}, txt{txt_box_count})这里用read_text().count(object)粗算xml框数逻辑简单但够用如果xml里object节点带属性改成解析后再统计。框数不一致通常不是标注出错而是xml里存在difficult1的难例样本转换时被过滤掉了。这种情况可以接受但你要在训练时知道这一点不然会误以为标签漏了。核验通过的图片才能进入下一步的目录整理。3. 组织目录与跑通yolov8训练435张图的划分、yaml与最小命令标签核验完毕接下来就是把两套格式落地成yolov8能直接吃的目录。这一章解决的是“处理数据集用于yolov8训练”这个需求里最高频的一段路目录怎么摆、yaml怎么生成、训练命令给到什么参数能稳住不翻车。3.1 统一images与labels目录后按8:2切分yolov8默认不看你原始文件在哪它只认两种目录images和labels必须平级图片文件和同名txt文件的后缀可以不同但主文件名必须完全一致。这套数据集的原始目录可能是Annotations、labels混着装不管它先统一。import random import shutil from pathlib import Path random.seed(42) src_images Path(images) src_txts Path(labels_yolo) dst Path(towel_dataset) for split in [train, val]: (dst / images / split).mkdir(parentsTrue, exist_okTrue) (dst / labels / split).mkdir(parentsTrue, exist_okTrue) all_images sorted(src_images.glob(*.jpg)) all_images sorted(src_images.glob(*.png)) random.shuffle(all_images) val_count int(len(all_images) * 0.2) val_images all_images[:val_count] train_images all_images[val_count:] for split, imgs in [(train, train_images), (val, val_images)]: for img in imgs: txt src_txts / f{img.stem}.txt if not txt.exists(): print(跳过缺失标签:, img.name) continue shutil.copy(img, dst / images / split / img.name) shutil.copy(txt, dst / labels / split / txt.name) print(f{split}: {len(imgs)} 张)435张图按8:2切训练集348张验证集87张。验证集比例看起来合理但87张图在缺陷类别分布上很可能不均衡后面会专门讲这个问题。切分时固定random.seed(42)保证每次复现的结果一致。我习惯把缺失标签的图片直接跳过而不是硬塞进训练集因为yolov8对无标签图片的默认处理是直接忽略留着你也不知道它到底忽略的是哪几张。3.2 从xml自动生成data.yaml别手填namesdata.yaml是yolov8的数据入口里面至少要有图片根路径path、训练集和验证集相对路径、类别名列表names。很多人卡在names这一项因为不知道毛巾缺陷数据集的类别顺序手填就容易和txt第一列的id错位。import xml.etree.ElementTree as ET from collections import Counter xml_dir Path(Annotations) cls_counter Counter() for xml_file in xml_dir.glob(*.xml): root ET.parse(xml_file).getroot() for obj in root.findall(object): cls_counter[obj.find(name).text] 1 cls_names [name for name, _ in cls_counter.most_common()] print(类别顺序:, cls_names) print(各类别样本数:, dict(cls_counter))先统计全部xml里出现的类别名按出现次数从高到低排这个顺序就是yolo标签里第一列的id顺序。然后把cls_names写进data.yaml的names列表顺序不能动。毛巾缺陷数据集里破洞、污渍、线头这类缺陷的出现频率差异可能很大直接用统计顺序做id是最稳的至少保证0号类别是数量最多的那个训练时损失函数的变化更平滑。# towel_dataset/data.yaml path: /home/user/towel_dataset train: images/train val: images/val names: 0: stain 1: hole 2: thread上面names里的名字按你实际统计结果替换。记住一个原则txt第一列的数字不是天生的是你生成标签时定的yaml里的names必须和它对齐否则mAP会一直上不去而且你根本看不出哪里错。3.3 小样本训练命令与核心参数目录和yaml就绪后训练命令其实很短。模型我建议从yolov8s起步别一上来就上yolov8x——435张图撑不起大模型的参数量强行训练只是把过拟合从第80轮提前到第30轮。cd towel_dataset yolo detect train \ datadata.yaml \ modelyolov8s.pt \ epochs200 \ batch16 \ imgsz640 \ patience60 \ workers4 \ device0 \ seed42参数说明epochs200对435张图来说足够跑完整个过拟合周期最终生效的模型由patience60决定也就是验证集指标连续60轮不涨就自动停imgsz640是yolov8系列默认输入尺寸如果缺陷目标比较小后面会专门讲怎么提batch16在8GB显存上基本安全显存不够就降到8。这条命令跑完runs/detect/train/weights/best.pt就是你这一轮的最优模型。如果你的显卡是4GB的老卡把modelyolov8s.pt换成yolov8n.pt训练时间砍半mAP大约低两个点但流程完全一致。4. 毛巾缺陷数据集最常见的五个翻车现场现象、原因、解决这一章是血泪经验。下面五条我基本都在类似纺织数据集上遇到过有些坑回看原因蠢得让人想删log。4.1 日志提示Not all images have labels现象训练正常启动但日志里频繁出现Not all images have labels甚至found 0 images in train, 0 in val。原因目录层级不对。yolov8要求labels和images在同一个父目录下也就是towel_dataset/images/train和towel_dataset/labels/train。你把txt放到towel_dataset/labels_yolo/train它就完全看不见。解决严格按照3.1节的目录结构重新组织然后跑一遍3.1的脚本确认train和val都有输出。之后用find towel_dataset -name *.txt | head看一眼实际路径别只看自己脑子里想象的路径。4.2 训练能跑但mAP一直在0.1附近来回跳现象Loss正常下降但验证集mAP0.5始终不到0.2跟随机猜测差不多。原因txt第一列的类别id和data.yaml里names的顺序对不上。最常见的是有人用网上随便找的类别名单替换了names而txt里的id是按原始类别统计顺序生成的两边根本不是一个顺序。解决删掉所有手动写的yaml回到第3.2节用xml统计类别顺序再生成。如果你的txt是别人提供的那就反着来遍历txt第一列取最大值加1用这个数字反推类别数量再用xml里的类别名做对照两边对上了再训。4.3 训练中途Loss变NaN模型权重全部变成nan现象前几十轮正常某轮loss直接变成nan之后权重文件再无法加载evaluation结果全为0。原因标注坐标越界或者说数值异常。毛巾这种纹理复杂的目标容易出现极细长的框归一化后高度只有0.000几前向传播时数值稳定性崩掉。还有一种情况是图像尺寸是0xml里的width节点读出来是空字符串换算时直接除0。from pathlib import Path for txt_file in Path(towel_dataset/labels).rglob(*.txt): lines txt_file.read_text().strip().splitlines() for idx, line in enumerate(lines): parts line.split() if len(parts) ! 5: print(f格式错误 {txt_file.name} 第{idx1}行: {line}) continue _, xc, yc, w, h parts xc, yc, w, h map(float, (xc, yc, w, h)) if w 0 or h 0 or xc 0 or yc 0 or xc 1 or yc 1: print(f坐标越界 {txt_file.name} 第{idx1}行: {line})解决跑上面的自检脚本把所有异常行打印出来。越界的框要么手动修正xml后重新转换要么直接删掉这一行。435张图里如果只有三五条异常删掉对训练影响几乎为零硬留着反而会把loss搅浑。4.4 验证集只有87张图mAP像过山车现象训练时mAP震荡幅度极大第90轮0.6第95轮0.35第100轮又回到0.58无法判断模型到底有没有变好。原因验证集87张其中某几个类别可能只有两三张图随机翻转和数据增强一扰动mAP就大幅波动。这是小样本数据集的统计噪声不是模型玄学。解决一是把随机种子固定住确保每轮验证用的是同一批图二是训练时以mAP0.5为主指标别去盯mAP0.5:0.95后者在小验证集上噪声更大三是最后用5折交叉验证每折训一个模型取平均mAP才是有说服力的数字。后面第5章会给简化的做法。4.5 模型把毛巾的毛圈纹理误检成缺陷现象val集上precision很高但推理测试时几乎每张毛巾都有十几个框全部打在正常纹理和折痕上。原因毛巾表面纹理周期性强破洞和污渍这类缺陷在灰度特征上和纹理阴影很接近。小样本训练时没给足够的负样本模型学到的其实是“局部对比度异常”而不是“缺陷”于是纹理稍乱的地方就被激活了。解决收集二十到五十张没有缺陷的毛巾图作为负样本放进训练集图片保持同样分辨率标签留空的txt即可。yolov8训练时会把负样本图片一起参与loss计算模型就能学到“纹理乱但没破洞”也得判为背景。如果找不到负样本退一步用mosaic0.5降低马赛克增强比例减少把多个纹理局部拼在一起制造的假缺陷。5. 只有435张图怎么打类别不均衡、增强参数与小目标处理毛巾缺陷检测的难点不在模型结构在于小目标多、类别数量悬殊、背景纹理干扰强。这一章把435张图的潜力全部榨干。5.1 先统计类别再决定是采样加权还是补样本训练前花两分钟统计一下各类别的实例数量。毛巾这类产品里破洞和污渍可能是大头线头、毛絮这类细长缺陷数量少且目标小模型很容易直接忽略它们因为对loss的贡献被大目标压住了。from pathlib import Path from collections import Counter txt_dir Path(towel_dataset/labels/train) cls_counter Counter() for txt_file in txt_dir.glob(*.txt): for line in txt_file.read_text().strip().splitlines(): if not line.strip(): continue cls_id int(line.split()[0]) cls_counter[cls_id] 1 total sum(cls_counter.values()) for cls_id, cnt in sorted(cls_counter.items()): print(f类别{cls_id}: {cnt} 个实例, 占比 {cnt / total * 100:.1f}%)当最少的类别占比不到最多类别的十分之一时先别急着调loss。我一般先做两步一是把占大头类别的训练图视觉上扫一遍确认没有一半框是误标二是把少数类的txt用工具复制一份做简单的离线增强。yolov8自带的scale和translate增强对小目标不友好后文会说。如果某个类别整图只有几个实例就直接接受“这个类别注定检不好”的现实先保主要类别。5.2 yolov8默认增强对毛巾场景的适配调整yolov8训练时默认开启mosaic、hsv扰动、平移缩放和随机翻转。这些增强对自然场景数据集很有效但用在毛巾上要逐个检查。增强项默认行为毛巾场景建议hsv_h / hsv_s / hsv_v0.015 / 0.7 / 0.4保持默认。毛巾缺陷与颜色相关度低不影响translate0.1降到0.05避免小目标偏移出图太多scale0.5降到0.3缺陷框小缩放过猛会让目标更小fliplr0.5保持0.5水平翻转对毛巾缺陷完全合理mosaic1.0降到0.3四张拼图产生的拼接缝会被当成缺陷纹理mixup0.0保持0.0混合样本对纹理类缺陷没有正面作用close_mosaic10保持默认最后10轮关掉mosaic让模型稳定收尾我的调参逻辑是小样本、小目标、纹理背景复杂的场景先削弱mosaic和scale让模型把注意力放在原始标注框上。反过来如果你发现模型对背景过拟合再去加强translate。每条改动后跑一个50轮小实验看val趋势别一次性全改。5.3 毛巾线头和小破洞加大imgsz还是切图毛巾缺陷里线头和轻微勾丝属于典型小目标占据的面积可能只有30×30像素。yolov8默认的640输入尺寸下网络下采样32倍后特征图上只剩不到一个像素的响应检不出来是正常的。最简单的方案是训练时把imgsz提高到960。显存要求会上升batch减半即可。如果960下显卡跑不动就用切图方案把1024×1024的毛巾图切成四个512×512的patch每个patch单独标注、单独训练推理时也切成patch分别检测再把框拼回去。两者的差别在于提高imgsz不改变数据分布整图语义保留完整切图相当于把样本数翻四倍模型对小目标的过拟合风险更低但切图会在边界产生截断目标需要额外处理。yolo detect train \ datadata.yaml \ modelyolov8s.pt \ epochs200 \ batch8 \ imgsz960 \ patience60 \ device0 \ seed42实践建议先按960训一版看val mAP是否明显超过640的版本。如果超过三个点以上说明数据集中小目标比例确实高再考虑上切图方案。如果差异不大说明主要瓶颈在样本量或标注质量这时候去花时间切图收益很低。6. 导出与自检从best.pt到固定尺寸ONNX再画框回验检测流程全通后最后一公里是把训练好的模型导出成ONNX部署到其他环境。导出的坑说多不多但值得防一手。yolo export modelruns/detect/train/weights/best.pt formatonnx dynamicFalse imgsz960 batch1dynamicFalse把输入尺寸固定成训练时的imgsz避免部署端因为动态维度在TensorRT上多花时间。用onnxruntime验证一下导出是否完整。import onnxruntime as ort import numpy as np from PIL import Image import cv2 session ort.InferenceSession(best.onnx) input_name session.get_inputs()[0].name print(输入节点:, input_name, session.get_inputs()[0].shape) img cv2.imread(test.jpg) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (960, 960)) img img.astype(np.float32) / 255.0 img np.transpose(img, (2, 0, 1))[None] onnx_outputs session.run(None, {input_name: img}) print(输出张量数:, len(onnx_outputs), [o.shape for o in onnx_outputs])紧接着做最后一道自检抽十张训练图把对应txt的归一化坐标还原成像素坐标画框肉眼确认框和缺陷对齐。这一步很土但比什么高级指标都管用能立刻发现类别id错位、坐标越界一类问题。from pathlib import Path import cv2 test_files [0001, 0020, 0100] for name in test_files: img cv2.imread(fimages/train/{name}.jpg) h, w img.shape[:2] for line in Path(flabels/train/{name}.txt).read_text().strip().splitlines(): cls_id, xc, yc, bw, bh map(float, line.split()) xmin int((xc - bw / 2) * w) ymin int((yc - bh / 2) * h) xmax int((xc bw / 2) * w) ymax int((yc bh / 2) * h) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 0, 255), 2) cv2.imwrite(fcheck_{name}.jpg, img)用归一化坐标乘回原始宽高时会遇到整数截断误差画出来的框比标注原始框小一到两个像素这是正常的。如果画出来的框大面积偏移那说明txt和当前图像不是同一批数据回去查文件名映射。我至今保留着这个画框自检的习惯每次拿到新数据集都先画一圈再训练。这个习惯救过我不止一次希望你也能用上。本文还有配套的精品资源点击获取
返回列表