ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

方便面调料包正反检测数据集与YOLO训练避坑指南

方便面调料包正反检测数据集与YOLO训练避坑指南

简介:面向方便面调料包正反面识别与产线质检场景的目标检测数据集,图像由海康设备拍摄,按正常放置与反方向放置分别标注为 one、two,适合训练和验证 YOLO 系列等检测模型。压缩包共 904 个文件,包含 441 张 jpg 图像、442 个 txt 标注文件及 21 个 xml 辅助标注文件,整体约 758.95MB,便于直接组织训练集、验证集与复核标注。数据为手动标注,描述中给出 YOLOv8 检测 mAP 约 90% 的参考结果,可用于课程实验、算法对比与工业视觉原型验证;包内样本覆盖不同拍摄角度与摆放状态,标注与图像对应,便于做数据清洗、标签核对和模型迭代。已有 110 人学习,适合计算机视觉初学者、目标检测实践者及需要正反面识别样本的开发者参考。

1. 方便面调料包的正反朝向,值得单独做一个检测数据集

产线上投料工位最容易被低估的一类异常,是调料包躺反了。正面朝上和背面朝上在俯视图里轮廓几乎一模一样,人眼要盯着图案方向、撕口位置才分得清,连看八小时必然走神。这份方便面调料正反目标检测数据集就是冲着这个场景整理的:工业面阵相机俯拍,正常放置标为 one,反向放置标为 two,标注直接是检测框格式,拿来就能进训练流程。它适合做食品包装产线视觉的工程师,也适合手头有工位相机、想快速验证「方向类缺陷能不能用目标检测兜住」的团队。真正难的地方不在模型,而在增强策略和标签一致性——这两点决定了这个数据集是帮你省两周,还是让你在产线上翻一次车。

2. 拆开数据集:one 与 two 的标注边界和采集口径

2.1 为什么把正反做成两个检测类,而不是「检测+分类」两段式

工业上处理方向异常,常见两条路线。第一条是先检出一个通用的「调料包」类,再把框裁出来送进一个二分类网络判断正反;第二条就是这份数据集采用的路线,直接把 one 和 two 当成两个检测类别,一次前向出结果。

两段式看起来更「模块化」,实际问题不少。裁剪框只要有几像素抖动,送进分类器的图就带上了不同比例的背景,分类器很容易把传送带纹理、阴影边缘当成判别依据,换个光照就崩。而且调料包的正反差异是全局的——整幅包装图案旋转了 180 度,撕口从左上跑到右下——这种差异在整图上判断比在裁剪块上判断更稳。单阶段两类检测还有个隐性好处:延迟低。产线节拍通常几十毫秒一个工位,两段式多一次推理和一次内存拷贝,节拍紧的时候就是硬伤。

代价也要说清楚。两类检测在目标外观高度相似时,容易出现同一包上出两个框、类别还打架的情况,后处理必须显式处理「一包只能有一个结论」。这一点在第 5 章会单独展开。

2.2 文件结构、标注格式与类别序

数据集按检测任务的通用组织方式给到,图片和同名 txt 标签一一对应,标签是 YOLO 的归一化格式,每行五个数:

<class_id> <cx> <cy> <w> <h>

其中 cx、cy、w、h 都是除以图宽图高之后的 0~1 浮点数,class_id 从 0 开始。这个数据集里,类别序必须钉死为:

class_id类别名含义现场语义
0one正常放置正面朝上,可直接投料
1two反向放置方向相反,需要翻包或剔除

目录上通常是这样的结构,划分脚本跑完就是这个样子:

seasoning/ ├── images/ │ ├── train/ *.jpg │ └── val/ *.jpg ├── labels/ │ ├── train/ *.txt │ └── val/ *.txt └── seasoning.yaml

这里有个血泪经验:类别序一旦定下来,转换脚本、data.yaml 的 names、后处理里的类别映射,三处必须同步,任何一处写反都不会报错,只会安静地把 one 判成 two。所以我一般会把类别映射抽成一个常量模块,所有脚本都从它 import,改一处全改。

