☰
瓷砖瑕疵检测实战:VOC与YOLO标注转换及YOLOv8训练避坑指南
2026/10/2 20:10:25 网站建设 项目流程

简介:一套面向瓷砖表面瑕疵检测的VOC标注数据集,兼容YOLO训练所需的格式转换,旨在解决人工目检效率低、漏检率高的问题,可直接用于目标检测模型的训练与验证。压缩包内共2000个XML标注文件,总大小仅4.35MB,每个XML详细记录图像中瑕疵的边界框坐标与类别,符合Pascal VOC规范,可借助现有工具快速转换为YOLO需要的txt标签。数据集文件以时间戳与相机编号命名,便于追溯产线来源,适合需要真实工业样本的算法工程师、在校学生和视觉系统开发者。目前已有791人学习下载,使用时可快速完成训练集/验证集划分、标注格式转换与模型调参,免去人工采集和标注瓷砖瑕疵图像的繁琐工作,显著降低时间成本,助力构建高精度自动化质检系统,提升制造环节质量控制效率。

1. 拿到瓷砖瑕疵检测数据集包:先分清VOC与YOLO两套标注

做陶瓷厂质检自动化的人,多半都下过这种压缩包:标题写着“瓷砖瑕疵检测数据集VOC+YOLO标注.zip”,解压进去一堆图片,配了两套标签。VOC 是 xml,YOLO 是 txt,二者坐标语义完全不同。很多人上来就把两套文件混着用,训练时报一大堆 missing label、异常 bbox 错误,最后怪数据集不行。其实这个数据集本身没毛病,只是你没摸清两套标注各自怎么和训练框架配合。这篇笔记就按我整理这类数据集的习惯来写:先看懂目录,再做格式转换和划分,然后跑通 YOLOv8 训练,最后把最常见的几个坑提前排掉。适合刚入行目标检测的工程师,也适合准备把新购入的瑕疵数据接进现有质检流程的熟手。

2. 数据集结构与坐标对照:先确认数据是干净再动手训练

2.1 解压后的目录速览:三类文件决定训练能否启动

VOC 格式那套一般长这样:JPEGImages放原图,Annotations放同名 xml,ImageSets/Main里是 train.txt、val.txt 这类分本列表。xml 里记录的是绝对像素坐标,也就是xmin, ymin, xmax, ymax,左上点和右下点。YOLO 那套则是images目录加labels目录,标签是纯文本,每行五个数字:类别 id、归一化中心点 x、归一化中心点 y、归一化宽、归一化高。

我先会用一条命令把当前目录结构扫出来,重点看 labels 下面有没有 train、val 子目录,因为不同数据包的分法不一样:

find . -maxdepth 3 -type d | sort

有些数据包把 labels 平铺在根目录下,有些按 train/val 分好。区分这两类很简单:平铺版本往往同时提供 train.txt、val.txt 两个文件清单;分好的版本直接按目录切分,不需要再读清单。如果两个都没有,那就是要自己划分,后面第三章我会写划分逻辑。

VOC 侧的ImageSets/Main里如果能看到文件名列表,建议解压后先数一遍 jpg 数量和 xml 数量。瓷砖这类工业数据集的常见问题是转存时丢了一部分 xml,但列表还留在文件里。数量对不上,后面训练就会发现一部分图片没有标签,在 YOLO 训练日志里表现为训练轮数内 val 指标始终偏低。

2.2 用脚本逐个标签读取:VOC 与 YOLO 的对应关系

我会先写一个很短的分析脚本,把第一张图片对应的 xml 和 yolo txt 同时读出来。目标不是看文件长啥样,而是确认类别名称、框坐标是否落在图片范围内。

import xml.etree.ElementTree as ET from pathlib import Path voc_xml = Path("VOCdevkit/Annotations/000001.xml") yolo_txt = Path("labels/000001.txt") img_size = (640, 480) # 换成 JPEGImages 里实际尺寸 root = ET.parse(voc_xml).getroot() print("VOC objects:") for obj in root.findall("object"): name = obj.find("name").text box = obj.find("bndbox") xmin = int(box.find("xmin").text) ymin = int(box.find("ymin").text) xmax = int(box.find("xmax").text) ymax = int(box.find("ymax").text) print(f" {name}: {xmin} {ymin} {xmax} {ymax}") print("YOLO lines:") for line in Path(yolo_txt).read_text().strip().splitlines(): cls_id, xc, yc, bw, bh = map(float, line.split()) x1 = int((xc - bw / 2) * img_size[0]) y1 = int((yc - bh / 2) * img_size[1]) x2 = int((xc + bw / 2) * img_size[0]) y2 = int((yc + bh / 2) * img_size[1]) print(f" class {cls_id}: pixel box = {x1} {y1} {x2} {y2}")

