- shufflenet_v2_x1_0 列在支持清单里却直接 AttributeError:ShuffleNetV2 既不是 扁平 children 也没有 .features,需要按 conv1/maxpool/stage2..4/conv5 自己拼。 该 bug 同时影响 SimCLR 与检测模型 - 新增参数化测试覆盖清单里的每个 arch:能前向、声明的 out_channels 与实际一致 - 配置支持 `_replace_: true`:子字典整块替换而不与 base 合并。换骨架时 tiny_conv 的 widths/depths 会残留并传给新骨架,此前无论用 --set 还是 _base_ 都绕不开,只能改原文件 - Registry.build 提前校验字段,报错直接指出是哪些字段不被接受、该怎么办, 取代裸 TypeError - README 补充可选骨架表与三种改法(含不该用 --set 改 name 的说明) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
148 lines
5.1 KiB
Python
148 lines
5.1 KiB
Python
import pytest
|
|
|
|
torch = pytest.importorskip("torch")
|
|
|
|
from models import build_model # noqa: E402
|
|
from models.backbones.resnet import _SUPPORTED as _SUPPORTED_ARCHS # noqa: E402
|
|
from models.postprocess import decode_heatmap # noqa: E402
|
|
|
|
|
|
@pytest.fixture
|
|
def model_cfg():
|
|
return {
|
|
"name": "defect_net",
|
|
"num_classes": 3,
|
|
"backbone": {"name": "tiny_conv", "widths": [8, 16, 32], "depths": [1, 1, 1]},
|
|
"neck": {"name": "upsample", "channels": 16, "num_upsamples": 2},
|
|
"head": {"hidden_channels": 16},
|
|
}
|
|
|
|
|
|
def test_forward_shapes_and_stride(model_cfg):
|
|
model = build_model(model_cfg).eval()
|
|
# stem(2) * 2 个下采样 stage = 8,再上采样 4 倍 => stride 2
|
|
assert model.out_stride == 2
|
|
|
|
with torch.no_grad():
|
|
out = model(torch.randn(2, 3, 64, 64))
|
|
|
|
size = 64 // model.out_stride
|
|
assert out["heatmap"].shape == (2, 3, size, size)
|
|
assert out["wh"].shape == (2, 2, size, size)
|
|
assert out["offset"].shape == (2, 2, size, size)
|
|
assert out["logits"].shape == (2, 3)
|
|
|
|
|
|
def test_backbone_is_convolution_only(model_cfg):
|
|
model = build_model(model_cfg)
|
|
forbidden = (torch.nn.MultiheadAttention, torch.nn.Transformer, torch.nn.LSTM, torch.nn.GRU)
|
|
assert not any(isinstance(m, forbidden) for m in model.modules())
|
|
|
|
|
|
def test_without_classification_head(model_cfg):
|
|
model = build_model({**model_cfg, "with_classification": False}).eval()
|
|
with torch.no_grad():
|
|
out = model(torch.randn(1, 3, 64, 64))
|
|
assert "logits" not in out
|
|
|
|
|
|
def test_decode_heatmap_recovers_planted_box():
|
|
heatmap = torch.full((1, 2, 32, 32), -10.0)
|
|
heatmap[0, 1, 8, 10] = 10.0
|
|
wh = torch.zeros(1, 2, 32, 32)
|
|
wh[0, :, 8, 10] = torch.tensor([4.0, 6.0])
|
|
offset = torch.zeros(1, 2, 32, 32)
|
|
|
|
(result,) = decode_heatmap(heatmap, wh, offset, stride=4, topk=5, score_threshold=0.5)
|
|
|
|
assert result["labels"].tolist() == [1]
|
|
assert result["boxes"][0].tolist() == pytest.approx([32.0, 20.0, 48.0, 44.0])
|
|
|
|
|
|
def test_losses_decrease_on_a_single_batch(model_cfg):
|
|
from engine.losses import DetectionLoss
|
|
|
|
torch.manual_seed(0)
|
|
model = build_model(model_cfg)
|
|
criterion = DetectionLoss()
|
|
optimizer = torch.optim.Adam(model.parameters(), lr=1e-2)
|
|
|
|
images = torch.randn(2, 3, 64, 64)
|
|
size = 64 // model.out_stride
|
|
target = {
|
|
"heatmap": torch.zeros(2, 3, size, size),
|
|
"wh": torch.zeros(2, 2, size, size),
|
|
"offset": torch.zeros(2, 2, size, size),
|
|
"mask": torch.zeros(2, size, size),
|
|
}
|
|
target["heatmap"][0, 1, 5, 5] = 1.0
|
|
target["mask"][0, 5, 5] = 1.0
|
|
target["wh"][0, :, 5, 5] = 3.0
|
|
batch = {"target": target, "image_labels": torch.zeros(2, 3)}
|
|
|
|
first = last = None
|
|
for step in range(5):
|
|
losses = criterion(model(images), batch)
|
|
optimizer.zero_grad()
|
|
losses["total"].backward()
|
|
optimizer.step()
|
|
total = float(losses["total"].detach())
|
|
first = total if step == 0 else first
|
|
last = total
|
|
|
|
assert last < first
|
|
|
|
|
|
@pytest.mark.parametrize("arch", sorted(_SUPPORTED_ARCHS))
|
|
def test_every_supported_torchvision_arch_actually_works(arch):
|
|
"""清单里列出的每个骨架都必须真能前向,且声明的 out_channels 与实际一致。
|
|
|
|
回归测试:shufflenet_v2_x1_0 曾被列为支持,但它没有 `.features` 属性,
|
|
构造时直接 AttributeError。
|
|
"""
|
|
from models.registry import BACKBONES
|
|
|
|
backbone = BACKBONES.build({"name": "torchvision", "arch": arch, "pretrained": False}).eval()
|
|
with torch.no_grad():
|
|
feat = backbone(torch.randn(1, 3, 64, 64))
|
|
|
|
assert feat.dim() == 4
|
|
assert feat.shape[1] == backbone.out_channels == _SUPPORTED_ARCHS[arch]
|
|
assert backbone.out_stride == 32
|
|
|
|
|
|
def test_unsupported_arch_is_rejected():
|
|
from models.registry import BACKBONES
|
|
|
|
with pytest.raises(ValueError, match="不支持的骨架"):
|
|
BACKBONES.build({"name": "torchvision", "arch": "vit_b_16"})
|
|
|
|
|
|
@pytest.mark.parametrize("arch", ["resnet18", "shufflenet_v2_x1_0", "mobilenet_v3_small"])
|
|
def test_alternative_backbones_drive_simclr(arch):
|
|
"""换骨架后 SimCLR 的投影头要能接上(out_channels 必须准)。"""
|
|
from models.heads.projection import ProjectionHead
|
|
from models.registry import BACKBONES
|
|
|
|
backbone = BACKBONES.build({"name": "torchvision", "arch": arch, "pretrained": False}).eval()
|
|
head = ProjectionHead(backbone.out_channels, 32, 16)
|
|
with torch.no_grad():
|
|
assert head(backbone(torch.randn(2, 3, 64, 64))).shape == (2, 16)
|
|
|
|
|
|
def test_alternative_backbone_drives_defect_net():
|
|
"""检测模型换骨架同样要能跑通(neck 的 in_channels 由 backbone 决定)。"""
|
|
model = build_model(
|
|
{
|
|
"name": "defect_net",
|
|
"num_classes": 3,
|
|
"backbone": {"name": "torchvision", "arch": "shufflenet_v2_x1_0", "pretrained": False},
|
|
"neck": {"name": "upsample", "channels": 16, "num_upsamples": 3},
|
|
"head": {"hidden_channels": 16},
|
|
}
|
|
).eval()
|
|
assert model.out_stride == 4
|
|
with torch.no_grad():
|
|
out = model(torch.randn(1, 3, 64, 64))
|
|
assert out["heatmap"].shape == (1, 3, 16, 16)
|