☰
图像半自动标注提效:预标注质量与数据集格式转换实战
2026/10/25 2:59:57 网站建设 项目流程

接手过一个图像检测项目,三万张原始图,八个人标了两周才标完四千张,按这个速度交付要三个月。后来换成半自动标注的路子——先用一个粗糙的基线模型把框吐出来,人工只做“改、删、补”,同样的八个人一周干完了一万两千张,而且标签一致性比纯手工那批还高。这件事让我彻底改变了对图像标注工作的看法:真正决定效率的不是手速,是预标注质量与数据集格式之间的衔接顺不顺。这篇就围绕两个东西展开:一是基于图像的半自动化标注怎么搭起来、卡点在哪;二是市面上各个训练框架吃的数据集格式到底差在哪些字段上,怎么转、转的时候会丢什么。不管你是刚开始接触标注的新手,还是已经带过几轮标注团队的老人,下面这些内容应该都能直接抄去用,尤其是格式转换那几段和坐标系那几个坑,我踩过的都写在里面了。

1. 半自动标注到底省在哪一段:先把成本结构拆开

1.1 人工标注的成本不是均匀分布的

很多人以为标注慢是因为“画框慢”,其实不是。让一个熟练标注员在一张空白图上从零画一个矩形框,平均三到五秒;但如果给他一个已经画好、位置略微偏了十来个像素的框,他只需要拖动两个角点,一两秒搞定。差距不算大,可一旦图里有十几个目标,差距就出来了——从零画十几个框要一分钟,改十几个框只要二十秒。

真正的大头其实在找目标这一步。人眼在复杂背景下搜索小目标、遮挡目标、密集目标时,注意力消耗极大,标两个小时就开始漏。预标注模型恰好补的就是这个短板:它可能框得不准,但它很少“看不见”。所以半自动标注的第一层价值是把“搜索”这件事外包给模型,人只负责“判断”。

第二层价值在一致性。纯手工标注时,同一个目标今天标得紧、明天标得松,换个人又是另一种风格,模型训练时会被这些噪声拖累。预标注把初始框的尺度先定下来,人工修改时是围绕一个参照在动,标注风格会自然收敛。这两点加起来,才是效率提升的真实来源,而不是简单的“机器替人画框”。

1.2 预标注不是免标注,是把判断变成确认

这个认知很关键。我见过有团队把模型置信度调到很低,把能出的框全导进去,结果标注员面对一屏密密麻麻的候选框,删除误检花的时间比从零画还多。这是典型的把成本转移了而不是消除了。

合理的做法是双阈值分层:高置信度区间(比如 0.6 以上)的框默认保留,人工快速扫一眼,不改直接过;中间区间(0.25 到 0.6)的框用醒目的颜色标出来,强制人工逐个确认;低置信度区间直接丢弃,宁可漏掉也不污染。这个策略的本质是把人工注意力当成稀缺资源,集中投放在模型最不确定的地方。

def triage(preds, hi=0.60, lo=0.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", encoding="utf-8") as f: coco = json.load(f) # 检查点1:category_id 未必是 0..N-1 连续整数,必须先建映射 cats = sorted(coco["categories"], key=lambda 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_ok=True) 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 # 检查点3:iscrowd=1 的框在检测任务里通常直接丢 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(f"labels/train/{stem}.txt", "w", encoding="utf-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/2,COCO 转 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 里iscrowd=1的标注必须用 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, quality=95)

这一步放在整个流水线最前面,只做一次,后面全用处理过的图,能杜绝一整类问题。

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 需要额外写合并脚本。

最后说一句关于精度的预期管理。半自动标注能把效率提两到三倍,但它不会让标签质量自动变好——预标注错了人又没改,错误就被固化下来,而且比手工错误更难发现,因为大家下意识信任模型。所以质检环节不能省,抽检比例可以降,但每一轮都得有,且质检结果要能追溯到具体的预标注批次。我在实际项目里会给每张图打一个“预标注来源批次”的标记,出问题时能快速定位是哪一轮模型的问题,回滚起来很干脆。

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

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

立即咨询