这个脚本把两组标签还原成了同一个坐标系。如果看到 VOC 里是 crack,YOLO txt 里对应类别 id 是 0,而 yaml 里把 0 定义成了 pinhole,那就是类别顺序错位,做多了就会遇到这种数据。所以不是在电脑上看一眼就算,是要逐字比对。

2.3 把标注渲染到图上,验证数据包里没有脏框

代码能打印坐标,但坐标在不在视觉上合理的位置,只有渲染出来才看得出。瓷砖瑕疵数据集最常见的毛病是框画得比实物大一圈,或者把水印、接地孔当成了瑕疵。我想写个快速渲染脚本,把 YOLO 标注画回原图,存成一张检查图,翻几页就能感觉数据质量如何。

import cv2 from pathlib import Path classes = ["crack", "chip", "pinhole", "glaze_drop", "color_defect"] def draw_yolo_label(img_path, txt_path, out_path): img = cv2.imread(str(img_path)) height, width = img.shape[:2] for line in Path(txt_path).read_text().strip().splitlines(): cls_id, xc, yc, bw, bh = map(float, line.split()) x1 = int((xc - bw / 2) * width) y1 = int((yc - bh / 2) * height) x2 = int((xc + bw / 2) * width) y2 = int((yc + bh / 2) * height) color = (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, classes[int(cls_id)], (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2) cv2.imwrite(str(out_path), img) draw_yolo_label(Path("images/train/000001.jpg"), Path("labels/train/000001.txt"), Path("check_000001.jpg"))

跑完翻几页检查图,目光放在三处:框有没有明显贴住瑕疵边缘,小裂纹是否漏标,以及类别名是不是张冠李戴。我见过一个包里面把“崩角”标成了“色差”,这类错位如果不纠正,后面训练出来的模型就会把这两类一起预测错。

3. 把VOC标注转成YOLO格式:转换脚本与数据集划分

3.1 转换脚本:归一化坐标与类别映射

数据集包给两套标注,不代表你一定用 YOLO 这套。实际我建议用 VOC 的那套 xml 作为母版重新转一遍。原因很简单:xml 可读性好,出了坐标问题容易定位;txt 一旦写错,连原始信息都没了。经过转换脚本处理后的 txt,格式和顺序都由自己控制。

下面是我常用的 VOC 转 YOLO 脚本。它做的事情很简单:读 xml → 算像素宽高 → 把 bndbox 改成归一化中心点格式 → 按类别映射表输出 txt。

import xml.etree.ElementTree as ET from pathlib import Path VOC_XML_DIR = Path("VOCdevkit/Annotations") VOC_IMG_DIR = Path("VOCdevkit/JPEGImages") OUT_LABEL_DIR = Path("labels") OUT_LABEL_DIR.mkdir(exist_ok=True) CLASS_MAP = { "crack": 0, "chip": 1, "pinhole": 2, "glaze_drop": 3, "color_defect": 4, } def convert_one_xml(xml_path: Path) -> list: tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") width = int(size.find("width").text) height = int(size.find("height").text) lines = [] for obj in root.findall("object"): name = obj.find("name").text.strip() if name not in CLASS_MAP: print(f"[skip] unknown class {name} in {xml_path.name}") continue bndbox = obj.find("bndbox") xmin = int(float(bndbox.find("xmin").text)) ymin = int(float(bndbox.find("ymin").text)) xmax = int(float(bndbox.find("xmax").text)) ymax = int(float(bndbox.find("ymax").text)) # 这里做一次裁剪,防止标注框把图片边界撑爆 xmin, xmax = max(0, xmin), min(width, xmax) ymin, ymax = max(0, ymin), min(height, ymax) if xmax <= xmin or ymax <= ymin: print(f"[warn] empty box in {xml_path.name}") continue xc = ((xmin + xmax) / 2) / width yc = ((ymin + ymax) / 2) / height bw = (xmax - xmin) / width bh = (ymax - ymin) / height lines.append( f"{CLASS_MAP[name]} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}" ) return lines for xml_path in sorted(VOC_XML_DIR.glob("*.xml")): lines = convert_one_xml(xml_path) out_path = OUT_LABEL_DIR / (xml_path.stem + ".txt") out_path.write_text("\n".join(lines) + "\n" if lines else "")

