☰
从VOC到YOLO:210张图片的筷子计数检测实战
2026/10/2 9:39:55 网站建设 项目流程

简介:这是一份面向目标检测模型训练的筷子计数专用标注数据集,包含210张JPEG原图、210个VOC格式XML标注文件,以及对应的YOLO格式TXT标注文件(共212个TXT,其中210个为标注数据)。数据集覆盖单一类别label,总框数14872个,由labelImg工具人工绘制矩形框标注,框体位置准确合理,可直接用于YOLO、SSD、Faster R-CNN等常见检测框架的训练与验证。压缩包共632个文件,整体大小约139.68MB,目录结构简洁,无需额外格式转换即可读取使用。目前已有558人学习下载,适合需要快速获取餐盘、后厨等场景中筷子计数训练数据的算法工程师、科研人员及竞赛团队使用。

1. 筷子计数标注数据集:210张照片能撬动的第一个视觉项目

做餐具计数项目的人,多半碰过同一个尴尬:训练集里啥都好看,摄像头一开,筷子叠在一起就开始乱数。这份筷子计数标注数据集,210张真实场景拍摄图,1个类别(chopstick),同时给了VOC和YOLO两种格式,属于那种不惊艳但极其省事的小规模基础资源——直接省掉你爬图、人工标注、写格式转换脚本三件脏活。适合两类人:想快速验证“检测+计数”完整流程的算法工程师,以及被课程设计或毕业设计卡住、需要一个能直接喂给YOLOv5/v8的数据集的学生。别被210这个数字劝退,单类别、小目标、背景相对固定的场景下,它的下限足够跑通一个能用的demo,上限则留给你的数据增强策略和后处理逻辑。

2. VOC与YOLO双格式解析:从XML坐标到txt归一化的转换原理

2.1 两种格式到底差在哪

这份数据集同时提供VOC和YOLO两种标注,不是单纯“给了两份文件”,而是让你在两种生态里都不用写解析器。理解它们的差异,能帮你避免后续喂给训练框架时出现坐标错乱。

VOC格式是一张图片配一个同名XML文件,标注是像素绝对坐标:

<annotation> <folder>JPEGImages</folder> <filename>IMG_0142.jpg</filename> <size> <width>1280</width> <height>720</height> <depth>3</depth> </size> <object> <name>chopstick</name> <bndbox> <xmin>412</xmin> <ymin>198</ymin> <xmax>476</xmax> <ymax>712</ymax> </bndbox> </object> </annotation>

YOLO格式则是一张图片配一个同名txt文件,每行五个数字,表示的是归一化后的中心点坐标和宽高:

0 0.346875 0.631944 0.050000 0.713889

这里第一个0是类别编号,0.346875是中心点x除以图片宽度,0.631944是中心点y除以图片高度,后面两个是框宽、框高各自除以图片宽高后的比例。关键点在于:YOLO的宽高是绝对值归一化,不是“中心点加减偏移量”。很多人自己手写转换时会把w算成(xmax-xmin)/2再除以图宽,结果框全部缩小一半,这就是典型翻车现场。

下面这张表把两种格式的字段对应关系理清,方便你对照排查:

VOC字段含义YOLO字段计算方式
xmin框左边界(像素)cx(xmin+xmax)/2 / width
ymin框上边界(像素)cy(ymin+ymax)/2 / height
xmax框右边界(像素)w(xmax-xmin) / width
ymax框下边界(像素)h(ymax-ymin) / height
width/height图片尺寸—必须和XML中一致

需要注意,VOC的XML里带有图片宽高信息,这是做归一化转换的唯一可靠来源。如果XML里没有size节点,就得用cv2.imread读图拿尺寸,但那样脚本会慢不少,而且依赖opencv环境。这份数据集里的XML自带完整size节点,转换时可以直接读,省一步。

2.2 手写一个五十分钟内能用上的转换脚本