2.3 工业面阵相机拍出来的图,对标注意味着什么

采集端是一台 GigE 接口的工业面阵相机,固定俯拍、配条形光源,快门和曝光锁死避免频闪。这种条件下图像有三个特征会直接影响标注和训练。

第一个是反光。调料包外层是复合膜,铝箔层在条光下会出一条高亮带,位置随包体摆放角度漂移。高亮带压在撕口或图案上时,标注员会犹豫框边界该画到哪,我给的规则是:框贴着包体最外轮廓画,不因为反光去缩框,反光交给增强和更多样本覆盖。

第二个是背景单一。传送带底色固定,模型很容易退化成「找背景里的异物」而不是「看包体方向」。所以采集时刻意保留了一些背景变化——皮带接头、边缘挡板、不同批次的色差——虽然样本变脏,但泛化会好一截。

第三个是分辨率。原图分辨率通常明显高于训练输入,直接缩到 640 会让撕口这种小结构糊掉。我的做法是训练输入不低于 960,并且关掉矩形推理,保证 letterbox 的缩放比例一致。

3. 从原始素材到可训练目录:划分、转换与自检

3.1 划分策略:按批次和时间段切,别用随机数切

这一步看着最简单,翻车率却最高。连续抽帧的图片放进随机划分,训练集和验证集里会出现几乎一样的画面,mAP 能刷到 0.98,上线一塌糊涂。

我的规则是按采集时段切分:同一个上午、同一批包材、同一次光源调整拍出来的图,整段进同一个集合,绝不拆散。训练集覆盖多种光照和包材批次,验证集单独留一到两个批次做「没见过」的检验。比例上 8:2 够用,但验证集必须至少包含一批 two 类样本,否则你根本不知道反向包检得怎么样。

如果数据里 two 类明显偏少(现场反向放置本来就是小概率事件),不要靠在验证集里多放 two 来「平衡」,那会把评估指标做虚。正确做法是在训练集里对 two 做过采样或复制粘贴增强,验证集保持真实分布,这样指标才对应产线真实表现。

3.2 标注转换脚本

如果原始标注是 Labelme 的 json,或者是一堆混在一起的 txt,用下面这段统一转成 YOLO 目录。转之前先把每张图对应的标注找齐,缺标注的图直接剔除,不要让空标签图混进训练集。

# convert_to_yolo.py import json import random import shutil from pathlib import Path # 类别序写死,禁止在其他脚本里重复定义 CLASS_MAP = {"one": 0, "two": 1} SRC_IMG = Path("raw/images") SRC_ANN = Path("raw/annotations") # labelme json 目录 DST = Path("seasoning") VAL_RATIO = 0.2 SEED = 42 def labelme_to_lines(json_path: Path, img_w: int, img_h: int) -> list[str]: data = json.loads(json_path.read_text(encoding="utf-8")) lines = [] for shape in data.get("shapes", []): label = shape["label"] if label not in CLASS_MAP: continue # 未知类别直接跳过,不猜 pts = shape["points"] xs = [p[0] for p in pts] ys = [p[1] for p in pts] # 越界坐标夹到图内,避免负值或 >1 导致训练报错 x1, x2 = max(0, min(xs)), min(img_w, max(xs)) y1, y2 = max(0, min(ys)), min(img_h, max(ys)) if x2 - x1 < 4 or y2 - y1 < 4: continue # 极小的误标框丢弃 cx = (x1 + x2) / 2 / img_w cy = (y1 + y2) / 2 / img_h w = (x2 - x1) / img_w h = (y2 - y1) / img_h lines.append(f"{CLASS_MAP[label]} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") return lines def main(): pairs = [] for img_path in sorted(SRC_IMG.glob("*.jpg")): json_path = SRC_ANN / f"{img_path.stem}.json" if not json_path.exists(): print(f"[skip] 无标注: {img_path.name}") continue pairs.append((img_path, json_path)) random.Random(SEED).shuffle(pairs) n_val = max(1, int(len(pairs) * VAL_RATIO)) split = {"val": pairs[:n_val], "train": pairs[n_val:]} for subset, items in split.items(): (DST / "images" / subset).mkdir(parents=True, exist_ok=True) (DST / "labels" / subset).mkdir(parents=True, exist_ok=True) for img_path, json_path in items: from PIL import Image with Image.open(img_path) as im: img_w, img_h = im.size lines = labelme_to_lines(json_path, img_w, img_h) if not lines: continue # 转完没框的图不进数据集 shutil.copy2(img_path, DST / "images" / subset / img_path.name) (DST / "labels" / subset / f"{img_path.stem}.txt").write_text( "\n".join(lines), encoding="utf-8" ) if __name__ == "__main__": main()

