celldetective 1.5.0b10__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 +4 -5
- celldetective/gui/settings/_settings_neighborhood.py +1 -0
- 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.0b11.dist-info}/METADATA +1 -1
- {celldetective-1.5.0b10.dist-info → celldetective-1.5.0b11.dist-info}/RECORD +19 -17
- tests/gui/test_event_annotator_cleanup.py +310 -0
- tests/test_contour_format.py +299 -0
- tests/test_measure.py +87 -0
- {celldetective-1.5.0b10.dist-info → celldetective-1.5.0b11.dist-info}/WHEEL +0 -0
- {celldetective-1.5.0b10.dist-info → celldetective-1.5.0b11.dist-info}/entry_points.txt +0 -0
- {celldetective-1.5.0b10.dist-info → celldetective-1.5.0b11.dist-info}/licenses/LICENSE +0 -0
- {celldetective-1.5.0b10.dist-info → celldetective-1.5.0b11.dist-info}/top_level.txt +0 -0
celldetective/_version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.5.
|
|
1
|
+
__version__ = "1.5.0b11"
|
|
@@ -89,7 +89,7 @@ class ListWidget(CelldetectiveWidget):
|
|
|
89
89
|
"""
|
|
90
90
|
Retrieves and returns the items from the list widget.
|
|
91
91
|
|
|
92
|
-
This method parses any items that contain a range (formatted as 'min
|
|
92
|
+
This method parses any items that contain a range (formatted as '(min,max)')
|
|
93
93
|
into a list of two values, and casts all items to the specified `dtype`.
|
|
94
94
|
|
|
95
95
|
Returns
|
|
@@ -97,18 +97,33 @@ class ListWidget(CelldetectiveWidget):
|
|
|
97
97
|
list
|
|
98
98
|
A list of the items in the list widget, with ranges split into two values.
|
|
99
99
|
"""
|
|
100
|
+
import re
|
|
101
|
+
|
|
102
|
+
# Pattern for tuple format: "(min,max)" with optional negative numbers and decimals
|
|
103
|
+
tuple_pattern = re.compile(r"^\((-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)$")
|
|
100
104
|
|
|
101
105
|
items = []
|
|
102
106
|
for x in range(self.list_widget.count()):
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
107
|
+
text = self.list_widget.item(x).text().strip()
|
|
108
|
+
|
|
109
|
+
# Try tuple format first: (min,max)
|
|
110
|
+
tuple_match = tuple_pattern.match(text)
|
|
111
|
+
if tuple_match:
|
|
112
|
+
try:
|
|
113
|
+
minn = self.dtype(float(tuple_match.group(1)))
|
|
114
|
+
maxx = self.dtype(float(tuple_match.group(2)))
|
|
115
|
+
items.append([minn, maxx])
|
|
116
|
+
continue
|
|
117
|
+
except ValueError:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
# Single value
|
|
121
|
+
try:
|
|
122
|
+
items.append(self.dtype(text))
|
|
123
|
+
except ValueError:
|
|
124
|
+
print(
|
|
125
|
+
f"Warning: Could not convert '{text}' to {self.dtype.__name__}, skipping..."
|
|
126
|
+
)
|
|
112
127
|
return items
|
|
113
128
|
|
|
114
129
|
def clear(self):
|
|
@@ -850,11 +850,40 @@ class EventAnnotator(BaseAnnotator):
|
|
|
850
850
|
|
|
851
851
|
try:
|
|
852
852
|
self.stop()
|
|
853
|
-
|
|
854
|
-
gc.collect()
|
|
855
|
-
except:
|
|
853
|
+
except Exception:
|
|
856
854
|
pass
|
|
857
855
|
|
|
856
|
+
# Stop and delete animation to break reference cycles
|
|
857
|
+
if hasattr(self, "anim") and self.anim:
|
|
858
|
+
try:
|
|
859
|
+
self.anim.event_source.stop()
|
|
860
|
+
except Exception:
|
|
861
|
+
pass
|
|
862
|
+
del self.anim
|
|
863
|
+
|
|
864
|
+
# Close matplotlib figures
|
|
865
|
+
if hasattr(self, "fig"):
|
|
866
|
+
try:
|
|
867
|
+
plt.close(self.fig)
|
|
868
|
+
except Exception:
|
|
869
|
+
pass
|
|
870
|
+
|
|
871
|
+
if hasattr(self, "cell_fig"):
|
|
872
|
+
try:
|
|
873
|
+
plt.close(self.cell_fig)
|
|
874
|
+
except Exception:
|
|
875
|
+
pass
|
|
876
|
+
|
|
877
|
+
# Delete large objects
|
|
878
|
+
if hasattr(self, "stack"):
|
|
879
|
+
del self.stack
|
|
880
|
+
|
|
881
|
+
if hasattr(self, "df_tracks"):
|
|
882
|
+
del self.df_tracks
|
|
883
|
+
|
|
884
|
+
gc.collect()
|
|
885
|
+
super().closeEvent(event)
|
|
886
|
+
|
|
858
887
|
def animation_generator(self):
|
|
859
888
|
"""
|
|
860
889
|
Generator yielding frame indices for the animation,
|
|
@@ -684,5 +684,14 @@ class NeighPanel(QFrame, Styles):
|
|
|
684
684
|
|
|
685
685
|
test = self.parent_window.locate_selected_position()
|
|
686
686
|
if test:
|
|
687
|
-
|
|
688
|
-
|
|
687
|
+
try:
|
|
688
|
+
self.pair_event_annotator = PairEventAnnotator(self)
|
|
689
|
+
self.pair_event_annotator.show()
|
|
690
|
+
except ValueError as e:
|
|
691
|
+
# Show useful feedback if no neighborhood detected
|
|
692
|
+
msg = QMessageBox()
|
|
693
|
+
msg.setIcon(QMessageBox.Warning)
|
|
694
|
+
msg.setText(str(e))
|
|
695
|
+
msg.setWindowTitle("Warning")
|
|
696
|
+
msg.exec_()
|
|
697
|
+
return
|
|
@@ -172,6 +172,10 @@ class PairEventAnnotator(CelldetectiveMainWindow):
|
|
|
172
172
|
print(
|
|
173
173
|
f"The following neighborhoods were detected: {self.neighborhood_cols=}..."
|
|
174
174
|
)
|
|
175
|
+
if len(self.neighborhood_cols) == 0:
|
|
176
|
+
raise ValueError(
|
|
177
|
+
"No neighborhoods detected. Please compute neighborhoods first."
|
|
178
|
+
)
|
|
175
179
|
|
|
176
180
|
self.locate_relative_tracks()
|
|
177
181
|
|
|
@@ -1449,6 +1453,7 @@ class PairEventAnnotator(CelldetectiveMainWindow):
|
|
|
1449
1453
|
self.reference_population = self.neighborhood_choice_cb.currentText().split(
|
|
1450
1454
|
"_"
|
|
1451
1455
|
)[0]
|
|
1456
|
+
self.neighbor_population = self.reference_population
|
|
1452
1457
|
|
|
1453
1458
|
if "_(" in self.current_neighborhood and ")_" in self.current_neighborhood:
|
|
1454
1459
|
self.neighbor_population = (
|
|
@@ -1457,9 +1462,6 @@ class PairEventAnnotator(CelldetectiveMainWindow):
|
|
|
1457
1462
|
self.reference_population = (
|
|
1458
1463
|
self.current_neighborhood.split("_(")[-1].split(")_")[0].split("-")[0]
|
|
1459
1464
|
)
|
|
1460
|
-
else:
|
|
1461
|
-
if "self" in self.current_neighborhood:
|
|
1462
|
-
self.neighbor_population = self.reference_population
|
|
1463
1465
|
|
|
1464
1466
|
print(f"Current neighborhood: {self.current_neighborhood}")
|
|
1465
1467
|
print(f"New reference population: {self.reference_population}")
|
|
@@ -2183,18 +2185,44 @@ class PairEventAnnotator(CelldetectiveMainWindow):
|
|
|
2183
2185
|
|
|
2184
2186
|
def closeEvent(self, event):
|
|
2185
2187
|
|
|
2186
|
-
self.stop()
|
|
2187
|
-
# result = QMessageBox.question(self,
|
|
2188
|
-
# "Confirm Exit...",
|
|
2189
|
-
# "Are you sure you want to exit ?",
|
|
2190
|
-
# QMessageBox.Yes| QMessageBox.No,
|
|
2191
|
-
# )
|
|
2192
2188
|
try:
|
|
2193
|
-
|
|
2194
|
-
except:
|
|
2189
|
+
self.stop()
|
|
2190
|
+
except Exception:
|
|
2195
2191
|
pass
|
|
2196
2192
|
|
|
2193
|
+
# Stop and delete animation to break reference cycles
|
|
2194
|
+
if hasattr(self, "anim") and self.anim:
|
|
2195
|
+
try:
|
|
2196
|
+
self.anim.event_source.stop()
|
|
2197
|
+
except Exception:
|
|
2198
|
+
pass
|
|
2199
|
+
del self.anim
|
|
2200
|
+
|
|
2201
|
+
# Close matplotlib figures
|
|
2202
|
+
if hasattr(self, "fig"):
|
|
2203
|
+
try:
|
|
2204
|
+
plt.close(self.fig)
|
|
2205
|
+
except Exception:
|
|
2206
|
+
pass
|
|
2207
|
+
|
|
2208
|
+
if hasattr(self, "cell_fig"):
|
|
2209
|
+
try:
|
|
2210
|
+
plt.close(self.cell_fig)
|
|
2211
|
+
except Exception:
|
|
2212
|
+
pass
|
|
2213
|
+
|
|
2214
|
+
# Delete large objects
|
|
2215
|
+
if hasattr(self, "stack"):
|
|
2216
|
+
del self.stack
|
|
2217
|
+
|
|
2218
|
+
if hasattr(self, "dataframes"):
|
|
2219
|
+
self.dataframes.clear()
|
|
2220
|
+
|
|
2221
|
+
if hasattr(self, "df_relative"):
|
|
2222
|
+
del self.df_relative
|
|
2223
|
+
|
|
2197
2224
|
gc.collect()
|
|
2225
|
+
super().closeEvent(event)
|
|
2198
2226
|
|
|
2199
2227
|
def animation_generator(self):
|
|
2200
2228
|
"""
|
|
@@ -2982,26 +3010,6 @@ class PairEventAnnotator(CelldetectiveMainWindow):
|
|
|
2982
3010
|
self.draw_frame(self.framedata)
|
|
2983
3011
|
self.fcanvas.canvas.draw()
|
|
2984
3012
|
|
|
2985
|
-
def set_first_frame(self):
|
|
2986
|
-
self.stop()
|
|
2987
|
-
self.framedata = 0
|
|
2988
|
-
self.draw_frame(self.framedata)
|
|
2989
|
-
self.fcanvas.canvas.draw()
|
|
2990
|
-
|
|
2991
|
-
def set_last_frame(self):
|
|
2992
|
-
self.stop()
|
|
2993
|
-
self.framedata = len(self.stack) - 1
|
|
2994
|
-
while len(np.where(self.stack[self.framedata].flatten() == 0)[0]) > 0.99 * len(
|
|
2995
|
-
self.stack[self.framedata].flatten()
|
|
2996
|
-
):
|
|
2997
|
-
self.framedata -= 1
|
|
2998
|
-
if self.framedata < 0:
|
|
2999
|
-
self.framedata = 0
|
|
3000
|
-
break
|
|
3001
|
-
|
|
3002
|
-
self.draw_frame(self.framedata)
|
|
3003
|
-
self.fcanvas.canvas.draw()
|
|
3004
|
-
|
|
3005
3013
|
def give_reference_cell_information(self):
|
|
3006
3014
|
|
|
3007
3015
|
df_reference = self.dataframes[self.reference_population]
|
|
@@ -680,16 +680,15 @@ class SettingsMeasurements(CelldetectiveSettingsPanel):
|
|
|
680
680
|
border_distances = measurement_instructions["border_distances"]
|
|
681
681
|
if border_distances is not None:
|
|
682
682
|
if isinstance(border_distances, int):
|
|
683
|
-
distances = [border_distances]
|
|
683
|
+
distances = [str(border_distances)]
|
|
684
684
|
elif isinstance(border_distances, list):
|
|
685
685
|
distances = []
|
|
686
686
|
for d in border_distances:
|
|
687
|
-
if isinstance(d, int
|
|
687
|
+
if isinstance(d, (int, float)):
|
|
688
688
|
distances.append(str(int(d)))
|
|
689
689
|
elif isinstance(d, list):
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
)
|
|
690
|
+
# Use (min,max) tuple format to match CellEdgeVisualizer output
|
|
691
|
+
distances.append(f"({int(d[0])},{int(d[1])})")
|
|
693
692
|
self.contours_list.list_widget.clear()
|
|
694
693
|
self.contours_list.list_widget.addItems(distances)
|
|
695
694
|
|
|
@@ -72,6 +72,7 @@ class CellEdgeVisualizer(StackVisualizer):
|
|
|
72
72
|
labels=None,
|
|
73
73
|
initial_edge=5,
|
|
74
74
|
initial_mask_alpha=0.5,
|
|
75
|
+
single_value_mode=False,
|
|
75
76
|
*args,
|
|
76
77
|
**kwargs,
|
|
77
78
|
):
|
|
@@ -86,6 +87,7 @@ class CellEdgeVisualizer(StackVisualizer):
|
|
|
86
87
|
self.invert = invert
|
|
87
88
|
self.parent_list_widget = parent_list_widget
|
|
88
89
|
self.parent_le = parent_le
|
|
90
|
+
self.single_value_mode = single_value_mode
|
|
89
91
|
|
|
90
92
|
# SDF cache (stores label + dist_in + dist_out + voronoi)
|
|
91
93
|
self.sdf_cache = OrderedDict()
|
|
@@ -201,8 +203,18 @@ class CellEdgeVisualizer(StackVisualizer):
|
|
|
201
203
|
|
|
202
204
|
def set_measurement_in_parent_list(self):
|
|
203
205
|
# Add the edge size to the parent QListWidget
|
|
204
|
-
|
|
205
|
-
self.
|
|
206
|
+
# edge_slider is a QLabeledRangeSlider returning (min, max) tuple
|
|
207
|
+
slider_val = self.edge_slider.value()
|
|
208
|
+
if isinstance(slider_val, tuple):
|
|
209
|
+
if self.single_value_mode:
|
|
210
|
+
# For neighborhood: just output the max absolute distance
|
|
211
|
+
edge_value = str(max(abs(slider_val[0]), abs(slider_val[1])))
|
|
212
|
+
else:
|
|
213
|
+
# For contour measurements: output as "(min,max)" tuple format
|
|
214
|
+
edge_value = f"({slider_val[0]},{slider_val[1]})"
|
|
215
|
+
else:
|
|
216
|
+
edge_value = str(abs(slider_val))
|
|
217
|
+
self.parent_list_widget.addItems([edge_value])
|
|
206
218
|
self.close()
|
|
207
219
|
|
|
208
220
|
def generate_label_imshow(self):
|
celldetective/measure.py
CHANGED
|
@@ -544,10 +544,19 @@ def measure_features(
|
|
|
544
544
|
df_props = df_props[[c for c in df_props.columns if not c.endswith("_delme")]]
|
|
545
545
|
|
|
546
546
|
if border_dist is not None:
|
|
547
|
+
# Get the names of extra properties that were actually requested by the user
|
|
548
|
+
# (these were moved from features to extra_props_list earlier)
|
|
549
|
+
requested_extra_names = []
|
|
550
|
+
if extra_props_list:
|
|
551
|
+
for prop_func in extra_props_list:
|
|
552
|
+
if hasattr(prop_func, "__name__"):
|
|
553
|
+
requested_extra_names.append(prop_func.__name__)
|
|
554
|
+
|
|
547
555
|
# Filter for features containing "intensity" but not "centroid" or "peripheral"
|
|
556
|
+
# Only use user-requested features, not all available extra properties
|
|
548
557
|
intensity_features = [
|
|
549
558
|
f
|
|
550
|
-
for f in (features +
|
|
559
|
+
for f in (features + requested_extra_names)
|
|
551
560
|
if "intensity" in f and "centroid" not in f and "peripheral" not in f
|
|
552
561
|
]
|
|
553
562
|
|
|
@@ -558,7 +567,7 @@ def measure_features(
|
|
|
558
567
|
|
|
559
568
|
clean_intensity_features = []
|
|
560
569
|
for s in intensity_features:
|
|
561
|
-
if s in
|
|
570
|
+
if s in requested_extra_names:
|
|
562
571
|
intensity_extra.append(getattr(extra_props, s))
|
|
563
572
|
else:
|
|
564
573
|
clean_intensity_features.append(s)
|
celldetective/utils/masks.py
CHANGED
|
@@ -49,8 +49,10 @@ def contour_of_instance_segmentation(label, distance, sdf=None, voronoi_map=None
|
|
|
49
49
|
"""
|
|
50
50
|
from scipy.ndimage import distance_transform_edt
|
|
51
51
|
|
|
52
|
-
# helper to parse string "rad1-rad2"
|
|
52
|
+
# helper to parse string "rad1-rad2" or "-12-13" or "-5--2"
|
|
53
53
|
if isinstance(distance, str):
|
|
54
|
+
import re
|
|
55
|
+
|
|
54
56
|
try:
|
|
55
57
|
# Check for stringified tuple "(a, b)"
|
|
56
58
|
distance = distance.strip()
|
|
@@ -74,13 +76,18 @@ def contour_of_instance_segmentation(label, distance, sdf=None, voronoi_map=None
|
|
|
74
76
|
min_r = val
|
|
75
77
|
max_r = 0
|
|
76
78
|
except ValueError:
|
|
77
|
-
# It's a range string "5-10"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
# It's a range string like "5-10", "-12-13", or "-5--2"
|
|
80
|
+
# Use regex to parse range with potentially negative numbers
|
|
81
|
+
range_pattern = re.compile(r"^(-?\d+(?:\.\d+)?)-(-?\d+(?:\.\d+)?)$")
|
|
82
|
+
match = range_pattern.match(distance)
|
|
83
|
+
if match:
|
|
84
|
+
r1 = float(match.group(1))
|
|
85
|
+
r2 = float(match.group(2))
|
|
86
|
+
# Use the values directly as specified (min, max in SDF space)
|
|
87
|
+
min_r = min(r1, r2)
|
|
88
|
+
max_r = max(r1, r2)
|
|
89
|
+
else:
|
|
90
|
+
raise ValueError(f"Could not parse range string: {distance}")
|
|
84
91
|
|
|
85
92
|
except Exception:
|
|
86
93
|
logger.warning(
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
celldetective/__init__.py,sha256=LfnOyfUnYPGDc8xcsF_PfYEL7-CqAb7BMBPBIWGv84o,666
|
|
2
2
|
celldetective/__main__.py,sha256=Rzzu9ArxZSgfBVjV-lyn-3oanQB2MumQR6itK5ZaRpA,2597
|
|
3
|
-
celldetective/_version.py,sha256=
|
|
3
|
+
celldetective/_version.py,sha256=4JH3pE2Gi7kHI-6YVma-6xegJyqqu0l3uH4fqRqdsGA,25
|
|
4
4
|
celldetective/event_detection_models.py,sha256=A7ZJFhJwfdhzfxJ-YZIj6IoI9Gc1hyAodFkKt8kNxDk,93549
|
|
5
5
|
celldetective/events.py,sha256=n15R53c7QZ2wT8gjb0oeNikQbuRBrVVbyNsRCqXjzXA,8166
|
|
6
6
|
celldetective/exceptions.py,sha256=f3VmIYOthWTiqMEV5xQCox2rw5c5e7yog88h-CcV4oI,356
|
|
7
7
|
celldetective/extra_properties.py,sha256=s_2R4_El2p-gQNZ_EpgDxgrN3UnRitN7KDKHhyLuoHc,21681
|
|
8
8
|
celldetective/filters.py,sha256=QSRSpqvwUfa0YrU5EKoobJWCQFy07fFHwZ2bXxTL3hE,6933
|
|
9
9
|
celldetective/log_manager.py,sha256=Tv7_mVn0TVOyTs_2VnyEKl9_NMeDUtEkLC5njUq-r-Y,2968
|
|
10
|
-
celldetective/measure.py,sha256=
|
|
10
|
+
celldetective/measure.py,sha256=LTV4HvMzGUIRD1L6SsfktUWW5EIG2qk-I69rLot0fuA,72711
|
|
11
11
|
celldetective/neighborhood.py,sha256=Z5wu2nSMvabrQz91oy6DRf2o90LUY0RMXTEwgW7ORAg,74844
|
|
12
12
|
celldetective/preprocessing.py,sha256=tjAMLG4ZMfopMSFulGGjMQox7czEcReV2MzEkWlL2eQ,59948
|
|
13
13
|
celldetective/relative_measurements.py,sha256=j7xIj1FiY3kn5nA_wMqHV3wvjqikjnk99kZ7v9G6I5Q,42928
|
|
@@ -25,14 +25,14 @@ celldetective/gui/classifier_widget.py,sha256=uQ9KQmUFp6qWy0Aic-AKB9DkGNpOLxaERt
|
|
|
25
25
|
celldetective/gui/configure_new_exp.py,sha256=vgT6ozRIGDvT3R0qENlqvDn17BpKnwyJRhRhDS6ax8A,29954
|
|
26
26
|
celldetective/gui/control_panel.py,sha256=dMNzgivt6GdYROPlYpEY5TTNcANenm9ifUI3W3OcpOo,24337
|
|
27
27
|
celldetective/gui/dynamic_progress.py,sha256=wjTmDPy62qssY0zdteP3T9umIGGQk0UvebFIdn54CIc,16676
|
|
28
|
-
celldetective/gui/event_annotator.py,sha256=
|
|
28
|
+
celldetective/gui/event_annotator.py,sha256=JcX1K-TAMEdbgg-8ENK1xx_H2nw-vhNvxl1ImhqhMgM,42013
|
|
29
29
|
celldetective/gui/generic_signal_plot.py,sha256=47kXIuMcnQXjeNEdzM_G1WbW9TL5eMSjHC9XgWXMly4,49492
|
|
30
30
|
celldetective/gui/gui_utils.py,sha256=l7P6emKVEciCRdmnbfYvJAhl0MnbT3QkL2zpSPuHRoY,34120
|
|
31
|
-
celldetective/gui/interactions_block.py,sha256=
|
|
31
|
+
celldetective/gui/interactions_block.py,sha256=G_ejjMPFwX3Zy99iZwyYyINkpA7QBDMtyS08vzANl0U,28786
|
|
32
32
|
celldetective/gui/interactive_timeseries_viewer.py,sha256=u_amAhLdEHRpYSRwPDtVm5v-JZIz0ANTcG4YGksX1Vo,16079
|
|
33
33
|
celldetective/gui/json_readers.py,sha256=t5rhtIxACj0pdwLrnPs-QjyhQo3P25UGWGgOCIBhQxs,4572
|
|
34
34
|
celldetective/gui/measure_annotator.py,sha256=ljNbsKmFXQk0R9zEfBZ6XfBHzFmlL7Gt6QyPHyqh08g,38357
|
|
35
|
-
celldetective/gui/pair_event_annotator.py,sha256=
|
|
35
|
+
celldetective/gui/pair_event_annotator.py,sha256=dXGWrn64Iu9kjr2S3LjUzCHny2QOC3Q-qrOSqtvLvg0,128930
|
|
36
36
|
celldetective/gui/plot_measurements.py,sha256=a_Mks-5XUTn2QEYih0PlXGp2lX3C34zuhK9ozzE1guM,56593
|
|
37
37
|
celldetective/gui/plot_signals_ui.py,sha256=fxgkUZ_m7jFTXOwdzfJdl8wt3n8RECny3n4Tpk-GE6w,29016
|
|
38
38
|
celldetective/gui/preprocessing_block.py,sha256=cgUBv-eQBwfidK48-cTWJi0PS62wlXhsNLnsKhy6MQY,14967
|
|
@@ -47,7 +47,7 @@ celldetective/gui/base/channel_norm_generator.py,sha256=HJ57wBMdBIvca92V477T-aPu
|
|
|
47
47
|
celldetective/gui/base/components.py,sha256=jNUsCU_QE7QUFR0_xEvEPFEBYolMJt7YXGUKMjF7uOE,8155
|
|
48
48
|
celldetective/gui/base/feature_choice.py,sha256=n1T2fPoNLiTDS_6fa_GuGReDhBW11HdUrKy2RywotF8,2800
|
|
49
49
|
celldetective/gui/base/figure_canvas.py,sha256=mSiIYvEfz7MYMgdPDf6RKSMpKN8FkeZL7lugDNnDpnM,2245
|
|
50
|
-
celldetective/gui/base/list_widget.py,sha256=
|
|
50
|
+
celldetective/gui/base/list_widget.py,sha256=jHA21z6KsMigTxVJn4g7Vcs5ufR2vGSm-8GJYWflUXA,5125
|
|
51
51
|
celldetective/gui/base/styles.py,sha256=3Kz1eXw6OLr90wtErhK0KPJyJbyhAlqkniqm0JNGwFc,7407
|
|
52
52
|
celldetective/gui/base/utils.py,sha256=KojauRKGM9XKNhaWn211p6mJNZWIHLH75yeLpDd7pvA,1103
|
|
53
53
|
celldetective/gui/help/DL-segmentation-strategy.json,sha256=PZD9xXjrwbX3TiudHJPuvcyZD28o4k-fVgeTd7dBKzI,1583
|
|
@@ -74,8 +74,8 @@ celldetective/gui/settings/_event_detection_model_params.py,sha256=f3jkh6f3oE-_5
|
|
|
74
74
|
celldetective/gui/settings/_segmentation_model_params.py,sha256=YooEXRlkmOlHCyReiFynagrxBQn2y-dTB0FgowqZno0,6471
|
|
75
75
|
celldetective/gui/settings/_settings_base.py,sha256=_Yfq6vLkwm4FW5n0-SjVQjdhfL3hR5pUGBc0ttq_YXE,2576
|
|
76
76
|
celldetective/gui/settings/_settings_event_model_training.py,sha256=hpA5DTCPIMe1aVZDqO_6FudEP3a-IO6SVPfys-gdfXY,30346
|
|
77
|
-
celldetective/gui/settings/_settings_measurements.py,sha256=
|
|
78
|
-
celldetective/gui/settings/_settings_neighborhood.py,sha256=
|
|
77
|
+
celldetective/gui/settings/_settings_measurements.py,sha256=SghdNs1x-XdZysh1e9WtgrzVViNSWXe3jFgQ-ee6b5U,48345
|
|
78
|
+
celldetective/gui/settings/_settings_neighborhood.py,sha256=_gMoKbJOx-ssJEqf_BabI94byP6zWUg37ZM1P83tMTU,23807
|
|
79
79
|
celldetective/gui/settings/_settings_segmentation.py,sha256=6DihD1mk-dN4Sstdth1iJ-0HR34rTVlTHP-pXUh_rY0,1901
|
|
80
80
|
celldetective/gui/settings/_settings_segmentation_model_training.py,sha256=g-y5ZTZDwGaaevm6eI3XK_QU8PbZOY0jBdFyb1kAsqA,30440
|
|
81
81
|
celldetective/gui/settings/_settings_signal_annotator.py,sha256=9LRAgOn3qcbHctAXpTDlySGSCm6kMX2Qwqpeuwz384E,11601
|
|
@@ -90,7 +90,7 @@ celldetective/gui/table_ops/_rename_col.py,sha256=UAgDSpXJo_h4pLJpHaNc2w2VhbaW4D
|
|
|
90
90
|
celldetective/gui/viewers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
91
91
|
celldetective/gui/viewers/base_viewer.py,sha256=Wn4na79xRL1R6PSXIoE_UabxJNtNQ-Y5l9Q-HSb508c,32013
|
|
92
92
|
celldetective/gui/viewers/channel_offset_viewer.py,sha256=cywBkxyMPyKIuwZTGA03DBSS4a-H1SAohMJYOPISLEE,16289
|
|
93
|
-
celldetective/gui/viewers/contour_viewer.py,sha256=
|
|
93
|
+
celldetective/gui/viewers/contour_viewer.py,sha256=gRAVkwIrAQKN9hFbWNYOO8RDOw50l92owIvDM1q5Cno,15360
|
|
94
94
|
celldetective/gui/viewers/size_viewer.py,sha256=uXITjaxg5dhQ09Q6TNUxwLxi-ZglyGFcxEH1RtGIZWw,6020
|
|
95
95
|
celldetective/gui/viewers/spot_detection_viewer.py,sha256=JSo6tpb7qBxGjUzUXQ-Zi4uts74eVZ2gxUdD67yhQ4A,17195
|
|
96
96
|
celldetective/gui/viewers/threshold_viewer.py,sha256=F-13JF2wFhyvvKfUvgRcSjWL3leAliOXy5yUergndnE,12000
|
|
@@ -153,7 +153,7 @@ celldetective/utils/image_transforms.py,sha256=cgaHuEUArWWHgxlBlcQLf--zQa-4VphPJ
|
|
|
153
153
|
celldetective/utils/io.py,sha256=WQH6B27iS722eVV8HHRaSvxMRZ217LoiEIPOqNGtqJk,1632
|
|
154
154
|
celldetective/utils/mask_cleaning.py,sha256=n1Q2RfyhX0W3AcLO0U6ucSyDGRCofj6bPLSO_xeVZPI,12545
|
|
155
155
|
celldetective/utils/mask_transforms.py,sha256=fX-ajhUhzZwOe7rAnMcQc6w4e2B8SZeRp9jrQLF6DFs,144
|
|
156
|
-
celldetective/utils/masks.py,sha256=
|
|
156
|
+
celldetective/utils/masks.py,sha256=ZynSkciNkiY2F9lxxW02ZwPS_54VnF9S1M0PNi7vT5M,7483
|
|
157
157
|
celldetective/utils/maths.py,sha256=pbbWWYNIHTnIBiaR2kJHPDaTPO0rpmQSPjHB7liUSG0,12465
|
|
158
158
|
celldetective/utils/model_getters.py,sha256=jVq9FhAF-xUmFOETWP6hByhoWgapmJGlNmSK11fQ69g,11370
|
|
159
159
|
celldetective/utils/model_loaders.py,sha256=CjScJBGtnenb8qRMZkEozdj1-ULYHvsDFS4AVgYbB5s,10576
|
|
@@ -167,13 +167,14 @@ celldetective/utils/event_detection/__init__.py,sha256=GvsdyQLMTXJj1S_FfRXjrpOxE
|
|
|
167
167
|
celldetective/utils/plots/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
168
168
|
celldetective/utils/plots/regression.py,sha256=oUCn29-hp7PxMqC-R0yoL60KMw5ZWpZAIoCDh2ErlcY,1764
|
|
169
169
|
celldetective/utils/stardist_utils/__init__.py,sha256=SY2kxFNXSRjXN4ncs3heDdXT3UNk8M3dELJQySysAf4,4231
|
|
170
|
-
celldetective-1.5.
|
|
170
|
+
celldetective-1.5.0b11.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
171
171
|
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
172
172
|
tests/test_cellpose_fallback.py,sha256=BJZTDFF8sFR1x7rDbvZQ2RQOB1OP6wuFBRfc8zbl5zw,3513
|
|
173
|
+
tests/test_contour_format.py,sha256=N11Rue_mxxwsZBhocRA621bpt4Cps-1DnPGzhKLSq9o,10873
|
|
173
174
|
tests/test_events.py,sha256=eLFwwEEJfQAdwhews3-fn1HSvzozcNNFN_Qn0gOvQkE,685
|
|
174
175
|
tests/test_filters.py,sha256=uj4NVyUnKXa18EpTSiWCetGKI1VFopDyNSJSUxX44wA,1689
|
|
175
176
|
tests/test_io.py,sha256=gk5FmoI7ANEczUtNXYRxc48KzkfYzemwS_eYaLq4_NI,2093
|
|
176
|
-
tests/test_measure.py,sha256=
|
|
177
|
+
tests/test_measure.py,sha256=xmp-fuTzOpquiqnk3CJC0vAMLZIkcCPstx3Ru8sOtzU,10963
|
|
177
178
|
tests/test_neighborhood.py,sha256=gk5FmoI7ANEczUtNXYRxc48KzkfYzemwS_eYaLq4_NI,2093
|
|
178
179
|
tests/test_notebooks.py,sha256=7HVmYiytsz0QIJ11iRkGGs4_hzNjofXAUs_OZou3Gm0,301
|
|
179
180
|
tests/test_partial_install.py,sha256=G69-GNcJ9YNgs6K2bVTEZO0Jpb14xMRQWTm8A6VuIco,2841
|
|
@@ -184,12 +185,13 @@ tests/test_tracking.py,sha256=_YLjwQ3EChQssoHDfEnUJ7fI1yC5KEcJsFnAVR64L70,18909
|
|
|
184
185
|
tests/test_utils.py,sha256=aSB_eMw9fzTsnxxdYoNmdQQRrXkWqBXB7Uv4TGU6kYE,4778
|
|
185
186
|
tests/gui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
186
187
|
tests/gui/test_enhancements.py,sha256=3x9au_rkQtMZ94DRj3OaEHKPr511RrWqBAUAcNQn1ys,13453
|
|
188
|
+
tests/gui/test_event_annotator_cleanup.py,sha256=nIFWqC3za06VF5n8EDnK-URbir2WiXzECBeNaVNS_FI,11226
|
|
187
189
|
tests/gui/test_measure_annotator_bugfix.py,sha256=tPfgWNKC0UkvrVssSrUcVDC1qgpzx6l2yCqvKtKYkM4,4544
|
|
188
190
|
tests/gui/test_new_project.py,sha256=wRjW2vEaZb0LWT-f8G8-Ptk8CW9z8-FDPLpV5uqj6ck,8778
|
|
189
191
|
tests/gui/test_project.py,sha256=KzAnodIc0Ovta0ARL5Kr5PkOR5euA6qczT_GhEZpyE4,4710
|
|
190
192
|
tests/gui/test_spot_detection_viewer.py,sha256=mCEsfTAJb5W5IeLyQmaZXq9Sjr8ehCI552RkiCEQvLw,13355
|
|
191
|
-
celldetective-1.5.
|
|
192
|
-
celldetective-1.5.
|
|
193
|
-
celldetective-1.5.
|
|
194
|
-
celldetective-1.5.
|
|
195
|
-
celldetective-1.5.
|
|
193
|
+
celldetective-1.5.0b11.dist-info/METADATA,sha256=hdMBe_-bLh4h2hnn_mfZ-uA1ZUHY2ThZ31CzQJNqHVY,11524
|
|
194
|
+
celldetective-1.5.0b11.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
195
|
+
celldetective-1.5.0b11.dist-info/entry_points.txt,sha256=2NU6_EOByvPxqBbCvjwxlVlvnQreqZ3BKRCVIKEv3dg,62
|
|
196
|
+
celldetective-1.5.0b11.dist-info/top_level.txt,sha256=6rsIKKfGMKgud7HPuATcpq6EhdXwcg_yknBVWn9x4C4,20
|
|
197
|
+
celldetective-1.5.0b11.dist-info/RECORD,,
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for EventAnnotator and PairEventAnnotator closeEvent cleanup.
|
|
3
|
+
|
|
4
|
+
These tests verify that the memory leak fixes in closeEvent properly:
|
|
5
|
+
1. Close matplotlib figures
|
|
6
|
+
2. Stop and delete animations
|
|
7
|
+
3. Clear large data structures
|
|
8
|
+
4. Call super().closeEvent()
|
|
9
|
+
|
|
10
|
+
Bug prevented: Memory leaks from unclosed matplotlib figures and FuncAnimation
|
|
11
|
+
reference cycles when closing the annotator windows.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
import gc
|
|
16
|
+
import logging
|
|
17
|
+
from unittest.mock import MagicMock, patch, PropertyMock
|
|
18
|
+
import matplotlib.pyplot as plt
|
|
19
|
+
from PyQt5.QtGui import QCloseEvent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture(autouse=True)
|
|
23
|
+
def disable_logging():
|
|
24
|
+
"""Disable all logging to avoid Windows OSError with pytest capture."""
|
|
25
|
+
try:
|
|
26
|
+
logging.disable(logging.CRITICAL)
|
|
27
|
+
yield
|
|
28
|
+
finally:
|
|
29
|
+
logging.disable(logging.NOTSET)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestEventAnnotatorCloseEvent:
|
|
33
|
+
"""
|
|
34
|
+
Tests for EventAnnotator.closeEvent memory cleanup.
|
|
35
|
+
|
|
36
|
+
Bug: closeEvent did not properly close matplotlib figures or delete
|
|
37
|
+
the FuncAnimation, causing memory leaks due to reference cycles.
|
|
38
|
+
|
|
39
|
+
Fix: Added proper cleanup of fig, cell_fig, anim, stack, and df_tracks.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def test_closeevent_closes_matplotlib_figures(self, qtbot):
|
|
43
|
+
"""
|
|
44
|
+
Test that closeEvent properly closes matplotlib figures.
|
|
45
|
+
|
|
46
|
+
Steps:
|
|
47
|
+
1. Create a mock EventAnnotator with fig and cell_fig attributes
|
|
48
|
+
2. Call closeEvent
|
|
49
|
+
3. Verify plt.close was called for both figures
|
|
50
|
+
"""
|
|
51
|
+
with patch("celldetective.gui.event_annotator.plt") as mock_plt:
|
|
52
|
+
# Create a minimal mock that simulates the annotator
|
|
53
|
+
from celldetective.gui.event_annotator import EventAnnotator
|
|
54
|
+
|
|
55
|
+
# Mock the parent and initialization to avoid complex setup
|
|
56
|
+
with patch.object(EventAnnotator, "__init__", lambda self, parent: None):
|
|
57
|
+
annotator = EventAnnotator(None)
|
|
58
|
+
|
|
59
|
+
# Set up minimal attributes needed for closeEvent
|
|
60
|
+
annotator.fig = MagicMock()
|
|
61
|
+
annotator.cell_fig = MagicMock()
|
|
62
|
+
annotator.anim = MagicMock()
|
|
63
|
+
annotator.anim.event_source = MagicMock()
|
|
64
|
+
annotator.stack = MagicMock()
|
|
65
|
+
annotator.df_tracks = MagicMock()
|
|
66
|
+
annotator.stop = MagicMock()
|
|
67
|
+
|
|
68
|
+
# Mock stop_btn for stop() method if needed
|
|
69
|
+
annotator.stop_btn = MagicMock()
|
|
70
|
+
annotator.start_btn = MagicMock()
|
|
71
|
+
annotator.prev_frame_btn = MagicMock()
|
|
72
|
+
annotator.next_frame_btn = MagicMock()
|
|
73
|
+
|
|
74
|
+
# Create a real QCloseEvent
|
|
75
|
+
event = QCloseEvent()
|
|
76
|
+
|
|
77
|
+
# Patch super().closeEvent to avoid Qt issues
|
|
78
|
+
with patch.object(EventAnnotator.__bases__[0], "closeEvent"):
|
|
79
|
+
EventAnnotator.closeEvent(annotator, event)
|
|
80
|
+
|
|
81
|
+
# Verify figures were closed
|
|
82
|
+
assert mock_plt.close.call_count >= 2
|
|
83
|
+
|
|
84
|
+
def test_closeevent_stops_animation(self, qtbot):
|
|
85
|
+
"""
|
|
86
|
+
Test that closeEvent stops the animation.
|
|
87
|
+
|
|
88
|
+
Steps:
|
|
89
|
+
1. Create mock annotator with anim attribute
|
|
90
|
+
2. Call closeEvent
|
|
91
|
+
3. Verify animation event_source.stop() was called
|
|
92
|
+
"""
|
|
93
|
+
from celldetective.gui.event_annotator import EventAnnotator
|
|
94
|
+
|
|
95
|
+
with patch.object(EventAnnotator, "__init__", lambda self, parent: None):
|
|
96
|
+
annotator = EventAnnotator(None)
|
|
97
|
+
|
|
98
|
+
# Set up animation mock
|
|
99
|
+
mock_anim = MagicMock()
|
|
100
|
+
mock_anim.event_source = MagicMock()
|
|
101
|
+
annotator.anim = mock_anim
|
|
102
|
+
|
|
103
|
+
# Set up other required attributes
|
|
104
|
+
annotator.fig = MagicMock()
|
|
105
|
+
annotator.cell_fig = MagicMock()
|
|
106
|
+
annotator.stack = MagicMock()
|
|
107
|
+
annotator.df_tracks = MagicMock()
|
|
108
|
+
annotator.stop = MagicMock()
|
|
109
|
+
annotator.stop_btn = MagicMock()
|
|
110
|
+
annotator.start_btn = MagicMock()
|
|
111
|
+
annotator.prev_frame_btn = MagicMock()
|
|
112
|
+
annotator.next_frame_btn = MagicMock()
|
|
113
|
+
|
|
114
|
+
event = QCloseEvent()
|
|
115
|
+
|
|
116
|
+
with patch("celldetective.gui.event_annotator.plt"):
|
|
117
|
+
with patch.object(EventAnnotator.__bases__[0], "closeEvent"):
|
|
118
|
+
EventAnnotator.closeEvent(annotator, event)
|
|
119
|
+
|
|
120
|
+
# Verify animation was stopped
|
|
121
|
+
mock_anim.event_source.stop.assert_called_once()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TestPairEventAnnotatorCloseEvent:
|
|
125
|
+
"""
|
|
126
|
+
Tests for PairEventAnnotator.closeEvent memory cleanup.
|
|
127
|
+
|
|
128
|
+
Bug: closeEvent only deleted self.stack and didn't close figures,
|
|
129
|
+
stop animations, or clear dataframes.
|
|
130
|
+
|
|
131
|
+
Fix: Added proper cleanup of fig, cell_fig, anim, stack, dataframes,
|
|
132
|
+
and df_relative. Also calls super().closeEvent().
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def test_closeevent_clears_dataframes(self, qtbot):
|
|
136
|
+
"""
|
|
137
|
+
Test that closeEvent properly clears dataframes dictionary.
|
|
138
|
+
|
|
139
|
+
Steps:
|
|
140
|
+
1. Create mock PairEventAnnotator with dataframes attribute
|
|
141
|
+
2. Call closeEvent
|
|
142
|
+
3. Verify dataframes.clear() was called
|
|
143
|
+
"""
|
|
144
|
+
from celldetective.gui.pair_event_annotator import PairEventAnnotator
|
|
145
|
+
|
|
146
|
+
with patch.object(PairEventAnnotator, "__init__", lambda self, parent: None):
|
|
147
|
+
annotator = PairEventAnnotator(None)
|
|
148
|
+
|
|
149
|
+
# Set up dataframes mock
|
|
150
|
+
mock_dataframes = MagicMock()
|
|
151
|
+
annotator.dataframes = mock_dataframes
|
|
152
|
+
|
|
153
|
+
# Set up other required attributes
|
|
154
|
+
annotator.fig = MagicMock()
|
|
155
|
+
annotator.cell_fig = MagicMock()
|
|
156
|
+
annotator.anim = MagicMock()
|
|
157
|
+
annotator.anim.event_source = MagicMock()
|
|
158
|
+
annotator.stack = MagicMock()
|
|
159
|
+
annotator.df_relative = MagicMock()
|
|
160
|
+
annotator.stop = MagicMock()
|
|
161
|
+
annotator.stop_btn = MagicMock()
|
|
162
|
+
annotator.start_btn = MagicMock()
|
|
163
|
+
annotator.prev_frame_btn = MagicMock()
|
|
164
|
+
annotator.next_frame_btn = MagicMock()
|
|
165
|
+
|
|
166
|
+
event = QCloseEvent()
|
|
167
|
+
|
|
168
|
+
with patch("celldetective.gui.pair_event_annotator.plt"):
|
|
169
|
+
with patch.object(PairEventAnnotator.__bases__[0], "closeEvent"):
|
|
170
|
+
PairEventAnnotator.closeEvent(annotator, event)
|
|
171
|
+
|
|
172
|
+
# Verify dataframes.clear() was called
|
|
173
|
+
mock_dataframes.clear.assert_called_once()
|
|
174
|
+
|
|
175
|
+
def test_closeevent_deletes_df_relative(self):
|
|
176
|
+
"""
|
|
177
|
+
Test that closeEvent code deletes df_relative.
|
|
178
|
+
|
|
179
|
+
Steps:
|
|
180
|
+
1. Inspect the closeEvent source code
|
|
181
|
+
2. Verify it contains the delete statement for df_relative
|
|
182
|
+
"""
|
|
183
|
+
import inspect
|
|
184
|
+
from celldetective.gui.pair_event_annotator import PairEventAnnotator
|
|
185
|
+
|
|
186
|
+
source = inspect.getsource(PairEventAnnotator.closeEvent)
|
|
187
|
+
|
|
188
|
+
# Verify the cleanup code exists
|
|
189
|
+
assert "del self.df_relative" in source or "df_relative" in source
|
|
190
|
+
|
|
191
|
+
def test_closeevent_closes_figures(self, qtbot):
|
|
192
|
+
"""
|
|
193
|
+
Test that closeEvent properly closes matplotlib figures.
|
|
194
|
+
|
|
195
|
+
Steps:
|
|
196
|
+
1. Create mock PairEventAnnotator with fig and cell_fig
|
|
197
|
+
2. Call closeEvent
|
|
198
|
+
3. Verify plt.close was called
|
|
199
|
+
"""
|
|
200
|
+
from celldetective.gui.pair_event_annotator import PairEventAnnotator
|
|
201
|
+
|
|
202
|
+
with patch("celldetective.gui.pair_event_annotator.plt") as mock_plt:
|
|
203
|
+
with patch.object(
|
|
204
|
+
PairEventAnnotator, "__init__", lambda self, parent: None
|
|
205
|
+
):
|
|
206
|
+
annotator = PairEventAnnotator(None)
|
|
207
|
+
|
|
208
|
+
# Set up required attributes
|
|
209
|
+
annotator.fig = MagicMock()
|
|
210
|
+
annotator.cell_fig = MagicMock()
|
|
211
|
+
annotator.anim = MagicMock()
|
|
212
|
+
annotator.anim.event_source = MagicMock()
|
|
213
|
+
annotator.stack = MagicMock()
|
|
214
|
+
annotator.dataframes = {}
|
|
215
|
+
annotator.df_relative = MagicMock()
|
|
216
|
+
annotator.stop = MagicMock()
|
|
217
|
+
annotator.stop_btn = MagicMock()
|
|
218
|
+
annotator.start_btn = MagicMock()
|
|
219
|
+
annotator.prev_frame_btn = MagicMock()
|
|
220
|
+
annotator.next_frame_btn = MagicMock()
|
|
221
|
+
|
|
222
|
+
event = QCloseEvent()
|
|
223
|
+
|
|
224
|
+
with patch.object(PairEventAnnotator.__bases__[0], "closeEvent"):
|
|
225
|
+
PairEventAnnotator.closeEvent(annotator, event)
|
|
226
|
+
|
|
227
|
+
# Verify figures were closed
|
|
228
|
+
assert mock_plt.close.call_count >= 2
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class TestPairEventAnnotatorNoDuplicateMethods:
|
|
232
|
+
"""
|
|
233
|
+
Test that duplicate method definitions have been removed.
|
|
234
|
+
|
|
235
|
+
Bug: set_first_frame and set_last_frame were defined twice in the class,
|
|
236
|
+
with the later definition shadowing the earlier one.
|
|
237
|
+
|
|
238
|
+
Fix: Removed the simpler first definitions, keeping the more complete versions.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def test_no_duplicate_set_first_frame(self):
|
|
242
|
+
"""
|
|
243
|
+
Test that set_first_frame is defined only once.
|
|
244
|
+
|
|
245
|
+
Steps:
|
|
246
|
+
1. Import PairEventAnnotator
|
|
247
|
+
2. Use inspect to find all method definitions
|
|
248
|
+
3. Verify set_first_frame appears only once
|
|
249
|
+
"""
|
|
250
|
+
import inspect
|
|
251
|
+
from celldetective.gui.pair_event_annotator import PairEventAnnotator
|
|
252
|
+
|
|
253
|
+
# Get the source code
|
|
254
|
+
source = inspect.getsource(PairEventAnnotator)
|
|
255
|
+
|
|
256
|
+
# Count occurrences of 'def set_first_frame'
|
|
257
|
+
count = source.count("def set_first_frame(")
|
|
258
|
+
|
|
259
|
+
assert count == 1, f"set_first_frame is defined {count} times, expected 1"
|
|
260
|
+
|
|
261
|
+
def test_no_duplicate_set_last_frame(self):
|
|
262
|
+
"""
|
|
263
|
+
Test that set_last_frame is defined only once.
|
|
264
|
+
|
|
265
|
+
Steps:
|
|
266
|
+
1. Import PairEventAnnotator
|
|
267
|
+
2. Use inspect to find all method definitions
|
|
268
|
+
3. Verify set_last_frame appears only once
|
|
269
|
+
"""
|
|
270
|
+
import inspect
|
|
271
|
+
from celldetective.gui.pair_event_annotator import PairEventAnnotator
|
|
272
|
+
|
|
273
|
+
# Get the source code
|
|
274
|
+
source = inspect.getsource(PairEventAnnotator)
|
|
275
|
+
|
|
276
|
+
# Count occurrences of 'def set_last_frame'
|
|
277
|
+
count = source.count("def set_last_frame(")
|
|
278
|
+
|
|
279
|
+
assert count == 1, f"set_last_frame is defined {count} times, expected 1"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class TestPairEventAnnotatorNoNeighborhoodsError:
|
|
283
|
+
"""
|
|
284
|
+
Test that PairEventAnnotator raises ValueError when no neighborhoods detected.
|
|
285
|
+
|
|
286
|
+
Bug: PairEventAnnotator crashed with KeyError when opened without computed
|
|
287
|
+
neighborhoods.
|
|
288
|
+
|
|
289
|
+
Fix: Added check for empty neighborhood_cols and raise ValueError with
|
|
290
|
+
user-friendly message.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
def test_raises_valueerror_on_empty_neighborhoods(self, qtbot):
|
|
294
|
+
"""
|
|
295
|
+
Test that ValueError is raised when neighborhood_cols is empty.
|
|
296
|
+
|
|
297
|
+
Steps:
|
|
298
|
+
1. Mock PairEventAnnotator initialization to simulate empty neighborhoods
|
|
299
|
+
2. Verify ValueError is raised with appropriate message
|
|
300
|
+
"""
|
|
301
|
+
# This is a more complex test that would require mocking the entire
|
|
302
|
+
# initialization chain. For now, we test the check exists in the code.
|
|
303
|
+
import inspect
|
|
304
|
+
from celldetective.gui.pair_event_annotator import PairEventAnnotator
|
|
305
|
+
|
|
306
|
+
source = inspect.getsource(PairEventAnnotator.__init__)
|
|
307
|
+
|
|
308
|
+
# Verify the check exists
|
|
309
|
+
assert "len(self.neighborhood_cols) == 0" in source
|
|
310
|
+
assert "raise ValueError" in source
|
|
@@ -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
|