逻辑上有个细节:CLASS_MAP这个字典的顺序就是之后训练 yaml 里names的顺序,两边必须完全一致,否则类别错位。脚本里:.6f保留六位小数,对 640 分辨率来说精度完全够,别为了省空间压到 3 位,否则坐标误差会被放大。

参数说一句:VOC_XML_DIR、VOC_IMG_DIR这些路径根据解压位置自己改。如果 xml 里没有<size>节点,这个脚本会直接抛异常。遇到这种情况就把该 xml 拿出来修完再跑,不要改脚本硬吞下去。

3.2 文件夹配额与同名校验:按批次划分而不是无限随机

转完所有 xml 之后,接下来做 train、val 划分。这里我强烈建议不要用train_test_split随机切,因为瓷砖瑕疵数据往往是同一片砖、同一光照条件下连续拍摄的,随机切很容易把几乎相同视角的图片同时放进训练集和验证集。训练时 mAP 很好看,一到现场换一条产线就现原形。

我一般先按文件名前缀或拍摄批次分组,再把整个组划进同一侧。比如文件名里带batch01_前缀的样本全部进训练集,batch05_的全部进验证集。下面是一段手动分组代码:

import random from pathlib import Path images = sorted(Path("images/train_src").glob("*.jpg")) random.seed(42) random.shuffle(images) train_ratio = 0.85 split_idx = int(len(images) * train_ratio) train_files = images[:split_idx] val_files = images[split_idx:] with open("train.txt", "w") as f: f.write("\n".join(p.as_posix() for p in train_files)) with open("val.txt", "w") as f: f.write("\n".join(p.as_posix() for p in val_files)) # 校验:每个被分出去的图片,labels 目录下必须有同名 txt label_dir = Path("labels") for split_name, file_list in [("train", train_files), ("val", val_files)]: missing = [] for img in file_list: label = label_dir / (img.stem + ".txt") if not label.exists(): missing.append(img.stem) if missing: print(f"[{split_name}] missing labels: {missing[:10]}")

这段代码核心是两点:一是随机种子固定,别人复现你的结果时不会因为 shuffle 顺序不同而偏离;二是划分完成后立刻做了同名校验,有图无标签的文件会被抓出来。出现 missing 的直接从文件列表里剔除,或者回到原数据集找对应 xml 补转一次。

3.3 YOLO 标签文件和图片的放置方式

转出来的 labels 目录里是所有 txt 平铺的,但 YOLO 训练框架依赖下面的目录结构:images/train、images/val、labels/train、labels/val。我见过有人把图片放images/,标签放labels/,yaml 里 train 指到images/,结果训练时框架完全找不到配对标签,或者找到了但目录名不对。所以要养成一个习惯:先把 images 移动好,再为它们生成对应位置的 labels,不让图片和标签错位。

mkdir -p tiles_dataset/images/{train,val} mkdir -p tiles_dataset/labels/{train,val} while read -r line; do cp "$line" tiles_dataset/images/train/ cp "labels/$(basename "$line" .jpg).txt" tiles_dataset/labels/train/ done < train.txt

最后再把train.txt、val.txt删掉,因为进入 YOLO 训练后不再需要这些清单,yaml 里的目录路径已经决定了划分。整套流程跑完,数据目录干净、配对关系明确,训练阶段的问题就少了一大半。

4. 用YOLOv8训练瓷砖瑕疵检测模型:配置文件与启动命令

4.1 训练 yaml 的写法:路径、类别名与数量

YOLO 训练自己对数据的描述文件是 yaml。这个文件每写错一个字符,都会在训练启动后给出各种离谱的报错。最典型的坑是names数量与 nc 不匹配:nc 写 5,names 列表里只有 4 个,框架初始化模型时直接崩。下面是我常用的一份模板,可以直接替换路径使用。

path: /data/tiles_dataset train: images/train val: images/val nc: 5 names: 0: crack 1: chip 2: pinhole 3: glaze_drop 4: color_defect

注意path这一段,建议写绝对路径。相对路径在某些框架版本里是相对启动目录解析的,你在项目根目录按下训练命令,和从weights/目录按下命令,解析出来的位置完全不一样。要省事就统一用绝对路径,train、val 则写成相对于 path 的目录名,不要写../这样的相对跳跃。

