news 2026/10/29 2:26:29

Label Studio接入YOLOv8-OBB:Model.py旋转框预标注与增量训练

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Label Studio接入YOLOv8-OBB:Model.py旋转框预标注与增量训练

简介:面向使用 Label Studio 搭建 YOLOv8 旋转框(OBB)目标检测半自动标注流程的算法工程师与标注平台开发者,这份 Model.py 后端实现用于衔接 Label Studio ML Backend 与 OBB 模型推理服务,重点解决自定义模型接入、预测结果回传以及标注预填的适配问题。压缩包内共 1 个 Python 脚本,大小约 2KB,属于轻量级后端接口文件,主要承担模型加载、预测请求处理与 OBB 检测框格式转换等职责,便于在现有标注项目中替换或扩展自己的权重、类别配置与推理参数。已有 831 人学习下载,适合具备一定 Python 与 YOLOv8 使用经验、希望减少后端脚本从零编写成本并快速验证半自动标注链路的读者。通过该脚本可以梳理 Label Studio ML 后端与旋转框检测模型之间的数据流转方式,为接入自训练模型、调试预测接口和排查标注结果映射问题提供直接参照,也可作为二次开发与接口联调的起点。

1. 旋转框标注落到 Label Studio 上,卡住的往往是 Model.py 这一层

航拍图、遥感影像、密集排列的货架、成排停放的车辆,这些场景里正框会把两个挨着的目标框成一大坨,标注员画得别扭,模型学得也糊。Label Studio 的 RectangleLabels 控件本身带 rotation 字段,前端能画旋转框;YOLOv8-OBB 也早就直接输出带角度的检测结果。两头都能干,卡在中间的常常是那层胶水——ML 后端里的 Model.py。官方脚手架给的示例是水平框,要把旋转框塞回去,坐标换算、角度符号、尺寸基准这几段得自己补。这篇从这个文件的骨架讲到 predict()、fit() 的完整实现,给出能直接跑的最小版本,再把上线后真正会翻车的地方摊开。适合已经在用 Label Studio 做标注、想把 OBB 模型接进去做预标注或主动学习的人。

2. Label Studio ML 后端的契约:Model.py 到底要实现哪几件事

ML 后端本质是一个被 Label Studio 反向调用的 HTTP 服务。你在项目设置里填上它的地址,之后每次打开一个任务、点「预测」,Label Studio 都会带着任务数据 POST 过来,你的服务返回一组 result,前端照着画。这个服务长什么样、暴露哪些路由、进程怎么起,SDK 全包了;留给你的只有 Model.py 里那几个约定好的方法。理解这份契约比背 API 更重要——绝大多数接不上的问题,都不是模型不行,而是返回值的一个字段对不上。

2.1 目录骨架与镜像启动:把 backend 跑成一个可被调用的服务

先起骨架,命令行工具会生成一套目录:

label-studio-ml create obb_backend cd obb_backend

生成的东西里,真正要动的是这几个:

文件作用要不要改
model.py你的模型逻辑,继承 LabelStudioMLBase必改
requirements.txt依赖清单必改,加 ultralytics
Dockerfile打镜像一般不碰
docker-compose.yml起服务、挂环境变量和模型目录必改,挂 weights
_wsgi.pyWSGI 入口不碰

requirements.txt 里补上模型库,别写latest这种漂移的版本,按你本机验证过的那套锁死:

label-studio-ml ultralytics pillow pyyaml

本地先跑通再上容器,这一步能省掉大量「到底是环境问题还是代码问题」的纠缠:

pip install -r requirements.txt label-studio-ml start . --port 9090

起来之后先别急着连 Label Studio,自己打一发健康检查,返回 200 才算服务活着:

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9090/health

容器化时最容易踩的是路径。权重文件不要 COPY 进镜像,用 volume 挂进去,这样换模型只需重启容器:

