简介:面向YOLO系列目标检测的初学者与算法工程师,这份葡萄图像数据集包含拣选点、斑点葡萄、腐烂葡萄、成熟葡萄、未成熟葡萄五类目标,共165张已标注图像。数据集已完成训练集与验证集划分,并附带可直接读取的data.yaml配置文件,适用于YOLOv5、YOLOv7、YOLOv8、YOLOv9、YOLOv10、YOLO11等主流版本,下载后即可开始训练和验证,免去自行整理标注的繁琐流程。压缩包共496个文件,包括165张jpg原图、165个txt格式的YOLO标注文件、165个xml格式的VOC标注文件,以及1个yaml配置;txt与xml两种标签格式同时提供,便于在不同工具链间切换或做格式对照学习。YOLO标注格式中,每行记录类别索引、目标框中心点归一化坐标以及宽高比例,结构清晰,非常适合用来理解目标检测标签的构造逻辑。整个资源仅13.85MB,轻量易下载;目前已有101人学习浏览,可作为快速搭建葡萄检测项目的实用数据集。
1. 葡萄数据集到手,165 张图先别急着训:这包料能干什么、不能干什么
拿到这个包的第一反应是“才 165 张图,五个类别,能训出什么”。但解压后扫了一圈,这包数据不是拿来拼精度的,它是拿来跑通全流程的:yolo 格式 txt 标签和 voc 格式 xml 标签双份齐全,data.yaml 配置直接躺在根目录,train / val / test 已经划分好,喂给 yolov5、v7、v8、v9、v10、yolo11 都能直接开工。类别也很有意思,不是简单的“葡萄/背景”,而是按分拣场景拆成了拣选点、斑点葡萄、腐烂葡萄、成熟葡萄、未成熟葡萄五个细类,标签粒度接近真实产线上的质检需求。适合两类人:第一次训自己数据集、想搞明白 yolo 标签到底怎么组织的入门者;以及需要一份规范双格式标注当模板,拿去做果品分拣预研的工程师。指望这 165 张图训出能直接上产线的模型不现实,但用它把数据体检、训练、验证、调置信度门限这一整条链路跑顺,非常值。
2. 目录结构与双标签格式:拿到手先摸清 165 张图的家底
2.1 目录结构与五个类别:从文件名到标签 id
这包数据解压之后的目录结构,常见是这样组织的(不同渠道打包习惯不一样,以实际解压为准):
grape_dataset/ ├── data.yaml ├── images/ │ ├── train/ │ │ ├── img_0771_16.jpg │ │ ├── img_0964_30.jpg │ │ ├── ... │ ├── val/ │ └── test/ ├── labels_txt/ │ ├── train/ │ ├── val/ │ └── test/ └── labels_xml/ ├── train/ ├── val/ └── test/逻辑很清楚:图像在 images 下,txt 格式标签在 labels_txt 下,xml 格式标签在 labels_xml 下,三个子集各自成目录,一一对应。图片命名是img_编号_序号.jpg这种风格,同一个场景的多张图会共享前面一段编号,实际操作里可以按这个规律反推哪些图来自同一批采集环境,帮助判断训练集和验证集有没有泄露。
五个类别的业务含义值得先说清楚,这直接关系到后面怎么调参:
- 拣选点:分拣流程中的操作参考点,属于典型小目标,框小、数量少,最容易漏检。
- 斑点葡萄:果面有早期病斑或药斑,特征弱、对比度低,和干净果面容易混淆。
- 腐烂葡萄:颜色、纹理变化明显,算是最好检的一类。
- 成熟葡萄与未成熟葡萄:按成熟度切分的双子类,二者边界是渐变的,标注本身就有主观性。
从类别性质就能预判两个坑:小目标(拣选点)和难分对(斑点 vs 腐烂、成熟 vs 未成熟)。数据量只有 165 张,这两类问题会被进一步放大,后面所有参数调整都绕不开这个前提。建议拿到手第一件事不是开训,而是确认 data.yaml 里的 names 顺序——它决定了 txt 里每个数字对应哪个类别。
cat data.yaml这个命令不用解释,但它的输出你要逐行看。下面这张表是 data.yaml 里常见字段的解析方式,我习惯把对应关系抄在笔记本上再往下走:
| 字段 | 含义 | 注意事项 |
|---|---|---|
| path | 数据集根目录 | 相对路径时依赖运行位置,跨机器要改 |
| train / val / test | 各子集图像路径 | 要与实际目录结构一致 |
| names | 类别名列表 | 列表下标就是标签 id,顺序错了全盘皆错 |
| nc | 类别数量 | 部分工具自动算,缺失时手动补 |
2.2 data.yaml:训练边界的说明书
data.yaml 就是这包数据的“施工图”。用 yolo 系列训练时,ultralytics 框架启动后第一件事就是解析这个文件,路径错了、names 顺序错了,后面全白跑。下面是一份典型的 data.yaml 内容:
path: ./grape_dataset train: images/train val: images/val test: images/test nc: 5 names: 0: picking_point 1: spotted_grape 2: rotten_grape 3: mature_grape 4: immature_grape字段说明:path是数据集根目录,这里写的是相对路径,意味着你执行训练命令时的工作目录必须和这个相对路径对上;train / val / test是三个子集的图片目录相对 path 的路径;nc是类别总数,5;names是类别名列表,列表的下标 0 到 4 直接对应 txt 标签每行的第一个数字。注意一个关键点:框架不会帮你校验 names 的顺序对不对,它只会按下标取名字。如果你发现训练日志里类别名对不上实际内容,先回来检查这个文件,而不是怀疑模型。
我一般拿到手会顺手跑一个命令确认路径是否存在,避免训练到一半报 FileNotFoundError:
ls -la images/train | head -20 && ls labels_txt/train | head -20这一步能同时确认图片和标签目录都有内容,还能扫一眼文件名前缀是否一致。文件名对不上是 yolo 训练最常见的“黑匣子报错”之一。
2.3 yolo txt 与 voc xml 双格式并存:为什么两份都留
同一份标注同时给 txt 和 xml,不是冗余,是两种工作流的需要。labelimg 默认保存为 voc 格式的 xml,人工复核、二次修框时用 xml 最顺手;yolo 训练框架读取效率最高的是 txt 归一化坐标。两份都在,意味着你可以直接用 labelimg 打开 xml 修完再转 txt,不用重打标。对比看更直观:
<annotation> <filename>img_0964_30.jpg</filename> <size> <width>1280</width> <height>720</height> </size> <object> <name>mature_grape</name> <bndbox> <xmin>412</xmin> <ymin>230</ymin> <xmax>618</xmax> <ymax>405</ymax> </bndbox> </object> </annotation>对应的 txt 是:
3 0.40234375 0.44097222 0.16093750 0.24305556这里3是类别 id,对应 data.yaml 里 names 的下标;后面四个数字分别是归一化后的中心点 x、中心点 y、框宽、框高。python 里验算一遍就清楚了:
x_center = (412 + 618) / 2 / 1280 # 0.40234375 y_center = (230 + 405) / 2 / 720 # 0.44097222 width = (618 - 412) / 1280 # 0.16093750 height = (405 - 230) / 720 # 0.24305556逻辑说明:把绝对像素坐标全部除以图像宽高,得到 0 到 1 之间的比例值,这样标签就和图像分辨率解耦了,训练时无论输入 640 还是 1280,都能直接换算回原图位置。参数说明:如果 xml 里尺寸是 1280x720,而你的训练 imgsz 是 640,框架会自动等比例缩放,不需要手动改 txt。双格式存在的另一个价值是:txt 丢了可以用 xml 重新生成,反过来也一样,这相当于给你的标注数据上了双保险。
3. 标签坐标换算与脚本体检:yolo txt 不是画框画出来的
3.1 五元组换算:归一化坐标与像素坐标的来回
yolo 标签每一行是五个数字:<class> <x_center> <y_center> <width> <height>。很多新手以为这四个坐标是随便填的,实际它们是归一化后的比例值。拿到 xml 要转 txt 时,换算逻辑固定如下:
import xml.etree.ElementTree as ET def xml_to_yolo(xml_path, img_width, img_height, class_map): tree = ET.parse(xml_path) root = tree.getroot() lines = [] for obj in root.iter('object'): name = obj.find('name').text if name not in class_map: continue cls_id = class_map[name] box = obj.find('bndbox') xmin = float(box.find('xmin').text) ymin = float(box.find('ymin').text) xmax = float(box.find('xmax').text) ymax = float(box.find('ymax').text) x_center = (xmin + xmax) / 2 / img_width y_center = (ymin + ymax) / 2 / img_height width = (xmax - xmin) / img_width height = (ymax - ymin) / img_height lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}") return lines逻辑说明:先取框的绝对像素坐标,算中心点和宽高,再分别除以图像宽高完成归一化。class_map是类别名到 id 的映射字典,必须与 data.yaml 里的 names 顺序一致,这一步错了模型会拿“腐烂”当“成熟”训练,而且不会有任何报错。参数说明:img_width和img_height从哪里来?可以直接读 xml 里<size>节点的值,也可以用 PIL 打开对应图片获取。我个人倾向用 PIL 实时读原图尺寸,因为有些打标工具写进 xml 的 size 和实际图片不一致,留个心眼。
反向换算同样常用。推理结果要画到原图上,或者要算检测框的绝对像素面积时,把归一化值乘回去就行:
def yolo_to_pixel(cls_id, x_center, y_center, width, height, img_w, img_h): x1 = int((x_center - width / 2) * img_w) y1 = int((y_center - height / 2) * img_h) x2 = int((x_center + width / 2) * img_w) y2 = int((y_center + height / 2) * img_h) return cls_id, x1, y1, x2, y2这里的核心思路是“中心点加减半宽高”得到左上和右下角点,乘回图像宽高即还原绝对坐标。注意:归一化值乘以宽高后要转 int,但转 int 的时机要放在最后,中间计算全程用 float,否则累计误差会在小目标上放大——拣选点这类小框差几个像素就可能错位到相邻果粒上。
3.2 脚本体检:越界框、空标签与类别分布
165 张图不算多,但手工打标难免出错。我拿到任何 yolo 数据集的第一件事,不是训练,是跑一遍体检脚本。越界框、空标签、类别数量失衡,这些问题都会在训练时变成莫名其妙的低 mAP。
import os label_dir = "labels_txt/train" img_dir = "images/train" img_sizes = {} # 先读所有图片尺寸 from PIL import Image for img_name in os.listdir(img_dir): if img_name.endswith(".jpg"): img_sizes[img_name[:-4]] = Image.open(os.path.join(img_dir, img_name)).size issues = [] class_count = {} empty_labels = 0 total_boxes = 0 for label_file in os.listdir(label_dir): if not label_file.endswith(".txt"): continue stem = label_file[:-4] if stem not in img_sizes: issues.append(f"{label_file}: 对应图片缺失") continue img_w, img_h = img_sizes[stem] with open(os.path.join(label_dir, label_file)) as f: lines = f.readlines() if len(lines) == 0: empty_labels += 1 issues.append(f"{label_file}: 空标签") continue for line in lines: parts = line.strip().split() if len(parts) != 5: issues.append(f"{label_file}: 列数异常 -> {line.strip()}") continue cls_id = int(parts[0]) xc, yc, w, h = map(float, parts[1:]) class_count[cls_id] = class_count.get(cls_id, 0) + 1 total_boxes += 1 # 越界检查:归一化坐标允许 -0.01 到 1.01 的容差 if not (-0.01 <= xc <= 1.01 and -0.01 <= yc <= 1.01): issues.append(f"{label_file}: 中心点越界 xc={xc} yc={yc}") if not (0 < w <= 1.01 and 0 < h <= 1.01): issues.append(f"{label_file}: 宽高异常 w={w} h={h}") print(f"总框数: {total_boxes}, 空标签文件: {empty_labels}") print(f"类别分布: {class_count}") print(f"发现问题 {len(issues)} 条:") for item in issues[:30]: print(" -", item)逻辑说明:脚本先建图片文件名到尺寸的映射,再逐行解析 txt 标签,做三类检查——文件是否存在、字段是否完整、坐标是否越界。最后统计类别分布。参数说明:越界容差给到-0.01到1.01,是因为 labelimg 偶尔会画出微幅越界的框,完全卡死 0 到 1 会误报。小越界可以容忍,超过 1% 就建议回炉修正。类别分布那一行尤其值得看:五个类别如果数量差一个数量级,比如腐烂葡萄有 800 框、拣选点只有 40 框,后面训练就得做类别权重处理,否则大类会把 loss 整个带走。
3.3 voc 转 yolo 兜底脚本:双格式不同步时的后悔药
txt 和 xml 两份标签并存,最大的风险是某次人工修订只改了其中一份,两份开始分叉。常见的做法是以 xml 为基准重新生成 txt,或者写个脚本对比两侧的框数量,数量不一致就报警。下面的函数是 xml 批量转 txt 的通用写法:
import os import glob from PIL import Image def batch_convert_xml_to_yolo(xml_dir, img_dir, out_dir, class_map): os.makedirs(out_dir, exist_ok=True) for xml_path in glob.glob(os.path.join(xml_dir, "*.xml")): stem = os.path.splitext(os.path.basename(xml_path))[0] img_path = os.path.join(img_dir, stem + ".jpg") if not os.path.exists(img_path): print(f"跳过 {stem}: 图片不存在") continue img_w, img_h = Image.open(img_path).size lines = xml_to_yolo(xml_path, img_w, img_h, class_map) if lines: out_path = os.path.join(out_dir, stem + ".txt") with open(out_path, "w") as f: f.write("\n".join(lines) + "\n") print(f"转换完成,输出目录: {out_dir}")兼容性说明:我在xml_to_yolo里通过class_map做了类别名过滤,xml 里出现 names 之外的类别会直接跳过而不是报错,这能避免脏数据打崩整个转换流程;但跳过意味着丢框,转换完务必对比一下总框数。我个人习惯是加一行统计打印,把每个 xml 的框数和转换后的 txt 行数对上,对不上就单独拎出来看。
4. 训练避坑:小数据集、类别不均衡与早停的取舍
4.1 过拟合翻车:train loss 一路掉,val loss 从第 20 轮开始反弹
现象:训练日志里 train box_loss 和 cls_loss 在稳步下降,看起来一切正常,但 val loss 从第 20 轮左右开始掉头向上,验证集 mAP 停滞甚至下滑。翻看 results.png,训练曲线和验证曲线在某个 epoch 之后明显分道扬镳。
原因:165 张图对五个类别来说样本量偏小,模型从第 20 轮开始已经把训练集纹理“背”下来了,验证集上泛化能力反而下降。这是小数据集的典型过拟合信号,不是代码写错了。
解决:分三路处理。第一路是早停,训练命令里加patience=20,让框架在验证指标连续 20 轮不刷新时自动停,用 best.pt 而不是 last.pt。第二路是增强,把hsv_h、hsv_s、hsv_v从默认值往上调一档,并对葡萄这类圆形果实加大scale和fliplr的扰动。第三路是换小模型,yolov8n 起步,跑通了再考虑 s 或 m,不要一上来就上 x,参数越多过拟合越快。
yolo detect train data=./grape_dataset/data.yaml model=yolov8n.pt epochs=150 patience=20 imgsz=640 batch=8 hsv_h=0.02 hsv_s=0.7 hsv_v=0.5 fliplr=0.5 scale=0.5参数说明:patience=20是早停轮数;hsv_h / hsv_s / hsv_v是色调、饱和度、亮度的增强幅度,葡萄颜色多样,适度加大能提升对光照变化的鲁棒性;scale=0.5是随机缩放比例,用来模拟不同拍摄距离。血泪经验:小数据集上增强参数宁可激进一点,也不要保守,欠增强的过拟合比过增强的形状失真更难处理。
4.2 类别不均衡:腐烂葡萄八百框,拣选点五十框
现象:训练结束后打开 confusion_matrix.png,对角线权重严重偏向腐烂葡萄和成熟葡萄,斑点葡萄和拣选点的召回率惨不忍睹,几乎全被预测成背景或相邻类别。
原因:cls_loss 是全局求均值,大类样本多、贡献的梯度大,模型自然偏向把难分样本归类到大类里。拣选点本身又是小目标,特征像素占比低,双重劣势叠在一起。
解决:先跑一遍 3.2 的体检脚本拿到各类别框数,按比例调类别权重。ultralytics 框架支持在 data.yaml 里加cls_weights字段(自定义 dataset 类时手动指定更稳),或者简单粗暴的做法是给稀有类别过采样,训练前把拣选点和斑点葡萄的样本复制几份混进 train 目录。更推荐的做法是直接用yolov8m替代yolov8n,大模型的特征提取能力对小目标和稀有类更友好,代价是训练时间变长,但这个数据集总共才 165 张,m 和 n 的绝对训练时长差距可以忽略。
4.3 训练中途断了,别从零再来:resume 与 best/last 双权重
现象:训练到第 80 轮,远程终端断了或显存被别的任务抢了,进程被杀。重新执行同一条训练命令,发现又从 epoch 0 开始跑,之前 80 轮白费。
原因:ultralytics 默认从头训练,并不会自动找到上次的断点。但它在训练过程中会持续保存两个权重:best.pt和last.pt,前者是验证集表现最好的快照,后者是最近一轮的快照。
解决:用resume=True从 last.pt 续跑:
yolo detect train resume model=runs/detect/grape_yolov8n/weights/last.pt参数说明:resume模式下框架会读取 last.pt 里记录的 epoch 和优化器状态,数据路径、模型结构、超参数全都从原训练配置里恢复,不需要重新指定。注意一个坑:续跑时不要更换数据集路径,也不要改 imgsz,优化器状态和数据增强缓存对不上会出诡异曲线。推理和部署只用 best.pt,last.pt 只是断点续跑的“后悔药”。
4.4 显卡只有 6GB 显存:batch 和 imgsz 怎么搭配才不炸
现象:照搬教程里的batch=16 imgsz=640,训练脚本跑起来两三秒就报CUDA out of memory,黑匣子一样的问题,不知道从哪个参数下手。
原因:yolov8 训练时显存占用主要由 batch size、输入分辨率和模型深度共同决定。6GB 显存跑 n 模型,batch=16 加 imgsz=640 必然超。最关键的是很多人不知道 amp(混合精度)没开,显存直接翻倍。
解决:显存有限时按这个顺序调——先开amp=True,再降 batch 到 8 或 4,最后才降 imgsz。imgsz 最好不要低于 480,否则本来就小的拣选点目标在特征图上只剩几个像素,召回率会崩。亲测 6GB 显存下yolov8n + imgsz=640 + batch=8 + amp可以稳定跑完 165 张图。另外有个技巧:小数据集迭代一轮本身就快,batch 小一点反而等于每轮更多次参数更新,对收敛未必是坏事,不必纠结一定要跑到标称 batch。
5. 用 yolov8 把训练跑通:命令行参数、结果指标与置信度门限
5.1 环境安装与第一条完整的训练命令
环境装好之后,训练命令本身不复杂,但每个参数都值得知道它在干什么。先把环境准备好:
conda create -n yolo python=3.10 -y conda activate yolo pip install ultralytics逻辑说明:ultralytics 是 yolov8、yolov9、yolov10、yolo11 的统一训练框架,装这一个包就带上了模型定义、训练、验证、导出全套能力。python 版本建议 3.10,3.11 及以上在部分老版本 torch 下会有兼容噪音。装完可以先跑一个yolo predict source=https://ultralytics.com/images/bus.jpg验证环境通不通,再进训练。
正式训练命令:
yolo detect train \ data=./grape_dataset/data.yaml \ model=yolov8n.pt \ epochs=150 \ imgsz=640 \ batch=8 \ patience=20 \ device=0 \ project=./runs \ name=grape_v8n \ amp=True参数说明:data指向 data.yaml,框架从这里读路径和类别;model=yolov8n.pt会下载 COCO 预训练权重做迁移学习初始化,前几层特征通用性很强,对 165 张小数据集帮助很大——千万不要用随机权重从零开始训;epochs=150配合patience=20是早停兜底;device=0指定第一张显卡,没 GPU 就写device=cpu,但这个数据量 CPU 也能训完,只是慢;project和name决定输出目录。跑起来之后日志里会实时打印每个 epoch 的 box_loss、cls_loss、dfl_loss 和 mAP 指标,留心观察它们的变化趋势。
5.2 结果目录里有什么:从 confusion_matrix 到 results.png
训练结束后,所有产物都在runs/detect/grape_v8n/目录下。这些文件不光是给训练过程画句号,更是判断这个模型能不能用的依据。
ls -la runs/detect/grape_v8n/正常会看到 weights 目录(best.pt 和 last.pt)、confusion_matrix.png、results.png、F1_curve.png、PR_curve.png、val_batch_pred.jpg 等。重点看三个:results.png 里三条 loss 曲线和 mAP 曲线整体走势;confusion_matrix.png 的对角线亮度;val_batch_pred.jpg 里真实标注与预测框的重合情况。读法上:如果最后 20 轮 val 的 box_loss 还在下降,说明训练没收敛,可以加 epochs 或去掉早停再跑;如果对角线只有少数几类亮,说明类别不均衡问题没有解决。
推理验证用 best.pt:
yolo detect predict model=runs/detect/grape_v8n/weights/best.pt source=grape_dataset/images/test/ conf=0.25 save=True参数说明:conf=0.25是置信度门限,低于这个分数的预测框会被过滤掉;save=True会把标注了预测框的图片存到 runs/detect/predict 目录里供人工目检。
5.3 置信度门限:漏检和误检之间的平衡点
模型输出的每个框都有一个置信度分数,门限设得低,框多但误检多;门限设得高,精度高但漏检多。分拣场景里这个参数直接决定产线效果,值得单独写一个小脚本批量看不同门限下的表现:
from ultralytics import YOLO model = YOLO("runs/detect/grape_v8n/weights/best.pt") results = model.predict( source="grape_dataset/images/test/", conf=0.1, iou=0.5, save=False, verbose=False ) for conf_thres in [0.1, 0.2, 0.25, 0.3, 0.4, 0.5, 0.6]: total = 0 for r in results: boxes = r.boxes if boxes is None: continue confs = boxes.conf.cpu().numpy() total += int((confs >= conf_thres).sum()) print(f"conf>={conf_thres}: 保留框数 {total}")逻辑说明:先用一个较低的门限(0.1)做推理,把所有候选框保留下来,再在内存里按不同门限统计保留框数量,观察阈值变化对框数量的影响曲线。参数说明:iou=0.5是 NMS 阶段两个框重叠超过这个比例时合并,一般保持默认即可,葡萄果实密集,如果重叠框被压得太多可以下调到 0.4。如果发现 conf 从 0.25 提到 0.4 时框数量骤降,说明大量预测框集中在低置信度区间,模型本身不确定度高,这时调门限只是粉饰,应该回头查训练数据质量和标注一致性。
6. 特征可视化和双格式回写:让模型不再是黑匣子
6.1 用类激活图看模型到底在看葡萄的哪个部位
模型训完,mAP 达标,但你不知道它靠什么特征做判断。对目标检测模型做可解释性分析,常用的是 Grad-CAM,它会生成一张热力图,标出模型做分类决策时重点关注图像中的哪些区域。用 pytorch_grad_cam 库对 yolo 模型做可视化,核心代码如下:
import torch from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget from pytorch_grad_cam.utils.image import show_cam_on_image from ultralytics import YOLO import cv2 import numpy as np model = YOLO("runs/detect/grape_v8n/weights/best.pt") image = cv2.imread("grape_dataset/images/test/img_0964_30.jpg") image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # 定位到模型最后一个卷积层 target_layers = [model.model.model[-2]] # 前向推理准备输入张量 img_resized = cv2.resize(image, (640, 640)) input_tensor = torch.from_numpy(img_resized.transpose(2, 0, 1)).float() / 255.0 input_tensor = input_tensor.unsqueeze(0) cam = GradCAM(model=model.model, target_layers=target_layers) targets = [ClassifierOutputTarget(3)] # 类 id 3,对应 mature_grape grayscale_cam = cam(input_tensor=input_tensor, targets=targets)[0] visualization = show_cam_on_image(img_resized.astype(np.float32) / 255.0, grayscale_cam)逻辑说明:Grad-CAM 的原理是拿类别得分对最后一个卷积层的特征图求梯度,用梯度加权特征图再上采样到原图尺寸,得到“哪里对决策贡献最大”的热力图。参数说明:target_layers选的是模型的倒数第二层结构,因为越深的卷积层携带的语义信息越强;ClassifierOutputTarget(3)指定想看哪个类别的响应,这里类 id 3 对应 mature_grape。用法上:如果热力图的亮区集中在果梗端部或病斑位置,说明模型学到了有区分力的特征;如果亮区散发到叶片或背景上,就要警惕模型在“抄近道”,比如靠背景颜色分类而不是靠果实本身。这个检查对 165 张小数据集尤其重要——数据少,模型学歪的概率比大数据集高得多。
6.2 labelimg 复核:把误检样本拉回打标台
可视化只能给你“模型在关注哪里”的宏观判断,具体到某些框为什么误检,还得把低置信度的预测框导出来,用 labelimg 打开原图人工核对。我惯用的流程是三步:先用 conf=0.1 跑一遍 test 集,把所有低置信度框输出为一个目录;再用 labelimg 逐个打开看这些低置信度框到底是真目标还是误检;最后只修正确认有问题的样本,改完统一走双格式回写脚本,保证 txt 和 xml 同步更新。labelimg 的操作很机械但效率高:Open Dir 选图片目录,Change Save Dir 选标签输出目录,w 键画框、ctrl+s 保存、d 键切到下一张。值得提醒的是,复核时一定要打开原图尺寸看,缩略图下很容易漏掉拣选点这类小目标。
从误检里能发现两类典型问题:一是标注漏框——模型在图上框出了一个真实存在的腐烂葡萄,但原始标签里没有,导致训练时它被当成负样本;二是边界类别主观性——同一串葡萄,打标员甲标“成熟”,标乙标“未成熟”,这类框在混淆矩阵里表现为相邻类别互相串。发现第二类问题时,不要急着改模型,先把这类边界样本统一回标,重新定义清楚判定标准再训。
6.3 双格式同步回写:确保 txt 与 xml 永远一致
人工复核改了标签,就要面对一个现实问题:txt 和 xml 两份标签怎么保持一致。我强烈建议只维护一份标签作为基准(我用 xml),改完基准后跑一个同步脚本重新生成另一份:
import os import glob def sync_labels(xml_dir, img_dir, txt_out_dir, class_map): os.makedirs(txt_out_dir, exist_ok=True) synced = 0 for xml_path in glob.glob(os.path.join(xml_dir, "*.xml")): stem = os.path.splitext(os.path.basename(xml_path))[0] img_path = os.path.join(img_dir, stem + ".jpg") if not os.path.exists(img_path): continue img_w, img_h = Image.open(img_path).size lines = xml_to_yolo(xml_path, img_w, img_h, class_map) with open(os.path.join(txt_out_dir, stem + ".txt"), "w") as f: f.write("\n".join(lines) + "\n") synced += 1 print(f"已同步 {synced} 个文件到 {txt_out_dir}")逻辑说明:这个函数本质上就是批量 xml 转 yolo,但它强调的是一个习惯——以 xml 为唯一基准,所有人工修订只动 xml,txt 永远由脚本生成。这样做的好处是彻底避免两份标签各自演化、最后对不上的问题。从那以后我每次新拿到数据集,都强制走一遍“体检脚本 → 确认基准格式 → 训练 → 复核 → 回写”的流水线,训练前再顺手跑一次同步校验。165 张图的数据集不大,但养成这个习惯后,后面换大项目、多人协作打标时,省下的对账时间远不止这几个小时。希望帮到你。
本文还有配套的精品资源,点击获取