feat(ml-backend): 把训练好的 YOLO 接进 Label Studio 做预标注

ml_backend/ 是一个 label-studio-ml 后端:Label Studio 打开任务时调 /predict,
后端跑 YOLO 并把框转成 Label Studio 的百分比坐标返回,人工只需修正。

- model.py:FilmDefectDetector。权重懒加载且只加载一次(Flask 多线程,加锁);
  控件名从项目的 labeling config 读取,不写死;model_version 带权重的修改时间,
  换权重后 Label Studio 能区分新旧预测。
- convert.py:坐标转换,刻意不 import label_studio_ml,好在主环境里单测。
- fit():每条标注累加计数,达到 RETRAIN_AFTER 就起子进程执行 RETRAIN_COMMAND
  并立刻返回——训练几十分钟,同步做会卡住 Label Studio。
- _wsgi.py 由官方模板生成,ruff 跳过,便于跟随上游。

必须用独立虚拟环境:label-studio-ml 会把 jinja2 降到 3.0.3,而 label-studio 1.23
需要 3.1,装进主环境会直接弄坏 Label Studio。PyPI 上的 1.0.9 是 2023 年的旧版,
要装 GitHub 上的现行版本。

本地用 COCO 权重端到端验证过:/health、/predict(bus.jpg 出 10 个框、坐标换算正确)、
/webhook(第 3 条标注触发重训命令并清零计数)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-18 17:36:12 +08:00
co-authored by Claude Opus 5
parent 154677032f
commit a4d099e41d
7 changed files with 489 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
# Label Studio ML Backend:用训练好的 YOLO 做预标注
把 `runs/detect/<name>/weights/best.pt` 接进 Label Studio,打开任务时自动给出框,
人工只需修正而不是从零画。标注攒够数量还能自动触发重训,形成"边标边训"的闭环。
## 为什么要单独的虚拟环境
`label-studio-ml` 会把 jinja2 降到 3.0.3、带进 Flask 1.1.2,而 label-studio 1.23 需要
jinja2 3.1。装进主环境会直接弄坏 Label Studio。它本来就是个独立服务,单独建环境最省事。
另外 **PyPI 上的 `label-studio-ml` 是 2023 年的 1.0.9,不要用**,要装 GitHub 上的现行版本
(本文档基于 2.0.1.dev0 验证)。
```bash
uv venv .venv-ml --python 3.12
VIRTUAL_ENV=.venv-ml uv pip install \
"label-studio-ml @ git+https://github.com/HumanSignal/label-studio-ml-backend.git" \
ultralytics
```
## 启动
```bash
cd ml_backend
YOLO_WEIGHTS=$PWD/../runs/detect/film/weights/best.pt \
YOLO_DEVICE=cuda \
LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT=/home/deep/labeling \
../.venv-ml/bin/python _wsgi.py -p 9090
```
**用 `python _wsgi.py` 而不是 `label-studio-ml start .`**:后者用 PATH 上的 python 起子进程,
在独立环境里会 `ModuleNotFoundError`。
`LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT` 必须和启动 Label Studio 时的值一致——任务里的图片是
`/data/local-files/?d=...`,后端要靠它把 URL 还原成磁盘路径。图片若来自上传或云存储,
则改为设置 `LABEL_STUDIO_URL` 与 `LABEL_STUDIO_API_KEY`,后端会自行下载。
健康检查:
```bash
curl http://localhost:9090/health
# {"model_class":"FilmDefectDetector","status":"UP"}
```
## 接入项目
Label Studio 里:Settings → **Model** → Connect Model,URL 填 `http://localhost:9090`。
- 勾选 **Interactive preannotations** 后,打开任务就会实时调用模型;
- 也可以在 Data Manager 里选中若干任务 → Actions → **Retrieve Predictions** 批量拉取。
**类别名必须和 labeling config 里的 `<Label value="...">` 完全一致。** 后端用的是权重里的
`model.names`(也就是训练时 `data.yaml` 的 names),对不上的话 Label Studio 会拒绝这条预测。
我们的模型是 9 类:black、blackdot、grease、mosquito、normal、obstacle、ripple、uneven、wrinkle。
## 环境变量
| 变量 | 默认 | 说明 |
| --- | --- | --- |
| `YOLO_WEIGHTS` | `yolo26n.pt` | 权重路径。版本号里带它的修改时间,换权重后 Label Studio 能区分新旧预测 |
| `YOLO_CONF` | `0.25` | 置信度阈值。预标注可以调低些,宁可多给几个框让人删 |
| `YOLO_IMGSZ` | `640` | 推理尺寸,与训练保持一致 |
| `YOLO_DEVICE` | `cuda` | `cuda` / `cpu` / `mps` |
| `RETRAIN_AFTER` | `0` | 累计多少条新标注触发重训,0 = 不触发 |
| `RETRAIN_COMMAND` | 空 | 触发时执行的命令 |
## 边标边训
Label Studio 每产生一条标注就会 POST `/webhook`,后端在 `fit()` 里累加计数,
达到 `RETRAIN_AFTER` 就起一个**子进程**执行 `RETRAIN_COMMAND` 并立刻返回——
训练要几十分钟,绝不能在这个请求里同步做,否则会把 Label Studio 卡住。
```bash
RETRAIN_AFTER=200 \
RETRAIN_COMMAND="/home/deep/ModelTrainEval/scripts/retrain.sh" \
../.venv-ml/bin/python _wsgi.py -p 9090
```
`retrain.sh` 需要自己写,大致是:导出项目 → `build_yolo_dataset.py` 装配 → `detect.py` 训练 →
把新权重放到 `YOLO_WEIGHTS` 指向的位置 → 重启本服务(或让它下次启动时加载新权重)。
**目前权重只在进程启动时加载一次**,重训完要重启服务才会生效。
## 已验证的行为
本地用 COCO 预训练权重实测过:
- `/health` 返回 UP;
- `/predict` 用 Label Studio 的真实请求体(tasks + label_config + params)调用,
`bus.jpg` 返回 10 个框,坐标换算正确(百分比、`original_width/height` 与原图一致);
- 图片无检出时返回一条空 `result` 的预测,表示"这张图没问题";
- `/webhook` 连发 3 条 `ANNOTATION_CREATED`,在第 3 条触发 `RETRAIN_COMMAND` 并把计数清零。
转换逻辑的单元测试在 `tests/test_ml_backend_convert.py`,跑在主环境里
(`convert.py` 刻意不 import `label_studio_ml`,就是为了能这样测)。