services: obb_backend: build: . ports: - "9090:9090" environment: - LABEL_STUDIO_URL=http://host.docker.internal:8080 - LABEL_STUDIO_API_KEY=<your-token> - OBB_WEIGHTS=/app/models/best.pt - OBB_CONF=0.25 - OBB_IMGSZ=1024 volumes: - ./models:/app/models

LABEL_STUDIO_URL和LABEL_STUDIO_API_KEY这两个环境变量不是可选项。后端要拿回原图才能推理,而任务数据里给的往往是一个需要鉴权的 URL,SDK 的get_local_path()正是靠这两个变量去下载并落本地缓存。少配一个,表现就是 predict 里一路 401。

参数上有个取舍:OBB_IMGSZ设多大,取决于你的目标占整图的比例。航拍大图里几十像素的小车,imgsz 给 640 基本等于没检出来,1024 起步、显存够就上 1280。代价是单次推理时间线性往上走,预标注阶段要算进人力等待成本里。

2.2 predict 的入参与返回值:result 里每个字段都要对得上

predict(self, tasks, context=None, **kwargs)收到的是一个任务列表,返回一个字典,形如{"result": [...], "model_version": "..."}。result 里每一项就是一个标注结果,前端拿它直接渲染。旋转框走的是rectanglelabels类型,字段含义比水平框多一个 rotation,每个都不能省:

字段取值要求写错的后果
from_name控件名,和 label config 里的<RectangleLabels name="xxx">一致前端静默丢弃,页面上什么都不出现
to_name对应的<Image name="yyy">同上
type固定rectanglelabels类型不匹配直接不渲染
value.x / value.y中心点 x、y,相对原图宽高的百分比(0–100)传绝对像素值,框会跑到图外
value.width / value.height宽高百分比同上
value.rotation旋转角度,单位度,逆时针为正框整体镜像
value.rectanglelabels标签名数组名字对不上会被过滤
score置信度 0–1缺了不影响显示,影响排序和筛选
original_width / original_height原图像素尺寸缺了 rotation 会算错,框会歪

最容易忽略的一行是original_width/original_height。前端的换算逻辑是:先把百分比乘回这两个值得到像素坐标,再围绕框中心按 rotation 旋转。如果你这里填的是推理时 resize 后的尺寸(比如 1024×1024),而原图是 4000×3000,框会整体缩到左上角一小块,看起来像「模型没学好」,其实是基准给错了。

一个最小可用的骨架长这样,先保证空结果也能正常返回:

import os from label_studio_ml.model import LabelStudioMLBase class OBBBackend(LabelStudioMLBase): def __init__(self, **kwargs): super().__init__(**kwargs) self.weights = os.getenv("OBB_WEIGHTS", "/app/models/best.pt") self.imgsz = int(os.getenv("OBB_IMGSZ", "1024")) self.conf = float(os.getenv("OBB_CONF", "0.25")) self.iou = float(os.getenv("OBB_IOU", "0.5")) self.max_det = int(os.getenv("OBB_MAX_DET", "300")) self._model = None self.set("model_version", "obb-v0") def predict(self, tasks, context=None, **kwargs): # 先返回空列表,把链路打通再填内容 return {"result": [], "model_version": self.model_version} def fit(self, event, data, **kwargs): return

这段里self.set("model_version", ...)不是装饰。Label Studio 会记录每条预测来自哪个版本,后面做模型对比、回滚都靠它。我的习惯是版本号里一定带上权重的修改时间或者轮次,别用v1、v2这种手写序号,久了根本对不上是哪次训练出来的。

还有一点:预测为空时返回{"result": []},不要抛异常,也不要返回None。抛异常前端会弹红框,None会让 SDK 在序列化阶段炸掉,两种都表现为「后端挂了」,但实际原因完全不同,排查时会绕远路。

2.3 从 parsed_label_config 取 from_name 和 to_name,别写死字符串

