YOLO笔识别实战:从数据集标注到模型训练与调优全攻略
发布时间:2026/10/2 8:55:07来源:尧图网络
简介目标检测技术已广泛应用于工业质检、智能监控与桌面场景分析其中小目标检测始终是工程落地的难点。笔作为典型的杆状小目标具有极端长宽比、类内差异大、易受光照和遮挡干扰等特点对数据集的构建与标注提出了更高要求。训练一个可靠的笔识别模型关键不在于选择哪个YOLO版本而在于自采数据的多样性、标注规范的严谨性以及标签格式转换的正确性。通过合理设计拍照策略、使用Labelme规范标注、转换YOLO格式并运用数据划分与清洗、增强参数调整、切图推理等手段可有效解决小目标漏检和误检问题。该方案适用于智能笔筒、桌面整理、美术教具识别等场景为细长小目标检测提供了可复现的工程路径。1. 笔识别为什么比通用目标检测更考验数据集小目标与类内差异的双重夹击YOLO 做笔识别听起来是目标检测里最没有排面的任务笔是杆状物特征简单背景干净随便跑跑都有九十几的 mAP。真正上手才发现这是典型的小目标加极端长宽比任务一支笔在 1080P 桌面上往往只有几十像素宽铅芯、笔帽、高光都会把模型带偏圆珠笔、钢笔、铅笔、马克笔共享同一个「笔」标签标注框长宽比从 1:3 到 1:20 都有而这正是 YOLO 系列最不擅长的输入分布。所以这个标题的价值不在选哪个 YOLO 版本而在数据集——怎么拍、怎么标、怎么转格式、怎么增强直接决定你收到的是一个能落地的笔识别模型还是一张漂亮的混淆矩阵图。适合正在做智能笔筒、桌面整理、书写姿态监测或美术教具识别的开发者按下面的流程复现一遍。2. 从零组装笔识别数据集拍照策略、公开素材与 Labelme 标注要点笔这个类目几乎没有现成的专门数据集。CUB 鸟类、SemanticKITTI、高光谱图像数据集这些公开集跟笔八竿子打不着目标检测常用公开集里最多在 COCO 的 office supplies 类目下蹭到一些低分辨率的笔数量少且多为俯拍。所以做笔识别数据集的组建是主菜训练只是验证。常见做法是自采为主、公开素材为辅核心目标是让图片覆盖「笔在真实桌面上会出现的样子」而不是收集一堆干净背景的商品图。2.1 笔这个类目标注几类单类与细分类的取舍先决定标注粒度。单类「pen」训练样本利用率最高模型只学「这是一支笔」漏检率最低适合做笔的计数、存在性检测、区域留存。细分类ballpoint、fountain、pencil、marker对后续的笔型统计、美术教具识别有意义但代价是标注成本翻倍且钢笔和圆珠笔在俯拍视角下轮廓高度相似分类头会内耗把精力花在区分它们不如花在提升召回上。第一版我建议先做单类。等单类模型在实拍里稳定了再在同一批图片上做第二遍细标注重训图片不用重采。这样能最快验证「笔识别」这个方向的可行性也避免一上来就被标注任务劝退。如果场景确实需要区分笔型可以按下面的粒度取舍应用场景建议标注粒度理由笔的计数、桌面笔是否存在单类 pen样本多、训练快、漏检少智能笔筒按类型分类收纳单类 pen 后置分类模型检测与分类解耦各自优化书写姿态监测笔在手中单类 pen手持笔遮挡多细分只会更乱美术教具识别、库存盘点分笔型多类识别价值主要集中在笔型2.2 拍照与素材收集覆盖角度、光照和背景多样性自采阶段的目标不是拍得好看而是把「变体」拍全。笔的变体轴包括背景木质桌面、白色桌面、书本、键盘、手边杂物、光照自然光侧射、台灯顶射、阴影遮挡、朝向水平、斜放、竖直靠墙、笔帽朝左/朝右、笔本身至少准备 5 支不同颜色和材质的笔、状态盖帽、脱帽、手持、与数据线或尺子混放。按 3 种背景、4 种光照、6 种朝向、5 支笔去排组合轻松能到 300 张以上再加手持和混放场景凑到 600 到 1000 张训练图并不难。公开素材可以补量但有三个注意点。一是版权优先找明确允许非商业或学术使用的图片别从搜索引擎随手扒。二是分辨率很多公开图里的笔连 40 像素都不到标出来也是小目标噪音宁缺毋滥。三是混合方式公开图和自采图要混在一起后重新划分训练验证集不要把公开图单独塞进验证集否则分布差异会让 mAP 虚低。视频抽帧也是个好来源录一段在桌面上拿笔、放笔、换笔的短视频每 1 秒抽 1 帧能快速获得连续姿态样本。2.3 标注工具与标注规范labelme 的矩形框该框到哪标注工具我固定用 labelme它是目标检测常用标注工具里上手最快的矩形框工具点两个对角点就完成一个框输出 JSON 带坐标和图片尺寸转换脚本几行就能写。CVAT 和 X-AnyLabeling 也有各自的优势但单人小规模标注labelme 的零部署优势最大。标注规范必须在动手前定死否则返工到哭。我的规范是框贴笔身最外层含笔帽和笔夹但不含阴影笔尖算进框内因为笔尖是笔识别的重要特征很多漏检就是标注时把笔尖切掉了透明笔身按可见墨迹的外沿标。两个具体边界要提前定好笔斜放时 labelme 的矩形工具不支持旋转画出来的轴对齐框会带大量背景这种情况我一般接受因为 YOLO 训练时框内的背景会被当作前景上下文只要不是框只框住一半笔身就行遮挡超过三分之一的笔删掉不标两笔交叉时各标各的露出部分不要硬把框补全。3. 把标注转成 YOLO 能吃的格式labelme 转 txt 脚本与四个边界坑labelme 的 JSON 和 YOLO 的 txt 格式差异不大无非是绝对坐标转归一化坐标、多边形转矩形但转换脚本里藏着不少细节坑。这个环节翻车的血泪经验我都踩过下面把脚本和边界条件一次说清。3.1 labelme 的 JSON 结构长什么样一张图画一个框labelme 会存成这样{ version: 5.2.1, imagePath: pen_001.jpg, imageWidth: 1920, imageHeight: 1080, shapes: [ { label: pen, group_id: null, shape_type: rectangle, points: [[310, 420], [680, 452]] } ] }注意 shape_type 是 rectangle 时points 里只有两个对角点如果有人用 create_polygon 手动描了四条边points 就会有五个点首尾重复。所以转换脚本不能假设 points 只有两个点要用 min/max 把所有点的坐标压成外接矩形这样两种画法都能兼容。3.2 转 YOLO 格式归一化中心点脚本YOLO 的标签格式是class_id cx cy bw bhcx、cy 是中心点坐标bw、bh 是宽高全部归一化到 0~1。下面是完整的转换脚本import json import os from glob import glob # labelme 里标注的名字 - YOLO 类别 id CLASS_MAP {pen: 0, Pen: 0, PEN: 0} def convert_labelme_json(json_path, out_dir): with open(json_path, r, encodingutf-8) as f: data json.load(f) img_w data[imageWidth] img_h data[imageHeight] # 用 json 文件所在目录下的同名图片名生成 txt 名 img_name os.path.splitext(os.path.basename(json_path))[0] .jpg out_path os.path.join(out_dir, os.path.splitext(img_name)[0] .txt) lines [] for shape in data[shapes]: label shape[label].strip() if label not in CLASS_MAP: continue # 兼容两点矩形和多边形取所有点的最小外接矩形 xs [p[0] for p in shape[points]] ys [p[1] for p in shape[points]] x1, y1, x2, y2 min(xs), min(ys), max(xs), max(ys) # 越界裁剪框超出图片边界会导致训练报错或 loss 异常 x1 max(0.0, min(x1, img_w)) y1 max(0.0, min(y1, img_h)) x2 max(0.0, min(x2, img_w)) y2 max(0.0, min(y2, img_h)) if x2 - x1 1 or y2 - y1 1: continue # 归一化到 0~1 cx (x1 x2) / 2.0 / img_w cy (y1 y2) / 2.0 / img_h bw (x2 - x1) / img_w bh (y2 - y1) / img_h lines.append(f{CLASS_MAP[label]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) # 没有有效框的图不生成空 txt后续用负样本目录单独处理 if lines: with open(out_path, w, encodingutf-8) as f: f.write(\n.join(lines) \n) if __name__ __main__: label_dir labelme_json # labelme 输出目录 out_dir yolo_labels # YOLO 标签输出目录 os.makedirs(out_dir, exist_okTrue) for json_path in glob(os.path.join(label_dir, *.json)): convert_labelme_json(json_path, out_dir) print(fprocessed: {json_path})脚本逻辑对每个 JSON 读取图片尺寸遍历 shapes 里每个标注把矩形坐标压成 x1、y1、x2、y2做越界裁剪后归一化。关键点是 min/max 兜底和越界裁剪——labelme 里手滑拉出去的框、画完又移动过的点可能出现坐标超出图片宽高YOLO 训练碰到这类标签会直接报「all class id is zero」或定位损失异常。CLASS_MAP 里故意写了三个大小写变体避免手工标注时粗细写不一致导致标签静默丢失。3.3 脚本跑完必须做的三件事标签检查、负样本筛查、漏标补齐转换完成后别急着训练先做可视化检查。只看 txt 数字最容易漏掉「框贴得偏了」「标签名没对上」这类问题。常见做法是把标签画回图上逐张翻一遍小图再针对小目标单独放大看import cv2 import os from glob import glob def visualize_yolo(img_path, txt_path, class_names(pen,)): img cv2.imread(img_path) h, w img.shape[:2] with open(txt_path, r, encodingutf-8) as f: for line in f.readlines(): parts line.strip().split() if len(parts) ! 5: print(fbad line in {txt_path}: {line}) continue cls, cx, cy, bw, bh int(parts[0]), float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4]) x1 int((cx - bw / 2) * w) y1 int((cy - bh / 2) * h) x2 int((cx bw / 2) * w) y2 int((cy bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, class_names[cls], (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) # 标记宽或高小于 15 像素的小目标这种框很容易标漏 small_count 0 with open(txt_path, r, encodingutf-8) as f: for line in f.readlines(): parts line.strip().split() bw, bh float(parts[3]) * w, float(parts[4]) * h if min(bw, bh) 15: small_count 1 cv2.imshow(f{os.path.basename(img_path)} | small{small_count}, cv2.resize(img, (960, 540))) cv2.waitKey(0) cv2.destroyAllWindows() for txt_path in glob(yolo_labels/*.txt): img_path os.path.join(images, os.path.splitext(os.path.basename(txt_path))[0] .jpg) if os.path.exists(img_path): visualize_yolo(img_path, txt_path)这里除了验证框的位置有一个最容易忽略的点负样本。你的采集场景里一定会有长得像笔但不是笔的东西——筷子、螺丝刀、数据线、温度计。这些干扰物如果不进训练集模型上线后就会把它们误检成笔。处理方式不是把它们标成笔而是保留一批完全空标签的图片丢进训练集让模型在训练时把这些背景学成「不是笔」的负样本。空标签图不要和有效图混在同一个 labels 目录里生成空 txt而是单独放一个images_neg目录划分时混入训练集即可。3.4 数据划分与清洗消除同源帧污染验证集数据划分是笔识别项目里最经典的翻车点。很多人按 8:2 随机打乱图片划分训练验证集结果验证集里全是同段视频、同一批连拍的邻居帧模型等于在考场上见过答案。划分的原则是同一支笔、同一背景、同一时间段拍的图必须进同一个集合绝不能按单张随机切。按拍摄批次或按文件夹划分是最稳的import os import random import shutil SEED 42 VAL_RATIO 0.12 # 按拍摄批次目录划分而不是按单张图片划分 batches [d for d in os.listdir(capture) if os.path.isdir(os.path.join(capture, d))] random.seed(SEED) random.shuffle(batches) val_n max(1, int(len(batches) * VAL_RATIO)) val_batches set(batches[:val_n]) for batch in batches: dest val if batch in val_batches else train for img_name in os.listdir(os.path.join(capture, batch)): if not img_name.endswith((.jpg, .jpeg, .png)): continue os.makedirs(f{dest}/images, exist_okTrue) os.makedirs(f{dest}/labels, exist_okTrue) shutil.copy(os.path.join(capture, batch, img_name), f{dest}/images/{img_name}) txt_name os.path.splitext(img_name)[0] .txt src_txt os.path.join(yolo_labels, txt_name) if os.path.exists(src_txt): shutil.copy(src_txt, f{dest}/labels/{txt_name})同一批次内的连拍帧也要做一遍去重否则一个批次被划分到训练集后它的缓慢连续帧会反复轰炸模型让训练 loss 看着很低但泛化很差。简单做法是抽帧间隔拉大每 10 帧取 1 帧如果视频里的笔位置基本没变间隔还要更大。验证集保留 10% 到 15% 就够关键是不和训练集共享拍摄批次。4. 用 YOLOv8 训练笔识别模型yaml 配置、损失函数与必调参数数据集就绪后训练本身反而是最省心的环节。YOLO 生态已经非常成熟yaml 配好、命令敲下、等玄学出结果就行。但有几个选型和参数值得动脑尤其是笔这种细长小目标一招不慎就变成「训练两小时、识别全靠猜」。4.1 选 v8n 还是 v8s算力、精度和推理速度的三角权衡笔识别对模型容量的需求其实不高难点在数据分布而不是特征提取能力。所以我一般建议从 YOLOv8n 起步而不是一上来就上 v8m 或 v8l。v8n 在 COCO 上的精度只比 v8s 低几个点但推理速度快一大截显存占用也只有一半左右。如果你手头是 V100 级别的卡v8s 甚至 v8m 随便跑但换来的精度提升在笔这个类目上很有限因为笔的特征差异主要来自视角和光照不是来自更深的网络。另一个现实因素是部署如果你后面要接到单片机或边缘盒子这类端侧设备v8n 几乎是唯一现实的选择INT8 量化后还能再快一倍。模型参数量推理速度参考适合场景yolov8n约 3.2M快适合端侧单片机、边缘盒子、快速验证yolov8s约 11.2M中普通 GPU 服务器、精度优先yolov8m约 25.9M慢大图切块推理、极低漏检要求4.2 数据集 yaml 与训练命令的最小配置训练前只需要一个 yaml 文件描述数据路径和类别名放在 datasets 目录下# pen.yaml path: ./datasets/pen train: train/images val: val/images names: 0: pen然后直接开训。首次训练我建议在 ultralytics 官方 release 下载 COCO 预训练权重 yolov8n.pt不要从零训练——预训练权重里已经有通用的边缘和纹理特征笔这种小目标迁移起来收益很大下载就按官方渠道走命令行里指定 modelyolov8n.pt 即可自动拉取yolo detect train \ modelyolov8n.pt \ datapen.yaml \ imgsz640 \ batch16 \ epochs200 \ patience30 \ projectruns/pen \ nameexp这里几个参数按笔识别的特点来定imgsz 保持 640 起步不要贪大上 1280因为 1280 会让显存占用翻接近 4 倍而笔这个类目靠数据增强和切图推理解决小目标问题比靠输入分辨率更划算batch 按显存调16 是最低建议值低于 8 会导致 BN 统计不稳定epochs 200 配合 patience 30 早停笔数据集小通常在 100 到 150 轮就会收敛跑满 200 轮也没什么收益。训练结束后看 runs/pen/exp 下的 results.png确认 loss 曲线不是断崖式波动再进入验证环节。4.3 损失函数与超参数哪些值得动、哪些保持默认YOLOv8 的损失函数由三部分组成box_loss边界框回归用 CIoU 系列、cls_loss分类BCE 系列、dfl_lossDistribution Focal Loss用于框的精细回归。在训练日志里能看到这三个分量分别下降最终 loss 是它们的加权和。对于笔这种小目标场景最常见的现象是 box_loss 贡献占比过大因为细长框的宽高回归比普通物体更难。如果发现 val 集的 box_loss 降不下去可以尝试把 box_loss 的权重从默认值往上调比如在 ultralytics 的默认 loss 权重基础上把 box 项乘以 1.2 到 1.5但这不是首要手段——优先回去检查小目标标注是否漏标。数据增强参数里真正影响笔识别的只有几个scale随机缩放保持默认即可mosaic四图拼接对笔这种小目标非常有用能模拟出「一支笔被跨边缘截断」的样本建议保持开启但 rotation 的增强角度别给太大笔在桌面上的自然姿态大多是倾斜的转 45 度以上会产生大量现实中不存在的竖笔样本让模型学到错误先验。hsv 颜色扰动对笔的身体颜色有一定帮助保持默认。这些参数在哪里改ultralytics 在训练时支持参数覆盖例如yolo detect train ... scale0.5 hsv_h0.015但我的习惯是先全部默认跑一版再动这些不要让超参数调整和数据问题混在一起排查。5. 训练翻车定位手册漏检、误检与标签错位排查清单数据集和训练都跑通之后真正折磨人的是验证阶段。这一章按「现象 → 原因 → 解决」写都是笔识别项目里高频出现的问题定位时直接对着表找。5.1 mAP 很高但实测狂漏验证集与测试集同源现象训练日志里 mAP0.5 有 0.95一拿到实拍视频就漏掉一半的笔尤其是斜放的笔几乎全部漏检。原因数据划分时按单张随机打乱验证集和训练集里混着同一批连拍帧模型记住的是背景纹理不是笔的语义特征。这种「看着很准、实际很虚」的指标是划分方式造成的假象。解决回到 3.4按拍摄批次重新划分训练验证集并保证验证集里没有训练集的同场景连续帧。改完再训练一轮如果 mAP 跌到 0.85 甚至 0.8 以下别慌这才是真实水平。5.2 小目标的铅笔在验证集里频频失踪漏标让模型学不到小样本现象PR 曲线在 recall 偏高段掉得很快验证集里细长的铅笔全部漏检但粗的马克笔都能框住。原因标注规范没定清笔尖归属标注时只框了笔身铅笔尖端露在框外导致模型学到「笔是短粗的」细长目标全部判负。解决重新过一遍小目标样本重点关注宽度小于 20 像素的框。打开可视化脚本逐张看把笔尖纳入框内。如果批量修改不方便直接删掉这些残标图片重新标小目标标注返工是血泪经验中最不能省的一步。5.3 BN 层崩溃类别数写错与 batch 过小现象训练到第 10 个 epoch loss 突然变成 NaN或在验证阶段 BN 层报错模型完全失效。原因两类情况最常见。一是 txt 里的 class id 超过 yaml 里 names 的定义范围比如 yaml 里只有 0: pen但某个 txt 写了个 1二是 batch 太小比如 2加 mosaic 增强BN 统计量在几个差异极大的四图拼接之间震荡直接数值溢出。解决先跑一个脚本扫描所有 txt 里的类别 id确认最大值等于 len(names) - 1再把 batch 提到至少 8。如果 batch 受显存限制上不去就把 imgsz 降到 480而不是硬扛 640。5.4 金属笔身高光被识别成别的细长物体增强策略失当现象模型在黑色金属笔上频繁出现 false positive把高光笔杆判成美工刀或剪刀又或者把桌面上的螺丝刀误检成笔。原因训练集里光照方向太单一高光反射只出现在一种角度模型把这个反光纹理当成了正样本特征。同时负样本图里没有覆盖细长工具模型没有见过「螺丝刀不是笔」。解决采集时拍两个光照方向侧光拍一批、顶光拍一批让高光形态多样化再往负样本目录里加入螺丝刀、筷子、数据线等桌面细长物体的空标签图数量按正样本的 5% 到 10% 加。重新训练后误检会明显回落。5.5 混淆矩阵对角线总和不为 1背景类被忽略导致的误读现象打开混淆矩阵图把对角线上的数值相加发现总和不是 1只有 0.7 左右怀疑模型坏了。原因YOLO 的混淆矩阵除了真实类别列还有一个「background」背景列。每一行的总和才是 1对角线总和当然不等于 1。这个背景列的数值恰恰是误检率的核心反映被误判成背景的比例越高说明漏检越严重。解决别去纠结对角线重点看背景列里每一行的数值分布。如果「pen → background」有较大占比回到 5.2 查漏标如果「background → pen」有占比回到 5.4 查负样本。混淆矩阵是定位漏检和误检方向的最快工具但前提是别把它当简单的对角矩阵读。6. 闭环验证混淆矩阵、PR 曲线与大图推理的小目标补救6.1 用混淆矩阵和 PR 曲线判断欠拟合还是数据问题训练完后先看两样东西confusion_matrix.png 里 pen 行的分布以及 PR 曲线在 recall 高段的表现。PR 曲线掉得越平缓说明模型在不同置信度下都能保持高召回这是一个健康的笔识别模型应有的样子如果曲线在 recall 0.6 之后断崖下跌说明低置信度目标全是误检小目标特征没学好。这时再结合混淆矩阵的背景列就能判断该补数据还是补负样本而不是盲目调一轮超参数玄学。笔识别这种数据规模不大的任务验证阶段的结论几乎都指向数据调参只是点缀。6.2 大图推理的切图策略小目标最后的补救手段如果上面都做完了实拍视频里仍有少量远端小笔漏检最后的补救手段是切图推理把 1080P 或更高分辨率的大图切成多个 640 的 tile各自推理后把坐标拼回原图import cv2 import numpy as np from ultralytics import YOLO model YOLO(runs/pen/exp/weights/best.pt) def infer_big(img_path, tile640, overlap0.2, conf0.25): img cv2.imread(img_path) h, w img.shape[:2] step int(tile * (1 - overlap)) boxes [] for y0 in range(0, h - tile step, step): for x0 in range(0, w - tile step, step): crop img[y0:y0 tile, x0:x0 tile] results model(crop, confconf, verboseFalse)[0] for box in results.boxes: x1, y1, x2, y2 box.xyxy[0].tolist() score float(box.conf[0]) boxes.append([x0 x1, y0 y1, x0 x2, y0 y2, score]) if not boxes: return np.empty((0, 5)) # 对接到全图的框做一次 NMS去除 tile 重叠区域的重复框 boxes np.array(boxes) keep cv2.dnn.NMSBoxes( boxes[:, :4].tolist(), boxes[:, 4].tolist(), 0.25, 0.5 ) return boxes[keep.flatten()] det infer_big(test_desk.jpg) print(det)切图的代价是推理次数变多1080P 大约要 6 个 tile但换来的是小目标在 640 输入里占据更大的像素比例。overlap 0.2 是经验值——如果笔恰好横跨 tile 边界没有重叠区域就会把笔截成两段全漏掉。这套方案做下来笔识别的精度已经摸到数据本身的天花板了再往下走如果你的场景里还会出现没见过的笔型闭集的 YOLO 就有边界那就要去看开放词汇目标检测的路子。我做这类细长小目标的第一个版本也翻过车最后发现大部分坑都出在标注和划分上而不是模型上后来养成的习惯是先补数据再谈调参。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网