简介:这份脸部皮肤病检测数据集面向计算机视觉入门与目标检测实践者,尤其适合正在做YOLO或VOC格式训练、需要真实医学图像练手的学习者。数据集聚焦粉刺、丘疹、结节、脓疱四类皮肤病灶,可用于目标检测模型的训练与验证,帮助理解多类别小目标标注与数据组织方式。压缩包共约2000个文件,以1590个xml标注文件和410个txt标签文件为主,另含对应jpg图片,整体约35.93MB,目录按图片、xml、txt分文件夹存放,结构清晰便于直接接入YOLO或VOC训练流程。标签框总数为8827个,其中粉刺3875、丘疹3041、脓疱1297、结节614,图片清晰且未做增强,标注均为矩形框。目前已有103人学习,适合作为个人学习项目的数据基础,但资源不对模型精度作保证,且仅限学习交流,请勿用于商业用途。
1. 脸部皮肤病检测数据集:从标注格式到训练落地的完整路径
拿到一份脸部皮肤病检测数据集,第一反应往往不是“数据够不够”,而是“这标注到底能不能直接喂给模型”。YOLO 格式和 VOC 格式同时存在,意味着你既可以走 Ultralytics 那条极简训练链路,也可以回到 torchvision 的 VOC 生态做细粒度改造。但真正动手时,很多人卡在第一步:XML 里的bndbox和 TXT 里的class x_center y_center w h到底怎么无损互转,类别映射表放哪,图像尺寸不一致时归一化会不会把框带偏。这篇笔记围绕“脸部皮肤病检测数据集(YOLO+VOC格式)”这个具体对象,把格式解析、转换脚本、训练参数、评估口径和踩坑记录一次讲透。适合已经跑通过通用目标检测、准备把模型往皮肤病灶检测上迁移的工程师,也适合手里有一批标注数据、但不确定该用哪套格式落地的团队。
2. 先搞清楚 YOLO 与 VOC 在皮肤病检测里的分工
2.1 两种格式的坐标体系差异
VOC 格式的核心是 XML 文件,每个目标对应一个<object>节点,里面包含<name>和<bndbox>。bndbox用的是绝对像素坐标:xmin、ymin、xmax、ymax,原点在图像左上角。YOLO 格式则是每张图一个 TXT,每行一个目标,格式为class_id x_center y_center width height,四个坐标全部除以图像宽高做归一化,取值在 0 到 1 之间。
这个差异在脸部皮肤病检测里特别要命。皮肤病灶的边界往往不是硬边缘,标注员在 XML 里画框时习惯贴着肉眼可见的皮损外沿,转成 YOLO 归一化后如果图像被 resize 到 640×640,框会跟着缩放,但归一化坐标本身不变。问题出在有些转换脚本先 resize 再算归一化,有些先算归一化再 resize,两者在长宽比不一致时结果不同。我一般会坚持“先读原图尺寸算归一化,再统一 resize 训练”,这样 TXT 里的值始终对应原图比例,换输入分辨率时不用重新转标注。
2.2 类别映射表为什么不能省
皮肤病检测的类别通常包括痤疮、湿疹、银屑病、玫瑰痤疮、荨麻疹等,不同数据集给的类别名可能是中文、英文缩写或拼音。VOC 的<name>是字符串,YOLO 的class_id是整数,中间必须有一张固定的映射表。常见做法是建一个classes.txt,每行一个类别名,行号从 0 开始就是class_id。转换时用字典查表,查不到就报错并记录文件名,不要静默跳过。
提示:类别名里不要出现空格和特殊符号,YOLO 训练时
data.yaml的names列表如果和 TXT 里的 id 对不上,模型会把所有类当成背景,loss 降不下去但 mAP 一直是 0。
2.3 数据集划分的坑:按图分还是按病灶分
脸部皮肤病有个特点:同一个人可能有多张不同角度的照片,同一个病灶可能出现在多张图里。如果按图随机划分训练集和验证集,同一病灶的不同角度可能同时出现在两边,验证集精度会虚高。我一般会先按患者 ID 分组,再在组内划分,确保同一个人的数据只出现在一个集合里。如果数据集没给患者 ID,至少按图像相似度做聚类,把高度相似的图分到同一侧。
3. 把 VOC 转成 YOLO:转换脚本与四个边界坑
3.1 转换脚本的完整实现
下面这个脚本处理 VOC 的Annotations和JPEGImages目录,输出 YOLO 的images和labels目录,同时生成classes.txt。脚本假设 XML 里的类别名已经统一,不做自动纠错。
import os import xml.etree.ElementTree as ET from PIL import Image # 类别映射:按实际数据集修改 CLASS_MAP = { "acne": 0, "eczema": 1, "psoriasis": 2, "rosacea": 3, "urticaria": 4, } def convert_bbox(size, box): """将 VOC 绝对坐标转为 YOLO 归一化坐标""" dw = 1.0 / size[0] dh = 1.0 / size[1] x_center = (box[0] + box[1]) / 2.0 y_center = (box[2] + box[3]) / 2.0 w = box[1] - box[0] h = box[3] - box[2] return (x_center * dw, y_center * dh, w * dw, h * dh) def convert_annotation(xml_path, img_path, out_label_path): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") w = int(size.find("width").text) h = int(size.find("height").text) # 用 PIL 再读一次实际尺寸,防止 XML 里写错 with Image.open(img_path) as im: real_w, real_h = im.size if (w, h) != (real_w, real_h): print(f"[WARN] size mismatch: {xml_path}, xml=({w},{h}), real=({real_w},{real_h})") w, h = real_w, real_h lines = [] for obj in root.iter("object"): name = obj.find("name").text.strip() if name not in CLASS_MAP: print(f"[SKIP] unknown class '{name}' in {xml_path}") continue cls_id = CLASS_MAP[name] bndbox = obj.find("bndbox") xmin = float(bndbox.find("xmin").text) ymin = float(bndbox.find("ymin").text) xmax = float(bndbox.find("xmax").text) ymax = float(bndbox.find("ymax").text) # 边界裁剪,防止标注超出图像范围 xmin = max(0, min(xmin, w - 1)) ymin = max(0, min(ymin, h - 1)) xmax = max(0, min(xmax, w - 1)) ymax = max(0, min(ymax, h - 1)) if xmax <= xmin or ymax <= ymin: print(f"[SKIP] invalid box in {xml_path}") continue bb = convert_bbox((w, h), (xmin, xmax, ymin, ymax)) lines.append(f"{cls_id} " + " ".join([f"{v:.6f}" for v in bb])) with open(out_label_path, "w") as f: f.write("\n".join(lines)) def main(anno_dir, img_dir, out_img_dir, out_lbl_dir): os.makedirs(out_img_dir, exist_ok=True) os.makedirs(out_lbl_dir, exist_ok=True) for xml_file in os.listdir(anno_dir): if not xml_file.endswith(".xml"): continue stem = os.path.splitext(xml_file)[0] xml_path = os.path.join(anno_dir, xml_file) img_path = os.path.join(img_dir, stem + ".jpg") if not os.path.exists(img_path): print(f"[MISS] image not found for {xml_file}") continue out_lbl_path = os.path.join(out_lbl_dir, stem + ".txt") convert_annotation(xml_path, img_path, out_lbl_path) # 复制图像到输出目录,保持同名 import shutil shutil.copy(img_path, os.path.join(out_img_dir, stem + ".jpg")) if __name__ == "__main__": main( anno_dir="VOC/Annotations", img_dir="VOC/JPEGImages", out_img_dir="YOLO/images", out_lbl_dir="YOLO/labels", )逻辑说明:convert_bbox接收的是(xmin, xmax, ymin, ymax)顺序,先算中心点再算宽高,最后乘缩放因子。convert_annotation里用 PIL 重新读图像尺寸,是因为部分 VOC 数据集的 XML 里size字段是标注工具默认值,和实际图不一致。边界裁剪和无效框跳过是必须的,皮肤病标注里经常出现框贴边甚至超出 1 像素的情况,不裁剪会导致归一化坐标小于 0 或大于 1,训练时被 Ultralytics 直接过滤掉。
参数说明:CLASS_MAP必须和data.yaml里的names顺序完全一致。f"{v:.6f}"保留 6 位小数,足够 YOLO 训练精度,再多了文件体积大且无意义。shutil.copy可以换成软链接或直接移动,看磁盘空间。
3.2 边界坑一:XML 里 size 字段不可信
现象:转换后的 TXT 里某些框的归一化坐标大于 1 或小于 0,训练时这些框被静默丢弃,对应图像变成负样本。原因:XML 的<size>写的是 500×500,实际图像是 1920×1080,标注员在 500×500 画布上标的框,直接按 500 归一化就错了。解决:脚本里用 PIL 读实际尺寸,发现不一致时以实际尺寸为准,并打印警告。如果数据集里这种不一致很多,建议先批量检查一遍。
3.3 边界坑二:类别名大小写和空格
现象:CLASS_MAP里写的是"Acne",XML 里是"acne",转换时全部跳过,TXT 为空。原因:字符串匹配区分大小写,且标注工具可能带尾部空格。解决:在name = obj.find("name").text.strip()之后统一转小写,CLASS_MAP的键也用小写。如果类别名有中文,建议先映射成英文再转,避免编码问题。
3.4 边界坑三:图像格式不是 JPG
现象:img_path拼出来是.jpg,但实际图像是.png或.jpeg,脚本报[MISS]。原因:VOC 数据集常见混合格式。解决:先扫描JPEGImages目录建立stem -> 实际文件名的字典,转换时按字典取。或者用glob匹配stem + ".*",取第一个存在的文件。
3.5 边界坑四:空标注文件
现象:某张图没有任何目标,转换后生成一个空 TXT。YOLO 训练时空 TXT 是合法的负样本,但如果数据集中负样本比例过高,模型会偏向预测背景。原因:皮肤病数据集里正常皮肤照片被误放进来了。解决:统计空 TXT 比例,超过 10% 就考虑剔除一部分,或者在data.yaml里单独设一个background类。我一般会保留少量负样本,但不超过总图数的 5%。
4. 用 YOLO 训练脸部皮肤病检测模型的参数怎么设
4.1 data.yaml 的写法与路径陷阱
YOLO 训练依赖一个data.yaml,里面指定训练、验证、测试集的路径和类别名。路径可以是绝对路径,也可以是相对data.yaml所在目录的相对路径。我一般用绝对路径,避免在ultralytics不同版本里相对路径解析不一致。
path: /data/skin_detection train: images/train val: images/val test: images/test nc: 5 names: 0: acne 1: eczema 2: psoriasis 3: rosacea 4: urticarianc必须等于names的长度,且names的键从 0 开始连续。如果 TXT 里出现了class_id=5但nc=5,训练时直接报索引越界。train和val指向的是图像目录,YOLO 会自动去找同级的labels目录,所以目录结构必须是images/train和labels/train并列。
4.2 输入分辨率与病灶尺寸的匹配
脸部皮肤病病灶在整张脸的照片里占比可能很小,比如一颗痤疮直径只有 30 像素。YOLO 默认imgsz=640,下采样 32 倍后特征图只有 20×20,小目标很容易丢。我一般会把imgsz提到 1024 或 1280,同时把batch降到 8 或 4,显存不够就开amp混合精度。如果病灶普遍偏小,还可以在data.yaml里加rect: True做矩形训练,减少 padding 带来的无效计算。
4.3 学习率与 warmup 的设置
皮肤病数据集通常不大,几千张图量级。从头训练容易过拟合,常见做法是加载 COCO 预训练权重,冻结 backbone 前几层,用较小学习率微调。我一般设lr0=0.001,lrf=0.01,warmup_epochs=3,warmup_momentum=0.8。如果验证集 loss 震荡,把lr0降到 0.0005,cos_lr=True开余弦退火。patience=20早停,避免无效训练。
yolo detect train \ data=/data/skin_detection/data.yaml \ model=yolov8m.pt \ imgsz=1024 \ epochs=200 \ batch=8 \ lr0=0.001 \ lrf=0.01 \ warmup_epochs=3 \ cos_lr=True \ patience=20 \ amp=True \ project=runs/skin \ name=exp01参数说明:model=yolov8m.pt是中等规模,显存 12G 左右能跑imgsz=1024。amp=True混合精度,速度提升约 30%,精度损失可忽略。project和name决定输出目录,方便对比不同实验。训练完在runs/skin/exp01/weights/best.pt拿最优权重。
4.4 数据增强的取舍
YOLO 默认开启mosaic、hsv、flip等增强。皮肤病检测里,flipud垂直翻转要慎用,因为脸部上下翻转后病灶位置不符合解剖结构,可能引入噪声。mosaic把四张图拼成一张,对小目标检测有帮助,但皮肤病病灶颜色和纹理是重要特征,拼接后边界处可能出现不自然的肤色过渡。我一般保留mosaic=1.0但把close_mosaic=10设成最后 10 个 epoch 关闭,让模型在真实分布上收尾。hsv_h=0.015、hsv_s=0.7、hsv_v=0.4是默认值,如果数据集本身色偏严重,可以适当降低饱和度增强幅度。
5. 评估与排查:mAP 上不去时先看什么
5.1 指标口径:mAP50 和 mAP50-95 的差距
YOLO 验证输出mAP50和mAP50-95。皮肤病病灶边界模糊,IoU 从 0.5 提到 0.95 时精度下降很快,所以mAP50-95通常比mAP50低 20 到 30 个点。如果mAP50正常但mAP50-95极低,说明框的位置不够准,可能是标注框偏大或偏小。我一般会抽 20 张验证图,把预测框和 GT 框画在一起看,偏得多的类别单独调。
5.2 混淆矩阵里看类别混淆
训练完在runs/skin/exp01/下有confusion_matrix.png。皮肤病类别之间外观相似,比如湿疹和银屑病都可能表现为红斑鳞屑,模型容易混。如果混淆矩阵显示 A 类大量被预测成 B 类,先检查标注里这两类是否定义清晰,再考虑加类别权重或补充难例。YOLO 不直接支持类别权重,但可以通过复制少数类图像来平衡。
5.3 排查清单:loss 不降的五个原因
现象:训练几个 epoch 后box_loss和cls_loss都不降。原因一:data.yaml路径错,模型读不到图,全部当背景。原因二:TXT 里class_id超出nc范围,被过滤。原因三:归一化坐标算错,框全在图像外。原因四:学习率太大,loss 震荡不收敛。原因五:预训练权重和类别数不匹配,model=yolov8m.pt加载时nc被覆盖。解决:先跑yolo detect train一个 epoch,看输出里train和val的图片数是否正常,再抽一张图用yolo detect predict看预测框位置。
5.4 验证集指标虚高的排查
如果验证集mAP50到 0.9 但实际测试效果差,先查数据泄露。按患者 ID 分组划分的验证集,指标通常比随机划分低 5 到 10 个点,但更真实。另外检查验证集里是否有训练集图像的增强版本,YOLO 默认不做跨集增强,但手动复制数据时容易混。我一般会在划分后算一次训练集和验证集的图像哈希,重复的直接剔除。
6. 从检测框到病灶计数:一个后处理技巧
训练完模型只是第一步,实际业务里经常需要统计每张脸的病灶数量。YOLO 输出的框会有重叠,尤其是同一病灶被多个 anchor 预测。conf=0.25、iou=0.45是默认 NMS 参数,但皮肤病病灶密集时,NMS 会把相邻的真实病灶误删。我一般把iou提到 0.6,再对同一类别的框做一次基于距离的合并:如果两个框中心点距离小于较小框宽度的 0.5 倍,且类别相同,就合并成一个。
import numpy as np def merge_boxes(boxes, labels, scores, dist_ratio=0.5): """合并中心点过近的同类别框,boxes 格式 xyxy""" keep = [] used = np.zeros(len(boxes), dtype=bool) order = np.argsort(scores)[::-1] for i in order: if used[i]: continue keep.append(i) used[i] = True for j in order: if used[j] or labels[j] != labels[i]: continue ci = ((boxes[i][0] + boxes[i][2]) / 2, (boxes[i][1] + boxes[i][3]) / 2) cj = ((boxes[j][0] + boxes[j][2]) / 2, (boxes[j][1] + boxes[j][3]) / 2) wi = boxes[i][2] - boxes[i][0] wj = boxes[j][2] - boxes[j][0] min_w = min(wi, wj) dist = ((ci[0] - cj[0]) ** 2 + (ci[1] - cj[1]) ** 2) ** 0.5 if dist < min_w * dist_ratio: used[j] = True return [boxes[i] for i in keep], [labels[i] for i in keep], [scores[i] for i in keep]逻辑说明:先按置信度降序排列,保留最高分框,然后遍历其余框,如果和已保留框类别相同且中心点距离小于较小框宽度的dist_ratio倍,就标记为已用。dist_ratio=0.5是经验值,病灶密集时调到 0.3,稀疏时调到 0.7。这个后处理在计数场景下比单纯 NMS 更稳,但会牺牲一点定位精度,适合只需要数量的业务。
参数说明:boxes是xyxy格式的 numpy 数组,labels是整数类别,scores是浮点置信度。合并后返回三个列表,可以直接画图或写报告。如果同一病灶被分成两个不同类别,这个逻辑不会合并,需要额外做类别优先级判断。
我自己的习惯是:每次换数据集,先跑一遍转换脚本,用yolo detect train一个 epoch 看 loss 是否正常下降,再抽 10 张图做预测可视化。确认标注和训练链路没问题后,再调imgsz和增强参数。这套流程在脸部皮肤病检测上帮我省了很多反复排查的时间,希望帮到你。
本文还有配套的精品资源,点击获取