虽然数据集本身已经给了两种格式,但你需要知道怎么互转——因为一旦你自己扩充数据,或者从其他VOC格式数据集迁移,这个能力就是刚需。下面是完整的VOC转YOLO脚本,用Python标准库实现,不依赖lxml和opencv:

import os import xml.etree.ElementTree as ET def voc_to_yolo(xml_file, class_names, out_dir): """ 将单个VOC XML标注转换为YOLO txt标注 :param xml_file: VOC标注文件绝对路径 :param class_names: 类别列表,例如 ['chopstick'] :param out_dir: 输出txt文件目录 """ tree = ET.parse(xml_file) root = tree.getroot() # 读取图片尺寸,做归一化的分母 size = root.find('size') img_w = int(size.find('width').text) img_h = int(size.find('height').text) txt_name = os.path.splitext(os.path.basename(xml_file))[0] + '.txt' lines = [] for obj in root.findall('object'): name = obj.find('name').text if name not in class_names: continue # 跳过未定义的类别 class_id = class_names.index(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) # 中心点与宽高,全部做归一化 cx = (xmin + xmax) / 2.0 / img_w cy = (ymin + ymax) / 2.0 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h # 裁剪到[0,1]区间,防止坐标越界造成训练loss跑飞 cx = max(0.0, min(cx, 1.0)) cy = max(0.0, min(cy, 1.0)) w = max(0.0, min(w, 1.0)) h = max(0.0, min(h, 1.0)) lines.append(f"{class_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") if lines: with open(os.path.join(out_dir, txt_name), 'w') as f: f.write('\n'.join(lines) + '\n') # 没有目标时跳过,不留空文件,避免训练时被当作负样本误读

逻辑说明:脚本先读取XML里的图片宽高,这是归一化计算的分母;再遍历所有object节点,把像素坐标系里的左上角/右下角坐标换算成中心点坐标和宽高比例。输出格式严格对齐YOLO要求:每行一个目标,五个字段,末尾换行,坐标保留6位小数。

参数说明:class_names列表的顺序决定了类别编号,如果你只有一个chopstick类别,列表就写['chopstick'],编号固定为0。out_dir必须提前创建,脚本不会自动建目录。裁剪到[0,1]的操作很多教程会忽略,但它能挡住一些标注越界的脏数据。

调用方式很简单,遍历Annotations目录即可:

if __name__ == '__main__': xml_dir = 'data/Annotations' out_dir = 'data/labels' os.makedirs(out_dir, exist_ok=True) class_names = ['chopstick'] for xml_file in os.listdir(xml_dir): if xml_file.endswith('.xml'): voc_to_yolo(os.path.join(xml_dir, xml_file), class_names, out_dir) print(f'converted: {xml_file}')

2.3 转换后的目录结构要和YOLO训练框架对齐

YOLO系列训练时默认的目录约定是:图片放在images目录下,标注txt放在labels目录下,且两者文件名必须完全相同(扩展名不同)。常见做法是给训练脚本传data.yaml,里面写好图片根路径。你拿到这份数据集后,建议整理成下面这样:

chopstick_dataset/ ├── images/ │ ├── IMG_0142.jpg │ └── ... ├── labels/ │ ├── IMG_0142.txt │ └── ... ├── train.txt ├── val.txt └── data.yaml

train.txt和val.txt里存的是图片的绝对路径或相对于yaml的路径,每行一张图。有些框架版本不要求这两个文件,直接靠yaml里写目录名,但保留它们能让你的数据划分更可控,尤其是做随机划分后想复现结果时,固定住这两个文件比每次都重新shuffle靠谱得多。

3. 训练一个筷子检测器:数据配置、超参与完整命令

3.1 数据划分与yaml配置:210张图要留出多少验证集

210张图属于典型的小规模数据集,划分比例直接影响你的验证集可信度。按我的习惯,随机抽80%训练、20%验证,大概168张训练、42张验证,这个比例在单类别场景下够用。如果验证集低于30张,mAP指标的波动会非常大,可能这轮0.85下一轮0.72,那不是模型问题,是样本太少。

