
简介这份脸部皮肤病检测数据集面向计算机视觉入门与目标检测实践者尤其适合正在做YOLO或VOC格式训练、需要真实医学图像练手的学习者。数据集聚焦粉刺、丘疹、结节和脓疱四类皮肤病灶标签名称分别为comedones、nodules、papules、pustules可用于目标检测模型的训练与验证。压缩包共2000个文件以1590个xml标注文件和410个txt标签文件为主另含1590张清晰jpg图片整体约35.93MB图片未做增强标注均为矩形框其中comedones框数3875、papules框数3041、pustules框数1297、nodules框数614总框数达8827类别分布差异明显便于观察样本不均衡对检测效果的影响。目前已有103人学习。读者可据此直接搭建YOLO与VOC双格式数据管线省去格式转换与标注整理成本快速投入模型调试与对比实验。资源来源于网络分享仅用于学习交流请勿用于商业用途。1. 脸部皮肤病检测数据集为什么“能跑通”和“能落地”是两回事做过人脸相关检测的工程师大多有过这种体验拿一个公开数据集训个 YOLOmAP 看着挺漂亮一换到自己手头的图就崩。脸部皮肤病检测这个方向尤其明显——病灶边界模糊、类间差异小、光照和肤色差异大再加上标注本身带主观性数据集的质量直接决定模型上限。标题里的“YOLOVOC格式”其实点出了两件事一是这份数据同时提供两种主流标注格式省去格式转换的折腾二是它面向的是目标检测任务不是分类。这意味着你要解决的是“病灶在哪、有多大、属于哪一类”而不是简单判断“有没有病”。适合谁做医疗辅助筛查原型的算法工程师、想入门医学目标检测的学生、以及需要快速验证某个检测想法但缺数据的团队。这一篇不讲空泛的医学 AI 前景只讲怎么把这份数据用起来、参数怎么调、哪里会翻车。2. 先搞清楚标注格式VOC 和 YOLO 到底差在哪2.1 两种格式的结构差异与转换逻辑VOC 格式的核心是每张图对应一个 XML 文件里面用object标签记录每个目标的类别和边界框边界框用xmin/ymin/xmax/ymax四个绝对像素坐标表示。YOLO 格式则是一张图对应一个.txt文件每行一个目标格式是类别索引 中心x 中心y 宽 高后四个值都是相对于图像宽高的归一化值0~1 之间。这个差异看着小实际用起来坑不少VOC 的坐标是整数像素YOLO 是浮点归一化VOC 的类别是字符串YOLO 是整数索引索引和类别的映射关系必须额外维护一个classes.txt或data.yaml。我一般会先确认数据集里两种格式是否完全对齐——有些数据集只保证图片数量一致但 XML 和 txt 的标注框数量对不上这种“半对齐”状态最坑训练时 loss 会莫名其妙震荡。验证方法很简单写个脚本遍历所有图片分别统计 XML 里的 object 数和 txt 里的行数不一致的直接列出来。import os import xml.etree.ElementTree as ET def count_voc_objects(xml_path): tree ET.parse(xml_path) root tree.getroot() return len(root.findall(object)) def count_yolo_lines(txt_path): with open(txt_path, r) as f: lines [l for l in f.readlines() if l.strip()] return len(lines) # 假设图片同名分别放在 annotations_xml 和 labels_yolo 目录 img_dir images xml_dir annotations_xml txt_dir labels_yolo mismatch [] for img_name in os.listdir(img_dir): base os.path.splitext(img_name)[0] xml_path os.path.join(xml_dir, base .xml) txt_path os.path.join(txt_dir, base .txt) if not os.path.exists(xml_path) or not os.path.exists(txt_path): mismatch.append((base, missing file)) continue n_xml count_voc_objects(xml_path) n_txt count_yolo_lines(txt_path) if n_xml ! n_txt: mismatch.append((base, fxml{n_xml}, txt{n_txt})) print(f不一致样本数: {len(mismatch)}) for m in mismatch[:20]: print(m)这段脚本的逻辑很直白对每张图分别数 XML 里的object节点和 txt 里的非空行不一致就记录下来。参数上没什么可调的但要注意 XML 解析时如果文件编码不是 UTF-8ET.parse可能报错稳妥做法是显式指定编码或先用open读成字符串再解析。跑完如果发现大量不一致别急着训先搞清楚是标注版本不同步还是转换脚本有 bug。2.2 类别映射表怎么建才不出错YOLO 训练时类别索引必须从 0 开始连续且和data.yaml里的names列表顺序严格对应。VOC 的类别名可能是中文、英文或拼音直接转容易乱序。我的习惯是先把所有 XML 里的类别名收集起来排序后生成一个固定的class_to_id字典然后所有转换都走这个字典绝不临时手写。import xml.etree.ElementTree as ET import os def collect_classes(xml_dir): classes set() for f in os.listdir(xml_dir): if not f.endswith(.xml): continue tree ET.parse(os.path.join(xml_dir, f)) for obj in tree.getroot().findall(object): name obj.find(name).text.strip() classes.add(name) return sorted(classes) classes collect_classes(annotations_xml) class_to_id {c: i for i, c in enumerate(classes)} print(类别映射:, class_to_id) # 生成 data.yaml 用的 names 列表 with open(classes.txt, w, encodingutf-8) as f: for c in classes: f.write(c \n)这里的关键是sorted(classes)保证每次运行顺序一致避免今天训完明天换台机器类别索引变了。classes.txt每行一个类别名行号就是索引YOLO 系列训练脚本读这个文件或data.yaml里的names都能对上。如果数据集里类别名有空格或特殊字符建议先做一次清洗否则 YAML 解析容易出问题。2.3 从 VOC 转 YOLO 的完整脚本与边界处理转换本身不复杂但边界情况多框超出图像范围、宽高为 0、坐标不是整数、图片尺寸和 XML 里记录的不一致。下面这个脚本把这些都考虑进去了。import os import xml.etree.ElementTree as ET from PIL import Image def voc_to_yolo(xml_path, img_path, class_to_id, out_txt_path): tree ET.parse(xml_path) root tree.getroot() # 优先用实际图片尺寸而不是 XML 里写的 size with Image.open(img_path) as im: img_w, img_h im.size lines [] for obj in root.findall(object): name obj.find(name).text.strip() if name not in class_to_id: continue cls_id class_to_id[name] bbox obj.find(bndbox) xmin float(bbox.find(xmin).text) ymin float(bbox.find(ymin).text) xmax float(bbox.find(xmax).text) ymax float(bbox.find(ymax).text) # 裁剪到图像范围内 xmin max(0, min(xmin, img_w - 1)) ymin max(0, min(ymin, img_h - 1)) xmax max(0, min(xmax, img_w - 1)) ymax max(0, min(ymax, img_h - 1)) w xmax - xmin h ymax - ymin if w 1 or h 1: continue # 跳过无效框 cx (xmin xmax) / 2.0 / img_w cy (ymin ymax) / 2.0 / img_h nw w / img_w nh h / img_h # 再次确保归一化值在 (0,1) cx min(max(cx, 0.0), 1.0) cy min(max(cy, 0.0), 1.0) nw min(max(nw, 0.0), 1.0) nh min(max(nh, 0.0), 1.0) lines.append(f{cls_id} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}) with open(out_txt_path, w) as f: f.write(\n.join(lines)) # 批量转换 img_dir images xml_dir annotations_xml out_dir labels_yolo os.makedirs(out_dir, exist_okTrue) for f in os.listdir(xml_dir): if not f.endswith(.xml): continue base os.path.splitext(f)[0] img_path os.path.join(img_dir, base .jpg) if not os.path.exists(img_path): # 尝试其他扩展名 for ext in [.png, .jpeg, .bmp]: if os.path.exists(os.path.join(img_dir, base ext)): img_path os.path.join(img_dir, base ext) break else: print(f找不到图片: {base}) continue voc_to_yolo(os.path.join(xml_dir, f), img_path, class_to_id, os.path.join(out_dir, base .txt))逻辑说明先读实际图片尺寸而不是 XML 里的size因为有些数据集 XML 里的尺寸是错的然后对每个框做范围裁剪跳过宽或高小于等于 1 像素的无效框归一化后再夹一次到 [0,1]防止浮点误差导致越界。参数上:.6f保留 6 位小数足够 YOLO 用再多没必要。如果数据集里图片格式混杂扩展名探测那段能省不少事。3. 用 YOLO 训练这份数据参数怎么设、指标怎么看3.1 数据划分与 data.yaml 的写法拿到转换好的 YOLO 格式后第一步是划分训练集、验证集、测试集。我的习惯是按 7:2:1 分但如果某类样本特别少会做分层抽样保证每类在验证集里都有出现。划分脚本很简单但要注意图片和标签必须同步移动不能只移图片。import os import random import shutil random.seed(42) # 固定随机种子保证可复现 img_dir images lbl_dir labels_yolo out_base dataset splits {train: 0.7, val: 0.2, test: 0.1} # 收集所有有标签的样本 samples [] for f in os.listdir(lbl_dir): if f.endswith(.txt): base os.path.splitext(f)[0] for ext in [.jpg, .png, .jpeg]: if os.path.exists(os.path.join(img_dir, base ext)): samples.append((base, ext)) break random.shuffle(samples) n len(samples) n_train int(n * splits[train]) n_val int(n * splits[val]) split_map { train: samples[:n_train], val: samples[n_train:n_train n_val], test: samples[n_train n_val:] } for split, items in split_map.items(): img_out os.path.join(out_base, images, split) lbl_out os.path.join(out_base, labels, split) os.makedirs(img_out, exist_okTrue) os.makedirs(lbl_out, exist_okTrue) for base, ext in items: shutil.copy(os.path.join(img_dir, base ext), os.path.join(img_out, base ext)) shutil.copy(os.path.join(lbl_dir, base .txt), os.path.join(lbl_out, base .txt)) print(f{split}: {len(items)} 张)random.seed(42)是为了每次划分结果一致方便对比实验。如果数据集类别极不均衡建议把random.shuffle换成按类别分层的划分逻辑否则验证集里可能某些类一个样本都没有mAP 算出来是 0 还找不到原因。对应的data.yaml写法path: ./dataset train: images/train val: images/val test: images/test nc: 6 names: - class_a - class_b - class_c - class_d - class_e - class_fnc是类别数必须和names长度一致。path用相对路径时训练脚本的工作目录要对否则会报找不到文件。我一般直接用绝对路径省事。3.2 训练命令与关键超参以常见的 YOLO 系列训练脚本为例核心命令大概是这样python train.py \ --data data.yaml \ --img 640 \ --batch 16 \ --epochs 100 \ --weights yolov8n.pt \ --device 0 \ --workers 4 \ --patience 20 \ --lr0 0.01 \ --lrf 0.01 \ --augment参数逐个说--img 640是输入分辨率皮肤病病灶通常占图比例不大640 是精度和速度的平衡点如果显存够可以上 1024但小目标提升有限--batch 16看显存调8G 显存跑 640 大概能到 16不够就降到 8--epochs 100配合--patience 20早停验证指标 20 轮不升就停省时间--lr0 0.01初始学习率医学数据通常比自然图像小一点更稳0.01 到 0.001 之间试--lrf 0.01最终学习率是初始的 1%余弦退火用--augment开默认增强但皮肤病数据要小心颜色抖动HSV 增强可能把病灶颜色改得不像病建议把hsv_h调小或关掉。训练时重点看三个指标mAP50、mAP50-95和每类的precision/recall。皮肤病检测里 recall 往往比 precision 重要漏检比误检代价大。如果某类 recall 特别低先看验证集里这类样本数是不是太少再考虑加采样权重或专门增强。3.3 推理与可视化验证训完别只看数字一定要把预测框画出来看。很多问题数字上看不出来图上一眼就明白。from ultralytics import YOLO import cv2 model YOLO(runs/detect/train/weights/best.pt) results model(test_image.jpg, conf0.25, iou0.45) for r in results: img r.plot() # 自带画框 cv2.imwrite(pred.jpg, img) # 打印每个框的类别和置信度 for box in r.boxes: cls_id int(box.cls[0]) conf float(box.conf[0]) xyxy box.xyxy[0].tolist() print(f类别: {model.names[cls_id]}, 置信度: {conf:.3f}, 框: {xyxy})conf0.25是置信度阈值医学场景可以调到 0.1 看更多候选再人工判断iou0.45是 NMS 的 IoU 阈值病灶密集时调低到 0.3 能减少框重叠。r.plot()返回的是 BGR 图像直接cv2.imwrite就行。重点看有没有把正常皮肤纹理误检成病灶、边界框是不是明显偏大或偏小、同一病灶有没有被重复框。4. 避坑与排查脸部皮肤病数据集的五个血泪教训4.1 标注框“贴边”导致训练 loss 不降现象训练几个 epoch 后 box loss 卡在某个值下不去mAP 一直在 0.1 附近晃。原因部分 XML 里的框坐标等于图像宽高转 YOLO 后归一化值正好是 1.0某些版本的 YOLO 实现会认为这是非法值直接忽略导致这些样本实际没参与训练。解决转换时把坐标上限夹到img_w - 1和img_h - 1归一化后再夹一次到 0.999别用 1.0。4.2 类别名带空格导致 data.yaml 解析失败现象训练启动时报KeyError或类别数对不上。原因VOC 里类别名可能是acne vulgaris这种带空格的写进 YAML 的names列表时没加引号YAML 把它当成了两个元素。解决生成classes.txt时把空格替换成下划线或者在 YAML 里给每个名字加引号。我一般直接统一成下划线命名省事。4.3 验证集和训练集图片重复现象验证 mAP 高得离谱测试集一跑就崩。原因划分时按标签文件随机分但同一张图可能有多个标签文件比如不同标注版本或者图片文件名大小写不一致导致重复计入。解决划分前先对图片做一次 MD5 去重确保每个 base name 只出现一次。另外检查images目录里有没有隐藏文件或缩略图缓存。4.4 小目标病灶被过度下采样现象大病灶检测没问题小丘疹、小斑点几乎全漏。原因输入 640 时原图里 20x20 像素的病灶下采样后只剩几个像素特征图上看不到。解决把输入分辨率提到 1024 或 1280同时在数据增强里加mosaic和copy_paste人为增加小目标样本密度。如果显存不够用切片推理SAHI 那套思路把大图切小块分别检测再合并。4.5 肤色和光照差异导致跨域崩盘现象在数据集上 mAP 0.8换一批不同设备拍的图直接掉到 0.3。原因数据集里肤色、光照、白平衡分布太集中模型学到了“背景颜色”而不是“病灶特征”。解决训练时加强颜色增强HSV 的 H 和 S 抖动调大但注意别把病灶颜色改得不像病更稳的做法是收集一批目标域的图做微调哪怕只有几十张fine-tune 几个 epoch 就能拉回来不少。5. 进阶技巧用切片推理和 TTA 把召回再拉高几个点如果前面都跑通了想让模型在真实场景里更稳有两个技巧值得试。第一个是切片推理slicing inference把原图切成有重叠的小块每块单独检测再把结果映射回原图做 NMS 合并。这对小病灶特别有效代价是推理时间线性增加。实现上可以用 SAHI 库也可以自己写核心就是控制切片大小和重叠比例。我一般设切片 512、重叠 128病灶特别小的场景重叠可以加到 256。第二个是测试时增强TTA推理时对同一张图做水平翻转、多尺度缩放把多次预测结果融合。YOLO 系列有些版本自带augmentTrue参数开了之后 mAP 通常能涨 1~3 个点但推理速度慢 2~3 倍。如果业务对延迟不敏感这个几乎是无脑开。from ultralytics import YOLO model YOLO(best.pt) # 开 TTA 推理 results model(test.jpg, augmentTrue, conf0.1, iou0.5) # 多尺度 TTA 手动实现 scales [640, 800, 1024] all_boxes [] for s in scales: r model(test.jpg, imgszs, conf0.1, iou0.5)[0] for box in r.boxes: all_boxes.append({ cls: int(box.cls[0]), conf: float(box.conf[0]), xyxy: box.xyxy[0].tolist() }) # 这里需要自己写一个跨尺度的 NMS 合并逻辑 # 核心思路把所有框按 conf 排序依次保留与已选框 IoU 低于阈值的多尺度 TTA 的合并逻辑要注意不同尺度下同一个病灶的框大小不一样直接 NMS 可能因为 IoU 算出来偏低而保留重复框。稳妥做法是先把所有框映射回原图坐标再统一做 NMS。另外conf阈值在 TTA 时要调低让更多候选进入融合阶段最终输出再卡一个高阈值。最后说个我自己的习惯每次训完模型除了看 mAP一定会把验证集里 recall 最低的那 20 张图单独拎出来看。十次里有八次能发现标注问题——要么框画歪了要么类别标错了要么病灶根本不在框里。数据集的坑最后往往不是模型能填的。希望帮到你。本文还有配套的精品资源点击获取