4.2 启动训练:模型选择与关键参数

数据集不大、瑕疵形态相对固定时,我从 yolov8s 起步,很少直接上 large 或 xlarge。瓷砖检测大多数时候是单类别或多类别小目标,s 模型已经能把裂纹、针孔这类小目标抓好,还能省显存。下面是启动命令:

yolo train data=tiles.yaml model=yolov8s.pt imgsz=640 batch=16 epochs=200 patience=20

如果走 Python 脚本方式,等效写法是:

from ultralytics import YOLO model = YOLO("yolov8s.pt") model.train( data="tiles.yaml", imgsz=640, batch=16, epochs=200, patience=20, workers=4, )

参数说明几处:patience=20表示验证集 mAP 连续 20 轮不涨就早停。工业数据集小,我一般设 15 到 30,太小容易被单点波动误杀,太大则白跑很多轮。imgsz=640是默认基准,如果瓷砖原图是 1200 甚至 3000 像素的小目标,可以先按 640 跑通流程,再对比 960 看看有没有收益。batch 大小受显存限制,显存只有 12G 时 batch=16 配 imgsz=640 通常能跑,再往上加就需要看显卡剩余空间。

这里还有个和标签质量相关的参数叫cache=True,它会在训练前把图片全部读入内存或磁盘缓存,数据集几百张图时建议打开,能明显缩短数据加载时间。数据量特别大时不要开,会把内存吃掉。

4.3 训练日志与中间产物:怎么判断模型在正常学习

训练启动后不要只盯着 loss 曲线。YOLO 会输出包含box_loss、cls_loss、dfl_loss、mAP50、mAP50-95的表格。对瓷砖瑕疵这种小目标检测,mAP50比mAP50-95更贴近产线需求,因为质检只需要判断有没有缺陷,IoU 阈值放宽一些反而更符合现场。如果 mAP50 能到 0.95 以上,而 mAP50-95 只有 0.6,说明框的位置还不够准,但检出能力已经可用。这时不必继续加训练轮数,应该做难样本标注迭代,后面第六章写。

训练完成后,runs/detect/train/下会出现best.pt和last.pt。排验证指标用best.pt,不要图省事拿last.pt推理,尤其早停触发时二者差距可能相当大。

4.4 推理看效果:置信度阈值怎么调

训练完成后立刻跑一次验证集推理,不要只信控制台指标。推理命令我一般这样写:

yolo predict model=runs/detect/train/weights/best.pt source=real_samples imgsz=640 conf=0.25 iou=0.45

conf是置信度阈值。工厂环境对漏检零容忍,对误检有一定容忍度时,我会把 conf 降到 0.15 再跑一遍,看哪些地方被误报出来。如果误报多,才把 conf 拉回 0.3。iou是 NMS 的 IoU 阈值,保留 0.45 即可,别为了压框数调到 0.7,重叠的裂纹框反而会被留下来。

5. 常见问题与避坑:五条我踩过的瓷砖检测翻车现场

5.1 图片名与标签名配对不齐,训练一直有静默漏检

现象:训练过程中能正常启动,tensorboard 里 loss 正常下降,但每个 epoch 都有大量图片没有参与训练,最后 mAP50 始终在 0.5 左右上不去。

原因:数据集里有部分图片被预先翻转、裁剪过,文件名多了_aug后缀,但对应 txt 没有一起复制。这类文件不会报错,只会被训练框架忽略。

解决:在划分完数据集后,立即可靠地做一次 stem 集合比对:

imgs = {p.stem for p in Path("images/train").glob("*.jpg")} labels = {p.stem for p in Path("labels/train").glob("*.txt")} print("有图无标签:", sorted(imgs - labels)[:20]) print("有标签无图:", sorted(labels - imgs)[:20])

把这些缺失标签的图片挑出来,回原 xml 目录补转,或者直接从训练集中剔除。剔除是快速解法,但如果缺失数量超过 5%,还是补标签更划算。

5.2 标注坐标越界,训练直接 NaN

现象:训练刚开始没几轮,box_loss 变成 nan,日志文件里出现assert之类报错。重启几次都一样。

原因:yolo 的 txt 文件没有做坐标归一化,写的还是 0 到 1000 的像素值。YOLO 对归一化坐标有隐式约束,超出[0, 1]后训练很快崩掉。另一类情况是 xml 的 bndbox 值超出了图片宽高,转换时没有裁剪。

