celldetective 1.5.0b10__py3-none-any.whl → 1.5.0b12__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 +4 -5
- celldetective/gui/settings/_settings_neighborhood.py +1 -0
- celldetective/gui/tableUI.py +2 -3
- celldetective/gui/viewers/contour_viewer.py +14 -2
- celldetective/measure.py +11 -2
- celldetective/utils/masks.py +15 -8
- {celldetective-1.5.0b10.dist-info → celldetective-1.5.0b12.dist-info}/METADATA +1 -1
- {celldetective-1.5.0b10.dist-info → celldetective-1.5.0b12.dist-info}/RECORD +21 -18
- tests/gui/test_event_annotator_cleanup.py +310 -0
- tests/gui/test_tableui_track_collapse.py +239 -0
- tests/test_contour_format.py +299 -0
- tests/test_measure.py +87 -0
- {celldetective-1.5.0b10.dist-info → celldetective-1.5.0b12.dist-info}/WHEEL +0 -0
- {celldetective-1.5.0b10.dist-info → celldetective-1.5.0b12.dist-info}/entry_points.txt +0 -0
- {celldetective-1.5.0b10.dist-info → celldetective-1.5.0b12.dist-info}/licenses/LICENSE +0 -0
- {celldetective-1.5.0b10.dist-info → celldetective-1.5.0b12.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()
|
tests/test_measure.py
CHANGED
|
@@ -239,5 +239,92 @@ class TestNumpyArrayHandling(unittest.TestCase):
|
|
|
239
239
|
self.fail(f"Spot detection failed with numpy array channels: {e}")
|
|
240
240
|
|
|
241
241
|
|
|
242
|
+
class TestExtraPropertiesNotAutoIncluded(unittest.TestCase):
|
|
243
|
+
"""
|
|
244
|
+
Regression test for bug where ALL extra_properties functions containing 'intensity'
|
|
245
|
+
were being included in edge measurements, instead of only user-requested ones.
|
|
246
|
+
|
|
247
|
+
Bug: In measure_features(), when border_dist was set, the code used `extra`
|
|
248
|
+
(ALL available extra_properties functions) instead of `requested_extra_names`
|
|
249
|
+
(only user-requested ones). This caused unwanted measurements and warnings.
|
|
250
|
+
|
|
251
|
+
Fix: Changed to use only requested_extra_names in intensity_features list.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
@classmethod
|
|
255
|
+
def setUpClass(cls):
|
|
256
|
+
"""Create simple test data."""
|
|
257
|
+
cls.frame = np.ones((100, 100, 1), dtype=float)
|
|
258
|
+
cls.labels = np.zeros((100, 100), dtype=int)
|
|
259
|
+
cls.labels[40:60, 40:60] = 1 # 20x20 square
|
|
260
|
+
|
|
261
|
+
def test_border_dist_does_not_include_unrequested_extra_props(self):
|
|
262
|
+
"""
|
|
263
|
+
Test that edge measurements only include requested features,
|
|
264
|
+
not all extra_properties containing 'intensity'.
|
|
265
|
+
|
|
266
|
+
Before fix: Would include ~30+ extra_properties functions with 'intensity'.
|
|
267
|
+
After fix: Only includes explicitly requested features.
|
|
268
|
+
"""
|
|
269
|
+
result = measure_features(
|
|
270
|
+
self.frame,
|
|
271
|
+
self.labels,
|
|
272
|
+
features=["intensity_mean"], # Only request mean intensity
|
|
273
|
+
channels=["test"],
|
|
274
|
+
border_dist=[5],
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
self.assertIsInstance(result, pd.DataFrame)
|
|
278
|
+
|
|
279
|
+
# Get all column names related to edge/slice measurements
|
|
280
|
+
edge_columns = [c for c in result.columns if "edge" in c or "slice" in c]
|
|
281
|
+
|
|
282
|
+
# Should only have requested intensity_mean edge measurement, not all extra_properties
|
|
283
|
+
# Before the fix, this would include many unwanted columns like:
|
|
284
|
+
# - mean_dark_intensity_*
|
|
285
|
+
# - intensity_percentile_*
|
|
286
|
+
# - etc.
|
|
287
|
+
|
|
288
|
+
# Count intensity-related edge columns - should be minimal (just what we requested)
|
|
289
|
+
intensity_edge_cols = [
|
|
290
|
+
c for c in edge_columns if "intensity" in c.lower() or "mean" in c.lower()
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
# We requested only intensity_mean, so should have at most 1-2 edge columns per channel
|
|
294
|
+
# (the mean intensity for the edge region)
|
|
295
|
+
# Before the fix, this would be 30+ columns
|
|
296
|
+
self.assertLess(
|
|
297
|
+
len(intensity_edge_cols),
|
|
298
|
+
10,
|
|
299
|
+
f"Too many intensity edge columns found ({len(intensity_edge_cols)}). "
|
|
300
|
+
f"This suggests unrequested extra_properties are being included. "
|
|
301
|
+
f"Columns: {intensity_edge_cols}",
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def test_no_features_requested_only_adds_mean(self):
|
|
305
|
+
"""
|
|
306
|
+
Test that when no intensity features are requested, only basic mean is added.
|
|
307
|
+
Should not include all extra_properties.
|
|
308
|
+
"""
|
|
309
|
+
result = measure_features(
|
|
310
|
+
self.frame,
|
|
311
|
+
self.labels,
|
|
312
|
+
features=["area"], # Non-intensity feature only
|
|
313
|
+
channels=["test"],
|
|
314
|
+
border_dist=[5],
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
self.assertIsInstance(result, pd.DataFrame)
|
|
318
|
+
|
|
319
|
+
# Should have area column
|
|
320
|
+
self.assertIn("area", result.columns)
|
|
321
|
+
|
|
322
|
+
# Edge columns should only have basic intensity (auto-added for edges)
|
|
323
|
+
edge_columns = [c for c in result.columns if "edge" in c or "slice" in c]
|
|
324
|
+
|
|
325
|
+
# Should have minimal edge measurements (just auto-added mean for edge measurement)
|
|
326
|
+
self.assertLess(len(edge_columns), 10, f"Too many edge columns: {edge_columns}")
|
|
327
|
+
|
|
328
|
+
|
|
242
329
|
if __name__ == "__main__":
|
|
243
330
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|