
接手过一个图像检测项目三万张原始图八个人标了两周才标完四千张按这个速度交付要三个月。后来换成半自动标注的路子——先用一个粗糙的基线模型把框吐出来人工只做“改、删、补”同样的八个人一周干完了一万两千张而且标签一致性比纯手工那批还高。这件事让我彻底改变了对图像标注工作的看法真正决定效率的不是手速是预标注质量与数据集格式之间的衔接顺不顺。这篇就围绕两个东西展开一是基于图像的半自动化标注怎么搭起来、卡点在哪二是市面上各个训练框架吃的数据集格式到底差在哪些字段上怎么转、转的时候会丢什么。不管你是刚开始接触标注的新手还是已经带过几轮标注团队的老人下面这些内容应该都能直接抄去用尤其是格式转换那几段和坐标系那几个坑我踩过的都写在里面了。1. 半自动标注到底省在哪一段先把成本结构拆开1.1 人工标注的成本不是均匀分布的很多人以为标注慢是因为“画框慢”其实不是。让一个熟练标注员在一张空白图上从零画一个矩形框平均三到五秒但如果给他一个已经画好、位置略微偏了十来个像素的框他只需要拖动两个角点一两秒搞定。差距不算大可一旦图里有十几个目标差距就出来了——从零画十几个框要一分钟改十几个框只要二十秒。真正的大头其实在找目标这一步。人眼在复杂背景下搜索小目标、遮挡目标、密集目标时注意力消耗极大标两个小时就开始漏。预标注模型恰好补的就是这个短板它可能框得不准但它很少“看不见”。所以半自动标注的第一层价值是把“搜索”这件事外包给模型人只负责“判断”。第二层价值在一致性。纯手工标注时同一个目标今天标得紧、明天标得松换个人又是另一种风格模型训练时会被这些噪声拖累。预标注把初始框的尺度先定下来人工修改时是围绕一个参照在动标注风格会自然收敛。这两点加起来才是效率提升的真实来源而不是简单的“机器替人画框”。1.2 预标注不是免标注是把判断变成确认这个认知很关键。我见过有团队把模型置信度调到很低把能出的框全导进去结果标注员面对一屏密密麻麻的候选框删除误检花的时间比从零画还多。这是典型的把成本转移了而不是消除了。合理的做法是双阈值分层高置信度区间比如 0.6 以上的框默认保留人工快速扫一眼不改直接过中间区间0.25 到 0.6的框用醒目的颜色标出来强制人工逐个确认低置信度区间直接丢弃宁可漏掉也不污染。这个策略的本质是把人工注意力当成稀缺资源集中投放在模型最不确定的地方。def triage(preds, hi0.60, lo0.25): auto_keep, need_review, discard [], [], [] for p in preds: s p[score] if s hi: auto_keep.append(p) elif s lo: need_review.append(p) else: discard.append(p) return auto_keep, need_review, discard阈值怎么定不是拍脑袋。拿五百张已经人工标好的图当验证集让模型跑一遍统计不同阈值下的精确率和召回率画出曲线。经验上 hi 取在精确率 0.95 附近、lo 取在召回率 0.85 附近比较平衡。如果项目对漏检极其敏感比如工业缺陷hi 可以降到 0.5把更多框推到人工确认区如果对误检敏感比如内容审核hi 提到 0.8让模型少说话。1.3 一套能跑起来的半自动标注流程长什么样把上面这些串起来流程大致是这样的先准备一个小的“种子集”五百到一千张覆盖所有类别和典型场景全人工精标用它训一个基线模型精度不用高mAP 0.3 就能用然后拿基线模型去跑剩余图像池输出带置信度的预标注结果接着按双阈值分层导入标注工具人工只处理中间区和抽查高置信区标完一批就回流训练模型变强下一批的预标注更准。这个循环的关键是每一轮都要留出验证集且验证集的图像不能参与预标注生成否则评估会虚高。另外池子的处理顺序也有讲究——先跑和种子集同分布的部分模型在这些图上表现好人工省力等模型迭代两轮之后再处理难例和长尾类别那时候模型的泛化能力才够。2. 预标注模型怎么选检测框、掩码、关键点各有各的兵2.1 目标检测任务通用检测器就够用别一上来就上大模型做矩形框标注预标注用通用检测器就行。选择时看三个维度推理速度、对自定义类别的适配成本、输出格式的可控性。如果种子集已经有了一定规模每类两百张以上直接微调一个成熟的一阶段检测器最省事训练几十分钟就能出个能用的基线。如果种子集几乎没有、类别又比较通用人、车、动物、常见物品用开放词表的检测模型做零样本预标注也完全可以虽然精度低一些但能避免从零开始画。有个容易被忽略的点预标注模型不必和最终训练模型是同一个。最终要部署到端侧的小模型预标注阶段完全可以用大模型甚至多模型融合精度优先。我见过不少团队非要“一套模型走到底”结果预标注精度上不去标注效率也就卡在那儿。2.2 分割与关键点任务掩码预标注是另一套逻辑矩形框能靠检测器直接出掩码不行。多边形标注的预标注通常走两条路一是用实例分割模型直接出掩码再把掩码转成多边形顶点二是用交互式分割的思路人工点一个正样本点模型给出候选掩码人工再补点修正。第二条路在标注量不大、目标边界复杂比如细长物体、镂空区域时效率极高点几下就出一个质量很好的掩码比手工沿着边缘点几十个顶点快一个数量级。第一条路适合批量处理代价是掩码转多边形时顶点数量需要抽稀抽得太狠边界失真抽得太松文件体积暴涨。关键点标注的预标注最麻烦因为关键点有顺序语义——左眼和右眼不能互换。用关键点检测模型直接出点位是可行的但必须固定关键点的索引顺序且输出时要带上置信度低置信度的点让人工补。我试过一个方案是把关键点预标注做成“半成品”模型给出点位标注员只需要拖动调整不做从零点击。这个改动让单人日均产出从两百张提到六百张。2.3 分类任务也别忽略它同样是半自动的一部分做检测和分割之前往往要先做一轮筛选——哪些图里根本没目标哪些图属于哪个大类。这部分用分类模型做预筛同样划算。把明显无目标的图先剔出去标注员就不会在空图上浪费时间把图按预分类打上初始标签标注员只需要确认或改正比从下拉框里选要快。这里有个细节预筛的阈值要留余量。分类模型判“无目标”的置信度如果只有 0.6千万别直接扔那里面大概率藏着难例阈值放到 0.95 以上再剔宁可多留。3. 从推理输出到标注工程文件这段“脏活”最费时间3.1 中间格式选哪个决定后面顺不顺模型吐出来的通常是[x1, y1, x2, y2, score, class_id]这样的数组标注工具认的是 XML、JSON 或者私有工程文件。中间要不要过一个统一格式我的答案是必须过而且选COCO 或者 LabelMe 二选一不要再多了。选 COCO 的理由是几乎所有检测分割框架都原生支持它转换器生态最全一张图多目标、多类别的表达能力也够。缺点是结构相对重字段多手写容易出错。选 LabelMe 的理由是结构极简人眼可读编辑方便几乎每个标注工具都能导入导出它做中间态特别合适。缺点是它不带“图像集合”的概念一个图一个文件规模大了以后文件数量爆炸。我自己的习惯是标注工具侧用 LabelMe训练框架侧用 COCO两者之间写一次转换脚本复用到底。这样预标注结果先落成 LabelMe 的 JSON人工在工具里改的就是这批文件改完再统一转 COCO。一个标准 LabelMe 文件长这样关键是points的顺序和shape_type{ version: 5.2.1, flags: {}, shapes: [ { label: defect_a, points: [[120.5, 88.0], [340.2, 88.0], [340.2, 210.7], [120.5, 210.7]], group_id: null, shape_type: rectangle, flags: {} } ], imagePath: sample_0001.jpg, imageData: null, imageHeight: 1080, imageWidth: 1920 }注意shape_type为rectangle时points只放两个对角点为polygon时放全部顶点且首尾不重复闭合。这两条写错了导入工具直接报错或者画出诡异的形状。3.2 写转换脚本时那几个必查项自己写 COCO 转 YOLO 的脚本很容易在几个地方翻车。贴一段我实际在用的注释里标了检查点import json, os with open(annotations/instances_train.json, r, encodingutf-8) as f: coco json.load(f) # 检查点1category_id 未必是 0..N-1 连续整数必须先建映射 cats sorted(coco[categories], keylambda c: c[id]) cat_id2idx {c[id]: i for i, c in enumerate(cats)} # 检查点2图片尺寸必须从 images 里取不能靠 PIL 现场读速度差几十倍 img_meta {im[id]: im for im in coco[images]} by_image {} for ann in coco[annotations]: by_image.setdefault(ann[image_id], []).append(ann) os.makedirs(labels/train, exist_okTrue) for img_id, anns in by_image.items(): im img_meta[img_id] W, H im[width], im[height] lines [] for ann in anns: if ann.get(iscrowd, 0) 1: continue # 检查点3iscrowd1 的框在检测任务里通常直接丢 x, y, w, h ann[bbox] # COCO 是 [左上x, 左上y, 宽, 高] x1, y1 max(0.0, x), max(0.0, y) x2, y2 min(float(W), x w), min(float(H), y h) # 检查点4越界裁剪 if x2 - x1 1 or y2 - y1 1: continue # 裁剪后退化的框直接丢否则归一化会出负数或零 cx, cy (x1 x2) / 2 / W, (y1 y2) / 2 / H nw, nh (x2 - x1) / W, (y2 - y1) / H idx cat_id2idx[ann[category_id]] lines.append(f{idx} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}) stem os.path.splitext(os.path.basename(im[file_name]))[0] with open(flabels/train/{stem}.txt, w, encodingutf-8) as f: f.write(\n.join(lines))这四个检查点每一个我都真出过问题。特别是第一条COCO 的category_id完全可能是一组不连续的整数比如[1, 3, 7, 12]你直接拿它当类别索引写进 txt训练时要么报维度越界要么更阴险——不报错但类别标签整体错位训出来的模型把 A 认成 B你还得从头查。3.3 导入标注工具之后人工环节怎么组织预标注导入之后标注员的动作就三种拖动调整、删除误检、补画漏检。这里有几个能明显提速的操作习惯。删除键要设成最顺手的那个因为删除是最高频动作快捷键切换上一张下一张要用单手能按的组合类别切换用数字键 1 到 9。这些配置看着琐碎实际能影响两成以上的效率。质检环节建议双人交叉A 标完的图随机抽一成给 B 过一遍只记录不改统计修正率。修正率超过 15% 说明预标注质量不行得回头调模型低于 5% 说明这道工序可以放松抽检比例。这个数据是后面迭代时判断“模型够不够好”的直接依据。4. 各框架要的数据集格式字段级对照4.1 检测任务的主流格式对比这张表我建议直接存下来做转换的时候对着看格式坐标基准坐标定义类别索引图像尺寸来源典型适用YOLO txt0 起始归一化到 0~1cx cy w h中心宽高从 0 连续训练时按图读取txt 里不含一阶段检测器主线Pascal VOC xml1 起始绝对像素xmin ymin xmax ymax左上右下字符串 namexml 内size节点早期两阶段检测器COCO json0 起始绝对像素[x, y, w, h]左上宽高从 1可不连续json 内images节点通用生态最广归一化 CSV/TFRecord0 起始归一化xmin ymin xmax ymax整数或字符串记录内附带部分训练框架专用最容易混的是 YOLO 和 COCO前者是中心点加宽高且归一化后者是左上角加宽高且绝对像素。写转换脚本时方向别搞反YOLO 转 COCO 要x cx*W - w*W/2COCO 转 YOLO 要cx (x w/2)/W这两个式子错一个符号画出来的框就会整体偏到图像另一侧。还有一个隐蔽差异VOC 的坐标是 1 起始的而且约定xmax和ymax是包含端点的。也就是说一个实际占 100 个像素宽的框xmax - xmin会是 99。有些转换器直接算w xmax - xmin有些算w xmax - xmin 1两者差 1 像素。单看没什么但如果你的数据集里全是小目标这 1 像素的差异会被放大成几个百分点的框尺寸偏差。我的做法是统一按xmax - xmin算牺牲那 1 像素的绝对精度换一致性因为标注本身的人工误差远大于 1 像素。4.2 分割任务的三种落地形态分割的数据表示比检测复杂主流是三条路各有取舍。多边形顶点最直观一个目标一组[x1,y1,x2,y2,...]或者[[x,y],...]。COCO 的segmentation字段用嵌套列表表示一张图多个目标就是多个子列表。优点是标注工具友好、可编辑、体积小缺点是曲线边界要用很多顶点去逼近顶点太密文件大太稀边界圆。RLE 编码把掩码按行优先压成一串“连续 0 的个数、连续 1 的个数”这样的游程再整体编码成字符串。优点是密集掩码压缩率极高还原无损缺点是人完全看不懂出问题只能靠代码调试。COCO 里iscrowd1的标注必须用 RLE普通目标两种都行。掩码 PNG每个像素的值就是类别索引0 表示背景。这是语义分割最常见的形态检测分割框架里也常用它做真值。优点是最直接、任何图像库都能读缺点是一个像素只能属于一个类别实例之间没法区分做实例分割还得配一份实例索引图或者用连通域分析反推。选择上看任务实例分割且要人工编辑用多边形实例分割且目标密集、边界复杂用 RLE语义分割用掩码 PNG。三者之间互转是常事多边形转掩码要光栅化注意抗锯齿导致的边缘像素取值问题掩码转多边形要轮廓提取加顶点抽稀cv2.findContours出来后记得按面积过滤掉噪点轮廓。import cv2, numpy as np mask cv2.imread(mask_0001.png, cv2.IMREAD_GRAYSCALE) contours, _ cv2.findContours((mask 0).astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for c in contours: if cv2.contourArea(c) 30: continue # 过滤噪点这个阈值按目标尺度调 eps 1.0 # 抽稀容差越大顶点越少 poly cv2.approxPolyDP(c, eps, True).reshape(-1, 2).tolist()eps这个参数很关键。设 0 等于不抽稀一个圆可能出来几百个点设 5 以上边界会变成多边形块状。经验值是按轮廓周长的 0.5% 到 1% 来设小目标用小值大目标用大值。4.3 关键点与其他任务格式的字段差异关键点格式的核心是“点位 可见性”。COCO 的关键点标注结构是[x1, y1, v1, x2, y2, v2, ...]其中v表示可见性0 未标注、1 被遮挡、2 可见。这个字段千万别丢丢了之后模型学不会区分“这个点不存在”和“这个点在图像外”。YOLO 体系里的姿态格式是每行class cx cy w h后面跟一串归一化的关键点坐标加可见性顺序必须和配置文件里的kpt_shape完全一致。我见过最离谱的错误是训练时关键点左右镜像了原因是配置里写的是[x, y, v]展开顺序实际数据是按[v, x, y]存的两者不匹配但程序不报错只是精度死活上不去查了两天才发现。其他格式还有 OCR 用的四点框、旋转框的五参数表示cx cy w h angle、以及部分框架自定义的序列化格式。这些相对小众但转换逻辑是同一个套路先明确源格式每个字段的语义和基准再映射到目标格式最后拿一张图可视化验证。4.4 转换过程中最容易静默丢失的四类信息“静默丢失”是指转换不报错、但信息没了最难查。常见的四类第一类难度标记和忽略标记。VOC 的difficult、COCO 的iscrowd、还有各种ignore字段。转到 YOLO 的纯 txt 里这些全没了结果是评估时那些本来就该忽略的目标被算进误检指标莫名偏低。第二类分组关系。一个目标被遮挡分成两块标注时用group_id关联转格式时丢了关联就变成两个独立目标。第三类属性字段。颜色、朝向、状态这类附加属性主流格式里没有标准位转换器一般不处理标了半天全丢。第四类负样本。有些团队会显式标注“这张图里没有目标”转成检测格式时这种信息无处安放最后训练集里负样本比例失衡模型到处乱框。对策很简单转换前后各做一次统计——目标总数、每类目标数、每类框的平均面积、有无目标的图像数四个数字对不上就说明丢了东西。这个校验脚本十几行能省掉大量排查时间。5. 坐标系那六个坑每一个都能让你重标一遍5.1 归一化基准搞错框全跑到左上角这个坑的典型症状是训练时 loss 正常下降可视化一看框全挤在图像左上角一小块。原因是把绝对像素坐标当成归一化坐标喂进去了或者反过来把归一化坐标乘了一遍宽高。判断方法很简单把所有框坐标取最大值如果接近 1说明是归一化的如果是几百上千说明是绝对像素。写转换脚本时养成习惯在入口处断言一次xs [a[bbox][0] for a in coco[annotations]] assert max(xs) 2, 坐标疑似已归一化检查输入5.2 0 起始和 1 起始混用COCO 是 0 起始VOC 是 1 起始差 1 个像素。单张图看不出来但如果你在做像素级分割掩码和多边形错开 1 像素边缘会出现一圈细缝模型会学到这个伪影。转换时统一在入口处减 1 或加 1别指望后面补。5.3 EXIF 方向标记最阴的一个手机拍的图经常带 EXIF 方向标记表示“这张图要转 90 度才是正的”。有些库读图时会自动应用有些不应用。如果标注工具应用了、训练框架没应用那框的位置和图像内容就是错位的而且是整体旋转 90 度那种错位一眼能看出来。真正阴的是应用了方向但宽高没交换图像内容正了但坐标系还是按原始宽高算的框会整体缩放到错误比例。处理方式是在数据预处理阶段就把 EXIF 拍平之后所有环节都按无方向标记的图来from PIL import Image, ImageOps with Image.open(src) as im: fixed ImageOps.exif_transpose(im) fixed.save(dst, quality95)这一步放在整个流水线最前面只做一次后面全用处理过的图能杜绝一整类问题。5.4 越界坐标不裁剪模型推理出来的框经常超出图像边界尤其是目标贴边的时候。如果不裁剪直接归一化会出现负值或者大于 1 的值有些框架训练时直接崩有些悄悄截断成 0 和 1导致框形变。统一在转换入口做clip同时把裁剪后退化的框宽或高小于 1 像素丢掉。5.5 图像尺寸取错来源转换脚本里如果现场用图像库读尺寸遇到损坏文件会抛异常中断整批任务如果从 JSON 元数据里读遇到元数据写错的情况会静默出错。我的做法是以元数据为准但抽样核对。随机抽 50 张图现场读一次尺寸和元数据比对不一致就报警。这样既不牺牲速度又不会整批错到底。5.6 中文路径和特殊字符这个不算坐标系问题但同属“能让你重做一遍”的级别。文件路径里有中文、空格、#、%之类的字符某些标注工具和转换脚本会读失败或者静默跳过。统一规则原始素材入库时全部重命名为 ASCII 安全字符加序号映射关系单独存一份表。文件名可读性靠这份表来保证不靠文件名本身。6. 迭代三轮之后预标注模型自己会长大6.1 每一轮该标哪些图比标多少更重要第一轮种子集五百到一千张全人工精标覆盖所有类别、光照、尺度、遮挡情况。这轮不追求量大追求覆盖全。第二轮用基线模型跑剩余池子按置信度从高到低排序优先处理高置信度那批。这批图模型有把握人工确认快能快速把标注量堆上去同时验证预标注流程是否通畅。第三轮模型用第二轮的数据重训精度提升后回头处理第一轮里被模型判为低置信度的“难例”以及所有长尾类别的样本。这一轮效率会明显下降但产出的数据价值最高。再往后就看边际收益了。我一般的判断标准是当预标注的接受率人工不改直接通过的比例连续两轮提升不足 3 个百分点就停下来不再迭代剩下的量老老实实人工标。硬撑着迭代往往是在过拟合已有的标注风格对最终模型没好处。6.2 迭代时怎么防止误差自我强化这是半自动标注最大的风险模型漏检的目标预标注里没有人工如果只看预标注、不做独立扫描就永远不会补上下一轮模型继续漏误差一代代固化。这叫锚定效应非常普遍。三个对策。第一每批数据里掺入 5% 到 10% 的空图什么预标注都不给强制标注员从零扫一遍用来发现系统性漏检。第二每轮随机抽 20 张图做全量人工复核和预标注结果比对统计漏检率和误检率形成趋势曲线。第三定期用一个不同的模型跑同一批图做交叉验证如果两个模型的漏检位置高度重合说明这个位置是数据本身的难点如果只有主模型漏说明是模型问题该换模型了。还有个反直觉的经验别把预标注的框做得太“漂亮”。如果框永远精准贴合标注员会丧失对边界判断的敏感度遇到模型没覆盖的类别时反而标得更差。适度的不完美能保持人的注意力这话听着玄但你带过几轮标注团队就会认同。7. 那些我希望早点知道的实操细节先说类别命名。标注时的类别名和训练配置里的类别名必须逐字符一致包括大小写和空格。转换脚本几乎不会帮你规范化Person和person在文件里是两个类训练时会多出一个空类别指标掉得莫名其妙。建议在项目最开始就定一份类别表所有环节从这份表读。再说数据划分。视频抽帧或者连拍图同一场景的图像在视觉上高度相似。如果随机划分训练验证集相似图会同时出现在两边验证指标虚高。按“场景”或“时间段”做分组划分同一组的图只进一边。这个改动会让指标掉几个点但那才是真实水平。第三说标注工具的版本。同一个工具不同版本导出的 JSON 字段可能不一样shape_type的枚举值、points的嵌套层级都可能变。团队协作时把工具版本锁死写进 README别让人随便升级。第四说存储。三万张图和对应的标注文件如果按一个图一个 json 存文件数量六万个很多文件系统的目录遍历会明显变慢。要么分桶存放按哈希前缀分十六个子目录要么打包成单个大 JSON。后者对 COCO 天然友好对 LabelMe 需要额外写合并脚本。最后说一句关于精度的预期管理。半自动标注能把效率提两到三倍但它不会让标签质量自动变好——预标注错了人又没改错误就被固化下来而且比手工错误更难发现因为大家下意识信任模型。所以质检环节不能省抽检比例可以降但每一轮都得有且质检结果要能追溯到具体的预标注批次。我在实际项目里会给每张图打一个“预标注来源批次”的标记出问题时能快速定位是哪一轮模型的问题回滚起来很干脆。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。