原先点 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>
155 lines
6.8 KiB
Python
155 lines
6.8 KiB
Python
"""把训练好的 YOLO 接进 Label Studio,边标边用模型预标注。
|
||
|
||
启动见 ml_backend/README.md。行为由环境变量控制:
|
||
|
||
YOLO_WEIGHTS 必填,权重路径,如 runs/detect/film/weights/best.pt
|
||
YOLO_CONF 置信度阈值,默认 0.25
|
||
YOLO_IMGSZ 推理尺寸,默认 640
|
||
YOLO_DEVICE cuda / cpu / mps,默认 cuda(不可用时 ultralytics 自己退回)
|
||
RETRAIN_AFTER 累计多少条新标注后自动触发重训,默认 0=只靠手动触发
|
||
RETRAIN_COMMAND 重训命令;不设则只记数不执行
|
||
RETRAIN_LOG 重训命令的输出写到哪,默认 /tmp/retrain.log
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
import shlex
|
||
import subprocess
|
||
import threading
|
||
from pathlib import Path
|
||
|
||
from convert import to_prediction
|
||
from label_studio_ml.model import LabelStudioMLBase
|
||
from label_studio_ml.response import ModelResponse
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class FilmDefectDetector(LabelStudioMLBase):
|
||
"""薄膜缺陷检测的预标注后端。"""
|
||
|
||
_model = None
|
||
_lock = threading.Lock()
|
||
_retraining = None # 正在跑的重训子进程
|
||
|
||
def setup(self) -> None:
|
||
"""记录模型版本。
|
||
|
||
版本带上权重文件的修改时间:重训换了权重之后版本号会变,Label Studio 就能把
|
||
新旧预测区分开(项目设置里按 model_version 选用哪一版预标注)。
|
||
"""
|
||
weights = Path(os.environ.get("YOLO_WEIGHTS", "yolo26n.pt"))
|
||
stamp = int(weights.stat().st_mtime) if weights.exists() else 0
|
||
self.set("model_version", f"{weights.stem}-{stamp}")
|
||
|
||
def _load(self):
|
||
"""首次预测时才加载权重,且只加载一次——Flask 是多线程的,加锁。"""
|
||
with self._lock:
|
||
if FilmDefectDetector._model is None:
|
||
from ultralytics import YOLO
|
||
|
||
weights = os.environ.get("YOLO_WEIGHTS", "yolo26n.pt")
|
||
logger.info("加载权重 %s", weights)
|
||
FilmDefectDetector._model = YOLO(weights)
|
||
return FilmDefectDetector._model
|
||
|
||
def predict(self, tasks: list[dict], context: dict | None = None, **kwargs) -> ModelResponse:
|
||
"""对一批任务推理,返回 Label Studio 格式的预测。"""
|
||
from PIL import Image
|
||
|
||
# 控件名从项目的 labeling config 里读,不写死:改了配置这里要跟着变
|
||
from_name, to_name, value_key = self.get_first_tag_occurence(
|
||
"RectangleLabels", "Image"
|
||
)
|
||
model = self._load()
|
||
classes = model.names
|
||
conf = float(os.environ.get("YOLO_CONF", 0.25))
|
||
imgsz = int(os.environ.get("YOLO_IMGSZ", 640))
|
||
device = os.environ.get("YOLO_DEVICE", "cuda")
|
||
|
||
paths, sizes = [], []
|
||
for task in tasks:
|
||
# 本地存储的图要靠 LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT 解析;
|
||
# 上传或云存储的图则会被下载到本地缓存
|
||
path = self.get_local_path(task["data"][value_key], task_id=task.get("id"))
|
||
with Image.open(path) as image:
|
||
sizes.append(image.size)
|
||
paths.append(path)
|
||
|
||
model_version = self.get("model_version")
|
||
if not paths:
|
||
return ModelResponse(model_version=model_version, predictions=[])
|
||
|
||
results = model.predict(
|
||
source=paths, imgsz=imgsz, conf=conf, device=device, verbose=False
|
||
)
|
||
predictions = []
|
||
for result, (width, height) in zip(results, sizes, strict=True):
|
||
boxes = [
|
||
(classes[int(class_id)], xyxyn, float(score))
|
||
for xyxyn, class_id, score in zip(
|
||
result.boxes.xyxyn.tolist(),
|
||
result.boxes.cls.tolist(),
|
||
result.boxes.conf.tolist(),
|
||
strict=True,
|
||
)
|
||
]
|
||
predictions.append(
|
||
to_prediction(boxes, width, height, from_name, to_name, model_version)
|
||
)
|
||
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))
|
||
|
||
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)
|
||
|
||
# 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("触发重训: %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}
|