简介:面向计算机视觉数据预处理的源代码与配套文档,专门解决将VOC格式数据集的XML标注转换为YOLO模型所需的TXT文本,并同步完成训练集与测试集的随机划分,适用于目标检测方向的大学生课程设计、期末大作业、毕业设计,也可作为算法工程师日常实验中的批量提效工具。整个资源压缩包体积仅有2KB,非常轻量,内部包含两个Python脚本:一个脚本负责解析VOC标注文件并生成归一化后的YOLO标签,另一个脚本根据预设比例把图像和标签一同分割为训练子集与测试子集,两个脚本都不依赖任何第三方库,可直接运行。当前已有560人学习或下载,从侧面反映出该小工具在数据准备阶段的真实需求。代码采用参数化编程方式,路径、划分比例、随机种子等关键参数均可自由调整,注释与输出信息非常完善,并附带运行结果,便于初学者反复对照;通过研读代码,可以直观理解标注坐标的归一化过程、类别映射表的设计以及数据划分时的文件遍历逻辑,从而减少重复造轮子,提高项目启动效率。所有代码均经过多次运行测试,结果稳定,适合直接嵌入实际工作流。
1. 为什么每个训练YOLO的人都要先过这一关:VOC转YOLO的烦恼
在labelimg里吭哧吭哧标了一晚上目标框,存出来全是VOC格式的xml,结果打开yolov8的官方教程一看,人家要的是每个图片配一个txt、每行五列数字的YOLO标注,还有一个只有训练集和验证集的文件夹结构。网上代码一搜,十个脚本九个跑不通,剩下的那个还要手工改一堆绝对路径。这篇就讲清楚VOC格式数据集转换成YOLO格式并且分割训练集与测试集这件事的完整做法:两种格式的坐标换算关系、一个改改就能用的转换脚本,以及分割数据集时最容易踩的坑。适合手里有VOC标注、准备用yolov8或yolov5训练自己的目标检测模型,但不想在数据格式上耗一晚上的开发者。
2. 两种格式到底差在哪:先看懂再写转换代码
很多人把格式转换当成黑匣子,脚本能跑就万岁了,结果训练出来的模型要么类别全错,要么框全飞了。其实VOC和YOLO的数据结构没有半点玄学,就是两种不同的存法,搞懂换算关系,转换脚本就是几十行的事。
2.1 VOC的xml和YOLO的txt各存了什么:一张图把数据结构对齐
VOC格式下,每一张图片对应一个同名的xml文件,里面用<bndbox>记录目标的真实像素坐标。核心字段是xmin、ymin、xmax、ymax,分别对应目标框左、上、右、下的像素位置,外加一个<name>记录目标类别名,比如person、car。
YOLO格式下,每一张图片对应一个同名的txt文件,每一行对应一个目标:
class_id x_center y_center width height其中class_id是从0开始的整数,x_center、y_center是目标框中心点相对于图片宽高的比例,width和height是目标框宽高相对于图片宽高的比例。注意,这四个数值全部归一化到0到1之间,跟图片的绝对像素大小无关。
坐标系上,两种格式的原点都在图片左上角,x轴向右、y轴向下,所以不需要翻转坐标轴。转换公式就是简单的算术:
x_center = (xmin + xmax) / 2 / img_width y_center = (ymin + ymax) / 2 / img_height width = (xmax - xmin) / img_width height = (ymax - ymin) / img_height类别名的处理更关键。VOC里是一个字符串,YOLO需要的是整数索引,这个索引由你定义的类别映射表决定。比如你想让模型识别人和车,那就要明确{"person": 0, "car": 1},转换时把name查表换成数字。这个映射表对应后面YOLO训练配置里的names列表,顺序必须完全一致,否则训练出来的模型类别就全错位了。
提示:写类别映射表时,先把项目最终需要检测哪些类定死,中途加类会让已经转换好的txt全部作废,又得重新来一遍。
2.2 用Python把VOC转成YOLO:核心转换脚本与参数说明
弄清楚关换算关系,脚本就是顺理成章的事。下面这份是我常用的转换脚本,基于Python标准库加Pillow,不需要安装额外依赖(Pillow装一下就行),直接复制到项目里,改三个路径和一个类别表就能跑。
import os import xml.etree.ElementTree as ET from pathlib import Path from PIL import Image def voc2yolo(xml_path, img_path, class_map): """ 把单个VOC xml文件转换为YOLO格式的标注文本行。 参数: xml_path: VOC标注文件路径 img_path: 对应的图片路径,用来读取真实宽高 class_map: 类别名到索引的映射字典,例如 {"person": 0, "car": 1} 返回: list[str] 每个元素是YOLO格式的一行标注文本 """ with open(xml_path, "r", encoding="utf-8-sig") as f: tree = ET.parse(f) root = tree.getroot() # 从xml里读取标注框,注意用iter而不是findall, # 因为有的工具导出xml会套多层object层级 boxes = [] for obj in root.iter("object"): name = obj.find("name").text if name not in class_map: print(f"[跳过] {xml_path.stem} 里有不认识的类别: {name}") continue bndbox = obj.find("bndbox") xmin = float(bndbox.find("xmin").text) ymin = float(bndbox.find("ymin").text) xmax = float(bndbox.find("xmax").text) ymax = float(bndbox.find("ymax").text) boxes.append((class_map[name], xmin, ymin, xmax, ymax)) # 读取图片宽高 with Image.open(img_path) as img: img_width, img_height = img.size lines = [] for class_id, xmin, ymin, xmax, ymax in boxes: # 中心点和宽高的归一化公式 x_center = (xmin + xmax) / 2.0 / img_width y_center = (ymin + ymax) / 2.0 / img_height width = (xmax - xmin) / img_width height = (ymax - ymin) / img_height # 6位小数足够训练使用,太多位反而让文件体积变大 lines.append(f"{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}") return lines def batch_convert(xml_dir, img_dir, out_dir, class_map): """ 批量转换文件夹内所有VOC xml为YOLO txt,输出到out_dir。 """ xml_dir = Path(xml_dir) img_dir = Path(img_dir) out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) xml_files = list(xml_dir.glob("*.xml")) if not xml_files: print("xml目录为空,请检查路径") return success = 0 for xml_file in xml_files: stem = xml_file.stem img_path = None # 优先尝试常见的图片后缀 for suffix in (".jpg", ".jpeg", ".png", ".bmp"): candidate = img_dir / (stem + suffix) if candidate.exists(): img_path = candidate break if img_path is None: print(f"[警告] {stem}.xml 找不到对应图片,跳过") continue lines = voc2yolo(xml_file, img_path, class_map) if not lines: print(f"[警告] {stem}.xml 没有可用的标注框,跳过") continue # 写入txt,文件名与xml保持一致 out_path = out_dir / (stem + ".txt") with open(out_path, "w", encoding="utf-8") as f: f.write("\n".join(lines)) success += 1 print(f"转换完成:成功 {success} 个,失败 {len(xml_files) - success} 个") if __name__ == "__main__": # 把这三行改成你自己的路径即可 XML_DIR = "VOCdevkit/VOC2007/Annotations" IMG_DIR = "VOCdevkit/VOC2007/JPEGImages" OUT_DIR = "yolo_labels" CLASS_MAP = {"person": 0, "car": 1, "bicycle": 2} batch_convert(XML_DIR, IMG_DIR, OUT_DIR, CLASS_MAP)逻辑说明:脚本先遍历xml目录里的所有xml文件,逐个解析后查找对应图片,读取真实宽高,再套用中心点和宽高的归一化公式生成一行标注文本。顺序上先找图片后换算,因为换算依赖图片的真实宽高。最终输出的txt文件名和xml文件名一致,这样之后和图片配对不会乱。
几个关键参数要解释一下。xml_path传的是单个VOC标注文件,img_path是对应的图片路径,二者必须能对得上,脚本里对缺失图片做了跳过处理。class_map里的值决定YOLO训练时的类别索引,这个映射表要和后续的data.yaml里的names顺序保持一致。另外用utf-8-sig读取xml,能处理一些标注工具导出的文件带BOM头导致解析报错的情况。
跑完这个脚本,你会得到一堆txt,每个txt里每行五个数字。先不要急着下一步,打开几个txt和原始图片对一下,确认数字看起来在合理范围内。这一步花不了两分钟,但能提前拦住八成错误。
3. 分割训练集与测试集:随机、干净、可复现
转换完格式之后就是数据分割了。别小看这一步,很多人直接把这个环节做成shutil.copyfile的搬运工,复制完才发现训练集里没有某些小类别的样本、验证集里缺了一个类,训练半天指标虚高。设计分割方案时,有三个要点值得先想清楚。
3.1 分训练集和测试集不只是复制文件:三个设计要点
第一个要点是随机种子的确定性。日常用random.shuffle加random.seed(42)固定随机序列,保证每次跑出来分割结果一致,方便复现和排错。
第二个要点是分层抽样。YOLO数据集的类别往往是不均衡的,某些类别只有几十个样本,如果纯随机划分,小类别的全部样本有可能都落在训练集里,验证集里一个都没有。训练时这个类别在验证集上的指标要么虚高(因为验证集里没有真值可算),要么直接异常。所以分割之前先统计一下每张图片包含哪些类别,再按类别维度做比例控制,保证每个类别的样本在训练集和验证集里都有合理占比。
第三个要点是文件组织方式。YOLOv5和YOLOv8都支持标准的目录结构,即images/train、images/val、labels/train、labels/val四个目录。有些老教程还会让你生成train.txt和val.txt文件列表,新版YOLO早就不是必需的了。我用的是目录方式,因为更直观,yolov8和yolov5都认。如果你有大量数据需要多次读取,用硬链接而不是复制能省一半磁盘空间。
3.2 分割脚本:按比例切分并生成YOLO训练所需的文件清单
下面这个脚本读取上一步转换好的images和labels两个目录,按比例分割后复制到标准目录结构里。默认85%做训练集、15%做验证集,你可以按自己的数据量调整。
import random import shutil from pathlib import Path def split_dataset(image_dir, label_dir, output_dir, train_ratio=0.85, seed=42): """ 把转换好的YOLO格式数据分割为训练集和验证集目录结构。 参数: image_dir: 存放所有图片的源目录 label_dir: 存放所有txt标注的源目录 output_dir: 输出目录,将在里面生成 images/train, images/val, labels/train, labels/val train_ratio: 训练集占比,默认0.85 seed: 随机种子,固定后保证可复现 """ random.seed(seed) image_dir = Path(image_dir) label_dir = Path(label_dir) output_dir = Path(output_dir) # 收集所有图片名(不含后缀),同时允许图片有多种后缀 image_stems = {} for img_path in image_dir.iterdir(): if img_path.suffix.lower() in (".jpg", ".jpeg", ".png", ".bmp"): image_stems[img_path.stem] = img_path print(f"共找到 {len(image_stems)} 张图片") # 只保留有txt标注的图片,避免把没有标注的文件带进训练集 valid_stems = [] for stem in image_stems: label_path = label_dir / (stem + ".txt") if label_path.exists(): valid_stems.append(stem) missing_label = len(image_stems) - len(valid_stems) if missing_label > 0: print(f"警告:{missing_label} 张图片缺少txt标注,已过滤") # 打乱顺序并按比例切分 random.shuffle(valid_stems) split_idx = int(len(valid_stems) * train_ratio) train_stems = valid_stems[:split_idx] val_stems = valid_stems[split_idx:] print(f"训练集 {len(train_stems)} 张,验证集 {len(val_stems)} 张") # 构建输出目录结构 for split_name, stems in [("train", train_stems), ("val", val_stems)]: img_out = output_dir / "images" / split_name lab_out = output_dir / "labels" / split_name img_out.mkdir(parents=True, exist_ok=True) lab_out.mkdir(parents=True, exist_ok=True) for stem in stems: shutil.copy(image_stems[stem], img_out / image_stems[stem].name) shutil.copy(label_dir / (stem + ".txt"), lab_out / (stem + ".txt")) # 额外生成文件列表,方便某些版本YOLO使用 for split_name, stems in [("train", train_stems), ("val", val_stems)]: list_file = output_dir / (split_name + ".txt") with open(list_file, "w", encoding="utf-8") as f: for stem in stems: f.write(str(image_stems[stem]) + "\n") print("分割完成,目录结构已生成") if __name__ == "__main__": split_dataset( image_dir="dataset/images", label_dir="dataset/labels", output_dir="dataset", train_ratio=0.85, seed=42 )逻辑说明:脚本先扫描所有图片,再和txt标注做配对,只保留“图片和txt都存在”的有效样本,从源头避免把缺标注的文件带进训练流程。然后把有效样本打乱顺序,按train_ratio比例切分成训练集和验证集,最后复制到标准目录结构,同时生成一份train.txt和val.txt的绝对路径列表,以备老版本yolov5使用。
参数上,train_ratio=0.85是常规值,数据量比较小时可以提高到0.9甚至0.95,但验证集太小的话训练指标波动会很大。seed=42固定了随机顺序,下次重跑结果完全一样。如果你有大规模数据集,把shutil.copy换成os.link硬链接可以避免复制文件的IO开销,前提是源目录和输出目录在同一个磁盘分区。
这套分割脚本跑完,项目的目录结构会是这样:
dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ ├── train.txt └── val.txt其中labels和images两棵树完全同步,同名文件的txt和图片一一对应。这个结构是yolov8直接认的,也是yolov5官方推荐的目录风格。到这一步,格式转换和数据分割就都完成了,接下来就是把这份目录交给训练框架去跑。
4. 从零跑通整条链路:VOC目录到YOLOv8开训
数据分割完成后,整条链路还差最后两步:写一个数据集配置文件data.yaml,然后启动训练。这一章把从VOC原始目录到yolov8真正跑起来的过程串一遍,每一步要做什么、文件放哪里、命令怎么敲,都给出可直接复制的版本。
4.1 整理VOC原始目录:先统一素材再动手
很多人的VOC数据集是从网络上东拼西凑的,有的目录叫JPEGImages,有的是VOCdevkit/VOC2007/JPEGImages,还有的是从别人的项目里拷来的散装文件夹。第一步不是急着转换,而是把所有图片和xml文件分别汇总到两个干净目录里,避免在转换脚本里写一长串难维护的绝对路径。
我一般会建一个工作目录叫dataset_raw,里面分images和annotations两个子目录。用下面这段命令把散落的文件归拢:
# 把所有jpg/png图片汇总到 dataset_raw/images mkdir -p dataset_raw/images dataset_raw/annotations find . -type f \( -name "*.jpg" -o -name "*.png" -o -name "*.jpeg" \) -exec cp {} dataset_raw/images/ \; # 把所有xml汇总到 dataset_raw/annotations find . -type f -name "*.xml" -exec cp {} dataset_raw/annotations/ \;逻辑说明:find配合-exec cp把分散在子目录里的文件都复制到统一目录里。完成后检查一下两个目录里的文件数量,如果数量和预期差很多,多半是有重名文件互相覆盖了,先处理再继续。
这一步不强求用代码脚本,手动在文件管理器里整理也行,但用命令更省事,尤其是文件特别多的时候。整理完的目录里只做一件事:检查是否有文件名重复但内容不同的情况,比如两个000001.jpg来自不同数据集,这会直接导致转换和训练时互相覆盖。
4.2 编写data.yaml:YOLO训练的数据入口
yolov8训练自己的数据集时,靠一个yaml文件描述数据和类别信息。这个文件放在哪里都行,但通常放在和dataset平级的位置,写绝对路径比较容易踩坑,我习惯用相对路径让项目整个目录可以整体迁移。
在dataset目录下新建data.yaml:
# 训练集和验证集图片所在目录(相对路径基于当前工作目录) train: images/train val: images/val # 类别数量 nc: 3 # 类别名称列表,顺序必须和转换脚本里的CLASS_MAP一致 names: 0: person 1: car 2: bicycle逻辑说明:train和val指向的是图片目录,不是txt目录。YOLO在训练时会在同级的labels目录下自动找对应标注文件。nc是类别总数,names是类别名列表,索引必须和转换脚本里的CLASS_MAP对齐。这里的顺序一旦不对,模型断训饱和后跑出来的结果就是类别语义全错位,排查起来很返工。
注意:有些教程里会把
train写成绝对路径,比如/home/user/dataset/images/train。换机器之后还得改,直接用相对路径更省心,前提是你把训练命令的工作目录设成dataset的上一级。
4.3 启动训练:常用命令与参数调优入口
yolov8和yolov5在训练数据导入上逻辑一样,只是命令格式不同。下面把两个版本都列出来,你在哪个环境就用哪套。yolov8需要先装好ultralytics包和对应的PyTorch环境,正式开跑之前至少确认import torch和import ultralytics都不报错。
yolov8命令:
yolo detect train data=data.yaml model=yolov8s.pt epochs=100 batch=16 imgsz=640yolov5命令(在yolov5仓库目录下执行):
python train.py --data ../dataset/data.yaml --weights yolov5s.pt --img 640 --epochs 100 --batch 16两个命令的语义基本一致:data指向yaml文件,model或weights指定预训练权重,epochs控制迭代轮数,batch按显存调整,imgsz或img是训练分辨率。如果是小尺寸目标,建议imgsz=640起步,想更稳就上1280,但显存占用会明显增加。
首次训练建议先用最小的yolov8n.pt或yolov5s.pt跑通整个流程,确认数据加载、标注读取、loss计算都没问题,再换大模型。数据格式上的错误往往在第一个epoch就暴露出来,用最小模型能帮你最快发现问题。看到进度条正常前进、每轮loss下降,再考虑正式训练。
从VOC原始目录到这一步,整个过程已经全部跑通了。后面的路就是训练和调参了,这篇的重点是格式与分割,所以训练细节点到为止。
5. 避坑指南:数据转换最常见的五个翻车现场
格式转换和数据分割涉及大量文件操作和数学计算,任何一个环节出错都会让训练结果变得莫名其妙。下面这五个坑都是我实际见过甚至自己踩过的,按“现象、原因、解决”的顺序逐一说明,希望你能在运行脚本之前就避开。
5.1 类别索引从0还是从1开始:错位得让人毫无察觉
现象:转换脚本跑完,打开一个txt看到第一列全是1、2、3,训练时loss正常下降,但最后用模型预测,把person识别成了car。
原因:labelimg里界面显示的类别标签从1开始,很多人习惯性地把类别写成{"person": 1, "car": 2},但YOLO的类别索引必须从0开始。索引错一位,所有类别语义整体偏移。
解决:写转换脚本时,在voc2yolo内部加一条断言,检查所有class_id是否落在[0, len(class_map))区间内。如果发现从1开始,脚本直接报错而不是默默写进txt。一个简单的做法是转换后用下面的脚本快速检查所有txt里出现的类别索引:
cat yolo_labels/*.txt | awk '{print $1}' | sort -n | uniq输出结果应该是从0开始的连续整数序列。如果最小的是1,说明映射表写错了。
5.2 归一化时除错了分母:坐标直接飞出画布
现象:转换后的txt里width或height大于1,甚至出现1.3741这种明显越界的数字。训练时模型无法收敛,或者画框全画到图片外面。
原因:公式里x相关的值要用图片宽度做分母,y相关的值要用图片高度做分母。有人图省事,统一除以图片宽度,或者把xmax - xmin除以了图片高度,结果框的宽高比例全错。
解决:在写入txt之前加一个检查,所有归一化后的值必须落在[0, 1]区间内,出现越界立即报错。下面这行代码可以在写入前过滤掉异常数据:
if not (0 <= x_center <= 1 and 0 <= y_center <= 1 and 0 <= width <= 1 and 0 <= height <= 1): raise ValueError(f"归一化结果越界: {x_center}, {y_center}, {width}, {height}")这条检查能保住一大堆后续问题,别偷懒不加。
5.3 图片和xml文件不对齐:训练到一半才报错
现象:转换脚本提示“找不到对应图片”,或者在yolov8训练到中途时直接报错“Can't find image: xxx.jpg”。
原因:数据来源比较复杂,有的xml标注了但同名图片被删了,有的图片文件名多了空格或后缀大小写不一致。转换脚本按stem去匹配文件名,遇到大小写混用或者后缀不匹配就落空。
解决:在转换脚本的batch_convert里做两件事。第一,列出所有xml和所有图片的文件名做交集,只转换两边都存在的文件;第二,使用Path.glob模糊匹配后缀,而不是硬编码.jpg。更省事的做法是,转换前跑一遍文件对比,先把缺图片的xml或缺xml的图片移到一个orphans目录,别让它们混在数据集里。
5.4 分割时没考虑类别均衡:验证集里缺了一个类
现象:训练结束后,某个类别的验证集mAP是0,但训练集的loss表现正常,检查代码逻辑也没错。
原因:分割时纯随机打乱,小类别样本数量少,恰好全部落在了训练集里,验证集一个样本都没有。验证集没有真值,这个类别在评估时自然得0分。
解决:写分割脚本时增加分层抽样逻辑,先按每张图片包含的类别做统计,再在切分时确保每个类别在训练集和验证集都保留一定比例。最简单粗暴的办法是,每个类别最少留出10%的样本到验证集,剩余再放回随机池子里。用5.1的检查方法,在分割完成后按类别统计一下训练集和验证集的样本数分布,大致均匀就可以放心用了。
from collections import Counter val_counter = Counter() for txt_path in Path("dataset/labels/val").glob("*.txt"): with open(txt_path) as f: for line in f: if line.strip(): val_counter[int(line.split()[0])] += 1 print("验证集类别分布:", val_counter)5.5 xml文件编码或格式异常:解析时莫名其妙崩
现象:转换脚本跑到一半抛ParseError,或者某个xml解析出来name是乱码。
原因:部分打标工具导出的xml文件带了BOM头(文件开头多了几个不可见字符),或者某个环节用记事本手动编辑过,留下了奇怪的编码。ET.parse默认按UTF-8解析,遇到BOM头直接报错。
解决:在读取xml时用encoding="utf-8-sig"代替默认编码,这个编码会自动吃掉开头的BOM标记。脚本里已经用过这个读法了,如果你自己写的脚本没有,改成下面这样就行:
with open(xml_path, "r", encoding="utf-8-sig") as f: tree = ET.parse(f)如果还想更稳,可以在batch_convert里加上异常捕获,单个xml解析失败时打印文件名并跳过,而不是让整个转换流程中断。这样一次能跑完整个目录,最后看日志统一处理有问题的文件。
6. 转换完先别急着训练:用可视化验证结果
数据转换和分割全部跑完,直接丢给yolov8开训当然可以,但99%的人都忽略了一步:把转换后的txt标注画回图片上,肉眼确认一遍。格式转换的错误在数字上很难看出来,但画到图片上一眼就能发现框没对齐、类别标反、坐标越界这些问题。
6.1 把YOLO格式的标注画回图片上看一眼
下面的脚本随机抽取几张验证集的图片,把txt里的标注框画上去,保存到本地。跑完直接打开看,框应该贴合在目标上,类别名和实际物体一一对应。
import random from pathlib import Path from PIL import Image, ImageDraw def visualize_labels(image_dir, label_dir, class_names, output_dir="vis", num_samples=10): image_dir = Path(image_dir) label_dir = Path(label_dir) output_dir = Path(output_dir) output_dir.mkdir(exist_ok=True) images = list(image_dir.glob("*.jpg")) + list(image_dir.glob("*.png")) random.shuffle(images) for img_path in images[:num_samples]: label_path = label_dir / (img_path.stem + ".txt") if not label_path.exists(): continue img = Image.open(img_path).convert("RGB") draw = ImageDraw.Draw(img) w, h = img.size with open(label_path, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split() if len(parts) < 5: continue cls_id = int(parts[0]) x_center, y_center, box_w, box_h = map(float, parts[1:5]) # 把归一化坐标还原成像素绝对坐标 x1 = int((x_center - box_w / 2) * w) y1 = int((y_center - box_h / 2) * h) x2 = int((x_center + box_w / 2) * w) y2 = int((y_center + box_h / 2) * h) draw.rectangle([x1, y1, x2, y2], outline="red", width=2) draw.text((x1, max(0, y1 - 10)), class_names[cls_id], fill="red") out_path = output_dir / img_path.name img.save(out_path) print(f"保存可视化结果: {out_path}") if __name__ == "__main__": visualize_labels( image_dir="dataset/images/val", label_dir="dataset/labels/val", class_names=["person", "car", "bicycle"], num_samples=10 )逻辑说明:脚本读取一张图片及其同名txt,把归一化的框坐标换算回像素坐标,用PIL画矩形框和类别名,最后保存到vis目录。num_samples=10表示随机抽10张图来看,够用了。如果你发现框明显偏移或者类别名对不上,那一定是前面哪一步出了问题,按第5章的内容排查。
画完框之后还要确认一件事:txt的坐标和图片是否来自同一分辨率。有的图片被resize过,但xml里的坐标还是原始尺寸,画框时就会看到框整体错位。如果有这种情况,需要先统一图片分辨率再转换。
6.2 用数据分布统计来提前判断训练效果
可视化看的是单张图片的准确性,但要评估整个数据集的质量,还需要统计一下类别分布和框尺寸分布。一段很短的脚本就能给出全局数据画像:
import statistics from collections import Counter from pathlib import Path def stat_dataset(label_dir): label_dir = Path(label_dir) class_counter = Counter() box_widths = [] box_heights = [] for txt_path in label_dir.glob("*.txt"): with open(txt_path, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split() if len(parts) < 5: continue cls_id = int(parts[0]) w = float(parts[3]) h = float(parts[4]) class_counter[cls_id] += 1 box_widths.append(w) box_heights.append(h) total = sum(class_counter.values()) print("各类别样本数及占比:") for cls_id in sorted(class_counter): print(f" 类别 {cls_id}: {class_counter[cls_id]} 个框, " f"{class_counter[cls_id] / total:.1%}") print(f"框平均宽占比: {statistics.mean(box_widths):.3f}") print(f"框平均高占比: {statistics.mean(box_heights):.3f}") if __name__ == "__main__": stat_dataset("dataset/labels/val")逻辑说明:这段脚本统计验证集里每个类别的框数量和占比,顺带算一下框的平均宽高占比。如果某个类别只占不到1%,那这个任务很可能存在严重的类别不均衡,训练时要么提升类别的loss权重,要么考虑做数据增强补充样本。框尺寸占比偏小说明目标很小,后续训练要适当调大imgsz或者关注小目标检测层的输出。
我自己的习惯是,每换一个数据集,转换完第一件事不是训练,而是先跑可视化和统计脚本。不眼见为实,总觉得数据集里藏着什么“惊喜”。做了这一步,训练时的心态会稳很多。数据转换和分割这件事本身不难,难的是每个环节都确认到位。希望帮到你,少走这些冤枉路。
本文还有配套的精品资源,点击获取