做宠物狗狗皮肤病检测,卡住大多数人的第一道坎往往不是模型选型,而是数据本身。一套干净、规范、能直接喂给训练流程的标注数据集太稀缺了。我最近整理了一套2973张、覆盖6个常见类别的狗狗皮肤病检测数据集,同时输出VOC和YOLO两种标注格式,实测下来可以直接用于YOLO系列和Faster R-CNN这类模型的训练。这篇文章把从图片来源、清洗筛选、标注规范,到格式转换、踩坑排查的完整过程写出来,给准备自己做数据集的你一个参考。
先说结论:这份数据集从头到尾整理下来,真正花时间最多的不是标注,而是清洗和校验。很多人以为标注完就万事大吉,其实后续的格式转换、坐标校验、目录组织才是决定训练过程是否顺畅的关键。下面我按实际整理的顺序来聊。
1. 项目整体设计与思路拆解
1.1 为什么选择皮肤病作为检测目标
宠物医疗领域的AI落地,皮肤问题是最容易被视觉识别的场景之一。狗皮肤病的病变区域通常会在毛发覆盖下露出红斑、脱毛、结痂、脓疱等显著特征,相比内科疾病需要依赖血液生化数据,皮肤病是少数能靠摄像头直接判断的病症,很适合用目标检测模型做辅助筛查工具。
在实际场景里,宠物医院接诊的皮肤病病例占比一直不低,而基层诊所缺少专业的皮肤科医生,很多主人发现狗狗异常后只能先拍照发到网上求助。如果有一个模型能先做一轮初筛,把可疑区域标出来,就能帮医生快速锁定检查范围,也能给主人在就诊前提供参考判断。这比那种只能输出“是否患病”二分类的模型实用得多——医生需要知道的不仅是“病了”,还要知道“病变区域在哪个位置、面积多大”。
1.2 六个类别的选型逻辑
类别设计是整个数据集的骨架,直接决定了模型的使用边界。我最终确定的是临床上最常见、且视觉特征能稳定区分的6类:
| 类别标识 | 中文名称 | 典型视觉特征 | 临床常见诱因 |
|---|---|---|---|
| bacterial_dermatitis | 细菌性皮炎 | 红肿、脓疱、丘疹、渗出物 | 环境潮湿、伤口继发感染 |
| fungal_dermatitis | 真菌性皮炎(癣菌病) | 圆形脱毛斑、边界清晰、鳞屑 | 犬小孢子菌感染 |
| mite_dermatitis | 寄生虫性皮炎 | 剧烈瘙痒、大面积脱毛、皮肤增厚 | 疥螨、蠕形螨感染 |
| eczema | 湿疹/过敏性皮炎 | 红斑、丘疹、瘙痒性抓痕 | 食物过敏、环境过敏原 |
| hot_spot | 急性湿性皮炎(热斑) | 局部红肿溃烂、湿润渗出、界限明显 | 舔舐刺激、细菌继发感染 |
| seborrheic_dermatitis | 脂溢性皮炎 | 皮屑增多、油脂分泌旺盛、被毛油腻 | 皮脂腺功能异常 |
这六类的选择不是拍脑袋。第一,它们覆盖了宠物医院日常接诊的大部分皮肤病类型,实用价值高;第二,它们在视觉特征上有明显的区分度——比如真菌感染的圆形脱毛斑和螨虫引起的弥漫性脱毛在形态上差异很大,标注时能保持相对一致性。如果类别之间长得很像,标注员会反复犹豫,最终的框和标签都会带着大量噪声,模型学出来也不可用。
1.3 2973张的数据规模考量
2973张这个数量,在目标检测数据集里不算大,但考虑是垂直领域的专业数据,这个规模是经过权衡的。深度学习目标检测模型一般建议每个类别至少要有几百个实例——2973张图,6个类别,平均每类约500张,而每张图往往不止一个病灶区域,实际上每类的标注框数量会超过图片数量。
这个规模对训练YOLOv5s这类轻量级模型是够用的,配合数据增强、预训练权重迁移,在验证集上能达到可接受的水平。要凑更多数据当然更好,但边际成本会急剧上升:皮肤病图片的获取、专业领域的标注审核、难例的筛选,每一张图的边际成本远高于通用物体数据集。对于个人开发者或小团队来说,2973张是一个平衡了成本和效果的选择。
2. 数据采集与清洗的实操要点
2.1 图片来源与合规整理
数据来源需要先想清楚。直接爬取网络图片是最快的方式,但存在版权风险和水印污染问题,我个人不建议这么干。我采用的组合方式是:自己拍摄一部分(在允许拍摄的场景下记录真实的病例皮肤病变区域),合作宠物医院提供脱敏授权影像,再从符合开源许可的数据集中筛选补充。这样既保证了数据真实性,也规避了后续发布和使用时的版权隐患。
采集时要注意控制图片的多样性。同一个医院、同一个光源下拍出来的照片,背景、角度、光照高度相似,模型很容易过拟合到这种“医院风格”上。补数据时要刻意增加不同环境——室内暖光、室外自然光、不同毛色的狗、不同体型的狗、手机和单反拍摄的照片都要有。模型没见过多样化的场景,换到真实手机随手拍的照片上就会掉点。
2.2 清洗筛选的三条硬标准
采集回来的原始图片远不能直接用,我筛了足足三遍,标准很明确:
一是清晰度。皮肤病诊断靠的就是细节纹理,糊图是最不能容忍的。筛选时用Laplacian算子计算图像方差,阈值以下的直接淘汰。这个做法比肉眼快速浏览可靠得多——靠肉眼看一百张图就会疲劳,但算法一分钟能扫几十张,稳定不犯错。
import cv2 def is_blurry(img_path, threshold=80): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: return True # 缩小后计算方差,避免不同尺寸图之间的不公平比较 img = cv2.resize(img, (320, 320)) variance = cv2.Laplacian(img, cv2.CV_64F).var() return variance < threshold二是特征明确性。很多皮肤病的早期症状非常轻微,比如只有一小块淡淡泛红,这类图即使清晰也难以标注,而且不同标注员对“这算不算病灶”的判断会冲突。我定的规矩是:病灶特征不明显、肉眼都需要仔细辨认的图,一律不保留。宁可少一些数量,也要保证每张图里都有“一眼能看出来”的病变区域。
三是重复度。翻拍、连拍容易产生大量近乎重复的图片,这些图会让训练集出现偏差。我用感知哈希(pHash)算法计算图像的相似度指纹,两张图的汉明距离小于10就视为重复图,保留清晰度更高的那一张。这一步不做的话,模型会在重复样本上过拟合,验证时看似指标不错,换到真实数据上立刻打回原形。
2.3 为什么不一开始就做大量数据增强
这个问题经常有人问。我的建议是:数据增强放到标注完成之后做,而不要在采集阶段做。采集阶段应该尽量保证原始样本的独立性——一张图旋转后变成四张,等于四张样本共享同一个病灶纹理信息,对模型的泛化贡献远小于四张真正独立拍摄的图片。
因为收集独立样本的成本太高,所以训练前的离线增强还是要做。但增强不能盲目堆量——皮肤病检测任务对颜色和纹理敏感,过强的颜色扰动会让模型把正常的皮肤纹理误判成病变。我在增强时只做了水平翻转、小角度旋转、亮度对比度的轻微调整,不动HSV色相,也不做大幅形变,避免把圆形病灶拉伸成奇怪的形状,给模型传递错误信息。
3. 标注规范与质量控制
3.1 标注工具的选型与配置
数据集的标注质量,工具选型影响很大。我用过几款主流开源工具,实际体验不完全一样。个人项目或小团队,我推荐labelImg;如果是多人协作标注,更推荐Label Studio,它有网页端和审核接口,能直接管理多个标注任务。
| 工具 | 输出格式 | 多人协作 | 上手成本 | 适用场景 |
|---|---|---|---|---|
| labelImg | VOC XML 原生 | 不支持 | 极低 | 个人或小团队快速标注 |
| Label Studio | 多种格式 | 支持 | 中 | 多人协作规范化流程 |
| CVAT | 多种格式 | 支持 | 中高 | 团队化、大规模标注 |
labelImg 值得推荐的原因很实际:轻量、启动快、快捷键顺手,标注界面直接显示VOC格式的XML内容,导出之后不需要额外转换就能跑通标注环节。缺点是多人协作时管理起来繁琐,需要自行约定文件命名规则。如果是一个人在做,labelImg完全够用。
3.2 框选标准与边界情况处理
很多数据集质量差,差在标注标准不统一。标注皮肤病和标注猫狗不同,病灶边界往往模糊,没有清晰的技术约定,不同人框出来的结果能差出好几倍。
我定的框选规范是:标注框紧贴病灶最外缘,不留过多空白,但也不能切掉病变区域。对边界模糊的病灶,框在“能明显辨认病变的范围内”略微外扩3%-5%,保证信息完整;对多个独立病灶,每个病灶单独框注;对大面积弥漫性病灶,框出可见区域中特征最明显的连续部分,不强行用一个巨大的框包住所有零散斑点——那样框内背景占比太高,对模型训练是负优化。
还有一个容易犯的错:把毛发阴影或皮肤褶皱当成病灶框进去。我见过不少标注数据里,框住的是狗狗身上正常的色素沉着区域。这类错误模型学不出来,还会被当作“难例”反复干扰训练。我的做法是:凡是难以判断的图片标记为“存疑”,后期人工复核,而不是硬标上某个类别。
3.3 标注质量的两道检查关卡
标注完成后必须做检查,我自己经历了不检查就开训,结果模型精度上不去的教训。检查分两道:
第一道是统计异常检查。写脚本统计每个框的尺寸分布、横纵比、越界情况。框面积小于图片面积0.5%的标注框,很可能是误标或点错,需要拉出来看;坐标值超出图片边界的框,格式转换时不会报错,但训练时会产生大量无效正样本,直接拖低mAP。
第二道是人工抽检。随机抽取20%的图片,逐张核对标注框与病灶区域的贴合度。这一步由不同的人做效果最好——标注员自己检查容易陷入惯性,旁观者更容易发现“这个框根本没包住病灶”之类的问题。我踩过这个坑:自己标完自己看,越看越顺眼,直到换人复核才发现某几个类别的标准执行偏了。
4. VOC转YOLO格式的实现
4.1 两种格式的差异与原理解读
VOC格式和YOLO格式的标注信息其实完全一样,都是“目标位置+类别”,区别在于坐标表示方式。
VOC的XML文件存的是绝对值坐标:
<object> <name>fungal_dermatitis</name> <bndbox> <xmin>120</xmin> <ymin>88</ymin> <xmax>346</xmax> <ymax>312</ymax> </bndbox> </object>这个框的坐标是相对图片像素坐标的,图片是多少分辨率就写多少数值。问题在于:图片resize之后,这个坐标就失效了。而YOLO训练时,输入尺寸不一定等于原始尺寸,所以不能直接用绝对坐标。
YOLO格式存的是归一化坐标,中心点加宽高:
2 0.4652 0.3821 0.2254 0.2537这行的含义是:类别ID为2,目标中心点在水平方向的46.52%位置,垂直方向的38.21%位置,框宽占图片宽度的22.54%,高占图片高度的25.37%。全部比例化之后,不管图片怎么缩放,标注框对应的相对位置不变。这就是为什么训练流程里普遍用YOLO格式——它是把坐标从“依赖具体尺寸”变成“纯粹描述目标几何信息”。
转换公式本质就两个:
x_center = (xmin + xmax) / 2 / img_width y_center = (ymin + ymax) / 2 / img_height box_width = (xmax - xmin) / img_width box_height = (ymax - ymin) / img_heightimg_width和img_height必须是标注时的原始图片尺寸,不能拿resize后的尺寸去算,否则坐标集体错位。
4.2 转换与划分的完整脚本
我自己整理这套数据集时,写了一个把VOC格式批量转成YOLO格式,同时完成数据集划分的脚本,这里贴出来可以直接用。
import xml.etree.ElementTree as ET import os import random from PIL import Image # 类别顺序即训练时的 class_id,务必固定,后期不要轻易调整 CLASSES = [ "bacterial_dermatitis", "fungal_dermatitis", "mite_dermatitis", "eczema", "hot_spot", "seborrheic_dermatitis" ] def voc_to_yolo_label(xml_path, img_root): """把单个VOC XML转换为YOLO格式的文本行列表。""" tree = ET.parse(xml_path) root = tree.getroot() # 确保从XML读到的文件名能找到真实图片 img_name = root.find("filename").text img_path = os.path.join(img_root, img_name) if not os.path.exists(img_path): # 有些XML存的路径带了目录前缀,兼容处理 img_path = img_name with Image.open(img_path) as img: img_w, img_h = img.size yolo_lines = [] for obj in root.iter("object"): cls_name = obj.find("name").text if cls_name not in CLASSES: continue cls_id = CLASSES.index(cls_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, xmin) ymin = max(0, ymin) xmax = min(img_w, xmax) ymax = min(img_h, ymax) x_center = ((xmin + xmax) / 2.0) / img_w y_center = ((ymin + ymax) / 2.0) / img_h box_w = (xmax - xmin) / img_w box_h = (ymax - ymin) / img_h # 过滤掉清洗后几乎消失的微小框 if box_w * img_w < 10 or box_h * img_h < 10: continue yolo_lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}") return yolo_lines, img_name def split_dataset(image_list, val_ratio=0.2, seed=42): """按种子随机划分训练集和验证集。""" random.seed(seed) shuffled = image_list[:] random.shuffle(shuffled) split_idx = int(len(shuffled) * (1 - val_ratio)) return shuffled[:split_idx], shuffled[split_idx:] # 主流程:遍历VOC目录,生成YOLO格式标注,并划分数据 voc_annotation_dir = "VOCdevkit/VOC2007/Annotations" voc_image_dir = "VOCdevkit/VOC2007/JPEGImages" yolo_image_root = "datasets/dog_skin/images" yolo_label_root = "datasets/dog_skin/labels" for subset in ["train", "val"]: os.makedirs(os.path.join(yolo_image_root, subset), exist_ok=True) os.makedirs(os.path.join(yolo_label_root, subset), exist_ok=True) all_xmls = [f for f in os.listdir(voc_annotation_dir) if f.endswith(".xml")] train_xmls, val_xmls = split_dataset(all_xmls) for subset, xml_list in [("train", train_xmls), ("val", val_xmls)]: for xml_name in xml_list: xml_path = os.path.join(voc_annotation_dir, xml_name) try: yolo_lines, img_name = voc_to_yolo_label(xml_path, voc_image_dir) except Exception as e: print(f"[失败] {xml_name}: {e}") continue txt_name = os.path.splitext(xml_name)[0] + ".txt" with open(os.path.join(yolo_label_root, subset, txt_name), "w") as f: f.write("\n".join(yolo_lines)) # 复制图片到对应训练/验证目录 src_img = os.path.join(voc_image_dir, img_name) if os.path.exists(src_img): dst_img = os.path.join(yolo_image_root, subset, img_name) if src_img != dst_img: # 这里用拷贝还是move按需选择,我建议copy保留VOC原始备份 with open(src_img, "rb") as fin, open(dst_img, "wb") as fout: fout.write(fin.read()) print("转换完成,请检查目录结构。")脚本里的两个设计值得说一下:一个是坐标裁剪,XML里偶尔会出现xmax超出图片宽度的情况,如果不裁剪,生成的归一化坐标会大于1,训练时YOLO会把框的范围算到图片外面去,这是mAP上不去的隐藏元凶之一。另一个是微小框过滤——清洗之后,有些病灶只占图片极小比例,这类样本对模型学习没有帮助,反而会产生大量难例,我先把它剔除掉了。实务中如果要做小目标检测场景,这个过滤阈值要根据任务调整,不能照搬。
4.3 目录结构与训练配置
转换完成后的标准目录长这样:
datasets/dog_skin/ ├── images/ │ ├── train/ # 约2378张 │ └── val/ # 约595张 ├── labels/ │ ├── train/ │ └── val/ └── data.yamlYOLOv5和YOLOv8都支持这种目录结构,只需要配套一个data.yaml配置文件:
train: datasets/dog_skin/images/train val: datasets/dog_skin/images/val nc: 6 names: ['bacterial_dermatitis', 'fungal_dermatitis', 'mite_dermatitis', 'eczema', 'hot_spot', 'seborrheic_dermatitis']注意names的顺序必须和CLASSES列表完全一致,因为训练时标签文件里存的只是类别ID,模型读names顺序来对应类别名称。如果顺序乱了,标签和类别名就对不上,验证集上的混淆矩阵会没法看。
5. 常见问题与排查技巧实录
5.1 标注与转换中的高频故障
我整理这套数据的过程中遇到了不少典型问题,给你整理成一个速查表:
| 问题现象 | 常见原因 | 排查与解决办法 |
|---|---|---|
| 转换脚本报错找不到图片 | XML中filename和实际文件名不一致 | 优先以XML的filename为准,但转换前统一检查一次文件名映射关系 |
| 生成的txt文件是空文件 | XML里没有object节点,或类别名不在CLASSES列表 | 检查XML内容,排查类别名拼写是否一致 |
| 验证集mAP极低但训练loss正常 | 标注框坐标越界 | 用脚本统计所有标签中的坐标,查找大于1或小于0的值 |
| 训练开始就报错category index out of range | txt里的类别ID大于yaml中的nc | 多半是有没转换到位的旧标签混进了目录 |
| 图片和标签对不上 | 划分时图片复制失败但没报错 | 训练前用脚本检查每个labels下的txt名都能在images下找到同名图片 |
其中“类别名拼写不一致”是我实际踩过最多次的坑。同一个类别在有些XML里写的是bacterial_dermatitis,有些写成了bacterial_dermatiti或者带了个下划线后缀,转换脚本处理时会直接跳过,这类样本就成了无标签数据,训练时被当背景吃掉,直接影响模型对这个类别的召回率。
5.2 训练阶段发现数据问题的排查思路
如果模型训练完后,某个类别的AP明显低于其他类别,先别急着调模型结构或超参数,优先怀疑数据。我的排查顺序是:先统计这个类别的样本框数量,看是不是严重偏少;再看该类别的框是否集中在少数几张图上——100个框但是只在10张图里出现,这个类别的多样性实际上不够;最后看标注框与病灶区域是否贴合。
还有一个容易被忽视的问题:类别不平衡。如果细菌性皮炎占了全部框的一半,而真菌感染只占不足5%,模型对真菌感染的检出能力会非常差。缓解办法有两个方向:一个是做类别重采样,在训练时对少样本类别多取几张图;另一个是回看数据采集环节,专门补拍和补充真菌感染的图片。我实际体验下来,后者比前者的效果更扎实,数据本身多样性不够,任何采样策略都是治标不治本。
5.3 一点关于数据版本管理的建议
我整理过程中最大的体会是把过程留下痕迹。每次对数据集做版本变更时,记清楚新增了哪些图片、删除了哪些图片、改了哪些标注,不要全推倒重来。因为模型训练到一半发现某个类别效果不行,回头补数据时,如果不知道上一个版本的数据细节,所有指标对比都失去了参考意义。
这里的做法其实不复杂,就是每次清洗、标注、转换之后,单独留一个副本文件夹,命名里带版本号。虽然多占一点磁盘空间,但训练模型迭代时的对比分析会顺畅很多。
最后分享一个我在实际整理过程中体会最深的一点:数据集的质量,决定性因素是标准和校验流程,而不是标注工具或样本数量。只要框选标准定得细、校验执行得到位,2973张图片已经足够启动一个相当有说服力的皮肤病检测训练了。拿到这个数据集之后先别急着上最大模型,建议先用YOLOv5s或YOLOv8n跑一遍,确认数据链路完整、指标正常后,再逐步换更大模型去榨取性能上限。这样能帮你快速区分问题到底出在数据还是模型,避免在错误的环节浪费时间。