celldetective 1.5.0b9__py3-none-any.whl → 1.5.0b11__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.
- celldetective/_version.py +1 -1
- celldetective/gui/base/list_widget.py +25 -10
- celldetective/gui/event_annotator.py +32 -3
- celldetective/gui/interactions_block.py +11 -2
- celldetective/gui/pair_event_annotator.py +39 -31
- celldetective/gui/settings/_settings_measurements.py +14 -7
- celldetective/gui/settings/_settings_neighborhood.py +1 -0
- celldetective/gui/viewers/contour_viewer.py +14 -2
- celldetective/gui/viewers/spot_detection_viewer.py +14 -0
- celldetective/measure.py +22 -13
- celldetective/utils/data_cleaning.py +7 -3
- celldetective/utils/masks.py +15 -8
- {celldetective-1.5.0b9.dist-info → celldetective-1.5.0b11.dist-info}/METADATA +1 -1
- {celldetective-1.5.0b9.dist-info → celldetective-1.5.0b11.dist-info}/RECORD +22 -20
- tests/gui/test_event_annotator_cleanup.py +310 -0
- tests/gui/test_spot_detection_viewer.py +207 -0
- tests/test_contour_format.py +299 -0
- tests/test_measure.py +318 -129
- {celldetective-1.5.0b9.dist-info → celldetective-1.5.0b11.dist-info}/WHEEL +0 -0
- {celldetective-1.5.0b9.dist-info → celldetective-1.5.0b11.dist-info}/entry_points.txt +0 -0
- {celldetective-1.5.0b9.dist-info → celldetective-1.5.0b11.dist-info}/licenses/LICENSE +0 -0
- {celldetective-1.5.0b9.dist-info → celldetective-1.5.0b11.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for contour measurement format parsing and interpretation.
|
|
3
|
+
|
|
4
|
+
Tests the complete pipeline:
|
|
5
|
+
1. CellEdgeVisualizer output format: "(min,max)"
|
|
6
|
+
2. ListWidget.getItems() parsing
|
|
7
|
+
3. contour_of_instance_segmentation() interpretation
|
|
8
|
+
4. measure_features() with border_distances
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import unittest
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestContourFormatParsing(unittest.TestCase):
|
|
17
|
+
"""
|
|
18
|
+
Test that the contour format "(min,max)" is correctly parsed by list_widget.getItems()
|
|
19
|
+
and contour_of_instance_segmentation().
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def test_tuple_format_parsing_positive(self):
|
|
23
|
+
"""Test parsing of positive range like (0,5)"""
|
|
24
|
+
from celldetective.utils.masks import contour_of_instance_segmentation
|
|
25
|
+
|
|
26
|
+
# Create a simple label with a square object
|
|
27
|
+
label = np.zeros((50, 50), dtype=int)
|
|
28
|
+
label[15:35, 15:35] = 1 # 20x20 square
|
|
29
|
+
|
|
30
|
+
# Test with tuple format string "(0,5)" - inner contour 0-5px
|
|
31
|
+
result = contour_of_instance_segmentation(label, "(0,5)")
|
|
32
|
+
|
|
33
|
+
# Should have non-zero pixels (edge region exists)
|
|
34
|
+
self.assertGreater(np.sum(result > 0), 0, "Contour should have pixels")
|
|
35
|
+
|
|
36
|
+
# The result should be smaller than the original object
|
|
37
|
+
self.assertLess(
|
|
38
|
+
np.sum(result > 0), np.sum(label > 0), "Edge should be smaller than object"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def test_tuple_format_parsing_negative(self):
|
|
42
|
+
"""Test parsing of negative range like (-5,0) - outer contour"""
|
|
43
|
+
from celldetective.utils.masks import contour_of_instance_segmentation
|
|
44
|
+
|
|
45
|
+
label = np.zeros((50, 50), dtype=int)
|
|
46
|
+
label[15:35, 15:35] = 1
|
|
47
|
+
|
|
48
|
+
# Test with tuple format string "(-5,0)" - outer contour
|
|
49
|
+
result = contour_of_instance_segmentation(label, "(-5,0)")
|
|
50
|
+
|
|
51
|
+
# Should have non-zero pixels in the region outside the original object
|
|
52
|
+
self.assertGreater(np.sum(result > 0), 0, "Outer contour should have pixels")
|
|
53
|
+
|
|
54
|
+
def test_tuple_format_parsing_mixed(self):
|
|
55
|
+
"""Test parsing of mixed range like (-3,3) - crossing boundary"""
|
|
56
|
+
from celldetective.utils.masks import contour_of_instance_segmentation
|
|
57
|
+
|
|
58
|
+
label = np.zeros((50, 50), dtype=int)
|
|
59
|
+
label[15:35, 15:35] = 1
|
|
60
|
+
|
|
61
|
+
# Test with tuple format string "(-3,3)" - straddles boundary
|
|
62
|
+
result = contour_of_instance_segmentation(label, "(-3,3)")
|
|
63
|
+
|
|
64
|
+
# Should have non-zero pixels
|
|
65
|
+
self.assertGreater(np.sum(result > 0), 0, "Mixed contour should have pixels")
|
|
66
|
+
|
|
67
|
+
def test_list_format_direct(self):
|
|
68
|
+
"""Test that list format [min, max] works correctly"""
|
|
69
|
+
from celldetective.utils.masks import contour_of_instance_segmentation
|
|
70
|
+
|
|
71
|
+
label = np.zeros((50, 50), dtype=int)
|
|
72
|
+
label[15:35, 15:35] = 1
|
|
73
|
+
|
|
74
|
+
# Test with list format [-5, 5]
|
|
75
|
+
result = contour_of_instance_segmentation(label, [-5, 5])
|
|
76
|
+
|
|
77
|
+
self.assertGreater(np.sum(result > 0), 0, "List format should work")
|
|
78
|
+
|
|
79
|
+
def test_tuple_format_direct(self):
|
|
80
|
+
"""Test that tuple format (min, max) works correctly"""
|
|
81
|
+
from celldetective.utils.masks import contour_of_instance_segmentation
|
|
82
|
+
|
|
83
|
+
label = np.zeros((50, 50), dtype=int)
|
|
84
|
+
label[15:35, 15:35] = 1
|
|
85
|
+
|
|
86
|
+
# Test with tuple format (-5, 5)
|
|
87
|
+
result = contour_of_instance_segmentation(label, (-5, 5))
|
|
88
|
+
|
|
89
|
+
self.assertGreater(np.sum(result > 0), 0, "Tuple format should work")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestListWidgetParsing(unittest.TestCase):
|
|
93
|
+
"""
|
|
94
|
+
Test that ListWidget.getItems() correctly parses the "(min,max)" format.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def setUp(self):
|
|
98
|
+
"""Set up a mock list widget for testing."""
|
|
99
|
+
from unittest.mock import MagicMock
|
|
100
|
+
|
|
101
|
+
# Create a mock item that returns text
|
|
102
|
+
self.mock_item = MagicMock()
|
|
103
|
+
self.mock_list_widget = MagicMock()
|
|
104
|
+
|
|
105
|
+
def test_parse_tuple_format(self):
|
|
106
|
+
"""Test that getItems parses (min,max) format correctly."""
|
|
107
|
+
import re
|
|
108
|
+
|
|
109
|
+
# Simulate the parsing logic from getItems
|
|
110
|
+
tuple_pattern = re.compile(r"^\((-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)$")
|
|
111
|
+
|
|
112
|
+
test_cases = [
|
|
113
|
+
("(0,5)", [0, 5]),
|
|
114
|
+
("(-5,0)", [-5, 0]),
|
|
115
|
+
("(-10,10)", [-10, 10]),
|
|
116
|
+
("(-3,3)", [-3, 3]),
|
|
117
|
+
("(0, 10)", [0, 10]), # with space
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
for text, expected in test_cases:
|
|
121
|
+
match = tuple_pattern.match(text.strip())
|
|
122
|
+
self.assertIsNotNone(match, f"Should match pattern: {text}")
|
|
123
|
+
minn = int(float(match.group(1)))
|
|
124
|
+
maxx = int(float(match.group(2)))
|
|
125
|
+
self.assertEqual([minn, maxx], expected, f"Failed for: {text}")
|
|
126
|
+
|
|
127
|
+
def test_parse_single_value(self):
|
|
128
|
+
"""Test that single values are parsed correctly."""
|
|
129
|
+
import re
|
|
130
|
+
|
|
131
|
+
tuple_pattern = re.compile(r"^\((-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)$")
|
|
132
|
+
|
|
133
|
+
test_cases = ["5", "10", "-5", "0"]
|
|
134
|
+
|
|
135
|
+
for text in test_cases:
|
|
136
|
+
match = tuple_pattern.match(text.strip())
|
|
137
|
+
self.assertIsNone(match, f"Should NOT match tuple pattern: {text}")
|
|
138
|
+
# Should be parseable as int
|
|
139
|
+
val = int(text)
|
|
140
|
+
self.assertIsInstance(val, int)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TestContourMeasurementIntegration(unittest.TestCase):
|
|
144
|
+
"""
|
|
145
|
+
Integration test for the complete contour measurement pipeline.
|
|
146
|
+
Tests that measure_features correctly uses border_distances with the new format.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def setUpClass(cls):
|
|
151
|
+
"""Create test data for all tests."""
|
|
152
|
+
# Create a larger image with clear intensity gradient
|
|
153
|
+
cls.frame = np.ones((100, 100, 1), dtype=float)
|
|
154
|
+
# Create a gradient - center is brighter
|
|
155
|
+
for i in range(100):
|
|
156
|
+
for j in range(100):
|
|
157
|
+
dist_from_center = np.sqrt((i - 50) ** 2 + (j - 50) ** 2)
|
|
158
|
+
cls.frame[i, j, 0] = max(0, 1 - dist_from_center / 50)
|
|
159
|
+
|
|
160
|
+
# Create a single centered object
|
|
161
|
+
cls.labels = np.zeros((100, 100), dtype=int)
|
|
162
|
+
cls.labels[35:65, 35:65] = 1 # 30x30 square centered at (50, 50)
|
|
163
|
+
|
|
164
|
+
def test_measure_with_list_border_distance(self):
|
|
165
|
+
"""Test that measure_features works with list [min, max] border distance."""
|
|
166
|
+
from celldetective.measure import measure_features
|
|
167
|
+
|
|
168
|
+
result = measure_features(
|
|
169
|
+
self.frame,
|
|
170
|
+
self.labels,
|
|
171
|
+
features=["intensity_mean"],
|
|
172
|
+
channels=["test"],
|
|
173
|
+
border_dist=[[-5, 5]], # Edge region from -5 to +5 around boundary
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
self.assertIsInstance(result, pd.DataFrame)
|
|
177
|
+
self.assertEqual(len(result), 1)
|
|
178
|
+
# Check that edge intensity column exists
|
|
179
|
+
edge_cols = [c for c in result.columns if "edge" in c or "slice" in c]
|
|
180
|
+
self.assertGreater(len(edge_cols), 0, "Should have edge measurement columns")
|
|
181
|
+
|
|
182
|
+
def test_measure_with_positive_only_border(self):
|
|
183
|
+
"""Test inner contour measurement [0, 5]."""
|
|
184
|
+
from celldetective.measure import measure_features
|
|
185
|
+
|
|
186
|
+
result = measure_features(
|
|
187
|
+
self.frame,
|
|
188
|
+
self.labels,
|
|
189
|
+
features=["intensity_mean"],
|
|
190
|
+
channels=["test"],
|
|
191
|
+
border_dist=[[0, 5]], # Inner edge only
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
self.assertIsInstance(result, pd.DataFrame)
|
|
195
|
+
self.assertEqual(len(result), 1)
|
|
196
|
+
|
|
197
|
+
def test_measure_with_negative_only_border(self):
|
|
198
|
+
"""Test outer contour measurement [-5, 0]."""
|
|
199
|
+
from celldetective.measure import measure_features
|
|
200
|
+
|
|
201
|
+
result = measure_features(
|
|
202
|
+
self.frame,
|
|
203
|
+
self.labels,
|
|
204
|
+
features=["intensity_mean"],
|
|
205
|
+
channels=["test"],
|
|
206
|
+
border_dist=[[-5, 0]], # Outer edge only
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
self.assertIsInstance(result, pd.DataFrame)
|
|
210
|
+
self.assertEqual(len(result), 1)
|
|
211
|
+
|
|
212
|
+
def test_measure_with_scalar_border_distance(self):
|
|
213
|
+
"""Test that scalar border_dist still works (backwards compatibility)."""
|
|
214
|
+
from celldetective.measure import measure_features
|
|
215
|
+
|
|
216
|
+
result = measure_features(
|
|
217
|
+
self.frame,
|
|
218
|
+
self.labels,
|
|
219
|
+
features=["intensity_mean"],
|
|
220
|
+
channels=["test"],
|
|
221
|
+
border_dist=[5], # Scalar - should be interpreted as [0, 5]
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
self.assertIsInstance(result, pd.DataFrame)
|
|
225
|
+
self.assertEqual(len(result), 1)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class TestContourWithEdgeCases(unittest.TestCase):
|
|
229
|
+
"""Test edge cases and potential issues with contour computation."""
|
|
230
|
+
|
|
231
|
+
def test_small_object_with_large_contour(self):
|
|
232
|
+
"""Test that small objects with large contour distance don't crash."""
|
|
233
|
+
from celldetective.utils.masks import contour_of_instance_segmentation
|
|
234
|
+
|
|
235
|
+
# Very small object
|
|
236
|
+
label = np.zeros((50, 50), dtype=int)
|
|
237
|
+
label[24:26, 24:26] = 1 # 2x2 pixel object
|
|
238
|
+
|
|
239
|
+
# Large contour request - may result in empty contour
|
|
240
|
+
result = contour_of_instance_segmentation(label, [0, 10])
|
|
241
|
+
|
|
242
|
+
# Should not crash, may be empty or small
|
|
243
|
+
self.assertEqual(result.shape, label.shape)
|
|
244
|
+
|
|
245
|
+
def test_empty_label_returns_zeros(self):
|
|
246
|
+
"""Test that empty labels return zero array."""
|
|
247
|
+
from celldetective.utils.masks import contour_of_instance_segmentation
|
|
248
|
+
|
|
249
|
+
label = np.zeros((50, 50), dtype=int)
|
|
250
|
+
|
|
251
|
+
result = contour_of_instance_segmentation(label, [0, 5])
|
|
252
|
+
|
|
253
|
+
self.assertTrue(np.all(result == 0), "Empty label should return zeros")
|
|
254
|
+
|
|
255
|
+
def test_multiple_objects(self):
|
|
256
|
+
"""Test contour with multiple distinct objects."""
|
|
257
|
+
from celldetective.utils.masks import contour_of_instance_segmentation
|
|
258
|
+
|
|
259
|
+
label = np.zeros((100, 100), dtype=int)
|
|
260
|
+
label[10:30, 10:30] = 1
|
|
261
|
+
label[60:80, 60:80] = 2
|
|
262
|
+
|
|
263
|
+
result = contour_of_instance_segmentation(label, [0, 3])
|
|
264
|
+
|
|
265
|
+
# Should have both object IDs in result
|
|
266
|
+
unique_ids = np.unique(result[result > 0])
|
|
267
|
+
self.assertIn(1, unique_ids, "Object 1 should be in result")
|
|
268
|
+
self.assertIn(2, unique_ids, "Object 2 should be in result")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class TestSuffixFormatting(unittest.TestCase):
|
|
272
|
+
"""Test that measurement column names are formatted correctly."""
|
|
273
|
+
|
|
274
|
+
def test_get_suffix_function(self):
|
|
275
|
+
"""Test the get_suffix helper function logic."""
|
|
276
|
+
|
|
277
|
+
# Simulate the get_suffix function from measure.py
|
|
278
|
+
def get_suffix(d):
|
|
279
|
+
d_str = str(d)
|
|
280
|
+
d_clean = (
|
|
281
|
+
d_str.replace("(", "")
|
|
282
|
+
.replace(")", "")
|
|
283
|
+
.replace(", ", "_")
|
|
284
|
+
.replace(",", "_")
|
|
285
|
+
)
|
|
286
|
+
if "-" in d_str or "," in d_str:
|
|
287
|
+
return f"_slice_{d_clean.replace('-', 'm')}px"
|
|
288
|
+
else:
|
|
289
|
+
return f"_edge_{d_clean}px"
|
|
290
|
+
|
|
291
|
+
# Test cases
|
|
292
|
+
self.assertEqual(get_suffix(5), "_edge_5px")
|
|
293
|
+
self.assertEqual(get_suffix(-5), "_slice_m5px")
|
|
294
|
+
self.assertEqual(get_suffix([0, 5]), "_slice_[0_5]px")
|
|
295
|
+
self.assertEqual(get_suffix([-5, 5]), "_slice_[m5_5]px")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
unittest.main()
|