简介:面向使用 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.py | WSGI 入口 | 不碰 |
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/modelsLABEL_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.yamlpath: /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.10 | 0.95 | 41 | 框太多,删到手酸 |
| 0.20 | 0.91 | 24 | 大致合适 |
| 0.30 | 0.84 | 15 | 漏得有点多,要补画 |
| 0.45 | 0.71 | 8 | 基本没用 |
这张表比拍脑袋定 0.25 靠谱得多,而且它能告诉你瓶颈在哪:如果 0.10 的召回也只有 0.8,说明不是阈值问题,是imgsz太小或者训练数据不够,这时候继续调 conf 是白费力气。我一般会在项目启动阶段就跑一遍这张表,把结论写进 README,后面换权重再跑一次对比。
最后说个血泪经验:这个后端最容易出问题的从来不是模型,是那几行百分比换算和尺寸基准。我现在的习惯是,任何一次改动 Model.py 之后,先跑往返自洽测试,再跑标定图,最后才连 Label Studio 看真实效果。顺序反过来,你会花大量时间在「是模型的问题还是代码的问题」上打转,而这两者的排查成本差了一个数量级。希望帮到你。
本文还有配套的精品资源,点击获取