☰
labelimg标注的光伏板XML数据,如何高效转成YOLOv8训练格式
2026/10/5 3:50:14 网站建设 项目流程

简介:光伏板检测数据集面向目标检测与智能运维场景,包含一百二十张光伏板照片、一百二十份标注文件及一百三十七张补充图片,共三百七十七个文件,压缩包约六十六点四三兆字节。标注由LabelImg工具手工完成,标注文件详细记录每个光伏板的边界框坐标、尺寸与类别,可直接接入YOLOv8等目标检测模型的训练与验证流程,免去自行采集与标注的烦琐环节,适合光伏电站巡检、设施监测及遥感识别方向的研究者和开发者使用。压缩包目录结构清楚,照片与对应标注一一匹配,下载后即可按标准数据集格式拆分训练集和验证集,也可用于对比不同检测算法的精度与速度。已有三百四十五人学习下载,资源体积适中,便于离线实验与算法验证。

1. 光伏板数据集与labelimg的xml,能直接喂给yolov8吗

光伏板数据集,用labelimg手动标注,每张光伏板图片配一个xml文件,下载解压就能拿去做yolov8训练——如果你搜到这个标题,多半已经把yolov8环境配置踩过一遍了,现在最缺的是能直接开工的数据。先说结论:这类数据集能省掉你从零标注的几周时间,但别指望解压后直接train。labelimg一般默认导出PASCAL VOC格式的xml,坐标是绝对像素值,而yolov8原生要的是归一化坐标的txt,所以中间那道“xml转txt”的工序谁也跳不过。这套流程适合两类人:一类是做无人机光伏巡检、想尽快验证模型精度的工程师,另一类是刚接触目标检测、想用一份干净数据走通数据链路的新手。接下来我从数据格式说到训练参数,再把你大概率会撞上的几个坑提前摆出来。

2. 读透labelimg的xml:VOC格式里藏着数据质量的一半答案

2.1 先从一张xml读起:VOC标注里到底记了什么

labelimg安装好之后,打开图片目录、选PASCAL VOC格式、画框、保存,这一步操作本身很简单,容易让人忽略的是它写出的xml文件里那些节点的含义。打开任意一张标注过的光伏板xml,结构大致长这样:

<annotation> <folder>pannels</folder> <filename>IMG_0001.jpg</filename> <path>C:/dataset/pannels/IMG_0001.jpg</path> <source> <database>Unknown</database> </source> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <object> <name>solar_panel</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>125</xmin> <ymin>85</ymin> <xmax>1610</xmax> <ymax>920</ymax> </bndbox> </object> </annotation>

几个值得注意的点。<filename>是图片文件名,<path>是标注时这台机器上的绝对路径,换机器之后<path>基本是错的,所以后面对文件做匹配时只信文件名,别信path。<size>里的width和height是原图的宽高,单位像素,不是你在labelimg画框时显示窗口的分辨率,坐标换算全靠这两个值。<bndbox>里存的是左上角xmin/ymin和右下角xmax/ymax,坐标系原点是图像左上角,x向右、y向下。

对光伏板这个场景,<name>的值是最需要警惕的字段。有人标solar_panel,有人标photovoltaic_panel,还有人直接标中文“光伏板”。txt阶段类别会变成数字ID,类别名只在xml和data.yaml里出现,所以如果xml里中文和英文混着来,后面转换和训练时大概率花式翻车。另外<truncated>表示目标是否被截断,<difficult>表示是否难识别,这两个字段在VOC时代用来控制评测逻辑,yolov8训练时是不看的,但转换时建议把difficult=1的框直接丢掉,因为这样的框本身就不适合当训练目标。

看懂了单张xml,下一个问题就是:整个数据集的xml质量是不是都过关。手动标注最典型的问题不是画得歪,而是画漏了、画重了、类别名打错了。

2.2 用脚本批量体检数据集:类别、空标注与越界框

拿到一份“下载即可获取”的光伏板数据集,我一般会先写一段解析脚本把全部xml扫一遍,而不是直接开训练。这一步花不了几分钟,却能把训练时最磨人的低级问题提前暴露出来。这段脚本做的事情很固定:统计类别与数量、检查空标注、检查坐标是否越界或宽高是否为0、核对xml里的文件名是否真能找到对应图片。