新手最常干的事是把from_name="label"、to_name="image"硬编码进去。当时能跑,因为你的 label config 恰好是这两个名字。等同事复制了一份项目、把控件改名成obb_label,后端就一声不响地全空——没有报错,没有日志,页面干干净净。

SDK 在每次请求前都会解析项目的 label config,结果放在self.parsed_label_config里,结构大致是:

{ "obb_label": { "type": "RectangleLabels", "to_name": ["image"], "inputs": [{"type": "Image", "name": "image", "value": "url"}], "labels": ["car", "ship", "plane"] } }

按类型去找,而不是按名字去找:

def _obb_tag(self): for from_name, cfg in self.parsed_label_config.items(): if cfg["type"] == "RectangleLabels": return from_name, cfg["to_name"][0], cfg.get("labels", []) raise ValueError("label config 里找不到 RectangleLabels 控件")

这个函数的返回值有两重用途。from_name/to_name用于拼结果;labels是当前项目允许的标签白名单,predict 里拿模型输出的类别名去和它对一遍,对不上的直接跳过,能挡掉大量「标签名大小写不一致」「模型多训了一个废弃类」的脏数据。

标签映射也在这里收口。模型训练时的类别顺序是按数据集 yaml 里的 names 来的,和 Label Studio 项目里的标签顺序大概率不同,靠下标对齐迟早出事。我一般显式维护一张 dict:

LABEL_MAP = { "car": "car", "small_vehicle": "car", "ship": "ship", }

映射表放在 Model.py 顶部,改一次看得到,比塞进环境变量强。

3. 把 YOLOv8-OBB 的四点输出翻译成 Label Studio 的旋转矩形

模型这一侧的接口很直接:一次推理拿到results[0].obb,里面有xyxyxyxy(四个角点)、conf、cls。这些是原图像素坐标,ultralytics 在内部已经做过 letterbox 逆变换,不需要你再手动还原 padding。真正要动手的是把角点变成中心点加宽高加角度,并且保证角度方向和 Label Studio 的约定一致。这一节把这三步拆开。

3.1 取图与尺寸基准:original_width 和 original_height 必须先确定

Label Studio 传给后端的是任务的data字典,图像字段的值通常是一个 URL。SDK 提供了get_local_path(),它会带着 API token 去下载并缓存到本地,同一个任务重复请求不会重复拉取:

from PIL import Image def _load_image(self, url, task_id=None): local_path = self.get_local_path(url, task_id=task_id) return Image.open(local_path).convert("RGB")

如果项目开的是本地文件模式(文件在 Label Studio 所在机器的某个目录下),需要把LABEL_STUDIO_LOCAL_FILES_SERVING_ENABLED打开,并配好文档根目录,否则下载会 403。这是环境配置问题,不是代码问题,但现象和代码 bug 一模一样。

图打开之后,img.size拿到的就是原始宽高,这个值要同时用于两处:推理的输入,和 result 里的original_width/original_height。中间千万不要用imgsz去覆盖它。我见过一份代码在predict里先img.resize((1024, 1024))再推理,然后拿 1024 当基准写结果,所有框都缩在左上角四分之一区域——这就是典型的角度和尺度基准混用。

还有个小细节:遇到超大图(比如 8000×8000 的遥感切片),直接喂给模型会把显存打满。稳妥做法是先按长边缩到一个工作尺寸做推理,再把检测结果按比例乘回原图坐标,最后用原图尺寸做百分比换算。缩放比例记在一个变量里,乘回去的时候别漏。

3.2 四点转旋转矩形:中心点、宽高、角度的换算与符号校验

ultralytics 的obb.xywhr里也有现成的中心点和角度,但我不建议直接用它。原因有两个:一是它的角度约定在不同版本、不同数据集格式下有过调整,跨版本升级时容易悄悄变号;二是它的 w 和 h 有「哪条边在前」的隐含定义,和你从角点算出来的可能差 90°。用四个角点自己算,输入输出都是你能验证的量,出问题好定位。

换算逻辑本身很短:

import math def corners_to_result(corners, score, label, from_name, to_name, img_w, img_h): (x1, y1), (x2, y2), (x3, y3), (x4, y4) = corners cx = (x1 + x2 + x3 + x4) / 4.0 cy = (y1 + y2 + y3 + y4) / 4.0 w = math.hypot(x2 - x1, y2 - y1) h = math.hypot(x3 - x2, y3 - y2) # 图像坐标系 y 向下,atan2 得到的是顺时针角 theta = math.degrees(math.atan2(y2 - y1, x2 - x1)) # Label Studio 逆时针为正,取反后归一到 [0, 180) rotation = (-theta) % 180.0 return { "from_name": from_name, "to_name": to_name, "type": "rectanglelabels", "value": { "x": cx / img_w * 100.0, "y": cy / img_h * 100.0, "width": w / img_w * 100.0, "height": h / img_h * 100.0, "rotation": rotation, "rectanglelabels": [label], }, "score": score, "original_width": img_w, "original_height": img_h, }

逐段解释一下。中心点用四角平均是对的,任意平行四边形都成立,不用管它是不是严格矩形。宽高取相邻两边的欧氏距离,注意顺序:w对应第 1、2 点连线,h对应第 2、3 点连线,两者必须配套,否则宽度和角度会各说各话。角度用atan2(dy, dx),这里 dy 是图像坐标下的差值,y 轴朝下,所以算出来的是「屏幕上顺时针为正」的角,而 Label Studio 的 rotation 是逆时针为正,取反即可。

归一化到[0, 180)是个人习惯,好处是同一个矩形不会出现-90和90两种写法,存库、比对、去重都省事。矩形旋转 180° 与原矩形重合,这个归一化不丢信息。

关于角点顺序:DOTA 格式的标注约定是四点有序(大致顺时针),模型训练时学到的角点顺序也遵循这个约定,所以xyxyxyxy的点序可以直接用。但如果你的权重是从别处转过来的,点序没有保证,w和h可能互换,表现是框看起来「转了 90°」。判断方法很简单:拿一个明显细长的目标(比如船),看前端画的框是顺着船身还是横着压过去。横着就是点序问题,把theta加 90 再归一化即可。

3.3 predict() 的完整实现与逐行说明

把前面几块拼起来,就是一个能用的 predict:

from ultralytics import YOLO @property def model(self): if self._model is None: self._model = YOLO(self.weights) return self._model def predict(self, tasks, context=None, **kwargs): from_name, to_name, allowed = self._obb_tag() results = [] for task in tasks: url = task["data"][to_name] img = self._load_image(url, task.get("id")) img_w, img_h = img.size out = self.model.predict( source=img, imgsz=self.imgsz, conf=self.conf, iou=self.iou, max_det=self.max_det, verbose=False, )[0] obb = out.obb if obb is None or len(obb) == 0: continue corners = obb.xyxyxyxy.cpu().numpy() scores = obb.conf.cpu().numpy() classes = obb.cls.cpu().numpy().astype(int) names = out.names for box, score, cls_id in zip(corners, scores, classes): raw = names.get(int(cls_id), str(cls_id)) label = self.label_map.get(raw) if label is None or label not in allowed: continue results.append( corners_to_result(box.tolist(), float(score), label, from_name, to_name, img_w, img_h) ) return {"result": results, "model_version": self.model_version}

几个参数值得单独说。imgsz决定小目标能不能检出来,用 OBB 的场景八成是小目标密集,别省这一档。conf是预标注阶段最该调的旋钮:给标注员看的预标注,宁可多给几个低置信度的框让他们删,也别漏——漏了他们要重新画,删一个只需要按一下 Del。我一般预标注用 0.15 到 0.2,评测用 0.25 到 0.35。iou在密集场景里要往下压一点,默认 0.7 会把并排的两条船 NMS 掉一条,试到 0.5 左右通常合适。max_det默认 300,航拍大图里目标数经常破千,一定要往上调。

