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:
co-authored by
Claude Opus 5
parent
32e56cb230
commit
65bc6b2045
@@ -0,0 +1,137 @@
|
||||
"""传统 CV 的缺陷显著图(背景差分)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
np = pytest.importorskip("numpy")
|
||||
|
||||
from engine.classical import box_mean, defect_saliency, to_gray # noqa: E402
|
||||
from engine.gradcam import cam_to_boxes # noqa: E402
|
||||
|
||||
BOX_KWARGS = {"threshold": 0.4, "min_area_ratio": 0.002, "max_boxes": 1, "peak_ratio": 0.75}
|
||||
|
||||
|
||||
def _image(size=96, level=0.5, seed=0, noise=0.01):
|
||||
rng = np.random.default_rng(seed)
|
||||
return np.clip(level + rng.normal(0, noise, (size, size)), 0, 1).astype(np.float32)
|
||||
|
||||
|
||||
def _with_blob(image, center=(30, 70), radius=4, delta=-0.25):
|
||||
ys, xs = np.ogrid[: image.shape[0], : image.shape[1]]
|
||||
blob = (ys - center[0]) ** 2 + (xs - center[1]) ** 2 <= radius**2
|
||||
out = image.copy()
|
||||
out[blob] = np.clip(out[blob] + delta, 0, 1)
|
||||
return out
|
||||
|
||||
|
||||
def test_locates_a_dark_spot():
|
||||
"""黑点应该落在框里,而且框不能大到没有意义。"""
|
||||
saliency = defect_saliency(_with_blob(_image()))
|
||||
boxes = cam_to_boxes(saliency, **BOX_KWARGS)
|
||||
assert len(boxes) == 1
|
||||
|
||||
x1, y1, x2, y2 = boxes[0]
|
||||
assert x1 <= 70 / 96 <= x2 and y1 <= 30 / 96 <= y2 # 覆盖缺陷中心
|
||||
assert (x2 - x1) * (y2 - y1) < 0.1 # 不到整图的 10%
|
||||
|
||||
|
||||
@pytest.mark.parametrize("noise", [0.005, 0.01, 0.03])
|
||||
def test_flat_image_gets_no_box(noise):
|
||||
"""只有噪声的正常图必须出 0 个框,噪声大小不同都要成立。
|
||||
|
||||
回归:若只按峰值归一化,噪声峰值会被拉到 1,满图假框;改成先判定有无缺陷。
|
||||
"""
|
||||
saliency = defect_saliency(_image(noise=noise, seed=1))
|
||||
assert not saliency.any()
|
||||
assert cam_to_boxes(saliency, **BOX_KWARGS) == []
|
||||
|
||||
|
||||
def test_illumination_gradient_is_not_a_defect():
|
||||
"""大尺度亮度不均(照明、暗角)属于背景,不该被当成缺陷。"""
|
||||
ramp = np.linspace(0.3, 0.7, 96, dtype=np.float32)
|
||||
gradient = np.repeat(ramp[None, :], 96, axis=0)
|
||||
assert cam_to_boxes(defect_saliency(gradient), **BOX_KWARGS) == []
|
||||
|
||||
# 同一张图上的黑点仍要找得到
|
||||
boxes = cam_to_boxes(defect_saliency(_with_blob(gradient)), **BOX_KWARGS)
|
||||
assert len(boxes) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"polarity,delta,expected",
|
||||
[("dark", -0.25, 1), ("dark", 0.25, 0), ("bright", 0.25, 1), ("bright", -0.25, 0),
|
||||
("both", -0.25, 1), ("both", 0.25, 1)],
|
||||
)
|
||||
def test_polarity_selects_dark_or_bright(polarity, delta, expected):
|
||||
image = _with_blob(_image(), delta=delta)
|
||||
saliency = defect_saliency(image, polarity=polarity)
|
||||
assert len(cam_to_boxes(saliency, **BOX_KWARGS)) == expected
|
||||
|
||||
|
||||
def test_rejects_unknown_polarity():
|
||||
with pytest.raises(ValueError, match="polarity"):
|
||||
defect_saliency(_image(), polarity="darker")
|
||||
|
||||
|
||||
def test_output_range_and_shape():
|
||||
saliency = defect_saliency(_with_blob(_image(size=64)))
|
||||
assert saliency.shape == (64, 64)
|
||||
assert saliency.min() >= 0.0 and saliency.max() <= 1.0
|
||||
|
||||
|
||||
def test_accepts_uint8_rgb_and_grayscale():
|
||||
"""uint8 / 浮点、单通道 / 三通道都要能进来,结果一致。"""
|
||||
gray = _with_blob(_image())
|
||||
as_uint8 = (gray * 255).astype(np.uint8)
|
||||
rgb = np.repeat(as_uint8[..., None], 3, axis=2)
|
||||
|
||||
from_float = cam_to_boxes(defect_saliency(gray), **BOX_KWARGS) # noqa: F841
|
||||
from_uint8 = cam_to_boxes(defect_saliency(as_uint8), **BOX_KWARGS)
|
||||
from_rgb = cam_to_boxes(defect_saliency(rgb), **BOX_KWARGS)
|
||||
assert from_uint8 == from_rgb
|
||||
assert np.allclose(from_float, from_uint8, atol=0.05)
|
||||
|
||||
|
||||
def test_to_gray_rejects_odd_shapes():
|
||||
with pytest.raises(ValueError, match="HxW"):
|
||||
to_gray(np.zeros((5,), dtype=np.float32))
|
||||
|
||||
|
||||
def test_default_radius_scales_with_the_image():
|
||||
"""background_radius=0 时按 min(高,宽)//8 取值。"""
|
||||
image = _with_blob(_image(size=96), radius=3)
|
||||
assert np.array_equal(
|
||||
defect_saliency(image), defect_saliency(image, background_radius=12)
|
||||
)
|
||||
|
||||
|
||||
def test_faint_defect_is_rejected_by_min_contrast():
|
||||
"""对比度低于下限的起伏不算缺陷;调低下限就能找出来。"""
|
||||
faint = _with_blob(_image(noise=0.005), delta=-0.04)
|
||||
assert cam_to_boxes(defect_saliency(faint), **BOX_KWARGS) == []
|
||||
assert len(cam_to_boxes(defect_saliency(faint, min_contrast=0.01), **BOX_KWARGS)) == 1
|
||||
|
||||
|
||||
def test_noisy_image_needs_more_contrast():
|
||||
"""噪声大的图自动要求更高对比度:同一个弱缺陷在干净图上能找到,在噪声图上不算数。"""
|
||||
weak = -0.05 # 实测比值:σ=0.002 时 109,σ=0.04 时 9.8
|
||||
assert len(cam_to_boxes(
|
||||
defect_saliency(_with_blob(_image(noise=0.002), delta=weak), min_contrast=0.01),
|
||||
**BOX_KWARGS,
|
||||
)) == 1
|
||||
assert cam_to_boxes(
|
||||
defect_saliency(_with_blob(_image(noise=0.04), delta=weak), min_contrast=0.01),
|
||||
**BOX_KWARGS,
|
||||
) == []
|
||||
|
||||
|
||||
def test_box_mean_matches_a_naive_average():
|
||||
rng = np.random.default_rng(3)
|
||||
array = rng.random((12, 15))
|
||||
radius = 2
|
||||
padded = np.pad(array, radius, mode="edge")
|
||||
expected = np.empty_like(array)
|
||||
for y in range(array.shape[0]):
|
||||
for x in range(array.shape[1]):
|
||||
expected[y, x] = padded[y:y + 2 * radius + 1, x:x + 2 * radius + 1].mean()
|
||||
assert np.allclose(box_mean(array, radius), expected)
|
||||
assert np.array_equal(box_mean(array, 0), array) # radius=0 原样返回
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user