import xml.etree.ElementTree as ET from collections import Counter from pathlib import Path xml_dir = Path("annotations") # labelimg输出的xml目录 img_dir = Path("images") # 光伏板图片目录 cls_counter = Counter() total_boxes = 0 for xml_path in xml_dir.glob("*.xml"): tree = ET.parse(xml_path) root = tree.getroot() fname = root.findtext("filename") if not (img_dir / fname).exists(): print(f"[warn] 图片不存在: {fname} <- {xml_path.name}") size = root.find("size") img_w = int(size.findtext("width")) img_h = int(size.findtext("height")) objects = root.findall("object") if len(objects) == 0: print(f"[warn] 空标注: {xml_path.name}") for obj in objects: name = obj.findtext("name").strip() cls_counter[name] += 1 total_boxes += 1 if obj.findtext("difficult") == "1": print(f"[info] difficult框已记录但建议丢弃: {xml_path.name}") box = obj.find("bndbox") xmin = float(box.findtext("xmin")) ymin = float(box.findtext("ymin")) xmax = float(box.findtext("xmax")) ymax = float(box.findtext("ymax")) if xmin < 0 or ymin < 0 or xmax > img_w or ymax > img_h: print(f"[err] 坐标越界: {xml_path.name}, {name}") if xmax - xmin < 5 or ymax - ymin < 5: print(f"[warn] 过小框: {xml_path.name}, {name}") print("类别统计:", cls_counter) print("总框数:", total_boxes)

代码不复杂,但几个检查项都是手动数据集里真实踩过的坑。filename匹配检查解决的是“图片在,但xml里名字对不上”的问题;空标注检查解决的是训练时该图片没有txt、yolo会直接跳过或报错的问题;越界框检查解决的是归一化后坐标出现负值或大于1、导致loss计算出nan的问题。还有一个很容易被漏掉的点:xml里坐标虽然是整数,但某些工具导出时可能写成浮点字符串,这里统一用float()解析就不会崩。

这段脚本跑完,你会得到一份数据集的“体检报告”。如果几百张图里只有一两个warn级别问题,可以修掉继续用;如果空标注和越界框成片出现,我的建议是直接放弃这份数据或者自己补标,别拿脏数据去考验模型。数据的坑在训练时会被放大,不是模型玄学,是脏标注在捣乱。

2.3 看清目录布局:这决定转换脚本怎么跑

labelimg标注完的数据集,目录组织方式常见的有两种:一种是把jpg和xml混在同一个目录里,另一种是分成images/和annotations/两个目录。这两种布局本身没有优劣,但直接影响后面xml转txt脚本的写法。混在同一目录时,脚本要做的是“扫xml,然后在同目录找同名jpg”;分目录时,则要在xml目录里取文件名的stem,然后去images目录拼路径。

yolov8官方推荐的数据布局是images/和labels/分开,训练时data.yaml分别指向两个目录。这个布局不是玄学,而是训练时要按文件名去labels里找对应的txt,目录干净能少很多匹配问题。所以拿到下载的数据集之后,第一步不是解压就训,而是重新整理成下面这个结构:

dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yaml

images/train放训练图片,labels/train放对应的txt标注,文件名必须完全一致,只是后缀不同。val目录同理。这个结构在下一章转换脚本里直接是目标形态,建议你从最开始就按这个标准来,省得后面反复改路径。

3. 把xml喂给yolov8:转换脚本、数据集划分与关键训练参数

3.1 yolov8的标签文件与VOC xml差在哪

yolov8训练时需要的标签文件是纯文本,每一行对应一个目标,格式是class_id cx cy w h,其中cx cy w h分别是目标中心点的x、y坐标和框的宽高,且全部归一化到0到1之间,除以图片本身的宽高。xml里则存的是像素绝对坐标,class_id在xml里是类别名字符串。一句话概括:xml给人看,txt给模型读。

还有一个新手最容易忽略的细节:yolo的class_id从0开始,而xml里没有数字ID,只有类别名,所以转换脚本里必须维护一个“类别名→数字ID”的映射。这个映射的顺序还必须和data.yaml里names列表的顺序严格一致,差了哪怕一位,训练时模型学到的就完全是另一个类别的特征,mAP甚至直接是0。比如转换脚本里把solar_panel映射成了0,data.yaml里names: {0: solar_panel},这就是对的;如果哪一天你在脚本里改了映射顺序,忘了同步yaml,这顿训练基本就白跑了。

3.2 把xml转成yolo格式:一份可直接复制的转换脚本

