feat(boxes): 新增传统 CV 定位方法(--method cv),不依赖分类模型

背景差分:大半径方框均值估背景 → 残差(可选极性)→ 平滑 → 有无判定 → 按峰值归一化,
再交给与 gradcam / shap 同一套 cam_to_boxes 出框。只依赖 numpy。

与归因方法的差别:不需要 --checkpoint,分类器还没训练出来就能先跑一版框,也不受分类
精度影响;类别只能来自目录(--target label);找的是偏离局部背景的区域,而不是模型
据以判断类别的区域,所以对局部目标好、对铺满整图的纹理类给不出有意义的框。

有无缺陷是显式判定的:残差峰值要同时高于绝对对比度下限与噪声水平(MAD 估计)的 15 倍,
否则返回全零图。这条是必需的——实测纯噪声图的"峰值/MAD"稳定在 9 附近(最大 10.9,
几乎与噪声大小无关),有 0.25 对比度黑点时是 25~330;若只按峰值归一化,正常图的噪声
会被拉到 1,满图假框。阈值 15 取在两者之间。

顺带把积分图方框均值从 shap_explain.smooth 提到 engine/classical.box_mean 共用,
避免两处实现各自漂移。

实测:单张 0.011 秒(全量 11k 约 2 分钟);真实薄膜图上框能套住黑点、虫状物与划痕。
已知局限:背景估计在图像边界处对明暗阶跃有响应,贴边的交界可能出假框。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-17 11:03:13 +08:00
co-authored by Claude Opus 5
parent 32e56cb230
commit 65bc6b2045
6 changed files with 494 additions and 46 deletions
+87 -3
View File
@@ -1,4 +1,4 @@
"""生成框标签的统一入口(gradcam / shap 两种方法)。"""
"""生成框标签的统一入口(gradcam / shap / cv 三种方法)。"""
import json
import sys
@@ -70,8 +70,8 @@ def base_argv(project, out, method):
"--checkpoint", str(checkpoint), "--config", str(config)]
@pytest.mark.parametrize("method", ["gradcam", "shap"])
def test_both_methods_produce_a_yolo_dataset(project, tmp_path, monkeypatch, method):
@pytest.mark.parametrize("method", ["gradcam", "shap", "cv"])
def test_every_method_produces_a_yolo_dataset(project, tmp_path, monkeypatch, method):
if method == "shap":
pytest.importorskip("shap")
root, _, _ = project
@@ -292,3 +292,87 @@ 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
# ---------------- 传统 CV 方法 ----------------
def test_cv_runs_without_a_classifier(project, tmp_path, monkeypatch):
"""cv 不判类别,也就不需要权重——分类器还没训练出来时就能先跑一版框。"""
root, _, config = project
out = tmp_path / "out_cv"
argv = ["--out", str(out), "--method", "cv", "--config", str(config),
"--checkpoint", str(tmp_path / "does_not_exist.pth")]
assert run(argv, monkeypatch) == 0
assert len(list((out / "labels").rglob("*.txt"))) == 5
summary = json.loads((out / "summary.json").read_text(encoding="utf-8"))
assert summary["method"] == "cv"
# 没加载模型,就没有预测可比
assert summary["prediction_matches_folder_label"] is None
assert summary["cv_params"]["polarity"] == "both"
def test_cv_labels_use_the_folder_class(project, tmp_path, monkeypatch):
"""类别只能来自目录:cv 自己不判类。"""
root, _, config = project
out = tmp_path / "out_cv"
assert run(["--out", str(out), "--method", "cv", "--config", str(config)], monkeypatch) == 0
classes = sorted(p.name for p in root.iterdir() if p.is_dir())
for label in (out / "labels").rglob("*.txt"):
folder = label.parent.name
for line in label.read_text(encoding="utf-8").strip().splitlines():
assert classes[int(line.split()[0])] == folder
def test_cv_finds_a_planted_defect(tmp_path, monkeypatch):
"""端到端:图上放一个黑点,框要套住它;平坦的图不出框。"""
root = tmp_path / "dataset"
(root / "blackdot").mkdir(parents=True)
(root / "normal").mkdir(parents=True)
rng = np.random.default_rng(0)
flat = np.clip(rng.normal(140, 2, (96, 96)), 0, 255).astype(np.uint8)
spotted = flat.copy()
spotted[28:36, 60:68] = 40 # 一个明显的黑点
Image.fromarray(spotted).save(root / "blackdot" / "spot.png")
Image.fromarray(flat).save(root / "normal" / "flat.png")
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: torchvision, arch: resnet18, pretrained: false}}}}
""",
encoding="utf-8",
)
out = tmp_path / "out"
assert run(["--out", str(out), "--method", "cv", "--config", str(config)], monkeypatch) == 0
spot = (out / "labels" / "blackdot" / "spot.txt").read_text(encoding="utf-8").split()
assert len(spot) == 5
_, cx, cy, width, height = (float(v) for v in spot)
assert abs(cx - 64 / 96) < 0.1 and abs(cy - 32 / 96) < 0.1 # 命中黑点
assert width * height < 0.15
# 平坦的图没有缺陷,应当是空标签
assert (out / "labels" / "normal" / "flat.txt").read_text(encoding="utf-8") == ""
def test_cv_polarity_is_passed_through(project, tmp_path, monkeypatch):
root, _, config = project
out = tmp_path / "out_cv"
argv = ["--out", str(out), "--method", "cv", "--config", str(config),
"--cv-polarity", "dark", "--cv-radius", "5", "--cv-min-contrast", "0.02"]
assert run(argv, monkeypatch) == 0
params = json.loads((out / "summary.json").read_text(encoding="utf-8"))["cv_params"]
assert params == {
"background_radius": 5, "polarity": "dark", "smooth_radius": 2, "min_contrast": 0.02,
}