原先点 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>
106 lines
4.1 KiB
Python
106 lines
4.1 KiB
Python
"""YOLO 结果 -> Label Studio 预测格式。
|
|
|
|
只测转换模块:ML backend 本体跑在独立虚拟环境里(label_studio_ml 与主环境的 jinja2
|
|
版本冲突),主环境 import 不了 model.py。
|
|
"""
|
|
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, "ml_backend")
|
|
from convert import box_to_result, to_prediction # noqa: E402
|
|
|
|
FROM_NAME, TO_NAME = "label", "image"
|
|
|
|
|
|
def test_box_converts_to_percent_coordinates():
|
|
"""Label Studio 用左上角+宽高的百分比,YOLO 给的是归一化 xyxy。"""
|
|
item = box_to_result("blackdot", [0.2, 0.3, 0.6, 0.5], 200, 100, FROM_NAME, TO_NAME, 0.9)
|
|
|
|
assert item["value"]["x"] == pytest.approx(20.0)
|
|
assert item["value"]["y"] == pytest.approx(30.0)
|
|
assert item["value"]["width"] == pytest.approx(40.0)
|
|
assert item["value"]["height"] == pytest.approx(20.0)
|
|
assert item["value"]["rectanglelabels"] == ["blackdot"]
|
|
assert item["type"] == "rectanglelabels"
|
|
assert item["from_name"] == FROM_NAME and item["to_name"] == TO_NAME
|
|
|
|
|
|
def test_original_size_is_the_real_pixel_size():
|
|
"""Label Studio 按 original_width/height 把百分比换算回像素,填错框就会偏。"""
|
|
item = box_to_result("grease", [0, 0, 1, 1], 810, 1080, FROM_NAME, TO_NAME)
|
|
assert (item["original_width"], item["original_height"]) == (810, 1080)
|
|
|
|
|
|
def test_ids_are_unique():
|
|
boxes = [box_to_result("a", [0, 0, 0.1, 0.1], 10, 10, FROM_NAME, TO_NAME) for _ in range(20)]
|
|
assert len({item["id"] for item in boxes}) == 20
|
|
|
|
|
|
def test_out_of_range_coordinates_are_clamped():
|
|
"""推理偶尔会给出略微越界的坐标;越界的框 Label Studio 画不出来。"""
|
|
item = box_to_result("x", [-0.01, 0.0, 1.02, 1.0], 100, 100, FROM_NAME, TO_NAME)
|
|
value = item["value"]
|
|
assert value["x"] == 0.0
|
|
assert value["x"] + value["width"] == pytest.approx(100.0)
|
|
|
|
|
|
def test_prediction_score_is_the_highest_confidence():
|
|
"""Label Studio 用 score 给任务排序,取最大值才能把最有把握的排在前面。"""
|
|
prediction = to_prediction(
|
|
[("blackdot", [0.1, 0.1, 0.2, 0.2], 0.4), ("grease", [0.5, 0.5, 0.7, 0.7], 0.85)],
|
|
128, 128, FROM_NAME, TO_NAME, "v1",
|
|
)
|
|
assert prediction["score"] == pytest.approx(0.85)
|
|
assert prediction["model_version"] == "v1"
|
|
assert len(prediction["result"]) == 2
|
|
|
|
|
|
def test_empty_prediction_is_wellformed():
|
|
"""没检出缺陷也要返回一条预测:空 result 表示"这张图没问题"。"""
|
|
prediction = to_prediction([], 128, 128, FROM_NAME, TO_NAME, "v1")
|
|
assert prediction["result"] == []
|
|
assert prediction["score"] == 0.0
|
|
|
|
|
|
def test_score_is_kept_per_box():
|
|
prediction = to_prediction(
|
|
[("blackdot", [0.1, 0.1, 0.2, 0.2], 0.42)], 64, 64, FROM_NAME, TO_NAME, "v1"
|
|
)
|
|
assert prediction["result"][0]["score"] == pytest.approx(0.42)
|
|
|
|
|
|
# ---------------- 重训触发的语义 ----------------
|
|
#
|
|
# fit() 本身要 import label_studio_ml,主环境里装不了,所以这里只把它的决策逻辑
|
|
# 单独拎出来验证:什么情况下该训、什么情况下不该。规则见 ml_backend/model.py。
|
|
|
|
|
|
def should_retrain(event: str, count: int, after: int) -> bool:
|
|
"""与 FilmDefectDetector.fit 中的判定保持一致。"""
|
|
if event == "START_TRAINING":
|
|
return True
|
|
return bool(after and count >= after)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"event,count,after,expected",
|
|
[
|
|
("START_TRAINING", 0, 0, True), # 人点的按钮,无条件训
|
|
("START_TRAINING", 3, 200, True), # 没攒够也训
|
|
("ANNOTATION_CREATED", 199, 200, False),
|
|
("ANNOTATION_CREATED", 200, 200, True),
|
|
("ANNOTATION_CREATED", 999, 0, False), # after=0 表示只手动触发
|
|
],
|
|
)
|
|
def test_retrain_trigger_rules(event, count, after, expected):
|
|
assert should_retrain(event, count, after) is expected
|
|
|
|
|
|
def test_only_new_annotations_count_toward_the_threshold():
|
|
"""UPDATED / DELETED 是对已有标注的修改,不该把阈值提前顶到。"""
|
|
counted = [e for e in ("ANNOTATION_CREATED", "ANNOTATION_UPDATED", "ANNOTATION_DELETED",
|
|
"START_TRAINING") if e == "ANNOTATION_CREATED"]
|
|
assert counted == ["ANNOTATION_CREATED"]
|