从VOC到YOLO:210张图片的筷子计数检测实战
发布时间:2026/10/2 9:40:00来源:尧图网络
简介这是一份面向目标检测模型训练的筷子计数专用标注数据集包含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 folderJPEGImages/folder filenameIMG_0142.jpg/filename size width1280/width height720/height depth3/depth /size object namechopstick/name bndbox xmin412/xmin ymin198/ymin xmax476/xmax ymax712/ymax /bndbox /object /annotationYOLO格式则是一张图片配一个同名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(xminxmax)/2 / widthymin框上边界像素cy(yminymax)/2 / heightxmax框右边界像素w(xmax-xmin) / widthymax框下边界像素h(ymax-ymin) / heightwidth/height图片尺寸—必须和XML中一致需要注意VOC的XML里带有图片宽高信息这是做归一化转换的唯一可靠来源。如果XML里没有size节点就得用cv2.imread读图拿尺寸但那样脚本会慢不少而且依赖opencv环境。这份数据集里的XML自带完整size节点转换时可以直接读省一步。2.2 手写一个五十分钟内能用上的转换脚本虽然数据集本身已经给了两种格式但你需要知道怎么互转——因为一旦你自己扩充数据或者从其他VOC格式数据集迁移这个能力就是刚需。下面是完整的VOC转YOLO脚本用Python标准库实现不依赖lxml和opencvimport 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_okTrue) 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(fconverted: {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.yamltrain.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(fimages/{name}\n) with open(chopstick_dataset/val.txt, w) as f: for name in val_imgs: f.write(fimages/{name}\n) print(ftrain: {len(train_imgs)}, val: {len(val_imgs)})参数说明相对路径前缀images/是相对于yaml文件位置写的如果你的yaml放在数据集根目录那这个前缀就对如果你的图片实际在data/images那前缀也要改成data/images/。固定seed为42是为了后续调参时排除数据划分带来的干扰。然后写data.yamlpath: /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.JPGtxt叫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数量虚检框重复匹配现象训练完看混淆矩阵发现预测框的TPFP数量远大于真实标注框总数手工数了一下预测输出明显有同一个筷子被两个框框住的情况。原因这就是“yolo混淆矩阵总合不唯一”的典型来源。NMS非极大值抑制阈值设置过高或者置信度阈值过低时同一个目标会输出多个高IoU的预测框矩阵在对齐时把多个预测框都算成了匹配项。解决先跑一次验证把NMS的IoU阈值从默认的0.45降到0.3yolo 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变NaNBN直接崩溃现象训练到第60轮左右loss突然变成NaNlog里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_thresh0.25, iou_thresh0.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(reverseTrue) 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、要不要做同区域聚类合并。这个习惯救了我好几次希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网