用 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>
121 lines
4.2 KiB
Python
121 lines
4.2 KiB
Python
"""用 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)
|