Files
Shaun ZhouandClaude Opus 5 d6c6aca325 feat(detect): 默认模型换成 yolo26n
CLAUDE.md 已把"只用 convolution 模型"收窄到聚类与数据集生成阶段,检测这一步
不再受限,于是换成最新的 YOLO26:端到端(NMS-free),推理侧少一道后处理。
取最小的 n 档——数据 1.1 万张且标签是弱监督的,模型大了容易把标注噪声学进去。

同时去掉 test_config.py 里"不许用 yolo11/yolo12"的断言,它的依据已随
CLAUDE.md 的改动失效。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:34:16 +08:00

190 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 的权重名(yolo26n.pt),不走本仓库的注册表,
# 这里只确认它确实是个权重文件名。
assert model_cfg["weights"].endswith(".pt")
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}}