refactor: 按实际的五步工作流裁掉无关代码

实际工作流是:simclr 训练 -> 聚类 -> 人工归类 -> 分类训练 -> GradCAM/SHAP 生成
热力图与 box。据此移除不参与其中的检测/定位链路,共 31 个文件、约 2600 行:

- 模型:defect_net、postprocess、necks/、heads/center_head
- 数据:film_defect(YOLO 读取端)、target_encoder
- 引擎:trainer、evaluator、losses
- 入口:train.py、eval.py
- 脚本:convert_annotations、split_dataset、export_onnx、profile_inference
- 配置:configs/{train,eval,model,dataset}
- utils/labels(多类/单类标签模式,仅检测分类头使用)

共享模块按实际用量瘦身:engine/metrics 只留 AverageMeter(检测指标随之移除);
datasets/yolo 只留写出侧(IMAGE_SUFFIXES 与 write_data_yaml)——第 5 步产出的
YOLO 标签是给外部检测训练用的交换格式,读取端不再需要。

裁剪过程中修掉一个真 bug:generate_boxes 的 --stage 默认 6(layer3)对 tiny_conv
这类非 nn.Sequential 骨架直接抛异常。现在自动退回骨架最终输出并警告,
另加 --stage -1 显式选择最终输出。

五步工作流已端到端实跑验证(simclr 1 轮 -> 聚类 -> 导出 -> 分类 1 轮 ->
gradcam 与 shap 两种方法各生成一遍)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-11 10:27:20 +08:00
co-authored by Claude Opus 5
parent 57446c2f10
commit 86c86b105c
45 changed files with 186 additions and 3218 deletions
+33 -37
View File
@@ -66,10 +66,13 @@ def test_load_config_detects_cycle(tmp_path):
def test_project_configs_are_loadable():
cfg = load_config("configs/train/baseline.yaml")
assert cfg["model"]["name"] == "defect_net"
assert cfg["data"]["train"]["dataset"]["name"] == "film_defect"
assert len(cfg["classes"]) > 0
cluster = load_config("configs/cluster/film.yaml")
assert cluster["dataset"]["name"] == "unlabeled"
assert cluster["num_clusters"] > 0
classify = load_config("configs/classify/film.yaml")
assert classify["model"]["name"] == "classifier"
assert classify["train"]["monitor"] == "macro_f1"
def test_replace_key_swaps_a_whole_subdict(tmp_path):
@@ -116,46 +119,39 @@ def test_registry_reports_incompatible_fields_actionably():
def test_every_shipped_config_builds_its_components(config_path):
"""仓库里每个配置都必须能真的构建出组件。
回归测试:configs/train/resnet18.yaml 曾经无法使用——它同时继承 tiny_conv 与
resnet18 的模型配置,合并后 backbone 混了两边的字段,构建时直接报错。
回归测试:派生配置曾经无法使用——它同时继承两份骨架配置,
合并后 backbone 混了两边的字段(tiny_conv 的 widths 混进 torchvision),构建时直接报错。
"""
torch = pytest.importorskip("torch")
from datasets.yolo import load_classes
from models import build_model
pytest.importorskip("torch")
from models.registry import BACKBONES
cfg = load_config(config_path)
if "model" in cfg:
if "classes" not in cfg:
# configs/model/*.yaml 是片段,只能被 _base_ 组合,单独用没有 classes
pytest.skip(f"{config_path} 是配置片段,不含 classes")
model_cfg = dict(cfg["model"])
model_cfg.setdefault("num_classes", len(load_classes(cfg["classes"])))
# 单测里不下载 ImageNet 权重
# 聚类配置:pretrained 用顶层 backbone,simclr 用 simclr.backbone
for backbone_cfg in (cfg.get("backbone"), cfg.get_path("simclr.backbone")):
if not backbone_cfg:
continue
backbone_cfg = dict(backbone_cfg)
if backbone_cfg.get("name") == "torchvision":
backbone_cfg["pretrained"] = False # 单测里不下载 ImageNet 权重
backbone = BACKBONES.build(backbone_cfg)
assert backbone.out_channels > 0
# 分类配置
model_cfg = cfg.get("model")
if model_cfg:
import torch
from models import build_model
model_cfg = dict(model_cfg)
model_cfg.setdefault("num_classes", 3)
model_cfg["init_from"] = None
if model_cfg.get("backbone", {}).get("name") == "torchvision":
model_cfg["backbone"] = {**model_cfg["backbone"], "pretrained": False}
model = build_model(model_cfg)
with torch.no_grad():
out = model(torch.randn(1, 3, 64, 64))
assert "heatmap" in out
simclr_backbone = cfg.get_path("simclr.backbone")
if simclr_backbone:
backbone_cfg = dict(simclr_backbone)
if backbone_cfg.get("name") == "torchvision":
backbone_cfg["pretrained"] = False
backbone = BACKBONES.build(backbone_cfg)
assert backbone.out_channels > 0
def test_eval_config_matches_its_training_backbone():
"""评估配置构建出的骨架必须与训练配置一致,否则权重根本加载不上。"""
for train_cfg, eval_cfg in [
("configs/train/baseline.yaml", "configs/eval/baseline.yaml"),
("configs/train/resnet18.yaml", "configs/eval/resnet18.yaml"),
]:
assert load_config(train_cfg)["model"] == load_config(eval_cfg)["model"], eval_cfg
assert model(torch.randn(1, 3, 64, 64)).shape[1] == model_cfg["num_classes"]
@pytest.mark.parametrize(
@@ -169,13 +165,13 @@ def test_eval_config_matches_its_training_backbone():
def test_set_rejects_typos_with_suggestion(override, expected_hint):
"""--set 打错字母会凭空造出没人读的键,训练照跑但改动无效——必须拦下并给出建议。"""
with pytest.raises(KeyError) as excinfo:
load_config("configs/cluster/film_simclr_resnet50.yaml", [override])
load_config("configs/cluster/film_simclr_resnet18.yaml", [override])
assert expected_hint in str(excinfo.value)
def test_set_accepts_existing_keys():
cfg = load_config(
"configs/cluster/film_simclr_resnet50.yaml",
"configs/cluster/film_simclr_resnet18.yaml",
["simclr.optimizer.lr=0.001", "simclr.optimizer.weight_decay=0.0", "num_clusters=7"],
)
assert cfg["simclr"]["optimizer"]["lr"] == 0.001