
简介面向使用 Label Studio 进行目标检测标注的开发者这份资源提供了 YOLOv8 OBB 旋转框检测模型接入 Label Studio ML 后端所需的 Model.py 文件。借助该脚本标注人员可在标注界面直接调用训练好的 YOLOv8 OBB 模型完成半自动预标注尤其适用于遥感影像、工业质检、文档版面分析等需要旋转矩形标注的场景。脚本封装了模型加载、图像预处理、推理预测、结果解析与标注回传等关键步骤可直接替换或集成到现有 ML 后端中。资源为 zip 压缩包仅含 1 个 Python 文件大小约 2KB结构精简方便对照官方教程快速部署。目前已有 831 人学习下载适合熟悉 YOLOv8 与 Label Studio 基础配置、希望搭建旋转框自动标注流程的中高级开发者。通过这份 Model.py读者可免去从零编写模型推理、坐标转换与标注回传等集成代码直接获得可运行的对接实现大幅缩短半自动标注管线的搭建时间。1. Label Studio 预标注链路里的 Model.py给 OBB 旋转框检测搭一座桥当标注任务里全是朝向各异的旋转目标时人工拖着多边形的四个角点去贴目标边缘一上午标不了几十张标注员的手腕和情绪都崩得很快。Label Studio 的 ML 后端就是用来缓解这件事的它在标注服务旁边额外跑一个推理服务打开新任务时自动请求这个后端把 YOLOV8 objectdetection-OBB 模型的检测结果先画出来人工只需要确认和修正。整个链路里最关键的实现文件就是后端的Model.py它决定了请求进来的图片如何被读取、如何推理、以及旋转框结果如何转回标注格式。这篇笔记从零讲清楚Model.py的完整写法、坐标转换细节和部署避坑点适合已经在 Label Studio 里建好 OBB 标注项目、想要引入模型预标注来提速的团队。2. 搭起 ML 后端骨架环境、依赖与 YOLOV8 OBB 权重加载2.1 先用脚手架生成最小后端装依赖、跑命令、认目录很多人在这一步会踩进同一个误区上来就照着老教程直接改model.py却不知道label-studio-ml的打包方式这几年已经重构过一次。2023 年之后的后端包名是label-studio-ml-backend命令行工具仍是label-studio-ml但目录结构、Model 基类接口都和旧版有了明显差异。先把环境装干净后面省掉很多排查时间。# Python 3.10 / 3.11 都可以建议在虚拟环境内部操作 python -m venv venv source venv/bin/activate pip install -U label-studio-ml-backend ultralytics opencv-python-headless pillow我一般不会把torch写进这一行里因为它必须跟本机 CUDA 版本对齐。如果机器上已经有可用的 PyTorch直接复用如果还没有先按自己显卡的 CUDA 版本装好 torch再执行上面这条安装命令。opencv-python-headless是为了在无显示环境下做图像解码pillow则配合 label_studio_ml 自带的工具函数读图。把后端项目和 yolov8 环境搭建步骤放在一起做会顺手很多。装完以后先确认命令行可用label-studio-ml --help能正常列出子命令说明安装没出问题。接下来用脚手架生成一个最小后端label-studio-ml create ml_obb cd ml_obb生成的目录里先认四个东西model.pyML 后端核心文件后面主要改它_wsgi.pyWSGI 启动入口一般不需要动requirements.txt部署时的依赖清单按实际删除多余项Dockerfile容器部署模板本地调试可以先不管。先不做任何改动直接启动一次默认后端验证环境是否真的通label-studio-ml start . --port 9090另开一个终端输入curl http://localhost:9090/health如果返回{status:UP}说明脚手架本身没有问题接下来可以安心把 Model.py 改造成 OBB 推理后端。注意这里必须是start .命令接受的是包含model.py的目录路径不是文件路径。2.2 Model.py 的类骨架setup 里加载权重设备与类别映射一次定死setup方法在服务启动时只执行一次适合把权重、设备、类别表这类重资源准备好。不要在predict里反复执行YOLO(...)加载模型那样每个请求都会出现肉眼可见的延迟。# ml_obb/model.py import os import torch from ultralytics import YOLO from label_studio_ml.model import Model class OBBPredictor(Model): def setup(self): weights_path os.environ.get(OBB_WEIGHTS, best_obb.pt) self.device torch.device(cuda:0 if torch.cuda.is_available() else cpu) self.model YOLO(weights_path) self.model.to(self.device) # 类别顺序必须和训练时的 data.yaml 完全一致 self.label_index { 0: airplane, 1: vehicle, 2: ship, } self.min_conf 0.35权重路径走环境变量原因很简单Model.py 一旦提交到 Git不同机器的绝对路径会不一样。用环境变量把路径和代码隔离开换机器部署时只改环境变量不碰代码。self.model.to(self.device)在部分 Ultralytics 版本里并不会真正把推理设备切过去真正起作用的是后面predict方法里显式传入device参数这里只是先做一个设备声明后面写predict时还会再传一次。类别映射是 OBB 后端最容易出错的地方。训练时data.yaml里的类别顺序决定了模型输出通道对应的类别编号如果你换过数据集或者合并过多个数据集编号很可能和业务标签对不上。更稳妥的做法是直接把训练用的data.yaml放在后端目录里启动时读进来import yaml with open(data.yaml, r, encodingutf-8) as f: data_cfg yaml.safe_load(f) self.label_index {int(k): v for k, v in data_cfg[names].items()}这样后端和训练配置永远共用同一份类别来源人工复查时只需要核对一个文件。如果你还没有训练好的 OBB 权重可以先从 Ultralytics 官方发布页下载一个预训练 OBB 权重用来打通流程等标注了几百张图再训练自己的权重。2.3 标签配置决定预测结果长什么样OBB 标注类型怎么选写predict之前得先把 Label Studio 那边的标签配置想清楚因为预测结果的type、from_name必须跟标注配置对齐否则标注页面根本拿不到预测框。OBB 旋转目标在 Label Studio 里常见有两种表示方式我的建议是直接用PolygonLabelsView Image nameimage value$image zoomtrue rotatetrue/ PolygonLabels namelabel toNameimage opacity0.6 strokeWidth1 Label valueairplane background#FF0000/ Label valuevehicle background#00FF00/ Label valueship background#0000FF/ /PolygonLabels /View用多边形的好处是旋转框的语义完全落在四个控制点上不依赖 Label Studio 不同版本对rectangle旋转字段的兼容性。YOLOv8 OBB 输出的是中心点、宽高、旋转角把它转成四点多边形只需要几十行坐标变换代码而且标注员在画面上拖顶点微调体感比拖一个旋转矩形更直观。如果后续要把标注结果导出成训练集多边形坐标也能通过最小外接矩形算法还原成xywhr。也有团队用RectangleLabels加旋转角字段来标 OBB预测类型对应写成rectangle值里带x、y、width、height、rotation。这个方案在部分版本里能跑但历史兼容性问题比较多同一个旋转字段在旧版前端可能不生效。除非你的标注团队已经习惯这种交互否则不建议拿它当主线。Model.py 里的逻辑应该以标签配置为准配置定了预测结果就跟着输出对应type不要混着来。3. 让 predict 真正干活的三个环节读图、推理、把旋转框还给 Label Studio3.1 predict 的输入输出契约tasks 里到底有什么predict方法的签名是predict(self, tasks, batch, **context)。tasks是一个列表每个元素是一个标注任务的字典。实际调试时打印一下就看到结构了{ id: 1023, data: { image: /data/upload/2024/05/11/a1b2c3.jpg }, annotations: [] }data.image的值由标签配置里Image value$image/决定。在正常标注流程里它通常是一个 Label Studio 内部的上传地址或者本地文件的绝对路径。context里会带上部署时配置的参数和其他运行时信息大部分情况下用不到但保留**context形参是必须的免得脚手架升级后接口不兼容。predict的返回值也不是随便一个字典都行。新版本的后端框架推荐用LabelStudioMLResponse包装预测结果它会做一层字段校验避免因为少写一个score导致前端渲染失败。下面几节先把结果一步一步拼出来最后统一返回这个对象。3.2 图像读取与推理参数统一用 PIL避免 BGR 通道翻车读取图像是第一个容易翻车的环节。label_studio_ml.utils里提供了get_image_local_path和load_image前者负责把 Label Studio 传过来的路径或 URL 整理成本地文件路径后者负责真正把图片加载成 PIL Image。from label_studio_ml.utils import get_image_local_path, load_image image_path get_image_local_path(task[data][image]) image load_image(image_path) # PIL Image这里有一个关键选择不要贪图 OpenCV 方便就额外用cv2.imread。OpenCV 默认读出来是 BGR 通道序而 YOLOv8 训练时用的是 RGB通道顺序反了会让模型在物体密集、纹理相似的数据上出现整片误检。直接用 PIL通道序从头到尾保持一致少一类玄学问题。推理参数看起来是几个数字实际上每一步都决定预标注质量results self.model.predict( sourceimage, imgsz1024, confself.min_conf, iou0.7, deviceself.device, verboseFalse, )imgsz尽量和训练时一致。OBB 模型如果训练时用了 1024推理时不要贪快改到 640旋转小目标的检测率会明显掉。conf是置信度阈值。预标注场景建议比训练时调高一些比如 0.35 到 0.5。阈值太低页面上会堆满低置信度框标注员的注意力被稀释。iou用于 NMS 去重。默认 0.7如果目标互相遮挡严重可以调到 0.75 到 0.8减少相邻框被合并的概率。device必须显式传self.device否则 Ultralytics 在部分环境中会自己挑一个设备可能落到 CPU 上跑速度慢好几倍。results是一个列表通常取results[0]就能拿到当前图像的预测结果。如果模型检测不到任何目标results[0].obb会是 None这个空判断不能省。3.3 后处理是重头戏xywhr 转 Label Studio 四点多边形YOLOv8 OBB 的输出和普通目标检测不一样不能用results[0].boxes必须取.obb属性。每一个旋转框对象里包含四样东西字段含义说明cx, cy旋转框中心点像素坐标基于原始图像分辨率w, h旋转框宽高宽高不受旋转影响是原始意义上的长和宽r旋转角度单位是弧度不是角度cls / conf类别索引与置信度类别索引对应训练时的 names 顺序很多移植代码在这里栽跟头把弧度当成角度直接算结果旋转框全部歪 90 度长边短边互换。下面这个函数把旋转框转成四个顶点按统一顺序输出保证 Label Studio 多边形不会自交import math def obb_to_polygon(cx, cy, w, h, angle_rad): cos_a math.cos(angle_rad) sin_a math.sin(angle_rad) half_w w / 2.0 half_h h / 2.0 # 局部坐标下的四个角点按顺时针排列 local [ (-half_w, -half_h), (half_w, -half_h), (half_w, half_h), (-half_w, half_h), ] points [] for dx, dy in local: x cx dx * cos_a - dy * sin_a y cy dx * sin_a dy * cos_a points.append([round(x, 2), round(y, 2)]) return points这个变换就是二维旋转矩阵的应用。局部角点先相对于中心点偏移再按angle_rad旋转最后平移到图像坐标。注意角度单位是弧度如果你在调试时打印结果想肉眼验证可以用angle_rad * 180 / math.pi转成角度输出。把旋转框拼成 Label Studio 能识别的预测结果result_item { from_name: label, to_name: image, type: polygon, value: { points: points, polygon: points, }, score: conf, }from_name和to_name必须和标签配置里的控件名字一致配置里写的是namelabel和toNameimage这里就照抄。type是polygon和PolygonLabels对应。points里的坐标是绝对像素值不是归一化小数这个尤其注意因为模型输出的坐标本来就是像素坐标不需要额外缩放。3.4 把多目标结果组装成一次完整预测返回把上面的零件拼起来写完整的predict方法。一次请求进来可能有多个任务每个任务都要独立走一遍读图、推理、后处理的流程。from label_studio_ml.response import LabelStudioMLResponse class OBBPredictor(Model): def predict(self, tasks, batch, **context): predictions [] for task in tasks: image_path get_image_local_path(task[data][image]) image load_image(image_path) results self.model.predict( sourceimage, imgsz1024, confself.min_conf, iou0.7, deviceself.device, verboseFalse, ) obb results[0].obb if obb is None: predictions.append({result: [], score: 0.0}) continue result_items [] conf_list [] for i in range(len(obb)): xywhr obb.xywhr[i].cpu().numpy() cx, cy, w, h, angle_rad xywhr class_idx int(obb.cls[i].item()) conf float(obb.conf[i].item()) label_name self.label_index.get(class_idx, fclass_{class_idx}) points obb_to_polygon(cx, cy, w, h, angle_rad) result_item { from_name: label, to_name: image, type: polygon, value: { points: points, polygon: points, }, score: conf, id: fobb_{i}, } result_items.append(result_item) conf_list.append(conf) predictions.append({ result: result_items, score: max(conf_list) if conf_list else 0.0, }) return LabelStudioMLResponse( predictionspredictions, model_versionyolov8-obb-001, )代码逻辑是先解析任务里的图片路径加载图像然后调用model.predict一次推理检测结果为空就返回空 result有结果则逐个把xywhr转成多边形组装成result_item。最终整个任务的score取最大置信度这个值会用于 Label Studio 端的预测排序。注意id字段最好保证每次预测的唯一性。如果你在predict返回值里复用了上一轮的idLabel Studio 前端在做预测合并时可能出现旧框残留的情况。用obb_加循环下标基本不会撞。4. 本地把 ML 后端跑起来自测、联动与旋转框验证4.1 用 curl 直接打 predict 接口绕开页面先看返回写完后端代码最怕直接在 Label Studio 页面里测因为页面链路长出错时很难判断是后端的问题、网络的问题还是前端渲染的问题。我更习惯先用 curl 把后端接口摸透再上标注页。label-studio-ml start . --port 9090服务起来后构造一个最简单的预测请求curl -X POST http://localhost:9090/predict \ -H Content-Type: application/json \ -d {tasks:[{data:{image:/absolute/path/to/test.jpg}}]}返回的 JSON 里会有results和model_version字段。重点检查两块一是results[0].result里是不是有预期的多边形点二是坐标值有没有超出图像宽高范围。如果图像是 1920x1080点坐标却出现 5000说明坐标空间没有对齐这通常是读取图像路径时用错了图片或者模型推理的输入尺寸被内部缩放后没有映射回原图。这个阶段不要去看怪异的可视化效果直接用数值判断最可靠。你还可以在本地写一个三行脚本把返回的四个点和图片画在一起import json import urllib.request body json.dumps({tasks: [{data: {image: /absolute/path/to/test.jpg}}]}).encode() req urllib.request.Request(http://localhost:9090/predict, databody, headers{Content-Type: application/json}) resp json.loads(urllib.request.urlopen(req).read()) for pred in resp[results][0][result]: print(pred[value][points], pred[score])这样能看到每个预测框的坐标和置信度对比原图里的目标位置角度转没转对立刻能看出来。4.2 在 Label Studio 页面挂接这个后端三步走完联动curl 通了以后再去 Label Studio 页面绑定后端。路径是项目设置里的 Machine Learning 页面点击 Add Model输入刚才启动的 URLhttp://localhost:9090。如果有鉴权要求填上 Label Studio 的 API Token本地调试一般不需要。保存后回到标注页面打开任意一个任务等一两秒预测框会自动出现在图像上。页面联动的常见问题集中在from_name和to_name的对应关系上。如果后端日志显示请求成功、返回了results但页面上一片空白十有八九是返回的from_name和标签配置里的name不一致。多检查一遍model.py里的字符串和label_config.xml这种错误后端不会报错因为框架层只负责转发数据语义对不对它不管。4.3 验证旋转框吻合度的三个小习惯页面联动成功后不要急着让标注团队开工先用几张典型图验证旋转框质量第一用有明显朝向的物体验证。拿一张飞机、汽车这类长宽比大且方向清晰的图看预测的多边形长边是否贴合目标的长轴。如果所有框都变成水平直框说明角度被当成 0 处理了问题出在xywhr解析或弧度转换上。第二检查多边形是否自交。Label Studio 渲染多边形时按你给定的点顺序连线闭合如果点顺序乱了会出现交叉的多边形视觉上一眼就能看到。上面代码里局部角点按顺时针排列基本不会出问题但如果你自己改了顺序就要留意。第三把模型预测结果和人工标注结果对齐比较。取同一张图先人工标一遍再打开预标注看同一目标的角度偏差是不是在可接受范围内。OBB 模型对角度特别敏感差 2 到 3 度人工修正很快差 20 度以上就要回看训练数据里是不是存在角度标注不一致的问题。5. 避坑记录Model.py 调试中最常翻车的几个位置5.1 图片本地路径解析失败后端拿不到图现象后端日志提示找不到文件或者请求直接超时。检查task[data][image]发现它是一串 HTTP URL但后端服务所在机器访问不了这个地址。原因Label Studio 传给你的data.image往往是对应上传文件的内部访问地址。ML 后端和标注服务不在同一台机器或者没有配置鉴权时这个地址无法从后端侧下载。解决在后端启动的同一台机器上配置LABEL_STUDIO_HOST和LABEL_STUDIO_API_KEY环境变量让get_image_local_path能通过 API 拉取图片更省事的做法是把图片目录做成共享存储让后端直接走本地路径。我一般倾向于共享存储因为对大批量任务来说逐个走 HTTP 下载图片的耗时不可忽略。5.2 xywhr 的角度单位没统一旋转框全部歪 90 度现象预测框的中心点和宽高基本正确但整体旋转方向不对长边短边互换界面里看起来就是“框倒了”。原因xywhr里的r是弧度不是角度。很多移植代码把它当角度直接传入三角函数或者把它乘了 180 再除 pi 导致方向反转。解决在obb_to_polygon函数里严格统一使用弧度不要在外面做任何角度转换。如果要从调试日志里肉眼判断只在打印时转换成角度最终参与坐标运算的一律用弧度。检查方法也简单在模型推理后打印一行angle_rad * 180 / math.pi和原图目标朝向对比一下心里就有数了。5.3 from_name / to_name 与标签配置对不上预测结果石沉大海现象后端日志里没有报错predict 返回正常但标注页面不显示任何预标注框前端控制台也没有异常。原因返回结果里的from_name和to_name字符串与label_config.xml不一致。框架只负责转发数据不校验字段语义所以不会报错。解决打开项目设置的标签配置确认PolygonLabels那个控件定义的name和toName再把model.py里的from_name和to_name一字不差地照抄过来。注意大小写to_name不要写成toname。5.4 置信度阈值太低预标注变成刷屏现象标注页面打开后一张图上堆了几十个框很多框明显对着背景纹理标注员右键删框的时间比从头标注还长。原因直接用了模型训练时的conf0.25这个值对训练阶段合适但对预标注场景太低。预标注的目的是给人省时间不是展示模型能检测到多少目标。解决预标注场景把conf调到 0.45 到 0.6。如果数据集中目标密集且互相遮挡严重可以适当降低一点但不要低于 0.35。这个值建议做成环境变量或者类属性不要写死在推理代码里这样现场调整阈值的时候不用改代码重启服务。5.5 模型一直跑 CPU显卡利用率上不去现象服务能跑但一张图推理耗时几百毫秒甚至几秒GPU 利用率很低甚至为零。原因只调用了self.model.to(self.device)没有在predict里显式传device参数。Ultralytics 在内部会根据参数重新选择设备你的to()调用可能根本没生效。解决在model.predict里加上deviceself.device并用环境变量控制默认设备。确认设备是否生效可以直接在日志里打一行print(self.device)或者用nvidia-smi看显存是否有占用。这个问题在多人共用 GPU 的服务器上尤其容易漏掉因为它不报错只是速度慢。6. 把预标注质量再往上提一档的后处理习惯6.1 角度归一化与类别级置信度两个快速调优手段YOLOv8 OBB 的角度定义在-pi/2到pi/2之间意味着一个旋转目标在表示上存在两种等价形式一个角度加半圈等价于同一个框的角度不变但长宽互换。模型本身已经做了归一化但你在转换出来的多边形的顶点顺序上还是会留下痕迹。我的习惯是每次转换后检查多边形四个点的排列方向确保顺时针排列并且第一条边的方向对应目标的长边。这个小细节能避免 Label Studio 渲染时出现交叉连线。类别级置信度比全局阈值更实用。全局阈值调到 0.5如果某个类别本来就难检它的召回率会掉得太狠调到 0.3其他好检的类别就开始刷屏。更好的做法是在setup阶段为每个类别单独设一个阈值比如self.class_conf {vehicle: 0.45, ship: 0.55}在组装 result 之前按类别查表查不到就使用全局默认值。这个方法用几行代码换来的是标注团队手感的大幅提升。6.2 为落地留一手TensorRT 部署思路Model.py 在本地跑通后接着要考虑标注团队多人同时使用时的吞吐量。预标注和训练不一样它不追求最高单图准确率而是追求“稳定、可预期、别让标注员等”。如果单张图上要跑 1024 分辨率的 OBB 推理纯 PyTorch 模型在高并发下很容易把显存占满。常见做法是先把权重导出成 TensorRT 的 engine 文件yolo export modelbest_obb.pt formatengine imgsz1024 device0导出后把 Model.py 里的权重路径指向.engine文件Ultralytics 在predict时走 TensorRT 推理速度通常能提升两到三倍。注意换用 engine 后imgsz只能沿用导出时的尺寸不要临时改否则要么报错要么自动插值导致精度下降。如果连 GPU 都不宽裕另一个保底方案是把imgsz从 1024 降到 768同时把预处理里加入一个简单的图像增强比如对超大图先缩放再裁剪保证长边不超过 1024。代价是小目标召回率会掉一些但换来了更稳定的并发响应时间。我自己在 OBB 项目上踩过的那几个坑最后基本都收敛在角度单位、字段对齐和置信度这三个问题上。把 Model.py 的前五分钟调试时间花在确认这三件事上后面能省一整天的排查精力。希望帮到你。本文还有配套的精品资源点击获取