celldetective 1.5.0b8__py3-none-any.whl → 1.5.0b10__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.
@@ -0,0 +1,394 @@
1
+ import pytest
2
+ import numpy as np
3
+ import logging
4
+ from PyQt5.QtWidgets import QApplication
5
+ from celldetective.gui.viewers.spot_detection_viewer import SpotDetectionVisualizer
6
+ from celldetective.gui.gui_utils import PreprocessingLayout2
7
+ from unittest.mock import MagicMock, patch
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def disable_logging():
12
+ """Disable all logging to avoid Windows OSError with pytest capture."""
13
+ logger = logging.getLogger()
14
+ try:
15
+ logging.disable(logging.CRITICAL)
16
+ yield
17
+ finally:
18
+ logging.disable(logging.NOTSET)
19
+
20
+
21
+ @pytest.fixture
22
+ def dummy_data():
23
+ """
24
+ Create a dummy stack: 5 frames, 100x100 pixels, 2 channels.
25
+ Channel 0: Clean background (zeros)
26
+ Channel 1: Two Gaussian spots with high intensity
27
+ """
28
+ frames = 5
29
+ y, x = 100, 100
30
+ channels = 2
31
+
32
+ stack = np.zeros((frames, y, x, channels), dtype=np.float32)
33
+
34
+ # Create coordinate grids
35
+ Y, X = np.ogrid[:y, :x]
36
+
37
+ # Gaussian spot 1 at (22, 22) - center of mask 1
38
+ center_y1, center_x1 = 22, 22
39
+ sigma = 2.0
40
+ gaussian1 = np.exp(-((Y - center_y1) ** 2 + (X - center_x1) ** 2) / (2 * sigma**2))
41
+
42
+ # Gaussian spot 2 at (62, 62) - center of mask 2
43
+ center_y2, center_x2 = 62, 62
44
+ gaussian2 = np.exp(-((Y - center_y2) ** 2 + (X - center_x2) ** 2) / (2 * sigma**2))
45
+
46
+ # Add to stack with high intensity (1000 for spot 1, 800 for spot 2)
47
+ spots_frame = (gaussian1 * 1000 + gaussian2 * 800).astype(np.float32)
48
+ for f in range(frames):
49
+ stack[f, :, :, 1] = spots_frame
50
+
51
+ # Channel 0 stays at zero (clean background)
52
+
53
+ # Create dummy masks (labels) - each spot is inside its own cell
54
+ masks = np.zeros((frames, y, x), dtype=np.uint16)
55
+ masks[:, 15:30, 15:30] = 1 # Mask around Spot 1 (22, 22)
56
+ masks[:, 55:70, 55:70] = 2 # Mask around Spot 2 (62, 62)
57
+
58
+ return stack, masks
59
+
60
+
61
+ def test_spot_detection_visualizer_interactions(qtbot, dummy_data):
62
+ """
63
+ Test interactions with SpotDetectionVisualizer.
64
+ """
65
+ stack, labels = dummy_data
66
+ channel_names = ["Background", "Spots"]
67
+
68
+ # Mock parent widgets that might be updated by the visualizer
69
+ parent_channel_cb = MagicMock()
70
+ parent_diameter_le = MagicMock()
71
+ parent_threshold_le = MagicMock()
72
+ parent_preprocessing_list = MagicMock()
73
+
74
+ viewer = SpotDetectionVisualizer(
75
+ stack=stack,
76
+ labels=labels,
77
+ channel_names=channel_names,
78
+ n_channels=2,
79
+ parent_channel_cb=parent_channel_cb,
80
+ parent_diameter_le=parent_diameter_le,
81
+ parent_threshold_le=parent_threshold_le,
82
+ parent_preprocessing_list=parent_preprocessing_list,
83
+ window_title="Test Spot Detective",
84
+ channel_cb=True,
85
+ contrast_slider=False,
86
+ frame_slider=False,
87
+ )
88
+
89
+ qtbot.addWidget(viewer)
90
+ viewer.show()
91
+ qtbot.waitForWindowShown(viewer)
92
+
93
+ # 1. Test Channel Selection
94
+ # Default is target_channel=0 (Background)
95
+ assert viewer.detection_channel == 0
96
+
97
+ # Switch to Spots channel (Index 1)
98
+ viewer.detection_channel_cb.setCurrentIndex(1)
99
+ assert viewer.detection_channel == 1
100
+
101
+ # Force frame update to ensure target_img is correct for channel 1
102
+ viewer.change_frame(0)
103
+
104
+ # Verify image updated to Channel 1, Frame 0
105
+ current_img = viewer.target_img
106
+ expected_img = stack[0, :, :, 1]
107
+ np.testing.assert_array_equal(current_img, expected_img)
108
+
109
+ # 2. Test Spot Detection Parameters
110
+ # Set Diameter (LoG works best with diameter ~ 2*sqrt(2)*sigma ~ 5.6 for sigma=2)
111
+ viewer.spot_diam_le.clear()
112
+ qtbot.keyClicks(viewer.spot_diam_le, "4")
113
+ assert viewer.spot_diam_le.text() == "4"
114
+
115
+ # Set Threshold (low threshold to ensure detection)
116
+ viewer.spot_thresh_le.clear()
117
+ qtbot.keyClicks(viewer.spot_thresh_le, "0.01")
118
+ assert viewer.spot_thresh_le.text() == "0.01"
119
+
120
+ # Manually trigger control_valid_parameters to update self.diameter and self.thresh
121
+ viewer.control_valid_parameters()
122
+
123
+ # Verify parameters were set
124
+ assert viewer.diameter == 4.0
125
+ assert viewer.thresh == 0.01
126
+
127
+ # Trigger detection by clicking apply button
128
+ qtbot.mouseClick(viewer.apply_diam_btn, 1) # Qt.LeftButton = 1
129
+ qtbot.wait(200) # Wait for detection to complete
130
+
131
+ # Check that we recovered 2 spots
132
+ # In dummy_data: Spot 1 at (22, 22), Spot 2 at (62, 62)
133
+ n_spots = (
134
+ len(viewer.spot_positions)
135
+ if hasattr(viewer, "spot_positions") and viewer.spot_positions is not None
136
+ else 0
137
+ )
138
+ assert (
139
+ n_spots == 2
140
+ ), f"Expected 2 spots, found {n_spots}. Positions: {viewer.spot_positions if hasattr(viewer, 'spot_positions') else 'N/A'}"
141
+
142
+ # Verify positions roughly match (22, 22) and (62, 62)
143
+ # spot_positions are (x, y) pairs
144
+ pos = viewer.spot_positions
145
+ has_spot_1 = np.any(np.all(np.abs(pos - [22, 22]) < 5, axis=1))
146
+ has_spot_2 = np.any(np.all(np.abs(pos - [62, 62]) < 5, axis=1))
147
+ assert has_spot_1, f"Spot 1 not found near (22, 22). Positions: {pos}"
148
+ assert has_spot_2, f"Spot 2 not found near (62, 62). Positions: {pos}"
149
+
150
+ # 3. Test Preprocessing and Preview
151
+ # Ensure preview is unchecked initially
152
+ assert not viewer.preview_cb.isChecked()
153
+
154
+ # Check "Preview" - should show original image if filter list is empty
155
+ viewer.preview_cb.setChecked(True)
156
+ assert viewer.preview_cb.isChecked()
157
+ # Image should still match original target since no filters
158
+ np.testing.assert_array_equal(viewer.im.get_array(), expected_img)
159
+
160
+ # Add a filter: "gauss" with sigma=2
161
+ # Directly manipulate the list since dialog interaction is complex
162
+ viewer.preprocessing.list.items.append(["gauss", 2])
163
+ viewer.preprocessing.list.list_widget.addItems(["gauss_filter"])
164
+
165
+ # Force preview update
166
+ viewer.update_preview_if_active()
167
+ qtbot.wait(200)
168
+
169
+ preview_img = viewer.im.get_array()
170
+ assert not np.array_equal(
171
+ preview_img, expected_img
172
+ ), "Preview image should differ after adding gaussian filter"
173
+
174
+ # 4. Remove Filter
175
+ # Select item 0
176
+ viewer.preprocessing.list.list_widget.setCurrentRow(0)
177
+ # Click remove button
178
+ viewer.preprocessing.delete_filter_btn.click()
179
+
180
+ qtbot.wait(200)
181
+
182
+ # Internal items should be empty
183
+ assert len(viewer.preprocessing.list.items) == 0
184
+
185
+ # Preview should revert to original
186
+ reverted_img = viewer.im.get_array()
187
+ np.testing.assert_array_equal(reverted_img, expected_img)
188
+
189
+
190
+ @pytest.fixture
191
+ def dark_spots_data():
192
+ """
193
+ Create a dummy stack with DARK spots on a BRIGHT background.
194
+ This simulates scenarios like:
195
+ - Absorbing particles on a bright field
196
+ - Phase contrast imaging with dark nuclei
197
+ The invert preprocessing filter should be used to detect these spots.
198
+ """
199
+ frames = 5
200
+ y, x = 100, 100
201
+ channels = 2
202
+
203
+ # Bright background (value 1000)
204
+ stack = np.ones((frames, y, x, channels), dtype=np.float32) * 1000
205
+
206
+ # Create coordinate grids
207
+ Y, X = np.ogrid[:y, :x]
208
+
209
+ # Dark Gaussian spot 1 at (22, 22)
210
+ center_y1, center_x1 = 22, 22
211
+ sigma = 2.0
212
+ gaussian1 = np.exp(-((Y - center_y1) ** 2 + (X - center_x1) ** 2) / (2 * sigma**2))
213
+
214
+ # Dark Gaussian spot 2 at (62, 62)
215
+ center_y2, center_x2 = 62, 62
216
+ gaussian2 = np.exp(-((Y - center_y2) ** 2 + (X - center_x2) ** 2) / (2 * sigma**2))
217
+
218
+ # Subtract from bright background to create dark spots
219
+ # Spot 1: drops to ~0 at center, Spot 2: drops to ~200 at center
220
+ dark_spots_frame = 1000 - (gaussian1 * 1000 + gaussian2 * 800)
221
+ dark_spots_frame = dark_spots_frame.astype(np.float32)
222
+
223
+ for f in range(frames):
224
+ stack[f, :, :, 1] = dark_spots_frame
225
+
226
+ # Channel 0 stays uniform bright (no spots)
227
+
228
+ # Create dummy masks
229
+ masks = np.zeros((frames, y, x), dtype=np.uint16)
230
+ masks[:, 15:30, 15:30] = 1 # Mask around Spot 1
231
+ masks[:, 55:70, 55:70] = 2 # Mask around Spot 2
232
+
233
+ return stack, masks
234
+
235
+
236
+ def test_dark_spot_detection_with_invert(qtbot, dark_spots_data):
237
+ """
238
+ Test detection of dark spots on a bright background using the invert filter.
239
+
240
+ Bug Prevention:
241
+ - Ensures the preprocessing pipeline (invert) correctly transforms images
242
+ before spot detection.
243
+ - Verifies that the detection uses the preprocessed image, not the raw image.
244
+
245
+ Steps:
246
+ 1. Load stack with dark spots on bright background.
247
+ 2. Switch to the dark spots channel.
248
+ 3. Add an "invert" preprocessing filter with max value 1000.
249
+ 4. Set detection parameters (diameter, threshold).
250
+ 5. Trigger detection.
251
+ 6. Verify that both dark spots are detected.
252
+
253
+ Expected Outcome:
254
+ - 2 spots detected at positions (22, 22) and (62, 62).
255
+ """
256
+ stack, labels = dark_spots_data
257
+ channel_names = ["Uniform", "DarkSpots"]
258
+
259
+ parent_channel_cb = MagicMock()
260
+ parent_diameter_le = MagicMock()
261
+ parent_threshold_le = MagicMock()
262
+ parent_preprocessing_list = MagicMock()
263
+
264
+ viewer = SpotDetectionVisualizer(
265
+ stack=stack,
266
+ labels=labels,
267
+ channel_names=channel_names,
268
+ n_channels=2,
269
+ parent_channel_cb=parent_channel_cb,
270
+ parent_diameter_le=parent_diameter_le,
271
+ parent_threshold_le=parent_threshold_le,
272
+ parent_preprocessing_list=parent_preprocessing_list,
273
+ window_title="Test Dark Spot Detection",
274
+ channel_cb=True,
275
+ contrast_slider=False,
276
+ frame_slider=False,
277
+ )
278
+
279
+ qtbot.addWidget(viewer)
280
+ viewer.show()
281
+ qtbot.waitForWindowShown(viewer)
282
+
283
+ # 1. Switch to DarkSpots channel (Index 1)
284
+ viewer.detection_channel_cb.setCurrentIndex(1)
285
+ assert viewer.detection_channel == 1
286
+ viewer.change_frame(0)
287
+
288
+ # Verify image is bright with dark spots
289
+ current_img = viewer.target_img
290
+ # The center of spot 1 (22, 22) should be dark (near 0)
291
+ assert (
292
+ current_img[22, 22] < 100
293
+ ), f"Expected dark spot at (22,22), got {current_img[22, 22]}"
294
+ # The background should be bright (near 1000)
295
+ assert (
296
+ current_img[0, 0] > 900
297
+ ), f"Expected bright background, got {current_img[0, 0]}"
298
+
299
+ # 2. Add invert preprocessing filter
300
+ # invert filter inverts the image: output = max_value - input
301
+ # With max_value=1000, dark spots become bright spots
302
+ viewer.preprocessing.list.items.append(["invert", 1000])
303
+ viewer.preprocessing.list.list_widget.addItems(["invert_1000"])
304
+
305
+ # 3. Set detection parameters
306
+ viewer.spot_diam_le.clear()
307
+ qtbot.keyClicks(viewer.spot_diam_le, "4")
308
+ viewer.spot_thresh_le.clear()
309
+ qtbot.keyClicks(viewer.spot_thresh_le, "0.01")
310
+ viewer.control_valid_parameters()
311
+
312
+ assert viewer.diameter == 4.0
313
+ assert viewer.thresh == 0.01
314
+
315
+ # Enable preview to verify inversion works
316
+ viewer.preview_cb.setChecked(True)
317
+ viewer.update_preview_if_active()
318
+ qtbot.wait(200)
319
+
320
+ # After inversion, the former dark spot at (22, 22) should now be bright
321
+ preview_img = viewer.im.get_array()
322
+ assert (
323
+ preview_img[22, 22] > 900
324
+ ), f"After invert, spot should be bright. Got {preview_img[22, 22]}"
325
+
326
+ # 4. Trigger detection
327
+ qtbot.mouseClick(viewer.apply_diam_btn, 1)
328
+ qtbot.wait(200)
329
+
330
+ # 5. Verify spots detected
331
+ n_spots = (
332
+ len(viewer.spot_positions)
333
+ if hasattr(viewer, "spot_positions") and viewer.spot_positions is not None
334
+ else 0
335
+ )
336
+ assert (
337
+ n_spots == 2
338
+ ), f"Expected 2 dark spots detected, found {n_spots}. Positions: {getattr(viewer, 'spot_positions', 'N/A')}"
339
+
340
+ # Verify positions
341
+ pos = viewer.spot_positions
342
+ has_spot_1 = np.any(np.all(np.abs(pos - [22, 22]) < 5, axis=1))
343
+ has_spot_2 = np.any(np.all(np.abs(pos - [62, 62]) < 5, axis=1))
344
+ assert has_spot_1, f"Dark Spot 1 not found near (22, 22). Positions: {pos}"
345
+ assert has_spot_2, f"Dark Spot 2 not found near (62, 62). Positions: {pos}"
346
+
347
+
348
+ def test_viewer_initializes_from_parent_values(qtbot, dummy_data):
349
+ """
350
+ Test that the viewer initializes its widgets from initial_* arguments.
351
+ """
352
+ stack, labels = dummy_data
353
+ channel_names = ["Background", "Spots"]
354
+
355
+ # Mock parent widgets
356
+ parent_channel_cb = MagicMock()
357
+ parent_diameter_le = MagicMock()
358
+ parent_threshold_le = MagicMock()
359
+ parent_preprocessing_list = MagicMock()
360
+
361
+ initial_params = [["gauss", 1.5], ["invert", 500]]
362
+
363
+ viewer = SpotDetectionVisualizer(
364
+ stack=stack,
365
+ labels=labels,
366
+ channel_names=channel_names,
367
+ n_channels=2,
368
+ parent_channel_cb=parent_channel_cb,
369
+ parent_diameter_le=parent_diameter_le,
370
+ parent_threshold_le=parent_threshold_le,
371
+ parent_preprocessing_list=parent_preprocessing_list,
372
+ window_title="Test Init Values",
373
+ channel_cb=True,
374
+ contrast_slider=False,
375
+ frame_slider=False,
376
+ # Pass initial values
377
+ initial_diameter="8.5",
378
+ initial_threshold="1.2",
379
+ initial_preprocessing=initial_params,
380
+ )
381
+
382
+ qtbot.addWidget(viewer)
383
+ viewer.show()
384
+ qtbot.waitForWindowShown(viewer)
385
+
386
+ # Verify widgets were initialized with passed values
387
+ assert viewer.spot_diam_le.text() == "8.5"
388
+ assert viewer.spot_thresh_le.text() == "1.2"
389
+
390
+ # Verify preprocessing list was populated
391
+ current_filters = viewer.preprocessing.list.items
392
+ assert len(current_filters) == 2
393
+ assert current_filters[0] == ["gauss", 1.5]
394
+ assert current_filters[1] == ["invert", 500]