逻辑上分四步:建立图片和标注的一一对应,按固定种子打乱后切分,逐张读原始尺寸做归一化,最后落盘。几个参数需要按你的实际情况改。VAL_RATIO控制验证集比例,样本量小于 500 张时建议提到 0.25,否则 two 类在验证集里可能只有十几张,指标抖动大得没法看。SEED固定住是为了复现,别每次跑都换。坐标夹取那两行是必须的,Labelme 手画的框越过图像边界很常见,不夹会在训练时抛归一化越界。

要注意random.shuffle是按文件切的。如果你的数据是连续抽帧,这样做仍然会泄漏,必须改成按时间段分组后整组切分,具体见 3.1。

3.3 转换后自检:三个统计量足够发现大部分问题

转完不检查,等于把雷埋进训练。我固定跑下面这段统计,看三个数:每类框数量、每类框的面积中位数、有没有空标签。

# check_labels.py import statistics from collections import Counter, defaultdict from pathlib import Path NAMES = {0: "one", 1: "two"} root = Path("seasoning/labels") for subset in ("train", "val"): cnt = Counter() areas = defaultdict(list) empty = 0 for txt in (root / subset).glob("*.txt"): lines = [l for l in txt.read_text().splitlines() if l.strip()] if not lines: empty += 1 continue for line in lines: c, cx, cy, w, h = line.split() c = int(c) cnt[NAMES[c]] += 1 areas[NAMES[c]].append(float(w) * float(h)) print(f"--- {subset} ---") print("空标签文件:", empty) for k in ("one", "two"): a = areas[k] med = statistics.median(a) if a else 0 print(f"{k}: 框数={cnt[k]}, 面积中位数={med:.4f}")

判断标准很直接。第一,one 和 two 的框数量比例如果超过 10:1,说明反向样本严重不足,训练时会明显偏向 one,需要过采样。第二,两类框的面积中位数应该接近,因为都是同一个包,如果 two 的面积中位数只有 one 的一半,多半是标注时把反向包框小了,或者混进了别的物体。第三,空标签文件数必须是 0,出现就说明有图漏标,回去补。

4. 训练配置:把翻转类增强全部关掉

4.1 方向敏感任务的增强白名单与黑名单

这是整个流程里最容易翻车的地方,也是这类数据集和普通检测数据集最大的区别。

调料包的正反,本质是 180 度旋转关系。也就是说,你把一张 one 的图做一次水平翻转,画面看起来就是 two 的样子,但标签还写着 one。模型学到的就是「长得像 two 的东西其实是 one」。这种脏标签不会让 loss 爆炸,只会让模型在现场表现得像玄学——训练集指标漂亮,实际一到反向包就犹豫。

所以增强必须分类处理:

增强项建议值原因
fliplr(水平翻转)0.0等价于把 one 翻成 two,标签失效
flipud(垂直翻转)0.0同上,180 度旋转的另一种实现
degrees(随机旋转)0.0 或 ±10大角度旋转等价于改变朝向,小角度只用来模拟摆放歪斜
mosaic0.0~0.3四图拼接不改变朝向,但会缩小目标,工位场景收益有限
scale0.3~0.5模拟相机高度差异和包体鼓包,安全
translate0.1模拟包体在视野内偏移,安全
hsv_h / hsv_s / hsv_v0.015 / 0.5 / 0.3覆盖不同批次包材色差和光源衰减,安全
copy_paste视 two 数量定对 two 做过采样时很有用,注意粘贴位置别叠到已有包上

表里前两行是硬性约束,不管你的框架默认值是什么,都必须显式改成 0。

4.2 data.yaml 与训练命令

先把数据集描述文件写对。names 的顺序必须和转换脚本里的 CLASS_MAP 完全一致,这是唯一一处不能靠记忆的地方。

# seasoning.yaml path: ./seasoning train: images/train val: images/val names: 0: one 1: two

训练命令把增强参数一并显式传入,不要依赖默认值。不同框架版本的默认 fliplr 通常是 0.5,不改就是给自己埋雷。

yolo detect train \ model=yolov8s.pt \ data=seasoning.yaml \ imgsz=960 \ epochs=200 \ batch=16 \ workers=8 \ fliplr=0.0 \ flipud=0.0 \ degrees=0.0 \ mosaic=0.2 \ scale=0.4 \ translate=0.1 \ hsv_h=0.015 hsv_s=0.5 hsv_v=0.3 \ patience=40 \ project=runs/seasoning

逐项说一下为什么这么给。imgsz=960是为了保住撕口和图案细节,代价是显存占用约为 640 的 2.2 倍,单卡 16G 上 batch 大概只能到 16,如果显存吃紧就降到 8 配合梯度累积。epochs=200配patience=40,是因为这个任务收敛其实很快,通常 60~80 轮就基本平了,后面留着是为了等 two 类的召回慢慢爬上来。scale=0.4不算激进,产线上包体鼓包程度不同,尺度抖动是真实存在的,这个值能覆盖住。

mosaic=0.2是个折中。完全关掉,模型对背景变化的鲁棒性会差一点;开到 1.0,四图拼接会把单包压得很小,反而削弱方向特征。0.2 到 0.3 之间是我试下来比较稳的区间。

4.3 类别不平衡与置信度校准

如果自检发现 two 只有 one 的三分之一以下,光靠增强不够,得在采样上下功夫。最简单的是复制 two 的图片并改名,让它在训练集里重复出现两到三次,注意复制时要连带标签一起复制,并且验证集不能这么做。稍微讲究一点的做法是用 copy_paste,把 two 的包体抠出来贴到不同背景上,同时生成新标签,这样既增加数量又增加多样性。

另一个常被忽略的点是置信度校准。两类检测在不平衡数据上训练完,two 的置信度分布整体会比 one 低一截,因为模型见得少、把握小。如果直接拿 0.5 当阈值,one 检得挺好,two 大面积漏。所以阈值不能拍脑袋定,必须看分布,具体做法放在第 6 章。

5. 避坑与排查:五条现场记录

5.1 训练指标 0.97,上线反向包漏检一半

现象:验证集 mAP50 很高,混淆矩阵里 one 和 two 都干净,但现场连续跑一天,反向包检出率不到一半。

原因:增强配置里 fliplr 没有显式关闭,用的是框架默认的 0.5。每一轮都有一半样本被水平翻转,one 的图被翻成 two 的样子却仍标 one,模型被迫学到「这种朝向也可能是 one」,判断边界被抹平。

解决:把 fliplr、flipud、degrees 全部显式置 0 写入训练命令,不用默认值;重训后在验证集上单独看 two 的召回,正常应该比之前提升十几个点。

5.2 推理结果全是 two,一个 one 都没有

现象:模型训完直接拿现场图测,框画出来了,但类别清一色是 two,置信度还不低。

