celldetective 1.5.0b11__py3-none-any.whl → 1.5.0b13__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,1083 @@
1
+ """
2
+ Tests for Measurement Settings UI.
3
+ Tests various combinations of measurement settings to ensure they can be set and run without bugs.
4
+ """
5
+
6
+ import pytest
7
+ import os
8
+ import numpy as np
9
+ import logging
10
+ import json
11
+ from PyQt5 import QtCore
12
+ import tifffile
13
+
14
+ from celldetective.gui.InitWindow import AppInitWindow
15
+ from celldetective.gui.settings._settings_measurements import SettingsMeasurements
16
+ from celldetective import get_software_location
17
+ from unittest.mock import patch
18
+
19
+
20
+ software_location = get_software_location()
21
+
22
+
23
+ @pytest.fixture(autouse=True)
24
+ def disable_logging():
25
+ """Disable all logging to avoid Windows OSError with pytest capture."""
26
+ logger = logging.getLogger()
27
+ try:
28
+ logging.disable(logging.CRITICAL)
29
+ yield
30
+ finally:
31
+ logging.disable(logging.NOTSET)
32
+
33
+
34
+ @pytest.fixture
35
+ def app(qtbot):
36
+ test_app = AppInitWindow(software_location=software_location)
37
+ qtbot.addWidget(test_app)
38
+ return test_app
39
+
40
+
41
+ def create_dummy_movie(
42
+ exp_dir, well="W1", pos="100", prefix="sample", frames=5, channels=2
43
+ ):
44
+ """Create a dummy movie with multiple channels."""
45
+ movie_dir = os.path.join(exp_dir, well, pos, "movie")
46
+ os.makedirs(movie_dir, exist_ok=True)
47
+ movie_path = os.path.join(movie_dir, f"{prefix}.tif")
48
+ # Create multi-channel, multi-frame movie
49
+ img = np.zeros((frames * channels, 100, 100), dtype=np.uint16)
50
+ tifffile.imwrite(movie_path, img)
51
+
52
+
53
+ def setup_experiment_dir(tmp_path, well="W1", pos="100"):
54
+ """Set up a complete experiment directory structure."""
55
+ exp_dir = str(tmp_path / "Experiment")
56
+ os.makedirs(os.path.join(exp_dir, well, pos, "output", "tables"), exist_ok=True)
57
+ os.makedirs(os.path.join(exp_dir, well, pos, "labels_targets"), exist_ok=True)
58
+ os.makedirs(os.path.join(exp_dir, "configs"), exist_ok=True)
59
+
60
+ with open(os.path.join(exp_dir, "config.ini"), "w") as f:
61
+ f.write(
62
+ "[MovieSettings]\nmovie_prefix = sample\nlen_movie = 10\nshape_x = 100\nshape_y = 100\npxtoum = 1.0\nframetomin = 1.0\n"
63
+ )
64
+ f.write(
65
+ "[Labels]\nconcentrations = 0\ncell_types = dummy\nantibodies = none\npharmaceutical_agents = none\n"
66
+ )
67
+ f.write("[Channels]\nDAPI = 0\nGFP = 1\n")
68
+
69
+ create_dummy_movie(
70
+ exp_dir, well=well, pos=pos, prefix="sample", frames=10, channels=2
71
+ )
72
+ return exp_dir
73
+
74
+
75
+ class TestMeasurementSettingsUI:
76
+ """Tests for SettingsMeasurements UI instantiation and basic functionality."""
77
+
78
+ def test_open_settings_panel(self, app, qtbot, tmp_path):
79
+ """Test that the measurement settings panel can be opened."""
80
+ exp_dir = setup_experiment_dir(tmp_path)
81
+
82
+ app.experiment_path_selection.setText(exp_dir)
83
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
84
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
85
+
86
+ cp = app.control_panel
87
+ p0 = cp.ProcessPopulations[0]
88
+
89
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
90
+
91
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
92
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
93
+ cp.update_position_options()
94
+ qtbot.wait(500)
95
+
96
+ # Click to open measurement settings
97
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
98
+
99
+ try:
100
+ qtbot.waitUntil(
101
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
102
+ )
103
+ except Exception:
104
+ pytest.skip("settings_measurements not available on p0")
105
+
106
+ settings = p0.settings_measurements
107
+ assert settings is not None
108
+ assert isinstance(settings, SettingsMeasurements)
109
+
110
+ settings.close()
111
+
112
+
113
+ class TestFeatureSettings:
114
+ """Tests for feature list configuration."""
115
+
116
+ def test_features_list_exists(self, app, qtbot, tmp_path):
117
+ """Test that features list widget exists and is populated."""
118
+ exp_dir = setup_experiment_dir(tmp_path)
119
+
120
+ app.experiment_path_selection.setText(exp_dir)
121
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
122
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
123
+
124
+ cp = app.control_panel
125
+ p0 = cp.ProcessPopulations[0]
126
+
127
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
128
+
129
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
130
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
131
+ cp.update_position_options()
132
+ qtbot.wait(500)
133
+
134
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
135
+
136
+ try:
137
+ qtbot.waitUntil(
138
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
139
+ )
140
+ except Exception:
141
+ pytest.skip("settings_measurements not available")
142
+
143
+ settings = p0.settings_measurements
144
+ assert hasattr(settings, "features_list")
145
+ assert settings.features_list is not None
146
+
147
+ # Default features should be populated
148
+ items = settings.features_list.getItems()
149
+ assert "area" in items or len(items) >= 0
150
+
151
+ settings.close()
152
+
153
+ def test_add_feature(self, app, qtbot, tmp_path):
154
+ """Test adding a feature to the list."""
155
+ exp_dir = setup_experiment_dir(tmp_path)
156
+
157
+ app.experiment_path_selection.setText(exp_dir)
158
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
159
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
160
+
161
+ cp = app.control_panel
162
+ p0 = cp.ProcessPopulations[0]
163
+
164
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
165
+
166
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
167
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
168
+ cp.update_position_options()
169
+ qtbot.wait(500)
170
+
171
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
172
+
173
+ try:
174
+ qtbot.waitUntil(
175
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
176
+ )
177
+ except Exception:
178
+ pytest.skip("settings_measurements not available")
179
+
180
+ settings = p0.settings_measurements
181
+
182
+ initial_count = settings.features_list.list_widget.count()
183
+ settings.add_feature_btn.click()
184
+ qtbot.wait(100)
185
+
186
+ new_count = settings.features_list.list_widget.count()
187
+ assert new_count >= initial_count
188
+
189
+ settings.close()
190
+
191
+
192
+ class TestContourSettings:
193
+ """Tests for contour measurement (border distance) settings."""
194
+
195
+ def test_contours_list_exists(self, app, qtbot, tmp_path):
196
+ """Test that contours list widget exists."""
197
+ exp_dir = setup_experiment_dir(tmp_path)
198
+
199
+ app.experiment_path_selection.setText(exp_dir)
200
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
201
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
202
+
203
+ cp = app.control_panel
204
+ p0 = cp.ProcessPopulations[0]
205
+
206
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
207
+
208
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
209
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
210
+ cp.update_position_options()
211
+ qtbot.wait(500)
212
+
213
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
214
+
215
+ try:
216
+ qtbot.waitUntil(
217
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
218
+ )
219
+ except Exception:
220
+ pytest.skip("settings_measurements not available")
221
+
222
+ settings = p0.settings_measurements
223
+ assert hasattr(settings, "contours_list")
224
+ assert settings.contours_list is not None
225
+
226
+ settings.close()
227
+
228
+ def test_add_contour_distance(self, app, qtbot, tmp_path):
229
+ """Test adding a contour distance."""
230
+ exp_dir = setup_experiment_dir(tmp_path)
231
+
232
+ app.experiment_path_selection.setText(exp_dir)
233
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
234
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
235
+
236
+ cp = app.control_panel
237
+ p0 = cp.ProcessPopulations[0]
238
+
239
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
240
+
241
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
242
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
243
+ cp.update_position_options()
244
+ qtbot.wait(500)
245
+
246
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
247
+
248
+ try:
249
+ qtbot.waitUntil(
250
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
251
+ )
252
+ except Exception:
253
+ pytest.skip("settings_measurements not available")
254
+
255
+ settings = p0.settings_measurements
256
+
257
+ initial_count = settings.contours_list.list_widget.count()
258
+ settings.add_contour_btn.click()
259
+ qtbot.wait(100)
260
+
261
+ new_count = settings.contours_list.list_widget.count()
262
+ assert new_count >= initial_count
263
+
264
+ settings.close()
265
+
266
+
267
+ class TestHaralickTextureSettings:
268
+ """Tests for Haralick texture measurement settings."""
269
+
270
+ def test_haralick_checkbox_exists(self, app, qtbot, tmp_path):
271
+ """Test that Haralick checkbox exists."""
272
+ exp_dir = setup_experiment_dir(tmp_path)
273
+
274
+ app.experiment_path_selection.setText(exp_dir)
275
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
276
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
277
+
278
+ cp = app.control_panel
279
+ p0 = cp.ProcessPopulations[0]
280
+
281
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
282
+
283
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
284
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
285
+ cp.update_position_options()
286
+ qtbot.wait(500)
287
+
288
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
289
+
290
+ try:
291
+ qtbot.waitUntil(
292
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
293
+ )
294
+ except Exception:
295
+ pytest.skip("settings_measurements not available")
296
+
297
+ settings = p0.settings_measurements
298
+ assert hasattr(settings, "activate_haralick_btn")
299
+ assert not settings.activate_haralick_btn.isChecked()
300
+
301
+ settings.close()
302
+
303
+ def test_enable_haralick_shows_options(self, app, qtbot, tmp_path):
304
+ """Test that enabling Haralick shows additional options."""
305
+ exp_dir = setup_experiment_dir(tmp_path)
306
+
307
+ app.experiment_path_selection.setText(exp_dir)
308
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
309
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
310
+
311
+ cp = app.control_panel
312
+ p0 = cp.ProcessPopulations[0]
313
+
314
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
315
+
316
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
317
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
318
+ cp.update_position_options()
319
+ qtbot.wait(500)
320
+
321
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
322
+
323
+ try:
324
+ qtbot.waitUntil(
325
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
326
+ )
327
+ except Exception:
328
+ pytest.skip("settings_measurements not available")
329
+
330
+ settings = p0.settings_measurements
331
+
332
+ # Initially Haralick options should be disabled
333
+ assert not settings.haralick_channel_choice.isEnabled()
334
+ assert not settings.haralick_distance_le.isEnabled()
335
+
336
+ # Enable Haralick
337
+ settings.activate_haralick_btn.setChecked(True)
338
+ qtbot.wait(100)
339
+
340
+ # Now options should be enabled
341
+ assert settings.haralick_channel_choice.isEnabled()
342
+ assert settings.haralick_distance_le.isEnabled()
343
+ assert settings.haralick_n_gray_levels_le.isEnabled()
344
+
345
+ settings.close()
346
+
347
+ def test_haralick_channel_selection(self, app, qtbot, tmp_path):
348
+ """Test Haralick channel selection."""
349
+ exp_dir = setup_experiment_dir(tmp_path)
350
+
351
+ app.experiment_path_selection.setText(exp_dir)
352
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
353
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
354
+
355
+ cp = app.control_panel
356
+ p0 = cp.ProcessPopulations[0]
357
+
358
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
359
+
360
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
361
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
362
+ cp.update_position_options()
363
+ qtbot.wait(500)
364
+
365
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
366
+
367
+ try:
368
+ qtbot.waitUntil(
369
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
370
+ )
371
+ except Exception:
372
+ pytest.skip("settings_measurements not available")
373
+
374
+ settings = p0.settings_measurements
375
+ settings.activate_haralick_btn.setChecked(True)
376
+ qtbot.wait(100)
377
+
378
+ # Should have channel options
379
+ assert settings.haralick_channel_choice.count() >= 1
380
+
381
+ # Change channel
382
+ if settings.haralick_channel_choice.count() > 1:
383
+ settings.haralick_channel_choice.setCurrentIndex(1)
384
+ qtbot.wait(50)
385
+ assert settings.haralick_channel_choice.currentIndex() == 1
386
+
387
+ settings.close()
388
+
389
+ def test_haralick_normalization_mode_toggle(self, app, qtbot, tmp_path):
390
+ """Test Haralick normalization mode toggle between percentile and absolute."""
391
+ exp_dir = setup_experiment_dir(tmp_path)
392
+
393
+ app.experiment_path_selection.setText(exp_dir)
394
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
395
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
396
+
397
+ cp = app.control_panel
398
+ p0 = cp.ProcessPopulations[0]
399
+
400
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
401
+
402
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
403
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
404
+ cp.update_position_options()
405
+ qtbot.wait(500)
406
+
407
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
408
+
409
+ try:
410
+ qtbot.waitUntil(
411
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
412
+ )
413
+ except Exception:
414
+ pytest.skip("settings_measurements not available")
415
+
416
+ settings = p0.settings_measurements
417
+ settings.activate_haralick_btn.setChecked(True)
418
+ qtbot.wait(100)
419
+
420
+ # Initially in percentile mode
421
+ assert settings.percentile_mode is True
422
+ assert (
423
+ "percentile" in settings.haralick_percentile_min_lbl.text().lower()
424
+ )
425
+
426
+ # Toggle to absolute mode
427
+ settings.haralick_normalization_mode_btn.click()
428
+ qtbot.wait(100)
429
+
430
+ assert settings.percentile_mode is False
431
+ assert "value" in settings.haralick_percentile_min_lbl.text().lower()
432
+
433
+ settings.close()
434
+
435
+
436
+ class TestIsotropicMeasurementSettings:
437
+ """Tests for isotropic (radii and operations) measurement settings."""
438
+
439
+ def test_radii_list_exists(self, app, qtbot, tmp_path):
440
+ """Test that radii list widget exists."""
441
+ exp_dir = setup_experiment_dir(tmp_path)
442
+
443
+ app.experiment_path_selection.setText(exp_dir)
444
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
445
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
446
+
447
+ cp = app.control_panel
448
+ p0 = cp.ProcessPopulations[0]
449
+
450
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
451
+
452
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
453
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
454
+ cp.update_position_options()
455
+ qtbot.wait(500)
456
+
457
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
458
+
459
+ try:
460
+ qtbot.waitUntil(
461
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
462
+ )
463
+ except Exception:
464
+ pytest.skip("settings_measurements not available")
465
+
466
+ settings = p0.settings_measurements
467
+ assert hasattr(settings, "radii_list")
468
+ assert settings.radii_list is not None
469
+
470
+ # Default should have at least one radius
471
+ items = settings.radii_list.getItems()
472
+ assert len(items) >= 0
473
+
474
+ settings.close()
475
+
476
+ def test_add_radius(self, app, qtbot, tmp_path):
477
+ """Test adding a radius."""
478
+ exp_dir = setup_experiment_dir(tmp_path)
479
+
480
+ app.experiment_path_selection.setText(exp_dir)
481
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
482
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
483
+
484
+ cp = app.control_panel
485
+ p0 = cp.ProcessPopulations[0]
486
+
487
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
488
+
489
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
490
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
491
+ cp.update_position_options()
492
+ qtbot.wait(500)
493
+
494
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
495
+
496
+ try:
497
+ qtbot.waitUntil(
498
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
499
+ )
500
+ except Exception:
501
+ pytest.skip("settings_measurements not available")
502
+
503
+ settings = p0.settings_measurements
504
+
505
+ initial_count = settings.radii_list.list_widget.count()
506
+ settings.add_radius_btn.click()
507
+ qtbot.wait(100)
508
+
509
+ new_count = settings.radii_list.list_widget.count()
510
+ assert new_count >= initial_count
511
+
512
+ settings.close()
513
+
514
+ def test_operations_list_exists(self, app, qtbot, tmp_path):
515
+ """Test that operations list widget exists."""
516
+ exp_dir = setup_experiment_dir(tmp_path)
517
+
518
+ app.experiment_path_selection.setText(exp_dir)
519
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
520
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
521
+
522
+ cp = app.control_panel
523
+ p0 = cp.ProcessPopulations[0]
524
+
525
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
526
+
527
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
528
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
529
+ cp.update_position_options()
530
+ qtbot.wait(500)
531
+
532
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
533
+
534
+ try:
535
+ qtbot.waitUntil(
536
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
537
+ )
538
+ except Exception:
539
+ pytest.skip("settings_measurements not available")
540
+
541
+ settings = p0.settings_measurements
542
+ assert hasattr(settings, "operations_list")
543
+ assert settings.operations_list is not None
544
+
545
+ settings.close()
546
+
547
+ def test_add_operation(self, app, qtbot, tmp_path):
548
+ """Test adding an operation."""
549
+ exp_dir = setup_experiment_dir(tmp_path)
550
+
551
+ app.experiment_path_selection.setText(exp_dir)
552
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
553
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
554
+
555
+ cp = app.control_panel
556
+ p0 = cp.ProcessPopulations[0]
557
+
558
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
559
+
560
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
561
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
562
+ cp.update_position_options()
563
+ qtbot.wait(500)
564
+
565
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
566
+
567
+ try:
568
+ qtbot.waitUntil(
569
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
570
+ )
571
+ except Exception:
572
+ pytest.skip("settings_measurements not available")
573
+
574
+ settings = p0.settings_measurements
575
+
576
+ initial_count = settings.operations_list.list_widget.count()
577
+ settings.add_op_btn.click()
578
+ qtbot.wait(100)
579
+
580
+ new_count = settings.operations_list.list_widget.count()
581
+ assert new_count >= initial_count
582
+
583
+ settings.close()
584
+
585
+
586
+ class TestSpotDetectionSettings:
587
+ """Tests for spot detection settings."""
588
+
589
+ def test_spot_detection_checkbox_exists(self, app, qtbot, tmp_path):
590
+ """Test that spot detection checkbox exists."""
591
+ exp_dir = setup_experiment_dir(tmp_path)
592
+
593
+ app.experiment_path_selection.setText(exp_dir)
594
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
595
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
596
+
597
+ cp = app.control_panel
598
+ p0 = cp.ProcessPopulations[0]
599
+
600
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
601
+
602
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
603
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
604
+ cp.update_position_options()
605
+ qtbot.wait(500)
606
+
607
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
608
+
609
+ try:
610
+ qtbot.waitUntil(
611
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
612
+ )
613
+ except Exception:
614
+ pytest.skip("settings_measurements not available")
615
+
616
+ settings = p0.settings_measurements
617
+ assert hasattr(settings, "spot_check")
618
+ assert not settings.spot_check.isChecked()
619
+
620
+ settings.close()
621
+
622
+ def test_enable_spot_detection(self, app, qtbot, tmp_path):
623
+ """Test enabling spot detection enables related widgets."""
624
+ exp_dir = setup_experiment_dir(tmp_path)
625
+
626
+ app.experiment_path_selection.setText(exp_dir)
627
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
628
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
629
+
630
+ cp = app.control_panel
631
+ p0 = cp.ProcessPopulations[0]
632
+
633
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
634
+
635
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
636
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
637
+ cp.update_position_options()
638
+ qtbot.wait(500)
639
+
640
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
641
+
642
+ try:
643
+ qtbot.waitUntil(
644
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
645
+ )
646
+ except Exception:
647
+ pytest.skip("settings_measurements not available")
648
+
649
+ settings = p0.settings_measurements
650
+
651
+ # Initially spot detection widgets are disabled
652
+ assert not settings.spot_channel.isEnabled()
653
+ assert not settings.diameter_value.isEnabled()
654
+
655
+ # Enable spot detection
656
+ settings.spot_check.setChecked(True)
657
+ qtbot.wait(100)
658
+
659
+ # Now widgets should be enabled
660
+ assert settings.spot_channel.isEnabled()
661
+ assert settings.diameter_value.isEnabled()
662
+ assert settings.threshold_value.isEnabled()
663
+
664
+ settings.close()
665
+
666
+ def test_spot_diameter_and_threshold(self, app, qtbot, tmp_path):
667
+ """Test setting spot diameter and threshold values."""
668
+ exp_dir = setup_experiment_dir(tmp_path)
669
+
670
+ app.experiment_path_selection.setText(exp_dir)
671
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
672
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
673
+
674
+ cp = app.control_panel
675
+ p0 = cp.ProcessPopulations[0]
676
+
677
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
678
+
679
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
680
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
681
+ cp.update_position_options()
682
+ qtbot.wait(500)
683
+
684
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
685
+
686
+ try:
687
+ qtbot.waitUntil(
688
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
689
+ )
690
+ except Exception:
691
+ pytest.skip("settings_measurements not available")
692
+
693
+ settings = p0.settings_measurements
694
+ settings.spot_check.setChecked(True)
695
+ qtbot.wait(100)
696
+
697
+ # Set diameter
698
+ settings.diameter_value.setText("11")
699
+ qtbot.wait(50)
700
+ assert settings.diameter_value.text() == "11"
701
+
702
+ # Set threshold
703
+ settings.threshold_value.setText("0.5")
704
+ qtbot.wait(50)
705
+ assert settings.threshold_value.text() == "0.5"
706
+
707
+ settings.close()
708
+
709
+
710
+ class TestWriteInstructions:
711
+ """Tests for writing measurement instructions to file."""
712
+
713
+ def test_write_instructions_basic(self, app, qtbot, tmp_path):
714
+ """Test writing basic measurement instructions."""
715
+ exp_dir = setup_experiment_dir(tmp_path)
716
+
717
+ app.experiment_path_selection.setText(exp_dir)
718
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
719
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
720
+
721
+ cp = app.control_panel
722
+ p0 = cp.ProcessPopulations[0]
723
+
724
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
725
+
726
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
727
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
728
+ cp.update_position_options()
729
+ qtbot.wait(500)
730
+
731
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
732
+
733
+ try:
734
+ qtbot.waitUntil(
735
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
736
+ )
737
+ except Exception:
738
+ pytest.skip("settings_measurements not available")
739
+
740
+ settings = p0.settings_measurements
741
+
742
+ # Click submit to write instructions
743
+ settings.submit_btn.click()
744
+ qtbot.wait(500)
745
+
746
+ # Check that instructions file was created
747
+ instructions_path = os.path.join(
748
+ exp_dir, "configs", f"measurement_instructions_{p0.mode}.json"
749
+ )
750
+
751
+ assert os.path.exists(instructions_path)
752
+
753
+ with open(instructions_path, "r") as f:
754
+ instructions = json.load(f)
755
+
756
+ assert "features" in instructions
757
+ assert "haralick_options" in instructions
758
+
759
+ def test_write_instructions_with_haralick(self, app, qtbot, tmp_path):
760
+ """Test writing instructions with Haralick enabled."""
761
+ exp_dir = setup_experiment_dir(tmp_path)
762
+
763
+ app.experiment_path_selection.setText(exp_dir)
764
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
765
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
766
+
767
+ cp = app.control_panel
768
+ p0 = cp.ProcessPopulations[0]
769
+
770
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
771
+
772
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
773
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
774
+ cp.update_position_options()
775
+ qtbot.wait(500)
776
+
777
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
778
+
779
+ try:
780
+ qtbot.waitUntil(
781
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
782
+ )
783
+ except Exception:
784
+ pytest.skip("settings_measurements not available")
785
+
786
+ settings = p0.settings_measurements
787
+
788
+ # Enable Haralick with custom settings
789
+ settings.activate_haralick_btn.setChecked(True)
790
+ qtbot.wait(100)
791
+
792
+ settings.haralick_distance_le.setText("3")
793
+ settings.haralick_n_gray_levels_le.setText("128")
794
+
795
+ # Submit
796
+ settings.submit_btn.click()
797
+ qtbot.wait(500)
798
+
799
+ instructions_path = os.path.join(
800
+ exp_dir, "configs", f"measurement_instructions_{p0.mode}.json"
801
+ )
802
+
803
+ with open(instructions_path, "r") as f:
804
+ instructions = json.load(f)
805
+
806
+ assert instructions["haralick_options"] is not None
807
+ assert instructions["haralick_options"]["distance"] == 3
808
+ assert instructions["haralick_options"]["n_intensity_bins"] == 128
809
+
810
+ def test_write_instructions_with_spot_detection(self, app, qtbot, tmp_path):
811
+ """Test writing instructions with spot detection enabled."""
812
+ exp_dir = setup_experiment_dir(tmp_path)
813
+
814
+ app.experiment_path_selection.setText(exp_dir)
815
+ qtbot.mouseClick(app.validate_button, QtCore.Qt.LeftButton)
816
+ qtbot.waitUntil(lambda: hasattr(app, "control_panel"), timeout=30000)
817
+
818
+ cp = app.control_panel
819
+ p0 = cp.ProcessPopulations[0]
820
+
821
+ qtbot.waitUntil(lambda: cp.well_list.count() > 0, timeout=30000)
822
+
823
+ with patch.object(cp.well_list, "getSelectedIndices", return_value=[0]):
824
+ with patch.object(cp.position_list, "getSelectedIndices", return_value=[0]):
825
+ cp.update_position_options()
826
+ qtbot.wait(500)
827
+
828
+ qtbot.mouseClick(p0.measurements_config_btn, QtCore.Qt.LeftButton)
829
+
830
+ try:
831
+ qtbot.waitUntil(
832
+ lambda: hasattr(p0, "settings_measurements"), timeout=15000
833
+ )
834
+ except Exception:
835
+ pytest.skip("settings_measurements not available")
836
+
837
+ settings = p0.settings_measurements
838
+
839
+ # Enable spot detection
840
+ settings.spot_check.setChecked(True)
841
+ qtbot.wait(100)
842
+
843
+ settings.diameter_value.setText("9")
844
+ settings.threshold_value.setText("0.3")
845
+
846
+ # Submit
847
+ settings.submit_btn.click()
848
+ qtbot.wait(500)
849
+
850
+ instructions_path = os.path.join(
851
+ exp_dir, "configs", f"measurement_instructions_{p0.mode}.json"
852
+ )
853
+
854
+ with open(instructions_path, "r") as f:
855
+ instructions = json.load(f)
856
+
857
+ assert instructions["spot_detection"] is not None
858
+ assert instructions["spot_detection"]["diameter"] == 9.0
859
+ assert instructions["spot_detection"]["threshold"] == 0.3
860
+
861
+
862
+ class TestMeasurementExecution:
863
+ """Tests that actually execute measurements to verify settings work without bugs."""
864
+
865
+ @pytest.fixture
866
+ def mock_data(self):
867
+ """Create mock stack, labels, and trajectories for testing."""
868
+ import pandas as pd
869
+
870
+ # Create a simple 5-frame, 2-channel stack (shape: T, Y, X, C)
871
+ np.random.seed(42)
872
+ stack = np.random.randint(0, 65535, (5, 100, 100, 2), dtype=np.uint16)
873
+
874
+ # Create labels with 3 cells per frame
875
+ labels = np.zeros((5, 100, 100), dtype=np.int32)
876
+ # Cell 1: centered at (25, 25)
877
+ labels[:, 20:30, 20:30] = 1
878
+ # Cell 2: centered at (50, 50)
879
+ labels[:, 45:55, 45:55] = 2
880
+ # Cell 3: centered at (75, 75)
881
+ labels[:, 70:80, 70:80] = 3
882
+
883
+ # Create trajectories DataFrame matching the labels
884
+ trajectories = pd.DataFrame(
885
+ {
886
+ "TRACK_ID": [1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3],
887
+ "FRAME": [0, 1, 2, 3, 4] * 3,
888
+ "POSITION_X": [25] * 5 + [50] * 5 + [75] * 5,
889
+ "POSITION_Y": [25] * 5 + [50] * 5 + [75] * 5,
890
+ "class_id": [1] * 15, # Required by measure()
891
+ }
892
+ )
893
+
894
+ channel_names = ["DAPI", "GFP"]
895
+
896
+ return stack, labels, trajectories, channel_names
897
+
898
+ def test_basic_features_measurement(self, mock_data):
899
+ """Test measuring basic features (area, intensity)."""
900
+ from celldetective.measure import measure
901
+
902
+ stack, labels, trajectories, channel_names = mock_data
903
+
904
+ result = measure(
905
+ stack=stack,
906
+ labels=labels,
907
+ trajectories=trajectories,
908
+ channel_names=channel_names,
909
+ features=["area", "intensity_mean"],
910
+ )
911
+
912
+ assert result is not None
913
+ assert len(result) == 15 # 3 cells * 5 frames
914
+ assert "area" in result.columns
915
+ # Intensity should be prefixed with channel name
916
+ assert any("intensity_mean" in col or "DAPI" in col for col in result.columns)
917
+
918
+ def test_isotropic_intensity_measurement(self, mock_data):
919
+ """Test measuring isotropic intensity with radii."""
920
+ from celldetective.measure import measure
921
+
922
+ stack, labels, trajectories, channel_names = mock_data
923
+
924
+ result = measure(
925
+ stack=stack,
926
+ labels=labels,
927
+ trajectories=trajectories,
928
+ channel_names=channel_names,
929
+ features=["area"],
930
+ intensity_measurement_radii=[5, 10],
931
+ isotropic_operations=["mean", "sum"],
932
+ )
933
+
934
+ assert result is not None
935
+ assert len(result) == 15
936
+ # Should have columns for circle intensities
937
+ cols = result.columns.tolist()
938
+ assert any("circle" in col.lower() or "5" in col for col in cols)
939
+
940
+ def test_border_distance_measurement(self, mock_data):
941
+ """Test measuring at border distances (contour measurements)."""
942
+ from celldetective.measure import measure
943
+
944
+ stack, labels, trajectories, channel_names = mock_data
945
+
946
+ result = measure(
947
+ stack=stack,
948
+ labels=labels,
949
+ trajectories=trajectories,
950
+ channel_names=channel_names,
951
+ features=["area", "intensity_mean"],
952
+ border_distances=[5, 10],
953
+ )
954
+
955
+ assert result is not None
956
+ assert len(result) == 15
957
+ # Should have columns for edge measurements
958
+ cols = result.columns.tolist()
959
+ assert any(
960
+ "edge" in col.lower() or "border" in col.lower() or "5px" in col.lower()
961
+ for col in cols
962
+ )
963
+
964
+ def test_haralick_texture_measurement(self, mock_data):
965
+ """Test measuring Haralick texture features."""
966
+ from celldetective.measure import measure
967
+
968
+ stack, labels, trajectories, channel_names = mock_data
969
+
970
+ haralick_options = {
971
+ "channel": "DAPI",
972
+ "channel_index": 0,
973
+ "distance": 1,
974
+ "n_intensity_bins": 64,
975
+ "percentile_mode": True,
976
+ "percentile_min": 0.01,
977
+ "percentile_max": 99.9,
978
+ }
979
+
980
+ result = measure(
981
+ stack=stack,
982
+ labels=labels,
983
+ trajectories=trajectories,
984
+ channel_names=channel_names,
985
+ features=["area"],
986
+ haralick_options=haralick_options,
987
+ )
988
+
989
+ assert result is not None
990
+ assert len(result) == 15
991
+ # Haralick features may or may not be computed depending on image content
992
+ # (random noise may not have enough texture). Just verify no errors.
993
+
994
+ def test_combined_settings_measurement(self, mock_data):
995
+ """Test measuring with multiple settings combined."""
996
+ from celldetective.measure import measure
997
+
998
+ stack, labels, trajectories, channel_names = mock_data
999
+
1000
+ haralick_options = {
1001
+ "channel": "DAPI",
1002
+ "channel_index": 0,
1003
+ "distance": 1,
1004
+ "n_intensity_bins": 32,
1005
+ "percentile_mode": True,
1006
+ "percentile_min": 0.01,
1007
+ "percentile_max": 99.9,
1008
+ }
1009
+
1010
+ result = measure(
1011
+ stack=stack,
1012
+ labels=labels,
1013
+ trajectories=trajectories,
1014
+ channel_names=channel_names,
1015
+ features=["area", "intensity_mean", "perimeter"],
1016
+ intensity_measurement_radii=[5],
1017
+ isotropic_operations=["mean"],
1018
+ border_distances=[5],
1019
+ haralick_options=haralick_options,
1020
+ )
1021
+
1022
+ assert result is not None
1023
+ assert len(result) == 15
1024
+ assert "area" in result.columns
1025
+ assert "TRACK_ID" in result.columns
1026
+ assert "FRAME" in result.columns
1027
+
1028
+ def test_measurement_without_stack(self, mock_data):
1029
+ """Test measuring features from labels only (no intensity)."""
1030
+ from celldetective.measure import measure
1031
+
1032
+ _, labels, trajectories, _ = mock_data
1033
+
1034
+ result = measure(
1035
+ stack=None,
1036
+ labels=labels,
1037
+ trajectories=trajectories,
1038
+ features=["area", "perimeter", "eccentricity"],
1039
+ )
1040
+
1041
+ assert result is not None
1042
+ assert len(result) == 15
1043
+ assert "area" in result.columns
1044
+
1045
+ def test_measurement_empty_features_list(self, mock_data):
1046
+ """Test measuring with empty features list (isotropic only)."""
1047
+ from celldetective.measure import measure
1048
+
1049
+ stack, labels, trajectories, channel_names = mock_data
1050
+
1051
+ result = measure(
1052
+ stack=stack,
1053
+ labels=labels,
1054
+ trajectories=trajectories,
1055
+ channel_names=channel_names,
1056
+ features=[],
1057
+ intensity_measurement_radii=[5],
1058
+ isotropic_operations=["mean"],
1059
+ )
1060
+
1061
+ assert result is not None
1062
+ assert len(result) == 15
1063
+
1064
+ def test_multiple_radii_and_operations(self, mock_data):
1065
+ """Test with multiple radii and operations."""
1066
+ from celldetective.measure import measure
1067
+
1068
+ stack, labels, trajectories, channel_names = mock_data
1069
+
1070
+ result = measure(
1071
+ stack=stack,
1072
+ labels=labels,
1073
+ trajectories=trajectories,
1074
+ channel_names=channel_names,
1075
+ features=["area"],
1076
+ intensity_measurement_radii=[3, 5, 10, 15],
1077
+ isotropic_operations=["mean", "sum", "std"], # Avoid min/max edge cases
1078
+ )
1079
+
1080
+ assert result is not None
1081
+ assert len(result) == 15
1082
+ # Should have many columns from the combinations
1083
+ assert len(result.columns) > 10