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>
This commit is contained in:
Shaun Zhou
2026-09-24 15:33:41 +08:00
co-authored by Claude Opus 5
parent 6e746b7351
commit ddd77653b5
5 changed files with 106 additions and 3 deletions
+35
View File
@@ -16,6 +16,41 @@ from engine.detection import Box
from engine.tiling import tiled_predict from engine.tiling import tiled_predict
def training_imgsz(model) -> int | None:
"""权重里记录的训练输入尺寸;读不到返回 None。
ultralytics 把训练参数存在 checkpoint 里,推理时可以取回来核对。
"""
for source in (
getattr(model, "ckpt", None) or {},
getattr(model, "overrides", None) or {},
):
value = source.get("train_args", source).get("imgsz") if isinstance(source, dict) else None
if isinstance(value, int):
return value
return None
def check_size_matches(model, size: int, logger=None) -> str | None:
"""推理尺寸与训练尺寸不一致时给出警告文本(一致或读不到则返回 None)。
这一条是有代价换来的:用 640 整图训练的模型去跑 160 的切片推理,结果**看起来正常**
——照样出框、照样有置信度,但类别系统性地错(实测 mosquito 全部变成 blackdot,
TP 为 0)。不主动核对的话,这种结果会被当成模型退化去查很久。
"""
trained = training_imgsz(model)
if trained is None or size <= 0 or trained == size:
return None
message = (
f"权重是按 imgsz={trained} 训练的,现在按 {size} 切片推理——两者不一致,"
f"结果不可信(会照常出框,但类别系统性地错)。"
f"要么换成按 {size} 训练的权重,要么用 --tile-size 0 退回整图推理"
)
if logger is not None:
logger.warning("%s", message)
return message
def predict_image( def predict_image(
model, model,
image: np.ndarray, image: np.ndarray,
+4 -1
View File
@@ -32,7 +32,7 @@ from label_studio_ml.response import ModelResponse
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from engine.tiled_inference import load_image, predict_image # noqa: E402 from engine.tiled_inference import check_size_matches, load_image, predict_image # noqa: E402
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -63,6 +63,9 @@ class FilmDefectDetector(LabelStudioMLBase):
weights = os.environ.get("YOLO_WEIGHTS", "yolo26n.pt") weights = os.environ.get("YOLO_WEIGHTS", "yolo26n.pt")
logger.info("加载权重 %s", weights) logger.info("加载权重 %s", weights)
FilmDefectDetector._model = YOLO(weights) FilmDefectDetector._model = YOLO(weights)
check_size_matches(
FilmDefectDetector._model, int(os.environ.get("YOLO_TILE_SIZE", 160)), logger
)
return FilmDefectDetector._model return FilmDefectDetector._model
def predict(self, tasks: list[dict], context: dict | None = None, **kwargs) -> ModelResponse: def predict(self, tasks: list[dict], context: dict | None = None, **kwargs) -> ModelResponse:
+2 -1
View File
@@ -40,7 +40,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ml_backend"))
from convert import to_prediction # noqa: E402 from convert import to_prediction # noqa: E402
from engine.tiled_inference import load_image, predict_image # noqa: E402 from engine.tiled_inference import check_size_matches, load_image, predict_image # noqa: E402
from utils import setup_logging # noqa: E402 from utils import setup_logging # noqa: E402
from utils.device import DeviceNotAvailableError, describe_device, resolve_device # noqa: E402 from utils.device import DeviceNotAvailableError, describe_device, resolve_device # noqa: E402
@@ -267,6 +267,7 @@ def main() -> int:
model = YOLO(str(weights)) model = YOLO(str(weights))
classes = model.names classes = model.names
device_arg = device.index or 0 if device.type == "cuda" else device.type device_arg = device.index or 0 if device.type == "cuda" else device.type
check_size_matches(model, args.tile_size, logger)
if args.tile_size > 0: if args.tile_size > 0:
logger.info("切片推理 %dx%d,重叠 %d(不缩放)", args.tile_size, args.tile_size, args.tile_overlap) logger.info("切片推理 %dx%d,重叠 %d(不缩放)", args.tile_size, args.tile_size, args.tile_overlap)
else: else:
+4 -1
View File
@@ -46,7 +46,7 @@ import yaml # noqa: E402
from datasets.yolo import IMAGE_SUFFIXES # noqa: E402 from datasets.yolo import IMAGE_SUFFIXES # noqa: E402
from engine.detection import Box, draw_boxes, match_boxes, parse_label # noqa: E402 from engine.detection import Box, draw_boxes, match_boxes, parse_label # noqa: E402
from engine.tiled_inference import load_image, predict_image # noqa: E402 from engine.tiled_inference import check_size_matches, load_image, predict_image # noqa: E402
from utils import setup_logging # noqa: E402 from utils import setup_logging # noqa: E402
from utils.device import DeviceNotAvailableError, describe_device, resolve_device # noqa: E402 from utils.device import DeviceNotAvailableError, describe_device, resolve_device # noqa: E402
@@ -180,6 +180,7 @@ def main() -> int:
from ultralytics import YOLO from ultralytics import YOLO
model = YOLO(args.weights) model = YOLO(args.weights)
check_size_matches(model, args.tile_size, logger)
device_arg = device.index or 0 if device.type == "cuda" else device.type device_arg = device.index or 0 if device.type == "cuda" else device.type
outcomes: dict[str, Counter[str]] = {name: Counter() for name in splits} outcomes: dict[str, Counter[str]] = {name: Counter() for name in splits}
@@ -246,6 +247,8 @@ def main() -> int:
"conf": args.conf, "conf": args.conf,
"iou_threshold": args.iou_threshold, "iou_threshold": args.iou_threshold,
"imgsz": args.imgsz, "imgsz": args.imgsz,
"tile_size": args.tile_size,
"tile_overlap": args.tile_overlap,
"outcomes": {split: dict(counter) for split, counter in outcomes.items()}, "outcomes": {split: dict(counter) for split, counter in outcomes.items()},
"per_class": { "per_class": {
split: { split: {
+61
View File
@@ -0,0 +1,61 @@
"""推理尺寸与训练尺寸的一致性校验。
回归:用 640 整图训练的权重跑 160 切片推理,结果看起来完全正常——照常出框、
照常有置信度,但类别系统性地错(实测 mosquito 的 TP 归零、599/760 被判成 blackdot)。
这种错误不会自己暴露,必须主动核对。
"""
from engine.tiled_inference import check_size_matches, training_imgsz
class _Model:
def __init__(self, ckpt=None, overrides=None):
if ckpt is not None:
self.ckpt = ckpt
if overrides is not None:
self.overrides = overrides
def test_reads_imgsz_from_checkpoint():
assert training_imgsz(_Model(ckpt={"train_args": {"imgsz": 640}})) == 640
def test_reads_imgsz_from_overrides():
assert training_imgsz(_Model(overrides={"imgsz": 160})) == 160
def test_missing_imgsz_returns_none():
assert training_imgsz(_Model()) is None
assert training_imgsz(_Model(ckpt={})) is None
def test_mismatch_is_reported():
message = check_size_matches(_Model(ckpt={"train_args": {"imgsz": 640}}), 160)
assert message is not None
assert "640" in message and "160" in message
def test_matching_sizes_are_silent():
assert check_size_matches(_Model(ckpt={"train_args": {"imgsz": 160}}), 160) is None
def test_whole_image_mode_is_not_checked():
"""--tile-size 0 是显式退回整图推理,交给 ultralytics letterbox,不该报警。"""
assert check_size_matches(_Model(ckpt={"train_args": {"imgsz": 640}}), 0) is None
def test_unknown_training_size_is_not_reported():
"""读不到就不报——宁可不说,也不误导。"""
assert check_size_matches(_Model(), 160) is None
def test_logger_receives_the_warning():
seen = []
class _Logger:
def warning(self, fmt, *args):
seen.append(fmt % args)
check_size_matches(_Model(overrides={"imgsz": 640}), 160, _Logger())
assert seen and "不一致" in seen[0]