|
1 | 1 | import numpy as np |
2 | 2 | import pytest |
3 | 3 | from scipy.ndimage import generate_binary_structure |
| 4 | +from scipy.ndimage import label as nd_label |
4 | 5 |
|
5 | 6 | from eitprocessing.roi import PixelMask |
6 | 7 | from eitprocessing.roi.roi_size_filter import FilterROIBySize |
7 | 8 |
|
8 | 9 |
|
9 | | -def make_pixel_mask(array: np.ndarray): |
10 | | - """Helper to wrap numpy array into PixelMask with NaNs for False.""" |
11 | | - return PixelMask(array, keep_zeros=True) |
12 | | - |
13 | | - |
14 | 10 | def test_basic_region_selection(): |
15 | 11 | arr = np.full((5, 5), np.nan) |
16 | 12 | arr[1:4, 1:4] = True |
17 | | - mask = make_pixel_mask(arr) |
18 | | - selector = FilterROIBySize(min_region_size=5, connectivity="4-connectivity") |
| 13 | + mask = PixelMask(arr, keep_zeros=True) |
| 14 | + selector = FilterROIBySize(min_region_size=5, connectivity="1-connectivity") |
19 | 15 | result = selector.apply(mask) |
20 | 16 | expected_mask = np.full(arr.shape, np.nan) |
21 | 17 | expected_mask[1:4, 1:4] = 1 |
22 | | - np.testing.assert_array_equal(result.mask, expected_mask) |
| 18 | + assert np.array_equal(result.mask, expected_mask, equal_nan=True) |
23 | 19 |
|
24 | 20 |
|
25 | 21 | def test_region_smaller_than_threshold_is_excluded(): |
26 | 22 | arr = np.full((5, 5), np.nan) |
27 | 23 | arr[0, 0:2] = True |
28 | 24 | arr[2:4, 2:5] = True |
29 | | - mask = make_pixel_mask(arr) |
30 | | - selector = FilterROIBySize(min_region_size=5, connectivity="4-connectivity") |
| 25 | + mask = PixelMask(arr, keep_zeros=True) |
| 26 | + selector = FilterROIBySize(min_region_size=5, connectivity="1-connectivity") |
31 | 27 | result = selector.apply(mask) |
32 | 28 | expected = np.zeros_like(arr, dtype=float) |
33 | 29 | expected[2:4, 2:5] = 1 |
34 | 30 | expected[expected == 0] = np.nan |
35 | | - np.testing.assert_array_equal(result.mask, expected) |
| 31 | + assert np.array_equal(result.mask, expected, equal_nan=True) |
36 | 32 |
|
37 | 33 |
|
38 | | -def test_no_regions_above_threshold_warns_and_returns_empty(): |
| 34 | +def test_no_regions_above_threshold_raises(): |
39 | 35 | arr = np.full((4, 4), np.nan) |
40 | 36 | arr[0, 0] = True |
41 | | - mask = make_pixel_mask(arr) |
42 | | - selector = FilterROIBySize(min_region_size=5, connectivity="4-connectivity") |
43 | | - with pytest.warns(UserWarning, match="No regions found above min_pixels threshold."): |
44 | | - result = selector.apply(mask) |
45 | | - assert isinstance(result, PixelMask) |
46 | | - assert np.all(np.isnan(result.mask)) |
| 37 | + mask = PixelMask(arr, keep_zeros=True) |
| 38 | + selector = FilterROIBySize(min_region_size=5, connectivity="1-connectivity") |
| 39 | + with pytest.raises(RuntimeError, match="No regions found above min_region_size threshold"): |
| 40 | + selector.apply(mask) |
47 | 41 |
|
48 | 42 |
|
49 | 43 | def test_custom_connectivity(): |
50 | 44 | arr = np.full((3, 3), np.nan) |
51 | 45 | arr[0, 0] = True |
52 | 46 | arr[1, 1] = True |
53 | | - mask = make_pixel_mask(arr) |
54 | | - # Default (4-connectivity) — should warn and return empty |
55 | | - selector_default = FilterROIBySize(min_region_size=2, connectivity="4-connectivity") |
56 | | - with pytest.warns(UserWarning, match="No regions found above min_pixels threshold."): |
57 | | - result_default = selector_default.apply(mask) |
58 | | - assert np.all(np.isnan(result_default.mask)) |
59 | | - # 8-connectivity — diagonal pixels connected |
60 | | - selector_diag = FilterROIBySize(min_region_size=2, connectivity="8-connectivity") |
| 47 | + mask = PixelMask(arr, keep_zeros=True) |
| 48 | + |
| 49 | + # Default (1-connectivity) — should raise |
| 50 | + selector_default = FilterROIBySize(min_region_size=2, connectivity="1-connectivity") |
| 51 | + with pytest.raises(RuntimeError, match="No regions found above min_region_size threshold"): |
| 52 | + selector_default.apply(mask) |
| 53 | + |
| 54 | + # 2-connectivity — diagonal pixels connected |
| 55 | + selector_diag = FilterROIBySize(min_region_size=2, connectivity="2-connectivity") |
61 | 56 | result_diag = selector_diag.apply(mask) |
62 | 57 | assert not np.all(np.isnan(result_diag.mask)) |
| 58 | + |
63 | 59 | # Custom structure |
64 | 60 | custom_structure = generate_binary_structure(2, 2) |
65 | 61 | selector_custom = FilterROIBySize(min_region_size=2, connectivity=custom_structure) |
66 | 62 | result_custom = selector_custom.apply(mask) |
67 | 63 | assert not np.all(np.isnan(result_custom.mask)) |
68 | 64 |
|
69 | 65 |
|
70 | | -def test_empty_mask_returns_empty(): |
| 66 | +def test_empty_mask_raises(): |
71 | 67 | arr = np.full((4, 4), np.nan) |
72 | | - mask = make_pixel_mask(arr) |
73 | | - selector = FilterROIBySize(min_region_size=1, connectivity="4-connectivity") |
74 | | - result = selector.apply(mask) |
75 | | - assert np.all(np.isnan(result.mask)) |
76 | | - |
77 | | - |
78 | | -def test_all_nan_mask_returns_empty(): |
79 | | - arr = np.full((4, 4), np.nan) |
80 | | - mask = PixelMask(arr) |
81 | | - selector = FilterROIBySize(min_region_size=1, connectivity="4-connectivity") |
82 | | - result = selector.apply(mask) |
83 | | - assert np.all(np.isnan(result.mask)) |
| 68 | + mask = PixelMask(arr, keep_zeros=True) |
| 69 | + selector = FilterROIBySize(min_region_size=1, connectivity="1-connectivity") |
| 70 | + with pytest.raises(RuntimeError, match="No regions found above min_region_size threshold"): |
| 71 | + selector.apply(mask) |
84 | 72 |
|
85 | 73 |
|
86 | 74 | def test_zeros_are_regions_nans_are_excluded(): |
87 | 75 | arr = np.array([[0, 1, np.nan], [0, 0.5, np.nan], [np.nan, np.nan, 0]], dtype=float) |
88 | | - mask = make_pixel_mask(arr) |
89 | | - selector = FilterROIBySize(min_region_size=3, connectivity="4-connectivity") |
| 76 | + mask = PixelMask(arr, keep_zeros=True) |
| 77 | + selector = FilterROIBySize(min_region_size=3, connectivity="1-connectivity") |
90 | 78 | result_mask = selector.apply(mask).mask |
91 | | - result_binary = ~np.isnan(result_mask) |
92 | | - expected_binary = np.zeros_like(arr, dtype=bool) |
93 | | - expected_binary[0:2, 0:2] = True |
94 | | - np.testing.assert_array_equal(result_binary, expected_binary) |
| 79 | + expected_mask = np.array([[1, 1, np.nan], [1, 1, np.nan], [np.nan, np.nan, np.nan]], dtype=float) |
| 80 | + assert np.array_equal(result_mask, expected_mask, equal_nan=True) |
95 | 81 |
|
96 | 82 |
|
97 | 83 | def test_multiple_large_regions_are_all_included(): |
98 | 84 | arr = np.full((6, 6), np.nan) |
99 | 85 | arr[0:2, 0:2] = True |
100 | 86 | arr[4:6, 4:6] = True |
101 | | - mask = make_pixel_mask(arr) |
102 | | - selector = FilterROIBySize(min_region_size=4, connectivity="4-connectivity") |
| 87 | + mask = PixelMask(arr, keep_zeros=True) |
| 88 | + selector = FilterROIBySize(min_region_size=4, connectivity="1-connectivity") |
103 | 89 | result = selector.apply(mask) |
104 | 90 | assert np.sum(~np.isnan(result.mask)) == 8 |
105 | 91 |
|
106 | 92 |
|
107 | 93 | def test_all_true_mask_returns_full_mask(): |
108 | 94 | arr = np.ones((3, 3), dtype=bool) |
109 | | - mask = make_pixel_mask(arr) |
110 | | - selector = FilterROIBySize(min_region_size=1, connectivity="4-connectivity") |
| 95 | + mask = PixelMask(arr, keep_zeros=True) |
| 96 | + selector = FilterROIBySize(min_region_size=1, connectivity="1-connectivity") |
111 | 97 | result = selector.apply(mask) |
112 | 98 | assert np.all(result.mask == 1) |
113 | 99 |
|
114 | 100 |
|
115 | 101 | def test_edge_connected_region(): |
| 102 | + """Test that a region around the edges of the mask is correctly identified. |
| 103 | +
|
| 104 | + This checks that all edge pixels (top, bottom, left, right borders) are included as a single connected region. |
| 105 | + """ |
116 | 106 | arr = np.full((5, 5), np.nan) |
117 | | - arr[0, :] = True |
118 | | - mask = make_pixel_mask(arr) |
119 | | - selector = FilterROIBySize(min_region_size=5, connectivity="4-connectivity") |
| 107 | + arr[0, :] = True # top edge |
| 108 | + arr[-1, :] = True # bottom edge |
| 109 | + arr[:, 0] = True # left edge |
| 110 | + arr[:, -1] = True # right edge |
| 111 | + mask = PixelMask(arr, keep_zeros=True) |
| 112 | + selector = FilterROIBySize(min_region_size=16, connectivity="1-connectivity") |
120 | 113 | result = selector.apply(mask) |
121 | | - assert np.sum(~np.isnan(result.mask)) == 5 |
| 114 | + # There are 16 edge pixels in a 5x5 array (corners counted only once) |
| 115 | + assert np.sum(~np.isnan(result.mask)) == 16 |
122 | 116 |
|
123 | 117 |
|
124 | 118 | def test_min_pixels_threshold_variation(): |
125 | 119 | arr = np.full((5, 5), np.nan) |
126 | 120 | arr[1:3, 1:3] = True |
127 | | - mask = make_pixel_mask(arr) |
128 | | - selector = FilterROIBySize(min_region_size=4, connectivity="4-connectivity") |
| 121 | + mask = PixelMask(arr, keep_zeros=True) |
| 122 | + selector = FilterROIBySize(min_region_size=4, connectivity="1-connectivity") |
129 | 123 | result = selector.apply(mask) |
130 | 124 | assert np.sum(~np.isnan(result.mask)) == 4 |
131 | | - selector2 = FilterROIBySize(min_region_size=5, connectivity="4-connectivity") |
132 | | - result2 = selector2.apply(mask) |
133 | | - assert np.all(np.isnan(result2.mask)) |
| 125 | + selector2 = FilterROIBySize(min_region_size=5, connectivity="1-connectivity") |
| 126 | + with pytest.raises(RuntimeError, match="No regions found above min_region_size threshold"): |
| 127 | + selector2.apply(mask) |
134 | 128 |
|
135 | 129 |
|
136 | 130 | def test_touching_regions_are_separated(): |
137 | 131 | arr = np.full((5, 5), np.nan) |
138 | 132 | arr[1:3, 1:3] = True |
139 | | - arr[3, 1:3] = True |
140 | | - mask = make_pixel_mask(arr) |
141 | | - selector = FilterROIBySize(min_region_size=2, connectivity="4-connectivity") |
| 133 | + arr[3:4, 3:5] = True |
| 134 | + mask = PixelMask(arr, keep_zeros=True) |
| 135 | + |
| 136 | + # Step 1: Check intermediate labeling for 1-connectivity |
| 137 | + binary_array = ~np.isnan(mask.mask) |
| 138 | + structure1 = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]]) # 1-connectivity |
| 139 | + labeled_array1, num_features1 = nd_label(binary_array, structure=structure1) |
| 140 | + assert num_features1 == 2, f"Expected 2 regions, got {num_features1}" |
| 141 | + sizes1 = [np.sum(labeled_array1 == i) for i in range(1, num_features1 + 1)] |
| 142 | + assert sorted(sizes1) == [2, 4], f"Unexpected sizes for 1-connectivity: {sizes1}" |
| 143 | + |
| 144 | + selector = FilterROIBySize(min_region_size=2, connectivity="1-connectivity") |
142 | 145 | result = selector.apply(mask) |
143 | | - assert np.sum(~np.isnan(result.mask)) == 6 |
144 | | - selector8 = FilterROIBySize(min_region_size=6, connectivity="8-connectivity") |
| 146 | + assert np.sum(~np.isnan(result.mask)) == 6 # total combined size |
| 147 | + |
| 148 | + # Step 2: Check intermediate labeling for 2-connectivity |
| 149 | + structure2 = np.ones((3, 3), dtype=int) # 2-connectivity |
| 150 | + labeled_array2, num_features2 = nd_label(binary_array, structure=structure2) |
| 151 | + assert num_features2 == 1, f"Expected 1 region for 2-connectivity, got {num_features2}" |
| 152 | + |
| 153 | + selector8 = FilterROIBySize(min_region_size=6, connectivity="2-connectivity") |
145 | 154 | result8 = selector8.apply(mask) |
146 | 155 | assert np.sum(~np.isnan(result8.mask)) == 6 |
147 | 156 |
|
148 | 157 |
|
149 | 158 | def test_structure_input_variants(): |
150 | | - """Test all possible structure inputs: None, string, array.""" |
| 159 | + """Test that selector.structure is the same for '1-connectivity' and the equivalent custom array.""" |
151 | 160 | arr = np.full((4, 4), np.nan) |
152 | 161 | arr[1:3, 1:3] = True |
153 | | - mask = make_pixel_mask(arr) |
154 | | - # None (should default to 4-connectivity) |
155 | | - selector_none = FilterROIBySize(min_region_size=4, connectivity=None) |
156 | | - result_none = selector_none.apply(mask) |
157 | | - assert np.sum(~np.isnan(result_none.mask)) == 4 |
| 162 | + mask = PixelMask(arr, keep_zeros=True) |
| 163 | + |
158 | 164 | # String |
159 | | - selector_str = FilterROIBySize(min_region_size=4, connectivity="4-connectivity") |
160 | | - result_str = selector_str.apply(mask) |
161 | | - assert np.sum(~np.isnan(result_str.mask)) == 4 |
| 165 | + selector_str = FilterROIBySize(min_region_size=4, connectivity="1-connectivity") |
162 | 166 | # Array |
163 | 167 | structure_arr = generate_binary_structure(2, 1) |
164 | 168 | selector_arr = FilterROIBySize(min_region_size=4, connectivity=structure_arr) |
| 169 | + |
| 170 | + # Check that the internal structure is the same |
| 171 | + assert np.array_equal(selector_str.connectivity, selector_arr.connectivity) |
| 172 | + |
| 173 | + # Also check that the results are the same |
| 174 | + result_str = selector_str.apply(mask) |
165 | 175 | result_arr = selector_arr.apply(mask) |
166 | | - assert np.sum(~np.isnan(result_arr.mask)) == 4 |
| 176 | + assert np.array_equal(result_str.mask, result_arr.mask, equal_nan=True) |
| 177 | + |
| 178 | + |
| 179 | +def test_invalid_connectivity_none_raises(): |
| 180 | + arr = np.full((4, 4), np.nan) |
| 181 | + arr[1:3, 1:3] = True |
| 182 | + with pytest.raises( |
| 183 | + ValueError, match="Unsupported connectivity type: <class 'NoneType'>. Must be a string or numpy array." |
| 184 | + ): |
| 185 | + FilterROIBySize(min_region_size=4, connectivity=None) |
| 186 | + |
| 187 | + |
| 188 | +def test_invalid_connectivity_string_raises(): |
| 189 | + arr = np.full((4, 4), np.nan) |
| 190 | + arr[1:3, 1:3] = True |
| 191 | + with pytest.raises(ValueError, match="Unsupported connectivity string: bad-connectivity."): |
| 192 | + FilterROIBySize(min_region_size=4, connectivity="bad-connectivity") |
0 commit comments