下面这段脚本是把VOC xml转成yolov8 txt的核心逻辑,也是这套流程里最能直接抄的部分。完整做的事有:读取xml里的图片宽高和每个框的坐标,将像素坐标归一化成中心点和宽高,按类别映射表转成数字ID,最后写入与图片同名的txt文件。

import xml.etree.ElementTree as ET from pathlib import Path XML_DIR = Path("annotations") # labelimg输出的xml目录 IMG_DIR = Path("images") # 原始图片目录 OUT_LABEL_DIR = Path("labels_all") # 转换后的txt输出目录 OUT_LABEL_DIR.mkdir(exist_ok=True) CLASS_MAP = { "solar_panel": 0, "photovoltaic_panel": 1, } def parse_xml(xml_path): tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.findtext("size/width")) img_h = int(root.findtext("size/height")) boxes = [] for obj in root.findall("object"): if obj.findtext("difficult") == "1": continue name = obj.findtext("name").strip() box = obj.find("bndbox") xmin = float(box.findtext("xmin")) ymin = float(box.findtext("ymin")) xmax = float(box.findtext("xmax")) ymax = float(box.findtext("ymax")) boxes.append((name, xmin, ymin, xmax, ymax)) return img_w, img_h, boxes def convert_one(xml_path): img_w, img_h, boxes = parse_xml(xml_path) txt_path = OUT_LABEL_DIR / (xml_path.stem + ".txt") lines = [] for name, xmin, ymin, xmax, ymax in boxes: if name not in CLASS_MAP: print(f"[skip] 未定义类别: {name} in {xml_path.name}") continue cx = (xmin + xmax) / 2 / img_w cy = (ymin + ymax) / 2 / img_h bw = (xmax - xmin) / img_w bh = (ymax - ymin) / img_h if cx < 0 or cy < 0 or bw <= 0 or bh <= 0 or cx > 1 or cy > 1: print(f"[warn] 越界框丢弃: {xml_path.name}") continue lines.append(f"{CLASS_MAP[name]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") txt_path.write_text("\n".join(lines) + "\n") for xml_path in XML_DIR.glob("*.xml"): convert_one(xml_path)

几个参数值得说清楚。CLASS_MAP是类别映射,建议先跑一遍上一章的统计脚本,把所有出现过的类别名列出来,再手动整理这个字典,不要在没看过数据分布的情况下闭眼写。difficult=1的框直接丢,理由上一章说过,这类框大多是遮挡严重或者标注拿不准的,保留只会给loss添加噪声。归一化公式本身不复杂,但注意分母是原图宽高,不是缩放后的训练尺寸,这一步写错后面yolov8会报标签范围错误或出现莫名其妙的漏检。

转换完成之后,还需要做数据集划分。yolov8官方做法是把图片按比例分成train和val,然后把对应txt也分进相同目录。脚本如下:

import random import shutil from pathlib import Path IMG_DIR = Path("images") LABEL_DIR = Path("labels_all") TRAIN_RATIO = 0.8 SEED = 42 random.seed(SEED) all_files = sorted(IMG_DIR.glob("*.jpg")) random.shuffle(all_files) split_idx = int(len(all_files) * TRAIN_RATIO) train_files = all_files[:split_idx] val_files = all_files[split_idx:] for split_name, file_list in [("train", train_files), ("val", val_files)]: img_out = Path(f"images/{split_name}") label_out = Path(f"labels/{split_name}") img_out.mkdir(parents=True, exist_ok=True) label_out.mkdir(parents=True, exist_ok=True) for img_file in file_list: shutil.copy(img_file, img_out / img_file.name) txt_file = LABEL_DIR / (img_file.stem + ".txt") if txt_file.exists(): shutil.copy(txt_file, label_out / txt_file.name) else: print(f"[warn] 缺少标签: {img_file.name}")

SEED固定随机种子,保证每次划分结果一致,方便复现。TRAIN_RATIO取0.8,对几百张的小数据集来说,val取20%已经足够看模型表现。划分后如果发现val目录里图片特别少,比如只有几十张,别急着换比例,先确认原始数据是否均衡覆盖了不同光照条件、不同拍摄角度。小数据集上val的分布比数量更重要。

数据整理好后,data.yaml按下面写:

path: /absolute/path/to/dataset train: images/train val: images/val names: 0: solar_panel 1: photovoltaic_panel

path用绝对路径,这是我在Windows和Linux上来回切换后得到的血泪经验,相对路径在yolov8里偶尔能跑、偶尔报“dataset not found”,排查起来非常费时间。names的顺序必须与转换脚本里CLASS_MAP的value一致,这是整个链路里最容易出错、也最不容易被察觉的地方。