解决:转换脚本里加边界检查和裁剪,这是我每次必写的逻辑。上面 3.1 的脚本已经写了max(0, xmin)和min(width, xmax),这段一定不要删。如果数据集是你拿到的现成 yolo 标注,直接用脚本扫描一遍:

import numpy as np from pathlib import Path for txt in Path("labels").glob("*.txt"): arr = np.loadtxt(txt) if arr.ndim == 1: arr = arr.reshape(1, -1) if arr[:, 1].max() > 1.0 or arr[:, 1].min() < 0.0: print("越界文件:", txt)

5.3 CLASS_MAP 顺序不一致,把裂纹学成了崩角

现象:验证时模型总把裂纹缺陷预测成别的类别,但召回率其实不低。单独看每个类别的 mAP,有的为零,有的高得离谱。

原因:VOC 转 YOLO 时,CLASS_MAP的字典写在脚本里,和训练 yaml 的names顺序不一致。比如脚本里crack: 0, chip: 1,yaml 里0: chip, 1: crack,模型看到的标签就是错位的。

解决:定下来类别映射后,把转换脚本里的CLASS_MAP和训练 yaml 的names放在同一个文件里维护。我习惯在工程根目录建classes.yaml,转换脚本直接读它,训练 yaml 也用同一份,这样只维护一个真源。转换环节不匹配的问题就根除了。

5.4 验证集泄漏,mAP 高到假象

现象:训练 mAP50 达到 0.99,实际到现场一试漏检严重。

原因:随机划分时,同一批次拍摄的瓷砖图像里,训练集和验证集出现相似甚至重复视角,验证指标虚高。瓷砖纹理、光照固定,模型把背景纹理记住也能得高分。

解决:按批次或场景划分数据,划分前看文件名前缀,把所有同前缀的图片放到同一组。不要偷懒用random_split。如果数据量少,宁可用 K 折交叉验证,也不要随机乱切。

5.5 混淆矩阵总和(总合)不等于样本数,别拿它核数据

现象:验证完导出混淆矩阵,发现每行数值加起来和标注总目标数对不上,总和也不是图片总数。

原因:YOLO 的混淆矩阵不是按图片算的,而是按“预测框 vs 真实框”配对统计。一个真实框可能匹配多个预测框,被漏检的框还会进到背景列,所以矩阵中的计数和标注目标数没有简单的一一对应关系。

解决:不要拿混淆矩阵的总数去做数据比对。要看的话按行归一化看召回率,按列归一化看精确率。真正要去核数据量,回 train 日志里的instances字段,那才是每类标注目标的真实数量。

6. 进阶用法:用LabelImg增量标注难样本并复查指标

6.1 难样本怎么标:LabelImg 与 VOC 格式的选择

模型在难样本上漏检是常态。我会先跑一次全验证集推理,把漏检图片筛出来,再用 LabelImg 对上面补标。补标时建议在 VOC 模式下进行,因为 xml 便于增量合并。标注完的 xml 放回原有 VOC 文件夹,跑一遍前面 3.1 的转换脚本,新样本就自动接进了训练集。LabelImg 里类别顺序的默认排序和 yaml 不一致时,手动保证类别选择正确即可。

标注时遇到有些常规瑕疵看不到、但测试中确实存在的情况,把它当作待确认样本,去产线复核真值后再决定是不是新类别。不要看到可疑点就加标签,噪声标签比缺标签对模型的伤害更大。

6.2 用 results.csv 判断有没有必要再标一版

训练结束后runs/detect/train/results.csv里有每一轮的完整指标。我会写一个小脚本直接读这个 csv,看最后一行的mAP50与 cls_loss,和上一版本训练做对比。如果 mAP50 提高不明显,但误检数量明显降低,说明新增样本确实压住了模型在难样本上的错误。如果两者都没变化,该换思路调数据分布,而不是继续堆同一类样本。

import pandas as pd df = pd.read_csv("runs/detect/train/results.csv") last_row = df.iloc[-1] print(last_row["epoch"], last_row["mAP50"], last_row["cls_loss"])

看完指标后我会把这次训练保存的 best.pt 复制到固定目录,文件名带上数据集版本,例如best_v2_batch01.pt。否则过一个月再回来看,你根本不知道这个权重对应哪一版标注。这是我踩过几次翻车后养成的习惯,数据版本和模型版本对不上是最难排查的一类玄学问题。这里给你参考,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询