简介:面向使用 Label Studio 进行计算机视觉数据标注的算法工程师与标注团队,这份 Model.py 提供 YOLOv8 旋转框(OBB)目标检测模型的 ML 后端接入实现,用于把本地检测模型接成 Label Studio 的预标注服务,减少重复手动画框,并为自定义后端开发提供可直接参考的代码骨架。压缩包仅含 1 个 py 文件,大小约 2KB,核心就是 Model.py,重点展示模型加载、预测接口与 Label Studio 结果转换等关键逻辑,适合在此基础上替换权重、类别和预处理参数。已有 831 人学习,便于快速理解半自动标注链路中后端脚本的组织方式,也可作为 YOLOv8-OBB 项目落地预标注的轻量模板。
1. 从一张歪斜的标注框说起:YOLOv8-OBB 接进 Label Studio 半自动标注
Label Studio ML Backend 的 model.py 是半自动标注链路的入口,尤其当你要把 YOLOv8-OBB 的旋转框接进 Label Studio 时,真正卡人的不是训练,而是推理结果怎么变成前端能认的 rectanglelabels。普通检测框只有 x/y/w/h,OBB 多一个角度,坐标还要从像素换成百分比,角度方向和旋转中心一旦对不上,页面上就会看到一排歪到离谱的框。这个资源包围绕 Label-studio ml 的 model.py 展开,目标很明确:让 Labelstudio 项目能调用 yolov8 权重,把预标注结果回填成可编辑的旋转框,适合已经有一版 OBB 权重、想把半自动标注跑通的从业者,也适合刚开始接手 Label Studio ML backend 的新手按步骤复现。它解决的是“模型能推理但前端不认结果”的最后一公里,不是替代人工标注,也不是训练框架。
2. 把 backend 目录跑起来:model.py 的入口、依赖与 Label Studio 连接
这一章先把可运行的最小目录搭出来。很多翻车不是代码写错,而是 model.py 放错位置、依赖没装全、容器里权重路径不对,导致 Label Studio 点 Validate 时只返回一个超时。下面按目录、依赖、启动、挂接四步走,先让 backend 健康检查通过,再谈预测结果。
2.1 目录结构与 requirements.txt
我一般把 backend 单独放一个目录,名字随意,结构保持干净:根目录放 model.py、requirements.txt、Dockerfile、docker-compose.yml,权重放 weights/ 下。model.py 必须在启动目录的根层,label-studio-ml 启动时会在这个文件里找继承 LabelStudioMLBase 的类。权重不要写死在代码里,用环境变量 MODEL_PATH 指向容器内路径,换模型时只改环境变量,不改代码。
依赖文件不要贪多,OBB 推理只需要四类:ML backend 本体、YOLO 推理库、图片读取、HTTP 下载。ultralytics 会带 torch,镜像会比较大,CPU 版和 GPU 版最好分开构建。requirements.txt 可以写成下面这样,版本不锁死,按你环境里能装上的组合来。
label-studio-ml ultralytics pillow requests代码块里没有版本号,是因为不同 CUDA 驱动对应的 torch 组合差异很大,硬锁版本反而容易在容器里装不上。实际落地时先把 ultralytics 跑通,再回头补 label-studio-ml。权重文件放在 weights/best.obb.pt,docker-compose 里用 volumes 挂进去,避免每次改模型都重建镜像。
提示:model.py 里只保留一个继承 LabelStudioMLBase 的类,类名不要求固定,但不要放多个入口类,启动脚本自动发现时容易选错。
2.2 启动命令与端口
本地调试直接用 label-studio-ml 启动,端口给 9090,和 Label Studio 默认的 8080 错开。启动目录就是 model.py 所在目录,命令里的点表示当前目录。第一次启动会加载模型,日志里能看到权重路径和设备信息,看到 Uvicorn running 再去做健康检查。
# 在 backend 根目录执行,-p 指定服务端口 label-studio-ml start . -p 9090 # 另开一个终端做健康检查,返回 200 或 JSON 说明服务已起 curl -s http://localhost:9090/health如果走容器,docker-compose 里把 9090 映射出来,同时把 LABEL_STUDIO_URL 和 LABEL_STUDIO_API_KEY 传进去。LABEL_STUDIO_URL 用于 backend 回连 Label Studio 拉取任务和图片,LABEL_STUDIO_API_KEY 用于带认证头下载图片。两个变量不配也能跑本地文件,但一旦图片是 Label Studio 的上传路径,缺认证就会 401 或 404。
# docker-compose 启动,--build 让改动的 model.py 和依赖生效 docker compose up --build # 只看 backend 日志,确认模型只加载一次 docker compose logs -f ml-backend启动参数里还有一个容易忽略的点:worker 数量。每个 worker 都会加载一份模型,CPU 上开太多会把内存吃满,GPU 上会把显存切碎。单卡单模型时我一般只开一个 worker,靠 Label Studio 侧控制一次提交的任务数。模型加载放在init里,不要放在 predict 里,否则每个请求都重新读权重,日志里会反复出现加载信息,响应时间也会从秒级掉到分钟级。
2.3 在 Label Studio 里挂 backend
Label Studio 项目里进入 Settings,找到 Machine Learning,Add Model,URL 填 backend 的可达地址。两个服务在同一台机器上时,本地调试填 http://localhost:9090;如果 Label Studio 在容器里、backend 在宿主机,容器内的 localhost 指向容器自己,需要换成平台支持的主机名;如果两个都在 compose 里,直接用服务名,例如 http://ml-backend:9090。填完保存,点 Validate,返回模型版本和健康状态才算挂接成功。
挂接失败先看三处:backend 日志有没有收到请求、Label Studio 日志里的连接错误是超时还是 401、容器网络里服务名能不能解析。Validate 超时很常见,原因是模型第一次加载慢,Label Studio 侧等不及。先把模型加载完、curl 健康检查通过,再点 Validate,能省掉很多误判。URL 后面不要带多余路径,除非你的部署确实挂在子路径下。
注意:LABEL_STUDIO_API_KEY 不要写进代码仓库,放环境变量或 compose 的 env_file 里,换 key 时只重启 backend。
3. 在 model.py 里写 predict:OBB 的 xywhr 到 Label Studio rectanglelabels
这一章是核心。model.py 不需要实现训练,至少要把 predict 的输入输出契约写对:收到一个 task 列表,返回同样长度的 predictions 列表,每个 prediction 里放 result 区域数组、score 和 model_version。OBB 的难点集中在 result 里每个区域的 value 字段,坐标是百分比,旋转框还要带 rotation。
3.1 predict 的输入输出契约
Label Studio 调用 backend 时传进来的 tasks 是列表,每个 task 至少有 data 字段,图片字段名取决于 label config 里 Image 的 value,常见是 image。predict 返回的 predictions 必须和 tasks 一一对应,不能少也不能多。每个 prediction 的 result 是一个区域数组,区域 type 用 rectanglelabels,from_name 对应 RectangleLabels 的 name,to_name 对应 Image 的 name。
一个最小 result 区域长这样,x、y、width、height 都是相对图片宽高的百分比,rotation 是角度。score 放在区域里方便前端按置信度筛选,prediction 层再放一个整体 score,取区域里的最大值。model_version 写模型标识,后续换权重时能看出预测来自哪一版。
{ "from_name": "label", "to_name": "image", "type": "rectanglelabels", "value": { "x": 31.2, "y": 22.8, "width": 18.5, "height": 9.4, "rotation": 37.5, "rectanglelabels": ["ship"] }, "score": 0.87 }这段 JSON 里最容易被忽略的是 from_name 和 to_name。label config 里写 name="label",这里就必须是 label;写 name="bbox",这里就得改成 bbox。名字对不上时,backend 日志一切正常,前端就是不显示框,排查起来很费时间。我一般把这两个名字做成环境变量 FROM_NAME 和 TO_NAME,联调时改环境变量,不反复改代码。
3.2 OBB 的 xywhr 与 Label Studio 的 rectanglelabels
YOLOv8-OBB 推理结果里,r.obb.xywhr 给的是中心点 x、中心点 y、宽 w、高 h、旋转量 r。Label Studio 的 rectanglelabels 常见字段是左上角 x、左上角 y、宽、高、rotation。转换分两步:先把像素坐标除以图片宽高乘 100 变成百分比,再把中心点减半个宽高变成左上角。公式如下,W 和 H 是原图像素宽高。
| 目标字段 | 计算方式 | 说明 |
|---|---|---|
| x | (cx - w/2) / W * 100 | 未旋转矩形的左上角横坐标 |
| y | (cy - h/2) / H * 100 | 未旋转矩形的左上角纵坐标 |
| width | w / W * 100 | 宽度百分比 |
| height | h / H * 100 | 高度百分比 |
| rotation | deg(r) 后取模 | 角度单位要先确认是弧度还是度 |
角度是最容易翻车的地方。不同版本、不同导出链路对 r 的单位可能是弧度,也可能是度;方向可能是逆时针,也可能是顺时针。代码里先按弧度处理,用 math.degrees 转成度,再对 180 取模。跑第一张图时不要批量跑,先打印原始 xywhr 和转换后的 rotation,在 Label Studio 里手动画一个同角度框对比。如果发现角度差 57.3 倍,说明原始值已经是度,去掉 math.degrees;如果方向反了,把角度取负或做 180 减,按你前端实际回显为准。
3.3 完整 model.py
下面这份 model.py 覆盖了模型加载、图片下载、OBB 转换和 predictions 组装。代码里保留了注释,重点是init只加载一次模型,_load_image 处理远程 URL 和本地路径,_obb_to_ls 做坐标和角度转换,predict 负责逐任务推理并返回结果。
import io import math import os import requests from PIL import Image from label_studio_ml.model import LabelStudioMLBase from ultralytics import YOLO class YOLOv8OBBModel(LabelStudioMLBase): def __init__(self, **kwargs): super().__init__(**kwargs) # 模型和推理参数全部走环境变量,换权重不用改代码 self.model_path = os.getenv("MODEL_PATH", "weights/best.obb.pt") self.conf = float(os.getenv("CONF", "0.25")) self.iou = float(os.getenv("IOU", "0.5")) self.imgsz = int(os.getenv("IMGSZ", "1024")) self.device = os.getenv("DEVICE", "cpu") self.from_name = os.getenv("FROM_NAME", "label") self.to_name = os.getenv("TO_NAME", "image") self.model_version = os.getenv("MODEL_VERSION", "yolov8-obb") self.model = YOLO(self.model_path) # 只在这里加载一次 def _load_image(self, task): image = task.get("data", {}).get("image", "") headers = {} token = os.getenv("LABEL_STUDIO_API_KEY", "") if token: headers["Authorization"] = f"Token {token}" if image.startswith("http://") or image.startswith("https://"): resp = requests.get(image, headers=headers, timeout=30) resp.raise_for_status() return Image.open(io.BytesIO(resp.content)).convert("RGB") # Label Studio 上传图片常是 /data/upload/... 这种相对路径 base = os.getenv("LABEL_STUDIO_URL", "").rstrip("/") if base and image.startswith("/"): url = base + image resp = requests.get(url, headers=headers, timeout=30) resp.raise_for_status() return Image.open(io.BytesIO(resp.content)).convert("RGB") return Image.open(image).convert("RGB") def _obb_to_ls(self, result, width, height): obb = result.obb if obb is None: return [] xywhr = obb.xywhr.cpu().numpy() classes = obb.cls.cpu().numpy().astype(int) confs = obb.conf.cpu().numpy() names = result.names regions = [] for (cx, cy, bw, bh, rad), cls_id, score in zip(xywhr, classes, confs): # 先按弧度转角度;如果实测单位是度,改成 float(rad) angle = math.degrees(float(rad)) % 180 # Label Studio 常见 rectanglelabels 使用左上角坐标 x_pct = (float(cx) - float(bw) / 2) / width * 100 y_pct = (float(cy) - float(bh) / 2) / height * 100 w_pct = float(bw) / width * 100 h_pct = float(bh) / height * 100 if isinstance(names, dict): label = names.get(int(cls_id), str(cls_id)) else: label = names[int(cls_id)] regions.append({ "from_name": self.from_name, "to_name": self.to_name, "type": "rectanglelabels", "value": { "x": x_pct, "y": y_pct, "width": w_pct, "height": h_pct, "rotation": angle, "rectanglelabels": [label], }, "score": float(score), }) return regions def predict(self, tasks, **kwargs): predictions = [] for task in tasks: image = self._load_image(task) width, height = image.size result = self.model.predict( source=image, conf=self.conf, iou=self.iou, imgsz=self.imgsz, device=self.device, verbose=False, )[0] regions = self._obb_to_ls(result, width, height) score = max([item["score"] for item in regions], default=0.0) predictions.append({ "result": regions, "score": score, "model_version": self.model_version, }) return predictions代码后的逻辑说明按方法拆开看。init里把 MODEL_PATH、CONF、IOU、IMGSZ、DEVICE、FROM_NAME、TO_NAME、MODEL_VERSION 全部读成实例属性,环境变量改完重启服务即可生效。_load_image 先判断绝对 URL,再判断以斜杠开头的相对路径,最后回退到本地文件;带 LABEL_STUDIO_API_KEY 时下载请求会加 Token 认证头,避免上传图片返回 401。_obb_to_ls 里先取 obb,空结果直接返回空列表,不返回 None,前端对空数组更友好;坐标转换使用左上角公式,rotation 取模 180;类别名从 result.names 取,兼容字典和列表两种结构。
predict 里逐任务推理,把区域数组、最大置信度和模型版本组装成 prediction。score 取区域最大值是为了让前端排序时有依据,区域里也保留了单框 score,方便按阈值过滤。result 为空时 score 为 0,prediction 仍然返回,Label Studio 侧不会因为空结果报错。model_version 建议每次换权重时改一下,回滚时能区分预测来源。
参数说明集中在几个环境变量上。MODEL_PATH 是容器内权重路径,不是宿主机路径;CONF 是置信度阈值,半自动标注可以比训练评估时低一点;IOU 是 NMS 阈值,密集旋转框场景可以适当调高;IMGSZ 是推理尺寸,小目标不要直接压到 640;DEVICE 填 cpu 或 cuda:0,GPU 容器要确认驱动和显存;FROM_NAME 和 TO_NAME 必须与 label config 完全一致;MODEL_VERSION 只影响预测标记,不影响推理。
3.4 结果为空和类别缺失时怎么查
结果为空先看 r.obb 是不是 None,再看 conf 是不是太高。OBB 模型在不同数据分布上置信度差异很大,半自动标注阶段我一般先把 CONF 降到 0.15 跑一张图,确认有框再往上调。类别缺失看 result.names 和 label config 里的 Label value 是否完全一致,大小写、空格、下划线都算不同。前端不显示框但日志有结果时,优先检查 from_name 和 to_name,再看 type 是不是 rectanglelabels。
4. 联调半自动标注:label config、阈值与批量预测的落地参数
backend 能返回 JSON 不等于前端能编辑。Label Studio 的 label config 决定了接收哪种区域、旋转矩形开不开、类别名有哪些。这一章把 label config、阈值参数和半自动标注操作串起来,目标是让预测框在任务页里正常显示、能拖动修正、提交后不丢标签。
4.1 label config 的旋转矩形与类别对齐
OBB 要用可旋转矩形,label config 里 RectangleLabels 需要开启旋转属性。下面这份配置里 Image 的 name 是 image,RectangleLabels 的 name 是 label,和 model.py 里的 TO_NAME、FROM_NAME 对应。Label 的 value 必须和 YOLO 的 names 一致,模型输出 ship,这里就写 ship,不要写 Ship 或 ship_type。
<View> <Image name="image" value="$image" zoom="true"/> <RectangleLabels name="label" toName="image" rotate="true"> <Label value="ship" background="#FFA39E"/> <Label value="vehicle" background="#D4380D"/> <Label value="plane" background="#1D39C4"/> </RectangleLabels> </View>如果前端不显示旋转手柄,先确认当前 Label Studio 版本对 rotate 属性的支持方式,再看模型返回的 rotation 是否在合理范围。类别名不一致时,预测结果里会出现一个前端不认识的标签,表现是框显示了但标签为空,或者整条 prediction 被丢弃。联调阶段先把类别收敛到两三个,确认链路通了再扩全量类别。
4.2 阈值、iou、imgsz 与 device 的取舍
半自动标注的阈值和训练评估不是一个思路。评估时追求精确,半自动标注追求召回,宁可多给框让人工删,也不要漏掉目标让人工补。CONF 从 0.15 到 0.25 之间试,密集小目标取低值,背景干净取高值。IOU 在旋转框密集场景可以试 0.5 到 0.6,太低会把相邻目标合并,太高会留下重复框。
| 环境变量 | 作用 | 建议起点 | 注意 |
|---|---|---|---|
| MODEL_PATH | OBB 权重路径 | weights/best.obb.pt | 容器内路径,挂载进去 |
| CONF | 置信度阈值 | 0.2 | 半自动标注可先降后升 |
| IOU | NMS 阈值 | 0.5 | 密集旋转框试 0.55 到 0.6 |
| IMGSZ | 推理尺寸 | 1024 | 小目标别用 640 |
| DEVICE | 推理设备 | cpu 或 cuda:0 | GPU 容器确认显存 |
| FROM_NAME | RectangleLabels 的 name | label | 与 label config 一致 |
| TO_NAME | Image 的 name | image | 与 label config 一致 |
| MODEL_VERSION | 预测版本标记 | yolov8-obb | 换权重时同步改 |
IMGSZ 不是越大越好。OBB 小目标在 1024 上通常比 640 稳,但显存和耗时也上去了。CPU 推理时一次请求多张图会明显变慢,Label Studio 侧可能超时,批量预测要控制单次任务数。GPU 上可以适当开大,但 worker 数量仍然要克制,一个 worker 一份模型是硬成本。
4.3 半自动标注操作与结果回显
挂接成功后,在项目任务页找 Retrieve predictions 或 Auto-Annotate 入口,不同版本按钮位置有差异,常见做法是先点一次拉取预测,再在画布上修正。预测结果不会自动提交,人工确认后点 Submit 才进入标注集。提交后的标注可以用于离线重训,但 model.py 默认不实现训练,别指望提交后 backend 自动变强。
批量跑的时候,Label Studio 可能一次把多个 tasks 发给 backend,predict 里是顺序推理,任务多时响应会拉长。我的做法是先把 CONF 调低、IMGSZ 调到能接受的速度,先跑十张看回显,再扩到全量。后端日志里重点看三类信息:模型加载次数、单次请求的 task 数、图片下载耗时。下载耗时突然变长,通常是图片 URL 走了远端存储或认证头没带对。
5. 避坑排查:坐标、角度、图片下载和标签名对不上的五类翻车
OBB 接 Label Studio 的翻车点很集中,基本逃不出坐标、角度、图片、标签、模型加载五类。下面每条按现象、原因、解决写,都是联调时真实会遇到的形态。先按这五条排查,能省掉大量“日志正常但前端不对”的时间。
5.1 框挤在左上角或尺寸明显偏小
现象是预测框全部集中在图片左上角,或者宽高只有正常值的几十分之一。原因通常是像素坐标直接塞进了百分比字段,Label Studio 把 500 当成 500% 处理;也可能是宽高用了原图尺寸而不是当前图片尺寸。解决是确认 x、y、width、height 都除以当前图片宽高再乘 100,图片尺寸从 PIL 打开的 image.size 取,不要用模型输入尺寸。另一个变体是中心点和左上角混用,框会整体偏移半个宽高,看起来像“框都往右下跑”,把 x 改成 cx - w/2、y 改成 cy - h/2 再试。
5.2 角度差 90 度、方向反了或框绕错中心
现象是框的位置对,但旋转方向明显反了,或者角度差 90 度、180 度。原因有三个:OBB 的 r 单位是弧度而代码按度处理,或者反过来;Label Studio 的 rotation 方向和模型输出方向相反;旋转中心定义不同,代码按左上角写,前端按中心点渲染。解决是先打一张 45 度校准图,打印原始 xywhr,在 Label Studio 里手动画一个 45 度框,对比 rotation 数值。差 57.3 倍就是单位问题,差符号就是方向问题,框整体偏半个宽高就是中心点问题。每次换模型版本都重新校准一次,不要沿用上一版的符号。
5.3 图片下载 401、404 或超时
现象是 backend 日志里 requests 报 401、404,或者任务一直卡在加载。原因是 Label Studio 上传图片的 URL 是内部地址,backend 容器访问不到;或者图片路径需要认证头,代码里没带 LABEL_STUDIO_API_KEY;或者容器网络里 localhost 指向了自己。解决是先把 LABEL_STUDIO_URL 配成 backend 能访问的地址,相对路径拼上 base 再下载,认证头用 Token 格式。两个服务放同一 compose 网络时用服务名,不要用 localhost。超时把 requests 的 timeout 调到 30 秒,同时看图片存储是不是在远端。
5.4 标签名对不上导致 prediction 被丢弃
现象是 backend 返回了 result,但前端不显示框,或者标签显示为空。原因是 YOLO 的 names 和 label config 里的 Label value 不一致,大小写、空格、下划线都算不同;也可能是 from_name 或 to_name 写错,区域挂不到对应控件上。解决是把 names 打印出来,和 label config 逐项对照,联调阶段只留两三个类别。from_name 必须等于 RectangleLabels 的 name,to_name 必须等于 Image 的 name,改名字时环境变量和 XML 一起改。
5.5 模型重复加载、显存涨满或 Validate 超时
现象是每次请求日志都出现模型加载信息,响应越来越慢;GPU 显存一路涨;Label Studio 点 Validate 直接超时。原因是模型加载写在了 predict 里,或者 worker 开太多,每个 worker 一份模型。解决是把 YOLO 初始化放到init,worker 数量降到 1 到 2,确认日志里只加载一次。Validate 超时先手动 curl 健康检查,等模型加载完再点。GPU 容器里还要确认 DEVICE 写的是 cuda:0,不是 cpu,否则会走 CPU 推理,速度慢到像卡死。
6. 用一张 45 度校准图做回归:验证 OBB 转换与预测分数
联调到最后,我习惯留一张 45 度旋转的校准图做回归。图里放一个已知角度的矩形目标,跑一遍原始 OBB 输出,再跑一遍转换后的 Label Studio JSON,对比中心点、宽高、角度和 score。这个动作看着笨,但能一次性抓住单位、方向、中心点三类问题,比在几十张任务里靠肉眼找歪框快得多。
import math from ultralytics import YOLO model = YOLO("weights/best.obb.pt") result = model.predict("calib_45.jpg", conf=0.1, imgsz=1024, verbose=False)[0] print("names:", result.names) print("xywhr:", result.obb.xywhr.cpu().numpy()) print("conf:", result.obb.conf.cpu().numpy()) for row in result.obb.xywhr.cpu().numpy(): cx, cy, bw, bh, r = row # 两个都打印,看哪个和 Label Studio 回显对得上 print("if_rad:", math.degrees(float(r)) % 180) print("if_deg:", float(r) % 180)代码里同时打印按弧度和按度转换的结果,哪个和前端手画框的 rotation 接近就用哪个。再看中心点转换后的左上角坐标是否落在图片合理范围内,宽高百分比加起来是否超过 100。score 检查两点:区域里的 score 和 prediction 层的 score 是否一致,空结果时 prediction 是否仍然返回。校准图跑通后,再拿十张真实任务跑批量,观察下载耗时和单次请求任务数。
从那以后我每次接 OBB 模型,都先用一张 45 度图走一遍原始输出到 Label Studio 回显的对照,再批量跑;这个习惯帮我省过很多次“框看起来对、角度差 90 度”的返工。希望帮到你。
本文还有配套的精品资源,点击获取