用 640 整图训练的权重跑 160 切片推理,结果看起来完全正常——照常出框、照常有置信度, 但类别系统性地错:实测 mosquito 的 TP 归零,760 张里 599 张被判成 blackdot, 而同一权重整图推理时 mosquito 有 92% 判对。这种错误不会自己暴露。 从 checkpoint 里读训练时的 imgsz,与当前切片尺寸不符就警告。三个调用点都接上: 可视化、批量推送预测、ML backend 加载权重时。visualize 的 summary.json 也补上 tile_size / tile_overlap,免得事后分不清某次产出是怎么跑的。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
7.5 KiB
Python
160 lines
7.5 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 自己退回)
|
||
YOLO_BATCH 一次推理几张,默认 16。Retrieve Predictions 会一次发来成百上千个
|
||
任务,不分批会直接把显存撑爆
|
||
YOLO_TILE_SIZE 模型输入边长,默认 160。按它把图切片推理再拼回,全程不缩放;
|
||
0 = 整图推理,交给 ultralytics letterbox(会缩放)
|
||
YOLO_TILE_OVERLAP 切片重叠像素,默认 32
|
||
RETRAIN_AFTER 累计多少条新标注后自动触发重训,默认 0=只靠手动触发
|
||
RETRAIN_COMMAND 重训命令;不设则只记数不执行
|
||
RETRAIN_LOG 重训命令的输出写到哪,默认 /tmp/retrain.log
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
import shlex
|
||
import subprocess
|
||
import sys
|
||
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
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
||
from engine.tiled_inference import check_size_matches, load_image, predict_image # noqa: E402
|
||
|
||
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)
|
||
check_size_matches(
|
||
FilmDefectDetector._model, int(os.environ.get("YOLO_TILE_SIZE", 160)), logger
|
||
)
|
||
return FilmDefectDetector._model
|
||
|
||
def predict(self, tasks: list[dict], context: dict | None = None, **kwargs) -> ModelResponse:
|
||
"""对一批任务推理,返回 Label Studio 格式的预测。"""
|
||
# 控件名从项目的 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))
|
||
device = os.environ.get("YOLO_DEVICE", "cuda")
|
||
batch = int(os.environ.get("YOLO_BATCH", 16))
|
||
tile_size = int(os.environ.get("YOLO_TILE_SIZE", 160))
|
||
overlap = int(os.environ.get("YOLO_TILE_OVERLAP", 32))
|
||
imgsz = int(os.environ.get("YOLO_IMGSZ", 640))
|
||
|
||
model_version = self.get("model_version")
|
||
predictions = []
|
||
for task in tasks:
|
||
# 本地存储的图要靠 LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT 解析;
|
||
# 上传或云存储的图则会被下载到本地缓存
|
||
path = self.get_local_path(task["data"][value_key], task_id=task.get("id"))
|
||
image = load_image(path)
|
||
height, width = image.shape[:2]
|
||
# 切片推理:模型的输入尺寸是固定的,整图直接送进去会被 letterbox 缩放。
|
||
# predict_image 内部也会分批,Retrieve Predictions 一次发来上千个任务也不会 OOM
|
||
detections = predict_image(
|
||
model, image, size=tile_size, overlap=overlap, conf=conf,
|
||
device=device, batch=batch, whole_image_imgsz=imgsz,
|
||
)
|
||
boxes = [
|
||
(classes[box.class_id], (box.x1, box.y1, box.x2, box.y2), box.score)
|
||
for box in detections
|
||
]
|
||
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}
|