3.3 三个必调训练参数:epochs、batch与imgsz

数据准备好了,训练命令本身不复杂,前提是你已经把yolov8环境配置好,pip install ultralytics那套做完,然后跑:

yolo detect train \ data=data.yaml \ model=yolov8s.pt \ epochs=60 \ batch=16 \ imgsz=640 \ project=runs \ name=pv_solar

model=yolov8s.pt是从官方预训练权重继续训练,比从零开始收敛快得多。光伏板这个目标的特点是:外形规整、纹理重复、背景相对干净,yolov8s这个档位在几百张数据量上足够用。如果数据集只有两三百张,甚至直接上yolov8n,训练更快,精度差距不会大到哪里去。

三个参数里,epochs起步给60,配合yolov8的early stopping机制,当val loss连续多轮不下降时会自动停。batch受显存约束,如果你手头是GTX 1660Ti这种6G显存的卡,batch=16配imgsz=640是安全的;把imgsz提到1280,batch要降到4或8,不然显存直接爆掉。imgsz对光伏板这类目标来说很关键,航拍图里单块光伏板在原图上可能只占几十像素,640的训练尺寸下采样后只剩几个像素,特征是保不住的,所以条件允许时建议直接上imgsz=1280。

训练结束后看runs/pv_solar/weights/目录下的best.pt和last.pt。best.pt是验证集上表现最好的那一轮权重,last.pt是最后一轮的权重。习惯上拿best.pt做后续的验证和部署,这个选择不是玄学,而是因为deep learning训练后期常常出现val loss回升的过拟合,best能帮你避开最后几轮的噪音。

4. 训练排查与避坑:数据侧最容易翻车的五个现场

4.1 loss一上来就是nan,训练白跑

现象:第一个epoch的train_loss直接显示nan,或者跑了几十步后突然变成nan,后面再也回不来。

原因:xml里藏着一个或多个脏框。常见的是某个框的xmin填了负数、xmax比ymax还小、或者某个坐标值写成了0,归一化之后会出现负数、0或者超过1的值,模型的反向传播把这些异常值一放大,loss就直接炸了。

解决:在转换脚本里加一道过滤。cx、cy、bw、bh任何一个数值超出0到1范围就直接丢弃这个框,而不是强行clamp到边界。宁缺毋滥,脏框丢掉,模型反而更稳。如果你在转换前跑过2.2节那段检查脚本,这个坑基本能在训练前就被拦住。

4.2 mAP一直是0,类别映射错了你都不知道

现象:loss正常下降,train过程看着一切正常,但val的mAP50从第一个epoch到最后一个epoch都是0,precision也起不来。

原因:转换脚本里的类别ID和data.yaml里的names顺序不一致。比如xml里类别名叫photovoltaic_panel,转换时字典里写成了1,但data.yaml里names: {0: photovoltaic_panel},模型把ID 1当成了不存在的第二类,最终结果就是什么都学不会。

解决:整个链路统一类别映射。最稳的做法不是人肉维护两个文件,而是让脚本直接生成data.yaml,用同一个CLASS_MAP写txt和names。脚本生成一个字典,转换成txt时用同名key查ID,再把这个字典原样写成yaml的names段,顺序天然一致。

4.3 图片找不到、标签错位:文件名才是唯一的锚点

现象:训练中途报FileNotFoundError,或者模型在推理时框出来的位置跟实际目标完全不重合,肉眼看着像“标注没对准”。

原因:xml里的<filename>和<path>是labelimg在标注机器上写死的,换机器后path基本都是失效的。更隐蔽的坑是有些数据集里图片叫IMG_0001.jpg,而标注文件叫IMG_0001.jpg.xml,这种双后缀结构在按stem匹配txt时,会生成IMG_0001.jpg.txt,图片对不上。还有的人重命名图片时只改了jpg,没改xml里的filename字段,导致两侧信息脱节。

解决:转换时一律不信任xml里的filename和path,只用xml本身的文件名xml_path.stem去images目录里找同名jpg。双后缀的xml先批量改名为去掉.jpg的格式,再跑转换。图片与标签配对这件事,文件名一致才算数,别的都不算。

4.4 光伏板小目标全漏检:imgsz和标注粒度的问题

现象:大块的光伏阵列能检出来,但单块光伏板、远端的小块板子全部漏检,recall低得没法看。

