ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Label Studio预标注导入指南:从模型输出到人工审核修正

Label Studio预标注导入指南:从模型输出到人工审核修正

做过数据标注的都知道,最痛苦的往往不是标注本身,而是怎么让“模型已经跑出来的结果”和“人工标注”这两件事顺畅地衔接起来。模型推理完了一堆预测框,如果让标注员从头重新画一遍,那前期推理等于白做;如果直接把预测结果拿去训练,又容易被里面的错框、漏框带偏。这就是我最近在项目里反复折腾的东西:把预标注数据导入开源标注工具Label Studio,让标注员在“人工审核修正”模式下干活,而不是从零开始创建标注。这套流程跑通之后,标注效率提升非常明显,这篇文章就把我的完整思路、格式设计和踩过的坑一次性讲清楚。

我自己在项目里实测下来,预标注导入这件事的核心并不在“导入”这个动作本身,而在“格式转换”和对齐上。你只要搞清楚Label Studio内部怎么描述一个标注对象、坐标系怎么计算、预测结果应该放在JSON的哪个位置,剩下的就是把模型输出翻译成它认识的格式而已。下面我会从需求拆解、格式原理、实操代码到排查技巧,一步步把这套流程完整复现出来。

1. 为什么要做预标注导入:批量标注的卡点与解法

1.1 纯人工标注与模型辅助标注的差距

先算一笔账。一个目标检测项目,假设有1万张图需要标注。纯人工标注每张图平均需要40秒(标注加检查),一个人一天工作6小时能标500张左右,两个标注员干十天才能完事。但如果你先把一个训练过的模型跑一遍推理,哪怕这模型的mAP只有60%,它也能把这1万张图里大概七八成目标的“位置”给框出来。标注员要做的事情就变成了:看一眼框对不对、压着框边缘微调几个像素、把漏掉的框补上、把多余的框删掉。每张图的处理时间能压到10秒以内,效率提升直接是3到4倍,而且标注员的脑力消耗小很多,错误率也会降下来。

这也是“半自动标注”或者叫“模型辅助标注”在生产流程里最朴素的价值。Label Studio支持把模型的预测结果预先挂到任务上,标注员打开图片时能看到这些预测框,他可以选择“接受”、“修正”或者“丢弃”,而不是对着空白画布自己找目标。这一套交互逻辑一旦走通,从模型训练到数据迭代就形成了一个完整的闭环,也就是常说的主动学习循环。

1.2 预标注导入要解决的三个核心问题

要把预标注做好,不是把模型的JSON丢给Label Studio就能完事。我在实际操作中总结下来,至少要解决三个层面的问题:

第一个是标签语义对齐。模型输出的类别名必须和Label Studio项目配置里的标签名完全一致。比如模型用的是person、car,项目里如果拼成了Person或者vehicle,那预测框就算导进去也不会显示,或者显示为未识别。

第二个是坐标系转换。Label Studio里的框坐标不是像素值,而是百分比。很多模型推理结果给出的是像素级别的xmin, ymin, xmax, ymax,直接填进去会导致框的位置完全错乱,这点特别容易踩坑。

第三个是数据结构封装。预测结果不能随便往某个字段里塞,必须按照Label Studio约定的predictions结构来组织,里面要包含result数组,每个结果对象需要声明标注类型、坐标值、置信度等信息。只有结构对了,导入之后才能在界面上正常展示。

2. 先把导入机制搞懂:两种导入路径与JSON数据结构

2.1 界面导入与API导入的适用场景

Label Studio提供两条导入路径。一条是通过Web界面手动上传文件,适合临时测试和小批量验证;另一条是通过SDK或者REST API写入,适合真正生产环境里的批量导入。

我用下来最大的感受是:界面导入适合“一次性的数据搬家”,API导入适合“每次重新生成预标注之后自动灌进去”的持续迭代场景。因为主动学习的循环里,模型每训练一轮就会推理出新的预标注结果,这些结果需要反复覆盖或者追加到Label Studio的任务上,纯靠手工上传根本忙不过来,必须用脚本搞定。

2.2 tasks与predictions字段:预标注数据的最佳物业