模型加载写成 lazy property 而不是放在__init__里,是为了让服务先起来、健康检查先过。YOLO()加载权重在大文件上要几秒,放在构造函数里,容器启动探针会在那几秒内判定失败并重启,进入循环。

4. 标注回灌与 fit():让 OBB 数据集自己长出来

预标注只是省了画框的力气,真正的价值在后面:标注员改完的框能自动回流成训练数据,模型一轮一轮变好。Label Studio 的机制是,当一条标注被创建或更新时,如果项目里勾选了「提交标注时触发训练」,就会带着事件名和数据回调后端的fit()。这里要做的三件事是:判断这次事件该不该训、把旋转矩形反解成 OBB 训练格式、把训练参数和产物版本管起来。

4.1 事件过滤:哪些回调才该触发训练

fit(self, event, data, **kwargs)的 event 常见有ANNOTATION_CREATED、ANNOTATION_UPDATED、ANNOTATION_DELETED、START_TRAINING、LABEL_SKIPPED。头两件事就是把不该训的挡掉:

def fit(self, event, data, **kwargs): if event not in ("ANNOTATION_CREATED", "ANNOTATION_UPDATED", "START_TRAINING"): return {"status": "ignored", "event": event} annotation = data.get("annotation") or {} if annotation.get("was_cancelled"): return {"status": "ignored", "reason": "cancelled"} result = annotation.get("result") or [] if not result: return {"status": "ignored", "reason": "empty"}

was_cancelled这个字段必须判。标注员点了跳过,Label Studio 依然会发一次ANNOTATION_CREATED,里面的 result 是空的或者带着取消标记。不判的话,你会把一批「这张图确实没有目标」的负样本和「这张图太难我不想标」的样本混在一起喂进去,模型会学到一些莫名其妙的东西。

另一个要防的是频繁触发。一个人连续标十张图就是十次回调,每次都启动训练,GPU 上一堆进程排队。稳妥做法是 fit 里只负责把数据落到暂存区,然后检查当前积压的样本数,超过阈值才真正拉起训练;不到阈值就返回「已入队」。这个阈值没有标准答案,我一般设 50 到 100,取决于单个 epoch 有多快。

4.2 旋转矩形反解成 DOTA 八点格式并落盘

YOLO 的 OBB 训练标签是一行文本:类别id x1 y1 x2 y2 x3 y3 x4 y4,八个坐标都按图像宽高归一化到 0–1。Label Studio 存的是中心点加宽高加角度的百分比,两者之间的转换是 3.2 节的逆运算:

def ls_rect_to_line(value, cls_id, img_w, img_h): cx = value["x"] / 100.0 * img_w cy = value["y"] / 100.0 * img_h w = value["width"] / 100.0 * img_w h = value["height"] / 100.0 * img_h # LS 逆时针为正,回到图像坐标系要取反 rad = math.radians(-value.get("rotation", 0.0)) cos_a, sin_a = math.cos(rad), math.sin(rad) dx, dy = w / 2.0, h / 2.0 local = [(-dx, -dy), (dx, -dy), (dx, dy), (-dx, dy)] pts = [] for px, py in local: gx = cx + px * cos_a - py * sin_a gy = cy + px * sin_a + py * cos_a pts.append((gx / img_w, gy / img_h)) coords = " ".join(f"{v:.6f}" for p in pts for v in p) return f"{cls_id} {coords}"

宽高和角度从 LS 的百分比还原成像素,再用旋转矩阵把四个角算出来。角度的取反要和 3.2 节严格对称——一边取反另一边不取反,回灌进训练集之后,模型的预测会越训越歪,而且这个过程是渐进的,头两轮看不出来,第五轮才发现框全斜了,那时候已经浪费了一天。

图像尺寸从哪来?别去重新下载图片算。Label Studio 的标注结果里每一项都带original_width和original_height,直接用:

