diff --git a/CorridorKeyModule/core/color_utils.py b/CorridorKeyModule/core/color_utils.py index 4a3b17e0..a1c085c8 100644 --- a/CorridorKeyModule/core/color_utils.py +++ b/CorridorKeyModule/core/color_utils.py @@ -251,6 +251,11 @@ def clean_matte(alpha_np: np.ndarray, area_threshold: int = 300, dilation: int = """ Cleans up small disconnected components (like tracking markers) from a predicted alpha matte. alpha_np: Numpy array [H, W] or [H, W, 1] float (0.0 - 1.0) + + Note: this function is not idempotent at default settings. The dilation and Gaussian blur + post-processing expand the feathered edge of surviving regions on each call, so running + clean_matte twice on the same matte produces slightly different output. The connected- + components filter alone (dilation=0, blur_size=0) is idempotent. """ # Needs to be 2D is_3d = False diff --git a/tests/test_color_utils.py b/tests/test_color_utils.py index 10ea0aef..cc178c17 100644 --- a/tests/test_color_utils.py +++ b/tests/test_color_utils.py @@ -366,6 +366,42 @@ def test_3d_input_preserved(self): assert result.ndim == 3 assert result.shape[2] == 1 + def test_all_zero_matte_returns_zeros(self): + """An all-zero matte must return all zeros without crashing. + + The connected-components pass on an empty matte must not produce + spurious output. Source: README 'Auto-Cleanup' boundary condition. + """ + matte = np.zeros((100, 100), dtype=np.float32) + result = cu.clean_matte(matte, area_threshold=300) + np.testing.assert_array_equal(result, np.zeros_like(matte)) + + def test_all_opaque_matte_preserved(self): + """An all-opaque matte must be returned intact. + + The single large component exceeds any reasonable threshold — cleanup + must not zero it out. Source: README 'Auto-Cleanup' boundary condition. + """ + matte = np.ones((100, 100), dtype=np.float32) + result = cu.clean_matte(matte, area_threshold=300) + assert result.min() > 0.9 + + def test_idempotent(self): + """Running clean_matte twice produces the same result as running it once. + + Tested with dilation=0 and blur_size=0 to isolate the connected- + components logic — the pure topological filter is the stable core. + Dilation + blur are not idempotent by design (they expand surviving + blobs, so a second pass would further expand feathered edges). + Source: README 'Auto-Cleanup' boundary condition. + """ + matte = np.zeros((100, 100), dtype=np.float32) + matte[20:80, 20:80] = 1.0 # large blob kept + matte[5:8, 5:8] = 1.0 # small blob removed on first pass + first = cu.clean_matte(matte.copy(), area_threshold=300, dilation=0, blur_size=0) + second = cu.clean_matte(first.copy(), area_threshold=300, dilation=0, blur_size=0) + np.testing.assert_allclose(first, second, atol=1e-6) + # --------------------------------------------------------------------------- # create_checkerboard