原因:这类数据集的标注对象粒度不统一。有人把整片光伏阵列标成一个框,有人把单块板子标成一个框,模型在训练时就被“一个框该多大”搞糊涂了。另一层原因是imgsz太小,640尺寸下采样32倍,一个在原图里只有60像素的小板子,特征图上的尺寸就2个像素不到,卷积根本学不到有效特征。

解决:先明确业务目标——你要检的是“单块光伏板”还是“整个阵列”,然后按这个目标重新审视标注。如果目标是小板子,imgsz提到1280;显存不够就做滑窗裁剪,把大图切成patch训练。不要指望靠模型自己去分辨不同粒度的目标,数据集没定好,模型怎么调都是错过检。

4.5 验证集很准、实拍全乱:样本偏差拦住了你

现象:val的mAP有0.9,看起来非常好,但换一批现场实拍图,框全乱飘,阴影、斜拍角、灰尘污渍让模型彻底失效。

原因:很多公开光伏板数据集是晴天、正射、光照均匀的图片,模型学到的是“蓝紫色矩形区域”这个表层特征,不是光伏板的本质结构。一旦遇到阴影遮挡或者视角倾斜,这种表层特征就被破坏了。

解决:建一个额外的“硬样本”测试集,专门放阴影、逆光、倾斜视角和夜间红外图,用来当最终验收数据,不要只看val的曲线。另外在训练时把数据增强里的hsv_h、hsv_s、hsv_v和degrees打开,让模型见过更多光照和角度的变化。mosaic增强对光伏板这种重复纹理目标不一定友好,拼图会把阵列的连接处切乱,建议把mosaic概率从默认值调低到0.3再观察效果。

5. 从验证到部署:用一条命令和一个习惯把模型用起来

5.1 一条推理命令,先把置信度阈值调出手感

模型训练完,先别急着导出部署。第一件事是在一批没参与训练的图片上跑推理,观察模型的实际输出:

yolo detect predict \ model=runs/pv_solar/weights/best.pt \ source=test_images/ \ save=True \ conf=0.25 \ iou=0.45

conf=0.25是置信度阈值,低于这个值的框会被丢掉。这个参数的调法很直觉:漏检多就往下调到0.1或0.15,误检多就往上调到0.4甚至0.5。iou=0.45是NMS的去重阈值,两个框重叠超过这个比例就保留置信度高的那个,对光伏板阵列这种密集排布的目标,阈值偏低一点能少丢掉相邻板子的框。先把这组参数在真实图片上调出感觉,再去动训练参数,顺序不要反。

5.2 用results.png判断训练有没有白跑

训练完的runs/pv_solar/目录里有一张results.png,训练损失函数曲线和mAP曲线全在里面。看这张图的顺序是:先看train_loss有没有稳步下降、最后有没有平台期,再看val_loss有没有跟着下降,val_loss如果早早就拐头向上而train_loss还在降,就是过拟合的信号,early stopping会自动处理好。最后看一眼mAP50曲线的走势,val的mAP50能稳定在0.8以上且没有剧烈抖动,这轮训练就是能用的。

5.3 部署到rk3588这类边缘盒子前,先导出ONNX

如果后续要把模型部署到rk3588、Jetson这类边缘设备上,PyTorch权重不能直接用。常见做法是先导出ONNX,再做后续的量化或格式转换:

yolo export model=runs/pv_solar/weights/best.pt format=onnx opset=12

导出后注意两点。一是ONNX的输入尺寸会固定成训练时的imgsz,推理时如果传入不同尺寸的图,预处理里的letterbox必须和训练时保持一致,否则坐标会整体偏移。二是边缘设备上做INT8量化时,校准集不要随便从训练集里抽,用val目录下的图片更客观,避免量化误差被训练集过拟合掩盖。我最早一次部署到rk3588时图省事,直接用训练集的子集做校准,结果模型在板子上精度掉了快10个点,换成val集重新校准才恢复正常。

拿到任何下载的数据集,我现在的第一反应不是开train.py,而是先花十分钟把2.2节的统计脚本跑一遍。这个习惯是实实在在踩坑换来的:有一回我图快直接训练,一个五百多张的光伏板数据集里混着几十张空xml和十几个越界框,第一次跑训练到中途loss变成nan,排查了半天才发现是数据的问题,白白浪费一个下午。先看数据,再碰训练,最后看曲线,这套顺序下来,你踩的坑会少很多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询