原因:data.yaml 里的 names 顺序和转换脚本的 CLASS_MAP 对不上,或者后处理里有另一份独立的类别映射写反了。这类错误框架不会报,因为类别数是对的。

解决:把类别映射收敛到一个常量模块,data.yaml 从它生成,后处理也 import 它;转换完成后抽十张图用可视化脚本画框,肉眼确认 one 的图上标的是 one。

5.3 换了批包材,指标断崖式下跌

现象:同一套权重,上一批包材测着挺好,换了一批印刷色偏深的,two 的检出率掉了三成。

原因:训练集里所有样本都来自同一两个批次,模型实际上记住了那一批的颜色和反光模式,没学到方向本身。

解决:按批次分层采样,训练集至少覆盖三批以上包材;hsv_s 和 hsv_v 适当加大到 0.5 和 0.3,模拟色差;验证集单独留一个没进训练的批次,作为上线前的真实门槛。

5.4 同一包上出两个框,类别还互相打架

现象:反向包上同时出现一个 one 框和一个 two 框,NMS 之后两个都留着,下游逻辑不知道该信谁。

原因:one 和 two 外观高度相似,模型在边界样本上会同时激活两类。默认 NMS 是类内抑制,不同类别之间不互相压制,所以两个框都能活下来。

解决:这个场景有个业务前提——每个工位视野里一包只有一个结论。所以后处理改成先做类别无关的 NMS,再对剩下的框按置信度取 top-1;如果 top-1 的置信度低于阈值,输出「未识别」交给人工复核,不要硬判。

5.5 标注框画得过紧,增强后目标被裁掉一半

现象:训练正常,但现场包体靠近视野边缘时经常漏检。

原因:标注时框贴着包体轮廓画,scale 和 translate 增强一作用,包体被推到图像边界外,只剩半个目标,模型学到的是残缺特征。

解决:标注时框外扩 2~3 像素留余量;训练时把 translate 控制在 0.1 以内,或者开启边界裁剪保护;验证集里专门放一批包体靠边的图,确认不塌。

6. 上线前的验证:拿混淆矩阵和置信度分布定阈值

模型训完不等于能用,最后一道关是把阈值定下来,并且证明这个阈值在现场站得住。

先跑一次完整验证,把混淆矩阵和每类的 P/R 曲线拉出来:

from ultralytics import YOLO model = YOLO("runs/seasoning/weights/best.pt") metrics = model.val( data="seasoning.yaml", imgsz=960, conf=0.001, # 压低阈值,拿到完整置信度分布 iou=0.6, plots=True, ) print(metrics.box.maps) # 每类 mAP50-95 print(metrics.confusion_matrix) # 看 one->two、two->one 的错分

conf=0.001是为了让所有候选框都吐出来,方便统计分布;正常推理不会用这个值。跑完重点看混淆矩阵的对角线和非对角线:如果 one 被大量判成 two,说明模型对正向样本太保守;如果 two 被大量判成 one,就是漏检反向包,这是产线上更不能接受的方向,优先级更高。

接着按类别统计置信度分位数,用验证集的结果反推阈值:

类别P50 置信度P10 置信度建议阈值
one0.930.780.5
two0.810.420.35

表里的数字是自己跑出来填的,不是固定值。取值思路是:让 two 的阈值落在它 P10 附近偏上,保证真实反向包九成以上能过;one 的阈值可以高一点,因为正向包数量多,误判成 two 的代价是误剔除,比漏检轻。两个阈值分开设,比全局一个阈值靠谱得多。

最后一步别省:从验证集里挑三十张图,用最终阈值跑一遍,人工核对每个框,尤其核对被压到阈值边缘的那些。我吃过一次亏,验证集指标好看,实际是模型把传送带接缝当成了包体的一部分,框大了半圈。从那以后每次定完阈值,我都强制走一遍人工抽检,不看指标只看框。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进