for item in result: if item.get("type") != "rectanglelabels": continue val = item["value"] img_w = item.get("original_width") or val.get("original_width") img_h = item.get("original_height") or val.get("original_height") label = val["rectanglelabels"][0] cls_id = label_to_id[label] line = ls_rect_to_line(val, cls_id, img_w, img_h)

落盘的时候按 task_id 分文件,并且维护一份 manifest 记录「哪个 task 的哪个 annotation 已经写过」。这一步是后悔药:没有 manifest,同一张图被修改三次就会在训练集里出现三份,其中两份是错的,模型在自相矛盾的标签上训练,loss 会卡在一个不高的位置下不去。

4.3 增量训练的参数与产物版本号

数据集目录按 YOLO 的约定摆好,yaml 里写清路径和类别名:

obb_dataset/ images/train/ labels/train/ images/val/ labels/val/ data.yaml
path: /app/datasets/obb_dataset train: images/train val: images/val names: 0: car 1: ship 2: plane

训练这一段,OBB 和水平框最大的差别在增强参数上。旋转增强对 OBB 是核心增强,不是锦上添花——模型要学的是角度,degrees给到 180 才够;水平翻转和垂直翻转对 OBB 也安全,可以都打开。关掉 mosaic 的轮次要留足,OBB 在最后几轮对角度收敛比较敏感,close_mosaic给 15 到 20 轮:

model = YOLO(self.weights if os.path.exists(self.weights) else "yolov8n-obb.pt") model.train( data="/app/datasets/obb_dataset/data.yaml", epochs=60, imgsz=self.imgsz, batch=8, degrees=180.0, fliplr=0.5, flipud=0.5, close_mosaic=15, project="/app/runs", name=f"obb_{int(time.time())}", exist_ok=True, )

imgsz要和推理时保持一致。训练 1024、推理 640,角度可能还准,小目标的召回会掉一大截,而且掉得很隐蔽——你会以为是数据不够。

训练完把best.pt复制到挂载的模型目录,覆盖前先备份一份带时间戳的,然后更新版本号:

shutil.copy(run_dir / "weights/best.pt", f"/app/models/best_{ts}.pt") shutil.copy(run_dir / "weights/best.pt", self.weights) self.set("model_version", f"obb-{ts}")

有一点要说清楚:fit() 是同步回调,训练跑几十分钟,Label Studio 那边早就超时了。生产上应该让 fit() 只做数据落盘和任务投递,训练放到独立进程或者队列里跑,跑完再更新权重文件。SDK 允许你这样返回,前端不会等你。

5. 排查清单:OBB 后端上线后最容易翻车的五个地方

前面几节讲的是怎么建起来,这一节讲建起来之后哪里会塌。下面五条都是我自己或者同事真实撞过的,按「现象 → 原因 → 解决」写,看到相似症状可以直接对号。

5.1 框画出来了,但整体镜像或者偏了 90 度

现象:前端能看到框,位置也对,但方向明显不对,细长目标横着压过去;或者上下翻转,斜向上的目标画成斜向下。

原因:角度符号或者角点顺序不一致。Label Studio 的 rotation 逆时针为正,图像坐标系 y 轴朝下,atan2算出来是顺时针角,中间少了一步取反。另一种情况是权重的角点顺序不是 DOTA 的顺时针约定,w和h互换,等价于偏 90°。

解决:做一张标定图——纯白底,画一个 30° 的细长矩形,用当前权重跑一遍,看前端画出来是多少度。如果是 -30°,说明符号反了;如果是 120°,说明点序有问题。这张图存进仓库,每次换权重先跑一次,三十秒的事,能省掉半天排查。

5.2 预测框整体缩小一圈,位置系统性偏移

现象:框的形状和角度都对,但所有框都比真实目标小一圈、位置统一往左上偏。

原因:original_width/original_height填的是推理时 resize 后的尺寸,不是原图尺寸。前端拿这个基准去乘百分比,比例全错。

