简介:面向太阳能光伏板积灰与灰尘检测场景的目标检测数据集,包含1463张JPEG图像及对应的VOC格式XML与YOLO格式TXT标注文件,标注类别为Dirt单一类,共6822个矩形框。压缩包约75.98MB,共2000个文件,以XML与TXT标注文件为主,目录结构清晰,可直接接入YOLO、MMDetection等主流检测框架训练。数据集使用labelImg人工画框标注,类别规则统一,适合光伏运维巡检、新能源视觉检测方向的学习者快速搭建基线模型,也可用于熟悉VOC与YOLO格式转换流程。由于图片重复度较高且价格定位偏低,更适合初学者练手或作为数据增强素材。已有536人学习下载,是一份实用且易上手的入门级光伏积灰检测数据资源。
1. 光伏板积灰检测,先处理数据再谈模型
巡检无人机传回来的光伏板照片里,真正影响发电量的往往不是热斑和隐裂,而是组件表面积灰。这套太阳能光伏板积灰灰尘检测数据集提供了1463张jpg图片,标注类别只有Dirt,总框数6822,同时给出labelImg导出的VOC xml和转换好的YOLO txt,不需要再做格式迁移就能接入yolo目标检测流程。适合两类人:一类是刚跑通yolov5或yolov8、想用真实脏污样本把训练流程走完整的初学者;另一类是准备研究数据增强和重复样本去重策略的算法工程师。需要先说清楚,数据集说明里明确写了图片重复度很高,如果你不管这个特点直接随机划分train/val,mAP会虚高,后面所有指标判断都不可信。所以拿到压缩包之后第一件事不是训练,是清洗和复核标注。
2. 数据集结构与VOC/YOLO标注格式的转换细节
2.1 解压后先核对三类同名文件
压缩包解压后是平铺结构,jpg、xml、txt三个后缀同名共处一个目录,文件名类似firc_solarpanel_1345.jpg、firc_solarpanel_1345.xml、firc_solarpanel_1345.txt,另外附带一个使用前必读.txt说明文件。这里的txt是YOLO检测格式,每行对应一个目标框,不包含分割掩码路径,和分割任务用的txt不是一回事。
unzip firc_solarpanel_1463.zip -d solar_panel_dirt cd solar_panel_dirt find . -name "*.jpg" | wc -l find . -name "*.xml" | wc -l find . -name "*.txt" ! -name "使用前必读.txt" | wc -lunzip -d指定解压目录,后面三条find命令分别统计jpg、xml、txt数量,! -name把说明文件排除在统计之外。我这里实际跑完得到三个1463,和摘要描述一致。如果某个数字偏少,优先检查是不是有空的xml文件,或者文件名后缀大小写不一致。
这三种文件的对应关系如下表:
| 文件类型 | 数量 | 作用 |
|---|---|---|
| jpg | 1463 | 原始光伏板图像 |
| xml | 1463 | VOC格式标注,包含目标类别与绝对坐标 |
| txt | 1463 | YOLO格式标注,包含类别id与归一化坐标 |
总框数6822除以1463张图,平均每张约4.66个框,说明Dirt目标不是每张只框一个大区域,而是分散在组件表面多个位置。这个分布特点会影响后面训练时anchor和imgsz的选择。
2.2 VOC的xml与YOLO的txt字段差异
VOC格式的核心是object节点,一个目标对应一个<object>,坐标用左上角和右下角的绝对像素值表示:
<annotation> <filename>firc_solarpanel_1345.jpg</filename> <size> <width>1280</width> <height>960</height> <depth>3</depth> </size> <object> <name>Dirt</name> <bndbox> <xmin>100</xmin> <ymin>80</ymin> <xmax>300</xmax> <ymax>240</ymax> </bndbox> </object> </annotation>上面这段是示意结构,实际坐标值以压缩包内xml为准。YOLO txt里每一行是类别id 中心点x 中心点y 宽度 高度,且全部除以图片宽高做了归一化。两种格式的差异直接决定了转换逻辑:
| 对比项 | VOC xml | YOLO txt |
|---|---|---|
| 类别标识 | 字符串名称,如Dirt | 整数索引,如0 |
| 坐标形式 | xmin、ymin、xmax、ymax | x_center、y_center、width、height |
| 坐标范围 | 像素绝对值 | 0到1之间的归一化值 |
| 是否需要图片尺寸 | 是,xml里自带size节点 | 否,训练时从图像本身获取 |
转换时容易踩的坑是坐标顺序。VOC里先给左上角再给右下角,YOLO里是先给中心点再给宽高,两个顺序很容易换错。另外类别索引必须从0开始,Dirt对应0,如果后续自己加了一类,这个索引就会整体错位。
2.3 用Python做VOC到YOLO的批量转换
labelImg默认导出VOC xml,而yolov5和yolov8训练需要YOLO txt。转换脚本关键点在于把绝对坐标换算成归一化中心坐标:
import os import xml.etree.ElementTree as ET source_xml_dir = "annotations" target_txt_dir = "labels" os.makedirs(target_txt_dir, exist_ok=True) class_names = ["Dirt"] def convert(size, box): dw = 1.0 / size[0] dh = 1.0 / size[1] x = (box[0] + box[1]) / 2.0 y = (box[2] + box[3]) / 2.0 w = box[1] - box[0] h = box[3] - box[2] return x * dw, y * dh, w * dw, h * dh for xml_name in os.listdir(source_xml_dir): if not xml_name.endswith(".xml"): continue root = ET.parse(os.path.join(source_xml_dir, xml_name)).getroot() size = root.find("size") width = int(size.find("width").text) height = int(size.find("height").text) lines = [] for obj in root.findall("object"): cls_id = class_names.index(obj.find("name").text) bndbox = obj.find("bndbox") box = [ float(bndbox.find("xmin").text), float(bndbox.find("ymin").text), float(bndbox.find("xmax").text), float(bndbox.find("ymax").text), ] cx, cy, bw, bh = convert((width, height), box) lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") out_path = os.path.join(target_txt_dir, xml_name.replace(".xml", ".txt")) with open(out_path, "w") as f: f.write("\n".join(lines))脚本读取xml里的width和height,把左上角和右下角的坐标换算成中心点加宽高的归一化形式。class_names.index负责把字符串类别映射成int索引,所以class_names列表的顺序必须和后续训练配置里的names顺序完全一致。{:.6f}保留6位小数,对640或960分辨率的图片足够,不需要担心精度损失。
注意:xml里如果缺少
<size>节点,这段脚本会直接报错。处理办法是打开对应图片用PIL读取宽高补进去,而不是跳过该文件。
3. 高重复度图片的清洗与标注复核
3.1 为什么重复图片会让训练指标失真
数据集的重复度高,这是影响使用方式的最关键属性。所谓重复不一定是完全相同的文件,也可能是同一块光伏板在相近角度、相近光照下拍摄的多张图片。这种近似重复如果同时落入训练集和验证集,模型等于提前见过验证集答案,mAP会异常高,但部署到新场景立刻打回原形。
常规做法是先用感知哈希把近似重复图片分到同一组,然后按组为单位划分数据集,保证同组图片要么全在train、要么全在val。另一个容易被忽略的动作是核对标注总数,1463个xml和1463个txt应该都包含6822个框,如果txt转换时漏了某个object,最终训练框数会偏少。
3.2 用感知哈希找出近似重复
感知哈希里最常用的是dhash和phash,dhash对亮度变化敏感,phash对JPEG重压缩更稳。当前这个场景可以用phash配合hash_size=8,把每张图压缩成64位指纹,指纹完全相同的图片视为同一组。
from PIL import Image import imagehash import os img_dir = "images" hash_to_imgs = {} for img_name in sorted(os.listdir(img_dir)): if not img_name.endswith(".jpg"): continue img_path = os.path.join(img_dir, img_name) img_hash = imagehash.phash(Image.open(img_path), hash_size=8) hash_to_imgs.setdefault(img_hash, []).append(img_name) duplicate_groups = [v for v in hash_to_imgs.values() if len(v) > 1] print(f"重复组数: {len(duplicate_groups)}") for group in duplicate_groups[:5]: print(group)hash_size=8生成8x8的灰度指纹,最终得到一个64位哈希值。setdefault把相同哈希的图片名聚合到同一个列表里。hash_size越大,指纹越敏感,过大的hash_size会把同一场景的光照差异也判定为不同图片。对光伏板这种背景单调的图像,8已经能区分“同一块板子换了个角度”的情况。
几种查重方式的选择可以按下面这个表格判断:
| 方法 | 敏感度 | 适合场景 |
|---|---|---|
| md5 | 最高 | 完全相同的文件 |
| dhash | 中 | 亮度变化较小的近似重复 |
| phash | 中低 | JPEG重压缩、缩放后的重复图 |
3.3 按哈希分组重新划分train和val
找到重复组之后,不要直接按单张图片随机划分,要按组划分。单张随机切会让同一场景的相似图同时出现在训练集和验证集,造成信息泄漏。
import os import shutil import random groups = list(hash_to_imgs.values()) random.Random(42).shuffle(groups) split_point = int(len(groups) * 0.85) train_groups = groups[:split_point] val_groups = groups[split_point:] def copy_group(group, split_name): out_img_dir = f"dataset/images/{split_name}" out_txt_dir = f"dataset/labels/{split_name}" os.makedirs(out_img_dir, exist_ok=True) os.makedirs(out_txt_dir, exist_ok=True) for img_name in group: shutil.copy( os.path.join(img_dir, img_name), os.path.join(out_img_dir, img_name), ) txt_name = img_name.replace(".jpg", ".txt") txt_path = os.path.join("labels", txt_name) if os.path.exists(txt_path): shutil.copy(txt_path, os.path.join(out_txt_dir, txt_name)) for group in train_groups: copy_group(group, "train") for group in val_groups: copy_group(group, "val")random.Random(42)固定随机种子,保证多次运行划分结果一致,便于复现。copy_group内部的os.makedirs会递归创建目录,exist_ok=True避免重复报错。这里有个细节:只有txt存在时才复制,因为个别xml可能没有对应标注,这种情况在labelImg里经常出现。
3.4 统计txt框数并检查越界坐标
划分完成后还要验证一下总框数。YOLO txt格式里,类别id必须是整数,中心点和宽高必须是0到1之间的浮点数,宽高必须大于0。编写一个快速校验脚本:
import os total_boxes = 0 bad_files = [] for txt_name in sorted(os.listdir("labels")): if not txt_name.endswith(".txt"): continue txt_path = os.path.join("labels", txt_name) with open(txt_path) as f: lines = [line.strip() for line in f if line.strip()] for line in lines: parts = line.split() if len(parts) != 5: bad_files.append((txt_name, "字段数异常")) continue cls_id, x, y, w, h = parts if not cls_id.isdigit(): bad_files.append((txt_name, "类别id不是整数")) if not (0 <= float(x) <= 1 and 0 <= float(y) <= 1): bad_files.append((txt_name, "中心点越界")) if float(w) <= 0 or float(h) <= 0: bad_files.append((txt_name, "宽高异常")) total_boxes += len(lines) print(f"总框数: {total_boxes}") print(f"异常记录: {bad_files[:10]}") print(f"异常文件数: {len(bad_files)}")总框数如果等于6822,说明VOC转YOLO过程没有丢目标。len(parts) != 5用于捕获空行或多余空格导致的问题,cls_id.isdigit()判断类别索引是否符合要求。越界检查对后续yolov8训练尤其重要,因为训练时数据加载器会把越界框裁剪到图像边缘,少量越界不报错但会干扰损失计算。
4. YOLOv8训练自己的Dirt数据集
4.1 准备data.yaml目录配置
yolov8训练过程中使用data.yaml描述数据集路径和类别名称。这里的关键是path、train、val相对于yaml文件的位置,以及names索引必须与txt里的类别id对应。
path: ./solar_panel_dirt train: images/train val: images/val names: 0: Dirtpath是数据集根目录,train和val写相对路径即可,yolov8会自动拼接成./solar_panel_dirt/images/train。names字典的key从0开始,当前数据集只有一个Dirt类别,所以只有0一项。如果你自己增加了类别,顺序必须和生成txt时使用的class_names列表保持一致。
4.2 训练超参数与命令
积灰目标通常比较小,且光伏板表面反光会导致同一块脏污在不同图片里亮度差异很大。训练前需要把imgsz、batch、数据增强这些参数确定下来。下面是我在这个数据集上常用的参数组合:
| 参数 | 建议值 | 说明 |
|---|---|---|
| imgsz | 640或960 | 960对小目标更友好,但显存占用明显增加 |
| batch | 16 | 8GB显存建议改成8 |
| epochs | 120 | 单类别任务不需要过大epoch |
| optimizer | AdamW | 比SGD收敛快,适合小数据集 |
| mosaic | 0.8 | 保留mosaic增强,但不要全程开启 |
| lr0 | 0.001 | 预训练模型沿用这个初始学习率比较稳 |
对应的训练命令为:
yolo detect train \ data=solar_panel_dirt.yaml \ model=yolov8s.pt \ epochs=120 \ imgsz=640 \ batch=16 \ optimizer=AdamW \ lr0=0.001 \ mosaic=0.8model=yolov8s.pt表示使用yolov8s的COCO预训练权重作为起点,迁移学习在小数据集上效果远好于随机初始化。mosaic=0.8在ultralytics中会自动按epoch衰减,不需要自己实现mosaic关闭逻辑。如果验证集mAP一直不稳定,先把mosaic关掉再观察。如果你的显卡显存只有6GB,把model换成yolov8n.pt并把batch改成8。
4.3 从训练曲线判断积灰模型是否在泛化
训练完成后,ultralytics会在runs/detect/train/目录下生成results.csv,里面记录了每个epoch的box loss、cls loss、mAP50、mAP50-95等指标。画出box loss和mAP50的变化趋势,能快速判断模型是否过拟合到重复图片上。
import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv("runs/detect/train/results.csv") fig, ax1 = plt.subplots() ax1.plot(df["train/box_loss"], color="tab:red", label="train box loss") ax2 = ax1.twinx() ax2.plot(df["metrics/mAP50(B)"], color="tab:blue", label="val mAP50") fig.legend() plt.savefig("training_curve.png")train/box_loss对应YOLOv8的CIoU边界框回归损失,下降说明预测框逐步贴近真实框。metrics/mAP50(B)是验证集IoU阈值0.5下的平均精度。如果box loss持续下降但mAP50在某个epoch后不再上涨,说明模型开始记忆训练集中的重复模式,应当用第3章的去重结果重新划分,或者提前早停。对于积灰这种边缘模糊的目标,mAP50和mAP50-95之间的差距通常比较大,这是正常的,不必因为mAP50-95偏低就怀疑标注质量。
5. 验证集中残留重复图的检查与TTA推理
5.1 训练前检查train和val之间是否存在图片泄漏
数据划分之后、训练开始之前,最好再做一次跨集合重复检查。即便第3章按hash分组划分,phash指纹完全相同的情况也可能漏掉少量“同一块板子不同曝光”的图片。检查逻辑很简单:分别收集train和val目录下所有图片的phash,求交集。
from PIL import Image import imagehash import os def collect_hashes(root): hash_to_name = {} for img_name in os.listdir(root): if not img_name.endswith(".jpg"): continue img_path = os.path.join(root, img_name) h = imagehash.phash(Image.open(img_path), hash_size=8) hash_to_name[h] = img_name return hash_to_name train_hashes = collect_hashes("dataset/images/train") val_hashes = collect_hashes("dataset/images/val") leaks = set(train_hashes.keys()) & set(val_hashes.keys()) print(f"train/val潜在重复数: {len(leaks)}") for h in list(leaks)[:5]: print(train_hashes[h], "->", val_hashes[h])这段脚本把train和val中phash完全一致的图片视为潜在泄漏。如果potential_leak_count大于0,说明之前的划分还不够严格,需要回到第3章改用更大的hash_size,或者把这些重复组整体挪到训练集一侧。注意这里用的是集合交集,每个hash只保留了一张图片名,所以输出只能用来定位问题,不能完整列出所有重复对。
5.2 用augment推理评估模型鲁棒性
最后一个小技巧是用yolo推理的augment参数做Test-Time Augmentation。训练完的权重直接推理会看到较低或较高的mAP,但TTA能把水平翻转、缩放、颜色抖动后的预测融合起来,对积灰这种边缘模糊目标更稳定。命令如下:
yolo detect predict \ model=runs/detect/train/weights/best.pt \ source=dataset/images/test \ imgsz=640 \ augment=Trueaugment=True会启用水平翻转、多尺度等推理增强,ultralytics会把多次预测结果融合后再输出最终框。这个参数默认是False,部署时不要打开,因为推理耗时大约会增加到原来的2到3倍。在验证集上比较augment=True和普通模式的mAP,如果TTA明显更高,说明模型对光照变化敏感,后续可以增加亮度扰动数据增强而不是继续堆epoch。
本文还有配套的精品资源,点击获取