napari-tmidas 0.2.1__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 +1458 -499
- napari_tmidas/_env_manager.py +76 -0
- napari_tmidas/_file_conversion.py +1646 -1131
- napari_tmidas/_file_selector.py +1464 -223
- napari_tmidas/_label_inspection.py +83 -8
- napari_tmidas/_processing_worker.py +309 -0
- napari_tmidas/_reader.py +6 -10
- napari_tmidas/_registry.py +15 -14
- 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_file_selector.py +90 -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 +135 -0
- 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/processing_functions/trackastra_tracking.py +24 -5
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/METADATA +92 -39
- napari_tmidas-0.2.4.dist-info/RECORD +63 -0
- napari_tmidas/_tests/__init__.py +0 -0
- napari_tmidas-0.2.1.dist-info/RECORD +0 -38
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/WHEEL +0 -0
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/entry_points.txt +0 -0
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for regionprops_analysis processing function
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from napari_tmidas.processing_functions.regionprops_analysis import (
|
|
14
|
+
analyze_folder_regionprops,
|
|
15
|
+
extract_regionprops_folder,
|
|
16
|
+
extract_regionprops_recursive,
|
|
17
|
+
find_label_images,
|
|
18
|
+
load_label_image,
|
|
19
|
+
parse_dimensions_from_shape,
|
|
20
|
+
reset_regionprops_cache,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def temp_label_folder():
|
|
26
|
+
"""Create a temporary folder with test label images."""
|
|
27
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
28
|
+
folder = Path(tmpdir) / "labels"
|
|
29
|
+
folder.mkdir()
|
|
30
|
+
|
|
31
|
+
# Create 2D label image
|
|
32
|
+
label_2d = np.zeros((100, 100), dtype=np.uint16)
|
|
33
|
+
label_2d[20:40, 20:40] = 1
|
|
34
|
+
label_2d[60:80, 60:80] = 2
|
|
35
|
+
np.save(folder / "image_2d.npy", label_2d)
|
|
36
|
+
|
|
37
|
+
# Create 3D label image (ZYX)
|
|
38
|
+
label_3d = np.zeros((10, 100, 100), dtype=np.uint16)
|
|
39
|
+
label_3d[2:5, 20:40, 20:40] = 1
|
|
40
|
+
label_3d[5:8, 60:80, 60:80] = 2
|
|
41
|
+
np.save(folder / "image_3d.npy", label_3d)
|
|
42
|
+
|
|
43
|
+
# Create 4D label image (TZYX) - 3 timepoints
|
|
44
|
+
label_4d = np.zeros((3, 10, 100, 100), dtype=np.uint16)
|
|
45
|
+
for t in range(3):
|
|
46
|
+
label_4d[t, 2:5, 20:40, 20:40] = 1
|
|
47
|
+
label_4d[t, 5:8, 60:80, 60:80] = 2
|
|
48
|
+
np.save(folder / "image_4d.npy", label_4d)
|
|
49
|
+
|
|
50
|
+
yield folder
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_parse_dimensions_from_shape():
|
|
54
|
+
"""Test dimension parsing from image shape."""
|
|
55
|
+
# Test 2D
|
|
56
|
+
dims = parse_dimensions_from_shape((100, 100), 2)
|
|
57
|
+
assert "Y" in dims and "X" in dims
|
|
58
|
+
assert dims["Y"] == 100 and dims["X"] == 100
|
|
59
|
+
|
|
60
|
+
# Test 3D
|
|
61
|
+
dims = parse_dimensions_from_shape((10, 100, 100), 3)
|
|
62
|
+
assert "Z" in dims and "Y" in dims and "X" in dims
|
|
63
|
+
assert dims["Z"] == 10
|
|
64
|
+
|
|
65
|
+
# Test 4D
|
|
66
|
+
dims = parse_dimensions_from_shape((5, 10, 100, 100), 4)
|
|
67
|
+
assert "T" in dims and "Z" in dims
|
|
68
|
+
assert dims["T"] == 5 and dims["Z"] == 10
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_find_label_images(temp_label_folder):
|
|
72
|
+
"""Test finding label images in a folder."""
|
|
73
|
+
files = find_label_images(str(temp_label_folder))
|
|
74
|
+
assert len(files) == 3
|
|
75
|
+
assert all(f.endswith(".npy") for f in files)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_load_label_image(temp_label_folder):
|
|
79
|
+
"""Test loading label images."""
|
|
80
|
+
files = find_label_images(str(temp_label_folder))
|
|
81
|
+
|
|
82
|
+
for filepath in files:
|
|
83
|
+
img = load_label_image(filepath)
|
|
84
|
+
assert isinstance(img, np.ndarray)
|
|
85
|
+
assert img.dtype in [np.uint16, np.int32, np.int64]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_extract_regionprops_recursive_2d(temp_label_folder):
|
|
89
|
+
"""Test extracting regionprops from 2D label image."""
|
|
90
|
+
files = find_label_images(str(temp_label_folder))
|
|
91
|
+
file_2d = [f for f in files if "2d" in f][0]
|
|
92
|
+
|
|
93
|
+
img = load_label_image(file_2d)
|
|
94
|
+
results = extract_regionprops_recursive(
|
|
95
|
+
img,
|
|
96
|
+
prefix_dims={"filename": os.path.basename(file_2d)},
|
|
97
|
+
max_spatial_dims=3,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Should have 2 regions
|
|
101
|
+
assert len(results) == 2
|
|
102
|
+
|
|
103
|
+
# Check that results have expected keys
|
|
104
|
+
for result in results:
|
|
105
|
+
assert "filename" in result
|
|
106
|
+
assert "label" in result
|
|
107
|
+
assert "size" in result # area is renamed to size
|
|
108
|
+
assert "centroid_y" in result
|
|
109
|
+
assert "centroid_x" in result
|
|
110
|
+
assert "bbox_min_y" in result
|
|
111
|
+
assert "bbox_max_x" in result
|
|
112
|
+
|
|
113
|
+
# Check areas (20x20 = 400 pixels each)
|
|
114
|
+
assert results[0]["size"] == 400
|
|
115
|
+
assert results[1]["size"] == 400
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_extract_regionprops_recursive_3d(temp_label_folder):
|
|
119
|
+
"""Test extracting regionprops from 3D label image."""
|
|
120
|
+
files = find_label_images(str(temp_label_folder))
|
|
121
|
+
file_3d = [f for f in files if "3d" in f][0]
|
|
122
|
+
|
|
123
|
+
img = load_label_image(file_3d)
|
|
124
|
+
results = extract_regionprops_recursive(
|
|
125
|
+
img,
|
|
126
|
+
prefix_dims={"filename": os.path.basename(file_3d)},
|
|
127
|
+
max_spatial_dims=3,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Should have 2 regions
|
|
131
|
+
assert len(results) == 2
|
|
132
|
+
|
|
133
|
+
# Check that results have expected keys including Z coordinates
|
|
134
|
+
for result in results:
|
|
135
|
+
assert "filename" in result
|
|
136
|
+
assert "label" in result
|
|
137
|
+
assert "size" in result # area is renamed to size
|
|
138
|
+
assert "centroid_z" in result
|
|
139
|
+
assert "centroid_y" in result
|
|
140
|
+
assert "centroid_x" in result
|
|
141
|
+
assert "bbox_min_z" in result
|
|
142
|
+
assert "bbox_max_z" in result
|
|
143
|
+
|
|
144
|
+
# Check volumes (3 z-slices x 20x20 = 1200 voxels each)
|
|
145
|
+
assert results[0]["size"] == 1200
|
|
146
|
+
assert results[1]["size"] == 1200
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_extract_regionprops_recursive_4d(temp_label_folder):
|
|
150
|
+
"""Test extracting regionprops from 4D label image with time dimension."""
|
|
151
|
+
files = find_label_images(str(temp_label_folder))
|
|
152
|
+
file_4d = [f for f in files if "4d" in f][0]
|
|
153
|
+
|
|
154
|
+
img = load_label_image(file_4d)
|
|
155
|
+
results = extract_regionprops_recursive(
|
|
156
|
+
img,
|
|
157
|
+
prefix_dims={"filename": os.path.basename(file_4d)},
|
|
158
|
+
max_spatial_dims=3,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Should have 2 regions x 3 timepoints = 6 results
|
|
162
|
+
assert len(results) == 6
|
|
163
|
+
|
|
164
|
+
# Check that results have time dimension
|
|
165
|
+
for result in results:
|
|
166
|
+
assert "T" in result
|
|
167
|
+
assert "label" in result
|
|
168
|
+
assert result["T"] in [0, 1, 2]
|
|
169
|
+
|
|
170
|
+
# Check that we have 2 labels per timepoint
|
|
171
|
+
for t in range(3):
|
|
172
|
+
t_results = [r for r in results if r["T"] == t]
|
|
173
|
+
assert len(t_results) == 2
|
|
174
|
+
labels = [r["label"] for r in t_results]
|
|
175
|
+
assert sorted(labels) == [1, 2]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_analyze_folder_regionprops(temp_label_folder):
|
|
179
|
+
"""Test analyzing entire folder and saving to CSV."""
|
|
180
|
+
output_csv = temp_label_folder.parent / "test_regionprops.csv"
|
|
181
|
+
|
|
182
|
+
df = analyze_folder_regionprops(
|
|
183
|
+
folder_path=str(temp_label_folder),
|
|
184
|
+
output_csv=str(output_csv),
|
|
185
|
+
max_spatial_dims=3,
|
|
186
|
+
dimension_order="Auto",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Check that CSV was created
|
|
190
|
+
assert output_csv.exists()
|
|
191
|
+
|
|
192
|
+
# Check DataFrame structure
|
|
193
|
+
assert isinstance(df, pd.DataFrame)
|
|
194
|
+
assert len(df) > 0
|
|
195
|
+
assert "filename" in df.columns
|
|
196
|
+
assert "label" in df.columns
|
|
197
|
+
assert "size" in df.columns # area is renamed to size
|
|
198
|
+
|
|
199
|
+
# Check that we have results from all files
|
|
200
|
+
filenames = df["filename"].unique()
|
|
201
|
+
assert len(filenames) == 3
|
|
202
|
+
|
|
203
|
+
# Load CSV and verify it matches the DataFrame
|
|
204
|
+
df_loaded = pd.read_csv(output_csv)
|
|
205
|
+
assert len(df_loaded) == len(df)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_extract_regionprops_folder_integration(temp_label_folder):
|
|
209
|
+
"""Test the full batch processing function."""
|
|
210
|
+
# Reset cache first
|
|
211
|
+
reset_regionprops_cache()
|
|
212
|
+
|
|
213
|
+
# Load one of the images
|
|
214
|
+
files = find_label_images(str(temp_label_folder))
|
|
215
|
+
img = load_label_image(files[0])
|
|
216
|
+
|
|
217
|
+
# Mock the filepath in the call stack by calling from a function
|
|
218
|
+
# that has filepath in its locals
|
|
219
|
+
def mock_process(filepath, image):
|
|
220
|
+
return extract_regionprops_folder(
|
|
221
|
+
image,
|
|
222
|
+
max_spatial_dims=3,
|
|
223
|
+
dimension_order="Auto",
|
|
224
|
+
overwrite_existing=True,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
result = mock_process(files[0], img)
|
|
228
|
+
|
|
229
|
+
# Function should return None (only generates CSV)
|
|
230
|
+
assert result is None
|
|
231
|
+
|
|
232
|
+
# Check that CSV was created
|
|
233
|
+
folder_name = temp_label_folder.name
|
|
234
|
+
parent_dir = temp_label_folder.parent
|
|
235
|
+
output_csv = parent_dir / f"{folder_name}_regionprops.csv"
|
|
236
|
+
assert output_csv.exists()
|
|
237
|
+
|
|
238
|
+
# Load and verify CSV
|
|
239
|
+
df = pd.read_csv(output_csv)
|
|
240
|
+
assert len(df) > 0
|
|
241
|
+
assert "filename" in df.columns
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_extract_regionprops_folder_no_overwrite(temp_label_folder):
|
|
245
|
+
"""Test that function respects overwrite_existing flag."""
|
|
246
|
+
reset_regionprops_cache()
|
|
247
|
+
|
|
248
|
+
# Create a CSV file first
|
|
249
|
+
folder_name = temp_label_folder.name
|
|
250
|
+
parent_dir = temp_label_folder.parent
|
|
251
|
+
output_csv = parent_dir / f"{folder_name}_regionprops.csv"
|
|
252
|
+
|
|
253
|
+
# Create dummy CSV
|
|
254
|
+
pd.DataFrame({"test": [1, 2, 3]}).to_csv(output_csv, index=False)
|
|
255
|
+
initial_mtime = output_csv.stat().st_mtime
|
|
256
|
+
|
|
257
|
+
# Load image
|
|
258
|
+
files = find_label_images(str(temp_label_folder))
|
|
259
|
+
img = load_label_image(files[0])
|
|
260
|
+
|
|
261
|
+
# Process with overwrite_existing=False
|
|
262
|
+
def mock_process(filepath, image):
|
|
263
|
+
return extract_regionprops_folder(
|
|
264
|
+
image,
|
|
265
|
+
max_spatial_dims=3,
|
|
266
|
+
dimension_order="Auto",
|
|
267
|
+
overwrite_existing=False,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
_ = mock_process(files[0], img) # result unused, just checking behavior
|
|
271
|
+
|
|
272
|
+
# File should not have been modified
|
|
273
|
+
assert output_csv.stat().st_mtime == initial_mtime
|
|
274
|
+
|
|
275
|
+
# Now test with overwrite=True
|
|
276
|
+
reset_regionprops_cache()
|
|
277
|
+
|
|
278
|
+
# Create a mock process that properly sets filepath in locals
|
|
279
|
+
def mock_process_with_overwrite(filepath, image):
|
|
280
|
+
return extract_regionprops_folder(
|
|
281
|
+
image,
|
|
282
|
+
max_spatial_dims=3,
|
|
283
|
+
dimension_order="Auto",
|
|
284
|
+
overwrite_existing=True,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
_ = mock_process_with_overwrite(files[0], img) # result unused
|
|
288
|
+
|
|
289
|
+
# Verify the CSV was updated properly
|
|
290
|
+
if output_csv.exists():
|
|
291
|
+
df = pd.read_csv(output_csv)
|
|
292
|
+
# Either it should have the proper structure, or it should have been skipped
|
|
293
|
+
# (because we can't determine filepath in test context)
|
|
294
|
+
if len(df.columns) > 1:
|
|
295
|
+
assert "filename" in df.columns
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_reset_cache():
|
|
299
|
+
"""Test that cache reset works."""
|
|
300
|
+
from napari_tmidas.processing_functions.regionprops_analysis import (
|
|
301
|
+
_REGIONPROPS_CSV_FILES,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# First clear any existing cache
|
|
305
|
+
reset_regionprops_cache()
|
|
306
|
+
assert len(_REGIONPROPS_CSV_FILES) == 0
|
|
307
|
+
|
|
308
|
+
# Add a CSV file to the cache
|
|
309
|
+
_REGIONPROPS_CSV_FILES["/test/folder/test_regionprops.csv"] = True
|
|
310
|
+
assert len(_REGIONPROPS_CSV_FILES) == 1
|
|
311
|
+
|
|
312
|
+
# Reset cache
|
|
313
|
+
reset_regionprops_cache()
|
|
314
|
+
assert len(_REGIONPROPS_CSV_FILES) == 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_empty_label_image():
|
|
318
|
+
"""Test handling of empty label images."""
|
|
319
|
+
empty_img = np.zeros((100, 100), dtype=np.uint16)
|
|
320
|
+
|
|
321
|
+
results = extract_regionprops_recursive(
|
|
322
|
+
empty_img,
|
|
323
|
+
prefix_dims={"filename": "empty.npy"},
|
|
324
|
+
max_spatial_dims=3,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Should return empty list
|
|
328
|
+
assert len(results) == 0
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_single_region():
|
|
332
|
+
"""Test handling of label image with single region."""
|
|
333
|
+
img = np.zeros((50, 50), dtype=np.uint16)
|
|
334
|
+
img[10:30, 10:30] = 5 # Single region with label 5
|
|
335
|
+
|
|
336
|
+
results = extract_regionprops_recursive(
|
|
337
|
+
img,
|
|
338
|
+
prefix_dims={"filename": "single.npy"},
|
|
339
|
+
max_spatial_dims=3,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
assert len(results) == 1
|
|
343
|
+
assert results[0]["label"] == 5
|
|
344
|
+
assert results[0]["size"] == 400 # 20x20
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_dimension_order_tzyx():
|
|
348
|
+
"""Test that dimension_order correctly identifies T and Z dimensions."""
|
|
349
|
+
# Create TZYX image (3 timepoints, 5 z-slices, 50x50)
|
|
350
|
+
label_tzyx = np.zeros((3, 5, 50, 50), dtype=np.uint16)
|
|
351
|
+
for t in range(3):
|
|
352
|
+
label_tzyx[t, 1:4, 10:20, 10:20] = 1
|
|
353
|
+
label_tzyx[t, 2:4, 30:40, 30:40] = 2
|
|
354
|
+
|
|
355
|
+
# Extract with explicit dimension order
|
|
356
|
+
results = extract_regionprops_recursive(
|
|
357
|
+
label_tzyx,
|
|
358
|
+
prefix_dims={"filename": "test_tzyx.npy"},
|
|
359
|
+
current_dim=0,
|
|
360
|
+
max_spatial_dims=3,
|
|
361
|
+
dimension_order="TZYX",
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Should have 2 regions x 3 timepoints = 6 results
|
|
365
|
+
assert len(results) == 6
|
|
366
|
+
|
|
367
|
+
# Check that T dimension is properly identified
|
|
368
|
+
for result in results:
|
|
369
|
+
assert "T" in result
|
|
370
|
+
assert result["T"] in [0, 1, 2]
|
|
371
|
+
assert "filename" in result
|
|
372
|
+
assert "label" in result
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_dimension_order_czyx():
|
|
376
|
+
"""Test that dimension_order correctly identifies C and Z dimensions."""
|
|
377
|
+
# Create CZYX image (2 channels, 5 z-slices, 50x50)
|
|
378
|
+
label_czyx = np.zeros((2, 5, 50, 50), dtype=np.uint16)
|
|
379
|
+
for c in range(2):
|
|
380
|
+
label_czyx[c, 1:4, 10:20, 10:20] = 1
|
|
381
|
+
label_czyx[c, 2:4, 30:40, 30:40] = 2
|
|
382
|
+
|
|
383
|
+
# Extract with explicit dimension order
|
|
384
|
+
results = extract_regionprops_recursive(
|
|
385
|
+
label_czyx,
|
|
386
|
+
prefix_dims={"filename": "test_czyx.npy"},
|
|
387
|
+
current_dim=0,
|
|
388
|
+
max_spatial_dims=3,
|
|
389
|
+
dimension_order="CZYX",
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# Should have 2 regions x 2 channels = 4 results
|
|
393
|
+
assert len(results) == 4
|
|
394
|
+
|
|
395
|
+
# Check that C dimension is properly identified
|
|
396
|
+
for result in results:
|
|
397
|
+
assert "C" in result
|
|
398
|
+
assert result["C"] in [0, 1]
|
|
399
|
+
assert "filename" in result
|
|
400
|
+
assert "label" in result
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def test_dimension_order_tczyx():
|
|
404
|
+
"""Test that dimension_order correctly identifies T, C, and Z dimensions."""
|
|
405
|
+
# Create TCZYX image (2 timepoints, 2 channels, 3 z-slices, 30x30)
|
|
406
|
+
label_tczyx = np.zeros((2, 2, 3, 30, 30), dtype=np.uint16)
|
|
407
|
+
for t in range(2):
|
|
408
|
+
for c in range(2):
|
|
409
|
+
label_tczyx[t, c, 1:3, 5:15, 5:15] = 1
|
|
410
|
+
label_tczyx[t, c, 1:2, 20:25, 20:25] = 2
|
|
411
|
+
|
|
412
|
+
# Extract with explicit dimension order
|
|
413
|
+
results = extract_regionprops_recursive(
|
|
414
|
+
label_tczyx,
|
|
415
|
+
prefix_dims={"filename": "test_tczyx.npy"},
|
|
416
|
+
current_dim=0,
|
|
417
|
+
max_spatial_dims=3,
|
|
418
|
+
dimension_order="TCZYX",
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Should have 2 regions x 2 timepoints x 2 channels = 8 results
|
|
422
|
+
assert len(results) == 8
|
|
423
|
+
|
|
424
|
+
# Check that T and C dimensions are properly identified
|
|
425
|
+
for result in results:
|
|
426
|
+
assert "T" in result
|
|
427
|
+
assert result["T"] in [0, 1]
|
|
428
|
+
assert "C" in result
|
|
429
|
+
assert result["C"] in [0, 1]
|
|
430
|
+
assert "filename" in result
|
|
431
|
+
assert "label" in result
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def test_extract_regionprops_summary_basic():
|
|
435
|
+
"""Test summary statistics extraction without grouping by dimensions."""
|
|
436
|
+
# Create a simple 2D label image
|
|
437
|
+
label_2d = np.zeros((100, 100), dtype=np.uint16)
|
|
438
|
+
label_2d[10:20, 10:20] = 1 # 100 pixels
|
|
439
|
+
label_2d[30:50, 30:50] = 2 # 400 pixels
|
|
440
|
+
label_2d[60:70, 60:80] = 3 # 200 pixels
|
|
441
|
+
|
|
442
|
+
# Create intensity image
|
|
443
|
+
intensity_2d = np.random.rand(100, 100) * 100
|
|
444
|
+
|
|
445
|
+
# Extract regionprops first to get individual values
|
|
446
|
+
results = extract_regionprops_recursive(
|
|
447
|
+
label_2d,
|
|
448
|
+
intensity_image=intensity_2d,
|
|
449
|
+
prefix_dims={"filename": "test.npy"},
|
|
450
|
+
max_spatial_dims=2,
|
|
451
|
+
properties=[
|
|
452
|
+
"label",
|
|
453
|
+
"area",
|
|
454
|
+
"mean_intensity",
|
|
455
|
+
"median_intensity",
|
|
456
|
+
"std_intensity",
|
|
457
|
+
],
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Convert to DataFrame
|
|
461
|
+
df = pd.DataFrame(results)
|
|
462
|
+
|
|
463
|
+
# Calculate expected summary statistics (note: 'area' is renamed to 'size')
|
|
464
|
+
expected_count = len(df)
|
|
465
|
+
expected_size_sum = df["size"].sum()
|
|
466
|
+
expected_size_mean = df["size"].mean()
|
|
467
|
+
expected_size_median = df["size"].median()
|
|
468
|
+
|
|
469
|
+
# Verify we have 3 labels
|
|
470
|
+
assert expected_count == 3
|
|
471
|
+
assert expected_size_sum == 700 # 100 + 400 + 200
|
|
472
|
+
assert expected_size_mean == pytest.approx(233.333, rel=0.01)
|
|
473
|
+
assert expected_size_median == 200
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def test_extract_regionprops_summary_with_grouping():
|
|
477
|
+
"""Test summary statistics extraction with grouping by dimensions."""
|
|
478
|
+
# Create 4D label image (T=2, Z=3, Y=50, X=50)
|
|
479
|
+
label_4d = np.zeros((2, 3, 50, 50), dtype=np.uint16)
|
|
480
|
+
|
|
481
|
+
# T=0: 2 labels
|
|
482
|
+
label_4d[0, 1:3, 10:20, 10:20] = 1 # 200 pixels
|
|
483
|
+
label_4d[0, 1:2, 30:40, 30:40] = 2 # 100 pixels
|
|
484
|
+
|
|
485
|
+
# T=1: 3 labels
|
|
486
|
+
label_4d[1, 1:3, 10:15, 10:15] = 1 # 50 pixels
|
|
487
|
+
label_4d[1, 1:2, 20:30, 20:30] = 2 # 100 pixels
|
|
488
|
+
label_4d[1, 1:2, 35:45, 35:45] = 3 # 100 pixels
|
|
489
|
+
|
|
490
|
+
# Extract with dimension order
|
|
491
|
+
results = extract_regionprops_recursive(
|
|
492
|
+
label_4d,
|
|
493
|
+
intensity_image=None,
|
|
494
|
+
prefix_dims={"filename": "test_4d.npy"},
|
|
495
|
+
current_dim=0,
|
|
496
|
+
max_spatial_dims=3,
|
|
497
|
+
dimension_order="TZYX",
|
|
498
|
+
properties=["label", "area"],
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
# Convert to DataFrame
|
|
502
|
+
df = pd.DataFrame(results)
|
|
503
|
+
|
|
504
|
+
# Group by T dimension (note: 'area' is renamed to 'size')
|
|
505
|
+
summary_stats = []
|
|
506
|
+
for t, group in df.groupby("T"):
|
|
507
|
+
summary_stats.append(
|
|
508
|
+
{
|
|
509
|
+
"T": t,
|
|
510
|
+
"label_count": len(group),
|
|
511
|
+
"size_sum": group["size"].sum(),
|
|
512
|
+
"size_mean": group["size"].mean(),
|
|
513
|
+
}
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Verify T=0 has 2 labels
|
|
517
|
+
t0_stats = [s for s in summary_stats if s["T"] == 0][0]
|
|
518
|
+
assert t0_stats["label_count"] == 2
|
|
519
|
+
assert t0_stats["size_sum"] == 300 # 200 + 100
|
|
520
|
+
|
|
521
|
+
# Verify T=1 has 3 labels
|
|
522
|
+
t1_stats = [s for s in summary_stats if s["T"] == 1][0]
|
|
523
|
+
assert t1_stats["label_count"] == 3
|
|
524
|
+
assert t1_stats["size_sum"] == 250 # 50 + 100 + 100
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def test_summary_statistics_calculations():
|
|
528
|
+
"""Test that summary statistics are calculated correctly."""
|
|
529
|
+
# Simple test data
|
|
530
|
+
data = np.array([10, 20, 30, 40, 50])
|
|
531
|
+
|
|
532
|
+
# Expected values
|
|
533
|
+
expected_sum = 150
|
|
534
|
+
expected_mean = 30.0
|
|
535
|
+
expected_median = 30.0
|
|
536
|
+
expected_std = np.std(data, ddof=1) # pandas uses ddof=1
|
|
537
|
+
|
|
538
|
+
# Verify using pandas
|
|
539
|
+
df = pd.DataFrame({"values": data})
|
|
540
|
+
assert df["values"].sum() == expected_sum
|
|
541
|
+
assert df["values"].mean() == expected_mean
|
|
542
|
+
assert df["values"].median() == expected_median
|
|
543
|
+
assert df["values"].std() == pytest.approx(expected_std, rel=0.01)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
if __name__ == "__main__":
|
|
547
|
+
pytest.main([__file__, "-v"])
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# src/napari_tmidas/_tests/test_registry.py
|
|
2
|
+
from napari_tmidas._registry import BatchProcessingRegistry
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TestBatchProcessingRegistry:
|
|
6
|
+
def setup_method(self):
|
|
7
|
+
"""Clear registry before each test"""
|
|
8
|
+
BatchProcessingRegistry._processing_functions.clear()
|
|
9
|
+
|
|
10
|
+
def test_register_function(self):
|
|
11
|
+
"""Test registering a processing function"""
|
|
12
|
+
|
|
13
|
+
@BatchProcessingRegistry.register(
|
|
14
|
+
name="Test Function",
|
|
15
|
+
suffix="_test",
|
|
16
|
+
description="Test description",
|
|
17
|
+
parameters={"param1": {"type": int, "default": 5}},
|
|
18
|
+
)
|
|
19
|
+
def test_func(image, param1=5):
|
|
20
|
+
return image + param1
|
|
21
|
+
|
|
22
|
+
assert "Test Function" in BatchProcessingRegistry.list_functions()
|
|
23
|
+
info = BatchProcessingRegistry.get_function_info("Test Function")
|
|
24
|
+
assert info["suffix"] == "_test"
|
|
25
|
+
assert info["description"] == "Test description"
|
|
26
|
+
assert info["func"] == test_func
|
|
27
|
+
|
|
28
|
+
def test_list_functions(self):
|
|
29
|
+
"""Test listing registered functions"""
|
|
30
|
+
|
|
31
|
+
@BatchProcessingRegistry.register(name="Func1")
|
|
32
|
+
def func1(image):
|
|
33
|
+
return image
|
|
34
|
+
|
|
35
|
+
@BatchProcessingRegistry.register(name="Func2")
|
|
36
|
+
def func2(image):
|
|
37
|
+
return image
|
|
38
|
+
|
|
39
|
+
functions = BatchProcessingRegistry.list_functions()
|
|
40
|
+
assert len(functions) == 2
|
|
41
|
+
assert "Func1" in functions
|
|
42
|
+
assert "Func2" in functions
|
|
43
|
+
|
|
44
|
+
def test_thread_safety(self):
|
|
45
|
+
"""Test thread-safe registration"""
|
|
46
|
+
import threading
|
|
47
|
+
|
|
48
|
+
results = []
|
|
49
|
+
|
|
50
|
+
def register_func(i):
|
|
51
|
+
@BatchProcessingRegistry.register(name=f"ThreadFunc{i}")
|
|
52
|
+
def func(image):
|
|
53
|
+
return image
|
|
54
|
+
|
|
55
|
+
results.append(i)
|
|
56
|
+
|
|
57
|
+
threads = [
|
|
58
|
+
threading.Thread(target=register_func, args=(i,))
|
|
59
|
+
for i in range(10)
|
|
60
|
+
]
|
|
61
|
+
for t in threads:
|
|
62
|
+
t.start()
|
|
63
|
+
for t in threads:
|
|
64
|
+
t.join()
|
|
65
|
+
|
|
66
|
+
def test_get_function_info_nonexistent(self):
|
|
67
|
+
"""Test getting info for non-existent function"""
|
|
68
|
+
info = BatchProcessingRegistry.get_function_info("NonExistent")
|
|
69
|
+
assert info is None
|
|
70
|
+
|
|
71
|
+
def test_register_with_none_parameters(self):
|
|
72
|
+
"""Test registering function with None parameters (should convert to empty dict)"""
|
|
73
|
+
|
|
74
|
+
@BatchProcessingRegistry.register(
|
|
75
|
+
name="None Params Function",
|
|
76
|
+
suffix="_none",
|
|
77
|
+
description="Function with None parameters",
|
|
78
|
+
)
|
|
79
|
+
def none_params_func(image):
|
|
80
|
+
return image
|
|
81
|
+
|
|
82
|
+
info = BatchProcessingRegistry.get_function_info(
|
|
83
|
+
"None Params Function"
|
|
84
|
+
)
|
|
85
|
+
assert info["parameters"] == {}
|
|
86
|
+
assert info["suffix"] == "_none"
|
|
87
|
+
assert info["description"] == "Function with None parameters"
|
|
88
|
+
|
|
89
|
+
def test_register_minimal(self):
|
|
90
|
+
"""Test registering function with minimal parameters"""
|
|
91
|
+
|
|
92
|
+
@BatchProcessingRegistry.register(name="Minimal Function")
|
|
93
|
+
def minimal_func(image):
|
|
94
|
+
return image
|
|
95
|
+
|
|
96
|
+
info = BatchProcessingRegistry.get_function_info("Minimal Function")
|
|
97
|
+
assert info is not None
|
|
98
|
+
assert callable(info["func"])
|
|
99
|
+
|
|
100
|
+
def test_register_with_complex_parameters(self):
|
|
101
|
+
"""Test registering function with complex parameter metadata"""
|
|
102
|
+
|
|
103
|
+
@BatchProcessingRegistry.register(
|
|
104
|
+
name="Complex Params Function",
|
|
105
|
+
suffix="_complex",
|
|
106
|
+
description="Function with complex parameters",
|
|
107
|
+
parameters={
|
|
108
|
+
"param1": {
|
|
109
|
+
"type": int,
|
|
110
|
+
"default": 5,
|
|
111
|
+
"min": 1,
|
|
112
|
+
"max": 10,
|
|
113
|
+
"description": "Parameter description",
|
|
114
|
+
},
|
|
115
|
+
"param2": {
|
|
116
|
+
"type": str,
|
|
117
|
+
"default": "default_value",
|
|
118
|
+
"description": "String parameter",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
def complex_func(image, param1=5, param2="default"):
|
|
123
|
+
return image
|
|
124
|
+
|
|
125
|
+
info = BatchProcessingRegistry.get_function_info(
|
|
126
|
+
"Complex Params Function"
|
|
127
|
+
)
|
|
128
|
+
assert info["parameters"]["param1"]["type"] is int
|
|
129
|
+
assert info["parameters"]["param1"]["default"] == 5
|
|
130
|
+
assert info["parameters"]["param1"]["min"] == 1
|
|
131
|
+
assert info["parameters"]["param1"]["max"] == 10
|
|
132
|
+
assert (
|
|
133
|
+
info["parameters"]["param1"]["description"]
|
|
134
|
+
== "Parameter description"
|
|
135
|
+
)
|