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>
125 lines
5.1 KiB
Python
125 lines
5.1 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 触发时执行的命令;不设则只记数不执行
|
|
"""
|
|
|
|
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()
|
|
|
|
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 等着这个请求返回,长任务会把它卡住。
|
|
所以只累加计数,达到阈值后起一个子进程去跑,立刻返回。
|
|
"""
|
|
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)
|
|
|
|
if not after or count < after or not command:
|
|
return {"annotations_since_training": count}
|
|
|
|
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}
|