简介:这份资源面向计算机视觉开发者与深度学习学习者,提供光伏电池缺陷检测的目标检测数据集,用于训练模型识别并定位电池表面的“损坏”与“无效”两类缺陷,可服务于太阳能行业的质量控制场景。压缩包共433个文件,包含216个png图像、216个xml标注文件及1个json类别索引文件,整体约8.35MB,xml中记录每个目标的边界框坐标与类别信息,json则便于映射类别标签。目前已有917人学习下载,适合作为Faster R-CNN、YOLO等检测模型的训练与验证素材。读者可据此完成标注解析、训练集与验证集划分、模型训练及mAP、召回率等指标评估,快速搭建从数据预处理到缺陷定位的完整流程,是入门目标检测与工业质检实践的实用起点。
1. 光伏电池缺陷检测数据集:xml 标注文件到底怎么用
光伏电池片产线上的 EL 和 PL 成像设备每天产出几万张图,真正卡住算法团队的往往不是模型结构,而是手里那堆 xml 标注文件——它们来自 LabelImg、CVAT 或产线自研标注工具,格式看着差不多,字段却各写各的。目标检测数据在光伏电池缺陷检测这个场景里,核心诉求就三件事:把 xml 里的缺陷框准确读出来、把类别体系对齐到产线关心的几类缺陷(隐裂、断栅、黑斑、混档、边缘崩边)、再把标注转成 YOLO 或 COCO 能直接吃的格式。这套流程做扎实了,后面换 yolov8、yolov11 还是更新的检测头,数据侧都不用返工。这篇写给正在处理光伏 EL 缺陷标注、被 xml 字段和坐标转换折腾过的工程师,从解析、校验到转换、训练前检查,一步步走通。
2. 先看懂 xml 标注文件:字段、坐标系与光伏缺陷类别
2.1 一份典型光伏缺陷 xml 里有什么
Pascal VOC 风格的 xml 是光伏缺陷标注里最常见的一种,结构大致长这样:
<annotation> <folder>PV_EL_2024</folder> <filename>cell_000123.jpg</filename> <size> <width>1024</width> <height>1024</height> <depth>3</depth> </size> <object> <name>crack</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>312</xmin> <ymin>488</ymin> <xmax>401</xmax> <ymax>560</ymax> </bndbox> </object> </annotation>size里的宽高是原图尺寸,bndbox用的是左上角加右下角的绝对像素坐标,原点在图片左上角,x 向右、y 向下。光伏 EL 图通常是 1024×1024 或 2048×2048 的灰度图,但标注工具存出来的 xml 里depth可能写 3,实际图是单通道,这个不一致后面会咬人。
name字段是类别名,光伏场景里常见的有 crack(隐裂)、finger_break(断栅)、black_spot(黑斑)、edge_damage(崩边)、mixed(混档)。不同标注团队命名习惯不一样,有的用中文,有的用拼音缩写,有的把隐裂细分成微裂和贯穿裂。类别体系不统一是后面训练翻车的头号原因。
2.2 坐标系与坐标越界:光伏图最容易出的问题
光伏电池片是方形,缺陷经常贴着边缘,标注时手一抖xmax就超过width,或者xmin写成负数。这类框在转 YOLO 归一化坐标时会算出大于 1 或小于 0 的值,训练时不一定报错,但会让模型学到错误的边界信息。
判断一个框是否越界,逻辑很简单:
def is_valid_box(box, w, h): xmin, ymin, xmax, ymax = box # 坐标必须落在图像范围内 if xmin < 0 or ymin < 0 or xmax > w or ymax > h: return False # 宽高必须为正,排除标注时点反了的情况 if xmax <= xmin or ymax <= ymin: return False return True参数说明:box是(xmin, ymin, xmax, ymax)四元组,w、h来自 xml 的size字段。返回 False 的框要么修正要么丢弃,别直接塞进训练集。我一般会把越界框单独写到一个bad_boxes.txt里,回头找标注团队核对,而不是默默裁掉——裁掉会改变缺陷的真实形态。
2.3 类别体系对齐:别让 crack 和 Crack 变成两类
光伏缺陷的类别名大小写、空格、中英文混用极其常见。crack、Crack、crack(带尾空格)、隐裂在字符串层面是四个不同类别,直接转 YOLO 会生成四个类,模型学得稀里糊涂。
对齐做法是维护一张映射表:
LABEL_MAP = { "crack": "crack", "Crack": "crack", "隐裂": "crack", "finger_break": "finger_break", "断栅": "finger_break", "black_spot": "black_spot", "黑斑": "black_spot", "edge_damage": "edge_damage", "崩边": "edge_damage", } def normalize_label(raw_name): key = raw_name.strip() if key not in LABEL_MAP: # 遇到没见过的类别名,记下来人工确认,不要静默丢弃 print(f"[WARN] unknown label: {raw_name}") return None return LABEL_MAP[key]参数说明:LABEL_MAP的 key 是 xml 里可能出现的原始写法,value 是最终统一的英文类别名。normalize_label返回 None 表示这个类别没登记过,需要人工介入。光伏项目里类别通常就 5 到 8 类,映射表维护成本很低,但能省掉后面大量排查时间。
3. 用 Python 批量解析 xml 并转成 YOLO 格式
3.1 解析 xml 的最小可用脚本
批量处理几千个 xml,用xml.etree.ElementTree就够,不必上 lxml。下面这个脚本读一个目录下所有 xml,输出 YOLO 格式的 txt:
import os import xml.etree.ElementTree as ET CLASSES = ["crack", "finger_break", "black_spot", "edge_damage", "mixed"] CLASS_TO_ID = {c: i for i, c in enumerate(CLASSES)} def convert_one(xml_path, out_dir): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") w = int(size.find("width").text) h = int(size.find("height").text) lines = [] for obj in root.findall("object"): raw_name = obj.find("name").text cls_name = normalize_label(raw_name) if cls_name is None or cls_name not in CLASS_TO_ID: continue bnd = obj.find("bndbox") xmin = float(bnd.find("xmin").text) ymin = float(bnd.find("ymin").text) xmax = float(bnd.find("xmax").text) ymax = float(bnd.find("ymax").text) if not is_valid_box((xmin, ymin, xmax, ymax), w, h): print(f"[SKIP] invalid box in {xml_path}") continue # YOLO 用归一化的中心点加宽高 cx = (xmin + xmax) / 2.0 / w cy = (ymin + ymax) / 2.0 / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h cls_id = CLASS_TO_ID[cls_name] lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") base = os.path.splitext(os.path.basename(xml_path))[0] out_path = os.path.join(out_dir, base + ".txt") with open(out_path, "w") as f: f.write("\n".join(lines))逻辑说明:先读size拿到原图宽高,再遍历每个object,做类别归一化和坐标校验,最后把绝对坐标转成 YOLO 的归一化中心点格式。cx、cy、bw、bh都除以了w或h,所以值域在 0 到 1 之间。保留 6 位小数是为了避免小目标精度损失——光伏隐裂有时候只有十几个像素宽,小数位太少会引入明显误差。
参数说明:CLASSES的顺序决定了类别 id,一旦定下来就不要改,改了要重新生成所有标签。out_dir要和图片目录分开管理,后面做数据集划分时按文件名配对。
3.2 批量跑目录并统计类别分布
单文件转换跑通后,套一层目录遍历:
import glob def convert_all(xml_dir, out_dir): os.makedirs(out_dir, exist_ok=True) xml_files = glob.glob(os.path.join(xml_dir, "*.xml")) print(f"found {len(xml_files)} xml files") for xp in xml_files: try: convert_one(xp, out_dir) except Exception as e: # 单个文件出错不要中断整批,记下来单独处理 print(f"[ERROR] {xp}: {e}") # 统计每个类别的框数量,判断类别是否失衡 counter = {c: 0 for c in CLASSES} for txt in glob.glob(os.path.join(out_dir, "*.txt")): with open(txt) as f: for line in f: if line.strip(): cid = int(line.split()[0]) counter[CLASSES[cid]] += 1 print("class distribution:", counter)逻辑说明:glob拿到所有 xml,逐个转换,用 try/except 保证一个坏文件不会让整批停掉。转换完统计每个类别的框数,这一步很关键——光伏数据里隐裂可能占 70%,崩边可能只有 2%,类别极度失衡时直接训练会让模型偏向多数类。
参数说明:xml_dir是标注目录,out_dir是输出 txt 目录。统计结果里如果某一类少于总框数的 1%,要么补标注,要么在训练时用重采样或 focal loss 处理,别指望模型自己学会。
3.3 划分训练验证集:按电池片而不是按图
光伏产线一个电池片可能拍多张图(不同角度或不同曝光),如果按图随机划分,同一个电池片的图可能同时出现在训练集和验证集,验证指标会虚高。正确做法是按电池片 id 分组划分。
import random from collections import defaultdict def split_by_cell(txt_dir, img_dir, val_ratio=0.2): # 假设文件名格式为 cellid_xxx.txt,取 cellid 作为分组键 groups = defaultdict(list) for txt in glob.glob(os.path.join(txt_dir, "*.txt")): base = os.path.splitext(os.path.basename(txt))[0] cell_id = base.split("_")[0] groups[cell_id].append(base) cell_ids = list(groups.keys()) random.seed(42) random.shuffle(cell_ids) n_val = int(len(cell_ids) * val_ratio) val_cells = set(cell_ids[:n_val]) train_list, val_list = [], [] for cid, bases in groups.items(): target = val_list if cid in val_cells else train_list for b in bases: target.append(b) return train_list, val_list逻辑说明:先按文件名前缀聚合成电池片分组,再对分组打乱后切分,保证同一电池片的所有图只进一个集合。random.seed(42)固定随机种子,方便复现。
参数说明:val_ratio一般取 0.15 到 0.2,光伏数据量小时可以取 0.25。如果产线有明确的时间划分(比如按天),优先按时间切,避免数据泄漏。
4. 避坑与排查:xml 转 YOLO 最常见的 5 个翻车点
4.1 图片和标签对不上号
现象:训练启动时报No labels found或大量图片被跳过。
原因:图片是.jpg,xml 里filename写的是.png;或者转换后 txt 的文件名和图片名差了一个前缀。光伏数据经常从不同设备导出,命名规则不统一。
解决:转换后做一次配对检查,遍历图片目录,确认每个图片都有同名 txt:
def check_pairing(img_dir, txt_dir): imgs = glob.glob(os.path.join(img_dir, "*.jpg")) missing = [] for img in imgs: base = os.path.splitext(os.path.basename(img))[0] if not os.path.exists(os.path.join(txt_dir, base + ".txt")): missing.append(base) print(f"missing labels: {len(missing)}") return missing4.2 坐标没归一化就喂给 YOLO
现象:训练 loss 一开始就很大,或者模型完全学不动。
原因:把 xml 里的绝对像素坐标直接写进 txt,没有除以宽高。YOLO 期望的是 0 到 1 的归一化值,绝对坐标会被当成超大框。
解决:转换脚本里强制检查,任何cx、cy、bw、bh超过 1.0 就报警:
if max(cx, cy, bw, bh) > 1.0: print(f"[WARN] unnormalized box in {xml_path}")4.3 空标签文件被当成负样本
现象:验证集 mAP 正常,但实际推理时误检特别多。
原因:有些图片确实没有缺陷,转换后生成了空 txt。YOLO 会把空 txt 当成负样本,这本身没错,但如果空 txt 是因为解析失败产生的,就会把有缺陷的图当成无缺陷训练。
解决:区分「真负样本」和「解析失败」。解析失败时不要写空文件,而是跳过并记录;真负样本才写空 txt。在转换脚本里,如果 xml 里有object但一个都没转成功,要单独报警。
4.4 类别 id 和 data.yaml 不一致
现象:训练能跑,但预测出来的类别名全是错的。
原因:转换脚本里CLASSES的顺序和data.yaml里的names顺序不一致。比如脚本里 crack 是 0,yaml 里 crack 是 2。
解决:把类别定义抽到一个公共文件,转换和训练都从同一个地方读。光伏项目类别少,但一旦错位,排查起来很费时间。
4.5 小目标框在缩放时丢失
现象:隐裂这类细长缺陷在训练时召回率很低。
原因:YOLO 默认输入 640×640,光伏原图 1024×1024 缩下来,十几像素宽的隐裂只剩几个像素,特征几乎没了。
解决:训练时把imgsz提到 1024 或 1280,或者用切片推理(SAHI 那套思路)。另外在转换阶段确认小框没有被误判为无效框丢掉——is_valid_box只检查越界和宽高为正,不检查框的大小,这点要守住。
5. 从标注到训练:数据质量检查与进阶技巧
5.1 训练前必做的三项数据体检
转完格式别急着开训,先做三项检查。第一项是类别分布,用 3.2 里的统计脚本跑一遍,看有没有类别少于 1%。第二项是框的尺寸分布,把bw、bh乘回原图尺寸,统计宽高直方图,光伏隐裂的宽高比通常很极端(细长),如果发现大量接近正方形的框,可能是标注时框错了。第三项是可视化抽查,随机抽 20 张图把框画出来看:
import cv2 def visualize(img_path, txt_path, classes): img = cv2.imread(img_path) h, w = img.shape[:2] with open(txt_path) as f: for line in f: if not line.strip(): continue cid, cx, cy, bw, bh = line.split() cid = int(cid) cx, cy, bw, bh = map(float, (cx, cy, bw, bh)) x1 = int((cx - bw / 2) * w) y1 = int((cy - bh / 2) * h) x2 = int((cx + bw / 2) * w) y2 = int((cy + bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, classes[cid], (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imwrite("check_vis.jpg", img)参数说明:classes是类别名列表,顺序必须和训练配置一致。可视化抽查是性价比最高的一步,很多标注错误肉眼一看就出来了,比看 loss 曲线快得多。
5.2 用 xml 里的 difficult 字段做难例挖掘
Pascal VOC 的 xml 里有个difficult字段,很多标注工具默认写 0,但光伏场景里标注员有时会把模糊的隐裂标成difficult=1。这个字段别浪费,转换时可以单独记录,训练时对这些样本加权,或者在验证时单独看这一子集的指标。
def collect_difficult(xml_dir): hard_samples = [] for xp in glob.glob(os.path.join(xml_dir, "*.xml")): root = ET.parse(xp).getroot() for obj in root.findall("object"): diff = obj.find("difficult") if diff is not None and diff.text == "1": hard_samples.append(os.path.basename(xp)) break return hard_samples逻辑说明:遍历所有 xml,只要有一个difficult=1的框就把这张图记为难例。返回的列表可以拿去单独评估模型表现,也可以作为主动学习的候选集优先补标。
5.3 增量标注时怎么合并新旧 xml
产线跑起来后,标注是持续增加的。新一批 xml 和老数据合并时,最容易出的问题是同一张图被标了两次,或者类别名用了新的写法。我的习惯是维护一个processed_files.txt,记录已经转过的图片名,新数据进来先过滤掉已处理的,再跑转换。类别映射表也要同步更新,遇到新类别名先停下来确认,别让脚本自动放行。
这套流程我在几个光伏 EL 项目里反复用过,最深的教训是:数据侧的检查脚本要写得比模型代码还细。模型换一版可能涨一个点,但一个类别映射错误能让指标直接掉十个点,而且排查起来毫无头绪。把 xml 解析、坐标校验、类别对齐、配对检查这几步固化成脚本,每次新数据进来跑一遍,比事后救火省心得多。希望帮到你。
本文还有配套的精品资源,点击获取