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
+71 -11
View File
@@ -94,21 +94,25 @@ def test_both_methods_produce_a_yolo_dataset(project, tmp_path, monkeypatch, met
assert all(0.0 <= float(v) <= 1.0 for v in parts[1:])
def test_output_is_readable_by_the_detection_dataset(project, tmp_path, monkeypatch):
"""产出要能直接喂给检测训练——这是这一步存在的意义。"""
from datasets import FilmDefectDataset
def test_output_is_a_wellformed_yolo_dataset(project, tmp_path, monkeypatch):
"""产出是给外部检测训练用的交换格式,必须自洽:
data.yaml 的类别与 labels/ 对得上,每行标签可解析且坐标在 [0,1] 内。"""
import yaml
out = tmp_path / "out"
run(base_argv(project, out, "gradcam"), monkeypatch)
dataset = FilmDefectDataset(
img_dir=out / "images", classes=str(out / "data.yaml"),
transforms=[{"name": "Resize", "size": [64, 64]}, {"name": "ToTensor"}], stride=4,
)
assert len(dataset) == 5
sample = dataset[0]
assert sample["image"].shape == (3, 64, 64)
assert sample["boxes"].shape[1] == 4
meta = yaml.safe_load((out / "data.yaml").read_text(encoding="utf-8"))
names = [meta["names"][i] for i in sorted(meta["names"])]
assert meta["nc"] == len(names) == 2
for label in (out / "labels").rglob("*.txt"):
assert (out / "images" / label.parent.name / f"{label.stem}.png").is_file()
for line in label.read_text(encoding="utf-8").strip().splitlines():
class_id, cx, cy, w, h = line.split()
assert 0 <= int(class_id) < meta["nc"]
assert all(0.0 <= float(v) <= 1.0 for v in (cx, cy, w, h))
assert float(w) > 0 and float(h) > 0
def test_images_are_hard_links_not_copies(project, tmp_path, monkeypatch):
@@ -224,3 +228,59 @@ def test_duplicate_source_names_are_rejected(tmp_path):
gb._emit(source=Path("/data/dirA/x.png"), **common)
with pytest.raises(gb.NameCollision, match="prefix-parent"):
gb._emit(source=Path("/data/dirB/x.png"), **common) # 同名,撞同一个标签路径
def test_gradcam_falls_back_when_backbone_has_no_sequential_features(tmp_path, monkeypatch):
"""tiny_conv 不是 nn.Sequential,取不了中间层——应当退回最终输出并警告,而不是崩掉。"""
from engine.checkpoint import save_checkpoint
from models import build_model
root = tmp_path / "dataset"
(root / "a").mkdir(parents=True)
rng = np.random.default_rng(0)
for i in range(2):
Image.fromarray(rng.integers(0, 255, (40, 56, 3), dtype=np.uint8)).save(
root / "a" / f"a_{i}.png"
)
backbone = {"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]}
checkpoint = tmp_path / "best.pth"
save_checkpoint(checkpoint, build_model(
{"name": "classifier", "num_classes": 1, "backbone": dict(backbone)}
))
config = tmp_path / "cfg.yaml"
config.write_text(
f"""
device: cpu
data:
root: {root}
classes: null
exclude: []
val_transforms: [{{name: Resize, size: [64, 64]}}, {{name: ToTensor}}]
loader: {{batch_size: 2, num_workers: 0}}
model:
name: classifier
backbone: {{name: tiny_conv, widths: [8, 16], depths: [1, 1]}}
init_from: null
""",
encoding="utf-8",
)
out = tmp_path / "out"
monkeypatch.setattr(
sys, "argv",
["generate_boxes.py", "--out", str(out), "--method", "gradcam",
"--checkpoint", str(checkpoint), "--config", str(config), "--no-heatmaps"],
)
assert gb.main() == 0 # 不崩
summary = json.loads((out / "summary.json").read_text("utf-8"))
assert summary["stage"] is None # 已退回最终输出
assert len(list((out / "labels").rglob("*.txt"))) == 2
def test_stage_minus_one_selects_the_final_output(project, tmp_path, monkeypatch):
out = tmp_path / "out"
assert run(base_argv(project, out, "gradcam") + ["--stage", "-1"], monkeypatch) == 0
assert json.loads((out / "summary.json").read_text("utf-8"))["stage"] is None