ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

927张痤疮图片如何训练YOLOv8检测模型:数据清洗到边缘部署全实践

927张痤疮图片如何训练YOLOv8检测模型:数据清洗到边缘部署全实践 简介面向计算机视觉与医学影像研究者这套痤疮检测数据集基于YOLOv8框架构建包含927张已专业标注的JPG皮肤图像可用于训练痤疮定位与识别模型辅助皮肤科医生快速筛查及远程医疗场景下的皮肤状况监测。资源整体共1855个文件其中927个JPG图像与927个txt标注文件一一对应另含一个YOLOv8格式的yaml配置文件压缩包大小33.93MB轻量便于分发。数据已按训练集、验证集、测试集划分符合标准机器学习项目流程配合yaml文件可直接接入YOLOv8训练管线若需进一步扩充样本可结合旋转、翻转、缩放等数据增强手段提升模型鲁棒性。目前已有329人学习使用适合需要规范YOLOv8格式数据的算法工程师、医学影像研究者及入门学习者作为基线数据集使用。1. 痤疮数据集 JPG YOLOv8 格式927 张图怎么做出能用的检测模型拿到一份“痤疮数据集 JPG YOLOv8 格式927 个图像”时多数人的第一反应是图太少训不出东西。我做完一版之后的结论正好相反927 张图配合 YOLOv8 自带的预训练权重和数据增强足够快速验证一个痤疮检测方案甚至能跑到 RK3588 这类边缘设备上做实时推理。YOLOv8 把数据格式、训练、验证都收敛成了目录约定你只需要把 JPG 和标注对齐。这个体量很适合三类人做皮肤检测 App 原型、做医学图像方向课设/论文预实验、以及刚入门 YOLOv8 想跑通自己数据集的开发者。2. 先摸清数据集底细目录结构、标注类标与 JPG 尺寸分布2.1 拿到数据集先统计文件别急着训练标题里写着“YOLOv8 格式”但实际下载到的压缩包常常是“看起来是 YOLO txt但内部有坏图、有重复图、甚至有图像尺寸为 0 的异常文件”。我一般不会直接开训而是先用脚本把家底盘清楚文件数量、JPG 完整性、标注文件是否一一对应。import os from pathlib import Path from PIL import Image from collections import Counter root Path(acne_dataset) imgs list(root.rglob(*.jpg)) list(root.rglob(*.jpeg)) labels list(root.rglob(*.txt)) print(f图像数量: {len(imgs)}) print(ftxt标注数量: {len(labels)}) img_stems {p.stem for p in imgs} label_stems {p.stem for p in labels} missing img_stems - label_stems print(f缺标注图像数: {len(missing)}) for m in list(missing)[:10]: print(缺标注:, m) sizes Counter() broken [] for p in imgs: try: with Image.open(p) as im: im.verify() sizes[im.size] 1 except Exception: broken.append(p.name) print(出现最多的5种分辨率:, sizes.most_common(5)) print(损坏图像:, broken[:5])这段脚本只做一件事统计。rglob会把dataset目录下所有子目录里的 jpg 和 txt 找出来分别统计数量后比对stem这样能看到有多少图没标注。之后用Image.verify()检查 JPG 文件是否真能解码很多公开数据集里混着内容截断的半张图训练到一半报错往往就是这种坏文件。分辨率分布决定了后面imgsz怎么选如果大多数图是 480×640 这种竖图硬拉到 640×640 会被压变形不如先统一长边。2.2 读几个标注文件确认类别 ID 和坐标范围YOLO txt 格式每行是class_id cx cy w h其中cx cy是归一化框中心w h是归一化宽高。看起来简单但不同来源的数据集坑很多类别 ID 可能从 1 开始、坐标可能是像素值、甚至有人把 COCO 的 JSON 直接改名成 txt。我拿到手第一件事是随机抽标注文件打印原始行。import random from pathlib import Path label_files list(Path(acne_dataset).rglob(*.txt)) for f in random.sample(label_files, 3): print(f) with open(f, r, encodingutf-8, errorsignore) as fp: for i, line in enumerate(fp): if i 5: break print(line.strip())打印出来的行如果第一位是0.123这类浮点而不是0或1这种整数类 ID说明这份标注的类目被写进了第一列的小数里整个格式都需要重排。正常 YOLO 标注的第一列应该是整数类别 ID后面四个是小于 1 的浮点。还能顺手统计一下每个类别出现了多少次判断是不是严重不平衡。比如只有0一个类那nc1如果是三类则要确认 0、1、2 分别对应粉刺、丘疹还是脓疱这个信息通常要在数据集说明里找。2.3 超界框、零宽高框、旋转图清洗脚本要先跑这一步是最容易被跳过的也是后期训练 loss 变成nan的头号原因。YOLO 格式要求坐标归一化到 [0,1]但很多标注工具导出的框有 1% 的概率超出边界还有少数 txt 里框宽高是 0这种框在 loss 计算里会除零。我习惯在进训练前统一清洗一遍把非法行过滤掉、把越界框裁回图像范围内。def clean_label_line(line, img_w, img_h, min_size2): parts line.strip().split() if len(parts) ! 5: return None c, cx, cy, w, h parts try: cx, cy, w, h map(float, (cx, cy, w, h)) except ValueError: return None if w 0 or h 0 or w 2 or h 2: return None # 先把归一化坐标还原成像素框裁到图像内再转回归一化 x1 min(max((cx - w / 2) * img_w, 0), img_w) y1 min(max((cy - h / 2) * img_h, 0), img_h) x2 min(max((cx w / 2) * img_w, 0), img_w) y2 min(max((cy h / 2) * img_h, 0), img_h) if x2 - x1 min_size or y2 - y1 min_size: return None new_cx (x1 x2) / 2 / img_w new_cy (y1 y2) / 2 / img_h new_w (x2 - x1) / img_w new_h (y2 - y1) / img_h return f{int(c)} {new_cx:.6f} {new_cy:.6f} {new_w:.6f} {new_h:.6f}这段函数的逻辑是先按归一化坐标还原成像素框再做越界裁剪最后重新归一化。好处是所有标注都约束在图像内坏处是裁剪会轻微改变框中心但对目标检测影响很小。min_size2表示宽高小于 2 像素的框直接丢弃因为这种框在缩小到 640 尺度后基本就消失了。另外JPG 的 EXIF 旋转信息经常被忽略用PIL读图时如果要拿原始尺寸算坐标最好先ImageOps.exif_transpose再拿width, height不然横竖图和标注会完全错位。3. 改造成标准 YOLOv8 数据集布局目录划分、yaml 与自动转换3.1 标准目录树images 和 labels 分开train/val 按图切YOLOv8 官方训练逻辑要求数据集目录里至少有一个data.yaml并约定图像路径和标签路径。目录不一定要按固定结构但按官方习惯组织最容易排错。我常用的目录如下acne-yolo/ ├── acne.yaml ├── images/ │ ├── train/ │ ├── val/ └── labels/ ├── train/ └── val/划分时按“图”来分不是按“标注”来分。一图多标注时同一张图的 txt 必须和 jpg 在同一个数据集划分里否则训练集和验证集之间会有信息泄漏mAP 虚高部署后翻车。import random import shutil from pathlib import Path random.seed(42) src_imgs Path(acne_dataset/images) src_labels Path(acne_dataset/labels) out Path(acne-yolo) for split in [train, val]: (out / images / split).mkdir(parentsTrue, exist_okTrue) (out / labels / split).mkdir(parentsTrue, exist_okTrue) all_imgs list(src_imgs.glob(*.jpg)) random.shuffle(all_imgs) n_val int(len(all_imgs) * 0.1) for i, img_path in enumerate(all_imgs): label_path src_labels / (img_path.stem .txt) if not label_path.exists(): continue split val if i n_val else train shutil.copy(img_path, out / images / split / img_path.name) shutil.copy(label_path, out / labels / split / label_path.name) print(train 图数:, len(list((out / images / train).glob(*.jpg)))) print(val 图数:, len(list((out / images / val).glob(*.jpg))))这里用random.seed(42)保证可复现。n_val int(927 * 0.1) 92张验证图对 927 张的数据集来说比例偏小但总比 8:2 更强因为训练数据本身就不多。需要说明的是如果原数据的标注文件名和图名不一致这一步会直接跳过最后统计的 train/val 数会小于 927正好作为一张体检报告。3.2 写 acne.yamlnc、names、路径这 3 处最容易错YOLOv8 的data.yaml是训练入口内容很精简但字段名写错会直接报错或者训练出一个你根本不知道是几类的模型。# acne.yaml path: /home/user/acne-yolo train: images/train val: images/val nc: 1 names: 0: acnepath我永远写绝对路径因为相对路径是基于 yaml 文件所在位置解析的很多时候训练命令里 cd 到别的目录就找不到了。nc是类别数量单类就写 1不是 0。names里的列表顺序必须和标注 txt 里的第一列整数一一对应如果训练后推理结果总把痘痘框出来但类名显示不对基本都是这里错位。还有一个小坑yaml 文件里不能用 tab 缩进且编码必须是 UTF-8直接从 Windows 记事本拷出来的 yaml 偶尔会带 BOM也会让解析炸掉。3.3 如果原始标注不是 YOLO txt从 VOC XML 转 YOLO 的核心套路尽管标题写了 YOLOv8 格式仍要留个后手。很多公开的痤疮数据集是 PASCAL VOC 的 XML 标注或者 COCO JSON。我在处理医学图像数据集时最常遇到的是 XML。转换时不用写一整套脚本抓住坐标系换算这个核心就够了。import xml.etree.ElementTree as ET class_names [acne] # 按你的类别表改写 def voc_to_yolo(xml_path, out_txt): tree ET.parse(xml_path) root tree.getroot() size root.find(size) w float(size.find(width).text) h float(size.find(height).text) lines [] for obj in root.iter(object): name obj.find(name).text if name not in class_names: continue cls_id class_names.index(name) box obj.find(bndbox) x1 float(box.find(xmin).text) y1 float(box.find(ymin).text) x2 float(box.find(xmax).text) y2 float(box.find(ymax).text) x1, x2 min(x1, x2), max(x1, x2) y1, y2 min(y1, y2), max(y1, y2) cx (x1 x2) / 2 / w cy (y1 y2) / 2 / h bw (x2 - x1) / w bh (y2 - y1) / h lines.append(f{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) with open(out_txt, w, encodingutf-8) as f: f.write(\n.join(lines))转换完一定要抽查。我常用的抽查方式是随便拉 5 张图把标注框画上去看一眼确认没有整体偏移一个身位。很多 VOC XML 里xmax和xmin写反了上面代码用min/max做了防御。坐标从像素变成归一化后一定要确认分母是宽高而不是 640有人会顺手除以 640小图上的框就全残废了。4. 跑通 YOLOv8 训练环境、三参数与损失曲线判读4.1 最小训练命令预训练权重是关键安装环境没有玄学pip install ultralytics是最常见的做法。训练命令的核心是选对预训练权重。927 张图从零训练 CNN 基本不可能收敛但加载yolov8n.pt后用迁移学习微调十几分钟就能看到损失明显下降。最小命令如下yolo detect train \ dataacne.yaml \ modelyolov8n.pt \ epochs100 \ imgsz640 \ batch16 \ device0这里modelyolov8n.pt既指定了网络结构又加载了 COCO 预训练权重。如果你的显存是 GTX 1660Ti 这种 6G 级别batch16、imgsz640能跑yolov8n但再往上加yolov8s就可能 OOM建议直接batch8。epochs100对小数据集并不算多配合早停patience20足够。4.2 必调三个参数imgsz、batch、学习率参数不是越多越好这张表是我在痤疮这类小目标数据上反复试出来的起点参数起始值调整依据imgsz640痘痘平均只有几十像素960 通常能换来 5~10% 的 mAP 提升batch16显存够就加大到 32数据量小 batch 过大反而震荡lr00.01 (SGD) 或 0.001 (AdamW)预训练权重微调时 lr0 超过 0.01 必翻车mosaic1.0对小数据集是双刃剑过拟合时降到 0.5fliplr0.5皮肤图像左右对称可以开上下翻转建议关hsv_h0.015光照变化大就开大一点但痤疮颜色敏感别超过 0.05具体到命令行增强参数可以这样传yolo detect train \ dataacne.yaml \ modelyolov8n.pt \ epochs150 \ imgsz960 \ batch16 \ lr00.001 \ optimizerAdamW \ mosaic0.5 \ fliplr0.5 \ hsv_h0.02 \ hsv_s0.4 \ hsv_v0.3选AdamW加lr00.001是我在 927 张小数据集上的保守牌。SGD 在数据充足时收敛更好但小数据集上一旦学习率不合适loss 曲线会像心电图。另外imgsz960会让训练耗时变长1660Ti 上yolov8n大约能跑到 40ms/张一个 epoch 也就 20 秒出头完全能接受。4.3 看损失曲线判断模型状态别傻等 100 个 epoch训练结束后runs/detect/train/下会生成results.png和results.csv。我喜欢直接读results.csv画自己的损失函数曲线图方便放大看局部波动。import pandas as pd import matplotlib.pyplot as plt df pd.read_csv(runs/detect/train/results.csv) fig, ax plt.subplots(1, 2, figsize(12, 4)) ax[0].plot(df[train/box_loss], labeltrain/box_loss) ax[0].plot(df[val/box_loss], labelval/box_loss) ax[0].set_title(box loss) ax[0].legend() ax[1].plot(df[metrics/mAP50(B)], labelmAP50) ax[1].plot(df[metrics/mAP50-95(B)], labelmAP50-95(S)) ax[1].set_title(mAP) ax[1].legend() plt.tight_layout() plt.savefig(acne_loss_curve.png, dpi200)判断逻辑就三条train/box_loss和val/box_loss一起下降是健康的train降但val上升是过拟合回退模型并加强增强两者都在高位震荡则说明学习率偏大或数据里有脏标注。另外记得看val/box_loss的最低点在哪个 epoch早停选出来的best.pt就是那个位置。5. 927 张小数据集避坑mAP 虚高、小目标漏检、背景误检与震荡5.1 现象验证集 mAP 高达 0.95换手机实拍一路翻车这是我第一次在痤疮数据集上踩的坑。训练时验证集 mAP50 到 0.95导出模型拿给同事手机拍了两张结果一张框出指纹、一张框出毛孔。原因是数据集的图像来源太单一同一台相机、同一个光照、同一个人的皮肤区域背景纹理被模型当成了特征。解决这个问题的唯一办法是引入外部测试集哪怕只有 20 张从不同设备拍的照片也远比把 927 张图按 8:2 重新划分更接近真实分布。训练端要做的配合是加大色彩增强hsv_h0.03、hsv_s0.6并开degrees10做小角度旋转让模型不再依赖固定的光照方向。5.2 现象痘痘在图上只有几十像素imgsz640 下漏检一半痤疮检测里最常见的痛点是目标太小。一张 1000×1000 的面部图痘痘直径可能只有 30 像素经过 YOLOv8 的 5 次下采样后小目标在特征图里只剩不到 1 像素的信息。直接拉高imgsz是最简单的解法比如imgsz960或imgsz1280。但 1280 在 1660Ti 上显存压力很大我一般用imgsz960配合 SAHI 切图推理推理时把原图切成不重叠的 640×640 小块分别检测再拼回坐标。这样相当于把每张图的有效分辨率放大到接近原图。需要注意切图后同一个痘痘可能同时出现在两个块里推理后要用 NMS 做合并否则同一目标出两个框。ultralytics 里没有内置 SAHI需要自己写合并逻辑但对 927 张数据训练出的模型这种操作能直接救回 15% 的召回率。5.3 现象痣、痘印、雀斑被当成痘痘一起框出来模型会学习标注者给它的所有“正确答案”。如果标注时把所有红色点都框进去了模型自然分不清痣和炎性丘疹。这是我见过最多的数据质量问题标注规范不统一。解决分两步。第一步是回头清洗标注痤疮检测的目标应该限定为凸起、有明显边界的丘疹和脓疱痘印和痣只给负样本身份。第二步是增加“背景负样本”在数据集里专门放一些没有痘痘的皮肤区域 JPG标注文件写成空 txt让模型有机会学习“这里什么都没有”。YOLOv8 是支持空标签图片的只要 txt 是 0 字节即可不用特殊标记。5.4 现象训练 loss 反复震荡几十个 epoch 后仍在高位用默认 SGD 在 927 张图上训练我第一次的train/box_loss从第 10 个 epoch 开始就没有下降趋势反而来回跳。查下来三个原因叠加学习率 0.01 对迁移学习太高、batch 只有 8 导致 BN 统计量不稳定、数据集里还有几条坐标错乱的标注在给梯度“投毒”。解决办法是我后来固定的组合optimizerAdamW、lr00.001、batch16并把之前写的清洗脚本重新跑了一遍。如果这样还震荡就把warmup_epochs从默认 3 调到 5让模型先稳定下来再全速学习。5.5 现象mAP 曲线像过山车每轮涨跌 10%927 张图分出 92 张验证集后每类平均可能只有几十个框任何一张含有密集痘痘的验证图进来mAP 都会被拉高 5 个百分点下一张没有痘痘的图又会把 mAP 砸下来。这不是模型问题是验证集太小。我建议把验证集占比从 10% 提高到 15%并且用分层采样先把所有图按“图内痘痘数量”分桶再在每个桶里按比例抽取验证图。或者干脆做 5 折交叉验证最终报告里给出 5 折 mAP 均值和方差这样论文或汇报里就不会被说成“手动挑了一张好验证集”。6. 把模型拿去部署前先做这三件事再谈效果6.1 分层 mAP单独看小目标指标训练完别只盯总 mAP50。执行一次验证并把结果里的metrics/precision(B)、metrics/recall(B)、metrics/mAP50(B)、以及按尺寸分层的AP_S全部记下来。如果AP_S明显低于AP_M说明模型对小痘痘仍然不稳定建议回到第 4 章的imgsz960方案重训一次。验证命令是yolo detect val \ modelruns/detect/train/weights/best.pt \ dataacne.yaml \ imgsz960注意验证时的imgsz必须和训练时一致不一致时 mAP 不具备可比性这是一个非常容易忽略的坑。6.2 热力图看模型到底在“看”什么我会用 Grad-CAM 对几张典型图片画热力图确认模型聚焦在痘痘区域而不是皮肤纹理。YOLOv8 生态里有现成的可视化工具也可以用ultralytics的explain接口。热力图高亮区域如果集中在背景、头发、衣领那说明模型又作弊了直接回炉。这一步是部署前最便宜的质量检查能省下大量现场返工时间。6.3 ONNX/RKNN 导出前的预处理对齐最后要做的技术动作是导出一份 ONNX做前后一致性验证yolo export modelruns/detect/train/weights/best.pt formatonnx imgsz640导出后的 ONNX 输入是 NCHW 的 RGB 张量YOLOv8 的预处理隐含了letterbox和像素归一化。到这一步最常出的错是在推理代码里直接用 OpenCVresize把图压成正方形导致框坐标错位。我自己的教训是不管导出 ONNX 还是迁往 RK3588 转 RKNN都要把预处理函数从 PyTorch 前处理里完整抄出来用同一张 JPG 对比 PyTorch 和 ONNX 的输出误差超过 1e-3 就说明中间某一步写错了。做对这三件事小数据集的模型才值得放进真机。希望这些踩坑记录能帮你少走一两次弯路祝你的痤疮数据集顺利跑出首个可用版本。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进