"""传统 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 原样返回