napari-tmidas 0.2.2__py3-none-any.whl → 0.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- napari_tmidas/__init__.py +35 -5
- napari_tmidas/_crop_anything.py +1520 -609
- napari_tmidas/_env_manager.py +76 -0
- napari_tmidas/_file_conversion.py +1646 -1131
- napari_tmidas/_file_selector.py +1455 -216
- napari_tmidas/_label_inspection.py +83 -8
- napari_tmidas/_processing_worker.py +309 -0
- napari_tmidas/_reader.py +6 -10
- napari_tmidas/_registry.py +2 -2
- napari_tmidas/_roi_colocalization.py +1221 -84
- napari_tmidas/_tests/test_crop_anything.py +123 -0
- napari_tmidas/_tests/test_env_manager.py +89 -0
- napari_tmidas/_tests/test_grid_view_overlay.py +193 -0
- napari_tmidas/_tests/test_init.py +98 -0
- napari_tmidas/_tests/test_intensity_label_filter.py +222 -0
- napari_tmidas/_tests/test_label_inspection.py +86 -0
- napari_tmidas/_tests/test_processing_basic.py +500 -0
- napari_tmidas/_tests/test_processing_worker.py +142 -0
- napari_tmidas/_tests/test_regionprops_analysis.py +547 -0
- napari_tmidas/_tests/test_registry.py +70 -2
- napari_tmidas/_tests/test_scipy_filters.py +168 -0
- napari_tmidas/_tests/test_skimage_filters.py +259 -0
- napari_tmidas/_tests/test_split_channels.py +217 -0
- napari_tmidas/_tests/test_spotiflow.py +87 -0
- napari_tmidas/_tests/test_tyx_display_fix.py +142 -0
- napari_tmidas/_tests/test_ui_utils.py +68 -0
- napari_tmidas/_tests/test_widget.py +30 -0
- napari_tmidas/_tests/test_windows_basic.py +66 -0
- napari_tmidas/_ui_utils.py +57 -0
- napari_tmidas/_version.py +16 -3
- napari_tmidas/_widget.py +41 -4
- napari_tmidas/processing_functions/basic.py +557 -20
- napari_tmidas/processing_functions/careamics_env_manager.py +72 -99
- napari_tmidas/processing_functions/cellpose_env_manager.py +415 -112
- napari_tmidas/processing_functions/cellpose_segmentation.py +132 -191
- napari_tmidas/processing_functions/colocalization.py +513 -56
- napari_tmidas/processing_functions/grid_view_overlay.py +703 -0
- napari_tmidas/processing_functions/intensity_label_filter.py +422 -0
- napari_tmidas/processing_functions/regionprops_analysis.py +1280 -0
- napari_tmidas/processing_functions/sam2_env_manager.py +53 -69
- napari_tmidas/processing_functions/sam2_mp4.py +274 -195
- napari_tmidas/processing_functions/scipy_filters.py +403 -8
- napari_tmidas/processing_functions/skimage_filters.py +424 -212
- napari_tmidas/processing_functions/spotiflow_detection.py +949 -0
- napari_tmidas/processing_functions/spotiflow_env_manager.py +591 -0
- napari_tmidas/processing_functions/timepoint_merger.py +334 -86
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.4.dist-info}/METADATA +70 -30
- napari_tmidas-0.2.4.dist-info/RECORD +63 -0
- napari_tmidas/_tests/__init__.py +0 -0
- napari_tmidas-0.2.2.dist-info/RECORD +0 -40
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.4.dist-info}/WHEEL +0 -0
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.4.dist-info}/entry_points.txt +0 -0
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
# src/napari_tmidas/_tests/test_processing_basic.py
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from napari_tmidas.processing_functions.basic import (
|
|
6
|
+
filter_label_by_id,
|
|
7
|
+
intersect_label_images,
|
|
8
|
+
invert_binary_labels,
|
|
9
|
+
keep_slice_range_by_area,
|
|
10
|
+
labels_to_binary,
|
|
11
|
+
mirror_labels,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestBasicProcessing:
|
|
16
|
+
def test_labels_to_binary(self):
|
|
17
|
+
"""Test converting labels to binary mask"""
|
|
18
|
+
# Create test label image
|
|
19
|
+
labels = np.array([[0, 1, 2], [1, 2, 0], [2, 0, 1]], dtype=np.uint32)
|
|
20
|
+
|
|
21
|
+
# Process
|
|
22
|
+
result = labels_to_binary(labels)
|
|
23
|
+
|
|
24
|
+
# Check result - now expects 255 instead of 1
|
|
25
|
+
expected = np.array(
|
|
26
|
+
[[0, 255, 255], [255, 255, 0], [255, 0, 255]], dtype=np.uint8
|
|
27
|
+
)
|
|
28
|
+
np.testing.assert_array_equal(result, expected)
|
|
29
|
+
assert result.dtype == np.uint8
|
|
30
|
+
|
|
31
|
+
def test_labels_to_binary_all_zeros(self):
|
|
32
|
+
"""Test with all zero labels"""
|
|
33
|
+
labels = np.zeros((3, 3), dtype=np.uint32)
|
|
34
|
+
result = labels_to_binary(labels)
|
|
35
|
+
np.testing.assert_array_equal(result, np.zeros((3, 3), dtype=np.uint8))
|
|
36
|
+
|
|
37
|
+
def test_labels_to_binary_all_nonzero(self):
|
|
38
|
+
"""Test with all non-zero labels"""
|
|
39
|
+
labels = np.ones((3, 3), dtype=np.uint32) * 5
|
|
40
|
+
result = labels_to_binary(labels)
|
|
41
|
+
np.testing.assert_array_equal(
|
|
42
|
+
result, np.ones((3, 3), dtype=np.uint8) * 255
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def test_labels_to_binary_empty_image(self):
|
|
46
|
+
"""Test with empty image"""
|
|
47
|
+
labels = np.zeros((0, 0), dtype=np.uint32)
|
|
48
|
+
result = labels_to_binary(labels)
|
|
49
|
+
assert result.shape == (0, 0)
|
|
50
|
+
assert result.dtype == np.uint8
|
|
51
|
+
|
|
52
|
+
def test_labels_to_binary_3d_image(self):
|
|
53
|
+
"""Test with 3D image"""
|
|
54
|
+
labels = np.array(
|
|
55
|
+
[[[0, 1], [1, 2]], [[2, 0], [1, 1]]], dtype=np.uint32
|
|
56
|
+
)
|
|
57
|
+
result = labels_to_binary(labels)
|
|
58
|
+
expected = np.array(
|
|
59
|
+
[[[0, 255], [255, 255]], [[255, 0], [255, 255]]], dtype=np.uint8
|
|
60
|
+
)
|
|
61
|
+
np.testing.assert_array_equal(result, expected)
|
|
62
|
+
|
|
63
|
+
def test_labels_to_binary_float_input(self):
|
|
64
|
+
"""Test with float input (should still work)"""
|
|
65
|
+
labels = np.array([[0.0, 1.5, 2.7]], dtype=np.float32)
|
|
66
|
+
result = labels_to_binary(labels)
|
|
67
|
+
expected = np.array([[0, 255, 255]], dtype=np.uint8)
|
|
68
|
+
np.testing.assert_array_equal(result, expected)
|
|
69
|
+
|
|
70
|
+
def test_invert_binary_labels_basic(self):
|
|
71
|
+
"""Test basic inversion of binary mask"""
|
|
72
|
+
# Create test binary image
|
|
73
|
+
binary = np.array([[0, 1, 1], [1, 0, 0], [1, 1, 0]], dtype=np.uint32)
|
|
74
|
+
|
|
75
|
+
# Process
|
|
76
|
+
result = invert_binary_labels(binary)
|
|
77
|
+
|
|
78
|
+
# Check result - zeros become 255, non-zeros become 0
|
|
79
|
+
expected = np.array(
|
|
80
|
+
[[255, 0, 0], [0, 255, 255], [0, 0, 255]], dtype=np.uint8
|
|
81
|
+
)
|
|
82
|
+
np.testing.assert_array_equal(result, expected)
|
|
83
|
+
assert result.dtype == np.uint8
|
|
84
|
+
|
|
85
|
+
def test_invert_binary_labels_all_zeros(self):
|
|
86
|
+
"""Test inversion with all zeros"""
|
|
87
|
+
binary = np.zeros((3, 3), dtype=np.uint32)
|
|
88
|
+
result = invert_binary_labels(binary)
|
|
89
|
+
# All zeros should become 255
|
|
90
|
+
np.testing.assert_array_equal(
|
|
91
|
+
result, np.ones((3, 3), dtype=np.uint8) * 255
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def test_invert_binary_labels_all_ones(self):
|
|
95
|
+
"""Test inversion with all ones"""
|
|
96
|
+
binary = np.ones((3, 3), dtype=np.uint32)
|
|
97
|
+
result = invert_binary_labels(binary)
|
|
98
|
+
# All ones should become zeros
|
|
99
|
+
np.testing.assert_array_equal(result, np.zeros((3, 3), dtype=np.uint8))
|
|
100
|
+
|
|
101
|
+
def test_invert_binary_labels_with_labels(self):
|
|
102
|
+
"""Test inversion with multi-label image"""
|
|
103
|
+
# Create label image with different values
|
|
104
|
+
labels = np.array([[0, 1, 2], [3, 0, 5], [7, 8, 0]], dtype=np.uint32)
|
|
105
|
+
|
|
106
|
+
# Process
|
|
107
|
+
result = invert_binary_labels(labels)
|
|
108
|
+
|
|
109
|
+
# Check result - zeros become 255, all non-zero values become 0
|
|
110
|
+
expected = np.array(
|
|
111
|
+
[[255, 0, 0], [0, 255, 0], [0, 0, 255]], dtype=np.uint8
|
|
112
|
+
)
|
|
113
|
+
np.testing.assert_array_equal(result, expected)
|
|
114
|
+
|
|
115
|
+
def test_invert_binary_labels_3d(self):
|
|
116
|
+
"""Test inversion with 3D image"""
|
|
117
|
+
binary = np.array(
|
|
118
|
+
[[[0, 1], [1, 0]], [[1, 0], [0, 1]]], dtype=np.uint32
|
|
119
|
+
)
|
|
120
|
+
result = invert_binary_labels(binary)
|
|
121
|
+
expected = np.array(
|
|
122
|
+
[[[255, 0], [0, 255]], [[0, 255], [255, 0]]], dtype=np.uint8
|
|
123
|
+
)
|
|
124
|
+
np.testing.assert_array_equal(result, expected)
|
|
125
|
+
|
|
126
|
+
def test_invert_binary_labels_empty(self):
|
|
127
|
+
"""Test with empty image"""
|
|
128
|
+
binary = np.zeros((0, 0), dtype=np.uint32)
|
|
129
|
+
result = invert_binary_labels(binary)
|
|
130
|
+
assert result.shape == (0, 0)
|
|
131
|
+
assert result.dtype == np.uint8
|
|
132
|
+
|
|
133
|
+
def test_filter_label_by_id_basic(self):
|
|
134
|
+
"""Test filtering to keep only one label ID"""
|
|
135
|
+
# Create test label image with multiple labels
|
|
136
|
+
labels = np.array([[0, 1, 2], [3, 1, 2], [1, 0, 3]], dtype=np.uint32)
|
|
137
|
+
|
|
138
|
+
# Keep only label 1
|
|
139
|
+
result = filter_label_by_id(labels, label_id=1)
|
|
140
|
+
|
|
141
|
+
# Check result - only label 1 should remain, others become 0
|
|
142
|
+
expected = np.array([[0, 1, 0], [0, 1, 0], [1, 0, 0]], dtype=np.uint32)
|
|
143
|
+
np.testing.assert_array_equal(result, expected)
|
|
144
|
+
assert result.dtype == labels.dtype
|
|
145
|
+
|
|
146
|
+
def test_filter_label_by_id_default_param(self):
|
|
147
|
+
"""Test filtering with default parameter (label_id=1)"""
|
|
148
|
+
labels = np.array([[0, 1, 2], [1, 2, 0], [2, 0, 1]], dtype=np.uint32)
|
|
149
|
+
result = filter_label_by_id(labels)
|
|
150
|
+
expected = np.array([[0, 1, 0], [1, 0, 0], [0, 0, 1]], dtype=np.uint32)
|
|
151
|
+
np.testing.assert_array_equal(result, expected)
|
|
152
|
+
|
|
153
|
+
def test_filter_label_by_id_nonexistent(self):
|
|
154
|
+
"""Test filtering with label ID that doesn't exist"""
|
|
155
|
+
labels = np.array([[1, 2, 3], [2, 3, 1], [3, 1, 2]], dtype=np.uint32)
|
|
156
|
+
# Try to keep label 99 which doesn't exist
|
|
157
|
+
result = filter_label_by_id(labels, label_id=99)
|
|
158
|
+
# All should become background
|
|
159
|
+
expected = np.zeros_like(labels)
|
|
160
|
+
np.testing.assert_array_equal(result, expected)
|
|
161
|
+
|
|
162
|
+
def test_filter_label_by_id_3d(self):
|
|
163
|
+
"""Test filtering with 3D label image"""
|
|
164
|
+
labels = np.array(
|
|
165
|
+
[[[1, 2], [3, 1]], [[2, 1], [1, 3]]], dtype=np.uint32
|
|
166
|
+
)
|
|
167
|
+
result = filter_label_by_id(labels, label_id=2)
|
|
168
|
+
expected = np.array(
|
|
169
|
+
[[[0, 2], [0, 0]], [[2, 0], [0, 0]]], dtype=np.uint32
|
|
170
|
+
)
|
|
171
|
+
np.testing.assert_array_equal(result, expected)
|
|
172
|
+
|
|
173
|
+
def test_filter_label_by_id_all_same(self):
|
|
174
|
+
"""Test filtering when all pixels are the target label"""
|
|
175
|
+
labels = np.ones((3, 3), dtype=np.uint32) * 5
|
|
176
|
+
result = filter_label_by_id(labels, label_id=5)
|
|
177
|
+
# All should remain
|
|
178
|
+
np.testing.assert_array_equal(result, labels)
|
|
179
|
+
|
|
180
|
+
def test_filter_label_by_id_all_background(self):
|
|
181
|
+
"""Test filtering with all background"""
|
|
182
|
+
labels = np.zeros((3, 3), dtype=np.uint32)
|
|
183
|
+
result = filter_label_by_id(labels, label_id=1)
|
|
184
|
+
# Should remain all zeros
|
|
185
|
+
np.testing.assert_array_equal(result, labels)
|
|
186
|
+
|
|
187
|
+
def test_mirror_labels_double_size_default_axis(self):
|
|
188
|
+
"""Mirroring keeps the same shape and mirrors around largest area slice"""
|
|
189
|
+
image = np.zeros((4, 2, 2), dtype=np.uint16)
|
|
190
|
+
image[0, 0, 0] = 5 # slice 0 has 1 pixel
|
|
191
|
+
image[1, :, :] = 3 # slice 1 has 4 pixels (largest area)
|
|
192
|
+
|
|
193
|
+
result = mirror_labels(image)
|
|
194
|
+
|
|
195
|
+
# Shape should remain the same
|
|
196
|
+
assert result.shape == (4, 2, 2)
|
|
197
|
+
# Mirror around slice 1 (largest area)
|
|
198
|
+
# slice 0 gets from slice 2 (2*1 - 0 = 2), which is empty
|
|
199
|
+
# slice 1 gets from slice 1 (2*1 - 1 = 1), which has value 3
|
|
200
|
+
# slice 2 gets from slice 0 (2*1 - 2 = 0), which has value 5 at [0,0]
|
|
201
|
+
# slice 3 gets from slice -1 (2*1 - 3 = -1, out of bounds)
|
|
202
|
+
expected = np.zeros((4, 2, 2), dtype=np.uint16)
|
|
203
|
+
expected[0] = 0 # mirrored from empty slice 2
|
|
204
|
+
expected[1] = 3 + 5 # mirrored from slice 1 (value 3) with offset
|
|
205
|
+
expected[2, 0, 0] = (
|
|
206
|
+
5 + 5
|
|
207
|
+
) # mirrored from slice 0 (value 5 at [0,0]) with offset
|
|
208
|
+
expected[3] = 0 # out of bounds
|
|
209
|
+
np.testing.assert_array_equal(result, expected)
|
|
210
|
+
assert result.dtype == image.dtype
|
|
211
|
+
|
|
212
|
+
def test_mirror_labels_other_axis(self):
|
|
213
|
+
"""Mirroring along a non-zero axis keeps shape and mirrors around largest area"""
|
|
214
|
+
image = np.zeros((1, 4, 4), dtype=np.int32)
|
|
215
|
+
image[0, 0, :] = 1 # slice 0: 4 pixels
|
|
216
|
+
image[0, 1, :] = (
|
|
217
|
+
2 # slice 1: 4 pixels (will be selected as max_area_idx)
|
|
218
|
+
)
|
|
219
|
+
image[0, 2, 0] = 3 # slice 2: 1 pixel
|
|
220
|
+
image[0, 3, 0] = 4 # slice 3: 1 pixel
|
|
221
|
+
|
|
222
|
+
result = mirror_labels(image, axis=1)
|
|
223
|
+
|
|
224
|
+
# Shape should remain the same
|
|
225
|
+
assert result.shape == (1, 4, 4)
|
|
226
|
+
# Mirror around slice 0 (first slice with max area)
|
|
227
|
+
# slice 0 gets from slice 0 (2*0 - 0 = 0), which has value 1
|
|
228
|
+
# slice 1 gets from slice -1 (2*0 - 1 = -1, out of bounds)
|
|
229
|
+
# slice 2 gets from slice -2 (2*0 - 2 = -2, out of bounds)
|
|
230
|
+
# slice 3 gets from slice -3 (2*0 - 3 = -3, out of bounds)
|
|
231
|
+
expected = np.zeros((1, 4, 4), dtype=np.int32)
|
|
232
|
+
expected[0, 0, :] = (
|
|
233
|
+
1 + 4
|
|
234
|
+
) # mirrored from slice 0 (value 1) with offset
|
|
235
|
+
expected[0, 1:, :] = 0 # out of bounds
|
|
236
|
+
np.testing.assert_array_equal(result, expected)
|
|
237
|
+
|
|
238
|
+
def test_mirror_labels_prefers_larger_end(self):
|
|
239
|
+
"""Mirrors around the slice with the largest area"""
|
|
240
|
+
image = np.zeros((4, 3, 3), dtype=np.uint8)
|
|
241
|
+
image[0, :2, :2] = 1 # slice 0: 4 pixels (largest area)
|
|
242
|
+
image[3, 0, 0] = 1 # slice 3: 1 pixel
|
|
243
|
+
|
|
244
|
+
result = mirror_labels(image)
|
|
245
|
+
|
|
246
|
+
# Shape should remain the same
|
|
247
|
+
assert result.shape == (4, 3, 3)
|
|
248
|
+
# Mirror around slice 0 (largest area)
|
|
249
|
+
# slice 0 mirrors slice 0 (2*0 - 0 = 0)
|
|
250
|
+
# slice 1 mirrors slice -1 (2*0 - 1 = -1, out of bounds)
|
|
251
|
+
# slice 2 mirrors slice -2 (2*0 - 2 = -2, out of bounds)
|
|
252
|
+
# slice 3 mirrors slice -3 (2*0 - 3 = -3, out of bounds)
|
|
253
|
+
expected = np.zeros((4, 3, 3), dtype=np.uint8)
|
|
254
|
+
expected[0, :2, :2] = 1 + 1 # mirrored from slice 0 itself
|
|
255
|
+
expected[1:] = 0 # out of bounds
|
|
256
|
+
np.testing.assert_array_equal(result, expected)
|
|
257
|
+
|
|
258
|
+
def test_mirror_labels_uniform(self):
|
|
259
|
+
"""Mirroring uniform labels creates offset mirrored labels"""
|
|
260
|
+
image = np.ones((3, 3, 3), dtype=np.uint8)
|
|
261
|
+
|
|
262
|
+
result = mirror_labels(image)
|
|
263
|
+
|
|
264
|
+
# Shape should remain the same
|
|
265
|
+
assert result.shape == (3, 3, 3)
|
|
266
|
+
# All slices have equal area (9 pixels), so slice 0 is chosen
|
|
267
|
+
# Mirror around slice 0 (first slice with max area)
|
|
268
|
+
# slice 0 mirrors slice 0 (2*0 - 0 = 0)
|
|
269
|
+
# slice 1 mirrors slice -1 (2*0 - 1 = -1, out of bounds)
|
|
270
|
+
# slice 2 mirrors slice -2 (2*0 - 2 = -2, out of bounds)
|
|
271
|
+
expected = np.zeros((3, 3, 3), dtype=np.uint8)
|
|
272
|
+
expected[0] = 2 # mirrored from slice 0 (1 + 1)
|
|
273
|
+
expected[1:] = 0 # out of bounds
|
|
274
|
+
np.testing.assert_array_equal(result, expected)
|
|
275
|
+
|
|
276
|
+
def test_mirror_labels_invalid_axis(self):
|
|
277
|
+
"""Invalid axis should raise an error"""
|
|
278
|
+
image = np.zeros((3, 3), dtype=np.uint8)
|
|
279
|
+
|
|
280
|
+
with pytest.raises(ValueError):
|
|
281
|
+
mirror_labels(image, axis=2)
|
|
282
|
+
|
|
283
|
+
def test_keep_slice_range_by_area_basic(self):
|
|
284
|
+
"""Keep label content between minimum and maximum area, preserving shape"""
|
|
285
|
+
volume = np.zeros((5, 4, 4), dtype=np.int32)
|
|
286
|
+
volume[0, 0, 0] = 1 # area 1 (min)
|
|
287
|
+
volume[1, :2, :2] = 1 # area 4
|
|
288
|
+
volume[2, :3, :3] = 1 # area 9 (max)
|
|
289
|
+
volume[3, :1, :3] = 1 # area 3
|
|
290
|
+
volume[4, :2, :1] = 1 # area 2
|
|
291
|
+
|
|
292
|
+
result = keep_slice_range_by_area(volume)
|
|
293
|
+
|
|
294
|
+
# Shape should be preserved
|
|
295
|
+
assert result.shape == (5, 4, 4)
|
|
296
|
+
# Content between min (slice 0) and max (slice 2) should be kept
|
|
297
|
+
np.testing.assert_array_equal(result[0:3], volume[0:3])
|
|
298
|
+
# Content after max should be zeroed
|
|
299
|
+
np.testing.assert_array_equal(result[3:], np.zeros((2, 4, 4)))
|
|
300
|
+
|
|
301
|
+
def test_keep_slice_range_by_area_with_axis(self):
|
|
302
|
+
"""Axis parameter allows zeroing content along any dimension while preserving shape"""
|
|
303
|
+
# Create volume with different areas along axis 1
|
|
304
|
+
volume = np.zeros((4, 5, 3), dtype=np.uint16)
|
|
305
|
+
volume[:2, 0, :2] = 1 # slice 0: area = 2*2 = 4
|
|
306
|
+
volume[:, 1, :] = 1 # slice 1: area = 4*3 = 12 (max)
|
|
307
|
+
volume[:3, 2, :] = 1 # slice 2: area = 3*3 = 9
|
|
308
|
+
volume[:2, 3, :2] = 1 # slice 3: area = 2*2 = 4
|
|
309
|
+
volume[0, 4, 0] = 1 # slice 4: area = 1 (min)
|
|
310
|
+
|
|
311
|
+
result = keep_slice_range_by_area(volume, axis=1)
|
|
312
|
+
|
|
313
|
+
# Shape should be preserved
|
|
314
|
+
assert result.shape == volume.shape
|
|
315
|
+
# Min area is at slice 4, max area is at slice 1, so range is 1-4 (inclusive)
|
|
316
|
+
# Slice 0 should be zeroed (before the range)
|
|
317
|
+
np.testing.assert_array_equal(
|
|
318
|
+
result[:, 0, :], np.zeros((4, 3), dtype=np.uint16)
|
|
319
|
+
)
|
|
320
|
+
# Slices 1-4 should be kept
|
|
321
|
+
np.testing.assert_array_equal(result[:, 1:5, :], volume[:, 1:5, :])
|
|
322
|
+
|
|
323
|
+
def test_keep_slice_range_by_area_uniform(self):
|
|
324
|
+
"""Uniform area returns the original volume"""
|
|
325
|
+
volume = np.ones((3, 4, 4), dtype=np.uint8)
|
|
326
|
+
|
|
327
|
+
result = keep_slice_range_by_area(volume)
|
|
328
|
+
|
|
329
|
+
np.testing.assert_array_equal(result, volume)
|
|
330
|
+
|
|
331
|
+
def test_keep_slice_range_by_area_shape_preserved(self):
|
|
332
|
+
"""Verify that output shape matches input shape (critical for image-label alignment)"""
|
|
333
|
+
# Simulate a label volume with 100 z-slices where labels exist in slices 20-80
|
|
334
|
+
volume = np.zeros((100, 50, 50), dtype=np.uint32)
|
|
335
|
+
volume[20, :10, :10] = 1 # Sparse content at slice 20 (min area)
|
|
336
|
+
for i in range(21, 80):
|
|
337
|
+
volume[i, :30, :30] = i # Denser content in middle slices
|
|
338
|
+
volume[79, :, :] = 100 # Maximum content at slice 79 (max area)
|
|
339
|
+
# Slices 0-19 and 80-99 should be empty and get zeroed
|
|
340
|
+
|
|
341
|
+
result = keep_slice_range_by_area(volume, axis=0)
|
|
342
|
+
|
|
343
|
+
# Critical: shape must be preserved to maintain alignment with image data
|
|
344
|
+
assert result.shape == (
|
|
345
|
+
100,
|
|
346
|
+
50,
|
|
347
|
+
50,
|
|
348
|
+
), "Output shape must match input shape"
|
|
349
|
+
|
|
350
|
+
# Slices before min (0-19) should be zeroed
|
|
351
|
+
assert np.all(
|
|
352
|
+
result[:20] == 0
|
|
353
|
+
), "Slices before min-area slice should be zeroed"
|
|
354
|
+
|
|
355
|
+
# Slices between min and max (20-79) should be preserved
|
|
356
|
+
np.testing.assert_array_equal(
|
|
357
|
+
result[20:80],
|
|
358
|
+
volume[20:80],
|
|
359
|
+
err_msg="Label content in range should be preserved",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Slices after max (80-99) should be zeroed
|
|
363
|
+
assert np.all(
|
|
364
|
+
result[80:] == 0
|
|
365
|
+
), "Slices after max-area slice should be zeroed"
|
|
366
|
+
|
|
367
|
+
def test_keep_slice_range_by_area_invalid_dims(self):
|
|
368
|
+
"""At least 3 dimensions are required"""
|
|
369
|
+
image = np.ones((4, 4), dtype=np.uint8)
|
|
370
|
+
|
|
371
|
+
with pytest.raises(ValueError):
|
|
372
|
+
keep_slice_range_by_area(image)
|
|
373
|
+
|
|
374
|
+
def test_intersect_label_images_basic(self, tmp_path):
|
|
375
|
+
"""Primary file intersects with its paired secondary"""
|
|
376
|
+
label_a = np.array([[0, 5], [2, 0]], dtype=np.uint8)
|
|
377
|
+
label_b = np.array([[1, 5], [0, 0]], dtype=np.uint8)
|
|
378
|
+
|
|
379
|
+
primary_path = tmp_path / "sample_a.npy"
|
|
380
|
+
secondary_path = tmp_path / "sample_b.npy"
|
|
381
|
+
np.save(primary_path, label_a)
|
|
382
|
+
np.save(secondary_path, label_b)
|
|
383
|
+
|
|
384
|
+
def call_primary() -> np.ndarray:
|
|
385
|
+
filepath = str(primary_path)
|
|
386
|
+
assert filepath
|
|
387
|
+
return intersect_label_images(
|
|
388
|
+
label_a,
|
|
389
|
+
primary_suffix="_a.npy",
|
|
390
|
+
secondary_suffix="_b.npy",
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
result = call_primary()
|
|
394
|
+
expected = np.array([[0, 5], [0, 0]], dtype=np.uint8)
|
|
395
|
+
np.testing.assert_array_equal(result, expected)
|
|
396
|
+
|
|
397
|
+
def call_secondary() -> np.ndarray:
|
|
398
|
+
filepath = str(secondary_path)
|
|
399
|
+
assert filepath
|
|
400
|
+
return intersect_label_images(
|
|
401
|
+
label_b,
|
|
402
|
+
primary_suffix="_a.npy",
|
|
403
|
+
secondary_suffix="_b.npy",
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
with pytest.warns(UserWarning, match="Skipping secondary label image"):
|
|
407
|
+
secondary_result = call_secondary()
|
|
408
|
+
assert secondary_result is None
|
|
409
|
+
|
|
410
|
+
def test_intersect_label_images_retains_primary_labels(self, tmp_path):
|
|
411
|
+
label_a = np.zeros((4, 4), dtype=np.uint8)
|
|
412
|
+
label_b = np.zeros((4, 4), dtype=np.uint8)
|
|
413
|
+
label_a[1:3, 1:3] = 1
|
|
414
|
+
label_b[1:2, 1:3] = 2
|
|
415
|
+
label_b[2:3, 1:3] = 3
|
|
416
|
+
|
|
417
|
+
primary_path = tmp_path / "detail_a.npy"
|
|
418
|
+
secondary_path = tmp_path / "detail_b.npy"
|
|
419
|
+
np.save(primary_path, label_a)
|
|
420
|
+
np.save(secondary_path, label_b)
|
|
421
|
+
|
|
422
|
+
def call_primary():
|
|
423
|
+
filepath = str(primary_path)
|
|
424
|
+
assert filepath
|
|
425
|
+
return intersect_label_images(
|
|
426
|
+
label_a,
|
|
427
|
+
primary_suffix="_a.npy",
|
|
428
|
+
secondary_suffix="_b.npy",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
result = call_primary()
|
|
432
|
+
expected = np.zeros_like(label_a)
|
|
433
|
+
expected[1:3, 1:3] = 1
|
|
434
|
+
np.testing.assert_array_equal(result, expected)
|
|
435
|
+
|
|
436
|
+
def test_intersect_label_images_preserve_primary_detail(self, tmp_path):
|
|
437
|
+
label_a = np.zeros((4, 4), dtype=np.uint8)
|
|
438
|
+
label_b = np.zeros((4, 4), dtype=np.uint8)
|
|
439
|
+
label_a[1:2, 1:3] = 4
|
|
440
|
+
label_a[2:3, 1:3] = 5
|
|
441
|
+
label_b[1:3, 1:3] = 7
|
|
442
|
+
|
|
443
|
+
primary_path = tmp_path / "detail_a.npy"
|
|
444
|
+
secondary_path = tmp_path / "detail_b.npy"
|
|
445
|
+
np.save(primary_path, label_a)
|
|
446
|
+
np.save(secondary_path, label_b)
|
|
447
|
+
|
|
448
|
+
def call_primary():
|
|
449
|
+
filepath = str(primary_path)
|
|
450
|
+
assert filepath
|
|
451
|
+
return intersect_label_images(
|
|
452
|
+
label_a,
|
|
453
|
+
primary_suffix="_a.npy",
|
|
454
|
+
secondary_suffix="_b.npy",
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
result = call_primary()
|
|
458
|
+
expected = np.zeros_like(label_a)
|
|
459
|
+
expected[1:2, 1:3] = 4
|
|
460
|
+
expected[2:3, 1:3] = 5
|
|
461
|
+
np.testing.assert_array_equal(result, expected)
|
|
462
|
+
|
|
463
|
+
def test_intersect_label_images_missing_pair(self, tmp_path):
|
|
464
|
+
label_a = np.ones((2, 2), dtype=np.uint16)
|
|
465
|
+
primary_path = tmp_path / "orphan_a.npy"
|
|
466
|
+
np.save(primary_path, label_a)
|
|
467
|
+
|
|
468
|
+
def call_primary():
|
|
469
|
+
filepath = str(primary_path)
|
|
470
|
+
assert filepath
|
|
471
|
+
return intersect_label_images(
|
|
472
|
+
label_a,
|
|
473
|
+
primary_suffix="_a.npy",
|
|
474
|
+
secondary_suffix="_b.npy",
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
with pytest.raises(FileNotFoundError):
|
|
478
|
+
call_primary()
|
|
479
|
+
|
|
480
|
+
def test_intersect_label_images_shape_mismatch(self, tmp_path):
|
|
481
|
+
label_a = np.ones((2, 2), dtype=np.uint16)
|
|
482
|
+
label_b = np.ones((3, 3), dtype=np.uint16)
|
|
483
|
+
|
|
484
|
+
primary_path = tmp_path / "sample_a.npy"
|
|
485
|
+
secondary_path = tmp_path / "sample_b.npy"
|
|
486
|
+
np.save(primary_path, label_a)
|
|
487
|
+
np.save(secondary_path, label_b)
|
|
488
|
+
|
|
489
|
+
def call_primary():
|
|
490
|
+
filepath = str(primary_path)
|
|
491
|
+
assert filepath
|
|
492
|
+
return intersect_label_images(
|
|
493
|
+
label_a,
|
|
494
|
+
primary_suffix="_a.npy",
|
|
495
|
+
secondary_suffix="_b.npy",
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
result = call_primary()
|
|
499
|
+
expected = np.ones_like(label_a)
|
|
500
|
+
np.testing.assert_array_equal(result, expected)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# src/napari_tmidas/_tests/test_processing_worker.py
|
|
2
|
+
import tempfile
|
|
3
|
+
from unittest.mock import Mock, patch
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from napari_tmidas._processing_worker import ProcessingWorker
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestProcessingWorker:
|
|
11
|
+
def setup_method(self):
|
|
12
|
+
"""Setup test environment"""
|
|
13
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
14
|
+
|
|
15
|
+
def teardown_method(self):
|
|
16
|
+
"""Cleanup"""
|
|
17
|
+
import shutil
|
|
18
|
+
|
|
19
|
+
shutil.rmtree(self.temp_dir)
|
|
20
|
+
|
|
21
|
+
def test_worker_initialization(self):
|
|
22
|
+
"""Test ProcessingWorker initialization"""
|
|
23
|
+
file_list = ["/path/to/file1.tif", "/path/to/file2.tif"]
|
|
24
|
+
processing_func = Mock()
|
|
25
|
+
param_values = {"param1": "value1"}
|
|
26
|
+
output_folder = "/output"
|
|
27
|
+
input_suffix = ".tif"
|
|
28
|
+
output_suffix = "_processed.tif"
|
|
29
|
+
|
|
30
|
+
worker = ProcessingWorker(
|
|
31
|
+
file_list,
|
|
32
|
+
processing_func,
|
|
33
|
+
param_values,
|
|
34
|
+
output_folder,
|
|
35
|
+
input_suffix,
|
|
36
|
+
output_suffix,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
assert worker.file_list == file_list
|
|
40
|
+
assert worker.processing_func == processing_func
|
|
41
|
+
assert worker.param_values == param_values
|
|
42
|
+
assert worker.output_folder == output_folder
|
|
43
|
+
assert worker.input_suffix == input_suffix
|
|
44
|
+
assert worker.output_suffix == output_suffix
|
|
45
|
+
assert not worker.stop_requested
|
|
46
|
+
assert worker.thread_count >= 1
|
|
47
|
+
|
|
48
|
+
def test_worker_stop(self):
|
|
49
|
+
"""Test stopping the worker"""
|
|
50
|
+
worker = ProcessingWorker([], Mock(), {}, "", "", "")
|
|
51
|
+
assert not worker.stop_requested
|
|
52
|
+
worker.stop()
|
|
53
|
+
assert worker.stop_requested
|
|
54
|
+
|
|
55
|
+
@patch(
|
|
56
|
+
"napari_tmidas._processing_worker.concurrent.futures.ThreadPoolExecutor"
|
|
57
|
+
)
|
|
58
|
+
@patch("napari_tmidas._processing_worker.load_image_file")
|
|
59
|
+
def test_process_file_single_output(self, mock_load, mock_executor):
|
|
60
|
+
"""Test processing a file with single output"""
|
|
61
|
+
# Mock the executor and future
|
|
62
|
+
mock_future = Mock()
|
|
63
|
+
mock_future.result.return_value = np.random.rand(100, 100)
|
|
64
|
+
mock_executor.return_value.__enter__.return_value.submit.return_value = (
|
|
65
|
+
mock_future
|
|
66
|
+
)
|
|
67
|
+
mock_executor.return_value.__enter__.return_value.as_completed.return_value = [
|
|
68
|
+
mock_future
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
# Mock image loading
|
|
72
|
+
mock_load.return_value = np.random.rand(100, 100)
|
|
73
|
+
|
|
74
|
+
# Create worker
|
|
75
|
+
worker = ProcessingWorker(
|
|
76
|
+
["/test/file.tif"],
|
|
77
|
+
Mock(return_value=np.random.rand(100, 100)),
|
|
78
|
+
{},
|
|
79
|
+
self.temp_dir,
|
|
80
|
+
".tif",
|
|
81
|
+
"_processed.tif",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Mock the run method to avoid threading issues
|
|
85
|
+
worker.run = Mock()
|
|
86
|
+
|
|
87
|
+
# Test process_file method
|
|
88
|
+
result = worker.process_file("/test/file.tif")
|
|
89
|
+
|
|
90
|
+
assert result is not None
|
|
91
|
+
assert "original_file" in result
|
|
92
|
+
assert "processed_file" in result
|
|
93
|
+
|
|
94
|
+
@patch("napari_tmidas._processing_worker.load_image_file")
|
|
95
|
+
def test_process_file_multiple_outputs(self, mock_load):
|
|
96
|
+
"""Test processing a file with multiple outputs"""
|
|
97
|
+
# Mock image loading
|
|
98
|
+
mock_load.return_value = np.random.rand(100, 100)
|
|
99
|
+
|
|
100
|
+
# Create worker with function that returns multiple outputs
|
|
101
|
+
def multi_output_func(image):
|
|
102
|
+
return [image, image * 2, image * 3]
|
|
103
|
+
|
|
104
|
+
worker = ProcessingWorker(
|
|
105
|
+
["/test/file.tif"],
|
|
106
|
+
multi_output_func,
|
|
107
|
+
{},
|
|
108
|
+
self.temp_dir,
|
|
109
|
+
".tif",
|
|
110
|
+
"_processed.tif",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
result = worker.process_file("/test/file.tif")
|
|
114
|
+
|
|
115
|
+
assert result is not None
|
|
116
|
+
assert "original_file" in result
|
|
117
|
+
assert "processed_files" in result
|
|
118
|
+
assert len(result["processed_files"]) == 3
|
|
119
|
+
|
|
120
|
+
@patch("napari_tmidas._processing_worker.load_image_file")
|
|
121
|
+
def test_process_file_folder_function(self, mock_load):
|
|
122
|
+
"""Test processing with folder function that returns None"""
|
|
123
|
+
# Mock image loading
|
|
124
|
+
mock_load.return_value = np.random.rand(100, 100)
|
|
125
|
+
|
|
126
|
+
# Create worker with folder function
|
|
127
|
+
def folder_func(image):
|
|
128
|
+
return None # Folder functions don't return processed images
|
|
129
|
+
|
|
130
|
+
worker = ProcessingWorker(
|
|
131
|
+
["/test/file.tif"],
|
|
132
|
+
folder_func,
|
|
133
|
+
{},
|
|
134
|
+
self.temp_dir,
|
|
135
|
+
".tif",
|
|
136
|
+
"_processed.tif",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
result = worker.process_file("/test/file.tif")
|
|
140
|
+
|
|
141
|
+
assert result is not None
|
|
142
|
+
assert result["processed_file"] is None
|