Label Studio导入数据的最小单位叫task,也就是一个标注任务。一个task的JSON可以长这样:

{ "data": { "image": "https://example.com/path/to/image.jpg" }, "predictions": [ { "model_version": "yolov8_v3", "score": 0.82, "result": [ { "id": "abc123", "type": "rectanglelabels", "from_name": "label", "to_name": "image", "original_width": 1280, "original_height": 720, "image_rotation": 0, "value": { "rotation": 0, "x": 12.5, "y": 20.0, "width": 30.0, "height": 25.0, "rectanglelabels": ["person"] }, "score": 0.95 } ] } ] }

这里面有几个关键点。data.image字段是标注员要看的图片地址,这个地址可以是对外可访问的URL,也可以是Label Studio本地上传后的相对路径。predictions是一个数组,可以有多个预测版本(多模型对比),每个预测包含model_version用来标识是哪个模型跑的,result表示具体标注结果,score是整个预测的置信度。

值得留意的是,一个task里data和predictions是平级的。data是任务本身的原始数据,predictions是机器给的“参考答案”。标注员最终提交的标注结果会放在另一个字段annotations里。也就是说,predictions和annotations本质上结构差不多,但一个代表机器预标,一个代表人工最终确认。理解了这一点,你就知道为什么Label Studio能把“预标注审核”做成一种天然合理的交互流程。

2.3 SDK初始化与项目关联

用SDK做批量导入,先做好初始化和项目定位。

from label_studio_sdk import Client ls = Client(url='http://localhost:8080', api_key='你的API_KEY') project = ls.get_project(id=21)

这里API_KEY需要在Label Studio的用户账户页面生成。拿到项目对象后,直接用project.create_prediction能往单个任务里写预测结果,但如果一开始任务还没创建,就涉及到“创建任务+挂预测结果”两条路。

我目前的推荐做法是分两步走:先把所有任务用project.create_tasks创建好,再循环用project.create_prediction写入预测结果。这么做的好处是便于控制错误:即使某条预测写入失败,也能根据任务ID定位重试,不用整个回滚。

不过需要注意版本差异。不同Label Studio版本的SDK里,接口参数出现过变化。我在某个版本里用create_prediction,在另一个版本里就要通过label_studio_sdk.project.Project的底层api_requester去调/api/predictions/接口。如果你发现方法名对不上,先看SDK源码里的project.py,别硬猜。

3. 模型输出转预标注JSON:核心转换逻辑与代码仿单

3.1 理解result内部的万有坐标系

Label Studio里所有形状的坐标都采用百分比归一化坐标,含义是:坐标值除以图片原始宽高,得到0到100之间的小数。以矩形框为例,x是左上角的水平位置百分比,y是左上角的垂直位置百分比,width是相对图片宽的百分比,height是相对图片高的百分比。

这个设计是为了让标注数据不依赖具体图片尺寸,同一份标注可以适配不同分辨率的图片。但也正是这一点,最坑。模型输出如果用像素坐标,必须先除以对应方向上的original_width和original_height,而且注意value里的x、y、width、height均除以各自的边长。

公式非常直接:

x_percent = xmin_pixel / original_width * 100 y_percent = ymin_pixel / original_height * 100 width_percent = (xmax_pixel - xmin_pixel) / original_width * 100 height_percent = (ymax_pixel - ymin_pixel) / original_height * 100

看到那个乘以100没有?很多教程会漏掉这一步,直接拿0到1之间的小数填进去,结果就是所有框全部挤在图片左上角那一小块区域里。我最初就吃过这个亏,导入之后打开一看,框全部缩在角落里,尺寸也完全不对。

3.2 构建result对象的完整参数

