简介:面向风力叶片缺陷检测任务的高质量标注数据集,包含2249张现场叶片图像,覆盖排水孔受损、雷击、污垢、漏油、PU胶带、表面裂纹、侵蚀等典型缺陷类型。全部图像采用COCO JSON格式标注,3个JSON文件与1997张JPG图片一一对应,标注中保留目标类别与边界框坐标,可直接用于训练YOLO、Faster R-CNN、MMDetection等主流目标检测模型,也可作为叶片巡检视觉方案的验证数据。压缩包共2000个文件,整体约74.32MB,以JPG图像和JSON标注文件为主,结构清晰,按需划分训练集与验证集后即可直接开展模型训练与评测。数据涵盖不同叶片型号和拍摄场景,兼顾正常与异常样本、缺陷类别多样性,有助于提升模型在实际风电运维场景中的泛化能力。已有818人学习下载,适合从事风电智能运维、无人机巡检算法开发的工程师、学生和算法爱好者使用。
1. 2249 张叶片缺陷图能撑起什么:数据构成与适用场景
风力叶片缺陷检测数据集,核心就 2249 张现场实拍图,COCO JSON 格式标注,覆盖排水孔受损、雷击、污垢、漏油、PU胶带、表面裂纹、侵蚀 7 类缺陷。说实话,这个规模在工业缺陷数据集里不算大,但胜在场景真实——不是实验室摆拍,是风机叶片上肉眼可见的损伤。适合谁?想快速验证缺陷检测方案的算法工程师,准备用 YOLO 系列练手的入门者,以及做风电巡检预研的团队。我拿它完整跑了一遍从数据检查、格式转换到模型训练验证的流程,踩了不少标注和坐标的坑,这篇就把数据怎么读、怎么改、坑在哪一次说清。
2. 先拆数据再谈训练:文件名规律与 COCO JSON 字段结构
2.1 图片命名拆解:ASP/MAN、BladeA/B/C、时间戳和哈希
这批图的文件名很有信息量,随便挑一个拆:ASP47BladeC1703859112_jpg.rf.721ccfc57d1b2244b8a4a1dcc41814c5.jpg。
前缀ASP47和MAN320是机组或叶片型号的分组标识,ASP和MAN代表两个不同的风机厂家序列,后面的数字是机组编号。BladeA / BladeB / BladeC表示叶片位置,同一台风机的三支叶片被区分开了。1703859112是 Unix 时间戳,换算一下大约是 2023 年 12 月底,1707124154对应 2024 年 2 月初,说明采集跨度了一个多月,覆盖了不同光照和天气条件。_jpg.rf.是 Roboflow 平台导出的痕迹,后面那串哈希是平台重命名时加的,作用就是保证文件名全局唯一。
这个命名规律直接影响数据划分。如果你直接把所有图片随机切成 train / val,同一支叶片的照片会同时出现在训练集和验证集里。叶片缺陷检测和普通分类不一样,同一次巡检拍的几十张图高度相似,模型在验证集上看到的几乎等于训练时见过的,mAP 虚高得厉害。我一般按文件名里的机组+叶片号分组,整组划分,比如用ASP47BladeC作为划分键,保证同叶片不进两个集合。
2.2 categories 与 annotations:7 类缺陷怎么在 JSON 里组织
COCO JSON 是目标检测最常见的标注格式之一,打开后会看到三个顶层字段:images、annotations、categories。categories定义了缺陷类型,7 类缺陷对应 7 个类别条目,每个条目有id、name、supercategory三个字段,supercategory通常写defect就行,方便后续按大类聚合。images数组里每条记录对应一张图,关键字段是id、file_name、width、height,要注意这里的id是平台随机分配的内部编号,不是从 0 开始连续递增的,后面转格式时容易在这里翻车。
annotations是核心,每条标注对应一个缺陷框。字段包括id、image_id、category_id、bbox、area等。bbox是[x, y, width, height]的绝对像素坐标,x, y是框左上角坐标。下面是这段 JSON 的实际结构:
{ "images": [ {"id": 831, "file_name": "ASP47BladeC1703859112_jpg.rf.721ccfc57d1b2244b8a4a1dcc41814c5.jpg", "width": 640, "height": 640} ], "categories": [ {"id": 1, "name": "drain_hole_damage", "supercategory": "defect"}, {"id": 2, "name": "lightning_strike", "supercategory": "defect"} ], "annotations": [ {"id": 1001, "image_id": 831, "category_id": 1, "bbox": [218.5, 302.1, 76.3, 42.8], "area": 3265.6} ] }category_id是从 1 开始的,这一点和 YOLO 的类别编号从 0 开始不同,转换时不能直接拿category_id当 YOLO 的 class id,必须按categories数组的顺序重新映射。area字段在 COCO 里通常是分割区域的面积,但很多纯框标注的导出工具会直接填bbox的宽乘高,所以这个字段参考价值有限,我统计目标大小分布时习惯自己用bbox重算。
2.3 bbox 字段的精度陷阱:坐标对齐与目标尺度分布
bbox里是浮点数,保留了一位小数,这在标注阶段是正常的,因为标注框的边界不会落在整数像素上。但转 YOLO 格式时要注意:归一化坐标如果保留太多位小数,生成的 txt 文件会很大,而且小数点后 6 位以内精度已经足够了,因为最终训练时图像会被 resize,多余精度在插值缩放中完全没有意义。我一般保留 6 位,写出来就是一个边界清晰的浮点数。
再一个是目标尺度分布。bbox的宽高差异极大:排水孔受损和 PU 胶带这类缺陷,框可能只有几十像素;雷击和侵蚀往往是叶片边缘的大片区域,框能占到整张图的三分之一。这两种极端目标混在同一个数据集里,训练时要格外注意输入分辨率。Roboflow 导出时width和height如果被 resize 成 640,原图细节被压缩,小目标的框在特征图上可能只剩几个像素,这直接影响检测精度。建议训练前先把images字段里的width和height分布打出来看一眼,确认是否被统一缩放,再决定imgsz参数怎么设。
3. 训练前体检:类别分布统计与 bbox 可视化检查
3.1 用 Python 统计类别分布与样本数
拿到数据集的第一件事不是训练,是统计。7 类缺陷在 2249 张图里肯定不是均匀分布的,雷击和排水孔受损这种偶发缺陷样本量大概率比污垢、侵蚀少。不摸清分布直接训,小样本类别基本学不出来。我写了个统计脚本,一次跑出每类别的框数量、涉及图片数、平均框面积:
import json from collections import Counter with open('annotations.json', 'r', encoding='utf-8') as f: coco = json.load(f) # 建立 category_id -> 类别名的映射 cat_map = {c['id']: c['name'] for c in coco['categories']} # 统计每个类别的标注框数量 box_counter = Counter() # 统计每个类别出现在多少张图里 img_counter = Counter() for ann in coco['annotations']: cat_name = cat_map[ann['category_id']] box_counter[cat_name] += 1 img_counter[cat_name].add(ann['image_id']) # 统计每类目标的平均框面积 area_by_cat = {} for ann in coco['annotations']: cat_name = cat_map[ann['category_id']] w, h = ann['bbox'][2], ann['bbox'][3] area = w * h area_by_cat.setdefault(cat_name, []).append(area) for cat in box_counter: avg_area = sum(area_by_cat[cat]) / len(area_by_cat[cat]) print(f"{cat}: 框数={box_counter[cat]}, 图片数={len(img_counter[cat])}, 平均面积={avg_area:.0f}")这个脚本的核心是用Counter统计类别频次,用set去重统计图片数。cat_map映射层是必须的,因为annotations里的category_id是数字,直接看数字没法判断是哪个缺陷,映射成名字才直观。跑完之后你心里就有数了:如果某类只有 20 来个框,后面就要考虑数据增强或者类别合并。
3.2 在原图上叠加 bbox:用 opencv 快速可视化
统计数据只能告诉你数量,不能告诉你质量。标注框是不是偏大、有没有框错位置、是不是包含了大片无关背景,这些必须要可视化抽检。用 opencv 把标注框画到原图上,随机抽 50 张看一眼,比任何统计都直观。
import cv2 import json import random with open('annotations.json', 'r', encoding='utf-8') as f: coco = json.load(f) # 建立 image_id -> 图片文件名 的映射 img_map = {img['id']: img for img in coco['images']} # 按图片分组标注 ann_by_img = {} for ann in coco['annotations']: ann_by_img.setdefault(ann['image_id'], []).append(ann) color_map = { 1: (0, 0, 255), 2: (255, 0, 0), 3: (0, 255, 0), 4: (0, 255, 255), 5: (255, 255, 0), 6: (255, 0, 255), 7: (128, 128, 0) } sample_ids = random.sample(list(ann_by_img.keys()), min(50, len(ann_by_img))) for img_id in sample_ids: img_info = img_map[img_id] img_path = img_info['file_name'] image = cv2.imread(img_path) for ann in ann_by_img[img_id]: x, y, w, h = [int(v) for v in ann['bbox']] cv2.rectangle(image, (x, y), (x + w, y + h), color_map[ann['category_id']], 2) cv2.imwrite(f'viz_{img_id}.jpg', image)抽样逻辑用random.sample取 50 张,避免只看前几张图产生的视觉偏差。画框时把 bbox 的浮点数强转成int,opencv 的rectangle不接受浮点坐标。color_map给每类缺陷固定一个颜色,这样一眼就能看出哪类框多、哪类框集中出现在什么位置。如果你发现某张图的框把叶片边缘的阴影也包进去了,说明标注人员把背景杂物当成了缺陷,这类噪声会直接干扰模型学特征。
3.3 数据划分策略:同叶片不出现在两个集合里
划分数据集的正确姿势是按机组叶片分组。前文提过文件名里有ASP47BladeC这种结构,划分前先把每个文件名解析出叶片分组,再按组切分:
import os import random import shutil images_dir = 'images' train_dir = 'train_images' val_dir = 'val_images' # 按 "ASP47BladeC" 分组:取前几个字符太长,用正则精确提取 import re def get_blade_group(filename): # 匹配形如 ASP47BladeC / MAN320BladeB 的前缀 m = re.match(r'^([A-Z]+\d+Blade[ABC])', filename) return m.group(1) if m else filename groups = {} for fname in os.listdir(images_dir): if not fname.endswith('.jpg'): continue group = get_blade_group(fname) groups.setdefault(group, []).append(fname) group_names = list(groups.keys()) random.shuffle(group_names) # 80% 的叶片组进训练集,20% 进验证集 split_point = int(len(group_names) * 0.8) train_groups = set(group_names[:split_point]) val_groups = set(group_names[split_point:]) for group, files in groups.items(): target = train_dir if group in train_groups else val_dir for f in files: shutil.copy(os.path.join(images_dir, f), target)代码里用正则^([A-Z]+\d+Blade[ABC])提取叶片组,比直接截取前几个字符可靠,因为机组的数字位数不等,有 47 也有 320,统一按 12 个字符截会截错。按叶片组划分的核心意义是隔离相关性:同一叶片同一时间段的照片几乎是从相似角度拍的,模型很容易记住场景而不是学习缺陷特征,整组划分能逼着模型真正泛化。
4. COCO 转 YOLO 格式:归一化坐标脚本与 data.yaml 配置
4.1 转换脚本:读取 JSON、按 images 映射文件名
YOLO 系列训练不接受 COCO JSON,它要的是每个图片同名.txt,每行格式是class_id cx cy w h的归一化坐标。转换前必须先干两件事:确认images[i].file_name和实际图片文件名一致,确认image_id能正确映射到文件名。Roboflow 导出时偶尔会改文件名,如果file_name和磁盘上的图对不上,后面全是黑匣子错误,所以我第一步就是把映射表打出来人工核对一遍。
import json import os with open('annotations.json', 'r', encoding='utf-8') as f: coco = json.load(f) img_id_to_name = {img['id']: img['file_name'] for img in coco['images']} img_id_to_size = {img['id']: (img['width'], img['height']) for img in coco['images']} # category_id 重映射为 0 起始的连续编号 cat_id_map = {c['id']: idx for idx, c in enumerate(coco['categories'])} for ann in coco['annotations']: img_name = img_id_to_name.get(ann['image_id']) if img_name is None: print(f"警告: image_id {ann['image_id']} 找不到对应图片") continue img_w, img_h = img_id_to_size[ann['image_id']] x, y, w, h = ann['bbox'] # COCO 的 xywh 左上角格式转 YOLO 的 cxcywh 中心格式 cx = (x + w / 2) / img_w cy = (y + h / 2) / img_h nw = w / img_w nh = h / img_h # 裁剪越界坐标,防止训练时索引越界 cx = max(0, min(1, cx)) cy = max(0, min(1, cy)) nw = max(0, min(1, nw)) nh = max(0, min(1, nh)) new_class = cat_id_map[ann['category_id']] line = f"{new_class} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}" label_path = img_name.rsplit('.', 1)[0] + '.txt' with open(os.path.join('labels', label_path), 'a', encoding='utf-8') as out: out.write(line + '\n')转换逻辑的关键点有三个。第一,cx = (x + w / 2) / img_w把左上角坐标换算成中心点,这是因为 COCO 的 bbox 定义是[x, y, width, height],而 YOLO 需要的是归一化中心坐标和宽高。第二,cat_id_map用字典推导式把原始的 category_id 映射成从 0 开始的连续编号,这一步能提前暴露 JSON 里 category 缺失的问题。第三,越界裁剪max(0, min(1, ...))是防御性写法,标注时手抖把框拉出图外的情况并不少见,先裁掉避免训练时索引异常。
4.2 归一化坐标计算:为什么边框不能直接除宽高
有人图省事,直接把 COCO 的 bbox 四项都除以图片宽高就完事,这是错的。[x, y, width, height]里的 x 和 y 是左上角坐标,直接除完得到的是左上角的位置,YOLO 拿这个当中心点去算先验框匹配,整个损失函数梯度全歪了,训练出来 mAP 极低还找不到原因。正确做法是先算中心点再归一化,具体就是cx = x + w/2落在像素坐标系里的中心,再除以img_w。这个顺序不能反,反了就是坐标偏右下,越小的目标偏差越大。
再提一个细节:x + w/2这里要注意 Python 的运算优先级,x + w / 2等价于x + (w / 2),没问题。但如果你用整数除法//,在小目标上会丢掉小数点后面的坐标精度,训练时框的定位会整体偏移 1-2 像素,虽然不大,但在排水孔受损这种小缺陷上是致命伤。统一用浮点计算,最后格式化时再截断。
4.3 目录结构与 data.yaml:YOLOv8 直接开训
转完格式后目录结构要固定成 YOLO 约定,训练时data.yaml才能指对路。一个可以用的结构是:
baseline/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yamldata.yaml是训练入口的配置文件,里面指定路径、类别数和类别名。写成这样:
path: ./baseline train: images/train val: images/val nc: 7 names: 0: drain_hole_damage 1: lightning_strike 2: dirt 3: oil_leak 4: pu_tape 5: surface_crack 6: erosion类别顺序必须和转换脚本里cat_id_map的编号一致,这一步最容易错。我自己吃过亏:转换脚本用的字典推导顺序和names列表手写的顺序不一样,结果类别全错位了,训练时模型每次都把污垢识别成雷击,一度以为数据标注有问题,最后定位到是names顺序写错。经验就是一个配置源,直接从 JSON 的categories数组顺序生成names,不要手敲。
准备就绪后可以直接用 YOLOv8 的 CLI 开训:
yolo detect train \ data=baseline/data.yaml \ model=yolov8n.pt \ epochs=120 \ imgsz=1280 \ batch=8 \ patience=20imgsz=1280是专项为小目标设置的。如果 Roboflow 导出的图本身就是 640,那imgsz=1280会把图放大两倍喂进去,显存占用会显著增加,batch=8是在 12G 显存下比较稳的组合。如果你显存紧张,先用yolov8n验证流程,跑通了再换yolov8s或yolov8m。patience=20表示 20 个 epoch 验证集没有提升就提前停止,省时间。
5. 避坑清单:标注口径、小目标、类别不平衡的实战记录
5.1 漏油 vs 污垢:视觉相似的类别要统一标注口径
现象:训练完模型后,漏油类别检测出来的框大多是虚警,污垢的召回率也上不去,定性看就是模型分不清这两类。
原因:漏油和污垢在视觉上高度相似,都是叶片表面的深色不规则区域。这个数据集在标注阶段没有给出明确的区分标准,有的标注员把油渍归为污垢,有的把灰黑色的污渍归为漏油,标注边界是模糊的,模型学到的类别边界自然也是模糊的。
解决:手动检查标注结果,或者标注时定一个硬标准。我习惯按颜色和光泽度区分:有反光感的深褐色或黑色区域归为漏油,无光泽的亲层状灰黑色附着物归为污垢。如果实在分不开,一个更实用的做法是直接把这两类合并成一类surface_contamination,后期需要细分的时候再用原始数据重新标注。
5.2 排水孔受损是小目标:输入分辨率与切片策略
现象:模型对排水孔受损的识别基本失效,漏检严重,即使标注框就在那里。
原因:排水孔在叶片上本来就是小结构,受损区域更小。如果原始图片被 resize 到 640×640,这类缺陷的框宽高可能只有 10 到 20 像素,经过特征提取网络的多次下采样后,在最后一层特征图上就剩一个点,目标特征彻底消失。
解决:第一优先把imgsz提到 1280 或 1536,让小目标在输入层面保留更多像素。第二是考虑切片训练,把原图按 50% 重叠切成若干 patch,每个 patch 单独训练和推理,最后把检测框映射回原图坐标合并。这是检测小目标的通用做法,代价是推理时间翻倍,但对排水孔受损这种缺陷值得。
5.3 Roboflow 重命名后 image_id 对不上:文件映射检查
现象:转格式脚本跑完没有任何报错,但训练时每个 epoch 都提示某些图片找不到标签文件,损失曲线异常。
原因:Roboflow 导出时会重命名文件,images数组里的file_name是平台生成的哈希名,而不是你最初上传的原始文件名。如果下载时只下载了图片没有下载 JSON,或者 JSON 是另外一个人从平台单独导出的,图片文件和file_name之间就可能对不上。
解决:训练前的 check 脚本非常必要。用images数组里的file_name逐一去磁盘上验证文件是否存在,把缺失的图片从数据集里剔除。还有个更隐蔽的坑是文件名大小写不一致,Roboflow 的哈希名是小写,本地如果是 Windows 系统复制出来可能被改成大写开头。我的教训是:解压完先跑一遍映射检查,再谈训练。
5.4 类别不平衡:雷击样本少怎么办
现象:雷击类别在整个数据集里的样本量可能只有几十张,训练完这个类别的 AP 通常是个位数,等于没学。
原因:雷击是偶发事件,不像污垢和侵蚀那样普遍存在。目标检测在类别极度不平衡时,损失函数被大类别主导,小类别梯度信号太弱,模型倾向于把预测都压向大类别。
解决:常见做法是给少数类加大 loss 权重,在 YOLOv8 里没有直接的 per-class weight 参数。我一般先做离线过采样:把雷击样本复制几份,配合随机缩放和亮度扰动,增加它在每个 batch 里出现的概率。更激进的做法是把雷击和表面裂纹合并成一个大类structural_damage,先保证能检测出损伤,再考虑细分。
5.5 bbox 越界与面积过滤
现象:转换脚本跑完后,训练出来的模型在验证集上出现大量中心点在图片边缘的预测框。
原因:标注阶段手抖,框拉出了图片边界,COCO 的 bbox 坐标出现了负值或者超出widthheight的情况,YOLO 训练时对这类异常坐标会算出离谱的先验匹配结果。
解决:转换脚本里加越界裁切是兜底,但不是最好的办法。我建议在可视化检查阶段就把这类标注找出来,框完全在边界外的直接删掉,半越界的裁切为有效区域,只有边缘几像素的标注几何意义也有限。此外可以加一个最小面积过滤:w < 5或h < 5的框对训练毫无贡献,还容易引入噪声,直接丢弃。
6. 第一版模型验证:按类别看 mAP 与召回,再做任务合并
训练完成后,训练日志里的总 mAP 只能给人一个整体印象。真正决定模型能不能落地的是每个类别的表现,尤其是雷击和排水孔受损这种关键缺陷,漏检的成本比误检高一个数量级。我习惯在验证集上单独统计每类的 AP50 和召回率,脚本可以直接加在训练环境里:
from ultralytics import YOLO model = YOLO('runs/detect/train/weights/best.pt') results = model.val( data='baseline/data.yaml', imgsz=1280, conf=0.25, iou=0.5, save_json=True ) # 从 results 里取每个类别的 AP 表 box = results.box for i, name in enumerate(box.names.values()): ap50 = box.ap50[i] if hasattr(box, 'ap50') else 'N/A' print(f"{name}: AP50 = {ap50}")conf=0.25是工程上比较常用的置信度阈值,高于这个阈值才算检测到;iou=0.5对应 COCO 的 AP50 标准。这里要提醒一句:验证时的imgsz必须和训练一致,如果训练用的 1280 验证用的 640,小目标的 AP 会断崖式下跌,这不是模型问题,是你评估口径不对。
看完分类别指标,下一步就要决策:这 7 类要不要全部保留。我的建议是按维修动作合并成一个粗粒度分类器。具体思路是四组:排水孔受损和 PU 胶带归为补胶修复类,表面裂纹和侵蚀归为结构损伤类,污垢和漏油归为清洁维护类,雷击单独保留。这样把相似的类别合并,类别间差异更大,模型的分类边界更稳定,小样本类别的召回率也会上来。等粗粒度模型在巡检流程里跑稳了,再拿原始数据单独训练细分类模型去区分具体缺陷类型。
实际跑完第一版模型你会发现,2249 张图训出来的模型在巡检预筛场景是够用的,但它扛不住严苛的运维验收。如果要做真正的缺陷等级评估,还得补两类数据:不同光照条件下的同缺陷样本,以及更多角度的雷击区域特写。这份数据集的定位是让你把流程跑通、把模型的基线打出来,后续的增量数据可以按叶片分组持续补充,整个训练管线不用改。
从那以后我每次拿到新数据集,都会强制先走一遍「统计类别分布 → 可视化抽检 → 整组划分 → 转换格式 → 训练前检查」这套流程,宁可多花半小时也不跳过,因为跳过之后的时间都会在两倍奉还。希望帮到你。
本文还有配套的精品资源,点击获取