用Python做划分比用shuf命令更稳,因为可以固定随机种子让结果可复现:

import os import random random.seed(42) # 固定种子,保证每次划分结果一致 img_dir = 'chopstick_dataset/images' imgs = [f for f in os.listdir(img_dir) if f.endswith('.jpg')] random.shuffle(imgs) train_ratio = 0.8 split_idx = int(len(imgs) * train_ratio) train_imgs = imgs[:split_idx] val_imgs = imgs[split_idx:] # 写入相对路径(相对于yaml文件所在目录),训练框架会自动拼接 with open('chopstick_dataset/train.txt', 'w') as f: for name in train_imgs: f.write(f'images/{name}\n') with open('chopstick_dataset/val.txt', 'w') as f: for name in val_imgs: f.write(f'images/{name}\n') print(f'train: {len(train_imgs)}, val: {len(val_imgs)}')

参数说明:相对路径前缀images/是相对于yaml文件位置写的,如果你的yaml放在数据集根目录,那这个前缀就对;如果你的图片实际在data/images,那前缀也要改成data/images/。固定seed为42是为了后续调参时排除数据划分带来的干扰。

然后写data.yaml:

path: /absolute/path/to/chopstick_dataset # 数据集根目录,改成你自己的绝对路径 train: train.txt val: val.txt names: 0: chopstick

注意:names是字典格式,编号必须从0开始连续,类别名必须和标注txt里的编号对应。如果你自己重新转换过XML,一定要检查类别编号有没有错位——编号写错了模型也能训练,但loss永远降不下去,混淆矩阵会告诉你真相。

3.2 训练命令与核心超参:从预训练权重到batch size

这份数据集量级小,不建议从头训练,用COCO预训练权重做迁移学习能省大量时间。YOLOv8的命令格式如下,v5也基本兼容:

yolo detect train \ --model yolov8s.pt \ --data chopstick_dataset/data.yaml \ --epochs 100 \ --batch 16 \ --imgsz 640 \ --patience 20 \ --device 0

命令说明:--model yolov8s.pt会自动下载COCO预训练权重到当前目录或缓存目录,这是让模型快速收敛的关键;--data指向第3.1节写的yaml文件;--epochs 100对小数据集够用,通常50轮左右loss就平了;--patience 20表示20轮没有提升就早停,可以帮你省时间;--device 0表示用第一块GPU,没有GPU可以改成cpu,但训练速度会慢很多。

参数调优建议:这套数据用的是640分辨率输入,筷子这类细长目标占图比例不大,如果你觉得小目标漏检严重,可以把imgsz提到960或1280,代价是显存占用和训练时间翻倍。V100级别以上显卡,100轮大概十几分钟能跑完,完全来得及反复试参数。

数据增强方面,210张图单靠原始样本是喂不饱模型的。默认的Mosaic增强对小数据集有利有弊——它能把四张图拼在一起让模型看到更多上下文,但到了训练后期反而会干扰细长目标的边界回归。常见做法是训练到80轮左右关掉Mosaic,在YOLOv8里可以这么指定:

yolo detect train \ --model yolov8s.pt \ --data chopstick_dataset/data.yaml \ --epochs 100 \ --batch 16 \ --imgsz 800 \ --close_mosaic 20 \ --hsv_h 0.015 \ --hsv_s 0.3 \ --hsv_v 0.2

--close_mosaic 20表示最后20个epoch关闭Mosaic增强;hsv_h/s/v三个值控制色调、饱和度、明度的随机扰动幅度,对筷子这种木质纹理目标,饱和度扰动设到0.3左右能增加模型对光照变化的鲁棒性,但不要超过0.4,否则颜色失真严重。

3.3 训练过程中的三个观察点