一个矩形框标注的result对象,每个字段都要理解它的作用:

  • id:当前标注对象的唯一标识,可以用UUID字符串,也可以简单用随机数。这个字段不参与坐标计算,但Label Studio内部用它对不同标注进行匹配和更新,重复写入时ID要稳定。
  • type:标注类型。矩形框对应rectanglelabels,多边形对应polygonlabels,关键点对应keypointlabels。对话、文本分类等类型则是choices、textarea等。
  • from_name:对应标注配置里的控件名。这必须在配置里存在。
  • to_name:对应标注配置里的数据源名。通常为image。
  • original_width/original_height:图片原始像素宽高。
  • value:具体标注内容。对于矩形框,包含x、y、width、height、rotation和标签数组rectanglelabels。
  • score:单个标注的置信度得分,可以来自模型输出的confidence。
  • image_rotation:如果导入前图片有旋转,需要设置这个值,一般情况设0即可。

这里我特别强调一下from_name和to_name。这两个字段是字符串,但它们不是随便写的,它们在Label Studio的标注配置里有对应的名称。比如常见的标注配置:

<View> <Image name="image" value="$image"/> <RectangleLabels name="label" toName="image"> <Label value="person" background="#FF0000"/> <Label value="car" background="#00FF00"/> </RectangleLabels> </View>

这里name="label"就是from_name,toName="image"就是to_name。如果你新建项目的时候没有自定义配置,默认的矩形标注一般叫label和image。如果改了名字,XML里的名字必须与代码里一致,否则任何预测都不会显示。

3.3 完整转换代码:从YOLO输出到Label Studio预测

我自己项目里主要用YOLO类模型,这里给一个完整的转换函数。假设模型输出是每张图的预测框列表,每项包含class_name、confidence、像素坐标bbox。

