Files
Shaun ZhouandClaude Opus 5 ddd77653b5 fix(detect): 推理尺寸与训练尺寸不一致时报警
用 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>
2026-09-24 15:33:41 +08:00

160 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""把训练好的 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}