feat(ml-backend): Start Training 按钮真正触发重训,启动/重训脚本进 scripts/
原先点 Label Studio 的 Start Training 只是把计数加一:START_TRAINING 和普通标注事件 走同一条阈值判断,没配 RETRAIN_COMMAND 时更是静默什么都不做。实际使用中用户点了按钮、 以为在训练,其实权重一直没变。 - Start Training 无条件触发重训;攒够 RETRAIN_AFTER 条新标注也自动触发。 - 只数 ANNOTATION_CREATED:UPDATED / DELETED 是对已有标注的修改,不该把阈值顶到。 - 已有重训在跑时重复点击被忽略,不叠加启动。 - 没配 RETRAIN_COMMAND 时打警告说明不会真的训练,并在返回值里说清楚。 - 子进程输出重定向到 RETRAIN_LOG(默认 /tmp/retrain.log),训练进度有地方看。 scripts/start_ml_backend.sh、scripts/retrain_from_label_studio.sh 放进仓库, 可从工作区直接启动。重训末尾把 runs/detect/current.pt 软链接指向新权重, 启动脚本默认读它,换模型不必改脚本。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1b8c8d7cac
commit
e61fd5887c
+26
-20
@@ -13,23 +13,26 @@ jinja2 3.1。装进主环境会直接弄坏 Label Studio。它本来就是个独
|
||||
|
||||
```bash
|
||||
uv venv .venv-ml --python 3.12
|
||||
# --no-deps 装本体:它把 label-studio-sdk pin 到了 GitHub 某个提交,
|
||||
# 那个仓库大、容易传输中断(服务器上实测失败过),依赖全部走 PyPI 更稳
|
||||
VIRTUAL_ENV=.venv-ml uv pip install --no-deps \
|
||||
"label-studio-ml @ git+https://github.com/HumanSignal/label-studio-ml-backend.git"
|
||||
VIRTUAL_ENV=.venv-ml uv pip install \
|
||||
"label-studio-ml @ git+https://github.com/HumanSignal/label-studio-ml-backend.git" \
|
||||
ultralytics
|
||||
"flask>2.3" "colorama~=0.4" "requests~=2.31" "semver~=3.0.2" \
|
||||
"label-studio-sdk>=2.1" 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
|
||||
bash scripts/start_ml_backend.sh
|
||||
```
|
||||
|
||||
**用 `python _wsgi.py` 而不是 `label-studio-ml start .`**:后者用 PATH 上的 python 起子进程,
|
||||
在独立环境里会 `ModuleNotFoundError`。
|
||||
脚本会停掉旧进程、带齐环境变量重启,并打印权重、日志位置与网页端要填的 URL。
|
||||
所有参数都能用环境变量覆盖,例如 `YOLO_WEIGHTS=... bash scripts/start_ml_backend.sh`。
|
||||
|
||||
它内部用 `python _wsgi.py` 而不是 `label-studio-ml start .`:后者用 PATH 上的 python
|
||||
起子进程,在独立环境里会 `ModuleNotFoundError`。
|
||||
|
||||
`LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT` 必须和启动 Label Studio 时的值一致——任务里的图片是
|
||||
`/data/local-files/?d=...`,后端要靠它把 URL 还原成磁盘路径。图片若来自上传或云存储,
|
||||
@@ -66,19 +69,22 @@ Label Studio 里:Settings → **Model** → Connect Model,URL 填 `http://lo
|
||||
|
||||
## 边标边训
|
||||
|
||||
Label Studio 每产生一条标注就会 POST `/webhook`,后端在 `fit()` 里累加计数,
|
||||
达到 `RETRAIN_AFTER` 就起一个**子进程**执行 `RETRAIN_COMMAND` 并立刻返回——
|
||||
训练要几十分钟,绝不能在这个请求里同步做,否则会把 Label Studio 卡住。
|
||||
**在 Label Studio 里点 Settings → Model → Start Training**,后端收到 `START_TRAINING`
|
||||
事件,起一个子进程跑 `scripts/retrain_from_label_studio.sh`,立刻返回——训练要几十分钟,
|
||||
绝不能在这个请求里同步做,否则会把 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` 指向的位置 → 重启本服务(或让它下次启动时加载新权重)。
|
||||
**目前权重只在进程启动时加载一次**,重训完要重启服务才会生效。
|
||||
- **Start Training 按钮无条件触发**,不看攒了多少条;
|
||||
- 新增标注累计到 `RETRAIN_AFTER` 也会自动触发(默认 0 = 只手动)。只数
|
||||
`ANNOTATION_CREATED`,`UPDATED` / `DELETED` 是对已有标注的修改,不该把阈值顶到;
|
||||
- 已有重训在跑时重复点击会被忽略,不会叠起多个训练。
|
||||
|
||||
重训脚本做四件事:导出标注 → `build_yolo_dataset.py` 装配 → `detect.py` 训练 →
|
||||
把 `runs/detect/current.pt` 软链接指向新权重并重启本服务。启动脚本默认读这个软链接,
|
||||
所以换模型不用改任何脚本。**权重只在进程启动时加载一次**,这也是重训末尾要重启服务的原因。
|
||||
|
||||
训练进度看 `$RETRAIN_LOG`(默认 `/tmp/retrain.log`);Label Studio 网页端不显示训练进度。
|
||||
|
||||
## 已验证的行为
|
||||
|
||||
|
||||
+43
-13
@@ -6,8 +6,9 @@
|
||||
YOLO_CONF 置信度阈值,默认 0.25
|
||||
YOLO_IMGSZ 推理尺寸,默认 640
|
||||
YOLO_DEVICE cuda / cpu / mps,默认 cuda(不可用时 ultralytics 自己退回)
|
||||
RETRAIN_AFTER 累计多少条新标注后触发重训,默认 0=不触发
|
||||
RETRAIN_COMMAND 触发时执行的命令;不设则只记数不执行
|
||||
RETRAIN_AFTER 累计多少条新标注后自动触发重训,默认 0=只靠手动触发
|
||||
RETRAIN_COMMAND 重训命令;不设则只记数不执行
|
||||
RETRAIN_LOG 重训命令的输出写到哪,默认 /tmp/retrain.log
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,6 +32,7 @@ class FilmDefectDetector(LabelStudioMLBase):
|
||||
|
||||
_model = None
|
||||
_lock = threading.Lock()
|
||||
_retraining = None # 正在跑的重训子进程
|
||||
|
||||
def setup(self) -> None:
|
||||
"""记录模型版本。
|
||||
@@ -100,25 +102,53 @@ class FilmDefectDetector(LabelStudioMLBase):
|
||||
return ModelResponse(model_version=model_version, predictions=predictions)
|
||||
|
||||
def fit(self, event, data, **kwargs):
|
||||
"""每产生一条标注就被调用一次,用来攒够数量后触发重训。
|
||||
"""Label Studio 的标注事件与 Start Training 按钮都走这里。
|
||||
|
||||
这里**不能**直接训练:Label Studio 等着这个请求返回,长任务会把它卡住。
|
||||
所以只累加计数,达到阈值后起一个子进程去跑,立刻返回。
|
||||
所以只决定"要不要训",真正的训练交给子进程,立刻返回。
|
||||
"""
|
||||
if event not in self.TRAIN_EVENTS:
|
||||
return None
|
||||
after = int(os.environ.get("RETRAIN_AFTER", 0))
|
||||
command = os.environ.get("RETRAIN_COMMAND", "")
|
||||
|
||||
count = int(self.get("annotations_since_training") or 0) + 1
|
||||
self.set("annotations_since_training", str(count))
|
||||
logger.info("收到 %s,距上次训练已有 %d 条标注", event, count)
|
||||
count = int(self.get("annotations_since_training") or 0)
|
||||
if event == "ANNOTATION_CREATED":
|
||||
# 只数新增:UPDATED / DELETED 是对已有标注的修改,不该让阈值提前到点
|
||||
count += 1
|
||||
self.set("annotations_since_training", str(count))
|
||||
logger.info("收到 %s,距上次训练已新增 %d 条标注", event, count)
|
||||
|
||||
if not after or count < after or not command:
|
||||
# Start Training 是人明确点的,无条件训;攒够阈值则自动训
|
||||
manual = event == "START_TRAINING"
|
||||
if not manual and not (after and count >= after):
|
||||
return {"annotations_since_training": count}
|
||||
return self._start_retraining(count, manual)
|
||||
|
||||
def _start_retraining(self, count: int, manual: bool) -> dict:
|
||||
"""起一个子进程跑重训命令,立刻返回。"""
|
||||
command = os.environ.get("RETRAIN_COMMAND", "")
|
||||
if not command:
|
||||
logger.warning(
|
||||
"%s,但没有设置 RETRAIN_COMMAND,不会真的训练"
|
||||
"(在 scripts/start_ml_backend.sh 里配置)",
|
||||
"收到 Start Training" if manual else f"已累计 {count} 条标注",
|
||||
)
|
||||
return {"annotations_since_training": count, "retrain_started": False,
|
||||
"detail": "RETRAIN_COMMAND 未设置"}
|
||||
|
||||
running = FilmDefectDetector._retraining
|
||||
if running is not None and running.poll() is None:
|
||||
# 重复点 Start Training 不该叠加启动多个训练
|
||||
logger.warning("已有重训在跑(pid=%s),这次忽略", running.pid)
|
||||
return {"annotations_since_training": count, "retrain_started": False,
|
||||
"detail": f"已有重训在跑 pid={running.pid}"}
|
||||
|
||||
log_path = os.environ.get("RETRAIN_LOG", "/tmp/retrain.log")
|
||||
self.set("annotations_since_training", "0")
|
||||
logger.info("累计 %d 条标注,触发重训: %s", count, command)
|
||||
# 不等它结束:训练要几十分钟,这个请求必须立刻返回
|
||||
subprocess.Popen(shlex.split(command)) # noqa: S603
|
||||
return {"annotations_since_training": 0, "retrain_started": True}
|
||||
logger.info("触发重训: %s(输出见 %s)", command, log_path)
|
||||
with open(log_path, "a", encoding="utf-8") as log: # noqa: PTH123
|
||||
FilmDefectDetector._retraining = subprocess.Popen( # noqa: S603
|
||||
shlex.split(command), stdout=log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
||||
)
|
||||
return {"annotations_since_training": 0, "retrain_started": True,
|
||||
"pid": FilmDefectDetector._retraining.pid, "log": log_path}
|
||||
|
||||
Reference in New Issue
Block a user