简介:脸部皮肤病检测数据集以 YOLO 与 VOC 双格式提供,面向需要训练皮肤病灶识别模型的开发者与学生。压缩包整体约 35.93MB,共 2000 个文件,其中 1590 个 XML 标注文件与配套 TXT 标签文件构成主体,JPEGImages、Annotations、labels 三目录规则清晰,便于直接接入 YOLO 或 Faster R-CNN 等目标检测流程。数据覆盖粉刺、丘疹、结节、脓疱 4 类,矩形框总数 8827,各框数分别为粉刺 3875、丘疹 3041、脓疱 1297、结节 614,类别分布不均,非常适合练习类别不平衡处理、数据增强与模型调参;同时附有图片清单和说明文本,可辅助完成训练集/验证集划分与样本核对。图片均为清晰原图,未做增强,标注采用矩形框,可复用于多种检测框架;YOLO 与 VOC 两种格式可对比使用,降低格式转换成本。目前已有 103 人学习下载,适合希望获得规范标注数据并开展皮肤病灶检测实验的个人学习者,仅限学习交流使用,请勿商用。
1. 脸部皮肤病检测数据集:一套标注好的病灶检测学习样本
做皮肤影像相关的检测任务时,最卡人的往往不是模型,而是数据。手里没有标注好的病灶图,YOLO 再强也跑不起来。这份脸部皮肤病检测数据集正好补上这段路:它同时提供 YOLO 和 VOC 两种标注格式,图片是真实场景下的脸部皮肤照片,每张图都带病灶框和类别标注,具体分几类、各有多少张,解压后打开 classes 文件就能看到。对正在做毕业设计、个人学习目标检测,或者想跑通「数据集→训练→推理」全流程的人来说,它把最脏最累的标注环节约掉了,剩下的是你真正该练的模型调参与训练部分。本文按我拆数据集的习惯来写:先讲两种标注格式的字段构成,再给 VOC 转 YOLO 的脚本和参数,接着是 YOLOv8 的训练配置,最后是几类高频踩坑记录,你可以直接按章节顺序复现。
2. 数据集结构先看明白:YOLO 与 VOC 两套标注的字段映射
拿到任何检测数据集,我第一件事不是急着训练,而是把目录结构和标注文件翻一遍。这个习惯帮我避开了大部分「训练半天不收敛,最后发现是标签读错了」的翻车现场。这份数据集的典型布局是图片、VOC 标注、YOLO 标注三个目录分开放,下面给出常见组织方式。
2.1 目录结构与文件组织
解压后一般能看到类似下面的结构:
| 路径 | 内容 | 说明 |
|---|---|---|
| images/ | 脸部皮肤照片,jpg 格式 | 训练与验证共用的原始图片 |
| annos/ 或 VOC/ | 每张图对应的 xml 标注 | 标签名是图片同名,扩展名 .xml |
| labels/ 或 YOLO/ | 每张图对应的 txt 标注 | 标签名是图片同名,扩展名 .txt |
| classes.txt 或 names.txt | 类别清单 | 每一行一个类别名,顺序决定类别 ID |
我一般会先确认图片和标注文件的数量是否一一对应。在 Linux 或 macOS 上执行ls images | wc -l和ls labels | wc -l,两边数量必须一致。如果发现某个目录多文件或少文件,多半是前面某一步数据整理时漏掉了,这种情况直接拿来训练,会出现训练时报「找不到标签」或图片被静默跳过的问题。数量核对通过后,再随机打开一张图片和一个标注文件,对照着看坐标是否真的贴合病灶区域。这一步虽然花五分钟,但能省掉后面排查脏数据的几个小时。
2.2 VOC 标注的字段构成
VOC 格式来源于 PASCAL VOC 检测任务,是一个用 XML 描述目标框的标准结构。一个典型的标注文件长这样:
<annotation> <folder>JPEGImages</folder> <filename>skin_001.jpg</filename> <size> <width>640</width> <height>480</height> <depth>3</depth> </size> <object> <name>acne</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>100</xmin> <ymin>120</ymin> <xmax>300</xmax> <ymax>360</ymax> </bndbox> </object> </annotation>这段 XML 里真正决定检测结果的是三组字段:filename负责把标注和图片关联起来,size里的width和height是后续做坐标归一化的分母,object里的name和bndbox则是模型要学的东西。一个文件里有几个 object 节点,就代表这张图有几个病灶框。truncated和difficult这两个字段在转换时一般可以忽略,difficult=1通常表示目标太小或遮挡严重,实际使用中大部分数据集的难例已经被人工筛掉了。
用 Python 去读这种 XML 有标准库xml.etree.ElementTree就够,不需要上 BeautifulSoup。下节转换脚本里我会用root.findall('object')遍历所有框,这也是处理 VOC 最常见的做法。有一点值得注意:bndbox里的四个值是像素坐标,左上角为原点,xmin/ymin是框左上角,xmax/ymax是右下角,坐标值不是归一化的,直接喂给 YOLO 之前必须做换算。
2.3 YOLO 标注的归一化坐标换算
YOLO 的 txt 标注格式和 VOC 完全不同,每行对应一个目标框,包含五个数字:类别 ID、中心点 x、中心点 y、框宽、框高,其中后四个值全部是相对图片宽高的比例,取值在 0 到 1 之间。
拿上面 XML 里的数字算一次:图片宽 640、高 480,框从 (100, 120) 到 (300, 360)。中心点 x 是 (100 + 300) / 2 / 640 = 0.3125,中心点 y 是 (120 + 360) / 2 / 480 = 0.5,框宽 (300 - 100) / 640 = 0.3125,框高 (360 - 120) / 480 = 0.5。所以对应的 YOLO 行就是0 0.3125 0.5000 0.3125 0.5000。你可以在数据集里随便找一张图,手算几行验证自己的理解,这个五分钟的小练习比看十篇博客都有用。
YOLO 采用归一化坐标不是为了省存储,而是为了让模型对不同分辨率的图片有天然的尺度不变性。训练时 YOLO 会把输入图缩放到统一尺寸,如果标注还是原始像素,缩放之后坐标就全错了。这个换算逻辑在从 VOC 转 YOLO 时是必须处理的,拿到手的数据集如果已经帮你转好了,你也要能看懂这些数字的含义,否则后面排查错检时根本无从下手。
3. 把 VOC 转成 YOLO 格式:转换脚本与四个边界坑
很多数据集只给一种标注,这份数据集同时给了两套,省了转换的麻烦。但实际场景里「VOC 转 YOLO」仍然是你大概率会遇到的操作,比如你自己标了一批数据、或者从其他开源项目拿到 VOC 标注,都需要转。这一章给出一个我常用的转换脚本,并解释四个容易出现问题的边界情况。
3.1 VOC 转 YOLO 的完整脚本
以下脚本假设 xml 放在voc_annos/目录,图片在images/目录,转换结果输出到labels/。类别清单通过classes列表传入,顺序决定 YOLO 的类别 ID,这个顺序转换后就不能再变,训练配置里必须和它保持一致。
import os import xml.etree.ElementTree as ET def convert_voc_to_yolo(xml_path, classes, out_dir): tree = ET.parse(xml_path) root = tree.getroot() size = root.find('size') width = int(size.find('width').text) height = int(size.find('height').text) base = os.path.splitext(os.path.basename(xml_path))[0] out_path = os.path.join(out_dir, base + '.txt') with open(out_path, 'w') as f: for obj in root.iter('object'): name = obj.find('name').text if name not in classes: continue class_id = classes.index(name) box = obj.find('bndbox') xmin = float(box.find('xmin').text) ymin = float(box.find('ymin').text) xmax = float(box.find('xmax').text) ymax = float(box.find('ymax').text) x_center = ((xmin + xmax) / 2) / width y_center = ((ymin + ymax) / 2) / height w = (xmax - xmin) / width h = (ymax - ymin) / height x_center = min(max(x_center, 0.0), 1.0) y_center = min(max(y_center, 0.0), 1.0) w = min(max(w, 0.0), 1.0) h = min(max(h, 0.0), 1.0) f.write(f"{class_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}\n") classes = ['acne', 'blackhead', 'pigmentation'] os.makedirs('labels', exist_ok=True) for xml_file in os.listdir('voc_annos'): if not xml_file.endswith('.xml'): continue convert_voc_to_yolo(os.path.join('voc_annos', xml_file), classes, 'labels')脚本的逻辑是逐层解析 XML:先从size节点拿图片宽高,再遍历每个object节点取类别名和框坐标,最后把bndbox的左上角右下角换算成归一化的中心点与宽高。换算公式和上一节手算的完全一致。
参数上有几个地方值得调整:classes列表的顺序是全局约定,我习惯按字母序排列,避免后来加类别时插队导致 ID 全部错位;clamp那两行把结果约束在 0 到 1 之间,这是防止标注超出图片边界的保险;f"{class_id} {x_center:.6f}"保留六位小数,精度足够,文件体积也不会膨胀。如果你的数据集里存在没有object节点的 XML,脚本会输出一个空 txt,训练时 YOLO 会把它视为背景图,这本身没问题,但建议你单独统计一下这种图的数量,如果占比超过 5%,说明标注漏检严重,会影响模型对病灶的召回率。
提示:转换之前务必备份原始 xml,或至少保证
voc_annos是只读的。别问我为什么强调这个,覆盖了源标注再想找回,是真的没有后悔药。
3.2 数据划分:先分后转还是先转后分
这个问题没有标准答案,我的习惯是先划分再转换。因为 VOC 的 xml 本身就是完整可读的中间格式,万一划分后某类标注出问题,重新从 xml 生成 txt 比反过来容易得多。下面这段脚本按 8:1:1 划分训练集、验证集和测试集:
import random import shutil from pathlib import Path random.seed(42) images = sorted(list(Path('images').glob('*.jpg'))) random.shuffle(images) n = len(images) train_split = images[:int(n * 0.8)] val_split = images[int(n * 0.8):int(n * 0.9)] test_split = images[int(n * 0.9):] for split, files in [('train', train_split), ('val', val_split), ('test', test_split)]: img_dir = Path(f'split/{split}/images') label_dir = Path(f'split/{split}/labels') img_dir.mkdir(parents=True, exist_ok=True) label_dir.mkdir(parents=True, exist_ok=True) for img in files: shutil.copy(str(img), str(img_dir / img.name)) label_file = Path('labels') / (img.stem + '.txt') if label_file.exists(): shutil.copy(str(label_file), str(label_dir / label_file.name))这里random.seed(42)是保证每次运行划分结果一致,方便复现实验结果。split目录生成三个子集,每个子集内部图片和标签保持同名同目录结构。划分时最好按整张图切而不是按病灶框切,避免同一张图出现在训练集和验证集里,导致验证指标虚高。数据集规模不大时,8:1:1 是比较保守的比例;如果你的图片总数上了一万,可以适当放宽到 9:0.5:0.5,验证集就算只留几百张也够用。
3.3 转换结果校验:别直接开训
转换完不能直接训练,至少要做两步校验。第一步是统计每个类别的样本数量,这能同时发现类别标签错位和类别不平衡问题:
import glob from collections import Counter counter = Counter() for txt_path in glob.glob('labels/*.txt'): for line in open(txt_path): class_id = int(line.split()[0]) counter[class_id] += 1 print(counter)把打印出来的计数和classes列表对照,比如acne在列表里是第 0 位,那counter[0]应该对应痤疮框的总数。如果发现数量对不上,十有八九是某个类别名在 XML 里和classes列表不一致,脚本里if name not in classes: continue静默跳过就会导致漏转。第二步是用 OpenCV 把标注画回图片上,随机抽几张目视检查框的位置是否贴合病灶区域。这个画框脚本很简短,核心就是cv2.rectangle(img, (xmin, ymin), (xmax, ymax), color, 2)。我每次转换后都会跑这两个步骤,加起来不到十分钟,却是训练能否收敛的第一道关卡。
4. 基于 YOLOv8 训练自定义皮肤病检测:参数选型与损失调优
数据集准备好了,接下来是训练环节。本章以 YOLOv8 为例讲参数配置,因为它是目前入门成本最低的框架,一行命令就能训练,同时保留了足够的调参空间。先建一个 data.yaml 描述数据集路径和类别,然后选择合理的训练参数,再讨论损失函数中类别不平衡的应对。
4.1 用 data.yaml 挂载数据集
data.yaml 是 YOLOv8 读取数据集的核心配置,路径建议用绝对路径,避免在不同目录下训练时报文件找不到的错误。以下是一个模板:
path: /home/you/facial-skin-dataset train: images/train val: images/val test: images/test names: 0: acne 1: blackhead 2: pigmentationnames的顺序必须和 VOC 转 YOLO 时classes列表的顺序完全一致,这是最容易出问题的地方。如果你在转换时把acne放在第 0 位,yaml 里也必须是第 0 位,一旦错位,模型会拿着痤疮的框去学黑头,训练出来基本是废的。path后面如果写相对路径,YOLO 会从当前工作目录去找,我见过不少人在服务器上换了个目录执行训练命令就说数据集加载失败,最后发现是 path 写得太随意。还有一点,train和val的值是相对于path的路径,不要再加images/train/images这样的重复嵌套,保持目录结构与 data.yaml 一一对应即可。
4.2 训练参数选型:小目标场景怎么设
脸部皮肤病检测属于典型的小目标场景。病灶区域往往只占整张脸的几个百分点,直接按默认参数训练,精度会比较勉强。我从实际应用中整理了一份参数起点表:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| imgsz | 640 或 960 | 病灶小就提分辨率,显存够就上 960 |
| batch | 16 | 按显存调整,不够用梯度累积 |
| epochs | 100 | 配 early stop,不必硬跑满 |
| lr0 | 0.01 | 预训练模型微调可降到 0.005 |
| optimizer | auto | 让框架自己选,或显式指定 AdamW |
| patience | 15 | 验证集指标连续不提升就提前停 |
| cos_lr | True | 余弦退火,后期收敛更平缓 |
训练命令如下:
yolo detect train \ data=facial_skin.yaml \ model=yolov8n.pt \ imgsz=640 \ batch=16 \ epochs=100 \ lr0=0.01 \ optimizer=auto \ patience=15 \ cos_lr=Trueimgsz是这张表里最值得动手调的参数。从 640 提到 960,小目标的 AP 通常能涨三到五个点,代价是显存占用和训练时间几乎翻倍。如果你跑在 8G 显存的卡上,960 配 batch 16 大概率 OOM,可以降到 batch 8 或者退回 640。lr0用预训练权重微调时不用给太大,0.01 是个安全的起点,如果你发现训练前期 loss 震荡得厉害,直接降一半到 0.005。cos_lr=True会让学习率在训练后期逐渐衰减到接近零,对小数据集来说能明显减少过拟合现象,我一般都会开。
4.3 损失函数与类别不平衡处理
YOLOv8 的损失函数由三部分组成:回归框的 box loss、分类的 cls loss、以及 DFL 损失,其中 DFL 负责细化边界框的分布。训练日志里显示的cls_loss和box_loss就是前两部分,dfl_loss比较小,但不要因为它小就忽略,它的作用体现在最终推理时边界框的精确定位上。
类别不平衡在皮肤病数据集里几乎是必然的。某种病灶特别常见,另一种只有几十个框,模型会偏向学样本量大的类。应对方式有两种:数据层面做难例过采样,把样本少的类别对应图片在训练时重复喂几次,通过repeat参数控制,或者在损失层面给稀有类别更高的权重。YOLOv8 里可以给不同类别设置 loss 权重,具体在 model 配置里修改不同类别的 cls loss 系数。我一般先看训练完的混淆矩阵再决定要不要动手——如果只是轻微偏斜,优先加训练轮次和调注意力;如果某个类 AP 低于 0.3,就必须处理数据了。这里也顺带提一句,很多人纠结 COCO 的 80 个类别在 YOLO 里怎么读,其实类别顺序完全由自己的classes列表定义,COCO 权重预训练只是借用了特征提取能力,分类头会被替换,所以不要被 80 类这个数字带偏。
5. 避坑指南:类别错位、小目标漏检与训练不收敛
这部分是血泪经验汇总。以下每条都是我在类似数据集上真实遇到过的坑,按「现象→原因→解决」的方式记录,你可以逐条对照。
5.1 训练完模型把所有图都检出同一个类
现象:模型训练 100 轮,验证集 mAP 看着还行,但推理时不管什么病灶图片,输出结果全是同一个类别,框的位置还特别粗犷。
原因:这是最典型的类别 ID 错位。VOC 转 YOLO 时classes列表顺序和 data.yaml 的names顺序不一致,导致模型学到的是「第 0 类 = 黑头」而你的标注里「第 0 类 = 痤疮」的错误映射。还有一种情况是多个类别在 XML 里名字有细微差异,比如acne和acne带了个空格,脚本里classes.index(name)直接跳过,所有带空格的类别全部落到后台。
解决:回到第 3.3 节,用统计脚本把每个类别的标注框数量拉出来,和classes.txt逐项核对。转换脚本里对name做一次strip(),把首尾空格清掉。如果你已经训了一半才发现,赶紧停掉,修好映射重新转标签,别硬着头皮继续跑。
5.2 训练到一半 loss 弹出 NaN
现象:训练日志前几十个 step 都正常,某个 epoch 开始box_loss直接变成nan,后面全部是nan,验证集的 mAP 也跟着崩掉。
原因:标注框出现越界值。比如 XML 里xmax大于图片宽度,或者 txt 里的归一化坐标大于 1。数据集中标注工具偶尔会留下这类脏数据,尤其是手工标注后批量修改图片尺寸时,坐标没有同步更新。
解决:转换脚本里把 clamp 操作加上,把越界值强制压回 0 到 1,这能兜住大部分问题。同时写一个扫描脚本,找出所有「宽高小于 0」或「中心点加半宽大于 1」的标注行,输出图片名和行号,人工确认后从标注文件里删掉或修正。这类脏数据占比通常极低,删掉对整体精度影响可以忽略。
5.3 小目标病灶的整体漏检率居高不下
现象:验证集上正常尺寸的病灶检测得不错,但很小的病灶一个也没检出,PR 曲线里 recall 在低置信度区间断崖式下跌。
原因:脸部的痘痘、黑头这些病灶,在 640x640 的输入下可能只有十几个像素。YOLOv8 下采样倍数高,小目标的特征图分辨率不足,经过几层卷积后细节信息基本丢失。这是模型结构层面的限制,不是调参能完全解决的。
解决:第一选择是提imgsz,640 提到 960,小目标的像素量几乎翻两倍,AP 提升明显。第二选择是训练时开启多尺度训练,YOLOv8 支持scale参数在训练中随机缩放输入,让模型见不同大小的目标。还有更进阶的方案是拿训练好的模型做 SAHI 切片推理,把大图切成重叠小块分别检测再合并,这个方案在推理阶段对小目标召回率提升非常明显,代价是推理速度变慢。
5.4 显存溢出导致训练中断
现象:训练命令跑了几分钟,报错CUDA out of memory,进程直接退出。改小 batch 后进到训练又报同样的错。
原因:显存分配不只是 batch 和 imgsz 两项决定的。数据集里图片分辨率远超imgsz时,瓶颈在数据加载阶段;开启 Mosaic 增强会额外占用显存做拼接,这也是隐性开销。很多情况下你以为改小 batch 够了,实际是worker数和缓存设置的问题。
解决:先按显存减半 batch,比如 batch 16 改成 8 再跑。如果还 OOM,把cache=True改成cache=False,让数据不缓存到显存中占用存量空间。再往下是检查workers数据加载进程数,设成 4 到 8 通常足够,开太多反而占内存。真不行就换小模型,yolov8n 换 yolov8s 显存开销差距接近一倍,精度损失可控。
5.5 训练 loss 一直降不下去
现象:训练从第一个 epoch 起cls_loss就在 2 以上,跑了 30 轮几乎没有变化,验证集 mAP 一直贴着 0。
原因:排除标签错位和脏数据后,最常见的是学习率设置问题。数据集很小的情况下 lr0=0.01 可能偏大,模型在损失曲面震荡,始终跳不进收敛区。其次是没有开启cos_lr,训练后期步长固定,loss 卡在底部下不去。还有一种情况是类别特别不均匀,多数的那个类把 loss 主导了,小类几乎学不到东西。
解决:把 lr0 从 0.01 降到 0.005 或 0.001 再训。开cos_lr=True让学习率后期平滑衰减。检查第一个 epoch 结束时各类别的 loss 贡献,如果某类占比过高,回到数据层面做重采样。还有一种玄学但有效的手段:把模型换成预训练权重继续微调,而不从随机初始化开始,预训练特征对小数据集收敛帮助很大。
6. 验证环节:用混淆矩阵和实际推理确认模型可用
训练完不是看个 mAP 就完事了,最终要回答的问题是「模型在我自己的真实照片上靠不靠谱」。我会分两步做验证:先看指标文件里的混淆矩阵,再做一次脱离数据集的实拍推理。
6.1 从混淆矩阵定位系统性错检
YOLOv8 训练结束后,runs/detect/train/目录下会生成confusion_matrix.png和results.png。混淆矩阵的每一行是真实类别,每一列是预测类别,对角线上的数字越高越好。拿到这张图先看两类位置:一是某个类别被大量预测成另一类,说明这两个类在视觉上高度相似,比如痤疮和黑头在红肿期颜色接近,模型区分困难;二是背景列的值偏高,说明模型把大量非病灶区域当成了目标,需要调高置信度阈值。
另一个文件results.png里包含val/box_loss和val/cls_loss两条曲线,正常情况应该在训练后期趋于平缓,如果验证集曲线在某个 epoch 后开始向上翘,就是过拟合信号。过拟合时优先考虑加数据增强、减小模型尺寸、或者提前结束训练,不要硬加 epoch。
6.2 真实图片推理验证
指标是抽象的,最终还得看推理结果。我习惯准备几张没参与过训练的数据集外照片,用训练好的权重跑一遍:
from ultralytics import YOLO model = YOLO('runs/detect/train/weights/best.pt') results = model.predict( source='test_images/', conf=0.25, iou=0.45, imgsz=640, save=True, save_txt=True )conf=0.25是置信度阈值,低于这个值的框会被丢弃,对小目标场景可以降到 0.15 观察漏检情况,但会出现大量误检,找一个平衡点要靠多跑几张图对比。iou=0.45是 NMS 的 IoU 阈值,值越大保留的框越少。save=True保存可视化图,save_txt=True额外输出标注文件,方便你对坐标精度。
看了推理图之后,我会顺手把save_txt=True生成的 txt 和图片放在一起逐张检查,重点看三个点:框有没有漏掉明显的病灶,框边缘是否贴住病灶边界,有没有把眼睛、鼻孔这类正常器官误检成皮肤病。这三类问题在 mAP 指标上未必能直接反映,但直接影响实际使用者的观感。从那以后,我拿到任何新数据集,都会强制先跑一遍「类别统计 → 转换校验 → 小样本训练 → 真实图推理」这四步,再谈正式训练和调参。这套流程已经帮我避开了至少三种会在后期返工的坑,希望帮到你。
本文还有配套的精品资源,点击获取