训练日志里每轮会输出box_loss、cls_loss、dfL等指标,大部分人会直奔mAP,但小数据集训练时我建议先看loss曲线。核心观察点有三个:

第一,loss是否在合理下降。box_loss通常在20轮内从0.1以上降到0.05以下才算正常,如果loss从一开始就不降甚至在0.7附近震荡,先查数据路径有没有配错、标注文件是不是空文件。

第二个观察点是val/box_loss和train/box_loss的差值。这个差值超过30%就要警惕过拟合,因为210张图的量级很容易让模型把训练集背下来。过拟合的典型特征是训练loss持续下降、验证loss在第50轮开始回升,对应mAP在验证集上卡住不涨。

第三,分类分支的置信度分布。训练结束后打印出验证集每张图的置信度,正常情况下筷子的置信度集中在0.6到0.95之间;如果大量目标置信度低于0.3,说明特征学习不充分,优先去调imgsz和数据增强,而不是加大epoch。

4. 210张小数据集的四个坑:过拟合、漏标与训练翻车现场

这份数据我前后折腾过三轮,也帮人排查过几次。单类别、小数据、细长目标这几个特征叠加,训练时容易踩到一些不明显的坑。按“现象→原因→解决”写下来,能帮你少走弯路。

坑一:训练loss正常下降,但验证集mAP一直是0

现象:训练时box_loss从0.12降到0.04,看起来很健康;但打印出来的mAP50恒为0,检测结果也是一片空白。

原因:最常见的是标签文件和图片文件名对不上。YOLO读取labels目录时是按图片名找同名txt的,如果你从VOC转YOLO时文件名大小写不一致(比如图片叫IMG_0142.JPG,txt叫IMG_0142.txt),框架会静默跳过,该图就被当作无目标图片参与训练。

解决:训练前写一个校验脚本,遍历images目录,检查每张图是否有对应的txt文件:

for f in images/*.jpg; do base=$(basename "$f" .jpg) if [ ! -f "labels/$base.txt" ]; then echo "missing label for $base" fi done

我一般会顺手检查txt内容是否为空,空文件的图片在训练时会被当作背景类,数量多了会严重稀释正样本。

坑二:混淆矩阵总和不等于GT数量,虚检框重复匹配

现象:训练完看混淆矩阵,发现预测框的TP+FP数量远大于真实标注框总数,手工数了一下预测输出,明显有同一个筷子被两个框框住的情况。

原因:这就是“yolo混淆矩阵总合不唯一”的典型来源。NMS(非极大值抑制)阈值设置过高,或者置信度阈值过低时,同一个目标会输出多个高IoU的预测框,矩阵在对齐时把多个预测框都算成了匹配项。

解决:先跑一次验证,把NMS的IoU阈值从默认的0.45降到0.3:

yolo detect val \ --model runs/detect/train/weights/best.pt \ --data chopstick_dataset/data.yaml \ --conf 0.25 \ --iou 0.3

--conf指置信度阈值,筷子这类目标形态简单,0.25是合理起点;--iou指NMS使用的IoU阈值,降下来能有效压掉重叠重复框。如果降完之后检测数量正常了,调参方向就明确。

坑三:训练到一半loss变NaN,BN直接崩溃

现象:训练到第60轮左右,loss突然变成NaN,log里batch_norm的running_mean也异常。

原因:小数据集加上高学习率时,BN层在batch size较小时统计量不稳定,容易出现“bn崩溃”问题。尤其是bach size设为16但图片里目标极稀疏时,某些batch可能只有一个目标甚至没有目标,BN的方差估计退化。

解决:最直接的办法是把batch size提到32或更大,如果显存不够就降低输入尺寸到512。另一手准备是把学习率从默认的0.01降到0.005,给训练留出稳定空间:

yolo detect train \ --model yolov8s.pt \ --data chopstick_dataset/data.yaml \ --epochs 100 \ --batch 32 \ --imgsz 512 \ --lr0 0.005

顺带一提,如果还炸,检查一下是不是自定义数据里混入了全黑或全白图片,这类极端输入的方差会直接击穿BN层。

坑四:验证集mAP不低,但实际场景一测就漏检

现象:训练集和验证集mAP都在0.85以上,但拿到食堂真实场景一拍,重叠摆放的筷子漏了一大半。

原因:小数据集划分时是纯随机划分,验证集和训练集可能来自同样的拍摄场景,画面内容高度相似,导致验证分数虚高。模型学会的是“记住这个背景里的筷子”,而不是“理解筷子形状”。

解决:划分训练验证集时按场景分,不按图片随机分。如果一份数据来自同一次拍摄,至少要保证同一批次中连续帧不要全集出现在训练集或全集出现在验证集。另外后处理时对检测框做一个更高置信度的兜底逻辑:图像上的预期筷子数量与非重叠区域的检测框数量做比对,低于预期就用更低置信度阈值再跑一遍推理。

5. 从检测框到筷子根数:计数后处理与验证技巧

训练出一个能出框的检测器只是第一步,筷子计数项目的最终输出是一个整数——这盘有多少根筷子。YOLO输出的是若干(class_id, conf, x1, y1, x2, y2)元组,如何把这些框变成根数,这里有具体的后处理逻辑要写。

关键先厘清数据集标注语义:这份数据集的标注是“一根筷子一个框”,所以图像中框的数量近似等于筷子根数。但实际推理时会有漏检和虚检,直接len(boxes)并不可靠。我一般会写一个带置信度过滤和同区域去重的计数函数:

def count_chopsticks(pred_boxes, conf_thresh=0.25, iou_thresh=0.3): """ 将检测结果转换为筷子根数 :param pred_boxes: 模型输出的框列表,每个元素为 [conf, x1, y1, x2, y2] :param conf_thresh: 置信度过滤阈值 :param iou_thresh: 重叠框合并阈值 :return: 筷子根数 """ # 第一步:按置信度过滤 valid = [box for box in pred_boxes if box[0] >= conf_thresh] if not valid: return 0 # 第二步:按置信度降序排列,大框优先保留 valid.sort(reverse=True) kept = [] for box in valid: too_close = False for k in kept: iou = compute_iou(box[1:], k[1:]) if iou > iou_thresh: too_close = True break if not too_close: kept.append(box) return len(kept)

逻辑说明:先按置信度把置信度低于阈值的框扔掉,再把剩下的框按置信度从高到低排序,逐个检查是不是和已保留的框重叠度太高,如果IoU超过阈值就认为是同一根筷子的重复检测,跳过。compute_iou函数在这里不做展开,是标准的目标检测IoU计算,用numpy或numba实现都行。

参数说明:conf_thresh在训练阶段验证时的0.25直接沿用即可,但实际部署时建议提高到达0.3或0.35,因为实拍场景的背景更杂;iou_thresh和推理时的NMS阈值保持一致,设置过低会把相互靠近但确实是两根的筷子错并成一根。

验证这套流程是否可靠,我有两个习惯。一个是目检法:随机抽50张现场照片,把模型输出叠加到图上,人工数一遍框的数量和实际视觉可见的筷子数,记录偏差。另一个是误差表法,对一批测试图统计“预测根数减真实根数”的分布,正常情况下误差应该在±1根以内浮动,如果系统性偏少,优先去调置信度阈值而不是重新训练。

有一回我直接丢给后厨阿姨试,她说“你数错了”,我回去一查,发现是标注语义没吃透——XML里筷子是一根一根标的,我后处理却按“一双”乘了2,结果直接翻倍。从那以后我每次拿到新数据集的第一件事,就是随机抽5张图打开XML看标注框,再决定后处理乘不乘2、要不要做同区域聚类合并。这个习惯救了我好几次,希望帮到你。

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

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

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

立即咨询