简介:一套面向YOLOv3目标识别任务的苹果照片数据集及Python处理代码包,旨在服务计算机视觉学习者与目标检测模型训练者,对于正在调试YOLOv3或考虑迁移至其他检测框架的开发者尤其适用。资源内置414张原始苹果图片和828张经过数据增强、尺寸调整与填充处理的预处理图片,同时配有使用labelimg工具生成的预选框标注XML文件;配套Python脚本覆盖数据集整理、格式转换、图像预处理等关键环节,能够直接接入模型训练前的数据准备流程。压缩包共约2000个文件,以jpg图像与xml标注为主体,另含少量txt说明与py代码,整体大小90.81MB,目录结构明晰,方便按原始图像、预处理图像、标注文件分类检索,也便于二次开发。目前已有1074人浏览学习。依托这套VOC2007风格的苹果检测数据集,可以节省大量人工采集与标注时间,进而快速完成YOLOv3模型的训练、验证与效果对比;配合附带脚本,还能灵活调整预处理参数,满足不同实验和论文评测的需求,并为后续模型迭代提供可靠数据基础。
1. 苹果照片数据集配VOC2007:解压之前先想清楚这包数据能用在哪
我拿到过不少这种压缩包,名字越具体,越容易让人忽略一个事实:解压出来不等于能用。苹果照片数据集和python处理代码-VOC2007.zip,从命名就能拆出三层信息:图像内容是苹果照片,标注格式是VOC2007,脚本语言是Python。它解决的是一类常见问题——想训练一个目标检测模型(比如苹果分级、果园机械臂定位、货架商品识别),最难的不是模型结构,而是“有图无标”的冷启动;这份数据把采集和初步标注都做好了,剩下的事情是把标注格式喂进训练框架。需要提醒的是,个人分享的数据集不是官方洗好的产物,压缩包里常混着“文件名对不上”“txt划分缺失”“坐标越界”这类毛病,哪一类都会拦住你的训练脚本。这篇文章就按实际动手的顺序,把格式、处理、转换和避坑一次讲完。
2. 先读懂VOC2007标注格式:从苹果照片XML里提取坐标与类别
很多初入目标检测的工程师拿到压缩包,习惯性直接执行python train.py。这样做十有八九会报错——不是代码版本问题,而是忽略了 VOC2007 既是一套标准目录结构,又是一套 XML 语法约定。苹果照片数据集里的原始图像在 JPEGImages,标注在 Annotations,图片对应关系由 ImageSets/Main 下的 txt 文件给出。这个三角关系比模型骨架更关键,先把它理顺,后面所有处理都不走弯路。
2.1 认识VOC2007标准目录:JPEGImages、Annotations与ImageSets的分工
VOC2007 的顶级目录一般是四类:JPEGImages 存放原始图像,Annotations 存放与图片同名的 XML,ImageSets/Main 存放不带扩展名的文件列表,SegmentationClass 与 SegmentationObject 存放像素级分割结果。这份苹果照片数据集里,前三类是主要“粮食”,分割目录常常被省掉,这不影响检测训练。JPEGImages 里的照片命名大多是apple_001.jpg这种“类别前缀加编号”的形式,Annotations 里的 XML 与之同名,ImageSets/Main 下的 train.txt、val.txt 则按行写这些前缀。
常见的一种错误是写处理脚本时直接遍历 JPEGImages,然后按前缀去对应找 Annotations。多数情况下没问题,但压缩包经过多次整理后,很容易出现某张图片有 JPG 没有 XML,或者反过来有 XML 没有 JPG。更稳妥的顺序是:先以 JPEGImages 为底建立集合,再读 Annotations 得到另一个集合,最后取交集和差集。这一步做在前头,能避免训练时因为空标注列表而莫名其妙丢样本。
另一个容易忽略的点是 ImageSets/Main 里的 txt 文件名。VOC2007 官方数据里同时存在 train.txt、val.txt、trainval.txt、test.txt,其中 trainval 是 train 与 val 的并集。个人分享的数据集常常只放了 train.txt 和 val.txt,有时甚至只有 trainval。你训练前最好先打开看一眼,确认它写的是纯文件名前缀还是带了路径,带了路径的话后续拼接时要再处理一次。
2.2 用xml.etree.ElementTree读取标注:一个能直接跑的解析脚本
Python 解析 XML 的常见做法是xml.etree.ElementTree,它属于标准库,不需要额外安装,处理这种几千个标注文件的场景也够快。下面这个脚本可以单独跑,也可以作为函数被后续转换脚本调用。
import xml.etree.ElementTree as ET def parse_voc_xml(xml_path): """解析VOC2007格式的单个XML标注文件 返回: 图片文件名、尺寸、目标列表(类别+边界框+标志位) """ root = ET.parse(xml_path).getroot() # size节点描述图片宽高和通道数 size = root.find("size") img_width = int(size.find("width").text) img_height = int(size.find("height").text) img_depth = int(size.find("depth").text) objects = [] for obj in root.iter("object"): name = obj.find("name").text.strip() # 这三个标志位可能缺失, 读取时给默认值更安全 truncated = int(obj.find("truncated").text) if obj.find("truncated") is not None else 0 difficult = int(obj.find("difficult").text) if obj.find("difficult") is not None else 0 bndbox = obj.find("bndbox") # 注意: 有些标注工具会写出20.0这种浮点坐标 xmin = int(float(bndbox.find("xmin").text)) ymin = int(float(bndbox.find("ymin").text)) xmax = int(float(bndbox.find("xmax").text)) ymax = int(float(bndbox.find("ymax").text)) objects.append({ "name": name, "truncated": truncated, "difficult": difficult, "bbox": [xmin, ymin, xmax, ymax] }) return { "filename": root.find("filename").text.strip(), "width": img_width, "height": img_height, "depth": img_depth, "objects": objects } if __name__ == "__main__": result = parse_voc_xml("Annotations/apple_001.xml") print(result["filename"], result["width"], result["height"]) for obj in result["objects"]: print(obj["name"], obj["bbox"], "difficult:", obj["difficult"])逻辑说明:root.find("size")拿到的是<size>节点,里面的 width、height 是图片原始尺寸;root.iter("object")会遍历所有<object>节点,比findall("object")更能容忍 XML 里夹带其他嵌套节点。坐标读取时先用float()再转int(),是为了兼容部分标注工具导出浮点坐标;如果直接int("20.0")会抛 ValueError。.strip()处理类别名首尾空格,这个细节后面专门讲,很多训练标签丢失就是它引起的。
2.3 理解truncated与difficult:两个影响训练取值的小布尔位
VOC2007 的每个 object 里都有truncated和difficult两个标志位,旧脚本经常把它们当装饰品直接忽略。truncated表示目标有部分被图像边界截断,difficult表示这个目标对计算机很难辨认,通常是遮挡严重、极小或者光线极差。PASCAL 官方在评测时会排除difficult=1的框,所以转换到 YOLO 时按原意过滤或单独保留 difficult 样本,都不算错;但个人数据集在补标时对这两个位的判定往往很随意,你得自己确认标准。
下面这段代码统计每个类别的 difficult 占比,再决定要不要在转换阶段过滤它。
import os import xml.etree.ElementTree as ET def count_difficult_by_class(xml_dir): """统计每个类别中被标记为difficult的目标总数""" counter = {} for xml_file in os.listdir(xml_dir): if not xml_file.endswith(".xml"): continue root = ET.parse(os.path.join(xml_dir, xml_file)).getroot() for obj in root.iter("object"): name = obj.find("name").text.strip() difficult = int(obj.find("difficult").text or 0) counter.setdefault(name, {"total": 0, "difficult": 0}) counter[name]["total"] += 1 counter[name]["difficult"] += difficult return counter if __name__ == "__main__": stat = count_difficult_by_class("Annotations") for cls, val in stat.items(): print(f"{cls}: total={val['total']}, difficult={val['difficult']}")逻辑说明:obj.find("difficult").text or 0处理标签缺失的情况,防止 None 参与 int() 报错。统计结果里如果某个类别的 difficult 占比超过三成,说明这批标注的“难例”标准不可信,我一般会直接在转换时把 difficult 全部剔除,避免干扰 loss 计算;如果占比很低,保留下来做难例挖掘反而有帮助。
3. 用Python把苹果照片数据集整理成可训练状态:筛选、拆分与统计
这一章把处理代码落到完整流程上:从 JPEGImages 和 Annotations 两端做文件匹配,再重建 train.txt 与 val.txt,最后统计类别分布。经过这三步,数据集才从“能打开”变成“能训练”。我见过太多人在这一步跳过去,结果训练到一半才发现某个类是空的,或者验证集里混进了大量训练图。
3.1 检查图片与标注是否一一对应:把“孤儿文件”揪出来
正常情况是 JPEGImages 里有多少张图,Annotations 里就有多少个同名 XML。实际操作里经常多出来几张没标注的图,或者残留几个没有图的“幽灵”标注。下面脚本把两个目录的差异一次性列出来。
import os def check_annotation_alignment(jpeg_dir, ann_dir): """对比图片和标注文件的文件名前缀,返回缺失或多余的部分""" jpeg_names = { os.path.splitext(f)[0] for f in os.listdir(jpeg_dir) if f.lower().endswith((".jpg", ".jpeg", ".png")) } ann_names = { os.path.splitext(f)[0] for f in os.listdir(ann_dir) if f.endswith(".xml") } missing_ann = sorted(jpeg_names - ann_names) # 有图没标注 missing_jpeg = sorted(ann_names - jpeg_names) # 有标注没图 return missing_ann, missing_jpeg if __name__ == "__main__": no_ann, no_img = check_annotation_alignment("JPEGImages", "Annotations") print(f"缺失标注: {no_ann}") print(f"缺失图片: {no_img}")逻辑说明:用集合的差集而不是列表遍历,数据量到几万张时差距明显。os.path.splitext(f)[0]把扩展名去掉,剩下的前缀作为唯一键。这里有个隐含假设:一张图对应一个同名 XML。如果数据集里存在一张图被拆成多个 XML 的情况,这个脚本会误报,处理前先确认数据结构不要想当然。
缺失标注的图片可以直接移出训练目录;缺失图片的 XML 建议先留着,等确认不是文件名大小写问题再删除。Windows 下 JPG 和 jpg 是两个不同的名字,但在 Linux 里却是两个文件,这种“看起来对得上”的错觉很常见。
3.2 重建ImageSets/Main训练验证名单:去重并检查互斥
VOC2007 的 txt 文件内容本质是文件名前缀,一行一个。个人数据集里常见的坑是 train.txt 和 val.txt 有交集,也就是同一张图既参与训练又参与验证,这对模型评估来说是无效的,测试指标会虚高。下面脚本读入两个文件并检查冲突。
def read_name_list(txt_path): """读取VOC的txt文件,返回去重后的文件名前缀列表""" names = [] with open(txt_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue # 容忍txt里可能带的.jpg后缀 base = line[:-4] if line.lower().endswith(".jpg") else line names.append(base) return list(set(names)) def check_train_val_conflict(train_txt, val_txt): train_names = read_name_list(train_txt) val_names = read_name_list(val_txt) conflict = sorted(set(train_names) & set(val_names)) print(f"训练集: {len(train_names)}, 验证集: {len(val_names)}, 冲突: {len(conflict)}") if conflict: print("冲突样例:", conflict[:10]) return conflict if __name__ == "__main__": check_train_val_conflict("ImageSets/Main/train.txt", "ImageSets/Main/val.txt")逻辑说明:读取时先用strip()去掉换行和首尾空格,空行直接跳过。后缀裁剪只处理.jpg,如果你遇到的 txt 里写的是绝对路径或相对路径,这个脚本就不能直接用,需要先按路径分隔符取最后一段再裁剪。返回值是冲突列表,后续处理建议直接在 val.txt 里删除这些前缀,而不是动训练集;训练集样本更多,保留它的完整性更重要。删除后重新统计,确认两边互斥再进入下一步。
3.3 统计类别框数量与尺度分布:提前发现类不平衡和小目标难题
决定模型能不能收敛的第一步不是选网络结构,而是看数据本身的分布。下面代码统计每类目标数量和边界框尺寸的极值,方便你判断是否需要过采样或裁切。
import os import xml.etree.ElementTree as ET def stat_bbox_statistics(xml_dir): """统计数据集里所有框的类别数量与尺寸范围""" stats = {} for xml_file in os.listdir(xml_dir): if not xml_file.endswith(".xml"): continue root = ET.parse(os.path.join(xml_dir, xml_file)).getroot() size_node = root.find("size") img_w = int(size_node.find("width").text) img_h = int(size_node.find("height").text) for obj in root.iter("object"): name = obj.find("name").text.strip() box = obj.find("bndbox") xmin = int(float(box.find("xmin").text)) ymin = int(float(box.find("ymin").text)) xmax = int(float(box.find("xmax").text)) ymax = int(float(box.find("ymax").text)) box_w = xmax - xmin box_h = ymax - ymin area_ratio = (box_w * box_h) / (img_w * img_h) if name not in stats: stats[name] = {"count": 0, "min_area": 1.0, "max_area": 0.0} stats[name]["count"] += 1 stats[name]["min_area"] = min(stats[name]["min_area"], area_ratio) stats[name]["max_area"] = max(stats[name]["max_area"], area_ratio) return stats if __name__ == "__main__": stats = stat_bbox_statistics("Annotations") for cls, val in stats.items(): print(f"{cls}: count={val['count']}, area_ratio=[{val['min_area']:.4f}, {val['max_area']:.4f}]")逻辑说明:area_ratio是框面积占整张图片面积的比例。苹果这类近圆形目标,正常情况 ratio 应该在 0.02 到 0.6 之间;如果你看到大量比例小于 0.005 的框,说明数据集里塞进了很多“远处的小苹果”,这会影响训练时 anchor 的选择。统计完成后,类别数量差异超过一个数量级,就需要考虑重采样或提高小类别在 loss 里的权重;这一步直接用输出结果决定,不用凭感觉。
4. 把VOC2007转换到YOLO格式:归一化坐标脚本与四个边界坑
VOC2007 的 XML 格式虽然是目标检测领域的通用标准,但像 Ultralytics YOLO 这类主流框架,更习惯为每张图单独准备一个 txt,每行表示一个目标,内容是“类别ID 中心点x 中心点y 宽 高”,且坐标全部归一化到 0 到 1。很多人在这一步翻车,不是算不对公式,而是没处理 XML 里已经存在的脏框。本章给出一个完整转换脚本,再把四个最容易出错的边界问题单独拉出来说。
4.1 坐标换算公式与最小转换脚本
换算公式不复杂:先算原始边界框的中心点(xmin+xmax)/2和(ymin+ymax)/2,再分别除以图片宽高得到归一化中心;框宽高同样除以图片宽高。关键是除数必须来自真实的图片文件尺寸,而不是直接信任 XML 里的<size>节点——很多标注工具写死了 1920×1080。
import os import xml.etree.ElementTree as ET def voc_to_yolo(xml_path, class_to_id, img_width, img_height, out_txt_path): """把VOC XML转换为一行一个目标的YOLO格式txt 参数: xml_path: 单个标注文件路径 class_to_id: 类别名到数字id的映射, 例如 {'apple': 0, 'green_apple': 1} img_width / img_height: 图片真实尺寸, 建议从图片文件读取 out_txt_path: 输出txt路径, 注意目录要先创建 """ root = ET.parse(xml_path).getroot() lines = [] for obj in root.iter("object"): name = obj.find("name").text.strip() if name not in class_to_id: print(f"跳过未注册类别: {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) x_center = (xmin + xmax) / 2.0 / img_width y_center = (ymin + ymax) / 2.0 / img_height w = (xmax - xmin) / img_width h = (ymax - ymin) / img_height # 越界保护: 将坐标夹到(0,1)区间, 防止后续训练出现NaN或anchor匹配失败 x_center = min(max(x_center, 0.0), 1.0) y_center = min(max(y_center, 0.0), 1.0) w = min(max(w, 0.0), 1.0) h = min(max(h, 0.0), 1.0) lines.append(f"{class_to_id[name]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") with open(out_txt_path, "w", encoding="utf-8") as f: f.write("\n".join(lines)) if __name__ == "__main__": # 先确认这两张苹果图的实际尺寸,而不是从XML里抄 class_map = {"apple": 0, "green_apple": 1} voc_to_yolo("Annotations/apple_001.xml", class_map, 800, 600, "labels/apple_001.txt")逻辑说明:用真实图片尺寸作除数,是为了防止 XML 和图片不一致时整个标签组整体偏移。min(max(x, 0.0), 1.0)这段是在做边界保护,但要注意它是“补救”不是“根治”。一个框如果原始坐标就超出图片边界,夹取后依然是个错误的框,正确做法是在打印警告的同时把这类样本记录到日志文件,训练结束后统一追溯。输出格式里的:.6f保留六位小数,够 YOLO 用,也不会因为过长撑大文件体积。
4.2 labels目录与images目录的组织方式:根名必须对齐
YOLO 训练框架通常要求图片和标签放在两个平级目录,图片目录叫 images,标签目录叫 labels,两张同名的图与 txt 通过文件名前缀关联。下面这段命令演示如何从已有 JPEGImages 批量生成空的标签目录,方便后续逐步填充转换结果。
# 创建与图片同名的空txt,避免训练时因为标签缺失报错 mkdir -p apple_yolo/labels find JPEGImages/ -name "*.jpg" -printf "%f\n" | sed 's/\.jpg$//' | while read base; do touch "apple_yolo/labels/${base}.txt" done逻辑说明:find -printf "%f"只输出文件名,去掉目录部分;sed把末尾的.jpg替换掉,剩下前缀;touch创建占位文件。这样做的意义在于让数据加载器从第一轮就能发现“哪些图还没有转换”,而不会因为某个 txt 缺失直接中断训练。实际转换时不建议挨个 touch,把转换脚本的输出路径直接指到 labels 目录更省事,占位文件留作用来比对遗漏。
4.3 四个边界坑:负坐标、零宽高、越界框与空的标注列表
第一坑是负坐标。VOC2007 的标注框偶尔会出现xmin=-5这种值,原因多半是标注人员把应标在图片外侧的裁切框也标了进来。转换脚本遇到负坐标,直接夹成 0 看起来无害,但会让框的语义从“包含目标”变成“包含残缺目标”。我一般会把负坐标样本单独目录,人工核对后再决定是否保留。
第二坑是零宽高。xmax - xmin = 0表示这条标注本身就是废的,转换后归一化宽为 0,训练时 anchor 匹配会直接跳过它,等于白白消耗一个样本。排查时用grep搜 XML 里的 bndbox 数值,或者用前面解析脚本打印所有宽高小于 1 像素的目标。
第三坑是越界框。xmax 大于图片宽度,这在标注时看不出来,因为标注工具界面可能会把框拉到图像画布外。转换后归一化坐标大于 1,有些框架会报警告,有些框架直接静默丢弃。最好在转换脚本里加一个判断,越界超过阈值就写入单独日志,训练后回查。
第四坑是空标注列表。如果 XML 里没有任何 object 节点,转换脚本输出的 txt 会是 0 字节,训练时这张图会被当作背景类。如果这种图占比超过百分之五,模型会倾向把所有区域都预测为背景,苹果漏检率直线上升。对这类图要么删掉,要么人工补标,不要抱着侥幸心理留着不管。
这四类脏数据在真实数据集里几乎都会遇到。我的例行做法是:先跑一遍全局扫描,记录每类异常框的数量,再决定是修还是删。不要边转换边处理“偶发”错误,因为你很快就会忘记哪些文件是被修过的,后面调参时容易误判数据真实性。
5. 避坑记录与常见问题排查:处理苹果照片VOC压缩包时的五个真实故障
以下五个问题是我处理多个个人分享的 VOC 数据集时反复遇到过的,按“现象—原因—解决”写清楚。前两个问题最隐蔽,后三个问题最影响训练结果,建议按顺序检查一遍。
5.1 XML里记录的文件名和实际图片名对不上
现象:解析脚本正常跑完,但把 XML 里的<filename>拿到 JPEGImages 里查找时找不到对应图片。原因:数据集在多次传递中重命名过图片文件,但 XML 里的 filename 字段没同步更新。解决:不要信任 XML 内的 filename 字段,直接以磁盘文件名作为唯一索引,解析 XML 时跳过 filename 字段,只靠前缀匹配路径。如果确实需要保留 filename,批量重命名前先做一次映射备份,否则后期对不上时连历史记录都没有。
5.2 Windows下解压出现乱码,代码读取直接抛异常
现象:在 Windows 资源管理器里解压后,部分 XML 文件名变成apple_001????.xml,Python 的os.listdir读出来是乱码,ET.parse 直接报错。原因:压缩包创建时用的是 macOS 或 Linux 下的 UTF-8 编码,Windows 自带 zip 解压工具默认按 GBK 解压,文件名编码对不上就会出现乱码。解决:解压时用 7-Zip 或 WinRAR 并显式选择 UTF-8 解码,不要用系统自带的“全部解压”。更稳妥的是在 Python 里用zipfile模块按二进制流解压,文件名用encoding="utf-8"处理。这一步处理后,顺手把全目录的大小写统一,避免后续脚本在 Linux 服务器上因为文件系统区分大小写而出错。
5.3 同一张照片同时出现在train.txt和val.txt
现象:训练好模型后在验证集上指标非常高,但实际测试效果很差。原因:划分脚本没有做互斥处理,或者原数据集本身就是把全部文件塞进 train 又塞进 val。解决:用前面 3.2 节的check_train_val_conflict检查冲突列表,把冲突项从 val.txt 里移除。移除后注意重新生成验证集,不要只删一行就完事,还要检查 val.txt 里的样本 ID 是否全部能对应上真实图片。这个问题在个人共享数据集里出现概率极高,建议在新数据集上线的第一周就建立“验证集纯净度”检查流程。
5.4 目标框宽高为零,转换后出现除零或NaN
现象:voc_to_yolo 转换后生成的 txt 里出现inf或nan,训练日志里偶发 RuntimeWarning。原因:XML 里xmax和xmin相等或ymax和ymin相等,归一化时分母为 0。解决:在解析阶段加校验,if xmax <= xmin or ymax <= ymin: continue,并把这组数据写入异常日志。不要试图通过加一个极小值 epsilon 来掩盖问题,因为宽高为 0 的框本身就是无效标注,修正后也不会让模型学到有效特征,只会干扰 anchor 采样。
5.5 类别名带空格或大小写不一致,训练时类别直接丢失
现象:转换后的 txt 里某几行开头类别 ID 明显错位,或者训练时提示“class not found”。原因:XML 中的<name>写成"apple "或"Apple",而 class_to_id 字典里的键是"apple",匹配不上时目标被静默跳过。解决:所有类别名读取后统一执行.strip().lower(),并且把映射字典的键也预先归一化。检查时打印一份全量类别列表,看看有没有意外多出来的类别名。这样的猫腻在跨平台传递数据时尤其常见,macOS 和 Windows 的文本编辑工具会自动在行尾加特殊字符,肉眼看起来没区别。
这五条每条我都实际翻过车,而且它们不是独立出现的,经常是同一个数据集里同时命中三四个。处理的顺序建议是:先修文件名和相关路径,再做 train/val 互斥,再清理脏框,最后统一类别名。按这个顺序做,能避免前面修的 bug 被后面的操作重新引入。
6. 画框验证:训练前用OpenCV把苹果标注框画回原图
数据整理完之后,最值得做的一步不是马上开训练,而是把标注框可视化地画回图片。这一步能直观发现 XML 坐标、类别和图像内容是不是真的对应。我常用的脚本逻辑很简单:遍历几张有代表性的图,用 OpenCV 画矩形框并标注类别,然后把结果保存到单独的 verify 目录。
import os import cv2 import xml.etree.ElementTree as ET def draw_annotations(image_dir, xml_dir, output_dir, sample_count=10): """从JPEGImages里挑几张图,把XML里的框画上去并保存,用于人工检查""" os.makedirs(output_dir, exist_ok=True) xml_files = sorted(f for f in os.listdir(xml_dir) if f.endswith(".xml"))[:sample_count] for xml_name in xml_files: xml_path = os.path.join(xml_dir, xml_name) root = ET.parse(xml_path).getroot() # 找对应图片,优先尝试jpg,不行再试png base = xml_name[:-4] img_path = os.path.join(image_dir, base + ".jpg") if not os.path.exists(img_path): img_path = os.path.join(image_dir, base + ".png") if not os.path.exists(img_path): print(f"图片不存在: {base}") continue img = cv2.imread(img_path) for obj in root.iter("object"): name = obj.find("name").text.strip() box = obj.find("bndbox") xmin = int(float(box.find("xmin").text)) ymin = int(float(box.find("ymin").text)) xmax = int(float(box.find("xmax").text)) ymax = int(float(box.find("ymax").text)) # 框宽高小于等于0的明显异常,跳过并打印 if xmax <= xmin or ymax <= ymin: print(f"异常框跳过: {base}, {name}") continue cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) cv2.putText(img, name, (xmin, max(ymin - 5, 0)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) out_path = os.path.join(output_dir, base + "_verify.jpg") cv2.imwrite(out_path, img) print(f"已生成: {out_path}") if __name__ == "__main__": draw_annotations("JPEGImages", "Annotations", "verify", sample_count=10)逻辑说明:xml_name[:-4]用文件名前缀匹配图片,这里再次强调不要依赖 XML 里的 filename 字段。cv2.putText里max(ymin - 5, 0)是防止文字画到图片外面被截断。人工检查时主要看两点:框是否紧贴苹果边缘,类别名是否和图像内容一致。我第一次检查一个苹果数据集的验证图时,发现标注人员把叶子的框全标成了苹果,这类错误在没有任何可视化脚本的情况下很难被发现。
如果验证图看起来正常,我会再从训练集和验证集各抽几十张检查,确认没有混入非苹果图像,然后用这批数据去跑一轮小规模训练,观察 loss 曲线是否正常下降。这个小流程现在已经成为我拿到任何数据集的第一动作,它花不了五分钟,但能避免后续数小时的无效训练。希望这套处理思路能帮你把类似的“数据集+代码压缩包”稳稳接住。
本文还有配套的精品资源,点击获取