第五步产出的框标签按类别分目录,便于人工核对;检测训练要的是 train/val 划分——类别写在标签第一列,不靠目录名。补上两个入口: - scripts/prepare_yolo_dataset.py:拍平类别目录、按类别分层切分、硬链接到 data/。分层是必需的,black 只有 53 张,随机切容易让小类在验证集里挂零。 无框的图默认保留(检测训练里是压低误检的负样本);拍平后的同名冲突直接 报错而不是静默覆盖。 - detect.py + configs/detect/film.yaml:包一层 ultralytics,沿用本仓库的 配置继承、设备解析与 SwanLab 可视化。 选 YOLOv8 而不是 v11/v12:CLAUDE.md 限定只用 convolution 模型,YOLO11 骨架 里有 C2PSA、YOLO12 以注意力为主,只有 v8 的 C2f + SPPF 是纯卷积。 mosaic/mixup 关掉——每张图只有一个弱监督框,拼接后小目标被缩得更小,对 blackdot/whitedot 这类只占千分之几面积的类别是净伤害。 ultralytics 的 project 必须传绝对路径,相对路径会被它再拼到全局 settings 的 runs_dir 上,产出会跑到 runs/detect/runs/detect/<name> 去。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
7.6 KiB
Python
192 lines
7.6 KiB
Python
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
pytest.importorskip("yaml")
|
||
|
||
from utils.config import Config, apply_overrides, deep_merge, load_config, parse_override # noqa: E402
|
||
|
||
|
||
def test_deep_merge_is_recursive():
|
||
merged = deep_merge({"a": {"b": 1, "c": 2}}, {"a": {"c": 3}, "d": 4})
|
||
assert merged == {"a": {"b": 1, "c": 3}, "d": 4}
|
||
|
||
|
||
def test_parse_override_literals():
|
||
assert parse_override("train.lr=1e-3") == ("train.lr", 1e-3)
|
||
assert parse_override("train.amp=False") == ("train.amp", False)
|
||
assert parse_override("model.backbone.name=tiny_conv") == ("model.backbone.name", "tiny_conv")
|
||
assert parse_override("simclr.feature_stages=[5, 6]") == ("simclr.feature_stages", [5, 6])
|
||
|
||
|
||
def test_parse_override_accepts_yaml_style_scalars():
|
||
"""配置文件里写 null/true/false,命令行上也得能这么写。
|
||
|
||
回归测试:只用 ast.literal_eval 时 `false` 会变成字符串 "false",
|
||
而非空字符串是真值——`--set xxx.enabled=false` 反而把开关打开了。
|
||
"""
|
||
assert parse_override("a=null") == ("a", None)
|
||
assert parse_override("a=~") == ("a", None)
|
||
assert parse_override("a=true") == ("a", True)
|
||
assert parse_override("a=false") == ("a", False)
|
||
assert parse_override("a=false")[1] is False # 不是字符串
|
||
|
||
# 路径、模型名这类仍应保持字符串
|
||
assert parse_override("a=resnet18") == ("a", "resnet18")
|
||
assert parse_override("a=runs/x/best.pth") == ("a", "runs/x/best.pth")
|
||
|
||
|
||
def test_apply_overrides_creates_missing_nodes():
|
||
"""非严格模式下仍可新建嵌套节点;严格模式(load_config 默认)会拦下未知键。"""
|
||
cfg = apply_overrides({}, ["a.b.c=1"], strict=False)
|
||
assert cfg == {"a": {"b": {"c": 1}}}
|
||
|
||
with pytest.raises(KeyError, match="不存在"):
|
||
apply_overrides({}, ["a.b.c=1"])
|
||
|
||
|
||
def test_load_config_with_base(tmp_path):
|
||
(tmp_path / "base.yaml").write_text("train:\n lr: 0.1\n epochs: 10\n", encoding="utf-8")
|
||
(tmp_path / "child.yaml").write_text(
|
||
"_base_: ./base.yaml\ntrain:\n lr: 0.01\nwork_dir: runs/x\n", encoding="utf-8"
|
||
)
|
||
|
||
cfg = load_config(tmp_path / "child.yaml", ["train.epochs=2"])
|
||
assert isinstance(cfg, Config)
|
||
assert cfg.train.lr == 0.01
|
||
assert cfg.train.epochs == 2
|
||
assert cfg.get_path("work_dir") == "runs/x"
|
||
|
||
|
||
def test_load_config_detects_cycle(tmp_path):
|
||
(tmp_path / "a.yaml").write_text("_base_: ./b.yaml\n", encoding="utf-8")
|
||
(tmp_path / "b.yaml").write_text("_base_: ./a.yaml\n", encoding="utf-8")
|
||
with pytest.raises(ValueError, match="循环引用"):
|
||
load_config(tmp_path / "a.yaml")
|
||
|
||
|
||
def test_project_configs_are_loadable():
|
||
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):
|
||
"""换组件时要能整块替换,否则旧组件的专有字段会残留下来传给新组件。"""
|
||
(tmp_path / "base.yaml").write_text(
|
||
"backbone:\n name: tiny_conv\n widths: [32, 64]\n depths: [1, 2]\n block: dw\n",
|
||
encoding="utf-8",
|
||
)
|
||
(tmp_path / "child.yaml").write_text(
|
||
"_base_: ./base.yaml\nbackbone:\n _replace_: true\n name: torchvision\n arch: resnet18\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
cfg = load_config(tmp_path / "child.yaml")
|
||
|
||
assert cfg["backbone"] == {"name": "torchvision", "arch": "resnet18"}
|
||
assert "_replace_" not in cfg["backbone"] # 标记本身不能漏进构造参数
|
||
|
||
|
||
def test_without_replace_key_subdicts_still_merge(tmp_path):
|
||
(tmp_path / "base.yaml").write_text("backbone:\n name: tiny_conv\n block: dw\n", encoding="utf-8")
|
||
(tmp_path / "child.yaml").write_text(
|
||
"_base_: ./base.yaml\nbackbone:\n block: res\n", encoding="utf-8"
|
||
)
|
||
|
||
cfg = load_config(tmp_path / "child.yaml")
|
||
assert cfg["backbone"] == {"name": "tiny_conv", "block": "res"}
|
||
|
||
|
||
def test_registry_reports_incompatible_fields_actionably():
|
||
"""--set 只改 name 时旧字段会残留,报错要说清楚是哪些字段、该怎么办。"""
|
||
import pytest
|
||
|
||
from models.registry import BACKBONES
|
||
|
||
with pytest.raises(TypeError, match=r"不接受的字段.*widths"):
|
||
BACKBONES.build({"name": "torchvision", "arch": "resnet18", "widths": [32]})
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"config_path",
|
||
sorted(str(p) for p in Path("configs").rglob("*.yaml")),
|
||
)
|
||
def test_every_shipped_config_builds_its_components(config_path):
|
||
"""仓库里每个配置都必须能真的构建出组件。
|
||
|
||
回归测试:派生配置曾经无法使用——它同时继承两份骨架配置,
|
||
合并后 backbone 混了两边的字段(tiny_conv 的 widths 混进 torchvision),构建时直接报错。
|
||
"""
|
||
pytest.importorskip("torch")
|
||
from models.registry import BACKBONES
|
||
|
||
cfg = load_config(config_path)
|
||
|
||
# 聚类配置: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 and "weights" in model_cfg:
|
||
# 检测配置:model 是 ultralytics 的权重名(yolov8s.pt),不走本仓库的注册表。
|
||
# 只校验它是纯卷积的那一档——CLAUDE.md 限定只用 convolution 模型,
|
||
# YOLO11 的 C2PSA 与 YOLO12 都带注意力。
|
||
assert "yolo11" not in model_cfg["weights"]
|
||
assert "yolo12" not in model_cfg["weights"]
|
||
elif 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():
|
||
assert model(torch.randn(1, 3, 64, 64)).shape[1] == model_cfg["num_classes"]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"override,expected_hint",
|
||
[
|
||
("simclr.optmizer.weight_decay=0.0", "optimizer"), # 少了个 i
|
||
("simclr.optimizer.lrr=1e-3", "lr"), # 多了个 r
|
||
("num_clusers=4", "num_clusters"), # 少了个 t
|
||
],
|
||
)
|
||
def test_set_rejects_typos_with_suggestion(override, expected_hint):
|
||
"""--set 打错字母会凭空造出没人读的键,训练照跑但改动无效——必须拦下并给出建议。"""
|
||
with pytest.raises(KeyError) as excinfo:
|
||
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_resnet18.yaml",
|
||
["simclr.optimizer.lr=0.001", "simclr.optimizer.weight_decay=0.0", "num_clusters=7"],
|
||
)
|
||
assert cfg["simclr"]["optimizer"]["lr"] == 0.001
|
||
assert cfg["simclr"]["optimizer"]["weight_decay"] == 0.0
|
||
assert cfg["num_clusters"] == 7
|
||
|
||
|
||
def test_apply_overrides_can_add_keys_when_not_strict():
|
||
from utils.config import apply_overrides
|
||
|
||
assert apply_overrides({"a": {"b": 1}}, ["a.c=2"], strict=False) == {"a": {"b": 1, "c": 2}}
|