简介:针对Label Studio半自动标注场景,这份资源提供YOLOv8目标检测OBB(旋转框)模型的Model.py后端文件。它面向需要将深度学习模型接入Label Studio ML后端、实现遥感影像或任意角度目标高效标注的开发者,主要解决Label Studio默认不支持旋转框预测结果接入的问题,可直接替换或参考改造,打通“模型预标注—人工修正”的标注流程。资源包仅包含1个Python文件,压缩包大小2KB,结构精简,便于直接阅读与移植。当前已有831人浏览学习。通过Model.py,读者可快速理解Label Studio ML后端的接口约定、OBB检测结果的坐标格式转换,以及推理请求的响应封装方式;结合作者博客中的教程,可完整复现YOLOv8-OBB模型的半自动标注环境,节省自行调试接口与排查格式错误的时间,适合具备YOLOv8基础、正在探索Label Studio二次开发的算法或工程人员直接参考。
1. Label Studio ML 后端与 YOLOv8-OBB:为什么绕不开 Model.py
搜这个标题的人,多半已经在 Label Studio 里用 rotate 控件标了一批旋转框,想用 YOLOv8 的目标检测-OBB 模型做自动预标注,结果发现不是把.pt文件拖进去就能用。Label Studio 的 ML 后端是一个独立 HTTP 服务,而Model.py就是这个服务的预测入口,负责把 YOLOv8-OBB 输出的旋转矩形翻译成 Label Studio 能识别的标注结果。YOLOv8 的 OBB 输出和普通目标检测比,多了一个角度维度,这个维度恰恰是接口层面最容易掉链子、最容易让人对着一堆空白预标注蒙圈的地方。
这篇文章只讲一件事:怎么让Model.py在 Label Studio ML 后端里正确返回 YOLOv8-OBB 的旋转框。从请求链路开始,到一个能跑的最小实现,再到标签映射、角度转换和几个我实际踩过的接口级坑。适合正在做旋转目标标注工具链、或者想把 OBB 模型接进弱监督流水线的工程师,新手能照着复现,熟手可以直接抄避坑清单。
2. 先看懂 Label Studio ML 后端的请求链路:每次 predict 背后发生了什么
2.1 一个被反复调用的 HTTP 服务,和一个只初始化一次的 setup
Label Studio ML 后端不是插件,是一个独立服务。Label Studio 和它通过 HTTP 通信,用户点自动标注、批量预标注或者模型训练时,Label Studio 会调用这个服务的 predict 或 train 接口。Model.py就是这个服务里最核心的文件,它通常继承一个基类,重写setup和predict两个方法就够了。
setup在整个服务生命周期里只被调用一次,适合做模型加载、标签映射、参数初始化这些准备工作。predict则会被反复调用,每次收到一个或多个标注任务,需要读图、推理、组装预测结果返回。有经验的工程师会把setup当成构造函数来用,把YOLO模型加载放在这里。不要在predict里加载模型,否则每点一次预标注都要重新读一次权重,显存也会被反复申请,整个界面会卡到像是在看慢放。这个坑几乎每个接 Label Studio ML 后端的人都会踩一次,区别只是早踩晚踩。
Model.py的另一个常见误用是把业务逻辑全塞进predict里。比如在predict里临时读配置文件、解析label_config、初始化类别表。短期看不出来,一旦批量预测五百张图,就会发现大量时间花在重复初始化上。我会把环境变量、文件路径、类别列表全部放在setup阶段准备好,让predict只保留三件事:拿图、推理、转换格式。
2.2 一条典型 predict 请求里,你能从 task 中拿到什么
当 Label Studio 调用 ML 后端的 predict 接口时,Model.py里收到的是一个列表,列表每个元素是一个task对象。最常用的是task["data"]["image"],它可能是本地路径、上传后的相对地址,也可能是一个公网 URL。Label Studio 默认把图片文件放在自己的数据目录下,ML 后端不一定能直接访问同样的本地路径,所以读图前要先判断是不是 URL,是 URL 就下载到临时目录,再交给 YOLO。
预测返回的结构是固定的:一个predictions数组,每个元素有一个result数组。result里的每一项必须和标注配置中的控件类型对应。比如你在label_config里定义的是旋转框控件,返回的type就是rotate;如果类型不匹配,Label Studio 可能直接丢弃或者界面报错。每一项的value在图片坐标系下通常是归一化到 0 到 100 的坐标,YOLO 输出的像素坐标要除以图片宽高再乘 100。这个归一化在Model.py里最容易被忽略,尤其旋转框场景下,x 和 y 到底是中心点还是左上角点,定义不同,换算方式就完全不同。
下面是一个典型的返回结构:
{ "predictions": [ { "result": [ { "from_name": "bbox", "to_name": "image", "type": "rotate", "value": { "x": 54.2, "y": 33.1, "width": 12.8, "height": 4.3, "rotation": 0.62, "rectanglelabels": ["traffic_light"] } } ], "score": 0.81 } ] }这里的from_name和to_name必须与标注配置里的控件名一致,bbox是 rotate 控件的 name,image是源图区域。如果写错,Label Studio 会提示找不到坐标来源。rotation字段在这个 JSON 里是弧度制,这个也是后面最容易翻车的地方。
2.3 OBB 比普通目标检测多出的那道坎在哪
YOLOv8 的 OBB 模型在推理时输出的是旋转矩形,除了中心点 x、y、宽、高之外,还带一个旋转角 r。这个 r 的表示方式在不同仓库里非常不统一:有的用角度制 0 到 180,有的用弧度制-pi/2到pi/2,有的用来表示矩形长边相对 x 轴的角度。Ultralytics 的 OBB 输出常见是xywhr形式,r 是弧度。而 Label Studio 的 rotate 控件在界面上看到的角度是度数,API 里存的却是弧度,正方向还可能因为图像坐标系原点在左上角而和数学坐标系反一下。这就是 OBB 特有的三道坎。
第一道坎是角度单位,第二道坎是正方向,第三道坎是角的定义基准。YOLO 的 r 通常是从 x 轴正方向起逆时针到矩形第一条边;图像坐标系的 y 轴向下,所以显示出来往往是顺时针。如果直接把模型的 r 塞进 Label Studio,轻则旋转方向反了,重则长边和短边互换,预标注结果没法用。所以Model.py在 OBB 场景里不只是一个模型调用脚本,还要承担角度约定转换的工作。
很多人会问,能不能返回普通rectangle然后不管角度?普通检测的框是水平矩形,旋转检测的框覆盖范围更紧,对于一个斜着的飞机,水平矩形会把一半背景也包进来,预标注质量明显下降,标注员反而要花更多时间去修正。OBB 从模型选型到数据结构都必须用rotate完整表达,这也让Model.py比普通 HBB 模型的对接多了一层工作。
3. 写出一个能跑的 Model.py:YOLOv8-OBB 最小实现骨架
3.1 目录与类初始化:把模型加载和环境变量隔离出来
先约定一个干净的目录结构,常见做法是这样:
my_obb_backend/ ├── model.py ├── _wsgi.py ├── requirements.txt └── .envrequirements.txt里至少要包含label-studio-ml、ultralytics、Pillow、numpy、requests。_wsgi.py是服务入口,负责启动 WSGI 应用;不写在model.py里,方便本地单测时直接调用模型逻辑。
下面是model.py的最小setup代码:
import os import torch from label_studio_ml.model import LabelStudioMLBase from label_studio_ml.response import ModelResponse from ultralytics import YOLO class OBBModel(LabelStudioMLBase): def setup(self): self.device = os.getenv("OBB_DEVICE", "cuda:0" if torch.cuda.is_available() else "cpu") self.model = YOLO(os.getenv("OBB_MODEL_PATH", "./runs/obb/train/weights/best.pt")) self.model.to(self.device) self.conf = float(os.getenv("OBB_CONF", "0.25")) self.iou = float(os.getenv("OBB_IOU", "0.7")) self.imgsz = int(os.getenv("OBB_IMGSZ", "640")) self.labels = ["class_a", "class_b"]这个setup里没有多余业务逻辑,只把推理常用参数提升为环境变量。好处是同一个Model.py在不同项目里切换阈值和模型路径不用改代码,只需改.env。模型加载、设备选择都只做一次,避免在预测链路上反复初始化。
要注意的是self.labels的类别顺序必须和模型训练时的 class index 严格一致,不能想当然认为和model.names顺序相同。我习惯在setup阶段打印一次self.model.names,再用训练时用的dataset.yaml做对比,确认两者一致后才继续。这里虽然看起来多写一行列表,但能省掉后面很多标签不对应的排查时间。
3.2 predict 入口:循环 task、处理图片、组装预测
predict是 Label Studio 每次调用预标注时真正执行的入口。下面的实现里,tasks是一个列表,所以要循环处理。对于每个 task,先拿图片路径,再加载、推理、转换,最后放入predictions。
def predict(self, tasks, context=None, **kwargs): predictions = [] for task in tasks: image_path = task["data"].get("image") img = self._load_image(image_path) if img is None: predictions.append({"result": [], "score": 0.0}) continue dets = self.model.predict( source=img, conf=self.conf, iou=self.iou, imgsz=self.imgsz, verbose=False, ) ls_result = self._convert_dets(dets, img) predictions.append({"result": ls_result, "score": 0.0}) return ModelResponse(predictions=predictions)这段代码只做三件事:取图片、调用模型、转换结果。model.predict传入的是 PIL 图片而不是路径,可以省掉一次磁盘读写。verbose=False是必要的,否则每个任务都会往日志里输出模型结构,几百张图跑下来日志会非常拥挤,也会让标注界面响应变慢。
这里的score暂时填 0.0,后续如果想用 Label Studio 的预标注排序功能,可以取dets里所有框的最高置信度填进去。_convert_dets被单独抽出来,因为坐标和角度转换是整个 OBB 对接里最复杂的部分,单独成一个函数可以在不启动 Label Studio 的情况下直接验证。
3.3 把 xywhr 输出转成 Label Studio 的 rotate value
转换是所有 OBB 对接里的重头戏。Ultralytics 的 OBB 推理结果里,检测数据常见格式是 N x 7,前四个是中心点 cx、cy、宽、高,第五个是旋转角 r,后两个是置信度和类别索引。不过不同版本的字段顺序偶尔有出入,所以转换前先打印一行 row,确认字段含义。
def _convert_dets(self, dets, img): img_w, img_h = img.size result = [] if len(dets) == 0: return result det = dets[0] # OBB 模型优先取 obb 专用输出,没有则退回普通框 if hasattr(det, "obb") and det.obb is not None: data = det.obb.data.cpu().numpy() else: data = det.boxes.data.cpu().numpy() for row in data: if len(row) < 7: continue cx, cy, w, h, r, conf, cls = row[:7] label_name = self.labels[int(cls)] result.append({ "from_name": "bbox", "to_name": "image", "type": "rotate", "value": { "x": cx / img_w * 100, "y": cy / img_h * 100, "width": w / img_w * 100, "height": h / img_h * 100, "rotation": float(-r), "rectanglelabels": [label_name] }, "score": float(conf) }) return result逻辑说明:转换本质上就是归一化加上一次角度符号修正。-r是我在本地图上试出来加上的,如果你的模型 r 定义正好是顺时针为正,去掉负号就行。这里需要你有一张已知旋转方向的图,先跑一次验证符号。
from_name和to_name暂时写死为bbox和image,对应label_config里 rotate 控件的 name 以及它绑定的图像区域。如果你的控件名不叫bbox,这里一定要改,否则返回结果在界面上看不到。
float()包裹 r 和 conf 是因为 numpy 的 float32 不能直接 JSON 序列化,Label Studio 拿到的结果必须是原生 Python 类型。row[:7]解包前,我建议先单独打印一次row,确认第五个字段是角度而不是置信度,才继续批量处理。
3.4 图片加载:本地路径、URL 和临时文件清理
_load_image要处理三种情况:HTTP/HTTPS URL、本地绝对路径、Label Studio 上传后的相对路径。常用的实现是:
import requests from io import BytesIO from PIL import Image def _load_image(self, image_path): if image_path.startswith(("http://", "https://")): resp = requests.get(image_path, timeout=10) resp.raise_for_status() img = Image.open(BytesIO(resp.content)).convert("RGB") else: img = Image.open(image_path).convert("RGB") return img这里必须加convert("RGB")。很多 PNG 图片是 RGBA 四通道,YOLO 前处理对输入通道要求固定,不转换会直接报错。timeout 也是必须的,图片 URL 响应慢时,如果没有超时,predict 会一直挂起,整个标注界面都会卡住。更完善的项目会用tempfile下载到本地再加载,方便后面跟文件清理逻辑,但这里的最小实现已经能满足大多数预标注场景。
4. 对齐标签和推理参数:旋转框空白/错位的源头
4.1 从 label_config 里自动识别控件名,而不是写死
前一章的代码里把from_name写死成bbox,个人项目能用,但换一个项目就要改代码。更可靠的做法是在setup里解析self.label_config,找到类型为 Rotate 的控件,再读取它的 name。self.label_config是一段 XML,手工解析比较啰嗦,我一般用正则先快速定位:
import re def setup(self): # 前面的模型加载代码省略 self.from_name = "bbox" self.to_name = "image" if self.label_config: match = re.search(r'<Rotate name="([^"]+)"', self.label_config) if match: self.from_name = match.group(1)这个正则只匹配<Rotate>块,能避免把 Rectangle 控件误当成旋转框。to_name也一样,可以在正则里补充一个toName属性。有的项目里图像区域叫image,有的叫img,都不能写死。
解析逻辑放在setup里只做一次,不要在predict中重复解析。否则每个任务都跑一遍正则,性能浪费不说,代码里字符串匹配的维护也会增加复杂性。
4.2 类别映射:模型 class id 和 Label Studio 的 Label value 是两套体系
很多人在Model.py里直接写label_name = self.model.names[int(cls)],看着没毛病,但一旦模型自带的类别名和标注界面里的 Label value 不完全一致,比如英文和中文、下划线和空格,Label Studio 就会显示有框但标签不识别。我习惯在setup里明确写一份类别清单,注释清楚顺序与训练 class index 一致:
self.labels = [ "car", "truck", "pedestrian", "traffic_light", "traffic_sign" ]如果已经有训练用的dataset.yaml,可以在启动时自动读取它的names字段来生成这个列表,保证不会手写错。这里的核心原则是:模型输出的 class id 必须经过self.labels映射,而不是直接信任模型自带的 names。否则在 Label Studio 里看到的预标注就是一串"未识别标签"的框,等于白跑。
4.3 四个必调参数:看看你漏了哪一个
OBB 对接正式运行前,我会把所有影响效果的参数收敛成环境变量,方便批量测试时快速调整。下面这个表是我每次接新项目都会检查一遍的参数。
| 参数 | 环境变量 | 默认值 | 影响 |
|---|---|---|---|
| 置信度阈值 | OBB_CONF | 0.25 | 低阈值会看到大量低分框,标注员容易烦;高阈值漏检多 |
| NMS IoU | OBB_IOU | 0.7 | 旋转框重叠率比水平框低,一般不需要调太小 |
| 输入尺寸 | OBB_IMGSZ | 640 | 长条形目标建议 1024,显存不够再降 |
| 角度符号 | OBB_ROTATION_SIGN | -1 | 决定 r 要不要取反,必须用一张已知方向图验证 |
置信度和 NMS 由 YOLO 内部处理,Model.py只负责把它们传进去。输入尺寸直接影响小目标和畸变目标的召回率,尤其长条形的船、飞机机身、工业零件,OBB 对尺寸变化比普通检测更敏感。OBB_ROTATION_SIGN这一项只存在于 OBB 场景,单独用环境变量而不是写死在代码里,换模型时调整方向会方便很多。
我之前踩过的一个细节是,rotation的 value 在 Label Studio API 里是弧度,但在模型输出里有些版本是角度,有些是弧度。如果发现旋转角度整体偏大或偏小,先检查一下是否在_convert_dets里漏掉了弧度换算,而不是先怀疑模型效果。
5. 避坑:Label Studio + YOLOv8-OBB 对接时的 5 个典型翻车现场
5.1 现象:模型一直在跑,但界面永远拿不到预标注
这种情况通常不是模型没检出,而是返回结构不对。Label Studio 要求predict最终返回一个 predictions 数组,数组里每个元素又要包含result数组。如果Model.py里直接返回了 YOLO 的原始检测结果,或者漏掉了一层嵌套,Label Studio 就会当成空结果处理。
另一个常见原因是result里的from_name写错。比如label_config里 rotate 控件叫box,代码里写死bbox,Label Studio 匹配不到控件名时不会报错,只当这次预标注没有命中。解决方法是先单独调一次 ML 后端接口,检查返回 JSON 的结构,再核对from_name和标注配置里 Rotate 控件的 name。
5.2 现象:旋转框变成了水平矩形,orientation 信息丢了
现象是预标注结果确实有框,但全部是正矩形,斜着的目标没有被旋转框包裹。原因通常是返回类型用了rectangle,而不是rotate;或者value里没有带rotation字段,Label Studio 的 rotate 控件如果缺这个字段会默认用 0 代替。
另一种原因是在转换时用了boxes.data而不是obb.data。有些版本的YOLO.predict返回对象里同时存在boxes和obb,boxes里是普通水平框,obb才是旋转框。解决方法是打印一次dets[0]的属性,确认走的路径是 OBB 专用输出,并且检查value字典里的rotation确实是浮点数且不为 0。
5.3 现象:图片能收到,但 predict 函数直接报错或被 timeout 中断
这种情况多半出在图片加载环节。task["data"]["image"]是 URL,requests.get没有设置 timeout,图片服务器响应不正常时就会一直卡住;或者图片是 RGBA 通道,没有做convert("RGB"),在 YOLO 前处理阶段报错。
更隐蔽的原因是有一个 task 的图片路径为空,代码里没有做 None 判断,直接去打开一个空字符串。解决方法是给_load_image加一层保护,图片路径为空时返回 None,并在predict里跳过这个 task,同时记录日志。我的习惯是在整个predict外围再加一层try/except,确保任何单条 task 出错时,批量预测能继续跑下去,而不是一错全错。
5.4 现象:连续预测几个任务后,ML 后端不响应,CPU 内存被打满
这个坑在 GPU 服务器上几乎必现。Label Studio ML 后端默认用 gunicorn 启动,如果 worker 数设置过多,每个 worker 都会在自己的进程里加载一份 YOLO-OBB 模型。四张显卡的机器可能同时跑八个模型实例,显存直接 OOM,或者 CPU 推理线程互相争抢,整个服务假死。
解决方法是把 gunicorn 的 worker 数改成 1,并开启 preload。preload 会在 master 进程里加载模型,worker 进程通过 fork 继承模型副本,显存占用大幅下降。如果确实需要并发处理大量标注任务,优先用一个 worker 加异步队列,不要在进程维度上堆并发数。实际跑下来,一个 worker 处理预标注的吞吐量已经足够,瓶颈通常在图片加载和网络请求上。
5.5 现象:旋转框方向总是反的,或者长边短边对不上
这个是最典型的 OBB 方向坑。模型输出的 r 是 -90 度到 90 度区间,Label Studio 的 rotate 控件用的是 0 到 180 度弧度值,两者如果不做符号转换,就会出现方向整体相反的情况。还有一种情况是长边和短边互换,因为旋转框的宽和高定义在不同的角度基准下,模型可能认为长边对应 width,Label Studio 可能认为长边对应 height。
解决方法是先做一次"角度验证图"。找一张有明显 30 度倾斜的测试图,跑一次预标注,看 Label Studio 里出来的框是不是接近 30 度。如果变成了 150 度,就把rotation的符号取反;如果是 90 度附近翻转,就交换 width 和 height。这个验证只需要十分钟,但能省掉后面整个数据集标注方向错乱的血泪经验。很多翻车现场不是模型问题,而是角度约定没有对齐。
6. 调试技巧:把 Model.py 当成独立服务来验
6.1 用本地脚本直接调用同一个入口,构造最小请求体
在model.py末尾加一个简单的本地入口,能让你不启动 Label Studio 就验证整条链路:
if __name__ == "__main__": import json m = OBBModel() m.setup() fake_task = [{"data": {"image": "/tmp/test.jpg"}}] output = m.predict(fake_task) print(json.dumps(output, ensure_ascii=False, indent=2))这样跑一次就能看到返回的 JSON 结构,比直接上 Label Studio 排查快很多。如果本地打印出来的rotation角度、归一化坐标都正常,再放进 ML 后端服务里接真实请求。
正式环境里,ML 后端包一般会提供 WSGI 入口,你也可以直接用 Flask 启动同一个OBBModel,然后用 curl 发一条模拟请求,确认端到端通了再对接 Label Studio。这个环节最大的价值是隔离问题:返回结构错了,一眼就能看到;图像加载错了,也不会被界面吞掉。
6.2 校验旋转框是否命中的自查脚本:把 xywhr 转回图片画一遍
有时候 JSON 看起来正常,但是在 Label Studio 界面里框的位置不对,这时候可以写一个小脚本,把Model.py输出的 xywhr 转换回图像上直接画出来:
import cv2 import numpy as np def draw_obb_on_image(img_path, cx, cy, w, h, r_rad): img = cv2.imread(img_path) h_img, w_img = img.shape[:2] cx_pix = cx / 100.0 * w_img cy_pix = cy / 100.0 * h_img w_pix = w / 100.0 * w_img h_pix = h / 100.0 * h_img angle_deg = -r_rad * 180.0 / np.pi box = cv2.boxPoints(((cx_pix, cy_pix), (w_pix, h_pix), angle_deg)) box = np.int0(box) cv2.drawContours(img, [box], 0, (0, 255, 0), 2) cv2.imshow("validate", img) cv2.waitKey(0)这个脚本能很直观地看出三个问题:角度方向是否正确、归一化后的中心点是否对得上、宽高是否发生了长边短边互换。cv2.boxPoints的角度参数是度数,所以要把弧度换算回来。如果你发现画出来的框在目标上是顺时针转了 90 度,那说明Model.py里的-r符号和这里用的负号重复了一次。这个自查习惯能让我在切换 OBB 模型后十分钟内确定每次角度约定需要怎么改,而不是反复上线上数据试错。
6.3 用预标注分数反向调阈值,而不是凭感觉
Label Studio 展示预标注结果时,同一个任务里的多个预测框会按score排序。我喜欢在_convert_dets里把当前任务所有框的最高置信度写到 predictions 的score字段,然后在界面上观察排序结果。如果高分框经常是误检,说明OBB_CONF偏低,可以试着拉到 0.35 或 0.4;如果排序靠前的都是正确目标,但召回率不够,说明OBB_CONF偏高或OBB_IMGSZ偏小,先降阈值再考虑提升输入尺寸。
调参要先调OBB_CONF,再调OBB_IMGSZ,最后调OBB_IOU。顺序反过来很容易出现阈值调到很极端才发现是输入尺寸不够。方向符号OBB_ROTATION_SIGN是一票否决项,必须在调任何阈值之前先确认。我自己吃过这个亏:方向没验证就调参,最后所有旋转框都差一个镜像,又花了一天重跑标注。先验证方向,再调阈值,这是我用真金白银换来的习惯。希望帮到你。
本文还有配套的精品资源,点击获取