import uuid def yolo_box_to_ls_result( class_name: str, confidence: float, bbox_pixels: list, # [xmin, ymin, xmax, ymax] original_width: int, original_height: int, ) -> dict: xmin, ymin, xmax, ymax = bbox_pixels box_width = max(0, xmax - xmin) box_height = max(0, ymax - ymin) return { "id": str(uuid.uuid4()), "type": "rectanglelabels", "from_name": "label", "to_name": "image", "original_width": original_width, "original_height": original_height, "image_rotation": 0, "value": { "rotation": 0, "x": round(xmin / original_width * 100, 6), "y": round(ymin / original_height * 100, 6), "width": round(box_width / original_width * 100, 6), "height": round(box_height / original_height * 100, 6), "rectanglelabels": [class_name], }, "score": float(confidence), } def build_task_with_prediction( image_url: str, result_objects: list, model_version: str, prediction_score: float, ) -> dict: return { "data": { "image": image_url }, "predictions": [ { "model_version": model_version, "score": float(prediction_score), "result": result_objects, } ], }

这个代码片段直接可复制使用。唯一需要替换的是from_name要和项目XML里匹配。另外一个经验:坐标计算时保留六位小数就足够了,因为Label Studio前端渲染时比例精度已经在像素级别以下,再多的小数位只会让JSON体积变大,没有实际意义。

4. 完整实操:从模型预测到标注员审核修正

4.1 项目配置准备

首先在Label Studio里创建一个检测项目。我会先把标注配置写好再导入任务,因为如果先导入任务再改配置,有时候历史任务的预测展示会异常。

我常用的配置包含矩形框、标签色、以及一个控制“是否需要人工审核确认”的附加选择框:

<View> <Image name="image" value="$image"/> <RectangleLabels name="label" toName="image"> <Label value="person" background="#FF0000"/> <Label value="car" background="#00FF00"/> <Label value="bicycle" background="#0000FF"/> </RectangleLabels> <Choices name="review" toName="image" showInLine="true"> <Choice value="合格"/> <Choice value="需要修改"/> </Choices> </View>

这个review字段是额外加的,目的是让标注员在审核时给这个预测打一个整体判定。当然不是必须的,如果你只想让标注员直接改框,也可以不加。加了之后导出的标注里会多出这个选择条件,后续可以据此自动筛出哪些任务需要返工。

4.2 批量推理与预标注文件生成

我一般先把所有待标注图片的路径收集起来,用模型批量跑推理,把每张图的结果变成上面定义的task结构,然后统一保存成一个JSON文件或者直接通过SDK写入。

这里给一个批量流程的伪代码:

import json from label_studio_sdk import Client # 假设已经拿到了每张图的预测结果 all_tasks = [] for item in prediction_items: image_url = item["image_url"] objects = [yolo_box_to_ls_result(...) for ... in item["boxes"]] task = build_task_with_prediction( image_url=image_url, result_objects=objects, model_version="yolov8_0712", prediction_score=item["mean_score"], ) all_tasks.append(task) # 直接写入Label Studio ls = Client(url='http://localhost:8080', api_key='YOUR_API_KEY') project = ls.get_project(id=21) project.create_tasks(all_tasks)

create_tasks接受list类型,一次可以创建一批。我建议每批控制在500到1000个任务之间。量太大会导致请求超时或者内存上涨,量太小又浪费HTTP往返时间。实测下来800个左右是一个比较舒服的比例,具体可以看你服务器配置调整。

4.3 导入后的检查清单

导入完成后,不要急着让标注员开工。先自己在界面上抽查几个任务:

  1. 打开任务页面,看图片能不能正常加载。如果图片加载不出来,检查data.image的值——是URL还是本地上传路径,网络是否可达。
  2. 看预测框是否显示。如果图片加载了但没有框,大概率是from_name、to_name对不上,或者value里的rectanglelabels与项目配置里的Label名称不一致。
  3. 点击某个框,看是否能选中和编辑。如果无法编辑,可能是type字段写错了,比如rectanglelabels拼成了rectangelabels。
  4. 看框的位置是否贴合目标。如果框出现在错误位置,检查坐标系是否已经转换为百分比,并且确认original_width和original_height是否正确。

这四步都通过,说明预标注导入成功。然后就可以把项目分配给标注员。

4.4 审核修正工作流要点

标注员的实际操作体验是:打开图片,看到一堆带置信度颜色的框,挑错的改一改,漏的补一补,然后提交。为了提高效率,我在项目设置里开启了“自动保存草稿”,这样标注员做一半不小心关掉页面也不会丢太多进度。

另外很关键的一点是,要让标注员明确知道“预标注是机器给的参考,最终以他们修改后的结果为准”。这个不只是管理问题,也关系到数据质量。所以我通常还会在任务里附带一个简单说明区块:

<View> <Paragraphs name="note" value="$guide_text"/> </View>

这里的guide_text在data字段里传入,内容比如“这些框是模型预标结果,请检查位置与类别,删除多余的,补上漏掉的”。有了这层说明,标注员操作起来心态就会很明确,不会把预标注当成既定事实照单全收。

最终标注完成后,通过Label Studio的导出功能导出结果。导出格式可以选择JSON、COCO、YOLO等,根据下游训练需要来选择。导出后的数据里,annotations字段下的result就是人工确认后的标注结果。

5. 预标注导入中的常见问题与排查技巧实录

5.1 预测结果在界面上完全不显示

这是遇到最多的一个问题。检查顺序如下:

  1. 确认任务里确实有predictions字段。用API取值后直接print出来看,别凭印象。
  2. 确认result数组不为空。如果预测框本身是空数组,界面自然显示不出来。
  3. 确认from_name与XML配置里的控件name一致,type与控件类型一致。
  4. 确认value里标签与配置标签完全一致,包括大小写和空格。
  5. 确认project.create_prediction没有报400错误,如果有错误,尽快看响应体里的message。

我记得有一次批量导入后有一半的任务不显示框,排查半天发现是create_tasks导入的tasks里缺了predictions字段。因为我当时的代码是先创建空task再写prediction,但有一批空task因为网络中断没执行到预测写入步骤。后来我改成先用本地Python脚本生成完整的task JSON再一次性导入,问题就解决了。

5.2 坐标错位:框的位置差到离谱

框位置对不上,通常就是坐标没有归一化。尤其要小心一些模型框架导出的坐标是“cx, cy, width, height”格式(中心点加宽高),而不是左上角加宽高。Label Studio的value里的x、y是左上角坐标,如果你拿中心点坐标直接归一化,那所有的框都会整体向右下方偏移一半。

我曾经在一个目标检测项目里把中心点当成左上角填入,结果所有框都指向目标的右下方。后来处理方法是:在转换函数里先计算xmin = cx - width/2等,再执行归一化。这个经验很值得记下来,因为很多深度模型框架默认用中心点坐标表示。

5.3 重复导入导致任务重复

如果你在持续迭代过程中反复导入同一批图片,create_tasks默认会每次都创建新任务,导致大量重复。规避方法是在导入前检查图片是否已经存在。Label Studio API支持按数据字段去重,但更稳妥的方案是维护一个本地映射表:图片路径(唯一标识)到Label Studio任务ID的字典,导入前先查一次。

如果你用的是项目最初由API创建的任务,那么每次导入预测时都调用project.create_prediction(task_id=...),通过task_id更新该图片已有的预测结果,而不是重复创建。Label Studio支持一个任务拥有多个预测版本,如果你的迭代是为了最新一轮模型结果,建议先删除任务下的旧预测再写入新的,避免新旧结果同时存在让标注员困惑。

5.4 大数据量的性能处理

当任务列表达到几万个时,直接调用create_tasks一次性全量导入会非常吃内存和带宽。我的建议是分文件批量操作:每1000个任务存成一个JSON文件,用脚本循环上传,或者利用Label Studio的数据导入界面分批导入这些JSON文件。如果部署在服务器上,优先考虑把图片放到对象存储里,给data.image配置对应的URL,而不是上传图片二进制,这样任务体积会小非常多,导入速度也快很多。

5.5 多模型预测版本的处理

如果你手里有多个模型(比如一个老的权重和一个新训练的权重),想对比它们在相同数据上的预测质量,可以把不同模型的预测结果放在同一个任务的predictions数组里:

{ "data": {"image": "..."}, "predictions": [ { "model_version": "model_old", "score": 0.75, "result": [ ... ] }, { "model_version": "model_new", "score": 0.88, "result": [ ... ] } ] }

在标注界面上,标注员可以在预测版本间切换查看。这个小功能用好了,实际上等于做了一个轻量的人肉模型评测:通过标注员选择哪个版本更接近真实情况,你可以快速判断新模型是否真的值得上线。我在某个项目里就用这个办法淘汰了一个涨点但实际框偏严重的模型,省下了大量线下评估时间。

5.6 导出后的数据一致性检查

导入和导出不是两件独立的事。我建议在导出后做一次抽查:随机抽若干张图,把导出的标注和原图叠在一起画出来看位置。因为Label Studio导出时可以进行坐标归一化,也可以保留原像素级坐标,如果你导出的坐标和预期不一致,需要检查导出配置。常见的坑就是导出的框坐标已经被还原成图片原始像素,但训练代码默认要求百分比坐标,或者反过来。

这个检查虽然简单,但能帮你提前拦住训练数据出错的灾难。我以前因为没做这一步,用错坐标形式的数据训了整整一个周末,模型全废。

6. 一些额外的经验总结

把这个预标注导入流程在项目里完整跑通之后,我最大的感受是:技术实现只占30%,剩下的70%都是规范和流程设计。你要想清楚预标注从哪里来、到哪里去、哪些字段必须保持稳定、哪些字段只是辅助参考,这比写一个转换函数要难得多。

我最终把整套逻辑固定成了下面这个稳定的流程:模型推理输出统一整理为中间格式,再通过转换层写入Label Studio,标注员审核时只做增量修正,提交后自动触发新一轮模型重训。重训之后又一次推理、导入、审核,循环往复。每轮循环所需的人工介入时间都在递减,因为模型越训越准,标注员的修动量也就越来越小。

另外,如果团队里不是所有人都熟悉标签配置,建议把项目配置导出并版本化。Label Studio支持导入导出项目模板,这能让新成员在几秒钟内复制一套相同配置的项目,不需要手动重新写XML配置,也避免了标签名在不同项目里不一致的风险。

最后分享一个小技巧:在predictions字段里记录模型的model_version和推理时间,在annotations里记录审核人和审核时间。这样每次导出数据后,你都能做一次溯源分析:某个错误框是模型误检还是标注员误标,在哪里发生的,在哪一轮被修正的。数据链路清晰之后,整个标注系统的质量改进就不再靠感觉了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进