解决:检查 predict 里写 result 时用的img_w/img_h是不是来自img.size。如果你的代码里有「先 resize 再推理」,确保坐标在写结果之前已经按缩放比乘回原图。加一行断言最省事:assert img_w > self.imgsz or img_h > self.imgsz,如果原图本身就比 imgsz 小,那说明你拿到的是缩略图。

5.3 第一个任务就报 500,之后又恢复正常

现象:刚部署完,第一次点预测转很久然后报错,再点一次就正常了,重启容器后重复。

原因:模型懒加载发生在第一个请求里,加载大权重加上首次推理的 CUDA 初始化要好几秒,超过 Label Studio 侧的超时。如果服务用了多 worker,每个进程还会各自加载一份,显存直接翻倍。

解决:启动时预热。在__init__之外提供一个显式初始化入口,服务起来后先跑一张 32×32 的空白图,把 CUDA context 和权重都拉起来。worker 数量设成 1,这类服务本来就是 GPU 密集,多进程只会抢显存不会提吞吐。

5.4 换了标签名之后,后端返回全空

现象:项目里把标签从vehicle改成car,预测页面变得干干净净,日志里没有任何报错。

原因:两个可能。一是from_name硬编码,控件改名后对不上,前端静默丢弃;二是allowed白名单过滤把模型的vehicle挡掉了。

解决:from_name从parsed_label_config里按类型取,别写死。标签映射表放在 Model.py 顶部,改标签时同步改表。再在 predict 末尾加一行统计日志,打出「模型检出 N 个,通过过滤 M 个」,M 为 0 而 N 不为 0,就是映射问题,一眼定位。

5.5 fit() 触发了,但训练集越训越乱

现象:前几轮指标还在涨,第五轮开始 mAP 掉,可视化一看,同一张图上有两套互相矛盾的框。

原因:没有做去重。标注员修改一次标注,Label Studio 发一次ANNOTATION_UPDATED,你的代码把整条标注重新写一遍,一个 task 在训练集里出现多份,其中包含已经被修正掉的旧框。另一种是was_cancelled没判,把「跳过」的空标注当成负样本写进去了。

解决:维护 manifest,按task_id + annotation_id去重,写过的跳过;was_cancelled为真的直接 return。另外每次训练前随机抽 10 张图,把标签画到图上肉眼过一遍,这个动作花不了两分钟,但能挡住绝大多数数据层的事故。

6. 把精度钉住:一张角度标定图加一轮阈值扫描

后端跑通之后,剩下的工作是让它可维护。我的习惯是仓库里固定放两个小工具,一个管角度,一个管阈值。

角度那个就是 5.1 提到的那张标定图:纯白底、几个已知角度的细长矩形(0°、30°、45°、60°、90°、135°),配一个脚本直接调 Model.py 里的换算函数,不经过模型推理,只验证corners_to_result和ls_rect_to_line这对往返函数是否自洽:

import math CASES = [0.0, 30.0, 45.0, 60.0, 90.0, 135.0] for deg in CASES: # 造一个 200x40、旋转 deg 的矩形,取四角 rad = math.radians(deg) cos_a, sin_a = math.cos(rad), math.sin(rad) corners = [] for px, py in [(-100, -20), (100, -20), (100, 20), (-100, 20)]: corners.append((500 + px * cos_a - py * sin_a, 400 + px * sin_a + py * cos_a)) res = corners_to_result(corners, 0.9, "car", "l", "i", 1000, 800) back = ls_rect_to_line(res["value"], 0, 1000, 800) # 往返后角度应回到 deg(mod 180) print(deg, res["value"]["rotation"])

这段往返自洽测试比任何前端截图都可靠,因为它把「模型不准」这个变量彻底摘掉了。换算本身有 bug 的话,这里的输出立刻对不上;换算没问题但前端还是画歪,那一定是original_width或者控件名的问题,排查范围一下缩到两三个字段。

阈值那个更简单,就是拿一批人工核对过的图,扫一遍 conf:

