
简介面向使用 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/hOBB 多一个角度坐标还要从像素换成百分比角度方向和旋转中心一旦对不上页面上就会看到一排歪到离谱的框。这个资源包围绕 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.ptdocker-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 里挂 backendLabel Studio 项目里进入 Settings找到 Machine LearningAdd ModelURL 填 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 里写 predictOBB 的 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 用 rectanglelabelsfrom_name 对应 RectangleLabels 的 nameto_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 里写 namelabel这里就必须是 label写 namebbox这里就得改成 bbox。名字对不上时backend 日志一切正常前端就是不显示框排查起来很费时间。我一般把这两个名字做成环境变量 FROM_NAME 和 TO_NAME联调时改环境变量不反复改代码。3.2 OBB 的 xywhr 与 Label Studio 的 rectanglelabelsYOLOv8-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未旋转矩形的左上角纵坐标widthw / W * 100宽度百分比heighth / H * 100高度百分比rotationdeg(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] fToken {token} if image.startswith(http://) or image.startswith(https://): resp requests.get(image, headersheaders, timeout30) 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, headersheaders, timeout30) 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( sourceimage, confself.conf, iouself.iou, imgszself.imgsz, deviceself.device, verboseFalse, )[0] regions self._obb_to_ls(result, width, height) score max([item[score] for item in regions], default0.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 为 0prediction 仍然返回Label Studio 侧不会因为空结果报错。model_version 建议每次换权重时改一下回滚时能区分预测来源。参数说明集中在几个环境变量上。MODEL_PATH 是容器内权重路径不是宿主机路径CONF 是置信度阈值半自动标注可以比训练评估时低一点IOU 是 NMS 阈值密集旋转框场景可以适当调高IMGSZ 是推理尺寸小目标不要直接压到 640DEVICE 填 cpu 或 cuda:0GPU 容器要确认驱动和显存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 是 imageRectangleLabels 的 name 是 label和 model.py 里的 TO_NAME、FROM_NAME 对应。Label 的 value 必须和 YOLO 的 names 一致模型输出 ship这里就写 ship不要写 Ship 或 ship_type。View Image nameimage value$image zoomtrue/ RectangleLabels namelabel toNameimage rotatetrue Label valueship background#FFA39E/ Label valuevehicle background#D4380D/ Label valueplane 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_PATHOBB 权重路径weights/best.obb.pt容器内路径挂载进去CONF置信度阈值0.2半自动标注可先降后升IOUNMS 阈值0.5密集旋转框试 0.55 到 0.6IMGSZ推理尺寸1024小目标别用 640DEVICE推理设备cpu 或 cuda:0GPU 容器确认显存FROM_NAMERectangleLabels 的 namelabel与 label config 一致TO_NAMEImage 的 nameimage与 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 发给 backendpredict 里是顺序推理任务多时响应会拉长。我的做法是先把 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 的 nameto_name 必须等于 Image 的 name改名字时环境变量和 XML 一起改。5.5 模型重复加载、显存涨满或 Validate 超时现象是每次请求日志都出现模型加载信息响应越来越慢GPU 显存一路涨Label Studio 点 Validate 直接超时。原因是模型加载写在了 predict 里或者 worker 开太多每个 worker 一份模型。解决是把 YOLO 初始化放到initworker 数量降到 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, conf0.1, imgsz1024, verboseFalse)[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 度”的返工。希望帮到你。本文还有配套的精品资源点击获取