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

121 lines
4.2 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.
"""用 ultralytics 做切片推理的公共封装。
三个调用方(可视化、批量推送预测、ML backend)都要"切片 -> 逐片推理 -> 拼回原图",
逻辑一致,放在一处免得各写一份走样。
几何在 `engine.tiling` 里,与推理框架无关且单独可测;这里只负责把 ultralytics 接上去。
"""
from __future__ import annotations
from collections.abc import Sequence
import numpy as np
from engine.detection import Box
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(
model,
image: np.ndarray,
size: int,
overlap: int = 0,
conf: float = 0.25,
device: str | int = "cpu",
batch: int = 16,
iou_threshold: float = 0.5,
whole_image_imgsz: int | None = None,
) -> list[Box]:
"""对一张任意尺寸的图做切片推理,返回整图上的归一化框。
`size <= 0` 时退化为整图推理,交给 ultralytics 按 `whole_image_imgsz` 做 letterbox
——那会缩放,只用于和旧模型对比。
"""
if size <= 0:
return _predict_batch(
model, [image], whole_image_imgsz, conf=conf, device=device, batch=batch
)[0]
def run(patches: Sequence[np.ndarray]) -> list[list[Box]]:
return _predict_batch(model, patches, size, conf, device, batch)
return tiled_predict(run, image, size, overlap, iou_threshold)
def _predict_batch(
model,
images: Sequence[np.ndarray],
size: int | None,
conf: float,
device: str | int,
batch: int,
) -> list[list[Box]]:
"""分批送进 ultralytics,返回每张图的归一化框。
必须自己分批:ultralytics 不理会 batch 参数,会把整个列表当成一个 batch,
片数一多就会撑爆显存。
"""
results = []
for start in range(0, len(images), batch):
chunk = list(images[start:start + batch])
kwargs = {"conf": conf, "device": device, "verbose": False}
if size:
kwargs["imgsz"] = size
results.extend(model.predict(source=chunk, **kwargs))
out = []
for result in results:
out.append([
Box(int(class_id), *(float(v) for v in xyxyn), float(score))
for xyxyn, class_id, score in zip(
result.boxes.xyxyn.tolist(), result.boxes.cls.tolist(),
result.boxes.conf.tolist(), strict=True,
)
])
return out
def load_image(path) -> np.ndarray:
"""读图为 numpy 数组,保持原始通道数(灰度图不强行转 RGB,免得多占三倍内存)。"""
from PIL import Image
with Image.open(path) as handle:
image = handle.convert("L") if handle.mode in ("L", "1", "I;16") else handle.convert("RGB")
return np.asarray(image)