conf召回(人工核对基准)每图平均框数标注员反馈
0.100.9541框太多,删到手酸
0.200.9124大致合适
0.300.8415漏得有点多,要补画
0.450.718基本没用

这张表比拍脑袋定 0.25 靠谱得多,而且它能告诉你瓶颈在哪:如果 0.10 的召回也只有 0.8,说明不是阈值问题,是imgsz太小或者训练数据不够,这时候继续调 conf 是白费力气。我一般会在项目启动阶段就跑一遍这张表,把结论写进 README,后面换权重再跑一次对比。

最后说个血泪经验:这个后端最容易出问题的从来不是模型,是那几行百分比换算和尺寸基准。我现在的习惯是,任何一次改动 Model.py 之后,先跑往返自洽测试,再跑标定图,最后才连 Label Studio 看真实效果。顺序反过来,你会花大量时间在「是模型的问题还是代码的问题」上打转,而这两者的排查成本差了一个数量级。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/29 2:25:48

CNN人脸识别实战:从数据准备到模型部署的完整流程

简介&#xff1a;这份资源是《DeepLearning tutorial&#xff08;5&#xff09;CNN卷积神经网络应用于人脸识别》一文的配套代码包&#xff0c;面向已具备一定深度学习基础、希望动手实践卷积神经网络人脸识别的开发者与学习者&#xff0c;帮助其把理论流程落地为可运行的训练与…

作者头像 李华
网站建设 2026/10/29 2:24:44

Ubuntu 安装 GitKraken 6.5.1:依赖、配置与冲突排查

简介&#xff1a;面向 Ubuntu 16.04 及以上系统的开发者&#xff0c;这份 GitKraken v6.5.1 安装包提供了图形化 Git 客户端的完整运行环境&#xff0c;适合希望减少命令行操作、同时清晰掌握分支与提交历史的中初级开发者&#xff0c;也是免费版最后一个可长期留存的迭代版本。…

作者头像 李华
网站建设 2026/10/29 2:24:21

AI大模型如何推动绿色科技创新与应用

1. 人工智能与绿色科技的融合趋势人工智能技术正在深刻改变人类社会的各个领域&#xff0c;其中在可持续发展领域的应用尤为引人注目。大模型作为AI技术的前沿代表&#xff0c;其强大的数据处理和模式识别能力为绿色科技创新提供了全新可能。这种融合不是简单的技术叠加&#x…

作者头像 李华
网站建设 2026/10/29 2:24:02

JavaWeb原生WebSocket实时聊天系统实战

简介&#xff1a;这是一套基于JavaWeb技术栈实现的一对一网页聊天系统实战项目&#xff0c;面向Java初学者与Web开发入门者&#xff0c;帮助理解前后端协同通信的核心机制。项目采用JSPJavaScriptAJAX构建前端交互界面&#xff0c;通过Servlet处理消息发送与轮询获取&#xff0…

作者头像 李华
网站建设 2026/10/29 2:22:59

Notepad++便携版配置迁移与常见故障排查指南

简介&#xff1a;Notepad 免安装版是一份面向程序员、运维人员及日常文本处理用户的便携式代码编辑工具包&#xff0c;适合在权限受限的电脑、U盘或临时工作环境中解压即用&#xff0c;用来替代系统记事本完成代码查看、文本编辑与轻量开发任务。压缩包共155个文件&#xff0c;…

作者头像 李华
网站建设 2026/10/29 2:22:58

Server 2012 离线安装 .NET 3.5:sxs 源与 DISM 排错

简介&#xff1a;面向 Windows Server 2012 系统管理员与运维人员的 .NET Framework 3.5 离线安装镜像包&#xff0c;专门应对系统默认未启用该框架、在线更新拉取失败&#xff0c;或服务器无外网、缺少完整安装介质时出现的 .NET 3.5 安装失败与服务无法开启等问题&#xff0c…

作者头像 李华